@edge-base/server 0.2.3 → 0.2.5

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 (89) hide show
  1. package/admin-build/_app/immutable/chunks/{DpVAayDG.js → 6oMK_164.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{B5Nwfelm.js → B2TnDKF7.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{DCvwWZrm.js → B6MschND.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{Du5vWVa2.js → B94PilAN.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{Dc1-6Po6.js → BEW7Ez_g.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{Dlty5069.js → BoOooyH6.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{CzSAxmuj.js → BqTb6Mxk.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{DCKcAiQH.js → BvHnF5tV.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/{B-_-hJ9o.js → CaVKAiCe.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{DRqPU3wD.js → Cdm5zBRA.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{byv2rTy8.js → CrOZMmdF.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{DiyBpamp.js → Cw6OYcq-.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{A_3UuvCe.js → D2j3I1VQ.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{BxoNtYHK.js → DPdQ7z0T.js} +3 -3
  15. package/admin-build/_app/immutable/chunks/{nZvorU8i.js → J2Gw0SMu.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{CZ0TVkCa.js → pUxw8jfq.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.CfrmEXPD.js → app.D3flihMw.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.Cl6sLxnz.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.Cn2BZ4da.js → 0.CdczqZLK.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.Dv4LX_Co.js → 1.DxcSsEqS.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.DPVv3kat.js → 10.DuAd4aIm.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.CiCb6Ayu.js → 11.0jgHQL92.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.CIPyeekF.js → 12.CKNPqmyy.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.Z15Lt36e.js → 13.B1p2POXS.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.s0l5bAq3.js → 14.Bb-REBND.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.UwSSNO76.js → 15.1uBFCX0X.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.qiD8i883.js → 16.BR7WwQrS.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.Dy3dcSvu.js → 17.Cm57KKXV.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.DeXyPYsO.js → 18.CoiwfAuQ.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CAbuyS6w.js → 19.B8ZdLlXj.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.Bec0T7un.js → 20.DnHeFlTv.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.CJFaf0Ia.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.CdVprrv2.js → 22.CItETFzy.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.Y8RzVLoF.js → 23.CWSGMcKJ.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.CWhHYFBx.js → 24.CWbEqNMB.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.wCBplOVt.js → 25.DRkLEhKi.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.Cod_JRFK.js → 26.BRxO8AYH.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.BO2HVMu9.js → 27.BLs-nVHz.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.DxG-FBVQ.js → 28.G79qkdBK.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.CjGqWGvE.js → 29.BOcI6g0N.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.By3_OmdZ.js → 3.B6q-7qr8.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.M_H7Htpq.js → 30.DAIC7dKd.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.DEU18izM.js → 31.pl0XXjXF.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.DeYhKtzJ.js → 4.DOdvVlZj.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.9WLgxhrD.js → 5.BW_zlgye.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.BdT2i_dd.js → 6.Dxy1CAI2.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.CHq0s4K6.js → 7.BG98w_o7.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.DuvRw-XZ.js → 8.DoG5R2rG.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.C2Ub82wn.js → 9.Dmxf6zAC.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/package.json +3 -3
  53. package/src/__tests__/admin-data-routes.test.ts +29 -0
  54. package/src/__tests__/database-do-route-validation.test.ts +108 -0
  55. package/src/__tests__/database-live-route.test.ts +82 -0
  56. package/src/__tests__/do-router.test.ts +116 -0
  57. package/src/__tests__/functions-context.test.ts +84 -0
  58. package/src/__tests__/functions-d1-proxy.test.ts +54 -0
  59. package/src/__tests__/meta-route-registration.test.ts +20 -15
  60. package/src/__tests__/plugin-migration-routing.test.ts +32 -0
  61. package/src/__tests__/provider-aware-sql.test.ts +9 -3
  62. package/src/__tests__/room-auth-state-loss.test.ts +122 -0
  63. package/src/__tests__/room-handler-context.test.ts +4 -4
  64. package/src/__tests__/room-rate-limit-scopes.test.ts +38 -0
  65. package/src/__tests__/runtime-startup.test.ts +49 -0
  66. package/src/__tests__/scheduled.test.ts +55 -0
  67. package/src/__tests__/service-key-db-proxy.test.ts +122 -1
  68. package/src/__tests__/sql-route.test.ts +66 -0
  69. package/src/__tests__/table-hook-runtime.test.ts +137 -0
  70. package/src/durable-objects/database-do.ts +50 -45
  71. package/src/durable-objects/database-live-do.ts +15 -0
  72. package/src/durable-objects/room-runtime-base.ts +387 -129
  73. package/src/durable-objects/rooms-do.ts +31 -24
  74. package/src/index.ts +334 -282
  75. package/src/lib/d1-handler.ts +10 -21
  76. package/src/lib/do-router.ts +135 -3
  77. package/src/lib/functions.ts +4 -3
  78. package/src/lib/internal-transport.ts +28 -12
  79. package/src/lib/plugin-migration-routing.ts +28 -0
  80. package/src/lib/postgres-handler.ts +12 -20
  81. package/src/lib/provider-aware-sql.ts +19 -15
  82. package/src/lib/runtime-startup.ts +53 -0
  83. package/src/lib/table-hook-runtime.ts +62 -0
  84. package/src/routes/admin.ts +41 -41
  85. package/src/routes/database-live.ts +110 -12
  86. package/src/routes/sql.ts +22 -17
  87. package/src/routes/tables.ts +42 -29
  88. package/admin-build/_app/immutable/entry/start.l1WvHznQ.js +0 -1
  89. package/admin-build/_app/immutable/nodes/21.DuDYelMY.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,31 @@ 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 resolveRoomAuthTimeoutMs(config?: EdgeBaseConfig): number {
118
+ const value = config?.databaseLive?.authTimeoutMs;
119
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
120
+ return DEFAULT_ROOM_AUTH_TIMEOUT_MS;
121
+ }
122
+ const normalized = Math.floor(value);
123
+ return normalized > 0 ? normalized : DEFAULT_ROOM_AUTH_TIMEOUT_MS;
124
+ }
125
+
92
126
  // ─── Compute delta between two states ───
93
127
 
94
128
  function computeDelta(
@@ -123,7 +157,7 @@ function cloneState(obj: Record<string, unknown>): Record<string, unknown> {
123
157
  // ─── Shared Room Runtime Base ───
124
158
 
125
159
  export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
126
- protected readonly config: EdgeBaseConfig;
160
+ protected config: EdgeBaseConfig;
127
161
 
128
162
  // ─── Room identification ───
129
163
  protected namespace: string | null = null;
@@ -143,27 +177,28 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
143
177
 
144
178
  // ─── Lifecycle ───
145
179
  private roomCreated = false;
180
+ private runtimeReadyPromise: Promise<void> | null = null;
146
181
 
147
- // ─── State persistence (replaces RESYNC) ───
182
+ // ─── State persistence (alarm-based, hibernation-friendly) ───
148
183
  private dirty = false;
149
- private saveTimer: ReturnType<typeof setInterval> | null = null;
184
+ private _stateSaveAt: number | null = null;
150
185
  private stateRecoveryNeeded = false;
151
186
 
152
187
  // ─── WebSocket metadata cache ───
153
188
  private _metaCache = new Map<WebSocket, RoomWSMeta>();
154
189
 
155
190
  // ─── Auth timeout tracking ───
156
- private pendingAuth = new Map<string, ReturnType<typeof setTimeout>>();
191
+ private pendingAuth = new Map<string, number>();
157
192
 
158
193
  // ─── Delta batching (shared state) ───
159
194
  private pendingSharedDelta: Record<string, unknown> | null = null;
160
- private sharedDeltaBatchTimer: ReturnType<typeof setTimeout> | null = null;
195
+ private sharedDeltaFlushQueued = false;
161
196
 
162
197
  // ─── Rate limiting (per connection, token bucket) ───
163
198
  private rateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
164
199
 
165
200
  // ─── Reconnect timers ───
166
- private disconnectTimers = new Map<string, ReturnType<typeof setTimeout>>(); // userId → timer
201
+ private disconnectTimers = new Map<string, PendingDisconnectDeadline>(); // userId → deadline
167
202
 
168
203
  // ─── Named Timers (alarm multiplexer) ───
169
204
  private _timers = new Map<string, { fireAt: number; data?: unknown }>();
@@ -186,6 +221,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
186
221
  // ─── HTTP Fetch Handler ───
187
222
 
188
223
  async fetch(request: Request): Promise<Response> {
224
+ await this.ensureRuntimeReady();
189
225
  const url = new URL(request.url);
190
226
  if (url.pathname === '/websocket') {
191
227
  return this.handleWebSocketUpgrade(request);
@@ -353,25 +389,11 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
353
389
  this._metaCache.set(server, meta);
354
390
  this.syncRoomMonitoringSnapshot();
355
391
 
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);
392
+ // Set auth timeout without pinning the DO with a JS timer.
393
+ const authTimeoutMs = resolveRoomAuthTimeoutMs(this.config);
394
+ this.pendingAuth.set(connectionId, Date.now() + authTimeoutMs);
395
+ this.syncEphemeralTimersToStorage();
396
+ this._scheduleNextAlarm();
375
397
 
376
398
  return new Response(null, { status: 101, webSocket: client });
377
399
  }
@@ -379,6 +401,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
379
401
  // ─── Hibernation API Callbacks ───
380
402
 
381
403
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
404
+ await this.ensureRuntimeReady();
382
405
  if (typeof message !== 'string') return;
383
406
 
384
407
  let msg: Record<string, unknown>;
@@ -447,10 +470,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
447
470
  const explicitLeave = code === ROOM_CLIENT_LEAVE_CLOSE_CODE;
448
471
  this.handleDisconnect(meta, kicked, explicitLeave);
449
472
  this._metaCache.delete(ws);
450
- const timer = this.pendingAuth.get(meta.connectionId);
451
- if (timer) {
452
- clearTimeout(timer);
453
- this.pendingAuth.delete(meta.connectionId);
473
+ if (this.pendingAuth.delete(meta.connectionId)) {
474
+ this.syncEphemeralTimersToStorage();
475
+ this._scheduleNextAlarm();
454
476
  }
455
477
  }
456
478
  this.syncRoomMonitoringSnapshot(ws);
@@ -461,12 +483,16 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
461
483
  if (meta) {
462
484
  this.handleDisconnect(meta);
463
485
  this._metaCache.delete(ws);
486
+ if (this.pendingAuth.delete(meta.connectionId)) {
487
+ this.syncEphemeralTimersToStorage();
488
+ this._scheduleNextAlarm();
489
+ }
464
490
  }
465
491
  this.syncRoomMonitoringSnapshot(ws);
466
492
  }
467
493
 
468
494
  // ─── Alarm Multiplexer ───
469
- // Single DO alarm is shared among: named timers, empty room cleanup, state TTL.
495
+ // Single DO alarm is shared among: named timers, state save, empty room cleanup, state TTL.
470
496
 
471
497
  /**
472
498
  * Recalculate and set the single DO alarm to the earliest pending event.
@@ -477,9 +503,18 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
477
503
  for (const timer of this._timers.values()) {
478
504
  if (timer.fireAt < earliest) earliest = timer.fireAt;
479
505
  }
506
+ for (const fireAt of this.pendingAuth.values()) {
507
+ if (fireAt < earliest) earliest = fireAt;
508
+ }
509
+ for (const timer of this.disconnectTimers.values()) {
510
+ if (timer.fireAt < earliest) earliest = timer.fireAt;
511
+ }
480
512
  if (this._emptyRoomCleanupAt !== null && this._emptyRoomCleanupAt < earliest) {
481
513
  earliest = this._emptyRoomCleanupAt;
482
514
  }
515
+ if (this._stateSaveAt !== null && this._stateSaveAt < earliest) {
516
+ earliest = this._stateSaveAt;
517
+ }
483
518
  if (this._stateTTLAlarmAt !== null && this._stateTTLAlarmAt < earliest) {
484
519
  earliest = this._stateTTLAlarmAt;
485
520
  }
@@ -490,9 +525,44 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
490
525
  }
491
526
 
492
527
  async alarm(): Promise<void> {
528
+ await this.ensureRuntimeReady();
529
+
530
+ if (this.shouldRecoverBeforeAlarm()) {
531
+ await this.recoverFromStorage();
532
+ this.stateRecoveryNeeded = false;
533
+ }
534
+
493
535
  const now = Date.now();
494
536
 
495
- // 1. Fire expired named timers
537
+ // 1. Close unauthenticated sockets whose auth deadline expired.
538
+ const expiredAuthConnectionIds: string[] = [];
539
+ for (const [connectionId, fireAt] of this.pendingAuth) {
540
+ if (fireAt <= now) {
541
+ expiredAuthConnectionIds.push(connectionId);
542
+ this.pendingAuth.delete(connectionId);
543
+ }
544
+ }
545
+ for (const connectionId of expiredAuthConnectionIds) {
546
+ const ws = this.findWebSocketByConnectionId(connectionId);
547
+ const currentMeta = ws ? this.getWSMeta(ws) : null;
548
+ if (ws && currentMeta && !currentMeta.authenticated) {
549
+ try {
550
+ this.safeSend(ws, {
551
+ type: 'error',
552
+ code: 'AUTH_TIMEOUT',
553
+ message: `Authentication required within ${resolveRoomAuthTimeoutMs(this.config)}ms`,
554
+ });
555
+ ws.close(4001, 'Authentication timeout');
556
+ } catch {
557
+ // WebSocket already closed by client.
558
+ }
559
+ }
560
+ }
561
+ if (expiredAuthConnectionIds.length > 0) {
562
+ this.syncEphemeralTimersToStorage();
563
+ }
564
+
565
+ // 2. Fire expired named timers
496
566
  const expiredTimers: Array<{ name: string; data?: unknown }> = [];
497
567
  for (const [name, timer] of this._timers) {
498
568
  if (timer.fireAt <= now) {
@@ -506,7 +576,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
506
576
  if (handler) {
507
577
  try {
508
578
  const roomApi = this.buildRoomServerAPI();
509
- const ctx = this.buildHandlerContext();
579
+ const ctx = await this.buildHandlerContext();
510
580
  await handler(roomApi, ctx, data);
511
581
  } catch (err) {
512
582
  console.error(`[Room] onTimer['${name}'] error: ${err instanceof Error ? err.message : String(err)}`);
@@ -515,10 +585,25 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
515
585
  }
516
586
 
517
587
  if (expiredTimers.length > 0) {
518
- this.dirty = true;
588
+ this.markDirty();
589
+ }
590
+
591
+ // 3. Finalize disconnect grace periods without pinning the DO.
592
+ const expiredDisconnects: Array<{ userId: string; connectionId: string }> = [];
593
+ for (const [userId, timer] of this.disconnectTimers) {
594
+ if (timer.fireAt <= now) {
595
+ expiredDisconnects.push({ userId, connectionId: timer.connectionId });
596
+ this.disconnectTimers.delete(userId);
597
+ }
598
+ }
599
+ for (const { userId, connectionId } of expiredDisconnects) {
600
+ await this.finalizePlayerLeave(userId, connectionId, 'disconnect');
601
+ }
602
+ if (expiredDisconnects.length > 0) {
603
+ this.syncEphemeralTimersToStorage();
519
604
  }
520
605
 
521
- // 2. Empty room cleanup
606
+ // 4. Empty room cleanup
522
607
  if (this._emptyRoomCleanupAt !== null && this._emptyRoomCleanupAt <= now) {
523
608
  this._emptyRoomCleanupAt = null;
524
609
  if (this.players.size === 0) {
@@ -532,39 +617,55 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
532
617
  this.roomCreated = false;
533
618
  this._timers.clear();
534
619
  this._metadata = {};
620
+ this.pendingAuth.clear();
621
+ this.disconnectTimers.clear();
535
622
  this.pendingSharedDelta = null;
536
- if (this.sharedDeltaBatchTimer) {
537
- clearTimeout(this.sharedDeltaBatchTimer);
538
- this.sharedDeltaBatchTimer = null;
539
- }
540
- this.stopSaveTimer();
623
+ this.sharedDeltaFlushQueued = false;
624
+ this.dirty = false;
625
+ this._stateSaveAt = null;
541
626
  // Clean up persisted state
542
627
  await this.ctx.storage.delete('roomState');
543
628
  await this.ctx.storage.delete('roomTimers');
544
629
  await this.ctx.storage.delete('roomMetadata');
630
+ await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
545
631
  // Phase 2: Schedule idleTimeout alarm
546
632
  this._stateTTLAlarmAt = Date.now() + DEFAULT_IDLE_TIMEOUT_SEC * 1000;
633
+ this.syncEphemeralTimersToStorage();
547
634
  } else {
548
635
  // TTL safety net alarm: room is empty and state already cleared
636
+ this.dirty = false;
637
+ this._stateSaveAt = null;
549
638
  await this.ctx.storage.delete('roomState');
550
639
  await this.ctx.storage.delete('roomTimers');
551
640
  await this.ctx.storage.delete('roomMetadata');
641
+ await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
552
642
  this._stateTTLAlarmAt = null;
553
643
  }
554
644
  }
645
+ this.syncEphemeralTimersToStorage();
646
+ }
647
+
648
+ // 5. Persist dirty state without keeping the DO awake via setInterval.
649
+ if (this._stateSaveAt !== null && this._stateSaveAt <= now) {
650
+ this._stateSaveAt = null;
651
+ if (this.dirty) {
652
+ await this.persistState();
653
+ }
555
654
  }
556
655
 
557
- // 3. State TTL safety net
656
+ // 6. State TTL safety net
558
657
  if (this._stateTTLAlarmAt !== null && this._stateTTLAlarmAt <= now) {
559
658
  this._stateTTLAlarmAt = null;
560
659
  if (this.players.size === 0) {
561
660
  await this.ctx.storage.delete('roomState');
562
661
  await this.ctx.storage.delete('roomTimers');
563
662
  await this.ctx.storage.delete('roomMetadata');
663
+ await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
564
664
  }
665
+ this.syncEphemeralTimersToStorage();
565
666
  }
566
667
 
567
- // 4. Reschedule for next pending event
668
+ // 7. Reschedule for next pending event
568
669
  this._scheduleNextAlarm();
569
670
  }
570
671
 
@@ -591,6 +692,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
591
692
  if (meta.ip) headers.set('CF-Connecting-IP', meta.ip);
592
693
  if (meta.userAgent) headers.set('User-Agent', meta.userAgent);
593
694
  headers.set('Authorization', `Bearer ${token}`);
695
+ const { resolveAuthContextFromToken } = await import('../middleware/auth.js');
594
696
  const auth = await resolveAuthContextFromToken(
595
697
  this.env,
596
698
  token,
@@ -611,19 +713,17 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
611
713
  this.setWSMeta(ws, meta);
612
714
 
613
715
  // Clear auth timeout
614
- const timer = this.pendingAuth.get(meta.connectionId);
615
- if (timer) {
616
- clearTimeout(timer);
617
- this.pendingAuth.delete(meta.connectionId);
716
+ if (this.pendingAuth.delete(meta.connectionId)) {
717
+ this.syncEphemeralTimersToStorage();
718
+ this._scheduleNextAlarm();
618
719
  }
619
720
 
620
721
  // Register player (only on first auth)
621
722
  if (!isReAuth && meta.userId) {
622
723
  // 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);
724
+ if (this.disconnectTimers.delete(meta.userId)) {
725
+ this.syncEphemeralTimersToStorage();
726
+ this._scheduleNextAlarm();
627
727
  }
628
728
 
629
729
  this.addPlayer(meta.connectionId, meta.userId);
@@ -643,11 +743,28 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
643
743
  this.stateRecoveryNeeded = false;
644
744
  }
645
745
  // Note: full sync is sent during handleJoin(), not here
646
- } catch {
746
+ } catch (error) {
747
+ const detail = error instanceof Error ? error.message : String(error);
748
+ console.error('[Room] handleAuth failed', {
749
+ room: this.namespace && this.roomId ? `${this.namespace}::${this.roomId}` : null,
750
+ connectionId: meta.connectionId,
751
+ isReAuth,
752
+ userId: meta.userId ?? null,
753
+ message: detail,
754
+ stack: error instanceof Error ? error.stack : undefined,
755
+ });
647
756
  if (isReAuth) {
648
- this.safeSend(ws, { type: 'error', code: 'AUTH_REFRESH_FAILED', message: 'Token refresh failed' });
757
+ this.safeSend(ws, {
758
+ type: 'error',
759
+ code: 'AUTH_REFRESH_FAILED',
760
+ message: this.config.release ? 'Token refresh failed' : detail,
761
+ });
649
762
  } else {
650
- this.safeSend(ws, { type: 'error', code: 'AUTH_FAILED', message: 'Invalid or expired token' });
763
+ this.safeSend(ws, {
764
+ type: 'error',
765
+ code: 'AUTH_FAILED',
766
+ message: this.config.release ? 'Invalid or expired token' : detail,
767
+ });
651
768
  ws.close(4002, 'Authentication failed');
652
769
  }
653
770
  }
@@ -714,14 +831,12 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
714
831
  if (onCreate) {
715
832
  try {
716
833
  const roomApi = this.buildRoomServerAPI();
717
- const ctx = this.buildHandlerContext();
834
+ const ctx = await this.buildHandlerContext();
718
835
  await onCreate(roomApi, ctx);
719
836
  } catch (err) {
720
837
  console.error(`[Room] onCreate error: ${err instanceof Error ? err.message : String(err)}`);
721
838
  }
722
839
  }
723
- // Start periodic state persistence
724
- this.startSaveTimer();
725
840
  }
726
841
 
727
842
  // Lifecycle: onJoin (throw to reject)
@@ -730,7 +845,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
730
845
  try {
731
846
  const sender = this.buildSender(meta);
732
847
  const roomApi = this.buildRoomServerAPI();
733
- const ctx = this.buildHandlerContext();
848
+ const ctx = await this.buildHandlerContext();
734
849
  await onJoin(sender, roomApi, ctx);
735
850
  } catch (err) {
736
851
  this.safeSend(ws, {
@@ -881,7 +996,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
881
996
 
882
997
  const sender = this.buildSender(meta);
883
998
  const roomApi = this.buildRoomServerAPI();
884
- const ctx = this.buildHandlerContext();
999
+ const ctx = await this.buildHandlerContext();
885
1000
 
886
1001
  try {
887
1002
  const result = await Promise.race([
@@ -933,10 +1048,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
933
1048
  }
934
1049
  }
935
1050
 
936
- const existing = this.disconnectTimers.get(player.userId);
937
- if (existing) {
938
- clearTimeout(existing);
939
- this.disconnectTimers.delete(player.userId);
1051
+ if (this.disconnectTimers.delete(player.userId)) {
1052
+ this.syncEphemeralTimersToStorage();
1053
+ this._scheduleNextAlarm();
940
1054
  }
941
1055
 
942
1056
  const remainingConns = this.userToConnections.get(player.userId);
@@ -966,16 +1080,21 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
966
1080
  setSharedState: (updater: (s: Record<string, unknown>) => Record<string, unknown>): void => {
967
1081
  const oldState = cloneState(this.sharedState);
968
1082
  const prevVersion = this.sharedVersion;
1083
+ const prevDirty = this.dirty;
1084
+ const prevStateSaveAt = this._stateSaveAt;
969
1085
  this.sharedState = updater(cloneState(this.sharedState));
970
1086
  this.sharedVersion++;
971
- this.dirty = true;
1087
+ this.markDirty();
972
1088
  try {
973
1089
  this.checkStateSizeLimit();
974
1090
  } catch (err) {
975
1091
  // Revert mutation
976
1092
  this.sharedState = oldState;
977
1093
  this.sharedVersion = prevVersion;
978
- this.dirty = false;
1094
+ this.dirty = prevDirty;
1095
+ this._stateSaveAt = prevStateSaveAt;
1096
+ this.syncEphemeralTimersToStorage();
1097
+ this._scheduleNextAlarm();
979
1098
  throw err;
980
1099
  }
981
1100
  const delta = computeDelta(oldState, this.sharedState);
@@ -1002,7 +1121,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1002
1121
  const prevVer = this.playerVersions.get(userId) ?? 0;
1003
1122
  const ver = prevVer + 1;
1004
1123
  this.playerVersions.set(userId, ver);
1005
- this.dirty = true;
1124
+ const prevDirty = this.dirty;
1125
+ const prevStateSaveAt = this._stateSaveAt;
1126
+ this.markDirty();
1006
1127
  try {
1007
1128
  this.checkStateSizeLimit();
1008
1129
  } catch (err) {
@@ -1013,7 +1134,10 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1013
1134
  this.playerStates.delete(userId);
1014
1135
  }
1015
1136
  this.playerVersions.set(userId, prevVer);
1016
- this.dirty = false;
1137
+ this.dirty = prevDirty;
1138
+ this._stateSaveAt = prevStateSaveAt;
1139
+ this.syncEphemeralTimersToStorage();
1140
+ this._scheduleNextAlarm();
1017
1141
  throw err;
1018
1142
  }
1019
1143
  const delta = computeDelta(oldState, newState);
@@ -1028,14 +1152,19 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1028
1152
 
1029
1153
  setServerState: (updater: (s: Record<string, unknown>) => Record<string, unknown>): void => {
1030
1154
  const oldState = this.serverState;
1155
+ const prevDirty = this.dirty;
1156
+ const prevStateSaveAt = this._stateSaveAt;
1031
1157
  this.serverState = updater(cloneState(this.serverState));
1032
- this.dirty = true;
1158
+ this.markDirty();
1033
1159
  try {
1034
1160
  this.checkStateSizeLimit();
1035
1161
  } catch (err) {
1036
1162
  // Revert mutation
1037
1163
  this.serverState = oldState;
1038
- this.dirty = false;
1164
+ this.dirty = prevDirty;
1165
+ this._stateSaveAt = prevStateSaveAt;
1166
+ this.syncEphemeralTimersToStorage();
1167
+ this._scheduleNextAlarm();
1039
1168
  throw err;
1040
1169
  }
1041
1170
  // No broadcast — server-only state
@@ -1075,7 +1204,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1075
1204
  throw new Error(`No onTimer handler for '${name}'`);
1076
1205
  }
1077
1206
  this._timers.set(name, { fireAt: Date.now() + ms, data });
1078
- this.dirty = true;
1207
+ this.markDirty();
1079
1208
  this._scheduleNextAlarm();
1080
1209
  },
1081
1210
 
@@ -1097,7 +1226,11 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1097
1226
 
1098
1227
  // ─── Handler Context Builder ───
1099
1228
 
1100
- protected buildHandlerContext(): RoomHandlerContext {
1229
+ protected async buildHandlerContext(): Promise<RoomHandlerContext> {
1230
+ const [{ buildFunctionContext }, { resolveRootServiceKey }] = await Promise.all([
1231
+ import('../lib/functions.js'),
1232
+ import('../lib/service-key.js'),
1233
+ ]);
1101
1234
  const ctx = buildFunctionContext({
1102
1235
  request: new Request('http://internal/room/action'),
1103
1236
  auth: null,
@@ -1138,21 +1271,18 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1138
1271
 
1139
1272
  // ─── State Persistence (DO Storage) ───
1140
1273
 
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;
1274
+ private markDirty(): void {
1275
+ this.dirty = true;
1276
+ let scheduledNewStateSave = false;
1277
+ if (this._stateSaveAt === null) {
1278
+ const interval = this.namespaceConfig?.stateSaveInterval ?? DEFAULT_STATE_SAVE_INTERVAL_MS;
1279
+ this._stateSaveAt = Date.now() + interval;
1280
+ scheduledNewStateSave = true;
1281
+ }
1282
+ if (scheduledNewStateSave) {
1283
+ this.syncEphemeralTimersToStorage();
1155
1284
  }
1285
+ this._scheduleNextAlarm();
1156
1286
  }
1157
1287
 
1158
1288
  private async persistState(): Promise<void> {
@@ -1171,9 +1301,11 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1171
1301
  await this.ctx.storage.delete('roomTimers');
1172
1302
  }
1173
1303
  this.dirty = false;
1304
+ this._stateSaveAt = null;
1174
1305
  // Set TTL alarm as safety net for orphaned storage cleanup
1175
1306
  const ttl = this.namespaceConfig?.stateTTL ?? DEFAULT_STATE_TTL_MS;
1176
1307
  this._stateTTLAlarmAt = Date.now() + ttl;
1308
+ this.syncEphemeralTimersToStorage();
1177
1309
  this._scheduleNextAlarm();
1178
1310
  }
1179
1311
 
@@ -1202,7 +1334,6 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1202
1334
  const savedTimers = await this.ctx.storage.get('roomTimers') as Record<string, { fireAt: number; data?: unknown }> | undefined;
1203
1335
  if (savedTimers) {
1204
1336
  this._timers = new Map(Object.entries(savedTimers));
1205
- this._scheduleNextAlarm();
1206
1337
  }
1207
1338
 
1208
1339
  // Recover metadata
@@ -1211,34 +1342,57 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1211
1342
  this._metadata = savedMeta;
1212
1343
  }
1213
1344
 
1214
- this.startSaveTimer();
1345
+ const savedEphemeral = await this.ctx.storage.get(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY) as PersistedRoomEphemeralTimers | undefined;
1346
+ if (savedEphemeral?.pendingAuth) {
1347
+ this.pendingAuth = new Map(
1348
+ Object.entries(savedEphemeral.pendingAuth)
1349
+ .map(([connectionId, fireAt]) => [connectionId, Number(fireAt)]),
1350
+ );
1351
+ } else {
1352
+ this.pendingAuth.clear();
1353
+ }
1354
+ if (savedEphemeral?.disconnects) {
1355
+ this.disconnectTimers = new Map(
1356
+ Object.entries(savedEphemeral.disconnects)
1357
+ .map(([userId, timer]) => [userId, { fireAt: Number(timer.fireAt), connectionId: timer.connectionId }]),
1358
+ );
1359
+ } else {
1360
+ this.disconnectTimers.clear();
1361
+ }
1362
+ this._stateSaveAt = typeof savedEphemeral?.stateSaveAt === 'number'
1363
+ ? savedEphemeral.stateSaveAt
1364
+ : null;
1365
+ this._emptyRoomCleanupAt = typeof savedEphemeral?.emptyRoomCleanupAt === 'number'
1366
+ ? savedEphemeral.emptyRoomCleanupAt
1367
+ : null;
1368
+ this._stateTTLAlarmAt = typeof savedEphemeral?.stateTTLAlarmAt === 'number'
1369
+ ? savedEphemeral.stateTTLAlarmAt
1370
+ : null;
1371
+
1372
+ this._scheduleNextAlarm();
1215
1373
  }
1216
1374
 
1217
1375
  // ─── Delta Broadcasting ───
1218
1376
 
1219
- /** Queue shared state delta (batched, broadcast to all) */
1377
+ /** Queue shared state delta and flush it at the end of the current turn. */
1220
1378
  private queueSharedDelta(delta: Record<string, unknown>): void {
1221
1379
  if (!this.pendingSharedDelta) {
1222
1380
  this.pendingSharedDelta = {};
1223
1381
  }
1224
1382
  Object.assign(this.pendingSharedDelta, delta);
1225
1383
 
1226
- if (!this.sharedDeltaBatchTimer) {
1227
- const batchMs = DEFAULT_DELTA_BATCH_MS;
1228
- this.sharedDeltaBatchTimer = setTimeout(() => {
1384
+ if (!this.sharedDeltaFlushQueued) {
1385
+ this.sharedDeltaFlushQueued = true;
1386
+ queueMicrotask(() => {
1387
+ this.sharedDeltaFlushQueued = false;
1229
1388
  this.flushSharedDelta();
1230
- }, batchMs);
1389
+ });
1231
1390
  }
1232
1391
  }
1233
1392
 
1234
1393
  private flushSharedDelta(): void {
1235
1394
  if (!this.pendingSharedDelta) return;
1236
1395
 
1237
- // Cancel batch timer if still pending
1238
- if (this.sharedDeltaBatchTimer) {
1239
- clearTimeout(this.sharedDeltaBatchTimer);
1240
- }
1241
-
1242
1396
  this.broadcastToAuthenticated({
1243
1397
  type: 'shared_delta',
1244
1398
  delta: this.pendingSharedDelta,
@@ -1246,7 +1400,6 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1246
1400
  });
1247
1401
 
1248
1402
  this.pendingSharedDelta = null;
1249
- this.sharedDeltaBatchTimer = null;
1250
1403
  }
1251
1404
 
1252
1405
  /** Send player state delta directly (unicast, no batching) */
@@ -1305,10 +1458,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1305
1458
  }
1306
1459
  }
1307
1460
  // Cancel any existing reconnect timer
1308
- const existingTimer = this.disconnectTimers.get(userId);
1309
- if (existingTimer) {
1310
- clearTimeout(existingTimer);
1311
- this.disconnectTimers.delete(userId);
1461
+ if (this.disconnectTimers.delete(userId)) {
1462
+ this.syncEphemeralTimersToStorage();
1463
+ this._scheduleNextAlarm();
1312
1464
  }
1313
1465
 
1314
1466
  // Fire onLeave with 'kicked' reason
@@ -1366,17 +1518,15 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1366
1518
  if (kicked) {
1367
1519
  // Kicked — immediate leave, no reconnect timer
1368
1520
  // 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);
1521
+ if (this.disconnectTimers.delete(player.userId)) {
1522
+ this.syncEphemeralTimersToStorage();
1523
+ this._scheduleNextAlarm();
1373
1524
  }
1374
1525
  await this.finalizePlayerLeave(player.userId, meta.connectionId, 'kicked');
1375
1526
  } else if (explicitLeave) {
1376
- const existing = this.disconnectTimers.get(player.userId);
1377
- if (existing) {
1378
- clearTimeout(existing);
1379
- this.disconnectTimers.delete(player.userId);
1527
+ if (this.disconnectTimers.delete(player.userId)) {
1528
+ this.syncEphemeralTimersToStorage();
1529
+ this._scheduleNextAlarm();
1380
1530
  }
1381
1531
  await this.finalizePlayerLeave(player.userId, meta.connectionId, 'leave');
1382
1532
  } else {
@@ -1384,11 +1534,12 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1384
1534
  const reconnectTimeout = this.namespaceConfig?.reconnectTimeout ?? DEFAULT_RECONNECT_TIMEOUT_MS;
1385
1535
 
1386
1536
  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);
1537
+ this.disconnectTimers.set(player.userId, {
1538
+ fireAt: Date.now() + reconnectTimeout,
1539
+ connectionId: meta.connectionId,
1540
+ });
1541
+ this.syncEphemeralTimersToStorage();
1542
+ this._scheduleNextAlarm();
1392
1543
  } else {
1393
1544
  // Immediate leave
1394
1545
  await this.finalizePlayerLeave(player.userId, meta.connectionId, 'disconnect');
@@ -1410,7 +1561,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1410
1561
  try {
1411
1562
  const sender: RoomSender = { userId, connectionId };
1412
1563
  const roomApi = this.buildRoomServerAPI();
1413
- const ctx = this.buildHandlerContext();
1564
+ const ctx = await this.buildHandlerContext();
1414
1565
  await onLeave(sender, roomApi, ctx, reason);
1415
1566
  } catch (err) {
1416
1567
  console.error(`[Room] onLeave error: ${err instanceof Error ? err.message : String(err)}`);
@@ -1434,7 +1585,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1434
1585
  if (onDestroy) {
1435
1586
  try {
1436
1587
  const roomApi = this.buildRoomServerAPI();
1437
- const ctx = this.buildHandlerContext();
1588
+ const ctx = await this.buildHandlerContext();
1438
1589
  await onDestroy(roomApi, ctx);
1439
1590
  } catch (err) {
1440
1591
  console.error(`[Room] onDestroy error: ${err instanceof Error ? err.message : String(err)}`);
@@ -1442,16 +1593,67 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1442
1593
  }
1443
1594
 
1444
1595
  // Clean up state persistence
1445
- this.stopSaveTimer();
1596
+ this.dirty = false;
1597
+ this._stateSaveAt = null;
1446
1598
  this._timers.clear();
1599
+ this.pendingAuth.clear();
1600
+ this.disconnectTimers.clear();
1447
1601
  this._metadata = {};
1448
1602
  await this.ctx.storage.delete('roomState');
1449
1603
  await this.ctx.storage.delete('roomTimers');
1450
1604
  await this.ctx.storage.delete('roomMetadata');
1605
+ await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
1451
1606
 
1452
1607
  this.scheduleEmptyRoomCleanup();
1453
1608
  }
1454
1609
 
1610
+ private syncEphemeralTimersToStorage(): void {
1611
+ const pendingAuth = Object.fromEntries(this.pendingAuth);
1612
+ const disconnects = Object.fromEntries(this.disconnectTimers);
1613
+ const stateSaveAt = this._stateSaveAt;
1614
+ const emptyRoomCleanupAt = this._emptyRoomCleanupAt;
1615
+ const stateTTLAlarmAt = this._stateTTLAlarmAt;
1616
+ this.ctx.waitUntil((async () => {
1617
+ try {
1618
+ if (
1619
+ Object.keys(pendingAuth).length === 0
1620
+ && Object.keys(disconnects).length === 0
1621
+ && stateSaveAt === null
1622
+ && emptyRoomCleanupAt === null
1623
+ && stateTTLAlarmAt === null
1624
+ ) {
1625
+ await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
1626
+ return;
1627
+ }
1628
+
1629
+ await this.ctx.storage.put(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY, {
1630
+ pendingAuth,
1631
+ disconnects,
1632
+ stateSaveAt,
1633
+ emptyRoomCleanupAt,
1634
+ stateTTLAlarmAt,
1635
+ } satisfies PersistedRoomEphemeralTimers);
1636
+ } catch (error) {
1637
+ console.warn('[Room] Ephemeral timer persistence skipped', {
1638
+ room: this.namespace && this.roomId ? `${this.namespace}::${this.roomId}` : null,
1639
+ pendingAuthCount: this.pendingAuth.size,
1640
+ disconnectCount: this.disconnectTimers.size,
1641
+ message: error instanceof Error ? error.message : String(error),
1642
+ });
1643
+ }
1644
+ })());
1645
+ }
1646
+
1647
+ private findWebSocketByConnectionId(connectionId: string): WebSocket | null {
1648
+ for (const ws of this.ctx.getWebSockets()) {
1649
+ const meta = this.getWSMeta(ws);
1650
+ if (meta?.connectionId === connectionId) {
1651
+ return ws;
1652
+ }
1653
+ }
1654
+ return null;
1655
+ }
1656
+
1455
1657
  private getPlayersArray(): Array<{ userId: string; connectionId: string }> {
1456
1658
  return Array.from(this.players.values()).map(p => ({
1457
1659
  userId: p.userId,
@@ -1522,14 +1724,29 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1522
1724
 
1523
1725
  // ─── Rate Limiting (Token Bucket) ───
1524
1726
 
1525
- protected checkRateLimit(connectionId: string): boolean {
1727
+ protected checkRateLimit(
1728
+ connectionId: string,
1729
+ scope: RoomRateLimitScope = 'actions',
1730
+ ): boolean {
1526
1731
  const now = Date.now();
1527
- const maxActions = this.namespaceConfig?.rateLimit?.actions ?? DEFAULT_RATE_LIMIT_ACTIONS;
1528
- let bucket = this.rateBuckets.get(connectionId);
1732
+ const rateLimit = this.namespaceConfig?.rateLimit as
1733
+ | { actions: number; signals?: number; media?: number; admin?: number }
1734
+ | undefined;
1735
+ const maxActions = (
1736
+ scope === 'signals'
1737
+ ? rateLimit?.signals
1738
+ : scope === 'media'
1739
+ ? rateLimit?.media
1740
+ : scope === 'admin'
1741
+ ? rateLimit?.admin
1742
+ : undefined
1743
+ ) ?? rateLimit?.actions ?? DEFAULT_RATE_LIMIT_ACTIONS;
1744
+ const bucketKey = `${connectionId}:${scope}`;
1745
+ let bucket = this.rateBuckets.get(bucketKey);
1529
1746
 
1530
1747
  if (!bucket) {
1531
1748
  bucket = { tokens: maxActions, lastRefill: now };
1532
- this.rateBuckets.set(connectionId, bucket);
1749
+ this.rateBuckets.set(bucketKey, bucket);
1533
1750
  }
1534
1751
 
1535
1752
  // Refill tokens (1 token per 1000/maxActions ms)
@@ -1549,6 +1766,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1549
1766
 
1550
1767
  private scheduleEmptyRoomCleanup(): void {
1551
1768
  this._emptyRoomCleanupAt = Date.now() + EMPTY_ROOM_CLEANUP_DELAY_MS;
1769
+ this.syncEphemeralTimersToStorage();
1552
1770
  this._scheduleNextAlarm();
1553
1771
  }
1554
1772
 
@@ -1625,4 +1843,44 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1625
1843
  private parseConfig(env: RoomDOEnv): EdgeBaseConfig {
1626
1844
  return getGlobalConfig(env);
1627
1845
  }
1846
+
1847
+ private async ensureRuntimeReady(): Promise<void> {
1848
+ if (!this.runtimeReadyPromise) {
1849
+ this.runtimeReadyPromise = (async () => {
1850
+ await ensureServerStartup();
1851
+ this.config = this.parseConfig(this.env);
1852
+ if (this.namespace) {
1853
+ this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
1854
+ }
1855
+ })();
1856
+ }
1857
+
1858
+ await this.runtimeReadyPromise;
1859
+ }
1860
+
1861
+ private shouldRecoverBeforeAlarm(): boolean {
1862
+ if (this.stateRecoveryNeeded) {
1863
+ return true;
1864
+ }
1865
+
1866
+ if (this.ctx.getWebSockets().length > 0) {
1867
+ return false;
1868
+ }
1869
+
1870
+ return (
1871
+ !this.roomCreated
1872
+ && Object.keys(this.sharedState).length === 0
1873
+ && this.playerStates.size === 0
1874
+ && Object.keys(this.serverState).length === 0
1875
+ && this.players.size === 0
1876
+ && this.userToConnections.size === 0
1877
+ && this.pendingAuth.size === 0
1878
+ && this.disconnectTimers.size === 0
1879
+ && this._timers.size === 0
1880
+ && this._stateSaveAt === null
1881
+ && this._emptyRoomCleanupAt === null
1882
+ && this._stateTTLAlarmAt === null
1883
+ && Object.keys(this._metadata).length === 0
1884
+ );
1885
+ }
1628
1886
  }