@edge-base/server 0.2.6 → 0.2.8

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 (78) hide show
  1. package/admin-build/_app/immutable/chunks/{CbfX3ELZ.js → B9efkx2V.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{CLHN9MVr.js → BMXWUTG-.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{DemDWbs-.js → Bt4AyT3o.js} +3 -3
  4. package/admin-build/_app/immutable/chunks/CKVjMXZi.js +1 -0
  5. package/admin-build/_app/immutable/chunks/{BvoGcDFV.js → CMYgGhZR.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{LL3ulaxa.js → CTRjWhGs.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{BN_-k-Ck.js → CwyE59Yt.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{DQVP4KC-.js → D8aeTKry.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/{Ff90owjx.js → DGAHkap7.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{CR37B8DX.js → DPgR4-0v.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{DdvsFblq.js → DYtrHeVQ.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{CrwlCAM0.js → DcVb45Ds.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/Djnkhy-S.js +1 -0
  14. package/admin-build/_app/immutable/chunks/{DmDTovpg.js → fPy6xmgG.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{CCUxCptE.js → j4jxnAKj.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{qBm6xof8.js → zl2AUKMP.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.CP83Ni80.js → app.Cmz0WjMl.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.JE7dcbK1.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.DiRq7puO.js → 0.y6D_QyUb.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.BFeyKLGT.js → 1.CndRxhbH.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.zcee7hJx.js → 10.CdA5FmXy.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.BW7wLs2Y.js → 11.DG8SzMp_.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.CxJRlYSd.js → 12.CvmQqpFa.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.pp0F_5hn.js → 13.BbGNdswT.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.t3AfGiGo.js → 14.CZKsN7-O.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.B3agc7NX.js → 15.A7-CYgkG.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.C4uG2-i8.js → 16.hgJT9H-x.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.CwGxi1Bn.js → 17.DkWZbcN2.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.CrQyN_gU.js → 18.sX3Fb5gh.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.NEPUOXl7.js → 19.VAZUW-1K.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.DGHO8ipr.js → 20.DkIKxacG.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.DOjJlQKc.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.Dri5It7a.js → 22.BDaHvtaw.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.BPQP_Zte.js → 23.BVRzw_pD.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.D580FdSS.js → 24.CVhSJyG0.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.BMNPOZwF.js → 25.Bme-9bZn.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.XcpEcbiz.js → 26.Dsx7RIIs.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.C1zHHcYv.js → 27.DMGQnzFM.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.CuKzzrY8.js → 28.GGwFmEhZ.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.nLpBMXnM.js → 29.Dnghr0nk.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.5G_aseoL.js → 3.Cg7zZJP1.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.CQC4nLoU.js → 30.C0J24z3I.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.Bet8kxOK.js → 31.MdxFI8v6.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.nmJDYJpC.js → 4.DCAOVzGE.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.CnbYLG4E.js → 5.DzUQ-cTc.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.KA01b-3y.js → 6.CptBYTVj.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.CP9fkn1L.js → 7.DfeeQ0Rg.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.BTzDb---.js → 8.CIcvctW7.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.DkNJg_J6.js → 9.QKrvq4RA.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__/admin-assets.test.ts +7 -7
  55. package/src/__tests__/frontend-assets.test.ts +75 -0
  56. package/src/__tests__/frontend-config.test.ts +16 -0
  57. package/src/__tests__/frontend-routing.test.ts +200 -0
  58. package/src/__tests__/openapi-coverage.test.ts +0 -6
  59. package/src/__tests__/room-auth-state-loss.test.ts +6 -0
  60. package/src/__tests__/room-handler-context.test.ts +0 -31
  61. package/src/__tests__/room-rate-limit-scopes.test.ts +1 -5
  62. package/src/__tests__/room-runtime-routing.test.ts +1 -111
  63. package/src/__tests__/smoke-skip-report.test.ts +1 -1
  64. package/src/durable-objects/room-runtime-base.ts +243 -17
  65. package/src/durable-objects/rooms-do.ts +190 -1345
  66. package/src/index.ts +97 -3
  67. package/src/lib/admin-assets.ts +5 -5
  68. package/src/lib/frontend-assets.ts +129 -0
  69. package/src/lib/frontend-config.ts +11 -0
  70. package/src/lib/openapi.ts +1 -4
  71. package/src/routes/room.ts +0 -285
  72. package/src/types.ts +1 -14
  73. package/admin-build/_app/immutable/chunks/Q3vAxeY-.js +0 -1
  74. package/admin-build/_app/immutable/chunks/SQVAC3Cv.js +0 -1
  75. package/admin-build/_app/immutable/entry/start.DY6YakU0.js +0 -1
  76. package/admin-build/_app/immutable/nodes/21.UVKBDvp4.js +0 -1
  77. package/src/__tests__/cloudflare-realtime.test.ts +0 -113
  78. 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);
@@ -1082,6 +1157,8 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1082
1157
 
1083
1158
  protected buildRoomServerAPI(): RoomServerAPI {
1084
1159
  return {
1160
+ namespace: this.namespace ?? '',
1161
+ roomId: this.roomId ?? '',
1085
1162
  getSharedState: (): Record<string, unknown> => {
1086
1163
  return cloneState(this.sharedState);
1087
1164
  },
@@ -1377,10 +1454,23 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1377
1454
  this._stateTTLAlarmAt = typeof savedEphemeral?.stateTTLAlarmAt === 'number'
1378
1455
  ? savedEphemeral.stateTTLAlarmAt
1379
1456
  : null;
1457
+ this._socketHeartbeatCheckAt = typeof savedEphemeral?.socketHeartbeatCheckAt === 'number'
1458
+ ? savedEphemeral.socketHeartbeatCheckAt
1459
+ : null;
1380
1460
 
1381
1461
  this._scheduleNextAlarm();
1382
1462
  }
1383
1463
 
1464
+ protected async recoverStateIfNeeded(): Promise<void> {
1465
+ if (!this.stateRecoveryNeeded) {
1466
+ return;
1467
+ }
1468
+
1469
+ await this.recoverFromStorage();
1470
+ await this.recoverRuntimeStateFromSockets();
1471
+ this.stateRecoveryNeeded = false;
1472
+ }
1473
+
1384
1474
  // ─── Delta Broadcasting ───
1385
1475
 
1386
1476
  /** Queue shared state delta and flush it at the end of the current turn. */
@@ -1622,6 +1712,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1622
1712
  const stateSaveAt = this._stateSaveAt;
1623
1713
  const emptyRoomCleanupAt = this._emptyRoomCleanupAt;
1624
1714
  const stateTTLAlarmAt = this._stateTTLAlarmAt;
1715
+ const socketHeartbeatCheckAt = this._socketHeartbeatCheckAt;
1625
1716
  this.ctx.waitUntil((async () => {
1626
1717
  try {
1627
1718
  if (
@@ -1630,6 +1721,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1630
1721
  && stateSaveAt === null
1631
1722
  && emptyRoomCleanupAt === null
1632
1723
  && stateTTLAlarmAt === null
1724
+ && socketHeartbeatCheckAt === null
1633
1725
  ) {
1634
1726
  await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
1635
1727
  return;
@@ -1641,6 +1733,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1641
1733
  stateSaveAt,
1642
1734
  emptyRoomCleanupAt,
1643
1735
  stateTTLAlarmAt,
1736
+ socketHeartbeatCheckAt,
1644
1737
  } satisfies PersistedRoomEphemeralTimers);
1645
1738
  } catch (error) {
1646
1739
  console.warn('[Room] Ephemeral timer persistence skipped', {
@@ -1739,14 +1832,12 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1739
1832
  ): boolean {
1740
1833
  const now = Date.now();
1741
1834
  const rateLimit = this.namespaceConfig?.rateLimit as
1742
- | { actions: number; signals?: number; media?: number; admin?: number }
1835
+ | { actions: number; signals?: number; admin?: number }
1743
1836
  | undefined;
1744
1837
  const maxActions = (
1745
1838
  scope === 'signals'
1746
1839
  ? rateLimit?.signals
1747
- : scope === 'media'
1748
- ? rateLimit?.media
1749
- : scope === 'admin'
1840
+ : scope === 'admin'
1750
1841
  ? rateLimit?.admin
1751
1842
  : undefined
1752
1843
  ) ?? rateLimit?.actions ?? DEFAULT_RATE_LIMIT_ACTIONS;
@@ -1785,8 +1876,17 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1785
1876
  const cached = this._metaCache.get(ws);
1786
1877
  if (cached) return cached;
1787
1878
 
1788
- // After hibernation wake-up: rebuild from tags
1789
1879
  try {
1880
+ const attachment = this.readWSAttachment(ws);
1881
+ if (attachment) {
1882
+ const meta = cloneRoomWSMeta(attachment.meta);
1883
+ this._metaCache.set(ws, meta);
1884
+ this._attachmentExtraCache.set(ws, attachment.extra);
1885
+ this.hydrateRoomIdentityFromWebSocketTags(ws);
1886
+ return meta;
1887
+ }
1888
+
1889
+ // After hibernation wake-up: rebuild from tags
1790
1890
  const tags = this.ctx.getTags(ws);
1791
1891
  if (tags.length === 0) return null;
1792
1892
 
@@ -1817,8 +1917,10 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1817
1917
  authStateLost: true,
1818
1918
  connectionId,
1819
1919
  ip,
1920
+ lastSeenAt: Date.now(),
1820
1921
  };
1821
1922
  this._metaCache.set(ws, meta);
1923
+ this._attachmentExtraCache.delete(ws);
1822
1924
  return meta;
1823
1925
  } catch {
1824
1926
  return null;
@@ -1826,7 +1928,77 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1826
1928
  }
1827
1929
 
1828
1930
  protected setWSMeta(ws: WebSocket, meta: RoomWSMeta): void {
1829
- this._metaCache.set(ws, meta);
1931
+ const clonedMeta = cloneRoomWSMeta(meta);
1932
+ const extra = this.buildWSAttachmentExtra(ws, clonedMeta);
1933
+ this._metaCache.set(ws, clonedMeta);
1934
+ this._attachmentExtraCache.set(ws, extra);
1935
+ this.writeWSAttachment(ws, {
1936
+ version: 1,
1937
+ meta: clonedMeta,
1938
+ extra,
1939
+ });
1940
+ }
1941
+
1942
+ protected getWSAttachmentExtra<T = unknown>(ws: WebSocket): T | undefined {
1943
+ return this._attachmentExtraCache.get(ws) as T | undefined;
1944
+ }
1945
+
1946
+ protected buildWSAttachmentExtra(_ws: WebSocket, _meta: RoomWSMeta): unknown {
1947
+ return undefined;
1948
+ }
1949
+
1950
+ private getSocketStaleTimeoutMs(): number {
1951
+ const configured = (this.namespaceConfig as { socketStaleTimeout?: unknown } | null)?.socketStaleTimeout;
1952
+ if (typeof configured !== 'number' || !Number.isFinite(configured)) {
1953
+ return DEFAULT_SOCKET_STALE_TIMEOUT_MS;
1954
+ }
1955
+ const normalized = Math.floor(configured);
1956
+ return normalized >= 3000 ? normalized : DEFAULT_SOCKET_STALE_TIMEOUT_MS;
1957
+ }
1958
+
1959
+ private getSocketHeartbeatCheckIntervalMs(): number {
1960
+ return Math.max(
1961
+ 2000,
1962
+ Math.min(SOCKET_HEARTBEAT_CHECK_INTERVAL_MS, Math.floor(this.getSocketStaleTimeoutMs() / 2)),
1963
+ );
1964
+ }
1965
+
1966
+ private ensureSocketHeartbeatCheckScheduled(): void {
1967
+ if (this.ctx.getWebSockets().length === 0) {
1968
+ this._socketHeartbeatCheckAt = null;
1969
+ return;
1970
+ }
1971
+
1972
+ const nextCheckAt = Date.now() + this.getSocketHeartbeatCheckIntervalMs();
1973
+ if (this._socketHeartbeatCheckAt === null || this._socketHeartbeatCheckAt > nextCheckAt) {
1974
+ this._socketHeartbeatCheckAt = nextCheckAt;
1975
+ this._scheduleNextAlarm();
1976
+ }
1977
+ }
1978
+
1979
+ protected async recoverRuntimeStateFromSockets(): Promise<void> {
1980
+ this.players.clear();
1981
+ this.userToConnections.clear();
1982
+
1983
+ let clearedPendingAuth = false;
1984
+ for (const ws of this.ctx.getWebSockets()) {
1985
+ const meta = this.getWSMeta(ws);
1986
+ if (!meta?.authenticated || !meta.userId) {
1987
+ continue;
1988
+ }
1989
+ this.addPlayer(meta.connectionId, meta.userId);
1990
+ if (this.pendingAuth.delete(meta.connectionId)) {
1991
+ clearedPendingAuth = true;
1992
+ }
1993
+ }
1994
+
1995
+ if (clearedPendingAuth) {
1996
+ this.syncEphemeralTimersToStorage();
1997
+ this._scheduleNextAlarm();
1998
+ }
1999
+
2000
+ this.syncRoomMonitoringSnapshot();
2001
+ this.ensureSocketHeartbeatCheckScheduled();
1830
2002
  }
1831
2003
 
1832
2004
  protected handleUnauthenticatedSocket(ws: WebSocket, meta: RoomWSMeta): void {
@@ -1853,7 +2025,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1853
2025
  return getGlobalConfig(env);
1854
2026
  }
1855
2027
 
1856
- private async ensureRuntimeReady(): Promise<void> {
2028
+ protected async ensureRuntimeReady(): Promise<void> {
1857
2029
  if (!this.runtimeReadyPromise) {
1858
2030
  this.runtimeReadyPromise = (async () => {
1859
2031
  await ensureServerStartup();
@@ -1892,4 +2064,58 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1892
2064
  && Object.keys(this._metadata).length === 0
1893
2065
  );
1894
2066
  }
2067
+
2068
+ private readWSAttachment(ws: WebSocket): PersistedRoomWSAttachment | null {
2069
+ const hibernatingSocket = ws as HibernatingWebSocket;
2070
+ if (typeof hibernatingSocket.deserializeAttachment !== 'function') {
2071
+ return null;
2072
+ }
2073
+
2074
+ try {
2075
+ const attachment = hibernatingSocket.deserializeAttachment();
2076
+ return isPersistedRoomWSAttachment(attachment) ? attachment : null;
2077
+ } catch {
2078
+ return null;
2079
+ }
2080
+ }
2081
+
2082
+ private writeWSAttachment(ws: WebSocket, attachment: PersistedRoomWSAttachment): void {
2083
+ const hibernatingSocket = ws as HibernatingWebSocket;
2084
+ if (typeof hibernatingSocket.serializeAttachment !== 'function') {
2085
+ return;
2086
+ }
2087
+
2088
+ try {
2089
+ hibernatingSocket.serializeAttachment(attachment);
2090
+ } catch (error) {
2091
+ console.warn('[Room] Failed to serialize websocket attachment', {
2092
+ room: this.namespace && this.roomId ? `${this.namespace}::${this.roomId}` : null,
2093
+ connectionId: attachment.meta.connectionId,
2094
+ message: error instanceof Error ? error.message : String(error),
2095
+ });
2096
+ }
2097
+ }
2098
+
2099
+ private hydrateRoomIdentityFromWebSocketTags(ws: WebSocket): void {
2100
+ if (this.namespace) {
2101
+ return;
2102
+ }
2103
+
2104
+ const tags = this.ctx.getTags(ws);
2105
+ const roomTag = tags.find(t => t.startsWith('room:'));
2106
+ if (!roomTag) {
2107
+ return;
2108
+ }
2109
+
2110
+ const roomFullName = roomTag.substring(5);
2111
+ const separatorIdx = roomFullName.indexOf('::');
2112
+ if (separatorIdx >= 0) {
2113
+ this.namespace = roomFullName.substring(0, separatorIdx);
2114
+ this.roomId = roomFullName.substring(separatorIdx + 2);
2115
+ } else {
2116
+ this.namespace = roomFullName;
2117
+ this.roomId = roomFullName;
2118
+ }
2119
+ this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
2120
+ }
1895
2121
  }