@couch-kit/display 0.3.0 → 0.5.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,58 @@
1
1
  # @couch-kit/display
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#206](https://github.com/faluciano/react-native-couch-kit/pull/206) [`a438965`](https://github.com/faluciano/react-native-couch-kit/commit/a43896586f43a2f7e4811fb9c4394a25b1018d69) Thanks [@faluciano](https://github.com/faluciano)! - Make `RelayDisplayHost` aware of its relay connection.
8
+
9
+ - New `status` (`connecting` → `open` → `closed`) and `onStatusChange` option. When the relay connection drops, every player is marked disconnected, the status becomes `closed`, and the loss is reported through `onError` — previously the display kept running with no sign that the room was gone.
10
+ - Nothing is written to a socket that is not open. A display that dispatched before the socket opened threw from the broadcast timer.
11
+ - Room broadcasts are skipped while no phone is in the room, saving a billed relay message per state change in an empty lobby.
12
+ - `stateThrottleMs` defaults to 50ms here (`DEFAULT_RELAY_STATE_THROTTLE_MS`) so a continuously updating game stays inside the relay's 30 messages/second limit, which closes the display's socket when exceeded.
13
+ - Relay errors reach `onError` as `RelayError` with the relay's `code`.
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [[`ddec122`](https://github.com/faluciano/react-native-couch-kit/commit/ddec122d9e415c54f157d6998c2b719ab31586b3), [`9465677`](https://github.com/faluciano/react-native-couch-kit/commit/9465677b63ad58273b8c0dee98ebece5d477a074)]:
18
+ - @couch-kit/client@0.15.0
19
+ - @couch-kit/runtime@0.4.0
20
+
21
+ ## 0.4.0
22
+
23
+ ### Minor Changes
24
+
25
+ - [#164](https://github.com/faluciano/react-native-couch-kit/pull/164) [`a157126`](https://github.com/faluciano/react-native-couch-kit/commit/a157126424e4d73dcc7185118d5be0db6719792e) Thanks [@faluciano](https://github.com/faluciano)! - Send a projected state update as one relay frame instead of one per player
26
+
27
+ A game with a `project` function sends every player their own view, which meant
28
+ one WebSocket frame per player for every state change. Relays bill and
29
+ rate-limit per inbound frame, so a four-player table paid four messages for one
30
+ update and spent four of the display's 30-per-second budget.
31
+
32
+ `GameRuntimeTransport` gains an optional `sendMany(entries)`. When a transport
33
+ implements it, the runtime hands over the whole projected batch at once;
34
+ transports that do not — the LAN WebSocket path — keep receiving one `send` per
35
+ connection and are unaffected.
36
+
37
+ `RelayDisplayHost` implements it with a new `DATA_MULTI` envelope carrying a
38
+ peer-id-to-payload map, which the relay unpacks into ordinary `DATA` frames.
39
+ Phones need no update — nothing on the client side can tell a batched update
40
+ from a unicast one. If the combined frame would exceed the relay's 256KB
41
+ ceiling, the display falls back to individual frames rather than send something
42
+ the relay would drop.
43
+
44
+ Relays must be updated before displays: both bundled implementations
45
+ (`services/relay`, `services/relay-worker`) understand `DATA_MULTI`, and an
46
+ older relay answers it with `MALFORMED`. The type is host-only — a phone sending
47
+ it is rejected, so it cannot be used to reach another phone directly.
48
+
49
+ ### Patch Changes
50
+
51
+ - Updated dependencies [[`a157126`](https://github.com/faluciano/react-native-couch-kit/commit/a157126424e4d73dcc7185118d5be0db6719792e), [`a157126`](https://github.com/faluciano/react-native-couch-kit/commit/a157126424e4d73dcc7185118d5be0db6719792e)]:
52
+ - @couch-kit/runtime@0.3.0
53
+ - @couch-kit/client@0.14.0
54
+ - @couch-kit/core@0.10.0
55
+
3
56
  ## 0.3.0
4
57
 
5
58
  ### Minor Changes
package/README.md CHANGED
@@ -69,13 +69,34 @@ plus the relay coordinates:
69
69
  | `reducer` | The shared game reducer |
70
70
  | `initialState` | The shared initial state |
71
71
 
72
- Instance methods:
72
+ | `onStatusChange` | Called when the relay connection changes: `connecting` → `open` → `closed` |
73
+ | `onError` | Receives runtime and relay errors; relay errors are `RelayError` with a `code` |
74
+ | `stateThrottleMs` | Minimum interval between state broadcasts. Defaults to 50ms here (not the LAN default of 33ms) so a continuously updating game stays inside the relay's per-connection rate limit |
75
+
76
+ Instance members:
73
77
 
74
78
  - `getState()` — current authoritative state.
75
79
  - `subscribe(listener)` — subscribe to state changes; returns an unsubscribe fn.
76
80
  - `dispatch(action)` — dispatch a trusted host-side action.
81
+ - `status` — `connecting` until the relay confirms the room, then `open`;
82
+ `closed` once the relay connection is gone.
77
83
  - `stop()` — tear down the runtime and close the relay socket.
78
84
 
85
+ ### When the relay connection drops
86
+
87
+ A relay room lives exactly as long as its display's socket. If that socket
88
+ closes — the tab loses its network, the relay restarts — the relay closes every
89
+ phone in the room, and the display host:
90
+
91
+ - marks every player `connected: false` in the game state,
92
+ - moves to `status: "closed"` and calls `onStatusChange("closed")`,
93
+ - reports the loss through `onError`.
94
+
95
+ It does not reconnect: a new connection is a new room with a new code. The game
96
+ state stays readable, so the display can show what happened rather than a board
97
+ that silently stopped updating. Phones see `disconnectReason: "HOST_LEFT"`,
98
+ which `describeRelayError` turns into a message for the join screen.
99
+
79
100
  The host maps relay `PEER_JOINED` / `DATA` / `PEER_LEFT` to the runtime's
80
101
  `handleConnection` / `handleMessage` / `handleDisconnect`, and implements the
81
102
  runtime's transport by wrapping outbound messages in relay `DATA` envelopes
package/dist/index.js CHANGED
@@ -8,20 +8,39 @@ import {
8
8
  RelayMessageTypes,
9
9
  relayRoomUrl
10
10
  } from "@couch-kit/client";
11
+ var DEFAULT_RELAY_STATE_THROTTLE_MS = 50;
12
+
13
+ class RelayError extends Error {
14
+ code;
15
+ constructor(code, message) {
16
+ super(message);
17
+ this.name = "RelayError";
18
+ this.code = code;
19
+ }
20
+ }
11
21
 
12
22
  class RelayDisplayHost {
13
23
  runtime;
14
24
  ws;
15
25
  assignedRoomId;
16
26
  onRoomCode;
27
+ onStatusChange;
17
28
  peers = new Set;
29
+ currentStatus = "connecting";
30
+ socketOpen = false;
31
+ stopped = false;
18
32
  constructor(options) {
19
- const { url, roomId, onRoomCode, ...runtimeConfig } = options;
33
+ const { url, roomId, onRoomCode, onStatusChange, ...runtimeConfig } = options;
20
34
  this.assignedRoomId = roomId ?? null;
21
35
  this.onRoomCode = onRoomCode;
22
- this.runtime = new GameHostRuntime(runtimeConfig);
36
+ this.onStatusChange = onStatusChange;
37
+ this.runtime = new GameHostRuntime({
38
+ ...runtimeConfig,
39
+ stateThrottleMs: runtimeConfig.stateThrottleMs ?? DEFAULT_RELAY_STATE_THROTTLE_MS
40
+ });
23
41
  this.ws = new WebSocket(relayRoomUrl(url, roomId));
24
42
  this.ws.onopen = () => {
43
+ this.socketOpen = true;
25
44
  this.ws.send(JSON.stringify({
26
45
  type: RelayMessageTypes.CREATE_ROOM,
27
46
  roomId: this.assignedRoomId ?? undefined
@@ -37,24 +56,74 @@ class RelayDisplayHost {
37
56
  this.handleRelayMessage(msg);
38
57
  };
39
58
  this.ws.onerror = (event) => this.runtime.handleError(event instanceof Error ? event : new Error("Relay socket error"));
59
+ this.ws.onclose = (event) => this.handleSocketClose(event);
40
60
  const transport = {
41
61
  send: (connectionId, message) => this.sendEnvelope(message, connectionId),
42
- broadcast: (message) => this.sendEnvelope(message)
62
+ broadcast: (message) => this.sendEnvelope(message),
63
+ sendMany: (entries) => this.sendMultiEnvelope(entries)
43
64
  };
44
65
  this.runtime.setTransport(transport);
45
66
  }
46
67
  get roomCode() {
47
68
  return this.assignedRoomId;
48
69
  }
70
+ get status() {
71
+ return this.currentStatus;
72
+ }
49
73
  getState = () => this.runtime.getState();
50
74
  subscribe = (listener) => this.runtime.subscribe(listener);
51
75
  dispatch = (action) => this.runtime.dispatch(action);
52
76
  stop() {
77
+ this.stopped = true;
78
+ this.socketOpen = false;
79
+ this.peers.clear();
53
80
  this.runtime.setTransport(null);
54
81
  this.runtime.stop();
55
82
  this.ws.close();
83
+ this.setStatus("closed");
84
+ }
85
+ setStatus(status) {
86
+ if (this.currentStatus === status)
87
+ return;
88
+ this.currentStatus = status;
89
+ this.onStatusChange?.(status);
90
+ }
91
+ handleSocketClose(event) {
92
+ this.socketOpen = false;
93
+ if (this.stopped)
94
+ return;
95
+ for (const peerId of this.peers) {
96
+ this.runtime.handleDisconnect(peerId);
97
+ }
98
+ this.peers.clear();
99
+ this.setStatus("closed");
100
+ this.runtime.handleError(new Error(`Relay connection closed (code ${event?.code ?? "unknown"})` + (event?.reason ? `: ${event.reason}` : "")));
101
+ }
102
+ sendMultiEnvelope(entries) {
103
+ if (!this.socketOpen)
104
+ return;
105
+ const payloads = {};
106
+ for (const { connectionId, message } of entries) {
107
+ payloads[connectionId] = JSON.stringify(message);
108
+ }
109
+ const frame = JSON.stringify({
110
+ type: RelayMessageTypes.DATA_MULTI,
111
+ roomId: this.assignedRoomId ?? undefined,
112
+ payloads
113
+ });
114
+ if (frameByteLength(frame) > DEFAULT_MAX_MESSAGE_BYTES) {
115
+ for (const { connectionId, message } of entries) {
116
+ this.sendEnvelope(message, connectionId);
117
+ }
118
+ return;
119
+ }
120
+ this.ws.send(frame);
56
121
  }
57
122
  sendEnvelope(message, to) {
123
+ if (!this.socketOpen)
124
+ return;
125
+ if (to === undefined && this.peers.size === 0)
126
+ return;
58
127
  const envelope = {
59
128
  type: RelayMessageTypes.DATA,
60
129
  roomId: this.assignedRoomId ?? undefined,
@@ -90,14 +159,17 @@ class RelayDisplayHost {
90
159
  }
91
160
  case RelayMessageTypes.ROOM_CREATED:
92
161
  this.assignedRoomId = msg.roomId;
162
+ this.setStatus("open");
93
163
  this.onRoomCode?.(msg.roomId);
94
164
  break;
95
165
  case RelayMessageTypes.ERROR:
96
- this.runtime.handleError(new Error(msg.message));
166
+ this.runtime.handleError(new RelayError(msg.code, msg.message));
97
167
  break;
98
168
  }
99
169
  }
100
170
  }
101
171
  export {
102
- RelayDisplayHost
172
+ DEFAULT_RELAY_STATE_THROTTLE_MS,
173
+ RelayDisplayHost,
174
+ RelayError
103
175
  };
@@ -1,5 +1,32 @@
1
1
  import { type GameHostRuntimeConfig } from "@couch-kit/runtime";
2
2
  import type { IGameState, IAction } from "@couch-kit/core";
3
+ import { type RelayErrorCode } from "@couch-kit/client";
4
+ /**
5
+ * Default minimum interval (ms) between state broadcasts through a relay.
6
+ *
7
+ * Slower than the LAN default on purpose: a relay rate-limits every connection
8
+ * (30 messages/second on the reference relays) and answers a breach by closing
9
+ * the socket — which, for the display, ends the room. 20 broadcasts/second
10
+ * leaves headroom for the unicast traffic the display also sends (WELCOME,
11
+ * PONG, errors), so a game that updates continuously cannot talk itself out of
12
+ * its own room.
13
+ */
14
+ export declare const DEFAULT_RELAY_STATE_THROTTLE_MS = 50;
15
+ /**
16
+ * Where the display's relay connection stands.
17
+ *
18
+ * - `connecting` — socket opening, or open but the room not yet confirmed.
19
+ * - `open` — the room exists and phones can join.
20
+ * - `closed` — the relay connection is gone, and the room with it. Terminal:
21
+ * the game state is still readable, but a new {@link RelayDisplayHost} (and a
22
+ * new room code) is needed for phones to rejoin.
23
+ */
24
+ export type RelayDisplayStatus = "connecting" | "open" | "closed";
25
+ /** An error reported by the relay, carrying its machine-readable code. */
26
+ export declare class RelayError extends Error {
27
+ readonly code: RelayErrorCode;
28
+ constructor(code: RelayErrorCode, message: string);
29
+ }
3
30
  /**
4
31
  * Options for {@link RelayDisplayHost}.
5
32
  *
@@ -27,6 +54,12 @@ export interface RelayDisplayHostOptions<S extends IGameState, A extends IAction
27
54
  * placeholder until this fires — roughly a round trip to the relay.
28
55
  */
29
56
  onRoomCode?: (roomCode: string) => void;
57
+ /**
58
+ * Called whenever {@link RelayDisplayHost.status} changes. `closed` is the
59
+ * one worth acting on: the room is gone, so show the players something
60
+ * rather than a board that will never update again.
61
+ */
62
+ onStatusChange?: (status: RelayDisplayStatus) => void;
30
63
  }
31
64
  /**
32
65
  * Browser **display host** for the cross-network relay transport.
@@ -51,8 +84,13 @@ export declare class RelayDisplayHost<S extends IGameState, A extends IAction> {
51
84
  /** Null until the relay confirms the room, when the code is relay-assigned. */
52
85
  private assignedRoomId;
53
86
  private readonly onRoomCode?;
87
+ private readonly onStatusChange?;
54
88
  /** Connected phone connection ids (relay peer ids). */
55
89
  private readonly peers;
90
+ private currentStatus;
91
+ /** Whether frames can be written; a socket still connecting throws on send. */
92
+ private socketOpen;
93
+ private stopped;
56
94
  constructor(options: RelayDisplayHostOptions<S, A>);
57
95
  /**
58
96
  * The room code phones join with, or `null` before the relay has assigned
@@ -60,6 +98,8 @@ export declare class RelayDisplayHost<S extends IGameState, A extends IAction> {
60
98
  * arrives.
61
99
  */
62
100
  get roomCode(): string | null;
101
+ /** Where the relay connection stands. See {@link RelayDisplayStatus}. */
102
+ get status(): RelayDisplayStatus;
63
103
  /** Current authoritative game state. */
64
104
  getState: () => S;
65
105
  /** Subscribe to state changes (for `useSyncExternalStore` or manual render). */
@@ -68,6 +108,28 @@ export declare class RelayDisplayHost<S extends IGameState, A extends IAction> {
68
108
  dispatch: (action: A) => void;
69
109
  /** Tear down the runtime and relay socket. */
70
110
  stop(): void;
111
+ private setStatus;
112
+ /**
113
+ * The relay connection ended. The relay drops the room with its host, so
114
+ * every phone is gone too: mark them disconnected so the state on screen
115
+ * says so, and report the loss unless this was our own {@link stop}.
116
+ */
117
+ private handleSocketClose;
118
+ /**
119
+ * Sends per-connection messages as one `DATA_MULTI` frame, which the relay
120
+ * unpacks into an ordinary `DATA` frame per phone.
121
+ *
122
+ * A projected game re-sends every player's view on every state change, so on
123
+ * a four-player table this is the difference between one billed relay message
124
+ * and four — and between one and four against the relay's per-connection rate
125
+ * limit, which the display shares across all its fan-out.
126
+ *
127
+ * Falls back to individual sends if the combined frame would exceed the
128
+ * relay's message ceiling: N views in one envelope is N times the bytes, and
129
+ * a frame the relay rejects delivers nothing to anyone. Splitting costs
130
+ * messages; being dropped costs the game.
131
+ */
132
+ private sendMultiEnvelope;
71
133
  private sendEnvelope;
72
134
  private handleRelayMessage;
73
135
  }
@@ -1 +1 @@
1
- {"version":3,"file":"relay-display-host.d.ts","sourceRoot":"","sources":["../src/relay-display-host.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,qBAAqB,EAE3B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAe,MAAM,iBAAiB,CAAC;AAOxE;;;;;;GAMG;AACH,MAAM,WAAW,uBAAuB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,CAC9E,SAAQ,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC;IACnC,gDAAgD;IAChD,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,gBAAgB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACnE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAC/B,+EAA+E;IAC/E,OAAO,CAAC,cAAc,CAAgB;IACtC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,CAA6B;IACzD,uDAAuD;IACvD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqB;IAE3C,YAAY,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,CAAC,EAwCjD;IAED;;;;OAIG;IACH,IAAI,QAAQ,IAAI,MAAM,GAAG,IAAI,CAE5B;IAED,wCAAwC;IACxC,QAAQ,QAAO,CAAC,CAA4B;IAE5C,gFAAgF;IAChF,SAAS,aAAc,MAAM,IAAI,KAAG,CAAC,MAAM,IAAI,CAAC,CACb;IAEnC,8CAA8C;IAC9C,QAAQ,WAAY,CAAC,KAAG,IAAI,CAAkC;IAE9D,8CAA8C;IAC9C,IAAI,IAAI,IAAI,CAIX;IAED,OAAO,CAAC,YAAY;IAapB,OAAO,CAAC,kBAAkB;CA2C3B"}
1
+ {"version":3,"file":"relay-display-host.d.ts","sourceRoot":"","sources":["../src/relay-display-host.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,qBAAqB,EAE3B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAe,MAAM,iBAAiB,CAAC;AACxE,OAAO,EAGL,KAAK,cAAc,EAEpB,MAAM,mBAAmB,CAAC;AAE3B;;;;;;;;;GASG;AACH,eAAO,MAAM,+BAA+B,KAAK,CAAC;AAElD;;;;;;;;GAQG;AACH,MAAM,MAAM,kBAAkB,GAAG,YAAY,GAAG,MAAM,GAAG,QAAQ,CAAC;AAElE,0EAA0E;AAC1E,qBAAa,UAAW,SAAQ,KAAK;IACnC,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAE9B,YAAY,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,EAIhD;CACF;AAED;;;;;;GAMG;AACH,MAAM,WAAW,uBAAuB,CACtC,CAAC,SAAS,UAAU,EACpB,CAAC,SAAS,OAAO,CACjB,SAAQ,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC;IACnC,gDAAgD;IAChD,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,IAAI,CAAC;CACvD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,gBAAgB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACnE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAC/B,+EAA+E;IAC/E,OAAO,CAAC,cAAc,CAAgB;IACtC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,CAA6B;IACzD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAuC;IACvE,uDAAuD;IACvD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqB;IAC3C,OAAO,CAAC,aAAa,CAAoC;IACzD,+EAA+E;IAC/E,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,OAAO,CAAS;IAExB,YAAY,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,CAAC,EAiDjD;IAED;;;;OAIG;IACH,IAAI,QAAQ,IAAI,MAAM,GAAG,IAAI,CAE5B;IAED,yEAAyE;IACzE,IAAI,MAAM,IAAI,kBAAkB,CAE/B;IAED,wCAAwC;IACxC,QAAQ,QAAO,CAAC,CAA4B;IAE5C,gFAAgF;IAChF,SAAS,aAAc,MAAM,IAAI,KAAG,CAAC,MAAM,IAAI,CAAC,CACb;IAEnC,8CAA8C;IAC9C,QAAQ,WAAY,CAAC,KAAG,IAAI,CAAkC;IAE9D,8CAA8C;IAC9C,IAAI,IAAI,IAAI,CAQX;IAED,OAAO,CAAC,SAAS;IAMjB;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;IAiBzB;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,iBAAiB;IAwBzB,OAAO,CAAC,YAAY;IAqBpB,OAAO,CAAC,kBAAkB;CA4C3B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@couch-kit/display",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
@@ -51,9 +51,9 @@
51
51
  "clean": "rm -rf dist lib"
52
52
  },
53
53
  "dependencies": {
54
- "@couch-kit/client": "0.13.0",
55
- "@couch-kit/core": "0.9.3",
56
- "@couch-kit/runtime": "0.2.0"
54
+ "@couch-kit/client": "0.15.0",
55
+ "@couch-kit/core": "0.10.0",
56
+ "@couch-kit/runtime": "0.4.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "typescript": "^7.0.0"
@@ -2,6 +2,7 @@ import {
2
2
  GameHostRuntime,
3
3
  frameByteLength,
4
4
  DEFAULT_MAX_MESSAGE_BYTES,
5
+ type AddressedMessage,
5
6
  type GameHostRuntimeConfig,
6
7
  type GameRuntimeTransport,
7
8
  } from "@couch-kit/runtime";
@@ -9,9 +10,44 @@ import type { IGameState, IAction, HostMessage } from "@couch-kit/core";
9
10
  import {
10
11
  RelayMessageTypes,
11
12
  relayRoomUrl,
13
+ type RelayErrorCode,
12
14
  type RelayServerMessage,
13
15
  } from "@couch-kit/client";
14
16
 
17
+ /**
18
+ * Default minimum interval (ms) between state broadcasts through a relay.
19
+ *
20
+ * Slower than the LAN default on purpose: a relay rate-limits every connection
21
+ * (30 messages/second on the reference relays) and answers a breach by closing
22
+ * the socket — which, for the display, ends the room. 20 broadcasts/second
23
+ * leaves headroom for the unicast traffic the display also sends (WELCOME,
24
+ * PONG, errors), so a game that updates continuously cannot talk itself out of
25
+ * its own room.
26
+ */
27
+ export const DEFAULT_RELAY_STATE_THROTTLE_MS = 50;
28
+
29
+ /**
30
+ * Where the display's relay connection stands.
31
+ *
32
+ * - `connecting` — socket opening, or open but the room not yet confirmed.
33
+ * - `open` — the room exists and phones can join.
34
+ * - `closed` — the relay connection is gone, and the room with it. Terminal:
35
+ * the game state is still readable, but a new {@link RelayDisplayHost} (and a
36
+ * new room code) is needed for phones to rejoin.
37
+ */
38
+ export type RelayDisplayStatus = "connecting" | "open" | "closed";
39
+
40
+ /** An error reported by the relay, carrying its machine-readable code. */
41
+ export class RelayError extends Error {
42
+ readonly code: RelayErrorCode;
43
+
44
+ constructor(code: RelayErrorCode, message: string) {
45
+ super(message);
46
+ this.name = "RelayError";
47
+ this.code = code;
48
+ }
49
+ }
50
+
15
51
  /**
16
52
  * Options for {@link RelayDisplayHost}.
17
53
  *
@@ -19,8 +55,10 @@ import {
19
55
  * object used by the RN-TV host) and the shared relay coordinates; the display
20
56
  * host owns the authoritative runtime and the relay socket.
21
57
  */
22
- export interface RelayDisplayHostOptions<S extends IGameState, A extends IAction>
23
- extends GameHostRuntimeConfig<S, A> {
58
+ export interface RelayDisplayHostOptions<
59
+ S extends IGameState,
60
+ A extends IAction,
61
+ > extends GameHostRuntimeConfig<S, A> {
24
62
  /** WebSocket URL of the shared relay server. */
25
63
  url: string;
26
64
  /**
@@ -40,6 +78,12 @@ export interface RelayDisplayHostOptions<S extends IGameState, A extends IAction
40
78
  * placeholder until this fires — roughly a round trip to the relay.
41
79
  */
42
80
  onRoomCode?: (roomCode: string) => void;
81
+ /**
82
+ * Called whenever {@link RelayDisplayHost.status} changes. `closed` is the
83
+ * one worth acting on: the room is gone, so show the players something
84
+ * rather than a board that will never update again.
85
+ */
86
+ onStatusChange?: (status: RelayDisplayStatus) => void;
43
87
  }
44
88
 
45
89
  /**
@@ -65,17 +109,29 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
65
109
  /** Null until the relay confirms the room, when the code is relay-assigned. */
66
110
  private assignedRoomId: string | null;
67
111
  private readonly onRoomCode?: (roomCode: string) => void;
112
+ private readonly onStatusChange?: (status: RelayDisplayStatus) => void;
68
113
  /** Connected phone connection ids (relay peer ids). */
69
114
  private readonly peers = new Set<string>();
115
+ private currentStatus: RelayDisplayStatus = "connecting";
116
+ /** Whether frames can be written; a socket still connecting throws on send. */
117
+ private socketOpen = false;
118
+ private stopped = false;
70
119
 
71
120
  constructor(options: RelayDisplayHostOptions<S, A>) {
72
- const { url, roomId, onRoomCode, ...runtimeConfig } = options;
121
+ const { url, roomId, onRoomCode, onStatusChange, ...runtimeConfig } =
122
+ options;
73
123
  this.assignedRoomId = roomId ?? null;
74
124
  this.onRoomCode = onRoomCode;
75
- this.runtime = new GameHostRuntime<S, A>(runtimeConfig);
125
+ this.onStatusChange = onStatusChange;
126
+ this.runtime = new GameHostRuntime<S, A>({
127
+ ...runtimeConfig,
128
+ stateThrottleMs:
129
+ runtimeConfig.stateThrottleMs ?? DEFAULT_RELAY_STATE_THROTTLE_MS,
130
+ });
76
131
  this.ws = new WebSocket(relayRoomUrl(url, roomId));
77
132
 
78
133
  this.ws.onopen = () => {
134
+ this.socketOpen = true;
79
135
  // No roomId asks the relay to allocate one. Sending the field as
80
136
  // undefined omits it from the JSON, which is what the relay reads as
81
137
  // "you pick".
@@ -102,10 +158,12 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
102
158
  event instanceof Error ? event : new Error("Relay socket error"),
103
159
  );
104
160
 
161
+ this.ws.onclose = (event: CloseEvent) => this.handleSocketClose(event);
162
+
105
163
  const transport: GameRuntimeTransport = {
106
- send: (connectionId, message) =>
107
- this.sendEnvelope(message, connectionId),
164
+ send: (connectionId, message) => this.sendEnvelope(message, connectionId),
108
165
  broadcast: (message) => this.sendEnvelope(message),
166
+ sendMany: (entries) => this.sendMultiEnvelope(entries),
109
167
  };
110
168
  this.runtime.setTransport(transport);
111
169
  }
@@ -119,6 +177,11 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
119
177
  return this.assignedRoomId;
120
178
  }
121
179
 
180
+ /** Where the relay connection stands. See {@link RelayDisplayStatus}. */
181
+ get status(): RelayDisplayStatus {
182
+ return this.currentStatus;
183
+ }
184
+
122
185
  /** Current authoritative game state. */
123
186
  getState = (): S => this.runtime.getState();
124
187
 
@@ -131,12 +194,90 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
131
194
 
132
195
  /** Tear down the runtime and relay socket. */
133
196
  stop(): void {
197
+ this.stopped = true;
198
+ this.socketOpen = false;
199
+ this.peers.clear();
134
200
  this.runtime.setTransport(null);
135
201
  this.runtime.stop();
136
202
  this.ws.close();
203
+ this.setStatus("closed");
204
+ }
205
+
206
+ private setStatus(status: RelayDisplayStatus): void {
207
+ if (this.currentStatus === status) return;
208
+ this.currentStatus = status;
209
+ this.onStatusChange?.(status);
210
+ }
211
+
212
+ /**
213
+ * The relay connection ended. The relay drops the room with its host, so
214
+ * every phone is gone too: mark them disconnected so the state on screen
215
+ * says so, and report the loss unless this was our own {@link stop}.
216
+ */
217
+ private handleSocketClose(event: CloseEvent): void {
218
+ this.socketOpen = false;
219
+ if (this.stopped) return;
220
+
221
+ for (const peerId of this.peers) {
222
+ this.runtime.handleDisconnect(peerId);
223
+ }
224
+ this.peers.clear();
225
+ this.setStatus("closed");
226
+ this.runtime.handleError(
227
+ new Error(
228
+ `Relay connection closed (code ${event?.code ?? "unknown"})` +
229
+ (event?.reason ? `: ${event.reason}` : ""),
230
+ ),
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Sends per-connection messages as one `DATA_MULTI` frame, which the relay
236
+ * unpacks into an ordinary `DATA` frame per phone.
237
+ *
238
+ * A projected game re-sends every player's view on every state change, so on
239
+ * a four-player table this is the difference between one billed relay message
240
+ * and four — and between one and four against the relay's per-connection rate
241
+ * limit, which the display shares across all its fan-out.
242
+ *
243
+ * Falls back to individual sends if the combined frame would exceed the
244
+ * relay's message ceiling: N views in one envelope is N times the bytes, and
245
+ * a frame the relay rejects delivers nothing to anyone. Splitting costs
246
+ * messages; being dropped costs the game.
247
+ */
248
+ private sendMultiEnvelope(entries: readonly AddressedMessage[]): void {
249
+ if (!this.socketOpen) return;
250
+
251
+ const payloads: Record<string, string> = {};
252
+ for (const { connectionId, message } of entries) {
253
+ payloads[connectionId] = JSON.stringify(message);
254
+ }
255
+
256
+ const frame = JSON.stringify({
257
+ type: RelayMessageTypes.DATA_MULTI,
258
+ roomId: this.assignedRoomId ?? undefined,
259
+ payloads,
260
+ });
261
+
262
+ if (frameByteLength(frame) > DEFAULT_MAX_MESSAGE_BYTES) {
263
+ for (const { connectionId, message } of entries) {
264
+ this.sendEnvelope(message, connectionId);
265
+ }
266
+ return;
267
+ }
268
+
269
+ this.ws.send(frame);
137
270
  }
138
271
 
139
272
  private sendEnvelope(message: HostMessage, to?: string): void {
273
+ // Nothing to write to yet (or any more): a socket that is still connecting
274
+ // throws on send, and the runtime may broadcast before it opens.
275
+ if (!this.socketOpen) return;
276
+ // A room broadcast with nobody in it is a frame the relay bills and
277
+ // rate-limits for no reader. A phone that joins later gets the whole state
278
+ // in its WELCOME.
279
+ if (to === undefined && this.peers.size === 0) return;
280
+
140
281
  const envelope: Record<string, unknown> = {
141
282
  type: RelayMessageTypes.DATA,
142
283
  // The relay routes by the sender's membership, not this field, so it is
@@ -184,10 +325,11 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
184
325
  // Carries the code when the relay chose it, and confirms the code when
185
326
  // the caller supplied one.
186
327
  this.assignedRoomId = msg.roomId;
328
+ this.setStatus("open");
187
329
  this.onRoomCode?.(msg.roomId);
188
330
  break;
189
331
  case RelayMessageTypes.ERROR:
190
- this.runtime.handleError(new Error(msg.message));
332
+ this.runtime.handleError(new RelayError(msg.code, msg.message));
191
333
  break;
192
334
  // ROOM_JOINED is an acknowledgement; no action needed.
193
335
  }