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