@vrtmrz/livesync-commonlib 0.1.19 → 0.1.21

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 (114) hide show
  1. package/README.md +5 -1
  2. package/dist/common/models/setting.p2p.d.ts +2 -0
  3. package/dist/common/models/setting.p2p.js +5 -0
  4. package/dist/interfaces/ServiceModule.d.ts +13 -4
  5. package/dist/managers/ChunkFetcher.js +10 -0
  6. package/dist/p2p/P2PService.d.ts +123 -0
  7. package/dist/p2p/P2PService.js +271 -0
  8. package/dist/p2p/index.d.ts +8 -0
  9. package/dist/p2p/index.js +8 -0
  10. package/dist/pouchdb/LiveSyncLocalDB.d.ts +3 -0
  11. package/dist/pouchdb/LiveSyncLocalDB.js +23 -3
  12. package/dist/pouchdb/ReplicatorShim.d.ts +43 -4
  13. package/dist/pouchdb/ReplicatorShim.js +78 -5
  14. package/dist/replication/CentralCompatibility.d.ts +38 -0
  15. package/dist/replication/CentralCompatibility.js +39 -0
  16. package/dist/replication/CentralRemoteAdministration.d.ts +84 -0
  17. package/dist/replication/CentralRemoteAdministration.js +71 -0
  18. package/dist/replication/LiveSyncAbstractReplicator.d.ts +19 -6
  19. package/dist/replication/ProviderCapability.d.ts +32 -0
  20. package/dist/replication/ProviderCapability.js +27 -0
  21. package/dist/replication/RemoteResource.d.ts +85 -0
  22. package/dist/replication/RemoteResource.js +17 -0
  23. package/dist/replication/ReplicatorInstance.d.ts +23 -0
  24. package/dist/replication/ReplicatorProvider.d.ts +262 -0
  25. package/dist/replication/ReplicatorProvider.js +150 -0
  26. package/dist/replication/couchdb/LiveSyncReplicator.d.ts +54 -8
  27. package/dist/replication/couchdb/LiveSyncReplicator.js +411 -158
  28. package/dist/replication/index.d.ts +10 -0
  29. package/dist/replication/index.js +72 -0
  30. package/dist/replication/journal/JournalSyncCore.d.ts +2 -0
  31. package/dist/replication/journal/JournalSyncCore.js +18 -5
  32. package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +38 -10
  33. package/dist/replication/journal/LiveSyncJournalReplicator.js +271 -63
  34. package/dist/replication/journal/LiveSyncJournalReplicatorEnv.d.ts +8 -0
  35. package/dist/replication/journal/objectstore/JournalStorageAdapter.d.ts +2 -0
  36. package/dist/replication/journal/objectstore/MinioStorageAdapter.d.ts +2 -0
  37. package/dist/replication/journal/objectstore/MinioStorageAdapter.js +10 -1
  38. package/dist/replication/trystero/LiveSyncTrysteroReplicator.d.ts +12 -40
  39. package/dist/replication/trystero/LiveSyncTrysteroReplicator.js +52 -152
  40. package/dist/replication/trystero/P2PAutomationCoordinator.d.ts +20 -0
  41. package/dist/replication/trystero/P2PAutomationCoordinator.js +43 -0
  42. package/dist/replication/trystero/P2PConnectionProbeAdmission.d.ts +32 -0
  43. package/dist/replication/trystero/P2PConnectionProbeAdmission.js +10 -0
  44. package/dist/replication/trystero/P2PReplicationWire.d.ts +31 -0
  45. package/dist/replication/trystero/P2PReplicationWire.js +50 -0
  46. package/dist/replication/trystero/P2PRoomSession.d.ts +32 -0
  47. package/dist/replication/trystero/P2PRoomSession.js +101 -0
  48. package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +61 -0
  49. package/dist/replication/trystero/P2PRoomSessionOwner.js +249 -0
  50. package/dist/replication/trystero/TrysteroReplicator.d.ts +88 -36
  51. package/dist/replication/trystero/TrysteroReplicator.js +273 -113
  52. package/dist/replication/trystero/TrysteroReplicatorP2PClient.d.ts +7 -4
  53. package/dist/replication/trystero/TrysteroReplicatorP2PClient.js +21 -14
  54. package/dist/replication/trystero/TrysteroReplicatorP2PServer.d.ts +26 -3
  55. package/dist/replication/trystero/TrysteroReplicatorP2PServer.js +84 -39
  56. package/dist/replication/trystero/UseP2PReplicatorResult.d.ts +10 -6
  57. package/dist/replication/trystero/addP2PEventHandlers.d.ts +8 -12
  58. package/dist/replication/trystero/addP2PEventHandlers.js +19 -19
  59. package/dist/replication/trystero/p2pReplicatorConfigurationIdentity.d.ts +9 -0
  60. package/dist/replication/trystero/p2pReplicatorConfigurationIdentity.js +30 -0
  61. package/dist/replication/trystero/types.d.ts +18 -0
  62. package/dist/replication/trystero/types.js +5 -1
  63. package/dist/replication/trystero/useP2PReplicatorCommands.d.ts +3 -3
  64. package/dist/replication/trystero/useP2PReplicatorCommands.js +6 -8
  65. package/dist/replication/trystero/useP2PReplicatorFeature.d.ts +16 -7
  66. package/dist/replication/trystero/useP2PReplicatorFeature.js +73 -55
  67. package/dist/rpc/RpcRoom.d.ts +15 -1
  68. package/dist/rpc/RpcRoom.js +176 -41
  69. package/dist/rpc/RpcSession.d.ts +3 -1
  70. package/dist/rpc/RpcSession.js +2 -2
  71. package/dist/rpc/index.d.ts +1 -1
  72. package/dist/rpc/transports/trysteroUtils.js +2 -1
  73. package/dist/rpc/types.d.ts +26 -1
  74. package/dist/serviceFeatures/checkRemoteSize.js +5 -2
  75. package/dist/serviceModules/Rebuilder.js +66 -56
  76. package/dist/services/base/AppLifecycleService.d.ts +4 -0
  77. package/dist/services/base/AppLifecycleService.js +4 -0
  78. package/dist/services/base/ControlService.js +0 -4
  79. package/dist/services/base/DatabaseEventService.js +1 -1
  80. package/dist/services/base/IService.d.ts +105 -8
  81. package/dist/services/base/ReadinessEvaluator.d.ts +40 -0
  82. package/dist/services/base/ReadinessEvaluator.js +16 -0
  83. package/dist/services/base/RemoteConnection.d.ts +3 -0
  84. package/dist/services/base/RemoteService.js +2 -2
  85. package/dist/services/base/ReplicationService.d.ts +45 -10
  86. package/dist/services/base/ReplicationService.js +250 -91
  87. package/dist/services/base/ReplicationService.readiness.d.ts +52 -0
  88. package/dist/services/base/ReplicationService.readiness.js +87 -0
  89. package/dist/services/base/ReplicationService.typedReplication.d.ts +93 -0
  90. package/dist/services/base/ReplicationService.typedReplication.js +214 -0
  91. package/dist/services/base/ReplicatorService.activeReplicatorState.d.ts +47 -0
  92. package/dist/services/base/ReplicatorService.activeReplicatorState.js +107 -0
  93. package/dist/services/base/ReplicatorService.centralRemoteAdministration.d.ts +5 -0
  94. package/dist/services/base/ReplicatorService.centralRemoteAdministration.js +44 -0
  95. package/dist/services/base/ReplicatorService.d.ts +75 -14
  96. package/dist/services/base/ReplicatorService.js +232 -53
  97. package/dist/services/base/ReplicatorService.remoteResourceResolver.d.ts +4 -0
  98. package/dist/services/base/ReplicatorService.remoteResourceResolver.js +11 -0
  99. package/dist/services/base/TweakValueService.d.ts +3 -2
  100. package/dist/services/implements/injectable/InjectableTweakValueService.d.ts +1 -1
  101. package/dist/services/lib/HandlerUtils.d.ts +24 -0
  102. package/dist/services/lib/HandlerUtils.js +40 -0
  103. package/docs/development.md +40 -7
  104. package/docs/p2p-transport-lifecycle.md +135 -11
  105. package/docs/proven-in-use.md +5 -1
  106. package/docs/remote-configurations.md +4 -0
  107. package/docs/service-feature-composition.md +250 -0
  108. package/package.json +17 -1
  109. package/dist/replication/trystero/P2PReplicatorBase.d.ts +0 -19
  110. package/dist/replication/trystero/P2PReplicatorCore.d.ts +0 -8
  111. package/dist/replication/trystero/P2PReplicatorCore.js +0 -56
  112. package/dist/replication/trystero/TrysteroReplicatorP2PConnection.d.ts +0 -2
  113. package/dist/replication/trystero/TrysteroReplicatorP2PConnection.js +0 -4
  114. /package/dist/replication/{trystero/P2PReplicatorBase.js → ReplicatorInstance.js} +0 -0
@@ -14,6 +14,9 @@ var DeviceDecisions = /* @__PURE__ */ ((DeviceDecisions2) => {
14
14
  return DeviceDecisions2;
15
15
  })(DeviceDecisions || {});
16
16
  const ID_P2PKnownDevices = "_local/P2PKnownDevices";
17
+ function resolveCurrentP2PSettings(env) {
18
+ return env.currentSettings?.() ?? env.settings;
19
+ }
17
20
  const KEY_DEVICE_DECISIONS = "p2p-device-decisions";
18
21
  export {
19
22
  BULK_GET_RPC_TIMEOUT,
@@ -23,5 +26,6 @@ export {
23
26
  DeviceDecisions,
24
27
  ID_P2PKnownDevices,
25
28
  KEY_DEVICE_DECISIONS,
26
- ResponsePreventedError
29
+ ResponsePreventedError,
30
+ resolveCurrentP2PSettings
27
31
  };
@@ -1,7 +1,7 @@
1
1
  import type { NecessaryServices } from "../../interfaces/ServiceModule.js";
2
- import type { UseP2PReplicatorResult } from "./UseP2PReplicatorResult.js";
2
+ import type { P2PServiceViews } from "../../p2p/P2PService.js";
3
3
  /**
4
- * ServiceFeature: Registers event handlers for P2P replication and manages the lifecycle of a LiveSyncTrysteroReplicator instance.
4
+ * Register manual transport commands against the focused lifecycle view.
5
5
  * @param host
6
6
  */
7
- export declare function useP2PReplicatorCommands(host: NecessaryServices<"API" | "setting", never>, result: UseP2PReplicatorResult): void;
7
+ export declare function useP2PReplicatorCommands(host: NecessaryServices<"API" | "setting", never>, result: Pick<P2PServiceViews, "transportLifecycle">): void;
@@ -4,21 +4,19 @@ function useP2PReplicatorCommands(host, result) {
4
4
  id: "p2p-establish-connection",
5
5
  name: "P2P Sync : Connect to the Signalling Server",
6
6
  checkCallback: (isChecking) => {
7
- const replicator = result.replicator;
8
- if (!replicator) return false;
9
- if (isChecking) return !(replicator.server?.isServing ?? false);
10
- void replicator.open();
7
+ const lifecycle = result.transportLifecycle;
8
+ if (isChecking) return !lifecycle.isConnected;
9
+ void lifecycle.connect();
11
10
  }
12
11
  });
13
12
  host.services.API.addCommand({
14
13
  id: "p2p-close-connection",
15
14
  name: "P2P Sync : Disconnect from the Signalling Server",
16
15
  checkCallback: (isChecking) => {
17
- const replicator = result.replicator;
18
- if (!replicator) return false;
19
- if (isChecking) return replicator.server?.isServing ?? false;
16
+ const lifecycle = result.transportLifecycle;
17
+ if (isChecking) return lifecycle.isConnected;
20
18
  Logger("Closing P2P Connection", LOG_LEVEL_NOTICE);
21
- void replicator.close();
19
+ void lifecycle.disconnect();
22
20
  }
23
21
  });
24
22
  }
@@ -1,17 +1,26 @@
1
1
  import type { NecessaryServices } from "../../interfaces/ServiceModule.js";
2
- import { LiveSyncTrysteroReplicator } from "./LiveSyncTrysteroReplicator.js";
2
+ import type { LiveSyncTrysteroReplicator } from "./LiveSyncTrysteroReplicator.js";
3
3
  import { type UseP2PReplicatorResult } from "./UseP2PReplicatorResult.js";
4
+ import { type P2PServiceViews } from "../../p2p/P2PService.js";
4
5
  /**
5
- * Factory type: given a replicator instance, returns the openReplicationUI callback for that instance.
6
- * Injected by the host platform (e.g. Obsidian). CLI/headless environments omit this.
6
+ * Factory type: given the compatibility Replicator and the stable service
7
+ * views, returns the openReplicationUI callback for that instance.
8
+ *
9
+ * The Replicator remains available for rebuild-specific compatibility work;
10
+ * modal lifecycle, status, and ordinary actions should use the narrow views.
11
+ * Injected by the host platform (for example, Obsidian). CLI and headless
12
+ * environments omit this.
7
13
  */
8
- export type OpenReplicationUIFactory = (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void>;
14
+ export type OpenReplicationUIFactory = (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) => (showResult: boolean) => Promise<boolean | void>;
9
15
  /** Same shape as OpenReplicationUIFactory, used for the rebuild/replicateAllFromServer flow. */
10
16
  export type OpenRebuildUIFactory = OpenReplicationUIFactory;
11
17
  /**
12
- * ServiceFeature: P2P Replicator integration and lifecycle management.
13
- * Registers a LiveSyncTrysteroReplicator instance as the active replicator when P2P is enabled in settings,
14
- * and binds it to lifecycle events for proper initialization and cleanup.
18
+ * Compose one private P2P service context and register non-owning active
19
+ * provider adapters over it.
20
+ *
21
+ * The returned views remain valid for the host composition lifetime. The
22
+ * compatibility facade is retained only for consumers which have not yet
23
+ * migrated to those views.
15
24
  * @param host
16
25
  */
17
26
  export declare function useP2PReplicatorFeature(host: NecessaryServices<"API" | "appLifecycle" | "setting" | "vault" | "database" | "databaseEvents" | "keyValueDB" | "replication" | "config" | "UI" | "replicator" | "remote", never>, openReplicationUIFactory?: OpenReplicationUIFactory, openRebuildUIFactory?: OpenRebuildUIFactory): UseP2PReplicatorResult;
@@ -1,76 +1,94 @@
1
- import { Logger, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
2
1
  import { AutoAccepting, REMOTE_P2P } from "../../common/types.js";
3
- import { LiveSyncTrysteroReplicator } from "./LiveSyncTrysteroReplicator.js";
4
2
  import {} from "./UseP2PReplicatorResult.js";
5
3
  import { addP2PEventHandlers } from "./addP2PEventHandlers.js";
6
- import { compatGlobal } from "../../common/coreEnvFunctions.js";
4
+ import { createP2PService } from "../../p2p/P2PService.js";
5
+ import {
6
+ CAPABILITY_NOT_APPLICABLE,
7
+ NO_REMOTE_RESOURCE_CAPABILITIES,
8
+ PEER_REPLICATION_READINESS,
9
+ REPLICATION_PROGRESS_PRESENTATIONS,
10
+ defineReplicatorProviderDefinitions,
11
+ outcomeFromFiniteOpenReplication,
12
+ replicationBlocked,
13
+ replicationFailed,
14
+ supportedCapability,
15
+ supportedStopActiveTransfer
16
+ } from "../index.js";
17
+ import { getP2PReplicatorConfigurationIdentity } from "./p2pReplicatorConfigurationIdentity.js";
7
18
  function useP2PReplicatorFeature(host, openReplicationUIFactory, openRebuildUIFactory) {
8
- let replicator = new LiveSyncTrysteroReplicator({
19
+ const service = createP2PService({
9
20
  services: host.services
10
21
  });
11
- let replacementPromise;
12
- if (openReplicationUIFactory) {
13
- replicator.env.openReplicationUI = openReplicationUIFactory(replicator);
14
- }
15
- if (openRebuildUIFactory) {
16
- replicator.env.openRebuildUI = openRebuildUIFactory(replicator);
17
- }
18
- const activeReplicator = {
19
- get replicator() {
20
- return replicator;
22
+ const replicator = service.compatibilityReplicator;
23
+ const { views, lifecycle } = service;
24
+ const configureReplicator = (instance) => {
25
+ if (openReplicationUIFactory) {
26
+ instance.env.openReplicationUI = openReplicationUIFactory(instance, views);
27
+ }
28
+ if (openRebuildUIFactory) {
29
+ instance.env.openRebuildUI = openRebuildUIFactory(instance, views);
21
30
  }
22
31
  };
23
- addP2PEventHandlers(() => activeReplicator.replicator, host.services.context.events);
24
- host.services.replicator.getNewReplicator.addHandler(
25
- async (settingOverride = {}) => {
26
- const settings = { ...host.services.setting.currentSettings(), ...settingOverride };
27
- if (settings.remoteType == REMOTE_P2P) {
28
- if (replacementPromise) return await replacementPromise;
29
- const operation = (async () => {
30
- const existingReplicator = replicator;
31
- try {
32
- await existingReplicator?.close();
33
- } catch (e) {
34
- Logger(`Error closing existing p2p replicator`);
35
- Logger(e, LOG_LEVEL_VERBOSE);
36
- }
37
- const newReplicator = new LiveSyncTrysteroReplicator({ services: host.services });
38
- if (openReplicationUIFactory) {
39
- newReplicator.env.openReplicationUI = openReplicationUIFactory(newReplicator);
40
- }
41
- if (openRebuildUIFactory) {
42
- newReplicator.env.openRebuildUI = openRebuildUIFactory(newReplicator);
43
- }
44
- replicator = newReplicator;
45
- return replicator;
46
- })();
47
- replacementPromise = operation;
48
- try {
49
- return await operation;
50
- } finally {
51
- if (replacementPromise === operation) replacementPromise = void 0;
52
- }
53
- }
54
- return void 0;
32
+ configureReplicator(replicator);
33
+ const createP2PReplicator = async () => service.createActiveReplicator();
34
+ const userInitiatedOneShot = async (instance, setting, request) => {
35
+ if (request.interaction.kind !== "permitted" || !request.interaction.permissions.peerSelection) {
36
+ return replicationBlocked("interaction-required");
37
+ }
38
+ try {
39
+ const result = await instance.openReplication(
40
+ setting,
41
+ false,
42
+ request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
43
+ false
44
+ );
45
+ return outcomeFromFiniteOpenReplication(result);
46
+ } catch (error) {
47
+ return replicationFailed(error);
48
+ }
49
+ };
50
+ const unattendedOneShot = async (_instance, _setting, request) => {
51
+ if (request.interaction.kind !== "forbidden") {
52
+ return replicationBlocked("interaction-required");
55
53
  }
54
+ return await views.targetedTransfer.synchroniseConfiguredTargets();
55
+ };
56
+ host.services.replicator.registerReplicatorProviderDefinitions(
57
+ defineReplicatorProviderDefinitions([REMOTE_P2P], {
58
+ [REMOTE_P2P]: {
59
+ kind: REMOTE_P2P,
60
+ diagnosticName: "P2P",
61
+ readiness: PEER_REPLICATION_READINESS,
62
+ isConfigured: (settings) => settings.remoteType === REMOTE_P2P && settings.P2P_Enabled,
63
+ configurationIdentity: getP2PReplicatorConfigurationIdentity,
64
+ create: createP2PReplicator,
65
+ remoteResources: NO_REMOTE_RESOURCE_CAPABILITIES,
66
+ userInitiatedOneShot: supportedCapability(userInitiatedOneShot),
67
+ unattendedOneShot: supportedCapability(unattendedOneShot),
68
+ continuous: CAPABILITY_NOT_APPLICABLE,
69
+ stopActiveTransfer: supportedStopActiveTransfer()
70
+ }
71
+ })
56
72
  );
73
+ const activeReplicator = { replicator, ...views };
74
+ addP2PEventHandlers(lifecycle, host.services.context.events);
57
75
  host.services.appLifecycle.onUnload.addHandler(async () => {
58
- await replicator?.close();
76
+ await lifecycle.closeForLifecycle();
59
77
  return true;
60
78
  });
61
79
  host.services.appLifecycle.onSuspending.addHandler(async () => {
62
- await replicator?.close();
80
+ await lifecycle.closeForLifecycle();
63
81
  return true;
64
82
  });
65
- host.services.databaseEvents.onDatabaseInitialisation.addHandler(async () => {
66
- await replicator?.close();
83
+ const closeForDatabaseLifecycle = async () => {
84
+ await lifecycle.closeForLifecycle();
67
85
  return true;
68
- });
86
+ };
87
+ host.services.databaseEvents.onResetDatabase.addHandler(closeForDatabaseLifecycle);
88
+ host.services.databaseEvents.onCloseDatabase.addHandler(closeForDatabaseLifecycle);
89
+ host.services.databaseEvents.onDatabaseInitialisation.addHandler(closeForDatabaseLifecycle);
69
90
  host.services.appLifecycle.onResumed.addHandler(() => {
70
- const settings = host.services.setting.currentSettings();
71
- if (settings.P2P_Enabled && settings.P2P_AutoStart) {
72
- compatGlobal.setTimeout(() => void replicator?.open(), 100);
73
- }
91
+ lifecycle.scheduleAutoStart();
74
92
  return Promise.resolve(true);
75
93
  });
76
94
  host.services.setting.suspendExtraSync.addHandler(() => {
@@ -1,5 +1,5 @@
1
1
  import { RpcSession } from "./RpcSession.js";
2
- import { type JsonLike, type RpcMethodHandler, type RpcRegisterOptions, type RpcRoomOptions } from "./types.js";
2
+ import { type JsonLike, type RpcCallOptions, type RpcCancellationAwareMethodHandler, type RpcMethodHandler, type RpcRegisterOptions, type RpcRoomOptions } from "./types.js";
3
3
  export declare class RpcRoom {
4
4
  private options;
5
5
  private pending;
@@ -11,12 +11,26 @@ export declare class RpcRoom {
11
11
  private incomingChunkTimers;
12
12
  private peerVersion;
13
13
  private disposers;
14
+ private closed;
14
15
  constructor(options: RpcRoomOptions);
15
16
  close(): void;
16
17
  session(peerId: string): RpcSession;
17
18
  register<T extends JsonLike[], U>(method: string, handler: RpcMethodHandler<T, U>, options?: RpcRegisterOptions): void;
19
+ /**
20
+ * Register a handler which receives an AbortSignal for cooperative
21
+ * cancellation. The original `register` API deliberately remains
22
+ * unchanged for handlers which do not need request context.
23
+ */
24
+ registerCancellable<T extends JsonLike[], U>(method: string, handler: RpcCancellationAwareMethodHandler<T, U>, options?: RpcRegisterOptions): void;
25
+ private registerMethod;
18
26
  invoke(peerId: string, method: string, args: JsonLike[], timeoutMs?: number): Promise<JsonLike>;
27
+ invoke(peerId: string, method: string, args: JsonLike[], options?: RpcCallOptions): Promise<JsonLike>;
28
+ invoke(peerId: string, method: string, args: JsonLike[], timeoutOrOptions?: number | RpcCallOptions): Promise<JsonLike>;
19
29
  cancel(peerId: string, requestId: string): Promise<void>;
30
+ private removePending;
31
+ private rejectPending;
32
+ private notifyRemoteCancellation;
33
+ private abortInboundCall;
20
34
  private sendEnvelope;
21
35
  private scheduleMissingAck;
22
36
  private onWireMessage;
@@ -24,6 +24,7 @@ class RpcRoom {
24
24
  incomingChunkTimers = /* @__PURE__ */ new Map();
25
25
  peerVersion = /* @__PURE__ */ new Map();
26
26
  disposers = [];
27
+ closed = false;
27
28
  constructor(options) {
28
29
  this.options = {
29
30
  maxWirePayloadBytes: options.maxWirePayloadBytes ?? 32 * 1024,
@@ -49,6 +50,18 @@ class RpcRoom {
49
50
  if (this.options.transport.onPeerLeave) {
50
51
  this.disposers.push(
51
52
  this.options.transport.onPeerLeave((peerId) => {
53
+ this.pending.forEach((pending, requestId) => {
54
+ if (pending.peerId !== peerId) return;
55
+ this.rejectPending(requestId, new RpcError("NOT_CONNECTED", "Peer disconnected"));
56
+ });
57
+ this.inboundCalls.forEach((context, requestId) => {
58
+ if (context.peerId !== peerId) return;
59
+ this.abortInboundCall(requestId, context);
60
+ this.inboundCalls.delete(requestId);
61
+ });
62
+ this.outgoingChunkMap.forEach((state, streamId) => {
63
+ if (state.peerId === peerId) this.outgoingChunkMap.delete(streamId);
64
+ });
52
65
  this.sessions.delete(peerId);
53
66
  this.peerVersion.delete(peerId);
54
67
  })
@@ -56,9 +69,15 @@ class RpcRoom {
56
69
  }
57
70
  }
58
71
  close() {
72
+ if (this.closed) return;
73
+ this.closed = true;
59
74
  this.disposers.splice(0).forEach((dispose) => dispose());
60
- this.pending.forEach((pending) => pending.reject(new RpcError("NOT_CONNECTED", "Room closed")));
61
- this.pending.clear();
75
+ this.pending.forEach((_pending, requestId) => {
76
+ this.rejectPending(requestId, new RpcError("NOT_CONNECTED", "Room closed"), true);
77
+ });
78
+ this.inboundCalls.forEach((context, requestId) => {
79
+ this.abortInboundCall(requestId, context);
80
+ });
62
81
  this.inboundCalls.clear();
63
82
  this.methods.clear();
64
83
  this.sessions.clear();
@@ -75,44 +94,131 @@ class RpcRoom {
75
94
  return s;
76
95
  }
77
96
  register(method, handler, options = {}) {
97
+ this.registerMethod(method, handler, false, options);
98
+ }
99
+ registerCancellable(method, handler, options = {}) {
100
+ this.registerMethod(method, handler, true, options);
101
+ }
102
+ registerMethod(method, handler, cancellationAware, options) {
78
103
  if (!validNamespacedMethod(method)) {
79
104
  throw new RpcError("PROTOCOL_ERROR", `Method must be namespaced: ${method}`);
80
105
  }
81
106
  this.methods.set(method, {
82
107
  handler,
108
+ cancellationAware,
83
109
  serial: options.serial ?? false,
84
110
  queue: Promise.resolve()
85
111
  });
86
112
  }
87
- async invoke(peerId, method, args, timeoutMs = 3e4) {
113
+ async invoke(peerId, method, args, timeoutOrOptions = 3e4) {
88
114
  if (!validNamespacedMethod(method)) {
89
115
  throw new RpcError("PROTOCOL_ERROR", `Method must be namespaced: ${method}`);
90
116
  }
117
+ if (this.closed) {
118
+ throw new RpcError("NOT_CONNECTED", "Room is closed");
119
+ }
120
+ const options = typeof timeoutOrOptions === "number" ? { timeoutMs: timeoutOrOptions } : timeoutOrOptions ?? {};
121
+ const timeoutMs = options.timeoutMs ?? 3e4;
122
+ if (options.signal?.aborted) {
123
+ throw new RpcError("CANCELLED", `RPC cancelled: ${method}`);
124
+ }
91
125
  const requestId = newId("req");
126
+ let pending;
92
127
  const p = new Promise((resolve, reject) => {
93
- const pending = { resolve, reject };
128
+ pending = {
129
+ peerId,
130
+ method,
131
+ resolve,
132
+ reject,
133
+ signal: options.signal,
134
+ requestSent: false,
135
+ cancelRequested: false
136
+ };
94
137
  if (timeoutMs > 0) {
95
138
  pending.timeoutHandle = compatGlobal.setTimeout(() => {
96
- this.pending.delete(requestId);
97
- reject(new RpcError("TIMEOUT", `RPC timed out: ${method}`));
139
+ this.rejectPending(requestId, new RpcError("TIMEOUT", `RPC timed out: ${method}`), true);
98
140
  }, timeoutMs);
99
141
  }
100
142
  this.pending.set(requestId, pending);
143
+ if (options.signal) {
144
+ const onAbort = () => {
145
+ const current = this.pending.get(requestId);
146
+ if (!current) return;
147
+ this.rejectPending(requestId, new RpcError("CANCELLED", `RPC cancelled: ${method}`), true);
148
+ };
149
+ pending.abortListener = onAbort;
150
+ options.signal.addEventListener("abort", onAbort, { once: true });
151
+ if (options.signal.aborted) onAbort();
152
+ }
101
153
  });
102
- await this.sendEnvelope(peerId, {
154
+ const sendTask = this.sendEnvelope(peerId, {
103
155
  kind: "request",
104
156
  requestId,
105
157
  method,
106
158
  args
107
159
  });
160
+ void sendTask.then(
161
+ () => {
162
+ const current = this.pending.get(requestId);
163
+ pending.requestSent = true;
164
+ if (!current && pending.cancelRequested) {
165
+ this.notifyRemoteCancellation(peerId, requestId);
166
+ }
167
+ },
168
+ (ex) => {
169
+ const current = this.pending.get(requestId);
170
+ if (!current) return;
171
+ const failed = this.removePending(requestId);
172
+ if (failed) failed.reject(ex);
173
+ }
174
+ );
108
175
  return await p;
109
176
  }
110
177
  async cancel(peerId, requestId) {
178
+ const pending = this.pending.get(requestId);
179
+ if (pending?.peerId === peerId) {
180
+ const requestSent = pending.requestSent;
181
+ pending.cancelRequested = true;
182
+ this.rejectPending(requestId, new RpcError("CANCELLED", "Invocation cancelled"));
183
+ if (!requestSent) return;
184
+ }
111
185
  await this.sendEnvelope(peerId, {
112
186
  kind: "cancel",
113
187
  requestId
114
188
  });
115
189
  }
190
+ removePending(requestId) {
191
+ const pending = this.pending.get(requestId);
192
+ if (!pending) return void 0;
193
+ this.pending.delete(requestId);
194
+ if (pending.timeoutHandle !== void 0) {
195
+ compatGlobal.clearTimeout(pending.timeoutHandle);
196
+ }
197
+ if (pending.signal && pending.abortListener) {
198
+ pending.signal.removeEventListener("abort", pending.abortListener);
199
+ }
200
+ return pending;
201
+ }
202
+ rejectPending(requestId, error, notifyRemote = false) {
203
+ const pending = this.pending.get(requestId);
204
+ if (!pending) return;
205
+ if (notifyRemote) pending.cancelRequested = true;
206
+ const removed = this.removePending(requestId);
207
+ if (!removed) return;
208
+ removed.reject(error);
209
+ if (notifyRemote && removed.requestSent) this.notifyRemoteCancellation(removed.peerId, requestId);
210
+ }
211
+ notifyRemoteCancellation(peerId, requestId) {
212
+ void this.sendEnvelope(peerId, { kind: "cancel", requestId }).catch((error) => {
213
+ this.options.onProtocolWarning?.(`Failed to send RPC cancellation: ${String(error)}`, peerId);
214
+ });
215
+ }
216
+ abortInboundCall(requestId, context) {
217
+ if (this.inboundCalls.get(requestId) !== context) return;
218
+ if (!context.signal.aborted) {
219
+ context.controller.abort(new RpcError("CANCELLED", "Invocation cancelled"));
220
+ }
221
+ }
116
222
  async sendEnvelope(peerId, envelope) {
117
223
  const serialized = JSON.stringify(envelope);
118
224
  if (estimateBytes(serialized) <= this.options.maxWirePayloadBytes) {
@@ -214,36 +320,61 @@ class RpcRoom {
214
320
  }
215
321
  if (envelope.kind === "cancel") {
216
322
  const ctx2 = this.inboundCalls.get(envelope.requestId);
217
- if (ctx2) ctx2.cancelled = true;
323
+ if (ctx2 && ctx2.peerId === peerId) this.abortInboundCall(envelope.requestId, ctx2);
218
324
  return;
219
325
  }
220
326
  if (envelope.kind === "response") {
221
327
  const pending = this.pending.get(envelope.requestId);
222
328
  if (!pending) return;
223
- this.pending.delete(envelope.requestId);
224
- if (pending.timeoutHandle) compatGlobal.clearTimeout(pending.timeoutHandle);
329
+ const settled = this.removePending(envelope.requestId);
330
+ if (!settled) return;
225
331
  if (envelope.ok === true) {
226
- pending.resolve(envelope.data);
332
+ settled.resolve(envelope.data);
227
333
  } else {
228
- pending.reject(new RpcError(envelope.error.code, envelope.error.message, envelope.error.details));
334
+ settled.reject(new RpcError(envelope.error.code, envelope.error.message, envelope.error.details));
335
+ }
336
+ return;
337
+ }
338
+ const controller = new AbortController();
339
+ const ctx = { controller, signal: controller.signal, peerId };
340
+ this.inboundCalls.set(envelope.requestId, ctx);
341
+ let accepted;
342
+ try {
343
+ accepted = await this.options.canAcceptRequest?.(peerId, envelope.method, ctx);
344
+ } catch (ex) {
345
+ this.inboundCalls.delete(envelope.requestId);
346
+ if (!ctx.signal.aborted && !this.closed) {
347
+ await this.sendEnvelope(peerId, {
348
+ kind: "response",
349
+ requestId: envelope.requestId,
350
+ ok: false,
351
+ error: asRpcErrorShape(ex)
352
+ });
229
353
  }
230
354
  return;
231
355
  }
232
- const accepted = await this.options.canAcceptRequest?.(peerId, envelope.method);
233
356
  if (accepted === false) {
357
+ this.inboundCalls.delete(envelope.requestId);
358
+ return;
359
+ }
360
+ if (ctx.signal.aborted || this.closed) {
361
+ this.inboundCalls.delete(envelope.requestId);
234
362
  return;
235
363
  }
236
364
  const version = this.peerVersion.get(peerId);
237
365
  if (version && version.major !== RPC_VERSION_MAJOR) {
238
- await this.sendEnvelope(peerId, {
239
- kind: "response",
240
- requestId: envelope.requestId,
241
- ok: false,
242
- error: {
243
- code: "REMOTE_ERROR",
244
- message: `RPC major mismatch: local=${RPC_VERSION_MAJOR}, remote=${version.major}`
245
- }
246
- });
366
+ if (!ctx.signal.aborted && !this.closed) {
367
+ await this.sendEnvelope(peerId, {
368
+ kind: "response",
369
+ requestId: envelope.requestId,
370
+ ok: false,
371
+ error: {
372
+ code: "REMOTE_ERROR",
373
+ message: `RPC major mismatch: local=${RPC_VERSION_MAJOR}, remote=${version.major}`
374
+ }
375
+ });
376
+ }
377
+ this.inboundCalls.delete(envelope.requestId);
247
378
  return;
248
379
  }
249
380
  if (version && version.minor !== RPC_VERSION_MINOR) {
@@ -254,28 +385,29 @@ class RpcRoom {
254
385
  }
255
386
  const method = this.methods.get(envelope.method);
256
387
  if (!method) {
257
- await this.sendEnvelope(peerId, {
258
- kind: "response",
259
- requestId: envelope.requestId,
260
- ok: false,
261
- error: {
262
- code: "REMOTE_ERROR",
263
- message: `Method not found: ${envelope.method}`
264
- }
265
- });
388
+ if (!ctx.signal.aborted && !this.closed) {
389
+ await this.sendEnvelope(peerId, {
390
+ kind: "response",
391
+ requestId: envelope.requestId,
392
+ ok: false,
393
+ error: {
394
+ code: "REMOTE_ERROR",
395
+ message: `Method not found: ${envelope.method}`
396
+ }
397
+ });
398
+ }
399
+ this.inboundCalls.delete(envelope.requestId);
266
400
  return;
267
401
  }
268
- const ctx = { cancelled: false };
269
- this.inboundCalls.set(envelope.requestId, ctx);
270
402
  const runner = async () => {
271
403
  try {
272
- if (ctx.cancelled) {
273
- throw new RpcError("CANCELLED", "Invocation cancelled");
274
- }
275
- const data = await method.handler(peerId, ...envelope.args);
276
- if (ctx.cancelled) {
277
- throw new RpcError("CANCELLED", "Invocation cancelled");
278
- }
404
+ if (ctx.signal.aborted || this.closed) return;
405
+ const data = method.cancellationAware ? await method.handler(
406
+ ctx,
407
+ peerId,
408
+ ...envelope.args
409
+ ) : await method.handler(peerId, ...envelope.args);
410
+ if (ctx.signal.aborted || this.closed) return;
279
411
  await this.sendEnvelope(peerId, {
280
412
  kind: "response",
281
413
  requestId: envelope.requestId,
@@ -283,6 +415,7 @@ class RpcRoom {
283
415
  data
284
416
  });
285
417
  } catch (ex) {
418
+ if (ctx.signal.aborted || this.closed) return;
286
419
  await this.sendEnvelope(peerId, {
287
420
  kind: "response",
288
421
  requestId: envelope.requestId,
@@ -290,7 +423,9 @@ class RpcRoom {
290
423
  error: asRpcErrorShape(ex)
291
424
  });
292
425
  } finally {
293
- this.inboundCalls.delete(envelope.requestId);
426
+ if (this.inboundCalls.get(envelope.requestId) === ctx) {
427
+ this.inboundCalls.delete(envelope.requestId);
428
+ }
294
429
  }
295
430
  };
296
431
  if (method.serial) {
@@ -1,9 +1,11 @@
1
- import type { JsonLike } from "./types.js";
1
+ import type { JsonLike, RpcCallOptions } from "./types.js";
2
2
  import type { RpcRoom } from "./RpcRoom.js";
3
3
  export declare class RpcSession {
4
4
  readonly peerId: string;
5
5
  private room;
6
6
  constructor(room: RpcRoom, peerId: string);
7
7
  call<T = JsonLike>(method: string, args?: JsonLike[], timeoutMs?: number): Promise<T>;
8
+ call<T = JsonLike>(method: string, args?: JsonLike[], options?: RpcCallOptions): Promise<T>;
9
+ call<T = JsonLike>(method: string, args: JsonLike[] | undefined, timeoutOrOptions?: number | RpcCallOptions): Promise<T>;
8
10
  createProxy<T extends object>(namespace: string): T;
9
11
  }
@@ -6,11 +6,11 @@ class RpcSession {
6
6
  this.room = room;
7
7
  this.peerId = peerId;
8
8
  }
9
- async call(method, args = [], timeoutMs) {
9
+ async call(method, args = [], timeoutOrOptions) {
10
10
  if (!this.peerId) {
11
11
  throw new RpcError("NOT_CONNECTED", "Peer is not connected");
12
12
  }
13
- return await this.room.invoke(this.peerId, method, args, timeoutMs);
13
+ return await this.room.invoke(this.peerId, method, args, timeoutOrOptions);
14
14
  }
15
15
  createProxy(namespace) {
16
16
  const session = this;
@@ -3,4 +3,4 @@ export { RpcSession } from "./RpcSession.js";
3
3
  export { RpcError } from "./errors.js";
4
4
  export { exposeDB } from "./pouchdb/RpcPouchDBServer.js";
5
5
  export { RpcPouchDBProxy } from "./pouchdb/RpcPouchDBProxy.js";
6
- export type { JsonLike, RpcEnvelope, RpcErrorCode, RpcErrorShape, RpcMethodHandler, RpcRegisterOptions, RpcRoomOptions, RpcWireMessage, TransportAdapter, } from "./types.js";
6
+ export type { JsonLike, RpcEnvelope, RpcErrorCode, RpcErrorShape, RpcCallOptions, RpcCancellationAwareMethodHandler, RpcMethodHandler, RpcRegisterOptions, RpcRequestContext, RpcRoomOptions, RpcWireMessage, TransportAdapter, } from "./types.js";
@@ -2,6 +2,7 @@ import { P2PConnectionPaths } from "../../common/models/setting.const.js";
2
2
  import {
3
3
  hasValidP2PTurnServerUrl,
4
4
  normaliseP2PConnectionPath,
5
+ splitP2PRelayUrls,
5
6
  splitP2PTurnServerUrls
6
7
  } from "../../common/models/setting.p2p.js";
7
8
  import { mixedHash } from "octagonal-wheels/hash/purejs";
@@ -10,7 +11,7 @@ import { createDiagRTCPeerConnectionConstructor } from "./DiagRTCPeerConnections
10
11
  function generateJoinRoomOptions(settings) {
11
12
  const passphraseNumbers = mixedHash(settings.P2P_passphrase, 0);
12
13
  const passphrase = passphraseNumbers[0].toString(36) + passphraseNumbers[1].toString(36);
13
- const relays = settings.P2P_relays.split(",").map((e) => e.trim()).filter((e) => e.length > 0);
14
+ const relays = splitP2PRelayUrls(settings.P2P_relays);
14
15
  const turnServers = splitP2PTurnServerUrls(settings.P2P_turnServers);
15
16
  const relayConfig = {
16
17
  manualReconnection: true,