@couch-kit/client 0.10.0 → 0.11.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,50 @@
1
1
  # @couch-kit/client
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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
8
+ when they don't have one.
9
+
10
+ A hosted controller opened without `?room=` has no LAN host to fall back to, so
11
+ it could only sit on "connecting" forever — and a wrong or expired code looked
12
+ exactly the same. Two additions fix that:
13
+
14
+ - `useGameClient` returns `disconnectReason`, carrying the relay's error code
15
+ (`ROOM_NOT_FOUND`, `ROOM_FULL`, …) for terminal failures. Previously the
16
+ transport collapsed these to an unexplained close.
17
+ - New `useRelayRoom()` tracks the room from `?room=CODE` and lets the app set one
18
+ (updating the URL so reloads and shared links keep it), plus
19
+ `normalizeRoomCode()` and `describeRelayError()` for the entry UI.
20
+
21
+ Games stay in control of rendering; the SDK supplies the state and the wording.
22
+
23
+ ### Patch Changes
24
+
25
+ - [#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
26
+ sent since abuse limits landed but the client's copy of the protocol never knew
27
+ about — a client could receive a code its own types called impossible.
28
+
29
+ A contract test now imports both copies of the wire constants and fails if they
30
+ ever diverge again.
31
+
32
+ ## 0.10.1
33
+
34
+ ### Patch Changes
35
+
36
+ - [#143](https://github.com/faluciano/react-native-couch-kit/pull/143) [`050239e`](https://github.com/faluciano/react-native-couch-kit/commit/050239e251b32641b0c754016510b70ae713fcaa) Thanks [@faluciano](https://github.com/faluciano)! - Room codes are now case-insensitive end to end.
37
+
38
+ A code created as `6DX8` could not be joined as `6dx8`: the Cloudflare relay
39
+ uppercased the code to route the connection to the right Durable Object, but the
40
+ room registry inside still keyed rooms by the raw string from the
41
+ `CREATE_ROOM` / `JOIN_ROOM` message. The join silently missed and the phone sat
42
+ on "connecting" forever.
43
+
44
+ Room codes get read off a TV and retyped or re-scanned, so case must not matter.
45
+ They are now canonicalised in one place — the routing core — which fixes both the
46
+ Bun relay and the Worker.
47
+
3
48
  ## 0.10.0
4
49
 
5
50
  ### Minor Changes
package/dist/index.js CHANGED
@@ -167,6 +167,7 @@ function interpretHostMessage(msg) {
167
167
  function useGameClient(config) {
168
168
  const [status, setStatus] = useState2("disconnected");
169
169
  const [playerId, setPlayerId] = useState2(null);
170
+ const [disconnectReason, setDisconnectReason] = useState2(null);
170
171
  const [state, dispatchLocal] = useReducer(createGameReducer(config.reducer), config.initialState);
171
172
  const socketRef = useRef2(null);
172
173
  const reconnectAttempts = useRef2(0);
@@ -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
@@ -46,6 +46,12 @@ export declare function useGameClient<S extends IGameState, A extends IAction>(c
46
46
  status: "connected" | "connecting" | "disconnected" | "error";
47
47
  state: S;
48
48
  playerId: string | null;
49
+ /**
50
+ * Why the last connection ended, if the transport reported a cause — for
51
+ * the relay, a `RelayErrorCodes` value like `ROOM_NOT_FOUND` or `ROOM_FULL`.
52
+ * `null` when connected or when the cause is unknown.
53
+ */
54
+ disconnectReason: string | null;
49
55
  sendAction: (action: A) => void;
50
56
  getServerTime: () => number;
51
57
  /** 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,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;;;;IAyOxB;;;;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.0",
3
+ "version": "0.11.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
package/src/client.ts CHANGED
@@ -76,6 +76,11 @@ export function useGameClient<S extends IGameState, A extends IAction>(
76
76
  "connecting" | "connected" | "disconnected" | "error"
77
77
  >("disconnected");
78
78
  const [playerId, setPlayerId] = useState<string | null>(null);
79
+ /**
80
+ * Why the last connection ended, when the transport knows. For the relay this
81
+ * is a {@link RelayErrorCodes} value such as `ROOM_NOT_FOUND`.
82
+ */
83
+ const [disconnectReason, setDisconnectReason] = useState<string | null>(null);
79
84
 
80
85
  // Local Optimistic State
81
86
  // Wrap the user's reducer with createGameReducer to handle HYDRATE automatically
@@ -195,8 +200,12 @@ export function useGameClient<S extends IGameState, A extends IAction>(
195
200
  }
196
201
  };
197
202
 
198
- transport.onclose = (code) => {
203
+ transport.onclose = (code, reason) => {
199
204
  setStatus("disconnected");
205
+ // Terminal room-level failures carry a relay error code (ROOM_NOT_FOUND,
206
+ // ROOM_FULL, …). Surfacing it lets the UI explain the failure rather than
207
+ // sit on "connecting" forever.
208
+ setDisconnectReason(reason ? reason : null);
200
209
  configRef.current.onDisconnect?.();
201
210
 
202
211
  // Don't reconnect if the close was intentional or if the server
@@ -294,6 +303,12 @@ export function useGameClient<S extends IGameState, A extends IAction>(
294
303
  status,
295
304
  state,
296
305
  playerId,
306
+ /**
307
+ * Why the last connection ended, if the transport reported a cause — for
308
+ * the relay, a `RelayErrorCodes` value like `ROOM_NOT_FOUND` or `ROOM_FULL`.
309
+ * `null` when connected or when the cause is unknown.
310
+ */
311
+ disconnectReason,
297
312
  sendAction,
298
313
  getServerTime,
299
314
  /** 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.