@couch-kit/client 0.10.1 → 0.12.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,56 @@
1
1
  # @couch-kit/client
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#155](https://github.com/faluciano/react-native-couch-kit/pull/155) [`4f297a1`](https://github.com/faluciano/react-native-couch-kit/commit/4f297a19c442541703c3bee7bee26354ae3476a4) Thanks [@faluciano](https://github.com/faluciano)! - Hidden information can now be hidden for real: `GameHostRuntimeConfig` takes an
8
+ optional `project(state, playerId)` that narrows the authoritative state to what
9
+ one player may see.
10
+
11
+ Without it the runtime broadcasts the same state to everyone, so hiding a hand
12
+ depends on the client choosing not to look — any player could read opponents'
13
+ cards from devtools. With it, the data never reaches their device: the runtime
14
+ sends each connection its own projection, in `WELCOME`, `RECONNECTED`, and every
15
+ state update.
16
+
17
+ Games with no hidden information omit `project` and are unchanged — state is
18
+ still broadcast in one frame.
19
+
20
+ Because a projected client holds a _view_ rather than the whole state, it cannot
21
+ run the game reducer, so `ClientConfig.reducer` is now optional. Omit it and the
22
+ client renders what the host sends (no optimistic updates); a round trip is
23
+ imperceptible for turn-based games, and it is what makes the guarantee real.
24
+
25
+ ## 0.11.0
26
+
27
+ ### Minor Changes
28
+
29
+ - [#147](https://github.com/faluciano/react-native-couch-kit/pull/147) [`abf8bbe`](https://github.com/faluciano/react-native-couch-kit/commit/abf8bbe075e8f5eff00ffee7d9131009195c6a0e) Thanks [@faluciano](https://github.com/faluciano)! - Relay clients can now tell players _why_ a join failed, and ask for a room code
30
+ when they don't have one.
31
+
32
+ A hosted controller opened without `?room=` has no LAN host to fall back to, so
33
+ it could only sit on "connecting" forever — and a wrong or expired code looked
34
+ exactly the same. Two additions fix that:
35
+
36
+ - `useGameClient` returns `disconnectReason`, carrying the relay's error code
37
+ (`ROOM_NOT_FOUND`, `ROOM_FULL`, …) for terminal failures. Previously the
38
+ transport collapsed these to an unexplained close.
39
+ - New `useRelayRoom()` tracks the room from `?room=CODE` and lets the app set one
40
+ (updating the URL so reloads and shared links keep it), plus
41
+ `normalizeRoomCode()` and `describeRelayError()` for the entry UI.
42
+
43
+ Games stay in control of rendering; the SDK supplies the state and the wording.
44
+
45
+ ### Patch Changes
46
+
47
+ - [#147](https://github.com/faluciano/react-native-couch-kit/pull/147) [`abf8bbe`](https://github.com/faluciano/react-native-couch-kit/commit/abf8bbe075e8f5eff00ffee7d9131009195c6a0e) Thanks [@faluciano](https://github.com/faluciano)! - Add the `RATE_LIMITED` and `SERVER_BUSY` relay error codes, which the relay has
48
+ sent since abuse limits landed but the client's copy of the protocol never knew
49
+ about — a client could receive a code its own types called impossible.
50
+
51
+ A contract test now imports both copies of the wire constants and fails if they
52
+ ever diverge again.
53
+
3
54
  ## 0.10.1
4
55
 
5
56
  ### Patch Changes
package/dist/index.js CHANGED
@@ -167,7 +167,8 @@ function interpretHostMessage(msg) {
167
167
  function useGameClient(config) {
168
168
  const [status, setStatus] = useState2("disconnected");
169
169
  const [playerId, setPlayerId] = useState2(null);
170
- const [state, dispatchLocal] = useReducer(createGameReducer(config.reducer), config.initialState);
170
+ const [disconnectReason, setDisconnectReason] = useState2(null);
171
+ const [state, dispatchLocal] = useReducer(createGameReducer(config.reducer ?? ((current) => current)), config.initialState);
171
172
  const socketRef = useRef2(null);
172
173
  const reconnectAttempts = useRef2(0);
173
174
  const reconnectTimer = useRef2(null);
@@ -247,8 +248,9 @@ function useGameClient(config) {
247
248
  }
248
249
  }
249
250
  };
250
- transport.onclose = (code) => {
251
+ transport.onclose = (code, reason) => {
251
252
  setStatus("disconnected");
253
+ setDisconnectReason(reason ? reason : null);
252
254
  configRef.current.onDisconnect?.();
253
255
  if (!shouldReconnect({
254
256
  intentionalClose: intentionalClose.current,
@@ -311,6 +313,7 @@ function useGameClient(config) {
311
313
  status,
312
314
  state,
313
315
  playerId,
316
+ disconnectReason,
314
317
  sendAction,
315
318
  getServerTime,
316
319
  rtt,
@@ -335,7 +338,9 @@ var RelayErrorCodes = {
335
338
  ROOM_FULL: "ROOM_FULL",
336
339
  NOT_IN_ROOM: "NOT_IN_ROOM",
337
340
  MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE",
338
- MALFORMED: "MALFORMED"
341
+ MALFORMED: "MALFORMED",
342
+ RATE_LIMITED: "RATE_LIMITED",
343
+ SERVER_BUSY: "SERVER_BUSY"
339
344
  };
340
345
  function relayRoomUrl(url, roomId) {
341
346
  const trimmed = url.replace(/\/+$/, "");
@@ -351,6 +356,7 @@ class RelayClientTransport {
351
356
  roomId;
352
357
  state = TransportReadyState.CONNECTING;
353
358
  pendingCloseCode = null;
359
+ pendingCloseReason = null;
354
360
  onopen;
355
361
  onmessage;
356
362
  onclose;
@@ -376,7 +382,7 @@ class RelayClientTransport {
376
382
  this.ws.onclose = (event) => {
377
383
  this.state = TransportReadyState.CLOSED;
378
384
  const code = this.pendingCloseCode ?? event.code;
379
- this.onclose?.(code, event.reason);
385
+ this.onclose?.(code, this.pendingCloseReason ?? event.reason);
380
386
  };
381
387
  this.ws.onerror = (event) => this.onerror?.(event);
382
388
  }
@@ -411,6 +417,7 @@ class RelayClientTransport {
411
417
  break;
412
418
  case RelayMessageTypes.ERROR:
413
419
  this.pendingCloseCode = POLICY_CLOSE_CODE;
420
+ this.pendingCloseReason = msg.code;
414
421
  this.ws.close();
415
422
  break;
416
423
  }
@@ -419,8 +426,74 @@ class RelayClientTransport {
419
426
  function createRelayTransport(options) {
420
427
  return () => new RelayClientTransport(options);
421
428
  }
429
+ // src/relay-room.ts
430
+ import { useCallback as useCallback3, useState as useState3 } from "react";
431
+ var ROOM_PARAM = "room";
432
+ function normalizeRoomCode(input) {
433
+ return input.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
434
+ }
435
+ function roomFromLocation() {
436
+ if (typeof window === "undefined")
437
+ return null;
438
+ try {
439
+ const raw = new URLSearchParams(window.location.search).get(ROOM_PARAM);
440
+ if (!raw)
441
+ return null;
442
+ const code = normalizeRoomCode(raw);
443
+ return code.length > 0 ? code : null;
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+ function useRelayRoom() {
449
+ const [roomId, setRoom] = useState3(() => roomFromLocation());
450
+ const syncUrl = useCallback3((code) => {
451
+ if (typeof window === "undefined")
452
+ return;
453
+ try {
454
+ const url = new URL(window.location.href);
455
+ if (code)
456
+ url.searchParams.set(ROOM_PARAM, code);
457
+ else
458
+ url.searchParams.delete(ROOM_PARAM);
459
+ window.history.replaceState(null, "", url.toString());
460
+ } catch {}
461
+ }, []);
462
+ const setRoomId = useCallback3((code) => {
463
+ const normalized = normalizeRoomCode(code);
464
+ if (normalized.length === 0)
465
+ return;
466
+ setRoom(normalized);
467
+ syncUrl(normalized);
468
+ }, [syncUrl]);
469
+ const clearRoomId = useCallback3(() => {
470
+ setRoom(null);
471
+ syncUrl(null);
472
+ }, [syncUrl]);
473
+ return { roomId, setRoomId, clearRoomId };
474
+ }
475
+ function describeRelayError(reason) {
476
+ switch (reason) {
477
+ case RelayErrorCodes.ROOM_NOT_FOUND:
478
+ return "That room isn't open. Check the code on the screen.";
479
+ case RelayErrorCodes.ROOM_FULL:
480
+ return "That room is full.";
481
+ case RelayErrorCodes.RATE_LIMITED:
482
+ return "Too many messages — slow down and try again.";
483
+ case RelayErrorCodes.ROOM_EXISTS:
484
+ return "That room is already hosted by another screen.";
485
+ case RelayErrorCodes.SERVER_BUSY:
486
+ return "The relay is busy. Try again in a moment.";
487
+ case RelayErrorCodes.MESSAGE_TOO_LARGE:
488
+ case RelayErrorCodes.MALFORMED:
489
+ case RelayErrorCodes.NOT_IN_ROOM:
490
+ return "The connection was rejected. Try rejoining.";
491
+ default:
492
+ return null;
493
+ }
494
+ }
422
495
  // src/assets.ts
423
- import { useState as useState3, useEffect as useEffect3, useRef as useRef3 } from "react";
496
+ import { useState as useState4, useEffect as useEffect3, useRef as useRef3 } from "react";
424
497
  import { MessageTypes as MessageTypes4 } from "@couch-kit/core";
425
498
  function arraysEqual(a, b) {
426
499
  if (a.length !== b.length)
@@ -432,9 +505,9 @@ function arraysEqual(a, b) {
432
505
  return true;
433
506
  }
434
507
  function usePreload(assets, sendMessage) {
435
- const [loaded, setLoaded] = useState3(false);
436
- const [progress, setProgress] = useState3(0);
437
- const [failedAssets, setFailedAssets] = useState3([]);
508
+ const [loaded, setLoaded] = useState4(false);
509
+ const [progress, setProgress] = useState4(0);
510
+ const [failedAssets, setFailedAssets] = useState4([]);
438
511
  const sendMessageRef = useRef3(sendMessage);
439
512
  sendMessageRef.current = sendMessage;
440
513
  const prevAssets = useRef3(assets);
@@ -497,19 +570,19 @@ function usePreload(assets, sendMessage) {
497
570
  return { loaded, progress, failedAssets };
498
571
  }
499
572
  // src/debug-panel.ts
500
- import { useCallback as useCallback3, useEffect as useEffect4, useRef as useRef4, useState as useState4 } from "react";
573
+ import { useCallback as useCallback4, useEffect as useEffect4, useRef as useRef4, useState as useState5 } from "react";
501
574
  function useDebugPanel(options) {
502
575
  const { enabled, state, status, rtt, maxEntries = 50 } = options;
503
576
  const nextIdRef = useRef4(0);
504
577
  const prevStateRef = useRef4(null);
505
- const [actionLog, setActionLog] = useState4([]);
506
- const [stateHistory, setStateHistory] = useState4([]);
507
- const clearHistory = useCallback3(() => {
578
+ const [actionLog, setActionLog] = useState5([]);
579
+ const [stateHistory, setStateHistory] = useState5([]);
580
+ const clearHistory = useCallback4(() => {
508
581
  setActionLog([]);
509
582
  setStateHistory([]);
510
583
  nextIdRef.current = 0;
511
584
  }, []);
512
- const logAction = useCallback3((action, source = "local") => {
585
+ const logAction = useCallback4((action, source = "local") => {
513
586
  if (!enabled)
514
587
  return;
515
588
  setActionLog((prev) => {
@@ -555,6 +628,7 @@ function useDebugPanel(options) {
555
628
  }
556
629
  export {
557
630
  useServerTime,
631
+ useRelayRoom,
558
632
  usePreload,
559
633
  useGameClient,
560
634
  useDebugPanel,
@@ -562,7 +636,9 @@ export {
562
636
  resolveWebSocketUrl,
563
637
  resolveSessionSecret,
564
638
  relayRoomUrl,
639
+ normalizeRoomCode,
565
640
  interpretHostMessage,
641
+ describeRelayError,
566
642
  createWebSocketTransport,
567
643
  createRelayTransport,
568
644
  computeBackoffDelay,
package/lib/client.d.ts CHANGED
@@ -3,7 +3,17 @@ import { type CreateClientTransport } from "./transport";
3
3
  export interface ClientConfig<S extends IGameState, A extends IAction> {
4
4
  url?: string;
5
5
  wsPort?: number;
6
- reducer: (state: S, action: A) => S;
6
+ /**
7
+ * Applies actions locally for an optimistic update before the host confirms.
8
+ *
9
+ * **Omit it when the host projects state per player** (`project` in
10
+ * `GameHostRuntimeConfig`): the client then holds a *view* rather than the
11
+ * whole state, and the game reducer cannot run over a partial view. Without a
12
+ * reducer the client simply renders what the host sends — a round trip that
13
+ * is imperceptible for turn-based games, and the price of hidden information
14
+ * never reaching the device.
15
+ */
16
+ reducer?: (state: S, action: A) => S;
7
17
  initialState: S;
8
18
  name?: string;
9
19
  avatar?: string;
@@ -46,6 +56,12 @@ export declare function useGameClient<S extends IGameState, A extends IAction>(c
46
56
  status: "connected" | "connecting" | "disconnected" | "error";
47
57
  state: S;
48
58
  playerId: string | null;
59
+ /**
60
+ * Why the last connection ended, if the transport reported a cause — for
61
+ * the relay, a `RelayErrorCodes` value like `ROOM_NOT_FOUND` or `ROOM_FULL`.
62
+ * `null` when connected or when the cause is unknown.
63
+ */
64
+ disconnectReason: string | null;
49
65
  sendAction: (action: A) => void;
50
66
  getServerTime: () => number;
51
67
  /** Round-trip time (ms) to the server. Updated periodically via PING/PONG. */
@@ -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;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"}
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;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACrC,YAAY,EAAE,CAAC,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mEAAmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,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;;;;IA2OxB;;;;OAIG;;yBAvBmC,CAAC;;IA2BvC,8EAA8E;;IAE9E,0EAA0E;;IAE1E,+EAA+E;;EAGlF"}
package/lib/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./connection";
3
3
  export * from "./transport";
4
4
  export * from "./relay-protocol";
5
5
  export * from "./relay-transport";
6
+ export * from "./relay-room";
6
7
  export * from "./time-sync";
7
8
  export * from "./assets";
8
9
  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,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,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,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC"}
@@ -38,6 +38,10 @@ export declare const RelayErrorCodes: {
38
38
  readonly NOT_IN_ROOM: "NOT_IN_ROOM";
39
39
  readonly MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE";
40
40
  readonly MALFORMED: "MALFORMED";
41
+ /** Connection exceeded the relay's per-connection message rate limit. */
42
+ readonly RATE_LIMITED: "RATE_LIMITED";
43
+ /** Relay is at its room capacity. */
44
+ readonly SERVER_BUSY: "SERVER_BUSY";
41
45
  };
42
46
  export type RelayErrorCode = (typeof RelayErrorCodes)[keyof typeof RelayErrorCodes];
43
47
  /** Display → relay: create and host a room. */
@@ -1 +1 @@
1
- {"version":3,"file":"relay-protocol.d.ts","sourceRoot":"","sources":["../src/relay-protocol.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"relay-protocol.d.ts","sourceRoot":"","sources":["../src/relay-protocol.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Room-code entry for relay clients.
3
+ *
4
+ * A hosted controller has no LAN host to fall back to: opened without a room
5
+ * code it can only sit there failing. These helpers give it the missing state —
6
+ * "which room am I trying to join, and why did the last attempt fail" — so the
7
+ * app can ask for a code instead of hanging.
8
+ */
9
+ export interface UseRelayRoomResult {
10
+ /** Room the client should join, or `null` when none has been chosen yet. */
11
+ readonly roomId: string | null;
12
+ /** Choose a room. Canonicalised, and reflected in the URL so reloads keep it. */
13
+ readonly setRoomId: (code: string) => void;
14
+ /** Forget the current room and return to the entry screen. */
15
+ readonly clearRoomId: () => void;
16
+ }
17
+ /**
18
+ * Canonical room code: upper-cased and stripped of spacing or punctuation that
19
+ * people add when copying a code off a TV ("ab 12" and "AB-12" are `AB12`).
20
+ */
21
+ export declare function normalizeRoomCode(input: string): string;
22
+ /**
23
+ * Tracks which room this client is joining, seeded from `?room=CODE`.
24
+ *
25
+ * Scanning a QR fills it in; typing a code sets it and updates the URL, so a
26
+ * reload — or a shared link — lands in the same room.
27
+ */
28
+ export declare function useRelayRoom(): UseRelayRoomResult;
29
+ /**
30
+ * Human-readable explanation for a relay failure, or `null` if the reason is
31
+ * unknown (an ordinary network drop, say, which the client will retry).
32
+ *
33
+ * Pass `disconnectReason` from `useGameClient`.
34
+ */
35
+ export declare function describeRelayError(reason: string | null): string | null;
36
+ //# sourceMappingURL=relay-room.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relay-room.d.ts","sourceRoot":"","sources":["../src/relay-room.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,MAAM,WAAW,kBAAkB;IACjC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,8DAA8D;IAC9D,QAAQ,CAAC,WAAW,EAAE,MAAM,IAAI,CAAC;CAClC;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD;AAcD;;;;;GAKG;AACH,wBAAgB,YAAY,IAAI,kBAAkB,CA+BjD;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAmBvE"}
@@ -22,6 +22,12 @@ export declare class RelayClientTransport implements ClientTransport {
22
22
  private state;
23
23
  /** When set, the code reported to `onclose` instead of the raw socket code. */
24
24
  private pendingCloseCode;
25
+ /**
26
+ * Relay error code (e.g. `ROOM_NOT_FOUND`) behind a terminal close, reported
27
+ * as the close `reason` so the UI can say what actually went wrong instead of
28
+ * showing an indefinite "connecting".
29
+ */
30
+ private pendingCloseReason;
25
31
  onopen?: () => void;
26
32
  onmessage?: (data: string) => void;
27
33
  onclose?: (code: number, reason?: string) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"relay-transport.d.ts","sourceRoot":"","sources":["../src/relay-transport.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC3B,MAAM,aAAa,CAAC;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"}
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;IAC/C;;;;OAIG;IACH,OAAO,CAAC,kBAAkB,CAAuB;IAEjD,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAEpC,YAAY,OAAO,EAAE,qBAAqB,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;CAqB3B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,qBAAqB,GAC7B,qBAAqB,CAEvB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@couch-kit/client",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
package/src/client.ts CHANGED
@@ -29,7 +29,17 @@ import {
29
29
  export interface ClientConfig<S extends IGameState, A extends IAction> {
30
30
  url?: string; // Full WebSocket URL (overrides auto-detection)
31
31
  wsPort?: number; // WebSocket port (default: auto-detected as HTTP port + 2)
32
- reducer: (state: S, action: A) => S;
32
+ /**
33
+ * Applies actions locally for an optimistic update before the host confirms.
34
+ *
35
+ * **Omit it when the host projects state per player** (`project` in
36
+ * `GameHostRuntimeConfig`): the client then holds a *view* rather than the
37
+ * whole state, and the game reducer cannot run over a partial view. Without a
38
+ * reducer the client simply renders what the host sends — a round trip that
39
+ * is imperceptible for turn-based games, and the price of hidden information
40
+ * never reaching the device.
41
+ */
42
+ reducer?: (state: S, action: A) => S;
33
43
  initialState: S;
34
44
  name?: string; // Player display name (default: "Player")
35
45
  avatar?: string; // Player avatar emoji (default: "\u{1F600}")
@@ -76,11 +86,18 @@ export function useGameClient<S extends IGameState, A extends IAction>(
76
86
  "connecting" | "connected" | "disconnected" | "error"
77
87
  >("disconnected");
78
88
  const [playerId, setPlayerId] = useState<string | null>(null);
89
+ /**
90
+ * Why the last connection ended, when the transport knows. For the relay this
91
+ * is a {@link RelayErrorCodes} value such as `ROOM_NOT_FOUND`.
92
+ */
93
+ const [disconnectReason, setDisconnectReason] = useState<string | null>(null);
79
94
 
80
95
  // Local Optimistic State
81
- // Wrap the user's reducer with createGameReducer to handle HYDRATE automatically
96
+ // Wrap the user's reducer with createGameReducer to handle HYDRATE automatically.
97
+ // With no reducer (server-projected views) the identity function keeps HYDRATE
98
+ // working while local application becomes a no-op.
82
99
  const [state, dispatchLocal] = useReducer(
83
- createGameReducer(config.reducer),
100
+ createGameReducer(config.reducer ?? ((current: S) => current)),
84
101
  config.initialState,
85
102
  );
86
103
 
@@ -195,8 +212,12 @@ export function useGameClient<S extends IGameState, A extends IAction>(
195
212
  }
196
213
  };
197
214
 
198
- transport.onclose = (code) => {
215
+ transport.onclose = (code, reason) => {
199
216
  setStatus("disconnected");
217
+ // Terminal room-level failures carry a relay error code (ROOM_NOT_FOUND,
218
+ // ROOM_FULL, …). Surfacing it lets the UI explain the failure rather than
219
+ // sit on "connecting" forever.
220
+ setDisconnectReason(reason ? reason : null);
200
221
  configRef.current.onDisconnect?.();
201
222
 
202
223
  // Don't reconnect if the close was intentional or if the server
@@ -294,6 +315,12 @@ export function useGameClient<S extends IGameState, A extends IAction>(
294
315
  status,
295
316
  state,
296
317
  playerId,
318
+ /**
319
+ * Why the last connection ended, if the transport reported a cause — for
320
+ * the relay, a `RelayErrorCodes` value like `ROOM_NOT_FOUND` or `ROOM_FULL`.
321
+ * `null` when connected or when the cause is unknown.
322
+ */
323
+ disconnectReason,
297
324
  sendAction,
298
325
  getServerTime,
299
326
  /** Round-trip time (ms) to the server. Updated periodically via PING/PONG. */
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./connection";
3
3
  export * from "./transport";
4
4
  export * from "./relay-protocol";
5
5
  export * from "./relay-transport";
6
+ export * from "./relay-room";
6
7
  export * from "./time-sync";
7
8
  export * from "./assets";
8
9
  export * from "./debug-panel";
@@ -40,6 +40,10 @@ export const RelayErrorCodes = {
40
40
  NOT_IN_ROOM: "NOT_IN_ROOM",
41
41
  MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE",
42
42
  MALFORMED: "MALFORMED",
43
+ /** Connection exceeded the relay's per-connection message rate limit. */
44
+ RATE_LIMITED: "RATE_LIMITED",
45
+ /** Relay is at its room capacity. */
46
+ SERVER_BUSY: "SERVER_BUSY",
43
47
  } as const;
44
48
 
45
49
  export type RelayErrorCode =
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Room-code entry for relay clients.
3
+ *
4
+ * A hosted controller has no LAN host to fall back to: opened without a room
5
+ * code it can only sit there failing. These helpers give it the missing state —
6
+ * "which room am I trying to join, and why did the last attempt fail" — so the
7
+ * app can ask for a code instead of hanging.
8
+ */
9
+
10
+ import { useCallback, useState } from "react";
11
+ import { RelayErrorCodes } from "./relay-protocol";
12
+
13
+ /** Query parameter carrying the room code, as produced by display QR codes. */
14
+ const ROOM_PARAM = "room";
15
+
16
+ export interface UseRelayRoomResult {
17
+ /** Room the client should join, or `null` when none has been chosen yet. */
18
+ readonly roomId: string | null;
19
+ /** Choose a room. Canonicalised, and reflected in the URL so reloads keep it. */
20
+ readonly setRoomId: (code: string) => void;
21
+ /** Forget the current room and return to the entry screen. */
22
+ readonly clearRoomId: () => void;
23
+ }
24
+
25
+ /**
26
+ * Canonical room code: upper-cased and stripped of spacing or punctuation that
27
+ * people add when copying a code off a TV ("ab 12" and "AB-12" are `AB12`).
28
+ */
29
+ export function normalizeRoomCode(input: string): string {
30
+ return input.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
31
+ }
32
+
33
+ function roomFromLocation(): string | null {
34
+ if (typeof window === "undefined") return null;
35
+ try {
36
+ const raw = new URLSearchParams(window.location.search).get(ROOM_PARAM);
37
+ if (!raw) return null;
38
+ const code = normalizeRoomCode(raw);
39
+ return code.length > 0 ? code : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Tracks which room this client is joining, seeded from `?room=CODE`.
47
+ *
48
+ * Scanning a QR fills it in; typing a code sets it and updates the URL, so a
49
+ * reload — or a shared link — lands in the same room.
50
+ */
51
+ export function useRelayRoom(): UseRelayRoomResult {
52
+ const [roomId, setRoom] = useState<string | null>(() => roomFromLocation());
53
+
54
+ const syncUrl = useCallback((code: string | null): void => {
55
+ if (typeof window === "undefined") return;
56
+ try {
57
+ const url = new URL(window.location.href);
58
+ if (code) url.searchParams.set(ROOM_PARAM, code);
59
+ else url.searchParams.delete(ROOM_PARAM);
60
+ window.history.replaceState(null, "", url.toString());
61
+ } catch {
62
+ // A URL we cannot rewrite is not worth failing the join over.
63
+ }
64
+ }, []);
65
+
66
+ const setRoomId = useCallback(
67
+ (code: string): void => {
68
+ const normalized = normalizeRoomCode(code);
69
+ if (normalized.length === 0) return;
70
+ setRoom(normalized);
71
+ syncUrl(normalized);
72
+ },
73
+ [syncUrl],
74
+ );
75
+
76
+ const clearRoomId = useCallback((): void => {
77
+ setRoom(null);
78
+ syncUrl(null);
79
+ }, [syncUrl]);
80
+
81
+ return { roomId, setRoomId, clearRoomId };
82
+ }
83
+
84
+ /**
85
+ * Human-readable explanation for a relay failure, or `null` if the reason is
86
+ * unknown (an ordinary network drop, say, which the client will retry).
87
+ *
88
+ * Pass `disconnectReason` from `useGameClient`.
89
+ */
90
+ export function describeRelayError(reason: string | null): string | null {
91
+ switch (reason) {
92
+ case RelayErrorCodes.ROOM_NOT_FOUND:
93
+ return "That room isn't open. Check the code on the screen.";
94
+ case RelayErrorCodes.ROOM_FULL:
95
+ return "That room is full.";
96
+ case RelayErrorCodes.RATE_LIMITED:
97
+ return "Too many messages — slow down and try again.";
98
+ case RelayErrorCodes.ROOM_EXISTS:
99
+ return "That room is already hosted by another screen.";
100
+ case RelayErrorCodes.SERVER_BUSY:
101
+ return "The relay is busy. Try again in a moment.";
102
+ case RelayErrorCodes.MESSAGE_TOO_LARGE:
103
+ case RelayErrorCodes.MALFORMED:
104
+ case RelayErrorCodes.NOT_IN_ROOM:
105
+ return "The connection was rejected. Try rejoining.";
106
+ default:
107
+ return null;
108
+ }
109
+ }
@@ -40,6 +40,12 @@ export class RelayClientTransport implements ClientTransport {
40
40
  private state: number = TransportReadyState.CONNECTING;
41
41
  /** When set, the code reported to `onclose` instead of the raw socket code. */
42
42
  private pendingCloseCode: number | null = null;
43
+ /**
44
+ * Relay error code (e.g. `ROOM_NOT_FOUND`) behind a terminal close, reported
45
+ * as the close `reason` so the UI can say what actually went wrong instead of
46
+ * showing an indefinite "connecting".
47
+ */
48
+ private pendingCloseReason: string | null = null;
43
49
 
44
50
  onopen?: () => void;
45
51
  onmessage?: (data: string) => void;
@@ -74,7 +80,7 @@ export class RelayClientTransport implements ClientTransport {
74
80
  this.ws.onclose = (event: CloseEvent) => {
75
81
  this.state = TransportReadyState.CLOSED;
76
82
  const code = this.pendingCloseCode ?? event.code;
77
- this.onclose?.(code, event.reason);
83
+ this.onclose?.(code, this.pendingCloseReason ?? event.reason);
78
84
  };
79
85
 
80
86
  this.ws.onerror = (event) => this.onerror?.(event);
@@ -116,8 +122,11 @@ export class RelayClientTransport implements ClientTransport {
116
122
  break;
117
123
  case RelayMessageTypes.ERROR:
118
124
  // Room-level failures are terminal: report a policy close so the client
119
- // does not attempt to reconnect, then close the underlying socket.
125
+ // does not attempt to reconnect, then close the underlying socket. The
126
+ // relay's code travels along as the reason — "ROOM_NOT_FOUND" is
127
+ // actionable ("check the code"), a silent hang is not.
120
128
  this.pendingCloseCode = POLICY_CLOSE_CODE;
129
+ this.pendingCloseReason = msg.code;
121
130
  this.ws.close();
122
131
  break;
123
132
  // PEER_JOINED / PEER_LEFT / ROOM_CREATED are host-facing; ignored here.