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