@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/dist/index.js CHANGED
@@ -1,13 +1,6 @@
1
- var __defProp = Object.defineProperty;
2
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
- var __decorateClass = (decorators, target, key, kind) => {
4
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
5
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
6
- if (decorator = decorators[i])
7
- result = (kind ? decorator(target, key, result) : decorator(result)) || result;
8
- if (kind && result) __defProp(target, key, result);
9
- return result;
10
- };
1
+ import {
2
+ __decorateClass
3
+ } from "./chunk-EUXUH3YW.js";
11
4
 
12
5
  // src/decorators.ts
13
6
  function Action(name, bodyValidation) {
@@ -76,11 +69,10 @@ function Guard(guards) {
76
69
  // ../sync/src/utils.ts
77
70
  function generateShortUUID() {
78
71
  const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
79
- const randomBytes = typeof globalThis.crypto?.getRandomValues === "function" ? globalThis.crypto.getRandomValues(new Uint8Array(8)) : null;
80
72
  let uuid = "";
81
73
  for (let i = 0; i < 8; i++) {
82
- const randomValue = randomBytes?.[i] ?? Math.floor(Math.random() * 256);
83
- uuid += chars[randomValue % chars.length];
74
+ const randomIndex = Math.floor(Math.random() * chars.length);
75
+ uuid += chars[randomIndex];
84
76
  }
85
77
  return uuid;
86
78
  }
@@ -91,16 +83,38 @@ var Storage = class {
91
83
  this.memory = /* @__PURE__ */ new Map();
92
84
  }
93
85
  async put(key, value) {
94
- this.memory.set(key, value);
86
+ if (typeof key === "string") {
87
+ this.memory.set(key, value);
88
+ return;
89
+ }
90
+ for (const [entryKey, entryValue] of Object.entries(key)) {
91
+ this.memory.set(entryKey, entryValue);
92
+ }
95
93
  }
96
94
  async get(key) {
97
95
  return this.memory.get(key);
98
96
  }
99
97
  async delete(key) {
100
- this.memory.delete(key);
98
+ if (Array.isArray(key)) {
99
+ let deleted = 0;
100
+ for (const entryKey of key) {
101
+ if (this.memory.delete(entryKey)) {
102
+ deleted += 1;
103
+ }
104
+ }
105
+ return deleted;
106
+ }
107
+ return this.memory.delete(key);
101
108
  }
102
- async list() {
103
- return this.memory;
109
+ async list(options) {
110
+ if (!options?.prefix) {
111
+ return this.memory;
112
+ }
113
+ return new Map(
114
+ Array.from(this.memory.entries()).filter(
115
+ ([key]) => String(key).startsWith(options.prefix)
116
+ )
117
+ );
104
118
  }
105
119
  };
106
120
 
@@ -439,6 +453,14 @@ var Message = z.object({
439
453
  action: z.string(),
440
454
  value: z.any()
441
455
  });
456
+ var STATE_PREFIX = "state:";
457
+ var SESSION_PREFIX = "session:";
458
+ var SESSION_PUBLIC_PREFIX = "session-public:";
459
+ var TRANSFER_PREFIX = "transfer:";
460
+ var INTERNAL_PREFIX = "$room:";
461
+ var SESSION_GC_LAST_RUN_KEY = `${INTERNAL_PREFIX}session-gc:last-run`;
462
+ var TRANSFER_GC_LAST_RUN_KEY = `${INTERNAL_PREFIX}transfer-gc:last-run`;
463
+ var DEFAULT_TRANSFER_EXPIRY_MS = 5 * 60 * 1e3;
442
464
  var Server = class {
443
465
  /**
444
466
  * @constructor
@@ -471,6 +493,88 @@ var Server = class {
471
493
  get roomStorage() {
472
494
  return this.room.storage;
473
495
  }
496
+ stateKey(path) {
497
+ return `${STATE_PREFIX}${path}`;
498
+ }
499
+ sessionKey(privateId) {
500
+ return `${SESSION_PREFIX}${privateId}`;
501
+ }
502
+ sessionPublicKey(publicId) {
503
+ return `${SESSION_PUBLIC_PREFIX}${publicId}`;
504
+ }
505
+ transferKey(token) {
506
+ return `${TRANSFER_PREFIX}${token}`;
507
+ }
508
+ isInternalStorageKey(key) {
509
+ return key.startsWith(STATE_PREFIX) || key.startsWith(SESSION_PREFIX) || key.startsWith(SESSION_PUBLIC_PREFIX) || key.startsWith(TRANSFER_PREFIX) || key.startsWith(INTERNAL_PREFIX);
510
+ }
511
+ async listStorage(prefix) {
512
+ if (!prefix) {
513
+ return this.room.storage.list();
514
+ }
515
+ return this.room.storage.list({ prefix });
516
+ }
517
+ async loadStatePath(path) {
518
+ return this.room.storage.get(this.stateKey(path));
519
+ }
520
+ async saveStatePath(path, value) {
521
+ await this.room.storage.put(this.stateKey(path), value);
522
+ }
523
+ async putStorageEntries(entries) {
524
+ const keys = Object.keys(entries);
525
+ if (keys.length === 0) return;
526
+ if (keys.length === 1) {
527
+ const key = keys[0];
528
+ await this.room.storage.put(key, entries[key]);
529
+ return;
530
+ }
531
+ await this.room.storage.put(entries);
532
+ }
533
+ async deleteStorageKeys(keys) {
534
+ const uniqueKeys = Array.from(new Set(keys));
535
+ if (uniqueKeys.length === 0) return;
536
+ if (uniqueKeys.length === 1) {
537
+ await this.room.storage.delete(uniqueKeys[0]);
538
+ return;
539
+ }
540
+ await this.room.storage.delete(uniqueKeys);
541
+ }
542
+ async deleteStatePath(path) {
543
+ const stateKey = this.stateKey(path);
544
+ const descendantEntries = await this.listStorage(`${stateKey}.`);
545
+ await this.deleteStorageKeys([
546
+ ...Array.from(descendantEntries.keys()),
547
+ path
548
+ ]);
549
+ await this.saveStatePath(path, DELETE_TOKEN);
550
+ }
551
+ createStorageMetrics() {
552
+ return {
553
+ loadMs: 0,
554
+ loadStateKeys: 0,
555
+ loadLegacyKeys: 0,
556
+ persistFlushes: 0,
557
+ persistWrites: 0,
558
+ persistDeletes: 0,
559
+ persistLastFlushMs: 0,
560
+ sessionGcRuns: 0,
561
+ sessionGcScanned: 0,
562
+ sessionGcExpired: 0,
563
+ sessionIndexRepairs: 0,
564
+ transferGcRuns: 0,
565
+ transferGcScanned: 0,
566
+ transferGcExpired: 0
567
+ };
568
+ }
569
+ getTransferExpiryTime(subRoom) {
570
+ return subRoom?.transferExpiryTime ?? subRoom?.constructor?.prototype?.transferExpiryTime ?? subRoom?.constructor?.transferExpiryTime ?? DEFAULT_TRANSFER_EXPIRY_MS;
571
+ }
572
+ getPrivateId(conn) {
573
+ return conn.state?.privateId || conn.sessionId || conn.id;
574
+ }
575
+ hasActiveSessionConnection(privateId) {
576
+ return Array.from(this.room.getConnections()).some((conn) => this.getPrivateId(conn) === privateId);
577
+ }
474
578
  async send(conn, obj, subRoom) {
475
579
  obj = structuredClone(obj);
476
580
  if (subRoom.interceptorPacket) {
@@ -512,33 +616,44 @@ var Server = class {
512
616
  async garbageCollector(options) {
513
617
  const subRoom = await this.getSubRoom();
514
618
  if (!subRoom) return;
619
+ const SESSION_EXPIRY_TIME = Number(options.sessionExpiryTime);
620
+ if (!Number.isFinite(SESSION_EXPIRY_TIME)) {
621
+ return;
622
+ }
515
623
  const activeConnections = [...this.room.getConnections()];
516
- const activePrivateIds = new Set(activeConnections.map((conn) => conn.id));
624
+ const activePrivateIds = new Set(activeConnections.map((conn) => this.getPrivateId(conn)));
517
625
  try {
518
- const sessions = await this.room.storage.list();
626
+ const sessions = await this.listStorage(SESSION_PREFIX);
519
627
  const users = this.getUsersProperty(subRoom);
520
628
  const usersPropName = this.getUsersPropName(subRoom);
629
+ const metrics = subRoom.$storageMetrics;
630
+ if (metrics) {
631
+ metrics.sessionGcRuns += 1;
632
+ metrics.sessionGcScanned += sessions.size;
633
+ }
521
634
  const validPublicIds = /* @__PURE__ */ new Set();
522
635
  const expiredPublicIds = /* @__PURE__ */ new Set();
523
- const SESSION_EXPIRY_TIME = options.sessionExpiryTime;
524
636
  const now = Date.now();
525
637
  for (const [key, session] of sessions) {
526
- if (!key.startsWith("session:")) continue;
527
- const privateId = key.replace("session:", "");
638
+ if (!key.startsWith(SESSION_PREFIX)) continue;
639
+ const privateId = key.slice(SESSION_PREFIX.length);
528
640
  const typedSession = session;
529
- if (!activePrivateIds.has(privateId) && !typedSession.connected && now - typedSession.created > SESSION_EXPIRY_TIME) {
641
+ if (!activePrivateIds.has(privateId) && !typedSession.connected && typedSession.disconnectedAt !== void 0 && now - typedSession.disconnectedAt >= SESSION_EXPIRY_TIME) {
530
642
  await this.deleteSession(privateId);
531
643
  expiredPublicIds.add(typedSession.publicId);
644
+ if (metrics) {
645
+ metrics.sessionGcExpired += 1;
646
+ }
532
647
  } else if (typedSession && typedSession.publicId) {
533
648
  validPublicIds.add(typedSession.publicId);
534
649
  }
535
650
  }
651
+ await this.repairSessionPublicIndexes(sessions, metrics);
536
652
  if (users && usersPropName) {
537
653
  const currentUsers = users();
538
654
  for (const publicId in currentUsers) {
539
655
  if (expiredPublicIds.has(publicId) && !validPublicIds.has(publicId)) {
540
656
  delete currentUsers[publicId];
541
- await this.room.storage.delete(`${usersPropName}.${publicId}`);
542
657
  }
543
658
  }
544
659
  }
@@ -546,6 +661,145 @@ var Server = class {
546
661
  console.error("Error in garbage collector:", error);
547
662
  }
548
663
  }
664
+ scheduleSessionGarbageCollector(sessionExpiryTime, privateId) {
665
+ const normalizedSessionExpiryTime = Number(sessionExpiryTime);
666
+ if (!Number.isFinite(normalizedSessionExpiryTime) || normalizedSessionExpiryTime < 0) {
667
+ return;
668
+ }
669
+ setTimeout(() => {
670
+ if (privateId) {
671
+ void this.expireDisconnectedSession(privateId, normalizedSessionExpiryTime);
672
+ return;
673
+ }
674
+ void this.garbageCollector({ sessionExpiryTime: normalizedSessionExpiryTime });
675
+ }, normalizedSessionExpiryTime);
676
+ }
677
+ getSessionExpiryTime(subRoom) {
678
+ return subRoom?.sessionExpiryTime ?? subRoom?.constructor?.prototype?.sessionExpiryTime ?? subRoom?.constructor?.sessionExpiryTime;
679
+ }
680
+ async shouldRunSessionGarbageCollector(sessionExpiryTime) {
681
+ const normalizedSessionExpiryTime = Number(sessionExpiryTime);
682
+ if (!Number.isFinite(normalizedSessionExpiryTime) || normalizedSessionExpiryTime < 0) {
683
+ return false;
684
+ }
685
+ const now = Date.now();
686
+ const lastRun = await this.room.storage.get(SESSION_GC_LAST_RUN_KEY);
687
+ if (lastRun && now - lastRun < normalizedSessionExpiryTime) {
688
+ return false;
689
+ }
690
+ await this.room.storage.put(SESSION_GC_LAST_RUN_KEY, now);
691
+ return true;
692
+ }
693
+ async shouldRunInterval(key, interval) {
694
+ const normalizedInterval = Number(interval);
695
+ if (!Number.isFinite(normalizedInterval) || normalizedInterval < 0) {
696
+ return false;
697
+ }
698
+ const now = Date.now();
699
+ const lastRun = await this.room.storage.get(key);
700
+ if (lastRun && now - lastRun < normalizedInterval) {
701
+ return false;
702
+ }
703
+ await this.room.storage.put(key, now);
704
+ return true;
705
+ }
706
+ async repairSessionPublicIndexes(sessions, metrics) {
707
+ const sessionEntries = sessions ?? await this.listStorage(SESSION_PREFIX);
708
+ const expected = /* @__PURE__ */ new Map();
709
+ for (const [key, session] of sessionEntries) {
710
+ if (!session?.publicId) continue;
711
+ const privateId = key.slice(SESSION_PREFIX.length);
712
+ const privateIds = expected.get(session.publicId) ?? [];
713
+ privateIds.push(privateId);
714
+ expected.set(session.publicId, privateIds);
715
+ }
716
+ const publicIndexes = await this.listStorage(SESSION_PUBLIC_PREFIX);
717
+ const writes = {};
718
+ const deletes = [];
719
+ let repairs = 0;
720
+ for (const [publicId, privateIds] of expected) {
721
+ privateIds.sort();
722
+ const key = this.sessionPublicKey(publicId);
723
+ const existing = publicIndexes.get(key) ?? [];
724
+ const normalizedExisting = Array.isArray(existing) ? [...existing].sort() : [];
725
+ if (JSON.stringify(normalizedExisting) !== JSON.stringify(privateIds)) {
726
+ writes[key] = privateIds;
727
+ repairs += 1;
728
+ }
729
+ }
730
+ for (const [key] of publicIndexes) {
731
+ const publicId = key.slice(SESSION_PUBLIC_PREFIX.length);
732
+ if (!expected.has(publicId)) {
733
+ deletes.push(key);
734
+ repairs += 1;
735
+ }
736
+ }
737
+ await Promise.all([
738
+ this.putStorageEntries(writes),
739
+ this.deleteStorageKeys(deletes)
740
+ ]);
741
+ if (metrics) {
742
+ metrics.sessionIndexRepairs += repairs;
743
+ }
744
+ }
745
+ isTransferExpired(transfer, transferExpiryTime) {
746
+ if (!transfer) return true;
747
+ if (!Number.isFinite(transferExpiryTime) || transferExpiryTime < 0) {
748
+ return false;
749
+ }
750
+ const created = Number(transfer.created);
751
+ if (!Number.isFinite(created)) {
752
+ return false;
753
+ }
754
+ return Date.now() - created >= transferExpiryTime;
755
+ }
756
+ async cleanupExpiredTransfers(transferExpiryTime, metrics) {
757
+ if (!Number.isFinite(transferExpiryTime) || transferExpiryTime < 0) {
758
+ return;
759
+ }
760
+ const transfers = await this.listStorage(TRANSFER_PREFIX);
761
+ const expiredKeys = [];
762
+ for (const [key, transfer] of transfers) {
763
+ if (this.isTransferExpired(transfer, transferExpiryTime)) {
764
+ expiredKeys.push(key);
765
+ }
766
+ }
767
+ await this.deleteStorageKeys(expiredKeys);
768
+ if (metrics) {
769
+ metrics.transferGcRuns += 1;
770
+ metrics.transferGcScanned += transfers.size;
771
+ metrics.transferGcExpired += expiredKeys.length;
772
+ }
773
+ }
774
+ async expireDisconnectedSession(privateId, sessionExpiryTime) {
775
+ const session = await this.getSession(privateId);
776
+ if (!session || session.connected || session.disconnectedAt === void 0) {
777
+ return;
778
+ }
779
+ if (this.hasActiveSessionConnection(privateId)) {
780
+ return;
781
+ }
782
+ const elapsed = Date.now() - session.disconnectedAt;
783
+ if (elapsed < sessionExpiryTime) {
784
+ setTimeout(() => {
785
+ void this.expireDisconnectedSession(privateId, sessionExpiryTime);
786
+ }, sessionExpiryTime - elapsed);
787
+ return;
788
+ }
789
+ await this.deleteSession(privateId);
790
+ const privateIds = await this.getSessionPrivateIds(session.publicId);
791
+ for (const otherPrivateId of privateIds) {
792
+ const otherSession = await this.getSession(otherPrivateId);
793
+ if (otherSession?.publicId === session.publicId) {
794
+ return;
795
+ }
796
+ }
797
+ const subRoom = await this.getSubRoom();
798
+ const users = this.getUsersProperty(subRoom);
799
+ if (users?.()[session.publicId]) {
800
+ delete users()[session.publicId];
801
+ }
802
+ }
549
803
  /**
550
804
  * @method createRoom
551
805
  * @private
@@ -577,21 +831,55 @@ var Server = class {
577
831
  return null;
578
832
  }
579
833
  const loadMemory = async () => {
580
- const root = await this.room.storage.get(".");
581
- const memory = await this.room.storage.list();
834
+ const startedAt = Date.now();
835
+ const metrics = instance.$storageMetrics;
836
+ const root = await this.loadStatePath(".");
837
+ const memory = await this.listStorage(STATE_PREFIX);
838
+ metrics.loadStateKeys = memory.size;
582
839
  const tmpObject = root || {};
583
- for (let [key, value] of memory) {
584
- if (key.startsWith("session:")) {
585
- continue;
586
- }
587
- if (key == ".") {
840
+ for (let [storageKey, value] of memory) {
841
+ const key = storageKey.slice(STATE_PREFIX.length);
842
+ if (key === ".") {
588
843
  continue;
589
844
  }
590
845
  dset2(tmpObject, key, value);
591
846
  }
847
+ if (root === void 0 && memory.size === 0) {
848
+ const legacyRoot = await this.room.storage.get(".");
849
+ const legacyMemory = await this.room.storage.list();
850
+ const legacyObject = legacyRoot || {};
851
+ const migratedEntries = [];
852
+ const legacyDeleteKeys = [];
853
+ if (legacyRoot !== void 0) {
854
+ migratedEntries.push([".", legacyRoot]);
855
+ legacyDeleteKeys.push(".");
856
+ }
857
+ for (let [key, value] of legacyMemory) {
858
+ if (key === "." || this.isInternalStorageKey(key)) {
859
+ continue;
860
+ }
861
+ dset2(legacyObject, key, value);
862
+ migratedEntries.push([key, value]);
863
+ legacyDeleteKeys.push(key);
864
+ }
865
+ metrics.loadLegacyKeys = migratedEntries.length;
866
+ await this.putStorageEntries(
867
+ Object.fromEntries(
868
+ migratedEntries.map(([path, value]) => [this.stateKey(path), value])
869
+ )
870
+ );
871
+ if (legacyRoot !== void 0) {
872
+ await this.deleteStorageKeys(legacyDeleteKeys);
873
+ }
874
+ load(instance, legacyObject, true);
875
+ metrics.loadMs = Date.now() - startedAt;
876
+ return;
877
+ }
592
878
  load(instance, tmpObject, true);
879
+ metrics.loadMs = Date.now() - startedAt;
593
880
  };
594
881
  instance.$memoryAll = {};
882
+ instance.$storageMetrics = this.createStorageMetrics();
595
883
  instance.$autoSync = instance["autoSync"] !== false;
596
884
  instance.$pendingSync = /* @__PURE__ */ new Map();
597
885
  instance.$pendingInitialSync = /* @__PURE__ */ new Map();
@@ -645,16 +933,9 @@ var Server = class {
645
933
  console.error(`[sessionTransfer] User with publicId ${publicId} not found.`);
646
934
  return null;
647
935
  }
648
- const sessions = await this.room.storage.list();
649
- let userSession = null;
650
- let privateId = null;
651
- for (const [key, session] of sessions) {
652
- if (key.startsWith("session:") && session.publicId === publicId) {
653
- userSession = session;
654
- privateId = key.replace("session:", "");
655
- break;
656
- }
657
- }
936
+ const sessionEntry = await this.getSessionEntryByPublicId(publicId);
937
+ const userSession = sessionEntry?.session;
938
+ const privateId = sessionEntry?.privateId ?? null;
658
939
  if (!userSession || !privateId) {
659
940
  console.error(`[sessionTransfer] Session for publicId ${publicId} not found.`);
660
941
  return null;
@@ -720,20 +1001,79 @@ var Server = class {
720
1001
  values.clear();
721
1002
  return;
722
1003
  }
1004
+ const startedAt = Date.now();
1005
+ const stateWrites = {};
1006
+ const deleteTasks = [];
723
1007
  for (let [path, value] of values) {
724
1008
  const _instance = path == "." ? instance : getByPath(instance, path);
725
- const itemValue = createStatesSnapshot(_instance);
726
1009
  if (value == DELETE_TOKEN) {
727
- await this.room.storage.delete(path);
1010
+ deleteTasks.push(this.deleteStatePath(path));
728
1011
  } else {
729
- await this.room.storage.put(path, itemValue);
1012
+ const itemValue = _instance?.$snapshot ? createStatesSnapshot(_instance) : value;
1013
+ stateWrites[this.stateKey(path)] = itemValue;
730
1014
  }
731
1015
  }
1016
+ await Promise.all([
1017
+ this.putStorageEntries(stateWrites),
1018
+ ...deleteTasks
1019
+ ]);
1020
+ const metrics = instance.$storageMetrics;
1021
+ if (metrics) {
1022
+ metrics.persistFlushes += 1;
1023
+ metrics.persistWrites += Object.keys(stateWrites).length;
1024
+ metrics.persistDeletes += deleteTasks.length;
1025
+ metrics.persistLastFlushMs = Date.now() - startedAt;
1026
+ }
732
1027
  values.clear();
733
1028
  };
1029
+ const debouncePersist = (wait) => {
1030
+ let timeout = null;
1031
+ let flushing = false;
1032
+ const pending = /* @__PURE__ */ new Map();
1033
+ const schedule = () => {
1034
+ if (timeout) {
1035
+ clearTimeout(timeout);
1036
+ }
1037
+ timeout = setTimeout(() => {
1038
+ void flush();
1039
+ }, wait);
1040
+ };
1041
+ const flush = async () => {
1042
+ timeout = null;
1043
+ if (flushing) {
1044
+ schedule();
1045
+ return;
1046
+ }
1047
+ const values = new Map(pending);
1048
+ pending.clear();
1049
+ if (!values.size) {
1050
+ return;
1051
+ }
1052
+ flushing = true;
1053
+ try {
1054
+ await persistCb(values);
1055
+ } finally {
1056
+ flushing = false;
1057
+ if (pending.size) {
1058
+ schedule();
1059
+ }
1060
+ }
1061
+ };
1062
+ return (values) => {
1063
+ if (initPersist) {
1064
+ values.clear();
1065
+ return;
1066
+ }
1067
+ for (const [path, value] of values) {
1068
+ pending.set(path, value);
1069
+ }
1070
+ values.clear();
1071
+ schedule();
1072
+ };
1073
+ };
734
1074
  syncClass(instance, {
735
1075
  onSync: instance["throttleSync"] ? throttle(syncCb, instance["throttleSync"]) : syncCb,
736
- onPersist: instance["throttleStorage"] ? throttle(persistCb, instance["throttleStorage"]) : persistCb
1076
+ onPersist: instance["throttleStorage"] ? debouncePersist(instance["throttleStorage"]) : persistCb
737
1077
  });
738
1078
  await loadMemory();
739
1079
  initPersist = false;
@@ -840,24 +1180,93 @@ var Server = class {
840
1180
  async getSession(privateId) {
841
1181
  if (!privateId) return null;
842
1182
  try {
843
- const session = await this.room.storage.get(`session:${privateId}`);
1183
+ const session = await this.room.storage.get(this.sessionKey(privateId));
844
1184
  return session;
845
1185
  } catch (e) {
846
1186
  return null;
847
1187
  }
848
1188
  }
1189
+ async getSessionPrivateIds(publicId) {
1190
+ if (!publicId) return [];
1191
+ const privateIds = await this.room.storage.get(this.sessionPublicKey(publicId));
1192
+ return Array.isArray(privateIds) ? privateIds : [];
1193
+ }
1194
+ async saveSessionPrivateIds(publicId, privateIds) {
1195
+ const key = this.sessionPublicKey(publicId);
1196
+ if (privateIds.length === 0) {
1197
+ await this.room.storage.delete(key);
1198
+ return;
1199
+ }
1200
+ await this.room.storage.put(key, privateIds);
1201
+ }
1202
+ async addSessionToPublicIndex(privateId, publicId) {
1203
+ const privateIds = await this.getSessionPrivateIds(publicId);
1204
+ if (privateIds.includes(privateId)) {
1205
+ return;
1206
+ }
1207
+ await this.saveSessionPrivateIds(publicId, [...privateIds, privateId]);
1208
+ }
1209
+ async removeSessionFromPublicIndex(privateId, publicId) {
1210
+ const privateIds = await this.getSessionPrivateIds(publicId);
1211
+ await this.saveSessionPrivateIds(
1212
+ publicId,
1213
+ privateIds.filter((id2) => id2 !== privateId)
1214
+ );
1215
+ }
1216
+ async getSessionEntryByPublicId(publicId) {
1217
+ const indexedPrivateIds = await this.getSessionPrivateIds(publicId);
1218
+ const stalePrivateIds = [];
1219
+ for (const privateId of indexedPrivateIds) {
1220
+ const session = await this.getSession(privateId);
1221
+ if (session?.publicId === publicId) {
1222
+ return { privateId, session };
1223
+ }
1224
+ stalePrivateIds.push(privateId);
1225
+ }
1226
+ if (stalePrivateIds.length) {
1227
+ await this.saveSessionPrivateIds(
1228
+ publicId,
1229
+ indexedPrivateIds.filter((id2) => !stalePrivateIds.includes(id2))
1230
+ );
1231
+ }
1232
+ const sessions = await this.listStorage(SESSION_PREFIX);
1233
+ for (const [key, session] of sessions) {
1234
+ const privateId = key.slice(SESSION_PREFIX.length);
1235
+ if (session?.publicId) {
1236
+ await this.addSessionToPublicIndex(privateId, session.publicId);
1237
+ }
1238
+ if (session?.publicId === publicId) {
1239
+ return { privateId, session };
1240
+ }
1241
+ }
1242
+ return null;
1243
+ }
849
1244
  async saveSession(privateId, data) {
1245
+ const existingSession = await this.getSession(privateId);
850
1246
  const sessionData = {
851
1247
  ...data,
852
1248
  created: data.created || Date.now(),
853
1249
  connected: data.connected !== void 0 ? data.connected : true
854
1250
  };
855
- await this.room.storage.put(`session:${privateId}`, sessionData);
1251
+ await this.room.storage.put(this.sessionKey(privateId), sessionData);
1252
+ if (existingSession?.publicId && existingSession.publicId !== sessionData.publicId) {
1253
+ await this.removeSessionFromPublicIndex(privateId, existingSession.publicId);
1254
+ }
1255
+ await this.addSessionToPublicIndex(privateId, sessionData.publicId);
856
1256
  }
857
1257
  async updateSessionConnection(privateId, connected) {
858
1258
  const session = await this.getSession(privateId);
859
1259
  if (session) {
860
- 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);
861
1270
  }
862
1271
  }
863
1272
  /**
@@ -872,7 +1281,11 @@ var Server = class {
872
1281
  * ```
873
1282
  */
874
1283
  async deleteSession(privateId) {
875
- await this.room.storage.delete(`session:${privateId}`);
1284
+ const session = await this.getSession(privateId);
1285
+ await this.room.storage.delete(this.sessionKey(privateId));
1286
+ if (session?.publicId) {
1287
+ await this.removeSessionFromPublicIndex(privateId, session.publicId);
1288
+ }
876
1289
  }
877
1290
  async onConnectClient(conn, ctx) {
878
1291
  const subRoom = await this.getSubRoom({
@@ -882,8 +1295,17 @@ var Server = class {
882
1295
  conn.close();
883
1296
  return;
884
1297
  }
885
- const sessionExpiryTime = subRoom.sessionExpiryTime ?? subRoom.constructor.sessionExpiryTime ?? 5 * 60 * 1e3;
886
- await this.garbageCollector({ sessionExpiryTime });
1298
+ const sessionExpiryTime = this.getSessionExpiryTime(subRoom);
1299
+ if (await this.shouldRunSessionGarbageCollector(sessionExpiryTime)) {
1300
+ await this.garbageCollector({ sessionExpiryTime });
1301
+ }
1302
+ const transferExpiryTime = this.getTransferExpiryTime(subRoom);
1303
+ if (await this.shouldRunInterval(TRANSFER_GC_LAST_RUN_KEY, transferExpiryTime)) {
1304
+ await this.cleanupExpiredTransfers(
1305
+ transferExpiryTime,
1306
+ subRoom.$storageMetrics
1307
+ );
1308
+ }
887
1309
  const roomGuards = subRoom.constructor["_roomGuards"] || [];
888
1310
  for (const guard of roomGuards) {
889
1311
  const isAuthorized = await guard(conn, ctx, this.room);
@@ -899,12 +1321,18 @@ var Server = class {
899
1321
  }
900
1322
  let transferData = null;
901
1323
  if (transferToken) {
902
- transferData = await this.room.storage.get(`transfer:${transferToken}`);
1324
+ const transferKey = this.transferKey(transferToken);
1325
+ transferData = await this.room.storage.get(transferKey);
903
1326
  if (transferData) {
904
- await this.room.storage.delete(`transfer:${transferToken}`);
1327
+ if (this.isTransferExpired(transferData, transferExpiryTime)) {
1328
+ transferData = null;
1329
+ }
1330
+ await this.room.storage.delete(transferKey);
905
1331
  }
906
1332
  }
907
- const existingSession = await this.getSession(conn.id);
1333
+ const requestedPrivateId = this.getPrivateId(conn);
1334
+ const privateId = transferData?.privateId || requestedPrivateId;
1335
+ const existingSession = await this.getSession(privateId);
908
1336
  const publicId = existingSession?.publicId || transferData?.publicId || generateShortUUID2();
909
1337
  let user = null;
910
1338
  const signal2 = this.getUsersProperty(subRoom);
@@ -918,24 +1346,24 @@ var Server = class {
918
1346
  user = isClass(classType) ? new classType() : classType(conn, ctx);
919
1347
  signal2()[publicId] = user;
920
1348
  const snapshot = createStatesSnapshotDeep(user);
921
- this.room.storage.put(`${usersPropName}.${publicId}`, snapshot);
1349
+ await this.saveStatePath(`${usersPropName}.${publicId}`, snapshot);
922
1350
  }
923
1351
  } else {
924
1352
  user = signal2()[existingSession.publicId];
925
1353
  }
926
1354
  if (!existingSession) {
927
- const sessionPrivateId = transferData?.privateId || conn.id;
928
- await this.saveSession(sessionPrivateId, {
1355
+ await this.saveSession(privateId, {
929
1356
  publicId
930
1357
  });
931
1358
  } else {
932
- await this.updateSessionConnection(conn.id, true);
1359
+ await this.updateSessionConnection(privateId, true);
933
1360
  }
934
1361
  }
935
1362
  this.updateUserConnectionStatus(user, true);
936
1363
  conn.setState({
937
1364
  ...conn.state,
938
- publicId
1365
+ publicId,
1366
+ privateId
939
1367
  });
940
1368
  await awaitReturn(subRoom["onJoin"]?.(user, conn, ctx));
941
1369
  if (subRoom.$autoSync) {
@@ -968,11 +1396,19 @@ var Server = class {
968
1396
  */
969
1397
  async onConnect(conn, ctx) {
970
1398
  if (ctx.request?.headers.has("x-shard-id")) {
1399
+ if (!this.isAuthorizedShardRequest(ctx.request)) {
1400
+ conn.close();
1401
+ return;
1402
+ }
971
1403
  this.onConnectShard(conn, ctx);
972
1404
  } else {
973
1405
  await this.onConnectClient(conn, ctx);
974
1406
  }
975
1407
  }
1408
+ isAuthorizedShardRequest(req) {
1409
+ const shardSecret = this.room.env.SHARD_SECRET;
1410
+ return typeof shardSecret === "string" && shardSecret.length > 0 && req?.headers.get("x-access-shard") === shardSecret;
1411
+ }
976
1412
  /**
977
1413
  * @method onConnectShard
978
1414
  * @private
@@ -983,9 +1419,11 @@ var Server = class {
983
1419
  */
984
1420
  onConnectShard(conn, ctx) {
985
1421
  const shardId = ctx.request?.headers.get("x-shard-id") || "unknown-shard";
1422
+ const worldId = ctx.request?.headers.get("x-shard-world-id") || "world-default";
986
1423
  conn.setState({
987
1424
  shard: true,
988
1425
  shardId,
1426
+ worldId,
989
1427
  clients: /* @__PURE__ */ new Map()
990
1428
  // Track clients connected through this shard
991
1429
  });
@@ -1255,11 +1693,15 @@ var Server = class {
1255
1693
  if (!conn.state) {
1256
1694
  return;
1257
1695
  }
1258
- const privateId = conn.id;
1696
+ const privateId = this.getPrivateId(conn);
1259
1697
  const { publicId } = conn.state;
1260
1698
  const user = signal2?.()[publicId];
1261
1699
  if (!user) return;
1700
+ if (this.hasActiveSessionConnection(privateId)) {
1701
+ return;
1702
+ }
1262
1703
  await this.updateSessionConnection(privateId, false);
1704
+ this.scheduleSessionGarbageCollector(this.getSessionExpiryTime(subRoom), privateId);
1263
1705
  const connectionUpdated = this.updateUserConnectionStatus(user, false);
1264
1706
  await awaitReturn(subRoom["onLeave"]?.(user, conn));
1265
1707
  if (!connectionUpdated) {
@@ -1294,6 +1736,9 @@ var Server = class {
1294
1736
  return res.status(200).send({});
1295
1737
  }
1296
1738
  if (isFromShard) {
1739
+ if (!this.isAuthorizedShardRequest(req)) {
1740
+ return res.unauthorized("Invalid shard credentials");
1741
+ }
1297
1742
  return this.handleShardRequest(req, res, shardId);
1298
1743
  }
1299
1744
  return this.handleDirectRequest(req, res);
@@ -1343,14 +1788,15 @@ var Server = class {
1343
1788
  ) ?? userSnapshot;
1344
1789
  signal2()[publicId] = user;
1345
1790
  load(user, hydratedSnapshot, true);
1346
- await this.room.storage.put(`${usersPropName}.${publicId}`, userSnapshot);
1791
+ await this.saveStatePath(`${usersPropName}.${publicId}`, userSnapshot);
1347
1792
  }
1348
1793
  }
1349
1794
  const transferToken = generateShortUUID2();
1350
- await this.room.storage.put(`transfer:${transferToken}`, {
1795
+ await this.room.storage.put(this.transferKey(transferToken), {
1351
1796
  privateId,
1352
1797
  publicId,
1353
- restored: true
1798
+ restored: true,
1799
+ created: Date.now()
1354
1800
  });
1355
1801
  return res.success({ transferToken });
1356
1802
  } catch (error) {
@@ -1404,7 +1850,8 @@ var Server = class {
1404
1850
  }
1405
1851
  const url = new URL(req.url);
1406
1852
  const method = req.method;
1407
- const pathname = this.normalizeRequestPath(url.pathname);
1853
+ let pathname = url.pathname;
1854
+ pathname = "/" + pathname.split("/").slice(4).join("/");
1408
1855
  for (const [routeKey, handler] of requestHandlers.entries()) {
1409
1856
  const firstColonIndex = routeKey.indexOf(":");
1410
1857
  const handlerMethod = routeKey.substring(0, firstColonIndex);
@@ -1428,17 +1875,16 @@ var Server = class {
1428
1875
  if (handler.bodyValidation && ["POST", "PUT", "PATCH"].includes(method)) {
1429
1876
  try {
1430
1877
  const contentType = req.headers.get("content-type") || "";
1431
- if (!contentType.includes("application/json")) {
1432
- return res.badRequest("Content-Type must be application/json");
1433
- }
1434
- const body = await req.json();
1435
- const validation = handler.bodyValidation.safeParse(body);
1436
- if (!validation.success) {
1437
- return res.badRequest("Invalid request body", {
1438
- details: validation.error
1439
- });
1878
+ if (contentType.includes("application/json")) {
1879
+ const body = await req.json();
1880
+ const validation = handler.bodyValidation.safeParse(body);
1881
+ if (!validation.success) {
1882
+ return res.badRequest("Invalid request body", {
1883
+ details: validation.error
1884
+ });
1885
+ }
1886
+ bodyData = validation.data;
1440
1887
  }
1441
- bodyData = validation.data;
1442
1888
  } catch (error) {
1443
1889
  return res.badRequest("Failed to parse request body");
1444
1890
  }
@@ -1470,7 +1916,9 @@ var Server = class {
1470
1916
  * @returns {boolean} True if the paths match
1471
1917
  */
1472
1918
  pathMatches(requestPath, handlerPath) {
1473
- return this.pathPatternToRegex(handlerPath).test(requestPath);
1919
+ const pathRegexString = handlerPath.replace(/\//g, "\\/").replace(/:([^\/]+)/g, "([^/]+)");
1920
+ const pathRegex = new RegExp(`^${pathRegexString}`);
1921
+ return pathRegex.test(requestPath);
1474
1922
  }
1475
1923
  /**
1476
1924
  * @method extractPathParams
@@ -1488,7 +1936,8 @@ var Server = class {
1488
1936
  paramNames.push(segment.substring(1));
1489
1937
  }
1490
1938
  });
1491
- const pathRegex = this.pathPatternToRegex(handlerPath);
1939
+ const pathRegexString = handlerPath.replace(/\//g, "\\/").replace(/:([^\/]+)/g, "([^/]+)");
1940
+ const pathRegex = new RegExp(`^${pathRegexString}`);
1492
1941
  const matches = requestPath.match(pathRegex);
1493
1942
  if (matches && matches.length > 1) {
1494
1943
  for (let i = 0; i < paramNames.length; i++) {
@@ -1497,23 +1946,6 @@ var Server = class {
1497
1946
  }
1498
1947
  return params;
1499
1948
  }
1500
- normalizeRequestPath(pathname) {
1501
- const parts = pathname.split("/").filter(Boolean);
1502
- if (parts[0] === "parties" && parts.length >= 3) {
1503
- const routePath = parts.slice(3).join("/");
1504
- return routePath ? `/${routePath}` : "/";
1505
- }
1506
- return pathname || "/";
1507
- }
1508
- pathPatternToRegex(handlerPath) {
1509
- const segments = handlerPath.split("/").map((segment) => {
1510
- if (segment.startsWith(":")) {
1511
- return "([^/]+)";
1512
- }
1513
- return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1514
- });
1515
- return new RegExp(`^${segments.join("/")}$`);
1516
- }
1517
1949
  /**
1518
1950
  * @method handleShardRequest
1519
1951
  * @private
@@ -1568,17 +2000,33 @@ var Server = class {
1568
2000
 
1569
2001
  // src/shard.ts
1570
2002
  var Shard = class {
1571
- constructor(room) {
2003
+ constructor(room, options = {}) {
1572
2004
  this.room = room;
1573
2005
  this.connectionMap = /* @__PURE__ */ new Map();
1574
2006
  this.worldUrl = null;
1575
- this.worldId = "default";
1576
2007
  this.lastReportedConnections = 0;
1577
2008
  this.statsInterval = 3e4;
1578
2009
  this.statsIntervalId = null;
2010
+ this.worldUrl = options.worldUrl ?? null;
2011
+ this.worldId = options.worldId ?? this.getWorldIdFromShardId(room.id) ?? this.getEnvString("WORLD_ID") ?? this.getEnvString("SIGNE_WORLD_ID") ?? "world-default";
2012
+ this.statsInterval = options.statsInterval ?? this.statsInterval;
2013
+ }
2014
+ getPrivateId(conn) {
2015
+ return conn.sessionId || conn.id;
2016
+ }
2017
+ getEnvString(key) {
2018
+ const value = this.room.env?.[key];
2019
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2020
+ }
2021
+ getRoomIdFromShardId(shardId) {
2022
+ return shardId.split(":")[0];
2023
+ }
2024
+ getWorldIdFromShardId(shardId) {
2025
+ const parts = shardId.split(":");
2026
+ return parts.length >= 3 ? parts[1] : void 0;
1579
2027
  }
1580
2028
  async onStart() {
1581
- const roomId = this.room.id.split(":")[0];
2029
+ const roomId = this.getRoomIdFromShardId(this.room.id);
1582
2030
  const roomStub = this.room.context.parties.main.get(roomId);
1583
2031
  if (!roomStub) {
1584
2032
  console.warn("No room room stub found in main party context");
@@ -1587,17 +2035,30 @@ var Shard = class {
1587
2035
  this.mainServerStub = roomStub;
1588
2036
  this.ws = await roomStub.socket({
1589
2037
  headers: {
1590
- "x-shard-id": this.room.id
2038
+ "x-shard-id": this.room.id,
2039
+ "x-shard-world-id": this.worldId,
2040
+ "x-access-shard": this.room.env.SHARD_SECRET
1591
2041
  }
1592
2042
  });
1593
2043
  this.ws.addEventListener("message", (event) => {
1594
2044
  try {
1595
2045
  const message = JSON.parse(event.data);
2046
+ if (message.type === "shard.closeClient" && message.privateId) {
2047
+ const clientConnections = this.connectionMap.get(message.privateId);
2048
+ if (clientConnections?.size) {
2049
+ for (const clientConn of [...clientConnections]) {
2050
+ clientConn.close();
2051
+ }
2052
+ }
2053
+ return;
2054
+ }
1596
2055
  if (message.targetClientId) {
1597
- const clientConn = this.connectionMap.get(message.targetClientId);
1598
- if (clientConn) {
2056
+ const clientConnections = this.connectionMap.get(message.targetClientId);
2057
+ if (clientConnections?.size) {
1599
2058
  delete message.targetClientId;
1600
- clientConn.send(message.data);
2059
+ for (const clientConn of clientConnections) {
2060
+ clientConn.send(message.data);
2061
+ }
1601
2062
  }
1602
2063
  } else {
1603
2064
  this.room.broadcast(event.data);
@@ -1606,21 +2067,22 @@ var Shard = class {
1606
2067
  console.error("Error processing message from main server:", error);
1607
2068
  }
1608
2069
  });
1609
- await this.updateWorldStats();
2070
+ await this.updateWorldStats(true);
1610
2071
  this.startPeriodicStatsUpdates();
1611
2072
  }
1612
2073
  startPeriodicStatsUpdates() {
1613
- if (!this.worldUrl) {
2074
+ if (this.statsInterval <= 0 || !this.room.context.parties.world) {
1614
2075
  return;
1615
2076
  }
1616
2077
  if (this.statsIntervalId) {
1617
2078
  clearInterval(this.statsIntervalId);
1618
2079
  }
1619
2080
  this.statsIntervalId = setInterval(() => {
1620
- this.updateWorldStats().catch((error) => {
2081
+ this.updateWorldStats(true).catch((error) => {
1621
2082
  console.error("Error in periodic stats update:", error);
1622
2083
  });
1623
2084
  }, this.statsInterval);
2085
+ this.statsIntervalId?.unref?.();
1624
2086
  }
1625
2087
  stopPeriodicStatsUpdates() {
1626
2088
  if (this.statsIntervalId) {
@@ -1629,7 +2091,10 @@ var Shard = class {
1629
2091
  }
1630
2092
  }
1631
2093
  onConnect(conn, ctx) {
1632
- this.connectionMap.set(conn.id, conn);
2094
+ const privateId = this.getPrivateId(conn);
2095
+ const connections = this.connectionMap.get(privateId) ?? /* @__PURE__ */ new Set();
2096
+ connections.add(conn);
2097
+ this.connectionMap.set(privateId, connections);
1633
2098
  const headers = {};
1634
2099
  if (ctx.request?.headers) {
1635
2100
  ctx.request.headers.forEach((value, key) => {
@@ -1643,7 +2108,7 @@ var Shard = class {
1643
2108
  } : null;
1644
2109
  this.ws.send(JSON.stringify({
1645
2110
  type: "shard.clientConnected",
1646
- privateId: conn.id,
2111
+ privateId,
1647
2112
  requestInfo
1648
2113
  }));
1649
2114
  this.updateWorldStats();
@@ -1653,7 +2118,7 @@ var Shard = class {
1653
2118
  const parsedMessage = typeof message === "string" ? JSON.parse(message) : message;
1654
2119
  const wrappedMessage = JSON.stringify({
1655
2120
  type: "shard.clientMessage",
1656
- privateId: sender.id,
2121
+ privateId: this.getPrivateId(sender),
1657
2122
  publicId: sender.state?.publicId,
1658
2123
  payload: parsedMessage
1659
2124
  });
@@ -1663,21 +2128,35 @@ var Shard = class {
1663
2128
  }
1664
2129
  }
1665
2130
  onClose(conn) {
1666
- this.connectionMap.delete(conn.id);
2131
+ const privateId = this.getPrivateId(conn);
2132
+ const connections = this.connectionMap.get(privateId);
2133
+ connections?.delete(conn);
2134
+ if (connections?.size) {
2135
+ this.updateWorldStats();
2136
+ return;
2137
+ }
2138
+ this.connectionMap.delete(privateId);
1667
2139
  this.ws.send(JSON.stringify({
1668
2140
  type: "shard.clientDisconnected",
1669
- privateId: conn.id,
2141
+ privateId,
1670
2142
  publicId: conn.state?.publicId
1671
2143
  }));
1672
2144
  this.updateWorldStats();
1673
2145
  }
1674
- async updateWorldStats() {
1675
- const currentConnections = this.connectionMap.size;
1676
- if (currentConnections === this.lastReportedConnections) {
2146
+ async updateWorldStats(force = false) {
2147
+ const currentConnections = Array.from(this.connectionMap.values()).reduce((total, connections) => total + connections.size, 0);
2148
+ if (!force && currentConnections === this.lastReportedConnections) {
1677
2149
  return true;
1678
2150
  }
1679
2151
  try {
1680
- const worldRoom = this.room.context.parties.world.get("world-default");
2152
+ const worldParty = this.room.context.parties.world;
2153
+ if (!worldParty) {
2154
+ return false;
2155
+ }
2156
+ const worldRoom = worldParty.get(this.worldId);
2157
+ if (!worldRoom?.fetch) {
2158
+ return false;
2159
+ }
1681
2160
  const response2 = await worldRoom.fetch("/update-shard", {
1682
2161
  method: "POST",
1683
2162
  headers: {
@@ -1686,6 +2165,7 @@ var Shard = class {
1686
2165
  },
1687
2166
  body: JSON.stringify({
1688
2167
  shardId: this.room.id,
2168
+ worldId: this.worldId,
1689
2169
  connections: currentConnections
1690
2170
  })
1691
2171
  });
@@ -1725,7 +2205,9 @@ var Shard = class {
1725
2205
  headers.set(key, value);
1726
2206
  });
1727
2207
  headers.set("x-shard-id", this.room.id);
2208
+ headers.set("x-shard-world-id", this.worldId);
1728
2209
  headers.set("x-forwarded-by-shard", "true");
2210
+ headers.set("x-access-shard", this.room.env.SHARD_SECRET);
1729
2211
  const clientIp = req.headers.get("x-forwarded-for") || "unknown";
1730
2212
  if (clientIp) {
1731
2213
  headers.set("x-original-client-ip", clientIp);
@@ -1735,7 +2217,7 @@ var Shard = class {
1735
2217
  headers,
1736
2218
  body
1737
2219
  };
1738
- const response2 = await this.mainServerStub.fetch(path, requestInit);
2220
+ const response2 = await this.mainServerStub.fetch(path + url.search, requestInit);
1739
2221
  return response2;
1740
2222
  } catch (error) {
1741
2223
  return response(500, { error: "Error forwarding request" });
@@ -1747,7 +2229,7 @@ var Shard = class {
1747
2229
  * @description Executed periodically, used to perform maintenance tasks
1748
2230
  */
1749
2231
  async onAlarm() {
1750
- await this.updateWorldStats();
2232
+ await this.updateWorldStats(true);
1751
2233
  }
1752
2234
  };
1753
2235
 
@@ -1759,7 +2241,7 @@ async function testRoom(Room2, options = {}) {
1759
2241
  return server2;
1760
2242
  };
1761
2243
  const isShard = options.shard || false;
1762
- const io = new ServerIo(Room2.path, isShard ? {
2244
+ const io = new ServerIo(options.id ?? Room2.path, isShard ? {
1763
2245
  parties: {
1764
2246
  game: createServer,
1765
2247
  ...options.parties || {}
@@ -1803,7 +2285,7 @@ async function testRoom(Room2, options = {}) {
1803
2285
  return client;
1804
2286
  },
1805
2287
  getServerUser: async (client, prop = "users") => {
1806
- const privateId = client.conn.id;
2288
+ const privateId = client.conn.sessionId || client.conn.id;
1807
2289
  const session = await server.getSession(privateId);
1808
2290
  return server.subRoom[prop]()[session?.publicId];
1809
2291
  }
@@ -1823,11 +2305,11 @@ function tick(ms = 0) {
1823
2305
 
1824
2306
  // src/mock.ts
1825
2307
  var MockPartyClient = class {
1826
- constructor(server, id2) {
2308
+ constructor(server, sessionId) {
1827
2309
  this.server = server;
1828
2310
  this.events = /* @__PURE__ */ new Map();
1829
- this.id = id2 || generateShortUUID();
1830
- this.conn = new MockConnection(this);
2311
+ this.id = generateShortUUID();
2312
+ this.conn = new MockConnection(this, sessionId || this.id);
1831
2313
  }
1832
2314
  addEventListener(event, cb) {
1833
2315
  if (!this.events.has(event)) {
@@ -1863,8 +2345,8 @@ var MockLobby = class {
1863
2345
  this.server = server;
1864
2346
  this.lobbyId = lobbyId;
1865
2347
  }
1866
- socket(_init) {
1867
- return new MockPartyClient(this.server);
2348
+ socket(init) {
2349
+ return new MockPartyClient(this.server, init?.id);
1868
2350
  }
1869
2351
  async connection(idOrOptions, maybeOptions) {
1870
2352
  const id2 = typeof idOrOptions === "string" ? idOrOptions : idOrOptions?.id;
@@ -1940,7 +2422,13 @@ var MockPartyRoom = class {
1940
2422
  });
1941
2423
  }
1942
2424
  getConnection(id2) {
1943
- return this.clients.get(id2);
2425
+ let connection;
2426
+ for (const client of this.clients.values()) {
2427
+ if (client.conn.id === id2 || client.conn.sessionId === id2) {
2428
+ connection = client.conn;
2429
+ }
2430
+ }
2431
+ return connection;
1944
2432
  }
1945
2433
  getConnections() {
1946
2434
  return Array.from(this.clients.values()).map((client) => client.conn);
@@ -1948,13 +2436,25 @@ var MockPartyRoom = class {
1948
2436
  clear() {
1949
2437
  this.clients.clear();
1950
2438
  }
2439
+ deleteConnection(id2, connection) {
2440
+ if (connection) {
2441
+ this.clients.delete(connection.id);
2442
+ return;
2443
+ }
2444
+ for (const [connectionKey, client] of this.clients) {
2445
+ if (client.conn.id === id2 || client.conn.sessionId === id2) {
2446
+ this.clients.delete(connectionKey);
2447
+ }
2448
+ }
2449
+ }
1951
2450
  };
1952
2451
  var MockConnection = class {
1953
- constructor(client) {
2452
+ constructor(client, sessionId) {
1954
2453
  this.client = client;
1955
2454
  this.state = {};
1956
2455
  this.server = client.server;
1957
2456
  this.id = client.id;
2457
+ this.sessionId = sessionId;
1958
2458
  }
1959
2459
  setState(value) {
1960
2460
  this.state = value;
@@ -1963,6 +2463,7 @@ var MockConnection = class {
1963
2463
  this.client._trigger("message", data);
1964
2464
  }
1965
2465
  close() {
2466
+ this.server.room.deleteConnection?.(this.id, this);
1966
2467
  this.server.onClose(this);
1967
2468
  }
1968
2469
  };
@@ -2142,7 +2643,7 @@ var guardManageWorld = async (_, req, room) => {
2142
2643
  return true;
2143
2644
  }
2144
2645
  const url = new URL(req.url);
2145
- const token = req.headers.get("Authorization") ?? url.searchParams.get("world-auth-token");
2646
+ const token = getAuthToken(req, url);
2146
2647
  if (!token) {
2147
2648
  return false;
2148
2649
  }
@@ -2152,11 +2653,28 @@ var guardManageWorld = async (_, req, room) => {
2152
2653
  if (!payload) {
2153
2654
  return false;
2154
2655
  }
2656
+ if (!canAccessWorld(payload, room.id)) {
2657
+ return false;
2658
+ }
2155
2659
  } catch (error) {
2156
2660
  return false;
2157
2661
  }
2158
2662
  return true;
2159
2663
  };
2664
+ function getAuthToken(req, url) {
2665
+ const authorization = req.headers.get("Authorization");
2666
+ if (authorization?.startsWith("Bearer ")) {
2667
+ return authorization.slice("Bearer ".length).trim();
2668
+ }
2669
+ return authorization ?? url.searchParams.get("world-auth-token");
2670
+ }
2671
+ function canAccessWorld(payload, worldId) {
2672
+ const worlds = payload.worlds;
2673
+ if (!Array.isArray(worlds)) {
2674
+ return false;
2675
+ }
2676
+ return worlds.some((world) => world === "*" || world === worldId);
2677
+ }
2160
2678
 
2161
2679
  // src/world.ts
2162
2680
  var MAX_PLAYERS_PER_SHARD = 75;
@@ -2168,8 +2686,16 @@ var RoomConfigSchema = z2.object({
2168
2686
  minShards: z2.number().int().min(0),
2169
2687
  maxShards: z2.number().int().positive().optional()
2170
2688
  });
2689
+ var RegisterShardSchema = z2.object({
2690
+ shardId: z2.string(),
2691
+ roomId: z2.string(),
2692
+ worldId: z2.string().optional(),
2693
+ url: z2.string().url(),
2694
+ maxConnections: z2.number().int().positive()
2695
+ });
2171
2696
  var UpdateShardStatsSchema = z2.object({
2172
2697
  shardId: z2.string(),
2698
+ worldId: z2.string().optional(),
2173
2699
  connections: z2.number().int().min(0),
2174
2700
  status: z2.enum(["active", "maintenance", "draining"]).optional()
2175
2701
  });
@@ -2215,6 +2741,7 @@ __decorateClass([
2215
2741
  var ShardInfo = class {
2216
2742
  constructor() {
2217
2743
  this.roomId = signal("");
2744
+ this.worldId = signal("");
2218
2745
  this.url = signal("");
2219
2746
  this.currentConnections = signal(0);
2220
2747
  this.maxConnections = signal(MAX_PLAYERS_PER_SHARD);
@@ -2228,6 +2755,9 @@ __decorateClass([
2228
2755
  __decorateClass([
2229
2756
  sync()
2230
2757
  ], ShardInfo.prototype, "roomId", 2);
2758
+ __decorateClass([
2759
+ sync()
2760
+ ], ShardInfo.prototype, "worldId", 2);
2231
2761
  __decorateClass([
2232
2762
  sync()
2233
2763
  ], ShardInfo.prototype, "url", 2);
@@ -2261,6 +2791,7 @@ var WorldRoom = class {
2261
2791
  if (!SHARD_SECRET) {
2262
2792
  throw new Error("SHARD_SECRET env variable is not set");
2263
2793
  }
2794
+ this.scheduleInactiveShardCleanup();
2264
2795
  }
2265
2796
  async onJoin(user, conn, ctx) {
2266
2797
  const canConnect = await guardManageWorld(user, ctx.request, this.room);
@@ -2276,6 +2807,13 @@ var WorldRoom = class {
2276
2807
  return obj;
2277
2808
  }
2278
2809
  // Helper methods
2810
+ getWorldId() {
2811
+ return this.room.id;
2812
+ }
2813
+ scheduleInactiveShardCleanup() {
2814
+ const timeoutId = setTimeout(() => this.cleanupInactiveShards(), 6e4);
2815
+ timeoutId?.unref?.();
2816
+ }
2279
2817
  cleanupInactiveShards() {
2280
2818
  const now = Date.now();
2281
2819
  const timeout = 5 * 60 * 1e3;
@@ -2287,16 +2825,25 @@ var WorldRoom = class {
2287
2825
  hasChanges = true;
2288
2826
  }
2289
2827
  });
2290
- setTimeout(() => this.cleanupInactiveShards(), 6e4);
2828
+ this.scheduleInactiveShardCleanup();
2829
+ }
2830
+ removeShard(shardId) {
2831
+ delete this.shards()[shardId];
2832
+ }
2833
+ shouldCompleteDrain(shard) {
2834
+ return shard.status() === "draining" && shard.currentConnections() === 0;
2291
2835
  }
2292
2836
  async registerRoom(req, res) {
2293
- const roomConfigResult = RoomConfigSchema.safeParse(await req.json());
2294
- if (!roomConfigResult.success) {
2837
+ const parseResult = RoomConfigSchema.safeParse(await req.json());
2838
+ if (!parseResult.success) {
2295
2839
  return res?.badRequest("Invalid room configuration", {
2296
- details: roomConfigResult.error
2840
+ details: parseResult.error
2297
2841
  });
2298
2842
  }
2299
- const roomConfig = roomConfigResult.data;
2843
+ const roomConfig = parseResult.data;
2844
+ if (roomConfig.maxShards !== void 0 && roomConfig.minShards > roomConfig.maxShards) {
2845
+ return res?.badRequest("minShards cannot be greater than maxShards");
2846
+ }
2300
2847
  const roomId = roomConfig.name;
2301
2848
  if (!this.rooms()[roomId]) {
2302
2849
  const newRoom = new RoomConfig();
@@ -2321,34 +2868,41 @@ var WorldRoom = class {
2321
2868
  room.minShards.set(roomConfig.minShards);
2322
2869
  room.maxShards.set(roomConfig.maxShards);
2323
2870
  }
2871
+ await this.ensureMinShards(roomId);
2324
2872
  }
2325
2873
  async updateShardStats(req, res) {
2326
- const bodyResult = UpdateShardStatsSchema.safeParse(await req.json());
2327
- if (!bodyResult.success) {
2328
- return res.badRequest("Invalid shard statistics", {
2329
- details: bodyResult.error
2874
+ const parseResult = UpdateShardStatsSchema.safeParse(await req.json());
2875
+ if (!parseResult.success) {
2876
+ return res.badRequest("Invalid shard stats", {
2877
+ details: parseResult.error
2330
2878
  });
2331
2879
  }
2332
- const body = bodyResult.data;
2880
+ const body = parseResult.data;
2333
2881
  const { shardId, connections, status } = body;
2334
2882
  const shard = this.shards()[shardId];
2335
2883
  if (!shard) {
2336
2884
  return res.notFound(`Shard ${shardId} not found`);
2337
2885
  }
2886
+ if (body.worldId && body.worldId !== this.getWorldId()) {
2887
+ return res.badRequest(`Shard ${shardId} belongs to world ${body.worldId}, not ${this.getWorldId()}`);
2888
+ }
2338
2889
  shard.currentConnections.set(connections);
2339
2890
  if (status) {
2340
2891
  shard.status.set(status);
2341
2892
  }
2342
2893
  shard.lastHeartbeat.set(Date.now());
2894
+ if (this.shouldCompleteDrain(shard)) {
2895
+ this.removeShard(shard.id);
2896
+ }
2343
2897
  }
2344
2898
  async scaleRoom(req, res) {
2345
- const dataResult = ScaleRoomSchema.safeParse(await req.json());
2346
- if (!dataResult.success) {
2347
- return res.badRequest("Invalid scale request", {
2348
- details: dataResult.error
2899
+ const parseResult = ScaleRoomSchema.safeParse(await req.json());
2900
+ if (!parseResult.success) {
2901
+ return res.badRequest("Invalid scale room request", {
2902
+ details: parseResult.error
2349
2903
  });
2350
2904
  }
2351
- const data = dataResult.data;
2905
+ const data = parseResult.data;
2352
2906
  const { targetShardCount, shardTemplate, roomId } = data;
2353
2907
  const room = this.rooms()[roomId];
2354
2908
  if (!room) {
@@ -2363,13 +2917,16 @@ var WorldRoom = class {
2363
2917
  });
2364
2918
  }
2365
2919
  if (targetShardCount < previousShardCount) {
2366
- const shardsToRemove = [...roomShards].sort((a, b) => {
2920
+ const shardsToDrain = [...roomShards].sort((a, b) => {
2367
2921
  if (a.status() === "draining" && b.status() !== "draining") return -1;
2368
2922
  if (a.status() !== "draining" && b.status() === "draining") return 1;
2369
2923
  return a.currentConnections() - b.currentConnections();
2370
2924
  }).slice(0, previousShardCount - targetShardCount);
2371
- for (const shard of shardsToRemove) {
2372
- delete this.shards()[shard.id];
2925
+ for (const shard of shardsToDrain) {
2926
+ shard.status.set("draining");
2927
+ if (this.shouldCompleteDrain(shard)) {
2928
+ this.removeShard(shard.id);
2929
+ }
2373
2930
  }
2374
2931
  return;
2375
2932
  }
@@ -2456,8 +3013,20 @@ var WorldRoom = class {
2456
3013
  return { error: `No shards available for room ${roomId}` };
2457
3014
  }
2458
3015
  }
2459
- const activeShards = roomShards.filter((shard) => shard && shard.status() === "active");
3016
+ let activeShards = this.getAvailableShards(roomShards);
2460
3017
  if (activeShards.length === 0) {
3018
+ if (autoCreate && this.canCreateShard(room, roomShards.length)) {
3019
+ const newShard = await this.createShard(roomId);
3020
+ if (newShard) {
3021
+ return {
3022
+ shardId: newShard.id,
3023
+ url: newShard.url()
3024
+ };
3025
+ }
3026
+ }
3027
+ if (roomShards.some((shard) => shard.status() === "active")) {
3028
+ return { error: `No shard capacity available for room ${roomId}` };
3029
+ }
2461
3030
  return { error: `No active shards available for room ${roomId}` };
2462
3031
  }
2463
3032
  const balancingStrategy = room.balancingStrategy();
@@ -2485,6 +3054,14 @@ var WorldRoom = class {
2485
3054
  url: selectedShard.url()
2486
3055
  };
2487
3056
  }
3057
+ getAvailableShards(shards) {
3058
+ return shards.filter(
3059
+ (shard) => shard && shard.status() === "active" && shard.currentConnections() < shard.maxConnections()
3060
+ );
3061
+ }
3062
+ canCreateShard(room, currentShardCount) {
3063
+ return room.maxShards() === void 0 || currentShardCount < room.maxShards();
3064
+ }
2488
3065
  // Private methods
2489
3066
  async createShard(roomId, urlTemplate, maxConnections) {
2490
3067
  const room = this.rooms()[roomId];
@@ -2492,13 +3069,15 @@ var WorldRoom = class {
2492
3069
  console.error(`Cannot create shard for non-existent room: ${roomId}`);
2493
3070
  return null;
2494
3071
  }
2495
- const shardId = `${roomId}:${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
3072
+ const worldId = this.getWorldId();
3073
+ const shardId = `${roomId}:${worldId}:${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
2496
3074
  const template = urlTemplate || this.defaultShardUrlTemplate();
2497
3075
  const url = template.replace("{shardId}", shardId).replace("{roomId}", roomId);
2498
3076
  const max = maxConnections || room.maxPlayersPerShard();
2499
3077
  const newShard = new ShardInfo();
2500
3078
  newShard.id = shardId;
2501
3079
  newShard.roomId.set(roomId);
3080
+ newShard.worldId.set(worldId);
2502
3081
  newShard.url.set(url);
2503
3082
  newShard.maxConnections.set(max);
2504
3083
  newShard.currentConnections.set(0);
@@ -2507,6 +3086,20 @@ var WorldRoom = class {
2507
3086
  this.shards()[shardId] = newShard;
2508
3087
  return newShard;
2509
3088
  }
3089
+ async ensureMinShards(roomId) {
3090
+ const room = this.rooms()[roomId];
3091
+ if (!room) {
3092
+ return;
3093
+ }
3094
+ const currentShardCount = Object.values(this.shards()).filter((shard) => shard.roomId() === roomId).length;
3095
+ const targetShardCount = room.minShards();
3096
+ if (currentShardCount >= targetShardCount) {
3097
+ return;
3098
+ }
3099
+ for (let i = currentShardCount; i < targetShardCount; i++) {
3100
+ await this.createShard(roomId);
3101
+ }
3102
+ }
2510
3103
  };
2511
3104
  __decorateClass([
2512
3105
  sync(RoomConfig)
@@ -2557,13 +3150,16 @@ WorldRoom = __decorateClass([
2557
3150
  ], WorldRoom);
2558
3151
 
2559
3152
  // src/session.guard.ts
3153
+ function getPrivateId(sender) {
3154
+ return sender.sessionId || sender.id;
3155
+ }
2560
3156
  function createRequireSessionGuard(storage) {
2561
3157
  return async (sender, value) => {
2562
3158
  if (!sender || !sender.id) {
2563
3159
  return false;
2564
3160
  }
2565
3161
  try {
2566
- const session = await storage.get(`session:${sender.id}`);
3162
+ const session = await storage.get(`session:${getPrivateId(sender)}`);
2567
3163
  if (!session) {
2568
3164
  return false;
2569
3165
  }
@@ -2583,7 +3179,7 @@ var requireSession = async (sender, value, room) => {
2583
3179
  return false;
2584
3180
  }
2585
3181
  try {
2586
- const session = await room.storage.get(`session:${sender.id}`);
3182
+ const session = await room.storage.get(`session:${getPrivateId(sender)}`);
2587
3183
  if (!session) {
2588
3184
  return false;
2589
3185
  }