@couch-kit/client 0.8.8 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @couch-kit/client
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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**
8
+
9
+ Add an injectable client transport so the web controller can connect over
10
+ transports other than the default LAN WebSocket. `useGameClient` now accepts a
11
+ `createTransport` factory, and the package exports a `ClientTransport` interface
12
+ plus `createWebSocketTransport` (the default).
13
+
14
+ Ship a cross-network relay transport (`createRelayTransport`) and the shared
15
+ relay wire protocol, enabling a hosted browser display to reach phones across
16
+ networks through a game-agnostic relay server. The default WebSocket behavior is
17
+ unchanged and fully backward compatible.
18
+
19
+ ## 0.8.9
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies [[`ec8a0ae`](https://github.com/faluciano/react-native-couch-kit/commit/ec8a0ae2626fce135d553e88eca1f0ad59d54e2d)]:
24
+ - @couch-kit/core@0.9.3
25
+
3
26
  ## 0.8.8
4
27
 
5
28
  ### 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 !== WebSocket.OPEN)
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
- const wsUrl = resolveWebSocketUrl({ url: cfg.url, wsPort: cfg.wsPort }, typeof window !== "undefined" ? window.location : null);
162
- if (!wsUrl)
163
- return;
164
- if (cfg.debug)
165
- console.log(`[GameClient] Connecting to ${wsUrl}`);
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
- const ws = new WebSocket(wsUrl);
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
- ws.send(JSON.stringify({
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
- ws.onmessage = (event) => {
225
+ transport.onmessage = (data) => {
190
226
  let msg;
191
227
  try {
192
- msg = JSON.parse(event.data);
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
- ws.onclose = (event) => {
250
+ transport.onclose = (code) => {
215
251
  setStatus("disconnected");
216
252
  configRef.current.onDisconnect?.();
217
253
  if (!shouldReconnect({
218
254
  intentionalClose: intentionalClose.current,
219
- closeCode: event.code,
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
- ws.onerror = (e) => {
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 === WebSocket.OPEN) {
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,101 @@ 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
+ // src/relay-transport.ts
341
+ var POLICY_CLOSE_CODE = 1008;
342
+
343
+ class RelayClientTransport {
344
+ ws;
345
+ roomId;
346
+ state = TransportReadyState.CONNECTING;
347
+ pendingCloseCode = null;
348
+ onopen;
349
+ onmessage;
350
+ onclose;
351
+ onerror;
352
+ constructor(options) {
353
+ this.roomId = options.roomId;
354
+ this.ws = new WebSocket(options.url);
355
+ this.ws.onopen = () => {
356
+ this.ws.send(JSON.stringify({
357
+ type: RelayMessageTypes.JOIN_ROOM,
358
+ roomId: this.roomId
359
+ }));
360
+ };
361
+ this.ws.onmessage = (event) => {
362
+ let msg;
363
+ try {
364
+ msg = JSON.parse(event.data);
365
+ } catch {
366
+ return;
367
+ }
368
+ this.handleRelayMessage(msg);
369
+ };
370
+ this.ws.onclose = (event) => {
371
+ this.state = TransportReadyState.CLOSED;
372
+ const code = this.pendingCloseCode ?? event.code;
373
+ this.onclose?.(code, event.reason);
374
+ };
375
+ this.ws.onerror = (event) => this.onerror?.(event);
376
+ }
377
+ get readyState() {
378
+ return this.state;
379
+ }
380
+ send(data) {
381
+ if (this.state !== TransportReadyState.OPEN)
382
+ return;
383
+ this.ws.send(JSON.stringify({
384
+ type: RelayMessageTypes.DATA,
385
+ roomId: this.roomId,
386
+ data
387
+ }));
388
+ }
389
+ close(code, reason) {
390
+ this.state = TransportReadyState.CLOSING;
391
+ if (code !== undefined && (code === 1000 || code >= 3000 && code <= 4999)) {
392
+ this.ws.close(code, reason);
393
+ } else {
394
+ this.ws.close();
395
+ }
396
+ }
397
+ handleRelayMessage(msg) {
398
+ switch (msg.type) {
399
+ case RelayMessageTypes.ROOM_JOINED:
400
+ this.state = TransportReadyState.OPEN;
401
+ this.onopen?.();
402
+ break;
403
+ case RelayMessageTypes.DATA:
404
+ this.onmessage?.(msg.data);
405
+ break;
406
+ case RelayMessageTypes.ERROR:
407
+ this.pendingCloseCode = POLICY_CLOSE_CODE;
408
+ this.ws.close();
409
+ break;
410
+ }
411
+ }
412
+ }
413
+ function createRelayTransport(options) {
414
+ return () => new RelayClientTransport(options);
415
+ }
285
416
  // src/assets.ts
286
417
  import { useState as useState3, useEffect as useEffect3, useRef as useRef3 } from "react";
287
418
  import { MessageTypes as MessageTypes4 } from "@couch-kit/core";
@@ -425,7 +556,13 @@ export {
425
556
  resolveWebSocketUrl,
426
557
  resolveSessionSecret,
427
558
  interpretHostMessage,
559
+ createWebSocketTransport,
560
+ createRelayTransport,
428
561
  computeBackoffDelay,
429
562
  calculateTimeSync,
430
- SESSION_SECRET_KEY
563
+ TransportReadyState,
564
+ SESSION_SECRET_KEY,
565
+ RelayMessageTypes,
566
+ RelayErrorCodes,
567
+ RelayClientTransport
431
568
  };
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.
@@ -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;AAUzB,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;CACjB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EACnE,MAAM,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC;;;;yBAoMc,CAAC;;IAqBvC,8EAA8E;;IAE9E,0EAA0E;;IAE1E,+EAA+E;;EAGlF"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,OAAO,EAEb,MAAM,iBAAiB,CAAC;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
@@ -1,5 +1,8 @@
1
1
  export * from "./client";
2
2
  export * from "./connection";
3
+ export * from "./transport";
4
+ export * from "./relay-protocol";
5
+ export * from "./relay-transport";
3
6
  export * from "./time-sync";
4
7
  export * from "./assets";
5
8
  export * from "./debug-panel";
@@ -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,108 @@
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
+ //# 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;;IAE9D,0EAA0E;;IAE1E,4CAA4C;;IAE5C,qEAAqE;;IAErE,gDAAgD;;IAEhD,8CAA8C;;IAE9C,yEAAyE;;IAEzE,6CAA6C;;CAErC,CAAC;AAEX,wEAAwE;AACxE,eAAO,MAAM,eAAe;;;;;;;CAOlB,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"}
@@ -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;AAMrB,+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;gBAExB,OAAO,EAAE,qBAAqB;IAkC1C,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAWxB,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;IAU3C,OAAO,CAAC,kBAAkB;CAkB3B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,qBAAqB,GAC7B,qBAAqB,CAEvB"}
@@ -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 WebSocket connection (or `null` if not yet connected).
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: WebSocket | null): {
31
+ export declare function useServerTime(socket: ClientTransport | null): {
31
32
  getServerTime: () => number;
32
33
  rtt: number;
33
34
  handlePong: (payload: {
@@ -1 +1 @@
1
- {"version":3,"file":"time-sync.d.ts","sourceRoot":"","sources":["../src/time-sync.ts"],"names":[],"mappings":"AAaA;;;;;;;;;;;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,SAAS,GAAG,IAAI;;;0BAgBxC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE;EAgDtE"}
1
+ {"version":3,"file":"time-sync.d.ts","sourceRoot":"","sources":["../src/time-sync.ts"],"names":[],"mappings":"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;;;;;CAKtB,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.8.8",
3
+ "version": "0.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
@@ -50,10 +50,10 @@
50
50
  "clean": "rm -rf dist lib"
51
51
  },
52
52
  "dependencies": {
53
- "@couch-kit/core": "0.9.2"
53
+ "@couch-kit/core": "0.9.3"
54
54
  },
55
55
  "devDependencies": {
56
- "react": "^18.2.0",
56
+ "react": "^19.0.0",
57
57
  "typescript": "^6.0.0"
58
58
  },
59
59
  "peerDependencies": {
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<WebSocket | null>(null);
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. Magic Client: Determine URL
102
- // If explicit URL provided, use it.
103
- // Otherwise, assume we are being served by the Host's static server,
104
- // so derive the WebSocket URL from window.location.
105
- // Convention: WS port = HTTP port + 2 (e.g., HTTP 8080 -> WS 8082)
106
- // Port + 1 is skipped to avoid conflicts with Metro bundler (which uses 8081)
107
- const wsUrl = resolveWebSocketUrl(
108
- { url: cfg.url, wsPort: cfg.wsPort },
109
- typeof window !== "undefined" ? window.location : null,
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
- if (!wsUrl) return;
132
+ if (!wsUrl) return;
113
133
 
114
- if (cfg.debug) console.log(`[GameClient] Connecting to ${wsUrl}`);
115
- setStatus("connecting");
134
+ if (cfg.debug) console.log(`[GameClient] Connecting to ${wsUrl}`);
135
+ transport = createWebSocketTransport(wsUrl);
136
+ }
116
137
 
117
- const ws = new WebSocket(wsUrl);
118
- socketRef.current = ws;
138
+ socketRef.current = transport;
139
+ setStatus("connecting");
119
140
 
120
- ws.onopen = () => {
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
- ws.send(
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
- ws.onmessage = (event) => {
170
+ transport.onmessage = (data) => {
150
171
  let msg: HostMessage;
151
172
  try {
152
- msg = JSON.parse(event.data) as HostMessage;
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
- ws.onclose = (event) => {
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: event.code,
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
- ws.onerror = (e) => {
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 from configRef.
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 === WebSocket.OPEN) {
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
@@ -1,5 +1,8 @@
1
1
  export * from "./client";
2
2
  export * from "./connection";
3
+ export * from "./transport";
4
+ export * from "./relay-protocol";
5
+ export * from "./relay-transport";
3
6
  export * from "./time-sync";
4
7
  export * from "./assets";
5
8
  export * from "./debug-panel";
@@ -0,0 +1,131 @@
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;
@@ -0,0 +1,144 @@
1
+ import {
2
+ TransportReadyState,
3
+ type ClientTransport,
4
+ type CreateClientTransport,
5
+ } from "./transport";
6
+ import {
7
+ RelayMessageTypes,
8
+ type RelayServerMessage,
9
+ } from "./relay-protocol";
10
+
11
+ /** Options for {@link createRelayTransport} / {@link RelayClientTransport}. */
12
+ export interface RelayTransportOptions {
13
+ /** WebSocket URL of the relay server (e.g. `wss://relay.example.com`). */
14
+ url: string;
15
+ /** Room code identifying the display host to connect to. */
16
+ roomId: string;
17
+ }
18
+
19
+ /**
20
+ * Terminal close code (WebSocket policy violation). `close()` can't be called
21
+ * with reserved codes like 1008, so the transport reports this to the client
22
+ * directly via `onclose` while closing the socket with a permitted code.
23
+ */
24
+ const POLICY_CLOSE_CODE = 1008;
25
+
26
+ /**
27
+ * A {@link ClientTransport} that reaches the display host through the
28
+ * cross-network relay instead of a direct LAN WebSocket.
29
+ *
30
+ * It opens a WebSocket to the relay, joins `roomId`, and then presents the same
31
+ * open/message/close surface as the default transport — wrapping each outbound
32
+ * game message in a relay `DATA` envelope and unwrapping inbound ones. Room-level
33
+ * failures (unknown/full room) are surfaced as a terminal close so the client's
34
+ * reconnect logic does not hammer a room that will never accept it.
35
+ */
36
+ export class RelayClientTransport implements ClientTransport {
37
+ private readonly ws: WebSocket;
38
+ private readonly roomId: string;
39
+ private state: number = TransportReadyState.CONNECTING;
40
+ /** When set, the code reported to `onclose` instead of the raw socket code. */
41
+ private pendingCloseCode: number | null = null;
42
+
43
+ onopen?: () => void;
44
+ onmessage?: (data: string) => void;
45
+ onclose?: (code: number, reason?: string) => void;
46
+ onerror?: (error?: unknown) => void;
47
+
48
+ constructor(options: RelayTransportOptions) {
49
+ this.roomId = options.roomId;
50
+ this.ws = new WebSocket(options.url);
51
+
52
+ this.ws.onopen = () => {
53
+ // The relay socket is up; ask to join the room. The transport is not yet
54
+ // "open" to the client until the relay confirms with ROOM_JOINED.
55
+ this.ws.send(
56
+ JSON.stringify({
57
+ type: RelayMessageTypes.JOIN_ROOM,
58
+ roomId: this.roomId,
59
+ }),
60
+ );
61
+ };
62
+
63
+ this.ws.onmessage = (event: MessageEvent) => {
64
+ let msg: RelayServerMessage;
65
+ try {
66
+ msg = JSON.parse(event.data as string) as RelayServerMessage;
67
+ } catch {
68
+ return;
69
+ }
70
+ this.handleRelayMessage(msg);
71
+ };
72
+
73
+ this.ws.onclose = (event: CloseEvent) => {
74
+ this.state = TransportReadyState.CLOSED;
75
+ const code = this.pendingCloseCode ?? event.code;
76
+ this.onclose?.(code, event.reason);
77
+ };
78
+
79
+ this.ws.onerror = (event) => this.onerror?.(event);
80
+ }
81
+
82
+ get readyState(): number {
83
+ return this.state;
84
+ }
85
+
86
+ send(data: string): void {
87
+ if (this.state !== TransportReadyState.OPEN) return;
88
+ this.ws.send(
89
+ JSON.stringify({
90
+ type: RelayMessageTypes.DATA,
91
+ roomId: this.roomId,
92
+ data,
93
+ }),
94
+ );
95
+ }
96
+
97
+ close(code?: number, reason?: string): void {
98
+ this.state = TransportReadyState.CLOSING;
99
+ // WebSocket.close only permits 1000 or 3000-4999; pass through only those.
100
+ if (code !== undefined && (code === 1000 || (code >= 3000 && code <= 4999))) {
101
+ this.ws.close(code, reason);
102
+ } else {
103
+ this.ws.close();
104
+ }
105
+ }
106
+
107
+ private handleRelayMessage(msg: RelayServerMessage): void {
108
+ switch (msg.type) {
109
+ case RelayMessageTypes.ROOM_JOINED:
110
+ this.state = TransportReadyState.OPEN;
111
+ this.onopen?.();
112
+ break;
113
+ case RelayMessageTypes.DATA:
114
+ this.onmessage?.(msg.data);
115
+ break;
116
+ case RelayMessageTypes.ERROR:
117
+ // Room-level failures are terminal: report a policy close so the client
118
+ // does not attempt to reconnect, then close the underlying socket.
119
+ this.pendingCloseCode = POLICY_CLOSE_CODE;
120
+ this.ws.close();
121
+ break;
122
+ // PEER_JOINED / PEER_LEFT / ROOM_CREATED are host-facing; ignored here.
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Build a {@link CreateClientTransport} factory for `useGameClient` that
129
+ * connects through the relay.
130
+ *
131
+ * @example
132
+ * ```tsx
133
+ * useGameClient({
134
+ * reducer,
135
+ * initialState,
136
+ * createTransport: createRelayTransport({ url: RELAY_URL, roomId }),
137
+ * });
138
+ * ```
139
+ */
140
+ export function createRelayTransport(
141
+ options: RelayTransportOptions,
142
+ ): CreateClientTransport {
143
+ return () => new RelayClientTransport(options);
144
+ }
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 WebSocket connection (or `null` if not yet connected).
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: WebSocket | null) {
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 !== WebSocket.OPEN) return;
89
+ if (!socket || socket.readyState !== TransportReadyState.OPEN) return;
89
90
 
90
91
  const sync = () => {
91
92
  // Prevent unbounded growth if PONGs are lost
@@ -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
+ }