libp2r2p 0.11.4 → 0.11.5

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
@@ -915,10 +915,16 @@ local defaults, including a bounded streaming file cache. The optional
915
915
  provides reusable DM inbox/outbox and attachment lifecycle without UI coupling,
916
916
  including an optional `onSendError` callback identifying the failed outbox message.
917
917
 
918
- Single-recipient private messages now retry NIP-65 read relays in pairs when
918
+ Private messages, including multi-recipient channels, retry NIP-65 read relays
919
+ in pairs per recipient when
919
920
  selected relays reject publication or become unreachable while connectivity is
920
- confirmed. The exact encrypted outer events are reused; explicit per-call and
921
- channel relay overrides remain authoritative. Successful sends keep first-ACK
921
+ confirmed. The exact encrypted outer events are reused. Optional `fallbackRelays` on
922
+ `createPrivateMessenger` and `createPrivateMessageSession` are tried after the
923
+ primary destinations, including explicit per-call `relays`, channel `sendRelays`
924
+ and channel/global `relays`, as well as explicit `relayToReceivers` maps.
925
+ Retries preserve each encrypted recipient subset; success requires accepted
926
+ publication covering every recipient in that subset. Receive routing adds them from the start for live
927
+ messages, history and files while preserving automatic NIP-65 updates. Successful sends keep first-ACK
922
928
  latency, and short-lived channel/recipient preferences avoid recently refused
923
929
  relays without changing receive subscriptions. See the
924
930
  [session coordinator](private-messenger/session/README.md) for deferred offline
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.4",
3
+ "version": "0.11.5",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -37,7 +37,7 @@ const encoder = new TextEncoder()
37
37
  const decoder = new TextDecoder()
38
38
  const NIP44_V3_SCOPE = ''
39
39
 
40
- const sendToRelays = (...args) => relayPool.sendEvent(...args)
40
+ const sendToRelays = (event, relays) => relayPool.sendEvent(event, relays)
41
41
  const getEvents = (...args) => relayPool.getEvents(...args)
42
42
  const getLiveEventsGenerator = (...args) => relayPool.getLiveEventsGenerator(...args)
43
43
  const getEventsFeedGenerator = (...args) => relayPool.getEventsFeedGenerator(...args)
@@ -540,7 +540,7 @@ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner
540
540
  try {
541
541
  for (const group of groups) {
542
542
  for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })) {
543
- results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays)))
543
+ results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(group.receivers), primaryRelays: group.relays }))
544
544
  }
545
545
  }
546
546
  } finally {
@@ -550,7 +550,7 @@ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner
550
550
  }
551
551
 
552
552
  for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs })) {
553
- results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays)))
553
+ results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(receivers), primaryRelays: relays }))
554
554
  }
555
555
  return results
556
556
  }
@@ -1,7 +1,8 @@
1
1
  import { getEventHash } from '../../event/index.js'
2
2
 
3
3
  // A report describes one outer event, not one relay. Each outer event needs
4
- // at least one relay acknowledgement. Keep the successful fast path immediate.
4
+ // accepted publication covering its routed recipients. Keep the successful fast
5
+ // path immediate; a partially covered multi-recipient report remains a failure.
5
6
  export async function assertMessagePublished (result, event) {
6
7
  const reports = result?.delivery?.reports
7
8
  const present = Array.isArray(reports) && reports.length > 0
@@ -1,4 +1,5 @@
1
1
  import { normalizeRelayUrl } from '../../url/index.js'
2
+ import { ValidationError } from '../../error/index.js'
2
3
 
3
4
  const REJECTION_PREFIXES = new Set(['blocked', 'restricted', 'auth-required', 'pow', 'rate-limited', 'error'])
4
5
  const TRANSPORT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
@@ -14,17 +15,60 @@ export function isReplaceableRelayFailure (error) {
14
15
  return REJECTION_PREFIXES.has(/^([a-z-]+):/.exec(error?.message || '')?.[1])
15
16
  }
16
17
 
17
- export function createSendRelayRouting ({ peer, relaysByPubkey, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, now = Date.now }) {
18
- // One finite snapshot, including the existing default pair if NIP-65 is
19
- // unavailable. Never invent additional fallback relays.
20
- const candidates = [...pickRelays([peer], relaysByPubkey, { relayType: 'read', maxPerPubkey: Infinity }).keys()]
18
+ export function normalizeFallbackRelays (value = []) {
19
+ if (!Array.isArray(value)) throw new ValidationError('INVALID_FALLBACK_RELAYS')
20
+ return [...new Set(Array.from(value, normalizeRelayUrl))]
21
+ }
22
+
23
+ function routeEntries (routes) {
24
+ return routes instanceof Map ? [...routes] : Object.entries(routes || {})
25
+ }
26
+
27
+ function routeGroups (routes) {
28
+ const groups = new Map()
29
+ for (const [relay, receivers] of routes) {
30
+ const key = [...receivers].sort().join(',')
31
+ if (!groups.has(key)) groups.set(key, { receivers, relays: [] })
32
+ groups.get(key).relays.push(relay)
33
+ }
34
+ return [...groups.values()]
35
+ }
36
+
37
+ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, now = Date.now }) {
38
+ // Keep discovery's defaults when NIP-65 is unavailable. Explicit maps/lists
39
+ // retain their fanout; configured fallbacks are a separate last-resort stage.
40
+ const explicit = Boolean(primaryRelays || primaryRelayToReceivers)
41
+ const primaryRoutes = primaryRelayToReceivers || (primaryRelays
42
+ ? new Map(primaryRelays.map(relay => [relay, peers]))
43
+ : pickRelays(peers, relaysByPubkey, { relayType: 'read', maxPerPubkey: Infinity }))
44
+ const primaryByPeer = Object.fromEntries(peers.map(pubkey => [pubkey, { read: [] }]))
45
+ for (const [relay, receivers] of routeEntries(primaryRoutes)) {
46
+ for (const pubkey of Array.isArray(receivers) ? receivers : [receivers]) primaryByPeer[pubkey]?.read.push(normalizeRelayUrl(relay))
47
+ }
48
+ const candidates = [...new Set([...Object.values(primaryByPeer).flatMap(value => value.read), ...fallbackRelays])]
21
49
  const candidateSet = new Set(candidates)
22
50
  for (const [relay, expires] of exclusions) if (expires <= now() || !candidateSet.has(relay)) exclusions.delete(relay)
23
- // New attempts may recheck an exhausted list. Each in-flight outer event has
24
- // its own tried set, preventing loops during concurrent sends.
25
- if (candidates.length && candidates.every(relay => exclusions.has(relay))) exclusions.clear()
26
- const select = excluded => pickRelays([peer], relaysByPubkey, { relayType: 'read', excludeRelaysByPubkey: new Map([[peer, [...excluded]]]) })
27
- const relayToReceivers = select(exclusions.keys())
51
+ // A fresh attempt can recheck an exhausted list; each outer event still owns
52
+ // a tried set so concurrent sends/fragments cannot create a retry loop.
53
+ if (peers.some(pubkey => {
54
+ const available = [...primaryByPeer[pubkey].read, ...fallbackRelays]
55
+ return available.length && available.every(relay => exclusions.has(relay))
56
+ })) exclusions.clear()
57
+ const select = (receivers, excluded, staged = false) => {
58
+ const excludedRelays = [...excluded]
59
+ const excludeRelaysByPubkey = new Map(receivers.map(pubkey => [pubkey, excludedRelays]))
60
+ const options = { relayType: 'read', excludeRelaysByPubkey, emptyRelaysFallback: [] }
61
+ const routes = pickRelays(receivers, primaryByPeer, { ...options, ...(explicit ? { maxPerPubkey: Infinity } : {}) })
62
+ // For one ciphertext, exhaust the remaining members' primary routes before
63
+ // using a common fallback. Initial wrapping must nevertheless cover everyone.
64
+ if (staged && routes.size) return routes
65
+ const covered = new Set([...routes.values()].flat())
66
+ const missing = receivers.filter(pubkey => !covered.has(pubkey))
67
+ const fallback = pickRelays(missing, Object.fromEntries(missing.map(pubkey => [pubkey, { read: fallbackRelays }])), options)
68
+ for (const [relay, pubkeys] of fallback) routes.set(relay, [...new Set([...(routes.get(relay) || []), ...pubkeys])])
69
+ return routes
70
+ }
71
+ const relayToReceivers = primaryRelayToReceivers || select(peers, exclusions.keys())
28
72
  const current = () => !signal?.aborted && isCurrent()
29
73
  const rejected = report => (report?.errors || []).filter(item => candidateSet.has(normalized(item.relay)) && isReplaceableRelayFailure(item.reason))
30
74
  const remember = errors => { for (const item of errors) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
@@ -33,58 +77,68 @@ export function createSendRelayRouting ({ peer, relaysByPubkey, exclusions, pick
33
77
  try { return await isOnline({ signal }) && current() } catch { return false }
34
78
  }
35
79
 
36
- async function send (event, initialRelays) {
80
+ async function send (event, initialRelays, context = {}) {
81
+ // This context describes the already encrypted recipient subset. It stays
82
+ // local: no recipient tags or routing metadata are added to the outer event.
83
+ const receivers = context.receiverPubkeys || peers
84
+ const pending = new Set(receivers)
37
85
  const mirrors = initialRelays.filter(relay => !candidateSet.has(normalized(relay)))
38
- let relays = [...new Set([...select(exclusions.keys()).keys(), ...mirrors])]
86
+ const initialPrimary = context.primaryRelays || (primaryRelays || peers.length === 1 ? routeEntries(relayToReceivers).map(([relay]) => relay) : [])
87
+ const first = [...new Set(initialPrimary.map(normalizeRelayUrl))].filter(relay => !exclusions.has(relay))
88
+ let batch = first.length ? { receivers, relays: first } : null
39
89
  const tried = new Set()
40
90
  const errors = []
41
91
  const summaries = []
42
- const finish = result => {
43
- if (summaries.length === 1) return result
44
- return {
45
- ...result,
92
+ const next = () => routeGroups(select([...pending], new Set([...exclusions.keys(), ...tried]), true))[0]
93
+ const finish = result => ({
94
+ ...result,
95
+ success: summaries.length > 0 && pending.size === 0,
96
+ total: tried.size,
97
+ promise: Promise.all(summaries).then(reports => ({
46
98
  total: tried.size,
47
- promise: Promise.all(summaries).then(reports => ({
48
- total: tried.size,
49
- success: reports.some(report => report.success),
50
- fulfilled: reports.reduce((total, report) => total + (report.fulfilled || 0), 0),
51
- succeededRelays: [...new Set(reports.flatMap(report => report.succeededRelays || []))],
52
- errors: reports.flatMap(report => report.errors || [])
53
- }))
54
- }
55
- }
56
- while (true) {
99
+ success: summaries.length > 0 && pending.size === 0,
100
+ fulfilled: reports.reduce((total, report) => total + (report.fulfilled || 0), 0),
101
+ succeededRelays: [...new Set(reports.flatMap(report => report.succeededRelays || []))],
102
+ errors: reports.flatMap(report => report.errors || [])
103
+ }))
104
+ })
105
+ let result = { success: false }
106
+ while ((batch ||= next())) {
57
107
  signal?.throwIfAborted()
108
+ const relays = [...new Set([...batch.relays, ...(summaries.length ? [] : mirrors)])]
58
109
  for (const relay of relays) tried.add(normalized(relay))
59
- // Republish the exact signed outer event, preserving encryption,
60
- // fragments, timestamps and deletion capabilities.
61
- const result = await sendEvent(event, relays)
110
+ // Every relay in this batch serves the same pending recipient subset.
111
+ // One ACK covers that subset, never unrelated members of the ciphertext.
112
+ result = await sendEvent(event, relays)
62
113
  const settled = Promise.resolve(result.promise)
63
114
  summaries.push(settled)
64
115
  if (result.success) {
116
+ for (const pubkey of batch.receivers) pending.delete(pubkey)
65
117
  // Keep the first ACK immediate; learn about redundant failures later.
66
118
  settled.then(async report => {
67
119
  const failed = rejected(report)
68
120
  if (failed.length && await online()) remember(failed)
69
121
  }).catch(() => {})
70
- return finish(result)
122
+ if (!pending.size) return finish(result)
123
+ } else {
124
+ const report = await settled
125
+ const failed = rejected(report)
126
+ errors.push(...(report?.errors || []))
127
+ if (!failed.length || errors.some(item => !isReplaceableRelayFailure(item.reason))) return finish(result)
128
+ if (!next()) { exclusions.clear(); return finish(result) }
129
+ if (!await online()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: current() }
130
+ remember(failed)
71
131
  }
72
- const report = await settled
73
- const failed = rejected(report)
74
- errors.push(...(report?.errors || []))
75
- if (!failed.length || errors.some(item => !isReplaceableRelayFailure(item.reason))) return finish(result)
76
- const next = [...select(new Set([...exclusions.keys(), ...tried])).keys()]
77
- if (!next.length) { exclusions.clear(); return finish(result) }
78
- if (!await online()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: current() }
79
- remember(failed)
80
- relays = next
132
+ if (!current()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: false }
133
+ batch = null
81
134
  }
135
+ return finish(result)
82
136
  }
83
137
 
84
138
  return {
85
139
  relayToReceivers,
86
- // A recovery mirror must not reintroduce the recipient's refused relays.
87
- recoveryRelays: recoveryRelays.filter(relay => !candidateSet.has(relay) || !exclusions.has(relay)),
140
+ // A recovery mirror must not reintroduce refused relays or promote a fallback.
141
+ recoveryRelays: recoveryRelays.filter(relay => !candidateSet.has(normalized(relay)) || !exclusions.has(normalized(relay))),
88
142
  _publish: options => (options.nymSigner ? publishNymEvent : publish)({ ...options, _publish: send })
89
143
  }
90
144
  }
@@ -44,7 +44,8 @@
44
44
 
45
45
  import * as privateMessage from '../private-message/index.js'
46
46
  import { isOnline } from '../network/index.js'
47
- import { createSendRelayRouting } from './helpers/send-routing.js'
47
+ import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
48
+ import { normalizeRelayUrl } from '../url/index.js'
48
49
  import { deliveryInfo } from '../private-channel/helpers/rumor.js'
49
50
  import { bytesToBase64 } from '../base64/index.js'
50
51
  import { ValidationError } from '../error/index.js'
@@ -193,6 +194,7 @@ export class PrivateMessenger {
193
194
  }
194
195
 
195
196
  constructor ({
197
+ fallbackRelays = [],
196
198
  offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
197
199
  staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
198
200
  identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
@@ -226,6 +228,7 @@ export class PrivateMessenger {
226
228
  _storageClearInterval = globalThis.clearInterval.bind(globalThis),
227
229
  _BroadcastChannel = _indexedDB === globalThis.indexedDB ? globalThis.BroadcastChannel : undefined
228
230
  } = {}) {
231
+ this.fallbackRelays = normalizeFallbackRelays(fallbackRelays)
229
232
  this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
230
233
  this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
231
234
  this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
@@ -756,42 +759,38 @@ export class PrivateMessenger {
756
759
  }
757
760
 
758
761
  async resolveWatchRelays (channel) {
759
- if (!channel.usesNip65WatchRelays && channel.relays.length) return channel.relays
760
- return this.readRelaysForPubkey(this.userPubkey)
762
+ const primary = !channel.usesNip65WatchRelays && channel.relays.length ? channel.relays : await this.readRelaysForPubkey(this.userPubkey)
763
+ return uniq([...primary, ...this.fallbackRelays].map(normalizeRelayUrl))
761
764
  }
762
765
 
763
766
  async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal }) {
764
767
  const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
765
- if (relayToReceivers) return { relayToReceivers, recoveryRelays }
766
- if (relays?.length) return { relays: uniq(relays), recoveryRelays }
767
- if (channel.sendRelays.length) return { relays: channel.sendRelays, recoveryRelays }
768
- if (channel.relays.length) return { relays: channel.relays, recoveryRelays }
769
- const recipients = uniq(receiverPubkeys)
770
- if (recipients.length === 1) {
771
- const peer = recipients[0]
772
- const relaysByPubkey = await this._getRelaysByPubkey(recipients)
773
- const key = `${channel.pubkey}:${peer}`
774
- if (!this.sendRelayExclusions.has(key)) {
775
- if (this.sendRelayExclusions.size >= 256) this.sendRelayExclusions.delete(this.sendRelayExclusions.keys().next().value)
776
- this.sendRelayExclusions.set(key, { channelPubkey: channel.pubkey, exclusions: new Map() })
777
- }
778
- const routing = createSendRelayRouting({
779
- peer, relaysByPubkey, recoveryRelays,
780
- signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
781
- exclusions: this.sendRelayExclusions.get(key).exclusions,
782
- pickRelays: this._pickRelaysForPubkeys,
783
- publish: this._privateChannel.publish || privateChannel.publish,
784
- publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
785
- sendEvent: (...args) => relayPool.sendEvent(...args),
786
- isOnline: this._isOnline,
787
- isCurrent: () => !this.closePromise && !this.pauseReasons.size && this.channels.has(channel.pubkey)
788
- })
789
- if (!relayMapRelays(routing.relayToReceivers).length) throw new ValidationError('NO_RELAYS')
790
- return routing
768
+ if (relayToReceivers && !this.fallbackRelays.length) return { relayToReceivers, recoveryRelays }
769
+ const fixed = relayToReceivers ? null : relays?.length ? uniq(relays) : channel.sendRelays.length ? channel.sendRelays : channel.relays.length ? channel.relays : null
770
+ if (fixed && !this.fallbackRelays.length) return { relays: fixed, recoveryRelays }
771
+ const recipients = uniq(receiverPubkeys?.length ? receiverPubkeys : relayMapReceivers(relayToReceivers))
772
+ const relaysByPubkey = fixed || relayToReceivers ? undefined : await this._getRelaysByPubkey(recipients)
773
+ const key = `${channel.pubkey}:${[...recipients].sort().join(',')}`
774
+ if (!this.sendRelayExclusions.has(key)) {
775
+ if (this.sendRelayExclusions.size >= 256) this.sendRelayExclusions.delete(this.sendRelayExclusions.keys().next().value)
776
+ this.sendRelayExclusions.set(key, { channelPubkey: channel.pubkey, exclusions: new Map() })
791
777
  }
792
- const derived = await this.readRelayToReceivers(recipients)
793
- if (!relayMapRelays(derived).length) throw new ValidationError('NO_RELAYS')
794
- return { relayToReceivers: derived, recoveryRelays }
778
+ const routing = createSendRelayRouting({
779
+ peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
780
+ signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
781
+ exclusions: this.sendRelayExclusions.get(key).exclusions,
782
+ pickRelays: this._pickRelaysForPubkeys,
783
+ publish: this._privateChannel.publish || privateChannel.publish,
784
+ publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
785
+ sendEvent: (...args) => relayPool.sendEvent(...args),
786
+ isOnline: this._isOnline,
787
+ isCurrent: () => !this.closePromise && !this.pauseReasons.size && this.channels.has(channel.pubkey)
788
+ })
789
+ if (!relayMapRelays(routing.relayToReceivers).length) throw new ValidationError('NO_RELAYS')
790
+ // Fixed lists address the original receiver set as a whole, preserving the
791
+ // existing encryption grouping instead of inventing per-recipient routes.
792
+ if (fixed) return { relays: fixed, recoveryRelays: routing.recoveryRelays, _publish: routing._publish }
793
+ return routing
795
794
  }
796
795
 
797
796
  readState () {
@@ -2330,6 +2329,12 @@ function oldestCreatedAt (events) {
2330
2329
  return oldest
2331
2330
  }
2332
2331
 
2332
+ function relayMapReceivers (relayToReceivers) {
2333
+ if (!relayToReceivers) return []
2334
+ const values = relayToReceivers instanceof Map ? [...relayToReceivers.values()] : Object.values(relayToReceivers)
2335
+ return uniq(values.flat())
2336
+ }
2337
+
2333
2338
  function relayMapRelays (relayToReceivers) {
2334
2339
  if (!relayToReceivers) return []
2335
2340
  const entries = relayToReceivers instanceof Map ? relayToReceivers.entries() : Object.entries(relayToReceivers)
@@ -48,21 +48,53 @@ This is a DM profile; group membership/key derivation is not implemented.
48
48
 
49
49
  ## Relay fallback
50
50
 
51
- Automatic single-recipient routing uses the recipient's NIP-65 read relays, at
52
- most two per attempt. On `blocked`, `restricted`, `auth-required`, `pow`,
53
- `rate-limited`, or `error` rejections, or connection/transport/timeout failures,
54
- it can try remaining read relays after `isOnline` confirms connectivity. Invalid
55
- messages, local signer/authentication failures and unknown error text retain their
56
- original diagnosis. Explicit per-call `relays`/`relayToReceivers`, channel
57
- `sendRelays`/`relays`, and multi-recipient sends do not enable automatic fallback.
51
+ Both `createPrivateMessenger` and `createPrivateMessageSession` accept an optional
52
+ `fallbackRelays` array (default `[]`) at construction. URLs are normalized,
53
+ deduplicated and copied; invalid values throw `ValidationError`. For example:
54
+
55
+ ```js
56
+ const session = createPrivateMessageSession({
57
+ owner, signer, eventStore,
58
+ fallbackRelays: ['wss://relay.44billion.net']
59
+ })
60
+ ```
61
+
62
+ Automatic sends try each recipient's NIP-65 read relays first, at most two per
63
+ recipient per attempt, then the configured fallbacks in pairs. Explicit
64
+ per-call `relays`, channel `sendRelays`/`relays` and global `relays` remain primary
65
+ and also support configured fallbacks, including fixed multi-recipient sends.
66
+ Explicit lists retain their requested initial fanout; no recipient relay lookup
67
+ is added. A relay present in both lists is attempted only once per outer event.
68
+ Automatic multi-recipient sends and explicit `relayToReceivers` maps also support
69
+ fallbacks. Explicit maps retain their precedence over fixed lists, initial fanout
70
+ and encrypted recipient subsets, without consulting recipient NIP-65 lists.
71
+ The session coordinator remains a DM API; multi-recipient channels use
72
+ `createPrivateMessenger` directly.
73
+
74
+ On `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or `error`
75
+ rejections, or connection/transport/timeout failures, replacement requires
76
+ `isOnline` to confirm connectivity. Invalid messages, local signer/authentication
77
+ failures and unknown error text retain their original diagnosis.
78
+
79
+ Receive routing always adds the configured fallback relays to the effective
80
+ primary list (explicit `relays` or the owner's NIP-65 read relays). Listen there
81
+ from the start, even when primary relays are healthy: a sender may have used a
82
+ fallback after publication was refused elsewhere. Live watches, historical
83
+ recovery and file-channel reads share this policy. NIP-65 refreshes retain the
84
+ fallbacks; outgoing exclusions never remove them from receive subscriptions.
85
+ No public recipient tags or changes to a user's NIP-65 event are introduced.
58
86
 
59
87
  Each signed outer event is reused verbatim, including router/carrier fragments
60
88
  and deletion capabilities. Native errors from exhausted attempts remain in its
61
- publication report. One successful relay acknowledgement completes that outer
62
- event immediately; other acknowledgements may update future routing preferences
63
- without delaying the send. Preferences are channel/recipient-scoped, in-memory,
89
+ publication report. A shared relay's first ACK covers all members assigned to
90
+ that publication batch. If replacements use different relays for different
91
+ members, every pending member must be covered before the outer event succeeds;
92
+ a partial ACK cannot hide another member's failure. Remaining primary routes are
93
+ exhausted before configured fallbacks. Redundant acknowledgements may update
94
+ future routing preferences without delaying a successfully covered send. Preferences are channel/recipient-scoped, in-memory,
64
95
  expire after five minutes, and never alter receive subscriptions. A fresh attempt
65
- can recheck an exhausted list. No additional public fallback relays are invented.
96
+ can recheck an exhausted list. Additional fallback relays come only from the
97
+ caller configuration; absent NIP-65 lists retain the existing discovery defaults.
66
98
 
67
99
  When offline/interrupted with alternatives remaining, reports carry
68
100
  `retryWhenAvailable: true`. The outbox remains pending and emits no `onSendError`.
@@ -8,6 +8,7 @@ import { decodeFileMetadata } from '../../nip94/index.js'
8
8
  import { createChatOutbox } from './helpers/work-storage.js'
9
9
  import { messengerSigner } from './helpers/signer.js'
10
10
  import { assertMessagePublished } from '../helpers/publication.js'
11
+ import { normalizeFallbackRelays } from '../helpers/send-routing.js'
11
12
 
12
13
  export function wireEvent (value, owner) {
13
14
  const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
@@ -17,7 +18,8 @@ export function wireEvent (value, owner) {
17
18
  // Relay rejection text must not be interpreted as a local signer denial.
18
19
  const retryable = error => error?.code === 'MESSAGE_NOT_PUBLISHED' || !/DENIED|PERMISSION|REVOKED|READ_ONLY|INVALID|BLOCKED|EXPIRED|NOT_IN_PERSONA/i.test(`${error?.code || ''} ${error?.message || ''}`)
19
20
 
20
- export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
21
+ export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
22
+ fallbackRelays = normalizeFallbackRelays(fallbackRelays)
21
23
  const userSigner = messengerSigner(signer)
22
24
  messageStorage ||= createEventStoreMessageStorage({ eventStore })
23
25
  chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
@@ -74,7 +76,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
74
76
  if (closed || !available || version !== lifecycle) return
75
77
  values.push(channel)
76
78
  }
77
- if (!messenger) messenger = await Messenger({ seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys: false, onMessageQueued: () => drain(), onError })
79
+ if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys: false, onMessageQueued: () => drain(), onError })
78
80
  if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
79
81
  await messenger.update({ channels: values })
80
82
  await messenger.resume('signer')