@couch-kit/client 0.13.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,72 @@
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
+
17
+ ## 0.14.0
18
+
19
+ ### Minor Changes
20
+
21
+ - [#164](https://github.com/faluciano/react-native-couch-kit/pull/164) [`a157126`](https://github.com/faluciano/react-native-couch-kit/commit/a157126424e4d73dcc7185118d5be0db6719792e) Thanks [@faluciano](https://github.com/faluciano)! - Send a projected state update as one relay frame instead of one per player
22
+
23
+ A game with a `project` function sends every player their own view, which meant
24
+ one WebSocket frame per player for every state change. Relays bill and
25
+ rate-limit per inbound frame, so a four-player table paid four messages for one
26
+ update and spent four of the display's 30-per-second budget.
27
+
28
+ `GameRuntimeTransport` gains an optional `sendMany(entries)`. When a transport
29
+ implements it, the runtime hands over the whole projected batch at once;
30
+ transports that do not — the LAN WebSocket path — keep receiving one `send` per
31
+ connection and are unaffected.
32
+
33
+ `RelayDisplayHost` implements it with a new `DATA_MULTI` envelope carrying a
34
+ peer-id-to-payload map, which the relay unpacks into ordinary `DATA` frames.
35
+ Phones need no update — nothing on the client side can tell a batched update
36
+ from a unicast one. If the combined frame would exceed the relay's 256KB
37
+ ceiling, the display falls back to individual frames rather than send something
38
+ the relay would drop.
39
+
40
+ Relays must be updated before displays: both bundled implementations
41
+ (`services/relay`, `services/relay-worker`) understand `DATA_MULTI`, and an
42
+ older relay answers it with `MALFORMED`. The type is host-only — a phone sending
43
+ it is rejected, so it cannot be used to reach another phone directly.
44
+
45
+ - [#164](https://github.com/faluciano/react-native-couch-kit/pull/164) [`a157126`](https://github.com/faluciano/react-native-couch-kit/commit/a157126424e4d73dcc7185118d5be0db6719792e) Thanks [@faluciano](https://github.com/faluciano)! - Back the time-sync ping off to 30s once the clock estimate settles
46
+
47
+ `useGameClient`'s clock sync pinged every 5 seconds for the life of the
48
+ connection. It now starts there and doubles to a 30-second ceiling
49
+ (`MAX_SYNC_INTERVAL`), resetting to the fast interval whenever a new socket is
50
+ established, including after a reconnect.
51
+
52
+ The first few pings are what converge the offset; the clock difference they
53
+ measure does not drift on a human timescale, so the fast interval stops earning
54
+ its cost within about a minute. On a relay transport it is not free: each ping
55
+ is a message in and a `PONG` back out, and pings are the only traffic an idle
56
+ table generates at all. A four-player lobby went from 5,760 relay messages an
57
+ hour to under 1,000 while sitting untouched.
58
+
59
+ `rtt` and `getServerTime()` are unchanged in accuracy — both are updated by the
60
+ same `PONG` handling as before, just less often once settled. Games needing the
61
+ old cadence can dispatch their own pings; the constants
62
+ (`DEFAULT_SYNC_INTERVAL`, `MAX_SYNC_INTERVAL`, `SYNC_BACKOFF_FACTOR`) are
63
+ exported from `@couch-kit/core`.
64
+
65
+ ### Patch Changes
66
+
67
+ - Updated dependencies [[`a157126`](https://github.com/faluciano/react-native-couch-kit/commit/a157126424e4d73dcc7185118d5be0db6719792e)]:
68
+ - @couch-kit/core@0.10.0
69
+
3
70
  ## 0.13.0
4
71
 
5
72
  ### 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
@@ -15,6 +15,8 @@ import {
15
15
  MessageTypes,
16
16
  generateId,
17
17
  DEFAULT_SYNC_INTERVAL,
18
+ MAX_SYNC_INTERVAL,
19
+ SYNC_BACKOFF_FACTOR,
18
20
  MAX_PENDING_PINGS
19
21
  } from "@couch-kit/core";
20
22
 
@@ -53,6 +55,9 @@ function calculateTimeSync(clientSendTime, clientReceiveTime, serverTime) {
53
55
  const offset = expectedServerTime - clientReceiveTime;
54
56
  return { offset, rtt };
55
57
  }
58
+ function nextSyncInterval(current) {
59
+ return Math.min(current * SYNC_BACKOFF_FACTOR, MAX_SYNC_INTERVAL);
60
+ }
56
61
  function useServerTime(socket) {
57
62
  const [timeSync, setTimeSync] = useState({
58
63
  offset: 0,
@@ -74,6 +79,8 @@ function useServerTime(socket) {
74
79
  useEffect(() => {
75
80
  if (!socket || socket.readyState !== TransportReadyState.OPEN)
76
81
  return;
82
+ let delay = DEFAULT_SYNC_INTERVAL;
83
+ let timer = null;
77
84
  const sync = () => {
78
85
  if (pings.current.size >= MAX_PENDING_PINGS) {
79
86
  const oldest = pings.current.keys().next().value;
@@ -87,10 +94,16 @@ function useServerTime(socket) {
87
94
  type: MessageTypes.PING,
88
95
  payload: { id, timestamp }
89
96
  }));
97
+ delay = nextSyncInterval(delay);
98
+ timer = setTimeout(sync, delay);
90
99
  };
91
100
  sync();
92
- const interval = setInterval(sync, DEFAULT_SYNC_INTERVAL);
93
- return () => clearInterval(interval);
101
+ const pending = pings.current;
102
+ return () => {
103
+ if (timer !== null)
104
+ clearTimeout(timer);
105
+ pending.clear();
106
+ };
94
107
  }, [socket]);
95
108
  return { getServerTime, rtt: timeSync.rtt, handlePong };
96
109
  }
@@ -100,9 +113,11 @@ import {
100
113
  MessageTypes as MessageTypes2,
101
114
  DEFAULT_WS_PORT_OFFSET,
102
115
  DEFAULT_WS_PATH,
103
- generateId as generateId2
116
+ generateId as generateId2,
117
+ isValidSecret
104
118
  } from "@couch-kit/core";
105
119
  var SESSION_SECRET_KEY = "ck_secret";
120
+ var DEFAULT_OPTIMISTIC_TIMEOUT = 2000;
106
121
  var NON_RECOVERABLE_CLOSE_CODES = new Set([
107
122
  1008,
108
123
  1011
@@ -133,7 +148,7 @@ function resolveSessionSecret(storage, generate = generateId2) {
133
148
  if (!storage)
134
149
  return generate();
135
150
  const stored = storage.getItem(SESSION_SECRET_KEY);
136
- if (stored)
151
+ if (stored && isValidSecret(stored))
137
152
  return stored;
138
153
  const secret = generate();
139
154
  storage.setItem(SESSION_SECRET_KEY, secret);
@@ -158,6 +173,8 @@ function interpretHostMessage(msg) {
158
173
  { kind: "setPlayerId", playerId: msg.payload.playerId },
159
174
  { kind: "hydrate", state: msg.payload.state }
160
175
  ];
176
+ case MessageTypes2.ERROR:
177
+ return [{ kind: "error", error: msg.payload }];
161
178
  default:
162
179
  return [];
163
180
  }
@@ -170,6 +187,9 @@ function useGameClient(config) {
170
187
  const [disconnectReason, setDisconnectReason] = useState2(null);
171
188
  const [state, dispatchLocal] = useReducer(createGameReducer(config.reducer ?? ((current) => current)), config.initialState);
172
189
  const socketRef = useRef2(null);
190
+ const [openTransport, setOpenTransport] = useState2(null);
191
+ const lastServerState = useRef2(null);
192
+ const rollbackTimer = useRef2(null);
173
193
  const reconnectAttempts = useRef2(0);
174
194
  const reconnectTimer = useRef2(null);
175
195
  const intentionalClose = useRef2(false);
@@ -177,11 +197,27 @@ function useGameClient(config) {
177
197
  useEffect2(() => {
178
198
  configRef.current = config;
179
199
  });
180
- const { getServerTime, rtt, handlePong } = useServerTime(socketRef.current);
200
+ const { getServerTime, rtt, handlePong } = useServerTime(config.timeSync === false ? null : openTransport);
181
201
  const handlePongRef = useRef2(handlePong);
182
202
  useEffect2(() => {
183
203
  handlePongRef.current = handlePong;
184
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]);
185
221
  const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
186
222
  const baseDelay = config.baseDelay ?? DEFAULT_BASE_DELAY;
187
223
  const maxDelay = config.maxDelay ?? DEFAULT_MAX_DELAY;
@@ -203,9 +239,14 @@ function useGameClient(config) {
203
239
  }
204
240
  socketRef.current = transport;
205
241
  setStatus("connecting");
242
+ const isCurrent = () => socketRef.current === transport;
206
243
  transport.onopen = () => {
244
+ if (!isCurrent())
245
+ return;
207
246
  const currentCfg = configRef.current;
208
247
  setStatus("connected");
248
+ setDisconnectReason(null);
249
+ setOpenTransport(transport);
209
250
  reconnectAttempts.current = 0;
210
251
  currentCfg.onConnect?.();
211
252
  const secret = resolveSessionSecret(typeof localStorage !== "undefined" ? localStorage : null);
@@ -224,6 +265,8 @@ function useGameClient(config) {
224
265
  }
225
266
  };
226
267
  transport.onmessage = (data) => {
268
+ if (!isCurrent())
269
+ return;
227
270
  let msg;
228
271
  try {
229
272
  msg = JSON.parse(data);
@@ -237,6 +280,8 @@ function useGameClient(config) {
237
280
  setPlayerId(effect.playerId);
238
281
  break;
239
282
  case "hydrate":
283
+ lastServerState.current = effect.state;
284
+ cancelRollback();
240
285
  dispatchLocal({
241
286
  type: InternalActionTypes.HYDRATE,
242
287
  payload: effect.state
@@ -245,10 +290,20 @@ function useGameClient(config) {
245
290
  case "pong":
246
291
  handlePongRef.current(effect.payload);
247
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;
248
299
  }
249
300
  }
250
301
  };
251
302
  transport.onclose = (code, reason) => {
303
+ if (!isCurrent())
304
+ return;
305
+ socketRef.current = null;
306
+ setOpenTransport(null);
252
307
  setStatus("disconnected");
253
308
  setDisconnectReason(reason ? reason : null);
254
309
  configRef.current.onDisconnect?.();
@@ -268,47 +323,66 @@ function useGameClient(config) {
268
323
  }, delay);
269
324
  };
270
325
  transport.onerror = (e) => {
326
+ if (!isCurrent())
327
+ return;
271
328
  if (configRef.current.debug)
272
329
  console.error("[GameClient] Error", e);
273
330
  setStatus("error");
274
331
  };
275
- }, [config.url, config.wsPort, maxRetries, baseDelay, maxDelay]);
276
- useEffect2(() => {
277
- connect();
278
- return () => {
279
- intentionalClose.current = true;
280
- if (socketRef.current)
281
- socketRef.current.close();
282
- if (reconnectTimer.current)
283
- clearTimeout(reconnectTimer.current);
284
- };
285
- }, [connect]);
286
- 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(() => {
287
342
  intentionalClose.current = true;
288
343
  if (reconnectTimer.current) {
289
344
  clearTimeout(reconnectTimer.current);
290
345
  reconnectTimer.current = null;
291
346
  }
292
- if (socketRef.current) {
293
- socketRef.current.close();
294
- socketRef.current = null;
295
- }
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();
296
362
  setStatus("disconnected");
297
- }, []);
363
+ }, [closeCurrent]);
298
364
  const reconnect = useCallback2(() => {
299
- disconnect();
365
+ closeCurrent();
300
366
  reconnectAttempts.current = 0;
301
- setTimeout(() => connect(), 50);
302
- }, [disconnect, connect]);
367
+ connect();
368
+ }, [closeCurrent, connect]);
303
369
  const sendAction = useCallback2((action) => {
304
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
+ }
305
379
  if (socketRef.current?.readyState === TransportReadyState.OPEN) {
306
380
  socketRef.current.send(JSON.stringify({
307
381
  type: MessageTypes3.ACTION,
308
382
  payload: action
309
383
  }));
310
384
  }
311
- }, []);
385
+ }, [rollbackToServerState]);
312
386
  return {
313
387
  status,
314
388
  state,
@@ -330,6 +404,7 @@ var RelayMessageTypes = {
330
404
  PEER_JOINED: "PEER_JOINED",
331
405
  PEER_LEFT: "PEER_LEFT",
332
406
  DATA: "DATA",
407
+ DATA_MULTI: "DATA_MULTI",
333
408
  ERROR: "ERROR"
334
409
  };
335
410
  var RelayErrorCodes = {
@@ -340,8 +415,10 @@ var RelayErrorCodes = {
340
415
  MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE",
341
416
  MALFORMED: "MALFORMED",
342
417
  RATE_LIMITED: "RATE_LIMITED",
343
- SERVER_BUSY: "SERVER_BUSY"
418
+ SERVER_BUSY: "SERVER_BUSY",
419
+ HOST_LEFT: "HOST_LEFT"
344
420
  };
421
+ var RELAY_CLOSE_HOST_LEFT = 4001;
345
422
  function relayRoomUrl(url, roomId) {
346
423
  const trimmed = url.replace(/\/+$/, "");
347
424
  const [base, query] = trimmed.split("?", 2);
@@ -382,6 +459,10 @@ class RelayClientTransport {
382
459
  };
383
460
  this.ws.onclose = (event) => {
384
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
+ }
385
466
  const code = this.pendingCloseCode ?? event.code;
386
467
  this.onclose?.(code, this.pendingCloseReason ?? event.reason);
387
468
  };
@@ -479,6 +560,8 @@ function describeRelayError(reason) {
479
560
  return "That room isn't open. Check the code on the screen.";
480
561
  case RelayErrorCodes.ROOM_FULL:
481
562
  return "That room is full.";
563
+ case RelayErrorCodes.HOST_LEFT:
564
+ return "The host screen closed, so this game has ended.";
482
565
  case RelayErrorCodes.RATE_LIMITED:
483
566
  return "Too many messages — slow down and try again.";
484
567
  case RelayErrorCodes.ROOM_EXISTS:
@@ -628,26 +711,29 @@ function useDebugPanel(options) {
628
711
  };
629
712
  }
630
713
  export {
631
- useServerTime,
632
- useRelayRoom,
633
- usePreload,
634
- useGameClient,
635
- useDebugPanel,
636
- shouldReconnect,
637
- resolveWebSocketUrl,
638
- resolveSessionSecret,
639
- relayRoomUrl,
640
- normalizeRoomCode,
641
- interpretHostMessage,
642
- describeRelayError,
643
- createWebSocketTransport,
644
- createRelayTransport,
645
- computeBackoffDelay,
646
- calculateTimeSync,
647
- TransportReadyState,
648
- SESSION_SECRET_KEY,
649
- RelayMessageTypes,
650
- RelayErrorCodes,
714
+ DEFAULT_OPTIMISTIC_TIMEOUT,
715
+ RELAY_CLOSE_HOST_LEFT,
716
+ RELAY_MINT_PATH,
651
717
  RelayClientTransport,
652
- 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
653
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"}
@@ -27,6 +27,8 @@ export declare const RelayMessageTypes: {
27
27
  readonly PEER_LEFT: "PEER_LEFT";
28
28
  /** Bidirectional: carries an opaque Couch Kit JSON message as `data`. */
29
29
  readonly DATA: "DATA";
30
+ /** Display → relay: one frame carrying a different payload per phone. */
31
+ readonly DATA_MULTI: "DATA_MULTI";
30
32
  /** Relay → client: a protocol/room error. */
31
33
  readonly ERROR: "ERROR";
32
34
  };
@@ -42,7 +44,19 @@ export declare const RelayErrorCodes: {
42
44
  readonly RATE_LIMITED: "RATE_LIMITED";
43
45
  /** Relay is at its room capacity. */
44
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";
45
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;
46
60
  export type RelayErrorCode = (typeof RelayErrorCodes)[keyof typeof RelayErrorCodes];
47
61
  /** Display → relay: create and host a room. */
48
62
  export interface CreateRoomMessage {
@@ -97,6 +111,22 @@ export interface DataMessage {
97
111
  to?: string;
98
112
  data: string;
99
113
  }
114
+ /**
115
+ * Display → relay: one envelope carrying a different payload per phone.
116
+ *
117
+ * `payloads` maps a phone's `peerId` to the already-serialized Couch Kit
118
+ * message for that phone. The relay unpacks it into ordinary
119
+ * {@link DataMessage} frames, so phones never see this type — it exists purely
120
+ * so a projected game costs one inbound relay message per state change instead
121
+ * of one per player. Peer ids the room does not know are skipped.
122
+ *
123
+ * Host-only: the relay rejects it from a phone.
124
+ */
125
+ export interface DataMultiMessage {
126
+ type: typeof RelayMessageTypes.DATA_MULTI;
127
+ roomId: string;
128
+ payloads: Record<string, string>;
129
+ }
100
130
  /** Relay → client: a protocol/room error. */
101
131
  export interface RelayErrorMessage {
102
132
  type: typeof RelayMessageTypes.ERROR;
@@ -104,7 +134,7 @@ export interface RelayErrorMessage {
104
134
  message: string;
105
135
  }
106
136
  /** Any message a client may send to the relay. */
107
- export type RelayClientMessage = CreateRoomMessage | JoinRoomMessage | DataMessage;
137
+ export type RelayClientMessage = CreateRoomMessage | JoinRoomMessage | DataMessage | DataMultiMessage;
108
138
  /** Any message the relay may send to a client. */
109
139
  export type RelayServerMessage = RoomCreatedMessage | RoomJoinedMessage | PeerJoinedMessage | PeerLeftMessage | DataMessage | RelayErrorMessage;
110
140
  /** Every relay wire message. */
@@ -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":"AAAA;;;;;;;;;;;;GAYG;AAEH,+EAA+E;AAC/E,eAAO,MAAM,iBAAiB;IAC5B,8DAA8D;aAC9D,WAAW,EAAE,aAAa;IAC1B,0EAA0E;aAC1E,YAAY,EAAE,cAAc;IAC5B,4CAA4C;aAC5C,SAAS,EAAE,WAAW;IACtB,qEAAqE;aACrE,WAAW,EAAE,aAAa;IAC1B,gDAAgD;aAChD,WAAW,EAAE,aAAa;IAC1B,8CAA8C;aAC9C,SAAS,EAAE,WAAW;IACtB,yEAAyE;aACzE,IAAI,EAAE,MAAM;IACZ,yEAAyE;aACzE,UAAU,EAAE,YAAY;IACxB,6CAA6C;aAC7C,KAAK,EAAE,OAAO;CACN,CAAC;AAEX,wEAAwE;AACxE,eAAO,MAAM,eAAe;aAC1B,cAAc,EAAE,gBAAgB;aAChC,WAAW,EAAE,aAAa;aAC1B,SAAS,EAAE,WAAW;aACtB,WAAW,EAAE,aAAa;aAC1B,iBAAiB,EAAE,mBAAmB;aACtC,SAAS,EAAE,WAAW;IACtB,yEAAyE;aACzE,YAAY,EAAE,cAAc;IAC5B,qCAAqC;aACrC,WAAW,EAAE,aAAa;IAC1B;;;;;OAKG;aACH,SAAS,EAAE,WAAW;CACd,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C,MAAM,MAAM,cAAc,GACxB,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,OAAO,eAAe,CAAC,CAAC;AAEzD,+CAA+C;AAC/C,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,iBAAiB,CAAC,WAAW,CAAC;IAC3C,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,uEAAuE;AACvE,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,OAAO,iBAAiB,CAAC,YAAY,CAAC;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,4CAA4C;AAC5C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,OAAO,iBAAiB,CAAC,SAAS,CAAC;IACzC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wEAAwE;AACxE,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,iBAAiB,CAAC,WAAW,CAAC;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,2EAA2E;AAC3E,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,iBAAiB,CAAC,WAAW,CAAC;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,qCAAqC;AACrC,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,OAAO,iBAAiB,CAAC,SAAS,CAAC;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,iBAAiB,CAAC,IAAI,CAAC;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,OAAO,iBAAiB,CAAC,UAAU,CAAC;IAC1C,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,6CAA6C;AAC7C,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,iBAAiB,CAAC,KAAK,CAAC;IACrC,IAAI,EAAE,cAAc,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,kDAAkD;AAClD,MAAM,MAAM,kBAAkB,GAC5B,iBAAiB,GAAG,eAAe,GAAG,WAAW,GAAG,gBAAgB,CAAC;AAEvE,kDAAkD;AAClD,MAAM,MAAM,kBAAkB,GAC1B,kBAAkB,GAClB,iBAAiB,GACjB,iBAAiB,GACjB,eAAe,GACf,WAAW,GACX,iBAAiB,CAAC;AAEtB,gCAAgC;AAChC,MAAM,MAAM,YAAY,GAAG,kBAAkB,GAAG,kBAAkB,CAAC;AAEnE;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAQjE;AAED;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,SAAS,CAAC"}
@@ -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"}
@@ -15,6 +15,19 @@ export declare function calculateTimeSync(clientSendTime: number, clientReceiveT
15
15
  offset: number;
16
16
  rtt: number;
17
17
  };
18
+ /**
19
+ * The interval to wait before the next PING, given the one just used.
20
+ *
21
+ * Grows geometrically to {@link MAX_SYNC_INTERVAL}: the first pings after
22
+ * connecting are what converge the offset, and re-measuring it every few
23
+ * seconds forever buys nothing — the clock difference does not move, while on a
24
+ * relay transport each ping is a billed message in both directions and the only
25
+ * traffic an idle table generates at all.
26
+ *
27
+ * @param current - Interval (ms) used for the ping just sent.
28
+ * @returns The next interval, capped at {@link MAX_SYNC_INTERVAL}.
29
+ */
30
+ export declare function nextSyncInterval(current: number): number;
18
31
  /**
19
32
  * React hook that synchronizes the client clock with the host server.
20
33
  *
@@ -25,7 +38,9 @@ export declare function calculateTimeSync(clientSendTime: number, clientReceiveT
25
38
  * called directly. Access `getServerTime()` and `rtt` from the
26
39
  * `useGameClient` return value instead.
27
40
  *
28
- * @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).
29
44
  * @returns An object with `getServerTime` (returns estimated server time), `rtt`, and `handlePong` (callback for PONG messages).
30
45
  */
31
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":"AAOA,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;;;;;;;;;;;;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;EAgDtE"}
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.13.0",
3
+ "version": "0.15.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
@@ -49,10 +49,15 @@
49
49
  "clean": "rm -rf dist lib"
50
50
  },
51
51
  "dependencies": {
52
- "@couch-kit/core": "0.9.3"
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
  }
@@ -28,6 +28,8 @@ export const RelayMessageTypes = {
28
28
  PEER_LEFT: "PEER_LEFT",
29
29
  /** Bidirectional: carries an opaque Couch Kit JSON message as `data`. */
30
30
  DATA: "DATA",
31
+ /** Display → relay: one frame carrying a different payload per phone. */
32
+ DATA_MULTI: "DATA_MULTI",
31
33
  /** Relay → client: a protocol/room error. */
32
34
  ERROR: "ERROR",
33
35
  } as const;
@@ -44,8 +46,21 @@ export const RelayErrorCodes = {
44
46
  RATE_LIMITED: "RATE_LIMITED",
45
47
  /** Relay is at its room capacity. */
46
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",
47
56
  } as const;
48
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
+
49
64
  export type RelayErrorCode =
50
65
  (typeof RelayErrorCodes)[keyof typeof RelayErrorCodes];
51
66
 
@@ -109,6 +124,23 @@ export interface DataMessage {
109
124
  data: string;
110
125
  }
111
126
 
127
+ /**
128
+ * Display → relay: one envelope carrying a different payload per phone.
129
+ *
130
+ * `payloads` maps a phone's `peerId` to the already-serialized Couch Kit
131
+ * message for that phone. The relay unpacks it into ordinary
132
+ * {@link DataMessage} frames, so phones never see this type — it exists purely
133
+ * so a projected game costs one inbound relay message per state change instead
134
+ * of one per player. Peer ids the room does not know are skipped.
135
+ *
136
+ * Host-only: the relay rejects it from a phone.
137
+ */
138
+ export interface DataMultiMessage {
139
+ type: typeof RelayMessageTypes.DATA_MULTI;
140
+ roomId: string;
141
+ payloads: Record<string, string>;
142
+ }
143
+
112
144
  /** Relay → client: a protocol/room error. */
113
145
  export interface RelayErrorMessage {
114
146
  type: typeof RelayMessageTypes.ERROR;
@@ -118,9 +150,7 @@ export interface RelayErrorMessage {
118
150
 
119
151
  /** Any message a client may send to the relay. */
120
152
  export type RelayClientMessage =
121
- | CreateRoomMessage
122
- | JoinRoomMessage
123
- | DataMessage;
153
+ CreateRoomMessage | JoinRoomMessage | DataMessage | DataMultiMessage;
124
154
 
125
155
  /** Any message the relay may send to a client. */
126
156
  export type RelayServerMessage =
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
@@ -3,6 +3,8 @@ import {
3
3
  MessageTypes,
4
4
  generateId,
5
5
  DEFAULT_SYNC_INTERVAL,
6
+ MAX_SYNC_INTERVAL,
7
+ SYNC_BACKOFF_FACTOR,
6
8
  MAX_PENDING_PINGS,
7
9
  } from "@couch-kit/core";
8
10
  import { TransportReadyState, type ClientTransport } from "./transport";
@@ -38,6 +40,22 @@ export function calculateTimeSync(
38
40
  return { offset, rtt };
39
41
  }
40
42
 
43
+ /**
44
+ * The interval to wait before the next PING, given the one just used.
45
+ *
46
+ * Grows geometrically to {@link MAX_SYNC_INTERVAL}: the first pings after
47
+ * connecting are what converge the offset, and re-measuring it every few
48
+ * seconds forever buys nothing — the clock difference does not move, while on a
49
+ * relay transport each ping is a billed message in both directions and the only
50
+ * traffic an idle table generates at all.
51
+ *
52
+ * @param current - Interval (ms) used for the ping just sent.
53
+ * @returns The next interval, capped at {@link MAX_SYNC_INTERVAL}.
54
+ */
55
+ export function nextSyncInterval(current: number): number {
56
+ return Math.min(current * SYNC_BACKOFF_FACTOR, MAX_SYNC_INTERVAL);
57
+ }
58
+
41
59
  /**
42
60
  * React hook that synchronizes the client clock with the host server.
43
61
  *
@@ -48,7 +66,9 @@ export function calculateTimeSync(
48
66
  * called directly. Access `getServerTime()` and `rtt` from the
49
67
  * `useGameClient` return value instead.
50
68
  *
51
- * @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).
52
72
  * @returns An object with `getServerTime` (returns estimated server time), `rtt`, and `handlePong` (callback for PONG messages).
53
73
  */
54
74
  export function useServerTime(socket: ClientTransport | null) {
@@ -85,9 +105,19 @@ export function useServerTime(socket: ClientTransport | null) {
85
105
  );
86
106
 
87
107
  // Periodic Sync
108
+ //
109
+ // The interval backs off from DEFAULT_SYNC_INTERVAL to MAX_SYNC_INTERVAL
110
+ // rather than staying fast forever: the first few pings are what converge the
111
+ // offset, and after that we are re-measuring a clock difference that does not
112
+ // move. A self-rescheduling timeout is used instead of setInterval because
113
+ // the delay changes between ticks. Backoff state lives inside the effect, so
114
+ // a new socket — including a reconnect — starts fast again.
88
115
  useEffect(() => {
89
116
  if (!socket || socket.readyState !== TransportReadyState.OPEN) return;
90
117
 
118
+ let delay = DEFAULT_SYNC_INTERVAL;
119
+ let timer: ReturnType<typeof setTimeout> | null = null;
120
+
91
121
  const sync = () => {
92
122
  // Prevent unbounded growth if PONGs are lost
93
123
  if (pings.current.size >= MAX_PENDING_PINGS) {
@@ -105,13 +135,20 @@ export function useServerTime(socket: ClientTransport | null) {
105
135
  payload: { id, timestamp },
106
136
  }),
107
137
  );
138
+
139
+ delay = nextSyncInterval(delay);
140
+ timer = setTimeout(sync, delay);
108
141
  };
109
142
 
110
143
  // Initial sync
111
144
  sync();
112
145
 
113
- const interval = setInterval(sync, DEFAULT_SYNC_INTERVAL);
114
- return () => clearInterval(interval);
146
+ const pending = pings.current;
147
+ return () => {
148
+ if (timer !== null) clearTimeout(timer);
149
+ // PONGs for this socket's pings can no longer arrive.
150
+ pending.clear();
151
+ };
115
152
  }, [socket]);
116
153
 
117
154
  return { getServerTime, rtt: timeSync.rtt, handlePong };