@signe/room 2.9.4 → 3.0.0
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/CHANGELOG.md +13 -0
- package/dist/chunk-EUXUH3YW.js +15 -0
- package/dist/chunk-EUXUH3YW.js.map +1 -0
- package/dist/cloudflare/index.d.ts +71 -0
- package/dist/cloudflare/index.js +320 -0
- package/dist/cloudflare/index.js.map +1 -0
- package/dist/index.d.ts +65 -188
- package/dist/index.js +742 -146
- package/dist/index.js.map +1 -1
- package/dist/node/index.d.ts +164 -0
- package/dist/node/index.js +786 -0
- package/dist/node/index.js.map +1 -0
- package/dist/party-dNs-hqkq.d.ts +175 -0
- package/examples/cloudflare/README.md +62 -0
- package/examples/cloudflare/node_modules/.bin/tsc +17 -0
- package/examples/cloudflare/node_modules/.bin/tsserver +17 -0
- package/examples/cloudflare/node_modules/.bin/wrangler +17 -0
- package/examples/cloudflare/node_modules/.bin/wrangler2 +17 -0
- package/examples/cloudflare/package.json +24 -0
- package/examples/cloudflare/public/index.html +443 -0
- package/examples/cloudflare/src/index.ts +28 -0
- package/examples/cloudflare/src/room.ts +44 -0
- package/examples/cloudflare/tsconfig.json +10 -0
- package/examples/cloudflare/wrangler.jsonc +25 -0
- package/examples/node/README.md +57 -0
- package/examples/node/node_modules/.bin/tsc +17 -0
- package/examples/node/node_modules/.bin/tsserver +17 -0
- package/examples/node/node_modules/.bin/tsx +17 -0
- package/examples/node/package.json +23 -0
- package/examples/node/public/index.html +443 -0
- package/examples/node/room.ts +44 -0
- package/examples/node/server.sqlite.ts +52 -0
- package/examples/node/server.ts +51 -0
- package/examples/node/tsconfig.json +10 -0
- package/examples/node-game/README.md +66 -0
- package/examples/node-game/package.json +23 -0
- package/examples/node-game/public/index.html +705 -0
- package/examples/node-game/room.ts +145 -0
- package/examples/node-game/server.sqlite.ts +54 -0
- package/examples/node-game/server.ts +53 -0
- package/examples/node-game/tsconfig.json +10 -0
- package/examples/node-shard/README.md +32 -0
- package/examples/node-shard/dev.ts +39 -0
- package/examples/node-shard/package.json +24 -0
- package/examples/node-shard/public/index.html +777 -0
- package/examples/node-shard/room-server.ts +68 -0
- package/examples/node-shard/room.ts +105 -0
- package/examples/node-shard/shared.ts +6 -0
- package/examples/node-shard/tsconfig.json +14 -0
- package/examples/node-shard/world-server.ts +169 -0
- package/package.json +14 -5
- package/readme.md +377 -11
- package/src/cloudflare/index.ts +474 -0
- package/src/mock.ts +29 -7
- package/src/node/index.ts +1112 -0
- package/src/server.ts +626 -90
- package/src/session.guard.ts +6 -2
- package/src/shard.ts +91 -23
- package/src/storage.ts +29 -5
- package/src/testing.ts +4 -3
- package/src/types/party.ts +4 -1
- package/src/world.guard.ts +23 -4
- package/src/world.ts +170 -79
- package/examples/game/.vscode/launch.json +0 -11
- package/examples/game/.vscode/settings.json +0 -11
- package/examples/game/README.md +0 -40
- package/examples/game/app/client.tsx +0 -15
- package/examples/game/app/components/Admin.tsx +0 -1089
- package/examples/game/app/components/Room.tsx +0 -162
- package/examples/game/app/styles.css +0 -31
- package/examples/game/package-lock.json +0 -225
- package/examples/game/package.json +0 -20
- package/examples/game/party/game.room.ts +0 -32
- package/examples/game/party/server.ts +0 -10
- package/examples/game/party/shard.ts +0 -5
- package/examples/game/partykit.json +0 -14
- package/examples/game/public/favicon.ico +0 -0
- package/examples/game/public/index.html +0 -27
- package/examples/game/public/normalize.css +0 -351
- package/examples/game/shared/room.schema.ts +0 -14
- package/examples/game/tsconfig.json +0 -109
package/src/server.ts
CHANGED
|
@@ -33,6 +33,47 @@ type CreateRoomOptions = {
|
|
|
33
33
|
throttleStorage?: number;
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
type SessionData = {
|
|
37
|
+
publicId: string;
|
|
38
|
+
state?: any;
|
|
39
|
+
created?: number;
|
|
40
|
+
connected?: boolean;
|
|
41
|
+
disconnectedAt?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type TransferData = {
|
|
45
|
+
privateId: string;
|
|
46
|
+
publicId: string;
|
|
47
|
+
restored: boolean;
|
|
48
|
+
created: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type StorageMetrics = {
|
|
52
|
+
loadMs: number;
|
|
53
|
+
loadStateKeys: number;
|
|
54
|
+
loadLegacyKeys: number;
|
|
55
|
+
persistFlushes: number;
|
|
56
|
+
persistWrites: number;
|
|
57
|
+
persistDeletes: number;
|
|
58
|
+
persistLastFlushMs: number;
|
|
59
|
+
sessionGcRuns: number;
|
|
60
|
+
sessionGcScanned: number;
|
|
61
|
+
sessionGcExpired: number;
|
|
62
|
+
sessionIndexRepairs: number;
|
|
63
|
+
transferGcRuns: number;
|
|
64
|
+
transferGcScanned: number;
|
|
65
|
+
transferGcExpired: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const STATE_PREFIX = "state:";
|
|
69
|
+
const SESSION_PREFIX = "session:";
|
|
70
|
+
const SESSION_PUBLIC_PREFIX = "session-public:";
|
|
71
|
+
const TRANSFER_PREFIX = "transfer:";
|
|
72
|
+
const INTERNAL_PREFIX = "$room:";
|
|
73
|
+
const SESSION_GC_LAST_RUN_KEY = `${INTERNAL_PREFIX}session-gc:last-run`;
|
|
74
|
+
const TRANSFER_GC_LAST_RUN_KEY = `${INTERNAL_PREFIX}transfer-gc:last-run`;
|
|
75
|
+
const DEFAULT_TRANSFER_EXPIRY_MS = 5 * 60 * 1000;
|
|
76
|
+
|
|
36
77
|
/**
|
|
37
78
|
* @class Server
|
|
38
79
|
* @implements {Party.Server}
|
|
@@ -89,6 +130,111 @@ export class Server implements Party.Server {
|
|
|
89
130
|
return this.room.storage
|
|
90
131
|
}
|
|
91
132
|
|
|
133
|
+
private stateKey(path: string) {
|
|
134
|
+
return `${STATE_PREFIX}${path}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private sessionKey(privateId: string) {
|
|
138
|
+
return `${SESSION_PREFIX}${privateId}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private sessionPublicKey(publicId: string) {
|
|
142
|
+
return `${SESSION_PUBLIC_PREFIX}${publicId}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private transferKey(token: string) {
|
|
146
|
+
return `${TRANSFER_PREFIX}${token}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private isInternalStorageKey(key: string) {
|
|
150
|
+
return key.startsWith(STATE_PREFIX)
|
|
151
|
+
|| key.startsWith(SESSION_PREFIX)
|
|
152
|
+
|| key.startsWith(SESSION_PUBLIC_PREFIX)
|
|
153
|
+
|| key.startsWith(TRANSFER_PREFIX)
|
|
154
|
+
|| key.startsWith(INTERNAL_PREFIX);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async listStorage<T = unknown>(prefix?: string) {
|
|
158
|
+
if (!prefix) {
|
|
159
|
+
return this.room.storage.list<T>();
|
|
160
|
+
}
|
|
161
|
+
return this.room.storage.list<T>({ prefix });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async loadStatePath<T = unknown>(path: string) {
|
|
165
|
+
return this.room.storage.get<T>(this.stateKey(path));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async saveStatePath(path: string, value: any) {
|
|
169
|
+
await this.room.storage.put(this.stateKey(path), value);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async putStorageEntries(entries: Record<string, any>) {
|
|
173
|
+
const keys = Object.keys(entries);
|
|
174
|
+
if (keys.length === 0) return;
|
|
175
|
+
if (keys.length === 1) {
|
|
176
|
+
const key = keys[0];
|
|
177
|
+
await this.room.storage.put(key, entries[key]);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await this.room.storage.put(entries);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async deleteStorageKeys(keys: string[]) {
|
|
184
|
+
const uniqueKeys = Array.from(new Set(keys));
|
|
185
|
+
if (uniqueKeys.length === 0) return;
|
|
186
|
+
if (uniqueKeys.length === 1) {
|
|
187
|
+
await this.room.storage.delete(uniqueKeys[0]);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await this.room.storage.delete(uniqueKeys);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async deleteStatePath(path: string) {
|
|
194
|
+
const stateKey = this.stateKey(path);
|
|
195
|
+
const descendantEntries = await this.listStorage(`${stateKey}.`);
|
|
196
|
+
await this.deleteStorageKeys([
|
|
197
|
+
...Array.from(descendantEntries.keys()),
|
|
198
|
+
path,
|
|
199
|
+
]);
|
|
200
|
+
await this.saveStatePath(path, DELETE_TOKEN);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private createStorageMetrics(): StorageMetrics {
|
|
204
|
+
return {
|
|
205
|
+
loadMs: 0,
|
|
206
|
+
loadStateKeys: 0,
|
|
207
|
+
loadLegacyKeys: 0,
|
|
208
|
+
persistFlushes: 0,
|
|
209
|
+
persistWrites: 0,
|
|
210
|
+
persistDeletes: 0,
|
|
211
|
+
persistLastFlushMs: 0,
|
|
212
|
+
sessionGcRuns: 0,
|
|
213
|
+
sessionGcScanned: 0,
|
|
214
|
+
sessionGcExpired: 0,
|
|
215
|
+
sessionIndexRepairs: 0,
|
|
216
|
+
transferGcRuns: 0,
|
|
217
|
+
transferGcScanned: 0,
|
|
218
|
+
transferGcExpired: 0,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private getTransferExpiryTime(subRoom: any) {
|
|
223
|
+
return subRoom?.transferExpiryTime
|
|
224
|
+
?? subRoom?.constructor?.prototype?.transferExpiryTime
|
|
225
|
+
?? subRoom?.constructor?.transferExpiryTime
|
|
226
|
+
?? DEFAULT_TRANSFER_EXPIRY_MS;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private getPrivateId(conn: Party.Connection) {
|
|
230
|
+
return (conn.state as any)?.privateId || conn.sessionId || conn.id;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private hasActiveSessionConnection(privateId: string) {
|
|
234
|
+
return Array.from(this.room.getConnections())
|
|
235
|
+
.some((conn) => this.getPrivateId(conn) === privateId);
|
|
236
|
+
}
|
|
237
|
+
|
|
92
238
|
async send(conn: Party.Connection, obj: any, subRoom: any) {
|
|
93
239
|
obj = structuredClone(obj);
|
|
94
240
|
if (subRoom.interceptorPacket) {
|
|
@@ -138,28 +284,37 @@ export class Server implements Party.Server {
|
|
|
138
284
|
const subRoom = await this.getSubRoom();
|
|
139
285
|
if (!subRoom) return;
|
|
140
286
|
|
|
287
|
+
const SESSION_EXPIRY_TIME = Number(options.sessionExpiryTime);
|
|
288
|
+
if (!Number.isFinite(SESSION_EXPIRY_TIME)) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
141
292
|
// Get active connections
|
|
142
293
|
const activeConnections = [...this.room.getConnections()];
|
|
143
|
-
const activePrivateIds = new Set(activeConnections.map(conn => conn
|
|
294
|
+
const activePrivateIds = new Set(activeConnections.map(conn => this.getPrivateId(conn)));
|
|
144
295
|
|
|
145
296
|
try {
|
|
146
297
|
// Get all sessions from storage
|
|
147
|
-
const sessions = await this.
|
|
298
|
+
const sessions = await this.listStorage<SessionData>(SESSION_PREFIX);
|
|
148
299
|
const users = this.getUsersProperty(subRoom);
|
|
149
300
|
const usersPropName = this.getUsersPropName(subRoom);
|
|
301
|
+
const metrics = subRoom.$storageMetrics as StorageMetrics | undefined;
|
|
302
|
+
if (metrics) {
|
|
303
|
+
metrics.sessionGcRuns += 1;
|
|
304
|
+
metrics.sessionGcScanned += sessions.size;
|
|
305
|
+
}
|
|
150
306
|
|
|
151
307
|
// Store valid publicIds from sessions
|
|
152
308
|
const validPublicIds = new Set<string>();
|
|
153
309
|
const expiredPublicIds = new Set<string>();
|
|
154
|
-
const SESSION_EXPIRY_TIME = options.sessionExpiryTime
|
|
155
310
|
const now = Date.now();
|
|
156
311
|
|
|
157
312
|
for (const [key, session] of sessions) {
|
|
158
313
|
// Only process session entries
|
|
159
|
-
if (!key.startsWith(
|
|
314
|
+
if (!key.startsWith(SESSION_PREFIX)) continue;
|
|
160
315
|
|
|
161
|
-
const privateId = key.
|
|
162
|
-
const typedSession = session as
|
|
316
|
+
const privateId = key.slice(SESSION_PREFIX.length);
|
|
317
|
+
const typedSession = session as SessionData;
|
|
163
318
|
|
|
164
319
|
// Check if session should be deleted based on:
|
|
165
320
|
// 1. Connection is not active
|
|
@@ -167,16 +322,22 @@ export class Server implements Party.Server {
|
|
|
167
322
|
// 3. Session is older than expiry time
|
|
168
323
|
if (!activePrivateIds.has(privateId) &&
|
|
169
324
|
!typedSession.connected &&
|
|
170
|
-
|
|
325
|
+
typedSession.disconnectedAt !== undefined &&
|
|
326
|
+
(now - typedSession.disconnectedAt) >= SESSION_EXPIRY_TIME) {
|
|
171
327
|
// Delete expired session
|
|
172
328
|
await this.deleteSession(privateId);
|
|
173
329
|
expiredPublicIds.add(typedSession.publicId);
|
|
330
|
+
if (metrics) {
|
|
331
|
+
metrics.sessionGcExpired += 1;
|
|
332
|
+
}
|
|
174
333
|
} else if (typedSession && typedSession.publicId) {
|
|
175
334
|
// Keep track of valid publicIds from active or recent sessions
|
|
176
335
|
validPublicIds.add(typedSession.publicId);
|
|
177
336
|
}
|
|
178
337
|
}
|
|
179
338
|
|
|
339
|
+
await this.repairSessionPublicIndexes(sessions, metrics);
|
|
340
|
+
|
|
180
341
|
// Clean up users only if ALL their sessions are expired
|
|
181
342
|
if (users && usersPropName) {
|
|
182
343
|
const currentUsers = users();
|
|
@@ -184,7 +345,6 @@ export class Server implements Party.Server {
|
|
|
184
345
|
// Only delete user if they have an expired session and no valid sessions
|
|
185
346
|
if (expiredPublicIds.has(publicId) && !validPublicIds.has(publicId)) {
|
|
186
347
|
delete currentUsers[publicId];
|
|
187
|
-
await this.room.storage.delete(`${usersPropName}.${publicId}`);
|
|
188
348
|
}
|
|
189
349
|
}
|
|
190
350
|
}
|
|
@@ -194,6 +354,177 @@ export class Server implements Party.Server {
|
|
|
194
354
|
}
|
|
195
355
|
}
|
|
196
356
|
|
|
357
|
+
private scheduleSessionGarbageCollector(sessionExpiryTime: number | undefined, privateId?: string) {
|
|
358
|
+
const normalizedSessionExpiryTime = Number(sessionExpiryTime);
|
|
359
|
+
if (!Number.isFinite(normalizedSessionExpiryTime) || normalizedSessionExpiryTime < 0) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
setTimeout(() => {
|
|
364
|
+
if (privateId) {
|
|
365
|
+
void this.expireDisconnectedSession(privateId, normalizedSessionExpiryTime);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
void this.garbageCollector({ sessionExpiryTime: normalizedSessionExpiryTime });
|
|
369
|
+
}, normalizedSessionExpiryTime);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private getSessionExpiryTime(subRoom: any) {
|
|
373
|
+
return subRoom?.sessionExpiryTime
|
|
374
|
+
?? subRoom?.constructor?.prototype?.sessionExpiryTime
|
|
375
|
+
?? subRoom?.constructor?.sessionExpiryTime;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async shouldRunSessionGarbageCollector(sessionExpiryTime: number | undefined) {
|
|
379
|
+
const normalizedSessionExpiryTime = Number(sessionExpiryTime);
|
|
380
|
+
if (!Number.isFinite(normalizedSessionExpiryTime) || normalizedSessionExpiryTime < 0) {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const now = Date.now();
|
|
385
|
+
const lastRun = await this.room.storage.get<number>(SESSION_GC_LAST_RUN_KEY);
|
|
386
|
+
if (lastRun && now - lastRun < normalizedSessionExpiryTime) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
await this.room.storage.put(SESSION_GC_LAST_RUN_KEY, now);
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private async shouldRunInterval(key: string, interval: number) {
|
|
395
|
+
const normalizedInterval = Number(interval);
|
|
396
|
+
if (!Number.isFinite(normalizedInterval) || normalizedInterval < 0) {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const now = Date.now();
|
|
401
|
+
const lastRun = await this.room.storage.get<number>(key);
|
|
402
|
+
if (lastRun && now - lastRun < normalizedInterval) {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
await this.room.storage.put(key, now);
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private async repairSessionPublicIndexes(
|
|
411
|
+
sessions?: Map<string, SessionData>,
|
|
412
|
+
metrics?: StorageMetrics
|
|
413
|
+
) {
|
|
414
|
+
const sessionEntries = sessions ?? await this.listStorage<SessionData>(SESSION_PREFIX);
|
|
415
|
+
const expected = new Map<string, string[]>();
|
|
416
|
+
|
|
417
|
+
for (const [key, session] of sessionEntries) {
|
|
418
|
+
if (!session?.publicId) continue;
|
|
419
|
+
const privateId = key.slice(SESSION_PREFIX.length);
|
|
420
|
+
const privateIds = expected.get(session.publicId) ?? [];
|
|
421
|
+
privateIds.push(privateId);
|
|
422
|
+
expected.set(session.publicId, privateIds);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const publicIndexes = await this.listStorage<string[]>(SESSION_PUBLIC_PREFIX);
|
|
426
|
+
const writes: Record<string, string[]> = {};
|
|
427
|
+
const deletes: string[] = [];
|
|
428
|
+
let repairs = 0;
|
|
429
|
+
|
|
430
|
+
for (const [publicId, privateIds] of expected) {
|
|
431
|
+
privateIds.sort();
|
|
432
|
+
const key = this.sessionPublicKey(publicId);
|
|
433
|
+
const existing = publicIndexes.get(key) ?? [];
|
|
434
|
+
const normalizedExisting = Array.isArray(existing) ? [...existing].sort() : [];
|
|
435
|
+
if (JSON.stringify(normalizedExisting) !== JSON.stringify(privateIds)) {
|
|
436
|
+
writes[key] = privateIds;
|
|
437
|
+
repairs += 1;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
for (const [key] of publicIndexes) {
|
|
442
|
+
const publicId = key.slice(SESSION_PUBLIC_PREFIX.length);
|
|
443
|
+
if (!expected.has(publicId)) {
|
|
444
|
+
deletes.push(key);
|
|
445
|
+
repairs += 1;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
await Promise.all([
|
|
450
|
+
this.putStorageEntries(writes),
|
|
451
|
+
this.deleteStorageKeys(deletes),
|
|
452
|
+
]);
|
|
453
|
+
|
|
454
|
+
if (metrics) {
|
|
455
|
+
metrics.sessionIndexRepairs += repairs;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private isTransferExpired(transfer: Partial<TransferData> | null | undefined, transferExpiryTime: number) {
|
|
460
|
+
if (!transfer) return true;
|
|
461
|
+
if (!Number.isFinite(transferExpiryTime) || transferExpiryTime < 0) {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
const created = Number(transfer.created);
|
|
465
|
+
if (!Number.isFinite(created)) {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
return Date.now() - created >= transferExpiryTime;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private async cleanupExpiredTransfers(transferExpiryTime: number, metrics?: StorageMetrics) {
|
|
472
|
+
if (!Number.isFinite(transferExpiryTime) || transferExpiryTime < 0) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const transfers = await this.listStorage<TransferData>(TRANSFER_PREFIX);
|
|
477
|
+
const expiredKeys: string[] = [];
|
|
478
|
+
for (const [key, transfer] of transfers) {
|
|
479
|
+
if (this.isTransferExpired(transfer, transferExpiryTime)) {
|
|
480
|
+
expiredKeys.push(key);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
await this.deleteStorageKeys(expiredKeys);
|
|
485
|
+
|
|
486
|
+
if (metrics) {
|
|
487
|
+
metrics.transferGcRuns += 1;
|
|
488
|
+
metrics.transferGcScanned += transfers.size;
|
|
489
|
+
metrics.transferGcExpired += expiredKeys.length;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private async expireDisconnectedSession(privateId: string, sessionExpiryTime: number) {
|
|
494
|
+
const session = await this.getSession(privateId);
|
|
495
|
+
if (!session || session.connected || session.disconnectedAt === undefined) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (this.hasActiveSessionConnection(privateId)) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const elapsed = Date.now() - session.disconnectedAt;
|
|
504
|
+
if (elapsed < sessionExpiryTime) {
|
|
505
|
+
setTimeout(() => {
|
|
506
|
+
void this.expireDisconnectedSession(privateId, sessionExpiryTime);
|
|
507
|
+
}, sessionExpiryTime - elapsed);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
await this.deleteSession(privateId);
|
|
512
|
+
|
|
513
|
+
const privateIds = await this.getSessionPrivateIds(session.publicId);
|
|
514
|
+
for (const otherPrivateId of privateIds) {
|
|
515
|
+
const otherSession = await this.getSession(otherPrivateId);
|
|
516
|
+
if (otherSession?.publicId === session.publicId) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const subRoom = await this.getSubRoom();
|
|
522
|
+
const users = this.getUsersProperty(subRoom);
|
|
523
|
+
if (users?.()[session.publicId]) {
|
|
524
|
+
delete users()[session.publicId];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
197
528
|
/**
|
|
198
529
|
* @method createRoom
|
|
199
530
|
* @private
|
|
@@ -231,22 +562,61 @@ export class Server implements Party.Server {
|
|
|
231
562
|
// Load the room's memory from storage
|
|
232
563
|
// This ensures persistence across server restarts
|
|
233
564
|
const loadMemory = async () => {
|
|
234
|
-
const
|
|
235
|
-
const
|
|
565
|
+
const startedAt = Date.now();
|
|
566
|
+
const metrics = instance.$storageMetrics as StorageMetrics;
|
|
567
|
+
const root = await this.loadStatePath(".");
|
|
568
|
+
const memory = await this.listStorage(STATE_PREFIX);
|
|
569
|
+
metrics.loadStateKeys = memory.size;
|
|
236
570
|
const tmpObject: any = root || {};
|
|
237
|
-
for (let [
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
if (key == ".") {
|
|
571
|
+
for (let [storageKey, value] of memory) {
|
|
572
|
+
const key = storageKey.slice(STATE_PREFIX.length);
|
|
573
|
+
if (key === ".") {
|
|
242
574
|
continue;
|
|
243
575
|
}
|
|
244
576
|
dset(tmpObject, key, value);
|
|
245
577
|
}
|
|
578
|
+
|
|
579
|
+
if (root === undefined && memory.size === 0) {
|
|
580
|
+
const legacyRoot = await this.room.storage.get(".");
|
|
581
|
+
const legacyMemory = await this.room.storage.list();
|
|
582
|
+
const legacyObject: any = legacyRoot || {};
|
|
583
|
+
const migratedEntries: Array<[string, any]> = [];
|
|
584
|
+
const legacyDeleteKeys: string[] = [];
|
|
585
|
+
|
|
586
|
+
if (legacyRoot !== undefined) {
|
|
587
|
+
migratedEntries.push([".", legacyRoot]);
|
|
588
|
+
legacyDeleteKeys.push(".");
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
for (let [key, value] of legacyMemory) {
|
|
592
|
+
if (key === "." || this.isInternalStorageKey(key)) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
dset(legacyObject, key, value);
|
|
596
|
+
migratedEntries.push([key, value]);
|
|
597
|
+
legacyDeleteKeys.push(key);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
metrics.loadLegacyKeys = migratedEntries.length;
|
|
601
|
+
await this.putStorageEntries(
|
|
602
|
+
Object.fromEntries(
|
|
603
|
+
migratedEntries.map(([path, value]) => [this.stateKey(path), value])
|
|
604
|
+
)
|
|
605
|
+
);
|
|
606
|
+
if (legacyRoot !== undefined) {
|
|
607
|
+
await this.deleteStorageKeys(legacyDeleteKeys);
|
|
608
|
+
}
|
|
609
|
+
load(instance, legacyObject, true);
|
|
610
|
+
metrics.loadMs = Date.now() - startedAt;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
246
614
|
load(instance, tmpObject, true);
|
|
615
|
+
metrics.loadMs = Date.now() - startedAt;
|
|
247
616
|
};
|
|
248
617
|
|
|
249
618
|
instance.$memoryAll = {}
|
|
619
|
+
instance.$storageMetrics = this.createStorageMetrics();
|
|
250
620
|
instance.$autoSync = instance["autoSync"] !== false; // Default to true
|
|
251
621
|
instance.$pendingSync = new Map<string, any>();
|
|
252
622
|
instance.$pendingInitialSync = new Map<Party.Connection, string>(); // Store connections waiting for initial sync with their publicId
|
|
@@ -333,17 +703,9 @@ export class Server implements Party.Server {
|
|
|
333
703
|
return null;
|
|
334
704
|
}
|
|
335
705
|
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
for (const [key, session] of sessions) {
|
|
341
|
-
if (key.startsWith('session:') && (session as any).publicId === publicId) {
|
|
342
|
-
userSession = session;
|
|
343
|
-
privateId = key.replace('session:', '');
|
|
344
|
-
break;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
706
|
+
const sessionEntry = await this.getSessionEntryByPublicId(publicId);
|
|
707
|
+
const userSession = sessionEntry?.session;
|
|
708
|
+
const privateId = sessionEntry?.privateId ?? null;
|
|
347
709
|
|
|
348
710
|
if (!userSession || !privateId) {
|
|
349
711
|
console.error(`[sessionTransfer] Session for publicId ${publicId} not found.`);
|
|
@@ -428,23 +790,91 @@ export class Server implements Party.Server {
|
|
|
428
790
|
values.clear();
|
|
429
791
|
return;
|
|
430
792
|
}
|
|
793
|
+
const startedAt = Date.now();
|
|
794
|
+
const stateWrites: Record<string, any> = {};
|
|
795
|
+
const deleteTasks: Promise<void>[] = [];
|
|
431
796
|
for (let [path, value] of values) {
|
|
432
797
|
const _instance =
|
|
433
798
|
path == "." ? instance : getByPath(instance, path);
|
|
434
|
-
const itemValue = createStatesSnapshot(_instance);
|
|
435
799
|
if (value == DELETE_TOKEN) {
|
|
436
|
-
|
|
800
|
+
deleteTasks.push(this.deleteStatePath(path));
|
|
437
801
|
} else {
|
|
438
|
-
|
|
802
|
+
const itemValue = _instance?.$snapshot
|
|
803
|
+
? createStatesSnapshot(_instance)
|
|
804
|
+
: value;
|
|
805
|
+
stateWrites[this.stateKey(path)] = itemValue;
|
|
439
806
|
}
|
|
440
807
|
}
|
|
808
|
+
await Promise.all([
|
|
809
|
+
this.putStorageEntries(stateWrites),
|
|
810
|
+
...deleteTasks,
|
|
811
|
+
]);
|
|
812
|
+
const metrics = instance.$storageMetrics as StorageMetrics | undefined;
|
|
813
|
+
if (metrics) {
|
|
814
|
+
metrics.persistFlushes += 1;
|
|
815
|
+
metrics.persistWrites += Object.keys(stateWrites).length;
|
|
816
|
+
metrics.persistDeletes += deleteTasks.length;
|
|
817
|
+
metrics.persistLastFlushMs = Date.now() - startedAt;
|
|
818
|
+
}
|
|
441
819
|
values.clear();
|
|
442
820
|
}
|
|
443
821
|
|
|
822
|
+
const debouncePersist = (wait: number) => {
|
|
823
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
824
|
+
let flushing = false;
|
|
825
|
+
const pending = new Map<string, any>();
|
|
826
|
+
|
|
827
|
+
const schedule = () => {
|
|
828
|
+
if (timeout) {
|
|
829
|
+
clearTimeout(timeout);
|
|
830
|
+
}
|
|
831
|
+
timeout = setTimeout(() => {
|
|
832
|
+
void flush();
|
|
833
|
+
}, wait);
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const flush = async () => {
|
|
837
|
+
timeout = null;
|
|
838
|
+
if (flushing) {
|
|
839
|
+
schedule();
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const values = new Map(pending);
|
|
844
|
+
pending.clear();
|
|
845
|
+
if (!values.size) {
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
flushing = true;
|
|
850
|
+
try {
|
|
851
|
+
await persistCb(values);
|
|
852
|
+
} finally {
|
|
853
|
+
flushing = false;
|
|
854
|
+
if (pending.size) {
|
|
855
|
+
schedule();
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
return (values: Map<string, any>) => {
|
|
861
|
+
if (initPersist) {
|
|
862
|
+
values.clear();
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
for (const [path, value] of values) {
|
|
867
|
+
pending.set(path, value);
|
|
868
|
+
}
|
|
869
|
+
values.clear();
|
|
870
|
+
schedule();
|
|
871
|
+
};
|
|
872
|
+
};
|
|
873
|
+
|
|
444
874
|
// Set up syncing and persistence with throttling to optimize performance
|
|
445
875
|
syncClass(instance, {
|
|
446
876
|
onSync: instance["throttleSync"] ? throttle(syncCb, instance["throttleSync"]) : syncCb,
|
|
447
|
-
onPersist: instance["throttleStorage"] ?
|
|
877
|
+
onPersist: instance["throttleStorage"] ? debouncePersist(instance["throttleStorage"]) : persistCb,
|
|
448
878
|
});
|
|
449
879
|
|
|
450
880
|
await loadMemory();
|
|
@@ -564,29 +994,107 @@ export class Server implements Party.Server {
|
|
|
564
994
|
* console.log(session);
|
|
565
995
|
* ```
|
|
566
996
|
*/
|
|
567
|
-
async getSession(privateId: string): Promise<
|
|
997
|
+
async getSession(privateId: string): Promise<SessionData | null> {
|
|
568
998
|
if (!privateId) return null;
|
|
569
999
|
try {
|
|
570
|
-
const session = await this.room.storage.get(
|
|
571
|
-
return session as
|
|
1000
|
+
const session = await this.room.storage.get(this.sessionKey(privateId));
|
|
1001
|
+
return session as SessionData | null;
|
|
572
1002
|
} catch (e) {
|
|
573
1003
|
return null;
|
|
574
1004
|
}
|
|
575
1005
|
}
|
|
576
1006
|
|
|
577
|
-
private async
|
|
1007
|
+
private async getSessionPrivateIds(publicId: string): Promise<string[]> {
|
|
1008
|
+
if (!publicId) return [];
|
|
1009
|
+
const privateIds = await this.room.storage.get<string[]>(this.sessionPublicKey(publicId));
|
|
1010
|
+
return Array.isArray(privateIds) ? privateIds : [];
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
private async saveSessionPrivateIds(publicId: string, privateIds: string[]) {
|
|
1014
|
+
const key = this.sessionPublicKey(publicId);
|
|
1015
|
+
if (privateIds.length === 0) {
|
|
1016
|
+
await this.room.storage.delete(key);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
await this.room.storage.put(key, privateIds);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
private async addSessionToPublicIndex(privateId: string, publicId: string) {
|
|
1023
|
+
const privateIds = await this.getSessionPrivateIds(publicId);
|
|
1024
|
+
if (privateIds.includes(privateId)) {
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
await this.saveSessionPrivateIds(publicId, [...privateIds, privateId]);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
private async removeSessionFromPublicIndex(privateId: string, publicId: string) {
|
|
1031
|
+
const privateIds = await this.getSessionPrivateIds(publicId);
|
|
1032
|
+
await this.saveSessionPrivateIds(
|
|
1033
|
+
publicId,
|
|
1034
|
+
privateIds.filter((id) => id !== privateId)
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private async getSessionEntryByPublicId(publicId: string): Promise<{ privateId: string; session: SessionData } | null> {
|
|
1039
|
+
const indexedPrivateIds = await this.getSessionPrivateIds(publicId);
|
|
1040
|
+
const stalePrivateIds: string[] = [];
|
|
1041
|
+
|
|
1042
|
+
for (const privateId of indexedPrivateIds) {
|
|
1043
|
+
const session = await this.getSession(privateId);
|
|
1044
|
+
if (session?.publicId === publicId) {
|
|
1045
|
+
return { privateId, session };
|
|
1046
|
+
}
|
|
1047
|
+
stalePrivateIds.push(privateId);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (stalePrivateIds.length) {
|
|
1051
|
+
await this.saveSessionPrivateIds(
|
|
1052
|
+
publicId,
|
|
1053
|
+
indexedPrivateIds.filter((id) => !stalePrivateIds.includes(id))
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const sessions = await this.listStorage<SessionData>(SESSION_PREFIX);
|
|
1058
|
+
for (const [key, session] of sessions) {
|
|
1059
|
+
const privateId = key.slice(SESSION_PREFIX.length);
|
|
1060
|
+
if (session?.publicId) {
|
|
1061
|
+
await this.addSessionToPublicIndex(privateId, session.publicId);
|
|
1062
|
+
}
|
|
1063
|
+
if (session?.publicId === publicId) {
|
|
1064
|
+
return { privateId, session };
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private async saveSession(privateId: string, data: SessionData) {
|
|
1072
|
+
const existingSession = await this.getSession(privateId);
|
|
578
1073
|
const sessionData = {
|
|
579
1074
|
...data,
|
|
580
1075
|
created: data.created || Date.now(),
|
|
581
1076
|
connected: data.connected !== undefined ? data.connected : true
|
|
582
1077
|
};
|
|
583
|
-
await this.room.storage.put(
|
|
1078
|
+
await this.room.storage.put(this.sessionKey(privateId), sessionData);
|
|
1079
|
+
if (existingSession?.publicId && existingSession.publicId !== sessionData.publicId) {
|
|
1080
|
+
await this.removeSessionFromPublicIndex(privateId, existingSession.publicId);
|
|
1081
|
+
}
|
|
1082
|
+
await this.addSessionToPublicIndex(privateId, sessionData.publicId);
|
|
584
1083
|
}
|
|
585
1084
|
|
|
586
1085
|
private async updateSessionConnection(privateId: string, connected: boolean) {
|
|
587
1086
|
const session = await this.getSession(privateId);
|
|
588
1087
|
if (session) {
|
|
589
|
-
|
|
1088
|
+
const nextSession = { ...session, connected };
|
|
1089
|
+
if (connected) {
|
|
1090
|
+
delete nextSession.disconnectedAt;
|
|
1091
|
+
} else {
|
|
1092
|
+
nextSession.disconnectedAt = Date.now();
|
|
1093
|
+
}
|
|
1094
|
+
if (!await this.getSession(privateId)) {
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
await this.saveSession(privateId, nextSession);
|
|
590
1098
|
}
|
|
591
1099
|
}
|
|
592
1100
|
|
|
@@ -602,7 +1110,11 @@ export class Server implements Party.Server {
|
|
|
602
1110
|
* ```
|
|
603
1111
|
*/
|
|
604
1112
|
async deleteSession(privateId: string) {
|
|
605
|
-
await this.
|
|
1113
|
+
const session = await this.getSession(privateId);
|
|
1114
|
+
await this.room.storage.delete(this.sessionKey(privateId));
|
|
1115
|
+
if (session?.publicId) {
|
|
1116
|
+
await this.removeSessionFromPublicIndex(privateId, session.publicId);
|
|
1117
|
+
}
|
|
606
1118
|
}
|
|
607
1119
|
|
|
608
1120
|
async onConnectClient(conn: Party.Connection, ctx: Party.ConnectionContext) {
|
|
@@ -615,11 +1127,17 @@ export class Server implements Party.Server {
|
|
|
615
1127
|
return;
|
|
616
1128
|
}
|
|
617
1129
|
|
|
618
|
-
const sessionExpiryTime =
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
1130
|
+
const sessionExpiryTime = this.getSessionExpiryTime(subRoom);
|
|
1131
|
+
if (await this.shouldRunSessionGarbageCollector(sessionExpiryTime)) {
|
|
1132
|
+
await this.garbageCollector({ sessionExpiryTime });
|
|
1133
|
+
}
|
|
1134
|
+
const transferExpiryTime = this.getTransferExpiryTime(subRoom);
|
|
1135
|
+
if (await this.shouldRunInterval(TRANSFER_GC_LAST_RUN_KEY, transferExpiryTime)) {
|
|
1136
|
+
await this.cleanupExpiredTransfers(
|
|
1137
|
+
transferExpiryTime,
|
|
1138
|
+
subRoom.$storageMetrics as StorageMetrics | undefined
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
623
1141
|
|
|
624
1142
|
// Check room guards
|
|
625
1143
|
const roomGuards = subRoom.constructor['_roomGuards'] || [];
|
|
@@ -639,14 +1157,20 @@ export class Server implements Party.Server {
|
|
|
639
1157
|
}
|
|
640
1158
|
let transferData: any = null;
|
|
641
1159
|
if (transferToken) {
|
|
642
|
-
|
|
1160
|
+
const transferKey = this.transferKey(transferToken);
|
|
1161
|
+
transferData = await this.room.storage.get<TransferData>(transferKey);
|
|
643
1162
|
if (transferData) {
|
|
644
|
-
|
|
1163
|
+
if (this.isTransferExpired(transferData, transferExpiryTime)) {
|
|
1164
|
+
transferData = null;
|
|
1165
|
+
}
|
|
1166
|
+
await this.room.storage.delete(transferKey);
|
|
645
1167
|
}
|
|
646
1168
|
}
|
|
647
1169
|
|
|
648
1170
|
// Check for existing session
|
|
649
|
-
const
|
|
1171
|
+
const requestedPrivateId = this.getPrivateId(conn);
|
|
1172
|
+
const privateId = transferData?.privateId || requestedPrivateId;
|
|
1173
|
+
const existingSession = await this.getSession(privateId)
|
|
650
1174
|
|
|
651
1175
|
// Generate IDs
|
|
652
1176
|
const publicId = existingSession?.publicId || transferData?.publicId || generateShortUUID();
|
|
@@ -667,7 +1191,7 @@ export class Server implements Party.Server {
|
|
|
667
1191
|
user = isClass(classType) ? new classType() : classType(conn, ctx);
|
|
668
1192
|
signal()[publicId] = user;
|
|
669
1193
|
const snapshot = createStatesSnapshotDeep(user);
|
|
670
|
-
this.
|
|
1194
|
+
await this.saveStatePath(`${usersPropName}.${publicId}`, snapshot);
|
|
671
1195
|
}
|
|
672
1196
|
}
|
|
673
1197
|
else {
|
|
@@ -677,13 +1201,12 @@ export class Server implements Party.Server {
|
|
|
677
1201
|
// Only store new session if it doesn't exist
|
|
678
1202
|
if (!existingSession) {
|
|
679
1203
|
// Use the transferred privateId if available, otherwise use connection id
|
|
680
|
-
|
|
681
|
-
await this.saveSession(sessionPrivateId, {
|
|
1204
|
+
await this.saveSession(privateId, {
|
|
682
1205
|
publicId
|
|
683
1206
|
});
|
|
684
1207
|
}
|
|
685
1208
|
else {
|
|
686
|
-
await this.updateSessionConnection(
|
|
1209
|
+
await this.updateSessionConnection(privateId, true);
|
|
687
1210
|
}
|
|
688
1211
|
}
|
|
689
1212
|
// Update user connection status if applicable
|
|
@@ -692,7 +1215,8 @@ export class Server implements Party.Server {
|
|
|
692
1215
|
// Store both IDs in connection state
|
|
693
1216
|
conn.setState({
|
|
694
1217
|
...conn.state,
|
|
695
|
-
publicId
|
|
1218
|
+
publicId,
|
|
1219
|
+
privateId
|
|
696
1220
|
});
|
|
697
1221
|
|
|
698
1222
|
// Call the room's onJoin method if it exists
|
|
@@ -732,6 +1256,10 @@ export class Server implements Party.Server {
|
|
|
732
1256
|
*/
|
|
733
1257
|
async onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
|
|
734
1258
|
if (ctx.request?.headers.has('x-shard-id')) {
|
|
1259
|
+
if (!this.isAuthorizedShardRequest(ctx.request)) {
|
|
1260
|
+
conn.close();
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
735
1263
|
this.onConnectShard(conn, ctx);
|
|
736
1264
|
}
|
|
737
1265
|
else {
|
|
@@ -739,6 +1267,13 @@ export class Server implements Party.Server {
|
|
|
739
1267
|
}
|
|
740
1268
|
}
|
|
741
1269
|
|
|
1270
|
+
private isAuthorizedShardRequest(req?: Party.Request) {
|
|
1271
|
+
const shardSecret = this.room.env.SHARD_SECRET;
|
|
1272
|
+
return typeof shardSecret === 'string'
|
|
1273
|
+
&& shardSecret.length > 0
|
|
1274
|
+
&& req?.headers.get('x-access-shard') === shardSecret;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
742
1277
|
/**
|
|
743
1278
|
* @method onConnectShard
|
|
744
1279
|
* @private
|
|
@@ -750,9 +1285,11 @@ export class Server implements Party.Server {
|
|
|
750
1285
|
onConnectShard(conn: Party.Connection, ctx: Party.ConnectionContext) {
|
|
751
1286
|
// Set shard metadata in connection state
|
|
752
1287
|
const shardId = ctx.request?.headers.get('x-shard-id') || 'unknown-shard';
|
|
1288
|
+
const worldId = ctx.request?.headers.get('x-shard-world-id') || 'world-default';
|
|
753
1289
|
conn.setState({
|
|
754
1290
|
shard: true,
|
|
755
1291
|
shardId,
|
|
1292
|
+
worldId,
|
|
756
1293
|
clients: new Map() // Track clients connected through this shard
|
|
757
1294
|
});
|
|
758
1295
|
}
|
|
@@ -1088,14 +1625,19 @@ export class Server implements Party.Server {
|
|
|
1088
1625
|
return;
|
|
1089
1626
|
}
|
|
1090
1627
|
|
|
1091
|
-
const privateId = conn
|
|
1628
|
+
const privateId = this.getPrivateId(conn);
|
|
1092
1629
|
const { publicId } = conn.state as any;
|
|
1093
1630
|
const user = signal?.()[publicId];
|
|
1094
1631
|
|
|
1095
1632
|
if (!user) return;
|
|
1096
1633
|
|
|
1634
|
+
if (this.hasActiveSessionConnection(privateId)) {
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1097
1638
|
// Mark session as disconnected instead of deleting it
|
|
1098
1639
|
await this.updateSessionConnection(privateId, false);
|
|
1640
|
+
this.scheduleSessionGarbageCollector(this.getSessionExpiryTime(subRoom), privateId);
|
|
1099
1641
|
|
|
1100
1642
|
// Update user connection status in the signal
|
|
1101
1643
|
const connectionUpdated = this.updateUserConnectionStatus(user, false);
|
|
@@ -1145,6 +1687,9 @@ export class Server implements Party.Server {
|
|
|
1145
1687
|
}
|
|
1146
1688
|
|
|
1147
1689
|
if (isFromShard) {
|
|
1690
|
+
if (!this.isAuthorizedShardRequest(req)) {
|
|
1691
|
+
return res.unauthorized('Invalid shard credentials');
|
|
1692
|
+
}
|
|
1148
1693
|
return this.handleShardRequest(req, res, shardId);
|
|
1149
1694
|
}
|
|
1150
1695
|
|
|
@@ -1219,16 +1764,17 @@ export class Server implements Party.Server {
|
|
|
1219
1764
|
load(user, hydratedSnapshot, true);
|
|
1220
1765
|
|
|
1221
1766
|
// Save user snapshot to storage
|
|
1222
|
-
await this.
|
|
1767
|
+
await this.saveStatePath(`${usersPropName}.${publicId}`, userSnapshot);
|
|
1223
1768
|
}
|
|
1224
1769
|
}
|
|
1225
1770
|
|
|
1226
1771
|
// Generate transfer token for the client to use when connecting
|
|
1227
1772
|
const transferToken = generateShortUUID();
|
|
1228
|
-
await this.room.storage.put(
|
|
1773
|
+
await this.room.storage.put(this.transferKey(transferToken), {
|
|
1229
1774
|
privateId,
|
|
1230
1775
|
publicId,
|
|
1231
|
-
restored: true
|
|
1776
|
+
restored: true,
|
|
1777
|
+
created: Date.now(),
|
|
1232
1778
|
});
|
|
1233
1779
|
|
|
1234
1780
|
return res.success({ transferToken });
|
|
@@ -1292,7 +1838,9 @@ export class Server implements Party.Server {
|
|
|
1292
1838
|
|
|
1293
1839
|
const url = new URL(req.url);
|
|
1294
1840
|
const method = req.method;
|
|
1295
|
-
|
|
1841
|
+
let pathname = url.pathname;
|
|
1842
|
+
|
|
1843
|
+
pathname = '/' + pathname.split('/').slice(4).join('/');
|
|
1296
1844
|
|
|
1297
1845
|
// Check each registered handler
|
|
1298
1846
|
for (const [routeKey, handler] of requestHandlers.entries()) {
|
|
@@ -1326,18 +1874,16 @@ export class Server implements Party.Server {
|
|
|
1326
1874
|
if (handler.bodyValidation && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
1327
1875
|
try {
|
|
1328
1876
|
const contentType = req.headers.get('content-type') || '';
|
|
1329
|
-
if (
|
|
1330
|
-
|
|
1877
|
+
if (contentType.includes('application/json')) {
|
|
1878
|
+
const body = await req.json();
|
|
1879
|
+
const validation = handler.bodyValidation.safeParse(body);
|
|
1880
|
+
if (!validation.success) {
|
|
1881
|
+
return res.badRequest("Invalid request body", {
|
|
1882
|
+
details: validation.error
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
bodyData = validation.data;
|
|
1331
1886
|
}
|
|
1332
|
-
|
|
1333
|
-
const body = await req.json();
|
|
1334
|
-
const validation = handler.bodyValidation.safeParse(body);
|
|
1335
|
-
if (!validation.success) {
|
|
1336
|
-
return res.badRequest("Invalid request body", {
|
|
1337
|
-
details: validation.error
|
|
1338
|
-
});
|
|
1339
|
-
}
|
|
1340
|
-
bodyData = validation.data;
|
|
1341
1887
|
} catch (error) {
|
|
1342
1888
|
return res.badRequest("Failed to parse request body");
|
|
1343
1889
|
}
|
|
@@ -1375,7 +1921,14 @@ export class Server implements Party.Server {
|
|
|
1375
1921
|
* @returns {boolean} True if the paths match
|
|
1376
1922
|
*/
|
|
1377
1923
|
private pathMatches(requestPath: string, handlerPath: string): boolean {
|
|
1378
|
-
|
|
1924
|
+
// Convert handler path pattern to regex
|
|
1925
|
+
// Replace :param with named capture groups
|
|
1926
|
+
const pathRegexString = handlerPath
|
|
1927
|
+
.replace(/\//g, '\\/') // Escape slashes
|
|
1928
|
+
.replace(/:([^\/]+)/g, '([^/]+)'); // Convert :params to capture groups
|
|
1929
|
+
|
|
1930
|
+
const pathRegex = new RegExp(`^${pathRegexString}`);
|
|
1931
|
+
return pathRegex.test(requestPath);
|
|
1379
1932
|
}
|
|
1380
1933
|
|
|
1381
1934
|
/**
|
|
@@ -1397,7 +1950,12 @@ export class Server implements Party.Server {
|
|
|
1397
1950
|
}
|
|
1398
1951
|
});
|
|
1399
1952
|
|
|
1400
|
-
|
|
1953
|
+
// Extract parameter values from request path
|
|
1954
|
+
const pathRegexString = handlerPath
|
|
1955
|
+
.replace(/\//g, '\\/') // Escape slashes
|
|
1956
|
+
.replace(/:([^\/]+)/g, '([^/]+)'); // Convert :params to capture groups
|
|
1957
|
+
|
|
1958
|
+
const pathRegex = new RegExp(`^${pathRegexString}`);
|
|
1401
1959
|
const matches = requestPath.match(pathRegex);
|
|
1402
1960
|
|
|
1403
1961
|
if (matches && matches.length > 1) {
|
|
@@ -1410,28 +1968,6 @@ export class Server implements Party.Server {
|
|
|
1410
1968
|
return params;
|
|
1411
1969
|
}
|
|
1412
1970
|
|
|
1413
|
-
private normalizeRequestPath(pathname: string): string {
|
|
1414
|
-
const parts = pathname.split('/').filter(Boolean);
|
|
1415
|
-
if (parts[0] === 'parties' && parts.length >= 3) {
|
|
1416
|
-
const routePath = parts.slice(3).join('/');
|
|
1417
|
-
return routePath ? `/${routePath}` : '/';
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
return pathname || '/';
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
private pathPatternToRegex(handlerPath: string): RegExp {
|
|
1424
|
-
const segments = handlerPath.split('/').map(segment => {
|
|
1425
|
-
if (segment.startsWith(':')) {
|
|
1426
|
-
return '([^/]+)';
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1430
|
-
});
|
|
1431
|
-
|
|
1432
|
-
return new RegExp(`^${segments.join('/')}$`);
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
1971
|
/**
|
|
1436
1972
|
* @method handleShardRequest
|
|
1437
1973
|
* @private
|