@edge-base/server 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-build/_app/immutable/chunks/{DILS_-VJ.js → B3CvhH3c.js} +1 -1
- package/admin-build/_app/immutable/chunks/BDYewzou.js +1 -0
- package/admin-build/_app/immutable/chunks/{Cdm5zBRA.js → BEM1BeVF.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dt4vL4Df.js → BYL_uBga.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B94PilAN.js → BYyykAbh.js} +1 -1
- package/admin-build/_app/immutable/chunks/BaUG2TJ-.js +1 -0
- package/admin-build/_app/immutable/chunks/{C72lTcG0.js → Bcs4KYNp.js} +1 -1
- package/admin-build/_app/immutable/chunks/{D2j3I1VQ.js → BfpUQYr3.js} +1 -1
- package/admin-build/_app/immutable/chunks/BhCO1Fpt.js +1 -0
- package/admin-build/_app/immutable/chunks/{B8s_s9QY.js → BkZCgsc3.js} +1 -1
- package/admin-build/_app/immutable/chunks/CIOC1v_q.js +128 -0
- package/admin-build/_app/immutable/chunks/CjcrXziO.js +2 -0
- package/admin-build/_app/immutable/chunks/CvczjTXx.js +1 -0
- package/admin-build/_app/immutable/chunks/D1u3u7xu.js +1 -0
- package/admin-build/_app/immutable/chunks/{B0HRJ657.js → DOOPbWwG.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BqTb6Mxk.js → DaXO-sFP.js} +1 -1
- package/admin-build/_app/immutable/chunks/DnpbvAPi.js +1 -0
- package/admin-build/_app/immutable/chunks/{B6MschND.js → Dz9cUCuv.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CaVKAiCe.js → Tea2dBJ8.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Z41NK6i6.js → bguI1TeA.js} +1 -1
- package/admin-build/_app/immutable/chunks/{J2Gw0SMu.js → ejoEf2I5.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B2TnDKF7.js → iEyeblJR.js} +1 -1
- package/admin-build/_app/immutable/chunks/{_teD5ji5.js → nlAMTi52.js} +1 -1
- package/admin-build/_app/immutable/chunks/qKdzaeX3.js +1 -0
- package/admin-build/_app/immutable/entry/{app.D3flihMw.js → app.DoUaxnew.js} +2 -2
- package/admin-build/_app/immutable/entry/start.MmZh8oBH.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.CdczqZLK.js → 0.Dsxi8s7i.js} +1 -1
- package/admin-build/_app/immutable/nodes/1.Cp2l-hol.js +1 -0
- package/admin-build/_app/immutable/nodes/10.4oY6m8Nz.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DfcozD4J.js +1 -0
- package/admin-build/_app/immutable/nodes/12.uJgZdCIA.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CaN1kRev.js +110 -0
- package/admin-build/_app/immutable/nodes/14.DQ5xIi3s.js +3 -0
- package/admin-build/_app/immutable/nodes/15.B_EkebTJ.js +1 -0
- package/admin-build/_app/immutable/nodes/{16.BR7WwQrS.js → 16.Tko1ZX8-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.Cm57KKXV.js → 17.BCmWMJX9.js} +1 -1
- package/admin-build/_app/immutable/nodes/18.hmGhl1O2.js +1 -0
- package/admin-build/_app/immutable/nodes/19.D-1infOo.js +2 -0
- package/admin-build/_app/immutable/nodes/{20.DnHeFlTv.js → 20.CY4KKcBL.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.B9lbNUQr.js +1 -0
- package/admin-build/_app/immutable/nodes/22.14Vd7bnt.js +1 -0
- package/admin-build/_app/immutable/nodes/{23.CWSGMcKJ.js → 23.Be6jK77o.js} +2 -2
- package/admin-build/_app/immutable/nodes/24.CSTFkr6R.js +2 -0
- package/admin-build/_app/immutable/nodes/25.DRTg8fHc.js +2 -0
- package/admin-build/_app/immutable/nodes/26.DKt-9lwQ.js +1 -0
- package/admin-build/_app/immutable/nodes/27.D5caPu0F.js +1 -0
- package/admin-build/_app/immutable/nodes/28.hJhlnlyY.js +1 -0
- package/admin-build/_app/immutable/nodes/29.CDYBzFyT.js +1 -0
- package/admin-build/_app/immutable/nodes/{3.B6q-7qr8.js → 3.DMyKwkGn.js} +1 -1
- package/admin-build/_app/immutable/nodes/30.BaHNeEmc.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C6PV5L-2.js +1 -0
- package/admin-build/_app/immutable/nodes/4.9E118Ftm.js +1 -0
- package/admin-build/_app/immutable/nodes/5.D8guAl3v.js +1 -0
- package/admin-build/_app/immutable/nodes/6.D1u__DtT.js +1 -0
- package/admin-build/_app/immutable/nodes/7.DWXHnRFf.js +1 -0
- package/admin-build/_app/immutable/nodes/8.Dojd8krc.js +1 -0
- package/admin-build/_app/immutable/nodes/9.CLtrr0K_.js +1 -0
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/openapi.json +6 -1941
- package/package.json +3 -3
- package/src/__tests__/openapi-coverage.test.ts +0 -6
- package/src/__tests__/push-handlers.test.ts +1 -1
- package/src/__tests__/room-auth-state-loss.test.ts +6 -0
- package/src/__tests__/room-handler-context.test.ts +0 -31
- package/src/__tests__/room-rate-limit-scopes.test.ts +1 -5
- package/src/__tests__/room-runtime-routing.test.ts +24 -111
- package/src/__tests__/route-parser.test.ts +6 -0
- package/src/__tests__/schema.test.ts +15 -6
- package/src/__tests__/smoke-skip-report.test.ts +1 -1
- package/src/durable-objects/database-do.ts +7 -1
- package/src/durable-objects/room-runtime-base.ts +290 -57
- package/src/durable-objects/rooms-do.ts +212 -1336
- package/src/index.ts +23 -9
- package/src/lib/d1-handler.ts +32 -17
- package/src/lib/openapi.ts +1 -4
- package/src/lib/postgres-handler.ts +24 -12
- package/src/lib/route-parser.ts +3 -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 +203 -280
- 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/src/types.ts +1 -14
- package/admin-build/_app/immutable/chunks/6oMK_164.js +0 -1
- package/admin-build/_app/immutable/chunks/BEW7Ez_g.js +0 -1
- package/admin-build/_app/immutable/chunks/BoOooyH6.js +0 -1
- package/admin-build/_app/immutable/chunks/BvHnF5tV.js +0 -1
- package/admin-build/_app/immutable/chunks/CoI6jjbg.js +0 -2
- package/admin-build/_app/immutable/chunks/CrOZMmdF.js +0 -1
- package/admin-build/_app/immutable/chunks/Cw6OYcq-.js +0 -1
- package/admin-build/_app/immutable/chunks/DPdQ7z0T.js +0 -128
- package/admin-build/_app/immutable/chunks/pUxw8jfq.js +0 -1
- package/admin-build/_app/immutable/entry/start.Cl6sLxnz.js +0 -1
- package/admin-build/_app/immutable/nodes/1.DxcSsEqS.js +0 -1
- package/admin-build/_app/immutable/nodes/10.DuAd4aIm.js +0 -1
- package/admin-build/_app/immutable/nodes/11.0jgHQL92.js +0 -1
- package/admin-build/_app/immutable/nodes/12.CKNPqmyy.js +0 -1
- package/admin-build/_app/immutable/nodes/13.B1p2POXS.js +0 -110
- package/admin-build/_app/immutable/nodes/14.Bb-REBND.js +0 -3
- package/admin-build/_app/immutable/nodes/15.1uBFCX0X.js +0 -1
- package/admin-build/_app/immutable/nodes/18.CoiwfAuQ.js +0 -1
- package/admin-build/_app/immutable/nodes/19.B8ZdLlXj.js +0 -2
- package/admin-build/_app/immutable/nodes/21.CJFaf0Ia.js +0 -1
- package/admin-build/_app/immutable/nodes/22.CItETFzy.js +0 -1
- package/admin-build/_app/immutable/nodes/24.CWbEqNMB.js +0 -2
- package/admin-build/_app/immutable/nodes/25.DRkLEhKi.js +0 -2
- package/admin-build/_app/immutable/nodes/26.BRxO8AYH.js +0 -1
- package/admin-build/_app/immutable/nodes/27.BLs-nVHz.js +0 -1
- package/admin-build/_app/immutable/nodes/28.G79qkdBK.js +0 -1
- package/admin-build/_app/immutable/nodes/29.BOcI6g0N.js +0 -1
- package/admin-build/_app/immutable/nodes/30.DAIC7dKd.js +0 -1
- package/admin-build/_app/immutable/nodes/31.pl0XXjXF.js +0 -1
- package/admin-build/_app/immutable/nodes/4.DOdvVlZj.js +0 -1
- package/admin-build/_app/immutable/nodes/5.BW_zlgye.js +0 -1
- package/admin-build/_app/immutable/nodes/6.Dxy1CAI2.js +0 -1
- package/admin-build/_app/immutable/nodes/7.BG98w_o7.js +0 -1
- package/admin-build/_app/immutable/nodes/8.DoG5R2rG.js +0 -1
- package/admin-build/_app/immutable/nodes/9.Dmxf6zAC.js +0 -1
- package/src/__tests__/cloudflare-realtime.test.ts +0 -113
- package/src/lib/cloudflare-realtime.ts +0 -251
|
@@ -49,8 +49,20 @@ export interface RoomWSMeta {
|
|
|
49
49
|
ip?: string;
|
|
50
50
|
userAgent?: string;
|
|
51
51
|
connectionId: string;
|
|
52
|
+
lastSeenAt?: number;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
interface PersistedRoomWSAttachment {
|
|
56
|
+
version: 1;
|
|
57
|
+
meta: RoomWSMeta;
|
|
58
|
+
extra?: unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type HibernatingWebSocket = WebSocket & {
|
|
62
|
+
serializeAttachment?: (value: unknown) => void;
|
|
63
|
+
deserializeAttachment?: () => unknown;
|
|
64
|
+
};
|
|
65
|
+
|
|
54
66
|
interface PlayerInfo {
|
|
55
67
|
userId: string;
|
|
56
68
|
connectionId: string;
|
|
@@ -72,9 +84,11 @@ const ACTION_TIMEOUT_MS = 5000;
|
|
|
72
84
|
const DEFAULT_ROOM_AUTH_TIMEOUT_MS = 5000;
|
|
73
85
|
const DEFAULT_STATE_SAVE_INTERVAL_MS = 60000; // 1 minute
|
|
74
86
|
const DEFAULT_STATE_TTL_MS = 86400000; // 24 hours
|
|
87
|
+
const DEFAULT_SOCKET_STALE_TIMEOUT_MS = 45000;
|
|
88
|
+
const SOCKET_HEARTBEAT_CHECK_INTERVAL_MS = 5000;
|
|
75
89
|
const ROOM_EPHEMERAL_TIMERS_STORAGE_KEY = 'roomEphemeralTimers';
|
|
76
90
|
const roomFallbackWarnings = new Set<string>();
|
|
77
|
-
type RoomRateLimitScope = 'actions' | 'signals' | '
|
|
91
|
+
type RoomRateLimitScope = 'actions' | 'signals' | 'admin';
|
|
78
92
|
|
|
79
93
|
interface PendingDisconnectDeadline {
|
|
80
94
|
fireAt: number;
|
|
@@ -87,6 +101,7 @@ interface PersistedRoomEphemeralTimers {
|
|
|
87
101
|
stateSaveAt?: number | null;
|
|
88
102
|
emptyRoomCleanupAt?: number | null;
|
|
89
103
|
stateTTLAlarmAt?: number | null;
|
|
104
|
+
socketHeartbeatCheckAt?: number | null;
|
|
90
105
|
}
|
|
91
106
|
|
|
92
107
|
function isRoomOperationPublic(
|
|
@@ -114,6 +129,22 @@ function getRoomTimerHandlers(namespaceConfig?: RoomNamespaceConfig | null) {
|
|
|
114
129
|
return namespaceConfig?.state?.timers ?? namespaceConfig?.handlers?.timers;
|
|
115
130
|
}
|
|
116
131
|
|
|
132
|
+
function warnRoomDevelopmentFallback(
|
|
133
|
+
namespace: string,
|
|
134
|
+
operation: 'join' | 'action',
|
|
135
|
+
): void {
|
|
136
|
+
const warningKey = `${namespace}:${operation}`;
|
|
137
|
+
if (roomFallbackWarnings.has(warningKey)) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
roomFallbackWarnings.add(warningKey);
|
|
141
|
+
console.warn(
|
|
142
|
+
`[Room] ${warningKey} is allowed because release=false and no explicit room rule was found. `
|
|
143
|
+
+ `This fallback is local-dev only. Add rooms.${namespace}.access.${operation} or set `
|
|
144
|
+
+ `rooms.${namespace}.public.${operation}=true to make the behavior explicit.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
117
148
|
function resolveRoomAuthTimeoutMs(config?: EdgeBaseConfig): number {
|
|
118
149
|
const value = config?.databaseLive?.authTimeoutMs;
|
|
119
150
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
@@ -154,6 +185,34 @@ function cloneState(obj: Record<string, unknown>): Record<string, unknown> {
|
|
|
154
185
|
return JSON.parse(JSON.stringify(obj));
|
|
155
186
|
}
|
|
156
187
|
|
|
188
|
+
function cloneRoomWSMeta(meta: RoomWSMeta): RoomWSMeta {
|
|
189
|
+
return {
|
|
190
|
+
authenticated: meta.authenticated,
|
|
191
|
+
authStateLost: meta.authStateLost === true,
|
|
192
|
+
userId: meta.userId,
|
|
193
|
+
role: meta.role,
|
|
194
|
+
auth: meta.auth
|
|
195
|
+
? {
|
|
196
|
+
...meta.auth,
|
|
197
|
+
custom: meta.auth.custom ? cloneState(meta.auth.custom as Record<string, unknown>) : undefined,
|
|
198
|
+
meta: meta.auth.meta ? cloneState(meta.auth.meta as Record<string, unknown>) : undefined,
|
|
199
|
+
}
|
|
200
|
+
: undefined,
|
|
201
|
+
ip: meta.ip,
|
|
202
|
+
userAgent: meta.userAgent,
|
|
203
|
+
connectionId: meta.connectionId,
|
|
204
|
+
lastSeenAt: meta.lastSeenAt,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isPersistedRoomWSAttachment(value: unknown): value is PersistedRoomWSAttachment {
|
|
209
|
+
return typeof value === 'object'
|
|
210
|
+
&& value !== null
|
|
211
|
+
&& (value as PersistedRoomWSAttachment).version === 1
|
|
212
|
+
&& typeof (value as PersistedRoomWSAttachment).meta === 'object'
|
|
213
|
+
&& (value as PersistedRoomWSAttachment).meta !== null;
|
|
214
|
+
}
|
|
215
|
+
|
|
157
216
|
// ─── Shared Room Runtime Base ───
|
|
158
217
|
|
|
159
218
|
export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
@@ -186,6 +245,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
186
245
|
|
|
187
246
|
// ─── WebSocket metadata cache ───
|
|
188
247
|
private _metaCache = new Map<WebSocket, RoomWSMeta>();
|
|
248
|
+
private _attachmentExtraCache = new Map<WebSocket, unknown>();
|
|
189
249
|
|
|
190
250
|
// ─── Auth timeout tracking ───
|
|
191
251
|
private pendingAuth = new Map<string, number>();
|
|
@@ -204,6 +264,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
204
264
|
private _timers = new Map<string, { fireAt: number; data?: unknown }>();
|
|
205
265
|
private _emptyRoomCleanupAt: number | null = null;
|
|
206
266
|
private _stateTTLAlarmAt: number | null = null;
|
|
267
|
+
private _socketHeartbeatCheckAt: number | null = null;
|
|
207
268
|
|
|
208
269
|
// ─── Room Metadata (queryable via HTTP without joining) ───
|
|
209
270
|
private _metadata: Record<string, unknown> = {};
|
|
@@ -307,30 +368,39 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
307
368
|
this.ctx.waitUntil(persistRoomMonitoringSnapshot(this.env.KV, snapshotToPersist));
|
|
308
369
|
}
|
|
309
370
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
private async handleGetMetadata(url: URL): Promise<Response> {
|
|
313
|
-
// Resolve namespace if not set (DO may have been cold-started via HTTP)
|
|
371
|
+
protected hydrateRoomIdentityFromUrl(url: URL): void {
|
|
314
372
|
const roomFullName = url.searchParams.get('room');
|
|
315
|
-
if (roomFullName
|
|
316
|
-
|
|
317
|
-
if (separatorIdx >= 0) {
|
|
318
|
-
this.namespace = roomFullName.substring(0, separatorIdx);
|
|
319
|
-
this.roomId = roomFullName.substring(separatorIdx + 2);
|
|
320
|
-
} else {
|
|
321
|
-
this.namespace = roomFullName;
|
|
322
|
-
this.roomId = roomFullName;
|
|
323
|
-
}
|
|
324
|
-
this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
|
|
373
|
+
if (!roomFullName || this.namespace) {
|
|
374
|
+
return;
|
|
325
375
|
}
|
|
326
376
|
|
|
327
|
-
|
|
377
|
+
const separatorIdx = roomFullName.indexOf('::');
|
|
378
|
+
if (separatorIdx >= 0) {
|
|
379
|
+
this.namespace = roomFullName.substring(0, separatorIdx);
|
|
380
|
+
this.roomId = roomFullName.substring(separatorIdx + 2);
|
|
381
|
+
} else {
|
|
382
|
+
this.namespace = roomFullName;
|
|
383
|
+
this.roomId = roomFullName;
|
|
384
|
+
}
|
|
385
|
+
this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
protected async getRoomMetadataSnapshot(): Promise<Record<string, unknown>> {
|
|
328
389
|
if (Object.keys(this._metadata).length === 0) {
|
|
329
390
|
const saved = await this.ctx.storage.get('roomMetadata') as Record<string, unknown> | undefined;
|
|
330
391
|
if (saved) this._metadata = saved;
|
|
331
392
|
}
|
|
332
393
|
|
|
333
|
-
return
|
|
394
|
+
return cloneState(this._metadata);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ─── Metadata HTTP Handler ───
|
|
398
|
+
|
|
399
|
+
private async handleGetMetadata(url: URL): Promise<Response> {
|
|
400
|
+
this.hydrateRoomIdentityFromUrl(url);
|
|
401
|
+
const metadata = await this.getRoomMetadataSnapshot();
|
|
402
|
+
|
|
403
|
+
return new Response(JSON.stringify(metadata), {
|
|
334
404
|
headers: { 'Content-Type': 'application/json' },
|
|
335
405
|
});
|
|
336
406
|
}
|
|
@@ -339,19 +409,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
339
409
|
|
|
340
410
|
private handleWebSocketUpgrade(request: Request): Response {
|
|
341
411
|
const url = new URL(request.url);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (roomFullName) {
|
|
345
|
-
const separatorIdx = roomFullName.indexOf('::');
|
|
346
|
-
if (separatorIdx >= 0) {
|
|
347
|
-
this.namespace = roomFullName.substring(0, separatorIdx);
|
|
348
|
-
this.roomId = roomFullName.substring(separatorIdx + 2);
|
|
349
|
-
} else {
|
|
350
|
-
this.namespace = roomFullName;
|
|
351
|
-
this.roomId = roomFullName;
|
|
352
|
-
}
|
|
353
|
-
this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
|
|
354
|
-
}
|
|
412
|
+
this.hydrateRoomIdentityFromUrl(url);
|
|
355
413
|
|
|
356
414
|
// Check max players
|
|
357
415
|
const maxPlayers = this.namespaceConfig?.maxPlayers ?? DEFAULT_MAX_PLAYERS;
|
|
@@ -371,6 +429,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
371
429
|
authenticated: false,
|
|
372
430
|
authStateLost: false,
|
|
373
431
|
connectionId,
|
|
432
|
+
lastSeenAt: Date.now(),
|
|
374
433
|
ip: request.headers.get('CF-Connecting-IP')
|
|
375
434
|
|| request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim()
|
|
376
435
|
|| undefined,
|
|
@@ -378,16 +437,21 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
378
437
|
};
|
|
379
438
|
|
|
380
439
|
// Accept with Hibernation API
|
|
440
|
+
const roomFullName =
|
|
441
|
+
this.namespace && this.roomId
|
|
442
|
+
? `${this.namespace}::${this.roomId}`
|
|
443
|
+
: url.searchParams.get('room') ?? '';
|
|
381
444
|
const tags = [
|
|
382
445
|
`conn:${connectionId}`,
|
|
383
|
-
`room:${roomFullName
|
|
446
|
+
`room:${roomFullName}`,
|
|
384
447
|
];
|
|
385
448
|
if (meta.ip) {
|
|
386
449
|
tags.push(`ip:${encodeURIComponent(meta.ip)}`);
|
|
387
450
|
}
|
|
388
451
|
this.ctx.acceptWebSocket(server, tags);
|
|
389
|
-
this.
|
|
452
|
+
this.setWSMeta(server, meta);
|
|
390
453
|
this.syncRoomMonitoringSnapshot();
|
|
454
|
+
this.ensureSocketHeartbeatCheckScheduled();
|
|
391
455
|
|
|
392
456
|
// Set auth timeout without pinning the DO with a JS timer.
|
|
393
457
|
const authTimeoutMs = resolveRoomAuthTimeoutMs(this.config);
|
|
@@ -402,6 +466,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
402
466
|
|
|
403
467
|
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
404
468
|
await this.ensureRuntimeReady();
|
|
469
|
+
await this.recoverStateIfNeeded();
|
|
405
470
|
if (typeof message !== 'string') return;
|
|
406
471
|
|
|
407
472
|
let msg: Record<string, unknown>;
|
|
@@ -417,6 +482,8 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
417
482
|
ws.close(4000, 'No metadata');
|
|
418
483
|
return;
|
|
419
484
|
}
|
|
485
|
+
meta.lastSeenAt = Date.now();
|
|
486
|
+
this.setWSMeta(ws, meta);
|
|
420
487
|
|
|
421
488
|
const type = msg.type as string;
|
|
422
489
|
|
|
@@ -470,11 +537,13 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
470
537
|
const explicitLeave = code === ROOM_CLIENT_LEAVE_CLOSE_CODE;
|
|
471
538
|
this.handleDisconnect(meta, kicked, explicitLeave);
|
|
472
539
|
this._metaCache.delete(ws);
|
|
540
|
+
this._attachmentExtraCache.delete(ws);
|
|
473
541
|
if (this.pendingAuth.delete(meta.connectionId)) {
|
|
474
542
|
this.syncEphemeralTimersToStorage();
|
|
475
543
|
this._scheduleNextAlarm();
|
|
476
544
|
}
|
|
477
545
|
}
|
|
546
|
+
this.ensureSocketHeartbeatCheckScheduled();
|
|
478
547
|
this.syncRoomMonitoringSnapshot(ws);
|
|
479
548
|
}
|
|
480
549
|
|
|
@@ -483,11 +552,13 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
483
552
|
if (meta) {
|
|
484
553
|
this.handleDisconnect(meta);
|
|
485
554
|
this._metaCache.delete(ws);
|
|
555
|
+
this._attachmentExtraCache.delete(ws);
|
|
486
556
|
if (this.pendingAuth.delete(meta.connectionId)) {
|
|
487
557
|
this.syncEphemeralTimersToStorage();
|
|
488
558
|
this._scheduleNextAlarm();
|
|
489
559
|
}
|
|
490
560
|
}
|
|
561
|
+
this.ensureSocketHeartbeatCheckScheduled();
|
|
491
562
|
this.syncRoomMonitoringSnapshot(ws);
|
|
492
563
|
}
|
|
493
564
|
|
|
@@ -518,6 +589,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
518
589
|
if (this._stateTTLAlarmAt !== null && this._stateTTLAlarmAt < earliest) {
|
|
519
590
|
earliest = this._stateTTLAlarmAt;
|
|
520
591
|
}
|
|
592
|
+
if (this._socketHeartbeatCheckAt !== null && this._socketHeartbeatCheckAt < earliest) {
|
|
593
|
+
earliest = this._socketHeartbeatCheckAt;
|
|
594
|
+
}
|
|
521
595
|
|
|
522
596
|
if (earliest < Infinity) {
|
|
523
597
|
this.ctx.storage.setAlarm(earliest);
|
|
@@ -526,10 +600,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
526
600
|
|
|
527
601
|
async alarm(): Promise<void> {
|
|
528
602
|
await this.ensureRuntimeReady();
|
|
529
|
-
|
|
530
603
|
if (this.shouldRecoverBeforeAlarm()) {
|
|
531
|
-
|
|
532
|
-
this.
|
|
604
|
+
this.stateRecoveryNeeded = true;
|
|
605
|
+
await this.recoverStateIfNeeded();
|
|
533
606
|
}
|
|
534
607
|
|
|
535
608
|
const now = Date.now();
|
|
@@ -665,7 +738,28 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
665
738
|
this.syncEphemeralTimersToStorage();
|
|
666
739
|
}
|
|
667
740
|
|
|
668
|
-
// 7.
|
|
741
|
+
// 7. Close stale authenticated sockets that stopped heartbeating.
|
|
742
|
+
if (this._socketHeartbeatCheckAt !== null && this._socketHeartbeatCheckAt <= now) {
|
|
743
|
+
this._socketHeartbeatCheckAt = null;
|
|
744
|
+
const staleBefore = now - this.getSocketStaleTimeoutMs();
|
|
745
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
746
|
+
const meta = this.getWSMeta(ws);
|
|
747
|
+
if (!meta?.authenticated) {
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
if ((meta.lastSeenAt ?? 0) > staleBefore) {
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
ws.close(4007, 'Heartbeat timeout');
|
|
755
|
+
} catch {
|
|
756
|
+
// Socket may already be closing.
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
this.ensureSocketHeartbeatCheckScheduled();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// 8. Reschedule for next pending event
|
|
669
763
|
this._scheduleNextAlarm();
|
|
670
764
|
}
|
|
671
765
|
|
|
@@ -700,6 +794,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
700
794
|
);
|
|
701
795
|
meta.authenticated = true;
|
|
702
796
|
meta.authStateLost = false;
|
|
797
|
+
meta.lastSeenAt = Date.now();
|
|
703
798
|
meta.userId = auth.id;
|
|
704
799
|
meta.role = auth.role;
|
|
705
800
|
meta.auth = {
|
|
@@ -738,10 +833,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
738
833
|
this.syncRoomMonitoringSnapshot();
|
|
739
834
|
|
|
740
835
|
// Recover state from storage if needed (after hibernation wake-up)
|
|
741
|
-
|
|
742
|
-
await this.recoverFromStorage();
|
|
743
|
-
this.stateRecoveryNeeded = false;
|
|
744
|
-
}
|
|
836
|
+
await this.recoverStateIfNeeded();
|
|
745
837
|
// Note: full sync is sent during handleJoin(), not here
|
|
746
838
|
} catch (error) {
|
|
747
839
|
const detail = error instanceof Error ? error.message : String(error);
|
|
@@ -794,11 +886,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
794
886
|
return;
|
|
795
887
|
}
|
|
796
888
|
if (!this.config.release && this.namespace) {
|
|
797
|
-
|
|
798
|
-
if (!roomFallbackWarnings.has(warningKey)) {
|
|
799
|
-
roomFallbackWarnings.add(warningKey);
|
|
800
|
-
console.warn(`[Room] ${warningKey} is using development-mode allow-by-default. Add rooms.${this.namespace}.access.join or public.join to make this explicit.`);
|
|
801
|
-
}
|
|
889
|
+
warnRoomDevelopmentFallback(this.namespace, 'join');
|
|
802
890
|
}
|
|
803
891
|
}
|
|
804
892
|
if (joinAccess && this.roomId) {
|
|
@@ -947,11 +1035,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
947
1035
|
return;
|
|
948
1036
|
}
|
|
949
1037
|
if (!this.config.release && this.namespace) {
|
|
950
|
-
|
|
951
|
-
if (!roomFallbackWarnings.has(warningKey)) {
|
|
952
|
-
roomFallbackWarnings.add(warningKey);
|
|
953
|
-
console.warn(`[Room] ${warningKey} is using development-mode allow-by-default. Add rooms.${this.namespace}.access.action or public.action to make this explicit.`);
|
|
954
|
-
}
|
|
1038
|
+
warnRoomDevelopmentFallback(this.namespace, 'action');
|
|
955
1039
|
}
|
|
956
1040
|
}
|
|
957
1041
|
if (actionAccess && this.roomId) {
|
|
@@ -1368,10 +1452,23 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1368
1452
|
this._stateTTLAlarmAt = typeof savedEphemeral?.stateTTLAlarmAt === 'number'
|
|
1369
1453
|
? savedEphemeral.stateTTLAlarmAt
|
|
1370
1454
|
: null;
|
|
1455
|
+
this._socketHeartbeatCheckAt = typeof savedEphemeral?.socketHeartbeatCheckAt === 'number'
|
|
1456
|
+
? savedEphemeral.socketHeartbeatCheckAt
|
|
1457
|
+
: null;
|
|
1371
1458
|
|
|
1372
1459
|
this._scheduleNextAlarm();
|
|
1373
1460
|
}
|
|
1374
1461
|
|
|
1462
|
+
protected async recoverStateIfNeeded(): Promise<void> {
|
|
1463
|
+
if (!this.stateRecoveryNeeded) {
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
await this.recoverFromStorage();
|
|
1468
|
+
await this.recoverRuntimeStateFromSockets();
|
|
1469
|
+
this.stateRecoveryNeeded = false;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1375
1472
|
// ─── Delta Broadcasting ───
|
|
1376
1473
|
|
|
1377
1474
|
/** Queue shared state delta and flush it at the end of the current turn. */
|
|
@@ -1613,6 +1710,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1613
1710
|
const stateSaveAt = this._stateSaveAt;
|
|
1614
1711
|
const emptyRoomCleanupAt = this._emptyRoomCleanupAt;
|
|
1615
1712
|
const stateTTLAlarmAt = this._stateTTLAlarmAt;
|
|
1713
|
+
const socketHeartbeatCheckAt = this._socketHeartbeatCheckAt;
|
|
1616
1714
|
this.ctx.waitUntil((async () => {
|
|
1617
1715
|
try {
|
|
1618
1716
|
if (
|
|
@@ -1621,6 +1719,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1621
1719
|
&& stateSaveAt === null
|
|
1622
1720
|
&& emptyRoomCleanupAt === null
|
|
1623
1721
|
&& stateTTLAlarmAt === null
|
|
1722
|
+
&& socketHeartbeatCheckAt === null
|
|
1624
1723
|
) {
|
|
1625
1724
|
await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
|
|
1626
1725
|
return;
|
|
@@ -1632,6 +1731,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1632
1731
|
stateSaveAt,
|
|
1633
1732
|
emptyRoomCleanupAt,
|
|
1634
1733
|
stateTTLAlarmAt,
|
|
1734
|
+
socketHeartbeatCheckAt,
|
|
1635
1735
|
} satisfies PersistedRoomEphemeralTimers);
|
|
1636
1736
|
} catch (error) {
|
|
1637
1737
|
console.warn('[Room] Ephemeral timer persistence skipped', {
|
|
@@ -1730,14 +1830,12 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1730
1830
|
): boolean {
|
|
1731
1831
|
const now = Date.now();
|
|
1732
1832
|
const rateLimit = this.namespaceConfig?.rateLimit as
|
|
1733
|
-
| { actions: number; signals?: number;
|
|
1833
|
+
| { actions: number; signals?: number; admin?: number }
|
|
1734
1834
|
| undefined;
|
|
1735
1835
|
const maxActions = (
|
|
1736
1836
|
scope === 'signals'
|
|
1737
1837
|
? rateLimit?.signals
|
|
1738
|
-
: scope === '
|
|
1739
|
-
? rateLimit?.media
|
|
1740
|
-
: scope === 'admin'
|
|
1838
|
+
: scope === 'admin'
|
|
1741
1839
|
? rateLimit?.admin
|
|
1742
1840
|
: undefined
|
|
1743
1841
|
) ?? rateLimit?.actions ?? DEFAULT_RATE_LIMIT_ACTIONS;
|
|
@@ -1776,8 +1874,17 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1776
1874
|
const cached = this._metaCache.get(ws);
|
|
1777
1875
|
if (cached) return cached;
|
|
1778
1876
|
|
|
1779
|
-
// After hibernation wake-up: rebuild from tags
|
|
1780
1877
|
try {
|
|
1878
|
+
const attachment = this.readWSAttachment(ws);
|
|
1879
|
+
if (attachment) {
|
|
1880
|
+
const meta = cloneRoomWSMeta(attachment.meta);
|
|
1881
|
+
this._metaCache.set(ws, meta);
|
|
1882
|
+
this._attachmentExtraCache.set(ws, attachment.extra);
|
|
1883
|
+
this.hydrateRoomIdentityFromWebSocketTags(ws);
|
|
1884
|
+
return meta;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// After hibernation wake-up: rebuild from tags
|
|
1781
1888
|
const tags = this.ctx.getTags(ws);
|
|
1782
1889
|
if (tags.length === 0) return null;
|
|
1783
1890
|
|
|
@@ -1808,8 +1915,10 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1808
1915
|
authStateLost: true,
|
|
1809
1916
|
connectionId,
|
|
1810
1917
|
ip,
|
|
1918
|
+
lastSeenAt: Date.now(),
|
|
1811
1919
|
};
|
|
1812
1920
|
this._metaCache.set(ws, meta);
|
|
1921
|
+
this._attachmentExtraCache.delete(ws);
|
|
1813
1922
|
return meta;
|
|
1814
1923
|
} catch {
|
|
1815
1924
|
return null;
|
|
@@ -1817,7 +1926,77 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1817
1926
|
}
|
|
1818
1927
|
|
|
1819
1928
|
protected setWSMeta(ws: WebSocket, meta: RoomWSMeta): void {
|
|
1820
|
-
|
|
1929
|
+
const clonedMeta = cloneRoomWSMeta(meta);
|
|
1930
|
+
const extra = this.buildWSAttachmentExtra(ws, clonedMeta);
|
|
1931
|
+
this._metaCache.set(ws, clonedMeta);
|
|
1932
|
+
this._attachmentExtraCache.set(ws, extra);
|
|
1933
|
+
this.writeWSAttachment(ws, {
|
|
1934
|
+
version: 1,
|
|
1935
|
+
meta: clonedMeta,
|
|
1936
|
+
extra,
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
protected getWSAttachmentExtra<T = unknown>(ws: WebSocket): T | undefined {
|
|
1941
|
+
return this._attachmentExtraCache.get(ws) as T | undefined;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
protected buildWSAttachmentExtra(_ws: WebSocket, _meta: RoomWSMeta): unknown {
|
|
1945
|
+
return undefined;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
private getSocketStaleTimeoutMs(): number {
|
|
1949
|
+
const configured = (this.namespaceConfig as { socketStaleTimeout?: unknown } | null)?.socketStaleTimeout;
|
|
1950
|
+
if (typeof configured !== 'number' || !Number.isFinite(configured)) {
|
|
1951
|
+
return DEFAULT_SOCKET_STALE_TIMEOUT_MS;
|
|
1952
|
+
}
|
|
1953
|
+
const normalized = Math.floor(configured);
|
|
1954
|
+
return normalized >= 3000 ? normalized : DEFAULT_SOCKET_STALE_TIMEOUT_MS;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
private getSocketHeartbeatCheckIntervalMs(): number {
|
|
1958
|
+
return Math.max(
|
|
1959
|
+
2000,
|
|
1960
|
+
Math.min(SOCKET_HEARTBEAT_CHECK_INTERVAL_MS, Math.floor(this.getSocketStaleTimeoutMs() / 2)),
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
private ensureSocketHeartbeatCheckScheduled(): void {
|
|
1965
|
+
if (this.ctx.getWebSockets().length === 0) {
|
|
1966
|
+
this._socketHeartbeatCheckAt = null;
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
const nextCheckAt = Date.now() + this.getSocketHeartbeatCheckIntervalMs();
|
|
1971
|
+
if (this._socketHeartbeatCheckAt === null || this._socketHeartbeatCheckAt > nextCheckAt) {
|
|
1972
|
+
this._socketHeartbeatCheckAt = nextCheckAt;
|
|
1973
|
+
this._scheduleNextAlarm();
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
protected async recoverRuntimeStateFromSockets(): Promise<void> {
|
|
1978
|
+
this.players.clear();
|
|
1979
|
+
this.userToConnections.clear();
|
|
1980
|
+
|
|
1981
|
+
let clearedPendingAuth = false;
|
|
1982
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
1983
|
+
const meta = this.getWSMeta(ws);
|
|
1984
|
+
if (!meta?.authenticated || !meta.userId) {
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
this.addPlayer(meta.connectionId, meta.userId);
|
|
1988
|
+
if (this.pendingAuth.delete(meta.connectionId)) {
|
|
1989
|
+
clearedPendingAuth = true;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
if (clearedPendingAuth) {
|
|
1994
|
+
this.syncEphemeralTimersToStorage();
|
|
1995
|
+
this._scheduleNextAlarm();
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
this.syncRoomMonitoringSnapshot();
|
|
1999
|
+
this.ensureSocketHeartbeatCheckScheduled();
|
|
1821
2000
|
}
|
|
1822
2001
|
|
|
1823
2002
|
protected handleUnauthenticatedSocket(ws: WebSocket, meta: RoomWSMeta): void {
|
|
@@ -1844,7 +2023,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1844
2023
|
return getGlobalConfig(env);
|
|
1845
2024
|
}
|
|
1846
2025
|
|
|
1847
|
-
|
|
2026
|
+
protected async ensureRuntimeReady(): Promise<void> {
|
|
1848
2027
|
if (!this.runtimeReadyPromise) {
|
|
1849
2028
|
this.runtimeReadyPromise = (async () => {
|
|
1850
2029
|
await ensureServerStartup();
|
|
@@ -1883,4 +2062,58 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1883
2062
|
&& Object.keys(this._metadata).length === 0
|
|
1884
2063
|
);
|
|
1885
2064
|
}
|
|
2065
|
+
|
|
2066
|
+
private readWSAttachment(ws: WebSocket): PersistedRoomWSAttachment | null {
|
|
2067
|
+
const hibernatingSocket = ws as HibernatingWebSocket;
|
|
2068
|
+
if (typeof hibernatingSocket.deserializeAttachment !== 'function') {
|
|
2069
|
+
return null;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
try {
|
|
2073
|
+
const attachment = hibernatingSocket.deserializeAttachment();
|
|
2074
|
+
return isPersistedRoomWSAttachment(attachment) ? attachment : null;
|
|
2075
|
+
} catch {
|
|
2076
|
+
return null;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
private writeWSAttachment(ws: WebSocket, attachment: PersistedRoomWSAttachment): void {
|
|
2081
|
+
const hibernatingSocket = ws as HibernatingWebSocket;
|
|
2082
|
+
if (typeof hibernatingSocket.serializeAttachment !== 'function') {
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
try {
|
|
2087
|
+
hibernatingSocket.serializeAttachment(attachment);
|
|
2088
|
+
} catch (error) {
|
|
2089
|
+
console.warn('[Room] Failed to serialize websocket attachment', {
|
|
2090
|
+
room: this.namespace && this.roomId ? `${this.namespace}::${this.roomId}` : null,
|
|
2091
|
+
connectionId: attachment.meta.connectionId,
|
|
2092
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
private hydrateRoomIdentityFromWebSocketTags(ws: WebSocket): void {
|
|
2098
|
+
if (this.namespace) {
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
const tags = this.ctx.getTags(ws);
|
|
2103
|
+
const roomTag = tags.find(t => t.startsWith('room:'));
|
|
2104
|
+
if (!roomTag) {
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
const roomFullName = roomTag.substring(5);
|
|
2109
|
+
const separatorIdx = roomFullName.indexOf('::');
|
|
2110
|
+
if (separatorIdx >= 0) {
|
|
2111
|
+
this.namespace = roomFullName.substring(0, separatorIdx);
|
|
2112
|
+
this.roomId = roomFullName.substring(separatorIdx + 2);
|
|
2113
|
+
} else {
|
|
2114
|
+
this.namespace = roomFullName;
|
|
2115
|
+
this.roomId = roomFullName;
|
|
2116
|
+
}
|
|
2117
|
+
this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
|
|
2118
|
+
}
|
|
1886
2119
|
}
|