@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 +38 -0
- package/README.md +30 -12
- package/dist/index.js +135 -22
- package/lib/relay-display-host.d.ts +55 -5
- package/lib/relay-display-host.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/relay-display-host.ts +207 -45
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`
|
|
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),
|
|
@@ -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(
|
|
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
|
|
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.
|
|
120
|
-
*
|
|
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,
|
|
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.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.
|
|
55
|
-
"@couch-kit/core": "0.10.
|
|
56
|
-
"@couch-kit/runtime": "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 =
|
|
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
|
|
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 {
|
|
123
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
223
|
-
*
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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.
|