@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 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
- 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,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), actions)
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.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,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), actions)
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
- isValidClientMessage,
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
- 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
597
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
@@ -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
@@ -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":"AAAA,OAAO,EAML,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,UAAU,EAEhB,MAAM,iBAAiB,CAAC;AAyBzB,kFAAkF;AAClF,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,WAAW,CAAC;CACtB;AAED,8EAA8E;AAC9E,MAAM,WAAW,oBAAoB;IACnC,IAAI,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI,CAAC;IACvD,SAAS,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CAAC;IACtC;;;;;;;;;OASG;IACH,QAAQ,CAAC,CAAC,OAAO,EAAE,SAAS,gBAAgB,EAAE,GAAG,IAAI,CAAC;CACvD;AAED,4EAA4E;AAC5E,MAAM,WAAW,qBAAqB,CACpC,CAAC,SAAS,UAAU,EACpB,CAAC,SAAS,OAAO;IAEjB,YAAY,EAAE,CAAC,CAAC;IAChB,OAAO,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC3B;;;;;;;;;;;;;;;;;;OAkBG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAClD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mEAAmE;IACnE,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,sDAAsD;IACtD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1D,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAED;;;GAGG;AACH,qBAAa,eAAe,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IAClE,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwC;IAChE,OAAO,CAAC,KAAK,CAAI;IACjB,OAAO,CAAC,SAAS,CAA8B;IAC/C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAyB;IACnD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAqB;IACpD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA2B;IACvD,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAqB;IACxD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA8B;IAC3D,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAA6B;IAC/D,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAqB;IACvD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAqB;IAClD,OAAO,CAAC,WAAW,CAAiB;IACpC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,wBAAwB,CAAK;IAErC,YACE,MAAM,EAAE,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC,EACnC,SAAS,GAAE,oBAAoB,GAAG,IAAW,EAa9C;IAED,oDAAoD;IACpD,QAAQ,CAAC,QAAQ,QAAO,CAAC,CAAe;IAExC,6CAA6C;IAC7C,QAAQ,CAAC,SAAS,aAAc,MAAM,IAAI,KAAG,CAAC,MAAM,IAAI,CAAC,CAKvD;IAEF,0EAA0E;IAC1E,YAAY,CAAC,SAAS,EAAE,oBAAoB,GAAG,IAAI,GAAG,IAAI,CAKzD;IAED,2EAA2E;IAC3E,YAAY,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAKtD;IAED,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,WAAY,CAAC,KAAG,IAAI,CAEnC;IAEF,8EAA8E;IAC9E,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAG3C;IAED,kEAAkE;IAC5D,aAAa,CACjB,YAAY,EAAE,MAAM,EACpB,UAAU,EAAE,OAAO,GAClB,OAAO,CAAC,IAAI,CAAC,CAwMf;IAED,4EAA4E;IAC5E,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CA+B3C;IAED,wEAAwE;IACxE,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAG9B;IAED,oEAAoE;IACpE,IAAI,IAAI,IAAI,CAQX;IAED,+DAA+D;IAC/D,OAAO,CAAC,WAAW;IAenB;;;OAGG;IACH,OAAO,CAAC,OAAO;IAKf,OAAO,CAAC,QAAQ,CAAC,cAAc,CAsC7B;IAEF,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,uBAAuB;IAgB/B,OAAO,CAAC,WAAW;IAcnB,OAAO,CAAC,GAAG;CAKZ"}
@@ -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,CAkC/B;IAED,gBAAgB,CAAC,CAAC,SAAS,UAAU,EACnC,QAAQ,EAAE,MAAM,GACf,uBAAuB,CAAC,CAAC,CAAC,CAsB5B;IAED,eAAe,CACb,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,GACnC,IAAI,CAQN;IAED,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAMpC;IAED,kBAAkB,IAAI,IAAI,CAKzB;IAED,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMzD;IAED,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAQxC;IAED,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEvD;IAED,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE3C;CACF"}
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;IAED;;;;;;;OAOG;IACH,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAEjD;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,CAG/B;IAED;;;;OAIG;IACH,YAAY,CAAC,CAAC,SAAS,UAAU,EAC/B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,kBAAkB,EAC3B,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,aAAa,CAAC,CAAC,CAAC,GAC9B,iBAAiB,CAAC,CAAC,CAAC,CAiCtB;IAED,gBAAgB,CAAC,CAAC,SAAS,UAAU,EACnC,QAAQ,EAAE,MAAM,GACf,uBAAuB,CAAC,CAAC,CAAC,CAsB5B;IAED,eAAe,CACb,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,GACnC,IAAI,CAQN;IAED,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAMpC;IAED,kBAAkB,IAAI,IAAI,CAKzB;IAED,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMzD;IAED,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAQxC;IAED,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEvD;IAED,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE3C;CACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@couch-kit/runtime",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
@@ -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,13 +16,24 @@ 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
+
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, ...payload } = message.payload;
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 { playerId, isReconnect, action } =
225
- await this.sessionManager.handleJoin<S>(
226
- connectionId,
227
- message.payload as JoinSessionPayload,
228
- () => this.state.players,
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, payload.name);
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
- 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
+ }
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
- private applyAction(action: A | InternalAction<S>): void {
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), actions),
481
+ message: createStateUpdateMessage(this.viewFor(playerId), []),
447
482
  });
448
483
  }
449
484
  if (entries.length === 0) return;
@@ -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;