@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/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) {
@@ -90,16 +83,38 @@ var Storage = class {
90
83
  this.memory = /* @__PURE__ */ new Map();
91
84
  }
92
85
  async put(key, value) {
93
- 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
+ }
94
93
  }
95
94
  async get(key) {
96
95
  return this.memory.get(key);
97
96
  }
98
97
  async delete(key) {
99
- 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);
100
108
  }
101
- async list() {
102
- 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
+ );
103
118
  }
104
119
  };
105
120
 
@@ -438,6 +453,14 @@ var Message = z.object({
438
453
  action: z.string(),
439
454
  value: z.any()
440
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;
441
464
  var Server = class {
442
465
  /**
443
466
  * @constructor
@@ -470,6 +493,88 @@ var Server = class {
470
493
  get roomStorage() {
471
494
  return this.room.storage;
472
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
+ }
473
578
  async send(conn, obj, subRoom) {
474
579
  obj = structuredClone(obj);
475
580
  if (subRoom.interceptorPacket) {
@@ -511,33 +616,44 @@ var Server = class {
511
616
  async garbageCollector(options) {
512
617
  const subRoom = await this.getSubRoom();
513
618
  if (!subRoom) return;
619
+ const SESSION_EXPIRY_TIME = Number(options.sessionExpiryTime);
620
+ if (!Number.isFinite(SESSION_EXPIRY_TIME)) {
621
+ return;
622
+ }
514
623
  const activeConnections = [...this.room.getConnections()];
515
- const activePrivateIds = new Set(activeConnections.map((conn) => conn.id));
624
+ const activePrivateIds = new Set(activeConnections.map((conn) => this.getPrivateId(conn)));
516
625
  try {
517
- const sessions = await this.room.storage.list();
626
+ const sessions = await this.listStorage(SESSION_PREFIX);
518
627
  const users = this.getUsersProperty(subRoom);
519
628
  const usersPropName = this.getUsersPropName(subRoom);
629
+ const metrics = subRoom.$storageMetrics;
630
+ if (metrics) {
631
+ metrics.sessionGcRuns += 1;
632
+ metrics.sessionGcScanned += sessions.size;
633
+ }
520
634
  const validPublicIds = /* @__PURE__ */ new Set();
521
635
  const expiredPublicIds = /* @__PURE__ */ new Set();
522
- const SESSION_EXPIRY_TIME = options.sessionExpiryTime;
523
636
  const now = Date.now();
524
637
  for (const [key, session] of sessions) {
525
- if (!key.startsWith("session:")) continue;
526
- const privateId = key.replace("session:", "");
638
+ if (!key.startsWith(SESSION_PREFIX)) continue;
639
+ const privateId = key.slice(SESSION_PREFIX.length);
527
640
  const typedSession = session;
528
- 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) {
529
642
  await this.deleteSession(privateId);
530
643
  expiredPublicIds.add(typedSession.publicId);
644
+ if (metrics) {
645
+ metrics.sessionGcExpired += 1;
646
+ }
531
647
  } else if (typedSession && typedSession.publicId) {
532
648
  validPublicIds.add(typedSession.publicId);
533
649
  }
534
650
  }
651
+ await this.repairSessionPublicIndexes(sessions, metrics);
535
652
  if (users && usersPropName) {
536
653
  const currentUsers = users();
537
654
  for (const publicId in currentUsers) {
538
655
  if (expiredPublicIds.has(publicId) && !validPublicIds.has(publicId)) {
539
656
  delete currentUsers[publicId];
540
- await this.room.storage.delete(`${usersPropName}.${publicId}`);
541
657
  }
542
658
  }
543
659
  }
@@ -545,6 +661,145 @@ var Server = class {
545
661
  console.error("Error in garbage collector:", error);
546
662
  }
547
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
+ }
548
803
  /**
549
804
  * @method createRoom
550
805
  * @private
@@ -576,21 +831,55 @@ var Server = class {
576
831
  return null;
577
832
  }
578
833
  const loadMemory = async () => {
579
- const root = await this.room.storage.get(".");
580
- 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;
581
839
  const tmpObject = root || {};
582
- for (let [key, value] of memory) {
583
- if (key.startsWith("session:")) {
584
- continue;
585
- }
586
- if (key == ".") {
840
+ for (let [storageKey, value] of memory) {
841
+ const key = storageKey.slice(STATE_PREFIX.length);
842
+ if (key === ".") {
587
843
  continue;
588
844
  }
589
845
  dset2(tmpObject, key, value);
590
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
+ }
591
878
  load(instance, tmpObject, true);
879
+ metrics.loadMs = Date.now() - startedAt;
592
880
  };
593
881
  instance.$memoryAll = {};
882
+ instance.$storageMetrics = this.createStorageMetrics();
594
883
  instance.$autoSync = instance["autoSync"] !== false;
595
884
  instance.$pendingSync = /* @__PURE__ */ new Map();
596
885
  instance.$pendingInitialSync = /* @__PURE__ */ new Map();
@@ -644,16 +933,9 @@ var Server = class {
644
933
  console.error(`[sessionTransfer] User with publicId ${publicId} not found.`);
645
934
  return null;
646
935
  }
647
- const sessions = await this.room.storage.list();
648
- let userSession = null;
649
- let privateId = null;
650
- for (const [key, session] of sessions) {
651
- if (key.startsWith("session:") && session.publicId === publicId) {
652
- userSession = session;
653
- privateId = key.replace("session:", "");
654
- break;
655
- }
656
- }
936
+ const sessionEntry = await this.getSessionEntryByPublicId(publicId);
937
+ const userSession = sessionEntry?.session;
938
+ const privateId = sessionEntry?.privateId ?? null;
657
939
  if (!userSession || !privateId) {
658
940
  console.error(`[sessionTransfer] Session for publicId ${publicId} not found.`);
659
941
  return null;
@@ -719,20 +1001,79 @@ var Server = class {
719
1001
  values.clear();
720
1002
  return;
721
1003
  }
1004
+ const startedAt = Date.now();
1005
+ const stateWrites = {};
1006
+ const deleteTasks = [];
722
1007
  for (let [path, value] of values) {
723
1008
  const _instance = path == "." ? instance : getByPath(instance, path);
724
- const itemValue = createStatesSnapshot(_instance);
725
1009
  if (value == DELETE_TOKEN) {
726
- await this.room.storage.delete(path);
1010
+ deleteTasks.push(this.deleteStatePath(path));
727
1011
  } else {
728
- await this.room.storage.put(path, itemValue);
1012
+ const itemValue = _instance?.$snapshot ? createStatesSnapshot(_instance) : value;
1013
+ stateWrites[this.stateKey(path)] = itemValue;
729
1014
  }
730
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
+ }
731
1027
  values.clear();
732
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
+ };
733
1074
  syncClass(instance, {
734
1075
  onSync: instance["throttleSync"] ? throttle(syncCb, instance["throttleSync"]) : syncCb,
735
- onPersist: instance["throttleStorage"] ? throttle(persistCb, instance["throttleStorage"]) : persistCb
1076
+ onPersist: instance["throttleStorage"] ? debouncePersist(instance["throttleStorage"]) : persistCb
736
1077
  });
737
1078
  await loadMemory();
738
1079
  initPersist = false;
@@ -839,24 +1180,93 @@ var Server = class {
839
1180
  async getSession(privateId) {
840
1181
  if (!privateId) return null;
841
1182
  try {
842
- const session = await this.room.storage.get(`session:${privateId}`);
1183
+ const session = await this.room.storage.get(this.sessionKey(privateId));
843
1184
  return session;
844
1185
  } catch (e) {
845
1186
  return null;
846
1187
  }
847
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
+ }
848
1244
  async saveSession(privateId, data) {
1245
+ const existingSession = await this.getSession(privateId);
849
1246
  const sessionData = {
850
1247
  ...data,
851
1248
  created: data.created || Date.now(),
852
1249
  connected: data.connected !== void 0 ? data.connected : true
853
1250
  };
854
- 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);
855
1256
  }
856
1257
  async updateSessionConnection(privateId, connected) {
857
1258
  const session = await this.getSession(privateId);
858
1259
  if (session) {
859
- 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);
860
1270
  }
861
1271
  }
862
1272
  /**
@@ -871,7 +1281,11 @@ var Server = class {
871
1281
  * ```
872
1282
  */
873
1283
  async deleteSession(privateId) {
874
- 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
+ }
875
1289
  }
876
1290
  async onConnectClient(conn, ctx) {
877
1291
  const subRoom = await this.getSubRoom({
@@ -881,8 +1295,17 @@ var Server = class {
881
1295
  conn.close();
882
1296
  return;
883
1297
  }
884
- const sessionExpiryTime = subRoom.constructor.sessionExpiryTime;
885
- 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
+ }
886
1309
  const roomGuards = subRoom.constructor["_roomGuards"] || [];
887
1310
  for (const guard of roomGuards) {
888
1311
  const isAuthorized = await guard(conn, ctx, this.room);
@@ -898,12 +1321,18 @@ var Server = class {
898
1321
  }
899
1322
  let transferData = null;
900
1323
  if (transferToken) {
901
- transferData = await this.room.storage.get(`transfer:${transferToken}`);
1324
+ const transferKey = this.transferKey(transferToken);
1325
+ transferData = await this.room.storage.get(transferKey);
902
1326
  if (transferData) {
903
- await this.room.storage.delete(`transfer:${transferToken}`);
1327
+ if (this.isTransferExpired(transferData, transferExpiryTime)) {
1328
+ transferData = null;
1329
+ }
1330
+ await this.room.storage.delete(transferKey);
904
1331
  }
905
1332
  }
906
- 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);
907
1336
  const publicId = existingSession?.publicId || transferData?.publicId || generateShortUUID2();
908
1337
  let user = null;
909
1338
  const signal2 = this.getUsersProperty(subRoom);
@@ -917,24 +1346,24 @@ var Server = class {
917
1346
  user = isClass(classType) ? new classType() : classType(conn, ctx);
918
1347
  signal2()[publicId] = user;
919
1348
  const snapshot = createStatesSnapshotDeep(user);
920
- this.room.storage.put(`${usersPropName}.${publicId}`, snapshot);
1349
+ await this.saveStatePath(`${usersPropName}.${publicId}`, snapshot);
921
1350
  }
922
1351
  } else {
923
1352
  user = signal2()[existingSession.publicId];
924
1353
  }
925
1354
  if (!existingSession) {
926
- const sessionPrivateId = transferData?.privateId || conn.id;
927
- await this.saveSession(sessionPrivateId, {
1355
+ await this.saveSession(privateId, {
928
1356
  publicId
929
1357
  });
930
1358
  } else {
931
- await this.updateSessionConnection(conn.id, true);
1359
+ await this.updateSessionConnection(privateId, true);
932
1360
  }
933
1361
  }
934
1362
  this.updateUserConnectionStatus(user, true);
935
1363
  conn.setState({
936
1364
  ...conn.state,
937
- publicId
1365
+ publicId,
1366
+ privateId
938
1367
  });
939
1368
  await awaitReturn(subRoom["onJoin"]?.(user, conn, ctx));
940
1369
  if (subRoom.$autoSync) {
@@ -967,11 +1396,19 @@ var Server = class {
967
1396
  */
968
1397
  async onConnect(conn, ctx) {
969
1398
  if (ctx.request?.headers.has("x-shard-id")) {
1399
+ if (!this.isAuthorizedShardRequest(ctx.request)) {
1400
+ conn.close();
1401
+ return;
1402
+ }
970
1403
  this.onConnectShard(conn, ctx);
971
1404
  } else {
972
1405
  await this.onConnectClient(conn, ctx);
973
1406
  }
974
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
+ }
975
1412
  /**
976
1413
  * @method onConnectShard
977
1414
  * @private
@@ -982,9 +1419,11 @@ var Server = class {
982
1419
  */
983
1420
  onConnectShard(conn, ctx) {
984
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";
985
1423
  conn.setState({
986
1424
  shard: true,
987
1425
  shardId,
1426
+ worldId,
988
1427
  clients: /* @__PURE__ */ new Map()
989
1428
  // Track clients connected through this shard
990
1429
  });
@@ -1254,11 +1693,15 @@ var Server = class {
1254
1693
  if (!conn.state) {
1255
1694
  return;
1256
1695
  }
1257
- const privateId = conn.id;
1696
+ const privateId = this.getPrivateId(conn);
1258
1697
  const { publicId } = conn.state;
1259
1698
  const user = signal2?.()[publicId];
1260
1699
  if (!user) return;
1700
+ if (this.hasActiveSessionConnection(privateId)) {
1701
+ return;
1702
+ }
1261
1703
  await this.updateSessionConnection(privateId, false);
1704
+ this.scheduleSessionGarbageCollector(this.getSessionExpiryTime(subRoom), privateId);
1262
1705
  const connectionUpdated = this.updateUserConnectionStatus(user, false);
1263
1706
  await awaitReturn(subRoom["onLeave"]?.(user, conn));
1264
1707
  if (!connectionUpdated) {
@@ -1293,6 +1736,9 @@ var Server = class {
1293
1736
  return res.status(200).send({});
1294
1737
  }
1295
1738
  if (isFromShard) {
1739
+ if (!this.isAuthorizedShardRequest(req)) {
1740
+ return res.unauthorized("Invalid shard credentials");
1741
+ }
1296
1742
  return this.handleShardRequest(req, res, shardId);
1297
1743
  }
1298
1744
  return this.handleDirectRequest(req, res);
@@ -1342,14 +1788,15 @@ var Server = class {
1342
1788
  ) ?? userSnapshot;
1343
1789
  signal2()[publicId] = user;
1344
1790
  load(user, hydratedSnapshot, true);
1345
- await this.room.storage.put(`${usersPropName}.${publicId}`, userSnapshot);
1791
+ await this.saveStatePath(`${usersPropName}.${publicId}`, userSnapshot);
1346
1792
  }
1347
1793
  }
1348
1794
  const transferToken = generateShortUUID2();
1349
- await this.room.storage.put(`transfer:${transferToken}`, {
1795
+ await this.room.storage.put(this.transferKey(transferToken), {
1350
1796
  privateId,
1351
1797
  publicId,
1352
- restored: true
1798
+ restored: true,
1799
+ created: Date.now()
1353
1800
  });
1354
1801
  return res.success({ transferToken });
1355
1802
  } catch (error) {
@@ -1553,17 +2000,33 @@ var Server = class {
1553
2000
 
1554
2001
  // src/shard.ts
1555
2002
  var Shard = class {
1556
- constructor(room) {
2003
+ constructor(room, options = {}) {
1557
2004
  this.room = room;
1558
2005
  this.connectionMap = /* @__PURE__ */ new Map();
1559
2006
  this.worldUrl = null;
1560
- this.worldId = "default";
1561
2007
  this.lastReportedConnections = 0;
1562
2008
  this.statsInterval = 3e4;
1563
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;
1564
2027
  }
1565
2028
  async onStart() {
1566
- const roomId = this.room.id.split(":")[0];
2029
+ const roomId = this.getRoomIdFromShardId(this.room.id);
1567
2030
  const roomStub = this.room.context.parties.main.get(roomId);
1568
2031
  if (!roomStub) {
1569
2032
  console.warn("No room room stub found in main party context");
@@ -1572,17 +2035,30 @@ var Shard = class {
1572
2035
  this.mainServerStub = roomStub;
1573
2036
  this.ws = await roomStub.socket({
1574
2037
  headers: {
1575
- "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
1576
2041
  }
1577
2042
  });
1578
2043
  this.ws.addEventListener("message", (event) => {
1579
2044
  try {
1580
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
+ }
1581
2055
  if (message.targetClientId) {
1582
- const clientConn = this.connectionMap.get(message.targetClientId);
1583
- if (clientConn) {
2056
+ const clientConnections = this.connectionMap.get(message.targetClientId);
2057
+ if (clientConnections?.size) {
1584
2058
  delete message.targetClientId;
1585
- clientConn.send(message.data);
2059
+ for (const clientConn of clientConnections) {
2060
+ clientConn.send(message.data);
2061
+ }
1586
2062
  }
1587
2063
  } else {
1588
2064
  this.room.broadcast(event.data);
@@ -1591,21 +2067,22 @@ var Shard = class {
1591
2067
  console.error("Error processing message from main server:", error);
1592
2068
  }
1593
2069
  });
1594
- await this.updateWorldStats();
2070
+ await this.updateWorldStats(true);
1595
2071
  this.startPeriodicStatsUpdates();
1596
2072
  }
1597
2073
  startPeriodicStatsUpdates() {
1598
- if (!this.worldUrl) {
2074
+ if (this.statsInterval <= 0 || !this.room.context.parties.world) {
1599
2075
  return;
1600
2076
  }
1601
2077
  if (this.statsIntervalId) {
1602
2078
  clearInterval(this.statsIntervalId);
1603
2079
  }
1604
2080
  this.statsIntervalId = setInterval(() => {
1605
- this.updateWorldStats().catch((error) => {
2081
+ this.updateWorldStats(true).catch((error) => {
1606
2082
  console.error("Error in periodic stats update:", error);
1607
2083
  });
1608
2084
  }, this.statsInterval);
2085
+ this.statsIntervalId?.unref?.();
1609
2086
  }
1610
2087
  stopPeriodicStatsUpdates() {
1611
2088
  if (this.statsIntervalId) {
@@ -1614,7 +2091,10 @@ var Shard = class {
1614
2091
  }
1615
2092
  }
1616
2093
  onConnect(conn, ctx) {
1617
- 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);
1618
2098
  const headers = {};
1619
2099
  if (ctx.request?.headers) {
1620
2100
  ctx.request.headers.forEach((value, key) => {
@@ -1628,7 +2108,7 @@ var Shard = class {
1628
2108
  } : null;
1629
2109
  this.ws.send(JSON.stringify({
1630
2110
  type: "shard.clientConnected",
1631
- privateId: conn.id,
2111
+ privateId,
1632
2112
  requestInfo
1633
2113
  }));
1634
2114
  this.updateWorldStats();
@@ -1638,7 +2118,7 @@ var Shard = class {
1638
2118
  const parsedMessage = typeof message === "string" ? JSON.parse(message) : message;
1639
2119
  const wrappedMessage = JSON.stringify({
1640
2120
  type: "shard.clientMessage",
1641
- privateId: sender.id,
2121
+ privateId: this.getPrivateId(sender),
1642
2122
  publicId: sender.state?.publicId,
1643
2123
  payload: parsedMessage
1644
2124
  });
@@ -1648,21 +2128,35 @@ var Shard = class {
1648
2128
  }
1649
2129
  }
1650
2130
  onClose(conn) {
1651
- 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);
1652
2139
  this.ws.send(JSON.stringify({
1653
2140
  type: "shard.clientDisconnected",
1654
- privateId: conn.id,
2141
+ privateId,
1655
2142
  publicId: conn.state?.publicId
1656
2143
  }));
1657
2144
  this.updateWorldStats();
1658
2145
  }
1659
- async updateWorldStats() {
1660
- const currentConnections = this.connectionMap.size;
1661
- 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) {
1662
2149
  return true;
1663
2150
  }
1664
2151
  try {
1665
- 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
+ }
1666
2160
  const response2 = await worldRoom.fetch("/update-shard", {
1667
2161
  method: "POST",
1668
2162
  headers: {
@@ -1671,6 +2165,7 @@ var Shard = class {
1671
2165
  },
1672
2166
  body: JSON.stringify({
1673
2167
  shardId: this.room.id,
2168
+ worldId: this.worldId,
1674
2169
  connections: currentConnections
1675
2170
  })
1676
2171
  });
@@ -1710,7 +2205,9 @@ var Shard = class {
1710
2205
  headers.set(key, value);
1711
2206
  });
1712
2207
  headers.set("x-shard-id", this.room.id);
2208
+ headers.set("x-shard-world-id", this.worldId);
1713
2209
  headers.set("x-forwarded-by-shard", "true");
2210
+ headers.set("x-access-shard", this.room.env.SHARD_SECRET);
1714
2211
  const clientIp = req.headers.get("x-forwarded-for") || "unknown";
1715
2212
  if (clientIp) {
1716
2213
  headers.set("x-original-client-ip", clientIp);
@@ -1720,7 +2217,7 @@ var Shard = class {
1720
2217
  headers,
1721
2218
  body
1722
2219
  };
1723
- const response2 = await this.mainServerStub.fetch(path, requestInit);
2220
+ const response2 = await this.mainServerStub.fetch(path + url.search, requestInit);
1724
2221
  return response2;
1725
2222
  } catch (error) {
1726
2223
  return response(500, { error: "Error forwarding request" });
@@ -1732,7 +2229,7 @@ var Shard = class {
1732
2229
  * @description Executed periodically, used to perform maintenance tasks
1733
2230
  */
1734
2231
  async onAlarm() {
1735
- await this.updateWorldStats();
2232
+ await this.updateWorldStats(true);
1736
2233
  }
1737
2234
  };
1738
2235
 
@@ -1744,7 +2241,7 @@ async function testRoom(Room2, options = {}) {
1744
2241
  return server2;
1745
2242
  };
1746
2243
  const isShard = options.shard || false;
1747
- const io = new ServerIo(Room2.path, isShard ? {
2244
+ const io = new ServerIo(options.id ?? Room2.path, isShard ? {
1748
2245
  parties: {
1749
2246
  game: createServer,
1750
2247
  ...options.parties || {}
@@ -1788,7 +2285,7 @@ async function testRoom(Room2, options = {}) {
1788
2285
  return client;
1789
2286
  },
1790
2287
  getServerUser: async (client, prop = "users") => {
1791
- const privateId = client.conn.id;
2288
+ const privateId = client.conn.sessionId || client.conn.id;
1792
2289
  const session = await server.getSession(privateId);
1793
2290
  return server.subRoom[prop]()[session?.publicId];
1794
2291
  }
@@ -1808,11 +2305,11 @@ function tick(ms = 0) {
1808
2305
 
1809
2306
  // src/mock.ts
1810
2307
  var MockPartyClient = class {
1811
- constructor(server, id2) {
2308
+ constructor(server, sessionId) {
1812
2309
  this.server = server;
1813
2310
  this.events = /* @__PURE__ */ new Map();
1814
- this.id = id2 || generateShortUUID();
1815
- this.conn = new MockConnection(this);
2311
+ this.id = generateShortUUID();
2312
+ this.conn = new MockConnection(this, sessionId || this.id);
1816
2313
  }
1817
2314
  addEventListener(event, cb) {
1818
2315
  if (!this.events.has(event)) {
@@ -1848,8 +2345,8 @@ var MockLobby = class {
1848
2345
  this.server = server;
1849
2346
  this.lobbyId = lobbyId;
1850
2347
  }
1851
- socket(_init) {
1852
- return new MockPartyClient(this.server);
2348
+ socket(init) {
2349
+ return new MockPartyClient(this.server, init?.id);
1853
2350
  }
1854
2351
  async connection(idOrOptions, maybeOptions) {
1855
2352
  const id2 = typeof idOrOptions === "string" ? idOrOptions : idOrOptions?.id;
@@ -1925,7 +2422,13 @@ var MockPartyRoom = class {
1925
2422
  });
1926
2423
  }
1927
2424
  getConnection(id2) {
1928
- 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;
1929
2432
  }
1930
2433
  getConnections() {
1931
2434
  return Array.from(this.clients.values()).map((client) => client.conn);
@@ -1933,13 +2436,25 @@ var MockPartyRoom = class {
1933
2436
  clear() {
1934
2437
  this.clients.clear();
1935
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
+ }
1936
2450
  };
1937
2451
  var MockConnection = class {
1938
- constructor(client) {
2452
+ constructor(client, sessionId) {
1939
2453
  this.client = client;
1940
2454
  this.state = {};
1941
2455
  this.server = client.server;
1942
2456
  this.id = client.id;
2457
+ this.sessionId = sessionId;
1943
2458
  }
1944
2459
  setState(value) {
1945
2460
  this.state = value;
@@ -1948,6 +2463,7 @@ var MockConnection = class {
1948
2463
  this.client._trigger("message", data);
1949
2464
  }
1950
2465
  close() {
2466
+ this.server.room.deleteConnection?.(this.id, this);
1951
2467
  this.server.onClose(this);
1952
2468
  }
1953
2469
  };
@@ -2127,7 +2643,7 @@ var guardManageWorld = async (_, req, room) => {
2127
2643
  return true;
2128
2644
  }
2129
2645
  const url = new URL(req.url);
2130
- const token = req.headers.get("Authorization") ?? url.searchParams.get("world-auth-token");
2646
+ const token = getAuthToken(req, url);
2131
2647
  if (!token) {
2132
2648
  return false;
2133
2649
  }
@@ -2137,11 +2653,28 @@ var guardManageWorld = async (_, req, room) => {
2137
2653
  if (!payload) {
2138
2654
  return false;
2139
2655
  }
2656
+ if (!canAccessWorld(payload, room.id)) {
2657
+ return false;
2658
+ }
2140
2659
  } catch (error) {
2141
2660
  return false;
2142
2661
  }
2143
2662
  return true;
2144
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
+ }
2145
2678
 
2146
2679
  // src/world.ts
2147
2680
  var MAX_PLAYERS_PER_SHARD = 75;
@@ -2156,10 +2689,13 @@ var RoomConfigSchema = z2.object({
2156
2689
  var RegisterShardSchema = z2.object({
2157
2690
  shardId: z2.string(),
2158
2691
  roomId: z2.string(),
2692
+ worldId: z2.string().optional(),
2159
2693
  url: z2.string().url(),
2160
2694
  maxConnections: z2.number().int().positive()
2161
2695
  });
2162
2696
  var UpdateShardStatsSchema = z2.object({
2697
+ shardId: z2.string(),
2698
+ worldId: z2.string().optional(),
2163
2699
  connections: z2.number().int().min(0),
2164
2700
  status: z2.enum(["active", "maintenance", "draining"]).optional()
2165
2701
  });
@@ -2205,6 +2741,7 @@ __decorateClass([
2205
2741
  var ShardInfo = class {
2206
2742
  constructor() {
2207
2743
  this.roomId = signal("");
2744
+ this.worldId = signal("");
2208
2745
  this.url = signal("");
2209
2746
  this.currentConnections = signal(0);
2210
2747
  this.maxConnections = signal(MAX_PLAYERS_PER_SHARD);
@@ -2218,6 +2755,9 @@ __decorateClass([
2218
2755
  __decorateClass([
2219
2756
  sync()
2220
2757
  ], ShardInfo.prototype, "roomId", 2);
2758
+ __decorateClass([
2759
+ sync()
2760
+ ], ShardInfo.prototype, "worldId", 2);
2221
2761
  __decorateClass([
2222
2762
  sync()
2223
2763
  ], ShardInfo.prototype, "url", 2);
@@ -2251,6 +2791,7 @@ var WorldRoom = class {
2251
2791
  if (!SHARD_SECRET) {
2252
2792
  throw new Error("SHARD_SECRET env variable is not set");
2253
2793
  }
2794
+ this.scheduleInactiveShardCleanup();
2254
2795
  }
2255
2796
  async onJoin(user, conn, ctx) {
2256
2797
  const canConnect = await guardManageWorld(user, ctx.request, this.room);
@@ -2266,6 +2807,13 @@ var WorldRoom = class {
2266
2807
  return obj;
2267
2808
  }
2268
2809
  // Helper methods
2810
+ getWorldId() {
2811
+ return this.room.id;
2812
+ }
2813
+ scheduleInactiveShardCleanup() {
2814
+ const timeoutId = setTimeout(() => this.cleanupInactiveShards(), 6e4);
2815
+ timeoutId?.unref?.();
2816
+ }
2269
2817
  cleanupInactiveShards() {
2270
2818
  const now = Date.now();
2271
2819
  const timeout = 5 * 60 * 1e3;
@@ -2277,10 +2825,25 @@ var WorldRoom = class {
2277
2825
  hasChanges = true;
2278
2826
  }
2279
2827
  });
2280
- setTimeout(() => this.cleanupInactiveShards(), 6e4);
2828
+ this.scheduleInactiveShardCleanup();
2281
2829
  }
2282
- async registerRoom(req) {
2283
- const roomConfig = await req.json();
2830
+ removeShard(shardId) {
2831
+ delete this.shards()[shardId];
2832
+ }
2833
+ shouldCompleteDrain(shard) {
2834
+ return shard.status() === "draining" && shard.currentConnections() === 0;
2835
+ }
2836
+ async registerRoom(req, res) {
2837
+ const parseResult = RoomConfigSchema.safeParse(await req.json());
2838
+ if (!parseResult.success) {
2839
+ return res?.badRequest("Invalid room configuration", {
2840
+ details: parseResult.error
2841
+ });
2842
+ }
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
+ }
2284
2847
  const roomId = roomConfig.name;
2285
2848
  if (!this.rooms()[roomId]) {
2286
2849
  const newRoom = new RoomConfig();
@@ -2305,22 +2868,41 @@ var WorldRoom = class {
2305
2868
  room.minShards.set(roomConfig.minShards);
2306
2869
  room.maxShards.set(roomConfig.maxShards);
2307
2870
  }
2871
+ await this.ensureMinShards(roomId);
2308
2872
  }
2309
2873
  async updateShardStats(req, res) {
2310
- const body = await req.json();
2874
+ const parseResult = UpdateShardStatsSchema.safeParse(await req.json());
2875
+ if (!parseResult.success) {
2876
+ return res.badRequest("Invalid shard stats", {
2877
+ details: parseResult.error
2878
+ });
2879
+ }
2880
+ const body = parseResult.data;
2311
2881
  const { shardId, connections, status } = body;
2312
2882
  const shard = this.shards()[shardId];
2313
2883
  if (!shard) {
2314
2884
  return res.notFound(`Shard ${shardId} not found`);
2315
2885
  }
2886
+ if (body.worldId && body.worldId !== this.getWorldId()) {
2887
+ return res.badRequest(`Shard ${shardId} belongs to world ${body.worldId}, not ${this.getWorldId()}`);
2888
+ }
2316
2889
  shard.currentConnections.set(connections);
2317
2890
  if (status) {
2318
2891
  shard.status.set(status);
2319
2892
  }
2320
2893
  shard.lastHeartbeat.set(Date.now());
2894
+ if (this.shouldCompleteDrain(shard)) {
2895
+ this.removeShard(shard.id);
2896
+ }
2321
2897
  }
2322
2898
  async scaleRoom(req, res) {
2323
- const data = await req.json();
2899
+ const parseResult = ScaleRoomSchema.safeParse(await req.json());
2900
+ if (!parseResult.success) {
2901
+ return res.badRequest("Invalid scale room request", {
2902
+ details: parseResult.error
2903
+ });
2904
+ }
2905
+ const data = parseResult.data;
2324
2906
  const { targetShardCount, shardTemplate, roomId } = data;
2325
2907
  const room = this.rooms()[roomId];
2326
2908
  if (!room) {
@@ -2335,16 +2917,16 @@ var WorldRoom = class {
2335
2917
  });
2336
2918
  }
2337
2919
  if (targetShardCount < previousShardCount) {
2338
- const shardsToRemove = [...roomShards].sort((a, b) => {
2920
+ const shardsToDrain = [...roomShards].sort((a, b) => {
2339
2921
  if (a.status() === "draining" && b.status() !== "draining") return -1;
2340
2922
  if (a.status() !== "draining" && b.status() === "draining") return 1;
2341
2923
  return a.currentConnections() - b.currentConnections();
2342
2924
  }).slice(0, previousShardCount - targetShardCount);
2343
- const shardsToKeep = roomShards.filter(
2344
- (shard) => !shardsToRemove.some((s) => s.id === shard.id)
2345
- );
2346
- for (const shard of shardsToRemove) {
2347
- 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
+ }
2348
2930
  }
2349
2931
  return;
2350
2932
  }
@@ -2431,8 +3013,20 @@ var WorldRoom = class {
2431
3013
  return { error: `No shards available for room ${roomId}` };
2432
3014
  }
2433
3015
  }
2434
- const activeShards = roomShards.filter((shard) => shard && shard.status() === "active");
3016
+ let activeShards = this.getAvailableShards(roomShards);
2435
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
+ }
2436
3030
  return { error: `No active shards available for room ${roomId}` };
2437
3031
  }
2438
3032
  const balancingStrategy = room.balancingStrategy();
@@ -2460,6 +3054,14 @@ var WorldRoom = class {
2460
3054
  url: selectedShard.url()
2461
3055
  };
2462
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
+ }
2463
3065
  // Private methods
2464
3066
  async createShard(roomId, urlTemplate, maxConnections) {
2465
3067
  const room = this.rooms()[roomId];
@@ -2467,13 +3069,15 @@ var WorldRoom = class {
2467
3069
  console.error(`Cannot create shard for non-existent room: ${roomId}`);
2468
3070
  return null;
2469
3071
  }
2470
- 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)}`;
2471
3074
  const template = urlTemplate || this.defaultShardUrlTemplate();
2472
3075
  const url = template.replace("{shardId}", shardId).replace("{roomId}", roomId);
2473
3076
  const max = maxConnections || room.maxPlayersPerShard();
2474
3077
  const newShard = new ShardInfo();
2475
3078
  newShard.id = shardId;
2476
3079
  newShard.roomId.set(roomId);
3080
+ newShard.worldId.set(worldId);
2477
3081
  newShard.url.set(url);
2478
3082
  newShard.maxConnections.set(max);
2479
3083
  newShard.currentConnections.set(0);
@@ -2482,6 +3086,20 @@ var WorldRoom = class {
2482
3086
  this.shards()[shardId] = newShard;
2483
3087
  return newShard;
2484
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
+ }
2485
3103
  };
2486
3104
  __decorateClass([
2487
3105
  sync(RoomConfig)
@@ -2532,13 +3150,16 @@ WorldRoom = __decorateClass([
2532
3150
  ], WorldRoom);
2533
3151
 
2534
3152
  // src/session.guard.ts
3153
+ function getPrivateId(sender) {
3154
+ return sender.sessionId || sender.id;
3155
+ }
2535
3156
  function createRequireSessionGuard(storage) {
2536
3157
  return async (sender, value) => {
2537
3158
  if (!sender || !sender.id) {
2538
3159
  return false;
2539
3160
  }
2540
3161
  try {
2541
- const session = await storage.get(`session:${sender.id}`);
3162
+ const session = await storage.get(`session:${getPrivateId(sender)}`);
2542
3163
  if (!session) {
2543
3164
  return false;
2544
3165
  }
@@ -2558,7 +3179,7 @@ var requireSession = async (sender, value, room) => {
2558
3179
  return false;
2559
3180
  }
2560
3181
  try {
2561
- const session = await room.storage.get(`session:${sender.id}`);
3182
+ const session = await room.storage.get(`session:${getPrivateId(sender)}`);
2562
3183
  if (!session) {
2563
3184
  return false;
2564
3185
  }