Merge pull request 'Stage 17 #2: Connecting indicator + auto-retry (no more red toasts)' (#29) from feature/connecting-indicator into development
This commit was merged in pull request #29.
This commit is contained in:
@@ -1437,6 +1437,30 @@ provided cert) at the contour caddy; prod VPN; rollback.
|
|||||||
connector. The backend resolves the mover's display name (the score line and result are
|
connector. The backend resolves the mover's display name (the score line and result are
|
||||||
built per recipient). Covered by notify round-trip, emit, connector-render (en/ru) and
|
built per recipient). Covered by notify round-trip, emit, connector-render (en/ru) and
|
||||||
routing tests.
|
routing tests.
|
||||||
|
- **No-op drain of all bot updates (#1, investigated — no change):** confirmed the Telegram bot
|
||||||
|
already long-polls and the library advances the offset for every delivered update (the default
|
||||||
|
handler no-ops anything but `/start`), so the queue never piles up. Telegram withholds only
|
||||||
|
`message_reaction` / `message_reaction_count` / `chat_member` by default, and — being
|
||||||
|
unrequested — those never queue either. Owner chose to leave `allowed_updates` at the default
|
||||||
|
(zero risk) rather than hand-maintain a full whitelist (a wrong/stale type would break
|
||||||
|
`getUpdates` entirely); a specific type will be requested when a concrete handler needs it.
|
||||||
|
- **Reconnect UX — "Connecting…" + soft-disable (#2, shipped):** connectivity failures became
|
||||||
|
**state, not toasts**. A global `online` signal (`lib/connection.svelte.ts`) flips on a
|
||||||
|
transport `unavailable` / `rate_limited` (and on the live stream's drop), driving a pure-CSS
|
||||||
|
header **spinner + "Connecting…"** in place of the title and softly disabling the in-game
|
||||||
|
server actions (commit / exchange / pass / hint; local board/rack/reset stay live). The
|
||||||
|
transport (`exec`) **auto-retries with capped backoff** — every op on a rate-limit, **reads
|
||||||
|
only** on `unavailable` (mutations are not blindly re-sent; their buttons are disabled while
|
||||||
|
offline, so the player re-issues on reconnect — the idempotency caveat the owner accepted). A
|
||||||
|
reachability **watcher** (`profile.get` probe) and any successful traffic clear the signal; the
|
||||||
|
old red `error.unavailable` toast is gone (the indicator replaces it). A server-data screen
|
||||||
|
still **opens with the spinner** and fills on reconnect (global indicator + read auto-retry),
|
||||||
|
so navigation is never dead. Pure policy unit-tested (`retry.ts`); a mock-only `window.__conn`
|
||||||
|
hook drives a Chromium+WebKit e2e (indicator appears offline, the action disables, both clear
|
||||||
|
on reconnect). The visual soft-disable spans the server-action buttons across the app: the
|
||||||
|
game bar (commit/exchange/pass/hint/resign), chat send + nudge, profile save / link / merge,
|
||||||
|
friends (request/respond/unfriend/block/code), New Game (auto-match + invite) and the lobby
|
||||||
|
hide ❌; purely local controls (board/rack/reset, menu, navigation, settings) stay live.
|
||||||
|
|
||||||
## Deferred TODOs (cross-stage)
|
## Deferred TODOs (cross-stage)
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import (
|
|||||||
// is unbounded; auto-provisioned platform names bypass this editor validation).
|
// is unbounded; auto-provisioned platform names bypass this editor validation).
|
||||||
const maxDisplayName = 32
|
const maxDisplayName = 32
|
||||||
|
|
||||||
|
// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators —
|
||||||
|
// every name rune that is neither a letter nor a space) an editable display name may
|
||||||
|
// carry, so a still-well-formed name cannot be made of mostly punctuation (Stage 17).
|
||||||
|
const maxDisplayNameSpecials = 5
|
||||||
|
|
||||||
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
|
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
|
||||||
const maxAwayWindow = 12 * time.Hour
|
const maxAwayWindow = 12 * time.Hour
|
||||||
|
|
||||||
@@ -110,6 +115,15 @@ func ValidateDisplayName(raw string) (string, error) {
|
|||||||
if !displayNameRe.MatchString(name) {
|
if !displayNameRe.MatchString(name) {
|
||||||
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
|
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
|
||||||
}
|
}
|
||||||
|
specials := 0
|
||||||
|
for _, r := range name {
|
||||||
|
if r != ' ' && !unicode.IsLetter(r) {
|
||||||
|
specials++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if specials > maxDisplayNameSpecials {
|
||||||
|
return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials)
|
||||||
|
}
|
||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ func TestValidateDisplayName(t *testing.T) {
|
|||||||
"digit rejected": {"Name2", "", false},
|
"digit rejected": {"Name2", "", false},
|
||||||
"blank": {" ", "", false},
|
"blank": {" ", "", false},
|
||||||
"too long": {strings.Repeat("a", 33), "", false},
|
"too long": {strings.Repeat("a", 33), "", false},
|
||||||
|
"five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots
|
||||||
|
"six specials": {"a.a.a.a.a.a.a", "", false}, // 6 dots
|
||||||
|
"initials spaces ok": {"J. R. R. Tolkien", "J. R. R. Tolkien", true}, // 3 dots; spaces don't count
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -89,7 +89,15 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
auth operations are unauthenticated and return the minted token. A unary
|
auth operations are unauthenticated and return the minted token. A unary
|
||||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||||
surface as Connect error codes.
|
surface as Connect error codes. The client (Stage 17) treats a connectivity edge failure as
|
||||||
|
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
|
||||||
|
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
|
||||||
|
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
|
||||||
|
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
|
||||||
|
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
|
||||||
|
response was lost — its button is disabled while offline and the player re-issues it on
|
||||||
|
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
|
||||||
|
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
|
||||||
- **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not
|
- **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not
|
||||||
concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the
|
concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the
|
||||||
`Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet
|
`Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet
|
||||||
|
|||||||
+8
-2
@@ -46,7 +46,12 @@ request) arrive as a **Telegram notification** instead — unless the player kee
|
|||||||
notifications in the app only (a profile setting, **on by default**). The "your turn"
|
notifications in the app only (a profile setting, **on by default**). The "your turn"
|
||||||
notification names the opponent and recaps their last move — the word and the running score
|
notification names the opponent and recaps their last move — the word and the running score
|
||||||
for a scoring play, or that they swapped or passed — and a finished game sends a "game over"
|
for a scoring play, or that they swapped or passed — and a finished game sends a "game over"
|
||||||
notification with your result and the final score (scores read with yours first).
|
notification with your result and the final score (scores read with yours first). If the
|
||||||
|
connection drops or the server is rate-limiting, the app does not nag with errors: the header
|
||||||
|
shows a quiet **"Connecting…"** spinner while it reconnects, actions that send to the server
|
||||||
|
pause until it is back (a server-data screen still opens, with the spinner, and fills in on
|
||||||
|
reconnect), and pending reads resume on their own — the interface stays usable instead of
|
||||||
|
flashing a red banner each time.
|
||||||
|
|
||||||
### Accounts, linking & merge *(Stage 1 / 11)*
|
### Accounts, linking & merge *(Stage 1 / 11)*
|
||||||
First platform contact auto-provisions a durable account. From the profile a player
|
First platform contact auto-provisions a durable account. From the profile a player
|
||||||
@@ -136,7 +141,8 @@ new chat message raises an **unread badge** on the game's menu until the chat is
|
|||||||
|
|
||||||
### Profile & settings *(Stage 4 / 8)*
|
### Profile & settings *(Stage 4 / 8)*
|
||||||
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||||||
optional trailing ".", up to 32 characters), the timezone (chosen as a UTC offset), the
|
optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_"
|
||||||
|
punctuation, spaces aside), the timezone (chosen as a UTC offset), the
|
||||||
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
||||||
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
||||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и
|
Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и
|
||||||
текущий счёт для результативного хода либо что он поменял фишки или пропустил, — а по
|
текущий счёт для результативного хода либо что он поменял фишки или пропустил, — а по
|
||||||
завершении партии приходит уведомление «конец партии» с твоим результатом и финальным
|
завершении партии приходит уведомление «конец партии» с твоим результатом и финальным
|
||||||
счётом (счёт читается, твой первым).
|
счётом (счёт читается, твой первым). Если связь пропадает или сервер ограничивает частоту
|
||||||
|
запросов, приложение не донимает ошибками: в шапке тихо крутится спиннер **«Подключение…»**,
|
||||||
|
пока идёт переподключение, действия, отправляющие данные на сервер, приостанавливаются до
|
||||||
|
восстановления связи (экран с серверными данными всё равно открывается — со спиннером — и
|
||||||
|
подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся
|
||||||
|
рабочим вместо красного баннера каждый раз.
|
||||||
|
|
||||||
### Аккаунты, привязка и слияние *(Stage 1 / 11)*
|
### Аккаунты, привязка и слияние *(Stage 1 / 11)*
|
||||||
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
||||||
@@ -141,7 +146,8 @@ push доставляется через платформу.
|
|||||||
|
|
||||||
### Профиль и настройки *(Stage 4 / 8)*
|
### Профиль и настройки *(Stage 4 / 8)*
|
||||||
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
||||||
«_», с необязательной завершающей «.», до 32 символов), таймзоны (выбор смещения от
|
«_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов —
|
||||||
|
пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от
|
||||||
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
|
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
|
||||||
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
||||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||||
|
|||||||
@@ -16,6 +16,25 @@ async function openGame(page: Page): Promise<void> {
|
|||||||
await expect(page.locator('.pane')).toHaveCount(1);
|
await expect(page.locator('.pane')).toHaveCount(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('offline shows the Connecting indicator and softly disables server actions (Stage 17)', async ({ page }) => {
|
||||||
|
await openGame(page);
|
||||||
|
// The exchange/draw tab is a server action; on my turn with tiles in the bag it is live.
|
||||||
|
const draw = page.locator('.tab').first();
|
||||||
|
await expect(draw).toBeEnabled();
|
||||||
|
await expect(page.getByText('Connecting…')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Drop the connection (mock-only hook): the header swaps the title for the spinner +
|
||||||
|
// "Connecting…", and the server action goes inert.
|
||||||
|
await page.evaluate(() => (window as unknown as { __conn: { offline(): void } }).__conn.offline());
|
||||||
|
await expect(page.getByText('Connecting…')).toBeVisible();
|
||||||
|
await expect(draw).toBeDisabled();
|
||||||
|
|
||||||
|
// Reconnect: the indicator clears and the action is live again.
|
||||||
|
await page.evaluate(() => (window as unknown as { __conn: { online(): void } }).__conn.online());
|
||||||
|
await expect(page.getByText('Connecting…')).toHaveCount(0);
|
||||||
|
await expect(draw).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
test('placing a tile and confirming via ✅ commits the move', async ({ page }) => {
|
test('placing a tile and confirming via ✅ commits the move', async ({ page }) => {
|
||||||
await openGame(page);
|
await openGame(page);
|
||||||
await page.locator('.rack .tile').first().click();
|
await page.locator('.rack .tile').first().click();
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { insideTelegram } from '../lib/telegram';
|
import { insideTelegram } from '../lib/telegram';
|
||||||
|
import { connection } from '../lib/connection.svelte';
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
import Spinner from './Spinner.svelte';
|
||||||
|
|
||||||
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
|
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
|
||||||
$props();
|
$props();
|
||||||
@@ -20,7 +23,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if connection.online}
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
|
{:else}
|
||||||
|
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
||||||
|
{/if}
|
||||||
<div class="end">{#if menu}{@render menu()}{/if}</div>
|
<div class="end">{#if menu}{@render menu()}{/if}</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -57,6 +64,16 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
/* The "Connecting…" indicator replaces the title while offline: a spinner + muted label,
|
||||||
|
centred like the title so the bar does not shift. */
|
||||||
|
h1.connecting {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.icon,
|
.icon,
|
||||||
.spacer,
|
.spacer,
|
||||||
.end {
|
.end {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = '1em' }: { size?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="sp" style="--sp-size: {size}" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* A pure-CSS ring with a single gap (a ~3/4 arc) that rotates — no bundled graphics. It
|
||||||
|
inherits the surrounding text colour via currentColor, so it works in any header/theme. */
|
||||||
|
.sp {
|
||||||
|
display: inline-block;
|
||||||
|
width: var(--sp-size, 1em);
|
||||||
|
height: var(--sp-size, 1em);
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.85;
|
||||||
|
vertical-align: -0.15em;
|
||||||
|
animation: sp-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes sp-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Reduced motion: slow the rotation rather than freeze it (a still ring reads as broken). */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sp {
|
||||||
|
animation-duration: 1.8s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ChatMessage } from '../lib/model';
|
import type { ChatMessage } from '../lib/model';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
import { connection } from '../lib/connection.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
messages,
|
messages,
|
||||||
@@ -55,11 +56,11 @@
|
|||||||
bind:value={text}
|
bind:value={text}
|
||||||
onkeydown={(e) => e.key === 'Enter' && send()}
|
onkeydown={(e) => e.key === 'Enter' && send()}
|
||||||
/>
|
/>
|
||||||
<button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button>
|
<button class="iconbtn" onclick={send} disabled={busy || !connection.online} aria-label={t('chat.send')}>⬆️</button>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
|
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
|
||||||
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
|
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
|
||||||
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown} aria-label={t('chat.nudgeAction')}>🛎️</button>
|
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||||
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import type { Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model';
|
import type { Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model';
|
||||||
@@ -720,7 +721,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if !gameOver && placement.pending.length > 0}
|
{#if !gameOver && placement.pending.length > 0}
|
||||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
|
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -730,14 +731,14 @@
|
|||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
{#if view}
|
{#if view}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}>
|
<button class="tab" disabled={busy || !isMyTurn || !connection.online || bagEmpty} onclick={openExchange}>
|
||||||
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
|
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||||
</button>
|
</button>
|
||||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
|
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || !connection.online} onhold={doPass}>
|
||||||
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
|
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
|
||||||
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||||
</HoldConfirm>
|
</HoldConfirm>
|
||||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
|
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
|
||||||
{#snippet trigger()}
|
{#snippet trigger()}
|
||||||
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
|
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
|
||||||
<span class="lbl">{t('game.hint')}</span>
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
@@ -793,7 +794,7 @@
|
|||||||
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
||||||
<div class="confirm-row">
|
<div class="confirm-row">
|
||||||
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
|
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
|
||||||
<button class="danger" onclick={doResign}>{t('game.dropGame')}</button>
|
<button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+16
-11
@@ -23,6 +23,8 @@ import {
|
|||||||
} from './telegram';
|
} from './telegram';
|
||||||
import { parseStartParam } from './deeplink';
|
import { parseStartParam } from './deeplink';
|
||||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||||
|
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
||||||
|
import { isConnectionCode } from './retry';
|
||||||
import { clearGameCache } from './gamecache';
|
import { clearGameCache } from './gamecache';
|
||||||
import { clearLobby } from './lobbycache';
|
import { clearLobby } from './lobbycache';
|
||||||
import type { BoardLabelMode } from './boardlabels';
|
import type { BoardLabelMode } from './boardlabels';
|
||||||
@@ -113,18 +115,18 @@ export function clearChatUnread(gameId: string): void {
|
|||||||
if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 };
|
if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
|
/** handleError maps a GatewayError to a toast; an invalid session logs out. A connectivity
|
||||||
|
* failure — or anything raised while the app is mid-reconnect — is shown by the "Connecting…"
|
||||||
|
* header indicator (and auto-retried), never a red toast. */
|
||||||
export function handleError(err: unknown): void {
|
export function handleError(err: unknown): void {
|
||||||
telegramHaptic('error');
|
const code = err instanceof GatewayError ? err.code : '';
|
||||||
if (err instanceof GatewayError) {
|
if (code === 'session_invalid' || code === 'unauthenticated') {
|
||||||
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
|
|
||||||
void logout();
|
void logout();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showToast(t(errorKey(err.code)), 'error');
|
if (isConnectionCode(code) || !connection.online) return;
|
||||||
return;
|
telegramHaptic('error');
|
||||||
}
|
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
||||||
showToast(t('error.generic'), 'error');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openStream(): void {
|
function openStream(): void {
|
||||||
@@ -132,6 +134,7 @@ function openStream(): void {
|
|||||||
streamAlive = true;
|
streamAlive = true;
|
||||||
unsubscribeStream = gateway.subscribe(
|
unsubscribeStream = gateway.subscribe(
|
||||||
(e) => {
|
(e) => {
|
||||||
|
reportOnline(); // a delivered event proves the gateway is reachable
|
||||||
app.lastEvent = e;
|
app.lastEvent = e;
|
||||||
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
|
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
|
||||||
// While the player is on that game's chat screen, neither toast nor bump the unread.
|
// While the player is on that game's chat screen, neither toast nor bump the unread.
|
||||||
@@ -155,9 +158,10 @@ function openStream(): void {
|
|||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
streamAlive = false;
|
streamAlive = false;
|
||||||
// A background suspend drops the single-shot stream. Keep the banner hidden while
|
// A background suspend drops the single-shot stream. Keep the indicator hidden while
|
||||||
// backgrounded or just-resumed (bannerSuppressed); always schedule a retry.
|
// backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the
|
||||||
if (!bannerSuppressed()) showToast(t('error.unavailable'), 'error');
|
// reachability watcher and this scheduled retry recover it). Always schedule a retry.
|
||||||
|
if (!bannerSuppressed()) reportOffline();
|
||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -399,6 +403,7 @@ export async function loginEmail(email: string, code: string): Promise<void> {
|
|||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
closeStream();
|
closeStream();
|
||||||
|
resetConnection();
|
||||||
clearGameCache();
|
clearGameCache();
|
||||||
clearLobby();
|
clearLobby();
|
||||||
gateway.setToken(null);
|
gateway.setToken(null);
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Global connectivity signal (Stage 17). `online` is false while the app is actively failing to
|
||||||
|
// reach the gateway — a unary call retrying after a transport/rate-limit failure, or the live
|
||||||
|
// stream dropped. The transport and the live-stream owner report transitions; the UI reads
|
||||||
|
// `connection.online` to show the "Connecting…" indicator and to softly disable proactive
|
||||||
|
// actions. In mock mode nothing ever reports trouble, so it simply stays online.
|
||||||
|
//
|
||||||
|
// Recovery is guaranteed by a reachability watcher: while offline it periodically fires a
|
||||||
|
// registered probe (a lightweight read) until one succeeds, so the indicator clears even when no
|
||||||
|
// other traffic is in flight.
|
||||||
|
|
||||||
|
import { backoffMs } from './retry';
|
||||||
|
|
||||||
|
let online = $state(true);
|
||||||
|
let watchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let probe: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
export const connection = {
|
||||||
|
/** online is true when the app believes it can reach the gateway. */
|
||||||
|
get online(): boolean {
|
||||||
|
return online;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** registerProbe installs the reachability probe the watcher fires while offline. The transport
|
||||||
|
* wires a cheap authenticated read; it should reject when there is no session. */
|
||||||
|
export function registerProbe(fn: () => Promise<void>): void {
|
||||||
|
probe = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** reportOnline marks the gateway reachable and stops the watcher. */
|
||||||
|
export function reportOnline(): void {
|
||||||
|
online = true;
|
||||||
|
if (watchTimer) {
|
||||||
|
clearTimeout(watchTimer);
|
||||||
|
watchTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** reportOffline marks the gateway unreachable and starts the reachability watcher (once). */
|
||||||
|
export function reportOffline(): void {
|
||||||
|
online = false;
|
||||||
|
if (!watchTimer && probe) scheduleProbe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** resetConnection restores the online state and stops the watcher (e.g. on logout). */
|
||||||
|
export function resetConnection(): void {
|
||||||
|
reportOnline();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleProbe(attempt: number): void {
|
||||||
|
watchTimer = setTimeout(
|
||||||
|
() => {
|
||||||
|
watchTimer = null;
|
||||||
|
if (online || !probe) return;
|
||||||
|
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
|
||||||
|
},
|
||||||
|
backoffMs(attempt),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,9 +5,19 @@
|
|||||||
import type { GatewayClient } from './client';
|
import type { GatewayClient } from './client';
|
||||||
import { MockGateway } from './mock/client';
|
import { MockGateway } from './mock/client';
|
||||||
import { createTransport } from './transport';
|
import { createTransport } from './transport';
|
||||||
|
import { reportOffline, reportOnline } from './connection.svelte';
|
||||||
|
|
||||||
const isMock = import.meta.env.MODE === 'mock';
|
const isMock = import.meta.env.MODE === 'mock';
|
||||||
|
|
||||||
export const gateway: GatewayClient = isMock
|
export const gateway: GatewayClient = isMock
|
||||||
? new MockGateway()
|
? new MockGateway()
|
||||||
: createTransport(import.meta.env.VITE_GATEWAY_URL ?? '');
|
: createTransport(import.meta.env.VITE_GATEWAY_URL ?? '');
|
||||||
|
|
||||||
|
// Mock-mode test hook (tree-shaken from a production build, where MODE !== 'mock'): the mock
|
||||||
|
// transport never exercises the connectivity indicator, so the Playwright e2e drives it directly.
|
||||||
|
if (isMock && typeof window !== 'undefined') {
|
||||||
|
(window as unknown as { __conn?: { offline(): void; online(): void } }).__conn = {
|
||||||
|
offline: reportOffline,
|
||||||
|
online: reportOnline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
export const en = {
|
export const en = {
|
||||||
'app.title': 'Scrabble',
|
'app.title': 'Scrabble',
|
||||||
|
'connection.connecting': 'Connecting…',
|
||||||
|
|
||||||
'common.back': 'Back',
|
'common.back': 'Back',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
|
|||||||
|
|
||||||
export const ru: Record<MessageKey, string> = {
|
export const ru: Record<MessageKey, string> = {
|
||||||
'app.title': 'Scrabble',
|
'app.title': 'Scrabble',
|
||||||
|
'connection.connecting': 'Подключение…',
|
||||||
|
|
||||||
'common.back': 'Назад',
|
'common.back': 'Назад',
|
||||||
'common.cancel': 'Отмена',
|
'common.cancel': 'Отмена',
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ describe('validDisplayName', () => {
|
|||||||
['Name2', false],
|
['Name2', false],
|
||||||
['', false],
|
['', false],
|
||||||
['a'.repeat(33), false],
|
['a'.repeat(33), false],
|
||||||
|
['a.a.a.a.a.a', true], // 5 dots — at the special-char limit
|
||||||
|
['a.a.a.a.a.a.a', false], // 6 dots — over the limit
|
||||||
|
['J. R. R. Tolkien', true], // 3 dots; spaces are not special
|
||||||
])('%s -> %s', (name, ok) => {
|
])('%s -> %s', (name, ok) => {
|
||||||
expect(validDisplayName(name)).toBe(ok);
|
expect(validDisplayName(name)).toBe(ok);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
/** maxDisplayName caps the editable display name in runes. */
|
/** maxDisplayName caps the editable display name in runes. */
|
||||||
export const maxDisplayName = 32;
|
export const maxDisplayName = 32;
|
||||||
|
|
||||||
|
/** maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — every
|
||||||
|
* rune that is neither a letter nor a space) a display name may carry. Mirrors the Go rule. */
|
||||||
|
export const maxDisplayNameSpecials = 5;
|
||||||
|
|
||||||
/** maxAwayMinutes bounds the daily away window's length (12 h). */
|
/** maxAwayMinutes bounds the daily away window's length (12 h). */
|
||||||
export const maxAwayMinutes = 12 * 60;
|
export const maxAwayMinutes = 12 * 60;
|
||||||
|
|
||||||
@@ -17,7 +21,10 @@ const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u;
|
|||||||
/** displayNameError returns true when the trimmed name is a valid display name. */
|
/** displayNameError returns true when the trimmed name is a valid display name. */
|
||||||
export function validDisplayName(raw: string): boolean {
|
export function validDisplayName(raw: string): boolean {
|
||||||
const name = raw.trim();
|
const name = raw.trim();
|
||||||
return name.length > 0 && [...name].length <= maxDisplayName && displayNameRe.test(name);
|
const chars = [...name];
|
||||||
|
if (name.length === 0 || chars.length > maxDisplayName || !displayNameRe.test(name)) return false;
|
||||||
|
const specials = chars.filter((c) => c !== ' ' && !/\p{L}/u.test(c)).length;
|
||||||
|
return specials <= maxDisplayNameSpecials;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pragmatic email check (the backend re-validates with net/mail). Rejects spaces
|
// A pragmatic email check (the backend re-validates with net/mail). Rejects spaces
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Code, ConnectError } from '@connectrpc/connect';
|
||||||
|
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||||
|
|
||||||
|
describe('toGatewayError', () => {
|
||||||
|
it('collapses every transport-level failure to a connection code (so it is retried + suppressed)', () => {
|
||||||
|
for (const c of [Code.Unavailable, Code.DeadlineExceeded, Code.Canceled, Code.Aborted, Code.Unknown]) {
|
||||||
|
const e = toGatewayError(new ConnectError('x', c));
|
||||||
|
expect(e.code).toBe('unavailable');
|
||||||
|
expect(isConnectionCode(e.code)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a raw (non-Connect) network error as a connection failure', () => {
|
||||||
|
expect(toGatewayError(new TypeError('Failed to fetch')).code).toBe('unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves rate-limit, an invalid session, and a genuine server-internal error', () => {
|
||||||
|
expect(toGatewayError(new ConnectError('x', Code.ResourceExhausted)).code).toBe('rate_limited');
|
||||||
|
expect(toGatewayError(new ConnectError('x', Code.Unauthenticated)).code).toBe('session_invalid');
|
||||||
|
expect(toGatewayError(new ConnectError('x', Code.Internal)).code).toBe('internal');
|
||||||
|
expect(isConnectionCode('internal')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('retryable', () => {
|
||||||
|
it('retries any op on a rate-limit rejection (it never reached the backend)', () => {
|
||||||
|
expect(retryable('rate_limited', 'game.submit_play')).toBe(true);
|
||||||
|
expect(retryable('rate_limited', 'games.list')).toBe(true);
|
||||||
|
expect(retryable('rate_limited', 'chat.post')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries only read-only ops on a transport unavailable (a mutation could double-apply)', () => {
|
||||||
|
expect(retryable('unavailable', 'games.list')).toBe(true);
|
||||||
|
expect(retryable('unavailable', 'game.state')).toBe(true);
|
||||||
|
expect(retryable('unavailable', 'draft.get')).toBe(true);
|
||||||
|
// mutations are not auto-resent on a dropped connection
|
||||||
|
expect(retryable('unavailable', 'game.submit_play')).toBe(false);
|
||||||
|
expect(retryable('unavailable', 'chat.post')).toBe(false);
|
||||||
|
expect(retryable('unavailable', 'game.hide')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never retries a domain rejection or an unknown code', () => {
|
||||||
|
expect(retryable('not_your_turn', 'game.submit_play')).toBe(false);
|
||||||
|
expect(retryable('illegal_play', 'game.submit_play')).toBe(false);
|
||||||
|
expect(retryable('not_found', 'game.state')).toBe(false);
|
||||||
|
expect(retryable('internal', 'games.list')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isConnectionCode', () => {
|
||||||
|
it('flags the transport/rate-limit codes the indicator covers', () => {
|
||||||
|
expect(isConnectionCode('unavailable')).toBe(true);
|
||||||
|
expect(isConnectionCode('rate_limited')).toBe(true);
|
||||||
|
expect(isConnectionCode('not_your_turn')).toBe(false);
|
||||||
|
expect(isConnectionCode('internal')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('backoffMs', () => {
|
||||||
|
it('grows exponentially and caps at 8s (+ jitter under 250ms)', () => {
|
||||||
|
// attempt 1 ~ 500, 2 ~ 1000, 3 ~ 2000, 4 ~ 4000, 5 ~ 8000, then capped.
|
||||||
|
expect(backoffMs(1)).toBeGreaterThanOrEqual(500);
|
||||||
|
expect(backoffMs(1)).toBeLessThan(750);
|
||||||
|
expect(backoffMs(3)).toBeGreaterThanOrEqual(2000);
|
||||||
|
expect(backoffMs(3)).toBeLessThan(2250);
|
||||||
|
for (const n of [5, 6, 10, 20]) {
|
||||||
|
expect(backoffMs(n)).toBeGreaterThanOrEqual(8000);
|
||||||
|
expect(backoffMs(n)).toBeLessThan(8250);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Retry policy + error classification for the gateway transport (Stage 17). When a unary call
|
||||||
|
// fails at the transport level the app retries it with capped exponential backoff while showing
|
||||||
|
// the "Connecting…" indicator, instead of flashing a red toast each time.
|
||||||
|
//
|
||||||
|
// Idempotency: a rate-limit rejection (ResourceExhausted) never reached the backend, so any op is
|
||||||
|
// safe to retry. A transport 'unavailable' is ambiguous for a mutation (its response could have
|
||||||
|
// been lost after the backend applied it), so only **read-only** ops are auto-retried on
|
||||||
|
// 'unavailable'; a mutation is surfaced instead (its button is disabled while offline and
|
||||||
|
// re-enables on reconnect, so the player re-issues it deliberately).
|
||||||
|
|
||||||
|
import { Code, ConnectError } from '@connectrpc/connect';
|
||||||
|
import { GatewayError } from './client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toGatewayError normalises a thrown Connect/transport error to a GatewayError with a stable code.
|
||||||
|
* Connection-level failures — the server is unreachable, the request timed out, was reset or
|
||||||
|
* cancelled, or a raw network error — all collapse to **'unavailable'**, so they are handled as
|
||||||
|
* connectivity (the indicator + retry), never as a red error toast. A genuine server-side
|
||||||
|
* 'internal' or a domain code is preserved.
|
||||||
|
*/
|
||||||
|
export function toGatewayError(e: unknown): GatewayError {
|
||||||
|
if (e instanceof ConnectError) {
|
||||||
|
switch (e.code) {
|
||||||
|
case Code.Unauthenticated:
|
||||||
|
return new GatewayError('session_invalid', e.message);
|
||||||
|
case Code.ResourceExhausted:
|
||||||
|
return new GatewayError('rate_limited', e.message);
|
||||||
|
case Code.Unavailable:
|
||||||
|
case Code.DeadlineExceeded:
|
||||||
|
case Code.Canceled:
|
||||||
|
case Code.Aborted:
|
||||||
|
case Code.Unknown:
|
||||||
|
return new GatewayError('unavailable', e.message);
|
||||||
|
case Code.NotFound:
|
||||||
|
return new GatewayError('not_found', e.message);
|
||||||
|
default:
|
||||||
|
return new GatewayError('internal', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new GatewayError('unavailable', String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** READ_OPS is the set of side-effect-free message types (safe to auto-retry on any failure). */
|
||||||
|
export const READ_OPS: ReadonlySet<string> = new Set([
|
||||||
|
'profile.get',
|
||||||
|
'games.list',
|
||||||
|
'game.state',
|
||||||
|
'game.history',
|
||||||
|
'game.gcg',
|
||||||
|
'game.evaluate',
|
||||||
|
'game.check_word',
|
||||||
|
'stats.get',
|
||||||
|
'lobby.poll',
|
||||||
|
'chat.list',
|
||||||
|
'draft.get',
|
||||||
|
'friends.list',
|
||||||
|
'friends.incoming',
|
||||||
|
'friends.outgoing',
|
||||||
|
'blocks.list',
|
||||||
|
'invitation.list',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retryable reports whether a failed op should be auto-retried. A rate-limit rejection is always
|
||||||
|
* safe (the gateway rejected it before processing); a transport 'unavailable' is retried only for
|
||||||
|
* read-only ops, never a mutation; every other code (a domain rejection, not-found, …) is final.
|
||||||
|
*/
|
||||||
|
export function retryable(code: string, op: string): boolean {
|
||||||
|
if (code === 'rate_limited') return true;
|
||||||
|
if (code === 'unavailable') return READ_OPS.has(op);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** isConnectionCode reports whether a code is a transport/connectivity failure the Connecting
|
||||||
|
* indicator covers (so the UI suppresses its red toast). */
|
||||||
|
export function isConnectionCode(code: string): boolean {
|
||||||
|
return code === 'unavailable' || code === 'rate_limited';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** backoffMs is the delay before retry attempt n (1-based): capped exponential growth plus a
|
||||||
|
* little jitter, so a fleet of clients does not retry in lockstep after an outage. */
|
||||||
|
export function backoffMs(attempt: number): number {
|
||||||
|
const base = Math.min(8000, 500 * 2 ** Math.max(0, attempt - 1));
|
||||||
|
return base + Math.floor(Math.random() * 250);
|
||||||
|
}
|
||||||
+26
-19
@@ -5,29 +5,16 @@
|
|||||||
// a thrown GatewayError. In dev the Vite proxy forwards the RPC path to the h2c
|
// a thrown GatewayError. In dev the Vite proxy forwards the RPC path to the h2c
|
||||||
// gateway; in a packaged app VITE_GATEWAY_URL points at the real origin.
|
// gateway; in a packaged app VITE_GATEWAY_URL points at the real origin.
|
||||||
|
|
||||||
import { Code, ConnectError, createClient } from '@connectrpc/connect';
|
import { createClient } from '@connectrpc/connect';
|
||||||
import { createConnectTransport } from '@connectrpc/connect-web';
|
import { createConnectTransport } from '@connectrpc/connect-web';
|
||||||
import { Gateway } from '../gen/edge/v1/edge_pb';
|
import { Gateway } from '../gen/edge/v1/edge_pb';
|
||||||
import { GatewayError, type GatewayClient } from './client';
|
import { GatewayError, type GatewayClient } from './client';
|
||||||
import * as codec from './codec';
|
import * as codec from './codec';
|
||||||
|
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
||||||
|
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||||
|
|
||||||
function toGatewayError(e: unknown): GatewayError {
|
const MAX_RETRIES = 6;
|
||||||
if (e instanceof ConnectError) {
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
switch (e.code) {
|
|
||||||
case Code.Unauthenticated:
|
|
||||||
return new GatewayError('session_invalid', e.message);
|
|
||||||
case Code.ResourceExhausted:
|
|
||||||
return new GatewayError('rate_limited', e.message);
|
|
||||||
case Code.Unavailable:
|
|
||||||
return new GatewayError('unavailable', e.message);
|
|
||||||
case Code.NotFound:
|
|
||||||
return new GatewayError('not_found', e.message);
|
|
||||||
default:
|
|
||||||
return new GatewayError('internal', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new GatewayError('unavailable', String(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTransport(baseUrl: string): GatewayClient {
|
export function createTransport(baseUrl: string): GatewayClient {
|
||||||
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
|
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
|
||||||
@@ -38,16 +25,36 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
const headers = (): Record<string, string> | undefined =>
|
const headers = (): Record<string, string> | undefined =>
|
||||||
token ? { authorization: `Bearer ${token}` } : undefined;
|
token ? { authorization: `Bearer ${token}` } : undefined;
|
||||||
|
|
||||||
|
// The reachability probe the connection watcher fires while offline: a cheap authenticated read
|
||||||
|
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
||||||
|
registerProbe(async () => {
|
||||||
|
if (!token) throw new Error('no session');
|
||||||
|
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a
|
||||||
|
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
|
||||||
|
// indicator. A successful round-trip marks the gateway reachable; a domain result_code is final.
|
||||||
async function exec(messageType: string, payload: Uint8Array): Promise<Uint8Array> {
|
async function exec(messageType: string, payload: Uint8Array): Promise<Uint8Array> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() });
|
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw toGatewayError(e);
|
const err = toGatewayError(e);
|
||||||
|
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
|
||||||
|
reportOffline();
|
||||||
|
await sleep(backoffMs(attempt + 1));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
if (isConnectionCode(err.code)) reportOffline();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
reportOnline();
|
||||||
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
||||||
return res.payload;
|
return res.payload;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setToken(t) {
|
setToken(t) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Screen from '../components/Screen.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
|
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
|
||||||
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { friendCodeParam, shareLink } from '../lib/deeplink';
|
import { friendCodeParam, shareLink } from '../lib/deeplink';
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
/>
|
/>
|
||||||
<button class="btn" onclick={redeem}>{t('friends.redeem')}</button>
|
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
|
||||||
</div>
|
</div>
|
||||||
{#if code}
|
{#if code}
|
||||||
{@const tg = shareLink(friendCodeParam(code.code))}
|
{@const tg = shareLink(friendCodeParam(code.code))}
|
||||||
@@ -112,7 +113,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="link" onclick={getCode}>{t('friends.getCode')}</button>
|
<button class="link" onclick={getCode} disabled={!connection.online}>{t('friends.getCode')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -123,8 +124,8 @@
|
|||||||
<div class="item">
|
<div class="item">
|
||||||
<span class="who">{r.displayName}</span>
|
<span class="who">{r.displayName}</span>
|
||||||
<span class="acts">
|
<span class="acts">
|
||||||
<button class="btn" onclick={() => respond(r.accountId, true)}>{t('friends.accept')}</button>
|
<button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button>
|
||||||
<button class="ghost" onclick={() => respond(r.accountId, false)}>{t('friends.decline')}</button>
|
<button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -138,8 +139,8 @@
|
|||||||
<div class="item">
|
<div class="item">
|
||||||
<span class="who">{f.displayName}</span>
|
<span class="who">{f.displayName}</span>
|
||||||
<span class="acts">
|
<span class="acts">
|
||||||
<button class="ghost" onclick={() => remove(f.accountId)}>{t('friends.unfriend')}</button>
|
<button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button>
|
||||||
<button class="ghost danger" onclick={() => blockUser(f.accountId)}>{t('friends.block')}</button>
|
<button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -154,7 +155,7 @@
|
|||||||
{#each blocked as b (b.accountId)}
|
{#each blocked as b (b.accountId)}
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<span class="who">{b.displayName}</span>
|
<span class="who">{b.displayName}</span>
|
||||||
<button class="ghost" onclick={() => unblock(b.accountId)}>{t('friends.unblock')}</button>
|
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import Menu from '../components/Menu.svelte';
|
import Menu from '../components/Menu.svelte';
|
||||||
import TabBar from '../components/TabBar.svelte';
|
import TabBar from '../components/TabBar.svelte';
|
||||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||||
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
@@ -191,7 +192,7 @@
|
|||||||
{#each group.list as g (g.id)}
|
{#each group.list as g (g.id)}
|
||||||
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
|
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
|
||||||
{#if group.finished}
|
{#if group.finished}
|
||||||
<button class="del" onclick={() => hide(g.id)} aria-label={t('lobby.hideGame')}>❌</button>
|
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Screen from '../components/Screen.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||||
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import type { AccountRef, Variant } from '../lib/model';
|
import type { AccountRef, Variant } from '../lib/model';
|
||||||
@@ -144,7 +145,7 @@
|
|||||||
<p class="subtitle">{t('new.subtitle')}</p>
|
<p class="subtitle">{t('new.subtitle')}</p>
|
||||||
<div class="variants">
|
<div class="variants">
|
||||||
{#each variants as v (v.id)}
|
{#each variants as v (v.id)}
|
||||||
<button class="variant" onclick={() => find(v.id)}>
|
<button class="variant" onclick={() => find(v.id)} disabled={!connection.online}>
|
||||||
<span class="vmain">
|
<span class="vmain">
|
||||||
<span class="vname">{t(v.label)}</span>
|
<span class="vname">{t(v.label)}</span>
|
||||||
{#if VARIANT_FLAG[v.id]}
|
{#if VARIANT_FLAG[v.id]}
|
||||||
@@ -196,7 +197,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="invite" disabled={selected.length === 0 || !inviteVariant} onclick={sendInvite}>{t('new.invite')}</button>
|
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
import Screen from '../components/Screen.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
|
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
|
||||||
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
|
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
@@ -209,7 +210,7 @@
|
|||||||
<span>{t('profile.notificationsInAppOnly')}</span>
|
<span>{t('profile.notificationsInAppOnly')}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="formacts">
|
<div class="formacts">
|
||||||
<button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button>
|
<button type="submit" class="btn" disabled={!formValid || !connection.online}>{t('common.save')}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -226,7 +227,7 @@
|
|||||||
placeholder={t('login.emailPlaceholder')}
|
placeholder={t('login.emailPlaceholder')}
|
||||||
type="email"
|
type="email"
|
||||||
/>
|
/>
|
||||||
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
|
<button class="ghost" onclick={requestEmail} disabled={!emailOk || !connection.online}>{t('login.sendCode')}</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="addrow">
|
<div class="addrow">
|
||||||
@@ -237,11 +238,11 @@
|
|||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
/>
|
/>
|
||||||
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
|
<button class="btn" onclick={confirmEmail} disabled={!connection.online}>{t('common.ok')}</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if telegramLinkable}
|
{#if telegramLinkable}
|
||||||
<button class="ghost tg" onclick={linkTelegram}>{t('profile.linkTelegram')}</button>
|
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>{t('profile.linkTelegram')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -257,7 +258,7 @@
|
|||||||
<p class="warn">{t('profile.mergeIrreversible')}</p>
|
<p class="warn">{t('profile.mergeIrreversible')}</p>
|
||||||
<div class="addrow end">
|
<div class="addrow end">
|
||||||
<button class="ghost" onclick={() => (pendingMerge = null)}>{t('common.cancel')}</button>
|
<button class="ghost" onclick={() => (pendingMerge = null)}>{t('common.cancel')}</button>
|
||||||
<button class="btn" onclick={confirmMerge}>{t('profile.mergeConfirm')}</button>
|
<button class="btn" onclick={confirmMerge} disabled={!connection.online}>{t('profile.mergeConfirm')}</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user