@couch-kit/client 0.8.9 → 0.10.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 +33 -0
- package/dist/index.js +161 -17
- package/lib/client.d.ts +9 -1
- package/lib/client.d.ts.map +1 -1
- package/lib/index.d.ts +3 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/relay-protocol.d.ts +122 -0
- package/lib/relay-protocol.d.ts.map +1 -0
- package/lib/relay-transport.d.ts +49 -0
- package/lib/relay-transport.d.ts.map +1 -0
- package/lib/time-sync.d.ts +3 -2
- package/lib/time-sync.d.ts.map +1 -1
- package/lib/transport.d.ts +66 -0
- package/lib/transport.d.ts.map +1 -0
- package/package.json +2 -3
- package/src/client.ts +47 -25
- package/src/index.ts +3 -0
- package/src/relay-protocol.ts +151 -0
- package/src/relay-transport.ts +145 -0
- package/src/time-sync.ts +4 -3
- package/src/transport.ts +92 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @couch-kit/client
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#141](https://github.com/faluciano/react-native-couch-kit/pull/141) [`5142ccd`](https://github.com/faluciano/react-native-couch-kit/commit/5142ccdb2ef37ab85cc57eac1e50f99b567cde70) Thanks [@faluciano](https://github.com/faluciano)! - Relay connections now address the room in the URL: the socket opens against
|
|
8
|
+
`<relayUrl>/r/<roomId>` instead of `<relayUrl>`, and the new `relayRoomUrl()`
|
|
9
|
+
helper builds it.
|
|
10
|
+
|
|
11
|
+
The `CREATE_ROOM` / `JOIN_ROOM` handshake is unchanged, so relays that hold
|
|
12
|
+
every room in one process (the Bun reference server in `services/relay`) ignore
|
|
13
|
+
the path and keep working. Putting the room in the URL lets a relay route a
|
|
14
|
+
connection _before_ reading any frames, which is what per-room hosting — such as
|
|
15
|
+
a Cloudflare Durable Object — requires.
|
|
16
|
+
|
|
17
|
+
No consumer code changes: both `createRelayTransport` and `RelayDisplayHost`
|
|
18
|
+
already take `roomId`, and build the URL themselves.
|
|
19
|
+
|
|
20
|
+
## 0.9.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- [#124](https://github.com/faluciano/react-native-couch-kit/pull/124) [`782d495`](https://github.com/faluciano/react-native-couch-kit/commit/782d4956c73918af35bff73410bf5e39d7261e56) Thanks [@faluciano](https://github.com/faluciano)! - **New features**
|
|
25
|
+
|
|
26
|
+
Add an injectable client transport so the web controller can connect over
|
|
27
|
+
transports other than the default LAN WebSocket. `useGameClient` now accepts a
|
|
28
|
+
`createTransport` factory, and the package exports a `ClientTransport` interface
|
|
29
|
+
plus `createWebSocketTransport` (the default).
|
|
30
|
+
|
|
31
|
+
Ship a cross-network relay transport (`createRelayTransport`) and the shared
|
|
32
|
+
relay wire protocol, enabling a hosted browser display to reach phones across
|
|
33
|
+
networks through a game-agnostic relay server. The default WebSocket behavior is
|
|
34
|
+
unchanged and fully backward compatible.
|
|
35
|
+
|
|
3
36
|
## 0.8.9
|
|
4
37
|
|
|
5
38
|
### Patch Changes
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,35 @@ import {
|
|
|
17
17
|
DEFAULT_SYNC_INTERVAL,
|
|
18
18
|
MAX_PENDING_PINGS
|
|
19
19
|
} from "@couch-kit/core";
|
|
20
|
+
|
|
21
|
+
// src/transport.ts
|
|
22
|
+
var TransportReadyState = {
|
|
23
|
+
CONNECTING: 0,
|
|
24
|
+
OPEN: 1,
|
|
25
|
+
CLOSING: 2,
|
|
26
|
+
CLOSED: 3
|
|
27
|
+
};
|
|
28
|
+
function createWebSocketTransport(url) {
|
|
29
|
+
const ws = new WebSocket(url);
|
|
30
|
+
const transport = {
|
|
31
|
+
get readyState() {
|
|
32
|
+
return ws.readyState;
|
|
33
|
+
},
|
|
34
|
+
send(data) {
|
|
35
|
+
ws.send(data);
|
|
36
|
+
},
|
|
37
|
+
close(code, reason) {
|
|
38
|
+
ws.close(code, reason);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
ws.onopen = () => transport.onopen?.();
|
|
42
|
+
ws.onmessage = (event) => transport.onmessage?.(event.data);
|
|
43
|
+
ws.onclose = (event) => transport.onclose?.(event.code, event.reason);
|
|
44
|
+
ws.onerror = (event) => transport.onerror?.(event);
|
|
45
|
+
return transport;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/time-sync.ts
|
|
20
49
|
function calculateTimeSync(clientSendTime, clientReceiveTime, serverTime) {
|
|
21
50
|
const rtt = clientReceiveTime - clientSendTime;
|
|
22
51
|
const latency = rtt / 2;
|
|
@@ -43,7 +72,7 @@ function useServerTime(socket) {
|
|
|
43
72
|
}
|
|
44
73
|
}, []);
|
|
45
74
|
useEffect(() => {
|
|
46
|
-
if (!socket || socket.readyState !==
|
|
75
|
+
if (!socket || socket.readyState !== TransportReadyState.OPEN)
|
|
47
76
|
return;
|
|
48
77
|
const sync = () => {
|
|
49
78
|
if (pings.current.size >= MAX_PENDING_PINGS) {
|
|
@@ -158,22 +187,29 @@ function useGameClient(config) {
|
|
|
158
187
|
const connect = useCallback2(() => {
|
|
159
188
|
const cfg = configRef.current;
|
|
160
189
|
intentionalClose.current = false;
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
190
|
+
let transport;
|
|
191
|
+
if (cfg.createTransport) {
|
|
192
|
+
if (cfg.debug)
|
|
193
|
+
console.log("[GameClient] Connecting via custom transport");
|
|
194
|
+
transport = cfg.createTransport();
|
|
195
|
+
} else {
|
|
196
|
+
const wsUrl = resolveWebSocketUrl({ url: cfg.url, wsPort: cfg.wsPort }, typeof window !== "undefined" ? window.location : null);
|
|
197
|
+
if (!wsUrl)
|
|
198
|
+
return;
|
|
199
|
+
if (cfg.debug)
|
|
200
|
+
console.log(`[GameClient] Connecting to ${wsUrl}`);
|
|
201
|
+
transport = createWebSocketTransport(wsUrl);
|
|
202
|
+
}
|
|
203
|
+
socketRef.current = transport;
|
|
166
204
|
setStatus("connecting");
|
|
167
|
-
|
|
168
|
-
socketRef.current = ws;
|
|
169
|
-
ws.onopen = () => {
|
|
205
|
+
transport.onopen = () => {
|
|
170
206
|
const currentCfg = configRef.current;
|
|
171
207
|
setStatus("connected");
|
|
172
208
|
reconnectAttempts.current = 0;
|
|
173
209
|
currentCfg.onConnect?.();
|
|
174
210
|
const secret = resolveSessionSecret(typeof localStorage !== "undefined" ? localStorage : null);
|
|
175
211
|
try {
|
|
176
|
-
|
|
212
|
+
transport.send(JSON.stringify({
|
|
177
213
|
type: MessageTypes3.JOIN,
|
|
178
214
|
payload: {
|
|
179
215
|
name: currentCfg.name || "Player",
|
|
@@ -186,10 +222,10 @@ function useGameClient(config) {
|
|
|
186
222
|
console.error("[GameClient] Failed to send JOIN:", e);
|
|
187
223
|
}
|
|
188
224
|
};
|
|
189
|
-
|
|
225
|
+
transport.onmessage = (data) => {
|
|
190
226
|
let msg;
|
|
191
227
|
try {
|
|
192
|
-
msg = JSON.parse(
|
|
228
|
+
msg = JSON.parse(data);
|
|
193
229
|
} catch (e) {
|
|
194
230
|
console.error("Failed to parse message", e);
|
|
195
231
|
return;
|
|
@@ -211,12 +247,12 @@ function useGameClient(config) {
|
|
|
211
247
|
}
|
|
212
248
|
}
|
|
213
249
|
};
|
|
214
|
-
|
|
250
|
+
transport.onclose = (code) => {
|
|
215
251
|
setStatus("disconnected");
|
|
216
252
|
configRef.current.onDisconnect?.();
|
|
217
253
|
if (!shouldReconnect({
|
|
218
254
|
intentionalClose: intentionalClose.current,
|
|
219
|
-
closeCode:
|
|
255
|
+
closeCode: code,
|
|
220
256
|
attempts: reconnectAttempts.current,
|
|
221
257
|
maxRetries
|
|
222
258
|
}))
|
|
@@ -229,7 +265,7 @@ function useGameClient(config) {
|
|
|
229
265
|
connect();
|
|
230
266
|
}, delay);
|
|
231
267
|
};
|
|
232
|
-
|
|
268
|
+
transport.onerror = (e) => {
|
|
233
269
|
if (configRef.current.debug)
|
|
234
270
|
console.error("[GameClient] Error", e);
|
|
235
271
|
setStatus("error");
|
|
@@ -264,7 +300,7 @@ function useGameClient(config) {
|
|
|
264
300
|
}, [disconnect, connect]);
|
|
265
301
|
const sendAction = useCallback2((action) => {
|
|
266
302
|
dispatchLocal(action);
|
|
267
|
-
if (socketRef.current?.readyState ===
|
|
303
|
+
if (socketRef.current?.readyState === TransportReadyState.OPEN) {
|
|
268
304
|
socketRef.current.send(JSON.stringify({
|
|
269
305
|
type: MessageTypes3.ACTION,
|
|
270
306
|
payload: action
|
|
@@ -282,6 +318,107 @@ function useGameClient(config) {
|
|
|
282
318
|
reconnect
|
|
283
319
|
};
|
|
284
320
|
}
|
|
321
|
+
// src/relay-protocol.ts
|
|
322
|
+
var RelayMessageTypes = {
|
|
323
|
+
CREATE_ROOM: "CREATE_ROOM",
|
|
324
|
+
ROOM_CREATED: "ROOM_CREATED",
|
|
325
|
+
JOIN_ROOM: "JOIN_ROOM",
|
|
326
|
+
ROOM_JOINED: "ROOM_JOINED",
|
|
327
|
+
PEER_JOINED: "PEER_JOINED",
|
|
328
|
+
PEER_LEFT: "PEER_LEFT",
|
|
329
|
+
DATA: "DATA",
|
|
330
|
+
ERROR: "ERROR"
|
|
331
|
+
};
|
|
332
|
+
var RelayErrorCodes = {
|
|
333
|
+
ROOM_NOT_FOUND: "ROOM_NOT_FOUND",
|
|
334
|
+
ROOM_EXISTS: "ROOM_EXISTS",
|
|
335
|
+
ROOM_FULL: "ROOM_FULL",
|
|
336
|
+
NOT_IN_ROOM: "NOT_IN_ROOM",
|
|
337
|
+
MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE",
|
|
338
|
+
MALFORMED: "MALFORMED"
|
|
339
|
+
};
|
|
340
|
+
function relayRoomUrl(url, roomId) {
|
|
341
|
+
const trimmed = url.replace(/\/+$/, "");
|
|
342
|
+
const [base, query] = trimmed.split("?", 2);
|
|
343
|
+
const path = `${base}/r/${encodeURIComponent(roomId)}`;
|
|
344
|
+
return query ? `${path}?${query}` : path;
|
|
345
|
+
}
|
|
346
|
+
// src/relay-transport.ts
|
|
347
|
+
var POLICY_CLOSE_CODE = 1008;
|
|
348
|
+
|
|
349
|
+
class RelayClientTransport {
|
|
350
|
+
ws;
|
|
351
|
+
roomId;
|
|
352
|
+
state = TransportReadyState.CONNECTING;
|
|
353
|
+
pendingCloseCode = null;
|
|
354
|
+
onopen;
|
|
355
|
+
onmessage;
|
|
356
|
+
onclose;
|
|
357
|
+
onerror;
|
|
358
|
+
constructor(options) {
|
|
359
|
+
this.roomId = options.roomId;
|
|
360
|
+
this.ws = new WebSocket(relayRoomUrl(options.url, options.roomId));
|
|
361
|
+
this.ws.onopen = () => {
|
|
362
|
+
this.ws.send(JSON.stringify({
|
|
363
|
+
type: RelayMessageTypes.JOIN_ROOM,
|
|
364
|
+
roomId: this.roomId
|
|
365
|
+
}));
|
|
366
|
+
};
|
|
367
|
+
this.ws.onmessage = (event) => {
|
|
368
|
+
let msg;
|
|
369
|
+
try {
|
|
370
|
+
msg = JSON.parse(event.data);
|
|
371
|
+
} catch {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
this.handleRelayMessage(msg);
|
|
375
|
+
};
|
|
376
|
+
this.ws.onclose = (event) => {
|
|
377
|
+
this.state = TransportReadyState.CLOSED;
|
|
378
|
+
const code = this.pendingCloseCode ?? event.code;
|
|
379
|
+
this.onclose?.(code, event.reason);
|
|
380
|
+
};
|
|
381
|
+
this.ws.onerror = (event) => this.onerror?.(event);
|
|
382
|
+
}
|
|
383
|
+
get readyState() {
|
|
384
|
+
return this.state;
|
|
385
|
+
}
|
|
386
|
+
send(data) {
|
|
387
|
+
if (this.state !== TransportReadyState.OPEN)
|
|
388
|
+
return;
|
|
389
|
+
this.ws.send(JSON.stringify({
|
|
390
|
+
type: RelayMessageTypes.DATA,
|
|
391
|
+
roomId: this.roomId,
|
|
392
|
+
data
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
close(code, reason) {
|
|
396
|
+
this.state = TransportReadyState.CLOSING;
|
|
397
|
+
if (code !== undefined && (code === 1000 || code >= 3000 && code <= 4999)) {
|
|
398
|
+
this.ws.close(code, reason);
|
|
399
|
+
} else {
|
|
400
|
+
this.ws.close();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
handleRelayMessage(msg) {
|
|
404
|
+
switch (msg.type) {
|
|
405
|
+
case RelayMessageTypes.ROOM_JOINED:
|
|
406
|
+
this.state = TransportReadyState.OPEN;
|
|
407
|
+
this.onopen?.();
|
|
408
|
+
break;
|
|
409
|
+
case RelayMessageTypes.DATA:
|
|
410
|
+
this.onmessage?.(msg.data);
|
|
411
|
+
break;
|
|
412
|
+
case RelayMessageTypes.ERROR:
|
|
413
|
+
this.pendingCloseCode = POLICY_CLOSE_CODE;
|
|
414
|
+
this.ws.close();
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function createRelayTransport(options) {
|
|
420
|
+
return () => new RelayClientTransport(options);
|
|
421
|
+
}
|
|
285
422
|
// src/assets.ts
|
|
286
423
|
import { useState as useState3, useEffect as useEffect3, useRef as useRef3 } from "react";
|
|
287
424
|
import { MessageTypes as MessageTypes4 } from "@couch-kit/core";
|
|
@@ -424,8 +561,15 @@ export {
|
|
|
424
561
|
shouldReconnect,
|
|
425
562
|
resolveWebSocketUrl,
|
|
426
563
|
resolveSessionSecret,
|
|
564
|
+
relayRoomUrl,
|
|
427
565
|
interpretHostMessage,
|
|
566
|
+
createWebSocketTransport,
|
|
567
|
+
createRelayTransport,
|
|
428
568
|
computeBackoffDelay,
|
|
429
569
|
calculateTimeSync,
|
|
430
|
-
|
|
570
|
+
TransportReadyState,
|
|
571
|
+
SESSION_SECRET_KEY,
|
|
572
|
+
RelayMessageTypes,
|
|
573
|
+
RelayErrorCodes,
|
|
574
|
+
RelayClientTransport
|
|
431
575
|
};
|
package/lib/client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type IGameState, type IAction } from "@couch-kit/core";
|
|
2
|
+
import { type CreateClientTransport } from "./transport";
|
|
2
3
|
export interface ClientConfig<S extends IGameState, A extends IAction> {
|
|
3
4
|
url?: string;
|
|
4
5
|
wsPort?: number;
|
|
@@ -15,6 +16,13 @@ export interface ClientConfig<S extends IGameState, A extends IAction> {
|
|
|
15
16
|
onConnect?: () => void;
|
|
16
17
|
onDisconnect?: () => void;
|
|
17
18
|
debug?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Provide a custom transport factory (e.g. a cross-network relay). When
|
|
21
|
+
* omitted, the client connects over the default LAN WebSocket derived from
|
|
22
|
+
* `url`/`wsPort`. Called on every (re)connect, so it must return a fresh,
|
|
23
|
+
* already-connecting transport each time.
|
|
24
|
+
*/
|
|
25
|
+
createTransport?: CreateClientTransport;
|
|
18
26
|
}
|
|
19
27
|
/**
|
|
20
28
|
* React hook that connects the web controller to the TV host via WebSocket.
|
|
@@ -35,7 +43,7 @@ export interface ClientConfig<S extends IGameState, A extends IAction> {
|
|
|
35
43
|
* ```
|
|
36
44
|
*/
|
|
37
45
|
export declare function useGameClient<S extends IGameState, A extends IAction>(config: ClientConfig<S, A>): {
|
|
38
|
-
status: "
|
|
46
|
+
status: "connected" | "connecting" | "disconnected" | "error";
|
|
39
47
|
state: S;
|
|
40
48
|
playerId: string | null;
|
|
41
49
|
sendAction: (action: A) => void;
|
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;AASzB,OAAO,EAIL,KAAK,qBAAqB,EAC3B,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACnE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACpC,YAAY,EAAE,CAAC,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mEAAmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,qBAAqB,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EACnE,MAAM,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC;;;;yBA6Mc,CAAC;;IAqBvC,8EAA8E;;IAE9E,0EAA0E;;IAE1E,+EAA+E;;EAGlF"}
|
package/lib/index.d.ts
CHANGED
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol for the cross-network **relay** transport.
|
|
3
|
+
*
|
|
4
|
+
* The relay is a small, game-agnostic WebSocket server that routes the existing
|
|
5
|
+
* Couch Kit JSON protocol between a browser **display host** (which owns the
|
|
6
|
+
* authoritative `GameHostRuntime`) and one or more **phones**, in a star
|
|
7
|
+
* topology keyed by a room id. The relay never inspects game payloads — it only
|
|
8
|
+
* tracks room membership and routes envelopes.
|
|
9
|
+
*
|
|
10
|
+
* These types are shared by the relay client transport (this package), the
|
|
11
|
+
* reference display host, and the relay server so all three agree on the wire
|
|
12
|
+
* format.
|
|
13
|
+
*/
|
|
14
|
+
/** Envelope/control message discriminators exchanged with the relay server. */
|
|
15
|
+
export declare const RelayMessageTypes: {
|
|
16
|
+
/** Display → relay: create and host a room under `roomId`. */
|
|
17
|
+
readonly CREATE_ROOM: "CREATE_ROOM";
|
|
18
|
+
/** Relay → display: room created; includes the display's own `peerId`. */
|
|
19
|
+
readonly ROOM_CREATED: "ROOM_CREATED";
|
|
20
|
+
/** Phone → relay: join an existing room. */
|
|
21
|
+
readonly JOIN_ROOM: "JOIN_ROOM";
|
|
22
|
+
/** Relay → phone: joined; includes the phone's assigned `peerId`. */
|
|
23
|
+
readonly ROOM_JOINED: "ROOM_JOINED";
|
|
24
|
+
/** Relay → display: a phone joined the room. */
|
|
25
|
+
readonly PEER_JOINED: "PEER_JOINED";
|
|
26
|
+
/** Relay → display: a phone left the room. */
|
|
27
|
+
readonly PEER_LEFT: "PEER_LEFT";
|
|
28
|
+
/** Bidirectional: carries an opaque Couch Kit JSON message as `data`. */
|
|
29
|
+
readonly DATA: "DATA";
|
|
30
|
+
/** Relay → client: a protocol/room error. */
|
|
31
|
+
readonly ERROR: "ERROR";
|
|
32
|
+
};
|
|
33
|
+
/** Error codes the relay may report in an {@link RelayErrorMessage}. */
|
|
34
|
+
export declare const RelayErrorCodes: {
|
|
35
|
+
readonly ROOM_NOT_FOUND: "ROOM_NOT_FOUND";
|
|
36
|
+
readonly ROOM_EXISTS: "ROOM_EXISTS";
|
|
37
|
+
readonly ROOM_FULL: "ROOM_FULL";
|
|
38
|
+
readonly NOT_IN_ROOM: "NOT_IN_ROOM";
|
|
39
|
+
readonly MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE";
|
|
40
|
+
readonly MALFORMED: "MALFORMED";
|
|
41
|
+
};
|
|
42
|
+
export type RelayErrorCode = (typeof RelayErrorCodes)[keyof typeof RelayErrorCodes];
|
|
43
|
+
/** Display → relay: create and host a room. */
|
|
44
|
+
export interface CreateRoomMessage {
|
|
45
|
+
type: typeof RelayMessageTypes.CREATE_ROOM;
|
|
46
|
+
roomId: string;
|
|
47
|
+
}
|
|
48
|
+
/** Relay → display: room created; `peerId` is the display's own id. */
|
|
49
|
+
export interface RoomCreatedMessage {
|
|
50
|
+
type: typeof RelayMessageTypes.ROOM_CREATED;
|
|
51
|
+
roomId: string;
|
|
52
|
+
peerId: string;
|
|
53
|
+
}
|
|
54
|
+
/** Phone → relay: join an existing room. */
|
|
55
|
+
export interface JoinRoomMessage {
|
|
56
|
+
type: typeof RelayMessageTypes.JOIN_ROOM;
|
|
57
|
+
roomId: string;
|
|
58
|
+
}
|
|
59
|
+
/** Relay → phone: joined; `peerId` is the phone's relay-assigned id. */
|
|
60
|
+
export interface RoomJoinedMessage {
|
|
61
|
+
type: typeof RelayMessageTypes.ROOM_JOINED;
|
|
62
|
+
roomId: string;
|
|
63
|
+
peerId: string;
|
|
64
|
+
}
|
|
65
|
+
/** Relay → display: a phone joined; `peerId` becomes its connection id. */
|
|
66
|
+
export interface PeerJoinedMessage {
|
|
67
|
+
type: typeof RelayMessageTypes.PEER_JOINED;
|
|
68
|
+
roomId: string;
|
|
69
|
+
peerId: string;
|
|
70
|
+
}
|
|
71
|
+
/** Relay → display: a phone left. */
|
|
72
|
+
export interface PeerLeftMessage {
|
|
73
|
+
type: typeof RelayMessageTypes.PEER_LEFT;
|
|
74
|
+
roomId: string;
|
|
75
|
+
peerId: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Bidirectional data envelope carrying an opaque Couch Kit JSON message.
|
|
79
|
+
*
|
|
80
|
+
* - Phone → relay: `data` only; the relay injects `from` and routes to the host.
|
|
81
|
+
* - Relay → display: `from` = sending phone's `peerId`.
|
|
82
|
+
* - Display → relay: `to` present = unicast to that phone; `to` absent =
|
|
83
|
+
* broadcast to every phone in the room.
|
|
84
|
+
* - Relay → phone: `data` only.
|
|
85
|
+
*
|
|
86
|
+
* `data` is the already-serialized Couch Kit message string, so the relay
|
|
87
|
+
* treats it as opaque.
|
|
88
|
+
*/
|
|
89
|
+
export interface DataMessage {
|
|
90
|
+
type: typeof RelayMessageTypes.DATA;
|
|
91
|
+
roomId: string;
|
|
92
|
+
from?: string;
|
|
93
|
+
to?: string;
|
|
94
|
+
data: string;
|
|
95
|
+
}
|
|
96
|
+
/** Relay → client: a protocol/room error. */
|
|
97
|
+
export interface RelayErrorMessage {
|
|
98
|
+
type: typeof RelayMessageTypes.ERROR;
|
|
99
|
+
code: RelayErrorCode;
|
|
100
|
+
message: string;
|
|
101
|
+
}
|
|
102
|
+
/** Any message a client may send to the relay. */
|
|
103
|
+
export type RelayClientMessage = CreateRoomMessage | JoinRoomMessage | DataMessage;
|
|
104
|
+
/** Any message the relay may send to a client. */
|
|
105
|
+
export type RelayServerMessage = RoomCreatedMessage | RoomJoinedMessage | PeerJoinedMessage | PeerLeftMessage | DataMessage | RelayErrorMessage;
|
|
106
|
+
/** Every relay wire message. */
|
|
107
|
+
export type RelayMessage = RelayClientMessage | RelayServerMessage;
|
|
108
|
+
/**
|
|
109
|
+
* Builds the socket URL for a room: the configured relay URL with `/r/<roomId>`
|
|
110
|
+
* appended.
|
|
111
|
+
*
|
|
112
|
+
* The room has to be in the URL, not just in the `CREATE_ROOM` / `JOIN_ROOM`
|
|
113
|
+
* message, so that a relay can route the connection before reading any frames —
|
|
114
|
+
* which is exactly what a per-room Cloudflare Durable Object must do. Relays
|
|
115
|
+
* that keep every room in one process (the Bun reference server) ignore the
|
|
116
|
+
* path, so this is safe to send to either.
|
|
117
|
+
*
|
|
118
|
+
* @param url - Base relay URL, e.g. `wss://relay.example.com`.
|
|
119
|
+
* @param roomId - Room code to address.
|
|
120
|
+
*/
|
|
121
|
+
export declare function relayRoomUrl(url: string, roomId: string): string;
|
|
122
|
+
//# sourceMappingURL=relay-protocol.d.ts.map
|
|
@@ -0,0 +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;CACd,CAAC;AAEX,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,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,GAC1B,iBAAiB,GACjB,eAAe,GACf,WAAW,CAAC;AAEhB,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;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAKhE"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type ClientTransport, type CreateClientTransport } from "./transport";
|
|
2
|
+
/** Options for {@link createRelayTransport} / {@link RelayClientTransport}. */
|
|
3
|
+
export interface RelayTransportOptions {
|
|
4
|
+
/** WebSocket URL of the relay server (e.g. `wss://relay.example.com`). */
|
|
5
|
+
url: string;
|
|
6
|
+
/** Room code identifying the display host to connect to. */
|
|
7
|
+
roomId: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A {@link ClientTransport} that reaches the display host through the
|
|
11
|
+
* cross-network relay instead of a direct LAN WebSocket.
|
|
12
|
+
*
|
|
13
|
+
* It opens a WebSocket to the relay, joins `roomId`, and then presents the same
|
|
14
|
+
* open/message/close surface as the default transport — wrapping each outbound
|
|
15
|
+
* game message in a relay `DATA` envelope and unwrapping inbound ones. Room-level
|
|
16
|
+
* failures (unknown/full room) are surfaced as a terminal close so the client's
|
|
17
|
+
* reconnect logic does not hammer a room that will never accept it.
|
|
18
|
+
*/
|
|
19
|
+
export declare class RelayClientTransport implements ClientTransport {
|
|
20
|
+
private readonly ws;
|
|
21
|
+
private readonly roomId;
|
|
22
|
+
private state;
|
|
23
|
+
/** When set, the code reported to `onclose` instead of the raw socket code. */
|
|
24
|
+
private pendingCloseCode;
|
|
25
|
+
onopen?: () => void;
|
|
26
|
+
onmessage?: (data: string) => void;
|
|
27
|
+
onclose?: (code: number, reason?: string) => void;
|
|
28
|
+
onerror?: (error?: unknown) => void;
|
|
29
|
+
constructor(options: RelayTransportOptions);
|
|
30
|
+
get readyState(): number;
|
|
31
|
+
send(data: string): void;
|
|
32
|
+
close(code?: number, reason?: string): void;
|
|
33
|
+
private handleRelayMessage;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build a {@link CreateClientTransport} factory for `useGameClient` that
|
|
37
|
+
* connects through the relay.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* useGameClient({
|
|
42
|
+
* reducer,
|
|
43
|
+
* initialState,
|
|
44
|
+
* createTransport: createRelayTransport({ url: RELAY_URL, roomId }),
|
|
45
|
+
* });
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function createRelayTransport(options: RelayTransportOptions): CreateClientTransport;
|
|
49
|
+
//# sourceMappingURL=relay-transport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-transport.d.ts","sourceRoot":"","sources":["../src/relay-transport.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC3B,MAAM,aAAa,CAAC;AAOrB,+EAA+E;AAC/E,MAAM,WAAW,qBAAqB;IACpC,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAC;IACZ,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;CAChB;AASD;;;;;;;;;GASG;AACH,qBAAa,oBAAqB,YAAW,eAAe;IAC1D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,KAAK,CAA0C;IACvD,+EAA+E;IAC/E,OAAO,CAAC,gBAAgB,CAAuB;IAE/C,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAEpC,YAAY,OAAO,EAAE,qBAAqB,EAgCzC;IAED,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CASvB;IAED,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAQ1C;IAED,OAAO,CAAC,kBAAkB;CAkB3B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,qBAAqB,GAC7B,qBAAqB,CAEvB"}
|
package/lib/time-sync.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ClientTransport } from "./transport";
|
|
1
2
|
/**
|
|
2
3
|
* Computes the clock offset and round-trip time between client and server.
|
|
3
4
|
*
|
|
@@ -24,10 +25,10 @@ export declare function calculateTimeSync(clientSendTime: number, clientReceiveT
|
|
|
24
25
|
* called directly. Access `getServerTime()` and `rtt` from the
|
|
25
26
|
* `useGameClient` return value instead.
|
|
26
27
|
*
|
|
27
|
-
* @param socket - The active
|
|
28
|
+
* @param socket - The active client transport (or `null` if not yet connected).
|
|
28
29
|
* @returns An object with `getServerTime` (returns estimated server time), `rtt`, and `handlePong` (callback for PONG messages).
|
|
29
30
|
*/
|
|
30
|
-
export declare function useServerTime(socket:
|
|
31
|
+
export declare function useServerTime(socket: ClientTransport | null): {
|
|
31
32
|
getServerTime: () => number;
|
|
32
33
|
rtt: number;
|
|
33
34
|
handlePong: (payload: {
|
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":"AAOA,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAOxE;;;;;;;;;;;GAWG;AAEH,wBAAgB,iBAAiB,CAC/B,cAAc,EAAE,MAAM,EACtB,iBAAiB,EAAE,MAAM,EACzB,UAAU,EAAE,MAAM;;;EAQnB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI;;;0BAgB9C;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE;EAgDtE"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport abstraction for the web game client.
|
|
3
|
+
*
|
|
4
|
+
* The client hook (`useGameClient`) speaks to the host through a small
|
|
5
|
+
* WebSocket-shaped interface rather than a concrete `WebSocket`. This lets the
|
|
6
|
+
* default LAN WebSocket transport and alternative transports (e.g. a
|
|
7
|
+
* cross-network relay) be swapped in without touching the hook's JOIN handshake,
|
|
8
|
+
* reconnect/backoff, session-recovery, or state-hydration logic.
|
|
9
|
+
*
|
|
10
|
+
* The interface intentionally mirrors the subset of the `WebSocket` API the
|
|
11
|
+
* client relies on, so the default implementation is a thin wrapper and the
|
|
12
|
+
* behavior of the LAN path is unchanged.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Ready-state constants mirroring the `WebSocket` readyState values. A transport
|
|
16
|
+
* reports these so the client can gate sends on an open connection without
|
|
17
|
+
* depending on the global `WebSocket` constructor (which may be absent in some
|
|
18
|
+
* runtimes/tests).
|
|
19
|
+
*/
|
|
20
|
+
export declare const TransportReadyState: {
|
|
21
|
+
readonly CONNECTING: 0;
|
|
22
|
+
readonly OPEN: 1;
|
|
23
|
+
readonly CLOSING: 2;
|
|
24
|
+
readonly CLOSED: 3;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The minimal message-transport surface the client requires.
|
|
28
|
+
*
|
|
29
|
+
* Implementations deliver and receive already-serialized JSON strings. Event
|
|
30
|
+
* callbacks are assigned by the client after construction, so a transport must
|
|
31
|
+
* begin connecting on construction and invoke `onopen` once ready.
|
|
32
|
+
*/
|
|
33
|
+
export interface ClientTransport {
|
|
34
|
+
/** Current connection state; compare against {@link TransportReadyState}. */
|
|
35
|
+
readonly readyState: number;
|
|
36
|
+
/** Send a serialized JSON message to the host. */
|
|
37
|
+
send(data: string): void;
|
|
38
|
+
/**
|
|
39
|
+
* Close the connection. `code`/`reason` follow WebSocket close semantics so
|
|
40
|
+
* the client's recoverable-vs-terminal reconnect logic keeps working
|
|
41
|
+
* (1008 policy / 1011 internal error are treated as terminal).
|
|
42
|
+
*/
|
|
43
|
+
close(code?: number, reason?: string): void;
|
|
44
|
+
/** Invoked once the connection is open and ready to send. */
|
|
45
|
+
onopen?: () => void;
|
|
46
|
+
/** Invoked with the raw JSON string of each inbound host message. */
|
|
47
|
+
onmessage?: (data: string) => void;
|
|
48
|
+
/** Invoked when the connection closes, with a WebSocket-compatible code. */
|
|
49
|
+
onclose?: (code: number, reason?: string) => void;
|
|
50
|
+
/** Invoked on a transport-level error. */
|
|
51
|
+
onerror?: (error?: unknown) => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Factory that creates a fresh {@link ClientTransport}. `useGameClient` calls
|
|
55
|
+
* this each time it (re)connects, so implementations must return a new,
|
|
56
|
+
* already-connecting transport on every call.
|
|
57
|
+
*/
|
|
58
|
+
export type CreateClientTransport = () => ClientTransport;
|
|
59
|
+
/**
|
|
60
|
+
* The default LAN transport: a thin wrapper around the browser `WebSocket` that
|
|
61
|
+
* adapts its event objects to the normalized {@link ClientTransport} callbacks
|
|
62
|
+
* (`onmessage(data)` instead of `event.data`; `onclose(code)` instead of
|
|
63
|
+
* `event.code`). Behavior is identical to using `WebSocket` directly.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createWebSocketTransport(url: string): ClientTransport;
|
|
66
|
+
//# sourceMappingURL=transport.d.ts.map
|
|
@@ -0,0 +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;CACrC;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.10.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"provenance": true
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
"build": "bun build ./src/index.ts --outdir ./dist --target browser --external react --external @couch-kit/core && tsc -p tsconfig.build.json",
|
|
46
46
|
"prepublishOnly": "bun run build",
|
|
47
47
|
"test": "bun test",
|
|
48
|
-
"lint": "eslint src/",
|
|
49
48
|
"typecheck": "tsc --noEmit",
|
|
50
49
|
"clean": "rm -rf dist lib"
|
|
51
50
|
},
|
|
@@ -54,7 +53,7 @@
|
|
|
54
53
|
},
|
|
55
54
|
"devDependencies": {
|
|
56
55
|
"react": "^19.0.0",
|
|
57
|
-
"typescript": "^
|
|
56
|
+
"typescript": "^7.0.0"
|
|
58
57
|
},
|
|
59
58
|
"peerDependencies": {
|
|
60
59
|
"react": ">=18.0.0"
|
package/src/client.ts
CHANGED
|
@@ -19,6 +19,12 @@ import {
|
|
|
19
19
|
resolveSessionSecret,
|
|
20
20
|
interpretHostMessage,
|
|
21
21
|
} from "./connection";
|
|
22
|
+
import {
|
|
23
|
+
TransportReadyState,
|
|
24
|
+
createWebSocketTransport,
|
|
25
|
+
type ClientTransport,
|
|
26
|
+
type CreateClientTransport,
|
|
27
|
+
} from "./transport";
|
|
22
28
|
|
|
23
29
|
export interface ClientConfig<S extends IGameState, A extends IAction> {
|
|
24
30
|
url?: string; // Full WebSocket URL (overrides auto-detection)
|
|
@@ -36,6 +42,13 @@ export interface ClientConfig<S extends IGameState, A extends IAction> {
|
|
|
36
42
|
onConnect?: () => void;
|
|
37
43
|
onDisconnect?: () => void;
|
|
38
44
|
debug?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Provide a custom transport factory (e.g. a cross-network relay). When
|
|
47
|
+
* omitted, the client connects over the default LAN WebSocket derived from
|
|
48
|
+
* `url`/`wsPort`. Called on every (re)connect, so it must return a fresh,
|
|
49
|
+
* already-connecting transport each time.
|
|
50
|
+
*/
|
|
51
|
+
createTransport?: CreateClientTransport;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
/**
|
|
@@ -71,7 +84,7 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
71
84
|
config.initialState,
|
|
72
85
|
);
|
|
73
86
|
|
|
74
|
-
const socketRef = useRef<
|
|
87
|
+
const socketRef = useRef<ClientTransport | null>(null);
|
|
75
88
|
const reconnectAttempts = useRef(0);
|
|
76
89
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
77
90
|
const intentionalClose = useRef(false);
|
|
@@ -98,26 +111,34 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
98
111
|
const cfg = configRef.current;
|
|
99
112
|
intentionalClose.current = false;
|
|
100
113
|
|
|
101
|
-
// 1.
|
|
102
|
-
// If
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
)
|
|
114
|
+
// 1. Build the transport.
|
|
115
|
+
// If a custom transport factory is provided (e.g. a cross-network relay),
|
|
116
|
+
// use it. Otherwise fall back to the default LAN WebSocket:
|
|
117
|
+
// - If an explicit URL is provided, use it.
|
|
118
|
+
// - Otherwise derive the WebSocket URL from window.location, assuming we
|
|
119
|
+
// are served by the Host's static server.
|
|
120
|
+
// Convention: WS port = HTTP port + 2 (e.g., HTTP 8080 -> WS 8082).
|
|
121
|
+
// Port + 1 is skipped to avoid conflicts with Metro bundler (uses 8081).
|
|
122
|
+
let transport: ClientTransport;
|
|
123
|
+
if (cfg.createTransport) {
|
|
124
|
+
if (cfg.debug) console.log("[GameClient] Connecting via custom transport");
|
|
125
|
+
transport = cfg.createTransport();
|
|
126
|
+
} else {
|
|
127
|
+
const wsUrl = resolveWebSocketUrl(
|
|
128
|
+
{ url: cfg.url, wsPort: cfg.wsPort },
|
|
129
|
+
typeof window !== "undefined" ? window.location : null,
|
|
130
|
+
);
|
|
111
131
|
|
|
112
|
-
|
|
132
|
+
if (!wsUrl) return;
|
|
113
133
|
|
|
114
|
-
|
|
115
|
-
|
|
134
|
+
if (cfg.debug) console.log(`[GameClient] Connecting to ${wsUrl}`);
|
|
135
|
+
transport = createWebSocketTransport(wsUrl);
|
|
136
|
+
}
|
|
116
137
|
|
|
117
|
-
|
|
118
|
-
|
|
138
|
+
socketRef.current = transport;
|
|
139
|
+
setStatus("connecting");
|
|
119
140
|
|
|
120
|
-
|
|
141
|
+
transport.onopen = () => {
|
|
121
142
|
const currentCfg = configRef.current;
|
|
122
143
|
setStatus("connected");
|
|
123
144
|
reconnectAttempts.current = 0;
|
|
@@ -130,7 +151,7 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
130
151
|
|
|
131
152
|
// Join with secret
|
|
132
153
|
try {
|
|
133
|
-
|
|
154
|
+
transport.send(
|
|
134
155
|
JSON.stringify({
|
|
135
156
|
type: MessageTypes.JOIN,
|
|
136
157
|
payload: {
|
|
@@ -146,10 +167,10 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
146
167
|
}
|
|
147
168
|
};
|
|
148
169
|
|
|
149
|
-
|
|
170
|
+
transport.onmessage = (data) => {
|
|
150
171
|
let msg: HostMessage;
|
|
151
172
|
try {
|
|
152
|
-
msg = JSON.parse(
|
|
173
|
+
msg = JSON.parse(data) as HostMessage;
|
|
153
174
|
} catch (e) {
|
|
154
175
|
console.error("Failed to parse message", e);
|
|
155
176
|
return;
|
|
@@ -174,7 +195,7 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
174
195
|
}
|
|
175
196
|
};
|
|
176
197
|
|
|
177
|
-
|
|
198
|
+
transport.onclose = (code) => {
|
|
178
199
|
setStatus("disconnected");
|
|
179
200
|
configRef.current.onDisconnect?.();
|
|
180
201
|
|
|
@@ -183,7 +204,7 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
183
204
|
if (
|
|
184
205
|
!shouldReconnect({
|
|
185
206
|
intentionalClose: intentionalClose.current,
|
|
186
|
-
closeCode:
|
|
207
|
+
closeCode: code,
|
|
187
208
|
attempts: reconnectAttempts.current,
|
|
188
209
|
maxRetries,
|
|
189
210
|
})
|
|
@@ -206,12 +227,13 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
206
227
|
}, delay);
|
|
207
228
|
};
|
|
208
229
|
|
|
209
|
-
|
|
230
|
+
transport.onerror = (e) => {
|
|
210
231
|
if (configRef.current.debug) console.error("[GameClient] Error", e);
|
|
211
232
|
setStatus("error");
|
|
212
233
|
};
|
|
213
234
|
// Only re-create the connect function when URL/port actually changes.
|
|
214
|
-
// Config values like name, avatar, callbacks are read
|
|
235
|
+
// Config values like name, avatar, callbacks, and createTransport are read
|
|
236
|
+
// from configRef.
|
|
215
237
|
}, [config.url, config.wsPort, maxRetries, baseDelay, maxDelay]);
|
|
216
238
|
|
|
217
239
|
// Initial Connection
|
|
@@ -258,7 +280,7 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
258
280
|
dispatchLocal(action);
|
|
259
281
|
|
|
260
282
|
// 2. Send to Host
|
|
261
|
-
if (socketRef.current?.readyState ===
|
|
283
|
+
if (socketRef.current?.readyState === TransportReadyState.OPEN) {
|
|
262
284
|
socketRef.current.send(
|
|
263
285
|
JSON.stringify({
|
|
264
286
|
type: MessageTypes.ACTION,
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol for the cross-network **relay** transport.
|
|
3
|
+
*
|
|
4
|
+
* The relay is a small, game-agnostic WebSocket server that routes the existing
|
|
5
|
+
* Couch Kit JSON protocol between a browser **display host** (which owns the
|
|
6
|
+
* authoritative `GameHostRuntime`) and one or more **phones**, in a star
|
|
7
|
+
* topology keyed by a room id. The relay never inspects game payloads — it only
|
|
8
|
+
* tracks room membership and routes envelopes.
|
|
9
|
+
*
|
|
10
|
+
* These types are shared by the relay client transport (this package), the
|
|
11
|
+
* reference display host, and the relay server so all three agree on the wire
|
|
12
|
+
* format.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Envelope/control message discriminators exchanged with the relay server. */
|
|
16
|
+
export const RelayMessageTypes = {
|
|
17
|
+
/** Display → relay: create and host a room under `roomId`. */
|
|
18
|
+
CREATE_ROOM: "CREATE_ROOM",
|
|
19
|
+
/** Relay → display: room created; includes the display's own `peerId`. */
|
|
20
|
+
ROOM_CREATED: "ROOM_CREATED",
|
|
21
|
+
/** Phone → relay: join an existing room. */
|
|
22
|
+
JOIN_ROOM: "JOIN_ROOM",
|
|
23
|
+
/** Relay → phone: joined; includes the phone's assigned `peerId`. */
|
|
24
|
+
ROOM_JOINED: "ROOM_JOINED",
|
|
25
|
+
/** Relay → display: a phone joined the room. */
|
|
26
|
+
PEER_JOINED: "PEER_JOINED",
|
|
27
|
+
/** Relay → display: a phone left the room. */
|
|
28
|
+
PEER_LEFT: "PEER_LEFT",
|
|
29
|
+
/** Bidirectional: carries an opaque Couch Kit JSON message as `data`. */
|
|
30
|
+
DATA: "DATA",
|
|
31
|
+
/** Relay → client: a protocol/room error. */
|
|
32
|
+
ERROR: "ERROR",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
/** Error codes the relay may report in an {@link RelayErrorMessage}. */
|
|
36
|
+
export const RelayErrorCodes = {
|
|
37
|
+
ROOM_NOT_FOUND: "ROOM_NOT_FOUND",
|
|
38
|
+
ROOM_EXISTS: "ROOM_EXISTS",
|
|
39
|
+
ROOM_FULL: "ROOM_FULL",
|
|
40
|
+
NOT_IN_ROOM: "NOT_IN_ROOM",
|
|
41
|
+
MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE",
|
|
42
|
+
MALFORMED: "MALFORMED",
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
export type RelayErrorCode =
|
|
46
|
+
(typeof RelayErrorCodes)[keyof typeof RelayErrorCodes];
|
|
47
|
+
|
|
48
|
+
/** Display → relay: create and host a room. */
|
|
49
|
+
export interface CreateRoomMessage {
|
|
50
|
+
type: typeof RelayMessageTypes.CREATE_ROOM;
|
|
51
|
+
roomId: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Relay → display: room created; `peerId` is the display's own id. */
|
|
55
|
+
export interface RoomCreatedMessage {
|
|
56
|
+
type: typeof RelayMessageTypes.ROOM_CREATED;
|
|
57
|
+
roomId: string;
|
|
58
|
+
peerId: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Phone → relay: join an existing room. */
|
|
62
|
+
export interface JoinRoomMessage {
|
|
63
|
+
type: typeof RelayMessageTypes.JOIN_ROOM;
|
|
64
|
+
roomId: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Relay → phone: joined; `peerId` is the phone's relay-assigned id. */
|
|
68
|
+
export interface RoomJoinedMessage {
|
|
69
|
+
type: typeof RelayMessageTypes.ROOM_JOINED;
|
|
70
|
+
roomId: string;
|
|
71
|
+
peerId: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Relay → display: a phone joined; `peerId` becomes its connection id. */
|
|
75
|
+
export interface PeerJoinedMessage {
|
|
76
|
+
type: typeof RelayMessageTypes.PEER_JOINED;
|
|
77
|
+
roomId: string;
|
|
78
|
+
peerId: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Relay → display: a phone left. */
|
|
82
|
+
export interface PeerLeftMessage {
|
|
83
|
+
type: typeof RelayMessageTypes.PEER_LEFT;
|
|
84
|
+
roomId: string;
|
|
85
|
+
peerId: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Bidirectional data envelope carrying an opaque Couch Kit JSON message.
|
|
90
|
+
*
|
|
91
|
+
* - Phone → relay: `data` only; the relay injects `from` and routes to the host.
|
|
92
|
+
* - Relay → display: `from` = sending phone's `peerId`.
|
|
93
|
+
* - Display → relay: `to` present = unicast to that phone; `to` absent =
|
|
94
|
+
* broadcast to every phone in the room.
|
|
95
|
+
* - Relay → phone: `data` only.
|
|
96
|
+
*
|
|
97
|
+
* `data` is the already-serialized Couch Kit message string, so the relay
|
|
98
|
+
* treats it as opaque.
|
|
99
|
+
*/
|
|
100
|
+
export interface DataMessage {
|
|
101
|
+
type: typeof RelayMessageTypes.DATA;
|
|
102
|
+
roomId: string;
|
|
103
|
+
from?: string;
|
|
104
|
+
to?: string;
|
|
105
|
+
data: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Relay → client: a protocol/room error. */
|
|
109
|
+
export interface RelayErrorMessage {
|
|
110
|
+
type: typeof RelayMessageTypes.ERROR;
|
|
111
|
+
code: RelayErrorCode;
|
|
112
|
+
message: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Any message a client may send to the relay. */
|
|
116
|
+
export type RelayClientMessage =
|
|
117
|
+
| CreateRoomMessage
|
|
118
|
+
| JoinRoomMessage
|
|
119
|
+
| DataMessage;
|
|
120
|
+
|
|
121
|
+
/** Any message the relay may send to a client. */
|
|
122
|
+
export type RelayServerMessage =
|
|
123
|
+
| RoomCreatedMessage
|
|
124
|
+
| RoomJoinedMessage
|
|
125
|
+
| PeerJoinedMessage
|
|
126
|
+
| PeerLeftMessage
|
|
127
|
+
| DataMessage
|
|
128
|
+
| RelayErrorMessage;
|
|
129
|
+
|
|
130
|
+
/** Every relay wire message. */
|
|
131
|
+
export type RelayMessage = RelayClientMessage | RelayServerMessage;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Builds the socket URL for a room: the configured relay URL with `/r/<roomId>`
|
|
135
|
+
* appended.
|
|
136
|
+
*
|
|
137
|
+
* The room has to be in the URL, not just in the `CREATE_ROOM` / `JOIN_ROOM`
|
|
138
|
+
* message, so that a relay can route the connection before reading any frames —
|
|
139
|
+
* which is exactly what a per-room Cloudflare Durable Object must do. Relays
|
|
140
|
+
* that keep every room in one process (the Bun reference server) ignore the
|
|
141
|
+
* path, so this is safe to send to either.
|
|
142
|
+
*
|
|
143
|
+
* @param url - Base relay URL, e.g. `wss://relay.example.com`.
|
|
144
|
+
* @param roomId - Room code to address.
|
|
145
|
+
*/
|
|
146
|
+
export function relayRoomUrl(url: string, roomId: string): string {
|
|
147
|
+
const trimmed = url.replace(/\/+$/, "");
|
|
148
|
+
const [base, query] = trimmed.split("?", 2);
|
|
149
|
+
const path = `${base}/r/${encodeURIComponent(roomId)}`;
|
|
150
|
+
return query ? `${path}?${query}` : path;
|
|
151
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TransportReadyState,
|
|
3
|
+
type ClientTransport,
|
|
4
|
+
type CreateClientTransport,
|
|
5
|
+
} from "./transport";
|
|
6
|
+
import {
|
|
7
|
+
RelayMessageTypes,
|
|
8
|
+
relayRoomUrl,
|
|
9
|
+
type RelayServerMessage,
|
|
10
|
+
} from "./relay-protocol";
|
|
11
|
+
|
|
12
|
+
/** Options for {@link createRelayTransport} / {@link RelayClientTransport}. */
|
|
13
|
+
export interface RelayTransportOptions {
|
|
14
|
+
/** WebSocket URL of the relay server (e.g. `wss://relay.example.com`). */
|
|
15
|
+
url: string;
|
|
16
|
+
/** Room code identifying the display host to connect to. */
|
|
17
|
+
roomId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Terminal close code (WebSocket policy violation). `close()` can't be called
|
|
22
|
+
* with reserved codes like 1008, so the transport reports this to the client
|
|
23
|
+
* directly via `onclose` while closing the socket with a permitted code.
|
|
24
|
+
*/
|
|
25
|
+
const POLICY_CLOSE_CODE = 1008;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A {@link ClientTransport} that reaches the display host through the
|
|
29
|
+
* cross-network relay instead of a direct LAN WebSocket.
|
|
30
|
+
*
|
|
31
|
+
* It opens a WebSocket to the relay, joins `roomId`, and then presents the same
|
|
32
|
+
* open/message/close surface as the default transport — wrapping each outbound
|
|
33
|
+
* game message in a relay `DATA` envelope and unwrapping inbound ones. Room-level
|
|
34
|
+
* failures (unknown/full room) are surfaced as a terminal close so the client's
|
|
35
|
+
* reconnect logic does not hammer a room that will never accept it.
|
|
36
|
+
*/
|
|
37
|
+
export class RelayClientTransport implements ClientTransport {
|
|
38
|
+
private readonly ws: WebSocket;
|
|
39
|
+
private readonly roomId: string;
|
|
40
|
+
private state: number = TransportReadyState.CONNECTING;
|
|
41
|
+
/** When set, the code reported to `onclose` instead of the raw socket code. */
|
|
42
|
+
private pendingCloseCode: number | null = null;
|
|
43
|
+
|
|
44
|
+
onopen?: () => void;
|
|
45
|
+
onmessage?: (data: string) => void;
|
|
46
|
+
onclose?: (code: number, reason?: string) => void;
|
|
47
|
+
onerror?: (error?: unknown) => void;
|
|
48
|
+
|
|
49
|
+
constructor(options: RelayTransportOptions) {
|
|
50
|
+
this.roomId = options.roomId;
|
|
51
|
+
this.ws = new WebSocket(relayRoomUrl(options.url, options.roomId));
|
|
52
|
+
|
|
53
|
+
this.ws.onopen = () => {
|
|
54
|
+
// The relay socket is up; ask to join the room. The transport is not yet
|
|
55
|
+
// "open" to the client until the relay confirms with ROOM_JOINED.
|
|
56
|
+
this.ws.send(
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
type: RelayMessageTypes.JOIN_ROOM,
|
|
59
|
+
roomId: this.roomId,
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
this.ws.onmessage = (event: MessageEvent) => {
|
|
65
|
+
let msg: RelayServerMessage;
|
|
66
|
+
try {
|
|
67
|
+
msg = JSON.parse(event.data as string) as RelayServerMessage;
|
|
68
|
+
} catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.handleRelayMessage(msg);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
this.ws.onclose = (event: CloseEvent) => {
|
|
75
|
+
this.state = TransportReadyState.CLOSED;
|
|
76
|
+
const code = this.pendingCloseCode ?? event.code;
|
|
77
|
+
this.onclose?.(code, event.reason);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
this.ws.onerror = (event) => this.onerror?.(event);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get readyState(): number {
|
|
84
|
+
return this.state;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
send(data: string): void {
|
|
88
|
+
if (this.state !== TransportReadyState.OPEN) return;
|
|
89
|
+
this.ws.send(
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
type: RelayMessageTypes.DATA,
|
|
92
|
+
roomId: this.roomId,
|
|
93
|
+
data,
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
close(code?: number, reason?: string): void {
|
|
99
|
+
this.state = TransportReadyState.CLOSING;
|
|
100
|
+
// WebSocket.close only permits 1000 or 3000-4999; pass through only those.
|
|
101
|
+
if (code !== undefined && (code === 1000 || (code >= 3000 && code <= 4999))) {
|
|
102
|
+
this.ws.close(code, reason);
|
|
103
|
+
} else {
|
|
104
|
+
this.ws.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private handleRelayMessage(msg: RelayServerMessage): void {
|
|
109
|
+
switch (msg.type) {
|
|
110
|
+
case RelayMessageTypes.ROOM_JOINED:
|
|
111
|
+
this.state = TransportReadyState.OPEN;
|
|
112
|
+
this.onopen?.();
|
|
113
|
+
break;
|
|
114
|
+
case RelayMessageTypes.DATA:
|
|
115
|
+
this.onmessage?.(msg.data);
|
|
116
|
+
break;
|
|
117
|
+
case RelayMessageTypes.ERROR:
|
|
118
|
+
// Room-level failures are terminal: report a policy close so the client
|
|
119
|
+
// does not attempt to reconnect, then close the underlying socket.
|
|
120
|
+
this.pendingCloseCode = POLICY_CLOSE_CODE;
|
|
121
|
+
this.ws.close();
|
|
122
|
+
break;
|
|
123
|
+
// PEER_JOINED / PEER_LEFT / ROOM_CREATED are host-facing; ignored here.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build a {@link CreateClientTransport} factory for `useGameClient` that
|
|
130
|
+
* connects through the relay.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```tsx
|
|
134
|
+
* useGameClient({
|
|
135
|
+
* reducer,
|
|
136
|
+
* initialState,
|
|
137
|
+
* createTransport: createRelayTransport({ url: RELAY_URL, roomId }),
|
|
138
|
+
* });
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
export function createRelayTransport(
|
|
142
|
+
options: RelayTransportOptions,
|
|
143
|
+
): CreateClientTransport {
|
|
144
|
+
return () => new RelayClientTransport(options);
|
|
145
|
+
}
|
package/src/time-sync.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
DEFAULT_SYNC_INTERVAL,
|
|
6
6
|
MAX_PENDING_PINGS,
|
|
7
7
|
} from "@couch-kit/core";
|
|
8
|
+
import { TransportReadyState, type ClientTransport } from "./transport";
|
|
8
9
|
|
|
9
10
|
interface TimeSyncState {
|
|
10
11
|
offset: number; // Difference between server time and local time
|
|
@@ -47,10 +48,10 @@ export function calculateTimeSync(
|
|
|
47
48
|
* called directly. Access `getServerTime()` and `rtt` from the
|
|
48
49
|
* `useGameClient` return value instead.
|
|
49
50
|
*
|
|
50
|
-
* @param socket - The active
|
|
51
|
+
* @param socket - The active client transport (or `null` if not yet connected).
|
|
51
52
|
* @returns An object with `getServerTime` (returns estimated server time), `rtt`, and `handlePong` (callback for PONG messages).
|
|
52
53
|
*/
|
|
53
|
-
export function useServerTime(socket:
|
|
54
|
+
export function useServerTime(socket: ClientTransport | null) {
|
|
54
55
|
const [timeSync, setTimeSync] = useState<TimeSyncState>({
|
|
55
56
|
offset: 0,
|
|
56
57
|
rtt: 0,
|
|
@@ -85,7 +86,7 @@ export function useServerTime(socket: WebSocket | null) {
|
|
|
85
86
|
|
|
86
87
|
// Periodic Sync
|
|
87
88
|
useEffect(() => {
|
|
88
|
-
if (!socket || socket.readyState !==
|
|
89
|
+
if (!socket || socket.readyState !== TransportReadyState.OPEN) return;
|
|
89
90
|
|
|
90
91
|
const sync = () => {
|
|
91
92
|
// Prevent unbounded growth if PONGs are lost
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport abstraction for the web game client.
|
|
3
|
+
*
|
|
4
|
+
* The client hook (`useGameClient`) speaks to the host through a small
|
|
5
|
+
* WebSocket-shaped interface rather than a concrete `WebSocket`. This lets the
|
|
6
|
+
* default LAN WebSocket transport and alternative transports (e.g. a
|
|
7
|
+
* cross-network relay) be swapped in without touching the hook's JOIN handshake,
|
|
8
|
+
* reconnect/backoff, session-recovery, or state-hydration logic.
|
|
9
|
+
*
|
|
10
|
+
* The interface intentionally mirrors the subset of the `WebSocket` API the
|
|
11
|
+
* client relies on, so the default implementation is a thin wrapper and the
|
|
12
|
+
* behavior of the LAN path is unchanged.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Ready-state constants mirroring the `WebSocket` readyState values. A transport
|
|
17
|
+
* reports these so the client can gate sends on an open connection without
|
|
18
|
+
* depending on the global `WebSocket` constructor (which may be absent in some
|
|
19
|
+
* runtimes/tests).
|
|
20
|
+
*/
|
|
21
|
+
export const TransportReadyState = {
|
|
22
|
+
CONNECTING: 0,
|
|
23
|
+
OPEN: 1,
|
|
24
|
+
CLOSING: 2,
|
|
25
|
+
CLOSED: 3,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The minimal message-transport surface the client requires.
|
|
30
|
+
*
|
|
31
|
+
* Implementations deliver and receive already-serialized JSON strings. Event
|
|
32
|
+
* callbacks are assigned by the client after construction, so a transport must
|
|
33
|
+
* begin connecting on construction and invoke `onopen` once ready.
|
|
34
|
+
*/
|
|
35
|
+
export interface ClientTransport {
|
|
36
|
+
/** Current connection state; compare against {@link TransportReadyState}. */
|
|
37
|
+
readonly readyState: number;
|
|
38
|
+
/** Send a serialized JSON message to the host. */
|
|
39
|
+
send(data: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Close the connection. `code`/`reason` follow WebSocket close semantics so
|
|
42
|
+
* the client's recoverable-vs-terminal reconnect logic keeps working
|
|
43
|
+
* (1008 policy / 1011 internal error are treated as terminal).
|
|
44
|
+
*/
|
|
45
|
+
close(code?: number, reason?: string): void;
|
|
46
|
+
/** Invoked once the connection is open and ready to send. */
|
|
47
|
+
onopen?: () => void;
|
|
48
|
+
/** Invoked with the raw JSON string of each inbound host message. */
|
|
49
|
+
onmessage?: (data: string) => void;
|
|
50
|
+
/** Invoked when the connection closes, with a WebSocket-compatible code. */
|
|
51
|
+
onclose?: (code: number, reason?: string) => void;
|
|
52
|
+
/** Invoked on a transport-level error. */
|
|
53
|
+
onerror?: (error?: unknown) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Factory that creates a fresh {@link ClientTransport}. `useGameClient` calls
|
|
58
|
+
* this each time it (re)connects, so implementations must return a new,
|
|
59
|
+
* already-connecting transport on every call.
|
|
60
|
+
*/
|
|
61
|
+
export type CreateClientTransport = () => ClientTransport;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The default LAN transport: a thin wrapper around the browser `WebSocket` that
|
|
65
|
+
* adapts its event objects to the normalized {@link ClientTransport} callbacks
|
|
66
|
+
* (`onmessage(data)` instead of `event.data`; `onclose(code)` instead of
|
|
67
|
+
* `event.code`). Behavior is identical to using `WebSocket` directly.
|
|
68
|
+
*/
|
|
69
|
+
export function createWebSocketTransport(url: string): ClientTransport {
|
|
70
|
+
const ws = new WebSocket(url);
|
|
71
|
+
|
|
72
|
+
const transport: ClientTransport = {
|
|
73
|
+
get readyState() {
|
|
74
|
+
return ws.readyState;
|
|
75
|
+
},
|
|
76
|
+
send(data: string) {
|
|
77
|
+
ws.send(data);
|
|
78
|
+
},
|
|
79
|
+
close(code?: number, reason?: string) {
|
|
80
|
+
ws.close(code, reason);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
ws.onopen = () => transport.onopen?.();
|
|
85
|
+
ws.onmessage = (event: MessageEvent) =>
|
|
86
|
+
transport.onmessage?.(event.data as string);
|
|
87
|
+
ws.onclose = (event: CloseEvent) =>
|
|
88
|
+
transport.onclose?.(event.code, event.reason);
|
|
89
|
+
ws.onerror = (event) => transport.onerror?.(event);
|
|
90
|
+
|
|
91
|
+
return transport;
|
|
92
|
+
}
|