libp2r2p 0.11.15 → 0.11.17
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 +24 -4
- package/package.json +1 -1
- package/private-messenger/helpers/pause-recovery.js +80 -0
- package/private-messenger/index.js +211 -46
- package/private-messenger/session/README.md +35 -7
- package/private-messenger/session/index.js +133 -30
- package/relay/helpers/error.js +5 -7
- package/relay/helpers/retry-advice.js +18 -0
- package/relay/index.js +1 -0
package/README.md
CHANGED
|
@@ -936,9 +936,22 @@ also admits relay policy refusals (`blocked:`, `restricted:`, `auth-required:`,
|
|
|
936
936
|
Local authentication denials, invalid events and unknown errors qualify for
|
|
937
937
|
neither policy. Predicates classify individual native errors; callers decide
|
|
938
938
|
how to handle mixed per-relay outcomes and application-specific aggregates.
|
|
939
|
-
|
|
940
|
-
`
|
|
941
|
-
|
|
939
|
+
`parseRelayRetryAdvice(reason, extra, { now = Date.now() })`, exported by
|
|
940
|
+
`libp2r2p/relay`, returns `{ retryAt, retryAfterMs? }` or `null`. It only interprets
|
|
941
|
+
leading `rate-limited:` refusals. Optional `retry_at` is a Unix timestamp in
|
|
942
|
+
seconds (fractions allowed), converted to milliseconds. Finite positive absolute
|
|
943
|
+
advice takes precedence over `retry_after`, including when already expired;
|
|
944
|
+
expired advice does not restart a relative wait. Future waits are capped at five
|
|
945
|
+
minutes from processing. Missing/invalid absolute advice falls back to finite
|
|
946
|
+
positive relative seconds, capped at 300. A valid relative value is retained as
|
|
947
|
+
`retryAfterMs` even when the absolute deadline wins. Schedulers wait until
|
|
948
|
+
max(local backoff deadline, retryAt), never sum the two or renew the deadline on
|
|
949
|
+
online notifications.
|
|
950
|
+
|
|
951
|
+
The optional Nostr extension works with older relays that only send
|
|
952
|
+
`retry_after` and older consumers that ignore `retry_at`. Neither field supplies
|
|
953
|
+
provenance or retry eligibility: `local`, `origin`, `retryable`, category and code
|
|
954
|
+
claims in extras are ignored. Native messages, causes and codes remain intact.
|
|
942
955
|
|
|
943
956
|
## Binary encodings
|
|
944
957
|
|
|
@@ -1059,7 +1072,7 @@ user-facing send error.
|
|
|
1059
1072
|
|
|
1060
1073
|
### Relay backpressure and bounded synchronization (0.11.7)
|
|
1061
1074
|
|
|
1062
|
-
Optional numeric `retry_after` seconds on relay `CLOSED` and rejected `OK`
|
|
1075
|
+
Optional `retry_at` Unix seconds or numeric `retry_after` seconds on relay `CLOSED` and rejected `OK`
|
|
1063
1076
|
responses are preserved as `retryAfterMs` and `retryAt` on native relay errors.
|
|
1064
1077
|
Only `rate-limited:` responses apply this advice; invalid values are ignored
|
|
1065
1078
|
and delays are capped at five minutes. New operations on that connection wait
|
|
@@ -1113,3 +1126,10 @@ are unchanged; newer live arrivals cannot erase a pending interval.
|
|
|
1113
1126
|
count/bytes, incoming bytes, limits and oldest queued age. Queue bytes are UTF-8
|
|
1114
1127
|
serialized volume, not heap use. Diagnostics never retain event contents or
|
|
1115
1128
|
filters. Existing 1,000-item / 8-MiB live limits are unchanged.
|
|
1129
|
+
|
|
1130
|
+
Private messenger status (`readStatus`, `onStateChanged`) exposes pause reasons
|
|
1131
|
+
without events or credentials. Sessions recover owned network/storage failures
|
|
1132
|
+
and wake pending sends on release rather than repeatedly publishing while paused.
|
|
1133
|
+
Custom session factories must expose `readStatus()` and deliver the initial and
|
|
1134
|
+
subsequent `onStateChanged` snapshots; missing observation is rejected instead of
|
|
1135
|
+
using polling compatibility. See [session lifecycle](private-messenger/session/README.md).
|
package/package.json
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Local persistence is not a relay failure. Retry operational storage failures,
|
|
2
|
+
// but leave validation/permission failures to their owner or an explicit retry.
|
|
3
|
+
export const isRecoverableStorageFailure = error => error != null && typeof error === 'object' && error?.name !== 'ValidationError' && error?.category !== 'validation' &&
|
|
4
|
+
!['SecurityError', 'NotAllowedError', 'TypeError', 'RangeError', 'SyntaxError', 'DataError', 'DataCloneError'].includes(error?.name) &&
|
|
5
|
+
!/DENIED|PERMISSION|REVOKED|READ_ONLY|INVALID|NOT_IN_PERSONA/.test(`${error?.code || ''} ${error?.name || ''} ${error?.message || ''}`)
|
|
6
|
+
|
|
7
|
+
// One cancellable job per cause. Waiting offline does not spend a backoff step.
|
|
8
|
+
export function createPauseRecovery ({ attempt, online, onOnline, retryable, onError, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout, random = Math.random }) {
|
|
9
|
+
let current
|
|
10
|
+
function stop () {
|
|
11
|
+
const job = current
|
|
12
|
+
current = null
|
|
13
|
+
if (!job) return
|
|
14
|
+
job.controller.abort()
|
|
15
|
+
if (job.timer != null) clearTimer(job.timer)
|
|
16
|
+
job.stopOnline?.()
|
|
17
|
+
}
|
|
18
|
+
function start ({ immediate = false, retryAt = 0 } = {}) {
|
|
19
|
+
const advice = Number.isFinite(retryAt) && retryAt > 0 ? retryAt : 0
|
|
20
|
+
if (current) {
|
|
21
|
+
if (advice > current.retryAt) { current.retryAt = advice; current.wake?.() }
|
|
22
|
+
return immediate ? current.retry?.() : current.work
|
|
23
|
+
}
|
|
24
|
+
const job = { controller: new AbortController(), delay: 1000, retryAt: advice, timer: null, nextAt: 0, wake: null, retry: null, stopOnline: null, work: null }
|
|
25
|
+
current = job
|
|
26
|
+
const active = () => current === job && !job.controller.signal.aborted
|
|
27
|
+
const schedule = delay => {
|
|
28
|
+
if (!active()) return
|
|
29
|
+
job.nextAt = Math.max(now() + delay, job.retryAt)
|
|
30
|
+
job.timer = setTimer(() => { job.timer = null; return run() }, Math.max(job.nextAt - now(), 0))
|
|
31
|
+
job.timer?.unref?.()
|
|
32
|
+
}
|
|
33
|
+
const run = () => {
|
|
34
|
+
if (!active() || job.work) return job.work
|
|
35
|
+
const work = (async () => {
|
|
36
|
+
if (online && !await online({ signal: job.controller.signal })) {
|
|
37
|
+
if (!active()) return
|
|
38
|
+
job.stopOnline = onOnline(() => {
|
|
39
|
+
if (!active()) return
|
|
40
|
+
job.stopOnline?.(); job.stopOnline = null
|
|
41
|
+
schedule(0)
|
|
42
|
+
})
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
if (!active()) return
|
|
46
|
+
try {
|
|
47
|
+
await attempt(job.controller.signal)
|
|
48
|
+
if (active()) stop()
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (!active()) return
|
|
51
|
+
onError(error)
|
|
52
|
+
if (!retryable(error)) { stop(); return }
|
|
53
|
+
job.retryAt = Math.max(job.retryAt, Number.isFinite(error?.retryAt) ? error.retryAt : 0)
|
|
54
|
+
schedule(Math.min(30000, job.delay * (0.8 + random() * 0.4)))
|
|
55
|
+
job.delay = Math.min(30000, job.delay * 2)
|
|
56
|
+
}
|
|
57
|
+
})().catch(error => { if (active()) { onError(error); stop() } }).finally(() => { if (job.work === work) job.work = null })
|
|
58
|
+
job.work = work
|
|
59
|
+
return work
|
|
60
|
+
}
|
|
61
|
+
job.wake = () => {
|
|
62
|
+
if (!active() || job.work) return job.work
|
|
63
|
+
if (job.timer != null) clearTimer(job.timer)
|
|
64
|
+
job.timer = null
|
|
65
|
+
if (job.nextAt > now()) { schedule(job.nextAt - now()); return }
|
|
66
|
+
return run()
|
|
67
|
+
}
|
|
68
|
+
job.retry = () => {
|
|
69
|
+
if (!active() || job.work) return job.work
|
|
70
|
+
if (job.timer != null) clearTimer(job.timer)
|
|
71
|
+
job.timer = null
|
|
72
|
+
if (job.retryAt > now()) { schedule(job.retryAt - now()); return }
|
|
73
|
+
return run()
|
|
74
|
+
}
|
|
75
|
+
if (immediate) return run()
|
|
76
|
+
schedule(1000)
|
|
77
|
+
job.delay = 2000
|
|
78
|
+
}
|
|
79
|
+
return { start, stop, wake: () => current?.wake?.() }
|
|
80
|
+
}
|
|
@@ -45,7 +45,9 @@
|
|
|
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'
|
|
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'
|
|
49
51
|
import { createSendRelayRouting, normalizeFallbackRelays } 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'
|
|
@@ -231,6 +233,7 @@ export class PrivateMessenger {
|
|
|
231
233
|
useContentKeys = true,
|
|
232
234
|
onContentKeyChange,
|
|
233
235
|
onMessageQueued,
|
|
236
|
+
onStateChanged,
|
|
234
237
|
onDebug,
|
|
235
238
|
onError = defaultOnError,
|
|
236
239
|
_privateMessage = privateMessage,
|
|
@@ -239,6 +242,8 @@ export class PrivateMessenger {
|
|
|
239
242
|
_pickRelaysForPubkeys = pickRelaysForPubkeys,
|
|
240
243
|
_subscribeRelayListUpdates = subscribeRelayListUpdates,
|
|
241
244
|
_isOnline = isOnline,
|
|
245
|
+
_onOnline = onOnline,
|
|
246
|
+
_random = Math.random,
|
|
242
247
|
_setTimeout = globalThis.setTimeout.bind(globalThis),
|
|
243
248
|
_clearTimeout = globalThis.clearTimeout.bind(globalThis),
|
|
244
249
|
_setInterval = globalThis.setInterval.bind(globalThis),
|
|
@@ -247,6 +252,7 @@ export class PrivateMessenger {
|
|
|
247
252
|
_storageClearInterval = globalThis.clearInterval.bind(globalThis),
|
|
248
253
|
_BroadcastChannel = _indexedDB === globalThis.indexedDB ? globalThis.BroadcastChannel : undefined
|
|
249
254
|
} = {}) {
|
|
255
|
+
if (onStateChanged != null && typeof onStateChanged !== 'function') throw new ValidationError('INVALID_ON_STATE_CHANGED')
|
|
250
256
|
this.fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
251
257
|
this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
|
|
252
258
|
this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
|
|
@@ -277,6 +283,16 @@ export class PrivateMessenger {
|
|
|
277
283
|
this._indexedDB = _indexedDB
|
|
278
284
|
this.useContentKeys = useContentKeys
|
|
279
285
|
this.onContentKeyChange = onContentKeyChange
|
|
286
|
+
this.onStateChanged = onStateChanged
|
|
287
|
+
this.statusFingerprint = null
|
|
288
|
+
this.automaticPauses = new Set()
|
|
289
|
+
this.pendingStorageWrites = new Map()
|
|
290
|
+
this.pauseRecoveries = new Map()
|
|
291
|
+
this.watchRecoveries = new Map()
|
|
292
|
+
this.pauseOnlineWork = null
|
|
293
|
+
this.pauseOnlineController = null
|
|
294
|
+
this._onOnline = _onOnline
|
|
295
|
+
this._random = _random
|
|
280
296
|
this.onMessageQueued = onMessageQueued
|
|
281
297
|
this.onDebug = onDebug
|
|
282
298
|
this.onError = onError
|
|
@@ -351,6 +367,7 @@ export class PrivateMessenger {
|
|
|
351
367
|
this.closePromise = null
|
|
352
368
|
this.initSettledPromise = null
|
|
353
369
|
this.initialized = false
|
|
370
|
+
this.notifyStatus()
|
|
354
371
|
}
|
|
355
372
|
|
|
356
373
|
async init ({ userSigner, contentKeySigner, nymSigner, channels = [], relays = [], mode = 'leecher' }) {
|
|
@@ -663,6 +680,7 @@ export class PrivateMessenger {
|
|
|
663
680
|
relays = [],
|
|
664
681
|
mode = 'leecher'
|
|
665
682
|
} = options
|
|
683
|
+
const readerIdentityChanged = userSigner !== this.userSigner || contentKeySigner !== this.contentKeySigner || nymSigner !== this.nymSigner
|
|
666
684
|
const updatesStalePolicy = Object.hasOwn(options, 'staleChannelSeconds')
|
|
667
685
|
const updatesIdentityPolicy = Object.hasOwn(options, 'identityStorageRetentionSeconds')
|
|
668
686
|
let nextStaleChannelSeconds = updatesStalePolicy
|
|
@@ -683,7 +701,12 @@ export class PrivateMessenger {
|
|
|
683
701
|
const nextChannels = await this.normalizeChannels(channels, { relays, mode })
|
|
684
702
|
this.assertOpen()
|
|
685
703
|
const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
|
|
686
|
-
const
|
|
704
|
+
const changedChannel = channel => {
|
|
705
|
+
const previous = this.channels.get(channel.pubkey)
|
|
706
|
+
return !previous || ['signer', 'readerSigner', 'readerPubkey', 'nymSigner', 'mode', 'usesNip65WatchRelays', 'offlineRecoverySeconds', 'autoDeletionCapability'].some(key => previous[key] !== channel[key]) ||
|
|
707
|
+
['relays', 'sendRelays', 'seeders'].some(key => JSON.stringify(previous[key]) !== JSON.stringify(channel[key]))
|
|
708
|
+
}
|
|
709
|
+
const watchPubkeys = nextChannels.filter(channel => !this.channels.has(channel.pubkey) || (this.desiredChannels.has(channel.pubkey) && (readerIdentityChanged || Object.hasOwn(options, 'staleChannelSeconds') || Object.hasOwn(options, 'identityStorageRetentionSeconds') || changedChannel(channel) || !this.stopByChannel.has(channel.pubkey)))).map(channel => channel.pubkey)
|
|
687
710
|
const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
|
|
688
711
|
const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
|
|
689
712
|
|
|
@@ -1118,31 +1141,53 @@ export class PrivateMessenger {
|
|
|
1118
1141
|
// Neither a crash nor a newly received message may erase the initial scan.
|
|
1119
1142
|
this.recordRecoveryWindow(pubkey)
|
|
1120
1143
|
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
|
-
|
|
1144
|
+
let stop; let watchRelays
|
|
1145
|
+
try {
|
|
1146
|
+
watchRelays = await this.resolveWatchRelays(channel)
|
|
1147
|
+
this.assertOpen()
|
|
1148
|
+
if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) continue
|
|
1149
|
+
stop = await this._privateMessage.watch({
|
|
1150
|
+
channels: [pubkey],
|
|
1151
|
+
relays: watchRelays,
|
|
1152
|
+
receiverSigner: this.userSigner,
|
|
1153
|
+
iykcSigner: this.contentKeySigner,
|
|
1154
|
+
privateChannelSigner: channel.signer,
|
|
1155
|
+
privateChannelReaderSigner: channel.readerSigner,
|
|
1156
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
1157
|
+
mode: channel.mode,
|
|
1158
|
+
onAsk: message => this.receive(pubkey, () => this.handleAsk(pubkey, message), message),
|
|
1159
|
+
onReply: message => this.receive(pubkey, () => this.handleReply(pubkey, message), message),
|
|
1160
|
+
onTell: message => this.receive(pubkey, () => this.handleTell(pubkey, message), message),
|
|
1161
|
+
onYell: message => this.receive(pubkey, () => this.handleYell(pubkey, message), message),
|
|
1162
|
+
onNym: message => this.receive(pubkey, () => this.handleNym(pubkey, message), message),
|
|
1163
|
+
onMessage: message => this.receive(pubkey, () => this.handleMessage(pubkey, message), message),
|
|
1164
|
+
onSeed: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
|
|
1165
|
+
onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
|
|
1166
|
+
receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
|
|
1167
|
+
receivedChunkIndexedDB: this._indexedDB,
|
|
1168
|
+
onSubscriptionState: state => this.handleSubscriptionState(pubkey, state),
|
|
1169
|
+
onError: err => this.onError?.(err)
|
|
1170
|
+
})
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
const transient = isRetryableRelayFailure(error)
|
|
1173
|
+
const remote = error?.name !== 'Nip42AuthenticationError' && (error?.category === 'relay' || isReplaceableRelayFailure(error))
|
|
1174
|
+
if (!transient && !remote) throw error
|
|
1175
|
+
this.onError?.(error)
|
|
1176
|
+
if (!transient) continue
|
|
1177
|
+
if (!this.watchRecoveries.has(pubkey)) {
|
|
1178
|
+
const task = this.recoveryTask(async signal => {
|
|
1179
|
+
signal.throwIfAborted()
|
|
1180
|
+
if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey)) return
|
|
1181
|
+
await this.watch([pubkey])
|
|
1182
|
+
if (!this.stopByChannel.has(pubkey)) throw error
|
|
1183
|
+
}, { network: true, retryable: isRetryableRelayFailure })
|
|
1184
|
+
this.watchRecoveries.set(pubkey, task)
|
|
1185
|
+
}
|
|
1186
|
+
this.watchRecoveries.get(pubkey).start({ retryAt: error.retryAt })
|
|
1187
|
+
continue
|
|
1188
|
+
}
|
|
1189
|
+
this.watchRecoveries.get(pubkey)?.stop()
|
|
1190
|
+
this.watchRecoveries.delete(pubkey)
|
|
1146
1191
|
if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) {
|
|
1147
1192
|
await stop?.()
|
|
1148
1193
|
this.assertOpen()
|
|
@@ -1182,6 +1227,8 @@ export class PrivateMessenger {
|
|
|
1182
1227
|
if (!interrupted) { interrupted = new Map(); this.liveInterruptions.set(pubkey, interrupted) }
|
|
1183
1228
|
const start = Math.min(interrupted.get(relay) ?? Infinity, since)
|
|
1184
1229
|
interrupted.set(relay, start)
|
|
1230
|
+
this.watchRecoveries.get(pubkey)?.stop()
|
|
1231
|
+
this.watchRecoveries.delete(pubkey)
|
|
1185
1232
|
this.cancelReloadGap(pubkey)
|
|
1186
1233
|
for (const controller of this.recoveryControllers) {
|
|
1187
1234
|
if (controller.channelPubkey === pubkey) { controller.liveInterrupted = true; controller.abort() }
|
|
@@ -1620,6 +1667,8 @@ export class PrivateMessenger {
|
|
|
1620
1667
|
const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
|
|
1621
1668
|
for (const extension of this.extensions) extension.unwatch?.(pubkeys)
|
|
1622
1669
|
for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
|
|
1670
|
+
for (const [key, pending] of this.pendingStorageWrites) if (pubkeys.includes(pending.record.channelPubkey)) this.pendingStorageWrites.delete(key)
|
|
1671
|
+
this.syncPauseRecovery()
|
|
1623
1672
|
for (const pubkey of pubkeys) this.dropRecoveryState(pubkey)
|
|
1624
1673
|
this.recordInterruption(pubkeys)
|
|
1625
1674
|
for (const controller of this.recoveryControllers) {
|
|
@@ -1628,10 +1677,110 @@ export class PrivateMessenger {
|
|
|
1628
1677
|
return Promise.all([this.stopWatches(pubkeys), this.flushStateWrites()])
|
|
1629
1678
|
}
|
|
1630
1679
|
|
|
1680
|
+
readStatus () {
|
|
1681
|
+
return { closed: Boolean(this.closePromise), paused: this.pauseReasons.size > 0, pauseReasons: [...this.pauseReasons].sort() }
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
notifyStatus () {
|
|
1685
|
+
const status = this.readStatus()
|
|
1686
|
+
const fingerprint = JSON.stringify(status)
|
|
1687
|
+
if (fingerprint !== this.statusFingerprint) {
|
|
1688
|
+
this.statusFingerprint = fingerprint
|
|
1689
|
+
try { this.onStateChanged?.(status) } catch (error) { this.onError?.(error) }
|
|
1690
|
+
}
|
|
1691
|
+
this.syncPauseRecovery()
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
pausedError () {
|
|
1695
|
+
return Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: this.readStatus().pauseReasons })
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
recoveryTask (attempt, { network = false, retryable = isRecoverableStorageFailure } = {}) {
|
|
1699
|
+
return createPauseRecovery({
|
|
1700
|
+
attempt, retryable, onError: error => this.onError?.(error),
|
|
1701
|
+
...(network ? { online: () => this.checkPauseOnline(), onOnline: this._onOnline } : {}),
|
|
1702
|
+
setTimer: this._setTimeout, clearTimer: this._clearTimeout, random: this._random
|
|
1703
|
+
})
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
checkPauseOnline () {
|
|
1707
|
+
if (!this.pauseOnlineWork) {
|
|
1708
|
+
const controller = new AbortController()
|
|
1709
|
+
this.pauseOnlineController = controller
|
|
1710
|
+
const work = Promise.resolve().then(() => this._isOnline({ signal: controller.signal })).finally(() => {
|
|
1711
|
+
if (this.pauseOnlineWork === work) { this.pauseOnlineWork = null; this.pauseOnlineController = null }
|
|
1712
|
+
})
|
|
1713
|
+
this.pauseOnlineWork = work
|
|
1714
|
+
}
|
|
1715
|
+
return this.pauseOnlineWork
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
syncPauseRecovery () {
|
|
1719
|
+
if (!this.pauseRecoveries) return
|
|
1720
|
+
const blocked = this.closePromise || !this.desiredChannels.size ||
|
|
1721
|
+
[...this.pauseReasons].some(reason => !['network', 'storage', 'capacity', 'session-storage'].includes(reason))
|
|
1722
|
+
for (const [reason, task] of this.pauseRecoveries) {
|
|
1723
|
+
if (blocked || !this.automaticPauses.has(reason) || !this.pauseReasons.has(reason)) task.stop()
|
|
1724
|
+
}
|
|
1725
|
+
if (blocked) {
|
|
1726
|
+
this.pauseOnlineController?.abort()
|
|
1727
|
+
this.pauseOnlineWork = null; this.pauseOnlineController = null
|
|
1728
|
+
return
|
|
1729
|
+
}
|
|
1730
|
+
for (const reason of this.automaticPauses) {
|
|
1731
|
+
if (!this.pauseReasons.has(reason)) continue
|
|
1732
|
+
let task = this.pauseRecoveries.get(reason)
|
|
1733
|
+
if (!task) {
|
|
1734
|
+
task = this.recoveryTask(async signal => {
|
|
1735
|
+
if (reason === 'storage') await this.retryStorageWrites(signal)
|
|
1736
|
+
await this.flushStateWrites()
|
|
1737
|
+
signal.throwIfAborted()
|
|
1738
|
+
await this.resume(reason)
|
|
1739
|
+
}, {
|
|
1740
|
+
network: reason === 'network', retryable: error => {
|
|
1741
|
+
const retry = reason === 'network' ? isRetryableRelayFailure(error) : isRecoverableStorageFailure(error)
|
|
1742
|
+
if (!retry) this.automaticPauses.delete(reason)
|
|
1743
|
+
return retry
|
|
1744
|
+
}
|
|
1745
|
+
})
|
|
1746
|
+
this.pauseRecoveries.set(reason, task)
|
|
1747
|
+
}
|
|
1748
|
+
task.start()
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
async retryStorageWrites (signal) {
|
|
1753
|
+
for (const [key, pending] of this.pendingStorageWrites) {
|
|
1754
|
+
signal.throwIfAborted()
|
|
1755
|
+
const pubkey = pending.record.channelPubkey
|
|
1756
|
+
if (!this.channels.has(pubkey) || !this.desiredChannels.has(pubkey)) { this.pendingStorageWrites.delete(key); continue }
|
|
1757
|
+
try {
|
|
1758
|
+
if (!pending.dedupeKey || !await this.queue.someBy('byChannelTypeEventId', pending.dedupeKey)) await this.queue.enqueue(pending.record)
|
|
1759
|
+
} catch (error) { if (error?.name !== 'ConstraintError') throw error }
|
|
1760
|
+
signal.throwIfAborted()
|
|
1761
|
+
this.pendingStorageWrites.delete(key)
|
|
1762
|
+
this.markSeen(pubkey, pending.at || nowSeconds())
|
|
1763
|
+
this.wakeDeliveries()
|
|
1764
|
+
this.onMessageQueued?.()
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
pauseInternally (reason) {
|
|
1769
|
+
if (!this.pauseReasons.has(reason) || this.automaticPauses.has(reason)) this.automaticPauses.add(reason)
|
|
1770
|
+
return this.applyPause(reason)
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1631
1773
|
pause (reason) {
|
|
1774
|
+
// An explicit caller takes ownership, even if this cause already exists.
|
|
1775
|
+
this.automaticPauses.delete(reason)
|
|
1776
|
+
return this.applyPause(reason)
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
applyPause (reason) {
|
|
1632
1780
|
if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
|
|
1633
1781
|
this.assertOpen()
|
|
1634
1782
|
this.pauseReasons.add(reason)
|
|
1783
|
+
this.notifyStatus()
|
|
1635
1784
|
for (const extension of this.extensions) extension.pause?.()
|
|
1636
1785
|
this.recordInterruption(this.desiredChannels)
|
|
1637
1786
|
for (const controller of this.recoveryControllers) controller.abort()
|
|
@@ -1641,7 +1790,14 @@ export class PrivateMessenger {
|
|
|
1641
1790
|
async resume (reason) {
|
|
1642
1791
|
if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
|
|
1643
1792
|
this.assertOpen()
|
|
1793
|
+
// Repair durable state before announcing that publication can resume.
|
|
1794
|
+
if (reason === 'storage' && this.pendingStorageWrites.size) await this.retryStorageWrites(this.sendRoutingLifetime.signal)
|
|
1795
|
+
await this.flushStateWrites()
|
|
1796
|
+
this.assertOpen()
|
|
1797
|
+
const wasAutomatic = this.automaticPauses.has(reason)
|
|
1644
1798
|
const removed = this.pauseReasons.delete(reason)
|
|
1799
|
+
this.automaticPauses.delete(reason)
|
|
1800
|
+
this.notifyStatus()
|
|
1645
1801
|
if (reason === 'capacity' && removed) {
|
|
1646
1802
|
clearInterval(this.capacityTimer)
|
|
1647
1803
|
this.capacityTimer = null
|
|
@@ -1664,7 +1820,10 @@ export class PrivateMessenger {
|
|
|
1664
1820
|
})()
|
|
1665
1821
|
this.resumeWork = work
|
|
1666
1822
|
try { await work } catch (err) {
|
|
1667
|
-
if (!this.closePromise)
|
|
1823
|
+
if (!this.closePromise) {
|
|
1824
|
+
if (wasAutomatic && (reason === 'network' ? isRetryableRelayFailure(err) : isRecoverableStorageFailure(err))) this.automaticPauses.add(reason)
|
|
1825
|
+
await this.applyPause(reason)
|
|
1826
|
+
}
|
|
1668
1827
|
throw err
|
|
1669
1828
|
} finally { if (this.resumeWork === work) this.resumeWork = null }
|
|
1670
1829
|
}
|
|
@@ -1677,7 +1836,7 @@ export class PrivateMessenger {
|
|
|
1677
1836
|
return this.queueIncoming(async () => {
|
|
1678
1837
|
if (this.pauseReasons.size || !this.desiredChannels.has(pubkey)) {
|
|
1679
1838
|
this.recordInterruption([pubkey], messageTime(message))
|
|
1680
|
-
throw
|
|
1839
|
+
throw this.pausedError()
|
|
1681
1840
|
}
|
|
1682
1841
|
return operation()
|
|
1683
1842
|
})
|
|
@@ -1790,19 +1949,20 @@ export class PrivateMessenger {
|
|
|
1790
1949
|
this.debug('dedupe', debugMessageInfo(type, channelPubkey, message))
|
|
1791
1950
|
return
|
|
1792
1951
|
}
|
|
1952
|
+
const record = {
|
|
1953
|
+
type,
|
|
1954
|
+
channelPubkey,
|
|
1955
|
+
receivedAt: nowSeconds(),
|
|
1956
|
+
event: message.event,
|
|
1957
|
+
...info,
|
|
1958
|
+
payload: message.payload,
|
|
1959
|
+
question: message.question || null,
|
|
1960
|
+
questionId: message.questionId || null,
|
|
1961
|
+
outer: message.outer || null,
|
|
1962
|
+
meta: message.meta || null
|
|
1963
|
+
}
|
|
1793
1964
|
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
|
-
})
|
|
1965
|
+
await this.queue.enqueue(record)
|
|
1806
1966
|
} catch (err) {
|
|
1807
1967
|
// The unique index closes the small cross-instance race after `someBy`.
|
|
1808
1968
|
if (dedupeKey && err?.name === 'ConstraintError') {
|
|
@@ -1813,7 +1973,8 @@ export class PrivateMessenger {
|
|
|
1813
1973
|
this.recordInterruption([channelPubkey], message.outer?.created_at || message.event?.created_at || nowSeconds())
|
|
1814
1974
|
// Do not await stop from inside its own transport callback.
|
|
1815
1975
|
const atCapacity = err.message === 'QUEUE_CAPACITY_EXCEEDED'
|
|
1816
|
-
this.
|
|
1976
|
+
if (!atCapacity && isRecoverableStorageFailure(err)) this.pendingStorageWrites.set(JSON.stringify(dedupeKey || [channelPubkey, type]), { record, dedupeKey, at: message.outer?.created_at || message.event?.created_at })
|
|
1977
|
+
;(atCapacity ? this.pause('capacity') : isRecoverableStorageFailure(err) ? this.pauseInternally('storage') : this.pause('storage')).catch(error => this.onError?.(error))
|
|
1817
1978
|
if (atCapacity) {
|
|
1818
1979
|
this.capacityRequiredBytes = Math.max(this.capacityRequiredBytes, err.requiredBytes || 0)
|
|
1819
1980
|
if (!this.capacityTimer) {
|
|
@@ -2247,7 +2408,7 @@ export class PrivateMessenger {
|
|
|
2247
2408
|
|
|
2248
2409
|
requireWritableChannel (pubkey) {
|
|
2249
2410
|
this.assertOpen()
|
|
2250
|
-
if (this.pauseReasons.size) throw
|
|
2411
|
+
if (this.pauseReasons.size) throw this.pausedError()
|
|
2251
2412
|
const channel = this.requireChannel(pubkey)
|
|
2252
2413
|
if (!channel.signer) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
|
|
2253
2414
|
return channel
|
|
@@ -2373,12 +2534,12 @@ export class PrivateMessenger {
|
|
|
2373
2534
|
ensureNetworkWatchers () {
|
|
2374
2535
|
if (typeof window === 'undefined') return
|
|
2375
2536
|
if (!this.stopOffline) {
|
|
2376
|
-
const offline = () => { this.
|
|
2537
|
+
const offline = () => { this.pauseInternally('network').catch(err => this.onError?.(err)) }
|
|
2377
2538
|
window.addEventListener('offline', offline)
|
|
2378
2539
|
this.stopOffline = () => window.removeEventListener('offline', offline)
|
|
2379
2540
|
}
|
|
2380
2541
|
if (!this.stopOnline) {
|
|
2381
|
-
const online = () => { this.
|
|
2542
|
+
const online = () => { this.syncPauseRecovery(); this.pauseRecoveries.get('network')?.wake() }
|
|
2382
2543
|
window.addEventListener('online', online)
|
|
2383
2544
|
this.stopOnline = () => window.removeEventListener('online', online)
|
|
2384
2545
|
}
|
|
@@ -2718,7 +2879,7 @@ export class PrivateMessenger {
|
|
|
2718
2879
|
controller.channelPubkey = pubkey
|
|
2719
2880
|
this.recoveryControllers.add(controller)
|
|
2720
2881
|
try {
|
|
2721
|
-
if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw
|
|
2882
|
+
if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw this.pausedError()
|
|
2722
2883
|
const fetchRelays = await this.resolveWatchRelays(channel)
|
|
2723
2884
|
controller.signal.throwIfAborted()
|
|
2724
2885
|
const record = this.recoveryRangeRecord(pubkey, range)
|
|
@@ -2875,6 +3036,9 @@ export class PrivateMessenger {
|
|
|
2875
3036
|
const initSettledPromise = this.initSettledPromise
|
|
2876
3037
|
this.sendRoutingLifetime.abort()
|
|
2877
3038
|
this.sendRelayExclusions.clear()
|
|
3039
|
+
this.pendingStorageWrites.clear()
|
|
3040
|
+
for (const task of this.pauseRecoveries.values()) task.stop()
|
|
3041
|
+
for (const task of this.watchRecoveries.values()) task.stop()
|
|
2878
3042
|
let unwatchPromise
|
|
2879
3043
|
try {
|
|
2880
3044
|
unwatchPromise = Promise.resolve(this.unwatch())
|
|
@@ -2932,6 +3096,7 @@ export class PrivateMessenger {
|
|
|
2932
3096
|
this.recoveryPriority.clear()
|
|
2933
3097
|
if (unwatchError) throw unwatchError
|
|
2934
3098
|
})()
|
|
3099
|
+
this.notifyStatus()
|
|
2935
3100
|
return this.closePromise
|
|
2936
3101
|
}
|
|
2937
3102
|
}
|
|
@@ -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.
|
|
@@ -5,11 +5,13 @@ import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/ind
|
|
|
5
5
|
import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
|
|
6
6
|
import { getIykcProofs } from '../../content-key/index.js'
|
|
7
7
|
import { onOnline } from '../../network/index.js'
|
|
8
|
+
import { ValidationError } from '../../error/index.js'
|
|
8
9
|
import { decodeFileMetadata } from '../../nip94/index.js'
|
|
9
10
|
import { createChatOutbox } from './helpers/work-storage.js'
|
|
10
11
|
import { messengerSigner } from './helpers/signer.js'
|
|
11
12
|
import { assertMessagePublished } from '../helpers/publication.js'
|
|
12
13
|
import { normalizeFallbackRelays } from '../helpers/send-routing.js'
|
|
14
|
+
import { createPauseRecovery, isRecoverableStorageFailure } from '../helpers/pause-recovery.js'
|
|
13
15
|
|
|
14
16
|
export function wireEvent (value, owner) {
|
|
15
17
|
const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
|
|
@@ -27,10 +29,18 @@ const SEND_RETRY_MAX_MS = 30000
|
|
|
27
29
|
const unavailableError = (error, signal) => Object.assign(new Error(error?.message || 'CHAT_UNAVAILABLE'), {
|
|
28
30
|
code: error?.code,
|
|
29
31
|
cause: error,
|
|
32
|
+
...(Array.isArray(error?.pauseReasons) ? { pauseReasons: [...error.pauseReasons] } : {}),
|
|
30
33
|
retryWhenAvailable: !signal?.aborted,
|
|
31
34
|
retryWhenOnline: false
|
|
32
35
|
})
|
|
33
36
|
|
|
37
|
+
function messengerStatusSnapshot (value) {
|
|
38
|
+
if (!value || typeof value.closed !== 'boolean' || typeof value.paused !== 'boolean' ||
|
|
39
|
+
!Array.isArray(value.pauseReasons) || value.pauseReasons.some(reason => typeof reason !== 'string' || !reason.trim()) ||
|
|
40
|
+
value.paused !== Boolean(value.pauseReasons.length)) throw new ValidationError('INVALID_MESSENGER_STATUS')
|
|
41
|
+
return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], 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' }) }) {
|
|
35
45
|
fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
36
46
|
const reportedErrors = new WeakSet()
|
|
@@ -51,6 +61,33 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
51
61
|
const cancelled = new Set()
|
|
52
62
|
const sendControllers = new Map()
|
|
53
63
|
const offlineSends = new Set()
|
|
64
|
+
const pausedSends = new Set()
|
|
65
|
+
let messengerStatus
|
|
66
|
+
let messengerBinding
|
|
67
|
+
let storageBlocked = false
|
|
68
|
+
const inboxRecovery = createPauseRecovery({
|
|
69
|
+
attempt: async signal => { signal.throwIfAborted(); await drain(); if (storageBlocked) throw inboxError },
|
|
70
|
+
retryable: isRecoverableStorageFailure, onError: reportError,
|
|
71
|
+
setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random
|
|
72
|
+
})
|
|
73
|
+
let inboxError
|
|
74
|
+
const stateChanged = state => {
|
|
75
|
+
if (closed) return
|
|
76
|
+
messengerStatus = messengerStatusSnapshot(state)
|
|
77
|
+
if (!available || state.closed) return
|
|
78
|
+
if (state.paused) {
|
|
79
|
+
for (const entry of entries.values()) {
|
|
80
|
+
if (entry.peer !== owner && entry.failed && entry.status === 'pending') pausedSends.add(entry.id)
|
|
81
|
+
}
|
|
82
|
+
if (![...entries.values()].some(entry => pendingRetryable(entry) && !pausedSends.has(entry.id))) clearSendRetry()
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
clearSendRetry()
|
|
86
|
+
for (const id of pausedSends) { const entry = entries.get(id); if (entry) entry.failed = false }
|
|
87
|
+
pausedSends.clear()
|
|
88
|
+
// The callback can run inside construction/resume, before configure settles.
|
|
89
|
+
queueMicrotask(() => pump())
|
|
90
|
+
}
|
|
54
91
|
let stopOnline
|
|
55
92
|
let sendRetryTimer
|
|
56
93
|
let sendRetryDelay = SEND_RETRY_MIN_MS
|
|
@@ -70,12 +107,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
70
107
|
let storage
|
|
71
108
|
let initialized
|
|
72
109
|
let closed = false
|
|
110
|
+
let closeWork
|
|
73
111
|
let available = false
|
|
74
112
|
let lifecycle = 0
|
|
75
113
|
let configuring = Promise.resolve()
|
|
114
|
+
let configurationFailed = false
|
|
76
115
|
let sending
|
|
77
116
|
let repump
|
|
78
117
|
let draining
|
|
118
|
+
let redrain = false
|
|
79
119
|
const idleWaiters = new Set()
|
|
80
120
|
const settled = () => { if (!sending && !draining) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
|
|
81
121
|
const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
|
|
@@ -84,6 +124,35 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
84
124
|
for (const entry of await storage.list()) entries.set(entry.id, entry)
|
|
85
125
|
emit()
|
|
86
126
|
})().catch(async error => { await storage?.close(); storage = null; initialized = null; throw error }))
|
|
127
|
+
async function createMessenger () {
|
|
128
|
+
const binding = {}
|
|
129
|
+
messengerBinding = binding
|
|
130
|
+
let notified = false
|
|
131
|
+
let candidate
|
|
132
|
+
try {
|
|
133
|
+
candidate = await Messenger({
|
|
134
|
+
fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
|
|
135
|
+
onMessageQueued: () => { if (messengerBinding === binding) return drain() },
|
|
136
|
+
onStateChanged: state => {
|
|
137
|
+
if (closed || messengerBinding !== binding) return
|
|
138
|
+
stateChanged(state)
|
|
139
|
+
notified = true
|
|
140
|
+
},
|
|
141
|
+
onError: reportError
|
|
142
|
+
})
|
|
143
|
+
if (closed) { messengerBinding = null; await candidate?.close?.(); return }
|
|
144
|
+
if (typeof candidate?.readStatus !== 'function' || !notified) throw new ValidationError('MESSENGER_STATE_CONTRACT_REQUIRED')
|
|
145
|
+
const status = messengerStatusSnapshot(candidate.readStatus())
|
|
146
|
+
messenger = candidate
|
|
147
|
+
stateChanged(status)
|
|
148
|
+
} catch (error) {
|
|
149
|
+
messengerBinding = null
|
|
150
|
+
messengerStatus = undefined
|
|
151
|
+
try { await candidate?.close?.() } catch (cleanupError) { reportError(cleanupError) }
|
|
152
|
+
throw error
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
87
156
|
async function configure () {
|
|
88
157
|
if (closed || !available) return
|
|
89
158
|
await ready()
|
|
@@ -102,7 +171,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
102
171
|
if (closed || !available || version !== lifecycle) return
|
|
103
172
|
values.push(channel)
|
|
104
173
|
}
|
|
105
|
-
if (!messenger)
|
|
174
|
+
if (!messenger) await createMessenger()
|
|
175
|
+
if (!messenger) return
|
|
106
176
|
if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
|
|
107
177
|
await messenger.update({ channels: values })
|
|
108
178
|
if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
|
|
@@ -116,6 +186,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
116
186
|
}
|
|
117
187
|
}
|
|
118
188
|
await messenger.resume('signer')
|
|
189
|
+
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
119
190
|
if (!fileTransfers) {
|
|
120
191
|
fileTransfers = FileTransfer({
|
|
121
192
|
messenger, onError: reportError,
|
|
@@ -153,13 +224,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
153
224
|
function schedule () {
|
|
154
225
|
const work = configuring.catch(() => {}).then(configure)
|
|
155
226
|
configuring = work
|
|
156
|
-
work.
|
|
227
|
+
work.then(() => { if (configuring === work) configurationFailed = false }, error => { if (configuring === work) configurationFailed = true; reportError(error) })
|
|
157
228
|
return work
|
|
158
229
|
}
|
|
159
230
|
async function drain () {
|
|
160
|
-
if (draining
|
|
231
|
+
if (draining) { redrain = true; return }
|
|
232
|
+
if (closed || !available || !messenger) return
|
|
161
233
|
draining = true
|
|
162
234
|
const deferred = []
|
|
235
|
+
let persisted = false
|
|
236
|
+
let passFailed = false
|
|
163
237
|
try {
|
|
164
238
|
while (true) {
|
|
165
239
|
if (closed || !available) break
|
|
@@ -181,16 +255,33 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
181
255
|
if (!available || closed) { await nack(); break }
|
|
182
256
|
const saved = await messageStorage.save(event, { peerPubkey: peer, hearsay: message.provenance === 'hearsay' })
|
|
183
257
|
if (!saved?.result?.ok && !['blocked', 'expired'].includes(saved?.result?.code)) throw Object.assign(new Error('INBOX_STORAGE_FAILED'), { code: saved?.result?.code?.toUpperCase() })
|
|
184
|
-
await
|
|
258
|
+
if (!available || closed) { await nack(); break }
|
|
259
|
+
if (await ack() === false) throw new Error('INBOX_ACK_FAILED')
|
|
260
|
+
persisted = true
|
|
185
261
|
} catch (error) {
|
|
186
262
|
await nack()
|
|
187
263
|
reportError(error)
|
|
188
264
|
// Retain the failed record and suspend network ingestion until recovery.
|
|
189
|
-
|
|
265
|
+
passFailed = true
|
|
266
|
+
storageBlocked = true
|
|
267
|
+
inboxError = error
|
|
268
|
+
await messenger.pause('session-storage')
|
|
269
|
+
if (isRecoverableStorageFailure(error)) inboxRecovery.start()
|
|
190
270
|
break
|
|
191
271
|
}
|
|
192
272
|
}
|
|
193
|
-
|
|
273
|
+
if (storageBlocked && persisted && !passFailed) {
|
|
274
|
+
// A retry can save one record and fail the next. Only release after the
|
|
275
|
+
// whole currently admissible pass completes without another failure.
|
|
276
|
+
storageBlocked = false; inboxError = null
|
|
277
|
+
await messenger.resume('session-storage')
|
|
278
|
+
}
|
|
279
|
+
} finally {
|
|
280
|
+
await Promise.all(deferred.map(nack => nack()))
|
|
281
|
+
draining = false; settled()
|
|
282
|
+
const again = redrain; redrain = false
|
|
283
|
+
if (again && !storageBlocked && !closed && available) queueMicrotask(() => drain())
|
|
284
|
+
}
|
|
194
285
|
}
|
|
195
286
|
async function publish (peer, event, signal) {
|
|
196
287
|
if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
|
|
@@ -229,6 +320,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
229
320
|
await storage.put(entry, { existing: true })
|
|
230
321
|
}
|
|
231
322
|
}
|
|
323
|
+
if (entry.peer !== owner && messengerStatus?.paused) {
|
|
324
|
+
throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
|
|
325
|
+
}
|
|
232
326
|
// Read/publish one chunk at a time. Progress is durable after each
|
|
233
327
|
// accepted chunk; replay after interruption keeps its original identity.
|
|
234
328
|
for (; entry.peer !== owner && entry.fileIndex < entry.files.length; entry.fileIndex++, entry.chunkIndex = 0) {
|
|
@@ -273,20 +367,20 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
273
367
|
if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
|
|
274
368
|
entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
|
|
275
369
|
await storage.put(entry, { existing: true }).catch(reportError)
|
|
370
|
+
if (entry.peer !== owner && error.retryWhenAvailable && messenger.readStatus().paused) pausedSends.add(entry.id)
|
|
276
371
|
reportError(error)
|
|
277
372
|
// The failed wire event may be a quote or file chunk. Report the
|
|
278
373
|
// owning outbox item separately, without mutating the native error.
|
|
279
374
|
if (error.retryWhenOnline) offlineSends.add(entry.id)
|
|
280
|
-
if (error.retryWhenAvailable) scheduleSendRetry()
|
|
375
|
+
if (error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
|
|
281
376
|
if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
|
|
282
377
|
}
|
|
283
378
|
emit()
|
|
284
379
|
} finally { sendControllers.delete(entry.id) }
|
|
285
380
|
}
|
|
286
381
|
function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
// instead of waiting for the next account-state change.
|
|
382
|
+
// Transient availability failures without an active messenger pause retain
|
|
383
|
+
// bounded retries. Observed pauses wait exclusively for their release.
|
|
290
384
|
function clearSendRetry () {
|
|
291
385
|
if (sendRetryTimer) _clearTimeout(sendRetryTimer)
|
|
292
386
|
sendRetryTimer = undefined
|
|
@@ -305,7 +399,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
305
399
|
if (closed || !available) return
|
|
306
400
|
let retried = false
|
|
307
401
|
for (const entry of entries.values()) {
|
|
308
|
-
if (!pendingRetryable(entry)) continue
|
|
402
|
+
if (!pendingRetryable(entry) || pausedSends.has(entry.id)) continue
|
|
309
403
|
entry.failed = false
|
|
310
404
|
retried = true
|
|
311
405
|
}
|
|
@@ -341,9 +435,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
341
435
|
for (const entry of entries.values()) {
|
|
342
436
|
if (closed || !available) break
|
|
343
437
|
if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
|
|
438
|
+
if (entry.peer !== owner && entry.files.length && !fileTransfers) continue
|
|
344
439
|
await sendEntry(entry)
|
|
345
440
|
}
|
|
346
|
-
} while (repump
|
|
441
|
+
} while (repump)
|
|
347
442
|
} finally {
|
|
348
443
|
sending = false
|
|
349
444
|
watchOfflineSends()
|
|
@@ -416,21 +511,26 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
416
511
|
return true
|
|
417
512
|
},
|
|
418
513
|
async setPeers (values) {
|
|
419
|
-
|
|
514
|
+
const next = new Set([...values].filter(peer => peer !== owner))
|
|
515
|
+
if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
|
|
516
|
+
peers.clear(); for (const peer of next) peers.add(peer)
|
|
420
517
|
for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
|
|
421
518
|
lifecycle++
|
|
422
519
|
return schedule()
|
|
423
520
|
},
|
|
424
521
|
async setAvailable (value) {
|
|
522
|
+
const changed = available !== (value === true)
|
|
425
523
|
available = value === true
|
|
524
|
+
if (!changed && !available) return
|
|
426
525
|
stopOnlineRetry(); offlineSends.clear()
|
|
427
526
|
if (available) resetSendRetry()
|
|
428
527
|
else clearSendRetry()
|
|
429
|
-
lifecycle++
|
|
430
|
-
if (!available) { await messenger?.pause('signer'); return }
|
|
528
|
+
if (changed) lifecycle++
|
|
529
|
+
if (!available) { inboxRecovery.stop(); await messenger?.pause('signer'); return }
|
|
431
530
|
await ready()
|
|
432
531
|
for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
|
|
433
|
-
|
|
532
|
+
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
533
|
+
if (!changed && messenger && !configurationFailed) return pump()
|
|
434
534
|
return schedule()
|
|
435
535
|
},
|
|
436
536
|
async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
|
|
@@ -446,7 +546,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
446
546
|
return id
|
|
447
547
|
},
|
|
448
548
|
async retry (id) {
|
|
449
|
-
offlineSends.delete(id); watchOfflineSends()
|
|
549
|
+
offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
|
|
550
|
+
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
450
551
|
await ready()
|
|
451
552
|
const entry = entries.get(id)
|
|
452
553
|
if (entry) { entry.failed = false; resetSendRetry(); if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
|
|
@@ -454,24 +555,26 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
454
555
|
async cancel (id) {
|
|
455
556
|
cancelled.add(id)
|
|
456
557
|
sendControllers.get(id)?.abort()
|
|
457
|
-
offlineSends.delete(id); watchOfflineSends()
|
|
558
|
+
offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
|
|
458
559
|
await ready()
|
|
459
560
|
entries.delete(id)
|
|
460
561
|
await storage.remove(id)
|
|
461
562
|
emit()
|
|
462
563
|
},
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
564
|
+
close () {
|
|
565
|
+
return (closeWork ??= (async () => {
|
|
566
|
+
closed = true; available = false; lifecycle++
|
|
567
|
+
stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
|
|
568
|
+
clearSendRetry(); inboxRecovery.stop(); pausedSends.clear()
|
|
569
|
+
for (const controller of sendControllers.values()) controller.abort()
|
|
570
|
+
await Promise.allSettled([configuring, initialized])
|
|
571
|
+
await messenger?.pause('closed')
|
|
572
|
+
if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
|
|
573
|
+
await messenger?.close()
|
|
574
|
+
await storage?.close()
|
|
575
|
+
await downloadWriteTail.catch(reportError)
|
|
576
|
+
await downloads?.close()
|
|
577
|
+
})())
|
|
475
578
|
}
|
|
476
579
|
}
|
|
477
580
|
}
|
package/relay/helpers/error.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseRelayRetryAdvice } from './retry-advice.js'
|
|
2
|
+
|
|
1
3
|
// Adds transport context without replacing native messages, codes or causes.
|
|
2
4
|
export function categorizeRelayError (reason, category, fallback = 'RELAY_OPERATION_FAILED') {
|
|
3
5
|
const error = reason instanceof Error ? reason : new Error(String(reason || fallback))
|
|
@@ -29,14 +31,10 @@ export function relayCloseError (event, category, cause) {
|
|
|
29
31
|
return categorizeRelayError(error, category)
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
+
// Keep native diagnostics and attach only bounded timing advice. An absolute
|
|
35
|
+
// deadline survives delayed delivery through a launcher-owned relay bridge.
|
|
34
36
|
export function relayRejectionError (reason, extra, fallback) {
|
|
35
37
|
const error = categorizeRelayError(reason, 'relay', fallback)
|
|
36
|
-
|
|
37
|
-
if (error.message.startsWith('rate-limited:') && typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0) {
|
|
38
|
-
error.retryAfterMs = Math.min(seconds, 300) * 1000
|
|
39
|
-
error.retryAt = Date.now() + error.retryAfterMs
|
|
40
|
-
}
|
|
38
|
+
Object.assign(error, parseRelayRetryAdvice(error.message, extra))
|
|
41
39
|
return error
|
|
42
40
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Nostr transport extensions, in seconds. Timing advice is bounded and never
|
|
2
|
+
// supplies an error category, provenance or a routing/retry policy.
|
|
3
|
+
export function parseRelayRetryAdvice (reason, extra, { now = Date.now() } = {}) {
|
|
4
|
+
if (typeof reason !== 'string' || !reason.startsWith('rate-limited:') ||
|
|
5
|
+
!extra || typeof extra !== 'object' || Array.isArray(extra) || !Number.isFinite(now)) return null
|
|
6
|
+
|
|
7
|
+
const after = extra.retry_after
|
|
8
|
+
const retryAfterMs = typeof after === 'number' && Number.isFinite(after) && after > 0
|
|
9
|
+
? Math.min(after, 300) * 1000
|
|
10
|
+
: undefined
|
|
11
|
+
const at = extra.retry_at
|
|
12
|
+
const absolute = typeof at === 'number' && Number.isFinite(at) && at > 0 && Number.isFinite(at * 1000)
|
|
13
|
+
? Math.min(at * 1000, now + 300000)
|
|
14
|
+
: undefined
|
|
15
|
+
const retryAt = absolute ?? (retryAfterMs === undefined ? undefined : now + retryAfterMs)
|
|
16
|
+
if (retryAt === undefined) return null
|
|
17
|
+
return { ...(retryAfterMs === undefined ? {} : { retryAfterMs }), retryAt }
|
|
18
|
+
}
|
package/relay/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { freeRelays, nappRelays, seedRelays } from './constants/index.js'
|
|
2
2
|
export { isRetryableRelayFailure, isReplaceableRelayFailure } from './helpers/failure.js'
|
|
3
|
+
export { parseRelayRetryAdvice } from './helpers/retry-advice.js'
|
|
3
4
|
export { pickRelaysForPubkeys } from './helpers/routing.js'
|
|
4
5
|
export { RelayPool, relayPool } from './services/relay-pool.js'
|
|
5
6
|
export { getLatestEventsByPubkey } from './services/events.js'
|