libp2r2p 0.11.3 → 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
@@ -914,3 +914,18 @@ local defaults, including a bounded streaming file cache. The optional
914
914
  [`private-messenger/session`](private-messenger/session/README.md) coordinator
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
+
918
+ Private messages, including multi-recipient channels, retry NIP-65 read relays
919
+ in pairs per recipient when
920
+ selected relays reject publication or become unreachable while connectivity is
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
928
+ latency, and short-lived channel/recipient preferences avoid recently refused
929
+ relays without changing receive subscriptions. See the
930
+ [session coordinator](private-messenger/session/README.md) for deferred offline
931
+ attempts and final failure notifications.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.3",
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
  }
@@ -21,8 +21,10 @@ append it to the thumbnail URL. This is not a group-key derivation scheme.
21
21
  The application calls it for an explicit share, using its persisted message
22
22
  timestamp (standalone 1063 uses its own timestamp). Registration, cache hits
23
23
  and incoming requests never grant access. Missing router `p` is not a wildcard.
24
- - `publishChunk(descriptor, event)` checks a 34601 proof and the descriptor,
24
+ - `publishChunk(descriptor, event, { signal } = {})` checks a 34601 proof and the descriptor,
25
25
  captures ciphertext only in watchtower mode, and publishes to the recipient's relays.
26
+ It shares the parent channel's automatic relay fallback and full publication
27
+ diagnostics; `signal` stops further replacement attempts.
26
28
  Every wrapper must receive at least one relay acceptance. Yield between calls
27
29
  so text/control sends can run. Publish all file chunks before announcing 1063/9.
28
30
  - `download(descriptor, { manual, thumbnail, signal })` returns the verified,
@@ -3,6 +3,7 @@ import { chunkStream } from './helpers/stream.js'
3
3
  import { createFileCache } from './helpers/cache.js'
4
4
  export { createFileCache, DEFAULT_FILE_CACHE_BYTES } from './helpers/cache.js'
5
5
  import { createFileCatalog } from './helpers/catalog.js'
6
+ import { assertMessagePublished } from '../helpers/publication.js'
6
7
  import { ValidationError } from '../../error/index.js'
7
8
  import { IRFS_CHUNK_BYTES, decodeIrfsChunk } from '../../irfs/index.js'
8
9
  import { createQueue } from '../../idb-queue/index.js'
@@ -48,11 +49,6 @@ function rangesFor (indices) {
48
49
  return ranges
49
50
  }
50
51
 
51
- function assertPublished (result) {
52
- const reports = result?.delivery?.reports
53
- if (!Array.isArray(reports) || !reports.length || !reports.every(report => report?.success === true)) throw Object.assign(new Error('FILE_NOT_PUBLISHED'), { code: 'MESSAGE_NOT_PUBLISHED' })
54
- }
55
-
56
52
  function waitForTransfer (promise, signal) {
57
53
  signal?.throwIfAborted()
58
54
  if (!signal) return promise
@@ -148,14 +144,14 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
148
144
  if (decoded.root !== file.root || (index !== undefined && decoded.index !== index) || (file.size !== undefined && (decoded.total !== Math.ceil(file.size / IRFS_CHUNK_BYTES) || (decoded.index === decoded.total - 1 && decoded.contentBytes.length !== file.size - decoded.index * IRFS_CHUNK_BYTES)))) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
149
145
  return decoded
150
146
  }
151
- async function routing (file, receiverPubkey) {
147
+ async function routing (file, receiverPubkey, signal) {
152
148
  const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
153
- return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey] })
149
+ return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey], signal })
154
150
  }
155
151
  function sendOptions (file, routes) {
156
152
  return { senderSigner: messenger.userSigner, imkcSigner: messenger.contentKeySigner, privateChannelSigner: file.signer, ...routes, temporaryStorageArea: messenger.temporaryStorageArea, expirationSeconds: messenger.eventExpirationSecondsFor(file.controlChannelPubkey), _getIykcProofs: messenger.contentKeyLookup() }
157
153
  }
158
- async function publishChunk (descriptor, event) {
154
+ async function publishChunk (descriptor, event, { signal } = {}) {
159
155
  const file = await register(descriptor)
160
156
  usable(file)
161
157
  const decoded = checked(file, event)
@@ -166,9 +162,9 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
166
162
  }
167
163
  let result
168
164
  try {
169
- const options = { ...sendOptions(file, await routing(file, file.peerPubkey)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, ...(messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
165
+ const options = { ...sendOptions(file, await routing(file, file.peerPubkey, signal)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, ...(messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
170
166
  result = event.sig ? await _messages.broadcastEvent({ ...options, event }) : await _messages.broadcastRumor({ ...options, rumor: event })
171
- assertPublished(result)
167
+ await assertMessagePublished(result, result?.rumor || result?.event || { ...event, pubkey: event.pubkey || messenger.userPubkey })
172
168
  } catch (error) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root); throw error }
173
169
  if (decoded.index === decoded.total - 1) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root) }
174
170
  // A scheduler turn between chunks lets queued text/control publications run.
@@ -1,7 +1,8 @@
1
- import { getEventHash } from '../../../event/index.js'
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
@@ -17,6 +18,8 @@ export async function assertMessagePublished (result, event) {
17
18
  return {
18
19
  index,
19
20
  total: settled?.total ?? report?.total,
21
+ ...(report?.retryWhenAvailable ? { retryWhenAvailable: true } : {}),
22
+ ...(report?.retryWhenOnline ? { retryWhenOnline: true } : {}),
20
23
  fulfilled: settled?.fulfilled,
21
24
  errors: (settled?.errors || []).map(({ relay, reason }) => ({ relay, reason }))
22
25
  }
@@ -32,6 +35,7 @@ export async function assertMessagePublished (result, event) {
32
35
  })
33
36
  throw Object.assign(new Error(`MESSAGE_NOT_PUBLISHED: ${details.join(' | ') || reason}`), {
34
37
  code: 'MESSAGE_NOT_PUBLISHED',
38
+ ...(failures.length && failures.every(report => report.retryWhenAvailable) ? { retryWhenAvailable: true, ...(failures.some(report => report.retryWhenOnline) ? { retryWhenOnline: true } : {}) } : {}),
35
39
  reason,
36
40
  eventId: getEventHash(event),
37
41
  eventKind: event.kind,
@@ -0,0 +1,144 @@
1
+ import { normalizeRelayUrl } from '../../url/index.js'
2
+ import { ValidationError } from '../../error/index.js'
3
+
4
+ const REJECTION_PREFIXES = new Set(['blocked', 'restricted', 'auth-required', 'pow', 'rate-limited', 'error'])
5
+ const TRANSPORT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
6
+ const EXCLUSION_MS = 5 * 60 * 1000
7
+ const normalized = relay => { try { return normalizeRelayUrl(relay) } catch { return '' } }
8
+
9
+ // Only machine-readable relay policy/server failures qualify. Invalid events,
10
+ // local signer denials and unknown prose must not rotate relays.
11
+ export function isReplaceableRelayFailure (error) {
12
+ if (error?.name === 'Nip42AuthenticationError') return false
13
+ if (TRANSPORT_CATEGORIES.has(error?.category)) return true
14
+ if (error?.category && error.category !== 'relay') return false
15
+ return REJECTION_PREFIXES.has(/^([a-z-]+):/.exec(error?.message || '')?.[1])
16
+ }
17
+
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])]
49
+ const candidateSet = new Set(candidates)
50
+ for (const [relay, expires] of exclusions) if (expires <= now() || !candidateSet.has(relay)) exclusions.delete(relay)
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())
72
+ const current = () => !signal?.aborted && isCurrent()
73
+ const rejected = report => (report?.errors || []).filter(item => candidateSet.has(normalized(item.relay)) && isReplaceableRelayFailure(item.reason))
74
+ const remember = errors => { for (const item of errors) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
75
+ const online = async () => {
76
+ if (!current()) return false
77
+ try { return await isOnline({ signal }) && current() } catch { return false }
78
+ }
79
+
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)
85
+ const mirrors = initialRelays.filter(relay => !candidateSet.has(normalized(relay)))
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
89
+ const tried = new Set()
90
+ const errors = []
91
+ const summaries = []
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 => ({
98
+ total: tried.size,
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())) {
107
+ signal?.throwIfAborted()
108
+ const relays = [...new Set([...batch.relays, ...(summaries.length ? [] : mirrors)])]
109
+ for (const relay of relays) tried.add(normalized(relay))
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)
113
+ const settled = Promise.resolve(result.promise)
114
+ summaries.push(settled)
115
+ if (result.success) {
116
+ for (const pubkey of batch.receivers) pending.delete(pubkey)
117
+ // Keep the first ACK immediate; learn about redundant failures later.
118
+ settled.then(async report => {
119
+ const failed = rejected(report)
120
+ if (failed.length && await online()) remember(failed)
121
+ }).catch(() => {})
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)
131
+ }
132
+ if (!current()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: false }
133
+ batch = null
134
+ }
135
+ return finish(result)
136
+ }
137
+
138
+ return {
139
+ relayToReceivers,
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))),
142
+ _publish: options => (options.nymSigner ? publishNymEvent : publish)({ ...options, _publish: send })
143
+ }
144
+ }
@@ -43,10 +43,13 @@
43
43
  // - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
44
44
 
45
45
  import * as privateMessage from '../private-message/index.js'
46
+ import { isOnline } from '../network/index.js'
47
+ import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
48
+ import { normalizeRelayUrl } from '../url/index.js'
46
49
  import { deliveryInfo } from '../private-channel/helpers/rumor.js'
47
50
  import { bytesToBase64 } from '../base64/index.js'
48
51
  import { ValidationError } from '../error/index.js'
49
- import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates } from '../relay/index.js'
52
+ import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates, relayPool } from '../relay/index.js'
50
53
  import * as privateChannel from '../private-channel/index.js'
51
54
  import { DEFAULT_RECEIVED_CHUNK_TTL_MS } from '../private-channel/services/received-chunks.js'
52
55
  import { createQueue } from '../idb-queue/index.js'
@@ -191,6 +194,7 @@ export class PrivateMessenger {
191
194
  }
192
195
 
193
196
  constructor ({
197
+ fallbackRelays = [],
194
198
  offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
195
199
  staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
196
200
  identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
@@ -215,6 +219,7 @@ export class PrivateMessenger {
215
219
  _getRelaysByPubkey = getRelaysByPubkey,
216
220
  _pickRelaysForPubkeys = pickRelaysForPubkeys,
217
221
  _subscribeRelayListUpdates = subscribeRelayListUpdates,
222
+ _isOnline = isOnline,
218
223
  _setTimeout = globalThis.setTimeout.bind(globalThis),
219
224
  _clearTimeout = globalThis.clearTimeout.bind(globalThis),
220
225
  _setInterval = globalThis.setInterval.bind(globalThis),
@@ -223,6 +228,7 @@ export class PrivateMessenger {
223
228
  _storageClearInterval = globalThis.clearInterval.bind(globalThis),
224
229
  _BroadcastChannel = _indexedDB === globalThis.indexedDB ? globalThis.BroadcastChannel : undefined
225
230
  } = {}) {
231
+ this.fallbackRelays = normalizeFallbackRelays(fallbackRelays)
226
232
  this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
227
233
  this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
228
234
  this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
@@ -247,6 +253,9 @@ export class PrivateMessenger {
247
253
  this._getRelaysByPubkey = _getRelaysByPubkey
248
254
  this._pickRelaysForPubkeys = _pickRelaysForPubkeys
249
255
  this._subscribeRelayListUpdates = _subscribeRelayListUpdates
256
+ this._isOnline = _isOnline
257
+ this.sendRelayExclusions = new Map()
258
+ this.sendRoutingLifetime = new AbortController()
250
259
  this._setTimeout = _setTimeout
251
260
  this._clearTimeout = _clearTimeout
252
261
  this._setInterval = _setInterval
@@ -670,6 +679,7 @@ export class PrivateMessenger {
670
679
 
671
680
  await this.unwatch(removedPubkeys)
672
681
  for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
682
+ for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
673
683
  for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
674
684
 
675
685
  await this.cleanupStaleChannels({ storageSnapshot })
@@ -749,19 +759,38 @@ export class PrivateMessenger {
749
759
  }
750
760
 
751
761
  async resolveWatchRelays (channel) {
752
- if (!channel.usesNip65WatchRelays && channel.relays.length) return channel.relays
753
- 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))
754
764
  }
755
765
 
756
- async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers }) {
766
+ async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal }) {
757
767
  const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
758
- if (relayToReceivers) return { relayToReceivers, recoveryRelays }
759
- if (relays?.length) return { relays: uniq(relays), recoveryRelays }
760
- if (channel.sendRelays.length) return { relays: channel.sendRelays, recoveryRelays }
761
- if (channel.relays.length) return { relays: channel.relays, recoveryRelays }
762
- const derived = await this.readRelayToReceivers(receiverPubkeys)
763
- if (!relayMapRelays(derived).length) throw new ValidationError('NO_RELAYS')
764
- return { relayToReceivers: derived, recoveryRelays }
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() })
777
+ }
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
765
794
  }
766
795
 
767
796
  readState () {
@@ -1600,9 +1629,9 @@ export class PrivateMessenger {
1600
1629
  })
1601
1630
  }
1602
1631
 
1603
- async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey }) {
1632
+ async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal }) {
1604
1633
  const channel = this.requireWritableChannel(channelPubkey)
1605
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
1634
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
1606
1635
  this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
1607
1636
  return this._privateMessage.broadcastRumor({
1608
1637
  senderSigner: this.userSigner,
@@ -1621,9 +1650,9 @@ export class PrivateMessenger {
1621
1650
  })
1622
1651
  }
1623
1652
 
1624
- async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey }) {
1653
+ async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal }) {
1625
1654
  const channel = this.requireWritableChannel(channelPubkey)
1626
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
1655
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
1627
1656
  this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
1628
1657
  return this._privateMessage.broadcastEvent({
1629
1658
  senderSigner: this.userSigner,
@@ -2187,6 +2216,8 @@ export class PrivateMessenger {
2187
2216
  close () {
2188
2217
  if (this.closePromise) return this.closePromise
2189
2218
  const initSettledPromise = this.initSettledPromise
2219
+ this.sendRoutingLifetime.abort()
2220
+ this.sendRelayExclusions.clear()
2190
2221
  let unwatchPromise
2191
2222
  try {
2192
2223
  unwatchPromise = Promise.resolve(this.unwatch())
@@ -2298,6 +2329,12 @@ function oldestCreatedAt (events) {
2298
2329
  return oldest
2299
2330
  }
2300
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
+
2301
2338
  function relayMapRelays (relayToReceivers) {
2302
2339
  if (!relayToReceivers) return []
2303
2340
  const entries = relayToReceivers instanceof Map ? relayToReceivers.entries() : Object.entries(relayToReceivers)
@@ -45,3 +45,66 @@ passing their established factories, as Zillion does.
45
45
  External recovery factories are owned by their caller and must be closed after
46
46
  the session. Work storage opened by the session is closed by the session.
47
47
  This is a DM profile; group membership/key derivation is not implemented.
48
+
49
+ ## Relay fallback
50
+
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.
86
+
87
+ Each signed outer event is reused verbatim, including router/carrier fragments
88
+ and deletion capabilities. Native errors from exhausted attempts remain in its
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,
95
+ expire after five minutes, and never alter receive subscriptions. A fresh attempt
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.
98
+
99
+ When offline/interrupted with alternatives remaining, reports carry
100
+ `retryWhenAvailable: true`. The outbox remains pending and emits no `onSendError`.
101
+ Offline reports also carry `retryWhenOnline: true`: the session owns a temporary
102
+ `onOnline` subscription to retry those entries after confirmed connectivity,
103
+ including brief outages the app's existing monitor might have missed. Cancel,
104
+ close and signer unavailability release the subscription. Account recovery still
105
+ uses `setAvailable(true)`; network recovery never overrides signer availability.
106
+ `onSendError` is emitted only after final failure, including exhausted eligible
107
+ relays. Validation/authorization failures are terminal without relay rotation.
108
+ `broadcastRumor`/`broadcastEvent` accept a `signal` to stop further replacement
109
+ publications; the session supplies it and aborts it on cancel/close. An already
110
+ published event cannot be recalled by cancellation.
@@ -3,10 +3,12 @@ import { createPrivateFileTransfer } from '../file/index.js'
3
3
  import { createPrivateMessenger } from '../index.js'
4
4
  import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/index.js'
5
5
  import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
6
+ import { onOnline } from '../../network/index.js'
6
7
  import { decodeFileMetadata } from '../../nip94/index.js'
7
8
  import { createChatOutbox } from './helpers/work-storage.js'
8
9
  import { messengerSigner } from './helpers/signer.js'
9
- import { assertMessagePublished } from './helpers/publication.js'
10
+ import { assertMessagePublished } from '../helpers/publication.js'
11
+ import { normalizeFallbackRelays } from '../helpers/send-routing.js'
10
12
 
11
13
  export function wireEvent (value, owner) {
12
14
  const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
@@ -16,7 +18,8 @@ export function wireEvent (value, owner) {
16
18
  // Relay rejection text must not be interpreted as a local signer denial.
17
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 || ''}`)
18
20
 
19
- 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, 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)
20
23
  const userSigner = messengerSigner(signer)
21
24
  messageStorage ||= createEventStoreMessageStorage({ eventStore })
22
25
  chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
@@ -25,6 +28,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
25
28
  const deniedPeers = new Set()
26
29
  const entries = new Map()
27
30
  const cancelled = new Set()
31
+ const sendControllers = new Map()
32
+ const offlineSends = new Set()
33
+ let stopOnline
28
34
  let messenger
29
35
  let fileTransfers
30
36
  let downloads
@@ -70,7 +76,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
70
76
  if (closed || !available || version !== lifecycle) return
71
77
  values.push(channel)
72
78
  }
73
- 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 })
74
80
  if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
75
81
  await messenger.update({ channels: values })
76
82
  await messenger.resume('signer')
@@ -140,16 +146,18 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
140
146
  }
141
147
  } finally { await Promise.all(deferred.map(nack => nack())); draining = false; settled() }
142
148
  }
143
- async function publish (peer, event) {
149
+ async function publish (peer, event, signal) {
144
150
  if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
145
151
  if (!available || !peers.has(peer) || !channels.has(peer)) throw new Error('CHAT_UNAVAILABLE')
146
- const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer] }
152
+ const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
147
153
  const report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
148
154
  // Published is relay acceptance, never a peer receipt.
149
155
  await assertMessagePublished(report, event)
150
156
  }
151
157
  async function sendEntry (entry) {
152
158
  entry.status = 'pending'; emit()
159
+ const controller = new AbortController()
160
+ sendControllers.set(entry.id, controller)
153
161
  try {
154
162
  const active = async () => {
155
163
  if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
@@ -180,7 +188,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
180
188
  const chunk = chunkStorage ? await chunkStorage.read(file.root, entry.chunkIndex, file) : await fileTransfers.readChunk(file.root, entry.chunkIndex)
181
189
  if (!chunk || decodeIrfsChunk(chunk).root !== file.root) { if (file.optional) break; throw new Error('FILE_UNAVAILABLE') }
182
190
  if (!fileTransfers) throw new Error('FILE_TRANSFER_UNAVAILABLE')
183
- await fileTransfers.publishChunk({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, wireEvent(chunk, owner))
191
+ await fileTransfers.publishChunk({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, wireEvent(chunk, owner), { signal: controller.signal })
184
192
  const completed = entry.files.slice(0, entry.fileIndex).reduce((sum, value) => sum + value.size, 0) + Math.min(file.size, (entry.chunkIndex + 1) * IRFS_CHUNK_BYTES)
185
193
  entry.uploadProgress = { completed, total: entry.files.reduce((sum, value) => sum + value.size, 0) }
186
194
  emit()
@@ -199,7 +207,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
199
207
  for (; entry.index < entry.events.length; entry.index++) {
200
208
  await active()
201
209
  const event = entry.events[entry.index]
202
- if (entry.peer !== owner) await publish(entry.peer, event)
210
+ if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
203
211
  await active()
204
212
  await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
205
213
  }
@@ -208,15 +216,33 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
208
216
  emit()
209
217
  } catch (error) {
210
218
  if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
211
- entry.status = 'error'; entry.failed = true; entry.retryable = retryable(error)
219
+ entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
212
220
  await storage.put(entry, { existing: true }).catch(onError)
213
221
  onError(error)
214
222
  // The failed wire event may be a quote or file chunk. Report the
215
223
  // owning outbox item separately, without mutating the native error.
216
- if (!closed && !entry.deletion && !cancelled.has(entry.id)) onSendError(error, { id: entry.id, peer: entry.peer })
224
+ if (error.retryWhenOnline) offlineSends.add(entry.id)
225
+ if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
217
226
  }
218
227
  emit()
219
- }
228
+ } finally { sendControllers.delete(entry.id) }
229
+ }
230
+ function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
231
+ function watchOfflineSends () {
232
+ if (closed || !available || !offlineSends.size) { stopOnlineRetry(); return }
233
+ if (stopOnline) return
234
+ // A fresh listener receives confirmed connectivity even when a brief outage
235
+ // occurred between the app's long-lived monitor checks.
236
+ stopOnline = _onOnline(() => {
237
+ stopOnlineRetry()
238
+ if (closed || !available) return
239
+ for (const id of offlineSends) {
240
+ const entry = entries.get(id)
241
+ if (entry?.retryable) entry.failed = false
242
+ }
243
+ offlineSends.clear()
244
+ return schedule()
245
+ })
220
246
  }
221
247
  async function pump () {
222
248
  if (sending || !available || closed || !messenger || !storage) return
@@ -227,7 +253,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
227
253
  if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
228
254
  await sendEntry(entry)
229
255
  }
230
- } finally { sending = false; settled() }
256
+ } finally { sending = false; watchOfflineSends(); settled() }
231
257
  }
232
258
  const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
233
259
  async function download (file, options = {}) {
@@ -266,6 +292,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
266
292
  },
267
293
  async setAvailable (value) {
268
294
  available = value === true
295
+ stopOnlineRetry(); offlineSends.clear()
269
296
  lifecycle++
270
297
  if (!available) { await messenger?.pause('signer'); return }
271
298
  await ready()
@@ -286,12 +313,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
286
313
  return id
287
314
  },
288
315
  async retry (id) {
316
+ offlineSends.delete(id); watchOfflineSends()
289
317
  await ready()
290
318
  const entry = entries.get(id)
291
319
  if (entry) { entry.failed = false; if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
292
320
  },
293
321
  async cancel (id) {
294
322
  cancelled.add(id)
323
+ sendControllers.get(id)?.abort()
324
+ offlineSends.delete(id); watchOfflineSends()
295
325
  await ready()
296
326
  entries.delete(id)
297
327
  await storage.remove(id)
@@ -299,6 +329,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
299
329
  },
300
330
  async close () {
301
331
  closed = true; available = false; lifecycle++
332
+ stopOnlineRetry(); offlineSends.clear()
333
+ for (const controller of sendControllers.values()) controller.abort()
302
334
  await Promise.allSettled([configuring, initialized])
303
335
  await messenger?.pause('closed')
304
336
  if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))