@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 +14 -0
- package/README.md +20 -3
- package/dist/index.js +120 -47
- package/lib/client.d.ts +23 -0
- package/lib/client.d.ts.map +1 -1
- package/lib/connection.d.ts +22 -5
- package/lib/connection.d.ts.map +1 -1
- package/lib/relay-protocol.d.ts +12 -0
- package/lib/relay-protocol.d.ts.map +1 -1
- package/lib/relay-room.d.ts.map +1 -1
- package/lib/relay-transport.d.ts.map +1 -1
- package/lib/time-sync.d.ts +3 -1
- package/lib/time-sync.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/client.ts +147 -38
- package/src/connection.ts +25 -7
- package/src/relay-protocol.ts +13 -0
- package/src/relay-room.ts +2 -0
- package/src/relay-transport.ts +16 -1
- package/src/time-sync.ts +6 -1
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(
|
|
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
|
-
}, [
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
365
|
+
closeCurrent();
|
|
311
366
|
reconnectAttempts.current = 0;
|
|
312
|
-
|
|
313
|
-
}, [
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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
|
package/lib/client.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/lib/connection.d.ts
CHANGED
|
@@ -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.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
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
|
package/lib/connection.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
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"}
|
package/lib/relay-protocol.d.ts
CHANGED
|
@@ -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":"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;
|
|
1
|
+
{"version":3,"file":"relay-protocol.d.ts","sourceRoot":"","sources":["../src/relay-protocol.ts"],"names":[],"mappings":"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"}
|
package/lib/relay-room.d.ts.map
CHANGED
|
@@ -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,
|
|
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;
|
|
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"}
|
package/lib/time-sync.d.ts
CHANGED
|
@@ -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
|
|
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): {
|
package/lib/time-sync.d.ts.map
CHANGED
|
@@ -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
|
|
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.
|
|
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(
|
|
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)
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
+
closeCurrent();
|
|
293
388
|
reconnectAttempts.current = 0;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}, [disconnect, connect]);
|
|
389
|
+
connect();
|
|
390
|
+
}, [closeCurrent, connect]);
|
|
297
391
|
|
|
298
392
|
// Action Dispatcher
|
|
299
|
-
const sendAction = useCallback(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
|
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
|
}
|
package/src/relay-protocol.ts
CHANGED
|
@@ -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:
|
package/src/relay-transport.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|