libp2r2p 0.11.18 → 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.
package/README.md CHANGED
@@ -535,7 +535,8 @@ new channels are watched unless paused. Applications may explicitly watch again.
535
535
 
536
536
  Pauses, unwatch and close record the start of the gap, even for channels that
537
537
  have never received a message. Rewatch/resume uses live delivery plus historical
538
- recovery with overlap. A failed resume remains retryable under its pause reason.
538
+ recovery with overlap. Remote recovery failures never restore the caller's pause
539
+ reason: signer availability belongs exclusively to the account owner.
539
540
  Before opening a watch, the messenger persists a pending recovery interval.
540
541
  A first watch scans the configured recovery window (seven days by default),
541
542
  including messages sent before this identity first opened the channel. Successful
@@ -546,6 +547,24 @@ This also covers abrupt termination without `close()` and channels with no
546
547
  messages. Applications still supply signers and desired channels on reopening;
547
548
  storage is scoped by origin and user pubkey, not the transient session ID.
548
549
  Failed ingestion or incomplete relay fetch reports never complete the gap.
550
+
551
+ `init()` and `createPrivateMessenger()` resolve after local preparation. Likewise,
552
+ `update(options, { waitForBackground = false })` and
553
+ `resume(reason, { waitForBackground = false })` wait for valid local channel
554
+ configuration and mandatory durable writes, allowing sends independently of
555
+ subscriptions, presence and history. Set `waitForBackground: true` to also await
556
+ the initial background tasks associated with that call, not every future retry.
557
+ Both modes share tasks; unchanged configuration does not recreate subscriptions.
558
+ Later errors reach `onError` with the original error as `cause` and
559
+ `operation: 'private-messenger.background'` plus a `phase`. Cancelled or obsolete
560
+ tasks cannot deliver into a new channel generation. Mandatory persistence still
561
+ owns its storage pause; incomplete history remains a pending recovery interval.
562
+ Subscription setup is bounded separately from presence, so a slow announcement
563
+ does not hold the other channels' readers. Sessions prioritize user persistence
564
+ and publication before the next background presence signer call. Signing an outer
565
+ envelope can overlap recovery-seed persistence, but no envelope is yielded for
566
+ publication unless both succeed; only one outer is prepared at a time.
567
+
549
568
  `private-channel.fetch` reports incomplete reads as an `AggregateError` with
550
569
  stable `code: 'PRIVATE_CHANNEL_FETCH_INCOMPLETE'`. Its message names relays,
551
570
  completion statuses and native failure messages, including status-only failures.
@@ -1,6 +1,6 @@
1
1
  // A bounded FIFO for asynchronous work. Acquired leases release explicitly;
2
2
  // queued cancellations never consume capacity or keep idle keyed gates alive.
3
- export function createAbortableSemaphore (capacity, onIdle = () => {}) {
3
+ export function createAbortableSemaphore (capacity, onIdle = () => {}, { maxQueued = 256 } = {}) {
4
4
  const queue = []
5
5
  let active = 0
6
6
  const drain = () => {
@@ -21,7 +21,7 @@ export function createAbortableSemaphore (capacity, onIdle = () => {}) {
21
21
  return {
22
22
  acquire (signal) {
23
23
  signal?.throwIfAborted()
24
- if (queue.length >= 256) return Promise.reject(Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_QUEUE_FULL'), { code: 'PRIVATE_CHANNEL_HISTORY_QUEUE_FULL' }))
24
+ if (queue.length >= maxQueued) return Promise.reject(Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_QUEUE_FULL'), { code: 'PRIVATE_CHANNEL_HISTORY_QUEUE_FULL' }))
25
25
  return new Promise((resolve, reject) => {
26
26
  const job = {
27
27
  signal, resolve, abort: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.18",
3
+ "version": "0.11.19",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -158,7 +158,8 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
158
158
  const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
159
159
  const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
160
160
  const total = preparedChunkCount(context.preparedRows, rowIndexes)
161
- if (onPreparedSeed) {
161
+ const persistSeeds = async () => {
162
+ if (!onPreparedSeed) return
162
163
  const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
163
164
  const payloadRow = readPreparedRow(context.preparedRows, 0)
164
165
  for (const rowIndex of rowIndexes) {
@@ -179,12 +180,18 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
179
180
  content
180
181
  }), routerSeckey)
181
182
  const createdAt = nowSeconds()
182
- const outer = await privateChannelSigner.signEvent({
183
+ const prepareOuter = async () => privateChannelSigner.signEvent({
183
184
  kind: PRIVATE_BROADCAST_KIND,
184
185
  created_at: createdAt,
185
186
  tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
186
187
  content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
187
188
  })
189
+ // These independent steps may run together, but no envelope is yielded
190
+ // until every seed is durable. Settle both before releasing prepared rows.
191
+ const [persistence, prepared] = await Promise.allSettled([index === 1 ? persistSeeds() : undefined, prepareOuter()])
192
+ if (persistence.status === 'rejected') throw persistence.reason
193
+ if (prepared.status === 'rejected') throw prepared.reason
194
+ const outer = prepared.value
188
195
  if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
189
196
  yield outer
190
197
  }
@@ -0,0 +1,16 @@
1
+ // Keep actual permissions and cryptography in the signer. Background presence
2
+ // only yields before its next RPC, without interrupting an operation in flight.
3
+ export function backgroundSigner (signer, wait, signal) {
4
+ if (!signer || !wait) return signer
5
+ return new Proxy(signer, {
6
+ get (target, key) {
7
+ const value = Reflect.get(target, key, target)
8
+ if (typeof value !== 'function') return value
9
+ return async (...args) => {
10
+ await wait(signal)
11
+ signal?.throwIfAborted()
12
+ return value.apply(target, args)
13
+ }
14
+ }
15
+ })
16
+ }
@@ -48,6 +48,7 @@ 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 { backgroundSigner } from './helpers/background-signer.js'
51
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'
@@ -243,6 +244,7 @@ export class PrivateMessenger {
243
244
  _pickRelaysForPubkeys = pickRelaysForPubkeys,
244
245
  _subscribeRelayListUpdates = subscribeRelayListUpdates,
245
246
  _isOnline = isOnline,
247
+ _waitForForeground,
246
248
  _onOnline = onOnline,
247
249
  _random = Math.random,
248
250
  _setTimeout = globalThis.setTimeout.bind(globalThis),
@@ -304,6 +306,7 @@ export class PrivateMessenger {
304
306
  this._pickRelaysForPubkeys = _pickRelaysForPubkeys
305
307
  this._subscribeRelayListUpdates = _subscribeRelayListUpdates
306
308
  this._isOnline = _isOnline
309
+ this._waitForForeground = _waitForForeground
307
310
  this.sendRelayExclusions = new Map()
308
311
  this.sendRoutingLifetime = new AbortController()
309
312
  this.sendRoutingPause = new AbortController()
@@ -341,7 +344,13 @@ export class PrivateMessenger {
341
344
  this.recoveryRanges = new Map()
342
345
  this.recoveryPriority = new Map()
343
346
  this.recoveryOnline = { at: 0, value: true }
344
- 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()
345
354
  this.capacityTimer = null
346
355
  this.capacityCheck = null
347
356
  this.capacityRequiredBytes = 0
@@ -350,6 +359,7 @@ export class PrivateMessenger {
350
359
  this.liveRecoveryTimers = new Map()
351
360
  this.watchRevisionByChannel = new Map()
352
361
  this.presenceTimers = new Map()
362
+ this.presenceJobs = new Map()
353
363
  this.stopRelayListWatcher = null
354
364
  this.relayListWatcherPubkey = ''
355
365
  this.relayListRefreshPromise = null
@@ -435,7 +445,6 @@ export class PrivateMessenger {
435
445
  this.startStorageMaintenance()
436
446
  this.state = { channels: await this.stateStore.load() }
437
447
  await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
438
- await this.pruneStoredSeeds()
439
448
  this.initialized = true
440
449
  await this.refreshAndApplyStoragePolicy()
441
450
  this.broadcastStoragePolicyChange()
@@ -673,7 +682,16 @@ export class PrivateMessenger {
673
682
  })
674
683
  }
675
684
 
676
- 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) {
677
695
  this.assertOpen()
678
696
  const {
679
697
  userSigner = this.userSigner,
@@ -692,15 +710,17 @@ export class PrivateMessenger {
692
710
  let nextIdentityStorageRetentionSeconds = updatesIdentityPolicy
693
711
  ? normalizeIdentityStorageRetentionSeconds(options.identityStorageRetentionSeconds)
694
712
  : this.identityStorageRetentionSeconds
695
- if (userSigner) {
713
+ if (userSigner && (userSigner !== this.userSigner || !this.userPubkey)) {
696
714
  const userPubkey = await userSigner.getPublicKey?.()
697
715
  if (!userPubkey) throw new ValidationError('USER_SIGNER_REQUIRED')
698
716
  if (this.userPubkey && userPubkey !== this.userPubkey) throw new ValidationError('USER_SIGNER_MISMATCH')
699
717
  this.userSigner = userSigner
700
718
  }
719
+ if (contentKeySigner !== this.contentKeySigner || (!this.contentKeyPubkey && contentKeySigner)) {
720
+ this.contentKeyPubkey = await contentKeySigner?.getPublicKey?.() || ''
721
+ }
701
722
  this.contentKeySigner = contentKeySigner || null
702
723
  this.nymSigner = nymSigner || null
703
- this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
704
724
  const nextChannels = await this.normalizeChannels(channels, { relays, mode })
705
725
  this.assertOpen()
706
726
  const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
@@ -709,9 +729,11 @@ export class PrivateMessenger {
709
729
  return !previous || ['signer', 'readerSigner', 'readerPubkey', 'nymSigner', 'mode', 'usesNip65WatchRelays', 'offlineRecoverySeconds', 'autoDeletionCapability'].some(key => previous[key] !== channel[key]) ||
710
730
  ['relays', 'sendRelays', 'seeders'].some(key => JSON.stringify(previous[key]) !== JSON.stringify(channel[key]))
711
731
  }
712
- 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)
713
734
  const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
714
735
  const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
736
+ if (!changed.length && !removedPubkeys.length && !updatesStoragePolicy) return [...this.channelBackground.values()].map(job => job.work)
715
737
 
716
738
  if (updatesStoragePolicy) {
717
739
  const currentPolicy = await this.readStoragePolicySnapshot()
@@ -743,18 +765,35 @@ export class PrivateMessenger {
743
765
  if (updatesStoragePolicy) this.broadcastStoragePolicyChange()
744
766
 
745
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
+ }
746
778
  for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
747
779
  for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
748
- for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
780
+ for (const channel of changed) this.channels.set(channel.pubkey, channel)
749
781
 
750
- await this.cleanupStaleChannels({ storageSnapshot })
751
- await this.applyRecoveryPolicies(nextChannels)
752
- await this.watch(watchPubkeys)
753
- 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()
754
793
  if (this.storagePolicyRevision === storageSnapshot.policyRevision) {
755
794
  this.storagePolicyNeedsApply = false
756
795
  }
757
- return this
796
+ return this.startChannelBackground(watchPubkeys)
758
797
  }
759
798
 
760
799
  async normalizeChannels (channels, defaults) {
@@ -772,7 +811,7 @@ export class PrivateMessenger {
772
811
  if (!signer && !readerSigner) throw new ValidationError('CHANNEL_SIGNER_REQUIRED')
773
812
  const mode = channel.mode || defaults.mode || 'leecher'
774
813
  if (!signer && doesModeStoreRecoverySeeds(mode)) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
775
- const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || await readerSigner?.getPublicKey?.() || pubkey
814
+ const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || (readerSigner === signer ? pubkey : await readerSigner?.getPublicKey?.()) || pubkey
776
815
  const autoDeletionCapability = channel.autoDeletionCapability === undefined
777
816
  ? undefined
778
817
  : normalizeAutoDeletionCapability(channel.autoDeletionCapability)
@@ -804,8 +843,8 @@ export class PrivateMessenger {
804
843
  return this._pickRelaysForPubkeys(pubkeys, relaysByPubkey, { relayType: 'read' })
805
844
  }
806
845
 
807
- async readRelaysForPubkey (pubkey) {
808
- const relaysByPubkey = await this._getRelaysByPubkey([pubkey])
846
+ async readRelaysForPubkey (pubkey, options) {
847
+ const relaysByPubkey = await this._getRelaysByPubkey([pubkey], options)
809
848
  const readRelays = uniq(relaysByPubkey?.[pubkey]?.read)
810
849
  if (readRelays.length) return readRelays
811
850
  return relayMapRelays(this._pickRelaysForPubkeys([pubkey], relaysByPubkey, { relayType: 'read' }))
@@ -823,8 +862,8 @@ export class PrivateMessenger {
823
862
  }
824
863
  }
825
864
 
826
- async resolveWatchRelays (channel) {
827
- 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)
828
867
  return uniq([...primary, ...this.fallbackRelays].map(normalizeRelayUrl))
829
868
  }
830
869
 
@@ -1124,7 +1163,7 @@ export class PrivateMessenger {
1124
1163
  this.writeState(state)
1125
1164
  }
1126
1165
 
1127
- async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true } = {}) {
1166
+ async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true, signal } = {}) {
1128
1167
  this.assertOpen()
1129
1168
  const channelPubkeys = uniq(channels)
1130
1169
  for (const pubkey of channelPubkeys) {
@@ -1148,13 +1187,15 @@ export class PrivateMessenger {
1148
1187
  await this.flushStateWrites()
1149
1188
  let stop; let watchRelays
1150
1189
  try {
1151
- watchRelays = await this.resolveWatchRelays(channel)
1190
+ signal?.throwIfAborted()
1191
+ watchRelays = await this.resolveWatchRelays(channel, { signal })
1152
1192
  this.assertOpen()
1153
1193
  if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) continue
1154
1194
  stop = await this._privateMessage.watch({
1155
1195
  channels: [pubkey],
1156
1196
  relays: watchRelays,
1157
1197
  receiverSigner: this.userSigner,
1198
+ receiverPubkey: this.userPubkey,
1158
1199
  iykcSigner: this.contentKeySigner,
1159
1200
  privateChannelSigner: channel.signer,
1160
1201
  privateChannelReaderSigner: channel.readerSigner,
@@ -1174,6 +1215,7 @@ export class PrivateMessenger {
1174
1215
  onError: err => this.onError?.(err)
1175
1216
  })
1176
1217
  } catch (error) {
1218
+ if (signal?.aborted) continue
1177
1219
  const transient = isRetryableRelayFailure(error)
1178
1220
  const remote = error?.name !== 'Nip42AuthenticationError' && (error?.category === 'relay' || isReplaceableRelayFailure(error))
1179
1221
  if (!transient && !remote) throw error
@@ -1655,6 +1697,8 @@ export class PrivateMessenger {
1655
1697
  stopWatches (channels = [...this.stopByChannel.keys()]) {
1656
1698
  const closing = []
1657
1699
  for (const pubkey of channels) {
1700
+ this.channelBackground.get(pubkey)?.controller.abort()
1701
+ this.channelBackground.delete(pubkey)
1658
1702
  this.cancelReloadGap(pubkey)
1659
1703
  this.cancelLiveRecovery(pubkey)
1660
1704
  this.liveInterruptions.delete(pubkey)
@@ -1785,6 +1829,7 @@ export class PrivateMessenger {
1785
1829
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1786
1830
  this.assertOpen()
1787
1831
  this.pauseReasons.add(reason)
1832
+ this.pauseRevisions.set(reason, (this.pauseRevisions.get(reason) || 0) + 1)
1788
1833
  this.sendRoutingPause.abort(this.pausedError())
1789
1834
  this.notifyStatus()
1790
1835
  for (const extension of this.extensions) extension.pause?.()
@@ -1793,14 +1838,16 @@ export class PrivateMessenger {
1793
1838
  return Promise.all([this.stopWatches([...this.desiredChannels]), this.flushStateWrites()])
1794
1839
  }
1795
1840
 
1796
- async resume (reason) {
1841
+ async resume (reason, { waitForBackground = false } = {}) {
1797
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')
1798
1844
  this.assertOpen()
1845
+ const revision = this.pauseRevisions.get(reason)
1799
1846
  // Repair durable state before announcing that publication can resume.
1800
1847
  if (reason === 'storage' && this.pendingStorageWrites.size) await this.retryStorageWrites(this.sendRoutingLifetime.signal)
1801
1848
  await this.flushStateWrites()
1802
1849
  this.assertOpen()
1803
- const wasAutomatic = this.automaticPauses.has(reason)
1850
+ if (revision !== this.pauseRevisions.get(reason)) return
1804
1851
  const removed = this.pauseReasons.delete(reason)
1805
1852
  this.automaticPauses.delete(reason)
1806
1853
  if (!this.pauseReasons.size && this.sendRoutingPause.signal.aborted) this.sendRoutingPause = new AbortController()
@@ -1810,29 +1857,75 @@ export class PrivateMessenger {
1810
1857
  this.capacityTimer = null
1811
1858
  this.capacityRequiredBytes = 0
1812
1859
  }
1813
- if (!removed || this.pauseReasons.size) return
1814
- if (this.resumeWork) await this.resumeWork
1815
- if (this.pauseReasons.size || this.closePromise) return
1816
- const work = (async () => {
1817
- const channels = [...this.desiredChannels]
1860
+ if (this.pauseReasons.size) return
1861
+ const channels = [...this.desiredChannels]
1862
+ if (removed) {
1818
1863
  this.closeOpenOfflineRanges(channels)
1819
- await this.watch(channels, { scheduleReloadGap: false })
1820
- if (this.pauseReasons.size || this.closePromise) return
1821
- await this.reconcilePresencePublishers()
1822
- await this.recoverOfflineRanges(channels)
1823
- for (const pubkey of channels) {
1824
- if (this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
1825
- }
1826
- for (const extension of this.extensions) await extension.resume?.()
1827
- })()
1828
- this.resumeWork = work
1829
- try { await work } catch (err) {
1830
- if (!this.closePromise) {
1831
- if (wasAutomatic && (reason === 'network' ? isRetryableRelayFailure(err) : isRecoverableStorageFailure(err))) this.automaticPauses.add(reason)
1832
- 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)
1833
1873
  }
1834
- throw err
1835
- } 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
1836
1929
  }
1837
1930
 
1838
1931
  receive (pubkey, operation, message = {}) {
@@ -2336,16 +2429,16 @@ export class PrivateMessenger {
2336
2429
  })
2337
2430
  }
2338
2431
 
2339
- async publishSeederPresence (channelPubkey = this.defaultChannelPubkey()) {
2432
+ async publishSeederPresence (channelPubkey = this.defaultChannelPubkey(), { signal } = {}) {
2340
2433
  const channel = this.requireWritableChannel(channelPubkey)
2341
2434
  if (!this.offlineRecoverySecondsFor(channel)) return null
2342
2435
  const receiverPubkeys = uniq([...this.knownSeeders(channelPubkey), this.userPubkey])
2343
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys })
2436
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, signal })
2344
2437
  this.debugSend('yell', channelPubkey, { code: SEEDER_PRESENCE_CODE, receiverPubkeys })
2345
2438
  return this._privateMessage.yell({
2346
- senderSigner: this.userSigner,
2347
- imkcSigner: this.contentKeySigner,
2348
- 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),
2349
2442
  privateChannelReaderPubkey: channel.readerPubkey,
2350
2443
  receiverPubkeys,
2351
2444
  ...routing,
@@ -2362,22 +2455,40 @@ export class PrivateMessenger {
2362
2455
  async startPresencePublisher (channelPubkey) {
2363
2456
  if (this.pauseReasons.size || !this.desiredChannels.has(channelPubkey) || !this.offlineRecoverySecondsFor(channelPubkey)) return
2364
2457
  if (this.presenceTimers.has(channelPubkey)) return
2365
- try {
2366
- await this.publishSeederPresence(channelPubkey)
2367
- } catch (err) {
2368
- 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
+ }
2369
2476
  }
2370
- if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(channelPubkey)) return
2371
- const timer = this._setInterval(() => {
2372
- return this.publishSeederPresence(channelPubkey).catch(err => {
2373
- console.warn('private-messenger seeder presence failed', err?.message ?? err)
2374
- })
2375
- }, this.seederPresenceIntervalMs)
2376
- timer?.unref?.()
2377
- 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
2378
2487
  }
2379
2488
 
2380
2489
  stopPresencePublisher (channelPubkey) {
2490
+ this.presenceJobs.get(channelPubkey)?.controller.abort()
2491
+ this.presenceJobs.delete(channelPubkey)
2381
2492
  const timer = this.presenceTimers.get(channelPubkey)
2382
2493
  if (timer) this._clearInterval(timer)
2383
2494
  this.presenceTimers.delete(channelPubkey)
@@ -2458,7 +2569,7 @@ export class PrivateMessenger {
2458
2569
  return seconds ? seconds * 1000 : DEFAULT_RECEIVED_CHUNK_TTL_MS
2459
2570
  }
2460
2571
 
2461
- async applyRecoveryPolicies (channels) {
2572
+ async applyRecoveryPolicies (channels, { prune = true } = {}) {
2462
2573
  return this.runQueueOperation(async () => {
2463
2574
  const state = this.readState()
2464
2575
  const now = nowSeconds()
@@ -2484,7 +2595,7 @@ export class PrivateMessenger {
2484
2595
  for (const channel of channels) {
2485
2596
  if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryState(channel.pubkey)
2486
2597
  }
2487
- for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
2598
+ if (prune) for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
2488
2599
  })
2489
2600
  }
2490
2601
 
@@ -3070,6 +3181,7 @@ export class PrivateMessenger {
3070
3181
  let unwatchError
3071
3182
  try { await unwatchPromise } catch (err) { unwatchError = err }
3072
3183
  await initSettledPromise
3184
+ await this.updateTail.catch(() => {})
3073
3185
  await Promise.all([...this.extensions].map(extension => extension.close?.()))
3074
3186
  await this.stampActiveChannelActivity()
3075
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:
@@ -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
+ }
@@ -0,0 +1,111 @@
1
+ import { createPauseRecovery } from '../../helpers/pause-recovery.js'
2
+
3
+ // One account owns the queue. Background work may occupy only three of the
4
+ // four slots so a newly opened conversation never sits behind the whole list.
5
+ export function createPeerPreparation ({ prepare, onReady, onError, isRetryable, online, onOnline, setTimer, clearTimer, random }) {
6
+ const entries = new Map()
7
+ let enabled = false
8
+ let closed = false
9
+ let running = 0
10
+ let background = 0
11
+ const unavailable = () => new Error('CHAT_UNAVAILABLE')
12
+ function entryFor (peer) {
13
+ const deferred = Promise.withResolvers()
14
+ deferred.promise.catch(() => {})
15
+ return { peer, ...deferred, controller: new AbortController(), state: 'queued', priority: false, value: null, error: null, recovery: null }
16
+ }
17
+ const current = entry => !closed && enabled && entries.get(entry.peer) === entry && !entry.controller.signal.aborted
18
+ function cancel (entry) {
19
+ entry.recovery?.stop()
20
+ entry.controller.abort(unavailable())
21
+ entry.reject(entry.controller.signal.reason)
22
+ entry.retryResult?.resolve()
23
+ entry.retryResult = null
24
+ }
25
+ function drain () {
26
+ if (!enabled || closed) return
27
+ while (running < 4) {
28
+ const queued = [...entries.values()].filter(entry => entry.state === 'queued')
29
+ const entry = queued.find(entry => entry.priority) || (background < 3 && queued[0])
30
+ if (!entry) break
31
+ const isBackground = !entry.priority
32
+ entry.state = 'running'
33
+ running++
34
+ if (isBackground) background++
35
+ Promise.resolve().then(() => {
36
+ entry.controller.signal.throwIfAborted()
37
+ return prepare(entry.peer, entry.controller.signal)
38
+ }).then(value => {
39
+ if (!current(entry)) return
40
+ entry.state = 'ready'; entry.value = value
41
+ entry.resolve(value)
42
+ onReady(entry.peer, value)
43
+ }, error => {
44
+ if (!current(entry)) return
45
+ entry.error = error
46
+ onError(error)
47
+ if (!isRetryable(error)) {
48
+ entry.state = 'failed'; entry.reject(error)
49
+ entry.retryResult?.reject(error); entry.retryResult = null
50
+ onReady(entry.peer)
51
+ return
52
+ }
53
+ entry.state = 'waiting'
54
+ entry.recovery ||= createPauseRecovery({
55
+ attempt: async signal => {
56
+ signal.throwIfAborted()
57
+ if (!current(entry)) return
58
+ entry.state = 'queued'; drain()
59
+ // Recovery waits for the actual preparation; no slot is held
60
+ // during backoff or a connectivity wait.
61
+ await new Promise((resolve, reject) => { entry.retryResult = { resolve, reject } })
62
+ },
63
+ retryable: isRetryable, onError: () => {}, online, onOnline, setTimer, clearTimer, random
64
+ })
65
+ if (entry.retryResult) { entry.retryResult.reject(error); entry.retryResult = null } else entry.recovery.start({ retryAt: error.retryAt })
66
+ }).finally(() => {
67
+ if (entry.state === 'ready') { entry.retryResult?.resolve(); entry.retryResult = null }
68
+ if (!current(entry)) { entry.retryResult?.resolve(); entry.retryResult = null }
69
+ running--
70
+ if (isBackground) background--
71
+ drain()
72
+ })
73
+ }
74
+ }
75
+ return {
76
+ get: peer => entries.get(peer)?.value,
77
+ error: peer => entries.get(peer)?.state === 'failed' ? entries.get(peer).error : null,
78
+ reconcile (peers) {
79
+ const wanted = new Set(peers)
80
+ for (const [peer, entry] of entries) if (!wanted.has(peer)) { entries.delete(peer); cancel(entry) }
81
+ for (const peer of wanted) if (!entries.has(peer)) entries.set(peer, entryFor(peer))
82
+ drain()
83
+ },
84
+ setAvailable (value) {
85
+ enabled = value === true
86
+ if (!enabled) {
87
+ for (const [peer, entry] of entries) {
88
+ if (entry.state === 'ready') continue
89
+ cancel(entry)
90
+ const replacement = entryFor(peer)
91
+ replacement.priority = entry.priority
92
+ entries.set(peer, replacement)
93
+ }
94
+ }
95
+ drain()
96
+ },
97
+ request (peer, { retry = false } = {}) {
98
+ if (closed || !entries.has(peer)) return Promise.reject(unavailable())
99
+ let entry = entries.get(peer)
100
+ if (retry && entry.state === 'failed') { cancel(entry); entries.set(peer, (entry = entryFor(peer))) }
101
+ entry.priority = true
102
+ drain()
103
+ return entry.promise
104
+ },
105
+ close () {
106
+ closed = true; enabled = false
107
+ for (const entry of entries.values()) cancel(entry)
108
+ entries.clear()
109
+ }
110
+ }
111
+ }
@@ -3,8 +3,12 @@ import { base64ToBytes, bytesToBase64 } from '../../../base64/index.js'
3
3
  // The library transport uses its Base64 signer contract. Conversion stays at
4
4
  // this adapter; the injected launcher/vault channel always carries raw bytes.
5
5
  export function messengerSigner (signer) {
6
+ // The adapter belongs to one account/channel generation. Share both the
7
+ // lookup in flight and its immutable result; failed permissions are retryable
8
+ // only when the owning session explicitly asks again.
9
+ let publicKey
6
10
  const adapter = {
7
- getPublicKey: () => signer.getPublicKey(),
11
+ getPublicKey: () => (publicKey ??= Promise.resolve().then(() => signer.getPublicKey()).catch(error => { publicKey = null; throw error })),
8
12
  getRelays: () => signer.getRelays(),
9
13
  signEvent: event => signer.signEvent(event),
10
14
  nip44v3Encrypt: (peer, kind, scope, text) => signer.nip44v3.encrypt(peer, kind, scope, base64ToBytes(text).buffer),
@@ -4,7 +4,10 @@ import { createPrivateMessenger } from '../index.js'
4
4
  import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/index.js'
5
5
  import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
6
6
  import { getIykcProofs } from '../../content-key/index.js'
7
- import { onOnline } from '../../network/index.js'
7
+ import { isOnline, onOnline } from '../../network/index.js'
8
+ import { createPeerPreparation } from './helpers/peer-preparation.js'
9
+ import { createForegroundWork } from './helpers/foreground-work.js'
10
+ import { isRetryableRelayFailure } from '../../relay/index.js'
8
11
  import { ValidationError } from '../../error/index.js'
9
12
  import { decodeFileMetadata } from '../../nip94/index.js'
10
13
  import { createChatOutbox } from './helpers/work-storage.js'
@@ -41,7 +44,7 @@ function messengerStatusSnapshot (value) {
41
44
  return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
42
45
  }
43
46
 
44
- export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
47
+ export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
45
48
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
46
49
  fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
47
50
  const reportedErrors = new WeakSet()
@@ -53,11 +56,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
53
56
  onError(error)
54
57
  }
55
58
  const userSigner = messengerSigner(signer)
59
+ const foreground = createForegroundWork()
56
60
  messageStorage ||= createEventStoreMessageStorage({ eventStore })
57
61
  chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
58
62
  const peers = new Set()
59
63
  const channels = new Map()
60
- const deniedPeers = new Set()
61
64
  const entries = new Map()
62
65
  const cancelled = new Set()
63
66
  const sendControllers = new Map()
@@ -113,6 +116,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
113
116
  let lifecycle = 0
114
117
  let configuring = Promise.resolve()
115
118
  let configurationFailed = false
119
+ let configureQueued = false
120
+ let configureWork
121
+ let messengerWork
122
+ let fileSetupWork
116
123
  let sending
117
124
  let repump
118
125
  let draining
@@ -132,7 +139,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
132
139
  let candidate
133
140
  try {
134
141
  candidate = await Messenger({
135
- fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
142
+ fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait,
136
143
  onMessageQueued: () => { if (messengerBinding === binding) return drain() },
137
144
  onStateChanged: state => {
138
145
  if (closed || messengerBinding !== binding) return
@@ -154,28 +161,41 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
154
161
  }
155
162
  }
156
163
 
164
+ const preparation = createPeerPreparation({
165
+ prepare: async (peer, signal) => {
166
+ const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
167
+ const pubkey = await scoped.getPublicKey()
168
+ signal.throwIfAborted()
169
+ const channel = { signer: scoped, pubkey, readerPubkey: pubkey, mode, seeders: seedersForPeer(peer) }
170
+ channels.set(peer, channel)
171
+ await schedule()
172
+ signal.throwIfAborted()
173
+ return channel
174
+ },
175
+ onReady: () => { drain(); pump() }, onError: reportError,
176
+ isRetryable: error => ['connection', 'transport', 'timeout'].includes(error?.category) && isRetryableRelayFailure(error),
177
+ online: _isOnline, onOnline: _onOnline, setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random
178
+ })
179
+ async function ensureMessenger () {
180
+ if (!messenger && !messengerWork) {
181
+ const work = createMessenger()
182
+ messengerWork = work
183
+ work.finally(() => { if (messengerWork === work) messengerWork = null }).catch(() => {})
184
+ }
185
+ await messengerWork
186
+ }
157
187
  async function configure () {
158
188
  if (closed || !available) return
159
- await ready()
160
189
  const version = lifecycle
161
- const values = []
162
- for (const peer of peers) {
163
- if (deniedPeers.has(peer)) continue
164
- let channel = channels.get(peer)
165
- if (!channel) {
166
- try {
167
- const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
168
- channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
169
- channels.set(peer, channel)
170
- } catch (error) { if (!retryable(error)) deniedPeers.add(peer); reportError(error); continue }
171
- }
172
- if (closed || !available || version !== lifecycle) return
173
- values.push(channel)
174
- }
175
- if (!messenger) await createMessenger()
190
+ await ready()
191
+ if (closed || !available || version !== lifecycle) return
192
+ await ensureMessenger()
176
193
  if (!messenger) return
177
- if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
194
+ if (closed || !available) { await messenger.pause('signer'); return }
195
+ if (version !== lifecycle) { configureQueued = true; return }
196
+ const values = [...channels].filter(([peer]) => peers.has(peer)).map(([, channel]) => channel)
178
197
  await messenger.update({ channels: values })
198
+ if (closed || !available || version !== lifecycle) return
179
199
  if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
180
200
  for (const [peer, list] of [...pendingPriorityRanges]) {
181
201
  const channel = channels.get(peer)
@@ -187,7 +207,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
187
207
  }
188
208
  }
189
209
  await messenger.resume('signer')
210
+ if (closed || !available || version !== lifecycle) return
211
+ preparation.setAvailable(true)
190
212
  if (storageBlocked) inboxRecovery.start({ immediate: true })
213
+ if (!fileSetupWork) {
214
+ fileSetupWork = setupFiles().catch(error => { fileSetupWork = null; if (!closed) reportError(error) })
215
+ }
216
+ fileSetupWork.then(() => { if (!closed && available) restoreDownloads() }).catch(reportError)
217
+ drain(); pump()
218
+ }
219
+ async function setupFiles () {
191
220
  if (!fileTransfers) {
192
221
  fileTransfers = FileTransfer({
193
222
  messenger, onError: reportError,
@@ -206,9 +235,17 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
206
235
  }
207
236
  }
208
237
  })
209
- downloads = await openDownloads({ owner, signer })
210
- for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
211
238
  }
239
+ if (!downloads) {
240
+ const candidate = await openDownloads({ owner, signer })
241
+ try {
242
+ const intents = await candidate.list()
243
+ downloads = candidate
244
+ for (const entry of intents) downloadIntents.set(entry.id, entry)
245
+ } catch (error) { await candidate.close?.(); throw error }
246
+ }
247
+ }
248
+ function restoreDownloads () {
212
249
  for (const entry of downloadIntents.values()) {
213
250
  if (!peers.has(entry.file.peer) || activeDownloads.has(entry.id)) continue
214
251
  const work = download(entry.file, { manual: true })
@@ -220,12 +257,37 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
220
257
  .finally(() => { if (activeDownloads.get(entry.id) === work) activeDownloads.delete(entry.id) })
221
258
  activeDownloads.set(entry.id, work)
222
259
  }
223
- drain(); pump()
260
+ pump()
224
261
  }
225
262
  function schedule () {
226
- const work = configuring.catch(() => {}).then(configure)
227
- configuring = work
228
- work.then(() => { if (configuring === work) configurationFailed = false }, error => { if (configuring === work) configurationFailed = true; reportError(error) })
263
+ configureQueued = true
264
+ if (configureWork) return configureWork
265
+ const work = Promise.resolve().then(async () => {
266
+ try {
267
+ do {
268
+ configureQueued = false
269
+ await configure()
270
+ if (closed || !available) break
271
+ } while (configureQueued)
272
+ } finally { if (configureWork === work) configureWork = null }
273
+ })
274
+ configureWork = configuring = work
275
+ work.then(() => {
276
+ if (configureWork === work) configureWork = null
277
+ configurationFailed = false
278
+ }, error => {
279
+ if (configureWork === work) configureWork = null
280
+ configurationFailed = true
281
+ if (!closed) reportError(error)
282
+ }).catch(() => {})
283
+ return work
284
+ }
285
+ async function preparePeer (peer, { retry = true } = {}) {
286
+ if (typeof peer !== 'string' || !/^[0-9a-f]{64}$/i.test(peer)) throw new ValidationError('INVALID_PRIVATE_PEER')
287
+ if (closed || !available || !peers.has(peer)) throw new Error('CHAT_UNAVAILABLE')
288
+ const work = preparation.request(peer, { retry })
289
+ work.catch(() => {})
290
+ if (!messenger || configurationFailed) await schedule()
229
291
  return work
230
292
  }
231
293
  async function drain () {
@@ -285,7 +347,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
285
347
  }
286
348
  }
287
349
  async function publish (peer, event, signal) {
288
- if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
350
+ if (preparation.error(peer)) throw preparation.error(peer)
289
351
  if (!available || !peers.has(peer) || !channels.has(peer)) throw unavailableError(new Error('CHAT_UNAVAILABLE'), signal)
290
352
  const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
291
353
  let report
@@ -299,6 +361,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
299
361
  await assertMessagePublished(report, event)
300
362
  }
301
363
  async function sendEntry (entry) {
364
+ const releaseForeground = foreground.enter()
302
365
  entry.status = 'pending'; emit()
303
366
  const controller = new AbortController()
304
367
  sendControllers.set(entry.id, controller)
@@ -321,6 +384,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
321
384
  await storage.put(entry, { existing: true })
322
385
  }
323
386
  }
387
+ if (entry.peer !== owner && !preparation.get(entry.peer)) {
388
+ if (preparation.error(entry.peer)) throw preparation.error(entry.peer)
389
+ preparation.request(entry.peer).catch(() => {})
390
+ return
391
+ }
324
392
  if (entry.peer !== owner && messengerStatus?.paused) {
325
393
  throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
326
394
  }
@@ -356,7 +424,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
356
424
  const event = entry.events[entry.index]
357
425
  if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
358
426
  await active()
359
- await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
427
+ // The final durable operation is removal. Avoid encrypting a checkpoint
428
+ // that would immediately be deleted; earlier context stages still checkpoint.
429
+ if (entry.index + 1 < entry.events.length) await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
360
430
  }
361
431
  await storage.remove(entry.id)
362
432
  entries.delete(entry.id)
@@ -368,16 +438,22 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
368
438
  if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
369
439
  entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
370
440
  await storage.put(entry, { existing: true }).catch(reportError)
371
- if (entry.peer !== owner && error.retryWhenAvailable && messenger.readStatus().paused) pausedSends.add(entry.id)
441
+ if (entry.peer !== owner && error.retryWhenAvailable && messenger?.readStatus().paused) pausedSends.add(entry.id)
442
+ else if (isUnavailableSend(cause) && cause.pauseReasons?.length && available && !closed) {
443
+ // Release can race persistence of the failed attempt. Do not lose
444
+ // its notification and spend a retry delay after the pause is gone.
445
+ entry.failed = false
446
+ queueMicrotask(() => pump())
447
+ }
372
448
  reportError(error)
373
449
  // The failed wire event may be a quote or file chunk. Report the
374
450
  // owning outbox item separately, without mutating the native error.
375
451
  if (error.retryWhenOnline) offlineSends.add(entry.id)
376
- if (error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
452
+ if (entry.failed && error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
377
453
  if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
378
454
  }
379
455
  emit()
380
- } finally { sendControllers.delete(entry.id) }
456
+ } finally { sendControllers.delete(entry.id); releaseForeground() }
381
457
  }
382
458
  function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
383
459
  // Transient availability failures without an active messenger pause retain
@@ -425,7 +501,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
425
501
  })
426
502
  }
427
503
  async function pump () {
428
- if (!available || closed || !messenger || !storage) return
504
+ if (!available || closed || !storage) return
429
505
  // A retry that lands while a pump is already running must not be skipped
430
506
  // just because that pass already walked past the entry.
431
507
  if (sending) { repump = true; return }
@@ -436,6 +512,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
436
512
  for (const entry of entries.values()) {
437
513
  if (closed || !available) break
438
514
  if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
515
+ if (entry.peer !== owner && entry.localSaved.every(Boolean) && !preparation.get(entry.peer) && !preparation.error(entry.peer)) {
516
+ preparation.request(entry.peer).catch(() => {})
517
+ continue
518
+ }
439
519
  if (entry.peer !== owner && entry.files.length && !fileTransfers) continue
440
520
  await sendEntry(entry)
441
521
  }
@@ -450,7 +530,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
450
530
  const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
451
531
  async function download (file, options = {}) {
452
532
  if (file.peer === owner) return file
453
- if (!available || closed || !peers.has(file.peer) || !fileTransfers) throw new Error('CHAT_UNAVAILABLE')
533
+ if (!available || closed || !peers.has(file.peer)) throw new Error('CHAT_UNAVAILABLE')
534
+ await preparePeer(file.peer)
535
+ await fileSetupWork
536
+ if (!fileTransfers || !downloads) throw new Error('CHAT_UNAVAILABLE')
454
537
  const id = intentId(file)
455
538
  if (options.manual) {
456
539
  const epoch = downloadEpochs.get(id) || 0
@@ -475,7 +558,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
475
558
  await writeDownloadIntent(() => downloads?.remove(id))
476
559
  }
477
560
  return {
478
- download, cancelDownload,
561
+ download, cancelDownload, preparePeer,
479
562
  // Peers-based so callers can prefetch as soon as a chat route opens, before
480
563
  // the shared-key channel exists. When group chats get real membership, add
481
564
  // a thin prefetchChannelContentKeys(channelPubkey) wrapper that resolves
@@ -503,20 +586,28 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
503
586
  if (type === 'unread-page' && since === undefined) throw new TypeError('PRIORITY_SINCE_REQUIRED')
504
587
  const request = { since, until, type }
505
588
  const channel = channels.get(peer)
506
- if (channel && typeof messenger?.prioritizeRange === 'function') {
589
+ if (channel && preparation.get(peer) && !pendingPriorityRanges.has(peer) && typeof messenger?.prioritizeRange === 'function') {
507
590
  try { return await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error); return false }
508
591
  }
509
592
  const list = pendingPriorityRanges.get(peer) || []
510
593
  list.push(request)
511
594
  pendingPriorityRanges.set(peer, list)
595
+ if (channel && available) schedule().catch(() => {})
512
596
  return true
513
597
  },
514
598
  async setPeers (values) {
515
599
  const next = new Set([...values].filter(peer => peer !== owner))
516
600
  if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
517
601
  peers.clear(); for (const peer of next) peers.add(peer)
518
- for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
519
- lifecycle++
602
+ preparation.reconcile(peers)
603
+ for (const peer of channels.keys()) {
604
+ if (!peers.has(peer)) {
605
+ channels.delete(peer); pendingPriorityRanges.delete(peer)
606
+ for (const entry of entries.values()) {
607
+ if (entry.peer === peer) sendControllers.get(entry.id)?.abort()
608
+ }
609
+ }
610
+ }
520
611
  return schedule()
521
612
  },
522
613
  async setAvailable (value) {
@@ -527,31 +618,39 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
527
618
  if (available) resetSendRetry()
528
619
  else clearSendRetry()
529
620
  if (changed) lifecycle++
530
- if (!available) { inboxRecovery.stop(); await messenger?.pause('signer'); return }
621
+ if (!available) {
622
+ preparation.setAvailable(false)
623
+ for (const peer of channels.keys()) if (!preparation.get(peer)) channels.delete(peer)
624
+ inboxRecovery.stop(); await messenger?.pause('signer'); return
625
+ }
531
626
  await ready()
532
627
  for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
533
628
  if (storageBlocked) inboxRecovery.start({ immediate: true })
534
- if (!changed && messenger && !configurationFailed) return pump()
629
+ if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
535
630
  return schedule()
536
631
  },
537
632
  async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
538
633
  if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
539
- await ready()
540
- const main = wireEvent(event, owner)
541
- const id = getEventHash(main)
542
- const events = [...context.map(value => wireEvent(value, owner)), main]
543
- const files = events.filter(value => value.kind === 1063).map(value => ({ ...decodeFileMetadata(value), optional: !requiredFiles.includes(getEventHash(value)) })).filter(file => file.service === 'irfs').flatMap(file => [file, ...(file.thumbnail?.root ? [{ ...file.thumbnail, optional: file.optional }] : [])])
544
- const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
545
- await storage.put(entry)
546
- entries.set(id, entry); emit(); pump()
547
- return id
634
+ const releaseForeground = foreground.enter()
635
+ try {
636
+ await ready()
637
+ const main = wireEvent(event, owner)
638
+ const id = getEventHash(main)
639
+ const events = [...context.map(value => wireEvent(value, owner)), main]
640
+ const files = events.filter(value => value.kind === 1063).map(value => ({ ...decodeFileMetadata(value), optional: !requiredFiles.includes(getEventHash(value)) })).filter(file => file.service === 'irfs').flatMap(file => [file, ...(file.thumbnail?.root ? [{ ...file.thumbnail, optional: file.optional }] : [])])
641
+ const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
642
+ await storage.put(entry)
643
+ entries.set(id, entry); emit(); pump()
644
+ if (peer !== owner) preparePeer(peer).catch(() => {})
645
+ return id
646
+ } finally { releaseForeground() }
548
647
  },
549
648
  async retry (id) {
550
649
  offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
551
650
  if (storageBlocked) inboxRecovery.start({ immediate: true })
552
651
  await ready()
553
652
  const entry = entries.get(id)
554
- if (entry) { entry.failed = false; resetSendRetry(); if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
653
+ if (entry) { entry.failed = false; resetSendRetry(); if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {}); return pump() }
555
654
  },
556
655
  async cancel (id) {
557
656
  cancelled.add(id)
@@ -566,9 +665,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
566
665
  return (closeWork ??= (async () => {
567
666
  closed = true; available = false; lifecycle++
568
667
  stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
569
- clearSendRetry(); inboxRecovery.stop(); pausedSends.clear()
668
+ clearSendRetry(); inboxRecovery.stop(); preparation.close(); foreground.close(); pausedSends.clear()
570
669
  for (const controller of sendControllers.values()) controller.abort()
571
- await Promise.allSettled([configuring, initialized])
670
+ await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
572
671
  await messenger?.pause('closed')
573
672
  if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
574
673
  await messenger?.close()