libp2r2p 0.11.17 → 0.11.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -535,7 +535,8 @@ new channels are watched unless paused. Applications may explicitly watch again.
535
535
 
536
536
  Pauses, unwatch and close record the start of the gap, even for channels that
537
537
  have never received a message. Rewatch/resume uses live delivery plus historical
538
- recovery with overlap. A failed resume remains retryable under its pause reason.
538
+ recovery with overlap. Remote recovery failures never restore the caller's pause
539
+ reason: signer availability belongs exclusively to the account owner.
539
540
  Before opening a watch, the messenger persists a pending recovery interval.
540
541
  A first watch scans the configured recovery window (seven days by default),
541
542
  including messages sent before this identity first opened the channel. Successful
@@ -546,6 +547,24 @@ This also covers abrupt termination without `close()` and channels with no
546
547
  messages. Applications still supply signers and desired channels on reopening;
547
548
  storage is scoped by origin and user pubkey, not the transient session ID.
548
549
  Failed ingestion or incomplete relay fetch reports never complete the gap.
550
+
551
+ `init()` and `createPrivateMessenger()` resolve after local preparation. Likewise,
552
+ `update(options, { waitForBackground = false })` and
553
+ `resume(reason, { waitForBackground = false })` wait for valid local channel
554
+ configuration and mandatory durable writes, allowing sends independently of
555
+ subscriptions, presence and history. Set `waitForBackground: true` to also await
556
+ the initial background tasks associated with that call, not every future retry.
557
+ Both modes share tasks; unchanged configuration does not recreate subscriptions.
558
+ Later errors reach `onError` with the original error as `cause` and
559
+ `operation: 'private-messenger.background'` plus a `phase`. Cancelled or obsolete
560
+ tasks cannot deliver into a new channel generation. Mandatory persistence still
561
+ owns its storage pause; incomplete history remains a pending recovery interval.
562
+ Subscription setup is bounded separately from presence, so a slow announcement
563
+ does not hold the other channels' readers. Sessions prioritize user persistence
564
+ and publication before the next background presence signer call. Signing an outer
565
+ envelope can overlap recovery-seed persistence, but no envelope is yielded for
566
+ publication unless both succeed; only one outer is prepared at a time.
567
+
549
568
  `private-channel.fetch` reports incomplete reads as an `AggregateError` with
550
569
  stable `code: 'PRIVATE_CHANNEL_FETCH_INCOMPLETE'`. Its message names relays,
551
570
  completion statuses and native failure messages, including status-only failures.
@@ -1056,7 +1075,7 @@ in pairs per recipient when
1056
1075
  selected relays reject publication or become unreachable while connectivity is
1057
1076
  confirmed. The exact encrypted outer events are reused. Optional `fallbackRelays` on
1058
1077
  `createPrivateMessenger` and `createPrivateMessageSession` are tried after the
1059
- primary destinations, including explicit per-call `relays`, channel `sendRelays`
1078
+ primary destinations by default, including explicit per-call `relays`, channel `sendRelays`
1060
1079
  and channel/global `relays`, as well as explicit `relayToReceivers` maps.
1061
1080
  Retries preserve each encrypted recipient subset; success requires accepted
1062
1081
  publication covering every recipient in that subset. Receive routing adds them from the start for live
@@ -1133,3 +1152,10 @@ and wake pending sends on release rather than repeatedly publishing while paused
1133
1152
  Custom session factories must expose `readStatus()` and deliver the initial and
1134
1153
  subsequent `onStateChanged` snapshots; missing observation is rejected instead of
1135
1154
  using polling compatibility. See [session lifecycle](private-messenger/session/README.md).
1155
+
1156
+ Private messenger/session constructors accept optional `fallbackDelayMs` (`null`
1157
+ by default). Automatic NIP-65 sends can start configured fallback relays after
1158
+ the chosen delay while primary publications retain their deadlines. Explicit
1159
+ routes keep sequential fallback; every encrypted recipient must be covered.
1160
+ `RelayPool.sendEvent` accepts a consumer `signal` without closing shared sockets.
1161
+ See [fallback and cancellation](private-messenger/session/README.md#relay-fallback).
@@ -1,6 +1,6 @@
1
1
  // A bounded FIFO for asynchronous work. Acquired leases release explicitly;
2
2
  // queued cancellations never consume capacity or keep idle keyed gates alive.
3
- export function createAbortableSemaphore (capacity, onIdle = () => {}) {
3
+ export function createAbortableSemaphore (capacity, onIdle = () => {}, { maxQueued = 256 } = {}) {
4
4
  const queue = []
5
5
  let active = 0
6
6
  const drain = () => {
@@ -21,7 +21,7 @@ export function createAbortableSemaphore (capacity, onIdle = () => {}) {
21
21
  return {
22
22
  acquire (signal) {
23
23
  signal?.throwIfAborted()
24
- if (queue.length >= 256) return Promise.reject(Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_QUEUE_FULL'), { code: 'PRIVATE_CHANNEL_HISTORY_QUEUE_FULL' }))
24
+ if (queue.length >= maxQueued) return Promise.reject(Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_QUEUE_FULL'), { code: 'PRIVATE_CHANNEL_HISTORY_QUEUE_FULL' }))
25
25
  return new Promise((resolve, reject) => {
26
26
  const job = {
27
27
  signal, resolve, abort: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.17",
3
+ "version": "0.11.19",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -158,7 +158,8 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
158
158
  const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
159
159
  const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
160
160
  const total = preparedChunkCount(context.preparedRows, rowIndexes)
161
- if (onPreparedSeed) {
161
+ const persistSeeds = async () => {
162
+ if (!onPreparedSeed) return
162
163
  const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
163
164
  const payloadRow = readPreparedRow(context.preparedRows, 0)
164
165
  for (const rowIndex of rowIndexes) {
@@ -179,12 +180,18 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
179
180
  content
180
181
  }), routerSeckey)
181
182
  const createdAt = nowSeconds()
182
- const outer = await privateChannelSigner.signEvent({
183
+ const prepareOuter = async () => privateChannelSigner.signEvent({
183
184
  kind: PRIVATE_BROADCAST_KIND,
184
185
  created_at: createdAt,
185
186
  tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
186
187
  content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
187
188
  })
189
+ // These independent steps may run together, but no envelope is yielded
190
+ // until every seed is durable. Settle both before releasing prepared rows.
191
+ const [persistence, prepared] = await Promise.allSettled([index === 1 ? persistSeeds() : undefined, prepareOuter()])
192
+ if (persistence.status === 'rejected') throw persistence.reason
193
+ if (prepared.status === 'rejected') throw prepared.reason
194
+ const outer = prepared.value
188
195
  if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
189
196
  yield outer
190
197
  }
@@ -0,0 +1,16 @@
1
+ // Keep actual permissions and cryptography in the signer. Background presence
2
+ // only yields before its next RPC, without interrupting an operation in flight.
3
+ export function backgroundSigner (signer, wait, signal) {
4
+ if (!signer || !wait) return signer
5
+ return new Proxy(signer, {
6
+ get (target, key) {
7
+ const value = Reflect.get(target, key, target)
8
+ if (typeof value !== 'function') return value
9
+ return async (...args) => {
10
+ await wait(signal)
11
+ signal?.throwIfAborted()
12
+ return value.apply(target, args)
13
+ }
14
+ }
15
+ })
16
+ }
@@ -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
+ }
@@ -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) {