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.
- package/README.md +15 -1
- package/package.json +1 -1
- package/private-messenger/helpers/fallback-race.js +134 -0
- package/private-messenger/helpers/pause-recovery.js +80 -0
- package/private-messenger/helpers/send-routing.js +31 -5
- package/private-messenger/index.js +219 -47
- package/private-messenger/session/README.md +60 -9
- package/private-messenger/session/index.js +136 -32
- package/relay/helpers/publish.js +5 -1
- package/relay/services/relay-connection.js +31 -18
- package/relay/services/relay-pool.js +11 -4
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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)
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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.
|