@couch-kit/client 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @couch-kit/client
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#227](https://github.com/faluciano/react-native-couch-kit/pull/227) [`8ef31ad`](https://github.com/faluciano/react-native-couch-kit/commit/8ef31adc53dc7342f1d81f8ba2e4acb6c8c1433e) Thanks [@faluciano](https://github.com/faluciano)! - **New features:** A phone can get out of the dead ends that used to need a page reload.
8
+
9
+ - `connectionKey` option. Changing it opens a fresh connection with the retry budget reset. With a relay, pass the room code: a phone that typed a wrong code (`ROOM_NOT_FOUND`, which is final) now connects as soon as the right code is entered. Retry the same code with `reconnect()`.
10
+ - `reconnectOnResume` (default `true`): when the page becomes visible again, is restored from the back/forward cache, or the browser comes back online, the client reconnects at once with a fresh retry budget. A phone that slept through its five retries used to stay disconnected. Final closes and `disconnect()` are not undone.
11
+ - `disconnectReason` now clears when a new connection starts, not when it opens.
12
+ - `sendAction` returns `true` if the action was sent and `false` when there is no open connection. An action that was not sent is no longer applied optimistically.
13
+ - New exported types `ClientStatus` and `UseGameClientResult`, and helpers `isRecoverableClose` and `sessionSecretKey`.
14
+
15
+ **Security:** The relay transport keeps a separate session secret per relay and room (`ClientTransport.sessionScope`, also settable as the `sessionScope` option). Anyone can open a relay room and share its link; with one secret per site, that room's display could collect a phone's secret and rejoin the player's real room as them. The LAN default is unchanged, so existing players keep their identity.
16
+
17
+ ### Patch Changes
18
+
19
+ - Updated dependencies [[`8ef31ad`](https://github.com/faluciano/react-native-couch-kit/commit/8ef31adc53dc7342f1d81f8ba2e4acb6c8c1433e)]:
20
+ - @couch-kit/core@0.10.3
21
+
3
22
  ## 0.16.1
4
23
 
5
24
  ### Patch Changes
package/README.md CHANGED
@@ -36,6 +36,10 @@ Config:
36
36
  - `maxDelay?`: maximum delay in ms cap for reconnection backoff (default: `10000`)
37
37
  - `optimisticTimeoutMs?`: how long an optimistic update may stand without the host confirming it (default: `2000`; `0` disables). See [Optimistic updates](#optimistic-updates).
38
38
  - `timeSync?`: exchange PING/PONG with the host to power `getServerTime()` and `rtt` (default: `true`). Turn it off if you use neither — on a relay every ping is a billed message.
39
+ - `createTransport?`: custom transport factory, e.g. `createRelayTransport({ url, roomId })` for cross-network play
40
+ - `connectionKey?`: identifies what `createTransport` connects to. Changing it opens a fresh connection with the retry budget reset. With a relay, pass the room code, so a phone that typed a wrong code connects as soon as the right one is entered. To retry the *same* code, call `reconnect()`.
41
+ - `reconnectOnResume?`: reconnect at once when the page becomes visible again, is restored from the back/forward cache, or the browser comes back online (default: `true`). A phone that slept through its retries otherwise stays disconnected. Final closes and `disconnect()` are not undone.
42
+ - `sessionScope?`: keep a separate session secret (and so a separate player identity) per scope. The relay transport scopes the secret to its relay and room automatically, so a secret sent to one room never reaches another.
39
43
  - `debug?`: enable console logs
40
44
  - `onConnect?`, `onDisconnect?`: lifecycle callbacks
41
45
  - `onError?`: called with `{ code, message }` when the host rejects something this client sent (`RATE_LIMITED`, `NOT_JOINED`, `FORBIDDEN_ACTION`, `INVALID_SECRET`, …)
@@ -45,7 +49,7 @@ Returns:
45
49
  - `status`: `'connecting' | 'connected' | 'disconnected' | 'error'`
46
50
  - `state`: current controller state (optimistic + hydrated)
47
51
  - `playerId`: stable public identifier derived from the session secret. Persists across page refreshes and reconnections (the same player always gets the same `playerId`).
48
- - `sendAction(action)`: optimistic dispatch + send to host
52
+ - `sendAction(action)`: optimistic dispatch + send to host. Returns `true` if sent, `false` when there is no open connection (nothing is applied or queued then). The host can still reject a sent action; that arrives through `onError`.
49
53
  - `disconnectReason`: why the last connection ended, when the transport knows (for the relay, a code such as `ROOM_NOT_FOUND` or `HOST_LEFT`); `null` once connected again
50
54
  - `getServerTime()`: NTP-ish server time based on periodic ping/pong
51
55
  - `rtt`: round-trip time (ms) to the server, updated periodically via PING/PONG
package/dist/index.js CHANGED
@@ -117,6 +117,9 @@ import {
117
117
  isValidSecret
118
118
  } from "@couch-kit/core";
119
119
  var SESSION_SECRET_KEY = "ck_secret";
120
+ function sessionSecretKey(scope) {
121
+ return scope ? `${SESSION_SECRET_KEY}:${scope}` : SESSION_SECRET_KEY;
122
+ }
120
123
  var DEFAULT_OPTIMISTIC_TIMEOUT = 2000;
121
124
  var NON_RECOVERABLE_CLOSE_CODES = new Set([
122
125
  1008,
@@ -136,22 +139,26 @@ function resolveWebSocketUrl(config, location) {
136
139
  function computeBackoffDelay(attempt, baseDelay, maxDelay) {
137
140
  return Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
138
141
  }
142
+ function isRecoverableClose(closeCode) {
143
+ return !NON_RECOVERABLE_CLOSE_CODES.has(closeCode);
144
+ }
139
145
  function shouldReconnect(decision) {
140
146
  if (decision.intentionalClose)
141
147
  return false;
142
- if (NON_RECOVERABLE_CLOSE_CODES.has(decision.closeCode))
148
+ if (!isRecoverableClose(decision.closeCode))
143
149
  return false;
144
150
  return decision.attempts < decision.maxRetries;
145
151
  }
146
- function resolveSessionSecret(storage, generate = generateId2) {
152
+ function resolveSessionSecret(storage, generate = generateId2, scope) {
153
+ const key = sessionSecretKey(scope);
147
154
  try {
148
155
  if (!storage)
149
156
  return generate();
150
- const stored = storage.getItem(SESSION_SECRET_KEY);
157
+ const stored = storage.getItem(key);
151
158
  if (stored && isValidSecret(stored))
152
159
  return stored;
153
160
  const secret = generate();
154
- storage.setItem(SESSION_SECRET_KEY, secret);
161
+ storage.setItem(key, secret);
155
162
  return secret;
156
163
  } catch {
157
164
  return generate();
@@ -193,6 +200,7 @@ function useGameClient(config) {
193
200
  const reconnectAttempts = useRef2(0);
194
201
  const reconnectTimer = useRef2(null);
195
202
  const intentionalClose = useRef2(false);
203
+ const resumable = useRef2(false);
196
204
  const configRef = useRef2(config);
197
205
  useEffect2(() => {
198
206
  configRef.current = config;
@@ -239,6 +247,7 @@ function useGameClient(config) {
239
247
  }
240
248
  socketRef.current = transport;
241
249
  setStatus("connecting");
250
+ setDisconnectReason(null);
242
251
  const isCurrent = () => socketRef.current === transport;
243
252
  transport.onopen = () => {
244
253
  if (!isCurrent())
@@ -249,7 +258,7 @@ function useGameClient(config) {
249
258
  setOpenTransport(transport);
250
259
  reconnectAttempts.current = 0;
251
260
  currentCfg.onConnect?.();
252
- const secret = resolveSessionSecret(typeof localStorage !== "undefined" ? localStorage : null);
261
+ const secret = resolveSessionSecret(typeof localStorage !== "undefined" ? localStorage : null, undefined, transport.sessionScope ?? currentCfg.sessionScope);
253
262
  try {
254
263
  transport.send(JSON.stringify({
255
264
  type: MessageTypes3.JOIN,
@@ -306,6 +315,7 @@ function useGameClient(config) {
306
315
  setOpenTransport(null);
307
316
  setStatus("disconnected");
308
317
  setDisconnectReason(reason ? reason : null);
318
+ resumable.current = !intentionalClose.current && isRecoverableClose(code);
309
319
  configRef.current.onDisconnect?.();
310
320
  if (!shouldReconnect({
311
321
  intentionalClose: intentionalClose.current,
@@ -332,6 +342,7 @@ function useGameClient(config) {
332
342
  }, [
333
343
  config.url,
334
344
  config.wsPort,
345
+ config.connectionKey,
335
346
  maxRetries,
336
347
  baseDelay,
337
348
  maxDelay,
@@ -340,6 +351,7 @@ function useGameClient(config) {
340
351
  ]);
341
352
  const closeCurrent = useCallback2(() => {
342
353
  intentionalClose.current = true;
354
+ resumable.current = false;
343
355
  if (reconnectTimer.current) {
344
356
  clearTimeout(reconnectTimer.current);
345
357
  reconnectTimer.current = null;
@@ -354,9 +366,39 @@ function useGameClient(config) {
354
366
  configRef.current.onDisconnect?.();
355
367
  }, [cancelRollback]);
356
368
  useEffect2(() => {
369
+ reconnectAttempts.current = 0;
357
370
  connect();
358
371
  return closeCurrent;
359
372
  }, [connect, closeCurrent]);
373
+ useEffect2(() => {
374
+ if (config.reconnectOnResume === false)
375
+ return;
376
+ if (typeof window === "undefined")
377
+ return;
378
+ const resume = () => {
379
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
380
+ return;
381
+ }
382
+ if (socketRef.current || !resumable.current)
383
+ return;
384
+ if (reconnectTimer.current) {
385
+ clearTimeout(reconnectTimer.current);
386
+ reconnectTimer.current = null;
387
+ }
388
+ if (configRef.current.debug)
389
+ console.log("[GameClient] Page resumed, reconnecting");
390
+ reconnectAttempts.current = 0;
391
+ connect();
392
+ };
393
+ document?.addEventListener("visibilitychange", resume);
394
+ window.addEventListener("online", resume);
395
+ window.addEventListener("pageshow", resume);
396
+ return () => {
397
+ document?.removeEventListener("visibilitychange", resume);
398
+ window.removeEventListener("online", resume);
399
+ window.removeEventListener("pageshow", resume);
400
+ };
401
+ }, [connect, config.reconnectOnResume]);
360
402
  const disconnect = useCallback2(() => {
361
403
  closeCurrent();
362
404
  setStatus("disconnected");
@@ -367,6 +409,12 @@ function useGameClient(config) {
367
409
  connect();
368
410
  }, [closeCurrent, connect]);
369
411
  const sendAction = useCallback2((action) => {
412
+ const transport = socketRef.current;
413
+ if (transport?.readyState !== TransportReadyState.OPEN) {
414
+ if (configRef.current.debug)
415
+ console.warn("[GameClient] Not connected, action dropped:", action);
416
+ return false;
417
+ }
370
418
  dispatchLocal(action);
371
419
  const cfg = configRef.current;
372
420
  const timeout = cfg.optimisticTimeoutMs ?? DEFAULT_OPTIMISTIC_TIMEOUT;
@@ -376,12 +424,18 @@ function useGameClient(config) {
376
424
  rollbackToServerState();
377
425
  }, timeout);
378
426
  }
379
- if (socketRef.current?.readyState === TransportReadyState.OPEN) {
380
- socketRef.current.send(JSON.stringify({
427
+ try {
428
+ transport.send(JSON.stringify({
381
429
  type: MessageTypes3.ACTION,
382
430
  payload: action
383
431
  }));
432
+ } catch (e) {
433
+ if (configRef.current.debug)
434
+ console.error("[GameClient] Failed to send action:", e);
435
+ rollbackToServerState();
436
+ return false;
384
437
  }
438
+ return true;
385
439
  }, [rollbackToServerState]);
386
440
  return {
387
441
  status,
@@ -435,6 +489,7 @@ var POLICY_CLOSE_CODE = 1008;
435
489
  class RelayClientTransport {
436
490
  ws;
437
491
  roomId;
492
+ sessionScope;
438
493
  state = TransportReadyState.CONNECTING;
439
494
  pendingCloseCode = null;
440
495
  pendingCloseReason = null;
@@ -444,6 +499,7 @@ class RelayClientTransport {
444
499
  onerror;
445
500
  constructor(options) {
446
501
  this.roomId = options.roomId;
502
+ this.sessionScope = `relay:${options.url}#${options.roomId.toUpperCase()}`;
447
503
  this.ws = new WebSocket(relayRoomUrl(options.url, options.roomId));
448
504
  this.ws.onopen = () => {
449
505
  this.ws.send(JSON.stringify({
@@ -734,11 +790,13 @@ export {
734
790
  createWebSocketTransport,
735
791
  describeRelayError,
736
792
  interpretHostMessage,
793
+ isRecoverableClose,
737
794
  nextSyncInterval,
738
795
  normalizeRoomCode,
739
796
  relayRoomUrl,
740
797
  resolveSessionSecret,
741
798
  resolveWebSocketUrl,
799
+ sessionSecretKey,
742
800
  shouldReconnect,
743
801
  useDebugPanel,
744
802
  useGameClient,
package/lib/client.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { type IGameState, type IAction } from "@couch-kit/core";
2
2
  import { type HostError } from "./connection";
3
3
  import { type CreateClientTransport } from "./transport";
4
+ /** Connection status reported by {@link useGameClient}. */
5
+ export type ClientStatus = "connecting" | "connected" | "disconnected" | "error";
4
6
  export interface ClientConfig<S extends IGameState, A extends IAction> {
5
7
  url?: string;
6
8
  wsPort?: number;
@@ -56,6 +58,30 @@ export interface ClientConfig<S extends IGameState, A extends IAction> {
56
58
  * already-connecting transport each time.
57
59
  */
58
60
  createTransport?: CreateClientTransport;
61
+ /**
62
+ * Identifies what `createTransport` connects to. Changing it closes the
63
+ * current connection and opens a fresh one, with the retry budget reset.
64
+ *
65
+ * `createTransport` itself is read on each connect but is usually a new
66
+ * function every render, so it cannot signal a change. With a relay, pass
67
+ * the room code: a phone that typed a wrong code (`ROOM_NOT_FOUND`, which is
68
+ * final) then connects as soon as the player enters the right one. To retry
69
+ * the *same* code, call `reconnect()`.
70
+ */
71
+ connectionKey?: string | number | null;
72
+ /**
73
+ * Reconnect at once when the page becomes visible again, is restored from
74
+ * the back/forward cache, or the browser comes back online (default:
75
+ * `true`). A phone that slept through the reconnect attempts would otherwise
76
+ * stay disconnected until the page was reloaded. Final closes (a room that
77
+ * does not exist, a rejected secret) and `disconnect()` are not undone.
78
+ */
79
+ reconnectOnResume?: boolean;
80
+ /**
81
+ * Scope for the session secret when the transport does not set its own (see
82
+ * `ClientTransport.sessionScope`). Players keep one identity per scope.
83
+ */
84
+ sessionScope?: string;
59
85
  }
60
86
  /**
61
87
  * React hook that connects the web controller to the TV host via WebSocket.
@@ -65,7 +91,7 @@ export interface ClientConfig<S extends IGameState, A extends IAction> {
65
91
  * reconnection with exponential backoff.
66
92
  *
67
93
  * @param config - Client configuration including reducer, initial state, and connection options.
68
- * @returns An object with `status`, `state`, `playerId`, `sendAction`, `getServerTime`, `rtt`, `disconnect`, and `reconnect`.
94
+ * @returns An object with `status`, `state`, `playerId`, `disconnectReason`, `sendAction`, `getServerTime`, `rtt`, `disconnect`, and `reconnect`.
69
95
  *
70
96
  * @example
71
97
  * ```tsx
@@ -76,7 +102,7 @@ export interface ClientConfig<S extends IGameState, A extends IAction> {
76
102
  * ```
77
103
  */
78
104
  export declare function useGameClient<S extends IGameState, A extends IAction>(config: ClientConfig<S, A>): {
79
- status: "connected" | "connecting" | "disconnected" | "error";
105
+ status: ClientStatus;
80
106
  state: S;
81
107
  playerId: string | null;
82
108
  /**
@@ -85,7 +111,7 @@ export declare function useGameClient<S extends IGameState, A extends IAction>(c
85
111
  * `null` when connected or when the cause is unknown.
86
112
  */
87
113
  disconnectReason: string | null;
88
- sendAction: (action: A) => void;
114
+ sendAction: (action: A) => boolean;
89
115
  getServerTime: () => number;
90
116
  /** Round-trip time (ms) to the server. Updated periodically via PING/PONG. */
91
117
  rtt: number;
@@ -94,4 +120,6 @@ export declare function useGameClient<S extends IGameState, A extends IAction>(c
94
120
  /** Manually reconnect to the host. Resets the reconnection attempt counter. */
95
121
  reconnect: () => void;
96
122
  };
123
+ /** What {@link useGameClient} returns. */
124
+ export type UseGameClientResult<S extends IGameState, A extends IAction> = ReturnType<typeof useGameClient<S, A>>;
97
125
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,OAAO,EAEb,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AACtB,OAAO,EAIL,KAAK,qBAAqB,EAC3B,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACnE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACrC,YAAY,EAAE,CAAC,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mEAAmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;OAQG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;IACrC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,qBAAqB,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EACnE,MAAM,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC;;;;IAgUxB;;;;OAIG;;yBArCM,CAAC;;IAyCV,8EAA8E;;IAE9E,0EAA0E;;IAE1E,+EAA+E;;EAGlF"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,OAAO,EAEb,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAQL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AACtB,OAAO,EAIL,KAAK,qBAAqB,EAC3B,MAAM,aAAa,CAAC;AAErB,2DAA2D;AAC3D,MAAM,MAAM,YAAY,GACtB,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;AAExD,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACnE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACrC,YAAY,EAAE,CAAC,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mEAAmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;OAQG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;IACrC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,qBAAqB,CAAC;IACxC;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACvC;;;;;;OAMG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EACnE,MAAM,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC;;;;IAiYxB;;;;OAIG;;yBAlDM,CAAC,KAAG,OAAO;;IAsDpB,8EAA8E;;IAE9E,0EAA0E;;IAE1E,+EAA+E;;EAGlF;AAED,0CAA0C;AAC1C,MAAM,MAAM,mBAAmB,CAC7B,CAAC,SAAS,UAAU,EACpB,CAAC,SAAS,OAAO,IACf,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC"}
@@ -10,6 +10,12 @@ import { type HostMessage } from "@couch-kit/core";
10
10
  */
11
11
  /** localStorage key under which the session-recovery secret is persisted. */
12
12
  export declare const SESSION_SECRET_KEY = "ck_secret";
13
+ /**
14
+ * The storage key for a session scope: {@link SESSION_SECRET_KEY} itself when
15
+ * unscoped (so existing LAN players keep their identity), otherwise suffixed
16
+ * with the scope.
17
+ */
18
+ export declare function sessionSecretKey(scope?: string): string;
13
19
  /**
14
20
  * Default time (ms) an optimistic update may stand without the host confirming
15
21
  * it before the client falls back to the last state the host sent.
@@ -56,6 +62,11 @@ export interface ReconnectDecision {
56
62
  /** Maximum number of reconnection attempts permitted. */
57
63
  maxRetries: number;
58
64
  }
65
+ /**
66
+ * Whether a close with this code may be retried at all, regardless of how many
67
+ * attempts remain. Policy (1008) and internal-error (1011) closes are final.
68
+ */
69
+ export declare function isRecoverableClose(closeCode: number): boolean;
59
70
  /**
60
71
  * Decide whether the client should attempt an automatic reconnect.
61
72
  *
@@ -76,8 +87,11 @@ export type SecretStorage = Pick<Storage, "getItem" | "setItem">;
76
87
  * storage is unavailable or throws (e.g. Safari private browsing, restrictive
77
88
  * WebViews), a fresh secret is generated without persistence so a JOIN can
78
89
  * still proceed.
90
+ *
91
+ * `scope` keeps a separate secret per scope (see
92
+ * `ClientTransport.sessionScope`).
79
93
  */
80
- export declare function resolveSessionSecret(storage: SecretStorage | null | undefined, generate?: () => string): string;
94
+ export declare function resolveSessionSecret(storage: SecretStorage | null | undefined, generate?: () => string, scope?: string): string;
81
95
  /**
82
96
  * A side-effect descriptor produced by {@link interpretHostMessage}. The client
83
97
  * hook executes these against React state so the routing logic itself stays
@@ -1 +1 @@
1
- {"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,WAAW,EACjB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;;GAQG;AAEH,6EAA6E;AAC7E,eAAO,MAAM,kBAAkB,cAAc,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAQ/C,iEAAiE;AACjE,MAAM,WAAW,mBAAmB;IAClC,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wEAAwE;AACxE,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,mBAAmB,EAC3B,QAAQ,EAAE,YAAY,GAAG,IAAI,GAAG,SAAS,GACxC,MAAM,GAAG,IAAI,CAUf;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,MAAM,CAER;AAED,sDAAsD;AACtD,MAAM,WAAW,iBAAiB;IAChC,gFAAgF;IAChF,gBAAgB,EAAE,OAAO,CAAC;IAC1B,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAIpE;AAED,oEAAoE;AACpE,MAAM,MAAM,aAAa,GAAG,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,CAAC,CAAC;AAEjE;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,aAAa,GAAG,IAAI,GAAG,SAAS,EACzC,QAAQ,GAAE,MAAM,MAAmB,GAClC,MAAM,CAWR;AAED;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,CAAC,CAAC,IAC3B;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,WAAW,CAAA;CAAE,GACtC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,CAAC;AAExC;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC,CAAC,SAAS,CAAC,CAAC;AAE3E,wCAAwC;AACxC,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,SAAS,CAAC,CAAC;AAE5E;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,GAAG,EAAE,WAAW,GACf,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAqBxB"}
1
+ {"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,WAAW,EACjB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;;GAQG;AAEH,6EAA6E;AAC7E,eAAO,MAAM,kBAAkB,cAAc,CAAC;AAE9C;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAQ/C,iEAAiE;AACjE,MAAM,WAAW,mBAAmB;IAClC,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wEAAwE;AACxE,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,mBAAmB,EAC3B,QAAQ,EAAE,YAAY,GAAG,IAAI,GAAG,SAAS,GACxC,MAAM,GAAG,IAAI,CAUf;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,MAAM,CAER;AAED,sDAAsD;AACtD,MAAM,WAAW,iBAAiB;IAChC,gFAAgF;IAChF,gBAAgB,EAAE,OAAO,CAAC;IAC1B,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAE7D;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAIpE;AAED,oEAAoE;AACpE,MAAM,MAAM,aAAa,GAAG,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,CAAC,CAAC;AAEjE;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,aAAa,GAAG,IAAI,GAAG,SAAS,EACzC,QAAQ,GAAE,MAAM,MAAmB,EACnC,KAAK,CAAC,EAAE,MAAM,GACb,MAAM,CAYR;AAED;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,CAAC,CAAC,IAC3B;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,WAAW,CAAA;CAAE,GACtC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,CAAC;AAExC;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC,CAAC,SAAS,CAAC,CAAC;AAE3E,wCAAwC;AACxC,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,SAAS,CAAC,CAAC;AAE5E;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,GAAG,EAAE,WAAW,GACf,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAqBxB"}
@@ -19,6 +19,12 @@ export interface RelayTransportOptions {
19
19
  export declare class RelayClientTransport implements ClientTransport {
20
20
  private readonly ws;
21
21
  private readonly roomId;
22
+ /**
23
+ * One session secret per relay and room. Anyone can open a relay room and
24
+ * share its link; a secret shared across rooms would let that room's display
25
+ * collect it and rejoin the player's real room as them.
26
+ */
27
+ readonly sessionScope: string;
22
28
  private state;
23
29
  /** When set, the code reported to `onclose` instead of the raw socket code. */
24
30
  private pendingCloseCode;
@@ -1 +1 @@
1
- {"version":3,"file":"relay-transport.d.ts","sourceRoot":"","sources":["../src/relay-transport.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC3B,MAAM,aAAa,CAAC;AASrB,+EAA+E;AAC/E,MAAM,WAAW,qBAAqB;IACpC,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAC;IACZ,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;CAChB;AASD;;;;;;;;;GASG;AACH,qBAAa,oBAAqB,YAAW,eAAe;IAC1D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,KAAK,CAA0C;IACvD,+EAA+E;IAC/E,OAAO,CAAC,gBAAgB,CAAuB;IAC/C;;;;OAIG;IACH,OAAO,CAAC,kBAAkB,CAAuB;IAEjD,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAEpC,YAAY,OAAO,EAAE,qBAAqB,EA0CzC;IAED,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CASvB;IAED,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAW1C;IAED,OAAO,CAAC,kBAAkB;CAqB3B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,qBAAqB,GAC7B,qBAAqB,CAEvB"}
1
+ {"version":3,"file":"relay-transport.d.ts","sourceRoot":"","sources":["../src/relay-transport.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC3B,MAAM,aAAa,CAAC;AASrB,+EAA+E;AAC/E,MAAM,WAAW,qBAAqB;IACpC,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAC;IACZ,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;CAChB;AASD;;;;;;;;;GASG;AACH,qBAAa,oBAAqB,YAAW,eAAe;IAC1D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,OAAO,CAAC,KAAK,CAA0C;IACvD,+EAA+E;IAC/E,OAAO,CAAC,gBAAgB,CAAuB;IAC/C;;;;OAIG;IACH,OAAO,CAAC,kBAAkB,CAAuB;IAEjD,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAEpC,YAAY,OAAO,EAAE,qBAAqB,EA2CzC;IAED,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CASvB;IAED,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAW1C;IAED,OAAO,CAAC,kBAAkB;CAqB3B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,qBAAqB,GAC7B,qBAAqB,CAEvB"}
@@ -49,6 +49,14 @@ export interface ClientTransport {
49
49
  onclose?: (code: number, reason?: string) => void;
50
50
  /** Invoked on a transport-level error. */
51
51
  onerror?: (error?: unknown) => void;
52
+ /**
53
+ * Scope for the session secret this transport's JOIN carries. The client
54
+ * keeps one secret per scope, so a secret sent to one game never reaches
55
+ * another. The relay transport sets it to the relay URL and room code: any
56
+ * party can open a relay room, and a phone's secret is what proves it is
57
+ * that player. Omit for a single trusted host (the LAN default).
58
+ */
59
+ readonly sessionScope?: string;
52
60
  }
53
61
  /**
54
62
  * Factory that creates a fresh {@link ClientTransport}. `useGameClient` calls
@@ -1 +1 @@
1
- {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB;aAC9B,UAAU,EAAE,CAAC;aACb,IAAI,EAAE,CAAC;aACP,OAAO,EAAE,CAAC;aACV,MAAM,EAAE,CAAC;CACD,CAAC;AAEX;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,kDAAkD;IAClD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;;;OAIG;IACH,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,6DAA6D;IAC7D,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,qEAAqE;IACrE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,4EAA4E;IAC5E,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAClD,0CAA0C;IAC1C,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CACrC;AAED;;;;GAIG;AACH,MAAM,MAAM,qBAAqB,GAAG,MAAM,eAAe,CAAC;AAE1D;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAuBrE"}
1
+ {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB;aAC9B,UAAU,EAAE,CAAC;aACb,IAAI,EAAE,CAAC;aACP,OAAO,EAAE,CAAC;aACV,MAAM,EAAE,CAAC;CACD,CAAC;AAEX;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,kDAAkD;IAClD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;;;OAIG;IACH,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,6DAA6D;IAC7D,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,qEAAqE;IACrE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,4EAA4E;IAC5E,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAClD,0CAA0C;IAC1C,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IACpC;;;;;;OAMG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;;GAIG;AACH,MAAM,MAAM,qBAAqB,GAAG,MAAM,eAAe,CAAC;AAE1D;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAuBrE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@couch-kit/client",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
@@ -49,7 +49,7 @@
49
49
  "clean": "rm -rf dist lib"
50
50
  },
51
51
  "dependencies": {
52
- "@couch-kit/core": "0.10.2"
52
+ "@couch-kit/core": "0.10.3"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@happy-dom/global-registrator": "^21.0.0",
package/src/client.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  resolveWebSocketUrl,
17
17
  computeBackoffDelay,
18
18
  shouldReconnect,
19
+ isRecoverableClose,
19
20
  resolveSessionSecret,
20
21
  interpretHostMessage,
21
22
  DEFAULT_OPTIMISTIC_TIMEOUT,
@@ -28,6 +29,10 @@ import {
28
29
  type CreateClientTransport,
29
30
  } from "./transport";
30
31
 
32
+ /** Connection status reported by {@link useGameClient}. */
33
+ export type ClientStatus =
34
+ "connecting" | "connected" | "disconnected" | "error";
35
+
31
36
  export interface ClientConfig<S extends IGameState, A extends IAction> {
32
37
  url?: string; // Full WebSocket URL (overrides auto-detection)
33
38
  wsPort?: number; // WebSocket port (default: auto-detected as HTTP port + 2)
@@ -83,6 +88,30 @@ export interface ClientConfig<S extends IGameState, A extends IAction> {
83
88
  * already-connecting transport each time.
84
89
  */
85
90
  createTransport?: CreateClientTransport;
91
+ /**
92
+ * Identifies what `createTransport` connects to. Changing it closes the
93
+ * current connection and opens a fresh one, with the retry budget reset.
94
+ *
95
+ * `createTransport` itself is read on each connect but is usually a new
96
+ * function every render, so it cannot signal a change. With a relay, pass
97
+ * the room code: a phone that typed a wrong code (`ROOM_NOT_FOUND`, which is
98
+ * final) then connects as soon as the player enters the right one. To retry
99
+ * the *same* code, call `reconnect()`.
100
+ */
101
+ connectionKey?: string | number | null;
102
+ /**
103
+ * Reconnect at once when the page becomes visible again, is restored from
104
+ * the back/forward cache, or the browser comes back online (default:
105
+ * `true`). A phone that slept through the reconnect attempts would otherwise
106
+ * stay disconnected until the page was reloaded. Final closes (a room that
107
+ * does not exist, a rejected secret) and `disconnect()` are not undone.
108
+ */
109
+ reconnectOnResume?: boolean;
110
+ /**
111
+ * Scope for the session secret when the transport does not set its own (see
112
+ * `ClientTransport.sessionScope`). Players keep one identity per scope.
113
+ */
114
+ sessionScope?: string;
86
115
  }
87
116
 
88
117
  /**
@@ -93,7 +122,7 @@ export interface ClientConfig<S extends IGameState, A extends IAction> {
93
122
  * reconnection with exponential backoff.
94
123
  *
95
124
  * @param config - Client configuration including reducer, initial state, and connection options.
96
- * @returns An object with `status`, `state`, `playerId`, `sendAction`, `getServerTime`, `rtt`, `disconnect`, and `reconnect`.
125
+ * @returns An object with `status`, `state`, `playerId`, `disconnectReason`, `sendAction`, `getServerTime`, `rtt`, `disconnect`, and `reconnect`.
97
126
  *
98
127
  * @example
99
128
  * ```tsx
@@ -106,9 +135,7 @@ export interface ClientConfig<S extends IGameState, A extends IAction> {
106
135
  export function useGameClient<S extends IGameState, A extends IAction>(
107
136
  config: ClientConfig<S, A>,
108
137
  ) {
109
- const [status, setStatus] = useState<
110
- "connecting" | "connected" | "disconnected" | "error"
111
- >("disconnected");
138
+ const [status, setStatus] = useState<ClientStatus>("disconnected");
112
139
  const [playerId, setPlayerId] = useState<string | null>(null);
113
140
  /**
114
141
  * Why the last connection ended, when the transport knows. For the relay this
@@ -138,6 +165,9 @@ export function useGameClient<S extends IGameState, A extends IAction>(
138
165
  const reconnectAttempts = useRef(0);
139
166
  const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
140
167
  const intentionalClose = useRef(false);
168
+ // The last close may be retried: not intentional, not a final close code.
169
+ // What resuming the page acts on once the backoff has given up.
170
+ const resumable = useRef(false);
141
171
 
142
172
  // Keep refs for values used inside connect() to avoid stale closures
143
173
  const configRef = useRef(config);
@@ -208,6 +238,7 @@ export function useGameClient<S extends IGameState, A extends IAction>(
208
238
 
209
239
  socketRef.current = transport;
210
240
  setStatus("connecting");
241
+ setDisconnectReason(null);
211
242
 
212
243
  // Every handler ignores a transport that is no longer the current one. A
213
244
  // socket closed by cleanup, `disconnect()` or a reconnect still fires its
@@ -227,6 +258,8 @@ export function useGameClient<S extends IGameState, A extends IAction>(
227
258
  // Session Recovery Logic -- use cryptographically random secrets
228
259
  const secret = resolveSessionSecret(
229
260
  typeof localStorage !== "undefined" ? localStorage : null,
261
+ undefined,
262
+ transport.sessionScope ?? currentCfg.sessionScope,
230
263
  );
231
264
 
232
265
  // Join with secret
@@ -296,6 +329,7 @@ export function useGameClient<S extends IGameState, A extends IAction>(
296
329
  // ROOM_FULL, …). Surfacing it lets the UI explain the failure rather than
297
330
  // sit on "connecting" forever.
298
331
  setDisconnectReason(reason ? reason : null);
332
+ resumable.current = !intentionalClose.current && isRecoverableClose(code);
299
333
  configRef.current.onDisconnect?.();
300
334
 
301
335
  // Don't reconnect if the close was intentional or if the server
@@ -331,12 +365,13 @@ export function useGameClient<S extends IGameState, A extends IAction>(
331
365
  if (configRef.current.debug) console.error("[GameClient] Error", e);
332
366
  setStatus("error");
333
367
  };
334
- // Only re-create the connect function when URL/port actually changes.
335
- // Config values like name, avatar, callbacks, and createTransport are read
336
- // from configRef.
368
+ // Only re-create the connect function when what it connects to changes:
369
+ // URL/port, or the caller's connectionKey. Config values like name,
370
+ // avatar, callbacks, and createTransport are read from configRef.
337
371
  }, [
338
372
  config.url,
339
373
  config.wsPort,
374
+ config.connectionKey,
340
375
  maxRetries,
341
376
  baseDelay,
342
377
  maxDelay,
@@ -351,6 +386,7 @@ export function useGameClient<S extends IGameState, A extends IAction>(
351
386
  */
352
387
  const closeCurrent = useCallback(() => {
353
388
  intentionalClose.current = true;
389
+ resumable.current = false;
354
390
  if (reconnectTimer.current) {
355
391
  clearTimeout(reconnectTimer.current);
356
392
  reconnectTimer.current = null;
@@ -364,12 +400,49 @@ export function useGameClient<S extends IGameState, A extends IAction>(
364
400
  configRef.current.onDisconnect?.();
365
401
  }, [cancelRollback]);
366
402
 
367
- // Initial Connection
403
+ // Initial connection, and a fresh one (with a fresh retry budget) whenever
404
+ // the target changes.
368
405
  useEffect(() => {
406
+ reconnectAttempts.current = 0;
369
407
  connect();
370
408
  return closeCurrent;
371
409
  }, [connect, closeCurrent]);
372
410
 
411
+ // Resume: a phone that slept, or lost its network, may have used up every
412
+ // retry while it could not connect. Try again the moment it is back.
413
+ useEffect(() => {
414
+ if (config.reconnectOnResume === false) return;
415
+ if (typeof window === "undefined") return;
416
+
417
+ const resume = () => {
418
+ if (
419
+ typeof document !== "undefined" &&
420
+ document.visibilityState === "hidden"
421
+ ) {
422
+ return;
423
+ }
424
+ // A live or connecting transport needs nothing; a final close stays final.
425
+ if (socketRef.current || !resumable.current) return;
426
+ if (reconnectTimer.current) {
427
+ clearTimeout(reconnectTimer.current);
428
+ reconnectTimer.current = null;
429
+ }
430
+ if (configRef.current.debug)
431
+ console.log("[GameClient] Page resumed, reconnecting");
432
+ reconnectAttempts.current = 0;
433
+ connect();
434
+ };
435
+
436
+ document?.addEventListener("visibilitychange", resume);
437
+ window.addEventListener("online", resume);
438
+ window.addEventListener("pageshow", resume);
439
+ return () => {
440
+ document?.removeEventListener("visibilitychange", resume);
441
+ window.removeEventListener("online", resume);
442
+ window.removeEventListener("pageshow", resume);
443
+ };
444
+ }, [connect, config.reconnectOnResume]);
445
+
373
446
  /**
374
447
  * Manually disconnect from the host.
375
448
  * Prevents automatic reconnection.
@@ -389,9 +462,24 @@ export function useGameClient<S extends IGameState, A extends IAction>(
389
462
  connect();
390
463
  }, [closeCurrent, connect]);
391
464
 
392
- // Action Dispatcher
465
+ /**
466
+ * Sends an action to the host, applying it locally first when a `reducer`
467
+ * is configured.
468
+ *
469
+ * @returns `true` if the action was sent, `false` if there is no open
470
+ * connection. Nothing is queued, and no optimistic update is applied, for
471
+ * an action that was not sent. Sent actions can still be rejected by the
472
+ * host; those arrive through `onError`.
473
+ */
393
474
  const sendAction = useCallback(
394
- (action: A) => {
475
+ (action: A): boolean => {
476
+ const transport = socketRef.current;
477
+ if (transport?.readyState !== TransportReadyState.OPEN) {
478
+ if (configRef.current.debug)
479
+ console.warn("[GameClient] Not connected, action dropped:", action);
480
+ return false;
481
+ }
482
+
395
483
  // 1. Optimistic Update
396
484
  dispatchLocal(action);
397
485
 
@@ -408,14 +496,20 @@ export function useGameClient<S extends IGameState, A extends IAction>(
408
496
  }
409
497
 
410
498
  // 2. Send to Host
411
- if (socketRef.current?.readyState === TransportReadyState.OPEN) {
412
- socketRef.current.send(
499
+ try {
500
+ transport.send(
413
501
  JSON.stringify({
414
502
  type: MessageTypes.ACTION,
415
503
  payload: action,
416
504
  }),
417
505
  );
506
+ } catch (e) {
507
+ if (configRef.current.debug)
508
+ console.error("[GameClient] Failed to send action:", e);
509
+ rollbackToServerState();
510
+ return false;
418
511
  }
512
+ return true;
419
513
  },
420
514
  [rollbackToServerState],
421
515
  );
@@ -440,3 +534,9 @@ export function useGameClient<S extends IGameState, A extends IAction>(
440
534
  reconnect,
441
535
  };
442
536
  }
537
+
538
+ /** What {@link useGameClient} returns. */
539
+ export type UseGameClientResult<
540
+ S extends IGameState,
541
+ A extends IAction,
542
+ > = ReturnType<typeof useGameClient<S, A>>;
package/src/connection.ts CHANGED
@@ -20,6 +20,15 @@ import {
20
20
  /** localStorage key under which the session-recovery secret is persisted. */
21
21
  export const SESSION_SECRET_KEY = "ck_secret";
22
22
 
23
+ /**
24
+ * The storage key for a session scope: {@link SESSION_SECRET_KEY} itself when
25
+ * unscoped (so existing LAN players keep their identity), otherwise suffixed
26
+ * with the scope.
27
+ */
28
+ export function sessionSecretKey(scope?: string): string {
29
+ return scope ? `${SESSION_SECRET_KEY}:${scope}` : SESSION_SECRET_KEY;
30
+ }
31
+
23
32
  /**
24
33
  * Default time (ms) an optimistic update may stand without the host confirming
25
34
  * it before the client falls back to the last state the host sent.
@@ -97,6 +106,14 @@ export interface ReconnectDecision {
97
106
  maxRetries: number;
98
107
  }
99
108
 
109
+ /**
110
+ * Whether a close with this code may be retried at all, regardless of how many
111
+ * attempts remain. Policy (1008) and internal-error (1011) closes are final.
112
+ */
113
+ export function isRecoverableClose(closeCode: number): boolean {
114
+ return !NON_RECOVERABLE_CLOSE_CODES.has(closeCode);
115
+ }
116
+
100
117
  /**
101
118
  * Decide whether the client should attempt an automatic reconnect.
102
119
  *
@@ -106,7 +123,7 @@ export interface ReconnectDecision {
106
123
  */
107
124
  export function shouldReconnect(decision: ReconnectDecision): boolean {
108
125
  if (decision.intentionalClose) return false;
109
- if (NON_RECOVERABLE_CLOSE_CODES.has(decision.closeCode)) return false;
126
+ if (!isRecoverableClose(decision.closeCode)) return false;
110
127
  return decision.attempts < decision.maxRetries;
111
128
  }
112
129
 
@@ -123,17 +140,22 @@ export type SecretStorage = Pick<Storage, "getItem" | "setItem">;
123
140
  * storage is unavailable or throws (e.g. Safari private browsing, restrictive
124
141
  * WebViews), a fresh secret is generated without persistence so a JOIN can
125
142
  * still proceed.
143
+ *
144
+ * `scope` keeps a separate secret per scope (see
145
+ * `ClientTransport.sessionScope`).
126
146
  */
127
147
  export function resolveSessionSecret(
128
148
  storage: SecretStorage | null | undefined,
129
149
  generate: () => string = generateId,
150
+ scope?: string,
130
151
  ): string {
152
+ const key = sessionSecretKey(scope);
131
153
  try {
132
154
  if (!storage) return generate();
133
- const stored = storage.getItem(SESSION_SECRET_KEY);
155
+ const stored = storage.getItem(key);
134
156
  if (stored && isValidSecret(stored)) return stored;
135
157
  const secret = generate();
136
- storage.setItem(SESSION_SECRET_KEY, secret);
158
+ storage.setItem(key, secret);
137
159
  return secret;
138
160
  } catch {
139
161
  return generate();
@@ -39,6 +39,12 @@ const POLICY_CLOSE_CODE = 1008;
39
39
  export class RelayClientTransport implements ClientTransport {
40
40
  private readonly ws: WebSocket;
41
41
  private readonly roomId: string;
42
+ /**
43
+ * One session secret per relay and room. Anyone can open a relay room and
44
+ * share its link; a secret shared across rooms would let that room's display
45
+ * collect it and rejoin the player's real room as them.
46
+ */
47
+ readonly sessionScope: string;
42
48
  private state: number = TransportReadyState.CONNECTING;
43
49
  /** When set, the code reported to `onclose` instead of the raw socket code. */
44
50
  private pendingCloseCode: number | null = null;
@@ -56,6 +62,7 @@ export class RelayClientTransport implements ClientTransport {
56
62
 
57
63
  constructor(options: RelayTransportOptions) {
58
64
  this.roomId = options.roomId;
65
+ this.sessionScope = `relay:${options.url}#${options.roomId.toUpperCase()}`;
59
66
  this.ws = new WebSocket(relayRoomUrl(options.url, options.roomId));
60
67
 
61
68
  this.ws.onopen = () => {
package/src/transport.ts CHANGED
@@ -51,6 +51,14 @@ export interface ClientTransport {
51
51
  onclose?: (code: number, reason?: string) => void;
52
52
  /** Invoked on a transport-level error. */
53
53
  onerror?: (error?: unknown) => void;
54
+ /**
55
+ * Scope for the session secret this transport's JOIN carries. The client
56
+ * keeps one secret per scope, so a secret sent to one game never reaches
57
+ * another. The relay transport sets it to the relay URL and room code: any
58
+ * party can open a relay room, and a phone's secret is what proves it is
59
+ * that player. Omit for a single trusted host (the LAN default).
60
+ */
61
+ readonly sessionScope?: string;
54
62
  }
55
63
 
56
64
  /**