@couch-kit/display 0.6.0 → 0.7.1

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,43 @@
1
1
  # @couch-kit/display
2
2
 
3
+ ## 0.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`072bc40`](https://github.com/faluciano/react-native-couch-kit/commit/072bc40f923dd9169f2bcb344deb733223402323), [`072bc40`](https://github.com/faluciano/react-native-couch-kit/commit/072bc40f923dd9169f2bcb344deb733223402323)]:
8
+ - @couch-kit/core@0.10.2
9
+ - @couch-kit/runtime@0.7.0
10
+ - @couch-kit/client@0.16.1
11
+
12
+ ## 0.7.0
13
+
14
+ ### Minor Changes
15
+
16
+ - [#210](https://github.com/faluciano/react-native-couch-kit/pull/210) [`0810bd2`](https://github.com/faluciano/react-native-couch-kit/commit/0810bd28a55fca11eb780c9ba19e3c8e17d4086d) Thanks [@faluciano](https://github.com/faluciano)! - **New features:** `RelayDisplayHost` takes its room back after a dropped relay connection. It used to lose the room on any drop.
17
+
18
+ When the display's network blinks, it moves to the new `reconnecting` status. It reconnects with backoff and claims the room with the resume token the relay issued at creation. Phones stay connected throughout and the room code does not change. On success it:
19
+
20
+ - disconnects phones that left while it was away
21
+ - connects phones that arrived, whose held JOINs follow
22
+ - re-sends the current state to everyone
23
+
24
+ The room ends, as before, if:
25
+
26
+ - the relay's 30-second grace period runs out
27
+ - the relay no longer has the room
28
+ - the relay closed the display on purpose
29
+ - the relay predates resumption
30
+
31
+ Set `resume: false` to end the room on any drop. `stop()` now closes with code 1000, so the relay ends the room at once rather than holding it.
32
+
33
+ **Migration:** `RelayDisplayStatus` gains `"reconnecting"`. An exhaustive `switch` over it needs a new case.
34
+
35
+ ### Patch Changes
36
+
37
+ - Updated dependencies [[`0810bd2`](https://github.com/faluciano/react-native-couch-kit/commit/0810bd28a55fca11eb780c9ba19e3c8e17d4086d), [`0810bd2`](https://github.com/faluciano/react-native-couch-kit/commit/0810bd28a55fca11eb780c9ba19e3c8e17d4086d)]:
38
+ - @couch-kit/client@0.16.0
39
+ - @couch-kit/runtime@0.6.0
40
+
3
41
  ## 0.6.0
4
42
 
5
43
  ### Minor Changes
package/README.md CHANGED
@@ -69,7 +69,8 @@ plus the relay coordinates:
69
69
  | `reducer` | The shared game reducer |
70
70
  | `initialState` | The shared initial state |
71
71
 
72
- | `onStatusChange` | Called when the relay connection changes: `connecting` → `open` → `closed` |
72
+ | `onStatusChange` | Called when the relay connection changes: `connecting` → `open`, then `reconnecting` ↔ `open` across drops, and `closed` at the end |
73
+ | `resume` | Take the room back after a dropped connection (default `true`). `false` ends the room on any drop |
73
74
  | `onError` | Receives runtime and relay errors; relay errors are `RelayError` with a `code` |
74
75
  | `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
 
@@ -79,23 +80,40 @@ Instance members:
79
80
  - `subscribe(listener)` — subscribe to state changes; returns an unsubscribe fn.
80
81
  - `dispatch(action)` — dispatch a trusted host-side action.
81
82
  - `status` — `connecting` until the relay confirms the room, then `open`;
82
- `closed` once the relay connection is gone.
83
+ `reconnecting` while taking the room back after a drop; `closed` once the
84
+ room is gone.
85
+ - `subscribeActions(listener)` — every action the runtime reduces, from any
86
+ source; pass the host to `useActionRecorder({ source })` to record sessions.
83
87
  - `stop()` — tear down the runtime and close the relay socket.
84
88
 
85
89
  ### When the relay connection drops
86
90
 
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:
91
+ When the room is created the relay hands the display a resume token. If the
92
+ display's socket then dies without closing — the tab's network blinks — the
93
+ relay keeps the room for 30 seconds, phones stay connected, and what they send
94
+ is held for the display. Meanwhile the display host:
90
95
 
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`.
96
+ - moves to `status: "reconnecting"`, keeping every player as they were,
97
+ - reconnects with backoff and claims the room with its token,
98
+ - on success, disconnects phones that left while it was away, connects phones
99
+ that arrived (their held JOINs follow), re-sends the current state to
100
+ everyone, and returns to `open`. The room code does not change.
94
101
 
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.
102
+ The room ends instead — the display marks every player `connected: false`,
103
+ moves to `status: "closed"`, and reports the loss through `onError` — when:
104
+
105
+ - the 30 seconds run out, or the relay no longer has the room (it restarted);
106
+ - the relay closed the display on purpose (a rate-limit breach);
107
+ - `resume: false` is set, or the relay predates resumption and issued no token.
108
+
109
+ The game state stays readable after `closed`, so the display can show what
110
+ happened rather than a board that silently stopped updating. Phones see
111
+ `disconnectReason: "HOST_LEFT"`, which `describeRelayError` turns into a
112
+ message for the join screen.
113
+
114
+ A page reload is not a drop: the browser closes the socket on purpose and the
115
+ game state is gone with the page, so the relay ends the room at once. So does
116
+ `stop()`.
99
117
 
100
118
  The host maps relay `PEER_JOINED` / `DATA` / `PEER_LEFT` to the runtime's
101
119
  `handleConnection` / `handleMessage` / `handleDisconnect`, and implements the
package/dist/index.js CHANGED
@@ -5,10 +5,15 @@ import {
5
5
  DEFAULT_MAX_MESSAGE_BYTES
6
6
  } from "@couch-kit/runtime";
7
7
  import {
8
+ RELAY_CLOSE_HOST_REPLACED,
9
+ RELAY_HOST_RESUME_GRACE_MS,
10
+ RelayErrorCodes,
8
11
  RelayMessageTypes,
9
12
  relayRoomUrl
10
13
  } from "@couch-kit/client";
11
14
  var DEFAULT_RELAY_STATE_THROTTLE_MS = 50;
15
+ var RECONNECT_BASE_DELAY_MS = 250;
16
+ var RECONNECT_MAX_DELAY_MS = 4000;
12
17
 
13
18
  class RelayError extends Error {
14
19
  code;
@@ -21,42 +26,39 @@ class RelayError extends Error {
21
26
 
22
27
  class RelayDisplayHost {
23
28
  runtime;
29
+ url;
24
30
  ws;
25
31
  assignedRoomId;
26
32
  onRoomCode;
27
33
  onStatusChange;
34
+ resumeEnabled;
28
35
  peers = new Set;
29
36
  currentStatus = "connecting";
30
37
  socketOpen = false;
31
38
  stopped = false;
39
+ resumeToken = null;
40
+ awaySince = null;
41
+ reconnectAttempts = 0;
42
+ reconnectTimer = null;
32
43
  constructor(options) {
33
- const { url, roomId, onRoomCode, onStatusChange, ...runtimeConfig } = options;
44
+ const {
45
+ url,
46
+ roomId,
47
+ onRoomCode,
48
+ onStatusChange,
49
+ resume,
50
+ ...runtimeConfig
51
+ } = options;
52
+ this.url = url;
34
53
  this.assignedRoomId = roomId ?? null;
35
54
  this.onRoomCode = onRoomCode;
36
55
  this.onStatusChange = onStatusChange;
56
+ this.resumeEnabled = resume ?? true;
37
57
  this.runtime = new GameHostRuntime({
38
58
  ...runtimeConfig,
39
59
  stateThrottleMs: runtimeConfig.stateThrottleMs ?? DEFAULT_RELAY_STATE_THROTTLE_MS
40
60
  });
41
- this.ws = new WebSocket(relayRoomUrl(url, roomId));
42
- this.ws.onopen = () => {
43
- this.socketOpen = true;
44
- this.ws.send(JSON.stringify({
45
- type: RelayMessageTypes.CREATE_ROOM,
46
- roomId: this.assignedRoomId ?? undefined
47
- }));
48
- };
49
- this.ws.onmessage = (event) => {
50
- let msg;
51
- try {
52
- msg = JSON.parse(event.data);
53
- } catch {
54
- return;
55
- }
56
- this.handleRelayMessage(msg);
57
- };
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);
61
+ this.connect();
60
62
  const transport = {
61
63
  send: (connectionId, message) => this.sendEnvelope(message, connectionId),
62
64
  broadcast: (message) => this.sendEnvelope(message),
@@ -77,12 +79,49 @@ class RelayDisplayHost {
77
79
  stop() {
78
80
  this.stopped = true;
79
81
  this.socketOpen = false;
82
+ this.clearReconnectTimer();
80
83
  this.peers.clear();
81
84
  this.runtime.setTransport(null);
82
85
  this.runtime.stop();
83
- this.ws.close();
86
+ this.ws.close(1000, "Display stopped");
84
87
  this.setStatus("closed");
85
88
  }
89
+ connect() {
90
+ const resumeToken = this.resumeToken;
91
+ const ws = new WebSocket(relayRoomUrl(this.url, this.assignedRoomId ?? undefined));
92
+ this.ws = ws;
93
+ ws.onopen = () => {
94
+ if (ws !== this.ws)
95
+ return;
96
+ this.socketOpen = true;
97
+ ws.send(JSON.stringify({
98
+ type: RelayMessageTypes.CREATE_ROOM,
99
+ roomId: this.assignedRoomId ?? undefined,
100
+ resumeToken: resumeToken ?? undefined
101
+ }));
102
+ };
103
+ ws.onmessage = (event) => {
104
+ if (ws !== this.ws)
105
+ return;
106
+ let msg;
107
+ try {
108
+ msg = JSON.parse(event.data);
109
+ } catch {
110
+ return;
111
+ }
112
+ this.handleRelayMessage(msg);
113
+ };
114
+ ws.onerror = (event) => {
115
+ if (ws !== this.ws || this.currentStatus === "reconnecting")
116
+ return;
117
+ this.runtime.handleError(event instanceof Error ? event : new Error("Relay socket error"));
118
+ };
119
+ ws.onclose = (event) => {
120
+ if (ws !== this.ws)
121
+ return;
122
+ this.handleSocketClose(event);
123
+ };
124
+ }
86
125
  setStatus(status) {
87
126
  if (this.currentStatus === status)
88
127
  return;
@@ -93,12 +132,76 @@ class RelayDisplayHost {
93
132
  this.socketOpen = false;
94
133
  if (this.stopped)
95
134
  return;
135
+ if (this.canResume(event)) {
136
+ this.awaySince ??= Date.now();
137
+ this.setStatus("reconnecting");
138
+ this.scheduleReconnect();
139
+ return;
140
+ }
141
+ this.endRoom(`Relay connection closed (code ${event?.code ?? "unknown"})` + (event?.reason ? `: ${event.reason}` : ""));
142
+ }
143
+ canResume(event) {
144
+ if (!this.resumeEnabled || this.resumeToken === null)
145
+ return false;
146
+ const code = event?.code;
147
+ if (code === 1008 || code === RELAY_CLOSE_HOST_REPLACED)
148
+ return false;
149
+ return this.awaySince === null || this.timeLeftToResume() > 0;
150
+ }
151
+ timeLeftToResume() {
152
+ if (this.awaySince === null)
153
+ return RELAY_HOST_RESUME_GRACE_MS;
154
+ return this.awaySince + RELAY_HOST_RESUME_GRACE_MS - Date.now();
155
+ }
156
+ scheduleReconnect() {
157
+ const remaining = this.timeLeftToResume();
158
+ if (remaining <= 0) {
159
+ this.endRoom("Could not reach the relay before the room expired");
160
+ return;
161
+ }
162
+ const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_DELAY_MS, remaining);
163
+ this.reconnectAttempts++;
164
+ this.clearReconnectTimer();
165
+ this.reconnectTimer = setTimeout(() => {
166
+ this.reconnectTimer = null;
167
+ if (!this.stopped)
168
+ this.connect();
169
+ }, delay);
170
+ }
171
+ clearReconnectTimer() {
172
+ if (this.reconnectTimer !== null)
173
+ clearTimeout(this.reconnectTimer);
174
+ this.reconnectTimer = null;
175
+ }
176
+ endRoom(reason) {
177
+ this.clearReconnectTimer();
178
+ this.resumeToken = null;
179
+ this.awaySince = null;
96
180
  for (const peerId of this.peers) {
97
181
  this.runtime.handleDisconnect(peerId);
98
182
  }
99
183
  this.peers.clear();
100
184
  this.setStatus("closed");
101
- this.runtime.handleError(new Error(`Relay connection closed (code ${event?.code ?? "unknown"})` + (event?.reason ? `: ${event.reason}` : "")));
185
+ this.runtime.handleError(new Error(reason));
186
+ }
187
+ handleResumed(peers) {
188
+ const present = new Set(peers);
189
+ for (const peerId of Array.from(this.peers)) {
190
+ if (present.has(peerId))
191
+ continue;
192
+ this.peers.delete(peerId);
193
+ this.runtime.handleDisconnect(peerId);
194
+ }
195
+ for (const peerId of present) {
196
+ if (this.peers.has(peerId))
197
+ continue;
198
+ this.peers.add(peerId);
199
+ this.runtime.handleConnection(peerId);
200
+ }
201
+ this.awaySince = null;
202
+ this.reconnectAttempts = 0;
203
+ this.setStatus("open");
204
+ this.runtime.resendState();
102
205
  }
103
206
  sendMultiEnvelope(entries) {
104
207
  if (!this.socketOpen)
@@ -160,10 +263,20 @@ class RelayDisplayHost {
160
263
  }
161
264
  case RelayMessageTypes.ROOM_CREATED:
162
265
  this.assignedRoomId = msg.roomId;
266
+ this.resumeToken = msg.resumeToken ?? null;
163
267
  this.setStatus("open");
164
268
  this.onRoomCode?.(msg.roomId);
165
269
  break;
270
+ case RelayMessageTypes.ROOM_RESUMED:
271
+ this.handleResumed(msg.peers);
272
+ break;
166
273
  case RelayMessageTypes.ERROR:
274
+ if (this.currentStatus === "reconnecting" && msg.code === RelayErrorCodes.ROOM_NOT_FOUND) {
275
+ this.socketOpen = false;
276
+ this.ws.close(1000, "Room expired");
277
+ this.endRoom("The room expired before the display reconnected");
278
+ break;
279
+ }
167
280
  this.runtime.handleError(new RelayError(msg.code, msg.message));
168
281
  break;
169
282
  }
@@ -17,11 +17,14 @@ export declare const DEFAULT_RELAY_STATE_THROTTLE_MS = 50;
17
17
  *
18
18
  * - `connecting` — socket opening, or open but the room not yet confirmed.
19
19
  * - `open` — the room exists and phones can join.
20
+ * - `reconnecting` — the connection dropped and the display is taking its
21
+ * room back. Phones stay connected to the relay meanwhile, and what they
22
+ * send is delivered once the room resumes.
20
23
  * - `closed` — the relay connection is gone, and the room with it. Terminal:
21
24
  * the game state is still readable, but a new {@link RelayDisplayHost} (and a
22
25
  * new room code) is needed for phones to rejoin.
23
26
  */
24
- export type RelayDisplayStatus = "connecting" | "open" | "closed";
27
+ export type RelayDisplayStatus = "connecting" | "open" | "reconnecting" | "closed";
25
28
  /** An error reported by the relay, carrying its machine-readable code. */
26
29
  export declare class RelayError extends Error {
27
30
  readonly code: RelayErrorCode;
@@ -60,6 +63,13 @@ export interface RelayDisplayHostOptions<S extends IGameState, A extends IAction
60
63
  * rather than a board that will never update again.
61
64
  */
62
65
  onStatusChange?: (status: RelayDisplayStatus) => void;
66
+ /**
67
+ * Whether to take the room back after the relay connection drops. Default
68
+ * `true`: a display whose network blinks reconnects within the relay's grace
69
+ * period (30 seconds on the reference relays) and keeps its room code and
70
+ * its phones. Set `false` to have any drop end the room, as before.
71
+ */
72
+ resume?: boolean;
63
73
  }
64
74
  /**
65
75
  * Browser **display host** for the cross-network relay transport.
@@ -80,17 +90,26 @@ export interface RelayDisplayHostOptions<S extends IGameState, A extends IAction
80
90
  */
81
91
  export declare class RelayDisplayHost<S extends IGameState, A extends IAction> {
82
92
  private readonly runtime;
83
- private readonly ws;
93
+ private readonly url;
94
+ /** The current relay socket; replaced on each reconnect. */
95
+ private ws;
84
96
  /** Null until the relay confirms the room, when the code is relay-assigned. */
85
97
  private assignedRoomId;
86
98
  private readonly onRoomCode?;
87
99
  private readonly onStatusChange?;
100
+ private readonly resumeEnabled;
88
101
  /** Connected phone connection ids (relay peer ids). */
89
102
  private readonly peers;
90
103
  private currentStatus;
91
104
  /** Whether frames can be written; a socket still connecting throws on send. */
92
105
  private socketOpen;
93
106
  private stopped;
107
+ /** The room's resume credential, from `ROOM_CREATED`. */
108
+ private resumeToken;
109
+ /** When the connection dropped, while reconnecting. */
110
+ private awaySince;
111
+ private reconnectAttempts;
112
+ private reconnectTimer;
94
113
  constructor(options: RelayDisplayHostOptions<S, A>);
95
114
  /**
96
115
  * The room code phones join with, or `null` before the relay has assigned
@@ -114,13 +133,44 @@ export declare class RelayDisplayHost<S extends IGameState, A extends IAction> {
114
133
  dispatch: (action: A) => void;
115
134
  /** Tear down the runtime and relay socket. */
116
135
  stop(): void;
136
+ /**
137
+ * Opens a relay socket and either creates the room or, once the relay has
138
+ * issued a resume token, takes it back.
139
+ *
140
+ * Every handler checks that its socket is still the current one: a socket
141
+ * replaced by a reconnect may still deliver a late close or message, and
142
+ * acting on it would tear down the connection that replaced it.
143
+ */
144
+ private connect;
117
145
  private setStatus;
118
146
  /**
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}.
147
+ * The relay connection ended. If the room can still be taken back, try;
148
+ * otherwise the room is gone.
122
149
  */
123
150
  private handleSocketClose;
151
+ /**
152
+ * Whether a drop is worth reconnecting after: the room exists and has a
153
+ * token, and the relay did not end it on purpose. A policy close (rate
154
+ * limited) ends the room on the relay's side, and a replaced host means
155
+ * another connection holds the token now.
156
+ */
157
+ private canResume;
158
+ /** How long until the relay gives up on this display. */
159
+ private timeLeftToResume;
160
+ private scheduleReconnect;
161
+ private clearReconnectTimer;
162
+ /**
163
+ * The room is gone, and every phone with it: mark them disconnected so the
164
+ * state on screen says so, and report the loss.
165
+ */
166
+ private endRoom;
167
+ /**
168
+ * The room is ours again. Phones that left while the display was away are
169
+ * disconnected, phones that arrived are connected (their JOINs follow as
170
+ * ordinary DATA), and everyone gets the current state: updates sent while
171
+ * the socket was down never left this machine.
172
+ */
173
+ private handleResumed;
124
174
  /**
125
175
  * Sends per-connection messages as one `DATA_MULTI` frame, which the relay
126
176
  * 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,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"}
1
+ {"version":3,"file":"relay-display-host.d.ts","sourceRoot":"","sources":["../src/relay-display-host.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@couch-kit/display",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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.15.1",
55
- "@couch-kit/core": "0.10.1",
56
- "@couch-kit/runtime": "0.5.0"
54
+ "@couch-kit/client": "0.16.1",
55
+ "@couch-kit/core": "0.10.2",
56
+ "@couch-kit/runtime": "0.7.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "typescript": "^7.0.0"
@@ -9,6 +9,9 @@ import {
9
9
  } from "@couch-kit/runtime";
10
10
  import type { IGameState, IAction, HostMessage } from "@couch-kit/core";
11
11
  import {
12
+ RELAY_CLOSE_HOST_REPLACED,
13
+ RELAY_HOST_RESUME_GRACE_MS,
14
+ RelayErrorCodes,
12
15
  RelayMessageTypes,
13
16
  relayRoomUrl,
14
17
  type RelayErrorCode,
@@ -32,11 +35,20 @@ export const DEFAULT_RELAY_STATE_THROTTLE_MS = 50;
32
35
  *
33
36
  * - `connecting` — socket opening, or open but the room not yet confirmed.
34
37
  * - `open` — the room exists and phones can join.
38
+ * - `reconnecting` — the connection dropped and the display is taking its
39
+ * room back. Phones stay connected to the relay meanwhile, and what they
40
+ * send is delivered once the room resumes.
35
41
  * - `closed` — the relay connection is gone, and the room with it. Terminal:
36
42
  * the game state is still readable, but a new {@link RelayDisplayHost} (and a
37
43
  * new room code) is needed for phones to rejoin.
38
44
  */
39
- export type RelayDisplayStatus = "connecting" | "open" | "closed";
45
+ export type RelayDisplayStatus =
46
+ "connecting" | "open" | "reconnecting" | "closed";
47
+
48
+ /** First delay before a reconnect attempt, doubled per failure. */
49
+ const RECONNECT_BASE_DELAY_MS = 250;
50
+ /** Longest wait between reconnect attempts. */
51
+ const RECONNECT_MAX_DELAY_MS = 4_000;
40
52
 
41
53
  /** An error reported by the relay, carrying its machine-readable code. */
42
54
  export class RelayError extends Error {
@@ -85,6 +97,13 @@ export interface RelayDisplayHostOptions<
85
97
  * rather than a board that will never update again.
86
98
  */
87
99
  onStatusChange?: (status: RelayDisplayStatus) => void;
100
+ /**
101
+ * Whether to take the room back after the relay connection drops. Default
102
+ * `true`: a display whose network blinks reconnects within the relay's grace
103
+ * period (30 seconds on the reference relays) and keeps its room code and
104
+ * its phones. Set `false` to have any drop end the room, as before.
105
+ */
106
+ resume?: boolean;
88
107
  }
89
108
 
90
109
  /**
@@ -106,60 +125,47 @@ export interface RelayDisplayHostOptions<
106
125
  */
107
126
  export class RelayDisplayHost<S extends IGameState, A extends IAction> {
108
127
  private readonly runtime: GameHostRuntime<S, A>;
109
- private readonly ws: WebSocket;
128
+ private readonly url: string;
129
+ /** The current relay socket; replaced on each reconnect. */
130
+ private ws!: WebSocket;
110
131
  /** Null until the relay confirms the room, when the code is relay-assigned. */
111
132
  private assignedRoomId: string | null;
112
133
  private readonly onRoomCode?: (roomCode: string) => void;
113
134
  private readonly onStatusChange?: (status: RelayDisplayStatus) => void;
135
+ private readonly resumeEnabled: boolean;
114
136
  /** Connected phone connection ids (relay peer ids). */
115
137
  private readonly peers = new Set<string>();
116
138
  private currentStatus: RelayDisplayStatus = "connecting";
117
139
  /** Whether frames can be written; a socket still connecting throws on send. */
118
140
  private socketOpen = false;
119
141
  private stopped = false;
142
+ /** The room's resume credential, from `ROOM_CREATED`. */
143
+ private resumeToken: string | null = null;
144
+ /** When the connection dropped, while reconnecting. */
145
+ private awaySince: number | null = null;
146
+ private reconnectAttempts = 0;
147
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
120
148
 
121
149
  constructor(options: RelayDisplayHostOptions<S, A>) {
122
- const { url, roomId, onRoomCode, onStatusChange, ...runtimeConfig } =
123
- options;
150
+ const {
151
+ url,
152
+ roomId,
153
+ onRoomCode,
154
+ onStatusChange,
155
+ resume,
156
+ ...runtimeConfig
157
+ } = options;
158
+ this.url = url;
124
159
  this.assignedRoomId = roomId ?? null;
125
160
  this.onRoomCode = onRoomCode;
126
161
  this.onStatusChange = onStatusChange;
162
+ this.resumeEnabled = resume ?? true;
127
163
  this.runtime = new GameHostRuntime<S, A>({
128
164
  ...runtimeConfig,
129
165
  stateThrottleMs:
130
166
  runtimeConfig.stateThrottleMs ?? DEFAULT_RELAY_STATE_THROTTLE_MS,
131
167
  });
132
- this.ws = new WebSocket(relayRoomUrl(url, roomId));
133
-
134
- this.ws.onopen = () => {
135
- this.socketOpen = true;
136
- // No roomId asks the relay to allocate one. Sending the field as
137
- // undefined omits it from the JSON, which is what the relay reads as
138
- // "you pick".
139
- this.ws.send(
140
- JSON.stringify({
141
- type: RelayMessageTypes.CREATE_ROOM,
142
- roomId: this.assignedRoomId ?? undefined,
143
- }),
144
- );
145
- };
146
-
147
- this.ws.onmessage = (event: MessageEvent) => {
148
- let msg: RelayServerMessage;
149
- try {
150
- msg = JSON.parse(event.data as string) as RelayServerMessage;
151
- } catch {
152
- return;
153
- }
154
- this.handleRelayMessage(msg);
155
- };
156
-
157
- this.ws.onerror = (event) =>
158
- this.runtime.handleError(
159
- event instanceof Error ? event : new Error("Relay socket error"),
160
- );
161
-
162
- this.ws.onclose = (event: CloseEvent) => this.handleSocketClose(event);
168
+ this.connect();
163
169
 
164
170
  const transport: GameRuntimeTransport = {
165
171
  send: (connectionId, message) => this.sendEnvelope(message, connectionId),
@@ -205,13 +211,72 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
205
211
  stop(): void {
206
212
  this.stopped = true;
207
213
  this.socketOpen = false;
214
+ this.clearReconnectTimer();
208
215
  this.peers.clear();
209
216
  this.runtime.setTransport(null);
210
217
  this.runtime.stop();
211
- this.ws.close();
218
+ // A deliberate close: the relay ends the room now rather than holding it
219
+ // for a resume that will never come.
220
+ this.ws.close(1000, "Display stopped");
212
221
  this.setStatus("closed");
213
222
  }
214
223
 
224
+ /**
225
+ * Opens a relay socket and either creates the room or, once the relay has
226
+ * issued a resume token, takes it back.
227
+ *
228
+ * Every handler checks that its socket is still the current one: a socket
229
+ * replaced by a reconnect may still deliver a late close or message, and
230
+ * acting on it would tear down the connection that replaced it.
231
+ */
232
+ private connect(): void {
233
+ const resumeToken = this.resumeToken;
234
+ const ws = new WebSocket(
235
+ relayRoomUrl(this.url, this.assignedRoomId ?? undefined),
236
+ );
237
+ this.ws = ws;
238
+
239
+ ws.onopen = () => {
240
+ if (ws !== this.ws) return;
241
+ this.socketOpen = true;
242
+ // No roomId asks the relay to allocate one. Sending the field as
243
+ // undefined omits it from the JSON, which is what the relay reads as
244
+ // "you pick".
245
+ ws.send(
246
+ JSON.stringify({
247
+ type: RelayMessageTypes.CREATE_ROOM,
248
+ roomId: this.assignedRoomId ?? undefined,
249
+ resumeToken: resumeToken ?? undefined,
250
+ }),
251
+ );
252
+ };
253
+
254
+ ws.onmessage = (event: MessageEvent) => {
255
+ if (ws !== this.ws) return;
256
+ let msg: RelayServerMessage;
257
+ try {
258
+ msg = JSON.parse(event.data as string) as RelayServerMessage;
259
+ } catch {
260
+ return;
261
+ }
262
+ this.handleRelayMessage(msg);
263
+ };
264
+
265
+ ws.onerror = (event) => {
266
+ // A failed reconnect attempt is expected while the network is down; its
267
+ // close event decides what happens next.
268
+ if (ws !== this.ws || this.currentStatus === "reconnecting") return;
269
+ this.runtime.handleError(
270
+ event instanceof Error ? event : new Error("Relay socket error"),
271
+ );
272
+ };
273
+
274
+ ws.onclose = (event: CloseEvent) => {
275
+ if (ws !== this.ws) return;
276
+ this.handleSocketClose(event);
277
+ };
278
+ }
279
+
215
280
  private setStatus(status: RelayDisplayStatus): void {
216
281
  if (this.currentStatus === status) return;
217
282
  this.currentStatus = status;
@@ -219,25 +284,107 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
219
284
  }
220
285
 
221
286
  /**
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}.
287
+ * The relay connection ended. If the room can still be taken back, try;
288
+ * otherwise the room is gone.
225
289
  */
226
290
  private handleSocketClose(event: CloseEvent): void {
227
291
  this.socketOpen = false;
228
292
  if (this.stopped) return;
229
293
 
294
+ if (this.canResume(event)) {
295
+ this.awaySince ??= Date.now();
296
+ this.setStatus("reconnecting");
297
+ this.scheduleReconnect();
298
+ return;
299
+ }
300
+
301
+ this.endRoom(
302
+ `Relay connection closed (code ${event?.code ?? "unknown"})` +
303
+ (event?.reason ? `: ${event.reason}` : ""),
304
+ );
305
+ }
306
+
307
+ /**
308
+ * Whether a drop is worth reconnecting after: the room exists and has a
309
+ * token, and the relay did not end it on purpose. A policy close (rate
310
+ * limited) ends the room on the relay's side, and a replaced host means
311
+ * another connection holds the token now.
312
+ */
313
+ private canResume(event: CloseEvent): boolean {
314
+ if (!this.resumeEnabled || this.resumeToken === null) return false;
315
+ const code = event?.code;
316
+ if (code === 1008 || code === RELAY_CLOSE_HOST_REPLACED) return false;
317
+ return this.awaySince === null || this.timeLeftToResume() > 0;
318
+ }
319
+
320
+ /** How long until the relay gives up on this display. */
321
+ private timeLeftToResume(): number {
322
+ if (this.awaySince === null) return RELAY_HOST_RESUME_GRACE_MS;
323
+ return this.awaySince + RELAY_HOST_RESUME_GRACE_MS - Date.now();
324
+ }
325
+
326
+ private scheduleReconnect(): void {
327
+ const remaining = this.timeLeftToResume();
328
+ if (remaining <= 0) {
329
+ this.endRoom("Could not reach the relay before the room expired");
330
+ return;
331
+ }
332
+ const delay = Math.min(
333
+ RECONNECT_BASE_DELAY_MS * 2 ** this.reconnectAttempts,
334
+ RECONNECT_MAX_DELAY_MS,
335
+ remaining,
336
+ );
337
+ this.reconnectAttempts++;
338
+ this.clearReconnectTimer();
339
+ this.reconnectTimer = setTimeout(() => {
340
+ this.reconnectTimer = null;
341
+ if (!this.stopped) this.connect();
342
+ }, delay);
343
+ }
344
+
345
+ private clearReconnectTimer(): void {
346
+ if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer);
347
+ this.reconnectTimer = null;
348
+ }
349
+
350
+ /**
351
+ * The room is gone, and every phone with it: mark them disconnected so the
352
+ * state on screen says so, and report the loss.
353
+ */
354
+ private endRoom(reason: string): void {
355
+ this.clearReconnectTimer();
356
+ this.resumeToken = null;
357
+ this.awaySince = null;
230
358
  for (const peerId of this.peers) {
231
359
  this.runtime.handleDisconnect(peerId);
232
360
  }
233
361
  this.peers.clear();
234
362
  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
- );
363
+ this.runtime.handleError(new Error(reason));
364
+ }
365
+
366
+ /**
367
+ * The room is ours again. Phones that left while the display was away are
368
+ * disconnected, phones that arrived are connected (their JOINs follow as
369
+ * ordinary DATA), and everyone gets the current state: updates sent while
370
+ * the socket was down never left this machine.
371
+ */
372
+ private handleResumed(peers: readonly string[]): void {
373
+ const present = new Set(peers);
374
+ for (const peerId of Array.from(this.peers)) {
375
+ if (present.has(peerId)) continue;
376
+ this.peers.delete(peerId);
377
+ this.runtime.handleDisconnect(peerId);
378
+ }
379
+ for (const peerId of present) {
380
+ if (this.peers.has(peerId)) continue;
381
+ this.peers.add(peerId);
382
+ this.runtime.handleConnection(peerId);
383
+ }
384
+ this.awaySince = null;
385
+ this.reconnectAttempts = 0;
386
+ this.setStatus("open");
387
+ this.runtime.resendState();
241
388
  }
242
389
 
243
390
  /**
@@ -334,10 +481,25 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
334
481
  // Carries the code when the relay chose it, and confirms the code when
335
482
  // the caller supplied one.
336
483
  this.assignedRoomId = msg.roomId;
484
+ this.resumeToken = msg.resumeToken ?? null;
337
485
  this.setStatus("open");
338
486
  this.onRoomCode?.(msg.roomId);
339
487
  break;
488
+ case RelayMessageTypes.ROOM_RESUMED:
489
+ this.handleResumed(msg.peers);
490
+ break;
340
491
  case RelayMessageTypes.ERROR:
492
+ // The relay no longer has the room (or the token): it expired, or
493
+ // the relay restarted. Nothing left to reconnect to.
494
+ if (
495
+ this.currentStatus === "reconnecting" &&
496
+ msg.code === RelayErrorCodes.ROOM_NOT_FOUND
497
+ ) {
498
+ this.socketOpen = false;
499
+ this.ws.close(1000, "Room expired");
500
+ this.endRoom("The room expired before the display reconnected");
501
+ break;
502
+ }
341
503
  this.runtime.handleError(new RelayError(msg.code, msg.message));
342
504
  break;
343
505
  // ROOM_JOINED is an acknowledgement; no action needed.