libp2r2p 0.11.17 → 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
@@ -1133,3 +1133,10 @@ and wake pending sends on release rather than repeatedly publishing while paused
1133
1133
  Custom session factories must expose `readStatus()` and deliver the initial and
1134
1134
  subsequent `onStateChanged` snapshots; missing observation is rejected instead of
1135
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.17",
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
+ }
@@ -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) {
@@ -48,7 +48,7 @@ import * as privateMessage from '../private-message/index.js'
48
48
  import { isOnline, onOnline } from '../network/index.js'
49
49
  import { isRetryableRelayFailure, isReplaceableRelayFailure } from '../relay/index.js'
50
50
  import { createPauseRecovery, isRecoverableStorageFailure } from './helpers/pause-recovery.js'
51
- import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
51
+ import { createSendRelayRouting, normalizeFallbackRelays, normalizeFallbackDelay } from './helpers/send-routing.js'
52
52
  import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
53
53
  import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
54
54
  import { normalizeRelayUrl } from '../url/index.js'
@@ -212,6 +212,7 @@ export class PrivateMessenger {
212
212
 
213
213
  constructor ({
214
214
  fallbackRelays = [],
215
+ fallbackDelayMs = null,
215
216
  offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
216
217
  staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
217
218
  identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
@@ -254,6 +255,7 @@ export class PrivateMessenger {
254
255
  } = {}) {
255
256
  if (onStateChanged != null && typeof onStateChanged !== 'function') throw new ValidationError('INVALID_ON_STATE_CHANGED')
256
257
  this.fallbackRelays = normalizeFallbackRelays(fallbackRelays)
258
+ this.fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
257
259
  this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
258
260
  this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
259
261
  this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
@@ -304,6 +306,7 @@ export class PrivateMessenger {
304
306
  this._isOnline = _isOnline
305
307
  this.sendRelayExclusions = new Map()
306
308
  this.sendRoutingLifetime = new AbortController()
309
+ this.sendRoutingPause = new AbortController()
307
310
  this._setTimeout = _setTimeout
308
311
  this._clearTimeout = _clearTimeout
309
312
  this._setInterval = _setInterval
@@ -841,6 +844,8 @@ export class PrivateMessenger {
841
844
  peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
842
845
  signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
843
846
  exclusions: this.sendRelayExclusions.get(key).exclusions,
847
+ fallbackDelayMs: this.fallbackDelayMs, pauseSignal: this.sendRoutingPause.signal,
848
+ setTimer: this._setTimeout, clearTimer: this._clearTimeout,
844
849
  pickRelays: this._pickRelaysForPubkeys,
845
850
  publish: this._privateChannel.publish || privateChannel.publish,
846
851
  publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
@@ -1780,6 +1785,7 @@ export class PrivateMessenger {
1780
1785
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1781
1786
  this.assertOpen()
1782
1787
  this.pauseReasons.add(reason)
1788
+ this.sendRoutingPause.abort(this.pausedError())
1783
1789
  this.notifyStatus()
1784
1790
  for (const extension of this.extensions) extension.pause?.()
1785
1791
  this.recordInterruption(this.desiredChannels)
@@ -1797,6 +1803,7 @@ export class PrivateMessenger {
1797
1803
  const wasAutomatic = this.automaticPauses.has(reason)
1798
1804
  const removed = this.pauseReasons.delete(reason)
1799
1805
  this.automaticPauses.delete(reason)
1806
+ if (!this.pauseReasons.size && this.sendRoutingPause.signal.aborted) this.sendRoutingPause = new AbortController()
1800
1807
  this.notifyStatus()
1801
1808
  if (reason === 'capacity' && removed) {
1802
1809
  clearInterval(this.capacityTimer)
@@ -110,7 +110,7 @@ const session = createPrivateMessageSession({
110
110
  })
111
111
  ```
112
112
 
113
- Automatic sends try each recipient's NIP-65 read relays first, at most two per
113
+ By default, automatic sends try each recipient's NIP-65 read relays first, at most two per
114
114
  recipient per attempt, then the configured fallbacks in pairs. Explicit
115
115
  per-call `relays`, channel `sendRelays`/`relays` and global `relays` remain primary
116
116
  and also support configured fallbacks, including fixed multi-recipient sends.
@@ -122,6 +122,19 @@ and encrypted recipient subsets, without consulting recipient NIP-65 lists.
122
122
  The session coordinator remains a DM API; multi-recipient channels use
123
123
  `createPrivateMessenger` directly.
124
124
 
125
+ Optional `fallbackDelayMs` defaults to `null` (sequential primary exhaustion).
126
+ A nonnegative integer up to 2147483647 opts automatic NIP-65 routing into early
127
+ fallback; explicit relay lists/maps retain the sequential policy. With `3000`,
128
+ start at most two eligible fallback relays after three seconds without complete
129
+ acceptance, or immediately after primary exhaustion. The deadline belongs to
130
+ one signed outer event; later batches do not renew it. Primary operations retain
131
+ their deadlines and can still win. Failed fallback operations leave primary
132
+ alternatives available; no relay is tried twice. Multiple recipients require
133
+ accepted coverage of every encrypted recipient subset. No wrapper, ID, recipient
134
+ tag or deletion capability is recreated. Confirmed connectivity, exclusions and
135
+ connection cooldowns remain authoritative; a waiting primary is not penalized
136
+ merely because fallback started. Late native failures may inform later sends.
137
+
125
138
  On `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or `error`
126
139
  rejections, or connection/transport/timeout failures, replacement requires
127
140
  `isOnline` to confirm connectivity. Invalid messages, local signer/authentication
@@ -141,7 +154,8 @@ publication report. A shared relay's first ACK covers all members assigned to
141
154
  that publication batch. If replacements use different relays for different
142
155
  members, every pending member must be covered before the outer event succeeds;
143
156
  a partial ACK cannot hide another member's failure. Remaining primary routes are
144
- exhausted before configured fallbacks. Redundant acknowledgements may update
157
+ exhausted before configured fallbacks with the default null delay or explicit
158
+ routes. Redundant acknowledgements may update
145
159
  future routing preferences without delaying a successfully covered send. Preferences are channel/recipient-scoped, in-memory,
146
160
  expire after five minutes, and never alter receive subscriptions. A fresh attempt
147
161
  can recheck an exhausted list. Additional fallback relays come only from the
@@ -159,3 +173,12 @@ relays. Validation/authorization failures are terminal without relay rotation.
159
173
  `broadcastRumor`/`broadcastEvent` accept a `signal` to stop further replacement
160
174
  publications; the session supplies it and aborts it on cancel/close. An already
161
175
  published event cannot be recalled by cancellation.
176
+
177
+ `RelayPool.sendEvent(event, relays, { signal })` supports optional cancellation.
178
+ It returns the existing result/report envelopes with the original abort reason
179
+ for unfinished relays, preserving partial acceptance and avoiding synthetic
180
+ timeouts. Cancellation releases publication waiters and deferred sends without
181
+ closing shared sockets or cancelling another subscriber for the same event ID.
182
+ Operations already transmitted cannot be recalled from relays. A messenger pause
183
+ cancels its outstanding publication work and leaves the outbox pending; resume
184
+ creates a fresh publication lifetime, without treating the pause as a user abort.
@@ -10,7 +10,7 @@ import { decodeFileMetadata } from '../../nip94/index.js'
10
10
  import { createChatOutbox } from './helpers/work-storage.js'
11
11
  import { messengerSigner } from './helpers/signer.js'
12
12
  import { assertMessagePublished } from '../helpers/publication.js'
13
- import { normalizeFallbackRelays } from '../helpers/send-routing.js'
13
+ import { normalizeFallbackRelays, normalizeFallbackDelay } from '../helpers/send-routing.js'
14
14
  import { createPauseRecovery, isRecoverableStorageFailure } from '../helpers/pause-recovery.js'
15
15
 
16
16
  export function wireEvent (value, owner) {
@@ -41,8 +41,9 @@ function messengerStatusSnapshot (value) {
41
41
  return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
42
42
  }
43
43
 
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' }) }) {
44
+ export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, 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' }) }) {
45
45
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
46
+ fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
46
47
  const reportedErrors = new WeakSet()
47
48
  const reportError = error => {
48
49
  if (error && (typeof error === 'object' || typeof error === 'function')) {
@@ -131,7 +132,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
131
132
  let candidate
132
133
  try {
133
134
  candidate = await Messenger({
134
- fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
135
+ fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
135
136
  onMessageQueued: () => { if (messengerBinding === binding) return drain() },
136
137
  onStateChanged: state => {
137
138
  if (closed || messengerBinding !== binding) return
@@ -76,7 +76,11 @@ export function createPublishSettlements (promises, timeout, { onSettled } = {})
76
76
  })
77
77
  }
78
78
 
79
- return { promise, timeout: timeoutPending }
79
+ const cancel = reason => {
80
+ if (isFinished) return
81
+ for (let index = 0; index < settlements.length; index++) settle(index, { status: 'rejected', outcome: 'failed', reason })
82
+ }
83
+ return { promise, timeout: timeoutPending, cancel }
80
84
  }
81
85
 
82
86
  // Turns ordered relay settlements into a stable, caller-facing report. Failed
@@ -186,23 +186,33 @@ export class RelayConnection {
186
186
  }
187
187
 
188
188
  #sendEventOperation (type, event, map, timeoutCode, signal) {
189
- if (signal?.aborted) return Promise.reject(signal.reason || relayTimeoutError(timeoutCode))
190
- if (map.has(event.id)) return map.get(event.id).promise
189
+ if (signal?.aborted) return Promise.reject(signal.reason)
190
+ let pending = map.get(event.id)
191
+ const created = !pending
192
+ if (created) {
193
+ const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
194
+ pending = { timer, waiters: new Set(), cancelSend: null }
195
+ map.set(event.id, pending)
196
+ }
191
197
  const deferred = Promise.withResolvers()
192
- const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
193
- const onAbort = () => this.#settleEvent(map, event.id, signal.reason || relayTimeoutError(timeoutCode))
194
- const stopAbort = () => signal?.removeEventListener('abort', onAbort)
195
- const pending = { ...deferred, timer, promise: deferred.promise, cancelSend: null, stopAbort }
196
- map.set(event.id, pending)
198
+ const onAbort = () => {
199
+ pending.waiters.delete(waiter)
200
+ signal.removeEventListener('abort', onAbort)
201
+ deferred.reject(signal.reason)
202
+ if (!pending.waiters.size && map.get(event.id) === pending) {
203
+ map.delete(event.id)
204
+ pending.cancelSend?.()
205
+ clearTimeout(pending.timer)
206
+ }
207
+ }
208
+ const waiter = { ...deferred, stopAbort: () => signal?.removeEventListener('abort', onAbort) }
209
+ pending.waiters.add(waiter)
197
210
  signal?.addEventListener('abort', onAbort, { once: true })
198
- pending.cancelSend = this.#dispatchWork(() => {
199
- // Once transmitted, keep the existing ACK/report semantics. Before that,
200
- // an operation-wide deadline must cancel the deferred publication.
201
- stopAbort()
202
- this.send(JSON.stringify([type, event]))
203
- }, error => {
204
- this.#settleEvent(map, event.id, error)
205
- })
211
+ if (created) {
212
+ pending.cancelSend = this.#dispatchWork(() => {
213
+ this.send(JSON.stringify([type, event]))
214
+ }, error => this.#settleEvent(map, event.id, error))
215
+ }
206
216
  return deferred.promise
207
217
  }
208
218
 
@@ -225,10 +235,13 @@ export class RelayConnection {
225
235
  if (!pending) return
226
236
  map.delete(id)
227
237
  pending.cancelSend?.()
228
- pending.stopAbort?.()
229
238
  clearTimeout(pending.timer)
230
- if (reason) pending.reject(errorFrom(reason, 'OPERATION_REJECTED'))
231
- else pending.resolve(value)
239
+ for (const waiter of pending.waiters) {
240
+ waiter.stopAbort()
241
+ if (reason) waiter.reject(errorFrom(reason, 'OPERATION_REJECTED'))
242
+ else waiter.resolve(value)
243
+ }
244
+ pending.waiters.clear()
232
245
  }
233
246
 
234
247
  #settleCount (id, payload, reason) {
@@ -1204,8 +1204,10 @@ export class RelayPool {
1204
1204
  timeout = SEND_TIMEOUT_MS,
1205
1205
  timeoutUntilFirstFulfillment = null,
1206
1206
  getAuthEvent,
1207
- onRelayResult
1207
+ onRelayResult,
1208
+ signal
1208
1209
  } = {}) {
1210
+ if (signal != null && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function')) throw new ValidationError('INVALID_RELAY_SIGNAL')
1209
1211
  const urls = normalizedRelayUrls(relays)
1210
1212
  if (!urls.length) {
1211
1213
  const promise = Promise.resolve(publishSummary([], urls, {
@@ -1236,7 +1238,11 @@ export class RelayPool {
1236
1238
  // Resolves after every relay settles (or reaches the operation timeout) as
1237
1239
  // { success, total, fulfilled, succeededRelays, errors },
1238
1240
  // where errors contains { relay, reason } entries for failed relays.
1241
+ const cancel = () => settlement.cancel(signal.reason)
1242
+ if (signal?.aborted) cancel()
1243
+ else signal?.addEventListener('abort', cancel, { once: true })
1239
1244
  const promise = settlement.promise
1245
+ .finally(() => signal?.removeEventListener('abort', cancel))
1240
1246
  .then(settlements => publishSummary(settlements, urls, {
1241
1247
  includeSucceededRelays: true
1242
1248
  }))
@@ -1245,10 +1251,11 @@ export class RelayPool {
1245
1251
  const deferred = sendDeferreds[index]
1246
1252
  ;(async () => {
1247
1253
  try {
1254
+ const operationSignal = sendControllers[index].signal
1255
+ if (operationSignal.aborted) throw operationSignal.reason
1248
1256
  const relay = await this.#getRelay(url)
1249
- const signal = sendControllers[index].signal
1250
- if (signal.aborted) throw signal.reason
1251
- return await this.#publishEvent(relay, eventToSend, getAuthEvent, signal)
1257
+ if (operationSignal.aborted) throw operationSignal.reason
1258
+ return await this.#publishEvent(relay, eventToSend, getAuthEvent, operationSignal)
1252
1259
  } catch (err) {
1253
1260
  const reason = err instanceof Error ? err : new Error(String(err))
1254
1261
  if (reason instanceof Nip42AuthenticationError) throw reason