@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.
- package/admin-build/_app/immutable/chunks/{DpVAayDG.js → 6oMK_164.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B5Nwfelm.js → B2TnDKF7.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DCvwWZrm.js → B6MschND.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Du5vWVa2.js → B94PilAN.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dc1-6Po6.js → BEW7Ez_g.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dlty5069.js → BoOooyH6.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CzSAxmuj.js → BqTb6Mxk.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DCKcAiQH.js → BvHnF5tV.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B-_-hJ9o.js → CaVKAiCe.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DRqPU3wD.js → Cdm5zBRA.js} +1 -1
- package/admin-build/_app/immutable/chunks/{byv2rTy8.js → CrOZMmdF.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DiyBpamp.js → Cw6OYcq-.js} +1 -1
- package/admin-build/_app/immutable/chunks/{A_3UuvCe.js → D2j3I1VQ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BxoNtYHK.js → DPdQ7z0T.js} +3 -3
- package/admin-build/_app/immutable/chunks/{nZvorU8i.js → J2Gw0SMu.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CZ0TVkCa.js → pUxw8jfq.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.CfrmEXPD.js → app.D3flihMw.js} +2 -2
- package/admin-build/_app/immutable/entry/start.Cl6sLxnz.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.Cn2BZ4da.js → 0.CdczqZLK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.Dv4LX_Co.js → 1.DxcSsEqS.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.DPVv3kat.js → 10.DuAd4aIm.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.CiCb6Ayu.js → 11.0jgHQL92.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.CIPyeekF.js → 12.CKNPqmyy.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.Z15Lt36e.js → 13.B1p2POXS.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.s0l5bAq3.js → 14.Bb-REBND.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.UwSSNO76.js → 15.1uBFCX0X.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.qiD8i883.js → 16.BR7WwQrS.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.Dy3dcSvu.js → 17.Cm57KKXV.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.DeXyPYsO.js → 18.CoiwfAuQ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.CAbuyS6w.js → 19.B8ZdLlXj.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.Bec0T7un.js → 20.DnHeFlTv.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.CJFaf0Ia.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.CdVprrv2.js → 22.CItETFzy.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.Y8RzVLoF.js → 23.CWSGMcKJ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.CWhHYFBx.js → 24.CWbEqNMB.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.wCBplOVt.js → 25.DRkLEhKi.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.Cod_JRFK.js → 26.BRxO8AYH.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.BO2HVMu9.js → 27.BLs-nVHz.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.DxG-FBVQ.js → 28.G79qkdBK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.CjGqWGvE.js → 29.BOcI6g0N.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.By3_OmdZ.js → 3.B6q-7qr8.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.M_H7Htpq.js → 30.DAIC7dKd.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.DEU18izM.js → 31.pl0XXjXF.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.DeYhKtzJ.js → 4.DOdvVlZj.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.9WLgxhrD.js → 5.BW_zlgye.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.BdT2i_dd.js → 6.Dxy1CAI2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.CHq0s4K6.js → 7.BG98w_o7.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.DuvRw-XZ.js → 8.DoG5R2rG.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.C2Ub82wn.js → 9.Dmxf6zAC.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/package.json +3 -3
- package/src/__tests__/admin-data-routes.test.ts +29 -0
- package/src/__tests__/database-do-route-validation.test.ts +108 -0
- package/src/__tests__/database-live-route.test.ts +82 -0
- package/src/__tests__/do-router.test.ts +116 -0
- package/src/__tests__/functions-context.test.ts +84 -0
- package/src/__tests__/functions-d1-proxy.test.ts +54 -0
- package/src/__tests__/meta-route-registration.test.ts +20 -15
- package/src/__tests__/plugin-migration-routing.test.ts +32 -0
- package/src/__tests__/provider-aware-sql.test.ts +9 -3
- package/src/__tests__/room-auth-state-loss.test.ts +122 -0
- package/src/__tests__/room-handler-context.test.ts +4 -4
- package/src/__tests__/room-rate-limit-scopes.test.ts +38 -0
- package/src/__tests__/runtime-startup.test.ts +49 -0
- package/src/__tests__/scheduled.test.ts +55 -0
- package/src/__tests__/service-key-db-proxy.test.ts +122 -1
- package/src/__tests__/sql-route.test.ts +66 -0
- package/src/__tests__/table-hook-runtime.test.ts +137 -0
- package/src/durable-objects/database-do.ts +50 -45
- package/src/durable-objects/database-live-do.ts +15 -0
- package/src/durable-objects/room-runtime-base.ts +387 -129
- package/src/durable-objects/rooms-do.ts +31 -24
- package/src/index.ts +334 -282
- package/src/lib/d1-handler.ts +10 -21
- package/src/lib/do-router.ts +135 -3
- package/src/lib/functions.ts +4 -3
- package/src/lib/internal-transport.ts +28 -12
- package/src/lib/plugin-migration-routing.ts +28 -0
- package/src/lib/postgres-handler.ts +12 -20
- package/src/lib/provider-aware-sql.ts +19 -15
- package/src/lib/runtime-startup.ts +53 -0
- package/src/lib/table-hook-runtime.ts +62 -0
- package/src/routes/admin.ts +41 -41
- package/src/routes/database-live.ts +110 -12
- package/src/routes/sql.ts +22 -17
- package/src/routes/tables.ts +42 -29
- package/admin-build/_app/immutable/entry/start.l1WvHznQ.js +0 -1
- 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 {
|
|
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
|
|
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 (
|
|
182
|
+
// ─── State persistence (alarm-based, hibernation-friendly) ───
|
|
148
183
|
private dirty = false;
|
|
149
|
-
private
|
|
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,
|
|
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
|
|
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,
|
|
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 =
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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.
|
|
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 =
|
|
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
|
|
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 =
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
this.
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
}
|
|
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.
|
|
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
|
|
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.
|
|
1227
|
-
|
|
1228
|
-
|
|
1384
|
+
if (!this.sharedDeltaFlushQueued) {
|
|
1385
|
+
this.sharedDeltaFlushQueued = true;
|
|
1386
|
+
queueMicrotask(() => {
|
|
1387
|
+
this.sharedDeltaFlushQueued = false;
|
|
1229
1388
|
this.flushSharedDelta();
|
|
1230
|
-
}
|
|
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
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
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
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
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
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
}
|
|
1391
|
-
this.
|
|
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.
|
|
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(
|
|
1727
|
+
protected checkRateLimit(
|
|
1728
|
+
connectionId: string,
|
|
1729
|
+
scope: RoomRateLimitScope = 'actions',
|
|
1730
|
+
): boolean {
|
|
1526
1731
|
const now = Date.now();
|
|
1527
|
-
const
|
|
1528
|
-
|
|
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(
|
|
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
|
}
|