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 CHANGED
@@ -1056,7 +1056,7 @@ in pairs per recipient when
1056
1056
  selected relays reject publication or become unreachable while connectivity is
1057
1057
  confirmed. The exact encrypted outer events are reused. Optional `fallbackRelays` on
1058
1058
  `createPrivateMessenger` and `createPrivateMessageSession` are tried after the
1059
- primary destinations, including explicit per-call `relays`, channel `sendRelays`
1059
+ primary destinations by default, including explicit per-call `relays`, channel `sendRelays`
1060
1060
  and channel/global `relays`, as well as explicit `relayToReceivers` maps.
1061
1061
  Retries preserve each encrypted recipient subset; success requires accepted
1062
1062
  publication covering every recipient in that subset. Receive routing adds them from the start for live
@@ -1126,3 +1126,17 @@ are unchanged; newer live arrivals cannot erase a pending interval.
1126
1126
  count/bytes, incoming bytes, limits and oldest queued age. Queue bytes are UTF-8
1127
1127
  serialized volume, not heap use. Diagnostics never retain event contents or
1128
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).
1136
+
1137
+ Private messenger/session constructors accept optional `fallbackDelayMs` (`null`
1138
+ by default). Automatic NIP-65 sends can start configured fallback relays after
1139
+ the chosen delay while primary publications retain their deadlines. Explicit
1140
+ routes keep sequential fallback; every encrypted recipient must be covered.
1141
+ `RelayPool.sendEvent` accepts a consumer `signal` without closing shared sockets.
1142
+ See [fallback and cancellation](private-messenger/session/README.md#relay-fallback).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.16",
3
+ "version": "0.11.18",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -0,0 +1,134 @@
1
+ import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
2
+
3
+ // Each invocation owns one already signed outer event and its encrypted subset.
4
+ // Lanes share coverage and the tried set; only started reports outlive the race.
5
+ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, now, setTimer, clearTimer }) {
6
+ const pending = new Set(receivers)
7
+ const summaries = []
8
+ const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
9
+ const outcome = Promise.withResolvers()
10
+ let primaryRunning = true
11
+ let fallbackRunning = false
12
+ let fallbackScheduled = true
13
+ let done = false
14
+ let blocked = false
15
+ let offline = false
16
+ let timer = null
17
+ let onlineWork
18
+ const current = () => !transportSignal?.aborted && isCurrent()
19
+ const summary = success => Promise.all(summaries).then(reports => ({
20
+ success, total: tried.size,
21
+ fulfilled: reports.reduce((total, report) => total + (report.fulfilled || 0), 0),
22
+ succeededRelays: [...new Set(reports.flatMap(report => report.succeededRelays || []))],
23
+ errors: reports.flatMap(report => report.errors || [])
24
+ }))
25
+ const finish = success => {
26
+ if (done) return
27
+ done = true
28
+ clearTimer(timer); timer = null
29
+ transportSignal?.removeEventListener('abort', interrupted)
30
+ const interruptedWork = !success && (offline || !current())
31
+ outcome.resolve({
32
+ success, total: tried.size, promise: summary(success),
33
+ ...(interruptedWork && !signal?.aborted ? { retryWhenAvailable: true, ...(offline ? { retryWhenOnline: true } : {}) } : {})
34
+ })
35
+ }
36
+ const check = () => {
37
+ if (done) return
38
+ if (!pending.size) finish(true)
39
+ else if (!primaryRunning && !fallbackRunning && !fallbackScheduled) finish(false)
40
+ }
41
+ const interrupted = () => finish(false)
42
+ const online = async () => {
43
+ if (done || !current()) return false
44
+ try {
45
+ if (!onlineWork) {
46
+ const work = Promise.resolve().then(() => isOnline({ signal: transportSignal })).finally(() => { if (onlineWork === work) onlineWork = null })
47
+ onlineWork = work
48
+ }
49
+ const connected = await onlineWork
50
+ if (done || !current()) return false
51
+ if (!connected) offline = true
52
+ return connected
53
+ } catch { if (!done && current()) offline = true; return false }
54
+ }
55
+ const launch = async (batch, extra = []) => {
56
+ const relays = [...new Set([...batch.relays, ...extra])].filter(relay => !tried.has(relay))
57
+ if (!relays.length) return
58
+ for (const relay of relays) tried.add(relay)
59
+ const slot = Promise.withResolvers()
60
+ summaries.push(slot.promise)
61
+ let result
62
+ try { result = await sendEvent(event, relays, { signal: transportSignal }) } catch (reason) {
63
+ result = { success: false, promise: Promise.resolve({ fulfilled: 0, errors: [{ reason }] }) }
64
+ }
65
+ const settled = Promise.resolve(result.promise).catch(reason => ({ fulfilled: 0, errors: [{ reason }] }))
66
+ slot.resolve(settled)
67
+ settled.then(async report => {
68
+ const failed = (report.errors || []).filter(item => isReplaceableRelayFailure(item.reason))
69
+ // Late redundant failures can inform later sends after this race wins.
70
+ if (failed.length && (result.success || (done && !pending.size)) && !transportSignal?.aborted && isCurrent()) {
71
+ try { if (await isOnline({ signal: transportSignal }) && isCurrent()) remember(failed) } catch {}
72
+ }
73
+ }).catch(() => {})
74
+ if (result.success) {
75
+ if (!done) for (const receiver of batch.receivers) pending.delete(receiver)
76
+ check()
77
+ return
78
+ }
79
+ const report = await settled
80
+ if (done) return
81
+ const errors = report.errors || []
82
+ if (!errors.length || errors.some(item => !isReplaceableRelayFailure(item.reason))) blocked = true
83
+ else if (await online()) remember(errors)
84
+ }
85
+ async function fallback () {
86
+ if (done || fallbackRunning || !fallbackScheduled) return
87
+ clearTimer(timer)
88
+ fallbackScheduled = false
89
+ fallbackRunning = true
90
+ try {
91
+ if (blocked || offline || !pending.size || !current()) return
92
+ let batch = nextFallback(pending)
93
+ if (!batch || !await online()) return
94
+ while (batch) {
95
+ if (done || blocked || offline || !current()) break
96
+ await launch(batch)
97
+ batch = nextFallback(pending)
98
+ }
99
+ } finally { fallbackRunning = false; check() }
100
+ }
101
+ async function primary () {
102
+ try {
103
+ let batch = first || nextPrimary(pending)
104
+ let opening = true
105
+ while (batch) {
106
+ if (done || blocked || offline || !current()) break
107
+ await launch(batch, opening ? mirrors : [])
108
+ opening = false
109
+ batch = nextPrimary(pending)
110
+ }
111
+ } finally {
112
+ primaryRunning = false
113
+ // Exhaustion before the deadline starts the fallback immediately.
114
+ if (!done && fallbackScheduled) {
115
+ if (!blocked && !offline && current()) await fallback()
116
+ else { clearTimer(timer); fallbackScheduled = false }
117
+ }
118
+ check()
119
+ }
120
+ }
121
+ if (transportSignal?.aborted || !isCurrent()) { finish(false); return outcome.promise }
122
+ transportSignal?.addEventListener('abort', interrupted, { once: true })
123
+ const deadline = now() + delay
124
+ const failed = reason => {
125
+ if (done) return
126
+ done = true; clearTimer(timer); timer = null
127
+ transportSignal?.removeEventListener('abort', interrupted)
128
+ outcome.reject(reason)
129
+ }
130
+ timer = setTimer(() => { fallback().catch(failed) }, Math.max(0, deadline - now()))
131
+ timer?.unref?.()
132
+ primary().catch(failed)
133
+ return outcome.promise
134
+ }
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { normalizeRelayUrl } from '../../url/index.js'
2
2
  import { ValidationError } from '../../error/index.js'
3
+ import { publishWithEarlyFallback } from './fallback-race.js'
3
4
  import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
4
5
 
5
6
  const EXCLUSION_MS = 5 * 60 * 1000
@@ -12,6 +13,11 @@ export function normalizeFallbackRelays (value = []) {
12
13
  return [...new Set(Array.from(value, normalizeRelayUrl))]
13
14
  }
14
15
 
16
+ export function normalizeFallbackDelay (value = null) {
17
+ if (value !== null && (!Number.isSafeInteger(value) || value < 0 || value > 2147483647)) throw new ValidationError('INVALID_FALLBACK_DELAY')
18
+ return value
19
+ }
20
+
15
21
  function routeEntries (routes) {
16
22
  return routes instanceof Map ? [...routes] : Object.entries(routes || {})
17
23
  }
@@ -26,9 +32,11 @@ function routeGroups (routes) {
26
32
  return [...groups.values()]
27
33
  }
28
34
 
29
- export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, now = Date.now }) {
35
+ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
30
36
  // Keep discovery's defaults when NIP-65 is unavailable. Explicit maps/lists
31
37
  // retain their fanout; configured fallbacks are a separate last-resort stage.
38
+ fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
39
+ const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
32
40
  const explicit = Boolean(primaryRelays || primaryRelayToReceivers)
33
41
  const primaryRoutes = primaryRelayToReceivers || (primaryRelays
34
42
  ? new Map(primaryRelays.map(relay => [relay, peers]))
@@ -61,12 +69,12 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
61
69
  return routes
62
70
  }
63
71
  const relayToReceivers = primaryRelayToReceivers || select(peers, exclusions.keys())
64
- const current = () => !signal?.aborted && isCurrent()
72
+ const current = () => !transportSignal?.aborted && isCurrent()
65
73
  const rejected = report => (report?.errors || []).filter(item => candidateSet.has(normalized(item.relay)) && isReplaceableRelayFailure(item.reason))
66
- const remember = errors => { for (const item of errors) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
74
+ const remember = errors => { for (const item of errors) if (candidateSet.has(normalized(item.relay))) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
67
75
  const online = async () => {
68
76
  if (!current()) return false
69
- try { return await isOnline({ signal }) && current() } catch { return false }
77
+ try { return await isOnline({ signal: transportSignal }) && current() } catch { return false }
70
78
  }
71
79
 
72
80
  async function send (event, initialRelays, context = {}) {
@@ -77,6 +85,24 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
77
85
  const mirrors = initialRelays.filter(relay => !candidateSet.has(normalized(relay)))
78
86
  const initialPrimary = context.primaryRelays || (primaryRelays || peers.length === 1 ? routeEntries(relayToReceivers).map(([relay]) => relay) : [])
79
87
  const first = [...new Set(initialPrimary.map(normalizeRelayUrl))].filter(relay => !exclusions.has(relay))
88
+ if (!explicit && fallbackDelayMs !== null && fallbackRelays.length) {
89
+ const tried = new Set()
90
+ const pick = (pending, fallback) => {
91
+ const excluded = [...new Set([...exclusions.keys(), ...tried])]
92
+ const routes = pickRelays([...pending], fallback
93
+ ? Object.fromEntries([...pending].map(pubkey => [pubkey, { read: fallbackRelays }]))
94
+ : primaryByPeer, { relayType: 'read', maxPerPubkey: 2, excludeRelaysByPubkey: new Map([...pending].map(pubkey => [pubkey, excluded])), emptyRelaysFallback: [] })
95
+ return routeGroups(routes)[0]
96
+ }
97
+ const result = await publishWithEarlyFallback({
98
+ event, receivers, first: first.length ? { receivers, relays: first } : null, mirrors, tried,
99
+ nextPrimary: pending => pick(pending, false), nextFallback: pending => pick(pending, true),
100
+ sendEvent, isOnline, isCurrent, signal, pauseSignal, remember,
101
+ delay: fallbackDelayMs, now, setTimer, clearTimer
102
+ })
103
+ if (!result.success && current()) exclusions.clear()
104
+ return result
105
+ }
80
106
  let batch = first.length ? { receivers, relays: first } : null
81
107
  const tried = new Set()
82
108
  const errors = []
@@ -106,7 +132,7 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
106
132
  for (const relay of relays) tried.add(normalized(relay))
107
133
  // Every relay in this batch serves the same pending recipient subset.
108
134
  // One ACK covers that subset, never unrelated members of the ciphertext.
109
- result = await sendEvent(event, relays)
135
+ result = await sendEvent(event, relays, { signal: transportSignal })
110
136
  const settled = Promise.resolve(result.promise)
111
137
  summaries.push(settled)
112
138
  if (result.success) {