@couch-kit/display 0.5.0 → 0.7.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 +42 -0
- package/README.md +30 -12
- package/dist/index.js +136 -22
- package/lib/relay-display-host.d.ts +68 -12
- package/lib/relay-display-host.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/relay-display-host.ts +222 -51
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# @couch-kit/display
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#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.
|
|
8
|
+
|
|
9
|
+
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:
|
|
10
|
+
|
|
11
|
+
- disconnects phones that left while it was away
|
|
12
|
+
- connects phones that arrived, whose held JOINs follow
|
|
13
|
+
- re-sends the current state to everyone
|
|
14
|
+
|
|
15
|
+
The room ends, as before, if:
|
|
16
|
+
|
|
17
|
+
- the relay's 30-second grace period runs out
|
|
18
|
+
- the relay no longer has the room
|
|
19
|
+
- the relay closed the display on purpose
|
|
20
|
+
- the relay predates resumption
|
|
21
|
+
|
|
22
|
+
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.
|
|
23
|
+
|
|
24
|
+
**Migration:** `RelayDisplayStatus` gains `"reconnecting"`. An exhaustive `switch` over it needs a new case.
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- Updated dependencies [[`0810bd2`](https://github.com/faluciano/react-native-couch-kit/commit/0810bd28a55fca11eb780c9ba19e3c8e17d4086d), [`0810bd2`](https://github.com/faluciano/react-native-couch-kit/commit/0810bd28a55fca11eb780c9ba19e3c8e17d4086d)]:
|
|
29
|
+
- @couch-kit/client@0.16.0
|
|
30
|
+
- @couch-kit/runtime@0.6.0
|
|
31
|
+
|
|
32
|
+
## 0.6.0
|
|
33
|
+
|
|
34
|
+
### Minor Changes
|
|
35
|
+
|
|
36
|
+
- [#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.
|
|
37
|
+
|
|
38
|
+
### Patch Changes
|
|
39
|
+
|
|
40
|
+
- 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)]:
|
|
41
|
+
- @couch-kit/client@0.15.1
|
|
42
|
+
- @couch-kit/core@0.10.1
|
|
43
|
+
- @couch-kit/runtime@0.5.0
|
|
44
|
+
|
|
3
45
|
## 0.5.0
|
|
4
46
|
|
|
5
47
|
### 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`
|
|
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
|
-
`
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
-
|
|
92
|
-
-
|
|
93
|
-
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 {
|
|
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.
|
|
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),
|
|
@@ -72,16 +74,54 @@ class RelayDisplayHost {
|
|
|
72
74
|
}
|
|
73
75
|
getState = () => this.runtime.getState();
|
|
74
76
|
subscribe = (listener) => this.runtime.subscribe(listener);
|
|
77
|
+
subscribeActions = (listener) => this.runtime.subscribeActions(listener);
|
|
75
78
|
dispatch = (action) => this.runtime.dispatch(action);
|
|
76
79
|
stop() {
|
|
77
80
|
this.stopped = true;
|
|
78
81
|
this.socketOpen = false;
|
|
82
|
+
this.clearReconnectTimer();
|
|
79
83
|
this.peers.clear();
|
|
80
84
|
this.runtime.setTransport(null);
|
|
81
85
|
this.runtime.stop();
|
|
82
|
-
this.ws.close();
|
|
86
|
+
this.ws.close(1000, "Display stopped");
|
|
83
87
|
this.setStatus("closed");
|
|
84
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
|
+
}
|
|
85
125
|
setStatus(status) {
|
|
86
126
|
if (this.currentStatus === status)
|
|
87
127
|
return;
|
|
@@ -92,12 +132,76 @@ class RelayDisplayHost {
|
|
|
92
132
|
this.socketOpen = false;
|
|
93
133
|
if (this.stopped)
|
|
94
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;
|
|
95
180
|
for (const peerId of this.peers) {
|
|
96
181
|
this.runtime.handleDisconnect(peerId);
|
|
97
182
|
}
|
|
98
183
|
this.peers.clear();
|
|
99
184
|
this.setStatus("closed");
|
|
100
|
-
this.runtime.handleError(new Error(
|
|
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();
|
|
101
205
|
}
|
|
102
206
|
sendMultiEnvelope(entries) {
|
|
103
207
|
if (!this.socketOpen)
|
|
@@ -159,10 +263,20 @@ class RelayDisplayHost {
|
|
|
159
263
|
}
|
|
160
264
|
case RelayMessageTypes.ROOM_CREATED:
|
|
161
265
|
this.assignedRoomId = msg.roomId;
|
|
266
|
+
this.resumeToken = msg.resumeToken ?? null;
|
|
162
267
|
this.setStatus("open");
|
|
163
268
|
this.onRoomCode?.(msg.roomId);
|
|
164
269
|
break;
|
|
270
|
+
case RelayMessageTypes.ROOM_RESUMED:
|
|
271
|
+
this.handleResumed(msg.peers);
|
|
272
|
+
break;
|
|
165
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
|
+
}
|
|
166
280
|
this.runtime.handleError(new RelayError(msg.code, msg.message));
|
|
167
281
|
break;
|
|
168
282
|
}
|
|
@@ -1,15 +1,15 @@
|
|
|
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
3
|
import { type RelayErrorCode } from "@couch-kit/client";
|
|
4
4
|
/**
|
|
5
5
|
* Default minimum interval (ms) between state broadcasts through a relay.
|
|
6
6
|
*
|
|
7
|
-
* Slower than the LAN default on purpose:
|
|
8
|
-
*
|
|
9
|
-
* the socket —
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
13
|
*/
|
|
14
14
|
export declare const DEFAULT_RELAY_STATE_THROTTLE_MS = 50;
|
|
15
15
|
/**
|
|
@@ -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
|
|
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
|
|
@@ -104,17 +123,54 @@ export declare class RelayDisplayHost<S extends IGameState, A extends IAction> {
|
|
|
104
123
|
getState: () => S;
|
|
105
124
|
/** Subscribe to state changes (for `useSyncExternalStore` or manual render). */
|
|
106
125
|
subscribe: (listener: () => void) => (() => void);
|
|
126
|
+
/**
|
|
127
|
+
* Subscribe to every action the runtime reduces — the display's own
|
|
128
|
+
* dispatches, players' actions, and join/leave lifecycle actions. A
|
|
129
|
+
* `RelayDisplayHost` can be passed straight to `useActionRecorder({ source })`.
|
|
130
|
+
*/
|
|
131
|
+
subscribeActions: (listener: RuntimeActionListener<S, A>) => (() => void);
|
|
107
132
|
/** Dispatch a trusted host-display action. */
|
|
108
133
|
dispatch: (action: A) => void;
|
|
109
134
|
/** Tear down the runtime and relay socket. */
|
|
110
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;
|
|
111
145
|
private setStatus;
|
|
112
146
|
/**
|
|
113
|
-
* The relay connection ended.
|
|
114
|
-
*
|
|
115
|
-
* 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.
|
|
116
149
|
*/
|
|
117
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;
|
|
118
174
|
/**
|
|
119
175
|
* Sends per-connection messages as one `DATA_MULTI` frame, which the relay
|
|
120
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,
|
|
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.
|
|
3
|
+
"version": "0.7.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.
|
|
55
|
-
"@couch-kit/core": "0.10.
|
|
56
|
-
"@couch-kit/runtime": "0.
|
|
54
|
+
"@couch-kit/client": "0.16.0",
|
|
55
|
+
"@couch-kit/core": "0.10.1",
|
|
56
|
+
"@couch-kit/runtime": "0.6.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"typescript": "^7.0.0"
|
|
@@ -5,9 +5,13 @@ 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 {
|
|
12
|
+
RELAY_CLOSE_HOST_REPLACED,
|
|
13
|
+
RELAY_HOST_RESUME_GRACE_MS,
|
|
14
|
+
RelayErrorCodes,
|
|
11
15
|
RelayMessageTypes,
|
|
12
16
|
relayRoomUrl,
|
|
13
17
|
type RelayErrorCode,
|
|
@@ -17,12 +21,12 @@ import {
|
|
|
17
21
|
/**
|
|
18
22
|
* Default minimum interval (ms) between state broadcasts through a relay.
|
|
19
23
|
*
|
|
20
|
-
* Slower than the LAN default on purpose:
|
|
21
|
-
*
|
|
22
|
-
* the socket —
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* Slower than the LAN default on purpose: every frame the display sends is
|
|
25
|
+
* billed by the relay and counted against the display's rate limit, whose
|
|
26
|
+
* breach closes the socket — and, for the display, ends the room. 20
|
|
27
|
+
* broadcasts/second is smooth for a party game and leaves the display's budget
|
|
28
|
+
* (1200 messages/second on the reference relays) to the unicast replies it
|
|
29
|
+
* also sends: WELCOME, PONG, errors.
|
|
26
30
|
*/
|
|
27
31
|
export const DEFAULT_RELAY_STATE_THROTTLE_MS = 50;
|
|
28
32
|
|
|
@@ -31,11 +35,20 @@ export const DEFAULT_RELAY_STATE_THROTTLE_MS = 50;
|
|
|
31
35
|
*
|
|
32
36
|
* - `connecting` — socket opening, or open but the room not yet confirmed.
|
|
33
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.
|
|
34
41
|
* - `closed` — the relay connection is gone, and the room with it. Terminal:
|
|
35
42
|
* the game state is still readable, but a new {@link RelayDisplayHost} (and a
|
|
36
43
|
* new room code) is needed for phones to rejoin.
|
|
37
44
|
*/
|
|
38
|
-
export type RelayDisplayStatus =
|
|
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;
|
|
39
52
|
|
|
40
53
|
/** An error reported by the relay, carrying its machine-readable code. */
|
|
41
54
|
export class RelayError extends Error {
|
|
@@ -84,6 +97,13 @@ export interface RelayDisplayHostOptions<
|
|
|
84
97
|
* rather than a board that will never update again.
|
|
85
98
|
*/
|
|
86
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;
|
|
87
107
|
}
|
|
88
108
|
|
|
89
109
|
/**
|
|
@@ -105,60 +125,47 @@ export interface RelayDisplayHostOptions<
|
|
|
105
125
|
*/
|
|
106
126
|
export class RelayDisplayHost<S extends IGameState, A extends IAction> {
|
|
107
127
|
private readonly runtime: GameHostRuntime<S, A>;
|
|
108
|
-
private readonly
|
|
128
|
+
private readonly url: string;
|
|
129
|
+
/** The current relay socket; replaced on each reconnect. */
|
|
130
|
+
private ws!: WebSocket;
|
|
109
131
|
/** Null until the relay confirms the room, when the code is relay-assigned. */
|
|
110
132
|
private assignedRoomId: string | null;
|
|
111
133
|
private readonly onRoomCode?: (roomCode: string) => void;
|
|
112
134
|
private readonly onStatusChange?: (status: RelayDisplayStatus) => void;
|
|
135
|
+
private readonly resumeEnabled: boolean;
|
|
113
136
|
/** Connected phone connection ids (relay peer ids). */
|
|
114
137
|
private readonly peers = new Set<string>();
|
|
115
138
|
private currentStatus: RelayDisplayStatus = "connecting";
|
|
116
139
|
/** Whether frames can be written; a socket still connecting throws on send. */
|
|
117
140
|
private socketOpen = false;
|
|
118
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;
|
|
119
148
|
|
|
120
149
|
constructor(options: RelayDisplayHostOptions<S, A>) {
|
|
121
|
-
const {
|
|
122
|
-
|
|
150
|
+
const {
|
|
151
|
+
url,
|
|
152
|
+
roomId,
|
|
153
|
+
onRoomCode,
|
|
154
|
+
onStatusChange,
|
|
155
|
+
resume,
|
|
156
|
+
...runtimeConfig
|
|
157
|
+
} = options;
|
|
158
|
+
this.url = url;
|
|
123
159
|
this.assignedRoomId = roomId ?? null;
|
|
124
160
|
this.onRoomCode = onRoomCode;
|
|
125
161
|
this.onStatusChange = onStatusChange;
|
|
162
|
+
this.resumeEnabled = resume ?? true;
|
|
126
163
|
this.runtime = new GameHostRuntime<S, A>({
|
|
127
164
|
...runtimeConfig,
|
|
128
165
|
stateThrottleMs:
|
|
129
166
|
runtimeConfig.stateThrottleMs ?? DEFAULT_RELAY_STATE_THROTTLE_MS,
|
|
130
167
|
});
|
|
131
|
-
this.
|
|
132
|
-
|
|
133
|
-
this.ws.onopen = () => {
|
|
134
|
-
this.socketOpen = true;
|
|
135
|
-
// No roomId asks the relay to allocate one. Sending the field as
|
|
136
|
-
// undefined omits it from the JSON, which is what the relay reads as
|
|
137
|
-
// "you pick".
|
|
138
|
-
this.ws.send(
|
|
139
|
-
JSON.stringify({
|
|
140
|
-
type: RelayMessageTypes.CREATE_ROOM,
|
|
141
|
-
roomId: this.assignedRoomId ?? undefined,
|
|
142
|
-
}),
|
|
143
|
-
);
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
this.ws.onmessage = (event: MessageEvent) => {
|
|
147
|
-
let msg: RelayServerMessage;
|
|
148
|
-
try {
|
|
149
|
-
msg = JSON.parse(event.data as string) as RelayServerMessage;
|
|
150
|
-
} catch {
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
this.handleRelayMessage(msg);
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
this.ws.onerror = (event) =>
|
|
157
|
-
this.runtime.handleError(
|
|
158
|
-
event instanceof Error ? event : new Error("Relay socket error"),
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
this.ws.onclose = (event: CloseEvent) => this.handleSocketClose(event);
|
|
168
|
+
this.connect();
|
|
162
169
|
|
|
163
170
|
const transport: GameRuntimeTransport = {
|
|
164
171
|
send: (connectionId, message) => this.sendEnvelope(message, connectionId),
|
|
@@ -189,6 +196,14 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
|
|
|
189
196
|
subscribe = (listener: () => void): (() => void) =>
|
|
190
197
|
this.runtime.subscribe(listener);
|
|
191
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Subscribe to every action the runtime reduces — the display's own
|
|
201
|
+
* dispatches, players' actions, and join/leave lifecycle actions. A
|
|
202
|
+
* `RelayDisplayHost` can be passed straight to `useActionRecorder({ source })`.
|
|
203
|
+
*/
|
|
204
|
+
subscribeActions = (listener: RuntimeActionListener<S, A>): (() => void) =>
|
|
205
|
+
this.runtime.subscribeActions(listener);
|
|
206
|
+
|
|
192
207
|
/** Dispatch a trusted host-display action. */
|
|
193
208
|
dispatch = (action: A): void => this.runtime.dispatch(action);
|
|
194
209
|
|
|
@@ -196,13 +211,72 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
|
|
|
196
211
|
stop(): void {
|
|
197
212
|
this.stopped = true;
|
|
198
213
|
this.socketOpen = false;
|
|
214
|
+
this.clearReconnectTimer();
|
|
199
215
|
this.peers.clear();
|
|
200
216
|
this.runtime.setTransport(null);
|
|
201
217
|
this.runtime.stop();
|
|
202
|
-
|
|
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");
|
|
203
221
|
this.setStatus("closed");
|
|
204
222
|
}
|
|
205
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
|
+
|
|
206
280
|
private setStatus(status: RelayDisplayStatus): void {
|
|
207
281
|
if (this.currentStatus === status) return;
|
|
208
282
|
this.currentStatus = status;
|
|
@@ -210,25 +284,107 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
|
|
|
210
284
|
}
|
|
211
285
|
|
|
212
286
|
/**
|
|
213
|
-
* The relay connection ended.
|
|
214
|
-
*
|
|
215
|
-
* 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.
|
|
216
289
|
*/
|
|
217
290
|
private handleSocketClose(event: CloseEvent): void {
|
|
218
291
|
this.socketOpen = false;
|
|
219
292
|
if (this.stopped) return;
|
|
220
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;
|
|
221
358
|
for (const peerId of this.peers) {
|
|
222
359
|
this.runtime.handleDisconnect(peerId);
|
|
223
360
|
}
|
|
224
361
|
this.peers.clear();
|
|
225
362
|
this.setStatus("closed");
|
|
226
|
-
this.runtime.handleError(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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();
|
|
232
388
|
}
|
|
233
389
|
|
|
234
390
|
/**
|
|
@@ -325,10 +481,25 @@ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
|
|
|
325
481
|
// Carries the code when the relay chose it, and confirms the code when
|
|
326
482
|
// the caller supplied one.
|
|
327
483
|
this.assignedRoomId = msg.roomId;
|
|
484
|
+
this.resumeToken = msg.resumeToken ?? null;
|
|
328
485
|
this.setStatus("open");
|
|
329
486
|
this.onRoomCode?.(msg.roomId);
|
|
330
487
|
break;
|
|
488
|
+
case RelayMessageTypes.ROOM_RESUMED:
|
|
489
|
+
this.handleResumed(msg.peers);
|
|
490
|
+
break;
|
|
331
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
|
+
}
|
|
332
503
|
this.runtime.handleError(new RelayError(msg.code, msg.message));
|
|
333
504
|
break;
|
|
334
505
|
// ROOM_JOINED is an acknowledgement; no action needed.
|