@couch-kit/client 0.14.0 → 0.15.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,19 @@
1
1
  # @couch-kit/client
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#206](https://github.com/faluciano/react-native-couch-kit/pull/206) [`ddec122`](https://github.com/faluciano/react-native-couch-kit/commit/ddec122d9e415c54f157d6998c2b719ab31586b3) Thanks [@faluciano](https://github.com/faluciano)! - Fix the `useGameClient` connection lifecycle and make optimistic updates self-correcting.
8
+
9
+ - Time sync now actually runs. The ping loop only started if the socket was already open when the hook first saw it, which it never was, so no `PING` was ever sent: `rtt` stayed `0` and `getServerTime()` returned the local clock. It now starts when the socket opens. Games that use neither can pass `timeSync: false` to skip the pings (on a relay each one is a billed message).
10
+ - No more duplicate connections. A socket closed by cleanup, `disconnect()` or `reconnect()` still fires its `close` event later; that event used to overwrite the new connection's status and schedule a second, parallel reconnect. This happened on every mount under React `StrictMode` and whenever `url`, `wsPort` or the retry options changed. Events from a replaced transport are now ignored.
11
+ - Optimistic updates no longer get stuck. The host stays silent when an action changes nothing, so an optimistic update it ignored stood forever. The client now falls back to the last host state if no update arrives within `optimisticTimeoutMs` (default 2000, `0` disables), and immediately on a host `ERROR`.
12
+ - New `onError` callback receives host rejections (`RATE_LIMITED`, `NOT_JOINED`, …), which were silently dropped before. `interpretHostMessage` now returns an `error` effect for `ERROR` messages.
13
+ - `disconnectReason` resets to `null` once a connection opens, as documented.
14
+ - A stored session secret the host would reject is replaced instead of reused, so a corrupted `ck_secret` no longer fails every JOIN.
15
+ - The relay transport reports a room whose display disconnected as a terminal `HOST_LEFT` close (new `RelayErrorCodes.HOST_LEFT`, `RELAY_CLOSE_HOST_LEFT`), and `describeRelayError` explains it.
16
+
3
17
  ## 0.14.0
4
18
 
5
19
  ### Minor Changes
package/README.md CHANGED
@@ -4,7 +4,7 @@ The client-side React hooks for the web controller.
4
4
 
5
5
  ## Features
6
6
 
7
- - **Default connection:** By default, connects to `ws(s)://{window.location.hostname}:8082`.
7
+ - **Default connection:** By default, connects to `ws(s)://{window.location.hostname}:8082/ws`.
8
8
  - **Time synchronization:** `useServerTime()` helps estimate server time using periodic ping/pong.
9
9
  - **Asset preloading:** `usePreload()` is a small helper for preloading images and fetching other URLs.
10
10
  - **Optimistic UI:** State updates apply locally immediately while being sent to the server.
@@ -27,15 +27,18 @@ Config:
27
27
 
28
28
  - `reducer`: `(state, action) => state` (your shared reducer)
29
29
  - `initialState`: initial state used until the host hydrates
30
- - `url?`: explicit WebSocket URL. If omitted, the hook uses `ws(s)://{window.location.hostname}:8082`.
30
+ - `url?`: explicit WebSocket URL. If omitted, the hook uses `ws(s)://{window.location.hostname}:8082/ws`.
31
31
  - `wsPort?`: WebSocket port override (default: auto-detected from page URL using HTTP port + 2)
32
32
  - `name?`: player display name (default: `"Player"`)
33
33
  - `avatar?`: player avatar emoji (default: `"\u{1F600}"`)
34
34
  - `maxRetries?`: maximum reconnection attempts before giving up (default: `5`)
35
35
  - `baseDelay?`: base delay in ms for exponential backoff reconnection (default: `1000`)
36
36
  - `maxDelay?`: maximum delay in ms cap for reconnection backoff (default: `10000`)
37
+ - `optimisticTimeoutMs?`: how long an optimistic update may stand without the host confirming it (default: `2000`; `0` disables). See [Optimistic updates](#optimistic-updates).
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.
37
39
  - `debug?`: enable console logs
38
40
  - `onConnect?`, `onDisconnect?`: lifecycle callbacks
41
+ - `onError?`: called with `{ code, message }` when the host rejects something this client sent (`RATE_LIMITED`, `NOT_JOINED`, `FORBIDDEN_ACTION`, `INVALID_SECRET`, …)
39
42
 
40
43
  Returns:
41
44
 
@@ -43,11 +46,25 @@ Returns:
43
46
  - `state`: current controller state (optimistic + hydrated)
44
47
  - `playerId`: stable public identifier derived from the session secret. Persists across page refreshes and reconnections (the same player always gets the same `playerId`).
45
48
  - `sendAction(action)`: optimistic dispatch + send to host
49
+ - `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
46
50
  - `getServerTime()`: NTP-ish server time based on periodic ping/pong
47
51
  - `rtt`: round-trip time (ms) to the server, updated periodically via PING/PONG
48
52
  - `disconnect()`: manually disconnect from the host (prevents automatic reconnection)
49
53
  - `reconnect()`: manually reconnect to the host (resets the reconnection attempt counter)
50
54
 
55
+ ### Optimistic updates
56
+
57
+ With a `reducer`, `sendAction` applies the action locally before the host
58
+ answers. The host is still the authority, and it only broadcasts when its state
59
+ actually changes — so an action it ignores (an illegal move, a rate-limited tap,
60
+ one sent while offline) produces no update at all. The client therefore treats
61
+ silence as a "no": if no state update arrives within `optimisticTimeoutMs`, it
62
+ falls back to the last state the host sent. A host `ERROR` does the same
63
+ immediately and is passed to `onError`.
64
+
65
+ The hook is safe under React `StrictMode` and across option changes: a
66
+ connection that has been replaced can no longer affect the current one.
67
+
51
68
  ## State Sync Contract
52
69
 
53
70
  The host broadcasts full state snapshots. The client automatically applies them using a higher-order reducer wrapper.
@@ -77,7 +94,7 @@ In dev, pass the TV WebSocket URL explicitly:
77
94
  useGameClient({
78
95
  reducer,
79
96
  initialState,
80
- url: "ws://TV_IP:8082",
97
+ url: "ws://TV_IP:8082/ws",
81
98
  });
82
99
  ```
83
100
 
package/dist/index.js CHANGED
@@ -98,9 +98,11 @@ function useServerTime(socket) {
98
98
  timer = setTimeout(sync, delay);
99
99
  };
100
100
  sync();
101
+ const pending = pings.current;
101
102
  return () => {
102
103
  if (timer !== null)
103
104
  clearTimeout(timer);
105
+ pending.clear();
104
106
  };
105
107
  }, [socket]);
106
108
  return { getServerTime, rtt: timeSync.rtt, handlePong };
@@ -111,9 +113,11 @@ import {
111
113
  MessageTypes as MessageTypes2,
112
114
  DEFAULT_WS_PORT_OFFSET,
113
115
  DEFAULT_WS_PATH,
114
- generateId as generateId2
116
+ generateId as generateId2,
117
+ isValidSecret
115
118
  } from "@couch-kit/core";
116
119
  var SESSION_SECRET_KEY = "ck_secret";
120
+ var DEFAULT_OPTIMISTIC_TIMEOUT = 2000;
117
121
  var NON_RECOVERABLE_CLOSE_CODES = new Set([
118
122
  1008,
119
123
  1011
@@ -144,7 +148,7 @@ function resolveSessionSecret(storage, generate = generateId2) {
144
148
  if (!storage)
145
149
  return generate();
146
150
  const stored = storage.getItem(SESSION_SECRET_KEY);
147
- if (stored)
151
+ if (stored && isValidSecret(stored))
148
152
  return stored;
149
153
  const secret = generate();
150
154
  storage.setItem(SESSION_SECRET_KEY, secret);
@@ -169,6 +173,8 @@ function interpretHostMessage(msg) {
169
173
  { kind: "setPlayerId", playerId: msg.payload.playerId },
170
174
  { kind: "hydrate", state: msg.payload.state }
171
175
  ];
176
+ case MessageTypes2.ERROR:
177
+ return [{ kind: "error", error: msg.payload }];
172
178
  default:
173
179
  return [];
174
180
  }
@@ -181,6 +187,9 @@ function useGameClient(config) {
181
187
  const [disconnectReason, setDisconnectReason] = useState2(null);
182
188
  const [state, dispatchLocal] = useReducer(createGameReducer(config.reducer ?? ((current) => current)), config.initialState);
183
189
  const socketRef = useRef2(null);
190
+ const [openTransport, setOpenTransport] = useState2(null);
191
+ const lastServerState = useRef2(null);
192
+ const rollbackTimer = useRef2(null);
184
193
  const reconnectAttempts = useRef2(0);
185
194
  const reconnectTimer = useRef2(null);
186
195
  const intentionalClose = useRef2(false);
@@ -188,11 +197,27 @@ function useGameClient(config) {
188
197
  useEffect2(() => {
189
198
  configRef.current = config;
190
199
  });
191
- const { getServerTime, rtt, handlePong } = useServerTime(socketRef.current);
200
+ const { getServerTime, rtt, handlePong } = useServerTime(config.timeSync === false ? null : openTransport);
192
201
  const handlePongRef = useRef2(handlePong);
193
202
  useEffect2(() => {
194
203
  handlePongRef.current = handlePong;
195
204
  });
205
+ const cancelRollback = useCallback2(() => {
206
+ if (rollbackTimer.current !== null) {
207
+ clearTimeout(rollbackTimer.current);
208
+ rollbackTimer.current = null;
209
+ }
210
+ }, []);
211
+ const rollbackToServerState = useCallback2(() => {
212
+ cancelRollback();
213
+ const serverState = lastServerState.current;
214
+ if (serverState === null)
215
+ return;
216
+ dispatchLocal({
217
+ type: InternalActionTypes.HYDRATE,
218
+ payload: serverState
219
+ });
220
+ }, [cancelRollback]);
196
221
  const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
197
222
  const baseDelay = config.baseDelay ?? DEFAULT_BASE_DELAY;
198
223
  const maxDelay = config.maxDelay ?? DEFAULT_MAX_DELAY;
@@ -214,9 +239,14 @@ function useGameClient(config) {
214
239
  }
215
240
  socketRef.current = transport;
216
241
  setStatus("connecting");
242
+ const isCurrent = () => socketRef.current === transport;
217
243
  transport.onopen = () => {
244
+ if (!isCurrent())
245
+ return;
218
246
  const currentCfg = configRef.current;
219
247
  setStatus("connected");
248
+ setDisconnectReason(null);
249
+ setOpenTransport(transport);
220
250
  reconnectAttempts.current = 0;
221
251
  currentCfg.onConnect?.();
222
252
  const secret = resolveSessionSecret(typeof localStorage !== "undefined" ? localStorage : null);
@@ -235,6 +265,8 @@ function useGameClient(config) {
235
265
  }
236
266
  };
237
267
  transport.onmessage = (data) => {
268
+ if (!isCurrent())
269
+ return;
238
270
  let msg;
239
271
  try {
240
272
  msg = JSON.parse(data);
@@ -248,6 +280,8 @@ function useGameClient(config) {
248
280
  setPlayerId(effect.playerId);
249
281
  break;
250
282
  case "hydrate":
283
+ lastServerState.current = effect.state;
284
+ cancelRollback();
251
285
  dispatchLocal({
252
286
  type: InternalActionTypes.HYDRATE,
253
287
  payload: effect.state
@@ -256,10 +290,20 @@ function useGameClient(config) {
256
290
  case "pong":
257
291
  handlePongRef.current(effect.payload);
258
292
  break;
293
+ case "error":
294
+ if (configRef.current.debug)
295
+ console.warn("[GameClient] Host error:", effect.error);
296
+ rollbackToServerState();
297
+ configRef.current.onError?.(effect.error);
298
+ break;
259
299
  }
260
300
  }
261
301
  };
262
302
  transport.onclose = (code, reason) => {
303
+ if (!isCurrent())
304
+ return;
305
+ socketRef.current = null;
306
+ setOpenTransport(null);
263
307
  setStatus("disconnected");
264
308
  setDisconnectReason(reason ? reason : null);
265
309
  configRef.current.onDisconnect?.();
@@ -279,47 +323,66 @@ function useGameClient(config) {
279
323
  }, delay);
280
324
  };
281
325
  transport.onerror = (e) => {
326
+ if (!isCurrent())
327
+ return;
282
328
  if (configRef.current.debug)
283
329
  console.error("[GameClient] Error", e);
284
330
  setStatus("error");
285
331
  };
286
- }, [config.url, config.wsPort, maxRetries, baseDelay, maxDelay]);
287
- useEffect2(() => {
288
- connect();
289
- return () => {
290
- intentionalClose.current = true;
291
- if (socketRef.current)
292
- socketRef.current.close();
293
- if (reconnectTimer.current)
294
- clearTimeout(reconnectTimer.current);
295
- };
296
- }, [connect]);
297
- const disconnect = useCallback2(() => {
332
+ }, [
333
+ config.url,
334
+ config.wsPort,
335
+ maxRetries,
336
+ baseDelay,
337
+ maxDelay,
338
+ cancelRollback,
339
+ rollbackToServerState
340
+ ]);
341
+ const closeCurrent = useCallback2(() => {
298
342
  intentionalClose.current = true;
299
343
  if (reconnectTimer.current) {
300
344
  clearTimeout(reconnectTimer.current);
301
345
  reconnectTimer.current = null;
302
346
  }
303
- if (socketRef.current) {
304
- socketRef.current.close();
305
- socketRef.current = null;
306
- }
347
+ cancelRollback();
348
+ const transport = socketRef.current;
349
+ if (!transport)
350
+ return;
351
+ socketRef.current = null;
352
+ setOpenTransport(null);
353
+ transport.close();
354
+ configRef.current.onDisconnect?.();
355
+ }, [cancelRollback]);
356
+ useEffect2(() => {
357
+ connect();
358
+ return closeCurrent;
359
+ }, [connect, closeCurrent]);
360
+ const disconnect = useCallback2(() => {
361
+ closeCurrent();
307
362
  setStatus("disconnected");
308
- }, []);
363
+ }, [closeCurrent]);
309
364
  const reconnect = useCallback2(() => {
310
- disconnect();
365
+ closeCurrent();
311
366
  reconnectAttempts.current = 0;
312
- setTimeout(() => connect(), 50);
313
- }, [disconnect, connect]);
367
+ connect();
368
+ }, [closeCurrent, connect]);
314
369
  const sendAction = useCallback2((action) => {
315
370
  dispatchLocal(action);
371
+ const cfg = configRef.current;
372
+ const timeout = cfg.optimisticTimeoutMs ?? DEFAULT_OPTIMISTIC_TIMEOUT;
373
+ if (cfg.reducer && timeout > 0 && rollbackTimer.current === null) {
374
+ rollbackTimer.current = setTimeout(() => {
375
+ rollbackTimer.current = null;
376
+ rollbackToServerState();
377
+ }, timeout);
378
+ }
316
379
  if (socketRef.current?.readyState === TransportReadyState.OPEN) {
317
380
  socketRef.current.send(JSON.stringify({
318
381
  type: MessageTypes3.ACTION,
319
382
  payload: action
320
383
  }));
321
384
  }
322
- }, []);
385
+ }, [rollbackToServerState]);
323
386
  return {
324
387
  status,
325
388
  state,
@@ -352,8 +415,10 @@ var RelayErrorCodes = {
352
415
  MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE",
353
416
  MALFORMED: "MALFORMED",
354
417
  RATE_LIMITED: "RATE_LIMITED",
355
- SERVER_BUSY: "SERVER_BUSY"
418
+ SERVER_BUSY: "SERVER_BUSY",
419
+ HOST_LEFT: "HOST_LEFT"
356
420
  };
421
+ var RELAY_CLOSE_HOST_LEFT = 4001;
357
422
  function relayRoomUrl(url, roomId) {
358
423
  const trimmed = url.replace(/\/+$/, "");
359
424
  const [base, query] = trimmed.split("?", 2);
@@ -394,6 +459,10 @@ class RelayClientTransport {
394
459
  };
395
460
  this.ws.onclose = (event) => {
396
461
  this.state = TransportReadyState.CLOSED;
462
+ if (this.pendingCloseCode === null && event.code === RELAY_CLOSE_HOST_LEFT) {
463
+ this.pendingCloseCode = POLICY_CLOSE_CODE;
464
+ this.pendingCloseReason = RelayErrorCodes.HOST_LEFT;
465
+ }
397
466
  const code = this.pendingCloseCode ?? event.code;
398
467
  this.onclose?.(code, this.pendingCloseReason ?? event.reason);
399
468
  };
@@ -491,6 +560,8 @@ function describeRelayError(reason) {
491
560
  return "That room isn't open. Check the code on the screen.";
492
561
  case RelayErrorCodes.ROOM_FULL:
493
562
  return "That room is full.";
563
+ case RelayErrorCodes.HOST_LEFT:
564
+ return "The host screen closed, so this game has ended.";
494
565
  case RelayErrorCodes.RATE_LIMITED:
495
566
  return "Too many messages — slow down and try again.";
496
567
  case RelayErrorCodes.ROOM_EXISTS:
@@ -640,27 +711,29 @@ function useDebugPanel(options) {
640
711
  };
641
712
  }
642
713
  export {
643
- useServerTime,
644
- useRelayRoom,
645
- usePreload,
646
- useGameClient,
647
- useDebugPanel,
648
- shouldReconnect,
649
- resolveWebSocketUrl,
650
- resolveSessionSecret,
651
- relayRoomUrl,
652
- normalizeRoomCode,
653
- nextSyncInterval,
654
- interpretHostMessage,
655
- describeRelayError,
656
- createWebSocketTransport,
657
- createRelayTransport,
658
- computeBackoffDelay,
659
- calculateTimeSync,
660
- TransportReadyState,
661
- SESSION_SECRET_KEY,
662
- RelayMessageTypes,
663
- RelayErrorCodes,
714
+ DEFAULT_OPTIMISTIC_TIMEOUT,
715
+ RELAY_CLOSE_HOST_LEFT,
716
+ RELAY_MINT_PATH,
664
717
  RelayClientTransport,
665
- RELAY_MINT_PATH
718
+ RelayErrorCodes,
719
+ RelayMessageTypes,
720
+ SESSION_SECRET_KEY,
721
+ TransportReadyState,
722
+ calculateTimeSync,
723
+ computeBackoffDelay,
724
+ createRelayTransport,
725
+ createWebSocketTransport,
726
+ describeRelayError,
727
+ interpretHostMessage,
728
+ nextSyncInterval,
729
+ normalizeRoomCode,
730
+ relayRoomUrl,
731
+ resolveSessionSecret,
732
+ resolveWebSocketUrl,
733
+ shouldReconnect,
734
+ useDebugPanel,
735
+ useGameClient,
736
+ usePreload,
737
+ useRelayRoom,
738
+ useServerTime
666
739
  };
package/lib/client.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type IGameState, type IAction } from "@couch-kit/core";
2
+ import { type HostError } from "./connection";
2
3
  import { type CreateClientTransport } from "./transport";
3
4
  export interface ClientConfig<S extends IGameState, A extends IAction> {
4
5
  url?: string;
@@ -23,8 +24,30 @@ export interface ClientConfig<S extends IGameState, A extends IAction> {
23
24
  baseDelay?: number;
24
25
  /** Maximum delay (ms) cap for reconnection backoff (default: 10000). */
25
26
  maxDelay?: number;
27
+ /**
28
+ * How long (ms) an optimistic update may stand without the host confirming
29
+ * it (default: 2000). The host only broadcasts when its state changes, so an
30
+ * action it ignores — an illegal move, a rate-limited tap, one sent while
31
+ * offline — produces no update, and without this the client would keep
32
+ * showing a state the host never had. When the window passes with no update
33
+ * the client falls back to the last state the host sent. Set to `0` to
34
+ * disable. Has no effect without a `reducer`.
35
+ */
36
+ optimisticTimeoutMs?: number;
37
+ /**
38
+ * Keep the clock in sync with the host by exchanging PING/PONG (default:
39
+ * `true`). Powers `getServerTime()` and `rtt`. A game that uses neither can
40
+ * turn it off: on a relay transport every ping, and the host's answer, is a
41
+ * billed message.
42
+ */
43
+ timeSync?: boolean;
26
44
  onConnect?: () => void;
27
45
  onDisconnect?: () => void;
46
+ /**
47
+ * Called when the host rejects something this client sent (`RATE_LIMITED`,
48
+ * `NOT_JOINED`, `FORBIDDEN_ACTION`, `INVALID_SECRET`, …).
49
+ */
50
+ onError?: (error: HostError) => void;
28
51
  debug?: boolean;
29
52
  /**
30
53
  * Provide a custom transport factory (e.g. a cross-network relay). When
@@ -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;AASzB,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,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,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;;;;IA2OxB;;;;OAIG;;yBAvBmC,CAAC;;IA2BvC,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,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"}
@@ -10,6 +10,11 @@ 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
+ * Default time (ms) an optimistic update may stand without the host confirming
15
+ * it before the client falls back to the last state the host sent.
16
+ */
17
+ export declare const DEFAULT_OPTIMISTIC_TIMEOUT = 2000;
13
18
  /** Connection options relevant to deriving the WebSocket URL. */
14
19
  export interface UrlResolutionConfig {
15
20
  /** Full WebSocket URL. When set, it is used verbatim. */
@@ -65,9 +70,12 @@ export type SecretStorage = Pick<Storage, "getItem" | "setItem">;
65
70
  * Resolve the session-recovery secret.
66
71
  *
67
72
  * Reuses an existing secret from storage when present, otherwise generates a
68
- * new one and persists it. When storage is unavailable or throws (e.g. Safari
69
- * private browsing, restrictive WebViews), a fresh secret is generated without
70
- * persistence so a JOIN can still proceed.
73
+ * new one and persists it. A stored value the host would reject (corrupted or
74
+ * written by something else) is replaced rather than reused — otherwise every
75
+ * JOIN would fail with `INVALID_SECRET` until the user cleared site data. When
76
+ * storage is unavailable or throws (e.g. Safari private browsing, restrictive
77
+ * WebViews), a fresh secret is generated without persistence so a JOIN can
78
+ * still proceed.
71
79
  */
72
80
  export declare function resolveSessionSecret(storage: SecretStorage | null | undefined, generate?: () => string): string;
73
81
  /**
@@ -84,15 +92,24 @@ export type HostMessageEffect<S> = {
84
92
  } | {
85
93
  kind: "pong";
86
94
  payload: PongPayload;
95
+ } | {
96
+ kind: "error";
97
+ error: HostError;
87
98
  };
99
+ /**
100
+ * A rejection reported by the host — e.g. `RATE_LIMITED`, `NOT_JOINED`,
101
+ * `FORBIDDEN_ACTION`, `INVALID_SECRET`.
102
+ */
103
+ export type HostError = Extract<HostMessage, {
104
+ type: "ERROR";
105
+ }>["payload"];
88
106
  /** Payload of a `PONG` host message. */
89
107
  export type PongPayload = Extract<HostMessage, {
90
108
  type: "PONG";
91
109
  }>["payload"];
92
110
  /**
93
111
  * Translate a parsed host message into the ordered list of effects the client
94
- * should apply. Unknown/irrelevant message types (e.g. `ERROR`) yield no
95
- * effects.
112
+ * should apply. Unknown message types yield no effects.
96
113
  */
97
114
  export declare function interpretHostMessage<S>(msg: HostMessage): HostMessageEffect<S>[];
98
115
  //# sourceMappingURL=connection.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,WAAW,EACjB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;;GAQG;AAEH,6EAA6E;AAC7E,eAAO,MAAM,kBAAkB,cAAc,CAAC;AAQ9C,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;;;;;;;GAOG;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,CAAC;AAE3C,wCAAwC;AACxC,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,SAAS,CAAC,CAAC;AAE5E;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,GAAG,EAAE,WAAW,GACf,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAmBxB"}
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"}
@@ -44,7 +44,19 @@ export declare const RelayErrorCodes: {
44
44
  readonly RATE_LIMITED: "RATE_LIMITED";
45
45
  /** Relay is at its room capacity. */
46
46
  readonly SERVER_BUSY: "SERVER_BUSY";
47
+ /**
48
+ * The display hosting the room disconnected, so the room is gone. Never sent
49
+ * as an `ERROR` frame: the relay closes each phone's socket with
50
+ * {@link RELAY_CLOSE_HOST_LEFT} and the client transport reports this code as
51
+ * the close reason.
52
+ */
53
+ readonly HOST_LEFT: "HOST_LEFT";
47
54
  };
55
+ /**
56
+ * WebSocket close code the relay uses when a room's host disconnects and its
57
+ * phones are dropped with it.
58
+ */
59
+ export declare const RELAY_CLOSE_HOST_LEFT = 4001;
48
60
  export type RelayErrorCode = (typeof RelayErrorCodes)[keyof typeof RelayErrorCodes];
49
61
  /** Display → relay: create and host a room. */
50
62
  export interface CreateRoomMessage {
@@ -1 +1 @@
1
- {"version":3,"file":"relay-protocol.d.ts","sourceRoot":"","sources":["../src/relay-protocol.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"relay-protocol.d.ts","sourceRoot":"","sources":["../src/relay-protocol.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"relay-room.d.ts","sourceRoot":"","sources":["../src/relay-room.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,MAAM,WAAW,kBAAkB;IACjC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,8DAA8D;IAC9D,QAAQ,CAAC,WAAW,EAAE,MAAM,IAAI,CAAC;CAClC;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD;AAcD;;;;;GAKG;AACH,wBAAgB,YAAY,IAAI,kBAAkB,CA+BjD;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAmBvE"}
1
+ {"version":3,"file":"relay-room.d.ts","sourceRoot":"","sources":["../src/relay-room.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,MAAM,WAAW,kBAAkB;IACjC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,8DAA8D;IAC9D,QAAQ,CAAC,WAAW,EAAE,MAAM,IAAI,CAAC;CAClC;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD;AAcD;;;;;GAKG;AACH,wBAAgB,YAAY,IAAI,kBAAkB,CA+BjD;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAqBvE"}
@@ -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;AAOrB,+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,EAgCzC;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,CAQ1C;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,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"}
@@ -38,7 +38,9 @@ export declare function nextSyncInterval(current: number): number;
38
38
  * called directly. Access `getServerTime()` and `rtt` from the
39
39
  * `useGameClient` return value instead.
40
40
  *
41
- * @param socket - The active client transport (or `null` if not yet connected).
41
+ * @param socket - The **open** client transport, or `null` while there is none.
42
+ * Syncing starts when this becomes an open transport, so pass a value that
43
+ * changes identity on open (state, not a ref read during render).
42
44
  * @returns An object with `getServerTime` (returns estimated server time), `rtt`, and `handlePong` (callback for PONG messages).
43
45
  */
44
46
  export declare function useServerTime(socket: ClientTransport | null): {
@@ -1 +1 @@
1
- {"version":3,"file":"time-sync.d.ts","sourceRoot":"","sources":["../src/time-sync.ts"],"names":[],"mappings":"AASA,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAOxE;;;;;;;;;;;GAWG;AAEH,wBAAgB,iBAAiB,CAC/B,cAAc,EAAE,MAAM,EACtB,iBAAiB,EAAE,MAAM,EACzB,UAAU,EAAE,MAAM;;;EAQnB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI;;;0BAgB9C;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE;EA8DtE"}
1
+ {"version":3,"file":"time-sync.d.ts","sourceRoot":"","sources":["../src/time-sync.ts"],"names":[],"mappings":"AASA,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAOxE;;;;;;;;;;;GAWG;AAEH,wBAAgB,iBAAiB,CAC/B,cAAc,EAAE,MAAM,EACtB,iBAAiB,EAAE,MAAM,EACzB,UAAU,EAAE,MAAM;;;EAQnB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI;;;0BAgB9C;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE;EAiEtE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@couch-kit/client",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
@@ -52,7 +52,12 @@
52
52
  "@couch-kit/core": "0.10.0"
53
53
  },
54
54
  "devDependencies": {
55
+ "@happy-dom/global-registrator": "^20.10.6",
56
+ "@testing-library/dom": "^10.4.1",
57
+ "@testing-library/react": "^16.3.2",
58
+ "@types/react-dom": "^19",
55
59
  "react": "^19.0.0",
60
+ "react-dom": "^19.0.0",
56
61
  "typescript": "^7.0.0"
57
62
  },
58
63
  "peerDependencies": {
package/src/client.ts CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  shouldReconnect,
19
19
  resolveSessionSecret,
20
20
  interpretHostMessage,
21
+ DEFAULT_OPTIMISTIC_TIMEOUT,
22
+ type HostError,
21
23
  } from "./connection";
22
24
  import {
23
25
  TransportReadyState,
@@ -49,8 +51,30 @@ export interface ClientConfig<S extends IGameState, A extends IAction> {
49
51
  baseDelay?: number;
50
52
  /** Maximum delay (ms) cap for reconnection backoff (default: 10000). */
51
53
  maxDelay?: number;
54
+ /**
55
+ * How long (ms) an optimistic update may stand without the host confirming
56
+ * it (default: 2000). The host only broadcasts when its state changes, so an
57
+ * action it ignores — an illegal move, a rate-limited tap, one sent while
58
+ * offline — produces no update, and without this the client would keep
59
+ * showing a state the host never had. When the window passes with no update
60
+ * the client falls back to the last state the host sent. Set to `0` to
61
+ * disable. Has no effect without a `reducer`.
62
+ */
63
+ optimisticTimeoutMs?: number;
64
+ /**
65
+ * Keep the clock in sync with the host by exchanging PING/PONG (default:
66
+ * `true`). Powers `getServerTime()` and `rtt`. A game that uses neither can
67
+ * turn it off: on a relay transport every ping, and the host's answer, is a
68
+ * billed message.
69
+ */
70
+ timeSync?: boolean;
52
71
  onConnect?: () => void;
53
72
  onDisconnect?: () => void;
73
+ /**
74
+ * Called when the host rejects something this client sent (`RATE_LIMITED`,
75
+ * `NOT_JOINED`, `FORBIDDEN_ACTION`, `INVALID_SECRET`, …).
76
+ */
77
+ onError?: (error: HostError) => void;
54
78
  debug?: boolean;
55
79
  /**
56
80
  * Provide a custom transport factory (e.g. a cross-network relay). When
@@ -102,6 +126,15 @@ export function useGameClient<S extends IGameState, A extends IAction>(
102
126
  );
103
127
 
104
128
  const socketRef = useRef<ClientTransport | null>(null);
129
+ // The transport once it is open, as state: time sync has to re-run when the
130
+ // socket *opens*, and a ref read during render cannot signal that.
131
+ const [openTransport, setOpenTransport] = useState<ClientTransport | null>(
132
+ null,
133
+ );
134
+ // Last state the host sent, and the pending fallback to it (see
135
+ // `optimisticTimeoutMs`).
136
+ const lastServerState = useRef<S | null>(null);
137
+ const rollbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
105
138
  const reconnectAttempts = useRef(0);
106
139
  const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
107
140
  const intentionalClose = useRef(false);
@@ -113,13 +146,33 @@ export function useGameClient<S extends IGameState, A extends IAction>(
113
146
  });
114
147
 
115
148
  // Time Sync Hook
116
- const { getServerTime, rtt, handlePong } = useServerTime(socketRef.current);
149
+ const { getServerTime, rtt, handlePong } = useServerTime(
150
+ config.timeSync === false ? null : openTransport,
151
+ );
117
152
 
118
153
  const handlePongRef = useRef(handlePong);
119
154
  useEffect(() => {
120
155
  handlePongRef.current = handlePong;
121
156
  });
122
157
 
158
+ const cancelRollback = useCallback(() => {
159
+ if (rollbackTimer.current !== null) {
160
+ clearTimeout(rollbackTimer.current);
161
+ rollbackTimer.current = null;
162
+ }
163
+ }, []);
164
+
165
+ /** Replaces local state with the last state the host sent, if any. */
166
+ const rollbackToServerState = useCallback(() => {
167
+ cancelRollback();
168
+ const serverState = lastServerState.current;
169
+ if (serverState === null) return;
170
+ dispatchLocal({
171
+ type: InternalActionTypes.HYDRATE,
172
+ payload: serverState,
173
+ } as InternalAction<S>);
174
+ }, [cancelRollback]);
175
+
123
176
  const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
124
177
  const baseDelay = config.baseDelay ?? DEFAULT_BASE_DELAY;
125
178
  const maxDelay = config.maxDelay ?? DEFAULT_MAX_DELAY;
@@ -138,7 +191,8 @@ export function useGameClient<S extends IGameState, A extends IAction>(
138
191
  // Port + 1 is skipped to avoid conflicts with Metro bundler (uses 8081).
139
192
  let transport: ClientTransport;
140
193
  if (cfg.createTransport) {
141
- if (cfg.debug) console.log("[GameClient] Connecting via custom transport");
194
+ if (cfg.debug)
195
+ console.log("[GameClient] Connecting via custom transport");
142
196
  transport = cfg.createTransport();
143
197
  } else {
144
198
  const wsUrl = resolveWebSocketUrl(
@@ -155,9 +209,18 @@ export function useGameClient<S extends IGameState, A extends IAction>(
155
209
  socketRef.current = transport;
156
210
  setStatus("connecting");
157
211
 
212
+ // Every handler ignores a transport that is no longer the current one. A
213
+ // socket closed by cleanup, `disconnect()` or a reconnect still fires its
214
+ // events later; without this they would overwrite the new connection's
215
+ // status and schedule a second, parallel reconnect.
216
+ const isCurrent = () => socketRef.current === transport;
217
+
158
218
  transport.onopen = () => {
219
+ if (!isCurrent()) return;
159
220
  const currentCfg = configRef.current;
160
221
  setStatus("connected");
222
+ setDisconnectReason(null);
223
+ setOpenTransport(transport);
161
224
  reconnectAttempts.current = 0;
162
225
  currentCfg.onConnect?.();
163
226
 
@@ -185,6 +248,7 @@ export function useGameClient<S extends IGameState, A extends IAction>(
185
248
  };
186
249
 
187
250
  transport.onmessage = (data) => {
251
+ if (!isCurrent()) return;
188
252
  let msg: HostMessage;
189
253
  try {
190
254
  msg = JSON.parse(data) as HostMessage;
@@ -199,7 +263,10 @@ export function useGameClient<S extends IGameState, A extends IAction>(
199
263
  setPlayerId(effect.playerId);
200
264
  break;
201
265
  case "hydrate":
202
- // Full state replacement from the host's authoritative state.
266
+ // Full state replacement from the host's authoritative state. It
267
+ // supersedes any optimistic update, so no fallback is needed.
268
+ lastServerState.current = effect.state;
269
+ cancelRollback();
203
270
  dispatchLocal({
204
271
  type: InternalActionTypes.HYDRATE,
205
272
  payload: effect.state,
@@ -208,11 +275,22 @@ export function useGameClient<S extends IGameState, A extends IAction>(
208
275
  case "pong":
209
276
  handlePongRef.current(effect.payload);
210
277
  break;
278
+ case "error":
279
+ if (configRef.current.debug)
280
+ console.warn("[GameClient] Host error:", effect.error);
281
+ // Whatever was rejected, the host's state did not change: drop
282
+ // any optimistic update now instead of waiting out the timer.
283
+ rollbackToServerState();
284
+ configRef.current.onError?.(effect.error);
285
+ break;
211
286
  }
212
287
  }
213
288
  };
214
289
 
215
290
  transport.onclose = (code, reason) => {
291
+ if (!isCurrent()) return;
292
+ socketRef.current = null;
293
+ setOpenTransport(null);
216
294
  setStatus("disconnected");
217
295
  // Terminal room-level failures carry a relay error code (ROOM_NOT_FOUND,
218
296
  // ROOM_FULL, …). Surfacing it lets the UI explain the failure rather than
@@ -249,67 +327,98 @@ export function useGameClient<S extends IGameState, A extends IAction>(
249
327
  };
250
328
 
251
329
  transport.onerror = (e) => {
330
+ if (!isCurrent()) return;
252
331
  if (configRef.current.debug) console.error("[GameClient] Error", e);
253
332
  setStatus("error");
254
333
  };
255
334
  // Only re-create the connect function when URL/port actually changes.
256
335
  // Config values like name, avatar, callbacks, and createTransport are read
257
336
  // from configRef.
258
- }, [config.url, config.wsPort, maxRetries, baseDelay, maxDelay]);
337
+ }, [
338
+ config.url,
339
+ config.wsPort,
340
+ maxRetries,
341
+ baseDelay,
342
+ maxDelay,
343
+ cancelRollback,
344
+ rollbackToServerState,
345
+ ]);
346
+
347
+ /**
348
+ * Closes the current transport for good. It is detached *before* closing, so
349
+ * its late `close`/`error` events are ignored and cannot trigger a reconnect
350
+ * or clobber the status of whatever connection replaces it.
351
+ */
352
+ const closeCurrent = useCallback(() => {
353
+ intentionalClose.current = true;
354
+ if (reconnectTimer.current) {
355
+ clearTimeout(reconnectTimer.current);
356
+ reconnectTimer.current = null;
357
+ }
358
+ cancelRollback();
359
+ const transport = socketRef.current;
360
+ if (!transport) return;
361
+ socketRef.current = null;
362
+ setOpenTransport(null);
363
+ transport.close();
364
+ configRef.current.onDisconnect?.();
365
+ }, [cancelRollback]);
259
366
 
260
367
  // Initial Connection
261
368
  useEffect(() => {
262
369
  connect();
263
- return () => {
264
- intentionalClose.current = true;
265
- if (socketRef.current) socketRef.current.close();
266
- if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
267
- };
268
- }, [connect]);
370
+ return closeCurrent;
371
+ }, [connect, closeCurrent]);
269
372
 
270
373
  /**
271
374
  * Manually disconnect from the host.
272
375
  * Prevents automatic reconnection.
273
376
  */
274
377
  const disconnect = useCallback(() => {
275
- intentionalClose.current = true;
276
- if (reconnectTimer.current) {
277
- clearTimeout(reconnectTimer.current);
278
- reconnectTimer.current = null;
279
- }
280
- if (socketRef.current) {
281
- socketRef.current.close();
282
- socketRef.current = null;
283
- }
378
+ closeCurrent();
284
379
  setStatus("disconnected");
285
- }, []);
380
+ }, [closeCurrent]);
286
381
 
287
382
  /**
288
383
  * Manually reconnect to the host.
289
384
  * Resets the reconnection attempt counter.
290
385
  */
291
386
  const reconnect = useCallback(() => {
292
- disconnect();
387
+ closeCurrent();
293
388
  reconnectAttempts.current = 0;
294
- // Small delay to let the close complete
295
- setTimeout(() => connect(), 50);
296
- }, [disconnect, connect]);
389
+ connect();
390
+ }, [closeCurrent, connect]);
297
391
 
298
392
  // Action Dispatcher
299
- const sendAction = useCallback((action: A) => {
300
- // 1. Optimistic Update
301
- dispatchLocal(action);
302
-
303
- // 2. Send to Host
304
- if (socketRef.current?.readyState === TransportReadyState.OPEN) {
305
- socketRef.current.send(
306
- JSON.stringify({
307
- type: MessageTypes.ACTION,
308
- payload: action,
309
- }),
310
- );
311
- }
312
- }, []);
393
+ const sendAction = useCallback(
394
+ (action: A) => {
395
+ // 1. Optimistic Update
396
+ dispatchLocal(action);
397
+
398
+ // The host stays silent when an action changes nothing, so an optimistic
399
+ // update it ignored would otherwise stand forever. Arm a single fallback to
400
+ // the last server state; the next STATE_UPDATE cancels it.
401
+ const cfg = configRef.current;
402
+ const timeout = cfg.optimisticTimeoutMs ?? DEFAULT_OPTIMISTIC_TIMEOUT;
403
+ if (cfg.reducer && timeout > 0 && rollbackTimer.current === null) {
404
+ rollbackTimer.current = setTimeout(() => {
405
+ rollbackTimer.current = null;
406
+ rollbackToServerState();
407
+ }, timeout);
408
+ }
409
+
410
+ // 2. Send to Host
411
+ if (socketRef.current?.readyState === TransportReadyState.OPEN) {
412
+ socketRef.current.send(
413
+ JSON.stringify({
414
+ type: MessageTypes.ACTION,
415
+ payload: action,
416
+ }),
417
+ );
418
+ }
419
+ },
420
+ [rollbackToServerState],
421
+ );
313
422
 
314
423
  return {
315
424
  status,
package/src/connection.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  DEFAULT_WS_PORT_OFFSET,
4
4
  DEFAULT_WS_PATH,
5
5
  generateId,
6
+ isValidSecret,
6
7
  type HostMessage,
7
8
  } from "@couch-kit/core";
8
9
 
@@ -19,6 +20,12 @@ import {
19
20
  /** localStorage key under which the session-recovery secret is persisted. */
20
21
  export const SESSION_SECRET_KEY = "ck_secret";
21
22
 
23
+ /**
24
+ * Default time (ms) an optimistic update may stand without the host confirming
25
+ * it before the client falls back to the last state the host sent.
26
+ */
27
+ export const DEFAULT_OPTIMISTIC_TIMEOUT = 2000;
28
+
22
29
  /** The subset of `WebSocket` close codes that must NOT trigger a reconnect. */
23
30
  const NON_RECOVERABLE_CLOSE_CODES = new Set<number>([
24
31
  1008, // policy violation (e.g. INVALID_SECRET / FORBIDDEN_ACTION)
@@ -110,9 +117,12 @@ export type SecretStorage = Pick<Storage, "getItem" | "setItem">;
110
117
  * Resolve the session-recovery secret.
111
118
  *
112
119
  * Reuses an existing secret from storage when present, otherwise generates a
113
- * new one and persists it. When storage is unavailable or throws (e.g. Safari
114
- * private browsing, restrictive WebViews), a fresh secret is generated without
115
- * persistence so a JOIN can still proceed.
120
+ * new one and persists it. A stored value the host would reject (corrupted or
121
+ * written by something else) is replaced rather than reused — otherwise every
122
+ * JOIN would fail with `INVALID_SECRET` until the user cleared site data. When
123
+ * storage is unavailable or throws (e.g. Safari private browsing, restrictive
124
+ * WebViews), a fresh secret is generated without persistence so a JOIN can
125
+ * still proceed.
116
126
  */
117
127
  export function resolveSessionSecret(
118
128
  storage: SecretStorage | null | undefined,
@@ -121,7 +131,7 @@ export function resolveSessionSecret(
121
131
  try {
122
132
  if (!storage) return generate();
123
133
  const stored = storage.getItem(SESSION_SECRET_KEY);
124
- if (stored) return stored;
134
+ if (stored && isValidSecret(stored)) return stored;
125
135
  const secret = generate();
126
136
  storage.setItem(SESSION_SECRET_KEY, secret);
127
137
  return secret;
@@ -138,15 +148,21 @@ export function resolveSessionSecret(
138
148
  export type HostMessageEffect<S> =
139
149
  | { kind: "setPlayerId"; playerId: string }
140
150
  | { kind: "hydrate"; state: S }
141
- | { kind: "pong"; payload: PongPayload };
151
+ | { kind: "pong"; payload: PongPayload }
152
+ | { kind: "error"; error: HostError };
153
+
154
+ /**
155
+ * A rejection reported by the host — e.g. `RATE_LIMITED`, `NOT_JOINED`,
156
+ * `FORBIDDEN_ACTION`, `INVALID_SECRET`.
157
+ */
158
+ export type HostError = Extract<HostMessage, { type: "ERROR" }>["payload"];
142
159
 
143
160
  /** Payload of a `PONG` host message. */
144
161
  export type PongPayload = Extract<HostMessage, { type: "PONG" }>["payload"];
145
162
 
146
163
  /**
147
164
  * Translate a parsed host message into the ordered list of effects the client
148
- * should apply. Unknown/irrelevant message types (e.g. `ERROR`) yield no
149
- * effects.
165
+ * should apply. Unknown message types yield no effects.
150
166
  */
151
167
  export function interpretHostMessage<S>(
152
168
  msg: HostMessage,
@@ -166,6 +182,8 @@ export function interpretHostMessage<S>(
166
182
  { kind: "setPlayerId", playerId: msg.payload.playerId },
167
183
  { kind: "hydrate", state: msg.payload.state as S },
168
184
  ];
185
+ case MessageTypes.ERROR:
186
+ return [{ kind: "error", error: msg.payload }];
169
187
  default:
170
188
  return [];
171
189
  }
@@ -46,8 +46,21 @@ export const RelayErrorCodes = {
46
46
  RATE_LIMITED: "RATE_LIMITED",
47
47
  /** Relay is at its room capacity. */
48
48
  SERVER_BUSY: "SERVER_BUSY",
49
+ /**
50
+ * The display hosting the room disconnected, so the room is gone. Never sent
51
+ * as an `ERROR` frame: the relay closes each phone's socket with
52
+ * {@link RELAY_CLOSE_HOST_LEFT} and the client transport reports this code as
53
+ * the close reason.
54
+ */
55
+ HOST_LEFT: "HOST_LEFT",
49
56
  } as const;
50
57
 
58
+ /**
59
+ * WebSocket close code the relay uses when a room's host disconnects and its
60
+ * phones are dropped with it.
61
+ */
62
+ export const RELAY_CLOSE_HOST_LEFT = 4001;
63
+
51
64
  export type RelayErrorCode =
52
65
  (typeof RelayErrorCodes)[keyof typeof RelayErrorCodes];
53
66
 
package/src/relay-room.ts CHANGED
@@ -93,6 +93,8 @@ export function describeRelayError(reason: string | null): string | null {
93
93
  return "That room isn't open. Check the code on the screen.";
94
94
  case RelayErrorCodes.ROOM_FULL:
95
95
  return "That room is full.";
96
+ case RelayErrorCodes.HOST_LEFT:
97
+ return "The host screen closed, so this game has ended.";
96
98
  case RelayErrorCodes.RATE_LIMITED:
97
99
  return "Too many messages — slow down and try again.";
98
100
  case RelayErrorCodes.ROOM_EXISTS:
@@ -4,6 +4,8 @@ import {
4
4
  type CreateClientTransport,
5
5
  } from "./transport";
6
6
  import {
7
+ RELAY_CLOSE_HOST_LEFT,
8
+ RelayErrorCodes,
7
9
  RelayMessageTypes,
8
10
  relayRoomUrl,
9
11
  type RelayServerMessage,
@@ -79,6 +81,16 @@ export class RelayClientTransport implements ClientTransport {
79
81
 
80
82
  this.ws.onclose = (event: CloseEvent) => {
81
83
  this.state = TransportReadyState.CLOSED;
84
+ // The host leaving ends the room: retrying the same code can only come
85
+ // back ROOM_NOT_FOUND, so report it as terminal with a reason the UI can
86
+ // explain.
87
+ if (
88
+ this.pendingCloseCode === null &&
89
+ event.code === RELAY_CLOSE_HOST_LEFT
90
+ ) {
91
+ this.pendingCloseCode = POLICY_CLOSE_CODE;
92
+ this.pendingCloseReason = RelayErrorCodes.HOST_LEFT;
93
+ }
82
94
  const code = this.pendingCloseCode ?? event.code;
83
95
  this.onclose?.(code, this.pendingCloseReason ?? event.reason);
84
96
  };
@@ -104,7 +116,10 @@ export class RelayClientTransport implements ClientTransport {
104
116
  close(code?: number, reason?: string): void {
105
117
  this.state = TransportReadyState.CLOSING;
106
118
  // WebSocket.close only permits 1000 or 3000-4999; pass through only those.
107
- if (code !== undefined && (code === 1000 || (code >= 3000 && code <= 4999))) {
119
+ if (
120
+ code !== undefined &&
121
+ (code === 1000 || (code >= 3000 && code <= 4999))
122
+ ) {
108
123
  this.ws.close(code, reason);
109
124
  } else {
110
125
  this.ws.close();
package/src/time-sync.ts CHANGED
@@ -66,7 +66,9 @@ export function nextSyncInterval(current: number): number {
66
66
  * called directly. Access `getServerTime()` and `rtt` from the
67
67
  * `useGameClient` return value instead.
68
68
  *
69
- * @param socket - The active client transport (or `null` if not yet connected).
69
+ * @param socket - The **open** client transport, or `null` while there is none.
70
+ * Syncing starts when this becomes an open transport, so pass a value that
71
+ * changes identity on open (state, not a ref read during render).
70
72
  * @returns An object with `getServerTime` (returns estimated server time), `rtt`, and `handlePong` (callback for PONG messages).
71
73
  */
72
74
  export function useServerTime(socket: ClientTransport | null) {
@@ -141,8 +143,11 @@ export function useServerTime(socket: ClientTransport | null) {
141
143
  // Initial sync
142
144
  sync();
143
145
 
146
+ const pending = pings.current;
144
147
  return () => {
145
148
  if (timer !== null) clearTimeout(timer);
149
+ // PONGs for this socket's pings can no longer arrive.
150
+ pending.clear();
146
151
  };
147
152
  }, [socket]);
148
153