@signe/room 2.10.0 → 3.0.1

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 (84) hide show
  1. package/CHANGELOG.md +7 -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 +87 -188
  8. package/dist/index.js +860 -114
  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 +418 -4
  53. package/src/cloudflare/index.ts +474 -0
  54. package/src/index.ts +2 -2
  55. package/src/jwt.ts +1 -5
  56. package/src/mock.ts +29 -7
  57. package/src/node/index.ts +1112 -0
  58. package/src/server.ts +781 -60
  59. package/src/session.guard.ts +6 -2
  60. package/src/shard.ts +91 -23
  61. package/src/storage.ts +29 -5
  62. package/src/testing.ts +4 -3
  63. package/src/types/party.ts +30 -1
  64. package/src/world.guard.ts +23 -4
  65. package/src/world.ts +121 -21
  66. package/tests/storage-restore.spec.ts +122 -0
  67. package/examples/game/.vscode/launch.json +0 -11
  68. package/examples/game/.vscode/settings.json +0 -11
  69. package/examples/game/README.md +0 -40
  70. package/examples/game/app/client.tsx +0 -15
  71. package/examples/game/app/components/Admin.tsx +0 -1089
  72. package/examples/game/app/components/Room.tsx +0 -162
  73. package/examples/game/app/styles.css +0 -31
  74. package/examples/game/package-lock.json +0 -225
  75. package/examples/game/package.json +0 -20
  76. package/examples/game/party/game.room.ts +0 -32
  77. package/examples/game/party/server.ts +0 -10
  78. package/examples/game/party/shard.ts +0 -5
  79. package/examples/game/partykit.json +0 -14
  80. package/examples/game/public/favicon.ico +0 -0
  81. package/examples/game/public/index.html +0 -27
  82. package/examples/game/public/normalize.css +0 -351
  83. package/examples/game/shared/room.schema.ts +0 -14
  84. package/examples/game/tsconfig.json +0 -109
package/src/server.ts CHANGED
@@ -33,6 +33,66 @@ type CreateRoomOptions = {
33
33
  throttleStorage?: number;
34
34
  };
35
35
 
36
+ export type StorageRestoreContext<TSnapshot = any> = {
37
+ snapshot: TSnapshot;
38
+ room: Party.Room;
39
+ server: Server;
40
+ legacy: boolean;
41
+ };
42
+
43
+ export type UserStorageRestoreContext<TUser = any, TSnapshot = any> = {
44
+ userSnapshot: TSnapshot;
45
+ user: TUser | undefined;
46
+ publicId: string;
47
+ usersPropName: string;
48
+ room: Party.Room;
49
+ server: Server;
50
+ legacy: boolean;
51
+ };
52
+
53
+ type SessionData = {
54
+ publicId: string;
55
+ state?: any;
56
+ created?: number;
57
+ connected?: boolean;
58
+ disconnectedAt?: number;
59
+ };
60
+
61
+ type TransferData = {
62
+ privateId: string;
63
+ publicId: string;
64
+ restored: boolean;
65
+ created: number;
66
+ };
67
+
68
+ type StorageMetrics = {
69
+ loadMs: number;
70
+ loadStateKeys: number;
71
+ loadLegacyKeys: number;
72
+ stateCompactions: number;
73
+ stateCompactionDeletes: number;
74
+ persistFlushes: number;
75
+ persistWrites: number;
76
+ persistDeletes: number;
77
+ persistLastFlushMs: number;
78
+ sessionGcRuns: number;
79
+ sessionGcScanned: number;
80
+ sessionGcExpired: number;
81
+ sessionIndexRepairs: number;
82
+ transferGcRuns: number;
83
+ transferGcScanned: number;
84
+ transferGcExpired: number;
85
+ };
86
+
87
+ const STATE_PREFIX = "state:";
88
+ const SESSION_PREFIX = "session:";
89
+ const SESSION_PUBLIC_PREFIX = "session-public:";
90
+ const TRANSFER_PREFIX = "transfer:";
91
+ const INTERNAL_PREFIX = "$room:";
92
+ const SESSION_GC_LAST_RUN_KEY = `${INTERNAL_PREFIX}session-gc:last-run`;
93
+ const TRANSFER_GC_LAST_RUN_KEY = `${INTERNAL_PREFIX}transfer-gc:last-run`;
94
+ const DEFAULT_TRANSFER_EXPIRY_MS = 5 * 60 * 1000;
95
+
36
96
  /**
37
97
  * @class Server
38
98
  * @implements {Party.Server}
@@ -89,6 +149,234 @@ export class Server implements Party.Server {
89
149
  return this.room.storage
90
150
  }
91
151
 
152
+ private stateKey(path: string) {
153
+ return `${STATE_PREFIX}${path}`;
154
+ }
155
+
156
+ private sessionKey(privateId: string) {
157
+ return `${SESSION_PREFIX}${privateId}`;
158
+ }
159
+
160
+ private sessionPublicKey(publicId: string) {
161
+ return `${SESSION_PUBLIC_PREFIX}${publicId}`;
162
+ }
163
+
164
+ private transferKey(token: string) {
165
+ return `${TRANSFER_PREFIX}${token}`;
166
+ }
167
+
168
+ private isInternalStorageKey(key: string) {
169
+ return key.startsWith(STATE_PREFIX)
170
+ || key.startsWith(SESSION_PREFIX)
171
+ || key.startsWith(SESSION_PUBLIC_PREFIX)
172
+ || key.startsWith(TRANSFER_PREFIX)
173
+ || key.startsWith(INTERNAL_PREFIX);
174
+ }
175
+
176
+ private async listStorage<T = unknown>(prefix?: string) {
177
+ if (!prefix) {
178
+ return this.room.storage.list<T>();
179
+ }
180
+ return this.room.storage.list<T>({ prefix });
181
+ }
182
+
183
+ private async loadStatePath<T = unknown>(path: string) {
184
+ return this.room.storage.get<T>(this.stateKey(path));
185
+ }
186
+
187
+ private async saveStatePath(path: string, value: any) {
188
+ await this.room.storage.put(this.stateKey(path), value);
189
+ }
190
+
191
+ private async putStorageEntries(entries: Record<string, any>) {
192
+ const keys = Object.keys(entries);
193
+ if (keys.length === 0) return;
194
+ if (keys.length === 1) {
195
+ const key = keys[0];
196
+ await this.room.storage.put(key, entries[key]);
197
+ return;
198
+ }
199
+ await this.room.storage.put(entries);
200
+ }
201
+
202
+ private async deleteStorageKeys(keys: string[]) {
203
+ const uniqueKeys = Array.from(new Set(keys));
204
+ if (uniqueKeys.length === 0) return;
205
+ if (uniqueKeys.length === 1) {
206
+ await this.room.storage.delete(uniqueKeys[0]);
207
+ return;
208
+ }
209
+ await this.room.storage.delete(uniqueKeys);
210
+ }
211
+
212
+ private async deleteStatePath(path: string) {
213
+ const stateKey = this.stateKey(path);
214
+ const descendantEntries = await this.listStorage(`${stateKey}.`);
215
+ await this.deleteStorageKeys([
216
+ ...Array.from(descendantEntries.keys()),
217
+ stateKey,
218
+ ]);
219
+ await this.saveStatePath(path, DELETE_TOKEN);
220
+ }
221
+
222
+ private containsDeleteToken(value: any): boolean {
223
+ if (value === DELETE_TOKEN) {
224
+ return true;
225
+ }
226
+ if (!value || typeof value !== "object") {
227
+ return false;
228
+ }
229
+ if (Array.isArray(value)) {
230
+ return value.some((item) => this.containsDeleteToken(item));
231
+ }
232
+ return Object.values(value).some((item) => this.containsDeleteToken(item));
233
+ }
234
+
235
+ private async compactStateStorage(instance: any) {
236
+ const entries = await this.listStorage(STATE_PREFIX);
237
+ const keys = Array.from(entries.keys());
238
+ if (keys.length === 0) {
239
+ return;
240
+ }
241
+
242
+ const snapshot = createStatesSnapshotDeep(instance);
243
+ await this.deleteStorageKeys(keys);
244
+ await this.saveStatePath(".", snapshot);
245
+
246
+ const metrics = instance.$storageMetrics as StorageMetrics | undefined;
247
+ if (metrics) {
248
+ metrics.stateCompactions += 1;
249
+ metrics.stateCompactionDeletes += keys.length;
250
+ }
251
+ }
252
+
253
+ private createUserFromClassType(classType: any, ...args: any[]) {
254
+ if (!classType) {
255
+ return undefined;
256
+ }
257
+ return isClass(classType) ? new classType() : classType(...args);
258
+ }
259
+
260
+ private async restoreUsersStorageSnapshot(
261
+ instance: any,
262
+ snapshot: any,
263
+ options: { legacy: boolean }
264
+ ) {
265
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
266
+ return snapshot;
267
+ }
268
+
269
+ const restoreUser = instance["onUserStorageRestore"];
270
+ if (typeof restoreUser !== "function") {
271
+ return snapshot;
272
+ }
273
+
274
+ const signal = this.getUsersProperty(instance);
275
+ const usersPropName = this.getUsersPropName(instance);
276
+ if (!signal || !usersPropName) {
277
+ return snapshot;
278
+ }
279
+
280
+ const usersSnapshot = snapshot[usersPropName];
281
+ if (!usersSnapshot || typeof usersSnapshot !== "object" || Array.isArray(usersSnapshot)) {
282
+ return snapshot;
283
+ }
284
+
285
+ const { classType } = signal.options || {};
286
+ let nextSnapshot = snapshot;
287
+ let nextUsersSnapshot = usersSnapshot;
288
+
289
+ for (const [publicId, userSnapshot] of Object.entries(usersSnapshot)) {
290
+ const user = this.createUserFromClassType(classType, publicId);
291
+ const restoredUserSnapshot = await awaitReturn(
292
+ restoreUser.call(instance, {
293
+ userSnapshot,
294
+ user,
295
+ publicId,
296
+ usersPropName,
297
+ room: this.room,
298
+ server: this,
299
+ legacy: options.legacy,
300
+ } satisfies UserStorageRestoreContext)
301
+ );
302
+
303
+ if (restoredUserSnapshot !== undefined && restoredUserSnapshot !== userSnapshot) {
304
+ if (nextSnapshot === snapshot) {
305
+ nextSnapshot = { ...snapshot };
306
+ }
307
+ if (nextUsersSnapshot === usersSnapshot) {
308
+ nextUsersSnapshot = { ...usersSnapshot };
309
+ nextSnapshot[usersPropName] = nextUsersSnapshot;
310
+ }
311
+ nextUsersSnapshot[publicId] = restoredUserSnapshot;
312
+ }
313
+ }
314
+
315
+ return nextSnapshot;
316
+ }
317
+
318
+ private async restoreStorageSnapshot(
319
+ instance: any,
320
+ snapshot: any,
321
+ options: { legacy: boolean }
322
+ ) {
323
+ const restoreSnapshot = instance["onStorageRestore"];
324
+ let restoredSnapshot = snapshot;
325
+
326
+ if (typeof restoreSnapshot === "function") {
327
+ const result = await awaitReturn(
328
+ restoreSnapshot.call(instance, {
329
+ snapshot,
330
+ room: this.room,
331
+ server: this,
332
+ legacy: options.legacy,
333
+ } satisfies StorageRestoreContext)
334
+ );
335
+ if (result !== undefined) {
336
+ restoredSnapshot = result;
337
+ }
338
+ }
339
+
340
+ return this.restoreUsersStorageSnapshot(instance, restoredSnapshot, options);
341
+ }
342
+
343
+ private createStorageMetrics(): StorageMetrics {
344
+ return {
345
+ loadMs: 0,
346
+ loadStateKeys: 0,
347
+ loadLegacyKeys: 0,
348
+ stateCompactions: 0,
349
+ stateCompactionDeletes: 0,
350
+ persistFlushes: 0,
351
+ persistWrites: 0,
352
+ persistDeletes: 0,
353
+ persistLastFlushMs: 0,
354
+ sessionGcRuns: 0,
355
+ sessionGcScanned: 0,
356
+ sessionGcExpired: 0,
357
+ sessionIndexRepairs: 0,
358
+ transferGcRuns: 0,
359
+ transferGcScanned: 0,
360
+ transferGcExpired: 0,
361
+ };
362
+ }
363
+
364
+ private getTransferExpiryTime(subRoom: any) {
365
+ return subRoom?.transferExpiryTime
366
+ ?? subRoom?.constructor?.prototype?.transferExpiryTime
367
+ ?? subRoom?.constructor?.transferExpiryTime
368
+ ?? DEFAULT_TRANSFER_EXPIRY_MS;
369
+ }
370
+
371
+ private getPrivateId(conn: Party.Connection) {
372
+ return (conn.state as any)?.privateId || conn.sessionId || conn.id;
373
+ }
374
+
375
+ private hasActiveSessionConnection(privateId: string) {
376
+ return Array.from(this.room.getConnections())
377
+ .some((conn) => this.getPrivateId(conn) === privateId);
378
+ }
379
+
92
380
  async send(conn: Party.Connection, obj: any, subRoom: any) {
93
381
  obj = structuredClone(obj);
94
382
  if (subRoom.interceptorPacket) {
@@ -138,28 +426,37 @@ export class Server implements Party.Server {
138
426
  const subRoom = await this.getSubRoom();
139
427
  if (!subRoom) return;
140
428
 
429
+ const SESSION_EXPIRY_TIME = Number(options.sessionExpiryTime);
430
+ if (!Number.isFinite(SESSION_EXPIRY_TIME)) {
431
+ return;
432
+ }
433
+
141
434
  // Get active connections
142
435
  const activeConnections = [...this.room.getConnections()];
143
- const activePrivateIds = new Set(activeConnections.map(conn => conn.id));
436
+ const activePrivateIds = new Set(activeConnections.map(conn => this.getPrivateId(conn)));
144
437
 
145
438
  try {
146
439
  // Get all sessions from storage
147
- const sessions = await this.room.storage.list();
440
+ const sessions = await this.listStorage<SessionData>(SESSION_PREFIX);
148
441
  const users = this.getUsersProperty(subRoom);
149
442
  const usersPropName = this.getUsersPropName(subRoom);
443
+ const metrics = subRoom.$storageMetrics as StorageMetrics | undefined;
444
+ if (metrics) {
445
+ metrics.sessionGcRuns += 1;
446
+ metrics.sessionGcScanned += sessions.size;
447
+ }
150
448
 
151
449
  // Store valid publicIds from sessions
152
450
  const validPublicIds = new Set<string>();
153
451
  const expiredPublicIds = new Set<string>();
154
- const SESSION_EXPIRY_TIME = options.sessionExpiryTime
155
452
  const now = Date.now();
156
453
 
157
454
  for (const [key, session] of sessions) {
158
455
  // Only process session entries
159
- if (!key.startsWith('session:')) continue;
456
+ if (!key.startsWith(SESSION_PREFIX)) continue;
160
457
 
161
- const privateId = key.replace('session:', '');
162
- const typedSession = session as { publicId: string, created: number, connected: boolean };
458
+ const privateId = key.slice(SESSION_PREFIX.length);
459
+ const typedSession = session as SessionData;
163
460
 
164
461
  // Check if session should be deleted based on:
165
462
  // 1. Connection is not active
@@ -167,16 +464,22 @@ export class Server implements Party.Server {
167
464
  // 3. Session is older than expiry time
168
465
  if (!activePrivateIds.has(privateId) &&
169
466
  !typedSession.connected &&
170
- (now - typedSession.created) > SESSION_EXPIRY_TIME) {
467
+ typedSession.disconnectedAt !== undefined &&
468
+ (now - typedSession.disconnectedAt) >= SESSION_EXPIRY_TIME) {
171
469
  // Delete expired session
172
470
  await this.deleteSession(privateId);
173
471
  expiredPublicIds.add(typedSession.publicId);
472
+ if (metrics) {
473
+ metrics.sessionGcExpired += 1;
474
+ }
174
475
  } else if (typedSession && typedSession.publicId) {
175
476
  // Keep track of valid publicIds from active or recent sessions
176
477
  validPublicIds.add(typedSession.publicId);
177
478
  }
178
479
  }
179
480
 
481
+ await this.repairSessionPublicIndexes(sessions, metrics);
482
+
180
483
  // Clean up users only if ALL their sessions are expired
181
484
  if (users && usersPropName) {
182
485
  const currentUsers = users();
@@ -184,7 +487,6 @@ export class Server implements Party.Server {
184
487
  // Only delete user if they have an expired session and no valid sessions
185
488
  if (expiredPublicIds.has(publicId) && !validPublicIds.has(publicId)) {
186
489
  delete currentUsers[publicId];
187
- await this.room.storage.delete(`${usersPropName}.${publicId}`);
188
490
  }
189
491
  }
190
492
  }
@@ -194,6 +496,177 @@ export class Server implements Party.Server {
194
496
  }
195
497
  }
196
498
 
499
+ private scheduleSessionGarbageCollector(sessionExpiryTime: number | undefined, privateId?: string) {
500
+ const normalizedSessionExpiryTime = Number(sessionExpiryTime);
501
+ if (!Number.isFinite(normalizedSessionExpiryTime) || normalizedSessionExpiryTime < 0) {
502
+ return;
503
+ }
504
+
505
+ setTimeout(() => {
506
+ if (privateId) {
507
+ void this.expireDisconnectedSession(privateId, normalizedSessionExpiryTime);
508
+ return;
509
+ }
510
+ void this.garbageCollector({ sessionExpiryTime: normalizedSessionExpiryTime });
511
+ }, normalizedSessionExpiryTime);
512
+ }
513
+
514
+ private getSessionExpiryTime(subRoom: any) {
515
+ return subRoom?.sessionExpiryTime
516
+ ?? subRoom?.constructor?.prototype?.sessionExpiryTime
517
+ ?? subRoom?.constructor?.sessionExpiryTime;
518
+ }
519
+
520
+ private async shouldRunSessionGarbageCollector(sessionExpiryTime: number | undefined) {
521
+ const normalizedSessionExpiryTime = Number(sessionExpiryTime);
522
+ if (!Number.isFinite(normalizedSessionExpiryTime) || normalizedSessionExpiryTime < 0) {
523
+ return false;
524
+ }
525
+
526
+ const now = Date.now();
527
+ const lastRun = await this.room.storage.get<number>(SESSION_GC_LAST_RUN_KEY);
528
+ if (lastRun && now - lastRun < normalizedSessionExpiryTime) {
529
+ return false;
530
+ }
531
+
532
+ await this.room.storage.put(SESSION_GC_LAST_RUN_KEY, now);
533
+ return true;
534
+ }
535
+
536
+ private async shouldRunInterval(key: string, interval: number) {
537
+ const normalizedInterval = Number(interval);
538
+ if (!Number.isFinite(normalizedInterval) || normalizedInterval < 0) {
539
+ return false;
540
+ }
541
+
542
+ const now = Date.now();
543
+ const lastRun = await this.room.storage.get<number>(key);
544
+ if (lastRun && now - lastRun < normalizedInterval) {
545
+ return false;
546
+ }
547
+
548
+ await this.room.storage.put(key, now);
549
+ return true;
550
+ }
551
+
552
+ private async repairSessionPublicIndexes(
553
+ sessions?: Map<string, SessionData>,
554
+ metrics?: StorageMetrics
555
+ ) {
556
+ const sessionEntries = sessions ?? await this.listStorage<SessionData>(SESSION_PREFIX);
557
+ const expected = new Map<string, string[]>();
558
+
559
+ for (const [key, session] of sessionEntries) {
560
+ if (!session?.publicId) continue;
561
+ const privateId = key.slice(SESSION_PREFIX.length);
562
+ const privateIds = expected.get(session.publicId) ?? [];
563
+ privateIds.push(privateId);
564
+ expected.set(session.publicId, privateIds);
565
+ }
566
+
567
+ const publicIndexes = await this.listStorage<string[]>(SESSION_PUBLIC_PREFIX);
568
+ const writes: Record<string, string[]> = {};
569
+ const deletes: string[] = [];
570
+ let repairs = 0;
571
+
572
+ for (const [publicId, privateIds] of expected) {
573
+ privateIds.sort();
574
+ const key = this.sessionPublicKey(publicId);
575
+ const existing = publicIndexes.get(key) ?? [];
576
+ const normalizedExisting = Array.isArray(existing) ? [...existing].sort() : [];
577
+ if (JSON.stringify(normalizedExisting) !== JSON.stringify(privateIds)) {
578
+ writes[key] = privateIds;
579
+ repairs += 1;
580
+ }
581
+ }
582
+
583
+ for (const [key] of publicIndexes) {
584
+ const publicId = key.slice(SESSION_PUBLIC_PREFIX.length);
585
+ if (!expected.has(publicId)) {
586
+ deletes.push(key);
587
+ repairs += 1;
588
+ }
589
+ }
590
+
591
+ await Promise.all([
592
+ this.putStorageEntries(writes),
593
+ this.deleteStorageKeys(deletes),
594
+ ]);
595
+
596
+ if (metrics) {
597
+ metrics.sessionIndexRepairs += repairs;
598
+ }
599
+ }
600
+
601
+ private isTransferExpired(transfer: Partial<TransferData> | null | undefined, transferExpiryTime: number) {
602
+ if (!transfer) return true;
603
+ if (!Number.isFinite(transferExpiryTime) || transferExpiryTime < 0) {
604
+ return false;
605
+ }
606
+ const created = Number(transfer.created);
607
+ if (!Number.isFinite(created)) {
608
+ return false;
609
+ }
610
+ return Date.now() - created >= transferExpiryTime;
611
+ }
612
+
613
+ private async cleanupExpiredTransfers(transferExpiryTime: number, metrics?: StorageMetrics) {
614
+ if (!Number.isFinite(transferExpiryTime) || transferExpiryTime < 0) {
615
+ return;
616
+ }
617
+
618
+ const transfers = await this.listStorage<TransferData>(TRANSFER_PREFIX);
619
+ const expiredKeys: string[] = [];
620
+ for (const [key, transfer] of transfers) {
621
+ if (this.isTransferExpired(transfer, transferExpiryTime)) {
622
+ expiredKeys.push(key);
623
+ }
624
+ }
625
+
626
+ await this.deleteStorageKeys(expiredKeys);
627
+
628
+ if (metrics) {
629
+ metrics.transferGcRuns += 1;
630
+ metrics.transferGcScanned += transfers.size;
631
+ metrics.transferGcExpired += expiredKeys.length;
632
+ }
633
+ }
634
+
635
+ private async expireDisconnectedSession(privateId: string, sessionExpiryTime: number) {
636
+ const session = await this.getSession(privateId);
637
+ if (!session || session.connected || session.disconnectedAt === undefined) {
638
+ return;
639
+ }
640
+
641
+ if (this.hasActiveSessionConnection(privateId)) {
642
+ return;
643
+ }
644
+
645
+ const elapsed = Date.now() - session.disconnectedAt;
646
+ if (elapsed < sessionExpiryTime) {
647
+ setTimeout(() => {
648
+ void this.expireDisconnectedSession(privateId, sessionExpiryTime);
649
+ }, sessionExpiryTime - elapsed);
650
+ return;
651
+ }
652
+
653
+ await this.deleteSession(privateId);
654
+
655
+ const privateIds = await this.getSessionPrivateIds(session.publicId);
656
+ for (const otherPrivateId of privateIds) {
657
+ const otherSession = await this.getSession(otherPrivateId);
658
+ if (otherSession?.publicId === session.publicId) {
659
+ return;
660
+ }
661
+ }
662
+
663
+ const subRoom = await this.getSubRoom();
664
+ const users = this.getUsersProperty(subRoom);
665
+ if (users?.()[session.publicId]) {
666
+ delete users()[session.publicId];
667
+ }
668
+ }
669
+
197
670
  /**
198
671
  * @method createRoom
199
672
  * @private
@@ -231,22 +704,73 @@ export class Server implements Party.Server {
231
704
  // Load the room's memory from storage
232
705
  // This ensures persistence across server restarts
233
706
  const loadMemory = async () => {
234
- const root = await this.room.storage.get(".");
235
- const memory = await this.room.storage.list();
707
+ const startedAt = Date.now();
708
+ const metrics = instance.$storageMetrics as StorageMetrics;
709
+ const root = await this.loadStatePath(".");
710
+ const memory = await this.listStorage(STATE_PREFIX);
711
+ metrics.loadStateKeys = memory.size;
236
712
  const tmpObject: any = root || {};
237
- for (let [key, value] of memory) {
238
- if (key.startsWith('session:')) {
239
- continue;
240
- }
241
- if (key == ".") {
713
+ for (let [storageKey, value] of memory) {
714
+ const key = storageKey.slice(STATE_PREFIX.length);
715
+ if (key === ".") {
242
716
  continue;
243
717
  }
244
718
  dset(tmpObject, key, value);
245
719
  }
246
- load(instance, tmpObject, true);
720
+
721
+ if (root === undefined && memory.size === 0) {
722
+ const legacyRoot = await this.room.storage.get(".");
723
+ const legacyMemory = await this.room.storage.list();
724
+ const legacyObject: any = legacyRoot || {};
725
+ const migratedEntries: Array<[string, any]> = [];
726
+ const legacyDeleteKeys: string[] = [];
727
+
728
+ if (legacyRoot !== undefined) {
729
+ migratedEntries.push([".", legacyRoot]);
730
+ legacyDeleteKeys.push(".");
731
+ }
732
+
733
+ for (let [key, value] of legacyMemory) {
734
+ if (key === "." || this.isInternalStorageKey(key)) {
735
+ continue;
736
+ }
737
+ dset(legacyObject, key, value);
738
+ migratedEntries.push([key, value]);
739
+ legacyDeleteKeys.push(key);
740
+ }
741
+
742
+ metrics.loadLegacyKeys = migratedEntries.length;
743
+ await this.putStorageEntries(
744
+ Object.fromEntries(
745
+ migratedEntries.map(([path, value]) => [this.stateKey(path), value])
746
+ )
747
+ );
748
+ if (legacyDeleteKeys.length > 0) {
749
+ await this.deleteStorageKeys(legacyDeleteKeys);
750
+ }
751
+ const restoredLegacyObject = await this.restoreStorageSnapshot(instance, legacyObject, {
752
+ legacy: true,
753
+ });
754
+ load(instance, restoredLegacyObject, true);
755
+ if (this.containsDeleteToken(restoredLegacyObject)) {
756
+ await this.compactStateStorage(instance);
757
+ }
758
+ metrics.loadMs = Date.now() - startedAt;
759
+ return;
760
+ }
761
+
762
+ const restoredObject = await this.restoreStorageSnapshot(instance, tmpObject, {
763
+ legacy: false,
764
+ });
765
+ load(instance, restoredObject, true);
766
+ if (this.containsDeleteToken(restoredObject)) {
767
+ await this.compactStateStorage(instance);
768
+ }
769
+ metrics.loadMs = Date.now() - startedAt;
247
770
  };
248
771
 
249
772
  instance.$memoryAll = {}
773
+ instance.$storageMetrics = this.createStorageMetrics();
250
774
  instance.$autoSync = instance["autoSync"] !== false; // Default to true
251
775
  instance.$pendingSync = new Map<string, any>();
252
776
  instance.$pendingInitialSync = new Map<Party.Connection, string>(); // Store connections waiting for initial sync with their publicId
@@ -333,17 +857,9 @@ export class Server implements Party.Server {
333
857
  return null;
334
858
  }
335
859
 
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
- }
860
+ const sessionEntry = await this.getSessionEntryByPublicId(publicId);
861
+ const userSession = sessionEntry?.session;
862
+ const privateId = sessionEntry?.privateId ?? null;
347
863
 
348
864
  if (!userSession || !privateId) {
349
865
  console.error(`[sessionTransfer] Session for publicId ${publicId} not found.`);
@@ -422,29 +938,115 @@ export class Server implements Party.Server {
422
938
  values.clear();
423
939
  }
424
940
 
425
- // Persist callback: Save changes to storage
426
- const persistCb = async (values: Map<string, any>) => {
427
- if (initPersist) {
428
- values.clear();
429
- return;
430
- }
941
+ const flushPersist = async (values: Map<string, any>) => {
942
+ const startedAt = Date.now();
943
+ const stateWrites: Record<string, any> = {};
944
+ const deleteTasks: Promise<void>[] = [];
945
+ let hasDeletes = false;
431
946
  for (let [path, value] of values) {
432
947
  const _instance =
433
948
  path == "." ? instance : getByPath(instance, path);
434
- const itemValue = createStatesSnapshot(_instance);
435
949
  if (value == DELETE_TOKEN) {
436
- await this.room.storage.delete(path);
950
+ hasDeletes = true;
951
+ deleteTasks.push(this.deleteStatePath(path));
437
952
  } else {
438
- await this.room.storage.put(path, itemValue);
953
+ const itemValue = _instance?.$snapshot
954
+ ? createStatesSnapshot(_instance)
955
+ : value;
956
+ stateWrites[this.stateKey(path)] = itemValue;
439
957
  }
440
958
  }
959
+ await Promise.all([
960
+ this.putStorageEntries(stateWrites),
961
+ ...deleteTasks,
962
+ ]);
963
+ if (hasDeletes) {
964
+ await this.compactStateStorage(instance);
965
+ }
966
+ const metrics = instance.$storageMetrics as StorageMetrics | undefined;
967
+ if (metrics) {
968
+ metrics.persistFlushes += 1;
969
+ metrics.persistWrites += Object.keys(stateWrites).length;
970
+ metrics.persistDeletes += deleteTasks.length;
971
+ metrics.persistLastFlushMs = Date.now() - startedAt;
972
+ }
973
+ }
974
+
975
+ let persistQueue = Promise.resolve();
976
+
977
+ // Persist callback: Save changes to storage
978
+ const persistCb = async (values: Map<string, any>) => {
979
+ if (initPersist) {
980
+ values.clear();
981
+ return;
982
+ }
983
+
984
+ const valuesSnapshot = new Map(values);
441
985
  values.clear();
986
+
987
+ persistQueue = persistQueue.then(
988
+ () => flushPersist(valuesSnapshot),
989
+ () => flushPersist(valuesSnapshot)
990
+ );
991
+ await persistQueue;
442
992
  }
443
993
 
994
+ const debouncePersist = (wait: number) => {
995
+ let timeout: ReturnType<typeof setTimeout> | null = null;
996
+ let flushing = false;
997
+ const pending = new Map<string, any>();
998
+
999
+ const schedule = () => {
1000
+ if (timeout) {
1001
+ clearTimeout(timeout);
1002
+ }
1003
+ timeout = setTimeout(() => {
1004
+ void flush();
1005
+ }, wait);
1006
+ };
1007
+
1008
+ const flush = async () => {
1009
+ timeout = null;
1010
+ if (flushing) {
1011
+ schedule();
1012
+ return;
1013
+ }
1014
+
1015
+ const values = new Map(pending);
1016
+ pending.clear();
1017
+ if (!values.size) {
1018
+ return;
1019
+ }
1020
+
1021
+ flushing = true;
1022
+ try {
1023
+ await persistCb(values);
1024
+ } finally {
1025
+ flushing = false;
1026
+ if (pending.size) {
1027
+ schedule();
1028
+ }
1029
+ }
1030
+ };
1031
+
1032
+ return (values: Map<string, any>) => {
1033
+ if (initPersist) {
1034
+ values.clear();
1035
+ return;
1036
+ }
1037
+
1038
+ for (const [path, value] of values) {
1039
+ pending.set(path, value);
1040
+ }
1041
+ values.clear();
1042
+ schedule();
1043
+ };
1044
+ };
1045
+
444
1046
  // Set up syncing and persistence with throttling to optimize performance
445
1047
  syncClass(instance, {
446
1048
  onSync: instance["throttleSync"] ? throttle(syncCb, instance["throttleSync"]) : syncCb,
447
- onPersist: instance["throttleStorage"] ? throttle(persistCb, instance["throttleStorage"]) : persistCb,
1049
+ onPersist: instance["throttleStorage"] ? debouncePersist(instance["throttleStorage"]) : persistCb,
448
1050
  });
449
1051
 
450
1052
  await loadMemory();
@@ -564,29 +1166,107 @@ export class Server implements Party.Server {
564
1166
  * console.log(session);
565
1167
  * ```
566
1168
  */
567
- async getSession(privateId: string): Promise<{ publicId: string, state?: any, created?: number, connected?: boolean } | null> {
1169
+ async getSession(privateId: string): Promise<SessionData | null> {
568
1170
  if (!privateId) return null;
569
1171
  try {
570
- const session = await this.room.storage.get(`session:${privateId}`);
571
- return session as { publicId: string, state?: any, created: number, connected: boolean } | null;
1172
+ const session = await this.room.storage.get(this.sessionKey(privateId));
1173
+ return session as SessionData | null;
572
1174
  } catch (e) {
573
1175
  return null;
574
1176
  }
575
1177
  }
576
1178
 
577
- private async saveSession(privateId: string, data: { publicId: string, state?: any, created?: number, connected?: boolean }) {
1179
+ private async getSessionPrivateIds(publicId: string): Promise<string[]> {
1180
+ if (!publicId) return [];
1181
+ const privateIds = await this.room.storage.get<string[]>(this.sessionPublicKey(publicId));
1182
+ return Array.isArray(privateIds) ? privateIds : [];
1183
+ }
1184
+
1185
+ private async saveSessionPrivateIds(publicId: string, privateIds: string[]) {
1186
+ const key = this.sessionPublicKey(publicId);
1187
+ if (privateIds.length === 0) {
1188
+ await this.room.storage.delete(key);
1189
+ return;
1190
+ }
1191
+ await this.room.storage.put(key, privateIds);
1192
+ }
1193
+
1194
+ private async addSessionToPublicIndex(privateId: string, publicId: string) {
1195
+ const privateIds = await this.getSessionPrivateIds(publicId);
1196
+ if (privateIds.includes(privateId)) {
1197
+ return;
1198
+ }
1199
+ await this.saveSessionPrivateIds(publicId, [...privateIds, privateId]);
1200
+ }
1201
+
1202
+ private async removeSessionFromPublicIndex(privateId: string, publicId: string) {
1203
+ const privateIds = await this.getSessionPrivateIds(publicId);
1204
+ await this.saveSessionPrivateIds(
1205
+ publicId,
1206
+ privateIds.filter((id) => id !== privateId)
1207
+ );
1208
+ }
1209
+
1210
+ private async getSessionEntryByPublicId(publicId: string): Promise<{ privateId: string; session: SessionData } | null> {
1211
+ const indexedPrivateIds = await this.getSessionPrivateIds(publicId);
1212
+ const stalePrivateIds: string[] = [];
1213
+
1214
+ for (const privateId of indexedPrivateIds) {
1215
+ const session = await this.getSession(privateId);
1216
+ if (session?.publicId === publicId) {
1217
+ return { privateId, session };
1218
+ }
1219
+ stalePrivateIds.push(privateId);
1220
+ }
1221
+
1222
+ if (stalePrivateIds.length) {
1223
+ await this.saveSessionPrivateIds(
1224
+ publicId,
1225
+ indexedPrivateIds.filter((id) => !stalePrivateIds.includes(id))
1226
+ );
1227
+ }
1228
+
1229
+ const sessions = await this.listStorage<SessionData>(SESSION_PREFIX);
1230
+ for (const [key, session] of sessions) {
1231
+ const privateId = key.slice(SESSION_PREFIX.length);
1232
+ if (session?.publicId) {
1233
+ await this.addSessionToPublicIndex(privateId, session.publicId);
1234
+ }
1235
+ if (session?.publicId === publicId) {
1236
+ return { privateId, session };
1237
+ }
1238
+ }
1239
+
1240
+ return null;
1241
+ }
1242
+
1243
+ private async saveSession(privateId: string, data: SessionData) {
1244
+ const existingSession = await this.getSession(privateId);
578
1245
  const sessionData = {
579
1246
  ...data,
580
1247
  created: data.created || Date.now(),
581
1248
  connected: data.connected !== undefined ? data.connected : true
582
1249
  };
583
- await this.room.storage.put(`session:${privateId}`, sessionData);
1250
+ await this.room.storage.put(this.sessionKey(privateId), sessionData);
1251
+ if (existingSession?.publicId && existingSession.publicId !== sessionData.publicId) {
1252
+ await this.removeSessionFromPublicIndex(privateId, existingSession.publicId);
1253
+ }
1254
+ await this.addSessionToPublicIndex(privateId, sessionData.publicId);
584
1255
  }
585
1256
 
586
1257
  private async updateSessionConnection(privateId: string, connected: boolean) {
587
1258
  const session = await this.getSession(privateId);
588
1259
  if (session) {
589
- await this.saveSession(privateId, { ...session, connected });
1260
+ const nextSession = { ...session, connected };
1261
+ if (connected) {
1262
+ delete nextSession.disconnectedAt;
1263
+ } else {
1264
+ nextSession.disconnectedAt = Date.now();
1265
+ }
1266
+ if (!await this.getSession(privateId)) {
1267
+ return;
1268
+ }
1269
+ await this.saveSession(privateId, nextSession);
590
1270
  }
591
1271
  }
592
1272
 
@@ -602,7 +1282,11 @@ export class Server implements Party.Server {
602
1282
  * ```
603
1283
  */
604
1284
  async deleteSession(privateId: string) {
605
- await this.room.storage.delete(`session:${privateId}`);
1285
+ const session = await this.getSession(privateId);
1286
+ await this.room.storage.delete(this.sessionKey(privateId));
1287
+ if (session?.publicId) {
1288
+ await this.removeSessionFromPublicIndex(privateId, session.publicId);
1289
+ }
606
1290
  }
607
1291
 
608
1292
  async onConnectClient(conn: Party.Connection, ctx: Party.ConnectionContext) {
@@ -615,8 +1299,17 @@ export class Server implements Party.Server {
615
1299
  return;
616
1300
  }
617
1301
 
618
- const sessionExpiryTime = subRoom.constructor.sessionExpiryTime;
619
- await this.garbageCollector({ sessionExpiryTime });
1302
+ const sessionExpiryTime = this.getSessionExpiryTime(subRoom);
1303
+ if (await this.shouldRunSessionGarbageCollector(sessionExpiryTime)) {
1304
+ await this.garbageCollector({ sessionExpiryTime });
1305
+ }
1306
+ const transferExpiryTime = this.getTransferExpiryTime(subRoom);
1307
+ if (await this.shouldRunInterval(TRANSFER_GC_LAST_RUN_KEY, transferExpiryTime)) {
1308
+ await this.cleanupExpiredTransfers(
1309
+ transferExpiryTime,
1310
+ subRoom.$storageMetrics as StorageMetrics | undefined
1311
+ );
1312
+ }
620
1313
 
621
1314
  // Check room guards
622
1315
  const roomGuards = subRoom.constructor['_roomGuards'] || [];
@@ -636,14 +1329,20 @@ export class Server implements Party.Server {
636
1329
  }
637
1330
  let transferData: any = null;
638
1331
  if (transferToken) {
639
- transferData = await this.room.storage.get(`transfer:${transferToken}`);
1332
+ const transferKey = this.transferKey(transferToken);
1333
+ transferData = await this.room.storage.get<TransferData>(transferKey);
640
1334
  if (transferData) {
641
- await this.room.storage.delete(`transfer:${transferToken}`);
1335
+ if (this.isTransferExpired(transferData, transferExpiryTime)) {
1336
+ transferData = null;
1337
+ }
1338
+ await this.room.storage.delete(transferKey);
642
1339
  }
643
1340
  }
644
1341
 
645
1342
  // Check for existing session
646
- const existingSession = await this.getSession(conn.id)
1343
+ const requestedPrivateId = this.getPrivateId(conn);
1344
+ const privateId = transferData?.privateId || requestedPrivateId;
1345
+ const existingSession = await this.getSession(privateId)
647
1346
 
648
1347
  // Generate IDs
649
1348
  const publicId = existingSession?.publicId || transferData?.publicId || generateShortUUID();
@@ -661,10 +1360,10 @@ export class Server implements Party.Server {
661
1360
  if (transferData?.restored && signal()[publicId]) {
662
1361
  user = signal()[publicId];
663
1362
  } else {
664
- user = isClass(classType) ? new classType() : classType(conn, ctx);
1363
+ user = this.createUserFromClassType(classType, conn, ctx);
665
1364
  signal()[publicId] = user;
666
1365
  const snapshot = createStatesSnapshotDeep(user);
667
- this.room.storage.put(`${usersPropName}.${publicId}`, snapshot);
1366
+ await this.saveStatePath(`${usersPropName}.${publicId}`, snapshot);
668
1367
  }
669
1368
  }
670
1369
  else {
@@ -674,13 +1373,12 @@ export class Server implements Party.Server {
674
1373
  // Only store new session if it doesn't exist
675
1374
  if (!existingSession) {
676
1375
  // Use the transferred privateId if available, otherwise use connection id
677
- const sessionPrivateId = transferData?.privateId || conn.id;
678
- await this.saveSession(sessionPrivateId, {
1376
+ await this.saveSession(privateId, {
679
1377
  publicId
680
1378
  });
681
1379
  }
682
1380
  else {
683
- await this.updateSessionConnection(conn.id, true);
1381
+ await this.updateSessionConnection(privateId, true);
684
1382
  }
685
1383
  }
686
1384
  // Update user connection status if applicable
@@ -689,7 +1387,8 @@ export class Server implements Party.Server {
689
1387
  // Store both IDs in connection state
690
1388
  conn.setState({
691
1389
  ...conn.state,
692
- publicId
1390
+ publicId,
1391
+ privateId
693
1392
  });
694
1393
 
695
1394
  // Call the room's onJoin method if it exists
@@ -729,6 +1428,10 @@ export class Server implements Party.Server {
729
1428
  */
730
1429
  async onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
731
1430
  if (ctx.request?.headers.has('x-shard-id')) {
1431
+ if (!this.isAuthorizedShardRequest(ctx.request)) {
1432
+ conn.close();
1433
+ return;
1434
+ }
732
1435
  this.onConnectShard(conn, ctx);
733
1436
  }
734
1437
  else {
@@ -736,6 +1439,13 @@ export class Server implements Party.Server {
736
1439
  }
737
1440
  }
738
1441
 
1442
+ private isAuthorizedShardRequest(req?: Party.Request) {
1443
+ const shardSecret = this.room.env.SHARD_SECRET;
1444
+ return typeof shardSecret === 'string'
1445
+ && shardSecret.length > 0
1446
+ && req?.headers.get('x-access-shard') === shardSecret;
1447
+ }
1448
+
739
1449
  /**
740
1450
  * @method onConnectShard
741
1451
  * @private
@@ -747,9 +1457,11 @@ export class Server implements Party.Server {
747
1457
  onConnectShard(conn: Party.Connection, ctx: Party.ConnectionContext) {
748
1458
  // Set shard metadata in connection state
749
1459
  const shardId = ctx.request?.headers.get('x-shard-id') || 'unknown-shard';
1460
+ const worldId = ctx.request?.headers.get('x-shard-world-id') || 'world-default';
750
1461
  conn.setState({
751
1462
  shard: true,
752
1463
  shardId,
1464
+ worldId,
753
1465
  clients: new Map() // Track clients connected through this shard
754
1466
  });
755
1467
  }
@@ -1085,14 +1797,19 @@ export class Server implements Party.Server {
1085
1797
  return;
1086
1798
  }
1087
1799
 
1088
- const privateId = conn.id;
1800
+ const privateId = this.getPrivateId(conn);
1089
1801
  const { publicId } = conn.state as any;
1090
1802
  const user = signal?.()[publicId];
1091
1803
 
1092
1804
  if (!user) return;
1093
1805
 
1806
+ if (this.hasActiveSessionConnection(privateId)) {
1807
+ return;
1808
+ }
1809
+
1094
1810
  // Mark session as disconnected instead of deleting it
1095
1811
  await this.updateSessionConnection(privateId, false);
1812
+ this.scheduleSessionGarbageCollector(this.getSessionExpiryTime(subRoom), privateId);
1096
1813
 
1097
1814
  // Update user connection status in the signal
1098
1815
  const connectionUpdated = this.updateUserConnectionStatus(user, false);
@@ -1142,6 +1859,9 @@ export class Server implements Party.Server {
1142
1859
  }
1143
1860
 
1144
1861
  if (isFromShard) {
1862
+ if (!this.isAuthorizedShardRequest(req)) {
1863
+ return res.unauthorized('Invalid shard credentials');
1864
+ }
1145
1865
  return this.handleShardRequest(req, res, shardId);
1146
1866
  }
1147
1867
 
@@ -1195,7 +1915,7 @@ export class Server implements Party.Server {
1195
1915
  const { classType } = signal.options;
1196
1916
 
1197
1917
  // Create new user instance
1198
- const user = isClass(classType) ? new classType() : classType();
1918
+ const user = this.createUserFromClassType(classType);
1199
1919
 
1200
1920
  const hydratedSnapshot =
1201
1921
  (await awaitReturn(
@@ -1216,16 +1936,17 @@ export class Server implements Party.Server {
1216
1936
  load(user, hydratedSnapshot, true);
1217
1937
 
1218
1938
  // Save user snapshot to storage
1219
- await this.room.storage.put(`${usersPropName}.${publicId}`, userSnapshot);
1939
+ await this.saveStatePath(`${usersPropName}.${publicId}`, userSnapshot);
1220
1940
  }
1221
1941
  }
1222
1942
 
1223
1943
  // Generate transfer token for the client to use when connecting
1224
1944
  const transferToken = generateShortUUID();
1225
- await this.room.storage.put(`transfer:${transferToken}`, {
1945
+ await this.room.storage.put(this.transferKey(transferToken), {
1226
1946
  privateId,
1227
1947
  publicId,
1228
- restored: true
1948
+ restored: true,
1949
+ created: Date.now(),
1229
1950
  });
1230
1951
 
1231
1952
  return res.success({ transferToken });