libp2r2p 0.11.16 → 0.11.18

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.
@@ -45,8 +45,10 @@
45
45
  import { createAbortableSemaphore } from '../helpers/abortable-semaphore.js'
46
46
  import { mergeRanges, subtractRanges, intersectRanges } from '../helpers/ranges.js'
47
47
  import * as privateMessage from '../private-message/index.js'
48
- import { isOnline } from '../network/index.js'
49
- import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
48
+ import { isOnline, onOnline } from '../network/index.js'
49
+ import { isRetryableRelayFailure, isReplaceableRelayFailure } from '../relay/index.js'
50
+ import { createPauseRecovery, isRecoverableStorageFailure } from './helpers/pause-recovery.js'
51
+ import { createSendRelayRouting, normalizeFallbackRelays, normalizeFallbackDelay } from './helpers/send-routing.js'
50
52
  import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
51
53
  import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
52
54
  import { normalizeRelayUrl } from '../url/index.js'
@@ -210,6 +212,7 @@ export class PrivateMessenger {
210
212
 
211
213
  constructor ({
212
214
  fallbackRelays = [],
215
+ fallbackDelayMs = null,
213
216
  offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
214
217
  staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
215
218
  identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
@@ -231,6 +234,7 @@ export class PrivateMessenger {
231
234
  useContentKeys = true,
232
235
  onContentKeyChange,
233
236
  onMessageQueued,
237
+ onStateChanged,
234
238
  onDebug,
235
239
  onError = defaultOnError,
236
240
  _privateMessage = privateMessage,
@@ -239,6 +243,8 @@ export class PrivateMessenger {
239
243
  _pickRelaysForPubkeys = pickRelaysForPubkeys,
240
244
  _subscribeRelayListUpdates = subscribeRelayListUpdates,
241
245
  _isOnline = isOnline,
246
+ _onOnline = onOnline,
247
+ _random = Math.random,
242
248
  _setTimeout = globalThis.setTimeout.bind(globalThis),
243
249
  _clearTimeout = globalThis.clearTimeout.bind(globalThis),
244
250
  _setInterval = globalThis.setInterval.bind(globalThis),
@@ -247,7 +253,9 @@ export class PrivateMessenger {
247
253
  _storageClearInterval = globalThis.clearInterval.bind(globalThis),
248
254
  _BroadcastChannel = _indexedDB === globalThis.indexedDB ? globalThis.BroadcastChannel : undefined
249
255
  } = {}) {
256
+ if (onStateChanged != null && typeof onStateChanged !== 'function') throw new ValidationError('INVALID_ON_STATE_CHANGED')
250
257
  this.fallbackRelays = normalizeFallbackRelays(fallbackRelays)
258
+ this.fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
251
259
  this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
252
260
  this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
253
261
  this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
@@ -277,6 +285,16 @@ export class PrivateMessenger {
277
285
  this._indexedDB = _indexedDB
278
286
  this.useContentKeys = useContentKeys
279
287
  this.onContentKeyChange = onContentKeyChange
288
+ this.onStateChanged = onStateChanged
289
+ this.statusFingerprint = null
290
+ this.automaticPauses = new Set()
291
+ this.pendingStorageWrites = new Map()
292
+ this.pauseRecoveries = new Map()
293
+ this.watchRecoveries = new Map()
294
+ this.pauseOnlineWork = null
295
+ this.pauseOnlineController = null
296
+ this._onOnline = _onOnline
297
+ this._random = _random
280
298
  this.onMessageQueued = onMessageQueued
281
299
  this.onDebug = onDebug
282
300
  this.onError = onError
@@ -288,6 +306,7 @@ export class PrivateMessenger {
288
306
  this._isOnline = _isOnline
289
307
  this.sendRelayExclusions = new Map()
290
308
  this.sendRoutingLifetime = new AbortController()
309
+ this.sendRoutingPause = new AbortController()
291
310
  this._setTimeout = _setTimeout
292
311
  this._clearTimeout = _clearTimeout
293
312
  this._setInterval = _setInterval
@@ -351,6 +370,7 @@ export class PrivateMessenger {
351
370
  this.closePromise = null
352
371
  this.initSettledPromise = null
353
372
  this.initialized = false
373
+ this.notifyStatus()
354
374
  }
355
375
 
356
376
  async init ({ userSigner, contentKeySigner, nymSigner, channels = [], relays = [], mode = 'leecher' }) {
@@ -663,6 +683,7 @@ export class PrivateMessenger {
663
683
  relays = [],
664
684
  mode = 'leecher'
665
685
  } = options
686
+ const readerIdentityChanged = userSigner !== this.userSigner || contentKeySigner !== this.contentKeySigner || nymSigner !== this.nymSigner
666
687
  const updatesStalePolicy = Object.hasOwn(options, 'staleChannelSeconds')
667
688
  const updatesIdentityPolicy = Object.hasOwn(options, 'identityStorageRetentionSeconds')
668
689
  let nextStaleChannelSeconds = updatesStalePolicy
@@ -683,7 +704,12 @@ export class PrivateMessenger {
683
704
  const nextChannels = await this.normalizeChannels(channels, { relays, mode })
684
705
  this.assertOpen()
685
706
  const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
686
- const watchPubkeys = [...nextPubkeys].filter(pubkey => !this.channels.has(pubkey) || this.desiredChannels.has(pubkey))
707
+ const changedChannel = channel => {
708
+ const previous = this.channels.get(channel.pubkey)
709
+ return !previous || ['signer', 'readerSigner', 'readerPubkey', 'nymSigner', 'mode', 'usesNip65WatchRelays', 'offlineRecoverySeconds', 'autoDeletionCapability'].some(key => previous[key] !== channel[key]) ||
710
+ ['relays', 'sendRelays', 'seeders'].some(key => JSON.stringify(previous[key]) !== JSON.stringify(channel[key]))
711
+ }
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)
687
713
  const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
688
714
  const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
689
715
 
@@ -818,6 +844,8 @@ export class PrivateMessenger {
818
844
  peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
819
845
  signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
820
846
  exclusions: this.sendRelayExclusions.get(key).exclusions,
847
+ fallbackDelayMs: this.fallbackDelayMs, pauseSignal: this.sendRoutingPause.signal,
848
+ setTimer: this._setTimeout, clearTimer: this._clearTimeout,
821
849
  pickRelays: this._pickRelaysForPubkeys,
822
850
  publish: this._privateChannel.publish || privateChannel.publish,
823
851
  publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
@@ -1118,31 +1146,53 @@ export class PrivateMessenger {
1118
1146
  // Neither a crash nor a newly received message may erase the initial scan.
1119
1147
  this.recordRecoveryWindow(pubkey)
1120
1148
  await this.flushStateWrites()
1121
- const watchRelays = await this.resolveWatchRelays(channel)
1122
- this.assertOpen()
1123
- if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) continue
1124
- const stop = await this._privateMessage.watch({
1125
- channels: [pubkey],
1126
- relays: watchRelays,
1127
- receiverSigner: this.userSigner,
1128
- iykcSigner: this.contentKeySigner,
1129
- privateChannelSigner: channel.signer,
1130
- privateChannelReaderSigner: channel.readerSigner,
1131
- privateChannelReaderPubkey: channel.readerPubkey,
1132
- mode: channel.mode,
1133
- onAsk: message => this.receive(pubkey, () => this.handleAsk(pubkey, message), message),
1134
- onReply: message => this.receive(pubkey, () => this.handleReply(pubkey, message), message),
1135
- onTell: message => this.receive(pubkey, () => this.handleTell(pubkey, message), message),
1136
- onYell: message => this.receive(pubkey, () => this.handleYell(pubkey, message), message),
1137
- onNym: message => this.receive(pubkey, () => this.handleNym(pubkey, message), message),
1138
- onMessage: message => this.receive(pubkey, () => this.handleMessage(pubkey, message), message),
1139
- onSeed: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
1140
- onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
1141
- receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1142
- receivedChunkIndexedDB: this._indexedDB,
1143
- onSubscriptionState: state => this.handleSubscriptionState(pubkey, state),
1144
- onError: err => this.onError?.(err)
1145
- })
1149
+ let stop; let watchRelays
1150
+ try {
1151
+ watchRelays = await this.resolveWatchRelays(channel)
1152
+ this.assertOpen()
1153
+ if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) continue
1154
+ stop = await this._privateMessage.watch({
1155
+ channels: [pubkey],
1156
+ relays: watchRelays,
1157
+ receiverSigner: this.userSigner,
1158
+ iykcSigner: this.contentKeySigner,
1159
+ privateChannelSigner: channel.signer,
1160
+ privateChannelReaderSigner: channel.readerSigner,
1161
+ privateChannelReaderPubkey: channel.readerPubkey,
1162
+ mode: channel.mode,
1163
+ onAsk: message => this.receive(pubkey, () => this.handleAsk(pubkey, message), message),
1164
+ onReply: message => this.receive(pubkey, () => this.handleReply(pubkey, message), message),
1165
+ onTell: message => this.receive(pubkey, () => this.handleTell(pubkey, message), message),
1166
+ onYell: message => this.receive(pubkey, () => this.handleYell(pubkey, message), message),
1167
+ onNym: message => this.receive(pubkey, () => this.handleNym(pubkey, message), message),
1168
+ onMessage: message => this.receive(pubkey, () => this.handleMessage(pubkey, message), message),
1169
+ onSeed: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
1170
+ onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
1171
+ receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1172
+ receivedChunkIndexedDB: this._indexedDB,
1173
+ onSubscriptionState: state => this.handleSubscriptionState(pubkey, state),
1174
+ onError: err => this.onError?.(err)
1175
+ })
1176
+ } catch (error) {
1177
+ const transient = isRetryableRelayFailure(error)
1178
+ const remote = error?.name !== 'Nip42AuthenticationError' && (error?.category === 'relay' || isReplaceableRelayFailure(error))
1179
+ if (!transient && !remote) throw error
1180
+ this.onError?.(error)
1181
+ if (!transient) continue
1182
+ if (!this.watchRecoveries.has(pubkey)) {
1183
+ const task = this.recoveryTask(async signal => {
1184
+ signal.throwIfAborted()
1185
+ if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey)) return
1186
+ await this.watch([pubkey])
1187
+ if (!this.stopByChannel.has(pubkey)) throw error
1188
+ }, { network: true, retryable: isRetryableRelayFailure })
1189
+ this.watchRecoveries.set(pubkey, task)
1190
+ }
1191
+ this.watchRecoveries.get(pubkey).start({ retryAt: error.retryAt })
1192
+ continue
1193
+ }
1194
+ this.watchRecoveries.get(pubkey)?.stop()
1195
+ this.watchRecoveries.delete(pubkey)
1146
1196
  if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) {
1147
1197
  await stop?.()
1148
1198
  this.assertOpen()
@@ -1182,6 +1232,8 @@ export class PrivateMessenger {
1182
1232
  if (!interrupted) { interrupted = new Map(); this.liveInterruptions.set(pubkey, interrupted) }
1183
1233
  const start = Math.min(interrupted.get(relay) ?? Infinity, since)
1184
1234
  interrupted.set(relay, start)
1235
+ this.watchRecoveries.get(pubkey)?.stop()
1236
+ this.watchRecoveries.delete(pubkey)
1185
1237
  this.cancelReloadGap(pubkey)
1186
1238
  for (const controller of this.recoveryControllers) {
1187
1239
  if (controller.channelPubkey === pubkey) { controller.liveInterrupted = true; controller.abort() }
@@ -1620,6 +1672,8 @@ export class PrivateMessenger {
1620
1672
  const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
1621
1673
  for (const extension of this.extensions) extension.unwatch?.(pubkeys)
1622
1674
  for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
1675
+ for (const [key, pending] of this.pendingStorageWrites) if (pubkeys.includes(pending.record.channelPubkey)) this.pendingStorageWrites.delete(key)
1676
+ this.syncPauseRecovery()
1623
1677
  for (const pubkey of pubkeys) this.dropRecoveryState(pubkey)
1624
1678
  this.recordInterruption(pubkeys)
1625
1679
  for (const controller of this.recoveryControllers) {
@@ -1628,10 +1682,111 @@ export class PrivateMessenger {
1628
1682
  return Promise.all([this.stopWatches(pubkeys), this.flushStateWrites()])
1629
1683
  }
1630
1684
 
1685
+ readStatus () {
1686
+ return { closed: Boolean(this.closePromise), paused: this.pauseReasons.size > 0, pauseReasons: [...this.pauseReasons].sort() }
1687
+ }
1688
+
1689
+ notifyStatus () {
1690
+ const status = this.readStatus()
1691
+ const fingerprint = JSON.stringify(status)
1692
+ if (fingerprint !== this.statusFingerprint) {
1693
+ this.statusFingerprint = fingerprint
1694
+ try { this.onStateChanged?.(status) } catch (error) { this.onError?.(error) }
1695
+ }
1696
+ this.syncPauseRecovery()
1697
+ }
1698
+
1699
+ pausedError () {
1700
+ return Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: this.readStatus().pauseReasons })
1701
+ }
1702
+
1703
+ recoveryTask (attempt, { network = false, retryable = isRecoverableStorageFailure } = {}) {
1704
+ return createPauseRecovery({
1705
+ attempt, retryable, onError: error => this.onError?.(error),
1706
+ ...(network ? { online: () => this.checkPauseOnline(), onOnline: this._onOnline } : {}),
1707
+ setTimer: this._setTimeout, clearTimer: this._clearTimeout, random: this._random
1708
+ })
1709
+ }
1710
+
1711
+ checkPauseOnline () {
1712
+ if (!this.pauseOnlineWork) {
1713
+ const controller = new AbortController()
1714
+ this.pauseOnlineController = controller
1715
+ const work = Promise.resolve().then(() => this._isOnline({ signal: controller.signal })).finally(() => {
1716
+ if (this.pauseOnlineWork === work) { this.pauseOnlineWork = null; this.pauseOnlineController = null }
1717
+ })
1718
+ this.pauseOnlineWork = work
1719
+ }
1720
+ return this.pauseOnlineWork
1721
+ }
1722
+
1723
+ syncPauseRecovery () {
1724
+ if (!this.pauseRecoveries) return
1725
+ const blocked = this.closePromise || !this.desiredChannels.size ||
1726
+ [...this.pauseReasons].some(reason => !['network', 'storage', 'capacity', 'session-storage'].includes(reason))
1727
+ for (const [reason, task] of this.pauseRecoveries) {
1728
+ if (blocked || !this.automaticPauses.has(reason) || !this.pauseReasons.has(reason)) task.stop()
1729
+ }
1730
+ if (blocked) {
1731
+ this.pauseOnlineController?.abort()
1732
+ this.pauseOnlineWork = null; this.pauseOnlineController = null
1733
+ return
1734
+ }
1735
+ for (const reason of this.automaticPauses) {
1736
+ if (!this.pauseReasons.has(reason)) continue
1737
+ let task = this.pauseRecoveries.get(reason)
1738
+ if (!task) {
1739
+ task = this.recoveryTask(async signal => {
1740
+ if (reason === 'storage') await this.retryStorageWrites(signal)
1741
+ await this.flushStateWrites()
1742
+ signal.throwIfAborted()
1743
+ await this.resume(reason)
1744
+ }, {
1745
+ network: reason === 'network', retryable: error => {
1746
+ const retry = reason === 'network' ? isRetryableRelayFailure(error) : isRecoverableStorageFailure(error)
1747
+ if (!retry) this.automaticPauses.delete(reason)
1748
+ return retry
1749
+ }
1750
+ })
1751
+ this.pauseRecoveries.set(reason, task)
1752
+ }
1753
+ task.start()
1754
+ }
1755
+ }
1756
+
1757
+ async retryStorageWrites (signal) {
1758
+ for (const [key, pending] of this.pendingStorageWrites) {
1759
+ signal.throwIfAborted()
1760
+ const pubkey = pending.record.channelPubkey
1761
+ if (!this.channels.has(pubkey) || !this.desiredChannels.has(pubkey)) { this.pendingStorageWrites.delete(key); continue }
1762
+ try {
1763
+ if (!pending.dedupeKey || !await this.queue.someBy('byChannelTypeEventId', pending.dedupeKey)) await this.queue.enqueue(pending.record)
1764
+ } catch (error) { if (error?.name !== 'ConstraintError') throw error }
1765
+ signal.throwIfAborted()
1766
+ this.pendingStorageWrites.delete(key)
1767
+ this.markSeen(pubkey, pending.at || nowSeconds())
1768
+ this.wakeDeliveries()
1769
+ this.onMessageQueued?.()
1770
+ }
1771
+ }
1772
+
1773
+ pauseInternally (reason) {
1774
+ if (!this.pauseReasons.has(reason) || this.automaticPauses.has(reason)) this.automaticPauses.add(reason)
1775
+ return this.applyPause(reason)
1776
+ }
1777
+
1631
1778
  pause (reason) {
1779
+ // An explicit caller takes ownership, even if this cause already exists.
1780
+ this.automaticPauses.delete(reason)
1781
+ return this.applyPause(reason)
1782
+ }
1783
+
1784
+ applyPause (reason) {
1632
1785
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1633
1786
  this.assertOpen()
1634
1787
  this.pauseReasons.add(reason)
1788
+ this.sendRoutingPause.abort(this.pausedError())
1789
+ this.notifyStatus()
1635
1790
  for (const extension of this.extensions) extension.pause?.()
1636
1791
  this.recordInterruption(this.desiredChannels)
1637
1792
  for (const controller of this.recoveryControllers) controller.abort()
@@ -1641,7 +1796,15 @@ export class PrivateMessenger {
1641
1796
  async resume (reason) {
1642
1797
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1643
1798
  this.assertOpen()
1799
+ // Repair durable state before announcing that publication can resume.
1800
+ if (reason === 'storage' && this.pendingStorageWrites.size) await this.retryStorageWrites(this.sendRoutingLifetime.signal)
1801
+ await this.flushStateWrites()
1802
+ this.assertOpen()
1803
+ const wasAutomatic = this.automaticPauses.has(reason)
1644
1804
  const removed = this.pauseReasons.delete(reason)
1805
+ this.automaticPauses.delete(reason)
1806
+ if (!this.pauseReasons.size && this.sendRoutingPause.signal.aborted) this.sendRoutingPause = new AbortController()
1807
+ this.notifyStatus()
1645
1808
  if (reason === 'capacity' && removed) {
1646
1809
  clearInterval(this.capacityTimer)
1647
1810
  this.capacityTimer = null
@@ -1664,7 +1827,10 @@ export class PrivateMessenger {
1664
1827
  })()
1665
1828
  this.resumeWork = work
1666
1829
  try { await work } catch (err) {
1667
- if (!this.closePromise) await this.pause(reason)
1830
+ if (!this.closePromise) {
1831
+ if (wasAutomatic && (reason === 'network' ? isRetryableRelayFailure(err) : isRecoverableStorageFailure(err))) this.automaticPauses.add(reason)
1832
+ await this.applyPause(reason)
1833
+ }
1668
1834
  throw err
1669
1835
  } finally { if (this.resumeWork === work) this.resumeWork = null }
1670
1836
  }
@@ -1677,7 +1843,7 @@ export class PrivateMessenger {
1677
1843
  return this.queueIncoming(async () => {
1678
1844
  if (this.pauseReasons.size || !this.desiredChannels.has(pubkey)) {
1679
1845
  this.recordInterruption([pubkey], messageTime(message))
1680
- throw new Error('PRIVATE_MESSENGER_PAUSED')
1846
+ throw this.pausedError()
1681
1847
  }
1682
1848
  return operation()
1683
1849
  })
@@ -1790,19 +1956,20 @@ export class PrivateMessenger {
1790
1956
  this.debug('dedupe', debugMessageInfo(type, channelPubkey, message))
1791
1957
  return
1792
1958
  }
1959
+ const record = {
1960
+ type,
1961
+ channelPubkey,
1962
+ receivedAt: nowSeconds(),
1963
+ event: message.event,
1964
+ ...info,
1965
+ payload: message.payload,
1966
+ question: message.question || null,
1967
+ questionId: message.questionId || null,
1968
+ outer: message.outer || null,
1969
+ meta: message.meta || null
1970
+ }
1793
1971
  try {
1794
- await this.queue.enqueue({
1795
- type,
1796
- channelPubkey,
1797
- receivedAt: nowSeconds(),
1798
- event: message.event,
1799
- ...info,
1800
- payload: message.payload,
1801
- question: message.question || null,
1802
- questionId: message.questionId || null,
1803
- outer: message.outer || null,
1804
- meta: message.meta || null
1805
- })
1972
+ await this.queue.enqueue(record)
1806
1973
  } catch (err) {
1807
1974
  // The unique index closes the small cross-instance race after `someBy`.
1808
1975
  if (dedupeKey && err?.name === 'ConstraintError') {
@@ -1813,7 +1980,8 @@ export class PrivateMessenger {
1813
1980
  this.recordInterruption([channelPubkey], message.outer?.created_at || message.event?.created_at || nowSeconds())
1814
1981
  // Do not await stop from inside its own transport callback.
1815
1982
  const atCapacity = err.message === 'QUEUE_CAPACITY_EXCEEDED'
1816
- this.pause(atCapacity ? 'capacity' : 'storage').catch(error => this.onError?.(error))
1983
+ if (!atCapacity && isRecoverableStorageFailure(err)) this.pendingStorageWrites.set(JSON.stringify(dedupeKey || [channelPubkey, type]), { record, dedupeKey, at: message.outer?.created_at || message.event?.created_at })
1984
+ ;(atCapacity ? this.pause('capacity') : isRecoverableStorageFailure(err) ? this.pauseInternally('storage') : this.pause('storage')).catch(error => this.onError?.(error))
1817
1985
  if (atCapacity) {
1818
1986
  this.capacityRequiredBytes = Math.max(this.capacityRequiredBytes, err.requiredBytes || 0)
1819
1987
  if (!this.capacityTimer) {
@@ -2247,7 +2415,7 @@ export class PrivateMessenger {
2247
2415
 
2248
2416
  requireWritableChannel (pubkey) {
2249
2417
  this.assertOpen()
2250
- if (this.pauseReasons.size) throw new Error('PRIVATE_MESSENGER_PAUSED')
2418
+ if (this.pauseReasons.size) throw this.pausedError()
2251
2419
  const channel = this.requireChannel(pubkey)
2252
2420
  if (!channel.signer) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
2253
2421
  return channel
@@ -2373,12 +2541,12 @@ export class PrivateMessenger {
2373
2541
  ensureNetworkWatchers () {
2374
2542
  if (typeof window === 'undefined') return
2375
2543
  if (!this.stopOffline) {
2376
- const offline = () => { this.pause('network').catch(err => this.onError?.(err)) }
2544
+ const offline = () => { this.pauseInternally('network').catch(err => this.onError?.(err)) }
2377
2545
  window.addEventListener('offline', offline)
2378
2546
  this.stopOffline = () => window.removeEventListener('offline', offline)
2379
2547
  }
2380
2548
  if (!this.stopOnline) {
2381
- const online = () => { this.resume('network').catch(err => this.onError?.(err)) }
2549
+ const online = () => { this.syncPauseRecovery(); this.pauseRecoveries.get('network')?.wake() }
2382
2550
  window.addEventListener('online', online)
2383
2551
  this.stopOnline = () => window.removeEventListener('online', online)
2384
2552
  }
@@ -2718,7 +2886,7 @@ export class PrivateMessenger {
2718
2886
  controller.channelPubkey = pubkey
2719
2887
  this.recoveryControllers.add(controller)
2720
2888
  try {
2721
- if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw new Error('PRIVATE_MESSENGER_PAUSED')
2889
+ if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw this.pausedError()
2722
2890
  const fetchRelays = await this.resolveWatchRelays(channel)
2723
2891
  controller.signal.throwIfAborted()
2724
2892
  const record = this.recoveryRangeRecord(pubkey, range)
@@ -2875,6 +3043,9 @@ export class PrivateMessenger {
2875
3043
  const initSettledPromise = this.initSettledPromise
2876
3044
  this.sendRoutingLifetime.abort()
2877
3045
  this.sendRelayExclusions.clear()
3046
+ this.pendingStorageWrites.clear()
3047
+ for (const task of this.pauseRecoveries.values()) task.stop()
3048
+ for (const task of this.watchRecoveries.values()) task.stop()
2878
3049
  let unwatchPromise
2879
3050
  try {
2880
3051
  unwatchPromise = Promise.resolve(this.unwatch())
@@ -2932,6 +3103,7 @@ export class PrivateMessenger {
2932
3103
  this.recoveryPriority.clear()
2933
3104
  if (unwatchError) throw unwatchError
2934
3105
  })()
3106
+ this.notifyStatus()
2935
3107
  return this.closePromise
2936
3108
  }
2937
3109
  }
@@ -40,13 +40,41 @@ or cancelled/closed sends. Apps own route visibility and user-intent filtering;
40
40
  automatic retries may also fail. Callbacks must not throw. Errors are not copied
41
41
  into persisted outbox records.
42
42
 
43
- Sends that only failed because the messenger was paused or otherwise
44
- unavailable (`PRIVATE_MESSENGER_PAUSED`, `CHAT_UNAVAILABLE`) stay pending
45
- without `onSendError`. The session re-pumps them on a bounded backoff
46
- (1–30 seconds with 20% jitter) in addition to availability/connectivity
47
- changes, so a paused messenger recovers without a manual retry. The backoff
48
- resets on a user `retry`, on `setAvailable(true)`, and once no retryable entry
49
- remains.
43
+ Sends that only failed because the messenger was paused stay pending without
44
+ `onSendError`. `PrivateMessenger.readStatus()` and optional `onStateChanged`
45
+ provide isolated `{ closed, paused, pauseReasons }` snapshots, initially and on
46
+ actual transitions. Paused errors retain their `pauseReasons` and native cause.
47
+ The session parks remote sends and wakes them immediately on release; local
48
+ personal copies and self-chat remain writable.
49
+
50
+ A custom `Messenger(options)` factory must provide synchronous `readStatus()`
51
+ and honor `options.onStateChanged`: deliver an initial snapshot before the factory
52
+ resolves and notify each effective state change. Snapshots contain boolean
53
+ `closed`/`paused` and a `pauseReasons` string array; `paused` reflects whether that
54
+ array is nonempty. The session copies snapshots and rejects missing observation
55
+ with `MESSENGER_STATE_CONTRACT_REQUIRED`, or malformed state with
56
+ `INVALID_MESSENGER_STATUS` (`ValidationError`). Rejected instances are closed and
57
+ obsolete callbacks are ignored. Wrappers can forward the complete options to
58
+ `createPrivateMessenger(options)` unchanged.
59
+
60
+ Observed pauses never fall back to polling sends. Other transient availability
61
+ failures without an active pause retain bounded 1..30s send retries; a pause
62
+ superseding such a retry parks it until the state notification releases it.
63
+
64
+ Network pauses observed internally use shared `isOnline`/`onOnline` monitoring,
65
+ including recovery when no second native online event arrives. Offline waiting
66
+ spends no backoff. Retryable watch failures recover by channel without globally
67
+ pausing publication. Background historical recovery does not gate text sends.
68
+ Internal storage pauses repair pending state writes before release; explicit
69
+ external pauses remain owned by their caller. Operational recovery retries use
70
+ 1, 2, 4... seconds, capped at 30 seconds with 20% jitter; permanent relay and
71
+ local validation/auth/permission failures await explicit recovery.
72
+
73
+ A failed session inbox save uses the separate `session-storage` reason. The
74
+ session retries the actual reserved record and releases only after save and ACK.
75
+ No message eviction or false historical coverage is introduced. Account
76
+ unavailability stops its recovery timers; availability or explicit retry can
77
+ reevaluate a local failure without clearing another owner's storage pause.
50
78
 
51
79
  `enqueue({ peer, event, context, requiredFiles, deletion })` preserves prepared
52
80
  identities. Local message commits and remote stages have independent checkpoints.
@@ -82,7 +110,7 @@ const session = createPrivateMessageSession({
82
110
  })
83
111
  ```
84
112
 
85
- Automatic sends try each recipient's NIP-65 read relays first, at most two per
113
+ By default, automatic sends try each recipient's NIP-65 read relays first, at most two per
86
114
  recipient per attempt, then the configured fallbacks in pairs. Explicit
87
115
  per-call `relays`, channel `sendRelays`/`relays` and global `relays` remain primary
88
116
  and also support configured fallbacks, including fixed multi-recipient sends.
@@ -94,6 +122,19 @@ and encrypted recipient subsets, without consulting recipient NIP-65 lists.
94
122
  The session coordinator remains a DM API; multi-recipient channels use
95
123
  `createPrivateMessenger` directly.
96
124
 
125
+ Optional `fallbackDelayMs` defaults to `null` (sequential primary exhaustion).
126
+ A nonnegative integer up to 2147483647 opts automatic NIP-65 routing into early
127
+ fallback; explicit relay lists/maps retain the sequential policy. With `3000`,
128
+ start at most two eligible fallback relays after three seconds without complete
129
+ acceptance, or immediately after primary exhaustion. The deadline belongs to
130
+ one signed outer event; later batches do not renew it. Primary operations retain
131
+ their deadlines and can still win. Failed fallback operations leave primary
132
+ alternatives available; no relay is tried twice. Multiple recipients require
133
+ accepted coverage of every encrypted recipient subset. No wrapper, ID, recipient
134
+ tag or deletion capability is recreated. Confirmed connectivity, exclusions and
135
+ connection cooldowns remain authoritative; a waiting primary is not penalized
136
+ merely because fallback started. Late native failures may inform later sends.
137
+
97
138
  On `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or `error`
98
139
  rejections, or connection/transport/timeout failures, replacement requires
99
140
  `isOnline` to confirm connectivity. Invalid messages, local signer/authentication
@@ -113,7 +154,8 @@ publication report. A shared relay's first ACK covers all members assigned to
113
154
  that publication batch. If replacements use different relays for different
114
155
  members, every pending member must be covered before the outer event succeeds;
115
156
  a partial ACK cannot hide another member's failure. Remaining primary routes are
116
- exhausted before configured fallbacks. Redundant acknowledgements may update
157
+ exhausted before configured fallbacks with the default null delay or explicit
158
+ routes. Redundant acknowledgements may update
117
159
  future routing preferences without delaying a successfully covered send. Preferences are channel/recipient-scoped, in-memory,
118
160
  expire after five minutes, and never alter receive subscriptions. A fresh attempt
119
161
  can recheck an exhausted list. Additional fallback relays come only from the
@@ -131,3 +173,12 @@ relays. Validation/authorization failures are terminal without relay rotation.
131
173
  `broadcastRumor`/`broadcastEvent` accept a `signal` to stop further replacement
132
174
  publications; the session supplies it and aborts it on cancel/close. An already
133
175
  published event cannot be recalled by cancellation.
176
+
177
+ `RelayPool.sendEvent(event, relays, { signal })` supports optional cancellation.
178
+ It returns the existing result/report envelopes with the original abort reason
179
+ for unfinished relays, preserving partial acceptance and avoiding synthetic
180
+ timeouts. Cancellation releases publication waiters and deferred sends without
181
+ closing shared sockets or cancelling another subscriber for the same event ID.
182
+ Operations already transmitted cannot be recalled from relays. A messenger pause
183
+ cancels its outstanding publication work and leaves the outbox pending; resume
184
+ creates a fresh publication lifetime, without treating the pause as a user abort.