@liveblocks/server 1.1.0 → 1.2.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.
package/dist/index.d.cts CHANGED
@@ -562,6 +562,11 @@ interface IStorageDriver {
562
562
  * Delete a leased session by session ID.
563
563
  */
564
564
  delete_leased_session(sessionId: string): Awaitable<void>;
565
+ /**
566
+ * Return the number of storage rows written since last call to this method,
567
+ * and reset the counter.
568
+ */
569
+ takeRowsWritten?(): number;
565
570
  }
566
571
 
567
572
  /**
@@ -838,7 +843,7 @@ declare class YjsStorage {
838
843
  * @returns a base64 encoded array of YJS updates
839
844
  */
840
845
  getYDocUpdate(logger: Logger, stateVector?: string, guid?: Guid, isV2?: boolean): Promise<string | null>;
841
- getYDocUpdateBinary(logger: Logger, stateVector?: string, guid?: Guid, isV2?: boolean): Promise<Uint8Array | null>;
846
+ getYDocUpdateBinary(logger: Logger, stateVector?: string, guid?: Guid, isV2?: boolean): Promise<Uint8Array<ArrayBuffer> | null>;
842
847
  getYStateVector(guid?: Guid): Promise<string | null>;
843
848
  getSnapshotHash(options: {
844
849
  guid?: Guid;
@@ -890,6 +895,8 @@ declare class YjsStorage {
890
895
 
891
896
  type LoadingState = "initial" | "loading" | "loaded";
892
897
  type ActorID = Brand<number, "ActorID">;
898
+ /** Number of milliseconds since Unix epoch. */
899
+ type Millis = Brand<number, "Millis">;
893
900
  /**
894
901
  * Session keys are also known as the "nonce" in the protocol. It's a random,
895
902
  * unique, but PRIVATE, identifier for the session, and it's important that
@@ -938,14 +945,13 @@ declare class BrowserSession<SM, CM extends JsonObject> {
938
945
  #private;
939
946
  readonly version: ProtocolVersion;
940
947
  readonly actor: ActorID;
941
- readonly createdAt: Date;
948
+ readonly createdAt: Millis;
942
949
  readonly user: IUserData;
943
950
  readonly scopes: string[];
944
951
  readonly meta: SM;
945
952
  readonly publicMeta?: CM;
946
- get lastActiveAt(): Date;
953
+ get lastActiveAt(): Millis;
947
954
  get hasNotifiedClientStorageUpdateError(): boolean;
948
- markActive(now?: Date): void;
949
955
  setHasNotifiedClientStorageUpdateError(): void;
950
956
  sendPong(): number;
951
957
  send(serverMsg: ServerMsg | ServerMsg[] | PreSerializedServerMsg): number;
@@ -1048,6 +1054,15 @@ declare class Room<RM, SM, CM extends JsonObject, C = undefined> {
1048
1054
  meta: RM;
1049
1055
  readonly driver: IStorageDriver;
1050
1056
  logger: Logger;
1057
+ /**
1058
+ * While a room is in "maintenance mode", all WebSocket connections to the
1059
+ * room should be rejected until it's pulled out of maintenance mode again.
1060
+ * Maintenance mode should only last a couple of milliseconds, typically.
1061
+ *
1062
+ * This mutex ensures that concurrent destructive operations (like
1063
+ * init-storage, storage-reset, etc.) cannot interfere with one another.
1064
+ */
1065
+ private readonly _maintenanceMode;
1051
1066
  private _loadData$;
1052
1067
  private _data;
1053
1068
  private _qsize;
@@ -1060,6 +1075,17 @@ declare class Room<RM, SM, CM extends JsonObject, C = undefined> {
1060
1075
  get yjsStorage(): YjsStorage;
1061
1076
  get mutex(): Mutex;
1062
1077
  private get data();
1078
+ /**
1079
+ * Returns true if the room is currently in maintenance mode.
1080
+ * When in maintenance mode, callers should refuse new WebSocket connections.
1081
+ */
1082
+ get isInMaintenance(): boolean;
1083
+ /**
1084
+ * Tries to enter maintenance mode and run the given callback exclusively.
1085
+ * If the room is already in maintenance mode, throws E_ALREADY_LOCKED
1086
+ * immediately instead of queuing the request.
1087
+ */
1088
+ runInMaintenanceMode<T>(callback: () => Promise<T>): Promise<T>;
1063
1089
  /**
1064
1090
  * Initializes the Room, so it's ready to start accepting connections. Safe
1065
1091
  * to call multiple times. After awaiting `room.load()` the Room is ready to
@@ -1102,6 +1128,8 @@ declare class Room<RM, SM, CM extends JsonObject, C = undefined> {
1102
1128
  ticket: Ticket<SM, CM>;
1103
1129
  socket: IServerWebSocket;
1104
1130
  lastActivity: Date;
1131
+ /** Original session creation time (e.g. from persisted attachment). Required for correct session duration after hibernation. */
1132
+ createdAt?: Date;
1105
1133
  }[]): void;
1106
1134
  private sendSessionStartMessages;
1107
1135
  /**
@@ -1230,7 +1258,7 @@ declare class Room<RM, SM, CM extends JsonObject, C = undefined> {
1230
1258
  /**
1231
1259
  * Concatenates multiple Uint8Arrays into a single Uint8Array.
1232
1260
  */
1233
- declare function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array;
1261
+ declare function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array<ArrayBuffer>;
1234
1262
  /**
1235
1263
  * Check if a leased session is expired.
1236
1264
  * Returns true if the current time is greater than or equal to updatedAt + ttl.
@@ -1468,18 +1496,19 @@ declare class InMemoryDriver implements IStorageDriver {
1468
1496
  initialActor?: number;
1469
1497
  initialNodes?: Iterable<[string, SerializedCrdt]>;
1470
1498
  });
1471
- raw_iter_nodes(): IterableIterator<[string, SerializedCrdt]>;
1499
+ raw_iter_nodes(): MapIterator<[string, SerializedCrdt]>;
1472
1500
  /** Deletes all nodes and replaces them with the given document. */
1473
1501
  DANGEROUSLY_reset_nodes(doc: PlainLsonObject): void;
1474
1502
  get_meta(key: string): Promise<Json | undefined>;
1475
1503
  put_meta(key: string, value: Json): Promise<void>;
1476
1504
  delete_meta(key: string): Promise<void>;
1477
- list_leased_sessions(): Promise<IterableIterator<[string, LeasedSession]>>;
1505
+ list_leased_sessions(): Promise<MapIterator<[string, LeasedSession]>>;
1478
1506
  get_leased_session(sessionId: string): Promise<LeasedSession | undefined>;
1479
1507
  put_leased_session(session: LeasedSession): Promise<void>;
1480
1508
  delete_leased_session(sessionId: string): Promise<void>;
1509
+ takeRowsWritten(): number;
1481
1510
  next_actor(): number;
1482
- iter_y_updates(docId: YDocId): Promise<IterableIterator<[string, Uint8Array]>>;
1511
+ iter_y_updates(docId: YDocId): Promise<IterableIterator<[string, Uint8Array<ArrayBufferLike>]>>;
1483
1512
  write_y_updates(docId: YDocId, key: string, data: Uint8Array): Promise<void>;
1484
1513
  delete_y_updates(docId: YDocId, keys: string[]): Promise<void>;
1485
1514
  /** @private Only use this in unit tests, never in production. */
@@ -1487,4 +1516,4 @@ declare class InMemoryDriver implements IStorageDriver {
1487
1516
  load_nodes_api(): IStorageDriverNodeAPI;
1488
1517
  }
1489
1518
 
1490
- export { type ActorID, BackendSession, BrowserSession, ConsoleTarget, type CreateTicketOptions, DefaultMap, type FixOp, type Guid, type IReadableSnapshot, type IServerWebSocket, type IStorageDriver, type IStorageDriverNodeAPI, type IUserData, InMemoryDriver, type LeasedSession, type LoadingState, LogLevel, LogTarget, Logger, type MetadataDB, NestedMap, type NodeMap, type NodeStream, type NodeTuple, type Pos, type PreSerializedServerMsg, ProtocolVersion, ROOT_YDOC_ID, Room, type SessionKey, type Ticket, UniqueMap, type YDocId, type YUpdate, type YVector, ackIgnoredOp, clientMsgDecoder, concatUint8Arrays, guidDecoder, isLeasedSessionExpired, jsonObjectYolo, jsonYolo, makeInMemorySnapshot, makeMetadataDB, plainLsonToNodeStream, protocolVersionDecoder, quote, serialize as serializeServerMsg, snapshotToLossyJson_eager, snapshotToLossyJson_lazy, snapshotToNodeStream, snapshotToPlainLson_eager, snapshotToPlainLson_lazy, Storage as test_only__Storage, YjsStorage as test_only__YjsStorage, transientClientMsgDecoder, tryCatch };
1519
+ export { type ActorID, BackendSession, BrowserSession, ConsoleTarget, type CreateTicketOptions, DefaultMap, type FixOp, type Guid, type IReadableSnapshot, type IServerWebSocket, type IStorageDriver, type IStorageDriverNodeAPI, type IUserData, InMemoryDriver, type LeasedSession, type LoadingState, LogLevel, LogTarget, Logger, type MetadataDB, type Millis, NestedMap, type NodeMap, type NodeStream, type NodeTuple, type Pos, type PreSerializedServerMsg, ProtocolVersion, ROOT_YDOC_ID, Room, type SessionKey, type Ticket, UniqueMap, type YDocId, type YUpdate, type YVector, ackIgnoredOp, clientMsgDecoder, concatUint8Arrays, guidDecoder, isLeasedSessionExpired, jsonObjectYolo, jsonYolo, makeInMemorySnapshot, makeMetadataDB, plainLsonToNodeStream, protocolVersionDecoder, quote, serialize as serializeServerMsg, snapshotToLossyJson_eager, snapshotToLossyJson_lazy, snapshotToNodeStream, snapshotToPlainLson_eager, snapshotToPlainLson_lazy, Storage as test_only__Storage, YjsStorage as test_only__YjsStorage, transientClientMsgDecoder, tryCatch };
package/dist/index.d.ts CHANGED
@@ -562,6 +562,11 @@ interface IStorageDriver {
562
562
  * Delete a leased session by session ID.
563
563
  */
564
564
  delete_leased_session(sessionId: string): Awaitable<void>;
565
+ /**
566
+ * Return the number of storage rows written since last call to this method,
567
+ * and reset the counter.
568
+ */
569
+ takeRowsWritten?(): number;
565
570
  }
566
571
 
567
572
  /**
@@ -838,7 +843,7 @@ declare class YjsStorage {
838
843
  * @returns a base64 encoded array of YJS updates
839
844
  */
840
845
  getYDocUpdate(logger: Logger, stateVector?: string, guid?: Guid, isV2?: boolean): Promise<string | null>;
841
- getYDocUpdateBinary(logger: Logger, stateVector?: string, guid?: Guid, isV2?: boolean): Promise<Uint8Array | null>;
846
+ getYDocUpdateBinary(logger: Logger, stateVector?: string, guid?: Guid, isV2?: boolean): Promise<Uint8Array<ArrayBuffer> | null>;
842
847
  getYStateVector(guid?: Guid): Promise<string | null>;
843
848
  getSnapshotHash(options: {
844
849
  guid?: Guid;
@@ -890,6 +895,8 @@ declare class YjsStorage {
890
895
 
891
896
  type LoadingState = "initial" | "loading" | "loaded";
892
897
  type ActorID = Brand<number, "ActorID">;
898
+ /** Number of milliseconds since Unix epoch. */
899
+ type Millis = Brand<number, "Millis">;
893
900
  /**
894
901
  * Session keys are also known as the "nonce" in the protocol. It's a random,
895
902
  * unique, but PRIVATE, identifier for the session, and it's important that
@@ -938,14 +945,13 @@ declare class BrowserSession<SM, CM extends JsonObject> {
938
945
  #private;
939
946
  readonly version: ProtocolVersion;
940
947
  readonly actor: ActorID;
941
- readonly createdAt: Date;
948
+ readonly createdAt: Millis;
942
949
  readonly user: IUserData;
943
950
  readonly scopes: string[];
944
951
  readonly meta: SM;
945
952
  readonly publicMeta?: CM;
946
- get lastActiveAt(): Date;
953
+ get lastActiveAt(): Millis;
947
954
  get hasNotifiedClientStorageUpdateError(): boolean;
948
- markActive(now?: Date): void;
949
955
  setHasNotifiedClientStorageUpdateError(): void;
950
956
  sendPong(): number;
951
957
  send(serverMsg: ServerMsg | ServerMsg[] | PreSerializedServerMsg): number;
@@ -1048,6 +1054,15 @@ declare class Room<RM, SM, CM extends JsonObject, C = undefined> {
1048
1054
  meta: RM;
1049
1055
  readonly driver: IStorageDriver;
1050
1056
  logger: Logger;
1057
+ /**
1058
+ * While a room is in "maintenance mode", all WebSocket connections to the
1059
+ * room should be rejected until it's pulled out of maintenance mode again.
1060
+ * Maintenance mode should only last a couple of milliseconds, typically.
1061
+ *
1062
+ * This mutex ensures that concurrent destructive operations (like
1063
+ * init-storage, storage-reset, etc.) cannot interfere with one another.
1064
+ */
1065
+ private readonly _maintenanceMode;
1051
1066
  private _loadData$;
1052
1067
  private _data;
1053
1068
  private _qsize;
@@ -1060,6 +1075,17 @@ declare class Room<RM, SM, CM extends JsonObject, C = undefined> {
1060
1075
  get yjsStorage(): YjsStorage;
1061
1076
  get mutex(): Mutex;
1062
1077
  private get data();
1078
+ /**
1079
+ * Returns true if the room is currently in maintenance mode.
1080
+ * When in maintenance mode, callers should refuse new WebSocket connections.
1081
+ */
1082
+ get isInMaintenance(): boolean;
1083
+ /**
1084
+ * Tries to enter maintenance mode and run the given callback exclusively.
1085
+ * If the room is already in maintenance mode, throws E_ALREADY_LOCKED
1086
+ * immediately instead of queuing the request.
1087
+ */
1088
+ runInMaintenanceMode<T>(callback: () => Promise<T>): Promise<T>;
1063
1089
  /**
1064
1090
  * Initializes the Room, so it's ready to start accepting connections. Safe
1065
1091
  * to call multiple times. After awaiting `room.load()` the Room is ready to
@@ -1102,6 +1128,8 @@ declare class Room<RM, SM, CM extends JsonObject, C = undefined> {
1102
1128
  ticket: Ticket<SM, CM>;
1103
1129
  socket: IServerWebSocket;
1104
1130
  lastActivity: Date;
1131
+ /** Original session creation time (e.g. from persisted attachment). Required for correct session duration after hibernation. */
1132
+ createdAt?: Date;
1105
1133
  }[]): void;
1106
1134
  private sendSessionStartMessages;
1107
1135
  /**
@@ -1230,7 +1258,7 @@ declare class Room<RM, SM, CM extends JsonObject, C = undefined> {
1230
1258
  /**
1231
1259
  * Concatenates multiple Uint8Arrays into a single Uint8Array.
1232
1260
  */
1233
- declare function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array;
1261
+ declare function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array<ArrayBuffer>;
1234
1262
  /**
1235
1263
  * Check if a leased session is expired.
1236
1264
  * Returns true if the current time is greater than or equal to updatedAt + ttl.
@@ -1468,18 +1496,19 @@ declare class InMemoryDriver implements IStorageDriver {
1468
1496
  initialActor?: number;
1469
1497
  initialNodes?: Iterable<[string, SerializedCrdt]>;
1470
1498
  });
1471
- raw_iter_nodes(): IterableIterator<[string, SerializedCrdt]>;
1499
+ raw_iter_nodes(): MapIterator<[string, SerializedCrdt]>;
1472
1500
  /** Deletes all nodes and replaces them with the given document. */
1473
1501
  DANGEROUSLY_reset_nodes(doc: PlainLsonObject): void;
1474
1502
  get_meta(key: string): Promise<Json | undefined>;
1475
1503
  put_meta(key: string, value: Json): Promise<void>;
1476
1504
  delete_meta(key: string): Promise<void>;
1477
- list_leased_sessions(): Promise<IterableIterator<[string, LeasedSession]>>;
1505
+ list_leased_sessions(): Promise<MapIterator<[string, LeasedSession]>>;
1478
1506
  get_leased_session(sessionId: string): Promise<LeasedSession | undefined>;
1479
1507
  put_leased_session(session: LeasedSession): Promise<void>;
1480
1508
  delete_leased_session(sessionId: string): Promise<void>;
1509
+ takeRowsWritten(): number;
1481
1510
  next_actor(): number;
1482
- iter_y_updates(docId: YDocId): Promise<IterableIterator<[string, Uint8Array]>>;
1511
+ iter_y_updates(docId: YDocId): Promise<IterableIterator<[string, Uint8Array<ArrayBufferLike>]>>;
1483
1512
  write_y_updates(docId: YDocId, key: string, data: Uint8Array): Promise<void>;
1484
1513
  delete_y_updates(docId: YDocId, keys: string[]): Promise<void>;
1485
1514
  /** @private Only use this in unit tests, never in production. */
@@ -1487,4 +1516,4 @@ declare class InMemoryDriver implements IStorageDriver {
1487
1516
  load_nodes_api(): IStorageDriverNodeAPI;
1488
1517
  }
1489
1518
 
1490
- export { type ActorID, BackendSession, BrowserSession, ConsoleTarget, type CreateTicketOptions, DefaultMap, type FixOp, type Guid, type IReadableSnapshot, type IServerWebSocket, type IStorageDriver, type IStorageDriverNodeAPI, type IUserData, InMemoryDriver, type LeasedSession, type LoadingState, LogLevel, LogTarget, Logger, type MetadataDB, NestedMap, type NodeMap, type NodeStream, type NodeTuple, type Pos, type PreSerializedServerMsg, ProtocolVersion, ROOT_YDOC_ID, Room, type SessionKey, type Ticket, UniqueMap, type YDocId, type YUpdate, type YVector, ackIgnoredOp, clientMsgDecoder, concatUint8Arrays, guidDecoder, isLeasedSessionExpired, jsonObjectYolo, jsonYolo, makeInMemorySnapshot, makeMetadataDB, plainLsonToNodeStream, protocolVersionDecoder, quote, serialize as serializeServerMsg, snapshotToLossyJson_eager, snapshotToLossyJson_lazy, snapshotToNodeStream, snapshotToPlainLson_eager, snapshotToPlainLson_lazy, Storage as test_only__Storage, YjsStorage as test_only__YjsStorage, transientClientMsgDecoder, tryCatch };
1519
+ export { type ActorID, BackendSession, BrowserSession, ConsoleTarget, type CreateTicketOptions, DefaultMap, type FixOp, type Guid, type IReadableSnapshot, type IServerWebSocket, type IStorageDriver, type IStorageDriverNodeAPI, type IUserData, InMemoryDriver, type LeasedSession, type LoadingState, LogLevel, LogTarget, Logger, type MetadataDB, type Millis, NestedMap, type NodeMap, type NodeStream, type NodeTuple, type Pos, type PreSerializedServerMsg, ProtocolVersion, ROOT_YDOC_ID, Room, type SessionKey, type Ticket, UniqueMap, type YDocId, type YUpdate, type YVector, ackIgnoredOp, clientMsgDecoder, concatUint8Arrays, guidDecoder, isLeasedSessionExpired, jsonObjectYolo, jsonYolo, makeInMemorySnapshot, makeMetadataDB, plainLsonToNodeStream, protocolVersionDecoder, quote, serialize as serializeServerMsg, snapshotToLossyJson_eager, snapshotToLossyJson_lazy, snapshotToNodeStream, snapshotToPlainLson_eager, snapshotToPlainLson_lazy, Storage as test_only__Storage, YjsStorage as test_only__YjsStorage, transientClientMsgDecoder, tryCatch };
package/dist/index.js CHANGED
@@ -650,7 +650,7 @@ import {
650
650
  tryParseJson,
651
651
  WebsocketCloseCodes as CloseCode
652
652
  } from "@liveblocks/core";
653
- import { Mutex } from "async-mutex";
653
+ import { Mutex, tryAcquire } from "async-mutex";
654
654
  import { array as array2, formatInline } from "decoders";
655
655
  import { chunked } from "itertools";
656
656
  import { nanoid as nanoid2 } from "nanoid";
@@ -891,6 +891,9 @@ var InMemoryDriver = class {
891
891
  async delete_leased_session(sessionId) {
892
892
  this._leasedSessions.delete(sessionId);
893
893
  }
894
+ takeRowsWritten() {
895
+ return 0;
896
+ }
894
897
  next_actor() {
895
898
  return ++this._nextActor;
896
899
  }
@@ -1445,7 +1448,7 @@ var YjsStorage = class {
1445
1448
  let encodedTargetVector;
1446
1449
  try {
1447
1450
  encodedTargetVector = stateVector.length > 0 ? Base64.toUint8Array(stateVector) : void 0;
1448
- } catch (e) {
1451
+ } catch {
1449
1452
  logger.warn(
1450
1453
  "Could not get update from passed vector, returning all updates"
1451
1454
  );
@@ -1701,7 +1704,7 @@ function stripOpId(op2) {
1701
1704
  var __socket, __debug, __lastActiveAt, __hasNotifiedClientStorageUpdateError;
1702
1705
  var BrowserSession = class {
1703
1706
  /** @internal - Never create a BrowserSession instance manually. Use the room.startBrowserSession() API instead. */
1704
- constructor(ticket, socket, debug) {
1707
+ constructor(ticket, socket, debug, createdAt) {
1705
1708
  // ^^ User-defined Session Metadata
1706
1709
  // ^^ User-defined Client Metadata (sent to client in ROOM_STATE)
1707
1710
  __publicField(this, "version");
@@ -1719,6 +1722,7 @@ var BrowserSession = class {
1719
1722
  // Metadata sent to client in ROOM_STATE message's "meta" field
1720
1723
  __privateAdd(this, __socket);
1721
1724
  __privateAdd(this, __debug);
1725
+ /** Updated on every incoming message (including pings) via handleData(). Used for idle timeout detection. */
1722
1726
  __privateAdd(this, __lastActiveAt);
1723
1727
  // We keep a status in-memory in the session of whether we already sent a rejected ops message to the client.
1724
1728
  __privateAdd(this, __hasNotifiedClientStorageUpdateError);
@@ -1730,15 +1734,15 @@ var BrowserSession = class {
1730
1734
  this.publicMeta = ticket.publicMeta;
1731
1735
  __privateSet(this, __socket, socket);
1732
1736
  __privateSet(this, __debug, debug);
1733
- const now = /* @__PURE__ */ new Date();
1737
+ const now = createdAt?.getTime() ?? Date.now();
1734
1738
  this.createdAt = now;
1735
1739
  __privateSet(this, __lastActiveAt, now);
1736
1740
  __privateSet(this, __hasNotifiedClientStorageUpdateError, false);
1737
1741
  }
1738
1742
  get lastActiveAt() {
1739
1743
  const lastPing = __privateGet(this, __socket).getLastPongTimestamp?.();
1740
- if (lastPing && lastPing > __privateGet(this, __lastActiveAt)) {
1741
- return lastPing;
1744
+ if (lastPing && lastPing.getTime() > __privateGet(this, __lastActiveAt)) {
1745
+ return lastPing.getTime();
1742
1746
  } else {
1743
1747
  return __privateGet(this, __lastActiveAt);
1744
1748
  }
@@ -1746,10 +1750,9 @@ var BrowserSession = class {
1746
1750
  get hasNotifiedClientStorageUpdateError() {
1747
1751
  return __privateGet(this, __hasNotifiedClientStorageUpdateError);
1748
1752
  }
1749
- markActive(now = /* @__PURE__ */ new Date()) {
1750
- if (now > __privateGet(this, __lastActiveAt)) {
1751
- __privateSet(this, __lastActiveAt, now);
1752
- }
1753
+ /** @internal - This should have to be called only from Room, never externally */
1754
+ markActive(now) {
1755
+ __privateSet(this, __lastActiveAt, now ? now.getTime() : Date.now());
1753
1756
  }
1754
1757
  setHasNotifiedClientStorageUpdateError() {
1755
1758
  __privateSet(this, __hasNotifiedClientStorageUpdateError, true);
@@ -1821,6 +1824,15 @@ var Room = class {
1821
1824
  __publicField(this, "meta");
1822
1825
  __publicField(this, "driver");
1823
1826
  __publicField(this, "logger");
1827
+ /**
1828
+ * While a room is in "maintenance mode", all WebSocket connections to the
1829
+ * room should be rejected until it's pulled out of maintenance mode again.
1830
+ * Maintenance mode should only last a couple of milliseconds, typically.
1831
+ *
1832
+ * This mutex ensures that concurrent destructive operations (like
1833
+ * init-storage, storage-reset, etc.) cannot interfere with one another.
1834
+ */
1835
+ __publicField(this, "_maintenanceMode", new Mutex());
1824
1836
  __publicField(this, "_loadData$", null);
1825
1837
  __publicField(this, "_data", null);
1826
1838
  __publicField(this, "_qsize", 0);
@@ -1881,6 +1893,24 @@ var Room = class {
1881
1893
  }
1882
1894
  // prettier-ignore
1883
1895
  // ------------------------------------------------------------------------------------
1896
+ // Maintenance mode
1897
+ // ------------------------------------------------------------------------------------
1898
+ /**
1899
+ * Returns true if the room is currently in maintenance mode.
1900
+ * When in maintenance mode, callers should refuse new WebSocket connections.
1901
+ */
1902
+ get isInMaintenance() {
1903
+ return this._maintenanceMode.isLocked();
1904
+ }
1905
+ /**
1906
+ * Tries to enter maintenance mode and run the given callback exclusively.
1907
+ * If the room is already in maintenance mode, throws E_ALREADY_LOCKED
1908
+ * immediately instead of queuing the request.
1909
+ */
1910
+ async runInMaintenanceMode(callback) {
1911
+ return tryAcquire(this._maintenanceMode).runExclusive(callback);
1912
+ }
1913
+ // ------------------------------------------------------------------------------------
1884
1914
  // Public API
1885
1915
  // ------------------------------------------------------------------------------------
1886
1916
  /**
@@ -1973,8 +2003,13 @@ var Room = class {
1973
2003
  if (this.sessions.size > 0) {
1974
2004
  throw new Error("This API can only be called before any sessions exist");
1975
2005
  }
1976
- for (const { ticket, socket, lastActivity } of sessions) {
1977
- const newSession = new BrowserSession(ticket, socket, __privateGet(this, __debug2));
2006
+ for (const { ticket, socket, lastActivity, createdAt } of sessions) {
2007
+ const newSession = new BrowserSession(
2008
+ ticket,
2009
+ socket,
2010
+ __privateGet(this, __debug2),
2011
+ createdAt
2012
+ );
1978
2013
  this.sessions.set(ticket.sessionKey, newSession);
1979
2014
  newSession.markActive(lastActivity);
1980
2015
  }
@@ -2119,6 +2154,7 @@ var Room = class {
2119
2154
  );
2120
2155
  }) {
2121
2156
  const text = typeof data === "string" ? data : raise3("Unsupported message format");
2157
+ this.sessions.get(key)?.markActive();
2122
2158
  if (text === "ping") {
2123
2159
  await this.handlePing(key, ctx);
2124
2160
  } else {