@couch-kit/display 0.4.0 → 0.6.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,36 @@
1
1
  # @couch-kit/display
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#208](https://github.com/faluciano/react-native-couch-kit/pull/208) [`99598d6`](https://github.com/faluciano/react-native-couch-kit/commit/99598d6796b0bd3042aa9f3fbd3bf44ee891b890) Thanks [@faluciano](https://github.com/faluciano)! - `RelayDisplayHost` gains `subscribeActions(listener)`, the runtime's complete action stream: the display's own dispatches, players' actions, and join/leave lifecycle actions. State updates are no longer sent through the relay while no phone has joined the game.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [[`f4b57e3`](https://github.com/faluciano/react-native-couch-kit/commit/f4b57e3fba385d18396781d22a413a1d8afcb909), [`99598d6`](https://github.com/faluciano/react-native-couch-kit/commit/99598d6796b0bd3042aa9f3fbd3bf44ee891b890), [`99598d6`](https://github.com/faluciano/react-native-couch-kit/commit/99598d6796b0bd3042aa9f3fbd3bf44ee891b890)]:
12
+ - @couch-kit/client@0.15.1
13
+ - @couch-kit/core@0.10.1
14
+ - @couch-kit/runtime@0.5.0
15
+
16
+ ## 0.5.0
17
+
18
+ ### Minor Changes
19
+
20
+ - [#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.
21
+
22
+ - 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.
23
+ - Nothing is written to a socket that is not open. A display that dispatched before the socket opened threw from the broadcast timer.
24
+ - Room broadcasts are skipped while no phone is in the room, saving a billed relay message per state change in an empty lobby.
25
+ - `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.
26
+ - Relay errors reach `onError` as `RelayError` with the relay's `code`.
27
+
28
+ ### Patch Changes
29
+
30
+ - Updated dependencies [[`ddec122`](https://github.com/faluciano/react-native-couch-kit/commit/ddec122d9e415c54f157d6998c2b719ab31586b3), [`9465677`](https://github.com/faluciano/react-native-couch-kit/commit/9465677b63ad58273b8c0dee98ebece5d477a074)]:
31
+ - @couch-kit/client@0.15.0
32
+ - @couch-kit/runtime@0.4.0
33
+
3
34
  ## 0.4.0
4
35
 
5
36
  ### 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,6 +56,7 @@ 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
62
  broadcast: (message) => this.sendEnvelope(message),
@@ -47,15 +67,42 @@ class RelayDisplayHost {
47
67
  get roomCode() {
48
68
  return this.assignedRoomId;
49
69
  }
70
+ get status() {
71
+ return this.currentStatus;
72
+ }
50
73
  getState = () => this.runtime.getState();
51
74
  subscribe = (listener) => this.runtime.subscribe(listener);
75
+ subscribeActions = (listener) => this.runtime.subscribeActions(listener);
52
76
  dispatch = (action) => this.runtime.dispatch(action);
53
77
  stop() {
78
+ this.stopped = true;
79
+ this.socketOpen = false;
80
+ this.peers.clear();
54
81
  this.runtime.setTransport(null);
55
82
  this.runtime.stop();
56
83
  this.ws.close();
84
+ this.setStatus("closed");
85
+ }
86
+ setStatus(status) {
87
+ if (this.currentStatus === status)
88
+ return;
89
+ this.currentStatus = status;
90
+ this.onStatusChange?.(status);
91
+ }
92
+ handleSocketClose(event) {
93
+ this.socketOpen = false;
94
+ if (this.stopped)
95
+ return;
96
+ for (const peerId of this.peers) {
97
+ this.runtime.handleDisconnect(peerId);
98
+ }
99
+ this.peers.clear();
100
+ this.setStatus("closed");
101
+ this.runtime.handleError(new Error(`Relay connection closed (code ${event?.code ?? "unknown"})` + (event?.reason ? `: ${event.reason}` : "")));
57
102
  }
58
103
  sendMultiEnvelope(entries) {
104
+ if (!this.socketOpen)
105
+ return;
59
106
  const payloads = {};
60
107
  for (const { connectionId, message } of entries) {
61
108
  payloads[connectionId] = JSON.stringify(message);
@@ -74,6 +121,10 @@ class RelayDisplayHost {
74
121
  this.ws.send(frame);
75
122
  }
76
123
  sendEnvelope(message, to) {
124
+ if (!this.socketOpen)
125
+ return;
126
+ if (to === undefined && this.peers.size === 0)
127
+ return;
77
128
  const envelope = {
78
129
  type: RelayMessageTypes.DATA,
79
130
  roomId: this.assignedRoomId ?? undefined,
@@ -109,14 +160,17 @@ class RelayDisplayHost {
109
160
  }
110
161
  case RelayMessageTypes.ROOM_CREATED:
111
162
  this.assignedRoomId = msg.roomId;
163
+ this.setStatus("open");
112
164
  this.onRoomCode?.(msg.roomId);
113
165
  break;
114
166
  case RelayMessageTypes.ERROR:
115
- this.runtime.handleError(new Error(msg.message));
167
+ this.runtime.handleError(new RelayError(msg.code, msg.message));
116
168
  break;
117
169
  }
118
170
  }
119
171
  }
120
172
  export {
121
- RelayDisplayHost
173
+ DEFAULT_RELAY_STATE_THROTTLE_MS,
174
+ RelayDisplayHost,
175
+ RelayError
122
176
  };
@@ -1,5 +1,32 @@
1
- import { type GameHostRuntimeConfig } from "@couch-kit/runtime";
1
+ import { type GameHostRuntimeConfig, type RuntimeActionListener } 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: every frame the display sends is
8
+ * billed by the relay and counted against the display's rate limit, whose
9
+ * breach closes the socket — and, for the display, ends the room. 20
10
+ * broadcasts/second is smooth for a party game and leaves the display's budget
11
+ * (1200 messages/second on the reference relays) to the unicast replies it
12
+ * also sends: WELCOME, PONG, errors.
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,14 +98,29 @@ 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). */
66
106
  subscribe: (listener: () => void) => (() => void);
107
+ /**
108
+ * Subscribe to every action the runtime reduces — the display's own
109
+ * dispatches, players' actions, and join/leave lifecycle actions. A
110
+ * `RelayDisplayHost` can be passed straight to `useActionRecorder({ source })`.
111
+ */
112
+ subscribeActions: (listener: RuntimeActionListener<S, A>) => (() => void);
67
113
  /** Dispatch a trusted host-display action. */
68
114
  dispatch: (action: A) => void;
69
115
  /** Tear down the runtime and relay socket. */
70
116
  stop(): void;
117
+ private setStatus;
118
+ /**
119
+ * The relay connection ended. The relay drops the room with its host, so
120
+ * every phone is gone too: mark them disconnected so the state on screen
121
+ * says so, and report the loss unless this was our own {@link stop}.
122
+ */
123
+ private handleSocketClose;
71
124
  /**
72
125
  * Sends per-connection messages as one `DATA_MULTI` frame, which the relay
73
126
  * unpacks into an ordinary `DATA` frame per phone.
@@ -1 +1 @@
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;AAOxE;;;;;;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;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;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,iBAAiB;IAsBzB,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,EAE1B,KAAK,qBAAqB,EAC3B,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;;;;OAIG;IACH,gBAAgB,aAAc,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC,KAAG,CAAC,MAAM,IAAI,CAAC,CAC9B;IAE1C,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.4.0",
3
+ "version": "0.6.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.14.0",
55
- "@couch-kit/core": "0.10.0",
56
- "@couch-kit/runtime": "0.3.0"
54
+ "@couch-kit/client": "0.15.1",
55
+ "@couch-kit/core": "0.10.1",
56
+ "@couch-kit/runtime": "0.5.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "typescript": "^7.0.0"
@@ -5,14 +5,50 @@ import {
5
5
  type AddressedMessage,
6
6
  type GameHostRuntimeConfig,
7
7
  type GameRuntimeTransport,
8
+ type RuntimeActionListener,
8
9
  } from "@couch-kit/runtime";
9
10
  import type { IGameState, IAction, HostMessage } from "@couch-kit/core";
10
11
  import {
11
12
  RelayMessageTypes,
12
13
  relayRoomUrl,
14
+ type RelayErrorCode,
13
15
  type RelayServerMessage,
14
16
  } from "@couch-kit/client";
15
17
 
18
+ /**
19
+ * Default minimum interval (ms) between state broadcasts through a relay.
20
+ *
21
+ * Slower than the LAN default on purpose: every frame the display sends is
22
+ * billed by the relay and counted against the display's rate limit, whose
23
+ * breach closes the socket — and, for the display, ends the room. 20
24
+ * broadcasts/second is smooth for a party game and leaves the display's budget
25
+ * (1200 messages/second on the reference relays) to the unicast replies it
26
+ * also sends: WELCOME, PONG, errors.
27
+ */
28
+ export const DEFAULT_RELAY_STATE_THROTTLE_MS = 50;
29
+
30
+ /**
31
+ * Where the display's relay connection stands.
32
+ *
33
+ * - `connecting` — socket opening, or open but the room not yet confirmed.
34
+ * - `open` — the room exists and phones can join.
35
+ * - `closed` — the relay connection is gone, and the room with it. Terminal:
36
+ * the game state is still readable, but a new {@link RelayDisplayHost} (and a
37
+ * new room code) is needed for phones to rejoin.
38
+ */
39
+ export type RelayDisplayStatus = "connecting" | "open" | "closed";
40
+
41
+ /** An error reported by the relay, carrying its machine-readable code. */
42
+ export class RelayError extends Error {
43
+ readonly code: RelayErrorCode;
44
+
45
+ constructor(code: RelayErrorCode, message: string) {
46
+ super(message);
47
+ this.name = "RelayError";
48
+ this.code = code;
49
+ }
50
+ }
51
+
16
52
  /**
17
53
  * Options for {@link RelayDisplayHost}.
18
54
  *
@@ -43,6 +79,12 @@ export interface RelayDisplayHostOptions<
43
79
  * placeholder until this fires — roughly a round trip to the relay.
44
80
  */
45
81
  onRoomCode?: (roomCode: string) => void;
82
+ /**
83
+ * Called whenever {@link RelayDisplayHost.status} changes. `closed` is the
84
+ * one worth acting on: the room is gone, so show the players something
85
+ * rather than a board that will never update again.
86
+ */
87
+ onStatusChange?: (status: RelayDisplayStatus) => void;
46
88
  }
47
89
 
48
90
  /**
@@ -68,17 +110,29 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
68
110
  /** Null until the relay confirms the room, when the code is relay-assigned. */
69
111
  private assignedRoomId: string | null;
70
112
  private readonly onRoomCode?: (roomCode: string) => void;
113
+ private readonly onStatusChange?: (status: RelayDisplayStatus) => void;
71
114
  /** Connected phone connection ids (relay peer ids). */
72
115
  private readonly peers = new Set<string>();
116
+ private currentStatus: RelayDisplayStatus = "connecting";
117
+ /** Whether frames can be written; a socket still connecting throws on send. */
118
+ private socketOpen = false;
119
+ private stopped = false;
73
120
 
74
121
  constructor(options: RelayDisplayHostOptions<S, A>) {
75
- const { url, roomId, onRoomCode, ...runtimeConfig } = options;
122
+ const { url, roomId, onRoomCode, onStatusChange, ...runtimeConfig } =
123
+ options;
76
124
  this.assignedRoomId = roomId ?? null;
77
125
  this.onRoomCode = onRoomCode;
78
- this.runtime = new GameHostRuntime<S, A>(runtimeConfig);
126
+ this.onStatusChange = onStatusChange;
127
+ this.runtime = new GameHostRuntime<S, A>({
128
+ ...runtimeConfig,
129
+ stateThrottleMs:
130
+ runtimeConfig.stateThrottleMs ?? DEFAULT_RELAY_STATE_THROTTLE_MS,
131
+ });
79
132
  this.ws = new WebSocket(relayRoomUrl(url, roomId));
80
133
 
81
134
  this.ws.onopen = () => {
135
+ this.socketOpen = true;
82
136
  // No roomId asks the relay to allocate one. Sending the field as
83
137
  // undefined omits it from the JSON, which is what the relay reads as
84
138
  // "you pick".
@@ -105,6 +159,8 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
105
159
  event instanceof Error ? event : new Error("Relay socket error"),
106
160
  );
107
161
 
162
+ this.ws.onclose = (event: CloseEvent) => this.handleSocketClose(event);
163
+
108
164
  const transport: GameRuntimeTransport = {
109
165
  send: (connectionId, message) => this.sendEnvelope(message, connectionId),
110
166
  broadcast: (message) => this.sendEnvelope(message),
@@ -122,6 +178,11 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
122
178
  return this.assignedRoomId;
123
179
  }
124
180
 
181
+ /** Where the relay connection stands. See {@link RelayDisplayStatus}. */
182
+ get status(): RelayDisplayStatus {
183
+ return this.currentStatus;
184
+ }
185
+
125
186
  /** Current authoritative game state. */
126
187
  getState = (): S => this.runtime.getState();
127
188
 
@@ -129,14 +190,54 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
129
190
  subscribe = (listener: () => void): (() => void) =>
130
191
  this.runtime.subscribe(listener);
131
192
 
193
+ /**
194
+ * Subscribe to every action the runtime reduces — the display's own
195
+ * dispatches, players' actions, and join/leave lifecycle actions. A
196
+ * `RelayDisplayHost` can be passed straight to `useActionRecorder({ source })`.
197
+ */
198
+ subscribeActions = (listener: RuntimeActionListener<S, A>): (() => void) =>
199
+ this.runtime.subscribeActions(listener);
200
+
132
201
  /** Dispatch a trusted host-display action. */
133
202
  dispatch = (action: A): void => this.runtime.dispatch(action);
134
203
 
135
204
  /** Tear down the runtime and relay socket. */
136
205
  stop(): void {
206
+ this.stopped = true;
207
+ this.socketOpen = false;
208
+ this.peers.clear();
137
209
  this.runtime.setTransport(null);
138
210
  this.runtime.stop();
139
211
  this.ws.close();
212
+ this.setStatus("closed");
213
+ }
214
+
215
+ private setStatus(status: RelayDisplayStatus): void {
216
+ if (this.currentStatus === status) return;
217
+ this.currentStatus = status;
218
+ this.onStatusChange?.(status);
219
+ }
220
+
221
+ /**
222
+ * The relay connection ended. The relay drops the room with its host, so
223
+ * every phone is gone too: mark them disconnected so the state on screen
224
+ * says so, and report the loss unless this was our own {@link stop}.
225
+ */
226
+ private handleSocketClose(event: CloseEvent): void {
227
+ this.socketOpen = false;
228
+ if (this.stopped) return;
229
+
230
+ for (const peerId of this.peers) {
231
+ this.runtime.handleDisconnect(peerId);
232
+ }
233
+ this.peers.clear();
234
+ this.setStatus("closed");
235
+ this.runtime.handleError(
236
+ new Error(
237
+ `Relay connection closed (code ${event?.code ?? "unknown"})` +
238
+ (event?.reason ? `: ${event.reason}` : ""),
239
+ ),
240
+ );
140
241
  }
141
242
 
142
243
  /**
@@ -154,6 +255,8 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
154
255
  * messages; being dropped costs the game.
155
256
  */
156
257
  private sendMultiEnvelope(entries: readonly AddressedMessage[]): void {
258
+ if (!this.socketOpen) return;
259
+
157
260
  const payloads: Record<string, string> = {};
158
261
  for (const { connectionId, message } of entries) {
159
262
  payloads[connectionId] = JSON.stringify(message);
@@ -176,6 +279,14 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
176
279
  }
177
280
 
178
281
  private sendEnvelope(message: HostMessage, to?: string): void {
282
+ // Nothing to write to yet (or any more): a socket that is still connecting
283
+ // throws on send, and the runtime may broadcast before it opens.
284
+ if (!this.socketOpen) return;
285
+ // A room broadcast with nobody in it is a frame the relay bills and
286
+ // rate-limits for no reader. A phone that joins later gets the whole state
287
+ // in its WELCOME.
288
+ if (to === undefined && this.peers.size === 0) return;
289
+
179
290
  const envelope: Record<string, unknown> = {
180
291
  type: RelayMessageTypes.DATA,
181
292
  // The relay routes by the sender's membership, not this field, so it is
@@ -223,10 +334,11 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
223
334
  // Carries the code when the relay chose it, and confirms the code when
224
335
  // the caller supplied one.
225
336
  this.assignedRoomId = msg.roomId;
337
+ this.setStatus("open");
226
338
  this.onRoomCode?.(msg.roomId);
227
339
  break;
228
340
  case RelayMessageTypes.ERROR:
229
- this.runtime.handleError(new Error(msg.message));
341
+ this.runtime.handleError(new RelayError(msg.code, msg.message));
230
342
  break;
231
343
  // ROOM_JOINED is an acknowledgement; no action needed.
232
344
  }