@edge-base/server 0.2.6 → 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/{BN_-k-Ck.js → BDYewzou.js} +1 -1
- package/admin-build/_app/immutable/chunks/{qBm6xof8.js → BEM1BeVF.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Ff90owjx.js → BYyykAbh.js} +1 -1
- package/admin-build/_app/immutable/chunks/{SQVAC3Cv.js → BaUG2TJ-.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BvoGcDFV.js → BfpUQYr3.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CLHN9MVr.js → BhCO1Fpt.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DemDWbs-.js → CIOC1v_q.js} +3 -3
- package/admin-build/_app/immutable/chunks/{CrwlCAM0.js → CvczjTXx.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DQVP4KC-.js → D1u3u7xu.js} +1 -1
- package/admin-build/_app/immutable/chunks/{LL3ulaxa.js → DaXO-sFP.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CbfX3ELZ.js → DnpbvAPi.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DdvsFblq.js → Dz9cUCuv.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DmDTovpg.js → Tea2dBJ8.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CR37B8DX.js → ejoEf2I5.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CCUxCptE.js → iEyeblJR.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Q3vAxeY-.js → qKdzaeX3.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.CP83Ni80.js → app.DoUaxnew.js} +2 -2
- package/admin-build/_app/immutable/entry/start.MmZh8oBH.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.DiRq7puO.js → 0.Dsxi8s7i.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.BFeyKLGT.js → 1.Cp2l-hol.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.zcee7hJx.js → 10.4oY6m8Nz.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.BW7wLs2Y.js → 11.DfcozD4J.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.CxJRlYSd.js → 12.uJgZdCIA.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.pp0F_5hn.js → 13.CaN1kRev.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.t3AfGiGo.js → 14.DQ5xIi3s.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.B3agc7NX.js → 15.B_EkebTJ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.C4uG2-i8.js → 16.Tko1ZX8-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.CwGxi1Bn.js → 17.BCmWMJX9.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.CrQyN_gU.js → 18.hmGhl1O2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.NEPUOXl7.js → 19.D-1infOo.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.DGHO8ipr.js → 20.CY4KKcBL.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.B9lbNUQr.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.Dri5It7a.js → 22.14Vd7bnt.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.BPQP_Zte.js → 23.Be6jK77o.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.D580FdSS.js → 24.CSTFkr6R.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.BMNPOZwF.js → 25.DRTg8fHc.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.XcpEcbiz.js → 26.DKt-9lwQ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.C1zHHcYv.js → 27.D5caPu0F.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.CuKzzrY8.js → 28.hJhlnlyY.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.nLpBMXnM.js → 29.CDYBzFyT.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.5G_aseoL.js → 3.DMyKwkGn.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.CQC4nLoU.js → 30.BaHNeEmc.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.Bet8kxOK.js → 31.C6PV5L-2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.nmJDYJpC.js → 4.9E118Ftm.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.CnbYLG4E.js → 5.D8guAl3v.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.KA01b-3y.js → 6.D1u__DtT.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.CP9fkn1L.js → 7.DWXHnRFf.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.BTzDb---.js → 8.Dojd8krc.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.DkNJg_J6.js → 9.CLtrr0K_.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__/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 +241 -17
- package/src/durable-objects/rooms-do.ts +190 -1345
- 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/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);
|
|
@@ -1377,10 +1452,23 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1377
1452
|
this._stateTTLAlarmAt = typeof savedEphemeral?.stateTTLAlarmAt === 'number'
|
|
1378
1453
|
? savedEphemeral.stateTTLAlarmAt
|
|
1379
1454
|
: null;
|
|
1455
|
+
this._socketHeartbeatCheckAt = typeof savedEphemeral?.socketHeartbeatCheckAt === 'number'
|
|
1456
|
+
? savedEphemeral.socketHeartbeatCheckAt
|
|
1457
|
+
: null;
|
|
1380
1458
|
|
|
1381
1459
|
this._scheduleNextAlarm();
|
|
1382
1460
|
}
|
|
1383
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
|
+
|
|
1384
1472
|
// ─── Delta Broadcasting ───
|
|
1385
1473
|
|
|
1386
1474
|
/** Queue shared state delta and flush it at the end of the current turn. */
|
|
@@ -1622,6 +1710,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1622
1710
|
const stateSaveAt = this._stateSaveAt;
|
|
1623
1711
|
const emptyRoomCleanupAt = this._emptyRoomCleanupAt;
|
|
1624
1712
|
const stateTTLAlarmAt = this._stateTTLAlarmAt;
|
|
1713
|
+
const socketHeartbeatCheckAt = this._socketHeartbeatCheckAt;
|
|
1625
1714
|
this.ctx.waitUntil((async () => {
|
|
1626
1715
|
try {
|
|
1627
1716
|
if (
|
|
@@ -1630,6 +1719,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1630
1719
|
&& stateSaveAt === null
|
|
1631
1720
|
&& emptyRoomCleanupAt === null
|
|
1632
1721
|
&& stateTTLAlarmAt === null
|
|
1722
|
+
&& socketHeartbeatCheckAt === null
|
|
1633
1723
|
) {
|
|
1634
1724
|
await this.ctx.storage.delete(ROOM_EPHEMERAL_TIMERS_STORAGE_KEY);
|
|
1635
1725
|
return;
|
|
@@ -1641,6 +1731,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1641
1731
|
stateSaveAt,
|
|
1642
1732
|
emptyRoomCleanupAt,
|
|
1643
1733
|
stateTTLAlarmAt,
|
|
1734
|
+
socketHeartbeatCheckAt,
|
|
1644
1735
|
} satisfies PersistedRoomEphemeralTimers);
|
|
1645
1736
|
} catch (error) {
|
|
1646
1737
|
console.warn('[Room] Ephemeral timer persistence skipped', {
|
|
@@ -1739,14 +1830,12 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1739
1830
|
): boolean {
|
|
1740
1831
|
const now = Date.now();
|
|
1741
1832
|
const rateLimit = this.namespaceConfig?.rateLimit as
|
|
1742
|
-
| { actions: number; signals?: number;
|
|
1833
|
+
| { actions: number; signals?: number; admin?: number }
|
|
1743
1834
|
| undefined;
|
|
1744
1835
|
const maxActions = (
|
|
1745
1836
|
scope === 'signals'
|
|
1746
1837
|
? rateLimit?.signals
|
|
1747
|
-
: scope === '
|
|
1748
|
-
? rateLimit?.media
|
|
1749
|
-
: scope === 'admin'
|
|
1838
|
+
: scope === 'admin'
|
|
1750
1839
|
? rateLimit?.admin
|
|
1751
1840
|
: undefined
|
|
1752
1841
|
) ?? rateLimit?.actions ?? DEFAULT_RATE_LIMIT_ACTIONS;
|
|
@@ -1785,8 +1874,17 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1785
1874
|
const cached = this._metaCache.get(ws);
|
|
1786
1875
|
if (cached) return cached;
|
|
1787
1876
|
|
|
1788
|
-
// After hibernation wake-up: rebuild from tags
|
|
1789
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
|
|
1790
1888
|
const tags = this.ctx.getTags(ws);
|
|
1791
1889
|
if (tags.length === 0) return null;
|
|
1792
1890
|
|
|
@@ -1817,8 +1915,10 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1817
1915
|
authStateLost: true,
|
|
1818
1916
|
connectionId,
|
|
1819
1917
|
ip,
|
|
1918
|
+
lastSeenAt: Date.now(),
|
|
1820
1919
|
};
|
|
1821
1920
|
this._metaCache.set(ws, meta);
|
|
1921
|
+
this._attachmentExtraCache.delete(ws);
|
|
1822
1922
|
return meta;
|
|
1823
1923
|
} catch {
|
|
1824
1924
|
return null;
|
|
@@ -1826,7 +1926,77 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1826
1926
|
}
|
|
1827
1927
|
|
|
1828
1928
|
protected setWSMeta(ws: WebSocket, meta: RoomWSMeta): void {
|
|
1829
|
-
|
|
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();
|
|
1830
2000
|
}
|
|
1831
2001
|
|
|
1832
2002
|
protected handleUnauthenticatedSocket(ws: WebSocket, meta: RoomWSMeta): void {
|
|
@@ -1853,7 +2023,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1853
2023
|
return getGlobalConfig(env);
|
|
1854
2024
|
}
|
|
1855
2025
|
|
|
1856
|
-
|
|
2026
|
+
protected async ensureRuntimeReady(): Promise<void> {
|
|
1857
2027
|
if (!this.runtimeReadyPromise) {
|
|
1858
2028
|
this.runtimeReadyPromise = (async () => {
|
|
1859
2029
|
await ensureServerStartup();
|
|
@@ -1892,4 +2062,58 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1892
2062
|
&& Object.keys(this._metadata).length === 0
|
|
1893
2063
|
);
|
|
1894
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
|
+
}
|
|
1895
2119
|
}
|