@edge-base/server 0.2.6 → 0.2.8
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/{CbfX3ELZ.js → B9efkx2V.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CLHN9MVr.js → BMXWUTG-.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DemDWbs-.js → Bt4AyT3o.js} +3 -3
- package/admin-build/_app/immutable/chunks/CKVjMXZi.js +1 -0
- package/admin-build/_app/immutable/chunks/{BvoGcDFV.js → CMYgGhZR.js} +1 -1
- package/admin-build/_app/immutable/chunks/{LL3ulaxa.js → CTRjWhGs.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BN_-k-Ck.js → CwyE59Yt.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DQVP4KC-.js → D8aeTKry.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Ff90owjx.js → DGAHkap7.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CR37B8DX.js → DPgR4-0v.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DdvsFblq.js → DYtrHeVQ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CrwlCAM0.js → DcVb45Ds.js} +1 -1
- package/admin-build/_app/immutable/chunks/Djnkhy-S.js +1 -0
- package/admin-build/_app/immutable/chunks/{DmDTovpg.js → fPy6xmgG.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CCUxCptE.js → j4jxnAKj.js} +1 -1
- package/admin-build/_app/immutable/chunks/{qBm6xof8.js → zl2AUKMP.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.CP83Ni80.js → app.Cmz0WjMl.js} +2 -2
- package/admin-build/_app/immutable/entry/start.JE7dcbK1.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.DiRq7puO.js → 0.y6D_QyUb.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.BFeyKLGT.js → 1.CndRxhbH.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.zcee7hJx.js → 10.CdA5FmXy.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.BW7wLs2Y.js → 11.DG8SzMp_.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.CxJRlYSd.js → 12.CvmQqpFa.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.pp0F_5hn.js → 13.BbGNdswT.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.t3AfGiGo.js → 14.CZKsN7-O.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.B3agc7NX.js → 15.A7-CYgkG.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.C4uG2-i8.js → 16.hgJT9H-x.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.CwGxi1Bn.js → 17.DkWZbcN2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.CrQyN_gU.js → 18.sX3Fb5gh.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.NEPUOXl7.js → 19.VAZUW-1K.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.DGHO8ipr.js → 20.DkIKxacG.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.DOjJlQKc.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.Dri5It7a.js → 22.BDaHvtaw.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.BPQP_Zte.js → 23.BVRzw_pD.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.D580FdSS.js → 24.CVhSJyG0.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.BMNPOZwF.js → 25.Bme-9bZn.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.XcpEcbiz.js → 26.Dsx7RIIs.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.C1zHHcYv.js → 27.DMGQnzFM.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.CuKzzrY8.js → 28.GGwFmEhZ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.nLpBMXnM.js → 29.Dnghr0nk.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.5G_aseoL.js → 3.Cg7zZJP1.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.CQC4nLoU.js → 30.C0J24z3I.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.Bet8kxOK.js → 31.MdxFI8v6.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.nmJDYJpC.js → 4.DCAOVzGE.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.CnbYLG4E.js → 5.DzUQ-cTc.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.KA01b-3y.js → 6.CptBYTVj.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.CP9fkn1L.js → 7.DfeeQ0Rg.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.BTzDb---.js → 8.CIcvctW7.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.DkNJg_J6.js → 9.QKrvq4RA.js} +1 -1
- 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__/admin-assets.test.ts +7 -7
- package/src/__tests__/frontend-assets.test.ts +75 -0
- package/src/__tests__/frontend-config.test.ts +16 -0
- package/src/__tests__/frontend-routing.test.ts +200 -0
- package/src/__tests__/openapi-coverage.test.ts +0 -6
- 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 +1 -111
- package/src/__tests__/smoke-skip-report.test.ts +1 -1
- package/src/durable-objects/room-runtime-base.ts +243 -17
- package/src/durable-objects/rooms-do.ts +190 -1345
- package/src/index.ts +97 -3
- package/src/lib/admin-assets.ts +5 -5
- package/src/lib/frontend-assets.ts +129 -0
- package/src/lib/frontend-config.ts +11 -0
- package/src/lib/openapi.ts +1 -4
- package/src/routes/room.ts +0 -285
- package/src/types.ts +1 -14
- package/admin-build/_app/immutable/chunks/Q3vAxeY-.js +0 -1
- package/admin-build/_app/immutable/chunks/SQVAC3Cv.js +0 -1
- package/admin-build/_app/immutable/entry/start.DY6YakU0.js +0 -1
- package/admin-build/_app/immutable/nodes/21.UVKBDvp4.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(
|
|
@@ -170,6 +185,34 @@ function cloneState(obj: Record<string, unknown>): Record<string, unknown> {
|
|
|
170
185
|
return JSON.parse(JSON.stringify(obj));
|
|
171
186
|
}
|
|
172
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
|
+
|
|
173
216
|
// ─── Shared Room Runtime Base ───
|
|
174
217
|
|
|
175
218
|
export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
@@ -202,6 +245,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
202
245
|
|
|
203
246
|
// ─── WebSocket metadata cache ───
|
|
204
247
|
private _metaCache = new Map<WebSocket, RoomWSMeta>();
|
|
248
|
+
private _attachmentExtraCache = new Map<WebSocket, unknown>();
|
|
205
249
|
|
|
206
250
|
// ─── Auth timeout tracking ───
|
|
207
251
|
private pendingAuth = new Map<string, number>();
|
|
@@ -220,6 +264,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
220
264
|
private _timers = new Map<string, { fireAt: number; data?: unknown }>();
|
|
221
265
|
private _emptyRoomCleanupAt: number | null = null;
|
|
222
266
|
private _stateTTLAlarmAt: number | null = null;
|
|
267
|
+
private _socketHeartbeatCheckAt: number | null = null;
|
|
223
268
|
|
|
224
269
|
// ─── Room Metadata (queryable via HTTP without joining) ───
|
|
225
270
|
private _metadata: Record<string, unknown> = {};
|
|
@@ -384,6 +429,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
384
429
|
authenticated: false,
|
|
385
430
|
authStateLost: false,
|
|
386
431
|
connectionId,
|
|
432
|
+
lastSeenAt: Date.now(),
|
|
387
433
|
ip: request.headers.get('CF-Connecting-IP')
|
|
388
434
|
|| request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim()
|
|
389
435
|
|| undefined,
|
|
@@ -403,8 +449,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
403
449
|
tags.push(`ip:${encodeURIComponent(meta.ip)}`);
|
|
404
450
|
}
|
|
405
451
|
this.ctx.acceptWebSocket(server, tags);
|
|
406
|
-
this.
|
|
452
|
+
this.setWSMeta(server, meta);
|
|
407
453
|
this.syncRoomMonitoringSnapshot();
|
|
454
|
+
this.ensureSocketHeartbeatCheckScheduled();
|
|
408
455
|
|
|
409
456
|
// Set auth timeout without pinning the DO with a JS timer.
|
|
410
457
|
const authTimeoutMs = resolveRoomAuthTimeoutMs(this.config);
|
|
@@ -419,6 +466,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
419
466
|
|
|
420
467
|
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
421
468
|
await this.ensureRuntimeReady();
|
|
469
|
+
await this.recoverStateIfNeeded();
|
|
422
470
|
if (typeof message !== 'string') return;
|
|
423
471
|
|
|
424
472
|
let msg: Record<string, unknown>;
|
|
@@ -434,6 +482,8 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
434
482
|
ws.close(4000, 'No metadata');
|
|
435
483
|
return;
|
|
436
484
|
}
|
|
485
|
+
meta.lastSeenAt = Date.now();
|
|
486
|
+
this.setWSMeta(ws, meta);
|
|
437
487
|
|
|
438
488
|
const type = msg.type as string;
|
|
439
489
|
|
|
@@ -487,11 +537,13 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
487
537
|
const explicitLeave = code === ROOM_CLIENT_LEAVE_CLOSE_CODE;
|
|
488
538
|
this.handleDisconnect(meta, kicked, explicitLeave);
|
|
489
539
|
this._metaCache.delete(ws);
|
|
540
|
+
this._attachmentExtraCache.delete(ws);
|
|
490
541
|
if (this.pendingAuth.delete(meta.connectionId)) {
|
|
491
542
|
this.syncEphemeralTimersToStorage();
|
|
492
543
|
this._scheduleNextAlarm();
|
|
493
544
|
}
|
|
494
545
|
}
|
|
546
|
+
this.ensureSocketHeartbeatCheckScheduled();
|
|
495
547
|
this.syncRoomMonitoringSnapshot(ws);
|
|
496
548
|
}
|
|
497
549
|
|
|
@@ -500,11 +552,13 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
500
552
|
if (meta) {
|
|
501
553
|
this.handleDisconnect(meta);
|
|
502
554
|
this._metaCache.delete(ws);
|
|
555
|
+
this._attachmentExtraCache.delete(ws);
|
|
503
556
|
if (this.pendingAuth.delete(meta.connectionId)) {
|
|
504
557
|
this.syncEphemeralTimersToStorage();
|
|
505
558
|
this._scheduleNextAlarm();
|
|
506
559
|
}
|
|
507
560
|
}
|
|
561
|
+
this.ensureSocketHeartbeatCheckScheduled();
|
|
508
562
|
this.syncRoomMonitoringSnapshot(ws);
|
|
509
563
|
}
|
|
510
564
|
|
|
@@ -535,6 +589,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
535
589
|
if (this._stateTTLAlarmAt !== null && this._stateTTLAlarmAt < earliest) {
|
|
536
590
|
earliest = this._stateTTLAlarmAt;
|
|
537
591
|
}
|
|
592
|
+
if (this._socketHeartbeatCheckAt !== null && this._socketHeartbeatCheckAt < earliest) {
|
|
593
|
+
earliest = this._socketHeartbeatCheckAt;
|
|
594
|
+
}
|
|
538
595
|
|
|
539
596
|
if (earliest < Infinity) {
|
|
540
597
|
this.ctx.storage.setAlarm(earliest);
|
|
@@ -543,10 +600,9 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
543
600
|
|
|
544
601
|
async alarm(): Promise<void> {
|
|
545
602
|
await this.ensureRuntimeReady();
|
|
546
|
-
|
|
547
603
|
if (this.shouldRecoverBeforeAlarm()) {
|
|
548
|
-
|
|
549
|
-
this.
|
|
604
|
+
this.stateRecoveryNeeded = true;
|
|
605
|
+
await this.recoverStateIfNeeded();
|
|
550
606
|
}
|
|
551
607
|
|
|
552
608
|
const now = Date.now();
|
|
@@ -682,7 +738,28 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
682
738
|
this.syncEphemeralTimersToStorage();
|
|
683
739
|
}
|
|
684
740
|
|
|
685
|
-
// 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
|
|
686
763
|
this._scheduleNextAlarm();
|
|
687
764
|
}
|
|
688
765
|
|
|
@@ -717,6 +794,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
717
794
|
);
|
|
718
795
|
meta.authenticated = true;
|
|
719
796
|
meta.authStateLost = false;
|
|
797
|
+
meta.lastSeenAt = Date.now();
|
|
720
798
|
meta.userId = auth.id;
|
|
721
799
|
meta.role = auth.role;
|
|
722
800
|
meta.auth = {
|
|
@@ -755,10 +833,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
755
833
|
this.syncRoomMonitoringSnapshot();
|
|
756
834
|
|
|
757
835
|
// Recover state from storage if needed (after hibernation wake-up)
|
|
758
|
-
|
|
759
|
-
await this.recoverFromStorage();
|
|
760
|
-
this.stateRecoveryNeeded = false;
|
|
761
|
-
}
|
|
836
|
+
await this.recoverStateIfNeeded();
|
|
762
837
|
// Note: full sync is sent during handleJoin(), not here
|
|
763
838
|
} catch (error) {
|
|
764
839
|
const detail = error instanceof Error ? error.message : String(error);
|
|
@@ -1082,6 +1157,8 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1082
1157
|
|
|
1083
1158
|
protected buildRoomServerAPI(): RoomServerAPI {
|
|
1084
1159
|
return {
|
|
1160
|
+
namespace: this.namespace ?? '',
|
|
1161
|
+
roomId: this.roomId ?? '',
|
|
1085
1162
|
getSharedState: (): Record<string, unknown> => {
|
|
1086
1163
|
return cloneState(this.sharedState);
|
|
1087
1164
|
},
|
|
@@ -1377,10 +1454,23 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1377
1454
|
this._stateTTLAlarmAt = typeof savedEphemeral?.stateTTLAlarmAt === 'number'
|
|
1378
1455
|
? savedEphemeral.stateTTLAlarmAt
|
|
1379
1456
|
: null;
|
|
1457
|
+
this._socketHeartbeatCheckAt = typeof savedEphemeral?.socketHeartbeatCheckAt === 'number'
|
|
1458
|
+
? savedEphemeral.socketHeartbeatCheckAt
|
|
1459
|
+
: null;
|
|
1380
1460
|
|
|
1381
1461
|
this._scheduleNextAlarm();
|
|
1382
1462
|
}
|
|
1383
1463
|
|
|
1464
|
+
protected async recoverStateIfNeeded(): Promise<void> {
|
|
1465
|
+
if (!this.stateRecoveryNeeded) {
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
await this.recoverFromStorage();
|
|
1470
|
+
await this.recoverRuntimeStateFromSockets();
|
|
1471
|
+
this.stateRecoveryNeeded = false;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1384
1474
|
// ─── Delta Broadcasting ───
|
|
1385
1475
|
|
|
1386
1476
|
/** Queue shared state delta and flush it at the end of the current turn. */
|
|
@@ -1622,6 +1712,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1622
1712
|
const stateSaveAt = this._stateSaveAt;
|
|
1623
1713
|
const emptyRoomCleanupAt = this._emptyRoomCleanupAt;
|
|
1624
1714
|
const stateTTLAlarmAt = this._stateTTLAlarmAt;
|
|
1715
|
+
const socketHeartbeatCheckAt = this._socketHeartbeatCheckAt;
|
|
1625
1716
|
this.ctx.waitUntil((async () => {
|
|
1626
1717
|
try {
|
|
1627
1718
|
if (
|
|
@@ -1630,6 +1721,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1630
1721
|
&& stateSaveAt === null
|
|
1631
1722
|
&& emptyRoomCleanupAt === null
|
|
1632
1723
|
&& stateTTLAlarmAt === null
|
|
1724
|
+
&& socketHeartbeatCheckAt === null
|
|
1633
1725
|
) {
|
|
1634
1726
|
await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
|
|
1635
1727
|
return;
|
|
@@ -1641,6 +1733,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1641
1733
|
stateSaveAt,
|
|
1642
1734
|
emptyRoomCleanupAt,
|
|
1643
1735
|
stateTTLAlarmAt,
|
|
1736
|
+
socketHeartbeatCheckAt,
|
|
1644
1737
|
} satisfies PersistedRoomEphemeralTimers);
|
|
1645
1738
|
} catch (error) {
|
|
1646
1739
|
console.warn('[Room] Ephemeral timer persistence skipped', {
|
|
@@ -1739,14 +1832,12 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1739
1832
|
): boolean {
|
|
1740
1833
|
const now = Date.now();
|
|
1741
1834
|
const rateLimit = this.namespaceConfig?.rateLimit as
|
|
1742
|
-
| { actions: number; signals?: number;
|
|
1835
|
+
| { actions: number; signals?: number; admin?: number }
|
|
1743
1836
|
| undefined;
|
|
1744
1837
|
const maxActions = (
|
|
1745
1838
|
scope === 'signals'
|
|
1746
1839
|
? rateLimit?.signals
|
|
1747
|
-
: scope === '
|
|
1748
|
-
? rateLimit?.media
|
|
1749
|
-
: scope === 'admin'
|
|
1840
|
+
: scope === 'admin'
|
|
1750
1841
|
? rateLimit?.admin
|
|
1751
1842
|
: undefined
|
|
1752
1843
|
) ?? rateLimit?.actions ?? DEFAULT_RATE_LIMIT_ACTIONS;
|
|
@@ -1785,8 +1876,17 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1785
1876
|
const cached = this._metaCache.get(ws);
|
|
1786
1877
|
if (cached) return cached;
|
|
1787
1878
|
|
|
1788
|
-
// After hibernation wake-up: rebuild from tags
|
|
1789
1879
|
try {
|
|
1880
|
+
const attachment = this.readWSAttachment(ws);
|
|
1881
|
+
if (attachment) {
|
|
1882
|
+
const meta = cloneRoomWSMeta(attachment.meta);
|
|
1883
|
+
this._metaCache.set(ws, meta);
|
|
1884
|
+
this._attachmentExtraCache.set(ws, attachment.extra);
|
|
1885
|
+
this.hydrateRoomIdentityFromWebSocketTags(ws);
|
|
1886
|
+
return meta;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// After hibernation wake-up: rebuild from tags
|
|
1790
1890
|
const tags = this.ctx.getTags(ws);
|
|
1791
1891
|
if (tags.length === 0) return null;
|
|
1792
1892
|
|
|
@@ -1817,8 +1917,10 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1817
1917
|
authStateLost: true,
|
|
1818
1918
|
connectionId,
|
|
1819
1919
|
ip,
|
|
1920
|
+
lastSeenAt: Date.now(),
|
|
1820
1921
|
};
|
|
1821
1922
|
this._metaCache.set(ws, meta);
|
|
1923
|
+
this._attachmentExtraCache.delete(ws);
|
|
1822
1924
|
return meta;
|
|
1823
1925
|
} catch {
|
|
1824
1926
|
return null;
|
|
@@ -1826,7 +1928,77 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1826
1928
|
}
|
|
1827
1929
|
|
|
1828
1930
|
protected setWSMeta(ws: WebSocket, meta: RoomWSMeta): void {
|
|
1829
|
-
|
|
1931
|
+
const clonedMeta = cloneRoomWSMeta(meta);
|
|
1932
|
+
const extra = this.buildWSAttachmentExtra(ws, clonedMeta);
|
|
1933
|
+
this._metaCache.set(ws, clonedMeta);
|
|
1934
|
+
this._attachmentExtraCache.set(ws, extra);
|
|
1935
|
+
this.writeWSAttachment(ws, {
|
|
1936
|
+
version: 1,
|
|
1937
|
+
meta: clonedMeta,
|
|
1938
|
+
extra,
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
protected getWSAttachmentExtra<T = unknown>(ws: WebSocket): T | undefined {
|
|
1943
|
+
return this._attachmentExtraCache.get(ws) as T | undefined;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
protected buildWSAttachmentExtra(_ws: WebSocket, _meta: RoomWSMeta): unknown {
|
|
1947
|
+
return undefined;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
private getSocketStaleTimeoutMs(): number {
|
|
1951
|
+
const configured = (this.namespaceConfig as { socketStaleTimeout?: unknown } | null)?.socketStaleTimeout;
|
|
1952
|
+
if (typeof configured !== 'number' || !Number.isFinite(configured)) {
|
|
1953
|
+
return DEFAULT_SOCKET_STALE_TIMEOUT_MS;
|
|
1954
|
+
}
|
|
1955
|
+
const normalized = Math.floor(configured);
|
|
1956
|
+
return normalized >= 3000 ? normalized : DEFAULT_SOCKET_STALE_TIMEOUT_MS;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
private getSocketHeartbeatCheckIntervalMs(): number {
|
|
1960
|
+
return Math.max(
|
|
1961
|
+
2000,
|
|
1962
|
+
Math.min(SOCKET_HEARTBEAT_CHECK_INTERVAL_MS, Math.floor(this.getSocketStaleTimeoutMs() / 2)),
|
|
1963
|
+
);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
private ensureSocketHeartbeatCheckScheduled(): void {
|
|
1967
|
+
if (this.ctx.getWebSockets().length === 0) {
|
|
1968
|
+
this._socketHeartbeatCheckAt = null;
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
const nextCheckAt = Date.now() + this.getSocketHeartbeatCheckIntervalMs();
|
|
1973
|
+
if (this._socketHeartbeatCheckAt === null || this._socketHeartbeatCheckAt > nextCheckAt) {
|
|
1974
|
+
this._socketHeartbeatCheckAt = nextCheckAt;
|
|
1975
|
+
this._scheduleNextAlarm();
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
protected async recoverRuntimeStateFromSockets(): Promise<void> {
|
|
1980
|
+
this.players.clear();
|
|
1981
|
+
this.userToConnections.clear();
|
|
1982
|
+
|
|
1983
|
+
let clearedPendingAuth = false;
|
|
1984
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
1985
|
+
const meta = this.getWSMeta(ws);
|
|
1986
|
+
if (!meta?.authenticated || !meta.userId) {
|
|
1987
|
+
continue;
|
|
1988
|
+
}
|
|
1989
|
+
this.addPlayer(meta.connectionId, meta.userId);
|
|
1990
|
+
if (this.pendingAuth.delete(meta.connectionId)) {
|
|
1991
|
+
clearedPendingAuth = true;
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
if (clearedPendingAuth) {
|
|
1996
|
+
this.syncEphemeralTimersToStorage();
|
|
1997
|
+
this._scheduleNextAlarm();
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
this.syncRoomMonitoringSnapshot();
|
|
2001
|
+
this.ensureSocketHeartbeatCheckScheduled();
|
|
1830
2002
|
}
|
|
1831
2003
|
|
|
1832
2004
|
protected handleUnauthenticatedSocket(ws: WebSocket, meta: RoomWSMeta): void {
|
|
@@ -1853,7 +2025,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1853
2025
|
return getGlobalConfig(env);
|
|
1854
2026
|
}
|
|
1855
2027
|
|
|
1856
|
-
|
|
2028
|
+
protected async ensureRuntimeReady(): Promise<void> {
|
|
1857
2029
|
if (!this.runtimeReadyPromise) {
|
|
1858
2030
|
this.runtimeReadyPromise = (async () => {
|
|
1859
2031
|
await ensureServerStartup();
|
|
@@ -1892,4 +2064,58 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1892
2064
|
&& Object.keys(this._metadata).length === 0
|
|
1893
2065
|
);
|
|
1894
2066
|
}
|
|
2067
|
+
|
|
2068
|
+
private readWSAttachment(ws: WebSocket): PersistedRoomWSAttachment | null {
|
|
2069
|
+
const hibernatingSocket = ws as HibernatingWebSocket;
|
|
2070
|
+
if (typeof hibernatingSocket.deserializeAttachment !== 'function') {
|
|
2071
|
+
return null;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
try {
|
|
2075
|
+
const attachment = hibernatingSocket.deserializeAttachment();
|
|
2076
|
+
return isPersistedRoomWSAttachment(attachment) ? attachment : null;
|
|
2077
|
+
} catch {
|
|
2078
|
+
return null;
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
private writeWSAttachment(ws: WebSocket, attachment: PersistedRoomWSAttachment): void {
|
|
2083
|
+
const hibernatingSocket = ws as HibernatingWebSocket;
|
|
2084
|
+
if (typeof hibernatingSocket.serializeAttachment !== 'function') {
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
try {
|
|
2089
|
+
hibernatingSocket.serializeAttachment(attachment);
|
|
2090
|
+
} catch (error) {
|
|
2091
|
+
console.warn('[Room] Failed to serialize websocket attachment', {
|
|
2092
|
+
room: this.namespace && this.roomId ? `${this.namespace}::${this.roomId}` : null,
|
|
2093
|
+
connectionId: attachment.meta.connectionId,
|
|
2094
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
private hydrateRoomIdentityFromWebSocketTags(ws: WebSocket): void {
|
|
2100
|
+
if (this.namespace) {
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
const tags = this.ctx.getTags(ws);
|
|
2105
|
+
const roomTag = tags.find(t => t.startsWith('room:'));
|
|
2106
|
+
if (!roomTag) {
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
const roomFullName = roomTag.substring(5);
|
|
2111
|
+
const separatorIdx = roomFullName.indexOf('::');
|
|
2112
|
+
if (separatorIdx >= 0) {
|
|
2113
|
+
this.namespace = roomFullName.substring(0, separatorIdx);
|
|
2114
|
+
this.roomId = roomFullName.substring(separatorIdx + 2);
|
|
2115
|
+
} else {
|
|
2116
|
+
this.namespace = roomFullName;
|
|
2117
|
+
this.roomId = roomFullName;
|
|
2118
|
+
}
|
|
2119
|
+
this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
|
|
2120
|
+
}
|
|
1895
2121
|
}
|