libp2r2p 0.11.17 → 0.11.19

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.
@@ -48,7 +48,8 @@ import * as privateMessage from '../private-message/index.js'
48
48
  import { isOnline, onOnline } from '../network/index.js'
49
49
  import { isRetryableRelayFailure, isReplaceableRelayFailure } from '../relay/index.js'
50
50
  import { createPauseRecovery, isRecoverableStorageFailure } from './helpers/pause-recovery.js'
51
- import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
51
+ import { backgroundSigner } from './helpers/background-signer.js'
52
+ import { createSendRelayRouting, normalizeFallbackRelays, normalizeFallbackDelay } from './helpers/send-routing.js'
52
53
  import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
53
54
  import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
54
55
  import { normalizeRelayUrl } from '../url/index.js'
@@ -212,6 +213,7 @@ export class PrivateMessenger {
212
213
 
213
214
  constructor ({
214
215
  fallbackRelays = [],
216
+ fallbackDelayMs = null,
215
217
  offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
216
218
  staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
217
219
  identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
@@ -242,6 +244,7 @@ export class PrivateMessenger {
242
244
  _pickRelaysForPubkeys = pickRelaysForPubkeys,
243
245
  _subscribeRelayListUpdates = subscribeRelayListUpdates,
244
246
  _isOnline = isOnline,
247
+ _waitForForeground,
245
248
  _onOnline = onOnline,
246
249
  _random = Math.random,
247
250
  _setTimeout = globalThis.setTimeout.bind(globalThis),
@@ -254,6 +257,7 @@ export class PrivateMessenger {
254
257
  } = {}) {
255
258
  if (onStateChanged != null && typeof onStateChanged !== 'function') throw new ValidationError('INVALID_ON_STATE_CHANGED')
256
259
  this.fallbackRelays = normalizeFallbackRelays(fallbackRelays)
260
+ this.fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
257
261
  this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
258
262
  this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
259
263
  this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
@@ -302,8 +306,10 @@ export class PrivateMessenger {
302
306
  this._pickRelaysForPubkeys = _pickRelaysForPubkeys
303
307
  this._subscribeRelayListUpdates = _subscribeRelayListUpdates
304
308
  this._isOnline = _isOnline
309
+ this._waitForForeground = _waitForForeground
305
310
  this.sendRelayExclusions = new Map()
306
311
  this.sendRoutingLifetime = new AbortController()
312
+ this.sendRoutingPause = new AbortController()
307
313
  this._setTimeout = _setTimeout
308
314
  this._clearTimeout = _clearTimeout
309
315
  this._setInterval = _setInterval
@@ -338,7 +344,13 @@ export class PrivateMessenger {
338
344
  this.recoveryRanges = new Map()
339
345
  this.recoveryPriority = new Map()
340
346
  this.recoveryOnline = { at: 0, value: true }
341
- this.resumeWork = null
347
+ this.channelBackground = new Map()
348
+ // At most one queued job exists per configured channel. Keep setup below
349
+ // foreground demand; history has its own independent admission limits.
350
+ this.channelSetupAdmission = createAbortableSemaphore(3, () => {}, { maxQueued: Infinity })
351
+ this.presenceAdmission = createAbortableSemaphore(1, () => {}, { maxQueued: Infinity })
352
+ this.updateTail = Promise.resolve()
353
+ this.pauseRevisions = new Map()
342
354
  this.capacityTimer = null
343
355
  this.capacityCheck = null
344
356
  this.capacityRequiredBytes = 0
@@ -347,6 +359,7 @@ export class PrivateMessenger {
347
359
  this.liveRecoveryTimers = new Map()
348
360
  this.watchRevisionByChannel = new Map()
349
361
  this.presenceTimers = new Map()
362
+ this.presenceJobs = new Map()
350
363
  this.stopRelayListWatcher = null
351
364
  this.relayListWatcherPubkey = ''
352
365
  this.relayListRefreshPromise = null
@@ -432,7 +445,6 @@ export class PrivateMessenger {
432
445
  this.startStorageMaintenance()
433
446
  this.state = { channels: await this.stateStore.load() }
434
447
  await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
435
- await this.pruneStoredSeeds()
436
448
  this.initialized = true
437
449
  await this.refreshAndApplyStoragePolicy()
438
450
  this.broadcastStoragePolicyChange()
@@ -670,7 +682,16 @@ export class PrivateMessenger {
670
682
  })
671
683
  }
672
684
 
673
- async update (options = {}) {
685
+ async update (options = {}, { waitForBackground = false } = {}) {
686
+ if (typeof waitForBackground !== 'boolean') throw new ValidationError('INVALID_WAIT_FOR_BACKGROUND')
687
+ const work = this.updateTail.catch(() => {}).then(() => this.applyUpdate(options))
688
+ this.updateTail = work
689
+ const background = await work
690
+ if (waitForBackground) await Promise.all(background)
691
+ return this
692
+ }
693
+
694
+ async applyUpdate (options) {
674
695
  this.assertOpen()
675
696
  const {
676
697
  userSigner = this.userSigner,
@@ -689,15 +710,17 @@ export class PrivateMessenger {
689
710
  let nextIdentityStorageRetentionSeconds = updatesIdentityPolicy
690
711
  ? normalizeIdentityStorageRetentionSeconds(options.identityStorageRetentionSeconds)
691
712
  : this.identityStorageRetentionSeconds
692
- if (userSigner) {
713
+ if (userSigner && (userSigner !== this.userSigner || !this.userPubkey)) {
693
714
  const userPubkey = await userSigner.getPublicKey?.()
694
715
  if (!userPubkey) throw new ValidationError('USER_SIGNER_REQUIRED')
695
716
  if (this.userPubkey && userPubkey !== this.userPubkey) throw new ValidationError('USER_SIGNER_MISMATCH')
696
717
  this.userSigner = userSigner
697
718
  }
719
+ if (contentKeySigner !== this.contentKeySigner || (!this.contentKeyPubkey && contentKeySigner)) {
720
+ this.contentKeyPubkey = await contentKeySigner?.getPublicKey?.() || ''
721
+ }
698
722
  this.contentKeySigner = contentKeySigner || null
699
723
  this.nymSigner = nymSigner || null
700
- this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
701
724
  const nextChannels = await this.normalizeChannels(channels, { relays, mode })
702
725
  this.assertOpen()
703
726
  const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
@@ -706,9 +729,11 @@ export class PrivateMessenger {
706
729
  return !previous || ['signer', 'readerSigner', 'readerPubkey', 'nymSigner', 'mode', 'usesNip65WatchRelays', 'offlineRecoverySeconds', 'autoDeletionCapability'].some(key => previous[key] !== channel[key]) ||
707
730
  ['relays', 'sendRelays', 'seeders'].some(key => JSON.stringify(previous[key]) !== JSON.stringify(channel[key]))
708
731
  }
709
- const watchPubkeys = nextChannels.filter(channel => !this.channels.has(channel.pubkey) || (this.desiredChannels.has(channel.pubkey) && (readerIdentityChanged || Object.hasOwn(options, 'staleChannelSeconds') || Object.hasOwn(options, 'identityStorageRetentionSeconds') || changedChannel(channel) || !this.stopByChannel.has(channel.pubkey)))).map(channel => channel.pubkey)
732
+ const changed = nextChannels.filter(channel => readerIdentityChanged || updatesStalePolicy || updatesIdentityPolicy || changedChannel(channel))
733
+ const watchPubkeys = changed.filter(channel => !this.channels.has(channel.pubkey) || this.desiredChannels.has(channel.pubkey)).map(channel => channel.pubkey)
710
734
  const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
711
735
  const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
736
+ if (!changed.length && !removedPubkeys.length && !updatesStoragePolicy) return [...this.channelBackground.values()].map(job => job.work)
712
737
 
713
738
  if (updatesStoragePolicy) {
714
739
  const currentPolicy = await this.readStoragePolicySnapshot()
@@ -740,18 +765,35 @@ export class PrivateMessenger {
740
765
  if (updatesStoragePolicy) this.broadcastStoragePolicyChange()
741
766
 
742
767
  await this.unwatch(removedPubkeys)
768
+ // Keep an established reader alive until private-message atomically
769
+ // replaces it. Only obsolete setup/presence work is retired here.
770
+ for (const pubkey of watchPubkeys) {
771
+ this.cancelReloadGap(pubkey)
772
+ this.cancelLiveRecovery(pubkey)
773
+ this.channelBackground.get(pubkey)?.controller.abort()
774
+ this.channelBackground.delete(pubkey)
775
+ this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
776
+ this.stopPresencePublisher(pubkey)
777
+ }
743
778
  for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
744
779
  for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
745
- for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
780
+ for (const channel of changed) this.channels.set(channel.pubkey, channel)
746
781
 
747
- await this.cleanupStaleChannels({ storageSnapshot })
748
- await this.applyRecoveryPolicies(nextChannels)
749
- await this.watch(watchPubkeys)
750
- await this.reconcilePresencePublishers()
782
+ // Incremental registration must not prune persisted channels whose signers
783
+ // are still being prepared. Retention cleanup belongs to maintenance.
784
+ await this.applyRecoveryPolicies(changed, { prune: !this.seedStorage })
785
+ this.assertOpen()
786
+ for (const pubkey of watchPubkeys) {
787
+ this.desiredChannels.add(pubkey)
788
+ this.recordRecoveryWindow(pubkey)
789
+ }
790
+ if (this.pauseReasons.size) this.recordInterruption(watchPubkeys)
791
+ await this.flushStateWrites()
792
+ this.ensureNetworkWatchers()
751
793
  if (this.storagePolicyRevision === storageSnapshot.policyRevision) {
752
794
  this.storagePolicyNeedsApply = false
753
795
  }
754
- return this
796
+ return this.startChannelBackground(watchPubkeys)
755
797
  }
756
798
 
757
799
  async normalizeChannels (channels, defaults) {
@@ -769,7 +811,7 @@ export class PrivateMessenger {
769
811
  if (!signer && !readerSigner) throw new ValidationError('CHANNEL_SIGNER_REQUIRED')
770
812
  const mode = channel.mode || defaults.mode || 'leecher'
771
813
  if (!signer && doesModeStoreRecoverySeeds(mode)) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
772
- const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || await readerSigner?.getPublicKey?.() || pubkey
814
+ const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || (readerSigner === signer ? pubkey : await readerSigner?.getPublicKey?.()) || pubkey
773
815
  const autoDeletionCapability = channel.autoDeletionCapability === undefined
774
816
  ? undefined
775
817
  : normalizeAutoDeletionCapability(channel.autoDeletionCapability)
@@ -801,8 +843,8 @@ export class PrivateMessenger {
801
843
  return this._pickRelaysForPubkeys(pubkeys, relaysByPubkey, { relayType: 'read' })
802
844
  }
803
845
 
804
- async readRelaysForPubkey (pubkey) {
805
- const relaysByPubkey = await this._getRelaysByPubkey([pubkey])
846
+ async readRelaysForPubkey (pubkey, options) {
847
+ const relaysByPubkey = await this._getRelaysByPubkey([pubkey], options)
806
848
  const readRelays = uniq(relaysByPubkey?.[pubkey]?.read)
807
849
  if (readRelays.length) return readRelays
808
850
  return relayMapRelays(this._pickRelaysForPubkeys([pubkey], relaysByPubkey, { relayType: 'read' }))
@@ -820,8 +862,8 @@ export class PrivateMessenger {
820
862
  }
821
863
  }
822
864
 
823
- async resolveWatchRelays (channel) {
824
- const primary = !channel.usesNip65WatchRelays && channel.relays.length ? channel.relays : await this.readRelaysForPubkey(this.userPubkey)
865
+ async resolveWatchRelays (channel, options) {
866
+ const primary = !channel.usesNip65WatchRelays && channel.relays.length ? channel.relays : await this.readRelaysForPubkey(this.userPubkey, options)
825
867
  return uniq([...primary, ...this.fallbackRelays].map(normalizeRelayUrl))
826
868
  }
827
869
 
@@ -841,6 +883,8 @@ export class PrivateMessenger {
841
883
  peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
842
884
  signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
843
885
  exclusions: this.sendRelayExclusions.get(key).exclusions,
886
+ fallbackDelayMs: this.fallbackDelayMs, pauseSignal: this.sendRoutingPause.signal,
887
+ setTimer: this._setTimeout, clearTimer: this._clearTimeout,
844
888
  pickRelays: this._pickRelaysForPubkeys,
845
889
  publish: this._privateChannel.publish || privateChannel.publish,
846
890
  publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
@@ -1119,7 +1163,7 @@ export class PrivateMessenger {
1119
1163
  this.writeState(state)
1120
1164
  }
1121
1165
 
1122
- async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true } = {}) {
1166
+ async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true, signal } = {}) {
1123
1167
  this.assertOpen()
1124
1168
  const channelPubkeys = uniq(channels)
1125
1169
  for (const pubkey of channelPubkeys) {
@@ -1143,13 +1187,15 @@ export class PrivateMessenger {
1143
1187
  await this.flushStateWrites()
1144
1188
  let stop; let watchRelays
1145
1189
  try {
1146
- watchRelays = await this.resolveWatchRelays(channel)
1190
+ signal?.throwIfAborted()
1191
+ watchRelays = await this.resolveWatchRelays(channel, { signal })
1147
1192
  this.assertOpen()
1148
1193
  if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) continue
1149
1194
  stop = await this._privateMessage.watch({
1150
1195
  channels: [pubkey],
1151
1196
  relays: watchRelays,
1152
1197
  receiverSigner: this.userSigner,
1198
+ receiverPubkey: this.userPubkey,
1153
1199
  iykcSigner: this.contentKeySigner,
1154
1200
  privateChannelSigner: channel.signer,
1155
1201
  privateChannelReaderSigner: channel.readerSigner,
@@ -1169,6 +1215,7 @@ export class PrivateMessenger {
1169
1215
  onError: err => this.onError?.(err)
1170
1216
  })
1171
1217
  } catch (error) {
1218
+ if (signal?.aborted) continue
1172
1219
  const transient = isRetryableRelayFailure(error)
1173
1220
  const remote = error?.name !== 'Nip42AuthenticationError' && (error?.category === 'relay' || isReplaceableRelayFailure(error))
1174
1221
  if (!transient && !remote) throw error
@@ -1650,6 +1697,8 @@ export class PrivateMessenger {
1650
1697
  stopWatches (channels = [...this.stopByChannel.keys()]) {
1651
1698
  const closing = []
1652
1699
  for (const pubkey of channels) {
1700
+ this.channelBackground.get(pubkey)?.controller.abort()
1701
+ this.channelBackground.delete(pubkey)
1653
1702
  this.cancelReloadGap(pubkey)
1654
1703
  this.cancelLiveRecovery(pubkey)
1655
1704
  this.liveInterruptions.delete(pubkey)
@@ -1780,6 +1829,8 @@ export class PrivateMessenger {
1780
1829
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1781
1830
  this.assertOpen()
1782
1831
  this.pauseReasons.add(reason)
1832
+ this.pauseRevisions.set(reason, (this.pauseRevisions.get(reason) || 0) + 1)
1833
+ this.sendRoutingPause.abort(this.pausedError())
1783
1834
  this.notifyStatus()
1784
1835
  for (const extension of this.extensions) extension.pause?.()
1785
1836
  this.recordInterruption(this.desiredChannels)
@@ -1787,45 +1838,94 @@ export class PrivateMessenger {
1787
1838
  return Promise.all([this.stopWatches([...this.desiredChannels]), this.flushStateWrites()])
1788
1839
  }
1789
1840
 
1790
- async resume (reason) {
1841
+ async resume (reason, { waitForBackground = false } = {}) {
1791
1842
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1843
+ if (typeof waitForBackground !== 'boolean') throw new ValidationError('INVALID_WAIT_FOR_BACKGROUND')
1792
1844
  this.assertOpen()
1845
+ const revision = this.pauseRevisions.get(reason)
1793
1846
  // Repair durable state before announcing that publication can resume.
1794
1847
  if (reason === 'storage' && this.pendingStorageWrites.size) await this.retryStorageWrites(this.sendRoutingLifetime.signal)
1795
1848
  await this.flushStateWrites()
1796
1849
  this.assertOpen()
1797
- const wasAutomatic = this.automaticPauses.has(reason)
1850
+ if (revision !== this.pauseRevisions.get(reason)) return
1798
1851
  const removed = this.pauseReasons.delete(reason)
1799
1852
  this.automaticPauses.delete(reason)
1853
+ if (!this.pauseReasons.size && this.sendRoutingPause.signal.aborted) this.sendRoutingPause = new AbortController()
1800
1854
  this.notifyStatus()
1801
1855
  if (reason === 'capacity' && removed) {
1802
1856
  clearInterval(this.capacityTimer)
1803
1857
  this.capacityTimer = null
1804
1858
  this.capacityRequiredBytes = 0
1805
1859
  }
1806
- if (!removed || this.pauseReasons.size) return
1807
- if (this.resumeWork) await this.resumeWork
1808
- if (this.pauseReasons.size || this.closePromise) return
1809
- const work = (async () => {
1810
- const channels = [...this.desiredChannels]
1860
+ if (this.pauseReasons.size) return
1861
+ const channels = [...this.desiredChannels]
1862
+ if (removed) {
1811
1863
  this.closeOpenOfflineRanges(channels)
1812
- await this.watch(channels, { scheduleReloadGap: false })
1813
- if (this.pauseReasons.size || this.closePromise) return
1814
- await this.reconcilePresencePublishers()
1815
- await this.recoverOfflineRanges(channels)
1816
- for (const pubkey of channels) {
1817
- if (this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
1818
- }
1819
- for (const extension of this.extensions) await extension.resume?.()
1820
- })()
1821
- this.resumeWork = work
1822
- try { await work } catch (err) {
1823
- if (!this.closePromise) {
1824
- if (wasAutomatic && (reason === 'network' ? isRetryableRelayFailure(err) : isRecoverableStorageFailure(err))) this.automaticPauses.add(reason)
1825
- await this.applyPause(reason)
1864
+ await this.flushStateWrites()
1865
+ }
1866
+ if (this.pauseReasons.size || this.closePromise) return
1867
+ const jobs = removed ? this.startChannelBackground(channels, { recover: true }) : [...this.channelBackground.values()].map(job => job.work)
1868
+ if (removed) {
1869
+ for (const extension of this.extensions) {
1870
+ const work = Promise.resolve().then(() => { if (!this.pauseReasons.size && !this.closePromise) return extension.resume?.() })
1871
+ work.catch(error => this.onError?.(error))
1872
+ jobs.push(work)
1826
1873
  }
1827
- throw err
1828
- } finally { if (this.resumeWork === work) this.resumeWork = null }
1874
+ }
1875
+ if (waitForBackground) await Promise.all(jobs)
1876
+ }
1877
+
1878
+ startChannelBackground (channels, { recover = false } = {}) {
1879
+ const jobs = []
1880
+ for (const pubkey of channels) {
1881
+ if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey)) continue
1882
+ let job = this.channelBackground.get(pubkey)
1883
+ if (job) { jobs.push(job.work); continue }
1884
+ const controller = new AbortController()
1885
+ const channel = this.channels.get(pubkey)
1886
+ job = { controller, work: null }
1887
+ this.channelBackground.set(pubkey, job)
1888
+ const active = () => this.initialized && !controller.signal.aborted && !this.closePromise && !this.pauseReasons.size && this.channels.get(pubkey) === channel && this.desiredChannels.has(pubkey)
1889
+ let phase = 'subscribe'
1890
+ let stopAbort
1891
+ const cancelled = new Promise(resolve => {
1892
+ stopAbort = resolve
1893
+ controller.signal.addEventListener('abort', stopAbort, { once: true })
1894
+ })
1895
+ const work = Promise.resolve().then(async () => {
1896
+ if (!this.initialized) await this.initSettledPromise
1897
+ if (!active()) return
1898
+ const release = await this.channelSetupAdmission.acquire(controller.signal)
1899
+ try {
1900
+ if (!active()) return
1901
+ await this.watch([pubkey], { scheduleReloadGap: !recover, signal: controller.signal })
1902
+ } finally { release() }
1903
+ if (!active()) return
1904
+ phase = 'presence'
1905
+ if (doesModeStoreRecoverySeeds(channel.mode)) await this.startPresencePublisher(pubkey)
1906
+ if (!active()) return
1907
+ phase = 'history'
1908
+ if (recover) await this.recoverOfflineRanges([pubkey])
1909
+ if (!active()) return
1910
+ if (recover && this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
1911
+ }).catch(error => {
1912
+ if (!active()) return
1913
+ const diagnostic = Object.assign(new Error(error?.message || 'PRIVATE_MESSENGER_BACKGROUND_FAILED', { cause: error }), {
1914
+ name: error?.name || 'Error', ...(error?.code ? { code: error.code } : {}),
1915
+ ...(error?.category ? { category: error.category } : {}),
1916
+ operation: 'private-messenger.background', phase
1917
+ })
1918
+ this.onError?.(diagnostic)
1919
+ throw error
1920
+ })
1921
+ job.work = Promise.race([work, cancelled]).finally(() => {
1922
+ controller.signal.removeEventListener('abort', stopAbort)
1923
+ if (this.channelBackground.get(pubkey) === job) this.channelBackground.delete(pubkey)
1924
+ })
1925
+ job.work.catch(() => {})
1926
+ jobs.push(job.work)
1927
+ }
1928
+ return jobs
1829
1929
  }
1830
1930
 
1831
1931
  receive (pubkey, operation, message = {}) {
@@ -2329,16 +2429,16 @@ export class PrivateMessenger {
2329
2429
  })
2330
2430
  }
2331
2431
 
2332
- async publishSeederPresence (channelPubkey = this.defaultChannelPubkey()) {
2432
+ async publishSeederPresence (channelPubkey = this.defaultChannelPubkey(), { signal } = {}) {
2333
2433
  const channel = this.requireWritableChannel(channelPubkey)
2334
2434
  if (!this.offlineRecoverySecondsFor(channel)) return null
2335
2435
  const receiverPubkeys = uniq([...this.knownSeeders(channelPubkey), this.userPubkey])
2336
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys })
2436
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, signal })
2337
2437
  this.debugSend('yell', channelPubkey, { code: SEEDER_PRESENCE_CODE, receiverPubkeys })
2338
2438
  return this._privateMessage.yell({
2339
- senderSigner: this.userSigner,
2340
- imkcSigner: this.contentKeySigner,
2341
- privateChannelSigner: channel.signer,
2439
+ senderSigner: backgroundSigner(this.userSigner, this._waitForForeground, signal),
2440
+ imkcSigner: backgroundSigner(this.contentKeySigner, this._waitForForeground, signal),
2441
+ privateChannelSigner: backgroundSigner(channel.signer, this._waitForForeground, signal),
2342
2442
  privateChannelReaderPubkey: channel.readerPubkey,
2343
2443
  receiverPubkeys,
2344
2444
  ...routing,
@@ -2355,22 +2455,40 @@ export class PrivateMessenger {
2355
2455
  async startPresencePublisher (channelPubkey) {
2356
2456
  if (this.pauseReasons.size || !this.desiredChannels.has(channelPubkey) || !this.offlineRecoverySecondsFor(channelPubkey)) return
2357
2457
  if (this.presenceTimers.has(channelPubkey)) return
2358
- try {
2359
- await this.publishSeederPresence(channelPubkey)
2360
- } catch (err) {
2361
- console.warn('private-messenger seeder presence failed', err?.message ?? err)
2458
+ if (this.presenceJobs.has(channelPubkey)) return this.presenceJobs.get(channelPubkey).work
2459
+ const job = { controller: new AbortController(), work: null }
2460
+ const active = () => this.presenceJobs.get(channelPubkey) === job && !this.pauseReasons.size && !this.closePromise && this.desiredChannels.has(channelPubkey)
2461
+ let publishing = false
2462
+ const publish = async () => {
2463
+ if (!active() || publishing) return
2464
+ publishing = true
2465
+ let release
2466
+ try {
2467
+ release = await this.presenceAdmission.acquire(job.controller.signal)
2468
+ job.controller.signal.addEventListener('abort', release, { once: true })
2469
+ if (active()) await this.publishSeederPresence(channelPubkey, { signal: job.controller.signal })
2470
+ } catch (error) {
2471
+ if (active()) this.onError?.(Object.assign(new Error(error?.message, { cause: error }), { operation: 'private-messenger.background', phase: 'presence' }))
2472
+ } finally {
2473
+ if (release) { job.controller.signal.removeEventListener('abort', release); release() }
2474
+ publishing = false
2475
+ }
2362
2476
  }
2363
- if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(channelPubkey)) return
2364
- const timer = this._setInterval(() => {
2365
- return this.publishSeederPresence(channelPubkey).catch(err => {
2366
- console.warn('private-messenger seeder presence failed', err?.message ?? err)
2367
- })
2368
- }, this.seederPresenceIntervalMs)
2369
- timer?.unref?.()
2370
- this.presenceTimers.set(channelPubkey, timer)
2477
+ const work = Promise.resolve().then(async () => {
2478
+ await publish()
2479
+ if (!active()) return
2480
+ const timer = this._setInterval(publish, this.seederPresenceIntervalMs)
2481
+ timer?.unref?.()
2482
+ this.presenceTimers.set(channelPubkey, timer)
2483
+ })
2484
+ job.work = work
2485
+ this.presenceJobs.set(channelPubkey, job)
2486
+ return work
2371
2487
  }
2372
2488
 
2373
2489
  stopPresencePublisher (channelPubkey) {
2490
+ this.presenceJobs.get(channelPubkey)?.controller.abort()
2491
+ this.presenceJobs.delete(channelPubkey)
2374
2492
  const timer = this.presenceTimers.get(channelPubkey)
2375
2493
  if (timer) this._clearInterval(timer)
2376
2494
  this.presenceTimers.delete(channelPubkey)
@@ -2451,7 +2569,7 @@ export class PrivateMessenger {
2451
2569
  return seconds ? seconds * 1000 : DEFAULT_RECEIVED_CHUNK_TTL_MS
2452
2570
  }
2453
2571
 
2454
- async applyRecoveryPolicies (channels) {
2572
+ async applyRecoveryPolicies (channels, { prune = true } = {}) {
2455
2573
  return this.runQueueOperation(async () => {
2456
2574
  const state = this.readState()
2457
2575
  const now = nowSeconds()
@@ -2477,7 +2595,7 @@ export class PrivateMessenger {
2477
2595
  for (const channel of channels) {
2478
2596
  if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryState(channel.pubkey)
2479
2597
  }
2480
- for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
2598
+ if (prune) for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
2481
2599
  })
2482
2600
  }
2483
2601
 
@@ -3063,6 +3181,7 @@ export class PrivateMessenger {
3063
3181
  let unwatchError
3064
3182
  try { await unwatchPromise } catch (err) { unwatchError = err }
3065
3183
  await initSettledPromise
3184
+ await this.updateTail.catch(() => {})
3066
3185
  await Promise.all([...this.extensions].map(extension => extension.close?.()))
3067
3186
  await this.stampActiveChannelActivity()
3068
3187
  await this.queueOperationTail
@@ -15,10 +15,35 @@ back to sender-content whenever a proof is missing or the lookup fails. Misses
15
15
  are cached for 5 minutes (found proofs for 40), so a later prefetch picks up a
16
16
  key published after an earlier miss. Set it to `false` when the signer owns that discovery. The DM context is `dm`; file contexts are `dm:media:<root>`.
17
17
  `createMessengerSigner` adapts this byte signer to the transport's Base64 API.
18
+ Each adapter belongs to an immutable account/channel identity: it shares the
19
+ first successful public-key lookup and retries failed lookups only on a new call.
20
+ Create a new adapter when changing identity. Signing/encryption still uses the
21
+ real signer and its current permissions on every operation.
18
22
 
19
- Methods: `setPeers`, `setAvailable(boolean)`, `prefetchContentKeys(peers?)`,
23
+ Methods: `setPeers`, `setAvailable(boolean)`, `preparePeer(peer)`, `prefetchContentKeys(peers?)`,
20
24
  `prioritizeRange(peer, options)`, `enqueue`, `retry`, `cancel`, `download`,
21
25
  `cancelDownload`, and `close`.
26
+
27
+ The session creates one messenger for the account, independently of contact
28
+ revisions. `setPeers` and `setAvailable` reconcile local configuration without
29
+ awaiting all contacts or remote recovery. Channel preparation is incremental and
30
+ deduplicated: at most four preparations run, with at most three in the background.
31
+ `preparePeer(peer)` promotes an existing contact and resolves when its channel is
32
+ locally writable. Call it when opening a conversation; enqueue, retry and remote
33
+ download also request preparation automatically. An unprepared conversation does
34
+ not block local saves or sends to a ready contact. Resolved identities are reused.
35
+ The session also defers the next optional presence signer call while user outbox
36
+ writes/publications are active. Existing signer calls, subscriptions and historical
37
+ coverage remain intact; releasing foreground work resumes the waiting presence.
38
+ Successful sends remove the final outbox record directly instead of encrypting a
39
+ redundant final checkpoint; intermediate context/file checkpoints remain durable.
40
+
41
+ Known transient preparation failures retry with bounded offline-aware backoff;
42
+ unknown, permission and validation failures remain isolated to that contact until
43
+ explicit preparation/retry or a real availability transition. Removing a contact,
44
+ locking or closing discards obsolete signer results and cancels its queued work.
45
+ Contact changes never invent a `signer` pause; only `setAvailable` controls that
46
+ reason. Storage, capacity, network and explicit pauses keep their own owners.
22
47
  `prefetchContentKeys` warms the content-key lookup cache for the owner and the
23
48
  given peers (default: current peers); it is best-effort and never rejects.
24
49
  `prioritizeRange` registers an in-memory priority window for a conversation:
@@ -110,7 +135,7 @@ const session = createPrivateMessageSession({
110
135
  })
111
136
  ```
112
137
 
113
- Automatic sends try each recipient's NIP-65 read relays first, at most two per
138
+ By default, automatic sends try each recipient's NIP-65 read relays first, at most two per
114
139
  recipient per attempt, then the configured fallbacks in pairs. Explicit
115
140
  per-call `relays`, channel `sendRelays`/`relays` and global `relays` remain primary
116
141
  and also support configured fallbacks, including fixed multi-recipient sends.
@@ -122,6 +147,19 @@ and encrypted recipient subsets, without consulting recipient NIP-65 lists.
122
147
  The session coordinator remains a DM API; multi-recipient channels use
123
148
  `createPrivateMessenger` directly.
124
149
 
150
+ Optional `fallbackDelayMs` defaults to `null` (sequential primary exhaustion).
151
+ A nonnegative integer up to 2147483647 opts automatic NIP-65 routing into early
152
+ fallback; explicit relay lists/maps retain the sequential policy. With `3000`,
153
+ start at most two eligible fallback relays after three seconds without complete
154
+ acceptance, or immediately after primary exhaustion. The deadline belongs to
155
+ one signed outer event; later batches do not renew it. Primary operations retain
156
+ their deadlines and can still win. Failed fallback operations leave primary
157
+ alternatives available; no relay is tried twice. Multiple recipients require
158
+ accepted coverage of every encrypted recipient subset. No wrapper, ID, recipient
159
+ tag or deletion capability is recreated. Confirmed connectivity, exclusions and
160
+ connection cooldowns remain authoritative; a waiting primary is not penalized
161
+ merely because fallback started. Late native failures may inform later sends.
162
+
125
163
  On `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or `error`
126
164
  rejections, or connection/transport/timeout failures, replacement requires
127
165
  `isOnline` to confirm connectivity. Invalid messages, local signer/authentication
@@ -141,7 +179,8 @@ publication report. A shared relay's first ACK covers all members assigned to
141
179
  that publication batch. If replacements use different relays for different
142
180
  members, every pending member must be covered before the outer event succeeds;
143
181
  a partial ACK cannot hide another member's failure. Remaining primary routes are
144
- exhausted before configured fallbacks. Redundant acknowledgements may update
182
+ exhausted before configured fallbacks with the default null delay or explicit
183
+ routes. Redundant acknowledgements may update
145
184
  future routing preferences without delaying a successfully covered send. Preferences are channel/recipient-scoped, in-memory,
146
185
  expire after five minutes, and never alter receive subscriptions. A fresh attempt
147
186
  can recheck an exhausted list. Additional fallback relays come only from the
@@ -159,3 +198,12 @@ relays. Validation/authorization failures are terminal without relay rotation.
159
198
  `broadcastRumor`/`broadcastEvent` accept a `signal` to stop further replacement
160
199
  publications; the session supplies it and aborts it on cancel/close. An already
161
200
  published event cannot be recalled by cancellation.
201
+
202
+ `RelayPool.sendEvent(event, relays, { signal })` supports optional cancellation.
203
+ It returns the existing result/report envelopes with the original abort reason
204
+ for unfinished relays, preserving partial acceptance and avoiding synthetic
205
+ timeouts. Cancellation releases publication waiters and deferred sends without
206
+ closing shared sockets or cancelling another subscriber for the same event ID.
207
+ Operations already transmitted cannot be recalled from relays. A messenger pause
208
+ cancels its outstanding publication work and leaves the outbox pending; resume
209
+ creates a fresh publication lifetime, without treating the pause as a user abort.
@@ -0,0 +1,29 @@
1
+ // Local outbox persistence and user publications take precedence over the next
2
+ // optional presence signer call. Already-started signer calls are not recalled.
3
+ export function createForegroundWork () {
4
+ let active = 0
5
+ const waiters = new Set()
6
+ const lifetime = new AbortController()
7
+ return {
8
+ enter () {
9
+ active++
10
+ let released = false
11
+ return () => {
12
+ if (released) return
13
+ released = true
14
+ if (--active === 0) for (const wake of [...waiters]) wake()
15
+ }
16
+ },
17
+ wait (signal) {
18
+ const combined = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal
19
+ combined.throwIfAborted()
20
+ if (!active) return Promise.resolve()
21
+ return new Promise((resolve, reject) => {
22
+ const finish = () => { waiters.delete(finish); combined.removeEventListener('abort', finish); combined.aborted ? reject(combined.reason) : resolve() }
23
+ waiters.add(finish)
24
+ combined.addEventListener('abort', finish, { once: true })
25
+ })
26
+ },
27
+ close () { lifetime.abort(); waiters.clear() }
28
+ }
29
+ }