@edge-base/server 0.2.5 → 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 (135) hide show
  1. package/admin-build/_app/immutable/chunks/{DILS_-VJ.js → B3CvhH3c.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/BDYewzou.js +1 -0
  3. package/admin-build/_app/immutable/chunks/{Cdm5zBRA.js → BEM1BeVF.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{Dt4vL4Df.js → BYL_uBga.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{B94PilAN.js → BYyykAbh.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/BaUG2TJ-.js +1 -0
  7. package/admin-build/_app/immutable/chunks/{C72lTcG0.js → Bcs4KYNp.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{D2j3I1VQ.js → BfpUQYr3.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/BhCO1Fpt.js +1 -0
  10. package/admin-build/_app/immutable/chunks/{B8s_s9QY.js → BkZCgsc3.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/CIOC1v_q.js +128 -0
  12. package/admin-build/_app/immutable/chunks/CjcrXziO.js +2 -0
  13. package/admin-build/_app/immutable/chunks/CvczjTXx.js +1 -0
  14. package/admin-build/_app/immutable/chunks/D1u3u7xu.js +1 -0
  15. package/admin-build/_app/immutable/chunks/{B0HRJ657.js → DOOPbWwG.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{BqTb6Mxk.js → DaXO-sFP.js} +1 -1
  17. package/admin-build/_app/immutable/chunks/DnpbvAPi.js +1 -0
  18. package/admin-build/_app/immutable/chunks/{B6MschND.js → Dz9cUCuv.js} +1 -1
  19. package/admin-build/_app/immutable/chunks/{CaVKAiCe.js → Tea2dBJ8.js} +1 -1
  20. package/admin-build/_app/immutable/chunks/{Z41NK6i6.js → bguI1TeA.js} +1 -1
  21. package/admin-build/_app/immutable/chunks/{J2Gw0SMu.js → ejoEf2I5.js} +1 -1
  22. package/admin-build/_app/immutable/chunks/{B2TnDKF7.js → iEyeblJR.js} +1 -1
  23. package/admin-build/_app/immutable/chunks/{_teD5ji5.js → nlAMTi52.js} +1 -1
  24. package/admin-build/_app/immutable/chunks/qKdzaeX3.js +1 -0
  25. package/admin-build/_app/immutable/entry/{app.D3flihMw.js → app.DoUaxnew.js} +2 -2
  26. package/admin-build/_app/immutable/entry/start.MmZh8oBH.js +1 -0
  27. package/admin-build/_app/immutable/nodes/{0.CdczqZLK.js → 0.Dsxi8s7i.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/1.Cp2l-hol.js +1 -0
  29. package/admin-build/_app/immutable/nodes/10.4oY6m8Nz.js +1 -0
  30. package/admin-build/_app/immutable/nodes/11.DfcozD4J.js +1 -0
  31. package/admin-build/_app/immutable/nodes/12.uJgZdCIA.js +1 -0
  32. package/admin-build/_app/immutable/nodes/13.CaN1kRev.js +110 -0
  33. package/admin-build/_app/immutable/nodes/14.DQ5xIi3s.js +3 -0
  34. package/admin-build/_app/immutable/nodes/15.B_EkebTJ.js +1 -0
  35. package/admin-build/_app/immutable/nodes/{16.BR7WwQrS.js → 16.Tko1ZX8-.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{17.Cm57KKXV.js → 17.BCmWMJX9.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/18.hmGhl1O2.js +1 -0
  38. package/admin-build/_app/immutable/nodes/19.D-1infOo.js +2 -0
  39. package/admin-build/_app/immutable/nodes/{20.DnHeFlTv.js → 20.CY4KKcBL.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/21.B9lbNUQr.js +1 -0
  41. package/admin-build/_app/immutable/nodes/22.14Vd7bnt.js +1 -0
  42. package/admin-build/_app/immutable/nodes/{23.CWSGMcKJ.js → 23.Be6jK77o.js} +2 -2
  43. package/admin-build/_app/immutable/nodes/24.CSTFkr6R.js +2 -0
  44. package/admin-build/_app/immutable/nodes/25.DRTg8fHc.js +2 -0
  45. package/admin-build/_app/immutable/nodes/26.DKt-9lwQ.js +1 -0
  46. package/admin-build/_app/immutable/nodes/27.D5caPu0F.js +1 -0
  47. package/admin-build/_app/immutable/nodes/28.hJhlnlyY.js +1 -0
  48. package/admin-build/_app/immutable/nodes/29.CDYBzFyT.js +1 -0
  49. package/admin-build/_app/immutable/nodes/{3.B6q-7qr8.js → 3.DMyKwkGn.js} +1 -1
  50. package/admin-build/_app/immutable/nodes/30.BaHNeEmc.js +1 -0
  51. package/admin-build/_app/immutable/nodes/31.C6PV5L-2.js +1 -0
  52. package/admin-build/_app/immutable/nodes/4.9E118Ftm.js +1 -0
  53. package/admin-build/_app/immutable/nodes/5.D8guAl3v.js +1 -0
  54. package/admin-build/_app/immutable/nodes/6.D1u__DtT.js +1 -0
  55. package/admin-build/_app/immutable/nodes/7.DWXHnRFf.js +1 -0
  56. package/admin-build/_app/immutable/nodes/8.Dojd8krc.js +1 -0
  57. package/admin-build/_app/immutable/nodes/9.CLtrr0K_.js +1 -0
  58. package/admin-build/_app/version.json +1 -1
  59. package/admin-build/index.html +7 -7
  60. package/openapi.json +6 -1941
  61. package/package.json +3 -3
  62. package/src/__tests__/openapi-coverage.test.ts +0 -6
  63. package/src/__tests__/push-handlers.test.ts +1 -1
  64. package/src/__tests__/room-auth-state-loss.test.ts +6 -0
  65. package/src/__tests__/room-handler-context.test.ts +0 -31
  66. package/src/__tests__/room-rate-limit-scopes.test.ts +1 -5
  67. package/src/__tests__/room-runtime-routing.test.ts +24 -111
  68. package/src/__tests__/route-parser.test.ts +6 -0
  69. package/src/__tests__/schema.test.ts +15 -6
  70. package/src/__tests__/smoke-skip-report.test.ts +1 -1
  71. package/src/durable-objects/database-do.ts +7 -1
  72. package/src/durable-objects/room-runtime-base.ts +290 -57
  73. package/src/durable-objects/rooms-do.ts +212 -1336
  74. package/src/index.ts +23 -9
  75. package/src/lib/d1-handler.ts +32 -17
  76. package/src/lib/openapi.ts +1 -4
  77. package/src/lib/postgres-handler.ts +24 -12
  78. package/src/lib/route-parser.ts +3 -0
  79. package/src/lib/schemas.ts +12 -2
  80. package/src/middleware/captcha-verify.ts +16 -3
  81. package/src/middleware/error-handler.ts +1 -1
  82. package/src/middleware/rules.ts +28 -9
  83. package/src/routes/admin-auth.ts +3 -3
  84. package/src/routes/admin.ts +13 -8
  85. package/src/routes/analytics-api.ts +3 -3
  86. package/src/routes/auth.ts +1 -1
  87. package/src/routes/backup.ts +1 -1
  88. package/src/routes/d1.ts +14 -7
  89. package/src/routes/database-live.ts +13 -6
  90. package/src/routes/kv.ts +21 -10
  91. package/src/routes/oauth.ts +1 -1
  92. package/src/routes/push.ts +119 -77
  93. package/src/routes/room.ts +203 -280
  94. package/src/routes/schema-endpoint.ts +2 -2
  95. package/src/routes/sql.ts +10 -6
  96. package/src/routes/storage.ts +4 -2
  97. package/src/routes/vectorize.ts +16 -4
  98. package/src/types.ts +1 -14
  99. package/admin-build/_app/immutable/chunks/6oMK_164.js +0 -1
  100. package/admin-build/_app/immutable/chunks/BEW7Ez_g.js +0 -1
  101. package/admin-build/_app/immutable/chunks/BoOooyH6.js +0 -1
  102. package/admin-build/_app/immutable/chunks/BvHnF5tV.js +0 -1
  103. package/admin-build/_app/immutable/chunks/CoI6jjbg.js +0 -2
  104. package/admin-build/_app/immutable/chunks/CrOZMmdF.js +0 -1
  105. package/admin-build/_app/immutable/chunks/Cw6OYcq-.js +0 -1
  106. package/admin-build/_app/immutable/chunks/DPdQ7z0T.js +0 -128
  107. package/admin-build/_app/immutable/chunks/pUxw8jfq.js +0 -1
  108. package/admin-build/_app/immutable/entry/start.Cl6sLxnz.js +0 -1
  109. package/admin-build/_app/immutable/nodes/1.DxcSsEqS.js +0 -1
  110. package/admin-build/_app/immutable/nodes/10.DuAd4aIm.js +0 -1
  111. package/admin-build/_app/immutable/nodes/11.0jgHQL92.js +0 -1
  112. package/admin-build/_app/immutable/nodes/12.CKNPqmyy.js +0 -1
  113. package/admin-build/_app/immutable/nodes/13.B1p2POXS.js +0 -110
  114. package/admin-build/_app/immutable/nodes/14.Bb-REBND.js +0 -3
  115. package/admin-build/_app/immutable/nodes/15.1uBFCX0X.js +0 -1
  116. package/admin-build/_app/immutable/nodes/18.CoiwfAuQ.js +0 -1
  117. package/admin-build/_app/immutable/nodes/19.B8ZdLlXj.js +0 -2
  118. package/admin-build/_app/immutable/nodes/21.CJFaf0Ia.js +0 -1
  119. package/admin-build/_app/immutable/nodes/22.CItETFzy.js +0 -1
  120. package/admin-build/_app/immutable/nodes/24.CWbEqNMB.js +0 -2
  121. package/admin-build/_app/immutable/nodes/25.DRkLEhKi.js +0 -2
  122. package/admin-build/_app/immutable/nodes/26.BRxO8AYH.js +0 -1
  123. package/admin-build/_app/immutable/nodes/27.BLs-nVHz.js +0 -1
  124. package/admin-build/_app/immutable/nodes/28.G79qkdBK.js +0 -1
  125. package/admin-build/_app/immutable/nodes/29.BOcI6g0N.js +0 -1
  126. package/admin-build/_app/immutable/nodes/30.DAIC7dKd.js +0 -1
  127. package/admin-build/_app/immutable/nodes/31.pl0XXjXF.js +0 -1
  128. package/admin-build/_app/immutable/nodes/4.DOdvVlZj.js +0 -1
  129. package/admin-build/_app/immutable/nodes/5.BW_zlgye.js +0 -1
  130. package/admin-build/_app/immutable/nodes/6.Dxy1CAI2.js +0 -1
  131. package/admin-build/_app/immutable/nodes/7.BG98w_o7.js +0 -1
  132. package/admin-build/_app/immutable/nodes/8.DoG5R2rG.js +0 -1
  133. package/admin-build/_app/immutable/nodes/9.Dmxf6zAC.js +0 -1
  134. package/src/__tests__/cloudflare-realtime.test.ts +0 -113
  135. 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(
@@ -114,6 +129,22 @@ function getRoomTimerHandlers(namespaceConfig?: RoomNamespaceConfig | null) {
114
129
  return namespaceConfig?.state?.timers ?? namespaceConfig?.handlers?.timers;
115
130
  }
116
131
 
132
+ function warnRoomDevelopmentFallback(
133
+ namespace: string,
134
+ operation: 'join' | 'action',
135
+ ): void {
136
+ const warningKey = `${namespace}:${operation}`;
137
+ if (roomFallbackWarnings.has(warningKey)) {
138
+ return;
139
+ }
140
+ roomFallbackWarnings.add(warningKey);
141
+ console.warn(
142
+ `[Room] ${warningKey} is allowed because release=false and no explicit room rule was found. `
143
+ + `This fallback is local-dev only. Add rooms.${namespace}.access.${operation} or set `
144
+ + `rooms.${namespace}.public.${operation}=true to make the behavior explicit.`,
145
+ );
146
+ }
147
+
117
148
  function resolveRoomAuthTimeoutMs(config?: EdgeBaseConfig): number {
118
149
  const value = config?.databaseLive?.authTimeoutMs;
119
150
  if (typeof value !== 'number' || !Number.isFinite(value)) {
@@ -154,6 +185,34 @@ function cloneState(obj: Record<string, unknown>): Record<string, unknown> {
154
185
  return JSON.parse(JSON.stringify(obj));
155
186
  }
156
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
+
157
216
  // ─── Shared Room Runtime Base ───
158
217
 
159
218
  export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
@@ -186,6 +245,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
186
245
 
187
246
  // ─── WebSocket metadata cache ───
188
247
  private _metaCache = new Map<WebSocket, RoomWSMeta>();
248
+ private _attachmentExtraCache = new Map<WebSocket, unknown>();
189
249
 
190
250
  // ─── Auth timeout tracking ───
191
251
  private pendingAuth = new Map<string, number>();
@@ -204,6 +264,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
204
264
  private _timers = new Map<string, { fireAt: number; data?: unknown }>();
205
265
  private _emptyRoomCleanupAt: number | null = null;
206
266
  private _stateTTLAlarmAt: number | null = null;
267
+ private _socketHeartbeatCheckAt: number | null = null;
207
268
 
208
269
  // ─── Room Metadata (queryable via HTTP without joining) ───
209
270
  private _metadata: Record<string, unknown> = {};
@@ -307,30 +368,39 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
307
368
  this.ctx.waitUntil(persistRoomMonitoringSnapshot(this.env.KV, snapshotToPersist));
308
369
  }
309
370
 
310
- // ─── Metadata HTTP Handler ───
311
-
312
- private async handleGetMetadata(url: URL): Promise<Response> {
313
- // Resolve namespace if not set (DO may have been cold-started via HTTP)
371
+ protected hydrateRoomIdentityFromUrl(url: URL): void {
314
372
  const roomFullName = url.searchParams.get('room');
315
- if (roomFullName && !this.namespace) {
316
- const separatorIdx = roomFullName.indexOf('::');
317
- if (separatorIdx >= 0) {
318
- this.namespace = roomFullName.substring(0, separatorIdx);
319
- this.roomId = roomFullName.substring(separatorIdx + 2);
320
- } else {
321
- this.namespace = roomFullName;
322
- this.roomId = roomFullName;
323
- }
324
- this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
373
+ if (!roomFullName || this.namespace) {
374
+ return;
325
375
  }
326
376
 
327
- // Metadata may not be in memory if DO was evicted/hibernated
377
+ const separatorIdx = roomFullName.indexOf('::');
378
+ if (separatorIdx >= 0) {
379
+ this.namespace = roomFullName.substring(0, separatorIdx);
380
+ this.roomId = roomFullName.substring(separatorIdx + 2);
381
+ } else {
382
+ this.namespace = roomFullName;
383
+ this.roomId = roomFullName;
384
+ }
385
+ this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
386
+ }
387
+
388
+ protected async getRoomMetadataSnapshot(): Promise<Record<string, unknown>> {
328
389
  if (Object.keys(this._metadata).length === 0) {
329
390
  const saved = await this.ctx.storage.get('roomMetadata') as Record<string, unknown> | undefined;
330
391
  if (saved) this._metadata = saved;
331
392
  }
332
393
 
333
- return new Response(JSON.stringify(this._metadata), {
394
+ return cloneState(this._metadata);
395
+ }
396
+
397
+ // ─── Metadata HTTP Handler ───
398
+
399
+ private async handleGetMetadata(url: URL): Promise<Response> {
400
+ this.hydrateRoomIdentityFromUrl(url);
401
+ const metadata = await this.getRoomMetadataSnapshot();
402
+
403
+ return new Response(JSON.stringify(metadata), {
334
404
  headers: { 'Content-Type': 'application/json' },
335
405
  });
336
406
  }
@@ -339,19 +409,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
339
409
 
340
410
  private handleWebSocketUpgrade(request: Request): Response {
341
411
  const url = new URL(request.url);
342
- const roomFullName = url.searchParams.get('room');
343
-
344
- if (roomFullName) {
345
- const separatorIdx = roomFullName.indexOf('::');
346
- if (separatorIdx >= 0) {
347
- this.namespace = roomFullName.substring(0, separatorIdx);
348
- this.roomId = roomFullName.substring(separatorIdx + 2);
349
- } else {
350
- this.namespace = roomFullName;
351
- this.roomId = roomFullName;
352
- }
353
- this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
354
- }
412
+ this.hydrateRoomIdentityFromUrl(url);
355
413
 
356
414
  // Check max players
357
415
  const maxPlayers = this.namespaceConfig?.maxPlayers ?? DEFAULT_MAX_PLAYERS;
@@ -371,6 +429,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
371
429
  authenticated: false,
372
430
  authStateLost: false,
373
431
  connectionId,
432
+ lastSeenAt: Date.now(),
374
433
  ip: request.headers.get('CF-Connecting-IP')
375
434
  || request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim()
376
435
  || undefined,
@@ -378,16 +437,21 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
378
437
  };
379
438
 
380
439
  // Accept with Hibernation API
440
+ const roomFullName =
441
+ this.namespace && this.roomId
442
+ ? `${this.namespace}::${this.roomId}`
443
+ : url.searchParams.get('room') ?? '';
381
444
  const tags = [
382
445
  `conn:${connectionId}`,
383
- `room:${roomFullName || ''}`,
446
+ `room:${roomFullName}`,
384
447
  ];
385
448
  if (meta.ip) {
386
449
  tags.push(`ip:${encodeURIComponent(meta.ip)}`);
387
450
  }
388
451
  this.ctx.acceptWebSocket(server, tags);
389
- this._metaCache.set(server, meta);
452
+ this.setWSMeta(server, meta);
390
453
  this.syncRoomMonitoringSnapshot();
454
+ this.ensureSocketHeartbeatCheckScheduled();
391
455
 
392
456
  // Set auth timeout without pinning the DO with a JS timer.
393
457
  const authTimeoutMs = resolveRoomAuthTimeoutMs(this.config);
@@ -402,6 +466,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
402
466
 
403
467
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
404
468
  await this.ensureRuntimeReady();
469
+ await this.recoverStateIfNeeded();
405
470
  if (typeof message !== 'string') return;
406
471
 
407
472
  let msg: Record<string, unknown>;
@@ -417,6 +482,8 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
417
482
  ws.close(4000, 'No metadata');
418
483
  return;
419
484
  }
485
+ meta.lastSeenAt = Date.now();
486
+ this.setWSMeta(ws, meta);
420
487
 
421
488
  const type = msg.type as string;
422
489
 
@@ -470,11 +537,13 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
470
537
  const explicitLeave = code === ROOM_CLIENT_LEAVE_CLOSE_CODE;
471
538
  this.handleDisconnect(meta, kicked, explicitLeave);
472
539
  this._metaCache.delete(ws);
540
+ this._attachmentExtraCache.delete(ws);
473
541
  if (this.pendingAuth.delete(meta.connectionId)) {
474
542
  this.syncEphemeralTimersToStorage();
475
543
  this._scheduleNextAlarm();
476
544
  }
477
545
  }
546
+ this.ensureSocketHeartbeatCheckScheduled();
478
547
  this.syncRoomMonitoringSnapshot(ws);
479
548
  }
480
549
 
@@ -483,11 +552,13 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
483
552
  if (meta) {
484
553
  this.handleDisconnect(meta);
485
554
  this._metaCache.delete(ws);
555
+ this._attachmentExtraCache.delete(ws);
486
556
  if (this.pendingAuth.delete(meta.connectionId)) {
487
557
  this.syncEphemeralTimersToStorage();
488
558
  this._scheduleNextAlarm();
489
559
  }
490
560
  }
561
+ this.ensureSocketHeartbeatCheckScheduled();
491
562
  this.syncRoomMonitoringSnapshot(ws);
492
563
  }
493
564
 
@@ -518,6 +589,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
518
589
  if (this._stateTTLAlarmAt !== null && this._stateTTLAlarmAt < earliest) {
519
590
  earliest = this._stateTTLAlarmAt;
520
591
  }
592
+ if (this._socketHeartbeatCheckAt !== null && this._socketHeartbeatCheckAt < earliest) {
593
+ earliest = this._socketHeartbeatCheckAt;
594
+ }
521
595
 
522
596
  if (earliest < Infinity) {
523
597
  this.ctx.storage.setAlarm(earliest);
@@ -526,10 +600,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
526
600
 
527
601
  async alarm(): Promise<void> {
528
602
  await this.ensureRuntimeReady();
529
-
530
603
  if (this.shouldRecoverBeforeAlarm()) {
531
- await this.recoverFromStorage();
532
- this.stateRecoveryNeeded = false;
604
+ this.stateRecoveryNeeded = true;
605
+ await this.recoverStateIfNeeded();
533
606
  }
534
607
 
535
608
  const now = Date.now();
@@ -665,7 +738,28 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
665
738
  this.syncEphemeralTimersToStorage();
666
739
  }
667
740
 
668
- // 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
669
763
  this._scheduleNextAlarm();
670
764
  }
671
765
 
@@ -700,6 +794,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
700
794
  );
701
795
  meta.authenticated = true;
702
796
  meta.authStateLost = false;
797
+ meta.lastSeenAt = Date.now();
703
798
  meta.userId = auth.id;
704
799
  meta.role = auth.role;
705
800
  meta.auth = {
@@ -738,10 +833,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
738
833
  this.syncRoomMonitoringSnapshot();
739
834
 
740
835
  // Recover state from storage if needed (after hibernation wake-up)
741
- if (this.stateRecoveryNeeded) {
742
- await this.recoverFromStorage();
743
- this.stateRecoveryNeeded = false;
744
- }
836
+ await this.recoverStateIfNeeded();
745
837
  // Note: full sync is sent during handleJoin(), not here
746
838
  } catch (error) {
747
839
  const detail = error instanceof Error ? error.message : String(error);
@@ -794,11 +886,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
794
886
  return;
795
887
  }
796
888
  if (!this.config.release && this.namespace) {
797
- const warningKey = `${this.namespace}:join`;
798
- if (!roomFallbackWarnings.has(warningKey)) {
799
- roomFallbackWarnings.add(warningKey);
800
- console.warn(`[Room] ${warningKey} is using development-mode allow-by-default. Add rooms.${this.namespace}.access.join or public.join to make this explicit.`);
801
- }
889
+ warnRoomDevelopmentFallback(this.namespace, 'join');
802
890
  }
803
891
  }
804
892
  if (joinAccess && this.roomId) {
@@ -947,11 +1035,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
947
1035
  return;
948
1036
  }
949
1037
  if (!this.config.release && this.namespace) {
950
- const warningKey = `${this.namespace}:action`;
951
- if (!roomFallbackWarnings.has(warningKey)) {
952
- roomFallbackWarnings.add(warningKey);
953
- console.warn(`[Room] ${warningKey} is using development-mode allow-by-default. Add rooms.${this.namespace}.access.action or public.action to make this explicit.`);
954
- }
1038
+ warnRoomDevelopmentFallback(this.namespace, 'action');
955
1039
  }
956
1040
  }
957
1041
  if (actionAccess && this.roomId) {
@@ -1368,10 +1452,23 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1368
1452
  this._stateTTLAlarmAt = typeof savedEphemeral?.stateTTLAlarmAt === 'number'
1369
1453
  ? savedEphemeral.stateTTLAlarmAt
1370
1454
  : null;
1455
+ this._socketHeartbeatCheckAt = typeof savedEphemeral?.socketHeartbeatCheckAt === 'number'
1456
+ ? savedEphemeral.socketHeartbeatCheckAt
1457
+ : null;
1371
1458
 
1372
1459
  this._scheduleNextAlarm();
1373
1460
  }
1374
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
+
1375
1472
  // ─── Delta Broadcasting ───
1376
1473
 
1377
1474
  /** Queue shared state delta and flush it at the end of the current turn. */
@@ -1613,6 +1710,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1613
1710
  const stateSaveAt = this._stateSaveAt;
1614
1711
  const emptyRoomCleanupAt = this._emptyRoomCleanupAt;
1615
1712
  const stateTTLAlarmAt = this._stateTTLAlarmAt;
1713
+ const socketHeartbeatCheckAt = this._socketHeartbeatCheckAt;
1616
1714
  this.ctx.waitUntil((async () => {
1617
1715
  try {
1618
1716
  if (
@@ -1621,6 +1719,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1621
1719
  && stateSaveAt === null
1622
1720
  && emptyRoomCleanupAt === null
1623
1721
  && stateTTLAlarmAt === null
1722
+ && socketHeartbeatCheckAt === null
1624
1723
  ) {
1625
1724
  await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
1626
1725
  return;
@@ -1632,6 +1731,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1632
1731
  stateSaveAt,
1633
1732
  emptyRoomCleanupAt,
1634
1733
  stateTTLAlarmAt,
1734
+ socketHeartbeatCheckAt,
1635
1735
  } satisfies PersistedRoomEphemeralTimers);
1636
1736
  } catch (error) {
1637
1737
  console.warn('[Room] Ephemeral timer persistence skipped', {
@@ -1730,14 +1830,12 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1730
1830
  ): boolean {
1731
1831
  const now = Date.now();
1732
1832
  const rateLimit = this.namespaceConfig?.rateLimit as
1733
- | { actions: number; signals?: number; media?: number; admin?: number }
1833
+ | { actions: number; signals?: number; admin?: number }
1734
1834
  | undefined;
1735
1835
  const maxActions = (
1736
1836
  scope === 'signals'
1737
1837
  ? rateLimit?.signals
1738
- : scope === 'media'
1739
- ? rateLimit?.media
1740
- : scope === 'admin'
1838
+ : scope === 'admin'
1741
1839
  ? rateLimit?.admin
1742
1840
  : undefined
1743
1841
  ) ?? rateLimit?.actions ?? DEFAULT_RATE_LIMIT_ACTIONS;
@@ -1776,8 +1874,17 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1776
1874
  const cached = this._metaCache.get(ws);
1777
1875
  if (cached) return cached;
1778
1876
 
1779
- // After hibernation wake-up: rebuild from tags
1780
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
1781
1888
  const tags = this.ctx.getTags(ws);
1782
1889
  if (tags.length === 0) return null;
1783
1890
 
@@ -1808,8 +1915,10 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1808
1915
  authStateLost: true,
1809
1916
  connectionId,
1810
1917
  ip,
1918
+ lastSeenAt: Date.now(),
1811
1919
  };
1812
1920
  this._metaCache.set(ws, meta);
1921
+ this._attachmentExtraCache.delete(ws);
1813
1922
  return meta;
1814
1923
  } catch {
1815
1924
  return null;
@@ -1817,7 +1926,77 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1817
1926
  }
1818
1927
 
1819
1928
  protected setWSMeta(ws: WebSocket, meta: RoomWSMeta): void {
1820
- 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();
1821
2000
  }
1822
2001
 
1823
2002
  protected handleUnauthenticatedSocket(ws: WebSocket, meta: RoomWSMeta): void {
@@ -1844,7 +2023,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1844
2023
  return getGlobalConfig(env);
1845
2024
  }
1846
2025
 
1847
- private async ensureRuntimeReady(): Promise<void> {
2026
+ protected async ensureRuntimeReady(): Promise<void> {
1848
2027
  if (!this.runtimeReadyPromise) {
1849
2028
  this.runtimeReadyPromise = (async () => {
1850
2029
  await ensureServerStartup();
@@ -1883,4 +2062,58 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1883
2062
  && Object.keys(this._metadata).length === 0
1884
2063
  );
1885
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
+ }
1886
2119
  }