@couch-kit/client 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/README.md +20 -3
- package/dist/index.js +134 -48
- 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 +31 -1
- 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 +16 -1
- package/lib/time-sync.d.ts.map +1 -1
- package/package.json +7 -2
- package/src/client.ts +147 -38
- package/src/connection.ts +25 -7
- package/src/relay-protocol.ts +33 -3
- package/src/relay-room.ts +2 -0
- package/src/relay-transport.ts +16 -1
- package/src/time-sync.ts +40 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# @couch-kit/client
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#206](https://github.com/faluciano/react-native-couch-kit/pull/206) [`ddec122`](https://github.com/faluciano/react-native-couch-kit/commit/ddec122d9e415c54f157d6998c2b719ab31586b3) Thanks [@faluciano](https://github.com/faluciano)! - Fix the `useGameClient` connection lifecycle and make optimistic updates self-correcting.
|
|
8
|
+
|
|
9
|
+
- Time sync now actually runs. The ping loop only started if the socket was already open when the hook first saw it, which it never was, so no `PING` was ever sent: `rtt` stayed `0` and `getServerTime()` returned the local clock. It now starts when the socket opens. Games that use neither can pass `timeSync: false` to skip the pings (on a relay each one is a billed message).
|
|
10
|
+
- No more duplicate connections. A socket closed by cleanup, `disconnect()` or `reconnect()` still fires its `close` event later; that event used to overwrite the new connection's status and schedule a second, parallel reconnect. This happened on every mount under React `StrictMode` and whenever `url`, `wsPort` or the retry options changed. Events from a replaced transport are now ignored.
|
|
11
|
+
- Optimistic updates no longer get stuck. The host stays silent when an action changes nothing, so an optimistic update it ignored stood forever. The client now falls back to the last host state if no update arrives within `optimisticTimeoutMs` (default 2000, `0` disables), and immediately on a host `ERROR`.
|
|
12
|
+
- New `onError` callback receives host rejections (`RATE_LIMITED`, `NOT_JOINED`, …), which were silently dropped before. `interpretHostMessage` now returns an `error` effect for `ERROR` messages.
|
|
13
|
+
- `disconnectReason` resets to `null` once a connection opens, as documented.
|
|
14
|
+
- A stored session secret the host would reject is replaced instead of reused, so a corrupted `ck_secret` no longer fails every JOIN.
|
|
15
|
+
- The relay transport reports a room whose display disconnected as a terminal `HOST_LEFT` close (new `RelayErrorCodes.HOST_LEFT`, `RELAY_CLOSE_HOST_LEFT`), and `describeRelayError` explains it.
|
|
16
|
+
|
|
17
|
+
## 0.14.0
|
|
18
|
+
|
|
19
|
+
### Minor Changes
|
|
20
|
+
|
|
21
|
+
- [#164](https://github.com/faluciano/react-native-couch-kit/pull/164) [`a157126`](https://github.com/faluciano/react-native-couch-kit/commit/a157126424e4d73dcc7185118d5be0db6719792e) Thanks [@faluciano](https://github.com/faluciano)! - Send a projected state update as one relay frame instead of one per player
|
|
22
|
+
|
|
23
|
+
A game with a `project` function sends every player their own view, which meant
|
|
24
|
+
one WebSocket frame per player for every state change. Relays bill and
|
|
25
|
+
rate-limit per inbound frame, so a four-player table paid four messages for one
|
|
26
|
+
update and spent four of the display's 30-per-second budget.
|
|
27
|
+
|
|
28
|
+
`GameRuntimeTransport` gains an optional `sendMany(entries)`. When a transport
|
|
29
|
+
implements it, the runtime hands over the whole projected batch at once;
|
|
30
|
+
transports that do not — the LAN WebSocket path — keep receiving one `send` per
|
|
31
|
+
connection and are unaffected.
|
|
32
|
+
|
|
33
|
+
`RelayDisplayHost` implements it with a new `DATA_MULTI` envelope carrying a
|
|
34
|
+
peer-id-to-payload map, which the relay unpacks into ordinary `DATA` frames.
|
|
35
|
+
Phones need no update — nothing on the client side can tell a batched update
|
|
36
|
+
from a unicast one. If the combined frame would exceed the relay's 256KB
|
|
37
|
+
ceiling, the display falls back to individual frames rather than send something
|
|
38
|
+
the relay would drop.
|
|
39
|
+
|
|
40
|
+
Relays must be updated before displays: both bundled implementations
|
|
41
|
+
(`services/relay`, `services/relay-worker`) understand `DATA_MULTI`, and an
|
|
42
|
+
older relay answers it with `MALFORMED`. The type is host-only — a phone sending
|
|
43
|
+
it is rejected, so it cannot be used to reach another phone directly.
|
|
44
|
+
|
|
45
|
+
- [#164](https://github.com/faluciano/react-native-couch-kit/pull/164) [`a157126`](https://github.com/faluciano/react-native-couch-kit/commit/a157126424e4d73dcc7185118d5be0db6719792e) Thanks [@faluciano](https://github.com/faluciano)! - Back the time-sync ping off to 30s once the clock estimate settles
|
|
46
|
+
|
|
47
|
+
`useGameClient`'s clock sync pinged every 5 seconds for the life of the
|
|
48
|
+
connection. It now starts there and doubles to a 30-second ceiling
|
|
49
|
+
(`MAX_SYNC_INTERVAL`), resetting to the fast interval whenever a new socket is
|
|
50
|
+
established, including after a reconnect.
|
|
51
|
+
|
|
52
|
+
The first few pings are what converge the offset; the clock difference they
|
|
53
|
+
measure does not drift on a human timescale, so the fast interval stops earning
|
|
54
|
+
its cost within about a minute. On a relay transport it is not free: each ping
|
|
55
|
+
is a message in and a `PONG` back out, and pings are the only traffic an idle
|
|
56
|
+
table generates at all. A four-player lobby went from 5,760 relay messages an
|
|
57
|
+
hour to under 1,000 while sitting untouched.
|
|
58
|
+
|
|
59
|
+
`rtt` and `getServerTime()` are unchanged in accuracy — both are updated by the
|
|
60
|
+
same `PONG` handling as before, just less often once settled. Games needing the
|
|
61
|
+
old cadence can dispatch their own pings; the constants
|
|
62
|
+
(`DEFAULT_SYNC_INTERVAL`, `MAX_SYNC_INTERVAL`, `SYNC_BACKOFF_FACTOR`) are
|
|
63
|
+
exported from `@couch-kit/core`.
|
|
64
|
+
|
|
65
|
+
### Patch Changes
|
|
66
|
+
|
|
67
|
+
- Updated dependencies [[`a157126`](https://github.com/faluciano/react-native-couch-kit/commit/a157126424e4d73dcc7185118d5be0db6719792e)]:
|
|
68
|
+
- @couch-kit/core@0.10.0
|
|
69
|
+
|
|
3
70
|
## 0.13.0
|
|
4
71
|
|
|
5
72
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The client-side React hooks for the web controller.
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **Default connection:** By default, connects to `ws(s)://{window.location.hostname}:8082`.
|
|
7
|
+
- **Default connection:** By default, connects to `ws(s)://{window.location.hostname}:8082/ws`.
|
|
8
8
|
- **Time synchronization:** `useServerTime()` helps estimate server time using periodic ping/pong.
|
|
9
9
|
- **Asset preloading:** `usePreload()` is a small helper for preloading images and fetching other URLs.
|
|
10
10
|
- **Optimistic UI:** State updates apply locally immediately while being sent to the server.
|
|
@@ -27,15 +27,18 @@ Config:
|
|
|
27
27
|
|
|
28
28
|
- `reducer`: `(state, action) => state` (your shared reducer)
|
|
29
29
|
- `initialState`: initial state used until the host hydrates
|
|
30
|
-
- `url?`: explicit WebSocket URL. If omitted, the hook uses `ws(s)://{window.location.hostname}:8082`.
|
|
30
|
+
- `url?`: explicit WebSocket URL. If omitted, the hook uses `ws(s)://{window.location.hostname}:8082/ws`.
|
|
31
31
|
- `wsPort?`: WebSocket port override (default: auto-detected from page URL using HTTP port + 2)
|
|
32
32
|
- `name?`: player display name (default: `"Player"`)
|
|
33
33
|
- `avatar?`: player avatar emoji (default: `"\u{1F600}"`)
|
|
34
34
|
- `maxRetries?`: maximum reconnection attempts before giving up (default: `5`)
|
|
35
35
|
- `baseDelay?`: base delay in ms for exponential backoff reconnection (default: `1000`)
|
|
36
36
|
- `maxDelay?`: maximum delay in ms cap for reconnection backoff (default: `10000`)
|
|
37
|
+
- `optimisticTimeoutMs?`: how long an optimistic update may stand without the host confirming it (default: `2000`; `0` disables). See [Optimistic updates](#optimistic-updates).
|
|
38
|
+
- `timeSync?`: exchange PING/PONG with the host to power `getServerTime()` and `rtt` (default: `true`). Turn it off if you use neither — on a relay every ping is a billed message.
|
|
37
39
|
- `debug?`: enable console logs
|
|
38
40
|
- `onConnect?`, `onDisconnect?`: lifecycle callbacks
|
|
41
|
+
- `onError?`: called with `{ code, message }` when the host rejects something this client sent (`RATE_LIMITED`, `NOT_JOINED`, `FORBIDDEN_ACTION`, `INVALID_SECRET`, …)
|
|
39
42
|
|
|
40
43
|
Returns:
|
|
41
44
|
|
|
@@ -43,11 +46,25 @@ Returns:
|
|
|
43
46
|
- `state`: current controller state (optimistic + hydrated)
|
|
44
47
|
- `playerId`: stable public identifier derived from the session secret. Persists across page refreshes and reconnections (the same player always gets the same `playerId`).
|
|
45
48
|
- `sendAction(action)`: optimistic dispatch + send to host
|
|
49
|
+
- `disconnectReason`: why the last connection ended, when the transport knows (for the relay, a code such as `ROOM_NOT_FOUND` or `HOST_LEFT`); `null` once connected again
|
|
46
50
|
- `getServerTime()`: NTP-ish server time based on periodic ping/pong
|
|
47
51
|
- `rtt`: round-trip time (ms) to the server, updated periodically via PING/PONG
|
|
48
52
|
- `disconnect()`: manually disconnect from the host (prevents automatic reconnection)
|
|
49
53
|
- `reconnect()`: manually reconnect to the host (resets the reconnection attempt counter)
|
|
50
54
|
|
|
55
|
+
### Optimistic updates
|
|
56
|
+
|
|
57
|
+
With a `reducer`, `sendAction` applies the action locally before the host
|
|
58
|
+
answers. The host is still the authority, and it only broadcasts when its state
|
|
59
|
+
actually changes — so an action it ignores (an illegal move, a rate-limited tap,
|
|
60
|
+
one sent while offline) produces no update at all. The client therefore treats
|
|
61
|
+
silence as a "no": if no state update arrives within `optimisticTimeoutMs`, it
|
|
62
|
+
falls back to the last state the host sent. A host `ERROR` does the same
|
|
63
|
+
immediately and is passed to `onError`.
|
|
64
|
+
|
|
65
|
+
The hook is safe under React `StrictMode` and across option changes: a
|
|
66
|
+
connection that has been replaced can no longer affect the current one.
|
|
67
|
+
|
|
51
68
|
## State Sync Contract
|
|
52
69
|
|
|
53
70
|
The host broadcasts full state snapshots. The client automatically applies them using a higher-order reducer wrapper.
|
|
@@ -77,7 +94,7 @@ In dev, pass the TV WebSocket URL explicitly:
|
|
|
77
94
|
useGameClient({
|
|
78
95
|
reducer,
|
|
79
96
|
initialState,
|
|
80
|
-
url: "ws://TV_IP:8082",
|
|
97
|
+
url: "ws://TV_IP:8082/ws",
|
|
81
98
|
});
|
|
82
99
|
```
|
|
83
100
|
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
MessageTypes,
|
|
16
16
|
generateId,
|
|
17
17
|
DEFAULT_SYNC_INTERVAL,
|
|
18
|
+
MAX_SYNC_INTERVAL,
|
|
19
|
+
SYNC_BACKOFF_FACTOR,
|
|
18
20
|
MAX_PENDING_PINGS
|
|
19
21
|
} from "@couch-kit/core";
|
|
20
22
|
|
|
@@ -53,6 +55,9 @@ function calculateTimeSync(clientSendTime, clientReceiveTime, serverTime) {
|
|
|
53
55
|
const offset = expectedServerTime - clientReceiveTime;
|
|
54
56
|
return { offset, rtt };
|
|
55
57
|
}
|
|
58
|
+
function nextSyncInterval(current) {
|
|
59
|
+
return Math.min(current * SYNC_BACKOFF_FACTOR, MAX_SYNC_INTERVAL);
|
|
60
|
+
}
|
|
56
61
|
function useServerTime(socket) {
|
|
57
62
|
const [timeSync, setTimeSync] = useState({
|
|
58
63
|
offset: 0,
|
|
@@ -74,6 +79,8 @@ function useServerTime(socket) {
|
|
|
74
79
|
useEffect(() => {
|
|
75
80
|
if (!socket || socket.readyState !== TransportReadyState.OPEN)
|
|
76
81
|
return;
|
|
82
|
+
let delay = DEFAULT_SYNC_INTERVAL;
|
|
83
|
+
let timer = null;
|
|
77
84
|
const sync = () => {
|
|
78
85
|
if (pings.current.size >= MAX_PENDING_PINGS) {
|
|
79
86
|
const oldest = pings.current.keys().next().value;
|
|
@@ -87,10 +94,16 @@ function useServerTime(socket) {
|
|
|
87
94
|
type: MessageTypes.PING,
|
|
88
95
|
payload: { id, timestamp }
|
|
89
96
|
}));
|
|
97
|
+
delay = nextSyncInterval(delay);
|
|
98
|
+
timer = setTimeout(sync, delay);
|
|
90
99
|
};
|
|
91
100
|
sync();
|
|
92
|
-
const
|
|
93
|
-
return () =>
|
|
101
|
+
const pending = pings.current;
|
|
102
|
+
return () => {
|
|
103
|
+
if (timer !== null)
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
pending.clear();
|
|
106
|
+
};
|
|
94
107
|
}, [socket]);
|
|
95
108
|
return { getServerTime, rtt: timeSync.rtt, handlePong };
|
|
96
109
|
}
|
|
@@ -100,9 +113,11 @@ import {
|
|
|
100
113
|
MessageTypes as MessageTypes2,
|
|
101
114
|
DEFAULT_WS_PORT_OFFSET,
|
|
102
115
|
DEFAULT_WS_PATH,
|
|
103
|
-
generateId as generateId2
|
|
116
|
+
generateId as generateId2,
|
|
117
|
+
isValidSecret
|
|
104
118
|
} from "@couch-kit/core";
|
|
105
119
|
var SESSION_SECRET_KEY = "ck_secret";
|
|
120
|
+
var DEFAULT_OPTIMISTIC_TIMEOUT = 2000;
|
|
106
121
|
var NON_RECOVERABLE_CLOSE_CODES = new Set([
|
|
107
122
|
1008,
|
|
108
123
|
1011
|
|
@@ -133,7 +148,7 @@ function resolveSessionSecret(storage, generate = generateId2) {
|
|
|
133
148
|
if (!storage)
|
|
134
149
|
return generate();
|
|
135
150
|
const stored = storage.getItem(SESSION_SECRET_KEY);
|
|
136
|
-
if (stored)
|
|
151
|
+
if (stored && isValidSecret(stored))
|
|
137
152
|
return stored;
|
|
138
153
|
const secret = generate();
|
|
139
154
|
storage.setItem(SESSION_SECRET_KEY, secret);
|
|
@@ -158,6 +173,8 @@ function interpretHostMessage(msg) {
|
|
|
158
173
|
{ kind: "setPlayerId", playerId: msg.payload.playerId },
|
|
159
174
|
{ kind: "hydrate", state: msg.payload.state }
|
|
160
175
|
];
|
|
176
|
+
case MessageTypes2.ERROR:
|
|
177
|
+
return [{ kind: "error", error: msg.payload }];
|
|
161
178
|
default:
|
|
162
179
|
return [];
|
|
163
180
|
}
|
|
@@ -170,6 +187,9 @@ function useGameClient(config) {
|
|
|
170
187
|
const [disconnectReason, setDisconnectReason] = useState2(null);
|
|
171
188
|
const [state, dispatchLocal] = useReducer(createGameReducer(config.reducer ?? ((current) => current)), config.initialState);
|
|
172
189
|
const socketRef = useRef2(null);
|
|
190
|
+
const [openTransport, setOpenTransport] = useState2(null);
|
|
191
|
+
const lastServerState = useRef2(null);
|
|
192
|
+
const rollbackTimer = useRef2(null);
|
|
173
193
|
const reconnectAttempts = useRef2(0);
|
|
174
194
|
const reconnectTimer = useRef2(null);
|
|
175
195
|
const intentionalClose = useRef2(false);
|
|
@@ -177,11 +197,27 @@ function useGameClient(config) {
|
|
|
177
197
|
useEffect2(() => {
|
|
178
198
|
configRef.current = config;
|
|
179
199
|
});
|
|
180
|
-
const { getServerTime, rtt, handlePong } = useServerTime(
|
|
200
|
+
const { getServerTime, rtt, handlePong } = useServerTime(config.timeSync === false ? null : openTransport);
|
|
181
201
|
const handlePongRef = useRef2(handlePong);
|
|
182
202
|
useEffect2(() => {
|
|
183
203
|
handlePongRef.current = handlePong;
|
|
184
204
|
});
|
|
205
|
+
const cancelRollback = useCallback2(() => {
|
|
206
|
+
if (rollbackTimer.current !== null) {
|
|
207
|
+
clearTimeout(rollbackTimer.current);
|
|
208
|
+
rollbackTimer.current = null;
|
|
209
|
+
}
|
|
210
|
+
}, []);
|
|
211
|
+
const rollbackToServerState = useCallback2(() => {
|
|
212
|
+
cancelRollback();
|
|
213
|
+
const serverState = lastServerState.current;
|
|
214
|
+
if (serverState === null)
|
|
215
|
+
return;
|
|
216
|
+
dispatchLocal({
|
|
217
|
+
type: InternalActionTypes.HYDRATE,
|
|
218
|
+
payload: serverState
|
|
219
|
+
});
|
|
220
|
+
}, [cancelRollback]);
|
|
185
221
|
const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
186
222
|
const baseDelay = config.baseDelay ?? DEFAULT_BASE_DELAY;
|
|
187
223
|
const maxDelay = config.maxDelay ?? DEFAULT_MAX_DELAY;
|
|
@@ -203,9 +239,14 @@ function useGameClient(config) {
|
|
|
203
239
|
}
|
|
204
240
|
socketRef.current = transport;
|
|
205
241
|
setStatus("connecting");
|
|
242
|
+
const isCurrent = () => socketRef.current === transport;
|
|
206
243
|
transport.onopen = () => {
|
|
244
|
+
if (!isCurrent())
|
|
245
|
+
return;
|
|
207
246
|
const currentCfg = configRef.current;
|
|
208
247
|
setStatus("connected");
|
|
248
|
+
setDisconnectReason(null);
|
|
249
|
+
setOpenTransport(transport);
|
|
209
250
|
reconnectAttempts.current = 0;
|
|
210
251
|
currentCfg.onConnect?.();
|
|
211
252
|
const secret = resolveSessionSecret(typeof localStorage !== "undefined" ? localStorage : null);
|
|
@@ -224,6 +265,8 @@ function useGameClient(config) {
|
|
|
224
265
|
}
|
|
225
266
|
};
|
|
226
267
|
transport.onmessage = (data) => {
|
|
268
|
+
if (!isCurrent())
|
|
269
|
+
return;
|
|
227
270
|
let msg;
|
|
228
271
|
try {
|
|
229
272
|
msg = JSON.parse(data);
|
|
@@ -237,6 +280,8 @@ function useGameClient(config) {
|
|
|
237
280
|
setPlayerId(effect.playerId);
|
|
238
281
|
break;
|
|
239
282
|
case "hydrate":
|
|
283
|
+
lastServerState.current = effect.state;
|
|
284
|
+
cancelRollback();
|
|
240
285
|
dispatchLocal({
|
|
241
286
|
type: InternalActionTypes.HYDRATE,
|
|
242
287
|
payload: effect.state
|
|
@@ -245,10 +290,20 @@ function useGameClient(config) {
|
|
|
245
290
|
case "pong":
|
|
246
291
|
handlePongRef.current(effect.payload);
|
|
247
292
|
break;
|
|
293
|
+
case "error":
|
|
294
|
+
if (configRef.current.debug)
|
|
295
|
+
console.warn("[GameClient] Host error:", effect.error);
|
|
296
|
+
rollbackToServerState();
|
|
297
|
+
configRef.current.onError?.(effect.error);
|
|
298
|
+
break;
|
|
248
299
|
}
|
|
249
300
|
}
|
|
250
301
|
};
|
|
251
302
|
transport.onclose = (code, reason) => {
|
|
303
|
+
if (!isCurrent())
|
|
304
|
+
return;
|
|
305
|
+
socketRef.current = null;
|
|
306
|
+
setOpenTransport(null);
|
|
252
307
|
setStatus("disconnected");
|
|
253
308
|
setDisconnectReason(reason ? reason : null);
|
|
254
309
|
configRef.current.onDisconnect?.();
|
|
@@ -268,47 +323,66 @@ function useGameClient(config) {
|
|
|
268
323
|
}, delay);
|
|
269
324
|
};
|
|
270
325
|
transport.onerror = (e) => {
|
|
326
|
+
if (!isCurrent())
|
|
327
|
+
return;
|
|
271
328
|
if (configRef.current.debug)
|
|
272
329
|
console.error("[GameClient] Error", e);
|
|
273
330
|
setStatus("error");
|
|
274
331
|
};
|
|
275
|
-
}, [
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}, [connect]);
|
|
286
|
-
const disconnect = useCallback2(() => {
|
|
332
|
+
}, [
|
|
333
|
+
config.url,
|
|
334
|
+
config.wsPort,
|
|
335
|
+
maxRetries,
|
|
336
|
+
baseDelay,
|
|
337
|
+
maxDelay,
|
|
338
|
+
cancelRollback,
|
|
339
|
+
rollbackToServerState
|
|
340
|
+
]);
|
|
341
|
+
const closeCurrent = useCallback2(() => {
|
|
287
342
|
intentionalClose.current = true;
|
|
288
343
|
if (reconnectTimer.current) {
|
|
289
344
|
clearTimeout(reconnectTimer.current);
|
|
290
345
|
reconnectTimer.current = null;
|
|
291
346
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
347
|
+
cancelRollback();
|
|
348
|
+
const transport = socketRef.current;
|
|
349
|
+
if (!transport)
|
|
350
|
+
return;
|
|
351
|
+
socketRef.current = null;
|
|
352
|
+
setOpenTransport(null);
|
|
353
|
+
transport.close();
|
|
354
|
+
configRef.current.onDisconnect?.();
|
|
355
|
+
}, [cancelRollback]);
|
|
356
|
+
useEffect2(() => {
|
|
357
|
+
connect();
|
|
358
|
+
return closeCurrent;
|
|
359
|
+
}, [connect, closeCurrent]);
|
|
360
|
+
const disconnect = useCallback2(() => {
|
|
361
|
+
closeCurrent();
|
|
296
362
|
setStatus("disconnected");
|
|
297
|
-
}, []);
|
|
363
|
+
}, [closeCurrent]);
|
|
298
364
|
const reconnect = useCallback2(() => {
|
|
299
|
-
|
|
365
|
+
closeCurrent();
|
|
300
366
|
reconnectAttempts.current = 0;
|
|
301
|
-
|
|
302
|
-
}, [
|
|
367
|
+
connect();
|
|
368
|
+
}, [closeCurrent, connect]);
|
|
303
369
|
const sendAction = useCallback2((action) => {
|
|
304
370
|
dispatchLocal(action);
|
|
371
|
+
const cfg = configRef.current;
|
|
372
|
+
const timeout = cfg.optimisticTimeoutMs ?? DEFAULT_OPTIMISTIC_TIMEOUT;
|
|
373
|
+
if (cfg.reducer && timeout > 0 && rollbackTimer.current === null) {
|
|
374
|
+
rollbackTimer.current = setTimeout(() => {
|
|
375
|
+
rollbackTimer.current = null;
|
|
376
|
+
rollbackToServerState();
|
|
377
|
+
}, timeout);
|
|
378
|
+
}
|
|
305
379
|
if (socketRef.current?.readyState === TransportReadyState.OPEN) {
|
|
306
380
|
socketRef.current.send(JSON.stringify({
|
|
307
381
|
type: MessageTypes3.ACTION,
|
|
308
382
|
payload: action
|
|
309
383
|
}));
|
|
310
384
|
}
|
|
311
|
-
}, []);
|
|
385
|
+
}, [rollbackToServerState]);
|
|
312
386
|
return {
|
|
313
387
|
status,
|
|
314
388
|
state,
|
|
@@ -330,6 +404,7 @@ var RelayMessageTypes = {
|
|
|
330
404
|
PEER_JOINED: "PEER_JOINED",
|
|
331
405
|
PEER_LEFT: "PEER_LEFT",
|
|
332
406
|
DATA: "DATA",
|
|
407
|
+
DATA_MULTI: "DATA_MULTI",
|
|
333
408
|
ERROR: "ERROR"
|
|
334
409
|
};
|
|
335
410
|
var RelayErrorCodes = {
|
|
@@ -340,8 +415,10 @@ var RelayErrorCodes = {
|
|
|
340
415
|
MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE",
|
|
341
416
|
MALFORMED: "MALFORMED",
|
|
342
417
|
RATE_LIMITED: "RATE_LIMITED",
|
|
343
|
-
SERVER_BUSY: "SERVER_BUSY"
|
|
418
|
+
SERVER_BUSY: "SERVER_BUSY",
|
|
419
|
+
HOST_LEFT: "HOST_LEFT"
|
|
344
420
|
};
|
|
421
|
+
var RELAY_CLOSE_HOST_LEFT = 4001;
|
|
345
422
|
function relayRoomUrl(url, roomId) {
|
|
346
423
|
const trimmed = url.replace(/\/+$/, "");
|
|
347
424
|
const [base, query] = trimmed.split("?", 2);
|
|
@@ -382,6 +459,10 @@ class RelayClientTransport {
|
|
|
382
459
|
};
|
|
383
460
|
this.ws.onclose = (event) => {
|
|
384
461
|
this.state = TransportReadyState.CLOSED;
|
|
462
|
+
if (this.pendingCloseCode === null && event.code === RELAY_CLOSE_HOST_LEFT) {
|
|
463
|
+
this.pendingCloseCode = POLICY_CLOSE_CODE;
|
|
464
|
+
this.pendingCloseReason = RelayErrorCodes.HOST_LEFT;
|
|
465
|
+
}
|
|
385
466
|
const code = this.pendingCloseCode ?? event.code;
|
|
386
467
|
this.onclose?.(code, this.pendingCloseReason ?? event.reason);
|
|
387
468
|
};
|
|
@@ -479,6 +560,8 @@ function describeRelayError(reason) {
|
|
|
479
560
|
return "That room isn't open. Check the code on the screen.";
|
|
480
561
|
case RelayErrorCodes.ROOM_FULL:
|
|
481
562
|
return "That room is full.";
|
|
563
|
+
case RelayErrorCodes.HOST_LEFT:
|
|
564
|
+
return "The host screen closed, so this game has ended.";
|
|
482
565
|
case RelayErrorCodes.RATE_LIMITED:
|
|
483
566
|
return "Too many messages — slow down and try again.";
|
|
484
567
|
case RelayErrorCodes.ROOM_EXISTS:
|
|
@@ -628,26 +711,29 @@ function useDebugPanel(options) {
|
|
|
628
711
|
};
|
|
629
712
|
}
|
|
630
713
|
export {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
useGameClient,
|
|
635
|
-
useDebugPanel,
|
|
636
|
-
shouldReconnect,
|
|
637
|
-
resolveWebSocketUrl,
|
|
638
|
-
resolveSessionSecret,
|
|
639
|
-
relayRoomUrl,
|
|
640
|
-
normalizeRoomCode,
|
|
641
|
-
interpretHostMessage,
|
|
642
|
-
describeRelayError,
|
|
643
|
-
createWebSocketTransport,
|
|
644
|
-
createRelayTransport,
|
|
645
|
-
computeBackoffDelay,
|
|
646
|
-
calculateTimeSync,
|
|
647
|
-
TransportReadyState,
|
|
648
|
-
SESSION_SECRET_KEY,
|
|
649
|
-
RelayMessageTypes,
|
|
650
|
-
RelayErrorCodes,
|
|
714
|
+
DEFAULT_OPTIMISTIC_TIMEOUT,
|
|
715
|
+
RELAY_CLOSE_HOST_LEFT,
|
|
716
|
+
RELAY_MINT_PATH,
|
|
651
717
|
RelayClientTransport,
|
|
652
|
-
|
|
718
|
+
RelayErrorCodes,
|
|
719
|
+
RelayMessageTypes,
|
|
720
|
+
SESSION_SECRET_KEY,
|
|
721
|
+
TransportReadyState,
|
|
722
|
+
calculateTimeSync,
|
|
723
|
+
computeBackoffDelay,
|
|
724
|
+
createRelayTransport,
|
|
725
|
+
createWebSocketTransport,
|
|
726
|
+
describeRelayError,
|
|
727
|
+
interpretHostMessage,
|
|
728
|
+
nextSyncInterval,
|
|
729
|
+
normalizeRoomCode,
|
|
730
|
+
relayRoomUrl,
|
|
731
|
+
resolveSessionSecret,
|
|
732
|
+
resolveWebSocketUrl,
|
|
733
|
+
shouldReconnect,
|
|
734
|
+
useDebugPanel,
|
|
735
|
+
useGameClient,
|
|
736
|
+
usePreload,
|
|
737
|
+
useRelayRoom,
|
|
738
|
+
useServerTime
|
|
653
739
|
};
|
package/lib/client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type IGameState, type IAction } from "@couch-kit/core";
|
|
2
|
+
import { type HostError } from "./connection";
|
|
2
3
|
import { type CreateClientTransport } from "./transport";
|
|
3
4
|
export interface ClientConfig<S extends IGameState, A extends IAction> {
|
|
4
5
|
url?: string;
|
|
@@ -23,8 +24,30 @@ export interface ClientConfig<S extends IGameState, A extends IAction> {
|
|
|
23
24
|
baseDelay?: number;
|
|
24
25
|
/** Maximum delay (ms) cap for reconnection backoff (default: 10000). */
|
|
25
26
|
maxDelay?: number;
|
|
27
|
+
/**
|
|
28
|
+
* How long (ms) an optimistic update may stand without the host confirming
|
|
29
|
+
* it (default: 2000). The host only broadcasts when its state changes, so an
|
|
30
|
+
* action it ignores — an illegal move, a rate-limited tap, one sent while
|
|
31
|
+
* offline — produces no update, and without this the client would keep
|
|
32
|
+
* showing a state the host never had. When the window passes with no update
|
|
33
|
+
* the client falls back to the last state the host sent. Set to `0` to
|
|
34
|
+
* disable. Has no effect without a `reducer`.
|
|
35
|
+
*/
|
|
36
|
+
optimisticTimeoutMs?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Keep the clock in sync with the host by exchanging PING/PONG (default:
|
|
39
|
+
* `true`). Powers `getServerTime()` and `rtt`. A game that uses neither can
|
|
40
|
+
* turn it off: on a relay transport every ping, and the host's answer, is a
|
|
41
|
+
* billed message.
|
|
42
|
+
*/
|
|
43
|
+
timeSync?: boolean;
|
|
26
44
|
onConnect?: () => void;
|
|
27
45
|
onDisconnect?: () => void;
|
|
46
|
+
/**
|
|
47
|
+
* Called when the host rejects something this client sent (`RATE_LIMITED`,
|
|
48
|
+
* `NOT_JOINED`, `FORBIDDEN_ACTION`, `INVALID_SECRET`, …).
|
|
49
|
+
*/
|
|
50
|
+
onError?: (error: HostError) => void;
|
|
28
51
|
debug?: boolean;
|
|
29
52
|
/**
|
|
30
53
|
* Provide a custom transport factory (e.g. a cross-network relay). When
|
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
|
@@ -27,6 +27,8 @@ export declare const RelayMessageTypes: {
|
|
|
27
27
|
readonly PEER_LEFT: "PEER_LEFT";
|
|
28
28
|
/** Bidirectional: carries an opaque Couch Kit JSON message as `data`. */
|
|
29
29
|
readonly DATA: "DATA";
|
|
30
|
+
/** Display → relay: one frame carrying a different payload per phone. */
|
|
31
|
+
readonly DATA_MULTI: "DATA_MULTI";
|
|
30
32
|
/** Relay → client: a protocol/room error. */
|
|
31
33
|
readonly ERROR: "ERROR";
|
|
32
34
|
};
|
|
@@ -42,7 +44,19 @@ export declare const RelayErrorCodes: {
|
|
|
42
44
|
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
43
45
|
/** Relay is at its room capacity. */
|
|
44
46
|
readonly SERVER_BUSY: "SERVER_BUSY";
|
|
47
|
+
/**
|
|
48
|
+
* The display hosting the room disconnected, so the room is gone. Never sent
|
|
49
|
+
* as an `ERROR` frame: the relay closes each phone's socket with
|
|
50
|
+
* {@link RELAY_CLOSE_HOST_LEFT} and the client transport reports this code as
|
|
51
|
+
* the close reason.
|
|
52
|
+
*/
|
|
53
|
+
readonly HOST_LEFT: "HOST_LEFT";
|
|
45
54
|
};
|
|
55
|
+
/**
|
|
56
|
+
* WebSocket close code the relay uses when a room's host disconnects and its
|
|
57
|
+
* phones are dropped with it.
|
|
58
|
+
*/
|
|
59
|
+
export declare const RELAY_CLOSE_HOST_LEFT = 4001;
|
|
46
60
|
export type RelayErrorCode = (typeof RelayErrorCodes)[keyof typeof RelayErrorCodes];
|
|
47
61
|
/** Display → relay: create and host a room. */
|
|
48
62
|
export interface CreateRoomMessage {
|
|
@@ -97,6 +111,22 @@ export interface DataMessage {
|
|
|
97
111
|
to?: string;
|
|
98
112
|
data: string;
|
|
99
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Display → relay: one envelope carrying a different payload per phone.
|
|
116
|
+
*
|
|
117
|
+
* `payloads` maps a phone's `peerId` to the already-serialized Couch Kit
|
|
118
|
+
* message for that phone. The relay unpacks it into ordinary
|
|
119
|
+
* {@link DataMessage} frames, so phones never see this type — it exists purely
|
|
120
|
+
* so a projected game costs one inbound relay message per state change instead
|
|
121
|
+
* of one per player. Peer ids the room does not know are skipped.
|
|
122
|
+
*
|
|
123
|
+
* Host-only: the relay rejects it from a phone.
|
|
124
|
+
*/
|
|
125
|
+
export interface DataMultiMessage {
|
|
126
|
+
type: typeof RelayMessageTypes.DATA_MULTI;
|
|
127
|
+
roomId: string;
|
|
128
|
+
payloads: Record<string, string>;
|
|
129
|
+
}
|
|
100
130
|
/** Relay → client: a protocol/room error. */
|
|
101
131
|
export interface RelayErrorMessage {
|
|
102
132
|
type: typeof RelayMessageTypes.ERROR;
|
|
@@ -104,7 +134,7 @@ export interface RelayErrorMessage {
|
|
|
104
134
|
message: string;
|
|
105
135
|
}
|
|
106
136
|
/** Any message a client may send to the relay. */
|
|
107
|
-
export type RelayClientMessage = CreateRoomMessage | JoinRoomMessage | DataMessage;
|
|
137
|
+
export type RelayClientMessage = CreateRoomMessage | JoinRoomMessage | DataMessage | DataMultiMessage;
|
|
108
138
|
/** Any message the relay may send to a client. */
|
|
109
139
|
export type RelayServerMessage = RoomCreatedMessage | RoomJoinedMessage | PeerJoinedMessage | PeerLeftMessage | DataMessage | RelayErrorMessage;
|
|
110
140
|
/** Every relay wire message. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay-protocol.d.ts","sourceRoot":"","sources":["../src/relay-protocol.ts"],"names":[],"mappings":"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,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":"AAAA;;;;;;;;;;;;GAYG;AAEH,+EAA+E;AAC/E,eAAO,MAAM,iBAAiB;IAC5B,8DAA8D;aAC9D,WAAW,EAAE,aAAa;IAC1B,0EAA0E;aAC1E,YAAY,EAAE,cAAc;IAC5B,4CAA4C;aAC5C,SAAS,EAAE,WAAW;IACtB,qEAAqE;aACrE,WAAW,EAAE,aAAa;IAC1B,gDAAgD;aAChD,WAAW,EAAE,aAAa;IAC1B,8CAA8C;aAC9C,SAAS,EAAE,WAAW;IACtB,yEAAyE;aACzE,IAAI,EAAE,MAAM;IACZ,yEAAyE;aACzE,UAAU,EAAE,YAAY;IACxB,6CAA6C;aAC7C,KAAK,EAAE,OAAO;CACN,CAAC;AAEX,wEAAwE;AACxE,eAAO,MAAM,eAAe;aAC1B,cAAc,EAAE,gBAAgB;aAChC,WAAW,EAAE,aAAa;aAC1B,SAAS,EAAE,WAAW;aACtB,WAAW,EAAE,aAAa;aAC1B,iBAAiB,EAAE,mBAAmB;aACtC,SAAS,EAAE,WAAW;IACtB,yEAAyE;aACzE,YAAY,EAAE,cAAc;IAC5B,qCAAqC;aACrC,WAAW,EAAE,aAAa;IAC1B;;;;;OAKG;aACH,SAAS,EAAE,WAAW;CACd,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C,MAAM,MAAM,cAAc,GACxB,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,OAAO,eAAe,CAAC,CAAC;AAEzD,+CAA+C;AAC/C,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,iBAAiB,CAAC,WAAW,CAAC;IAC3C,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,uEAAuE;AACvE,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,OAAO,iBAAiB,CAAC,YAAY,CAAC;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,4CAA4C;AAC5C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,OAAO,iBAAiB,CAAC,SAAS,CAAC;IACzC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wEAAwE;AACxE,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,iBAAiB,CAAC,WAAW,CAAC;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,2EAA2E;AAC3E,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,iBAAiB,CAAC,WAAW,CAAC;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,qCAAqC;AACrC,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,OAAO,iBAAiB,CAAC,SAAS,CAAC;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,iBAAiB,CAAC,IAAI,CAAC;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,OAAO,iBAAiB,CAAC,UAAU,CAAC;IAC1C,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,6CAA6C;AAC7C,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,iBAAiB,CAAC,KAAK,CAAC;IACrC,IAAI,EAAE,cAAc,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,kDAAkD;AAClD,MAAM,MAAM,kBAAkB,GAC5B,iBAAiB,GAAG,eAAe,GAAG,WAAW,GAAG,gBAAgB,CAAC;AAEvE,kDAAkD;AAClD,MAAM,MAAM,kBAAkB,GAC1B,kBAAkB,GAClB,iBAAiB,GACjB,iBAAiB,GACjB,eAAe,GACf,WAAW,GACX,iBAAiB,CAAC;AAEtB,gCAAgC;AAChC,MAAM,MAAM,YAAY,GAAG,kBAAkB,GAAG,kBAAkB,CAAC;AAEnE;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAQjE;AAED;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,SAAS,CAAC"}
|
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
|
@@ -15,6 +15,19 @@ export declare function calculateTimeSync(clientSendTime: number, clientReceiveT
|
|
|
15
15
|
offset: number;
|
|
16
16
|
rtt: number;
|
|
17
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* The interval to wait before the next PING, given the one just used.
|
|
20
|
+
*
|
|
21
|
+
* Grows geometrically to {@link MAX_SYNC_INTERVAL}: the first pings after
|
|
22
|
+
* connecting are what converge the offset, and re-measuring it every few
|
|
23
|
+
* seconds forever buys nothing — the clock difference does not move, while on a
|
|
24
|
+
* relay transport each ping is a billed message in both directions and the only
|
|
25
|
+
* traffic an idle table generates at all.
|
|
26
|
+
*
|
|
27
|
+
* @param current - Interval (ms) used for the ping just sent.
|
|
28
|
+
* @returns The next interval, capped at {@link MAX_SYNC_INTERVAL}.
|
|
29
|
+
*/
|
|
30
|
+
export declare function nextSyncInterval(current: number): number;
|
|
18
31
|
/**
|
|
19
32
|
* React hook that synchronizes the client clock with the host server.
|
|
20
33
|
*
|
|
@@ -25,7 +38,9 @@ export declare function calculateTimeSync(clientSendTime: number, clientReceiveT
|
|
|
25
38
|
* called directly. Access `getServerTime()` and `rtt` from the
|
|
26
39
|
* `useGameClient` return value instead.
|
|
27
40
|
*
|
|
28
|
-
* @param socket - The
|
|
41
|
+
* @param socket - The **open** client transport, or `null` while there is none.
|
|
42
|
+
* Syncing starts when this becomes an open transport, so pass a value that
|
|
43
|
+
* changes identity on open (state, not a ref read during render).
|
|
29
44
|
* @returns An object with `getServerTime` (returns estimated server time), `rtt`, and `handlePong` (callback for PONG messages).
|
|
30
45
|
*/
|
|
31
46
|
export declare function useServerTime(socket: ClientTransport | null): {
|
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":"
|
|
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
|
|
@@ -49,10 +49,15 @@
|
|
|
49
49
|
"clean": "rm -rf dist lib"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@couch-kit/core": "0.
|
|
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
|
@@ -28,6 +28,8 @@ export const RelayMessageTypes = {
|
|
|
28
28
|
PEER_LEFT: "PEER_LEFT",
|
|
29
29
|
/** Bidirectional: carries an opaque Couch Kit JSON message as `data`. */
|
|
30
30
|
DATA: "DATA",
|
|
31
|
+
/** Display → relay: one frame carrying a different payload per phone. */
|
|
32
|
+
DATA_MULTI: "DATA_MULTI",
|
|
31
33
|
/** Relay → client: a protocol/room error. */
|
|
32
34
|
ERROR: "ERROR",
|
|
33
35
|
} as const;
|
|
@@ -44,8 +46,21 @@ export const RelayErrorCodes = {
|
|
|
44
46
|
RATE_LIMITED: "RATE_LIMITED",
|
|
45
47
|
/** Relay is at its room capacity. */
|
|
46
48
|
SERVER_BUSY: "SERVER_BUSY",
|
|
49
|
+
/**
|
|
50
|
+
* The display hosting the room disconnected, so the room is gone. Never sent
|
|
51
|
+
* as an `ERROR` frame: the relay closes each phone's socket with
|
|
52
|
+
* {@link RELAY_CLOSE_HOST_LEFT} and the client transport reports this code as
|
|
53
|
+
* the close reason.
|
|
54
|
+
*/
|
|
55
|
+
HOST_LEFT: "HOST_LEFT",
|
|
47
56
|
} as const;
|
|
48
57
|
|
|
58
|
+
/**
|
|
59
|
+
* WebSocket close code the relay uses when a room's host disconnects and its
|
|
60
|
+
* phones are dropped with it.
|
|
61
|
+
*/
|
|
62
|
+
export const RELAY_CLOSE_HOST_LEFT = 4001;
|
|
63
|
+
|
|
49
64
|
export type RelayErrorCode =
|
|
50
65
|
(typeof RelayErrorCodes)[keyof typeof RelayErrorCodes];
|
|
51
66
|
|
|
@@ -109,6 +124,23 @@ export interface DataMessage {
|
|
|
109
124
|
data: string;
|
|
110
125
|
}
|
|
111
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Display → relay: one envelope carrying a different payload per phone.
|
|
129
|
+
*
|
|
130
|
+
* `payloads` maps a phone's `peerId` to the already-serialized Couch Kit
|
|
131
|
+
* message for that phone. The relay unpacks it into ordinary
|
|
132
|
+
* {@link DataMessage} frames, so phones never see this type — it exists purely
|
|
133
|
+
* so a projected game costs one inbound relay message per state change instead
|
|
134
|
+
* of one per player. Peer ids the room does not know are skipped.
|
|
135
|
+
*
|
|
136
|
+
* Host-only: the relay rejects it from a phone.
|
|
137
|
+
*/
|
|
138
|
+
export interface DataMultiMessage {
|
|
139
|
+
type: typeof RelayMessageTypes.DATA_MULTI;
|
|
140
|
+
roomId: string;
|
|
141
|
+
payloads: Record<string, string>;
|
|
142
|
+
}
|
|
143
|
+
|
|
112
144
|
/** Relay → client: a protocol/room error. */
|
|
113
145
|
export interface RelayErrorMessage {
|
|
114
146
|
type: typeof RelayMessageTypes.ERROR;
|
|
@@ -118,9 +150,7 @@ export interface RelayErrorMessage {
|
|
|
118
150
|
|
|
119
151
|
/** Any message a client may send to the relay. */
|
|
120
152
|
export type RelayClientMessage =
|
|
121
|
-
|
|
|
122
|
-
| JoinRoomMessage
|
|
123
|
-
| DataMessage;
|
|
153
|
+
CreateRoomMessage | JoinRoomMessage | DataMessage | DataMultiMessage;
|
|
124
154
|
|
|
125
155
|
/** Any message the relay may send to a client. */
|
|
126
156
|
export type RelayServerMessage =
|
package/src/relay-room.ts
CHANGED
|
@@ -93,6 +93,8 @@ export function describeRelayError(reason: string | null): string | null {
|
|
|
93
93
|
return "That room isn't open. Check the code on the screen.";
|
|
94
94
|
case RelayErrorCodes.ROOM_FULL:
|
|
95
95
|
return "That room is full.";
|
|
96
|
+
case RelayErrorCodes.HOST_LEFT:
|
|
97
|
+
return "The host screen closed, so this game has ended.";
|
|
96
98
|
case RelayErrorCodes.RATE_LIMITED:
|
|
97
99
|
return "Too many messages — slow down and try again.";
|
|
98
100
|
case RelayErrorCodes.ROOM_EXISTS:
|
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
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
MessageTypes,
|
|
4
4
|
generateId,
|
|
5
5
|
DEFAULT_SYNC_INTERVAL,
|
|
6
|
+
MAX_SYNC_INTERVAL,
|
|
7
|
+
SYNC_BACKOFF_FACTOR,
|
|
6
8
|
MAX_PENDING_PINGS,
|
|
7
9
|
} from "@couch-kit/core";
|
|
8
10
|
import { TransportReadyState, type ClientTransport } from "./transport";
|
|
@@ -38,6 +40,22 @@ export function calculateTimeSync(
|
|
|
38
40
|
return { offset, rtt };
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The interval to wait before the next PING, given the one just used.
|
|
45
|
+
*
|
|
46
|
+
* Grows geometrically to {@link MAX_SYNC_INTERVAL}: the first pings after
|
|
47
|
+
* connecting are what converge the offset, and re-measuring it every few
|
|
48
|
+
* seconds forever buys nothing — the clock difference does not move, while on a
|
|
49
|
+
* relay transport each ping is a billed message in both directions and the only
|
|
50
|
+
* traffic an idle table generates at all.
|
|
51
|
+
*
|
|
52
|
+
* @param current - Interval (ms) used for the ping just sent.
|
|
53
|
+
* @returns The next interval, capped at {@link MAX_SYNC_INTERVAL}.
|
|
54
|
+
*/
|
|
55
|
+
export function nextSyncInterval(current: number): number {
|
|
56
|
+
return Math.min(current * SYNC_BACKOFF_FACTOR, MAX_SYNC_INTERVAL);
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
/**
|
|
42
60
|
* React hook that synchronizes the client clock with the host server.
|
|
43
61
|
*
|
|
@@ -48,7 +66,9 @@ export function calculateTimeSync(
|
|
|
48
66
|
* called directly. Access `getServerTime()` and `rtt` from the
|
|
49
67
|
* `useGameClient` return value instead.
|
|
50
68
|
*
|
|
51
|
-
* @param socket - The
|
|
69
|
+
* @param socket - The **open** client transport, or `null` while there is none.
|
|
70
|
+
* Syncing starts when this becomes an open transport, so pass a value that
|
|
71
|
+
* changes identity on open (state, not a ref read during render).
|
|
52
72
|
* @returns An object with `getServerTime` (returns estimated server time), `rtt`, and `handlePong` (callback for PONG messages).
|
|
53
73
|
*/
|
|
54
74
|
export function useServerTime(socket: ClientTransport | null) {
|
|
@@ -85,9 +105,19 @@ export function useServerTime(socket: ClientTransport | null) {
|
|
|
85
105
|
);
|
|
86
106
|
|
|
87
107
|
// Periodic Sync
|
|
108
|
+
//
|
|
109
|
+
// The interval backs off from DEFAULT_SYNC_INTERVAL to MAX_SYNC_INTERVAL
|
|
110
|
+
// rather than staying fast forever: the first few pings are what converge the
|
|
111
|
+
// offset, and after that we are re-measuring a clock difference that does not
|
|
112
|
+
// move. A self-rescheduling timeout is used instead of setInterval because
|
|
113
|
+
// the delay changes between ticks. Backoff state lives inside the effect, so
|
|
114
|
+
// a new socket — including a reconnect — starts fast again.
|
|
88
115
|
useEffect(() => {
|
|
89
116
|
if (!socket || socket.readyState !== TransportReadyState.OPEN) return;
|
|
90
117
|
|
|
118
|
+
let delay = DEFAULT_SYNC_INTERVAL;
|
|
119
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
120
|
+
|
|
91
121
|
const sync = () => {
|
|
92
122
|
// Prevent unbounded growth if PONGs are lost
|
|
93
123
|
if (pings.current.size >= MAX_PENDING_PINGS) {
|
|
@@ -105,13 +135,20 @@ export function useServerTime(socket: ClientTransport | null) {
|
|
|
105
135
|
payload: { id, timestamp },
|
|
106
136
|
}),
|
|
107
137
|
);
|
|
138
|
+
|
|
139
|
+
delay = nextSyncInterval(delay);
|
|
140
|
+
timer = setTimeout(sync, delay);
|
|
108
141
|
};
|
|
109
142
|
|
|
110
143
|
// Initial sync
|
|
111
144
|
sync();
|
|
112
145
|
|
|
113
|
-
const
|
|
114
|
-
return () =>
|
|
146
|
+
const pending = pings.current;
|
|
147
|
+
return () => {
|
|
148
|
+
if (timer !== null) clearTimeout(timer);
|
|
149
|
+
// PONGs for this socket's pings can no longer arrive.
|
|
150
|
+
pending.clear();
|
|
151
|
+
};
|
|
115
152
|
}, [socket]);
|
|
116
153
|
|
|
117
154
|
return { getServerTime, rtt: timeSync.rtt, handlePong };
|