@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/chunk-EUXUH3YW.js +15 -0
  3. package/dist/chunk-EUXUH3YW.js.map +1 -0
  4. package/dist/cloudflare/index.d.ts +71 -0
  5. package/dist/cloudflare/index.js +320 -0
  6. package/dist/cloudflare/index.js.map +1 -0
  7. package/dist/index.d.ts +65 -188
  8. package/dist/index.js +742 -146
  9. package/dist/index.js.map +1 -1
  10. package/dist/node/index.d.ts +164 -0
  11. package/dist/node/index.js +786 -0
  12. package/dist/node/index.js.map +1 -0
  13. package/dist/party-dNs-hqkq.d.ts +175 -0
  14. package/examples/cloudflare/README.md +62 -0
  15. package/examples/cloudflare/node_modules/.bin/tsc +17 -0
  16. package/examples/cloudflare/node_modules/.bin/tsserver +17 -0
  17. package/examples/cloudflare/node_modules/.bin/wrangler +17 -0
  18. package/examples/cloudflare/node_modules/.bin/wrangler2 +17 -0
  19. package/examples/cloudflare/package.json +24 -0
  20. package/examples/cloudflare/public/index.html +443 -0
  21. package/examples/cloudflare/src/index.ts +28 -0
  22. package/examples/cloudflare/src/room.ts +44 -0
  23. package/examples/cloudflare/tsconfig.json +10 -0
  24. package/examples/cloudflare/wrangler.jsonc +25 -0
  25. package/examples/node/README.md +57 -0
  26. package/examples/node/node_modules/.bin/tsc +17 -0
  27. package/examples/node/node_modules/.bin/tsserver +17 -0
  28. package/examples/node/node_modules/.bin/tsx +17 -0
  29. package/examples/node/package.json +23 -0
  30. package/examples/node/public/index.html +443 -0
  31. package/examples/node/room.ts +44 -0
  32. package/examples/node/server.sqlite.ts +52 -0
  33. package/examples/node/server.ts +51 -0
  34. package/examples/node/tsconfig.json +10 -0
  35. package/examples/node-game/README.md +66 -0
  36. package/examples/node-game/package.json +23 -0
  37. package/examples/node-game/public/index.html +705 -0
  38. package/examples/node-game/room.ts +145 -0
  39. package/examples/node-game/server.sqlite.ts +54 -0
  40. package/examples/node-game/server.ts +53 -0
  41. package/examples/node-game/tsconfig.json +10 -0
  42. package/examples/node-shard/README.md +32 -0
  43. package/examples/node-shard/dev.ts +39 -0
  44. package/examples/node-shard/package.json +24 -0
  45. package/examples/node-shard/public/index.html +777 -0
  46. package/examples/node-shard/room-server.ts +68 -0
  47. package/examples/node-shard/room.ts +105 -0
  48. package/examples/node-shard/shared.ts +6 -0
  49. package/examples/node-shard/tsconfig.json +14 -0
  50. package/examples/node-shard/world-server.ts +169 -0
  51. package/package.json +14 -5
  52. package/readme.md +377 -11
  53. package/src/cloudflare/index.ts +474 -0
  54. package/src/mock.ts +29 -7
  55. package/src/node/index.ts +1112 -0
  56. package/src/server.ts +626 -90
  57. package/src/session.guard.ts +6 -2
  58. package/src/shard.ts +91 -23
  59. package/src/storage.ts +29 -5
  60. package/src/testing.ts +4 -3
  61. package/src/types/party.ts +4 -1
  62. package/src/world.guard.ts +23 -4
  63. package/src/world.ts +170 -79
  64. package/examples/game/.vscode/launch.json +0 -11
  65. package/examples/game/.vscode/settings.json +0 -11
  66. package/examples/game/README.md +0 -40
  67. package/examples/game/app/client.tsx +0 -15
  68. package/examples/game/app/components/Admin.tsx +0 -1089
  69. package/examples/game/app/components/Room.tsx +0 -162
  70. package/examples/game/app/styles.css +0 -31
  71. package/examples/game/package-lock.json +0 -225
  72. package/examples/game/package.json +0 -20
  73. package/examples/game/party/game.room.ts +0 -32
  74. package/examples/game/party/server.ts +0 -10
  75. package/examples/game/party/shard.ts +0 -5
  76. package/examples/game/partykit.json +0 -14
  77. package/examples/game/public/favicon.ico +0 -0
  78. package/examples/game/public/index.html +0 -27
  79. package/examples/game/public/normalize.css +0 -351
  80. package/examples/game/shared/room.schema.ts +0 -14
  81. 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.id));
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.room.storage.list();
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('session:')) continue;
314
+ if (!key.startsWith(SESSION_PREFIX)) continue;
160
315
 
161
- const privateId = key.replace('session:', '');
162
- const typedSession = session as { publicId: string, created: number, connected: boolean };
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
- (now - typedSession.created) > SESSION_EXPIRY_TIME) {
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 root = await this.room.storage.get(".");
235
- const memory = await this.room.storage.list();
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 [key, value] of memory) {
238
- if (key.startsWith('session:')) {
239
- continue;
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 sessions = await this.room.storage.list();
337
- let userSession: any = null;
338
- let privateId: string | null = null;
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
- await this.room.storage.delete(path);
800
+ deleteTasks.push(this.deleteStatePath(path));
437
801
  } else {
438
- await this.room.storage.put(path, itemValue);
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"] ? throttle(persistCb, instance["throttleStorage"]) : persistCb,
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<{ publicId: string, state?: any, created?: number, connected?: boolean } | null> {
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(`session:${privateId}`);
571
- return session as { publicId: string, state?: any, created: number, connected: boolean } | null;
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 saveSession(privateId: string, data: { publicId: string, state?: any, created?: number, connected?: boolean }) {
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(`session:${privateId}`, sessionData);
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
- await this.saveSession(privateId, { ...session, connected });
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.room.storage.delete(`session:${privateId}`);
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
- subRoom.sessionExpiryTime ??
620
- subRoom.constructor.sessionExpiryTime ??
621
- 5 * 60 * 1000;
622
- await this.garbageCollector({ sessionExpiryTime });
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
- transferData = await this.room.storage.get(`transfer:${transferToken}`);
1160
+ const transferKey = this.transferKey(transferToken);
1161
+ transferData = await this.room.storage.get<TransferData>(transferKey);
643
1162
  if (transferData) {
644
- await this.room.storage.delete(`transfer:${transferToken}`);
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 existingSession = await this.getSession(conn.id)
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.room.storage.put(`${usersPropName}.${publicId}`, snapshot);
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
- const sessionPrivateId = transferData?.privateId || conn.id;
681
- await this.saveSession(sessionPrivateId, {
1204
+ await this.saveSession(privateId, {
682
1205
  publicId
683
1206
  });
684
1207
  }
685
1208
  else {
686
- await this.updateSessionConnection(conn.id, true);
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.id;
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.room.storage.put(`${usersPropName}.${publicId}`, userSnapshot);
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(`transfer:${transferToken}`, {
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
- const pathname = this.normalizeRequestPath(url.pathname);
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 (!contentType.includes('application/json')) {
1330
- return res.badRequest("Content-Type must be application/json");
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
- return this.pathPatternToRegex(handlerPath).test(requestPath);
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
- const pathRegex = this.pathPatternToRegex(handlerPath);
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