@vrtmrz/livesync-commonlib 0.1.23 → 0.1.25

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 (53) hide show
  1. package/dist/API/processSetting.d.ts +1 -1
  2. package/dist/API/processSetting.js +10 -6
  3. package/dist/common/ConnectionString.js +15 -8
  4. package/dist/common/models/setting.const.qr.js +5 -0
  5. package/dist/common/models/setting.lifecycle.js +5 -3
  6. package/dist/common/models/setting.p2p.d.ts +10 -0
  7. package/dist/common/models/setting.p2p.js +13 -0
  8. package/dist/common/models/setting.type.d.ts +10 -0
  9. package/dist/common/models/tweak.compatibility.d.ts +34 -0
  10. package/dist/common/models/tweak.compatibility.js +110 -0
  11. package/dist/common/models/tweak.definition.d.ts +110 -19
  12. package/dist/common/models/tweak.definition.js +62 -29
  13. package/dist/common/settingConstants.d.ts +5 -0
  14. package/dist/common/types.d.ts +6 -3
  15. package/dist/common/types.js +6 -0
  16. package/dist/common/utils.js +4 -1
  17. package/dist/p2p/P2PService.d.ts +7 -1
  18. package/dist/p2p/P2PService.js +13 -7
  19. package/dist/p2p/index.d.ts +3 -1
  20. package/dist/p2p/index.js +6 -0
  21. package/dist/pouchdb/LiveSyncDBFunctions.d.ts +3 -3
  22. package/dist/pouchdb/LiveSyncDBFunctions.js +9 -15
  23. package/dist/replication/CentralCompatibility.d.ts +4 -2
  24. package/dist/replication/CentralCompatibility.js +5 -3
  25. package/dist/replication/couchdb/LiveSyncReplicator.js +10 -2
  26. package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +2 -2
  27. package/dist/replication/journal/LiveSyncJournalReplicator.js +18 -4
  28. package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +22 -1
  29. package/dist/replication/trystero/P2PRoomSessionOwner.js +193 -13
  30. package/dist/replication/trystero/TrysteroReplicator.js +12 -7
  31. package/dist/replication/trystero/TrysteroReplicatorP2PServer.d.ts +1 -0
  32. package/dist/replication/trystero/TrysteroReplicatorP2PServer.js +10 -3
  33. package/dist/replication/trystero/p2pReplicatorConfigurationIdentity.js +6 -3
  34. package/dist/replication/trystero/useP2PReplicatorFeature.d.ts +6 -1
  35. package/dist/replication/trystero/useP2PReplicatorFeature.js +7 -4
  36. package/dist/rpc/transports/trysteroUtils.d.ts +2 -2
  37. package/dist/rpc/transports/trysteroUtils.js +32 -2
  38. package/dist/serviceFeatures/remoteConfig.js +3 -0
  39. package/dist/services/base/IService.d.ts +3 -3
  40. package/dist/services/base/ReplicationService.d.ts +2 -2
  41. package/dist/services/base/ReplicationService.js +14 -11
  42. package/dist/services/base/SettingService.d.ts +1 -0
  43. package/dist/services/base/SettingService.js +37 -9
  44. package/dist/services/base/TweakValueService.d.ts +5 -3
  45. package/dist/services/implements/injectable/InjectableTweakValueService.d.ts +5 -5
  46. package/dist/settings.d.ts +3 -0
  47. package/dist/settings.js +2 -0
  48. package/dist/worker/bgWorker.js +1 -1
  49. package/docs/development.md +3 -3
  50. package/docs/p2p-transport-lifecycle.md +86 -4
  51. package/docs/proven-in-use.md +32 -0
  52. package/docs/settings-lifecycle.md +20 -1
  53. package/package.json +1 -1
@@ -2,8 +2,12 @@ import { Logger } from "../../common/logger.js";
2
2
  import {
3
3
  LOG_LEVEL_NOTICE,
4
4
  LOG_LEVEL_VERBOSE,
5
- SETTING_KEY_P2P_DEVICE_NAME
5
+ SETTING_KEY_P2P_DEVICE_NAME,
6
+ hasManagedP2PTurnConfiguration,
7
+ omitP2PRuntimeSettings
6
8
  } from "../../common/types.js";
9
+ import { P2PConnectionPaths } from "../../common/models/setting.const.js";
10
+ import { normaliseP2PConnectionPath } from "../../common/models/setting.p2p.js";
7
11
  import { P2PRoomSession } from "./P2PRoomSession.js";
8
12
  import { P2PAutomationCoordinator } from "./P2PAutomationCoordinator.js";
9
13
  import { PEER_REPLICATION_READINESS } from "../ReplicatorProvider.js";
@@ -12,19 +16,28 @@ import {
12
16
  ACTIVE_P2P_RELAY_BINDING_CONFLICT,
13
17
  activeP2PRelayBindingCovers
14
18
  } from "./P2PConnectionProbeAdmission.js";
19
+ class StaleRoomReconciliationError extends Error {
20
+ }
21
+ const P2P_ROOM_OPEN_TIMEOUT_MS = 3e4;
22
+ const P2P_SETTINGS_PREPARATION_TIMEOUT_MS = 3e4;
23
+ const P2P_ICE_SERVER_EXPIRY_MARGIN_MS = 3e4;
15
24
  class P2PRoomSessionOwner {
16
- constructor(env, createSession = (sessionEnv) => new P2PRoomSession(sessionEnv)) {
25
+ constructor(env, createSession = (sessionEnv) => new P2PRoomSession(sessionEnv), options = {}) {
17
26
  this.env = env;
18
27
  this.createSession = createSession;
28
+ this.options = options;
19
29
  }
20
30
  env;
21
31
  createSession;
32
+ options;
22
33
  current;
23
34
  lifecycleOperation = Promise.resolve();
24
35
  persistentDemands = /* @__PURE__ */ new Set();
25
36
  finiteDemands = /* @__PURE__ */ new Set();
26
37
  automationCoordinator = new P2PAutomationCoordinator();
27
38
  activeBinding;
39
+ pendingPreparation;
40
+ runtimeGeneration = 0;
28
41
  get currentSession() {
29
42
  return this.current;
30
43
  }
@@ -52,6 +65,7 @@ class P2PRoomSessionOwner {
52
65
  if (active && !enabled) {
53
66
  Logger(this.env.services.context.translate("P2P.NotEnabled"), LOG_LEVEL_NOTICE);
54
67
  }
68
+ this.fencePreparationForCurrentSettings(enabled);
55
69
  await this.reconcileTransport();
56
70
  }
57
71
  /**
@@ -66,6 +80,7 @@ class P2PRoomSessionOwner {
66
80
  }
67
81
  const demand = /* @__PURE__ */ Symbol("p2p-finite-room-demand");
68
82
  this.finiteDemands.add(demand);
83
+ this.fencePreparationForCurrentSettings(true);
69
84
  try {
70
85
  await this.reconcileTransport();
71
86
  if (!this.finiteDemands.has(demand)) {
@@ -116,7 +131,7 @@ class P2PRoomSessionOwner {
116
131
  return;
117
132
  }
118
133
  const binding = this.getEffectiveBinding();
119
- if (this.current?.host.isServing && this.bindingsMatch(this.activeBinding, binding)) {
134
+ if (this.current?.host.isServing && this.bindingsMatch(this.activeBinding, binding) && this.connectionSettingsRemainUsable(this.activeBinding?.settings)) {
120
135
  this.reconcileCurrentSessionPolicy(this.current);
121
136
  Logger("P2P replicator is already open.");
122
137
  return;
@@ -125,34 +140,55 @@ class P2PRoomSessionOwner {
125
140
  await this.closeTransport();
126
141
  }
127
142
  let candidate;
143
+ const operationGeneration = this.runtimeGeneration;
128
144
  try {
129
- candidate = this.createSession(this.buildSessionEnv(binding));
130
- await candidate.open();
145
+ const connectionSettings = await this.prepareConnectionSettings(binding, operationGeneration);
146
+ this.assertReconciliationCurrent(binding, operationGeneration, connectionSettings);
147
+ const connectionBinding = { ...binding, settings: connectionSettings };
148
+ candidate = this.createSession(this.buildSessionEnv(connectionBinding));
149
+ if (hasManagedP2PTurnConfiguration(binding.settings)) {
150
+ await this.withRoomOpenDeadline(candidate.open());
151
+ } else {
152
+ await candidate.open();
153
+ }
131
154
  if (!candidate.host.isServing) {
132
155
  throw new Error("The P2P room did not start serving.");
133
156
  }
134
157
  const currentBinding = this.getEffectiveBinding();
135
- if (!currentBinding.enabled || !this.hasRoomDemand() || !this.bindingsMatch(binding, currentBinding)) {
158
+ if (!currentBinding.enabled || !this.hasRoomDemand() || !this.bindingsMatch(binding, currentBinding) || operationGeneration !== this.runtimeGeneration || !this.connectionSettingsRemainUsable(connectionSettings)) {
136
159
  await candidate.retire();
137
160
  return;
138
161
  }
139
162
  this.current = candidate;
140
- this.activeBinding = binding;
163
+ this.activeBinding = connectionBinding;
141
164
  this.reconcileCurrentSessionPolicy(candidate);
142
165
  } catch (error) {
143
166
  await candidate?.retire(error).catch((retirementError) => {
144
- Logger(retirementError, LOG_LEVEL_VERBOSE);
167
+ if (hasManagedP2PTurnConfiguration(binding.settings)) {
168
+ Logger("The managed P2P room could not be retired cleanly.", LOG_LEVEL_VERBOSE);
169
+ } else {
170
+ Logger(retirementError, LOG_LEVEL_VERBOSE);
171
+ }
145
172
  });
146
- Logger(error instanceof Error ? error.message : "Error while opening P2P connection", LOG_LEVEL_NOTICE);
147
- Logger(error, LOG_LEVEL_VERBOSE);
148
173
  this.current = void 0;
149
174
  this.activeBinding = void 0;
175
+ if (error instanceof StaleRoomReconciliationError || operationGeneration !== this.runtimeGeneration) {
176
+ return;
177
+ }
178
+ if (hasManagedP2PTurnConfiguration(binding.settings)) {
179
+ Logger("The managed P2P room could not be prepared or opened.", LOG_LEVEL_NOTICE);
180
+ Logger("Managed P2P room failure details were omitted.", LOG_LEVEL_VERBOSE);
181
+ throw new Error("The managed P2P room could not be prepared or opened.");
182
+ }
183
+ Logger(error instanceof Error ? error.message : "Error while opening P2P connection", LOG_LEVEL_NOTICE);
184
+ Logger(error, LOG_LEVEL_VERBOSE);
150
185
  }
151
186
  });
152
187
  }
153
188
  async close() {
154
189
  this.persistentDemands.clear();
155
190
  this.finiteDemands.clear();
191
+ this.invalidatePreparation();
156
192
  await this.enqueueLifecycleOperation(async () => {
157
193
  await this.closeTransport();
158
194
  });
@@ -179,11 +215,153 @@ class P2PRoomSessionOwner {
179
215
  bindingsMatch(current, candidate) {
180
216
  return current?.database === candidate.database && current.signature === candidate.signature;
181
217
  }
218
+ connectionSettingsRemainUsable(settings) {
219
+ if (!settings) return false;
220
+ const managed = hasManagedP2PTurnConfiguration(settings);
221
+ const iceServers = settings.P2P_iceServers;
222
+ if (iceServers === void 0) return !managed;
223
+ if (!this.hasUsableIceServer(iceServers)) return false;
224
+ const hasTurnServer = this.hasUsableTurnServer(iceServers);
225
+ if (managed && !hasTurnServer) return false;
226
+ if (normaliseP2PConnectionPath(settings.P2P_connectionPath) === P2PConnectionPaths.Relay && !hasTurnServer) {
227
+ return false;
228
+ }
229
+ const expiresAt = settings.P2P_iceServersExpiresAt;
230
+ if (expiresAt === void 0) return !managed;
231
+ return Number.isFinite(expiresAt) && expiresAt > Date.now() + P2P_ICE_SERVER_EXPIRY_MARGIN_MS;
232
+ }
233
+ fencePreparationForCurrentSettings(enabled) {
234
+ if (!enabled) {
235
+ this.invalidatePreparation();
236
+ return;
237
+ }
238
+ const pendingChanged = this.pendingPreparation !== void 0 && this.pendingPreparation.signature !== this.getEffectiveBinding().signature;
239
+ if (pendingChanged) this.invalidatePreparation();
240
+ }
241
+ invalidatePreparation() {
242
+ this.runtimeGeneration += 1;
243
+ const pending = this.pendingPreparation;
244
+ this.pendingPreparation = void 0;
245
+ if (pending && !pending.controller.signal.aborted) {
246
+ pending.controller.abort(new Error("The P2P settings preparation was cancelled."));
247
+ }
248
+ }
249
+ async prepareConnectionSettings(binding, operationGeneration) {
250
+ const managed = hasManagedP2PTurnConfiguration(binding.settings);
251
+ const prepare = this.options.prepareP2PSettings;
252
+ if (!prepare) {
253
+ if (managed) throw new Error("The selected managed TURN configuration is not supported by this host.");
254
+ return binding.settings;
255
+ }
256
+ const controller = new AbortController();
257
+ const pending = {
258
+ signature: binding.signature,
259
+ controller
260
+ };
261
+ this.pendingPreparation = pending;
262
+ try {
263
+ const prepared = await this.prepareWithDeadline(prepare, binding.settings, controller);
264
+ if (prepared.P2P_iceServers === void 0 && prepared.P2P_iceServersExpiresAt === void 0) {
265
+ if (managed) throw new Error("The prepared managed TURN settings do not include ICE servers.");
266
+ return binding.settings;
267
+ }
268
+ const iceServers = this.copyPreparedIceServers(prepared.P2P_iceServers);
269
+ const expiresAt = prepared.P2P_iceServersExpiresAt;
270
+ const connectionSettings = {
271
+ ...binding.settings,
272
+ P2P_iceServers: iceServers,
273
+ P2P_iceServersExpiresAt: expiresAt
274
+ };
275
+ if (!this.connectionSettingsRemainUsable(connectionSettings)) {
276
+ throw new Error("The prepared managed TURN settings are missing usable ICE servers or expiry.");
277
+ }
278
+ this.assertReconciliationCurrent(binding, operationGeneration, connectionSettings);
279
+ return connectionSettings;
280
+ } finally {
281
+ if (this.pendingPreparation === pending) {
282
+ this.pendingPreparation = void 0;
283
+ }
284
+ }
285
+ }
286
+ prepareWithDeadline(prepare, settings, controller) {
287
+ const preparation = Promise.resolve().then(() => prepare(Object.freeze({ ...settings }), controller.signal));
288
+ return new Promise((resolve, reject) => {
289
+ let settled = false;
290
+ const finish = (operation) => {
291
+ if (settled) return;
292
+ settled = true;
293
+ globalThis.clearTimeout(timeout);
294
+ controller.signal.removeEventListener("abort", onAbort);
295
+ operation();
296
+ };
297
+ const onAbort = () => finish(
298
+ () => reject(
299
+ controller.signal.reason instanceof Error ? controller.signal.reason : new Error("The P2P settings preparation was cancelled.")
300
+ )
301
+ );
302
+ const timeout = globalThis.setTimeout(() => {
303
+ controller.abort(new Error("The P2P settings preparation timed out."));
304
+ }, P2P_SETTINGS_PREPARATION_TIMEOUT_MS);
305
+ controller.signal.addEventListener("abort", onAbort, { once: true });
306
+ if (controller.signal.aborted) onAbort();
307
+ preparation.then(
308
+ (prepared) => finish(() => resolve(prepared)),
309
+ () => finish(() => reject(new Error("The P2P settings preparation failed.")))
310
+ );
311
+ });
312
+ }
313
+ copyPreparedIceServers(iceServers) {
314
+ if (!Array.isArray(iceServers) || iceServers.length === 0) return [];
315
+ return iceServers.map((server) => ({
316
+ urls: typeof server.urls === "string" ? server.urls : [...server.urls],
317
+ ...server.username === void 0 ? {} : { username: server.username },
318
+ ...server.credential === void 0 ? {} : { credential: server.credential }
319
+ }));
320
+ }
321
+ hasUsableIceServer(iceServers) {
322
+ return Array.isArray(iceServers) && iceServers.some((server) => {
323
+ const urls = typeof server?.urls === "string" ? [server.urls] : server?.urls;
324
+ return Array.isArray(urls) && urls.some((url) => typeof url === "string" && url.trim().length > 0);
325
+ });
326
+ }
327
+ hasUsableTurnServer(iceServers) {
328
+ return Array.isArray(iceServers) && iceServers.some((server) => {
329
+ const urls = typeof server?.urls === "string" ? [server.urls] : server?.urls;
330
+ return Array.isArray(urls) && urls.some((url) => typeof url === "string" && /^turns?:/iu.test(url));
331
+ });
332
+ }
333
+ async withRoomOpenDeadline(opening) {
334
+ let timeout;
335
+ try {
336
+ await Promise.race([
337
+ opening,
338
+ new Promise((_resolve, reject) => {
339
+ timeout = globalThis.setTimeout(
340
+ () => reject(new Error("The P2P room opening timed out.")),
341
+ P2P_ROOM_OPEN_TIMEOUT_MS
342
+ );
343
+ })
344
+ ]);
345
+ } finally {
346
+ if (timeout !== void 0) globalThis.clearTimeout(timeout);
347
+ }
348
+ }
349
+ assertReconciliationCurrent(binding, operationGeneration, connectionSettings) {
350
+ if (operationGeneration !== this.runtimeGeneration || !this.hasRoomDemand()) {
351
+ throw new StaleRoomReconciliationError();
352
+ }
353
+ const currentBinding = this.getEffectiveBinding();
354
+ if (!currentBinding.enabled) throw new StaleRoomReconciliationError();
355
+ if (binding && !this.bindingsMatch(binding, currentBinding)) throw new StaleRoomReconciliationError();
356
+ if (connectionSettings && !this.connectionSettingsRemainUsable(connectionSettings)) {
357
+ throw new StaleRoomReconciliationError();
358
+ }
359
+ }
182
360
  /** Capture immutable session inputs while keeping policy live. */
183
361
  getEffectiveBinding() {
184
- const settings = {
362
+ const settings = omitP2PRuntimeSettings({
185
363
  ...this.env.services.setting.currentSettings()
186
- };
364
+ });
187
365
  const deviceName = this.env.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.env.services.vault.getVaultName();
188
366
  const database = this.env.services.database.localDatabase.localDatabase;
189
367
  this.automationCoordinator.reconcileIdentity(
@@ -245,5 +423,7 @@ class P2PRoomSessionOwner {
245
423
  }
246
424
  }
247
425
  export {
248
- P2PRoomSessionOwner
426
+ P2PRoomSessionOwner,
427
+ P2P_ICE_SERVER_EXPIRY_MARGIN_MS,
428
+ P2P_SETTINGS_PREPARATION_TIMEOUT_MS
249
429
  };
@@ -1,4 +1,8 @@
1
- import { TweakValuesShouldMatchedTemplate } from "../../common/types.js";
1
+ import {
2
+ TweakValuesShouldMatchedTemplate,
3
+ omitP2PRuntimeSettings
4
+ } from "../../common/types.js";
5
+ import { assessTweakCompatibility } from "../../common/models/tweak.compatibility.js";
2
6
  import {
3
7
  LOG_LEVEL_INFO,
4
8
  LOG_LEVEL_NOTICE,
@@ -17,7 +21,6 @@ import {
17
21
  } from "./TrysteroReplicatorP2PServer.js";
18
22
  import { encryptWithEphemeralSalt, decryptWithEphemeralSalt } from "octagonal-wheels/encryption/hkdf";
19
23
  import { sha1 } from "octagonal-wheels/hash/purejs";
20
- import { isObjectDifferent } from "octagonal-wheels/object";
21
24
  import { getRelaySockets, pauseRelayReconnection, resumeRelayReconnection } from "@trystero-p2p/nostr";
22
25
  import { P2PAutomationCoordinator } from "./P2PAutomationCoordinator.js";
23
26
  import { compatGlobal } from "../../common/coreEnvFunctions.js";
@@ -253,12 +256,15 @@ class TrysteroReplicator {
253
256
  );
254
257
  });
255
258
  const setting = {
256
- ...this.settings,
259
+ ...omitP2PRuntimeSettings(this.settings),
257
260
  configPassphraseStore: "",
258
261
  encryptedCouchDBConnection: "",
259
262
  encryptedPassphrase: "",
260
263
  pluginSyncExtendedSetting: {}
261
264
  };
265
+ delete setting.P2P_managedType;
266
+ delete setting.P2P_managedId;
267
+ delete setting.P2P_managedToken;
262
268
  if (!passphrase || passphrase.trim() == "") {
263
269
  Logger(
264
270
  "Passphrase is required to transfer the configuration. The peer cannot be decrypt the config\nIf you repeatedly receive unintended configuration-sharing requests, change the RPC channel immediately. It allows you to leave the connection and disappear, while they are trying brute force attack for the decoy on their local.",
@@ -700,7 +706,7 @@ ${info.lastSeq} / ${info.maxSeqInBatch})`,
700
706
  const decryptedConfig = JSON.parse(
701
707
  await decrypt(encryptedConfig, passphrase)
702
708
  );
703
- return decryptedConfig;
709
+ return omitP2PRuntimeSettings(decryptedConfig);
704
710
  } catch (e) {
705
711
  Logger("Error while decrypting the configuration", LOG_LEVEL_NOTICE);
706
712
  Logger(e, LOG_LEVEL_VERBOSE);
@@ -728,9 +734,6 @@ ${info.lastSeq} / ${info.maxSeqInBatch})`,
728
734
  const tweakValues = await connection.invokeRemoteObjectFunction("getTweakSettings", [this.server.serverPeerId], 5e3, signal);
729
735
  if (signal?.aborted) return false;
730
736
  const thisTweakValues = await this.getTweakSettings("");
731
- if (!isObjectDifferent(thisTweakValues, tweakValues)) {
732
- return true;
733
- }
734
737
  if (thisTweakValues.passphrase !== tweakValues.passphrase) {
735
738
  Logger(
736
739
  "Replication cancelled: Passphrase is not matched\nCannot replicate to a remote database until the problem is resolved.",
@@ -738,6 +741,8 @@ ${info.lastSeq} / ${info.maxSeqInBatch})`,
738
741
  );
739
742
  return false;
740
743
  }
744
+ const tweakAssessment = assessTweakCompatibility(thisTweakValues, tweakValues);
745
+ if (!tweakAssessment.representationDiffers) return true;
741
746
  Logger(
742
747
  "Some mismatched configuration have been detected... Please check settings for efficient replication.",
743
748
  logLevel
@@ -121,6 +121,7 @@ export declare class TrysteroReplicatorP2PServer {
121
121
  onAfterJoinRoom(): void;
122
122
  startService(bindings?: BindableObject[], beforeAdvertisement?: () => void): Promise<void>;
123
123
  start(bindings?: BindableObject[], beforeAdvertisement?: () => void): Promise<void>;
124
+ private logTransportError;
124
125
  /**
125
126
  * @deprecated Use serveFunction or serveObject instead. This is only for backward compatibility and may be removed in the future.
126
127
  * @param type
@@ -79,7 +79,7 @@ class TrysteroReplicatorP2PServer {
79
79
  `Some error has been occurred while leaving the room, but possibly can be ignored`,
80
80
  LOG_LEVEL_VERBOSE
81
81
  );
82
- Logger(ex, LOG_LEVEL_VERBOSE);
82
+ this.logTransportError(ex);
83
83
  }
84
84
  this._room = void 0;
85
85
  this._env.events.emitEvent(EVENT_P2P_DISCONNECTED);
@@ -97,7 +97,7 @@ class TrysteroReplicatorP2PServer {
97
97
  this._rpcRoom = void 0;
98
98
  } catch (ex) {
99
99
  Logger(`Some error has been occurred while shutting down the server`, LOG_LEVEL_INFO);
100
- Logger(ex, LOG_LEVEL_VERBOSE);
100
+ this.logTransportError(ex);
101
101
  }
102
102
  }
103
103
  /** Release subscriptions whose lifetime is the host object rather than one transport join. */
@@ -459,7 +459,7 @@ You can chose as follows:
459
459
  handshakeTimeoutMs: 3e4,
460
460
  onJoinError: (error) => {
461
461
  Logger("Some peer Failed to join Trystero room");
462
- Logger(error, LOG_LEVEL_VERBOSE);
462
+ this.logTransportError(error);
463
463
  }
464
464
  });
465
465
  await this.setRoom(room);
@@ -468,6 +468,13 @@ You can chose as follows:
468
468
  void this.dispatchConnectionStatus();
469
469
  await this.startService(bindings, beforeAdvertisement);
470
470
  }
471
+ logTransportError(error) {
472
+ if (this.settings.P2P_iceServers !== void 0) {
473
+ Logger("Managed P2P transport error details were omitted.", LOG_LEVEL_VERBOSE);
474
+ return;
475
+ }
476
+ Logger(error, LOG_LEVEL_VERBOSE);
477
+ }
471
478
  /**
472
479
  * @deprecated Use serveFunction or serveObject instead. This is only for backward compatibility and may be removed in the future.
473
480
  * @param type
@@ -1,25 +1,28 @@
1
1
  import { P2PConnectionPaths } from "../../common/models/setting.const.js";
2
2
  import {
3
3
  hasValidP2PTurnServerUrl,
4
+ hasManagedP2PTurnConfiguration,
4
5
  normaliseP2PConnectionPath,
5
6
  normaliseP2PMaxWirePayloadBytes,
6
7
  splitP2PRelayUrls,
7
8
  splitP2PTurnServerUrls
8
9
  } from "../../common/models/setting.p2p.js";
9
10
  function getP2PReplicatorConfigurationIdentity(settings) {
11
+ const managedTurnSelected = hasManagedP2PTurnConfiguration(settings);
10
12
  const turnServerValue = settings.P2P_turnServers ?? "";
11
13
  const turnServers = splitP2PTurnServerUrls(turnServerValue);
12
14
  const configuredPath = normaliseP2PConnectionPath(settings.P2P_connectionPath);
13
- const effectivePath = configuredPath === P2PConnectionPaths.Relay && hasValidP2PTurnServerUrl(turnServerValue) ? P2PConnectionPaths.Relay : P2PConnectionPaths.Automatic;
14
- const turnAuthentication = turnServers.length > 0 ? [settings.P2P_turnUsername, settings.P2P_turnCredential] : void 0;
15
+ const effectivePath = configuredPath === P2PConnectionPaths.Relay && (managedTurnSelected || hasValidP2PTurnServerUrl(turnServerValue)) ? P2PConnectionPaths.Relay : P2PConnectionPaths.Automatic;
16
+ const turnAuthentication = !managedTurnSelected && turnServers.length > 0 ? [settings.P2P_turnUsername, settings.P2P_turnCredential] : void 0;
15
17
  return JSON.stringify([
16
18
  "p2p",
17
19
  settings.P2P_AppID || "self-hosted-livesync",
18
20
  settings.P2P_roomID,
19
21
  settings.P2P_passphrase,
20
22
  splitP2PRelayUrls(settings.P2P_relays),
21
- turnServers,
23
+ managedTurnSelected ? void 0 : turnServers,
22
24
  turnAuthentication,
25
+ managedTurnSelected ? [settings.P2P_managedType, settings.P2P_managedId, settings.P2P_managedToken] : void 0,
23
26
  normaliseP2PMaxWirePayloadBytes(settings.P2P_maxWirePayloadBytes),
24
27
  effectivePath,
25
28
  settings.P2P_useDiagRTC ?? false
@@ -2,6 +2,7 @@ import type { NecessaryServices } from "../../interfaces/ServiceModule.js";
2
2
  import type { LiveSyncTrysteroReplicator } from "./LiveSyncTrysteroReplicator.js";
3
3
  import { type UseP2PReplicatorResult } from "./UseP2PReplicatorResult.js";
4
4
  import { type P2PServiceViews } from "../../p2p/P2PService.js";
5
+ import type { PrepareP2PSettings } from "../../p2p/P2PService.js";
5
6
  /**
6
7
  * Factory type: given the compatibility Replicator and the stable service
7
8
  * views, returns the openReplicationUI callback for that instance.
@@ -14,6 +15,10 @@ import { type P2PServiceViews } from "../../p2p/P2PService.js";
14
15
  export type OpenReplicationUIFactory = (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) => (showResult: boolean) => Promise<boolean | void>;
15
16
  /** Same shape as OpenReplicationUIFactory, used for the rebuild/replicateAllFromServer flow. */
16
17
  export type OpenRebuildUIFactory = OpenReplicationUIFactory;
18
+ /** Optional host integrations for the P2P room lifecycle. */
19
+ export interface P2PReplicatorFeatureOptions {
20
+ readonly prepareP2PSettings?: PrepareP2PSettings;
21
+ }
17
22
  /**
18
23
  * Compose one private P2P service context and register non-owning active
19
24
  * provider adapters over it.
@@ -23,4 +28,4 @@ export type OpenRebuildUIFactory = OpenReplicationUIFactory;
23
28
  * migrated to those views.
24
29
  * @param host
25
30
  */
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;
31
+ export declare function useP2PReplicatorFeature(host: NecessaryServices<"API" | "appLifecycle" | "setting" | "vault" | "database" | "databaseEvents" | "keyValueDB" | "replication" | "config" | "UI" | "replicator" | "remote", never>, openReplicationUIFactory?: OpenReplicationUIFactory, openRebuildUIFactory?: OpenRebuildUIFactory, options?: P2PReplicatorFeatureOptions): UseP2PReplicatorResult;
@@ -15,10 +15,13 @@ import {
15
15
  supportedStopActiveTransfer
16
16
  } from "../index.js";
17
17
  import { getP2PReplicatorConfigurationIdentity } from "./p2pReplicatorConfigurationIdentity.js";
18
- function useP2PReplicatorFeature(host, openReplicationUIFactory, openRebuildUIFactory) {
19
- const service = createP2PService({
20
- services: host.services
21
- });
18
+ function useP2PReplicatorFeature(host, openReplicationUIFactory, openRebuildUIFactory, options = {}) {
19
+ const service = createP2PService(
20
+ {
21
+ services: host.services
22
+ },
23
+ options
24
+ );
22
25
  const replicator = service.compatibilityReplicator;
23
26
  const { views, lifecycle } = service;
24
27
  const configureReplicator = (instance) => {
@@ -1,3 +1,3 @@
1
1
  import type { BaseRoomConfig } from "@trystero-p2p/nostr";
2
- import type { P2PConnectionInfo } from "../../common/models/setting.type.js";
3
- export declare function generateJoinRoomOptions(settings: P2PConnectionInfo): BaseRoomConfig;
2
+ import type { P2PConnectionInfo, P2PSyncSetting } from "../../common/models/setting.type.js";
3
+ export declare function generateJoinRoomOptions(settings: P2PConnectionInfo & Partial<Pick<P2PSyncSetting, "P2P_iceServers" | "P2P_iceServersExpiresAt">>): BaseRoomConfig;
@@ -1,5 +1,6 @@
1
1
  import { P2PConnectionPaths } from "../../common/models/setting.const.js";
2
2
  import {
3
+ hasManagedP2PTurnConfiguration,
3
4
  hasValidP2PTurnServerUrl,
4
5
  normaliseP2PConnectionPath,
5
6
  splitP2PRelayUrls,
@@ -8,11 +9,38 @@ import {
8
9
  import { mixedHash } from "octagonal-wheels/hash/purejs";
9
10
  import { compatGlobal } from "../../common/coreEnvFunctions.js";
10
11
  import { createDiagRTCPeerConnectionConstructor } from "./DiagRTCPeerConnections.js";
12
+ function containsTurnUrl(iceServers) {
13
+ return iceServers.some((server) => {
14
+ const urls = typeof server.urls === "string" ? [server.urls] : server.urls;
15
+ return urls.some((url) => /^turns?:/iu.test(url));
16
+ });
17
+ }
18
+ function copyIceServers(iceServers) {
19
+ return iceServers.map((server) => ({
20
+ urls: typeof server.urls === "string" ? server.urls : [...server.urls],
21
+ ...server.username === void 0 ? {} : { username: server.username },
22
+ ...server.credential === void 0 ? {} : { credential: server.credential }
23
+ }));
24
+ }
11
25
  function generateJoinRoomOptions(settings) {
12
26
  const passphraseNumbers = mixedHash(settings.P2P_passphrase, 0);
13
27
  const passphrase = passphraseNumbers[0].toString(36) + passphraseNumbers[1].toString(36);
14
28
  const relays = splitP2PRelayUrls(settings.P2P_relays);
15
29
  const turnServers = splitP2PTurnServerUrls(settings.P2P_turnServers);
30
+ const managedTurnSelected = hasManagedP2PTurnConfiguration(settings);
31
+ const preparedIceServers = settings.P2P_iceServers;
32
+ if (preparedIceServers !== void 0 && preparedIceServers.length === 0) {
33
+ throw new Error("Prepared ICE servers must not be empty.");
34
+ }
35
+ if (managedTurnSelected && preparedIceServers === void 0) {
36
+ throw new Error("The selected managed TURN configuration requires prepared ICE servers.");
37
+ }
38
+ if (managedTurnSelected && preparedIceServers && !containsTurnUrl(preparedIceServers)) {
39
+ throw new Error("The prepared ICE servers do not contain a TURN route.");
40
+ }
41
+ if (preparedIceServers !== void 0 && normaliseP2PConnectionPath(settings.P2P_connectionPath) === P2PConnectionPaths.Relay && !containsTurnUrl(preparedIceServers)) {
42
+ throw new Error("Relay-only P2P requires a prepared TURN route.");
43
+ }
16
44
  const relayConfig = {
17
45
  manualReconnection: true,
18
46
  urls: relays
@@ -28,7 +56,9 @@ function generateJoinRoomOptions(settings) {
28
56
  } else if (typeof compatGlobal.RTCPeerConnection !== "undefined") {
29
57
  options.rtcPolyfill = compatGlobal.RTCPeerConnection;
30
58
  }
31
- if (turnServers.length > 0) {
59
+ if (preparedIceServers !== void 0) {
60
+ options.turnConfig = copyIceServers(preparedIceServers);
61
+ } else if (!managedTurnSelected && turnServers.length > 0) {
32
62
  options.turnConfig = [
33
63
  {
34
64
  urls: turnServers,
@@ -37,7 +67,7 @@ function generateJoinRoomOptions(settings) {
37
67
  }
38
68
  ];
39
69
  }
40
- if (normaliseP2PConnectionPath(settings.P2P_connectionPath) === P2PConnectionPaths.Relay && hasValidP2PTurnServerUrl(settings.P2P_turnServers)) {
70
+ if (normaliseP2PConnectionPath(settings.P2P_connectionPath) === P2PConnectionPaths.Relay && (preparedIceServers !== void 0 ? containsTurnUrl(preparedIceServers) : !managedTurnSelected && hasValidP2PTurnServerUrl(settings.P2P_turnServers))) {
41
71
  options.rtcConfig = {
42
72
  iceTransportPolicy: "relay"
43
73
  };
@@ -46,6 +46,9 @@ function migrateLegacyRemoteConfigurationsInPlace(settings, log) {
46
46
  }
47
47
  const preferredId = settings.remoteType === REMOTE_MINIO ? "legacy-s3" : settings.remoteType === REMOTE_P2P ? "legacy-p2p" : "legacy-couchdb";
48
48
  settings.activeConfigurationId = settings.remoteConfigurations[preferredId] ? preferredId : createdIds[0];
49
+ if (settings.remoteConfigurations["legacy-p2p"]) {
50
+ settings.P2P_ActiveRemoteConfigurationId = "legacy-p2p";
51
+ }
49
52
  return true;
50
53
  }
51
54
  function createRemoteConfigurationId() {
@@ -1,6 +1,6 @@
1
1
  import type { FetchHttpHandler } from "@smithy/fetch-http-handler";
2
2
  import { type LOG_LEVEL } from "octagonal-wheels/common/logger";
3
- import type { AnyEntry, AUTO_MERGED, CouchDBCredentials, diff_result, DocumentID, EntryDoc, EntryHasPath, FileEventItem, FilePath, FilePathWithPrefix, LoadedEntry, MetaEntry, MISSING_OR_ERROR, ObsidianLiveSyncSettings, RemoteDBSettings, RemotePreferredTweakResult, SettingsMigrationState, TweakValues, UXFileInfo, UXFileInfoStub } from "../../common/types.js";
3
+ import type { AnyEntry, AUTO_MERGED, CouchDBCredentials, diff_result, DocumentID, EntryDoc, EntryHasPath, FileEventItem, FilePath, FilePathWithPrefix, LoadedEntry, MetaEntry, MISSING_OR_ERROR, ObsidianLiveSyncSettings, RemoteDBSettings, RemotePreferredTweakResult, SettingsMigrationState, TweakAssessment, TweakValues, UXFileInfo, UXFileInfoStub } from "../../common/types.js";
4
4
  import type { LiveSyncLocalDB } from "../../pouchdb/LiveSyncLocalDB.js";
5
5
  import type { ReplicatorInstance } from "../../replication/ReplicatorInstance.js";
6
6
  import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
@@ -335,8 +335,8 @@ export interface ISettingService {
335
335
  }
336
336
  export interface ITweakValueService {
337
337
  fetchRemotePreferred(trialSetting: RemoteDBSettings): Promise<RemotePreferredTweakResult>;
338
- checkAndAskResolvingMismatched(preferred: Partial<TweakValues>): Promise<[TweakValues | boolean, boolean]>;
339
- askResolvingMismatched(preferredSource: TweakValues, updatePreferredRemote?: (setting: ObsidianLiveSyncSettings) => Promise<boolean>): Promise<"OK" | "CHECKAGAIN" | "IGNORE">;
338
+ checkAndAskResolvingMismatched(preferred: Partial<TweakValues>, assessment?: TweakAssessment): Promise<[TweakValues | boolean, boolean]>;
339
+ askResolvingMismatched(preferredSource: TweakValues, updatePreferredRemote?: (setting: ObsidianLiveSyncSettings) => Promise<boolean>, assessment?: TweakAssessment): Promise<"OK" | "CHECKAGAIN" | "IGNORE">;
340
340
  checkAndAskUseRemoteConfiguration(settings: RemoteDBSettings): Promise<{
341
341
  result: false | TweakValues;
342
342
  requireFetch: boolean;
@@ -117,8 +117,8 @@ export declare abstract class ReplicationService<T extends ServiceContext = Serv
117
117
  *
118
118
  * Each attempt owns a separate reservation. Compatibility recovery runs
119
119
  * between those reservations, and a retry is admitted only when the exact
120
- * first-attempt context remains active. Both attempts use one detached
121
- * settings snapshot.
120
+ * first-attempt context remains active. Each attempt uses a detached settings
121
+ * snapshot, and a compatibility retry captures its snapshot after recovery.
122
122
  */
123
123
  private performDirectionalReplication;
124
124
  replicateAllToRemote(showingNotice?: boolean): Promise<boolean>;
@@ -329,40 +329,43 @@ class ReplicationService extends ServiceBase {
329
329
  *
330
330
  * Each attempt owns a separate reservation. Compatibility recovery runs
331
331
  * between those reservations, and a retry is admitted only when the exact
332
- * first-attempt context remains active. Both attempts use one detached
333
- * settings snapshot.
332
+ * first-attempt context remains active. Each attempt uses a detached settings
333
+ * snapshot, and a compatibility retry captures its snapshot after recovery.
334
334
  */
335
335
  async performDirectionalReplication(direction, showingNotice, admission = DIRECTIONAL_REPLICATION_ADMISSION.ORDINARY) {
336
336
  if (direction === DIRECTIONAL_REPLICATION.UPLOAD && !await this.onBeforeReplicate(showingNotice)) {
337
337
  this._log(this.context.translate("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE);
338
338
  return false;
339
339
  }
340
- const detachedSetting = asCopy(this.settingService.currentSettings());
341
- if (admission === DIRECTIONAL_REPLICATION_ADMISSION.EXPLICIT_REBUILD) {
342
- detachedSetting.versionUpFlash = "";
343
- }
344
- const setting = Object.freeze(detachedSetting);
340
+ const captureSetting = () => {
341
+ const detachedSetting = asCopy(this.settingService.currentSettings());
342
+ if (admission === DIRECTIONAL_REPLICATION_ADMISSION.EXPLICIT_REBUILD) {
343
+ detachedSetting.versionUpFlash = "";
344
+ }
345
+ return Object.freeze(detachedSetting);
346
+ };
347
+ const setting = captureSetting();
345
348
  let expectedContext;
346
- const run = async () => {
349
+ const run = async (attemptSetting) => {
347
350
  const admitted = await this.replicatorService.runWithActiveReplicatorContext((context) => {
348
351
  if (expectedContext && context !== expectedContext) {
349
352
  return replicationBlocked("not-ready");
350
353
  }
351
354
  expectedContext ??= context;
352
- return runDirectionalReplication(context, setting, direction, showingNotice);
355
+ return runDirectionalReplication(context, attemptSetting, direction, showingNotice);
353
356
  });
354
357
  if (admitted) return admitted;
355
358
  this._log(`Active replicator not found during directional ${direction}`, LOG_LEVEL_NOTICE);
356
359
  return replicationBlocked("no-active-replicator");
357
360
  };
358
- const outcome = await run();
361
+ const outcome = await run(setting);
359
362
  if (isReplicationCompleted(outcome)) return true;
360
363
  if (outcome.status !== "failed") return false;
361
364
  const failedContext = expectedContext;
362
365
  if (!failedContext) return false;
363
366
  const failure = Object.freeze({ context: failedContext, setting, outcome });
364
367
  const checkResult = await this.checkConnectionFailure(failure);
365
- return checkResult === "CHECKAGAIN" && isReplicationCompleted(await run());
368
+ return checkResult === "CHECKAGAIN" && isReplicationCompleted(await run(captureSetting()));
366
369
  }
367
370
  async replicateAllToRemote(showingNotice = false) {
368
371
  if (!this.appLifecycleService.isReady()) return false;