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