@couch-kit/runtime 0.3.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 +12 -0
- package/dist/index.cjs +78 -24
- package/dist/index.js +78 -24
- 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 +1 -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 +1 -1
- package/src/broadcast-scheduler.ts +17 -5
- package/src/message-validation.ts +49 -4
- package/src/runtime.ts +51 -16
- package/src/session-manager.ts +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## 0.3.0
|
|
4
16
|
|
|
5
17
|
### 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,13 +605,14 @@ 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;
|
|
@@ -582,7 +636,7 @@ class GameHostRuntime {
|
|
|
582
636
|
continue;
|
|
583
637
|
entries.push({
|
|
584
638
|
connectionId,
|
|
585
|
-
message: createStateUpdateMessage(this.viewFor(playerId),
|
|
639
|
+
message: createStateUpdateMessage(this.viewFor(playerId), [])
|
|
586
640
|
});
|
|
587
641
|
}
|
|
588
642
|
if (entries.length === 0)
|
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,13 +555,14 @@ 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;
|
|
@@ -537,7 +586,7 @@ class GameHostRuntime {
|
|
|
537
586
|
continue;
|
|
538
587
|
entries.push({
|
|
539
588
|
connectionId,
|
|
540
|
-
message: createStateUpdateMessage(this.viewFor(playerId),
|
|
589
|
+
message: createStateUpdateMessage(this.viewFor(playerId), [])
|
|
541
590
|
});
|
|
542
591
|
}
|
|
543
592
|
if (entries.length === 0)
|
|
@@ -582,16 +631,21 @@ class GameHostRuntime {
|
|
|
582
631
|
}
|
|
583
632
|
}
|
|
584
633
|
export {
|
|
585
|
-
|
|
586
|
-
frameByteLength,
|
|
587
|
-
createStateUpdateMessage,
|
|
588
|
-
authorizeClientAction,
|
|
589
|
-
RATE_LIMIT_WINDOW,
|
|
590
|
-
RATE_LIMIT_MAX,
|
|
591
|
-
HostSessionManager,
|
|
592
|
-
GameHostRuntime,
|
|
593
|
-
DEFAULT_STATE_THROTTLE_MS,
|
|
594
|
-
DEFAULT_MAX_MESSAGE_BYTES,
|
|
634
|
+
ActionRateLimiter,
|
|
595
635
|
BroadcastScheduler,
|
|
596
|
-
|
|
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
|
|
597
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
|
@@ -94,6 +94,7 @@ export declare class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
94
94
|
handleError(error: Error): void;
|
|
95
95
|
/** Cancels runtime timers and releases transport-specific state. */
|
|
96
96
|
stop(): void;
|
|
97
|
+
/** @returns whether the action changed the canonical state. */
|
|
97
98
|
private applyAction;
|
|
98
99
|
/**
|
|
99
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
|
@@ -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,13 +16,24 @@ 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
|
+
|
|
26
37
|
/** One connection's share of a {@link GameRuntimeTransport.sendMany} delivery. */
|
|
27
38
|
export interface AddressedMessage {
|
|
28
39
|
connectionId: string;
|
|
@@ -192,7 +203,7 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
192
203
|
|
|
193
204
|
switch (message.type) {
|
|
194
205
|
case MessageTypes.JOIN: {
|
|
195
|
-
const { secret
|
|
206
|
+
const { secret } = message.payload;
|
|
196
207
|
|
|
197
208
|
if (!secret || typeof secret !== "string" || !isValidSecret(secret)) {
|
|
198
209
|
this.send(connectionId, {
|
|
@@ -221,20 +232,30 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
221
232
|
|
|
222
233
|
this.pendingJoins.add(connectionId);
|
|
223
234
|
try {
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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.
|
|
231
245
|
if (
|
|
232
246
|
this.activeConnections.get(connectionId) !== connectionGeneration
|
|
233
247
|
) {
|
|
234
|
-
this.sessionManager.abandonConnection(connectionId);
|
|
235
248
|
return;
|
|
236
249
|
}
|
|
237
250
|
|
|
251
|
+
const { playerId, isReconnect, action } =
|
|
252
|
+
this.sessionManager.registerJoin<S>(
|
|
253
|
+
connectionId,
|
|
254
|
+
joinPayload,
|
|
255
|
+
hashedId,
|
|
256
|
+
this.state.players,
|
|
257
|
+
);
|
|
258
|
+
|
|
238
259
|
this.applyAction(action);
|
|
239
260
|
this.joinedConnections.add(connectionId);
|
|
240
261
|
this.assetsLoaded.set(playerId, false);
|
|
@@ -242,7 +263,7 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
242
263
|
"onPlayerJoined",
|
|
243
264
|
this.config.onPlayerJoined
|
|
244
265
|
? () => {
|
|
245
|
-
this.config.onPlayerJoined?.(playerId,
|
|
266
|
+
this.config.onPlayerJoined?.(playerId, joinPayload.name);
|
|
246
267
|
}
|
|
247
268
|
: undefined,
|
|
248
269
|
);
|
|
@@ -316,11 +337,20 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
316
337
|
return;
|
|
317
338
|
}
|
|
318
339
|
|
|
319
|
-
this.applyAction({
|
|
340
|
+
const changed = this.applyAction({
|
|
320
341
|
...actionPayload,
|
|
321
342
|
playerId: authorization.playerId,
|
|
322
343
|
} as A);
|
|
323
|
-
|
|
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
|
+
}
|
|
324
354
|
break;
|
|
325
355
|
}
|
|
326
356
|
|
|
@@ -397,9 +427,10 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
397
427
|
this.transport = null;
|
|
398
428
|
}
|
|
399
429
|
|
|
400
|
-
|
|
430
|
+
/** @returns whether the action changed the canonical state. */
|
|
431
|
+
private applyAction(action: A | InternalAction<S>): boolean {
|
|
401
432
|
const nextState = this.reducer(this.state, action);
|
|
402
|
-
if (Object.is(nextState, this.state)) return;
|
|
433
|
+
if (Object.is(nextState, this.state)) return false;
|
|
403
434
|
|
|
404
435
|
this.state = nextState;
|
|
405
436
|
this.stateDirty = true;
|
|
@@ -409,6 +440,7 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
409
440
|
}
|
|
410
441
|
|
|
411
442
|
this.broadcastScheduler.schedule(this.broadcastState);
|
|
443
|
+
return true;
|
|
412
444
|
}
|
|
413
445
|
|
|
414
446
|
/**
|
|
@@ -437,13 +469,16 @@ export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
|
437
469
|
// a shared frame cannot carry different views. Transports that can batch
|
|
438
470
|
// (see GameRuntimeTransport.sendMany) still put them on the wire as a
|
|
439
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.
|
|
440
475
|
const entries: AddressedMessage[] = [];
|
|
441
476
|
for (const connectionId of this.joinedConnections) {
|
|
442
477
|
const playerId = this.sessionManager.getPlayerIdForSocket(connectionId);
|
|
443
478
|
if (!playerId) continue;
|
|
444
479
|
entries.push({
|
|
445
480
|
connectionId,
|
|
446
|
-
message: createStateUpdateMessage(this.viewFor(playerId),
|
|
481
|
+
message: createStateUpdateMessage(this.viewFor(playerId), []),
|
|
447
482
|
});
|
|
448
483
|
}
|
|
449
484
|
if (entries.length === 0) return;
|
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;
|