@signe/room 2.10.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/chunk-EUXUH3YW.js +15 -0
  3. package/dist/chunk-EUXUH3YW.js.map +1 -0
  4. package/dist/cloudflare/index.d.ts +71 -0
  5. package/dist/cloudflare/index.js +320 -0
  6. package/dist/cloudflare/index.js.map +1 -0
  7. package/dist/index.d.ts +87 -188
  8. package/dist/index.js +860 -114
  9. package/dist/index.js.map +1 -1
  10. package/dist/node/index.d.ts +164 -0
  11. package/dist/node/index.js +786 -0
  12. package/dist/node/index.js.map +1 -0
  13. package/dist/party-dNs-hqkq.d.ts +175 -0
  14. package/examples/cloudflare/README.md +62 -0
  15. package/examples/cloudflare/node_modules/.bin/tsc +17 -0
  16. package/examples/cloudflare/node_modules/.bin/tsserver +17 -0
  17. package/examples/cloudflare/node_modules/.bin/wrangler +17 -0
  18. package/examples/cloudflare/node_modules/.bin/wrangler2 +17 -0
  19. package/examples/cloudflare/package.json +24 -0
  20. package/examples/cloudflare/public/index.html +443 -0
  21. package/examples/cloudflare/src/index.ts +28 -0
  22. package/examples/cloudflare/src/room.ts +44 -0
  23. package/examples/cloudflare/tsconfig.json +10 -0
  24. package/examples/cloudflare/wrangler.jsonc +25 -0
  25. package/examples/node/README.md +57 -0
  26. package/examples/node/node_modules/.bin/tsc +17 -0
  27. package/examples/node/node_modules/.bin/tsserver +17 -0
  28. package/examples/node/node_modules/.bin/tsx +17 -0
  29. package/examples/node/package.json +23 -0
  30. package/examples/node/public/index.html +443 -0
  31. package/examples/node/room.ts +44 -0
  32. package/examples/node/server.sqlite.ts +52 -0
  33. package/examples/node/server.ts +51 -0
  34. package/examples/node/tsconfig.json +10 -0
  35. package/examples/node-game/README.md +66 -0
  36. package/examples/node-game/package.json +23 -0
  37. package/examples/node-game/public/index.html +705 -0
  38. package/examples/node-game/room.ts +145 -0
  39. package/examples/node-game/server.sqlite.ts +54 -0
  40. package/examples/node-game/server.ts +53 -0
  41. package/examples/node-game/tsconfig.json +10 -0
  42. package/examples/node-shard/README.md +32 -0
  43. package/examples/node-shard/dev.ts +39 -0
  44. package/examples/node-shard/package.json +24 -0
  45. package/examples/node-shard/public/index.html +777 -0
  46. package/examples/node-shard/room-server.ts +68 -0
  47. package/examples/node-shard/room.ts +105 -0
  48. package/examples/node-shard/shared.ts +6 -0
  49. package/examples/node-shard/tsconfig.json +14 -0
  50. package/examples/node-shard/world-server.ts +169 -0
  51. package/package.json +14 -5
  52. package/readme.md +418 -4
  53. package/src/cloudflare/index.ts +474 -0
  54. package/src/index.ts +2 -2
  55. package/src/jwt.ts +1 -5
  56. package/src/mock.ts +29 -7
  57. package/src/node/index.ts +1112 -0
  58. package/src/server.ts +781 -60
  59. package/src/session.guard.ts +6 -2
  60. package/src/shard.ts +91 -23
  61. package/src/storage.ts +29 -5
  62. package/src/testing.ts +4 -3
  63. package/src/types/party.ts +30 -1
  64. package/src/world.guard.ts +23 -4
  65. package/src/world.ts +121 -21
  66. package/tests/storage-restore.spec.ts +122 -0
  67. package/examples/game/.vscode/launch.json +0 -11
  68. package/examples/game/.vscode/settings.json +0 -11
  69. package/examples/game/README.md +0 -40
  70. package/examples/game/app/client.tsx +0 -15
  71. package/examples/game/app/components/Admin.tsx +0 -1089
  72. package/examples/game/app/components/Room.tsx +0 -162
  73. package/examples/game/app/styles.css +0 -31
  74. package/examples/game/package-lock.json +0 -225
  75. package/examples/game/package.json +0 -20
  76. package/examples/game/party/game.room.ts +0 -32
  77. package/examples/game/party/server.ts +0 -10
  78. package/examples/game/party/shard.ts +0 -5
  79. package/examples/game/partykit.json +0 -14
  80. package/examples/game/public/favicon.ico +0 -0
  81. package/examples/game/public/index.html +0 -27
  82. package/examples/game/public/normalize.css +0 -351
  83. package/examples/game/shared/room.schema.ts +0 -14
  84. package/examples/game/tsconfig.json +0 -109
package/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,187 @@ 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
+ stateKey
548
+ ]);
549
+ await this.saveStatePath(path, DELETE_TOKEN);
550
+ }
551
+ containsDeleteToken(value) {
552
+ if (value === DELETE_TOKEN) {
553
+ return true;
554
+ }
555
+ if (!value || typeof value !== "object") {
556
+ return false;
557
+ }
558
+ if (Array.isArray(value)) {
559
+ return value.some((item) => this.containsDeleteToken(item));
560
+ }
561
+ return Object.values(value).some((item) => this.containsDeleteToken(item));
562
+ }
563
+ async compactStateStorage(instance) {
564
+ const entries = await this.listStorage(STATE_PREFIX);
565
+ const keys = Array.from(entries.keys());
566
+ if (keys.length === 0) {
567
+ return;
568
+ }
569
+ const snapshot = createStatesSnapshotDeep(instance);
570
+ await this.deleteStorageKeys(keys);
571
+ await this.saveStatePath(".", snapshot);
572
+ const metrics = instance.$storageMetrics;
573
+ if (metrics) {
574
+ metrics.stateCompactions += 1;
575
+ metrics.stateCompactionDeletes += keys.length;
576
+ }
577
+ }
578
+ createUserFromClassType(classType, ...args) {
579
+ if (!classType) {
580
+ return void 0;
581
+ }
582
+ return isClass(classType) ? new classType() : classType(...args);
583
+ }
584
+ async restoreUsersStorageSnapshot(instance, snapshot, options) {
585
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
586
+ return snapshot;
587
+ }
588
+ const restoreUser = instance["onUserStorageRestore"];
589
+ if (typeof restoreUser !== "function") {
590
+ return snapshot;
591
+ }
592
+ const signal2 = this.getUsersProperty(instance);
593
+ const usersPropName = this.getUsersPropName(instance);
594
+ if (!signal2 || !usersPropName) {
595
+ return snapshot;
596
+ }
597
+ const usersSnapshot = snapshot[usersPropName];
598
+ if (!usersSnapshot || typeof usersSnapshot !== "object" || Array.isArray(usersSnapshot)) {
599
+ return snapshot;
600
+ }
601
+ const { classType } = signal2.options || {};
602
+ let nextSnapshot = snapshot;
603
+ let nextUsersSnapshot = usersSnapshot;
604
+ for (const [publicId, userSnapshot] of Object.entries(usersSnapshot)) {
605
+ const user = this.createUserFromClassType(classType, publicId);
606
+ const restoredUserSnapshot = await awaitReturn(
607
+ restoreUser.call(instance, {
608
+ userSnapshot,
609
+ user,
610
+ publicId,
611
+ usersPropName,
612
+ room: this.room,
613
+ server: this,
614
+ legacy: options.legacy
615
+ })
616
+ );
617
+ if (restoredUserSnapshot !== void 0 && restoredUserSnapshot !== userSnapshot) {
618
+ if (nextSnapshot === snapshot) {
619
+ nextSnapshot = { ...snapshot };
620
+ }
621
+ if (nextUsersSnapshot === usersSnapshot) {
622
+ nextUsersSnapshot = { ...usersSnapshot };
623
+ nextSnapshot[usersPropName] = nextUsersSnapshot;
624
+ }
625
+ nextUsersSnapshot[publicId] = restoredUserSnapshot;
626
+ }
627
+ }
628
+ return nextSnapshot;
629
+ }
630
+ async restoreStorageSnapshot(instance, snapshot, options) {
631
+ const restoreSnapshot = instance["onStorageRestore"];
632
+ let restoredSnapshot = snapshot;
633
+ if (typeof restoreSnapshot === "function") {
634
+ const result = await awaitReturn(
635
+ restoreSnapshot.call(instance, {
636
+ snapshot,
637
+ room: this.room,
638
+ server: this,
639
+ legacy: options.legacy
640
+ })
641
+ );
642
+ if (result !== void 0) {
643
+ restoredSnapshot = result;
644
+ }
645
+ }
646
+ return this.restoreUsersStorageSnapshot(instance, restoredSnapshot, options);
647
+ }
648
+ createStorageMetrics() {
649
+ return {
650
+ loadMs: 0,
651
+ loadStateKeys: 0,
652
+ loadLegacyKeys: 0,
653
+ stateCompactions: 0,
654
+ stateCompactionDeletes: 0,
655
+ persistFlushes: 0,
656
+ persistWrites: 0,
657
+ persistDeletes: 0,
658
+ persistLastFlushMs: 0,
659
+ sessionGcRuns: 0,
660
+ sessionGcScanned: 0,
661
+ sessionGcExpired: 0,
662
+ sessionIndexRepairs: 0,
663
+ transferGcRuns: 0,
664
+ transferGcScanned: 0,
665
+ transferGcExpired: 0
666
+ };
667
+ }
668
+ getTransferExpiryTime(subRoom) {
669
+ return subRoom?.transferExpiryTime ?? subRoom?.constructor?.prototype?.transferExpiryTime ?? subRoom?.constructor?.transferExpiryTime ?? DEFAULT_TRANSFER_EXPIRY_MS;
670
+ }
671
+ getPrivateId(conn) {
672
+ return conn.state?.privateId || conn.sessionId || conn.id;
673
+ }
674
+ hasActiveSessionConnection(privateId) {
675
+ return Array.from(this.room.getConnections()).some((conn) => this.getPrivateId(conn) === privateId);
676
+ }
473
677
  async send(conn, obj, subRoom) {
474
678
  obj = structuredClone(obj);
475
679
  if (subRoom.interceptorPacket) {
@@ -511,33 +715,44 @@ var Server = class {
511
715
  async garbageCollector(options) {
512
716
  const subRoom = await this.getSubRoom();
513
717
  if (!subRoom) return;
718
+ const SESSION_EXPIRY_TIME = Number(options.sessionExpiryTime);
719
+ if (!Number.isFinite(SESSION_EXPIRY_TIME)) {
720
+ return;
721
+ }
514
722
  const activeConnections = [...this.room.getConnections()];
515
- const activePrivateIds = new Set(activeConnections.map((conn) => conn.id));
723
+ const activePrivateIds = new Set(activeConnections.map((conn) => this.getPrivateId(conn)));
516
724
  try {
517
- const sessions = await this.room.storage.list();
725
+ const sessions = await this.listStorage(SESSION_PREFIX);
518
726
  const users = this.getUsersProperty(subRoom);
519
727
  const usersPropName = this.getUsersPropName(subRoom);
728
+ const metrics = subRoom.$storageMetrics;
729
+ if (metrics) {
730
+ metrics.sessionGcRuns += 1;
731
+ metrics.sessionGcScanned += sessions.size;
732
+ }
520
733
  const validPublicIds = /* @__PURE__ */ new Set();
521
734
  const expiredPublicIds = /* @__PURE__ */ new Set();
522
- const SESSION_EXPIRY_TIME = options.sessionExpiryTime;
523
735
  const now = Date.now();
524
736
  for (const [key, session] of sessions) {
525
- if (!key.startsWith("session:")) continue;
526
- const privateId = key.replace("session:", "");
737
+ if (!key.startsWith(SESSION_PREFIX)) continue;
738
+ const privateId = key.slice(SESSION_PREFIX.length);
527
739
  const typedSession = session;
528
- if (!activePrivateIds.has(privateId) && !typedSession.connected && now - typedSession.created > SESSION_EXPIRY_TIME) {
740
+ if (!activePrivateIds.has(privateId) && !typedSession.connected && typedSession.disconnectedAt !== void 0 && now - typedSession.disconnectedAt >= SESSION_EXPIRY_TIME) {
529
741
  await this.deleteSession(privateId);
530
742
  expiredPublicIds.add(typedSession.publicId);
743
+ if (metrics) {
744
+ metrics.sessionGcExpired += 1;
745
+ }
531
746
  } else if (typedSession && typedSession.publicId) {
532
747
  validPublicIds.add(typedSession.publicId);
533
748
  }
534
749
  }
750
+ await this.repairSessionPublicIndexes(sessions, metrics);
535
751
  if (users && usersPropName) {
536
752
  const currentUsers = users();
537
753
  for (const publicId in currentUsers) {
538
754
  if (expiredPublicIds.has(publicId) && !validPublicIds.has(publicId)) {
539
755
  delete currentUsers[publicId];
540
- await this.room.storage.delete(`${usersPropName}.${publicId}`);
541
756
  }
542
757
  }
543
758
  }
@@ -545,6 +760,145 @@ var Server = class {
545
760
  console.error("Error in garbage collector:", error);
546
761
  }
547
762
  }
763
+ scheduleSessionGarbageCollector(sessionExpiryTime, privateId) {
764
+ const normalizedSessionExpiryTime = Number(sessionExpiryTime);
765
+ if (!Number.isFinite(normalizedSessionExpiryTime) || normalizedSessionExpiryTime < 0) {
766
+ return;
767
+ }
768
+ setTimeout(() => {
769
+ if (privateId) {
770
+ void this.expireDisconnectedSession(privateId, normalizedSessionExpiryTime);
771
+ return;
772
+ }
773
+ void this.garbageCollector({ sessionExpiryTime: normalizedSessionExpiryTime });
774
+ }, normalizedSessionExpiryTime);
775
+ }
776
+ getSessionExpiryTime(subRoom) {
777
+ return subRoom?.sessionExpiryTime ?? subRoom?.constructor?.prototype?.sessionExpiryTime ?? subRoom?.constructor?.sessionExpiryTime;
778
+ }
779
+ async shouldRunSessionGarbageCollector(sessionExpiryTime) {
780
+ const normalizedSessionExpiryTime = Number(sessionExpiryTime);
781
+ if (!Number.isFinite(normalizedSessionExpiryTime) || normalizedSessionExpiryTime < 0) {
782
+ return false;
783
+ }
784
+ const now = Date.now();
785
+ const lastRun = await this.room.storage.get(SESSION_GC_LAST_RUN_KEY);
786
+ if (lastRun && now - lastRun < normalizedSessionExpiryTime) {
787
+ return false;
788
+ }
789
+ await this.room.storage.put(SESSION_GC_LAST_RUN_KEY, now);
790
+ return true;
791
+ }
792
+ async shouldRunInterval(key, interval) {
793
+ const normalizedInterval = Number(interval);
794
+ if (!Number.isFinite(normalizedInterval) || normalizedInterval < 0) {
795
+ return false;
796
+ }
797
+ const now = Date.now();
798
+ const lastRun = await this.room.storage.get(key);
799
+ if (lastRun && now - lastRun < normalizedInterval) {
800
+ return false;
801
+ }
802
+ await this.room.storage.put(key, now);
803
+ return true;
804
+ }
805
+ async repairSessionPublicIndexes(sessions, metrics) {
806
+ const sessionEntries = sessions ?? await this.listStorage(SESSION_PREFIX);
807
+ const expected = /* @__PURE__ */ new Map();
808
+ for (const [key, session] of sessionEntries) {
809
+ if (!session?.publicId) continue;
810
+ const privateId = key.slice(SESSION_PREFIX.length);
811
+ const privateIds = expected.get(session.publicId) ?? [];
812
+ privateIds.push(privateId);
813
+ expected.set(session.publicId, privateIds);
814
+ }
815
+ const publicIndexes = await this.listStorage(SESSION_PUBLIC_PREFIX);
816
+ const writes = {};
817
+ const deletes = [];
818
+ let repairs = 0;
819
+ for (const [publicId, privateIds] of expected) {
820
+ privateIds.sort();
821
+ const key = this.sessionPublicKey(publicId);
822
+ const existing = publicIndexes.get(key) ?? [];
823
+ const normalizedExisting = Array.isArray(existing) ? [...existing].sort() : [];
824
+ if (JSON.stringify(normalizedExisting) !== JSON.stringify(privateIds)) {
825
+ writes[key] = privateIds;
826
+ repairs += 1;
827
+ }
828
+ }
829
+ for (const [key] of publicIndexes) {
830
+ const publicId = key.slice(SESSION_PUBLIC_PREFIX.length);
831
+ if (!expected.has(publicId)) {
832
+ deletes.push(key);
833
+ repairs += 1;
834
+ }
835
+ }
836
+ await Promise.all([
837
+ this.putStorageEntries(writes),
838
+ this.deleteStorageKeys(deletes)
839
+ ]);
840
+ if (metrics) {
841
+ metrics.sessionIndexRepairs += repairs;
842
+ }
843
+ }
844
+ isTransferExpired(transfer, transferExpiryTime) {
845
+ if (!transfer) return true;
846
+ if (!Number.isFinite(transferExpiryTime) || transferExpiryTime < 0) {
847
+ return false;
848
+ }
849
+ const created = Number(transfer.created);
850
+ if (!Number.isFinite(created)) {
851
+ return false;
852
+ }
853
+ return Date.now() - created >= transferExpiryTime;
854
+ }
855
+ async cleanupExpiredTransfers(transferExpiryTime, metrics) {
856
+ if (!Number.isFinite(transferExpiryTime) || transferExpiryTime < 0) {
857
+ return;
858
+ }
859
+ const transfers = await this.listStorage(TRANSFER_PREFIX);
860
+ const expiredKeys = [];
861
+ for (const [key, transfer] of transfers) {
862
+ if (this.isTransferExpired(transfer, transferExpiryTime)) {
863
+ expiredKeys.push(key);
864
+ }
865
+ }
866
+ await this.deleteStorageKeys(expiredKeys);
867
+ if (metrics) {
868
+ metrics.transferGcRuns += 1;
869
+ metrics.transferGcScanned += transfers.size;
870
+ metrics.transferGcExpired += expiredKeys.length;
871
+ }
872
+ }
873
+ async expireDisconnectedSession(privateId, sessionExpiryTime) {
874
+ const session = await this.getSession(privateId);
875
+ if (!session || session.connected || session.disconnectedAt === void 0) {
876
+ return;
877
+ }
878
+ if (this.hasActiveSessionConnection(privateId)) {
879
+ return;
880
+ }
881
+ const elapsed = Date.now() - session.disconnectedAt;
882
+ if (elapsed < sessionExpiryTime) {
883
+ setTimeout(() => {
884
+ void this.expireDisconnectedSession(privateId, sessionExpiryTime);
885
+ }, sessionExpiryTime - elapsed);
886
+ return;
887
+ }
888
+ await this.deleteSession(privateId);
889
+ const privateIds = await this.getSessionPrivateIds(session.publicId);
890
+ for (const otherPrivateId of privateIds) {
891
+ const otherSession = await this.getSession(otherPrivateId);
892
+ if (otherSession?.publicId === session.publicId) {
893
+ return;
894
+ }
895
+ }
896
+ const subRoom = await this.getSubRoom();
897
+ const users = this.getUsersProperty(subRoom);
898
+ if (users?.()[session.publicId]) {
899
+ delete users()[session.publicId];
900
+ }
901
+ }
548
902
  /**
549
903
  * @method createRoom
550
904
  * @private
@@ -576,21 +930,67 @@ var Server = class {
576
930
  return null;
577
931
  }
578
932
  const loadMemory = async () => {
579
- const root = await this.room.storage.get(".");
580
- const memory = await this.room.storage.list();
933
+ const startedAt = Date.now();
934
+ const metrics = instance.$storageMetrics;
935
+ const root = await this.loadStatePath(".");
936
+ const memory = await this.listStorage(STATE_PREFIX);
937
+ metrics.loadStateKeys = memory.size;
581
938
  const tmpObject = root || {};
582
- for (let [key, value] of memory) {
583
- if (key.startsWith("session:")) {
584
- continue;
585
- }
586
- if (key == ".") {
939
+ for (let [storageKey, value] of memory) {
940
+ const key = storageKey.slice(STATE_PREFIX.length);
941
+ if (key === ".") {
587
942
  continue;
588
943
  }
589
944
  dset2(tmpObject, key, value);
590
945
  }
591
- load(instance, tmpObject, true);
946
+ if (root === void 0 && memory.size === 0) {
947
+ const legacyRoot = await this.room.storage.get(".");
948
+ const legacyMemory = await this.room.storage.list();
949
+ const legacyObject = legacyRoot || {};
950
+ const migratedEntries = [];
951
+ const legacyDeleteKeys = [];
952
+ if (legacyRoot !== void 0) {
953
+ migratedEntries.push([".", legacyRoot]);
954
+ legacyDeleteKeys.push(".");
955
+ }
956
+ for (let [key, value] of legacyMemory) {
957
+ if (key === "." || this.isInternalStorageKey(key)) {
958
+ continue;
959
+ }
960
+ dset2(legacyObject, key, value);
961
+ migratedEntries.push([key, value]);
962
+ legacyDeleteKeys.push(key);
963
+ }
964
+ metrics.loadLegacyKeys = migratedEntries.length;
965
+ await this.putStorageEntries(
966
+ Object.fromEntries(
967
+ migratedEntries.map(([path, value]) => [this.stateKey(path), value])
968
+ )
969
+ );
970
+ if (legacyDeleteKeys.length > 0) {
971
+ await this.deleteStorageKeys(legacyDeleteKeys);
972
+ }
973
+ const restoredLegacyObject = await this.restoreStorageSnapshot(instance, legacyObject, {
974
+ legacy: true
975
+ });
976
+ load(instance, restoredLegacyObject, true);
977
+ if (this.containsDeleteToken(restoredLegacyObject)) {
978
+ await this.compactStateStorage(instance);
979
+ }
980
+ metrics.loadMs = Date.now() - startedAt;
981
+ return;
982
+ }
983
+ const restoredObject = await this.restoreStorageSnapshot(instance, tmpObject, {
984
+ legacy: false
985
+ });
986
+ load(instance, restoredObject, true);
987
+ if (this.containsDeleteToken(restoredObject)) {
988
+ await this.compactStateStorage(instance);
989
+ }
990
+ metrics.loadMs = Date.now() - startedAt;
592
991
  };
593
992
  instance.$memoryAll = {};
993
+ instance.$storageMetrics = this.createStorageMetrics();
594
994
  instance.$autoSync = instance["autoSync"] !== false;
595
995
  instance.$pendingSync = /* @__PURE__ */ new Map();
596
996
  instance.$pendingInitialSync = /* @__PURE__ */ new Map();
@@ -644,16 +1044,9 @@ var Server = class {
644
1044
  console.error(`[sessionTransfer] User with publicId ${publicId} not found.`);
645
1045
  return null;
646
1046
  }
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
- }
1047
+ const sessionEntry = await this.getSessionEntryByPublicId(publicId);
1048
+ const userSession = sessionEntry?.session;
1049
+ const privateId = sessionEntry?.privateId ?? null;
657
1050
  if (!userSession || !privateId) {
658
1051
  console.error(`[sessionTransfer] Session for publicId ${publicId} not found.`);
659
1052
  return null;
@@ -714,25 +1107,98 @@ var Server = class {
714
1107
  );
715
1108
  values.clear();
716
1109
  };
717
- const persistCb = async (values) => {
718
- if (initPersist) {
719
- values.clear();
720
- return;
721
- }
1110
+ const flushPersist = async (values) => {
1111
+ const startedAt = Date.now();
1112
+ const stateWrites = {};
1113
+ const deleteTasks = [];
1114
+ let hasDeletes = false;
722
1115
  for (let [path, value] of values) {
723
1116
  const _instance = path == "." ? instance : getByPath(instance, path);
724
- const itemValue = createStatesSnapshot(_instance);
725
1117
  if (value == DELETE_TOKEN) {
726
- await this.room.storage.delete(path);
1118
+ hasDeletes = true;
1119
+ deleteTasks.push(this.deleteStatePath(path));
727
1120
  } else {
728
- await this.room.storage.put(path, itemValue);
1121
+ const itemValue = _instance?.$snapshot ? createStatesSnapshot(_instance) : value;
1122
+ stateWrites[this.stateKey(path)] = itemValue;
729
1123
  }
730
1124
  }
1125
+ await Promise.all([
1126
+ this.putStorageEntries(stateWrites),
1127
+ ...deleteTasks
1128
+ ]);
1129
+ if (hasDeletes) {
1130
+ await this.compactStateStorage(instance);
1131
+ }
1132
+ const metrics = instance.$storageMetrics;
1133
+ if (metrics) {
1134
+ metrics.persistFlushes += 1;
1135
+ metrics.persistWrites += Object.keys(stateWrites).length;
1136
+ metrics.persistDeletes += deleteTasks.length;
1137
+ metrics.persistLastFlushMs = Date.now() - startedAt;
1138
+ }
1139
+ };
1140
+ let persistQueue = Promise.resolve();
1141
+ const persistCb = async (values) => {
1142
+ if (initPersist) {
1143
+ values.clear();
1144
+ return;
1145
+ }
1146
+ const valuesSnapshot = new Map(values);
731
1147
  values.clear();
1148
+ persistQueue = persistQueue.then(
1149
+ () => flushPersist(valuesSnapshot),
1150
+ () => flushPersist(valuesSnapshot)
1151
+ );
1152
+ await persistQueue;
1153
+ };
1154
+ const debouncePersist = (wait) => {
1155
+ let timeout = null;
1156
+ let flushing = false;
1157
+ const pending = /* @__PURE__ */ new Map();
1158
+ const schedule = () => {
1159
+ if (timeout) {
1160
+ clearTimeout(timeout);
1161
+ }
1162
+ timeout = setTimeout(() => {
1163
+ void flush();
1164
+ }, wait);
1165
+ };
1166
+ const flush = async () => {
1167
+ timeout = null;
1168
+ if (flushing) {
1169
+ schedule();
1170
+ return;
1171
+ }
1172
+ const values = new Map(pending);
1173
+ pending.clear();
1174
+ if (!values.size) {
1175
+ return;
1176
+ }
1177
+ flushing = true;
1178
+ try {
1179
+ await persistCb(values);
1180
+ } finally {
1181
+ flushing = false;
1182
+ if (pending.size) {
1183
+ schedule();
1184
+ }
1185
+ }
1186
+ };
1187
+ return (values) => {
1188
+ if (initPersist) {
1189
+ values.clear();
1190
+ return;
1191
+ }
1192
+ for (const [path, value] of values) {
1193
+ pending.set(path, value);
1194
+ }
1195
+ values.clear();
1196
+ schedule();
1197
+ };
732
1198
  };
733
1199
  syncClass(instance, {
734
1200
  onSync: instance["throttleSync"] ? throttle(syncCb, instance["throttleSync"]) : syncCb,
735
- onPersist: instance["throttleStorage"] ? throttle(persistCb, instance["throttleStorage"]) : persistCb
1201
+ onPersist: instance["throttleStorage"] ? debouncePersist(instance["throttleStorage"]) : persistCb
736
1202
  });
737
1203
  await loadMemory();
738
1204
  initPersist = false;
@@ -839,24 +1305,93 @@ var Server = class {
839
1305
  async getSession(privateId) {
840
1306
  if (!privateId) return null;
841
1307
  try {
842
- const session = await this.room.storage.get(`session:${privateId}`);
1308
+ const session = await this.room.storage.get(this.sessionKey(privateId));
843
1309
  return session;
844
1310
  } catch (e) {
845
1311
  return null;
846
1312
  }
847
1313
  }
1314
+ async getSessionPrivateIds(publicId) {
1315
+ if (!publicId) return [];
1316
+ const privateIds = await this.room.storage.get(this.sessionPublicKey(publicId));
1317
+ return Array.isArray(privateIds) ? privateIds : [];
1318
+ }
1319
+ async saveSessionPrivateIds(publicId, privateIds) {
1320
+ const key = this.sessionPublicKey(publicId);
1321
+ if (privateIds.length === 0) {
1322
+ await this.room.storage.delete(key);
1323
+ return;
1324
+ }
1325
+ await this.room.storage.put(key, privateIds);
1326
+ }
1327
+ async addSessionToPublicIndex(privateId, publicId) {
1328
+ const privateIds = await this.getSessionPrivateIds(publicId);
1329
+ if (privateIds.includes(privateId)) {
1330
+ return;
1331
+ }
1332
+ await this.saveSessionPrivateIds(publicId, [...privateIds, privateId]);
1333
+ }
1334
+ async removeSessionFromPublicIndex(privateId, publicId) {
1335
+ const privateIds = await this.getSessionPrivateIds(publicId);
1336
+ await this.saveSessionPrivateIds(
1337
+ publicId,
1338
+ privateIds.filter((id2) => id2 !== privateId)
1339
+ );
1340
+ }
1341
+ async getSessionEntryByPublicId(publicId) {
1342
+ const indexedPrivateIds = await this.getSessionPrivateIds(publicId);
1343
+ const stalePrivateIds = [];
1344
+ for (const privateId of indexedPrivateIds) {
1345
+ const session = await this.getSession(privateId);
1346
+ if (session?.publicId === publicId) {
1347
+ return { privateId, session };
1348
+ }
1349
+ stalePrivateIds.push(privateId);
1350
+ }
1351
+ if (stalePrivateIds.length) {
1352
+ await this.saveSessionPrivateIds(
1353
+ publicId,
1354
+ indexedPrivateIds.filter((id2) => !stalePrivateIds.includes(id2))
1355
+ );
1356
+ }
1357
+ const sessions = await this.listStorage(SESSION_PREFIX);
1358
+ for (const [key, session] of sessions) {
1359
+ const privateId = key.slice(SESSION_PREFIX.length);
1360
+ if (session?.publicId) {
1361
+ await this.addSessionToPublicIndex(privateId, session.publicId);
1362
+ }
1363
+ if (session?.publicId === publicId) {
1364
+ return { privateId, session };
1365
+ }
1366
+ }
1367
+ return null;
1368
+ }
848
1369
  async saveSession(privateId, data) {
1370
+ const existingSession = await this.getSession(privateId);
849
1371
  const sessionData = {
850
1372
  ...data,
851
1373
  created: data.created || Date.now(),
852
1374
  connected: data.connected !== void 0 ? data.connected : true
853
1375
  };
854
- await this.room.storage.put(`session:${privateId}`, sessionData);
1376
+ await this.room.storage.put(this.sessionKey(privateId), sessionData);
1377
+ if (existingSession?.publicId && existingSession.publicId !== sessionData.publicId) {
1378
+ await this.removeSessionFromPublicIndex(privateId, existingSession.publicId);
1379
+ }
1380
+ await this.addSessionToPublicIndex(privateId, sessionData.publicId);
855
1381
  }
856
1382
  async updateSessionConnection(privateId, connected) {
857
1383
  const session = await this.getSession(privateId);
858
1384
  if (session) {
859
- await this.saveSession(privateId, { ...session, connected });
1385
+ const nextSession = { ...session, connected };
1386
+ if (connected) {
1387
+ delete nextSession.disconnectedAt;
1388
+ } else {
1389
+ nextSession.disconnectedAt = Date.now();
1390
+ }
1391
+ if (!await this.getSession(privateId)) {
1392
+ return;
1393
+ }
1394
+ await this.saveSession(privateId, nextSession);
860
1395
  }
861
1396
  }
862
1397
  /**
@@ -871,7 +1406,11 @@ var Server = class {
871
1406
  * ```
872
1407
  */
873
1408
  async deleteSession(privateId) {
874
- await this.room.storage.delete(`session:${privateId}`);
1409
+ const session = await this.getSession(privateId);
1410
+ await this.room.storage.delete(this.sessionKey(privateId));
1411
+ if (session?.publicId) {
1412
+ await this.removeSessionFromPublicIndex(privateId, session.publicId);
1413
+ }
875
1414
  }
876
1415
  async onConnectClient(conn, ctx) {
877
1416
  const subRoom = await this.getSubRoom({
@@ -881,8 +1420,17 @@ var Server = class {
881
1420
  conn.close();
882
1421
  return;
883
1422
  }
884
- const sessionExpiryTime = subRoom.constructor.sessionExpiryTime;
885
- await this.garbageCollector({ sessionExpiryTime });
1423
+ const sessionExpiryTime = this.getSessionExpiryTime(subRoom);
1424
+ if (await this.shouldRunSessionGarbageCollector(sessionExpiryTime)) {
1425
+ await this.garbageCollector({ sessionExpiryTime });
1426
+ }
1427
+ const transferExpiryTime = this.getTransferExpiryTime(subRoom);
1428
+ if (await this.shouldRunInterval(TRANSFER_GC_LAST_RUN_KEY, transferExpiryTime)) {
1429
+ await this.cleanupExpiredTransfers(
1430
+ transferExpiryTime,
1431
+ subRoom.$storageMetrics
1432
+ );
1433
+ }
886
1434
  const roomGuards = subRoom.constructor["_roomGuards"] || [];
887
1435
  for (const guard of roomGuards) {
888
1436
  const isAuthorized = await guard(conn, ctx, this.room);
@@ -898,12 +1446,18 @@ var Server = class {
898
1446
  }
899
1447
  let transferData = null;
900
1448
  if (transferToken) {
901
- transferData = await this.room.storage.get(`transfer:${transferToken}`);
1449
+ const transferKey = this.transferKey(transferToken);
1450
+ transferData = await this.room.storage.get(transferKey);
902
1451
  if (transferData) {
903
- await this.room.storage.delete(`transfer:${transferToken}`);
1452
+ if (this.isTransferExpired(transferData, transferExpiryTime)) {
1453
+ transferData = null;
1454
+ }
1455
+ await this.room.storage.delete(transferKey);
904
1456
  }
905
1457
  }
906
- const existingSession = await this.getSession(conn.id);
1458
+ const requestedPrivateId = this.getPrivateId(conn);
1459
+ const privateId = transferData?.privateId || requestedPrivateId;
1460
+ const existingSession = await this.getSession(privateId);
907
1461
  const publicId = existingSession?.publicId || transferData?.publicId || generateShortUUID2();
908
1462
  let user = null;
909
1463
  const signal2 = this.getUsersProperty(subRoom);
@@ -914,27 +1468,27 @@ var Server = class {
914
1468
  if (transferData?.restored && signal2()[publicId]) {
915
1469
  user = signal2()[publicId];
916
1470
  } else {
917
- user = isClass(classType) ? new classType() : classType(conn, ctx);
1471
+ user = this.createUserFromClassType(classType, conn, ctx);
918
1472
  signal2()[publicId] = user;
919
1473
  const snapshot = createStatesSnapshotDeep(user);
920
- this.room.storage.put(`${usersPropName}.${publicId}`, snapshot);
1474
+ await this.saveStatePath(`${usersPropName}.${publicId}`, snapshot);
921
1475
  }
922
1476
  } else {
923
1477
  user = signal2()[existingSession.publicId];
924
1478
  }
925
1479
  if (!existingSession) {
926
- const sessionPrivateId = transferData?.privateId || conn.id;
927
- await this.saveSession(sessionPrivateId, {
1480
+ await this.saveSession(privateId, {
928
1481
  publicId
929
1482
  });
930
1483
  } else {
931
- await this.updateSessionConnection(conn.id, true);
1484
+ await this.updateSessionConnection(privateId, true);
932
1485
  }
933
1486
  }
934
1487
  this.updateUserConnectionStatus(user, true);
935
1488
  conn.setState({
936
1489
  ...conn.state,
937
- publicId
1490
+ publicId,
1491
+ privateId
938
1492
  });
939
1493
  await awaitReturn(subRoom["onJoin"]?.(user, conn, ctx));
940
1494
  if (subRoom.$autoSync) {
@@ -967,11 +1521,19 @@ var Server = class {
967
1521
  */
968
1522
  async onConnect(conn, ctx) {
969
1523
  if (ctx.request?.headers.has("x-shard-id")) {
1524
+ if (!this.isAuthorizedShardRequest(ctx.request)) {
1525
+ conn.close();
1526
+ return;
1527
+ }
970
1528
  this.onConnectShard(conn, ctx);
971
1529
  } else {
972
1530
  await this.onConnectClient(conn, ctx);
973
1531
  }
974
1532
  }
1533
+ isAuthorizedShardRequest(req) {
1534
+ const shardSecret = this.room.env.SHARD_SECRET;
1535
+ return typeof shardSecret === "string" && shardSecret.length > 0 && req?.headers.get("x-access-shard") === shardSecret;
1536
+ }
975
1537
  /**
976
1538
  * @method onConnectShard
977
1539
  * @private
@@ -982,9 +1544,11 @@ var Server = class {
982
1544
  */
983
1545
  onConnectShard(conn, ctx) {
984
1546
  const shardId = ctx.request?.headers.get("x-shard-id") || "unknown-shard";
1547
+ const worldId = ctx.request?.headers.get("x-shard-world-id") || "world-default";
985
1548
  conn.setState({
986
1549
  shard: true,
987
1550
  shardId,
1551
+ worldId,
988
1552
  clients: /* @__PURE__ */ new Map()
989
1553
  // Track clients connected through this shard
990
1554
  });
@@ -1254,11 +1818,15 @@ var Server = class {
1254
1818
  if (!conn.state) {
1255
1819
  return;
1256
1820
  }
1257
- const privateId = conn.id;
1821
+ const privateId = this.getPrivateId(conn);
1258
1822
  const { publicId } = conn.state;
1259
1823
  const user = signal2?.()[publicId];
1260
1824
  if (!user) return;
1825
+ if (this.hasActiveSessionConnection(privateId)) {
1826
+ return;
1827
+ }
1261
1828
  await this.updateSessionConnection(privateId, false);
1829
+ this.scheduleSessionGarbageCollector(this.getSessionExpiryTime(subRoom), privateId);
1262
1830
  const connectionUpdated = this.updateUserConnectionStatus(user, false);
1263
1831
  await awaitReturn(subRoom["onLeave"]?.(user, conn));
1264
1832
  if (!connectionUpdated) {
@@ -1293,6 +1861,9 @@ var Server = class {
1293
1861
  return res.status(200).send({});
1294
1862
  }
1295
1863
  if (isFromShard) {
1864
+ if (!this.isAuthorizedShardRequest(req)) {
1865
+ return res.unauthorized("Invalid shard credentials");
1866
+ }
1296
1867
  return this.handleShardRequest(req, res, shardId);
1297
1868
  }
1298
1869
  return this.handleDirectRequest(req, res);
@@ -1329,7 +1900,7 @@ var Server = class {
1329
1900
  const usersPropName = this.getUsersPropName(subRoom);
1330
1901
  if (signal2 && usersPropName) {
1331
1902
  const { classType } = signal2.options;
1332
- const user = isClass(classType) ? new classType() : classType();
1903
+ const user = this.createUserFromClassType(classType);
1333
1904
  const hydratedSnapshot = await awaitReturn(
1334
1905
  subRoom["onSessionRestore"]?.({
1335
1906
  userSnapshot,
@@ -1342,14 +1913,15 @@ var Server = class {
1342
1913
  ) ?? userSnapshot;
1343
1914
  signal2()[publicId] = user;
1344
1915
  load(user, hydratedSnapshot, true);
1345
- await this.room.storage.put(`${usersPropName}.${publicId}`, userSnapshot);
1916
+ await this.saveStatePath(`${usersPropName}.${publicId}`, userSnapshot);
1346
1917
  }
1347
1918
  }
1348
1919
  const transferToken = generateShortUUID2();
1349
- await this.room.storage.put(`transfer:${transferToken}`, {
1920
+ await this.room.storage.put(this.transferKey(transferToken), {
1350
1921
  privateId,
1351
1922
  publicId,
1352
- restored: true
1923
+ restored: true,
1924
+ created: Date.now()
1353
1925
  });
1354
1926
  return res.success({ transferToken });
1355
1927
  } catch (error) {
@@ -1553,17 +2125,33 @@ var Server = class {
1553
2125
 
1554
2126
  // src/shard.ts
1555
2127
  var Shard = class {
1556
- constructor(room) {
2128
+ constructor(room, options = {}) {
1557
2129
  this.room = room;
1558
2130
  this.connectionMap = /* @__PURE__ */ new Map();
1559
2131
  this.worldUrl = null;
1560
- this.worldId = "default";
1561
2132
  this.lastReportedConnections = 0;
1562
2133
  this.statsInterval = 3e4;
1563
2134
  this.statsIntervalId = null;
2135
+ this.worldUrl = options.worldUrl ?? null;
2136
+ this.worldId = options.worldId ?? this.getWorldIdFromShardId(room.id) ?? this.getEnvString("WORLD_ID") ?? this.getEnvString("SIGNE_WORLD_ID") ?? "world-default";
2137
+ this.statsInterval = options.statsInterval ?? this.statsInterval;
2138
+ }
2139
+ getPrivateId(conn) {
2140
+ return conn.sessionId || conn.id;
2141
+ }
2142
+ getEnvString(key) {
2143
+ const value = this.room.env?.[key];
2144
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2145
+ }
2146
+ getRoomIdFromShardId(shardId) {
2147
+ return shardId.split(":")[0];
2148
+ }
2149
+ getWorldIdFromShardId(shardId) {
2150
+ const parts = shardId.split(":");
2151
+ return parts.length >= 3 ? parts[1] : void 0;
1564
2152
  }
1565
2153
  async onStart() {
1566
- const roomId = this.room.id.split(":")[0];
2154
+ const roomId = this.getRoomIdFromShardId(this.room.id);
1567
2155
  const roomStub = this.room.context.parties.main.get(roomId);
1568
2156
  if (!roomStub) {
1569
2157
  console.warn("No room room stub found in main party context");
@@ -1572,17 +2160,30 @@ var Shard = class {
1572
2160
  this.mainServerStub = roomStub;
1573
2161
  this.ws = await roomStub.socket({
1574
2162
  headers: {
1575
- "x-shard-id": this.room.id
2163
+ "x-shard-id": this.room.id,
2164
+ "x-shard-world-id": this.worldId,
2165
+ "x-access-shard": this.room.env.SHARD_SECRET
1576
2166
  }
1577
2167
  });
1578
2168
  this.ws.addEventListener("message", (event) => {
1579
2169
  try {
1580
2170
  const message = JSON.parse(event.data);
2171
+ if (message.type === "shard.closeClient" && message.privateId) {
2172
+ const clientConnections = this.connectionMap.get(message.privateId);
2173
+ if (clientConnections?.size) {
2174
+ for (const clientConn of [...clientConnections]) {
2175
+ clientConn.close();
2176
+ }
2177
+ }
2178
+ return;
2179
+ }
1581
2180
  if (message.targetClientId) {
1582
- const clientConn = this.connectionMap.get(message.targetClientId);
1583
- if (clientConn) {
2181
+ const clientConnections = this.connectionMap.get(message.targetClientId);
2182
+ if (clientConnections?.size) {
1584
2183
  delete message.targetClientId;
1585
- clientConn.send(message.data);
2184
+ for (const clientConn of clientConnections) {
2185
+ clientConn.send(message.data);
2186
+ }
1586
2187
  }
1587
2188
  } else {
1588
2189
  this.room.broadcast(event.data);
@@ -1591,21 +2192,22 @@ var Shard = class {
1591
2192
  console.error("Error processing message from main server:", error);
1592
2193
  }
1593
2194
  });
1594
- await this.updateWorldStats();
2195
+ await this.updateWorldStats(true);
1595
2196
  this.startPeriodicStatsUpdates();
1596
2197
  }
1597
2198
  startPeriodicStatsUpdates() {
1598
- if (!this.worldUrl) {
2199
+ if (this.statsInterval <= 0 || !this.room.context.parties.world) {
1599
2200
  return;
1600
2201
  }
1601
2202
  if (this.statsIntervalId) {
1602
2203
  clearInterval(this.statsIntervalId);
1603
2204
  }
1604
2205
  this.statsIntervalId = setInterval(() => {
1605
- this.updateWorldStats().catch((error) => {
2206
+ this.updateWorldStats(true).catch((error) => {
1606
2207
  console.error("Error in periodic stats update:", error);
1607
2208
  });
1608
2209
  }, this.statsInterval);
2210
+ this.statsIntervalId?.unref?.();
1609
2211
  }
1610
2212
  stopPeriodicStatsUpdates() {
1611
2213
  if (this.statsIntervalId) {
@@ -1614,7 +2216,10 @@ var Shard = class {
1614
2216
  }
1615
2217
  }
1616
2218
  onConnect(conn, ctx) {
1617
- this.connectionMap.set(conn.id, conn);
2219
+ const privateId = this.getPrivateId(conn);
2220
+ const connections = this.connectionMap.get(privateId) ?? /* @__PURE__ */ new Set();
2221
+ connections.add(conn);
2222
+ this.connectionMap.set(privateId, connections);
1618
2223
  const headers = {};
1619
2224
  if (ctx.request?.headers) {
1620
2225
  ctx.request.headers.forEach((value, key) => {
@@ -1628,7 +2233,7 @@ var Shard = class {
1628
2233
  } : null;
1629
2234
  this.ws.send(JSON.stringify({
1630
2235
  type: "shard.clientConnected",
1631
- privateId: conn.id,
2236
+ privateId,
1632
2237
  requestInfo
1633
2238
  }));
1634
2239
  this.updateWorldStats();
@@ -1638,7 +2243,7 @@ var Shard = class {
1638
2243
  const parsedMessage = typeof message === "string" ? JSON.parse(message) : message;
1639
2244
  const wrappedMessage = JSON.stringify({
1640
2245
  type: "shard.clientMessage",
1641
- privateId: sender.id,
2246
+ privateId: this.getPrivateId(sender),
1642
2247
  publicId: sender.state?.publicId,
1643
2248
  payload: parsedMessage
1644
2249
  });
@@ -1648,21 +2253,35 @@ var Shard = class {
1648
2253
  }
1649
2254
  }
1650
2255
  onClose(conn) {
1651
- this.connectionMap.delete(conn.id);
2256
+ const privateId = this.getPrivateId(conn);
2257
+ const connections = this.connectionMap.get(privateId);
2258
+ connections?.delete(conn);
2259
+ if (connections?.size) {
2260
+ this.updateWorldStats();
2261
+ return;
2262
+ }
2263
+ this.connectionMap.delete(privateId);
1652
2264
  this.ws.send(JSON.stringify({
1653
2265
  type: "shard.clientDisconnected",
1654
- privateId: conn.id,
2266
+ privateId,
1655
2267
  publicId: conn.state?.publicId
1656
2268
  }));
1657
2269
  this.updateWorldStats();
1658
2270
  }
1659
- async updateWorldStats() {
1660
- const currentConnections = this.connectionMap.size;
1661
- if (currentConnections === this.lastReportedConnections) {
2271
+ async updateWorldStats(force = false) {
2272
+ const currentConnections = Array.from(this.connectionMap.values()).reduce((total, connections) => total + connections.size, 0);
2273
+ if (!force && currentConnections === this.lastReportedConnections) {
1662
2274
  return true;
1663
2275
  }
1664
2276
  try {
1665
- const worldRoom = this.room.context.parties.world.get("world-default");
2277
+ const worldParty = this.room.context.parties.world;
2278
+ if (!worldParty) {
2279
+ return false;
2280
+ }
2281
+ const worldRoom = worldParty.get(this.worldId);
2282
+ if (!worldRoom?.fetch) {
2283
+ return false;
2284
+ }
1666
2285
  const response2 = await worldRoom.fetch("/update-shard", {
1667
2286
  method: "POST",
1668
2287
  headers: {
@@ -1671,6 +2290,7 @@ var Shard = class {
1671
2290
  },
1672
2291
  body: JSON.stringify({
1673
2292
  shardId: this.room.id,
2293
+ worldId: this.worldId,
1674
2294
  connections: currentConnections
1675
2295
  })
1676
2296
  });
@@ -1710,7 +2330,9 @@ var Shard = class {
1710
2330
  headers.set(key, value);
1711
2331
  });
1712
2332
  headers.set("x-shard-id", this.room.id);
2333
+ headers.set("x-shard-world-id", this.worldId);
1713
2334
  headers.set("x-forwarded-by-shard", "true");
2335
+ headers.set("x-access-shard", this.room.env.SHARD_SECRET);
1714
2336
  const clientIp = req.headers.get("x-forwarded-for") || "unknown";
1715
2337
  if (clientIp) {
1716
2338
  headers.set("x-original-client-ip", clientIp);
@@ -1720,7 +2342,7 @@ var Shard = class {
1720
2342
  headers,
1721
2343
  body
1722
2344
  };
1723
- const response2 = await this.mainServerStub.fetch(path, requestInit);
2345
+ const response2 = await this.mainServerStub.fetch(path + url.search, requestInit);
1724
2346
  return response2;
1725
2347
  } catch (error) {
1726
2348
  return response(500, { error: "Error forwarding request" });
@@ -1732,7 +2354,7 @@ var Shard = class {
1732
2354
  * @description Executed periodically, used to perform maintenance tasks
1733
2355
  */
1734
2356
  async onAlarm() {
1735
- await this.updateWorldStats();
2357
+ await this.updateWorldStats(true);
1736
2358
  }
1737
2359
  };
1738
2360
 
@@ -1744,7 +2366,7 @@ async function testRoom(Room2, options = {}) {
1744
2366
  return server2;
1745
2367
  };
1746
2368
  const isShard = options.shard || false;
1747
- const io = new ServerIo(Room2.path, isShard ? {
2369
+ const io = new ServerIo(options.id ?? Room2.path, isShard ? {
1748
2370
  parties: {
1749
2371
  game: createServer,
1750
2372
  ...options.parties || {}
@@ -1788,7 +2410,7 @@ async function testRoom(Room2, options = {}) {
1788
2410
  return client;
1789
2411
  },
1790
2412
  getServerUser: async (client, prop = "users") => {
1791
- const privateId = client.conn.id;
2413
+ const privateId = client.conn.sessionId || client.conn.id;
1792
2414
  const session = await server.getSession(privateId);
1793
2415
  return server.subRoom[prop]()[session?.publicId];
1794
2416
  }
@@ -1808,11 +2430,11 @@ function tick(ms = 0) {
1808
2430
 
1809
2431
  // src/mock.ts
1810
2432
  var MockPartyClient = class {
1811
- constructor(server, id2) {
2433
+ constructor(server, sessionId) {
1812
2434
  this.server = server;
1813
2435
  this.events = /* @__PURE__ */ new Map();
1814
- this.id = id2 || generateShortUUID();
1815
- this.conn = new MockConnection(this);
2436
+ this.id = generateShortUUID();
2437
+ this.conn = new MockConnection(this, sessionId || this.id);
1816
2438
  }
1817
2439
  addEventListener(event, cb) {
1818
2440
  if (!this.events.has(event)) {
@@ -1848,8 +2470,8 @@ var MockLobby = class {
1848
2470
  this.server = server;
1849
2471
  this.lobbyId = lobbyId;
1850
2472
  }
1851
- socket(_init) {
1852
- return new MockPartyClient(this.server);
2473
+ socket(init) {
2474
+ return new MockPartyClient(this.server, init?.id);
1853
2475
  }
1854
2476
  async connection(idOrOptions, maybeOptions) {
1855
2477
  const id2 = typeof idOrOptions === "string" ? idOrOptions : idOrOptions?.id;
@@ -1925,7 +2547,13 @@ var MockPartyRoom = class {
1925
2547
  });
1926
2548
  }
1927
2549
  getConnection(id2) {
1928
- return this.clients.get(id2);
2550
+ let connection;
2551
+ for (const client of this.clients.values()) {
2552
+ if (client.conn.id === id2 || client.conn.sessionId === id2) {
2553
+ connection = client.conn;
2554
+ }
2555
+ }
2556
+ return connection;
1929
2557
  }
1930
2558
  getConnections() {
1931
2559
  return Array.from(this.clients.values()).map((client) => client.conn);
@@ -1933,13 +2561,25 @@ var MockPartyRoom = class {
1933
2561
  clear() {
1934
2562
  this.clients.clear();
1935
2563
  }
2564
+ deleteConnection(id2, connection) {
2565
+ if (connection) {
2566
+ this.clients.delete(connection.id);
2567
+ return;
2568
+ }
2569
+ for (const [connectionKey, client] of this.clients) {
2570
+ if (client.conn.id === id2 || client.conn.sessionId === id2) {
2571
+ this.clients.delete(connectionKey);
2572
+ }
2573
+ }
2574
+ }
1936
2575
  };
1937
2576
  var MockConnection = class {
1938
- constructor(client) {
2577
+ constructor(client, sessionId) {
1939
2578
  this.client = client;
1940
2579
  this.state = {};
1941
2580
  this.server = client.server;
1942
2581
  this.id = client.id;
2582
+ this.sessionId = sessionId;
1943
2583
  }
1944
2584
  setState(value) {
1945
2585
  this.state = value;
@@ -1948,6 +2588,7 @@ var MockConnection = class {
1948
2588
  this.client._trigger("message", data);
1949
2589
  }
1950
2590
  close() {
2591
+ this.server.room.deleteConnection?.(this.id, this);
1951
2592
  this.server.onClose(this);
1952
2593
  }
1953
2594
  };
@@ -2127,7 +2768,7 @@ var guardManageWorld = async (_, req, room) => {
2127
2768
  return true;
2128
2769
  }
2129
2770
  const url = new URL(req.url);
2130
- const token = req.headers.get("Authorization") ?? url.searchParams.get("world-auth-token");
2771
+ const token = getAuthToken(req, url);
2131
2772
  if (!token) {
2132
2773
  return false;
2133
2774
  }
@@ -2137,11 +2778,28 @@ var guardManageWorld = async (_, req, room) => {
2137
2778
  if (!payload) {
2138
2779
  return false;
2139
2780
  }
2781
+ if (!canAccessWorld(payload, room.id)) {
2782
+ return false;
2783
+ }
2140
2784
  } catch (error) {
2141
2785
  return false;
2142
2786
  }
2143
2787
  return true;
2144
2788
  };
2789
+ function getAuthToken(req, url) {
2790
+ const authorization = req.headers.get("Authorization");
2791
+ if (authorization?.startsWith("Bearer ")) {
2792
+ return authorization.slice("Bearer ".length).trim();
2793
+ }
2794
+ return authorization ?? url.searchParams.get("world-auth-token");
2795
+ }
2796
+ function canAccessWorld(payload, worldId) {
2797
+ const worlds = payload.worlds;
2798
+ if (!Array.isArray(worlds)) {
2799
+ return false;
2800
+ }
2801
+ return worlds.some((world) => world === "*" || world === worldId);
2802
+ }
2145
2803
 
2146
2804
  // src/world.ts
2147
2805
  var MAX_PLAYERS_PER_SHARD = 75;
@@ -2156,10 +2814,13 @@ var RoomConfigSchema = z2.object({
2156
2814
  var RegisterShardSchema = z2.object({
2157
2815
  shardId: z2.string(),
2158
2816
  roomId: z2.string(),
2817
+ worldId: z2.string().optional(),
2159
2818
  url: z2.string().url(),
2160
2819
  maxConnections: z2.number().int().positive()
2161
2820
  });
2162
2821
  var UpdateShardStatsSchema = z2.object({
2822
+ shardId: z2.string(),
2823
+ worldId: z2.string().optional(),
2163
2824
  connections: z2.number().int().min(0),
2164
2825
  status: z2.enum(["active", "maintenance", "draining"]).optional()
2165
2826
  });
@@ -2205,6 +2866,7 @@ __decorateClass([
2205
2866
  var ShardInfo = class {
2206
2867
  constructor() {
2207
2868
  this.roomId = signal("");
2869
+ this.worldId = signal("");
2208
2870
  this.url = signal("");
2209
2871
  this.currentConnections = signal(0);
2210
2872
  this.maxConnections = signal(MAX_PLAYERS_PER_SHARD);
@@ -2218,6 +2880,9 @@ __decorateClass([
2218
2880
  __decorateClass([
2219
2881
  sync()
2220
2882
  ], ShardInfo.prototype, "roomId", 2);
2883
+ __decorateClass([
2884
+ sync()
2885
+ ], ShardInfo.prototype, "worldId", 2);
2221
2886
  __decorateClass([
2222
2887
  sync()
2223
2888
  ], ShardInfo.prototype, "url", 2);
@@ -2251,6 +2916,7 @@ var WorldRoom = class {
2251
2916
  if (!SHARD_SECRET) {
2252
2917
  throw new Error("SHARD_SECRET env variable is not set");
2253
2918
  }
2919
+ this.scheduleInactiveShardCleanup();
2254
2920
  }
2255
2921
  async onJoin(user, conn, ctx) {
2256
2922
  const canConnect = await guardManageWorld(user, ctx.request, this.room);
@@ -2266,6 +2932,13 @@ var WorldRoom = class {
2266
2932
  return obj;
2267
2933
  }
2268
2934
  // Helper methods
2935
+ getWorldId() {
2936
+ return this.room.id;
2937
+ }
2938
+ scheduleInactiveShardCleanup() {
2939
+ const timeoutId = setTimeout(() => this.cleanupInactiveShards(), 6e4);
2940
+ timeoutId?.unref?.();
2941
+ }
2269
2942
  cleanupInactiveShards() {
2270
2943
  const now = Date.now();
2271
2944
  const timeout = 5 * 60 * 1e3;
@@ -2277,10 +2950,25 @@ var WorldRoom = class {
2277
2950
  hasChanges = true;
2278
2951
  }
2279
2952
  });
2280
- setTimeout(() => this.cleanupInactiveShards(), 6e4);
2953
+ this.scheduleInactiveShardCleanup();
2954
+ }
2955
+ removeShard(shardId) {
2956
+ delete this.shards()[shardId];
2281
2957
  }
2282
- async registerRoom(req) {
2283
- const roomConfig = await req.json();
2958
+ shouldCompleteDrain(shard) {
2959
+ return shard.status() === "draining" && shard.currentConnections() === 0;
2960
+ }
2961
+ async registerRoom(req, res) {
2962
+ const parseResult = RoomConfigSchema.safeParse(await req.json());
2963
+ if (!parseResult.success) {
2964
+ return res?.badRequest("Invalid room configuration", {
2965
+ details: parseResult.error
2966
+ });
2967
+ }
2968
+ const roomConfig = parseResult.data;
2969
+ if (roomConfig.maxShards !== void 0 && roomConfig.minShards > roomConfig.maxShards) {
2970
+ return res?.badRequest("minShards cannot be greater than maxShards");
2971
+ }
2284
2972
  const roomId = roomConfig.name;
2285
2973
  if (!this.rooms()[roomId]) {
2286
2974
  const newRoom = new RoomConfig();
@@ -2305,22 +2993,41 @@ var WorldRoom = class {
2305
2993
  room.minShards.set(roomConfig.minShards);
2306
2994
  room.maxShards.set(roomConfig.maxShards);
2307
2995
  }
2996
+ await this.ensureMinShards(roomId);
2308
2997
  }
2309
2998
  async updateShardStats(req, res) {
2310
- const body = await req.json();
2999
+ const parseResult = UpdateShardStatsSchema.safeParse(await req.json());
3000
+ if (!parseResult.success) {
3001
+ return res.badRequest("Invalid shard stats", {
3002
+ details: parseResult.error
3003
+ });
3004
+ }
3005
+ const body = parseResult.data;
2311
3006
  const { shardId, connections, status } = body;
2312
3007
  const shard = this.shards()[shardId];
2313
3008
  if (!shard) {
2314
3009
  return res.notFound(`Shard ${shardId} not found`);
2315
3010
  }
3011
+ if (body.worldId && body.worldId !== this.getWorldId()) {
3012
+ return res.badRequest(`Shard ${shardId} belongs to world ${body.worldId}, not ${this.getWorldId()}`);
3013
+ }
2316
3014
  shard.currentConnections.set(connections);
2317
3015
  if (status) {
2318
3016
  shard.status.set(status);
2319
3017
  }
2320
3018
  shard.lastHeartbeat.set(Date.now());
3019
+ if (this.shouldCompleteDrain(shard)) {
3020
+ this.removeShard(shard.id);
3021
+ }
2321
3022
  }
2322
3023
  async scaleRoom(req, res) {
2323
- const data = await req.json();
3024
+ const parseResult = ScaleRoomSchema.safeParse(await req.json());
3025
+ if (!parseResult.success) {
3026
+ return res.badRequest("Invalid scale room request", {
3027
+ details: parseResult.error
3028
+ });
3029
+ }
3030
+ const data = parseResult.data;
2324
3031
  const { targetShardCount, shardTemplate, roomId } = data;
2325
3032
  const room = this.rooms()[roomId];
2326
3033
  if (!room) {
@@ -2335,16 +3042,16 @@ var WorldRoom = class {
2335
3042
  });
2336
3043
  }
2337
3044
  if (targetShardCount < previousShardCount) {
2338
- const shardsToRemove = [...roomShards].sort((a, b) => {
3045
+ const shardsToDrain = [...roomShards].sort((a, b) => {
2339
3046
  if (a.status() === "draining" && b.status() !== "draining") return -1;
2340
3047
  if (a.status() !== "draining" && b.status() === "draining") return 1;
2341
3048
  return a.currentConnections() - b.currentConnections();
2342
3049
  }).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];
3050
+ for (const shard of shardsToDrain) {
3051
+ shard.status.set("draining");
3052
+ if (this.shouldCompleteDrain(shard)) {
3053
+ this.removeShard(shard.id);
3054
+ }
2348
3055
  }
2349
3056
  return;
2350
3057
  }
@@ -2431,8 +3138,20 @@ var WorldRoom = class {
2431
3138
  return { error: `No shards available for room ${roomId}` };
2432
3139
  }
2433
3140
  }
2434
- const activeShards = roomShards.filter((shard) => shard && shard.status() === "active");
3141
+ let activeShards = this.getAvailableShards(roomShards);
2435
3142
  if (activeShards.length === 0) {
3143
+ if (autoCreate && this.canCreateShard(room, roomShards.length)) {
3144
+ const newShard = await this.createShard(roomId);
3145
+ if (newShard) {
3146
+ return {
3147
+ shardId: newShard.id,
3148
+ url: newShard.url()
3149
+ };
3150
+ }
3151
+ }
3152
+ if (roomShards.some((shard) => shard.status() === "active")) {
3153
+ return { error: `No shard capacity available for room ${roomId}` };
3154
+ }
2436
3155
  return { error: `No active shards available for room ${roomId}` };
2437
3156
  }
2438
3157
  const balancingStrategy = room.balancingStrategy();
@@ -2460,6 +3179,14 @@ var WorldRoom = class {
2460
3179
  url: selectedShard.url()
2461
3180
  };
2462
3181
  }
3182
+ getAvailableShards(shards) {
3183
+ return shards.filter(
3184
+ (shard) => shard && shard.status() === "active" && shard.currentConnections() < shard.maxConnections()
3185
+ );
3186
+ }
3187
+ canCreateShard(room, currentShardCount) {
3188
+ return room.maxShards() === void 0 || currentShardCount < room.maxShards();
3189
+ }
2463
3190
  // Private methods
2464
3191
  async createShard(roomId, urlTemplate, maxConnections) {
2465
3192
  const room = this.rooms()[roomId];
@@ -2467,13 +3194,15 @@ var WorldRoom = class {
2467
3194
  console.error(`Cannot create shard for non-existent room: ${roomId}`);
2468
3195
  return null;
2469
3196
  }
2470
- const shardId = `${roomId}:${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
3197
+ const worldId = this.getWorldId();
3198
+ const shardId = `${roomId}:${worldId}:${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
2471
3199
  const template = urlTemplate || this.defaultShardUrlTemplate();
2472
3200
  const url = template.replace("{shardId}", shardId).replace("{roomId}", roomId);
2473
3201
  const max = maxConnections || room.maxPlayersPerShard();
2474
3202
  const newShard = new ShardInfo();
2475
3203
  newShard.id = shardId;
2476
3204
  newShard.roomId.set(roomId);
3205
+ newShard.worldId.set(worldId);
2477
3206
  newShard.url.set(url);
2478
3207
  newShard.maxConnections.set(max);
2479
3208
  newShard.currentConnections.set(0);
@@ -2482,6 +3211,20 @@ var WorldRoom = class {
2482
3211
  this.shards()[shardId] = newShard;
2483
3212
  return newShard;
2484
3213
  }
3214
+ async ensureMinShards(roomId) {
3215
+ const room = this.rooms()[roomId];
3216
+ if (!room) {
3217
+ return;
3218
+ }
3219
+ const currentShardCount = Object.values(this.shards()).filter((shard) => shard.roomId() === roomId).length;
3220
+ const targetShardCount = room.minShards();
3221
+ if (currentShardCount >= targetShardCount) {
3222
+ return;
3223
+ }
3224
+ for (let i = currentShardCount; i < targetShardCount; i++) {
3225
+ await this.createShard(roomId);
3226
+ }
3227
+ }
2485
3228
  };
2486
3229
  __decorateClass([
2487
3230
  sync(RoomConfig)
@@ -2532,13 +3275,16 @@ WorldRoom = __decorateClass([
2532
3275
  ], WorldRoom);
2533
3276
 
2534
3277
  // src/session.guard.ts
3278
+ function getPrivateId(sender) {
3279
+ return sender.sessionId || sender.id;
3280
+ }
2535
3281
  function createRequireSessionGuard(storage) {
2536
3282
  return async (sender, value) => {
2537
3283
  if (!sender || !sender.id) {
2538
3284
  return false;
2539
3285
  }
2540
3286
  try {
2541
- const session = await storage.get(`session:${sender.id}`);
3287
+ const session = await storage.get(`session:${getPrivateId(sender)}`);
2542
3288
  if (!session) {
2543
3289
  return false;
2544
3290
  }
@@ -2558,7 +3304,7 @@ var requireSession = async (sender, value, room) => {
2558
3304
  return false;
2559
3305
  }
2560
3306
  try {
2561
- const session = await room.storage.get(`session:${sender.id}`);
3307
+ const session = await room.storage.get(`session:${getPrivateId(sender)}`);
2562
3308
  if (!session) {
2563
3309
  return false;
2564
3310
  }