@edge-base/server 0.2.6 → 0.2.7

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.
Files changed (68) hide show
  1. package/admin-build/_app/immutable/chunks/{BN_-k-Ck.js → BDYewzou.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{qBm6xof8.js → BEM1BeVF.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{Ff90owjx.js → BYyykAbh.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{SQVAC3Cv.js → BaUG2TJ-.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{BvoGcDFV.js → BfpUQYr3.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{CLHN9MVr.js → BhCO1Fpt.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{DemDWbs-.js → CIOC1v_q.js} +3 -3
  8. package/admin-build/_app/immutable/chunks/{CrwlCAM0.js → CvczjTXx.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/{DQVP4KC-.js → D1u3u7xu.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{LL3ulaxa.js → DaXO-sFP.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{CbfX3ELZ.js → DnpbvAPi.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{DdvsFblq.js → Dz9cUCuv.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{DmDTovpg.js → Tea2dBJ8.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{CR37B8DX.js → ejoEf2I5.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{CCUxCptE.js → iEyeblJR.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{Q3vAxeY-.js → qKdzaeX3.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.CP83Ni80.js → app.DoUaxnew.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.MmZh8oBH.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.DiRq7puO.js → 0.Dsxi8s7i.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.BFeyKLGT.js → 1.Cp2l-hol.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.zcee7hJx.js → 10.4oY6m8Nz.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.BW7wLs2Y.js → 11.DfcozD4J.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.CxJRlYSd.js → 12.uJgZdCIA.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.pp0F_5hn.js → 13.CaN1kRev.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.t3AfGiGo.js → 14.DQ5xIi3s.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.B3agc7NX.js → 15.B_EkebTJ.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.C4uG2-i8.js → 16.Tko1ZX8-.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.CwGxi1Bn.js → 17.BCmWMJX9.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.CrQyN_gU.js → 18.hmGhl1O2.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.NEPUOXl7.js → 19.D-1infOo.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.DGHO8ipr.js → 20.CY4KKcBL.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.B9lbNUQr.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.Dri5It7a.js → 22.14Vd7bnt.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.BPQP_Zte.js → 23.Be6jK77o.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.D580FdSS.js → 24.CSTFkr6R.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.BMNPOZwF.js → 25.DRTg8fHc.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.XcpEcbiz.js → 26.DKt-9lwQ.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.C1zHHcYv.js → 27.D5caPu0F.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.CuKzzrY8.js → 28.hJhlnlyY.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.nLpBMXnM.js → 29.CDYBzFyT.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.5G_aseoL.js → 3.DMyKwkGn.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.CQC4nLoU.js → 30.BaHNeEmc.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.Bet8kxOK.js → 31.C6PV5L-2.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.nmJDYJpC.js → 4.9E118Ftm.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.CnbYLG4E.js → 5.D8guAl3v.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.KA01b-3y.js → 6.D1u__DtT.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.CP9fkn1L.js → 7.DWXHnRFf.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.BTzDb---.js → 8.Dojd8krc.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.DkNJg_J6.js → 9.CLtrr0K_.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/openapi.json +6 -1941
  53. package/package.json +3 -3
  54. package/src/__tests__/openapi-coverage.test.ts +0 -6
  55. package/src/__tests__/room-auth-state-loss.test.ts +6 -0
  56. package/src/__tests__/room-handler-context.test.ts +0 -31
  57. package/src/__tests__/room-rate-limit-scopes.test.ts +1 -5
  58. package/src/__tests__/room-runtime-routing.test.ts +1 -111
  59. package/src/__tests__/smoke-skip-report.test.ts +1 -1
  60. package/src/durable-objects/room-runtime-base.ts +241 -17
  61. package/src/durable-objects/rooms-do.ts +190 -1345
  62. package/src/lib/openapi.ts +1 -4
  63. package/src/routes/room.ts +0 -285
  64. package/src/types.ts +1 -14
  65. package/admin-build/_app/immutable/entry/start.DY6YakU0.js +0 -1
  66. package/admin-build/_app/immutable/nodes/21.UVKBDvp4.js +0 -1
  67. package/src/__tests__/cloudflare-realtime.test.ts +0 -113
  68. package/src/lib/cloudflare-realtime.ts +0 -251
@@ -49,8 +49,20 @@ export interface RoomWSMeta {
49
49
  ip?: string;
50
50
  userAgent?: string;
51
51
  connectionId: string;
52
+ lastSeenAt?: number;
52
53
  }
53
54
 
55
+ interface PersistedRoomWSAttachment {
56
+ version: 1;
57
+ meta: RoomWSMeta;
58
+ extra?: unknown;
59
+ }
60
+
61
+ type HibernatingWebSocket = WebSocket & {
62
+ serializeAttachment?: (value: unknown) => void;
63
+ deserializeAttachment?: () => unknown;
64
+ };
65
+
54
66
  interface PlayerInfo {
55
67
  userId: string;
56
68
  connectionId: string;
@@ -72,9 +84,11 @@ const ACTION_TIMEOUT_MS = 5000;
72
84
  const DEFAULT_ROOM_AUTH_TIMEOUT_MS = 5000;
73
85
  const DEFAULT_STATE_SAVE_INTERVAL_MS = 60000; // 1 minute
74
86
  const DEFAULT_STATE_TTL_MS = 86400000; // 24 hours
87
+ const DEFAULT_SOCKET_STALE_TIMEOUT_MS = 45000;
88
+ const SOCKET_HEARTBEAT_CHECK_INTERVAL_MS = 5000;
75
89
  const ROOM_EPHEMERAL_TIMERS_STORAGE_KEY = 'roomEphemeralTimers';
76
90
  const roomFallbackWarnings = new Set<string>();
77
- type RoomRateLimitScope = 'actions' | 'signals' | 'media' | 'admin';
91
+ type RoomRateLimitScope = 'actions' | 'signals' | 'admin';
78
92
 
79
93
  interface PendingDisconnectDeadline {
80
94
  fireAt: number;
@@ -87,6 +101,7 @@ interface PersistedRoomEphemeralTimers {
87
101
  stateSaveAt?: number | null;
88
102
  emptyRoomCleanupAt?: number | null;
89
103
  stateTTLAlarmAt?: number | null;
104
+ socketHeartbeatCheckAt?: number | null;
90
105
  }
91
106
 
92
107
  function isRoomOperationPublic(
@@ -170,6 +185,34 @@ function cloneState(obj: Record<string, unknown>): Record<string, unknown> {
170
185
  return JSON.parse(JSON.stringify(obj));
171
186
  }
172
187
 
188
+ function cloneRoomWSMeta(meta: RoomWSMeta): RoomWSMeta {
189
+ return {
190
+ authenticated: meta.authenticated,
191
+ authStateLost: meta.authStateLost === true,
192
+ userId: meta.userId,
193
+ role: meta.role,
194
+ auth: meta.auth
195
+ ? {
196
+ ...meta.auth,
197
+ custom: meta.auth.custom ? cloneState(meta.auth.custom as Record<string, unknown>) : undefined,
198
+ meta: meta.auth.meta ? cloneState(meta.auth.meta as Record<string, unknown>) : undefined,
199
+ }
200
+ : undefined,
201
+ ip: meta.ip,
202
+ userAgent: meta.userAgent,
203
+ connectionId: meta.connectionId,
204
+ lastSeenAt: meta.lastSeenAt,
205
+ };
206
+ }
207
+
208
+ function isPersistedRoomWSAttachment(value: unknown): value is PersistedRoomWSAttachment {
209
+ return typeof value === 'object'
210
+ && value !== null
211
+ && (value as PersistedRoomWSAttachment).version === 1
212
+ && typeof (value as PersistedRoomWSAttachment).meta === 'object'
213
+ && (value as PersistedRoomWSAttachment).meta !== null;
214
+ }
215
+
173
216
  // ─── Shared Room Runtime Base ───
174
217
 
175
218
  export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
@@ -202,6 +245,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
202
245
 
203
246
  // ─── WebSocket metadata cache ───
204
247
  private _metaCache = new Map<WebSocket, RoomWSMeta>();
248
+ private _attachmentExtraCache = new Map<WebSocket, unknown>();
205
249
 
206
250
  // ─── Auth timeout tracking ───
207
251
  private pendingAuth = new Map<string, number>();
@@ -220,6 +264,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
220
264
  private _timers = new Map<string, { fireAt: number; data?: unknown }>();
221
265
  private _emptyRoomCleanupAt: number | null = null;
222
266
  private _stateTTLAlarmAt: number | null = null;
267
+ private _socketHeartbeatCheckAt: number | null = null;
223
268
 
224
269
  // ─── Room Metadata (queryable via HTTP without joining) ───
225
270
  private _metadata: Record<string, unknown> = {};
@@ -384,6 +429,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
384
429
  authenticated: false,
385
430
  authStateLost: false,
386
431
  connectionId,
432
+ lastSeenAt: Date.now(),
387
433
  ip: request.headers.get('CF-Connecting-IP')
388
434
  || request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim()
389
435
  || undefined,
@@ -403,8 +449,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
403
449
  tags.push(`ip:${encodeURIComponent(meta.ip)}`);
404
450
  }
405
451
  this.ctx.acceptWebSocket(server, tags);
406
- this._metaCache.set(server, meta);
452
+ this.setWSMeta(server, meta);
407
453
  this.syncRoomMonitoringSnapshot();
454
+ this.ensureSocketHeartbeatCheckScheduled();
408
455
 
409
456
  // Set auth timeout without pinning the DO with a JS timer.
410
457
  const authTimeoutMs = resolveRoomAuthTimeoutMs(this.config);
@@ -419,6 +466,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
419
466
 
420
467
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
421
468
  await this.ensureRuntimeReady();
469
+ await this.recoverStateIfNeeded();
422
470
  if (typeof message !== 'string') return;
423
471
 
424
472
  let msg: Record<string, unknown>;
@@ -434,6 +482,8 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
434
482
  ws.close(4000, 'No metadata');
435
483
  return;
436
484
  }
485
+ meta.lastSeenAt = Date.now();
486
+ this.setWSMeta(ws, meta);
437
487
 
438
488
  const type = msg.type as string;
439
489
 
@@ -487,11 +537,13 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
487
537
  const explicitLeave = code === ROOM_CLIENT_LEAVE_CLOSE_CODE;
488
538
  this.handleDisconnect(meta, kicked, explicitLeave);
489
539
  this._metaCache.delete(ws);
540
+ this._attachmentExtraCache.delete(ws);
490
541
  if (this.pendingAuth.delete(meta.connectionId)) {
491
542
  this.syncEphemeralTimersToStorage();
492
543
  this._scheduleNextAlarm();
493
544
  }
494
545
  }
546
+ this.ensureSocketHeartbeatCheckScheduled();
495
547
  this.syncRoomMonitoringSnapshot(ws);
496
548
  }
497
549
 
@@ -500,11 +552,13 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
500
552
  if (meta) {
501
553
  this.handleDisconnect(meta);
502
554
  this._metaCache.delete(ws);
555
+ this._attachmentExtraCache.delete(ws);
503
556
  if (this.pendingAuth.delete(meta.connectionId)) {
504
557
  this.syncEphemeralTimersToStorage();
505
558
  this._scheduleNextAlarm();
506
559
  }
507
560
  }
561
+ this.ensureSocketHeartbeatCheckScheduled();
508
562
  this.syncRoomMonitoringSnapshot(ws);
509
563
  }
510
564
 
@@ -535,6 +589,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
535
589
  if (this._stateTTLAlarmAt !== null && this._stateTTLAlarmAt < earliest) {
536
590
  earliest = this._stateTTLAlarmAt;
537
591
  }
592
+ if (this._socketHeartbeatCheckAt !== null && this._socketHeartbeatCheckAt < earliest) {
593
+ earliest = this._socketHeartbeatCheckAt;
594
+ }
538
595
 
539
596
  if (earliest < Infinity) {
540
597
  this.ctx.storage.setAlarm(earliest);
@@ -543,10 +600,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
543
600
 
544
601
  async alarm(): Promise<void> {
545
602
  await this.ensureRuntimeReady();
546
-
547
603
  if (this.shouldRecoverBeforeAlarm()) {
548
- await this.recoverFromStorage();
549
- this.stateRecoveryNeeded = false;
604
+ this.stateRecoveryNeeded = true;
605
+ await this.recoverStateIfNeeded();
550
606
  }
551
607
 
552
608
  const now = Date.now();
@@ -682,7 +738,28 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
682
738
  this.syncEphemeralTimersToStorage();
683
739
  }
684
740
 
685
- // 7. Reschedule for next pending event
741
+ // 7. Close stale authenticated sockets that stopped heartbeating.
742
+ if (this._socketHeartbeatCheckAt !== null && this._socketHeartbeatCheckAt <= now) {
743
+ this._socketHeartbeatCheckAt = null;
744
+ const staleBefore = now - this.getSocketStaleTimeoutMs();
745
+ for (const ws of this.ctx.getWebSockets()) {
746
+ const meta = this.getWSMeta(ws);
747
+ if (!meta?.authenticated) {
748
+ continue;
749
+ }
750
+ if ((meta.lastSeenAt ?? 0) > staleBefore) {
751
+ continue;
752
+ }
753
+ try {
754
+ ws.close(4007, 'Heartbeat timeout');
755
+ } catch {
756
+ // Socket may already be closing.
757
+ }
758
+ }
759
+ this.ensureSocketHeartbeatCheckScheduled();
760
+ }
761
+
762
+ // 8. Reschedule for next pending event
686
763
  this._scheduleNextAlarm();
687
764
  }
688
765
 
@@ -717,6 +794,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
717
794
  );
718
795
  meta.authenticated = true;
719
796
  meta.authStateLost = false;
797
+ meta.lastSeenAt = Date.now();
720
798
  meta.userId = auth.id;
721
799
  meta.role = auth.role;
722
800
  meta.auth = {
@@ -755,10 +833,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
755
833
  this.syncRoomMonitoringSnapshot();
756
834
 
757
835
  // Recover state from storage if needed (after hibernation wake-up)
758
- if (this.stateRecoveryNeeded) {
759
- await this.recoverFromStorage();
760
- this.stateRecoveryNeeded = false;
761
- }
836
+ await this.recoverStateIfNeeded();
762
837
  // Note: full sync is sent during handleJoin(), not here
763
838
  } catch (error) {
764
839
  const detail = error instanceof Error ? error.message : String(error);
@@ -1377,10 +1452,23 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1377
1452
  this._stateTTLAlarmAt = typeof savedEphemeral?.stateTTLAlarmAt === 'number'
1378
1453
  ? savedEphemeral.stateTTLAlarmAt
1379
1454
  : null;
1455
+ this._socketHeartbeatCheckAt = typeof savedEphemeral?.socketHeartbeatCheckAt === 'number'
1456
+ ? savedEphemeral.socketHeartbeatCheckAt
1457
+ : null;
1380
1458
 
1381
1459
  this._scheduleNextAlarm();
1382
1460
  }
1383
1461
 
1462
+ protected async recoverStateIfNeeded(): Promise<void> {
1463
+ if (!this.stateRecoveryNeeded) {
1464
+ return;
1465
+ }
1466
+
1467
+ await this.recoverFromStorage();
1468
+ await this.recoverRuntimeStateFromSockets();
1469
+ this.stateRecoveryNeeded = false;
1470
+ }
1471
+
1384
1472
  // ─── Delta Broadcasting ───
1385
1473
 
1386
1474
  /** Queue shared state delta and flush it at the end of the current turn. */
@@ -1622,6 +1710,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1622
1710
  const stateSaveAt = this._stateSaveAt;
1623
1711
  const emptyRoomCleanupAt = this._emptyRoomCleanupAt;
1624
1712
  const stateTTLAlarmAt = this._stateTTLAlarmAt;
1713
+ const socketHeartbeatCheckAt = this._socketHeartbeatCheckAt;
1625
1714
  this.ctx.waitUntil((async () => {
1626
1715
  try {
1627
1716
  if (
@@ -1630,6 +1719,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1630
1719
  && stateSaveAt === null
1631
1720
  && emptyRoomCleanupAt === null
1632
1721
  && stateTTLAlarmAt === null
1722
+ && socketHeartbeatCheckAt === null
1633
1723
  ) {
1634
1724
  await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
1635
1725
  return;
@@ -1641,6 +1731,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1641
1731
  stateSaveAt,
1642
1732
  emptyRoomCleanupAt,
1643
1733
  stateTTLAlarmAt,
1734
+ socketHeartbeatCheckAt,
1644
1735
  } satisfies PersistedRoomEphemeralTimers);
1645
1736
  } catch (error) {
1646
1737
  console.warn('[Room] Ephemeral timer persistence skipped', {
@@ -1739,14 +1830,12 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1739
1830
  ): boolean {
1740
1831
  const now = Date.now();
1741
1832
  const rateLimit = this.namespaceConfig?.rateLimit as
1742
- | { actions: number; signals?: number; media?: number; admin?: number }
1833
+ | { actions: number; signals?: number; admin?: number }
1743
1834
  | undefined;
1744
1835
  const maxActions = (
1745
1836
  scope === 'signals'
1746
1837
  ? rateLimit?.signals
1747
- : scope === 'media'
1748
- ? rateLimit?.media
1749
- : scope === 'admin'
1838
+ : scope === 'admin'
1750
1839
  ? rateLimit?.admin
1751
1840
  : undefined
1752
1841
  ) ?? rateLimit?.actions ?? DEFAULT_RATE_LIMIT_ACTIONS;
@@ -1785,8 +1874,17 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1785
1874
  const cached = this._metaCache.get(ws);
1786
1875
  if (cached) return cached;
1787
1876
 
1788
- // After hibernation wake-up: rebuild from tags
1789
1877
  try {
1878
+ const attachment = this.readWSAttachment(ws);
1879
+ if (attachment) {
1880
+ const meta = cloneRoomWSMeta(attachment.meta);
1881
+ this._metaCache.set(ws, meta);
1882
+ this._attachmentExtraCache.set(ws, attachment.extra);
1883
+ this.hydrateRoomIdentityFromWebSocketTags(ws);
1884
+ return meta;
1885
+ }
1886
+
1887
+ // After hibernation wake-up: rebuild from tags
1790
1888
  const tags = this.ctx.getTags(ws);
1791
1889
  if (tags.length === 0) return null;
1792
1890
 
@@ -1817,8 +1915,10 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1817
1915
  authStateLost: true,
1818
1916
  connectionId,
1819
1917
  ip,
1918
+ lastSeenAt: Date.now(),
1820
1919
  };
1821
1920
  this._metaCache.set(ws, meta);
1921
+ this._attachmentExtraCache.delete(ws);
1822
1922
  return meta;
1823
1923
  } catch {
1824
1924
  return null;
@@ -1826,7 +1926,77 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1826
1926
  }
1827
1927
 
1828
1928
  protected setWSMeta(ws: WebSocket, meta: RoomWSMeta): void {
1829
- this._metaCache.set(ws, meta);
1929
+ const clonedMeta = cloneRoomWSMeta(meta);
1930
+ const extra = this.buildWSAttachmentExtra(ws, clonedMeta);
1931
+ this._metaCache.set(ws, clonedMeta);
1932
+ this._attachmentExtraCache.set(ws, extra);
1933
+ this.writeWSAttachment(ws, {
1934
+ version: 1,
1935
+ meta: clonedMeta,
1936
+ extra,
1937
+ });
1938
+ }
1939
+
1940
+ protected getWSAttachmentExtra<T = unknown>(ws: WebSocket): T | undefined {
1941
+ return this._attachmentExtraCache.get(ws) as T | undefined;
1942
+ }
1943
+
1944
+ protected buildWSAttachmentExtra(_ws: WebSocket, _meta: RoomWSMeta): unknown {
1945
+ return undefined;
1946
+ }
1947
+
1948
+ private getSocketStaleTimeoutMs(): number {
1949
+ const configured = (this.namespaceConfig as { socketStaleTimeout?: unknown } | null)?.socketStaleTimeout;
1950
+ if (typeof configured !== 'number' || !Number.isFinite(configured)) {
1951
+ return DEFAULT_SOCKET_STALE_TIMEOUT_MS;
1952
+ }
1953
+ const normalized = Math.floor(configured);
1954
+ return normalized >= 3000 ? normalized : DEFAULT_SOCKET_STALE_TIMEOUT_MS;
1955
+ }
1956
+
1957
+ private getSocketHeartbeatCheckIntervalMs(): number {
1958
+ return Math.max(
1959
+ 2000,
1960
+ Math.min(SOCKET_HEARTBEAT_CHECK_INTERVAL_MS, Math.floor(this.getSocketStaleTimeoutMs() / 2)),
1961
+ );
1962
+ }
1963
+
1964
+ private ensureSocketHeartbeatCheckScheduled(): void {
1965
+ if (this.ctx.getWebSockets().length === 0) {
1966
+ this._socketHeartbeatCheckAt = null;
1967
+ return;
1968
+ }
1969
+
1970
+ const nextCheckAt = Date.now() + this.getSocketHeartbeatCheckIntervalMs();
1971
+ if (this._socketHeartbeatCheckAt === null || this._socketHeartbeatCheckAt > nextCheckAt) {
1972
+ this._socketHeartbeatCheckAt = nextCheckAt;
1973
+ this._scheduleNextAlarm();
1974
+ }
1975
+ }
1976
+
1977
+ protected async recoverRuntimeStateFromSockets(): Promise<void> {
1978
+ this.players.clear();
1979
+ this.userToConnections.clear();
1980
+
1981
+ let clearedPendingAuth = false;
1982
+ for (const ws of this.ctx.getWebSockets()) {
1983
+ const meta = this.getWSMeta(ws);
1984
+ if (!meta?.authenticated || !meta.userId) {
1985
+ continue;
1986
+ }
1987
+ this.addPlayer(meta.connectionId, meta.userId);
1988
+ if (this.pendingAuth.delete(meta.connectionId)) {
1989
+ clearedPendingAuth = true;
1990
+ }
1991
+ }
1992
+
1993
+ if (clearedPendingAuth) {
1994
+ this.syncEphemeralTimersToStorage();
1995
+ this._scheduleNextAlarm();
1996
+ }
1997
+
1998
+ this.syncRoomMonitoringSnapshot();
1999
+ this.ensureSocketHeartbeatCheckScheduled();
1830
2000
  }
1831
2001
 
1832
2002
  protected handleUnauthenticatedSocket(ws: WebSocket, meta: RoomWSMeta): void {
@@ -1853,7 +2023,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1853
2023
  return getGlobalConfig(env);
1854
2024
  }
1855
2025
 
1856
- private async ensureRuntimeReady(): Promise<void> {
2026
+ protected async ensureRuntimeReady(): Promise<void> {
1857
2027
  if (!this.runtimeReadyPromise) {
1858
2028
  this.runtimeReadyPromise = (async () => {
1859
2029
  await ensureServerStartup();
@@ -1892,4 +2062,58 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1892
2062
  && Object.keys(this._metadata).length === 0
1893
2063
  );
1894
2064
  }
2065
+
2066
+ private readWSAttachment(ws: WebSocket): PersistedRoomWSAttachment | null {
2067
+ const hibernatingSocket = ws as HibernatingWebSocket;
2068
+ if (typeof hibernatingSocket.deserializeAttachment !== 'function') {
2069
+ return null;
2070
+ }
2071
+
2072
+ try {
2073
+ const attachment = hibernatingSocket.deserializeAttachment();
2074
+ return isPersistedRoomWSAttachment(attachment) ? attachment : null;
2075
+ } catch {
2076
+ return null;
2077
+ }
2078
+ }
2079
+
2080
+ private writeWSAttachment(ws: WebSocket, attachment: PersistedRoomWSAttachment): void {
2081
+ const hibernatingSocket = ws as HibernatingWebSocket;
2082
+ if (typeof hibernatingSocket.serializeAttachment !== 'function') {
2083
+ return;
2084
+ }
2085
+
2086
+ try {
2087
+ hibernatingSocket.serializeAttachment(attachment);
2088
+ } catch (error) {
2089
+ console.warn('[Room] Failed to serialize websocket attachment', {
2090
+ room: this.namespace && this.roomId ? `${this.namespace}::${this.roomId}` : null,
2091
+ connectionId: attachment.meta.connectionId,
2092
+ message: error instanceof Error ? error.message : String(error),
2093
+ });
2094
+ }
2095
+ }
2096
+
2097
+ private hydrateRoomIdentityFromWebSocketTags(ws: WebSocket): void {
2098
+ if (this.namespace) {
2099
+ return;
2100
+ }
2101
+
2102
+ const tags = this.ctx.getTags(ws);
2103
+ const roomTag = tags.find(t => t.startsWith('room:'));
2104
+ if (!roomTag) {
2105
+ return;
2106
+ }
2107
+
2108
+ const roomFullName = roomTag.substring(5);
2109
+ const separatorIdx = roomFullName.indexOf('::');
2110
+ if (separatorIdx >= 0) {
2111
+ this.namespace = roomFullName.substring(0, separatorIdx);
2112
+ this.roomId = roomFullName.substring(separatorIdx + 2);
2113
+ } else {
2114
+ this.namespace = roomFullName;
2115
+ this.roomId = roomFullName;
2116
+ }
2117
+ this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
2118
+ }
1895
2119
  }