@couch-kit/runtime 0.2.0 → 0.4.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 +45 -0
- package/dist/index.cjs +94 -26
- package/dist/index.js +94 -26
- package/lib/broadcast-scheduler.d.ts +8 -2
- package/lib/broadcast-scheduler.d.ts.map +1 -1
- package/lib/message-validation.d.ts +20 -0
- package/lib/message-validation.d.ts.map +1 -1
- package/lib/runtime.d.ts +17 -0
- package/lib/runtime.d.ts.map +1 -1
- package/lib/session-manager.d.ts +15 -0
- package/lib/session-manager.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/broadcast-scheduler.ts +17 -5
- package/src/message-validation.ts +49 -4
- package/src/runtime.ts +86 -22
- package/src/session-manager.ts +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# @couch-kit/runtime
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#206](https://github.com/faluciano/react-native-couch-kit/pull/206) [`9465677`](https://github.com/faluciano/react-native-couch-kit/commit/9465677b63ad58273b8c0dee98ebece5d477a074) Thanks [@faluciano](https://github.com/faluciano)! - Make state broadcasts a real throttle and harden JOIN handling.
|
|
8
|
+
|
|
9
|
+
- `BroadcastScheduler` no longer resets its timer on every change. It was a debounce: a host updating faster than `stateThrottleMs` sent nothing until the updates paused. The first change now opens a window and everything inside it is coalesced into one broadcast when the window closes.
|
|
10
|
+
- Projected games (`project`) no longer attach client actions to `STATE_UPDATE`. They leaked one player's action payload to every other player, which is what a projection exists to prevent.
|
|
11
|
+
- Actions that leave state unchanged are no longer queued for the next `STATE_UPDATE`, and the queue is capped. Previously they accumulated without bound until an unrelated change flushed them all.
|
|
12
|
+
- A JOIN whose socket closes while the player ID is being derived no longer touches session state. It used to cancel the player's pending removal (leaving a disconnected ghost forever) and could orphan their live connection. `HostSessionManager` gains `derivePlayerIdFor` and `registerJoin` for this; `handleJoin` is unchanged.
|
|
13
|
+
- JOIN fields are sanitized: the name is trimmed, capped at 64 characters and never blank; an avatar that is not a string of at most 16 KiB is dropped (the avatar is re-sent on every state update). A non-string `avatar` is rejected as `INVALID_MESSAGE`. New exports: `sanitizePlayerName`, `sanitizePlayerAvatar`, `MAX_PLAYER_NAME_LENGTH`, `MAX_PLAYER_AVATAR_LENGTH`, `DEFAULT_PLAYER_NAME`.
|
|
14
|
+
|
|
15
|
+
## 0.3.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- [#164](https://github.com/faluciano/react-native-couch-kit/pull/164) [`a157126`](https://github.com/faluciano/react-native-couch-kit/commit/a157126424e4d73dcc7185118d5be0db6719792e) Thanks [@faluciano](https://github.com/faluciano)! - Send a projected state update as one relay frame instead of one per player
|
|
20
|
+
|
|
21
|
+
A game with a `project` function sends every player their own view, which meant
|
|
22
|
+
one WebSocket frame per player for every state change. Relays bill and
|
|
23
|
+
rate-limit per inbound frame, so a four-player table paid four messages for one
|
|
24
|
+
update and spent four of the display's 30-per-second budget.
|
|
25
|
+
|
|
26
|
+
`GameRuntimeTransport` gains an optional `sendMany(entries)`. When a transport
|
|
27
|
+
implements it, the runtime hands over the whole projected batch at once;
|
|
28
|
+
transports that do not — the LAN WebSocket path — keep receiving one `send` per
|
|
29
|
+
connection and are unaffected.
|
|
30
|
+
|
|
31
|
+
`RelayDisplayHost` implements it with a new `DATA_MULTI` envelope carrying a
|
|
32
|
+
peer-id-to-payload map, which the relay unpacks into ordinary `DATA` frames.
|
|
33
|
+
Phones need no update — nothing on the client side can tell a batched update
|
|
34
|
+
from a unicast one. If the combined frame would exceed the relay's 256KB
|
|
35
|
+
ceiling, the display falls back to individual frames rather than send something
|
|
36
|
+
the relay would drop.
|
|
37
|
+
|
|
38
|
+
Relays must be updated before displays: both bundled implementations
|
|
39
|
+
(`services/relay`, `services/relay-worker`) understand `DATA_MULTI`, and an
|
|
40
|
+
older relay answers it with `MALFORMED`. The type is host-only — a phone sending
|
|
41
|
+
it is rejected, so it cannot be used to reach another phone directly.
|
|
42
|
+
|
|
43
|
+
### Patch Changes
|
|
44
|
+
|
|
45
|
+
- Updated dependencies [[`a157126`](https://github.com/faluciano/react-native-couch-kit/commit/a157126424e4d73dcc7185118d5be0db6719792e)]:
|
|
46
|
+
- @couch-kit/core@0.10.0
|
|
47
|
+
|
|
3
48
|
## 0.2.0
|
|
4
49
|
|
|
5
50
|
### Minor Changes
|
package/dist/index.cjs
CHANGED
|
@@ -39,18 +39,23 @@ var __export = (target, all) => {
|
|
|
39
39
|
// src/index.ts
|
|
40
40
|
var exports_src = {};
|
|
41
41
|
__export(exports_src, {
|
|
42
|
-
|
|
43
|
-
frameByteLength: () => frameByteLength,
|
|
44
|
-
createStateUpdateMessage: () => createStateUpdateMessage,
|
|
45
|
-
authorizeClientAction: () => authorizeClientAction,
|
|
46
|
-
RATE_LIMIT_WINDOW: () => RATE_LIMIT_WINDOW,
|
|
47
|
-
RATE_LIMIT_MAX: () => RATE_LIMIT_MAX,
|
|
48
|
-
HostSessionManager: () => HostSessionManager,
|
|
49
|
-
GameHostRuntime: () => GameHostRuntime,
|
|
50
|
-
DEFAULT_STATE_THROTTLE_MS: () => DEFAULT_STATE_THROTTLE_MS,
|
|
51
|
-
DEFAULT_MAX_MESSAGE_BYTES: () => DEFAULT_MAX_MESSAGE_BYTES,
|
|
42
|
+
ActionRateLimiter: () => ActionRateLimiter,
|
|
52
43
|
BroadcastScheduler: () => BroadcastScheduler,
|
|
53
|
-
|
|
44
|
+
DEFAULT_MAX_MESSAGE_BYTES: () => DEFAULT_MAX_MESSAGE_BYTES,
|
|
45
|
+
DEFAULT_PLAYER_NAME: () => DEFAULT_PLAYER_NAME,
|
|
46
|
+
DEFAULT_STATE_THROTTLE_MS: () => DEFAULT_STATE_THROTTLE_MS,
|
|
47
|
+
GameHostRuntime: () => GameHostRuntime,
|
|
48
|
+
HostSessionManager: () => HostSessionManager,
|
|
49
|
+
MAX_PLAYER_AVATAR_LENGTH: () => MAX_PLAYER_AVATAR_LENGTH,
|
|
50
|
+
MAX_PLAYER_NAME_LENGTH: () => MAX_PLAYER_NAME_LENGTH,
|
|
51
|
+
RATE_LIMIT_MAX: () => RATE_LIMIT_MAX,
|
|
52
|
+
RATE_LIMIT_WINDOW: () => RATE_LIMIT_WINDOW,
|
|
53
|
+
authorizeClientAction: () => authorizeClientAction,
|
|
54
|
+
createStateUpdateMessage: () => createStateUpdateMessage,
|
|
55
|
+
frameByteLength: () => frameByteLength,
|
|
56
|
+
isValidClientMessage: () => isValidClientMessage,
|
|
57
|
+
sanitizePlayerAvatar: () => sanitizePlayerAvatar,
|
|
58
|
+
sanitizePlayerName: () => sanitizePlayerName
|
|
54
59
|
});
|
|
55
60
|
module.exports = __toCommonJS(exports_src);
|
|
56
61
|
|
|
@@ -92,19 +97,25 @@ class BroadcastScheduler {
|
|
|
92
97
|
stateThrottleMs;
|
|
93
98
|
scheduler;
|
|
94
99
|
timer = null;
|
|
100
|
+
pending = null;
|
|
95
101
|
constructor(options = {}) {
|
|
96
102
|
this.stateThrottleMs = options.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS;
|
|
97
103
|
this.scheduler = options.scheduler ?? defaultTimerScheduler;
|
|
98
104
|
}
|
|
99
105
|
schedule(callback) {
|
|
100
|
-
this.
|
|
106
|
+
this.pending = callback;
|
|
107
|
+
if (this.timer !== null)
|
|
108
|
+
return;
|
|
101
109
|
this.timer = this.scheduler.setTimeout(() => {
|
|
102
110
|
this.timer = null;
|
|
103
|
-
|
|
111
|
+
const pending = this.pending;
|
|
112
|
+
this.pending = null;
|
|
113
|
+
pending?.();
|
|
104
114
|
}, this.stateThrottleMs);
|
|
105
115
|
}
|
|
106
116
|
cancel() {
|
|
107
|
-
|
|
117
|
+
this.pending = null;
|
|
118
|
+
if (this.timer !== null) {
|
|
108
119
|
this.scheduler.clearTimeout(this.timer);
|
|
109
120
|
this.timer = null;
|
|
110
121
|
}
|
|
@@ -148,6 +159,26 @@ function frameByteLength(data) {
|
|
|
148
159
|
}
|
|
149
160
|
return bytes;
|
|
150
161
|
}
|
|
162
|
+
var MAX_PLAYER_NAME_LENGTH = 64;
|
|
163
|
+
var MAX_PLAYER_AVATAR_LENGTH = 16 * 1024;
|
|
164
|
+
var DEFAULT_PLAYER_NAME = "Player";
|
|
165
|
+
function truncate(value, max) {
|
|
166
|
+
if (value.length <= max)
|
|
167
|
+
return value;
|
|
168
|
+
return Array.from(value.slice(0, max * 2)).slice(0, max).join("");
|
|
169
|
+
}
|
|
170
|
+
function sanitizePlayerName(name) {
|
|
171
|
+
const trimmed = truncate(name.trim(), MAX_PLAYER_NAME_LENGTH).trim();
|
|
172
|
+
return trimmed.length > 0 ? trimmed : DEFAULT_PLAYER_NAME;
|
|
173
|
+
}
|
|
174
|
+
function sanitizePlayerAvatar(avatar) {
|
|
175
|
+
if (typeof avatar !== "string")
|
|
176
|
+
return;
|
|
177
|
+
if (avatar.length === 0 || avatar.length > MAX_PLAYER_AVATAR_LENGTH) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
return avatar;
|
|
181
|
+
}
|
|
151
182
|
function isValidClientMessage(msg) {
|
|
152
183
|
if (typeof msg !== "object" || msg === null)
|
|
153
184
|
return false;
|
|
@@ -155,8 +186,12 @@ function isValidClientMessage(msg) {
|
|
|
155
186
|
if (typeof m.type !== "string")
|
|
156
187
|
return false;
|
|
157
188
|
switch (m.type) {
|
|
158
|
-
case import_core3.MessageTypes.JOIN:
|
|
159
|
-
|
|
189
|
+
case import_core3.MessageTypes.JOIN: {
|
|
190
|
+
if (typeof m.payload !== "object" || m.payload === null)
|
|
191
|
+
return false;
|
|
192
|
+
const { name, avatar } = m.payload;
|
|
193
|
+
return typeof name === "string" && (avatar === undefined || avatar === null || typeof avatar === "string");
|
|
194
|
+
}
|
|
160
195
|
case import_core3.MessageTypes.ACTION:
|
|
161
196
|
return typeof m.payload === "object" && m.payload !== null && typeof m.payload.type === "string";
|
|
162
197
|
case import_core3.MessageTypes.PING:
|
|
@@ -229,8 +264,14 @@ class HostSessionManager {
|
|
|
229
264
|
this.derivePlayerIdFn = options.derivePlayerId ?? import_core4.derivePlayerId;
|
|
230
265
|
this.derivePlayerIdLegacyFn = options.derivePlayerIdLegacy ?? import_core4.derivePlayerIdLegacy;
|
|
231
266
|
}
|
|
267
|
+
derivePlayerIdFor(secret) {
|
|
268
|
+
return this.derivePlayerIdFn(secret);
|
|
269
|
+
}
|
|
232
270
|
async handleJoin(socketId, payload, playersSource) {
|
|
233
271
|
const hashedId = await this.derivePlayerIdFn(payload.secret);
|
|
272
|
+
return this.registerJoin(socketId, payload, hashedId, playersSource);
|
|
273
|
+
}
|
|
274
|
+
registerJoin(socketId, payload, hashedId, playersSource) {
|
|
234
275
|
const players = typeof playersSource === "function" ? playersSource() : playersSource;
|
|
235
276
|
let playerId = hashedId;
|
|
236
277
|
const legacyId = this.derivePlayerIdLegacyFn(payload.secret);
|
|
@@ -322,6 +363,8 @@ class HostSessionManager {
|
|
|
322
363
|
}
|
|
323
364
|
|
|
324
365
|
// src/runtime.ts
|
|
366
|
+
var MAX_QUEUED_ACTIONS = 64;
|
|
367
|
+
|
|
325
368
|
class GameHostRuntime {
|
|
326
369
|
config;
|
|
327
370
|
reducer;
|
|
@@ -395,7 +438,7 @@ class GameHostRuntime {
|
|
|
395
438
|
this.log(`[GameRuntime] Msg from ${connectionId}:`, message);
|
|
396
439
|
switch (message.type) {
|
|
397
440
|
case import_core5.MessageTypes.JOIN: {
|
|
398
|
-
const { secret
|
|
441
|
+
const { secret } = message.payload;
|
|
399
442
|
if (!secret || typeof secret !== "string" || !import_core5.isValidSecret(secret)) {
|
|
400
443
|
this.send(connectionId, {
|
|
401
444
|
type: import_core5.MessageTypes.ERROR,
|
|
@@ -418,16 +461,21 @@ class GameHostRuntime {
|
|
|
418
461
|
}
|
|
419
462
|
this.pendingJoins.add(connectionId);
|
|
420
463
|
try {
|
|
421
|
-
const
|
|
464
|
+
const joinPayload = {
|
|
465
|
+
name: sanitizePlayerName(message.payload.name),
|
|
466
|
+
avatar: sanitizePlayerAvatar(message.payload.avatar),
|
|
467
|
+
secret
|
|
468
|
+
};
|
|
469
|
+
const hashedId = await this.sessionManager.derivePlayerIdFor(secret);
|
|
422
470
|
if (this.activeConnections.get(connectionId) !== connectionGeneration) {
|
|
423
|
-
this.sessionManager.abandonConnection(connectionId);
|
|
424
471
|
return;
|
|
425
472
|
}
|
|
473
|
+
const { playerId, isReconnect, action } = this.sessionManager.registerJoin(connectionId, joinPayload, hashedId, this.state.players);
|
|
426
474
|
this.applyAction(action);
|
|
427
475
|
this.joinedConnections.add(connectionId);
|
|
428
476
|
this.assetsLoaded.set(playerId, false);
|
|
429
477
|
this.invokeLifecycleCallback("onPlayerJoined", this.config.onPlayerJoined ? () => {
|
|
430
|
-
this.config.onPlayerJoined?.(playerId,
|
|
478
|
+
this.config.onPlayerJoined?.(playerId, joinPayload.name);
|
|
431
479
|
} : undefined);
|
|
432
480
|
if (isReconnect) {
|
|
433
481
|
this.send(connectionId, {
|
|
@@ -487,11 +535,16 @@ class GameHostRuntime {
|
|
|
487
535
|
});
|
|
488
536
|
return;
|
|
489
537
|
}
|
|
490
|
-
this.applyAction({
|
|
538
|
+
const changed = this.applyAction({
|
|
491
539
|
...actionPayload,
|
|
492
540
|
playerId: authorization.playerId
|
|
493
541
|
});
|
|
494
|
-
this.
|
|
542
|
+
if (changed && !this.config.project) {
|
|
543
|
+
this.actionQueue.push(actionPayload);
|
|
544
|
+
if (this.actionQueue.length > MAX_QUEUED_ACTIONS) {
|
|
545
|
+
this.actionQueue.shift();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
495
548
|
break;
|
|
496
549
|
}
|
|
497
550
|
case import_core5.MessageTypes.PING:
|
|
@@ -552,33 +605,48 @@ class GameHostRuntime {
|
|
|
552
605
|
applyAction(action) {
|
|
553
606
|
const nextState = this.reducer(this.state, action);
|
|
554
607
|
if (Object.is(nextState, this.state))
|
|
555
|
-
return;
|
|
608
|
+
return false;
|
|
556
609
|
this.state = nextState;
|
|
557
610
|
this.stateDirty = true;
|
|
558
611
|
for (const listener of this.listeners) {
|
|
559
612
|
listener();
|
|
560
613
|
}
|
|
561
614
|
this.broadcastScheduler.schedule(this.broadcastState);
|
|
615
|
+
return true;
|
|
562
616
|
}
|
|
563
617
|
viewFor(playerId) {
|
|
564
618
|
const project = this.config.project;
|
|
565
619
|
return project ? project(this.state, playerId) : this.state;
|
|
566
620
|
}
|
|
567
621
|
broadcastState = () => {
|
|
568
|
-
|
|
622
|
+
const transport = this.transport;
|
|
623
|
+
if (!transport)
|
|
569
624
|
return;
|
|
570
625
|
const actions = this.actionQueue;
|
|
571
626
|
this.actionQueue = [];
|
|
572
627
|
this.stateDirty = false;
|
|
573
628
|
if (!this.config.project) {
|
|
574
|
-
|
|
629
|
+
transport.broadcast(createStateUpdateMessage(this.state, actions));
|
|
575
630
|
return;
|
|
576
631
|
}
|
|
632
|
+
const entries = [];
|
|
577
633
|
for (const connectionId of this.joinedConnections) {
|
|
578
634
|
const playerId = this.sessionManager.getPlayerIdForSocket(connectionId);
|
|
579
635
|
if (!playerId)
|
|
580
636
|
continue;
|
|
581
|
-
|
|
637
|
+
entries.push({
|
|
638
|
+
connectionId,
|
|
639
|
+
message: createStateUpdateMessage(this.viewFor(playerId), [])
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
if (entries.length === 0)
|
|
643
|
+
return;
|
|
644
|
+
if (transport.sendMany) {
|
|
645
|
+
transport.sendMany(entries);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
for (const { connectionId, message } of entries) {
|
|
649
|
+
this.send(connectionId, message);
|
|
582
650
|
}
|
|
583
651
|
};
|
|
584
652
|
send(connectionId, message) {
|
package/dist/index.js
CHANGED
|
@@ -36,19 +36,25 @@ class BroadcastScheduler {
|
|
|
36
36
|
stateThrottleMs;
|
|
37
37
|
scheduler;
|
|
38
38
|
timer = null;
|
|
39
|
+
pending = null;
|
|
39
40
|
constructor(options = {}) {
|
|
40
41
|
this.stateThrottleMs = options.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS;
|
|
41
42
|
this.scheduler = options.scheduler ?? defaultTimerScheduler;
|
|
42
43
|
}
|
|
43
44
|
schedule(callback) {
|
|
44
|
-
this.
|
|
45
|
+
this.pending = callback;
|
|
46
|
+
if (this.timer !== null)
|
|
47
|
+
return;
|
|
45
48
|
this.timer = this.scheduler.setTimeout(() => {
|
|
46
49
|
this.timer = null;
|
|
47
|
-
|
|
50
|
+
const pending = this.pending;
|
|
51
|
+
this.pending = null;
|
|
52
|
+
pending?.();
|
|
48
53
|
}, this.stateThrottleMs);
|
|
49
54
|
}
|
|
50
55
|
cancel() {
|
|
51
|
-
|
|
56
|
+
this.pending = null;
|
|
57
|
+
if (this.timer !== null) {
|
|
52
58
|
this.scheduler.clearTimeout(this.timer);
|
|
53
59
|
this.timer = null;
|
|
54
60
|
}
|
|
@@ -92,6 +98,26 @@ function frameByteLength(data) {
|
|
|
92
98
|
}
|
|
93
99
|
return bytes;
|
|
94
100
|
}
|
|
101
|
+
var MAX_PLAYER_NAME_LENGTH = 64;
|
|
102
|
+
var MAX_PLAYER_AVATAR_LENGTH = 16 * 1024;
|
|
103
|
+
var DEFAULT_PLAYER_NAME = "Player";
|
|
104
|
+
function truncate(value, max) {
|
|
105
|
+
if (value.length <= max)
|
|
106
|
+
return value;
|
|
107
|
+
return Array.from(value.slice(0, max * 2)).slice(0, max).join("");
|
|
108
|
+
}
|
|
109
|
+
function sanitizePlayerName(name) {
|
|
110
|
+
const trimmed = truncate(name.trim(), MAX_PLAYER_NAME_LENGTH).trim();
|
|
111
|
+
return trimmed.length > 0 ? trimmed : DEFAULT_PLAYER_NAME;
|
|
112
|
+
}
|
|
113
|
+
function sanitizePlayerAvatar(avatar) {
|
|
114
|
+
if (typeof avatar !== "string")
|
|
115
|
+
return;
|
|
116
|
+
if (avatar.length === 0 || avatar.length > MAX_PLAYER_AVATAR_LENGTH) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
return avatar;
|
|
120
|
+
}
|
|
95
121
|
function isValidClientMessage(msg) {
|
|
96
122
|
if (typeof msg !== "object" || msg === null)
|
|
97
123
|
return false;
|
|
@@ -99,8 +125,12 @@ function isValidClientMessage(msg) {
|
|
|
99
125
|
if (typeof m.type !== "string")
|
|
100
126
|
return false;
|
|
101
127
|
switch (m.type) {
|
|
102
|
-
case MessageTypes2.JOIN:
|
|
103
|
-
|
|
128
|
+
case MessageTypes2.JOIN: {
|
|
129
|
+
if (typeof m.payload !== "object" || m.payload === null)
|
|
130
|
+
return false;
|
|
131
|
+
const { name, avatar } = m.payload;
|
|
132
|
+
return typeof name === "string" && (avatar === undefined || avatar === null || typeof avatar === "string");
|
|
133
|
+
}
|
|
104
134
|
case MessageTypes2.ACTION:
|
|
105
135
|
return typeof m.payload === "object" && m.payload !== null && typeof m.payload.type === "string";
|
|
106
136
|
case MessageTypes2.PING:
|
|
@@ -184,8 +214,14 @@ class HostSessionManager {
|
|
|
184
214
|
this.derivePlayerIdFn = options.derivePlayerId ?? derivePlayerId;
|
|
185
215
|
this.derivePlayerIdLegacyFn = options.derivePlayerIdLegacy ?? derivePlayerIdLegacy;
|
|
186
216
|
}
|
|
217
|
+
derivePlayerIdFor(secret) {
|
|
218
|
+
return this.derivePlayerIdFn(secret);
|
|
219
|
+
}
|
|
187
220
|
async handleJoin(socketId, payload, playersSource) {
|
|
188
221
|
const hashedId = await this.derivePlayerIdFn(payload.secret);
|
|
222
|
+
return this.registerJoin(socketId, payload, hashedId, playersSource);
|
|
223
|
+
}
|
|
224
|
+
registerJoin(socketId, payload, hashedId, playersSource) {
|
|
189
225
|
const players = typeof playersSource === "function" ? playersSource() : playersSource;
|
|
190
226
|
let playerId = hashedId;
|
|
191
227
|
const legacyId = this.derivePlayerIdLegacyFn(payload.secret);
|
|
@@ -277,6 +313,8 @@ class HostSessionManager {
|
|
|
277
313
|
}
|
|
278
314
|
|
|
279
315
|
// src/runtime.ts
|
|
316
|
+
var MAX_QUEUED_ACTIONS = 64;
|
|
317
|
+
|
|
280
318
|
class GameHostRuntime {
|
|
281
319
|
config;
|
|
282
320
|
reducer;
|
|
@@ -350,7 +388,7 @@ class GameHostRuntime {
|
|
|
350
388
|
this.log(`[GameRuntime] Msg from ${connectionId}:`, message);
|
|
351
389
|
switch (message.type) {
|
|
352
390
|
case MessageTypes3.JOIN: {
|
|
353
|
-
const { secret
|
|
391
|
+
const { secret } = message.payload;
|
|
354
392
|
if (!secret || typeof secret !== "string" || !isValidSecret(secret)) {
|
|
355
393
|
this.send(connectionId, {
|
|
356
394
|
type: MessageTypes3.ERROR,
|
|
@@ -373,16 +411,21 @@ class GameHostRuntime {
|
|
|
373
411
|
}
|
|
374
412
|
this.pendingJoins.add(connectionId);
|
|
375
413
|
try {
|
|
376
|
-
const
|
|
414
|
+
const joinPayload = {
|
|
415
|
+
name: sanitizePlayerName(message.payload.name),
|
|
416
|
+
avatar: sanitizePlayerAvatar(message.payload.avatar),
|
|
417
|
+
secret
|
|
418
|
+
};
|
|
419
|
+
const hashedId = await this.sessionManager.derivePlayerIdFor(secret);
|
|
377
420
|
if (this.activeConnections.get(connectionId) !== connectionGeneration) {
|
|
378
|
-
this.sessionManager.abandonConnection(connectionId);
|
|
379
421
|
return;
|
|
380
422
|
}
|
|
423
|
+
const { playerId, isReconnect, action } = this.sessionManager.registerJoin(connectionId, joinPayload, hashedId, this.state.players);
|
|
381
424
|
this.applyAction(action);
|
|
382
425
|
this.joinedConnections.add(connectionId);
|
|
383
426
|
this.assetsLoaded.set(playerId, false);
|
|
384
427
|
this.invokeLifecycleCallback("onPlayerJoined", this.config.onPlayerJoined ? () => {
|
|
385
|
-
this.config.onPlayerJoined?.(playerId,
|
|
428
|
+
this.config.onPlayerJoined?.(playerId, joinPayload.name);
|
|
386
429
|
} : undefined);
|
|
387
430
|
if (isReconnect) {
|
|
388
431
|
this.send(connectionId, {
|
|
@@ -442,11 +485,16 @@ class GameHostRuntime {
|
|
|
442
485
|
});
|
|
443
486
|
return;
|
|
444
487
|
}
|
|
445
|
-
this.applyAction({
|
|
488
|
+
const changed = this.applyAction({
|
|
446
489
|
...actionPayload,
|
|
447
490
|
playerId: authorization.playerId
|
|
448
491
|
});
|
|
449
|
-
this.
|
|
492
|
+
if (changed && !this.config.project) {
|
|
493
|
+
this.actionQueue.push(actionPayload);
|
|
494
|
+
if (this.actionQueue.length > MAX_QUEUED_ACTIONS) {
|
|
495
|
+
this.actionQueue.shift();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
450
498
|
break;
|
|
451
499
|
}
|
|
452
500
|
case MessageTypes3.PING:
|
|
@@ -507,33 +555,48 @@ class GameHostRuntime {
|
|
|
507
555
|
applyAction(action) {
|
|
508
556
|
const nextState = this.reducer(this.state, action);
|
|
509
557
|
if (Object.is(nextState, this.state))
|
|
510
|
-
return;
|
|
558
|
+
return false;
|
|
511
559
|
this.state = nextState;
|
|
512
560
|
this.stateDirty = true;
|
|
513
561
|
for (const listener of this.listeners) {
|
|
514
562
|
listener();
|
|
515
563
|
}
|
|
516
564
|
this.broadcastScheduler.schedule(this.broadcastState);
|
|
565
|
+
return true;
|
|
517
566
|
}
|
|
518
567
|
viewFor(playerId) {
|
|
519
568
|
const project = this.config.project;
|
|
520
569
|
return project ? project(this.state, playerId) : this.state;
|
|
521
570
|
}
|
|
522
571
|
broadcastState = () => {
|
|
523
|
-
|
|
572
|
+
const transport = this.transport;
|
|
573
|
+
if (!transport)
|
|
524
574
|
return;
|
|
525
575
|
const actions = this.actionQueue;
|
|
526
576
|
this.actionQueue = [];
|
|
527
577
|
this.stateDirty = false;
|
|
528
578
|
if (!this.config.project) {
|
|
529
|
-
|
|
579
|
+
transport.broadcast(createStateUpdateMessage(this.state, actions));
|
|
530
580
|
return;
|
|
531
581
|
}
|
|
582
|
+
const entries = [];
|
|
532
583
|
for (const connectionId of this.joinedConnections) {
|
|
533
584
|
const playerId = this.sessionManager.getPlayerIdForSocket(connectionId);
|
|
534
585
|
if (!playerId)
|
|
535
586
|
continue;
|
|
536
|
-
|
|
587
|
+
entries.push({
|
|
588
|
+
connectionId,
|
|
589
|
+
message: createStateUpdateMessage(this.viewFor(playerId), [])
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
if (entries.length === 0)
|
|
593
|
+
return;
|
|
594
|
+
if (transport.sendMany) {
|
|
595
|
+
transport.sendMany(entries);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
for (const { connectionId, message } of entries) {
|
|
599
|
+
this.send(connectionId, message);
|
|
537
600
|
}
|
|
538
601
|
};
|
|
539
602
|
send(connectionId, message) {
|
|
@@ -568,16 +631,21 @@ class GameHostRuntime {
|
|
|
568
631
|
}
|
|
569
632
|
}
|
|
570
633
|
export {
|
|
571
|
-
|
|
572
|
-
frameByteLength,
|
|
573
|
-
createStateUpdateMessage,
|
|
574
|
-
authorizeClientAction,
|
|
575
|
-
RATE_LIMIT_WINDOW,
|
|
576
|
-
RATE_LIMIT_MAX,
|
|
577
|
-
HostSessionManager,
|
|
578
|
-
GameHostRuntime,
|
|
579
|
-
DEFAULT_STATE_THROTTLE_MS,
|
|
580
|
-
DEFAULT_MAX_MESSAGE_BYTES,
|
|
634
|
+
ActionRateLimiter,
|
|
581
635
|
BroadcastScheduler,
|
|
582
|
-
|
|
636
|
+
DEFAULT_MAX_MESSAGE_BYTES,
|
|
637
|
+
DEFAULT_PLAYER_NAME,
|
|
638
|
+
DEFAULT_STATE_THROTTLE_MS,
|
|
639
|
+
GameHostRuntime,
|
|
640
|
+
HostSessionManager,
|
|
641
|
+
MAX_PLAYER_AVATAR_LENGTH,
|
|
642
|
+
MAX_PLAYER_NAME_LENGTH,
|
|
643
|
+
RATE_LIMIT_MAX,
|
|
644
|
+
RATE_LIMIT_WINDOW,
|
|
645
|
+
authorizeClientAction,
|
|
646
|
+
createStateUpdateMessage,
|
|
647
|
+
frameByteLength,
|
|
648
|
+
isValidClientMessage,
|
|
649
|
+
sanitizePlayerAvatar,
|
|
650
|
+
sanitizePlayerName
|
|
583
651
|
};
|
|
@@ -13,13 +13,19 @@ export interface BroadcastSchedulerOptions<TTimer> {
|
|
|
13
13
|
scheduler?: TimerScheduler<TTimer>;
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Throttled state-broadcast scheduler used by the authoritative runtime.
|
|
17
|
+
*
|
|
18
|
+
* The first change opens a window of `stateThrottleMs`; every change inside it
|
|
19
|
+
* is coalesced into the single broadcast that fires when the window closes.
|
|
20
|
+
* The window is never extended by later changes, so a host that updates
|
|
21
|
+
* faster than the throttle still broadcasts once per window instead of being
|
|
22
|
+
* starved until the updates pause.
|
|
18
23
|
*/
|
|
19
24
|
export declare class BroadcastScheduler<TTimer = ReturnType<typeof setTimeout>> {
|
|
20
25
|
private stateThrottleMs;
|
|
21
26
|
private readonly scheduler;
|
|
22
27
|
private timer;
|
|
28
|
+
private pending;
|
|
23
29
|
constructor(options?: BroadcastSchedulerOptions<TTimer>);
|
|
24
30
|
schedule(callback: () => void): void;
|
|
25
31
|
cancel(): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"broadcast-scheduler.d.ts","sourceRoot":"","sources":["../src/broadcast-scheduler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEjE,iDAAiD;AACjD,eAAO,MAAM,yBAAyB,KAAK,CAAC;AAE5C,MAAM,MAAM,kBAAkB,GAAG,OAAO,CACtC,WAAW,EACX;IAAE,IAAI,EAAE,OAAO,YAAY,CAAC,YAAY,CAAA;CAAE,CAC3C,CAAC;AAEF,MAAM,WAAW,cAAc,CAAC,MAAM;IACpC,UAAU,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1D,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAOD,MAAM,WAAW,yBAAyB,CAAC,MAAM;IAC/C,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;CACpC;AAED
|
|
1
|
+
{"version":3,"file":"broadcast-scheduler.d.ts","sourceRoot":"","sources":["../src/broadcast-scheduler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEjE,iDAAiD;AACjD,eAAO,MAAM,yBAAyB,KAAK,CAAC;AAE5C,MAAM,MAAM,kBAAkB,GAAG,OAAO,CACtC,WAAW,EACX;IAAE,IAAI,EAAE,OAAO,YAAY,CAAC,YAAY,CAAA;CAAE,CAC3C,CAAC;AAEF,MAAM,WAAW,cAAc,CAAC,MAAM;IACpC,UAAU,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1D,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAOD,MAAM,WAAW,yBAAyB,CAAC,MAAM;IAC/C,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;CACpC;AAED;;;;;;;;GAQG;AACH,qBAAa,kBAAkB,CAAC,MAAM,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC;IACpE,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAyB;IACnD,OAAO,CAAC,KAAK,CAAuB;IACpC,OAAO,CAAC,OAAO,CAA6B;IAE5C,YAAY,OAAO,GAAE,yBAAyB,CAAC,MAAM,CAAM,EAK1D;IAED,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAWnC;IAED,MAAM,IAAI,IAAI,CAMb;IAED,kBAAkB,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI,CAEhD;IAED,mBAAmB,IAAI,OAAO,CAE7B;CACF;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,OAAO,EACjB,OAAO,EAAE,SAAS,OAAO,EAAE,EAC3B,SAAS,GAAE,MAAmB,GAC7B,kBAAkB,CAapB"}
|
|
@@ -14,6 +14,26 @@ export declare const DEFAULT_MAX_MESSAGE_BYTES: number;
|
|
|
14
14
|
* encoder buffer, so an oversized frame can be rejected cheaply.
|
|
15
15
|
*/
|
|
16
16
|
export declare function frameByteLength(data: string | ArrayBuffer): number;
|
|
17
|
+
/** Longest player display name the runtime stores; longer names are truncated. */
|
|
18
|
+
export declare const MAX_PLAYER_NAME_LENGTH = 64;
|
|
19
|
+
/**
|
|
20
|
+
* Longest avatar string the runtime stores. The avatar is part of game state,
|
|
21
|
+
* so it is re-sent to every player on every state update; anything larger than
|
|
22
|
+
* a small icon is dropped rather than paid for on each broadcast.
|
|
23
|
+
*/
|
|
24
|
+
export declare const MAX_PLAYER_AVATAR_LENGTH: number;
|
|
25
|
+
/** Display name used when a JOIN supplies a blank one. */
|
|
26
|
+
export declare const DEFAULT_PLAYER_NAME = "Player";
|
|
27
|
+
/**
|
|
28
|
+
* Normalizes the player-supplied name from a JOIN: trimmed, capped at
|
|
29
|
+
* {@link MAX_PLAYER_NAME_LENGTH}, and never blank.
|
|
30
|
+
*/
|
|
31
|
+
export declare function sanitizePlayerName(name: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Normalizes the player-supplied avatar from a JOIN. Anything that is not a
|
|
34
|
+
* string of at most {@link MAX_PLAYER_AVATAR_LENGTH} characters is dropped.
|
|
35
|
+
*/
|
|
36
|
+
export declare function sanitizePlayerAvatar(avatar: unknown): string | undefined;
|
|
17
37
|
type ClientMessageOf<TType extends ClientMessage["type"]> = Extract<ClientMessage, {
|
|
18
38
|
type: TType;
|
|
19
39
|
}>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"message-validation.d.ts","sourceRoot":"","sources":["../src/message-validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEnE;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,QAAa,CAAC;AAEpD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,MAAM,CAmBlE;AAED,KAAK,eAAe,CAAC,KAAK,SAAS,aAAa,CAAC,MAAM,CAAC,IAAI,OAAO,CACjE,aAAa,EACb;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,CAChB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAC9B;IACE,IAAI,EAAE,OAAO,YAAY,CAAC,IAAI,CAAC;IAC/B,OAAO,EAAE;QACP,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;CACH,GACD,eAAe,CAAC,QAAQ,CAAC,GACzB,eAAe,CAAC,MAAM,CAAC,GACvB,eAAe,CAAC,eAAe,CAAC,CAAC;AAErC;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,GACX,GAAG,IAAI,sBAAsB,
|
|
1
|
+
{"version":3,"file":"message-validation.d.ts","sourceRoot":"","sources":["../src/message-validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEnE;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,QAAa,CAAC;AAEpD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,MAAM,CAmBlE;AAED,kFAAkF;AAClF,eAAO,MAAM,sBAAsB,KAAK,CAAC;AAEzC;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,QAAY,CAAC;AAElD,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,WAAW,CAAC;AAW5C;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGvD;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAMxE;AAED,KAAK,eAAe,CAAC,KAAK,SAAS,aAAa,CAAC,MAAM,CAAC,IAAI,OAAO,CACjE,aAAa,EACb;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,CAChB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAC9B;IACE,IAAI,EAAE,OAAO,YAAY,CAAC,IAAI,CAAC;IAC/B,OAAO,EAAE;QACP,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;CACH,GACD,eAAe,CAAC,QAAQ,CAAC,GACzB,eAAe,CAAC,MAAM,CAAC,GACvB,eAAe,CAAC,eAAe,CAAC,CAAC;AAErC;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,GACX,GAAG,IAAI,sBAAsB,CAgC/B"}
|
package/lib/runtime.d.ts
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
import { type GameReducer, type HostMessage, type IAction, type IGameState } from "@couch-kit/core";
|
|
2
|
+
/** One connection's share of a {@link GameRuntimeTransport.sendMany} delivery. */
|
|
3
|
+
export interface AddressedMessage {
|
|
4
|
+
connectionId: string;
|
|
5
|
+
message: HostMessage;
|
|
6
|
+
}
|
|
2
7
|
/** Minimal message-delivery surface required by the authoritative runtime. */
|
|
3
8
|
export interface GameRuntimeTransport {
|
|
4
9
|
send(connectionId: string, message: HostMessage): void;
|
|
5
10
|
broadcast(message: HostMessage): void;
|
|
11
|
+
/**
|
|
12
|
+
* Delivers a batch of per-connection messages, for transports that can carry
|
|
13
|
+
* them in one frame.
|
|
14
|
+
*
|
|
15
|
+
* Optional: the runtime falls back to a {@link GameRuntimeTransport.send} per
|
|
16
|
+
* entry, which is what a plain LAN WebSocket transport wants anyway. It earns
|
|
17
|
+
* its keep on the relay, where each frame the display sends is separately
|
|
18
|
+
* billed and rate-limited, so a projected state update costs one message
|
|
19
|
+
* rather than one per player.
|
|
20
|
+
*/
|
|
21
|
+
sendMany?(entries: readonly AddressedMessage[]): void;
|
|
6
22
|
}
|
|
7
23
|
/** Configuration shared by every authoritative Couch Kit host transport. */
|
|
8
24
|
export interface GameHostRuntimeConfig<S extends IGameState, A extends IAction> {
|
|
@@ -78,6 +94,7 @@ export declare class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
78
94
|
handleError(error: Error): void;
|
|
79
95
|
/** Cancels runtime timers and releases transport-specific state. */
|
|
80
96
|
stop(): void;
|
|
97
|
+
/** @returns whether the action changed the canonical state. */
|
|
81
98
|
private applyAction;
|
|
82
99
|
/**
|
|
83
100
|
* What `playerId` is allowed to see. Identity unless a projection is
|
package/lib/runtime.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,UAAU,EAEhB,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"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"}
|
package/lib/session-manager.d.ts
CHANGED
|
@@ -49,7 +49,22 @@ export declare class HostSessionManager<TTimer = ReturnType<typeof setTimeout>>
|
|
|
49
49
|
private readonly derivePlayerIdFn;
|
|
50
50
|
private readonly derivePlayerIdLegacyFn;
|
|
51
51
|
constructor(options?: HostSessionManagerOptions<TTimer>);
|
|
52
|
+
/**
|
|
53
|
+
* Derives the public player ID for a secret without touching session state.
|
|
54
|
+
*
|
|
55
|
+
* Split from {@link HostSessionManager.registerJoin} so a caller can check
|
|
56
|
+
* that the connection is still alive after the (asynchronous) hash and before
|
|
57
|
+
* anything is recorded — a join for a socket that already closed must not
|
|
58
|
+
* displace the player's live session or cancel their pending removal.
|
|
59
|
+
*/
|
|
60
|
+
derivePlayerIdFor(secret: string): Promise<string>;
|
|
52
61
|
handleJoin<S extends IGameState>(socketId: string, payload: JoinSessionPayload, playersSource: PlayersSource<S>): Promise<JoinSessionResult<S>>;
|
|
62
|
+
/**
|
|
63
|
+
* Records a join whose player ID was already derived with
|
|
64
|
+
* {@link HostSessionManager.derivePlayerIdFor}. Synchronous, so the session
|
|
65
|
+
* maps and the removal timer change atomically with the caller's own checks.
|
|
66
|
+
*/
|
|
67
|
+
registerJoin<S extends IGameState>(socketId: string, payload: JoinSessionPayload, hashedId: string, playersSource: PlayersSource<S>): JoinSessionResult<S>;
|
|
53
68
|
handleDisconnect<S extends IGameState>(socketId: string): DisconnectSessionResult<S>;
|
|
54
69
|
scheduleRemoval(playerId: string, secret: string, onRemove: (playerId: string) => void): void;
|
|
55
70
|
cancelRemoval(playerId: string): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,UAAU,EACf,KAAK,cAAc,EACpB,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,qBAAqB,CAAC,MAAM;IAC3C,UAAU,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1D,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AASD,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,yBAAyB,CAAC,MAAM;IAC/C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,oBAAoB,CAAC,EAAE,MAAM,MAAM,CAAC;IACpC,SAAS,CAAC,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAC1C,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACrD,oBAAoB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;CACnD;AAED,MAAM,MAAM,iBAAiB,CAAC,CAAC,SAAS,UAAU,IAAI;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,uBAAuB,CAAC,CAAC,SAAS,UAAU,IACpD;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACnD;IACE,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC;CAC3B,CAAC;AAEN,KAAK,aAAa,CAAC,CAAC,SAAS,UAAU,IAAI,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;AAE/E;;;GAGG;AACH,qBAAa,kBAAkB,CAAC,MAAM,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC;IACpE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6B;IACtD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA6B;IACxD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA6B;IAC3D,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA6B;IAChE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAgC;IAC1D,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAe;IACpD,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAsC;IACvE,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAA6B;IAEpE,YAAY,OAAO,GAAE,yBAAyB,CAAC,MAAM,CAAM,EAU1D;IAEK,UAAU,CAAC,CAAC,SAAS,UAAU,EACnC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,kBAAkB,EAC3B,aAAa,EAAE,aAAa,CAAC,CAAC,CAAC,GAC9B,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@couch-kit/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"provenance": true
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"clean": "rm -rf dist lib"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@couch-kit/core": "0.
|
|
59
|
+
"@couch-kit/core": "0.10.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"typescript": "^7.0.0"
|
|
@@ -24,13 +24,19 @@ export interface BroadcastSchedulerOptions<TTimer> {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Throttled state-broadcast scheduler used by the authoritative runtime.
|
|
28
|
+
*
|
|
29
|
+
* The first change opens a window of `stateThrottleMs`; every change inside it
|
|
30
|
+
* is coalesced into the single broadcast that fires when the window closes.
|
|
31
|
+
* The window is never extended by later changes, so a host that updates
|
|
32
|
+
* faster than the throttle still broadcasts once per window instead of being
|
|
33
|
+
* starved until the updates pause.
|
|
29
34
|
*/
|
|
30
35
|
export class BroadcastScheduler<TTimer = ReturnType<typeof setTimeout>> {
|
|
31
36
|
private stateThrottleMs: number;
|
|
32
37
|
private readonly scheduler: TimerScheduler<TTimer>;
|
|
33
38
|
private timer: TTimer | null = null;
|
|
39
|
+
private pending: (() => void) | null = null;
|
|
34
40
|
|
|
35
41
|
constructor(options: BroadcastSchedulerOptions<TTimer> = {}) {
|
|
36
42
|
this.stateThrottleMs = options.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS;
|
|
@@ -40,15 +46,21 @@ export class BroadcastScheduler<TTimer = ReturnType<typeof setTimeout>> {
|
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
schedule(callback: () => void): void {
|
|
43
|
-
|
|
49
|
+
// The latest callback wins, but the window opened by the first call stands.
|
|
50
|
+
this.pending = callback;
|
|
51
|
+
if (this.timer !== null) return;
|
|
52
|
+
|
|
44
53
|
this.timer = this.scheduler.setTimeout(() => {
|
|
45
54
|
this.timer = null;
|
|
46
|
-
|
|
55
|
+
const pending = this.pending;
|
|
56
|
+
this.pending = null;
|
|
57
|
+
pending?.();
|
|
47
58
|
}, this.stateThrottleMs);
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
cancel(): void {
|
|
51
|
-
|
|
62
|
+
this.pending = null;
|
|
63
|
+
if (this.timer !== null) {
|
|
52
64
|
this.scheduler.clearTimeout(this.timer);
|
|
53
65
|
this.timer = null;
|
|
54
66
|
}
|
|
@@ -36,6 +36,49 @@ export function frameByteLength(data: string | ArrayBuffer): number {
|
|
|
36
36
|
return bytes;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** Longest player display name the runtime stores; longer names are truncated. */
|
|
40
|
+
export const MAX_PLAYER_NAME_LENGTH = 64;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Longest avatar string the runtime stores. The avatar is part of game state,
|
|
44
|
+
* so it is re-sent to every player on every state update; anything larger than
|
|
45
|
+
* a small icon is dropped rather than paid for on each broadcast.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_PLAYER_AVATAR_LENGTH = 16 * 1024;
|
|
48
|
+
|
|
49
|
+
/** Display name used when a JOIN supplies a blank one. */
|
|
50
|
+
export const DEFAULT_PLAYER_NAME = "Player";
|
|
51
|
+
|
|
52
|
+
/** Truncates to `max` code points without splitting a surrogate pair. */
|
|
53
|
+
function truncate(value: string, max: number): string {
|
|
54
|
+
if (value.length <= max) return value;
|
|
55
|
+
// Slice generously first so a huge string is not spread into an array.
|
|
56
|
+
return Array.from(value.slice(0, max * 2))
|
|
57
|
+
.slice(0, max)
|
|
58
|
+
.join("");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Normalizes the player-supplied name from a JOIN: trimmed, capped at
|
|
63
|
+
* {@link MAX_PLAYER_NAME_LENGTH}, and never blank.
|
|
64
|
+
*/
|
|
65
|
+
export function sanitizePlayerName(name: string): string {
|
|
66
|
+
const trimmed = truncate(name.trim(), MAX_PLAYER_NAME_LENGTH).trim();
|
|
67
|
+
return trimmed.length > 0 ? trimmed : DEFAULT_PLAYER_NAME;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalizes the player-supplied avatar from a JOIN. Anything that is not a
|
|
72
|
+
* string of at most {@link MAX_PLAYER_AVATAR_LENGTH} characters is dropped.
|
|
73
|
+
*/
|
|
74
|
+
export function sanitizePlayerAvatar(avatar: unknown): string | undefined {
|
|
75
|
+
if (typeof avatar !== "string") return undefined;
|
|
76
|
+
if (avatar.length === 0 || avatar.length > MAX_PLAYER_AVATAR_LENGTH) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return avatar;
|
|
80
|
+
}
|
|
81
|
+
|
|
39
82
|
type ClientMessageOf<TType extends ClientMessage["type"]> = Extract<
|
|
40
83
|
ClientMessage,
|
|
41
84
|
{ type: TType }
|
|
@@ -67,12 +110,14 @@ export function isValidClientMessage(
|
|
|
67
110
|
if (typeof m.type !== "string") return false;
|
|
68
111
|
|
|
69
112
|
switch (m.type) {
|
|
70
|
-
case MessageTypes.JOIN:
|
|
113
|
+
case MessageTypes.JOIN: {
|
|
114
|
+
if (typeof m.payload !== "object" || m.payload === null) return false;
|
|
115
|
+
const { name, avatar } = m.payload as Record<string, unknown>;
|
|
71
116
|
return (
|
|
72
|
-
typeof
|
|
73
|
-
|
|
74
|
-
typeof (m.payload as Record<string, unknown>).name === "string"
|
|
117
|
+
typeof name === "string" &&
|
|
118
|
+
(avatar === undefined || avatar === null || typeof avatar === "string")
|
|
75
119
|
);
|
|
120
|
+
}
|
|
76
121
|
case MessageTypes.ACTION:
|
|
77
122
|
return (
|
|
78
123
|
typeof m.payload === "object" &&
|
package/src/runtime.ts
CHANGED
|
@@ -16,17 +16,45 @@ import {
|
|
|
16
16
|
DEFAULT_STATE_THROTTLE_MS,
|
|
17
17
|
createStateUpdateMessage,
|
|
18
18
|
} from "./broadcast-scheduler.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
isValidClientMessage,
|
|
21
|
+
sanitizePlayerAvatar,
|
|
22
|
+
sanitizePlayerName,
|
|
23
|
+
} from "./message-validation.js";
|
|
20
24
|
import { ActionRateLimiter } from "./rate-limiter.js";
|
|
21
25
|
import {
|
|
22
26
|
HostSessionManager,
|
|
23
27
|
type JoinSessionPayload,
|
|
24
28
|
} from "./session-manager.js";
|
|
25
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Most client actions kept for the next `STATE_UPDATE`. A transport that is
|
|
32
|
+
* detached for a while must not grow the queue without bound; the newest
|
|
33
|
+
* actions are the ones a debug log wants.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_QUEUED_ACTIONS = 64;
|
|
36
|
+
|
|
37
|
+
/** One connection's share of a {@link GameRuntimeTransport.sendMany} delivery. */
|
|
38
|
+
export interface AddressedMessage {
|
|
39
|
+
connectionId: string;
|
|
40
|
+
message: HostMessage;
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
/** Minimal message-delivery surface required by the authoritative runtime. */
|
|
27
44
|
export interface GameRuntimeTransport {
|
|
28
45
|
send(connectionId: string, message: HostMessage): void;
|
|
29
46
|
broadcast(message: HostMessage): void;
|
|
47
|
+
/**
|
|
48
|
+
* Delivers a batch of per-connection messages, for transports that can carry
|
|
49
|
+
* them in one frame.
|
|
50
|
+
*
|
|
51
|
+
* Optional: the runtime falls back to a {@link GameRuntimeTransport.send} per
|
|
52
|
+
* entry, which is what a plain LAN WebSocket transport wants anyway. It earns
|
|
53
|
+
* its keep on the relay, where each frame the display sends is separately
|
|
54
|
+
* billed and rate-limited, so a projected state update costs one message
|
|
55
|
+
* rather than one per player.
|
|
56
|
+
*/
|
|
57
|
+
sendMany?(entries: readonly AddressedMessage[]): void;
|
|
30
58
|
}
|
|
31
59
|
|
|
32
60
|
/** Configuration shared by every authoritative Couch Kit host transport. */
|
|
@@ -175,7 +203,7 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
175
203
|
|
|
176
204
|
switch (message.type) {
|
|
177
205
|
case MessageTypes.JOIN: {
|
|
178
|
-
const { secret
|
|
206
|
+
const { secret } = message.payload;
|
|
179
207
|
|
|
180
208
|
if (!secret || typeof secret !== "string" || !isValidSecret(secret)) {
|
|
181
209
|
this.send(connectionId, {
|
|
@@ -204,20 +232,30 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
204
232
|
|
|
205
233
|
this.pendingJoins.add(connectionId);
|
|
206
234
|
try {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
235
|
+
const joinPayload: JoinSessionPayload = {
|
|
236
|
+
name: sanitizePlayerName(message.payload.name),
|
|
237
|
+
avatar: sanitizePlayerAvatar(message.payload.avatar),
|
|
238
|
+
secret,
|
|
239
|
+
};
|
|
240
|
+
const hashedId = await this.sessionManager.derivePlayerIdFor(secret);
|
|
241
|
+
|
|
242
|
+
// The socket closed while the ID was being derived. Nothing has been
|
|
243
|
+
// recorded yet, so the player's previous session and any pending
|
|
244
|
+
// removal are left exactly as they were.
|
|
214
245
|
if (
|
|
215
246
|
this.activeConnections.get(connectionId) !== connectionGeneration
|
|
216
247
|
) {
|
|
217
|
-
this.sessionManager.abandonConnection(connectionId);
|
|
218
248
|
return;
|
|
219
249
|
}
|
|
220
250
|
|
|
251
|
+
const { playerId, isReconnect, action } =
|
|
252
|
+
this.sessionManager.registerJoin<S>(
|
|
253
|
+
connectionId,
|
|
254
|
+
joinPayload,
|
|
255
|
+
hashedId,
|
|
256
|
+
this.state.players,
|
|
257
|
+
);
|
|
258
|
+
|
|
221
259
|
this.applyAction(action);
|
|
222
260
|
this.joinedConnections.add(connectionId);
|
|
223
261
|
this.assetsLoaded.set(playerId, false);
|
|
@@ -225,7 +263,7 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
225
263
|
"onPlayerJoined",
|
|
226
264
|
this.config.onPlayerJoined
|
|
227
265
|
? () => {
|
|
228
|
-
this.config.onPlayerJoined?.(playerId,
|
|
266
|
+
this.config.onPlayerJoined?.(playerId, joinPayload.name);
|
|
229
267
|
}
|
|
230
268
|
: undefined,
|
|
231
269
|
);
|
|
@@ -299,11 +337,20 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
299
337
|
return;
|
|
300
338
|
}
|
|
301
339
|
|
|
302
|
-
this.applyAction({
|
|
340
|
+
const changed = this.applyAction({
|
|
303
341
|
...actionPayload,
|
|
304
342
|
playerId: authorization.playerId,
|
|
305
343
|
} as A);
|
|
306
|
-
|
|
344
|
+
// Only actions that changed state ride along with the next update: a
|
|
345
|
+
// no-op schedules no broadcast, so queueing it would grow the queue
|
|
346
|
+
// until some unrelated change flushed the lot. Projected games never
|
|
347
|
+
// attach actions (see broadcastState), so nothing is queued for them.
|
|
348
|
+
if (changed && !this.config.project) {
|
|
349
|
+
this.actionQueue.push(actionPayload);
|
|
350
|
+
if (this.actionQueue.length > MAX_QUEUED_ACTIONS) {
|
|
351
|
+
this.actionQueue.shift();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
307
354
|
break;
|
|
308
355
|
}
|
|
309
356
|
|
|
@@ -380,9 +427,10 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
380
427
|
this.transport = null;
|
|
381
428
|
}
|
|
382
429
|
|
|
383
|
-
|
|
430
|
+
/** @returns whether the action changed the canonical state. */
|
|
431
|
+
private applyAction(action: A | InternalAction<S>): boolean {
|
|
384
432
|
const nextState = this.reducer(this.state, action);
|
|
385
|
-
if (Object.is(nextState, this.state)) return;
|
|
433
|
+
if (Object.is(nextState, this.state)) return false;
|
|
386
434
|
|
|
387
435
|
this.state = nextState;
|
|
388
436
|
this.stateDirty = true;
|
|
@@ -392,6 +440,7 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
392
440
|
}
|
|
393
441
|
|
|
394
442
|
this.broadcastScheduler.schedule(this.broadcastState);
|
|
443
|
+
return true;
|
|
395
444
|
}
|
|
396
445
|
|
|
397
446
|
/**
|
|
@@ -404,27 +453,42 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
404
453
|
}
|
|
405
454
|
|
|
406
455
|
private readonly broadcastState = (): void => {
|
|
407
|
-
|
|
456
|
+
const transport = this.transport;
|
|
457
|
+
if (!transport) return;
|
|
408
458
|
|
|
409
459
|
const actions = this.actionQueue;
|
|
410
460
|
this.actionQueue = [];
|
|
411
461
|
this.stateDirty = false;
|
|
412
462
|
|
|
413
463
|
if (!this.config.project) {
|
|
414
|
-
|
|
464
|
+
transport.broadcast(createStateUpdateMessage(this.state, actions));
|
|
415
465
|
return;
|
|
416
466
|
}
|
|
417
467
|
|
|
418
468
|
// Projected games get one message per connection rather than a broadcast:
|
|
419
|
-
// a shared frame cannot carry different views.
|
|
420
|
-
//
|
|
469
|
+
// a shared frame cannot carry different views. Transports that can batch
|
|
470
|
+
// (see GameRuntimeTransport.sendMany) still put them on the wire as a
|
|
471
|
+
// single frame; the rest fall back to N sends, bounded by players-per-room.
|
|
472
|
+
//
|
|
473
|
+
// No actions are attached: a player's action payload is exactly the kind
|
|
474
|
+
// of thing a projection exists to hide from everyone else.
|
|
475
|
+
const entries: AddressedMessage[] = [];
|
|
421
476
|
for (const connectionId of this.joinedConnections) {
|
|
422
477
|
const playerId = this.sessionManager.getPlayerIdForSocket(connectionId);
|
|
423
478
|
if (!playerId) continue;
|
|
424
|
-
|
|
479
|
+
entries.push({
|
|
425
480
|
connectionId,
|
|
426
|
-
createStateUpdateMessage(this.viewFor(playerId),
|
|
427
|
-
);
|
|
481
|
+
message: createStateUpdateMessage(this.viewFor(playerId), []),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
if (entries.length === 0) return;
|
|
485
|
+
|
|
486
|
+
if (transport.sendMany) {
|
|
487
|
+
transport.sendMany(entries);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
for (const { connectionId, message } of entries) {
|
|
491
|
+
this.send(connectionId, message);
|
|
428
492
|
}
|
|
429
493
|
};
|
|
430
494
|
|
package/src/session-manager.ts
CHANGED
|
@@ -79,12 +79,38 @@ export class HostSessionManager<TTimer = ReturnType<typeof setTimeout>> {
|
|
|
79
79
|
options.derivePlayerIdLegacy ?? derivePlayerIdLegacy;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Derives the public player ID for a secret without touching session state.
|
|
84
|
+
*
|
|
85
|
+
* Split from {@link HostSessionManager.registerJoin} so a caller can check
|
|
86
|
+
* that the connection is still alive after the (asynchronous) hash and before
|
|
87
|
+
* anything is recorded — a join for a socket that already closed must not
|
|
88
|
+
* displace the player's live session or cancel their pending removal.
|
|
89
|
+
*/
|
|
90
|
+
derivePlayerIdFor(secret: string): Promise<string> {
|
|
91
|
+
return this.derivePlayerIdFn(secret);
|
|
92
|
+
}
|
|
93
|
+
|
|
82
94
|
async handleJoin<S extends IGameState>(
|
|
83
95
|
socketId: string,
|
|
84
96
|
payload: JoinSessionPayload,
|
|
85
97
|
playersSource: PlayersSource<S>,
|
|
86
98
|
): Promise<JoinSessionResult<S>> {
|
|
87
99
|
const hashedId = await this.derivePlayerIdFn(payload.secret);
|
|
100
|
+
return this.registerJoin<S>(socketId, payload, hashedId, playersSource);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Records a join whose player ID was already derived with
|
|
105
|
+
* {@link HostSessionManager.derivePlayerIdFor}. Synchronous, so the session
|
|
106
|
+
* maps and the removal timer change atomically with the caller's own checks.
|
|
107
|
+
*/
|
|
108
|
+
registerJoin<S extends IGameState>(
|
|
109
|
+
socketId: string,
|
|
110
|
+
payload: JoinSessionPayload,
|
|
111
|
+
hashedId: string,
|
|
112
|
+
playersSource: PlayersSource<S>,
|
|
113
|
+
): JoinSessionResult<S> {
|
|
88
114
|
const players =
|
|
89
115
|
typeof playersSource === "function" ? playersSource() : playersSource;
|
|
90
116
|
let playerId = hashedId;
|