libp2r2p 0.11.3 → 0.11.4

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,12 @@ 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
+ Single-recipient private messages now retry NIP-65 read relays in pairs when
919
+ 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
922
+ latency, and short-lived channel/recipient preferences avoid recently refused
923
+ relays without changing receive subscriptions. See the
924
+ [session coordinator](private-messenger/session/README.md) for deferred offline
925
+ 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.4",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -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,4 +1,4 @@
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
4
  // at least one relay acknowledgement. Keep the successful fast path immediate.
@@ -17,6 +17,8 @@ export async function assertMessagePublished (result, event) {
17
17
  return {
18
18
  index,
19
19
  total: settled?.total ?? report?.total,
20
+ ...(report?.retryWhenAvailable ? { retryWhenAvailable: true } : {}),
21
+ ...(report?.retryWhenOnline ? { retryWhenOnline: true } : {}),
20
22
  fulfilled: settled?.fulfilled,
21
23
  errors: (settled?.errors || []).map(({ relay, reason }) => ({ relay, reason }))
22
24
  }
@@ -32,6 +34,7 @@ export async function assertMessagePublished (result, event) {
32
34
  })
33
35
  throw Object.assign(new Error(`MESSAGE_NOT_PUBLISHED: ${details.join(' | ') || reason}`), {
34
36
  code: 'MESSAGE_NOT_PUBLISHED',
37
+ ...(failures.length && failures.every(report => report.retryWhenAvailable) ? { retryWhenAvailable: true, ...(failures.some(report => report.retryWhenOnline) ? { retryWhenOnline: true } : {}) } : {}),
35
38
  reason,
36
39
  eventId: getEventHash(event),
37
40
  eventKind: event.kind,
@@ -0,0 +1,90 @@
1
+ import { normalizeRelayUrl } from '../../url/index.js'
2
+
3
+ const REJECTION_PREFIXES = new Set(['blocked', 'restricted', 'auth-required', 'pow', 'rate-limited', 'error'])
4
+ const TRANSPORT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
5
+ const EXCLUSION_MS = 5 * 60 * 1000
6
+ const normalized = relay => { try { return normalizeRelayUrl(relay) } catch { return '' } }
7
+
8
+ // Only machine-readable relay policy/server failures qualify. Invalid events,
9
+ // local signer denials and unknown prose must not rotate relays.
10
+ export function isReplaceableRelayFailure (error) {
11
+ if (error?.name === 'Nip42AuthenticationError') return false
12
+ if (TRANSPORT_CATEGORIES.has(error?.category)) return true
13
+ if (error?.category && error.category !== 'relay') return false
14
+ return REJECTION_PREFIXES.has(/^([a-z-]+):/.exec(error?.message || '')?.[1])
15
+ }
16
+
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()]
21
+ const candidateSet = new Set(candidates)
22
+ 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())
28
+ const current = () => !signal?.aborted && isCurrent()
29
+ const rejected = report => (report?.errors || []).filter(item => candidateSet.has(normalized(item.relay)) && isReplaceableRelayFailure(item.reason))
30
+ const remember = errors => { for (const item of errors) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
31
+ const online = async () => {
32
+ if (!current()) return false
33
+ try { return await isOnline({ signal }) && current() } catch { return false }
34
+ }
35
+
36
+ async function send (event, initialRelays) {
37
+ const mirrors = initialRelays.filter(relay => !candidateSet.has(normalized(relay)))
38
+ let relays = [...new Set([...select(exclusions.keys()).keys(), ...mirrors])]
39
+ const tried = new Set()
40
+ const errors = []
41
+ const summaries = []
42
+ const finish = result => {
43
+ if (summaries.length === 1) return result
44
+ return {
45
+ ...result,
46
+ 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) {
57
+ signal?.throwIfAborted()
58
+ 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)
62
+ const settled = Promise.resolve(result.promise)
63
+ summaries.push(settled)
64
+ if (result.success) {
65
+ // Keep the first ACK immediate; learn about redundant failures later.
66
+ settled.then(async report => {
67
+ const failed = rejected(report)
68
+ if (failed.length && await online()) remember(failed)
69
+ }).catch(() => {})
70
+ return finish(result)
71
+ }
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
81
+ }
82
+ }
83
+
84
+ return {
85
+ relayToReceivers,
86
+ // A recovery mirror must not reintroduce the recipient's refused relays.
87
+ recoveryRelays: recoveryRelays.filter(relay => !candidateSet.has(relay) || !exclusions.has(relay)),
88
+ _publish: options => (options.nymSigner ? publishNymEvent : publish)({ ...options, _publish: send })
89
+ }
90
+ }
@@ -43,10 +43,12 @@
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 } from './helpers/send-routing.js'
46
48
  import { deliveryInfo } from '../private-channel/helpers/rumor.js'
47
49
  import { bytesToBase64 } from '../base64/index.js'
48
50
  import { ValidationError } from '../error/index.js'
49
- import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates } from '../relay/index.js'
51
+ import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates, relayPool } from '../relay/index.js'
50
52
  import * as privateChannel from '../private-channel/index.js'
51
53
  import { DEFAULT_RECEIVED_CHUNK_TTL_MS } from '../private-channel/services/received-chunks.js'
52
54
  import { createQueue } from '../idb-queue/index.js'
@@ -215,6 +217,7 @@ export class PrivateMessenger {
215
217
  _getRelaysByPubkey = getRelaysByPubkey,
216
218
  _pickRelaysForPubkeys = pickRelaysForPubkeys,
217
219
  _subscribeRelayListUpdates = subscribeRelayListUpdates,
220
+ _isOnline = isOnline,
218
221
  _setTimeout = globalThis.setTimeout.bind(globalThis),
219
222
  _clearTimeout = globalThis.clearTimeout.bind(globalThis),
220
223
  _setInterval = globalThis.setInterval.bind(globalThis),
@@ -247,6 +250,9 @@ export class PrivateMessenger {
247
250
  this._getRelaysByPubkey = _getRelaysByPubkey
248
251
  this._pickRelaysForPubkeys = _pickRelaysForPubkeys
249
252
  this._subscribeRelayListUpdates = _subscribeRelayListUpdates
253
+ this._isOnline = _isOnline
254
+ this.sendRelayExclusions = new Map()
255
+ this.sendRoutingLifetime = new AbortController()
250
256
  this._setTimeout = _setTimeout
251
257
  this._clearTimeout = _clearTimeout
252
258
  this._setInterval = _setInterval
@@ -670,6 +676,7 @@ export class PrivateMessenger {
670
676
 
671
677
  await this.unwatch(removedPubkeys)
672
678
  for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
679
+ for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
673
680
  for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
674
681
 
675
682
  await this.cleanupStaleChannels({ storageSnapshot })
@@ -753,13 +760,36 @@ export class PrivateMessenger {
753
760
  return this.readRelaysForPubkey(this.userPubkey)
754
761
  }
755
762
 
756
- async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers }) {
763
+ async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal }) {
757
764
  const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
758
765
  if (relayToReceivers) return { relayToReceivers, recoveryRelays }
759
766
  if (relays?.length) return { relays: uniq(relays), recoveryRelays }
760
767
  if (channel.sendRelays.length) return { relays: channel.sendRelays, recoveryRelays }
761
768
  if (channel.relays.length) return { relays: channel.relays, recoveryRelays }
762
- const derived = await this.readRelayToReceivers(receiverPubkeys)
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
791
+ }
792
+ const derived = await this.readRelayToReceivers(recipients)
763
793
  if (!relayMapRelays(derived).length) throw new ValidationError('NO_RELAYS')
764
794
  return { relayToReceivers: derived, recoveryRelays }
765
795
  }
@@ -1600,9 +1630,9 @@ export class PrivateMessenger {
1600
1630
  })
1601
1631
  }
1602
1632
 
1603
- async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey }) {
1633
+ async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal }) {
1604
1634
  const channel = this.requireWritableChannel(channelPubkey)
1605
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
1635
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
1606
1636
  this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
1607
1637
  return this._privateMessage.broadcastRumor({
1608
1638
  senderSigner: this.userSigner,
@@ -1621,9 +1651,9 @@ export class PrivateMessenger {
1621
1651
  })
1622
1652
  }
1623
1653
 
1624
- async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey }) {
1654
+ async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal }) {
1625
1655
  const channel = this.requireWritableChannel(channelPubkey)
1626
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
1656
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
1627
1657
  this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
1628
1658
  return this._privateMessage.broadcastEvent({
1629
1659
  senderSigner: this.userSigner,
@@ -2187,6 +2217,8 @@ export class PrivateMessenger {
2187
2217
  close () {
2188
2218
  if (this.closePromise) return this.closePromise
2189
2219
  const initSettledPromise = this.initSettledPromise
2220
+ this.sendRoutingLifetime.abort()
2221
+ this.sendRelayExclusions.clear()
2190
2222
  let unwatchPromise
2191
2223
  try {
2192
2224
  unwatchPromise = Promise.resolve(this.unwatch())
@@ -45,3 +45,34 @@ 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
+ 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.
58
+
59
+ Each signed outer event is reused verbatim, including router/carrier fragments
60
+ 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,
64
+ 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.
66
+
67
+ When offline/interrupted with alternatives remaining, reports carry
68
+ `retryWhenAvailable: true`. The outbox remains pending and emits no `onSendError`.
69
+ Offline reports also carry `retryWhenOnline: true`: the session owns a temporary
70
+ `onOnline` subscription to retry those entries after confirmed connectivity,
71
+ including brief outages the app's existing monitor might have missed. Cancel,
72
+ close and signer unavailability release the subscription. Account recovery still
73
+ uses `setAvailable(true)`; network recovery never overrides signer availability.
74
+ `onSendError` is emitted only after final failure, including exhausted eligible
75
+ relays. Validation/authorization failures are terminal without relay rotation.
76
+ `broadcastRumor`/`broadcastEvent` accept a `signal` to stop further replacement
77
+ publications; the session supplies it and aborts it on cancel/close. An already
78
+ published event cannot be recalled by cancellation.
@@ -3,10 +3,11 @@ 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'
10
11
 
11
12
  export function wireEvent (value, owner) {
12
13
  const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
@@ -16,7 +17,7 @@ export function wireEvent (value, owner) {
16
17
  // Relay rejection text must not be interpreted as a local signer denial.
17
18
  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
19
 
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' }) }) {
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' }) }) {
20
21
  const userSigner = messengerSigner(signer)
21
22
  messageStorage ||= createEventStoreMessageStorage({ eventStore })
22
23
  chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
@@ -25,6 +26,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
25
26
  const deniedPeers = new Set()
26
27
  const entries = new Map()
27
28
  const cancelled = new Set()
29
+ const sendControllers = new Map()
30
+ const offlineSends = new Set()
31
+ let stopOnline
28
32
  let messenger
29
33
  let fileTransfers
30
34
  let downloads
@@ -140,16 +144,18 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
140
144
  }
141
145
  } finally { await Promise.all(deferred.map(nack => nack())); draining = false; settled() }
142
146
  }
143
- async function publish (peer, event) {
147
+ async function publish (peer, event, signal) {
144
148
  if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
145
149
  if (!available || !peers.has(peer) || !channels.has(peer)) throw new Error('CHAT_UNAVAILABLE')
146
- const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer] }
150
+ const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
147
151
  const report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
148
152
  // Published is relay acceptance, never a peer receipt.
149
153
  await assertMessagePublished(report, event)
150
154
  }
151
155
  async function sendEntry (entry) {
152
156
  entry.status = 'pending'; emit()
157
+ const controller = new AbortController()
158
+ sendControllers.set(entry.id, controller)
153
159
  try {
154
160
  const active = async () => {
155
161
  if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
@@ -180,7 +186,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
180
186
  const chunk = chunkStorage ? await chunkStorage.read(file.root, entry.chunkIndex, file) : await fileTransfers.readChunk(file.root, entry.chunkIndex)
181
187
  if (!chunk || decodeIrfsChunk(chunk).root !== file.root) { if (file.optional) break; throw new Error('FILE_UNAVAILABLE') }
182
188
  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))
189
+ 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
190
  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
191
  entry.uploadProgress = { completed, total: entry.files.reduce((sum, value) => sum + value.size, 0) }
186
192
  emit()
@@ -199,7 +205,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
199
205
  for (; entry.index < entry.events.length; entry.index++) {
200
206
  await active()
201
207
  const event = entry.events[entry.index]
202
- if (entry.peer !== owner) await publish(entry.peer, event)
208
+ if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
203
209
  await active()
204
210
  await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
205
211
  }
@@ -208,15 +214,33 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
208
214
  emit()
209
215
  } catch (error) {
210
216
  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)
217
+ entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
212
218
  await storage.put(entry, { existing: true }).catch(onError)
213
219
  onError(error)
214
220
  // The failed wire event may be a quote or file chunk. Report the
215
221
  // 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 })
222
+ if (error.retryWhenOnline) offlineSends.add(entry.id)
223
+ if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
217
224
  }
218
225
  emit()
219
- }
226
+ } finally { sendControllers.delete(entry.id) }
227
+ }
228
+ function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
229
+ function watchOfflineSends () {
230
+ if (closed || !available || !offlineSends.size) { stopOnlineRetry(); return }
231
+ if (stopOnline) return
232
+ // A fresh listener receives confirmed connectivity even when a brief outage
233
+ // occurred between the app's long-lived monitor checks.
234
+ stopOnline = _onOnline(() => {
235
+ stopOnlineRetry()
236
+ if (closed || !available) return
237
+ for (const id of offlineSends) {
238
+ const entry = entries.get(id)
239
+ if (entry?.retryable) entry.failed = false
240
+ }
241
+ offlineSends.clear()
242
+ return schedule()
243
+ })
220
244
  }
221
245
  async function pump () {
222
246
  if (sending || !available || closed || !messenger || !storage) return
@@ -227,7 +251,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
227
251
  if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
228
252
  await sendEntry(entry)
229
253
  }
230
- } finally { sending = false; settled() }
254
+ } finally { sending = false; watchOfflineSends(); settled() }
231
255
  }
232
256
  const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
233
257
  async function download (file, options = {}) {
@@ -266,6 +290,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
266
290
  },
267
291
  async setAvailable (value) {
268
292
  available = value === true
293
+ stopOnlineRetry(); offlineSends.clear()
269
294
  lifecycle++
270
295
  if (!available) { await messenger?.pause('signer'); return }
271
296
  await ready()
@@ -286,12 +311,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
286
311
  return id
287
312
  },
288
313
  async retry (id) {
314
+ offlineSends.delete(id); watchOfflineSends()
289
315
  await ready()
290
316
  const entry = entries.get(id)
291
317
  if (entry) { entry.failed = false; if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
292
318
  },
293
319
  async cancel (id) {
294
320
  cancelled.add(id)
321
+ sendControllers.get(id)?.abort()
322
+ offlineSends.delete(id); watchOfflineSends()
295
323
  await ready()
296
324
  entries.delete(id)
297
325
  await storage.remove(id)
@@ -299,6 +327,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
299
327
  },
300
328
  async close () {
301
329
  closed = true; available = false; lifecycle++
330
+ stopOnlineRetry(); offlineSends.clear()
331
+ for (const controller of sendControllers.values()) controller.abort()
302
332
  await Promise.allSettled([configuring, initialized])
303
333
  await messenger?.pause('closed')
304
334
  if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))