@signe/room 2.10.0 → 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 (82) 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 +66 -187
  8. package/dist/index.js +727 -106
  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 +371 -4
  53. package/src/cloudflare/index.ts +474 -0
  54. package/src/jwt.ts +1 -5
  55. package/src/mock.ts +29 -7
  56. package/src/node/index.ts +1112 -0
  57. package/src/server.ts +600 -51
  58. package/src/session.guard.ts +6 -2
  59. package/src/shard.ts +91 -23
  60. package/src/storage.ts +29 -5
  61. package/src/testing.ts +4 -3
  62. package/src/types/party.ts +4 -1
  63. package/src/world.guard.ts +23 -4
  64. package/src/world.ts +121 -21
  65. package/examples/game/.vscode/launch.json +0 -11
  66. package/examples/game/.vscode/settings.json +0 -11
  67. package/examples/game/README.md +0 -40
  68. package/examples/game/app/client.tsx +0 -15
  69. package/examples/game/app/components/Admin.tsx +0 -1089
  70. package/examples/game/app/components/Room.tsx +0 -162
  71. package/examples/game/app/styles.css +0 -31
  72. package/examples/game/package-lock.json +0 -225
  73. package/examples/game/package.json +0 -20
  74. package/examples/game/party/game.room.ts +0 -32
  75. package/examples/game/party/server.ts +0 -10
  76. package/examples/game/party/shard.ts +0 -5
  77. package/examples/game/partykit.json +0 -14
  78. package/examples/game/public/favicon.ico +0 -0
  79. package/examples/game/public/index.html +0 -27
  80. package/examples/game/public/normalize.css +0 -351
  81. package/examples/game/shared/room.schema.ts +0 -14
  82. 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,8 +1127,17 @@ export class Server implements Party.Server {
615
1127
  return;
616
1128
  }
617
1129
 
618
- const sessionExpiryTime = subRoom.constructor.sessionExpiryTime;
619
- 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
+ }
620
1141
 
621
1142
  // Check room guards
622
1143
  const roomGuards = subRoom.constructor['_roomGuards'] || [];
@@ -636,14 +1157,20 @@ export class Server implements Party.Server {
636
1157
  }
637
1158
  let transferData: any = null;
638
1159
  if (transferToken) {
639
- transferData = await this.room.storage.get(`transfer:${transferToken}`);
1160
+ const transferKey = this.transferKey(transferToken);
1161
+ transferData = await this.room.storage.get<TransferData>(transferKey);
640
1162
  if (transferData) {
641
- await this.room.storage.delete(`transfer:${transferToken}`);
1163
+ if (this.isTransferExpired(transferData, transferExpiryTime)) {
1164
+ transferData = null;
1165
+ }
1166
+ await this.room.storage.delete(transferKey);
642
1167
  }
643
1168
  }
644
1169
 
645
1170
  // Check for existing session
646
- 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)
647
1174
 
648
1175
  // Generate IDs
649
1176
  const publicId = existingSession?.publicId || transferData?.publicId || generateShortUUID();
@@ -664,7 +1191,7 @@ export class Server implements Party.Server {
664
1191
  user = isClass(classType) ? new classType() : classType(conn, ctx);
665
1192
  signal()[publicId] = user;
666
1193
  const snapshot = createStatesSnapshotDeep(user);
667
- this.room.storage.put(`${usersPropName}.${publicId}`, snapshot);
1194
+ await this.saveStatePath(`${usersPropName}.${publicId}`, snapshot);
668
1195
  }
669
1196
  }
670
1197
  else {
@@ -674,13 +1201,12 @@ export class Server implements Party.Server {
674
1201
  // Only store new session if it doesn't exist
675
1202
  if (!existingSession) {
676
1203
  // Use the transferred privateId if available, otherwise use connection id
677
- const sessionPrivateId = transferData?.privateId || conn.id;
678
- await this.saveSession(sessionPrivateId, {
1204
+ await this.saveSession(privateId, {
679
1205
  publicId
680
1206
  });
681
1207
  }
682
1208
  else {
683
- await this.updateSessionConnection(conn.id, true);
1209
+ await this.updateSessionConnection(privateId, true);
684
1210
  }
685
1211
  }
686
1212
  // Update user connection status if applicable
@@ -689,7 +1215,8 @@ export class Server implements Party.Server {
689
1215
  // Store both IDs in connection state
690
1216
  conn.setState({
691
1217
  ...conn.state,
692
- publicId
1218
+ publicId,
1219
+ privateId
693
1220
  });
694
1221
 
695
1222
  // Call the room's onJoin method if it exists
@@ -729,6 +1256,10 @@ export class Server implements Party.Server {
729
1256
  */
730
1257
  async onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
731
1258
  if (ctx.request?.headers.has('x-shard-id')) {
1259
+ if (!this.isAuthorizedShardRequest(ctx.request)) {
1260
+ conn.close();
1261
+ return;
1262
+ }
732
1263
  this.onConnectShard(conn, ctx);
733
1264
  }
734
1265
  else {
@@ -736,6 +1267,13 @@ export class Server implements Party.Server {
736
1267
  }
737
1268
  }
738
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
+
739
1277
  /**
740
1278
  * @method onConnectShard
741
1279
  * @private
@@ -747,9 +1285,11 @@ export class Server implements Party.Server {
747
1285
  onConnectShard(conn: Party.Connection, ctx: Party.ConnectionContext) {
748
1286
  // Set shard metadata in connection state
749
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';
750
1289
  conn.setState({
751
1290
  shard: true,
752
1291
  shardId,
1292
+ worldId,
753
1293
  clients: new Map() // Track clients connected through this shard
754
1294
  });
755
1295
  }
@@ -1085,14 +1625,19 @@ export class Server implements Party.Server {
1085
1625
  return;
1086
1626
  }
1087
1627
 
1088
- const privateId = conn.id;
1628
+ const privateId = this.getPrivateId(conn);
1089
1629
  const { publicId } = conn.state as any;
1090
1630
  const user = signal?.()[publicId];
1091
1631
 
1092
1632
  if (!user) return;
1093
1633
 
1634
+ if (this.hasActiveSessionConnection(privateId)) {
1635
+ return;
1636
+ }
1637
+
1094
1638
  // Mark session as disconnected instead of deleting it
1095
1639
  await this.updateSessionConnection(privateId, false);
1640
+ this.scheduleSessionGarbageCollector(this.getSessionExpiryTime(subRoom), privateId);
1096
1641
 
1097
1642
  // Update user connection status in the signal
1098
1643
  const connectionUpdated = this.updateUserConnectionStatus(user, false);
@@ -1142,6 +1687,9 @@ export class Server implements Party.Server {
1142
1687
  }
1143
1688
 
1144
1689
  if (isFromShard) {
1690
+ if (!this.isAuthorizedShardRequest(req)) {
1691
+ return res.unauthorized('Invalid shard credentials');
1692
+ }
1145
1693
  return this.handleShardRequest(req, res, shardId);
1146
1694
  }
1147
1695
 
@@ -1216,16 +1764,17 @@ export class Server implements Party.Server {
1216
1764
  load(user, hydratedSnapshot, true);
1217
1765
 
1218
1766
  // Save user snapshot to storage
1219
- await this.room.storage.put(`${usersPropName}.${publicId}`, userSnapshot);
1767
+ await this.saveStatePath(`${usersPropName}.${publicId}`, userSnapshot);
1220
1768
  }
1221
1769
  }
1222
1770
 
1223
1771
  // Generate transfer token for the client to use when connecting
1224
1772
  const transferToken = generateShortUUID();
1225
- await this.room.storage.put(`transfer:${transferToken}`, {
1773
+ await this.room.storage.put(this.transferKey(transferToken), {
1226
1774
  privateId,
1227
1775
  publicId,
1228
- restored: true
1776
+ restored: true,
1777
+ created: Date.now(),
1229
1778
  });
1230
1779
 
1231
1780
  return res.success({ transferToken });