@edge-base/server 0.2.4 → 0.2.6

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 (131) hide show
  1. package/admin-build/_app/immutable/chunks/{DILS_-VJ.js → B3CvhH3c.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/BN_-k-Ck.js +1 -0
  3. package/admin-build/_app/immutable/chunks/{Dt4vL4Df.js → BYL_uBga.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{C72lTcG0.js → Bcs4KYNp.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{B8s_s9QY.js → BkZCgsc3.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{BgDzp0i0.js → BvoGcDFV.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{BME_U9TJ.js → CCUxCptE.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/CLHN9MVr.js +1 -0
  9. package/admin-build/_app/immutable/chunks/{DYaCRWMA.js → CR37B8DX.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/CbfX3ELZ.js +1 -0
  11. package/admin-build/_app/immutable/chunks/CjcrXziO.js +2 -0
  12. package/admin-build/_app/immutable/chunks/CrwlCAM0.js +1 -0
  13. package/admin-build/_app/immutable/chunks/{B0HRJ657.js → DOOPbWwG.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/DQVP4KC-.js +1 -0
  15. package/admin-build/_app/immutable/chunks/{Dj0QUuOf.js → DdvsFblq.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/DemDWbs-.js +128 -0
  17. package/admin-build/_app/immutable/chunks/{XQM1k9PM.js → DmDTovpg.js} +1 -1
  18. package/admin-build/_app/immutable/chunks/{fYEKMQ-Z.js → Ff90owjx.js} +1 -1
  19. package/admin-build/_app/immutable/chunks/{5RQRbp5q.js → LL3ulaxa.js} +1 -1
  20. package/admin-build/_app/immutable/chunks/{DBsVqhuh.js → Q3vAxeY-.js} +1 -1
  21. package/admin-build/_app/immutable/chunks/{D__dwMuW.js → SQVAC3Cv.js} +1 -1
  22. package/admin-build/_app/immutable/chunks/{Z41NK6i6.js → bguI1TeA.js} +1 -1
  23. package/admin-build/_app/immutable/chunks/{_teD5ji5.js → nlAMTi52.js} +1 -1
  24. package/admin-build/_app/immutable/chunks/{BjWZuf8W.js → qBm6xof8.js} +1 -1
  25. package/admin-build/_app/immutable/entry/{app.C8ylfBe6.js → app.CP83Ni80.js} +2 -2
  26. package/admin-build/_app/immutable/entry/start.DY6YakU0.js +1 -0
  27. package/admin-build/_app/immutable/nodes/{0.CJJ6HZbp.js → 0.DiRq7puO.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/1.BFeyKLGT.js +1 -0
  29. package/admin-build/_app/immutable/nodes/10.zcee7hJx.js +1 -0
  30. package/admin-build/_app/immutable/nodes/11.BW7wLs2Y.js +1 -0
  31. package/admin-build/_app/immutable/nodes/12.CxJRlYSd.js +1 -0
  32. package/admin-build/_app/immutable/nodes/13.pp0F_5hn.js +110 -0
  33. package/admin-build/_app/immutable/nodes/14.t3AfGiGo.js +3 -0
  34. package/admin-build/_app/immutable/nodes/15.B3agc7NX.js +1 -0
  35. package/admin-build/_app/immutable/nodes/{16.D0xkPUBW.js → 16.C4uG2-i8.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{17.CebNqPeh.js → 17.CwGxi1Bn.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/18.CrQyN_gU.js +1 -0
  38. package/admin-build/_app/immutable/nodes/19.NEPUOXl7.js +2 -0
  39. package/admin-build/_app/immutable/nodes/{20.DYb-q3W8.js → 20.DGHO8ipr.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/21.UVKBDvp4.js +1 -0
  41. package/admin-build/_app/immutable/nodes/22.Dri5It7a.js +1 -0
  42. package/admin-build/_app/immutable/nodes/{23.BLgq21om.js → 23.BPQP_Zte.js} +2 -2
  43. package/admin-build/_app/immutable/nodes/24.D580FdSS.js +2 -0
  44. package/admin-build/_app/immutable/nodes/25.BMNPOZwF.js +2 -0
  45. package/admin-build/_app/immutable/nodes/26.XcpEcbiz.js +1 -0
  46. package/admin-build/_app/immutable/nodes/27.C1zHHcYv.js +1 -0
  47. package/admin-build/_app/immutable/nodes/28.CuKzzrY8.js +1 -0
  48. package/admin-build/_app/immutable/nodes/29.nLpBMXnM.js +1 -0
  49. package/admin-build/_app/immutable/nodes/{3.z8ut3jS-.js → 3.5G_aseoL.js} +1 -1
  50. package/admin-build/_app/immutable/nodes/30.CQC4nLoU.js +1 -0
  51. package/admin-build/_app/immutable/nodes/31.Bet8kxOK.js +1 -0
  52. package/admin-build/_app/immutable/nodes/4.nmJDYJpC.js +1 -0
  53. package/admin-build/_app/immutable/nodes/5.CnbYLG4E.js +1 -0
  54. package/admin-build/_app/immutable/nodes/6.KA01b-3y.js +1 -0
  55. package/admin-build/_app/immutable/nodes/7.CP9fkn1L.js +1 -0
  56. package/admin-build/_app/immutable/nodes/8.BTzDb---.js +1 -0
  57. package/admin-build/_app/immutable/nodes/9.DkNJg_J6.js +1 -0
  58. package/admin-build/_app/version.json +1 -1
  59. package/admin-build/index.html +7 -7
  60. package/package.json +3 -3
  61. package/src/__tests__/database-do-route-validation.test.ts +10 -7
  62. package/src/__tests__/meta-route-registration.test.ts +20 -15
  63. package/src/__tests__/push-handlers.test.ts +1 -1
  64. package/src/__tests__/room-auth-state-loss.test.ts +122 -0
  65. package/src/__tests__/room-handler-context.test.ts +4 -4
  66. package/src/__tests__/room-rate-limit-scopes.test.ts +38 -0
  67. package/src/__tests__/room-runtime-routing.test.ts +23 -0
  68. package/src/__tests__/route-parser.test.ts +6 -0
  69. package/src/__tests__/runtime-startup.test.ts +49 -0
  70. package/src/__tests__/schema.test.ts +15 -6
  71. package/src/durable-objects/database-do.ts +21 -1
  72. package/src/durable-objects/database-live-do.ts +15 -0
  73. package/src/durable-objects/room-runtime-base.ts +436 -169
  74. package/src/durable-objects/rooms-do.ts +63 -25
  75. package/src/index.ts +340 -280
  76. package/src/lib/d1-handler.ts +32 -17
  77. package/src/lib/postgres-handler.ts +24 -12
  78. package/src/lib/route-parser.ts +3 -0
  79. package/src/lib/runtime-startup.ts +53 -0
  80. package/src/lib/schemas.ts +12 -2
  81. package/src/middleware/captcha-verify.ts +16 -3
  82. package/src/middleware/error-handler.ts +1 -1
  83. package/src/middleware/rules.ts +28 -9
  84. package/src/routes/admin-auth.ts +3 -3
  85. package/src/routes/admin.ts +13 -8
  86. package/src/routes/analytics-api.ts +3 -3
  87. package/src/routes/auth.ts +1 -1
  88. package/src/routes/backup.ts +1 -1
  89. package/src/routes/d1.ts +14 -7
  90. package/src/routes/database-live.ts +13 -6
  91. package/src/routes/kv.ts +21 -10
  92. package/src/routes/oauth.ts +1 -1
  93. package/src/routes/push.ts +119 -77
  94. package/src/routes/room.ts +215 -7
  95. package/src/routes/schema-endpoint.ts +2 -2
  96. package/src/routes/sql.ts +10 -6
  97. package/src/routes/storage.ts +4 -2
  98. package/src/routes/vectorize.ts +16 -4
  99. package/admin-build/_app/immutable/chunks/BYI6CUvd.js +0 -1
  100. package/admin-build/_app/immutable/chunks/C6lpZLE2.js +0 -1
  101. package/admin-build/_app/immutable/chunks/CoI6jjbg.js +0 -2
  102. package/admin-build/_app/immutable/chunks/D5GswVnI.js +0 -128
  103. package/admin-build/_app/immutable/chunks/Dj-E9-FO.js +0 -1
  104. package/admin-build/_app/immutable/chunks/g_-Kpxu3.js +0 -1
  105. package/admin-build/_app/immutable/chunks/wCNueVYy.js +0 -1
  106. package/admin-build/_app/immutable/entry/start.CtsqDyfj.js +0 -1
  107. package/admin-build/_app/immutable/nodes/1.B4sI5cB4.js +0 -1
  108. package/admin-build/_app/immutable/nodes/10.D6hvCer6.js +0 -1
  109. package/admin-build/_app/immutable/nodes/11.Dx7b8aQ5.js +0 -1
  110. package/admin-build/_app/immutable/nodes/12.Bqmy5KIF.js +0 -1
  111. package/admin-build/_app/immutable/nodes/13.CC6KpXgS.js +0 -110
  112. package/admin-build/_app/immutable/nodes/14.yCo1Ix8E.js +0 -3
  113. package/admin-build/_app/immutable/nodes/15.co0UfPlh.js +0 -1
  114. package/admin-build/_app/immutable/nodes/18.JUoLOZxh.js +0 -1
  115. package/admin-build/_app/immutable/nodes/19.ND8kmQJe.js +0 -2
  116. package/admin-build/_app/immutable/nodes/21.cz3IN9Cc.js +0 -1
  117. package/admin-build/_app/immutable/nodes/22.UOzm8WYV.js +0 -1
  118. package/admin-build/_app/immutable/nodes/24.DN9usmUs.js +0 -2
  119. package/admin-build/_app/immutable/nodes/25.BddRfAyE.js +0 -2
  120. package/admin-build/_app/immutable/nodes/26.Dl6XHIeT.js +0 -1
  121. package/admin-build/_app/immutable/nodes/27.D0iNwALG.js +0 -1
  122. package/admin-build/_app/immutable/nodes/28.9dKQmdGi.js +0 -1
  123. package/admin-build/_app/immutable/nodes/29.wXzfJUXp.js +0 -1
  124. package/admin-build/_app/immutable/nodes/30.BtZETNsL.js +0 -1
  125. package/admin-build/_app/immutable/nodes/31.CYonj2Jh.js +0 -1
  126. package/admin-build/_app/immutable/nodes/4.COtDPQ9b.js +0 -1
  127. package/admin-build/_app/immutable/nodes/5.CTRCeIhp.js +0 -1
  128. package/admin-build/_app/immutable/nodes/6.ChHi3QkR.js +0 -1
  129. package/admin-build/_app/immutable/nodes/7.CCMtr6Ac.js +0 -1
  130. package/admin-build/_app/immutable/nodes/8.DpWJ-X_-.js +0 -1
  131. package/admin-build/_app/immutable/nodes/9.DOkvfmir.js +0 -1
@@ -14,9 +14,6 @@
14
14
  */
15
15
  import { DurableObject } from 'cloudflare:workers';
16
16
  import {
17
- getRoomActionHandlers,
18
- getRoomLifecycleHandlers,
19
- getRoomTimerHandlers,
20
17
  type AuthContext as SharedAuthContext,
21
18
  type EdgeBaseConfig,
22
19
  type RoomNamespaceConfig,
@@ -24,15 +21,12 @@ import {
24
21
  type RoomSender,
25
22
  type RoomHandlerContext,
26
23
  } from '@edge-base/shared';
27
- import { parseConfig as getGlobalConfig } from '../lib/do-router.js';
28
- import { resolveAuthContextFromToken } from '../middleware/auth.js';
29
- import { buildFunctionContext } from '../lib/functions.js';
30
- import { resolveDbLiveAuthTimeoutMs } from '../lib/database-live-config.js';
31
24
  import {
32
25
  persistRoomMonitoringSnapshot,
33
26
  type RoomMonitoringSnapshot,
34
27
  } from '../lib/room-monitoring.js';
35
- import { resolveRootServiceKey } from '../lib/service-key.js';
28
+ import { parseConfig as getGlobalConfig } from '../lib/do-router.js';
29
+ import { ensureServerStartup } from '../lib/runtime-startup.js';
36
30
 
37
31
  // ─── Types ───
38
32
 
@@ -67,7 +61,6 @@ interface PlayerInfo {
67
61
 
68
62
  const DEFAULT_MAX_PLAYERS = 100;
69
63
  const DEFAULT_MAX_STATE_SIZE = 1048576; // 1MB
70
- const DEFAULT_DELTA_BATCH_MS = 50;
71
64
  const DEFAULT_RATE_LIMIT_ACTIONS = 10;
72
65
  const DEFAULT_RECONNECT_TIMEOUT_MS = 30000;
73
66
  const ROOM_CLIENT_LEAVE_CLOSE_CODE = 4005;
@@ -76,9 +69,25 @@ const ROOM_AUTH_STATE_LOST_CLOSE_REASON = 'Room authentication state lost';
76
69
  const EMPTY_ROOM_CLEANUP_DELAY_MS = 100;
77
70
  const DEFAULT_IDLE_TIMEOUT_SEC = 300;
78
71
  const ACTION_TIMEOUT_MS = 5000;
72
+ const DEFAULT_ROOM_AUTH_TIMEOUT_MS = 5000;
79
73
  const DEFAULT_STATE_SAVE_INTERVAL_MS = 60000; // 1 minute
80
74
  const DEFAULT_STATE_TTL_MS = 86400000; // 24 hours
75
+ const ROOM_EPHEMERAL_TIMERS_STORAGE_KEY = 'roomEphemeralTimers';
81
76
  const roomFallbackWarnings = new Set<string>();
77
+ type RoomRateLimitScope = 'actions' | 'signals' | 'media' | 'admin';
78
+
79
+ interface PendingDisconnectDeadline {
80
+ fireAt: number;
81
+ connectionId: string;
82
+ }
83
+
84
+ interface PersistedRoomEphemeralTimers {
85
+ pendingAuth?: Record<string, number>;
86
+ disconnects?: Record<string, PendingDisconnectDeadline>;
87
+ stateSaveAt?: number | null;
88
+ emptyRoomCleanupAt?: number | null;
89
+ stateTTLAlarmAt?: number | null;
90
+ }
82
91
 
83
92
  function isRoomOperationPublic(
84
93
  namespaceConfig: RoomNamespaceConfig | null,
@@ -89,6 +98,47 @@ function isRoomOperationPublic(
89
98
  return !!namespaceConfig.public[operation];
90
99
  }
91
100
 
101
+ function getRoomHooks(namespaceConfig?: RoomNamespaceConfig | null) {
102
+ return namespaceConfig?.hooks;
103
+ }
104
+
105
+ function getRoomLifecycleHandlers(namespaceConfig?: RoomNamespaceConfig | null) {
106
+ return getRoomHooks(namespaceConfig)?.lifecycle ?? namespaceConfig?.handlers?.lifecycle;
107
+ }
108
+
109
+ function getRoomActionHandlers(namespaceConfig?: RoomNamespaceConfig | null) {
110
+ return namespaceConfig?.state?.actions ?? namespaceConfig?.handlers?.actions;
111
+ }
112
+
113
+ function getRoomTimerHandlers(namespaceConfig?: RoomNamespaceConfig | null) {
114
+ return namespaceConfig?.state?.timers ?? namespaceConfig?.handlers?.timers;
115
+ }
116
+
117
+ function warnRoomDevelopmentFallback(
118
+ namespace: string,
119
+ operation: 'join' | 'action',
120
+ ): void {
121
+ const warningKey = `${namespace}:${operation}`;
122
+ if (roomFallbackWarnings.has(warningKey)) {
123
+ return;
124
+ }
125
+ roomFallbackWarnings.add(warningKey);
126
+ console.warn(
127
+ `[Room] ${warningKey} is allowed because release=false and no explicit room rule was found. `
128
+ + `This fallback is local-dev only. Add rooms.${namespace}.access.${operation} or set `
129
+ + `rooms.${namespace}.public.${operation}=true to make the behavior explicit.`,
130
+ );
131
+ }
132
+
133
+ function resolveRoomAuthTimeoutMs(config?: EdgeBaseConfig): number {
134
+ const value = config?.databaseLive?.authTimeoutMs;
135
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
136
+ return DEFAULT_ROOM_AUTH_TIMEOUT_MS;
137
+ }
138
+ const normalized = Math.floor(value);
139
+ return normalized > 0 ? normalized : DEFAULT_ROOM_AUTH_TIMEOUT_MS;
140
+ }
141
+
92
142
  // ─── Compute delta between two states ───
93
143
 
94
144
  function computeDelta(
@@ -123,7 +173,7 @@ function cloneState(obj: Record<string, unknown>): Record<string, unknown> {
123
173
  // ─── Shared Room Runtime Base ───
124
174
 
125
175
  export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
126
- protected readonly config: EdgeBaseConfig;
176
+ protected config: EdgeBaseConfig;
127
177
 
128
178
  // ─── Room identification ───
129
179
  protected namespace: string | null = null;
@@ -143,27 +193,28 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
143
193
 
144
194
  // ─── Lifecycle ───
145
195
  private roomCreated = false;
196
+ private runtimeReadyPromise: Promise<void> | null = null;
146
197
 
147
- // ─── State persistence (replaces RESYNC) ───
198
+ // ─── State persistence (alarm-based, hibernation-friendly) ───
148
199
  private dirty = false;
149
- private saveTimer: ReturnType<typeof setInterval> | null = null;
200
+ private _stateSaveAt: number | null = null;
150
201
  private stateRecoveryNeeded = false;
151
202
 
152
203
  // ─── WebSocket metadata cache ───
153
204
  private _metaCache = new Map<WebSocket, RoomWSMeta>();
154
205
 
155
206
  // ─── Auth timeout tracking ───
156
- private pendingAuth = new Map<string, ReturnType<typeof setTimeout>>();
207
+ private pendingAuth = new Map<string, number>();
157
208
 
158
209
  // ─── Delta batching (shared state) ───
159
210
  private pendingSharedDelta: Record<string, unknown> | null = null;
160
- private sharedDeltaBatchTimer: ReturnType<typeof setTimeout> | null = null;
211
+ private sharedDeltaFlushQueued = false;
161
212
 
162
213
  // ─── Rate limiting (per connection, token bucket) ───
163
214
  private rateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
164
215
 
165
216
  // ─── Reconnect timers ───
166
- private disconnectTimers = new Map<string, ReturnType<typeof setTimeout>>(); // userId → timer
217
+ private disconnectTimers = new Map<string, PendingDisconnectDeadline>(); // userId → deadline
167
218
 
168
219
  // ─── Named Timers (alarm multiplexer) ───
169
220
  private _timers = new Map<string, { fireAt: number; data?: unknown }>();
@@ -186,6 +237,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
186
237
  // ─── HTTP Fetch Handler ───
187
238
 
188
239
  async fetch(request: Request): Promise<Response> {
240
+ await this.ensureRuntimeReady();
189
241
  const url = new URL(request.url);
190
242
  if (url.pathname === '/websocket') {
191
243
  return this.handleWebSocketUpgrade(request);
@@ -271,30 +323,39 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
271
323
  this.ctx.waitUntil(persistRoomMonitoringSnapshot(this.env.KV, snapshotToPersist));
272
324
  }
273
325
 
274
- // ─── Metadata HTTP Handler ───
275
-
276
- private async handleGetMetadata(url: URL): Promise<Response> {
277
- // Resolve namespace if not set (DO may have been cold-started via HTTP)
326
+ protected hydrateRoomIdentityFromUrl(url: URL): void {
278
327
  const roomFullName = url.searchParams.get('room');
279
- if (roomFullName && !this.namespace) {
280
- const separatorIdx = roomFullName.indexOf('::');
281
- if (separatorIdx >= 0) {
282
- this.namespace = roomFullName.substring(0, separatorIdx);
283
- this.roomId = roomFullName.substring(separatorIdx + 2);
284
- } else {
285
- this.namespace = roomFullName;
286
- this.roomId = roomFullName;
287
- }
288
- this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
328
+ if (!roomFullName || this.namespace) {
329
+ return;
289
330
  }
290
331
 
291
- // Metadata may not be in memory if DO was evicted/hibernated
332
+ const separatorIdx = roomFullName.indexOf('::');
333
+ if (separatorIdx >= 0) {
334
+ this.namespace = roomFullName.substring(0, separatorIdx);
335
+ this.roomId = roomFullName.substring(separatorIdx + 2);
336
+ } else {
337
+ this.namespace = roomFullName;
338
+ this.roomId = roomFullName;
339
+ }
340
+ this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
341
+ }
342
+
343
+ protected async getRoomMetadataSnapshot(): Promise<Record<string, unknown>> {
292
344
  if (Object.keys(this._metadata).length === 0) {
293
345
  const saved = await this.ctx.storage.get('roomMetadata') as Record<string, unknown> | undefined;
294
346
  if (saved) this._metadata = saved;
295
347
  }
296
348
 
297
- return new Response(JSON.stringify(this._metadata), {
349
+ return cloneState(this._metadata);
350
+ }
351
+
352
+ // ─── Metadata HTTP Handler ───
353
+
354
+ private async handleGetMetadata(url: URL): Promise<Response> {
355
+ this.hydrateRoomIdentityFromUrl(url);
356
+ const metadata = await this.getRoomMetadataSnapshot();
357
+
358
+ return new Response(JSON.stringify(metadata), {
298
359
  headers: { 'Content-Type': 'application/json' },
299
360
  });
300
361
  }
@@ -303,19 +364,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
303
364
 
304
365
  private handleWebSocketUpgrade(request: Request): Response {
305
366
  const url = new URL(request.url);
306
- const roomFullName = url.searchParams.get('room');
307
-
308
- if (roomFullName) {
309
- const separatorIdx = roomFullName.indexOf('::');
310
- if (separatorIdx >= 0) {
311
- this.namespace = roomFullName.substring(0, separatorIdx);
312
- this.roomId = roomFullName.substring(separatorIdx + 2);
313
- } else {
314
- this.namespace = roomFullName;
315
- this.roomId = roomFullName;
316
- }
317
- this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
318
- }
367
+ this.hydrateRoomIdentityFromUrl(url);
319
368
 
320
369
  // Check max players
321
370
  const maxPlayers = this.namespaceConfig?.maxPlayers ?? DEFAULT_MAX_PLAYERS;
@@ -342,9 +391,13 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
342
391
  };
343
392
 
344
393
  // Accept with Hibernation API
394
+ const roomFullName =
395
+ this.namespace && this.roomId
396
+ ? `${this.namespace}::${this.roomId}`
397
+ : url.searchParams.get('room') ?? '';
345
398
  const tags = [
346
399
  `conn:${connectionId}`,
347
- `room:${roomFullName || ''}`,
400
+ `room:${roomFullName}`,
348
401
  ];
349
402
  if (meta.ip) {
350
403
  tags.push(`ip:${encodeURIComponent(meta.ip)}`);
@@ -353,25 +406,11 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
353
406
  this._metaCache.set(server, meta);
354
407
  this.syncRoomMonitoringSnapshot();
355
408
 
356
- // Set auth timeout
357
- const authTimeoutMs = resolveDbLiveAuthTimeoutMs(this.config);
358
- const timer = setTimeout(() => {
359
- const currentMeta = this.getWSMeta(server);
360
- if (currentMeta && !currentMeta.authenticated) {
361
- try {
362
- this.safeSend(server, {
363
- type: 'error',
364
- code: 'AUTH_TIMEOUT',
365
- message: `Authentication required within ${authTimeoutMs}ms`,
366
- });
367
- server.close(4001, 'Authentication timeout');
368
- } catch {
369
- // WebSocket already closed by client
370
- }
371
- }
372
- this.pendingAuth.delete(connectionId);
373
- }, authTimeoutMs);
374
- this.pendingAuth.set(connectionId, timer);
409
+ // Set auth timeout without pinning the DO with a JS timer.
410
+ const authTimeoutMs = resolveRoomAuthTimeoutMs(this.config);
411
+ this.pendingAuth.set(connectionId, Date.now() + authTimeoutMs);
412
+ this.syncEphemeralTimersToStorage();
413
+ this._scheduleNextAlarm();
375
414
 
376
415
  return new Response(null, { status: 101, webSocket: client });
377
416
  }
@@ -379,6 +418,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
379
418
  // ─── Hibernation API Callbacks ───
380
419
 
381
420
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
421
+ await this.ensureRuntimeReady();
382
422
  if (typeof message !== 'string') return;
383
423
 
384
424
  let msg: Record<string, unknown>;
@@ -447,10 +487,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
447
487
  const explicitLeave = code === ROOM_CLIENT_LEAVE_CLOSE_CODE;
448
488
  this.handleDisconnect(meta, kicked, explicitLeave);
449
489
  this._metaCache.delete(ws);
450
- const timer = this.pendingAuth.get(meta.connectionId);
451
- if (timer) {
452
- clearTimeout(timer);
453
- this.pendingAuth.delete(meta.connectionId);
490
+ if (this.pendingAuth.delete(meta.connectionId)) {
491
+ this.syncEphemeralTimersToStorage();
492
+ this._scheduleNextAlarm();
454
493
  }
455
494
  }
456
495
  this.syncRoomMonitoringSnapshot(ws);
@@ -461,12 +500,16 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
461
500
  if (meta) {
462
501
  this.handleDisconnect(meta);
463
502
  this._metaCache.delete(ws);
503
+ if (this.pendingAuth.delete(meta.connectionId)) {
504
+ this.syncEphemeralTimersToStorage();
505
+ this._scheduleNextAlarm();
506
+ }
464
507
  }
465
508
  this.syncRoomMonitoringSnapshot(ws);
466
509
  }
467
510
 
468
511
  // ─── Alarm Multiplexer ───
469
- // Single DO alarm is shared among: named timers, empty room cleanup, state TTL.
512
+ // Single DO alarm is shared among: named timers, state save, empty room cleanup, state TTL.
470
513
 
471
514
  /**
472
515
  * Recalculate and set the single DO alarm to the earliest pending event.
@@ -477,9 +520,18 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
477
520
  for (const timer of this._timers.values()) {
478
521
  if (timer.fireAt < earliest) earliest = timer.fireAt;
479
522
  }
523
+ for (const fireAt of this.pendingAuth.values()) {
524
+ if (fireAt < earliest) earliest = fireAt;
525
+ }
526
+ for (const timer of this.disconnectTimers.values()) {
527
+ if (timer.fireAt < earliest) earliest = timer.fireAt;
528
+ }
480
529
  if (this._emptyRoomCleanupAt !== null && this._emptyRoomCleanupAt < earliest) {
481
530
  earliest = this._emptyRoomCleanupAt;
482
531
  }
532
+ if (this._stateSaveAt !== null && this._stateSaveAt < earliest) {
533
+ earliest = this._stateSaveAt;
534
+ }
483
535
  if (this._stateTTLAlarmAt !== null && this._stateTTLAlarmAt < earliest) {
484
536
  earliest = this._stateTTLAlarmAt;
485
537
  }
@@ -490,9 +542,44 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
490
542
  }
491
543
 
492
544
  async alarm(): Promise<void> {
545
+ await this.ensureRuntimeReady();
546
+
547
+ if (this.shouldRecoverBeforeAlarm()) {
548
+ await this.recoverFromStorage();
549
+ this.stateRecoveryNeeded = false;
550
+ }
551
+
493
552
  const now = Date.now();
494
553
 
495
- // 1. Fire expired named timers
554
+ // 1. Close unauthenticated sockets whose auth deadline expired.
555
+ const expiredAuthConnectionIds: string[] = [];
556
+ for (const [connectionId, fireAt] of this.pendingAuth) {
557
+ if (fireAt <= now) {
558
+ expiredAuthConnectionIds.push(connectionId);
559
+ this.pendingAuth.delete(connectionId);
560
+ }
561
+ }
562
+ for (const connectionId of expiredAuthConnectionIds) {
563
+ const ws = this.findWebSocketByConnectionId(connectionId);
564
+ const currentMeta = ws ? this.getWSMeta(ws) : null;
565
+ if (ws && currentMeta && !currentMeta.authenticated) {
566
+ try {
567
+ this.safeSend(ws, {
568
+ type: 'error',
569
+ code: 'AUTH_TIMEOUT',
570
+ message: `Authentication required within ${resolveRoomAuthTimeoutMs(this.config)}ms`,
571
+ });
572
+ ws.close(4001, 'Authentication timeout');
573
+ } catch {
574
+ // WebSocket already closed by client.
575
+ }
576
+ }
577
+ }
578
+ if (expiredAuthConnectionIds.length > 0) {
579
+ this.syncEphemeralTimersToStorage();
580
+ }
581
+
582
+ // 2. Fire expired named timers
496
583
  const expiredTimers: Array<{ name: string; data?: unknown }> = [];
497
584
  for (const [name, timer] of this._timers) {
498
585
  if (timer.fireAt <= now) {
@@ -506,7 +593,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
506
593
  if (handler) {
507
594
  try {
508
595
  const roomApi = this.buildRoomServerAPI();
509
- const ctx = this.buildHandlerContext();
596
+ const ctx = await this.buildHandlerContext();
510
597
  await handler(roomApi, ctx, data);
511
598
  } catch (err) {
512
599
  console.error(`[Room] onTimer['${name}'] error: ${err instanceof Error ? err.message : String(err)}`);
@@ -515,10 +602,25 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
515
602
  }
516
603
 
517
604
  if (expiredTimers.length > 0) {
518
- this.dirty = true;
605
+ this.markDirty();
519
606
  }
520
607
 
521
- // 2. Empty room cleanup
608
+ // 3. Finalize disconnect grace periods without pinning the DO.
609
+ const expiredDisconnects: Array<{ userId: string; connectionId: string }> = [];
610
+ for (const [userId, timer] of this.disconnectTimers) {
611
+ if (timer.fireAt <= now) {
612
+ expiredDisconnects.push({ userId, connectionId: timer.connectionId });
613
+ this.disconnectTimers.delete(userId);
614
+ }
615
+ }
616
+ for (const { userId, connectionId } of expiredDisconnects) {
617
+ await this.finalizePlayerLeave(userId, connectionId, 'disconnect');
618
+ }
619
+ if (expiredDisconnects.length > 0) {
620
+ this.syncEphemeralTimersToStorage();
621
+ }
622
+
623
+ // 4. Empty room cleanup
522
624
  if (this._emptyRoomCleanupAt !== null && this._emptyRoomCleanupAt <= now) {
523
625
  this._emptyRoomCleanupAt = null;
524
626
  if (this.players.size === 0) {
@@ -532,39 +634,55 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
532
634
  this.roomCreated = false;
533
635
  this._timers.clear();
534
636
  this._metadata = {};
637
+ this.pendingAuth.clear();
638
+ this.disconnectTimers.clear();
535
639
  this.pendingSharedDelta = null;
536
- if (this.sharedDeltaBatchTimer) {
537
- clearTimeout(this.sharedDeltaBatchTimer);
538
- this.sharedDeltaBatchTimer = null;
539
- }
540
- this.stopSaveTimer();
640
+ this.sharedDeltaFlushQueued = false;
641
+ this.dirty = false;
642
+ this._stateSaveAt = null;
541
643
  // Clean up persisted state
542
644
  await this.ctx.storage.delete('roomState');
543
645
  await this.ctx.storage.delete('roomTimers');
544
646
  await this.ctx.storage.delete('roomMetadata');
647
+ await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
545
648
  // Phase 2: Schedule idleTimeout alarm
546
649
  this._stateTTLAlarmAt = Date.now() + DEFAULT_IDLE_TIMEOUT_SEC * 1000;
650
+ this.syncEphemeralTimersToStorage();
547
651
  } else {
548
652
  // TTL safety net alarm: room is empty and state already cleared
653
+ this.dirty = false;
654
+ this._stateSaveAt = null;
549
655
  await this.ctx.storage.delete('roomState');
550
656
  await this.ctx.storage.delete('roomTimers');
551
657
  await this.ctx.storage.delete('roomMetadata');
658
+ await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
552
659
  this._stateTTLAlarmAt = null;
553
660
  }
554
661
  }
662
+ this.syncEphemeralTimersToStorage();
555
663
  }
556
664
 
557
- // 3. State TTL safety net
665
+ // 5. Persist dirty state without keeping the DO awake via setInterval.
666
+ if (this._stateSaveAt !== null && this._stateSaveAt <= now) {
667
+ this._stateSaveAt = null;
668
+ if (this.dirty) {
669
+ await this.persistState();
670
+ }
671
+ }
672
+
673
+ // 6. State TTL safety net
558
674
  if (this._stateTTLAlarmAt !== null && this._stateTTLAlarmAt <= now) {
559
675
  this._stateTTLAlarmAt = null;
560
676
  if (this.players.size === 0) {
561
677
  await this.ctx.storage.delete('roomState');
562
678
  await this.ctx.storage.delete('roomTimers');
563
679
  await this.ctx.storage.delete('roomMetadata');
680
+ await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
564
681
  }
682
+ this.syncEphemeralTimersToStorage();
565
683
  }
566
684
 
567
- // 4. Reschedule for next pending event
685
+ // 7. Reschedule for next pending event
568
686
  this._scheduleNextAlarm();
569
687
  }
570
688
 
@@ -591,6 +709,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
591
709
  if (meta.ip) headers.set('CF-Connecting-IP', meta.ip);
592
710
  if (meta.userAgent) headers.set('User-Agent', meta.userAgent);
593
711
  headers.set('Authorization', `Bearer ${token}`);
712
+ const { resolveAuthContextFromToken } = await import('../middleware/auth.js');
594
713
  const auth = await resolveAuthContextFromToken(
595
714
  this.env,
596
715
  token,
@@ -611,19 +730,17 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
611
730
  this.setWSMeta(ws, meta);
612
731
 
613
732
  // Clear auth timeout
614
- const timer = this.pendingAuth.get(meta.connectionId);
615
- if (timer) {
616
- clearTimeout(timer);
617
- this.pendingAuth.delete(meta.connectionId);
733
+ if (this.pendingAuth.delete(meta.connectionId)) {
734
+ this.syncEphemeralTimersToStorage();
735
+ this._scheduleNextAlarm();
618
736
  }
619
737
 
620
738
  // Register player (only on first auth)
621
739
  if (!isReAuth && meta.userId) {
622
740
  // Cancel disconnect timer if this user is reconnecting
623
- const existingTimer = this.disconnectTimers.get(meta.userId);
624
- if (existingTimer) {
625
- clearTimeout(existingTimer);
626
- this.disconnectTimers.delete(meta.userId);
741
+ if (this.disconnectTimers.delete(meta.userId)) {
742
+ this.syncEphemeralTimersToStorage();
743
+ this._scheduleNextAlarm();
627
744
  }
628
745
 
629
746
  this.addPlayer(meta.connectionId, meta.userId);
@@ -643,11 +760,28 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
643
760
  this.stateRecoveryNeeded = false;
644
761
  }
645
762
  // Note: full sync is sent during handleJoin(), not here
646
- } catch {
763
+ } catch (error) {
764
+ const detail = error instanceof Error ? error.message : String(error);
765
+ console.error('[Room] handleAuth failed', {
766
+ room: this.namespace && this.roomId ? `${this.namespace}::${this.roomId}` : null,
767
+ connectionId: meta.connectionId,
768
+ isReAuth,
769
+ userId: meta.userId ?? null,
770
+ message: detail,
771
+ stack: error instanceof Error ? error.stack : undefined,
772
+ });
647
773
  if (isReAuth) {
648
- this.safeSend(ws, { type: 'error', code: 'AUTH_REFRESH_FAILED', message: 'Token refresh failed' });
774
+ this.safeSend(ws, {
775
+ type: 'error',
776
+ code: 'AUTH_REFRESH_FAILED',
777
+ message: this.config.release ? 'Token refresh failed' : detail,
778
+ });
649
779
  } else {
650
- this.safeSend(ws, { type: 'error', code: 'AUTH_FAILED', message: 'Invalid or expired token' });
780
+ this.safeSend(ws, {
781
+ type: 'error',
782
+ code: 'AUTH_FAILED',
783
+ message: this.config.release ? 'Invalid or expired token' : detail,
784
+ });
651
785
  ws.close(4002, 'Authentication failed');
652
786
  }
653
787
  }
@@ -677,11 +811,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
677
811
  return;
678
812
  }
679
813
  if (!this.config.release && this.namespace) {
680
- const warningKey = `${this.namespace}:join`;
681
- if (!roomFallbackWarnings.has(warningKey)) {
682
- roomFallbackWarnings.add(warningKey);
683
- console.warn(`[Room] ${warningKey} is using development-mode allow-by-default. Add rooms.${this.namespace}.access.join or public.join to make this explicit.`);
684
- }
814
+ warnRoomDevelopmentFallback(this.namespace, 'join');
685
815
  }
686
816
  }
687
817
  if (joinAccess && this.roomId) {
@@ -714,14 +844,12 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
714
844
  if (onCreate) {
715
845
  try {
716
846
  const roomApi = this.buildRoomServerAPI();
717
- const ctx = this.buildHandlerContext();
847
+ const ctx = await this.buildHandlerContext();
718
848
  await onCreate(roomApi, ctx);
719
849
  } catch (err) {
720
850
  console.error(`[Room] onCreate error: ${err instanceof Error ? err.message : String(err)}`);
721
851
  }
722
852
  }
723
- // Start periodic state persistence
724
- this.startSaveTimer();
725
853
  }
726
854
 
727
855
  // Lifecycle: onJoin (throw to reject)
@@ -730,7 +858,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
730
858
  try {
731
859
  const sender = this.buildSender(meta);
732
860
  const roomApi = this.buildRoomServerAPI();
733
- const ctx = this.buildHandlerContext();
861
+ const ctx = await this.buildHandlerContext();
734
862
  await onJoin(sender, roomApi, ctx);
735
863
  } catch (err) {
736
864
  this.safeSend(ws, {
@@ -832,11 +960,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
832
960
  return;
833
961
  }
834
962
  if (!this.config.release && this.namespace) {
835
- const warningKey = `${this.namespace}:action`;
836
- if (!roomFallbackWarnings.has(warningKey)) {
837
- roomFallbackWarnings.add(warningKey);
838
- console.warn(`[Room] ${warningKey} is using development-mode allow-by-default. Add rooms.${this.namespace}.access.action or public.action to make this explicit.`);
839
- }
963
+ warnRoomDevelopmentFallback(this.namespace, 'action');
840
964
  }
841
965
  }
842
966
  if (actionAccess && this.roomId) {
@@ -881,7 +1005,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
881
1005
 
882
1006
  const sender = this.buildSender(meta);
883
1007
  const roomApi = this.buildRoomServerAPI();
884
- const ctx = this.buildHandlerContext();
1008
+ const ctx = await this.buildHandlerContext();
885
1009
 
886
1010
  try {
887
1011
  const result = await Promise.race([
@@ -933,10 +1057,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
933
1057
  }
934
1058
  }
935
1059
 
936
- const existing = this.disconnectTimers.get(player.userId);
937
- if (existing) {
938
- clearTimeout(existing);
939
- this.disconnectTimers.delete(player.userId);
1060
+ if (this.disconnectTimers.delete(player.userId)) {
1061
+ this.syncEphemeralTimersToStorage();
1062
+ this._scheduleNextAlarm();
940
1063
  }
941
1064
 
942
1065
  const remainingConns = this.userToConnections.get(player.userId);
@@ -966,16 +1089,21 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
966
1089
  setSharedState: (updater: (s: Record<string, unknown>) => Record<string, unknown>): void => {
967
1090
  const oldState = cloneState(this.sharedState);
968
1091
  const prevVersion = this.sharedVersion;
1092
+ const prevDirty = this.dirty;
1093
+ const prevStateSaveAt = this._stateSaveAt;
969
1094
  this.sharedState = updater(cloneState(this.sharedState));
970
1095
  this.sharedVersion++;
971
- this.dirty = true;
1096
+ this.markDirty();
972
1097
  try {
973
1098
  this.checkStateSizeLimit();
974
1099
  } catch (err) {
975
1100
  // Revert mutation
976
1101
  this.sharedState = oldState;
977
1102
  this.sharedVersion = prevVersion;
978
- this.dirty = false;
1103
+ this.dirty = prevDirty;
1104
+ this._stateSaveAt = prevStateSaveAt;
1105
+ this.syncEphemeralTimersToStorage();
1106
+ this._scheduleNextAlarm();
979
1107
  throw err;
980
1108
  }
981
1109
  const delta = computeDelta(oldState, this.sharedState);
@@ -1002,7 +1130,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1002
1130
  const prevVer = this.playerVersions.get(userId) ?? 0;
1003
1131
  const ver = prevVer + 1;
1004
1132
  this.playerVersions.set(userId, ver);
1005
- this.dirty = true;
1133
+ const prevDirty = this.dirty;
1134
+ const prevStateSaveAt = this._stateSaveAt;
1135
+ this.markDirty();
1006
1136
  try {
1007
1137
  this.checkStateSizeLimit();
1008
1138
  } catch (err) {
@@ -1013,7 +1143,10 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1013
1143
  this.playerStates.delete(userId);
1014
1144
  }
1015
1145
  this.playerVersions.set(userId, prevVer);
1016
- this.dirty = false;
1146
+ this.dirty = prevDirty;
1147
+ this._stateSaveAt = prevStateSaveAt;
1148
+ this.syncEphemeralTimersToStorage();
1149
+ this._scheduleNextAlarm();
1017
1150
  throw err;
1018
1151
  }
1019
1152
  const delta = computeDelta(oldState, newState);
@@ -1028,14 +1161,19 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1028
1161
 
1029
1162
  setServerState: (updater: (s: Record<string, unknown>) => Record<string, unknown>): void => {
1030
1163
  const oldState = this.serverState;
1164
+ const prevDirty = this.dirty;
1165
+ const prevStateSaveAt = this._stateSaveAt;
1031
1166
  this.serverState = updater(cloneState(this.serverState));
1032
- this.dirty = true;
1167
+ this.markDirty();
1033
1168
  try {
1034
1169
  this.checkStateSizeLimit();
1035
1170
  } catch (err) {
1036
1171
  // Revert mutation
1037
1172
  this.serverState = oldState;
1038
- this.dirty = false;
1173
+ this.dirty = prevDirty;
1174
+ this._stateSaveAt = prevStateSaveAt;
1175
+ this.syncEphemeralTimersToStorage();
1176
+ this._scheduleNextAlarm();
1039
1177
  throw err;
1040
1178
  }
1041
1179
  // No broadcast — server-only state
@@ -1075,7 +1213,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1075
1213
  throw new Error(`No onTimer handler for '${name}'`);
1076
1214
  }
1077
1215
  this._timers.set(name, { fireAt: Date.now() + ms, data });
1078
- this.dirty = true;
1216
+ this.markDirty();
1079
1217
  this._scheduleNextAlarm();
1080
1218
  },
1081
1219
 
@@ -1097,7 +1235,11 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1097
1235
 
1098
1236
  // ─── Handler Context Builder ───
1099
1237
 
1100
- protected buildHandlerContext(): RoomHandlerContext {
1238
+ protected async buildHandlerContext(): Promise<RoomHandlerContext> {
1239
+ const [{ buildFunctionContext }, { resolveRootServiceKey }] = await Promise.all([
1240
+ import('../lib/functions.js'),
1241
+ import('../lib/service-key.js'),
1242
+ ]);
1101
1243
  const ctx = buildFunctionContext({
1102
1244
  request: new Request('http://internal/room/action'),
1103
1245
  auth: null,
@@ -1138,21 +1280,18 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1138
1280
 
1139
1281
  // ─── State Persistence (DO Storage) ───
1140
1282
 
1141
- private startSaveTimer(): void {
1142
- if (this.saveTimer) return;
1143
- const interval = this.namespaceConfig?.stateSaveInterval ?? DEFAULT_STATE_SAVE_INTERVAL_MS;
1144
- this.saveTimer = setInterval(async () => {
1145
- if (this.dirty) {
1146
- await this.persistState();
1147
- }
1148
- }, interval);
1149
- }
1150
-
1151
- private stopSaveTimer(): void {
1152
- if (this.saveTimer) {
1153
- clearInterval(this.saveTimer);
1154
- this.saveTimer = null;
1283
+ private markDirty(): void {
1284
+ this.dirty = true;
1285
+ let scheduledNewStateSave = false;
1286
+ if (this._stateSaveAt === null) {
1287
+ const interval = this.namespaceConfig?.stateSaveInterval ?? DEFAULT_STATE_SAVE_INTERVAL_MS;
1288
+ this._stateSaveAt = Date.now() + interval;
1289
+ scheduledNewStateSave = true;
1155
1290
  }
1291
+ if (scheduledNewStateSave) {
1292
+ this.syncEphemeralTimersToStorage();
1293
+ }
1294
+ this._scheduleNextAlarm();
1156
1295
  }
1157
1296
 
1158
1297
  private async persistState(): Promise<void> {
@@ -1171,9 +1310,11 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1171
1310
  await this.ctx.storage.delete('roomTimers');
1172
1311
  }
1173
1312
  this.dirty = false;
1313
+ this._stateSaveAt = null;
1174
1314
  // Set TTL alarm as safety net for orphaned storage cleanup
1175
1315
  const ttl = this.namespaceConfig?.stateTTL ?? DEFAULT_STATE_TTL_MS;
1176
1316
  this._stateTTLAlarmAt = Date.now() + ttl;
1317
+ this.syncEphemeralTimersToStorage();
1177
1318
  this._scheduleNextAlarm();
1178
1319
  }
1179
1320
 
@@ -1202,7 +1343,6 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1202
1343
  const savedTimers = await this.ctx.storage.get('roomTimers') as Record<string, { fireAt: number; data?: unknown }> | undefined;
1203
1344
  if (savedTimers) {
1204
1345
  this._timers = new Map(Object.entries(savedTimers));
1205
- this._scheduleNextAlarm();
1206
1346
  }
1207
1347
 
1208
1348
  // Recover metadata
@@ -1211,34 +1351,57 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1211
1351
  this._metadata = savedMeta;
1212
1352
  }
1213
1353
 
1214
- this.startSaveTimer();
1354
+ const savedEphemeral = await this.ctx.storage.get(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY) as PersistedRoomEphemeralTimers | undefined;
1355
+ if (savedEphemeral?.pendingAuth) {
1356
+ this.pendingAuth = new Map(
1357
+ Object.entries(savedEphemeral.pendingAuth)
1358
+ .map(([connectionId, fireAt]) => [connectionId, Number(fireAt)]),
1359
+ );
1360
+ } else {
1361
+ this.pendingAuth.clear();
1362
+ }
1363
+ if (savedEphemeral?.disconnects) {
1364
+ this.disconnectTimers = new Map(
1365
+ Object.entries(savedEphemeral.disconnects)
1366
+ .map(([userId, timer]) => [userId, { fireAt: Number(timer.fireAt), connectionId: timer.connectionId }]),
1367
+ );
1368
+ } else {
1369
+ this.disconnectTimers.clear();
1370
+ }
1371
+ this._stateSaveAt = typeof savedEphemeral?.stateSaveAt === 'number'
1372
+ ? savedEphemeral.stateSaveAt
1373
+ : null;
1374
+ this._emptyRoomCleanupAt = typeof savedEphemeral?.emptyRoomCleanupAt === 'number'
1375
+ ? savedEphemeral.emptyRoomCleanupAt
1376
+ : null;
1377
+ this._stateTTLAlarmAt = typeof savedEphemeral?.stateTTLAlarmAt === 'number'
1378
+ ? savedEphemeral.stateTTLAlarmAt
1379
+ : null;
1380
+
1381
+ this._scheduleNextAlarm();
1215
1382
  }
1216
1383
 
1217
1384
  // ─── Delta Broadcasting ───
1218
1385
 
1219
- /** Queue shared state delta (batched, broadcast to all) */
1386
+ /** Queue shared state delta and flush it at the end of the current turn. */
1220
1387
  private queueSharedDelta(delta: Record<string, unknown>): void {
1221
1388
  if (!this.pendingSharedDelta) {
1222
1389
  this.pendingSharedDelta = {};
1223
1390
  }
1224
1391
  Object.assign(this.pendingSharedDelta, delta);
1225
1392
 
1226
- if (!this.sharedDeltaBatchTimer) {
1227
- const batchMs = DEFAULT_DELTA_BATCH_MS;
1228
- this.sharedDeltaBatchTimer = setTimeout(() => {
1393
+ if (!this.sharedDeltaFlushQueued) {
1394
+ this.sharedDeltaFlushQueued = true;
1395
+ queueMicrotask(() => {
1396
+ this.sharedDeltaFlushQueued = false;
1229
1397
  this.flushSharedDelta();
1230
- }, batchMs);
1398
+ });
1231
1399
  }
1232
1400
  }
1233
1401
 
1234
1402
  private flushSharedDelta(): void {
1235
1403
  if (!this.pendingSharedDelta) return;
1236
1404
 
1237
- // Cancel batch timer if still pending
1238
- if (this.sharedDeltaBatchTimer) {
1239
- clearTimeout(this.sharedDeltaBatchTimer);
1240
- }
1241
-
1242
1405
  this.broadcastToAuthenticated({
1243
1406
  type: 'shared_delta',
1244
1407
  delta: this.pendingSharedDelta,
@@ -1246,7 +1409,6 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1246
1409
  });
1247
1410
 
1248
1411
  this.pendingSharedDelta = null;
1249
- this.sharedDeltaBatchTimer = null;
1250
1412
  }
1251
1413
 
1252
1414
  /** Send player state delta directly (unicast, no batching) */
@@ -1305,10 +1467,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1305
1467
  }
1306
1468
  }
1307
1469
  // Cancel any existing reconnect timer
1308
- const existingTimer = this.disconnectTimers.get(userId);
1309
- if (existingTimer) {
1310
- clearTimeout(existingTimer);
1311
- this.disconnectTimers.delete(userId);
1470
+ if (this.disconnectTimers.delete(userId)) {
1471
+ this.syncEphemeralTimersToStorage();
1472
+ this._scheduleNextAlarm();
1312
1473
  }
1313
1474
 
1314
1475
  // Fire onLeave with 'kicked' reason
@@ -1366,17 +1527,15 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1366
1527
  if (kicked) {
1367
1528
  // Kicked — immediate leave, no reconnect timer
1368
1529
  // Cancel any existing reconnect timer for this user
1369
- const existing = this.disconnectTimers.get(player.userId);
1370
- if (existing) {
1371
- clearTimeout(existing);
1372
- this.disconnectTimers.delete(player.userId);
1530
+ if (this.disconnectTimers.delete(player.userId)) {
1531
+ this.syncEphemeralTimersToStorage();
1532
+ this._scheduleNextAlarm();
1373
1533
  }
1374
1534
  await this.finalizePlayerLeave(player.userId, meta.connectionId, 'kicked');
1375
1535
  } else if (explicitLeave) {
1376
- const existing = this.disconnectTimers.get(player.userId);
1377
- if (existing) {
1378
- clearTimeout(existing);
1379
- this.disconnectTimers.delete(player.userId);
1536
+ if (this.disconnectTimers.delete(player.userId)) {
1537
+ this.syncEphemeralTimersToStorage();
1538
+ this._scheduleNextAlarm();
1380
1539
  }
1381
1540
  await this.finalizePlayerLeave(player.userId, meta.connectionId, 'leave');
1382
1541
  } else {
@@ -1384,11 +1543,12 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1384
1543
  const reconnectTimeout = this.namespaceConfig?.reconnectTimeout ?? DEFAULT_RECONNECT_TIMEOUT_MS;
1385
1544
 
1386
1545
  if (reconnectTimeout > 0) {
1387
- const timer = setTimeout(async () => {
1388
- this.disconnectTimers.delete(player.userId);
1389
- await this.finalizePlayerLeave(player.userId, meta.connectionId, 'disconnect');
1390
- }, reconnectTimeout);
1391
- this.disconnectTimers.set(player.userId, timer);
1546
+ this.disconnectTimers.set(player.userId, {
1547
+ fireAt: Date.now() + reconnectTimeout,
1548
+ connectionId: meta.connectionId,
1549
+ });
1550
+ this.syncEphemeralTimersToStorage();
1551
+ this._scheduleNextAlarm();
1392
1552
  } else {
1393
1553
  // Immediate leave
1394
1554
  await this.finalizePlayerLeave(player.userId, meta.connectionId, 'disconnect');
@@ -1410,7 +1570,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1410
1570
  try {
1411
1571
  const sender: RoomSender = { userId, connectionId };
1412
1572
  const roomApi = this.buildRoomServerAPI();
1413
- const ctx = this.buildHandlerContext();
1573
+ const ctx = await this.buildHandlerContext();
1414
1574
  await onLeave(sender, roomApi, ctx, reason);
1415
1575
  } catch (err) {
1416
1576
  console.error(`[Room] onLeave error: ${err instanceof Error ? err.message : String(err)}`);
@@ -1434,7 +1594,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1434
1594
  if (onDestroy) {
1435
1595
  try {
1436
1596
  const roomApi = this.buildRoomServerAPI();
1437
- const ctx = this.buildHandlerContext();
1597
+ const ctx = await this.buildHandlerContext();
1438
1598
  await onDestroy(roomApi, ctx);
1439
1599
  } catch (err) {
1440
1600
  console.error(`[Room] onDestroy error: ${err instanceof Error ? err.message : String(err)}`);
@@ -1442,16 +1602,67 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1442
1602
  }
1443
1603
 
1444
1604
  // Clean up state persistence
1445
- this.stopSaveTimer();
1605
+ this.dirty = false;
1606
+ this._stateSaveAt = null;
1446
1607
  this._timers.clear();
1608
+ this.pendingAuth.clear();
1609
+ this.disconnectTimers.clear();
1447
1610
  this._metadata = {};
1448
1611
  await this.ctx.storage.delete('roomState');
1449
1612
  await this.ctx.storage.delete('roomTimers');
1450
1613
  await this.ctx.storage.delete('roomMetadata');
1614
+ await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
1451
1615
 
1452
1616
  this.scheduleEmptyRoomCleanup();
1453
1617
  }
1454
1618
 
1619
+ private syncEphemeralTimersToStorage(): void {
1620
+ const pendingAuth = Object.fromEntries(this.pendingAuth);
1621
+ const disconnects = Object.fromEntries(this.disconnectTimers);
1622
+ const stateSaveAt = this._stateSaveAt;
1623
+ const emptyRoomCleanupAt = this._emptyRoomCleanupAt;
1624
+ const stateTTLAlarmAt = this._stateTTLAlarmAt;
1625
+ this.ctx.waitUntil((async () => {
1626
+ try {
1627
+ if (
1628
+ Object.keys(pendingAuth).length === 0
1629
+ && Object.keys(disconnects).length === 0
1630
+ && stateSaveAt === null
1631
+ && emptyRoomCleanupAt === null
1632
+ && stateTTLAlarmAt === null
1633
+ ) {
1634
+ await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
1635
+ return;
1636
+ }
1637
+
1638
+ await this.ctx.storage.put(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY, {
1639
+ pendingAuth,
1640
+ disconnects,
1641
+ stateSaveAt,
1642
+ emptyRoomCleanupAt,
1643
+ stateTTLAlarmAt,
1644
+ } satisfies PersistedRoomEphemeralTimers);
1645
+ } catch (error) {
1646
+ console.warn('[Room] Ephemeral timer persistence skipped', {
1647
+ room: this.namespace && this.roomId ? `${this.namespace}::${this.roomId}` : null,
1648
+ pendingAuthCount: this.pendingAuth.size,
1649
+ disconnectCount: this.disconnectTimers.size,
1650
+ message: error instanceof Error ? error.message : String(error),
1651
+ });
1652
+ }
1653
+ })());
1654
+ }
1655
+
1656
+ private findWebSocketByConnectionId(connectionId: string): WebSocket | null {
1657
+ for (const ws of this.ctx.getWebSockets()) {
1658
+ const meta = this.getWSMeta(ws);
1659
+ if (meta?.connectionId === connectionId) {
1660
+ return ws;
1661
+ }
1662
+ }
1663
+ return null;
1664
+ }
1665
+
1455
1666
  private getPlayersArray(): Array<{ userId: string; connectionId: string }> {
1456
1667
  return Array.from(this.players.values()).map(p => ({
1457
1668
  userId: p.userId,
@@ -1522,14 +1733,29 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1522
1733
 
1523
1734
  // ─── Rate Limiting (Token Bucket) ───
1524
1735
 
1525
- protected checkRateLimit(connectionId: string): boolean {
1736
+ protected checkRateLimit(
1737
+ connectionId: string,
1738
+ scope: RoomRateLimitScope = 'actions',
1739
+ ): boolean {
1526
1740
  const now = Date.now();
1527
- const maxActions = this.namespaceConfig?.rateLimit?.actions ?? DEFAULT_RATE_LIMIT_ACTIONS;
1528
- let bucket = this.rateBuckets.get(connectionId);
1741
+ const rateLimit = this.namespaceConfig?.rateLimit as
1742
+ | { actions: number; signals?: number; media?: number; admin?: number }
1743
+ | undefined;
1744
+ const maxActions = (
1745
+ scope === 'signals'
1746
+ ? rateLimit?.signals
1747
+ : scope === 'media'
1748
+ ? rateLimit?.media
1749
+ : scope === 'admin'
1750
+ ? rateLimit?.admin
1751
+ : undefined
1752
+ ) ?? rateLimit?.actions ?? DEFAULT_RATE_LIMIT_ACTIONS;
1753
+ const bucketKey = `${connectionId}:${scope}`;
1754
+ let bucket = this.rateBuckets.get(bucketKey);
1529
1755
 
1530
1756
  if (!bucket) {
1531
1757
  bucket = { tokens: maxActions, lastRefill: now };
1532
- this.rateBuckets.set(connectionId, bucket);
1758
+ this.rateBuckets.set(bucketKey, bucket);
1533
1759
  }
1534
1760
 
1535
1761
  // Refill tokens (1 token per 1000/maxActions ms)
@@ -1549,6 +1775,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1549
1775
 
1550
1776
  private scheduleEmptyRoomCleanup(): void {
1551
1777
  this._emptyRoomCleanupAt = Date.now() + EMPTY_ROOM_CLEANUP_DELAY_MS;
1778
+ this.syncEphemeralTimersToStorage();
1552
1779
  this._scheduleNextAlarm();
1553
1780
  }
1554
1781
 
@@ -1625,4 +1852,44 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1625
1852
  private parseConfig(env: RoomDOEnv): EdgeBaseConfig {
1626
1853
  return getGlobalConfig(env);
1627
1854
  }
1855
+
1856
+ private async ensureRuntimeReady(): Promise<void> {
1857
+ if (!this.runtimeReadyPromise) {
1858
+ this.runtimeReadyPromise = (async () => {
1859
+ await ensureServerStartup();
1860
+ this.config = this.parseConfig(this.env);
1861
+ if (this.namespace) {
1862
+ this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
1863
+ }
1864
+ })();
1865
+ }
1866
+
1867
+ await this.runtimeReadyPromise;
1868
+ }
1869
+
1870
+ private shouldRecoverBeforeAlarm(): boolean {
1871
+ if (this.stateRecoveryNeeded) {
1872
+ return true;
1873
+ }
1874
+
1875
+ if (this.ctx.getWebSockets().length > 0) {
1876
+ return false;
1877
+ }
1878
+
1879
+ return (
1880
+ !this.roomCreated
1881
+ && Object.keys(this.sharedState).length === 0
1882
+ && this.playerStates.size === 0
1883
+ && Object.keys(this.serverState).length === 0
1884
+ && this.players.size === 0
1885
+ && this.userToConnections.size === 0
1886
+ && this.pendingAuth.size === 0
1887
+ && this.disconnectTimers.size === 0
1888
+ && this._timers.size === 0
1889
+ && this._stateSaveAt === null
1890
+ && this._emptyRoomCleanupAt === null
1891
+ && this._stateTTLAlarmAt === null
1892
+ && Object.keys(this._metadata).length === 0
1893
+ );
1894
+ }
1628
1895
  }