@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 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
- isValidClientMessage: () => isValidClientMessage,
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
- ActionRateLimiter: () => ActionRateLimiter
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.cancel();
106
+ this.pending = callback;
107
+ if (this.timer !== null)
108
+ return;
101
109
  this.timer = this.scheduler.setTimeout(() => {
102
110
  this.timer = null;
103
- callback();
111
+ const pending = this.pending;
112
+ this.pending = null;
113
+ pending?.();
104
114
  }, this.stateThrottleMs);
105
115
  }
106
116
  cancel() {
107
- if (this.timer) {
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
- return typeof m.payload === "object" && m.payload !== null && typeof m.payload.name === "string";
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, ...payload } = message.payload;
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 { playerId, isReconnect, action } = await this.sessionManager.handleJoin(connectionId, message.payload, () => this.state.players);
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, payload.name);
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.actionQueue.push(actionPayload);
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
- if (!this.transport)
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
- this.transport.broadcast(createStateUpdateMessage(this.state, actions));
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
- this.send(connectionId, createStateUpdateMessage(this.viewFor(playerId), actions));
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.cancel();
45
+ this.pending = callback;
46
+ if (this.timer !== null)
47
+ return;
45
48
  this.timer = this.scheduler.setTimeout(() => {
46
49
  this.timer = null;
47
- callback();
50
+ const pending = this.pending;
51
+ this.pending = null;
52
+ pending?.();
48
53
  }, this.stateThrottleMs);
49
54
  }
50
55
  cancel() {
51
- if (this.timer) {
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
- return typeof m.payload === "object" && m.payload !== null && typeof m.payload.name === "string";
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, ...payload } = message.payload;
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 { playerId, isReconnect, action } = await this.sessionManager.handleJoin(connectionId, message.payload, () => this.state.players);
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, payload.name);
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.actionQueue.push(actionPayload);
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
- if (!this.transport)
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
- this.transport.broadcast(createStateUpdateMessage(this.state, actions));
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
- this.send(connectionId, createStateUpdateMessage(this.viewFor(playerId), actions));
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
- isValidClientMessage,
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
- ActionRateLimiter
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
- * Debounced state-broadcast scheduler used by the authoritative runtime.
17
- * Rapid state changes are coalesced into one broadcast after the latest change.
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;;;GAGG;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;IAEpC,YAAY,OAAO,GAAE,yBAAyB,CAAC,MAAM,CAAM,EAK1D;IAED,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAMnC;IAED,MAAM,IAAI,IAAI,CAKb;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"}
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,CA8B/B"}
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
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"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"}
@@ -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":"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"}
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.2.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.9.3"
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
- * Debounced state-broadcast scheduler used by the authoritative runtime.
28
- * Rapid state changes are coalesced into one broadcast after the latest change.
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
- this.cancel();
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
- callback();
55
+ const pending = this.pending;
56
+ this.pending = null;
57
+ pending?.();
47
58
  }, this.stateThrottleMs);
48
59
  }
49
60
 
50
61
  cancel(): void {
51
- if (this.timer) {
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 m.payload === "object" &&
73
- m.payload !== null &&
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 { isValidClientMessage } from "./message-validation.js";
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, ...payload } = message.payload;
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 { playerId, isReconnect, action } =
208
- await this.sessionManager.handleJoin<S>(
209
- connectionId,
210
- message.payload as JoinSessionPayload,
211
- () => this.state.players,
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, payload.name);
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
- this.actionQueue.push(actionPayload);
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
- private applyAction(action: A | InternalAction<S>): void {
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
- if (!this.transport) return;
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
- this.transport.broadcast(createStateUpdateMessage(this.state, actions));
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. Costs N sends instead of 1,
420
- // which is bounded by players-per-room.
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
- this.send(
479
+ entries.push({
425
480
  connectionId,
426
- createStateUpdateMessage(this.viewFor(playerId), actions),
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
 
@@ -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;