libp2r2p 0.11.17 → 0.11.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ import { createPauseRecovery } from '../../helpers/pause-recovery.js'
2
+
3
+ // One account owns the queue. Background work may occupy only three of the
4
+ // four slots so a newly opened conversation never sits behind the whole list.
5
+ export function createPeerPreparation ({ prepare, onReady, onError, isRetryable, online, onOnline, setTimer, clearTimer, random }) {
6
+ const entries = new Map()
7
+ let enabled = false
8
+ let closed = false
9
+ let running = 0
10
+ let background = 0
11
+ const unavailable = () => new Error('CHAT_UNAVAILABLE')
12
+ function entryFor (peer) {
13
+ const deferred = Promise.withResolvers()
14
+ deferred.promise.catch(() => {})
15
+ return { peer, ...deferred, controller: new AbortController(), state: 'queued', priority: false, value: null, error: null, recovery: null }
16
+ }
17
+ const current = entry => !closed && enabled && entries.get(entry.peer) === entry && !entry.controller.signal.aborted
18
+ function cancel (entry) {
19
+ entry.recovery?.stop()
20
+ entry.controller.abort(unavailable())
21
+ entry.reject(entry.controller.signal.reason)
22
+ entry.retryResult?.resolve()
23
+ entry.retryResult = null
24
+ }
25
+ function drain () {
26
+ if (!enabled || closed) return
27
+ while (running < 4) {
28
+ const queued = [...entries.values()].filter(entry => entry.state === 'queued')
29
+ const entry = queued.find(entry => entry.priority) || (background < 3 && queued[0])
30
+ if (!entry) break
31
+ const isBackground = !entry.priority
32
+ entry.state = 'running'
33
+ running++
34
+ if (isBackground) background++
35
+ Promise.resolve().then(() => {
36
+ entry.controller.signal.throwIfAborted()
37
+ return prepare(entry.peer, entry.controller.signal)
38
+ }).then(value => {
39
+ if (!current(entry)) return
40
+ entry.state = 'ready'; entry.value = value
41
+ entry.resolve(value)
42
+ onReady(entry.peer, value)
43
+ }, error => {
44
+ if (!current(entry)) return
45
+ entry.error = error
46
+ onError(error)
47
+ if (!isRetryable(error)) {
48
+ entry.state = 'failed'; entry.reject(error)
49
+ entry.retryResult?.reject(error); entry.retryResult = null
50
+ onReady(entry.peer)
51
+ return
52
+ }
53
+ entry.state = 'waiting'
54
+ entry.recovery ||= createPauseRecovery({
55
+ attempt: async signal => {
56
+ signal.throwIfAborted()
57
+ if (!current(entry)) return
58
+ entry.state = 'queued'; drain()
59
+ // Recovery waits for the actual preparation; no slot is held
60
+ // during backoff or a connectivity wait.
61
+ await new Promise((resolve, reject) => { entry.retryResult = { resolve, reject } })
62
+ },
63
+ retryable: isRetryable, onError: () => {}, online, onOnline, setTimer, clearTimer, random
64
+ })
65
+ if (entry.retryResult) { entry.retryResult.reject(error); entry.retryResult = null } else entry.recovery.start({ retryAt: error.retryAt })
66
+ }).finally(() => {
67
+ if (entry.state === 'ready') { entry.retryResult?.resolve(); entry.retryResult = null }
68
+ if (!current(entry)) { entry.retryResult?.resolve(); entry.retryResult = null }
69
+ running--
70
+ if (isBackground) background--
71
+ drain()
72
+ })
73
+ }
74
+ }
75
+ return {
76
+ get: peer => entries.get(peer)?.value,
77
+ error: peer => entries.get(peer)?.state === 'failed' ? entries.get(peer).error : null,
78
+ reconcile (peers) {
79
+ const wanted = new Set(peers)
80
+ for (const [peer, entry] of entries) if (!wanted.has(peer)) { entries.delete(peer); cancel(entry) }
81
+ for (const peer of wanted) if (!entries.has(peer)) entries.set(peer, entryFor(peer))
82
+ drain()
83
+ },
84
+ setAvailable (value) {
85
+ enabled = value === true
86
+ if (!enabled) {
87
+ for (const [peer, entry] of entries) {
88
+ if (entry.state === 'ready') continue
89
+ cancel(entry)
90
+ const replacement = entryFor(peer)
91
+ replacement.priority = entry.priority
92
+ entries.set(peer, replacement)
93
+ }
94
+ }
95
+ drain()
96
+ },
97
+ request (peer, { retry = false } = {}) {
98
+ if (closed || !entries.has(peer)) return Promise.reject(unavailable())
99
+ let entry = entries.get(peer)
100
+ if (retry && entry.state === 'failed') { cancel(entry); entries.set(peer, (entry = entryFor(peer))) }
101
+ entry.priority = true
102
+ drain()
103
+ return entry.promise
104
+ },
105
+ close () {
106
+ closed = true; enabled = false
107
+ for (const entry of entries.values()) cancel(entry)
108
+ entries.clear()
109
+ }
110
+ }
111
+ }
@@ -3,8 +3,12 @@ import { base64ToBytes, bytesToBase64 } from '../../../base64/index.js'
3
3
  // The library transport uses its Base64 signer contract. Conversion stays at
4
4
  // this adapter; the injected launcher/vault channel always carries raw bytes.
5
5
  export function messengerSigner (signer) {
6
+ // The adapter belongs to one account/channel generation. Share both the
7
+ // lookup in flight and its immutable result; failed permissions are retryable
8
+ // only when the owning session explicitly asks again.
9
+ let publicKey
6
10
  const adapter = {
7
- getPublicKey: () => signer.getPublicKey(),
11
+ getPublicKey: () => (publicKey ??= Promise.resolve().then(() => signer.getPublicKey()).catch(error => { publicKey = null; throw error })),
8
12
  getRelays: () => signer.getRelays(),
9
13
  signEvent: event => signer.signEvent(event),
10
14
  nip44v3Encrypt: (peer, kind, scope, text) => signer.nip44v3.encrypt(peer, kind, scope, base64ToBytes(text).buffer),
@@ -4,13 +4,16 @@ 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
6
  import { getIykcProofs } from '../../content-key/index.js'
7
- import { onOnline } from '../../network/index.js'
7
+ import { isOnline, onOnline } from '../../network/index.js'
8
+ import { createPeerPreparation } from './helpers/peer-preparation.js'
9
+ import { createForegroundWork } from './helpers/foreground-work.js'
10
+ import { isRetryableRelayFailure } from '../../relay/index.js'
8
11
  import { ValidationError } from '../../error/index.js'
9
12
  import { decodeFileMetadata } from '../../nip94/index.js'
10
13
  import { createChatOutbox } from './helpers/work-storage.js'
11
14
  import { messengerSigner } from './helpers/signer.js'
12
15
  import { assertMessagePublished } from '../helpers/publication.js'
13
- import { normalizeFallbackRelays } from '../helpers/send-routing.js'
16
+ import { normalizeFallbackRelays, normalizeFallbackDelay } from '../helpers/send-routing.js'
14
17
  import { createPauseRecovery, isRecoverableStorageFailure } from '../helpers/pause-recovery.js'
15
18
 
16
19
  export function wireEvent (value, owner) {
@@ -41,8 +44,9 @@ function messengerStatusSnapshot (value) {
41
44
  return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
42
45
  }
43
46
 
44
- export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
47
+ export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
45
48
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
49
+ fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
46
50
  const reportedErrors = new WeakSet()
47
51
  const reportError = error => {
48
52
  if (error && (typeof error === 'object' || typeof error === 'function')) {
@@ -52,11 +56,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
52
56
  onError(error)
53
57
  }
54
58
  const userSigner = messengerSigner(signer)
59
+ const foreground = createForegroundWork()
55
60
  messageStorage ||= createEventStoreMessageStorage({ eventStore })
56
61
  chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
57
62
  const peers = new Set()
58
63
  const channels = new Map()
59
- const deniedPeers = new Set()
60
64
  const entries = new Map()
61
65
  const cancelled = new Set()
62
66
  const sendControllers = new Map()
@@ -112,6 +116,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
112
116
  let lifecycle = 0
113
117
  let configuring = Promise.resolve()
114
118
  let configurationFailed = false
119
+ let configureQueued = false
120
+ let configureWork
121
+ let messengerWork
122
+ let fileSetupWork
115
123
  let sending
116
124
  let repump
117
125
  let draining
@@ -131,7 +139,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
131
139
  let candidate
132
140
  try {
133
141
  candidate = await Messenger({
134
- fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
142
+ fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait,
135
143
  onMessageQueued: () => { if (messengerBinding === binding) return drain() },
136
144
  onStateChanged: state => {
137
145
  if (closed || messengerBinding !== binding) return
@@ -153,28 +161,41 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
153
161
  }
154
162
  }
155
163
 
164
+ const preparation = createPeerPreparation({
165
+ prepare: async (peer, signal) => {
166
+ const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
167
+ const pubkey = await scoped.getPublicKey()
168
+ signal.throwIfAborted()
169
+ const channel = { signer: scoped, pubkey, readerPubkey: pubkey, mode, seeders: seedersForPeer(peer) }
170
+ channels.set(peer, channel)
171
+ await schedule()
172
+ signal.throwIfAborted()
173
+ return channel
174
+ },
175
+ onReady: () => { drain(); pump() }, onError: reportError,
176
+ isRetryable: error => ['connection', 'transport', 'timeout'].includes(error?.category) && isRetryableRelayFailure(error),
177
+ online: _isOnline, onOnline: _onOnline, setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random
178
+ })
179
+ async function ensureMessenger () {
180
+ if (!messenger && !messengerWork) {
181
+ const work = createMessenger()
182
+ messengerWork = work
183
+ work.finally(() => { if (messengerWork === work) messengerWork = null }).catch(() => {})
184
+ }
185
+ await messengerWork
186
+ }
156
187
  async function configure () {
157
188
  if (closed || !available) return
158
- await ready()
159
189
  const version = lifecycle
160
- const values = []
161
- for (const peer of peers) {
162
- if (deniedPeers.has(peer)) continue
163
- let channel = channels.get(peer)
164
- if (!channel) {
165
- try {
166
- const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
167
- channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
168
- channels.set(peer, channel)
169
- } catch (error) { if (!retryable(error)) deniedPeers.add(peer); reportError(error); continue }
170
- }
171
- if (closed || !available || version !== lifecycle) return
172
- values.push(channel)
173
- }
174
- if (!messenger) await createMessenger()
190
+ await ready()
191
+ if (closed || !available || version !== lifecycle) return
192
+ await ensureMessenger()
175
193
  if (!messenger) return
176
- if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
194
+ if (closed || !available) { await messenger.pause('signer'); return }
195
+ if (version !== lifecycle) { configureQueued = true; return }
196
+ const values = [...channels].filter(([peer]) => peers.has(peer)).map(([, channel]) => channel)
177
197
  await messenger.update({ channels: values })
198
+ if (closed || !available || version !== lifecycle) return
178
199
  if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
179
200
  for (const [peer, list] of [...pendingPriorityRanges]) {
180
201
  const channel = channels.get(peer)
@@ -186,7 +207,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
186
207
  }
187
208
  }
188
209
  await messenger.resume('signer')
210
+ if (closed || !available || version !== lifecycle) return
211
+ preparation.setAvailable(true)
189
212
  if (storageBlocked) inboxRecovery.start({ immediate: true })
213
+ if (!fileSetupWork) {
214
+ fileSetupWork = setupFiles().catch(error => { fileSetupWork = null; if (!closed) reportError(error) })
215
+ }
216
+ fileSetupWork.then(() => { if (!closed && available) restoreDownloads() }).catch(reportError)
217
+ drain(); pump()
218
+ }
219
+ async function setupFiles () {
190
220
  if (!fileTransfers) {
191
221
  fileTransfers = FileTransfer({
192
222
  messenger, onError: reportError,
@@ -205,9 +235,17 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
205
235
  }
206
236
  }
207
237
  })
208
- downloads = await openDownloads({ owner, signer })
209
- for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
210
238
  }
239
+ if (!downloads) {
240
+ const candidate = await openDownloads({ owner, signer })
241
+ try {
242
+ const intents = await candidate.list()
243
+ downloads = candidate
244
+ for (const entry of intents) downloadIntents.set(entry.id, entry)
245
+ } catch (error) { await candidate.close?.(); throw error }
246
+ }
247
+ }
248
+ function restoreDownloads () {
211
249
  for (const entry of downloadIntents.values()) {
212
250
  if (!peers.has(entry.file.peer) || activeDownloads.has(entry.id)) continue
213
251
  const work = download(entry.file, { manual: true })
@@ -219,12 +257,37 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
219
257
  .finally(() => { if (activeDownloads.get(entry.id) === work) activeDownloads.delete(entry.id) })
220
258
  activeDownloads.set(entry.id, work)
221
259
  }
222
- drain(); pump()
260
+ pump()
223
261
  }
224
262
  function schedule () {
225
- const work = configuring.catch(() => {}).then(configure)
226
- configuring = work
227
- work.then(() => { if (configuring === work) configurationFailed = false }, error => { if (configuring === work) configurationFailed = true; reportError(error) })
263
+ configureQueued = true
264
+ if (configureWork) return configureWork
265
+ const work = Promise.resolve().then(async () => {
266
+ try {
267
+ do {
268
+ configureQueued = false
269
+ await configure()
270
+ if (closed || !available) break
271
+ } while (configureQueued)
272
+ } finally { if (configureWork === work) configureWork = null }
273
+ })
274
+ configureWork = configuring = work
275
+ work.then(() => {
276
+ if (configureWork === work) configureWork = null
277
+ configurationFailed = false
278
+ }, error => {
279
+ if (configureWork === work) configureWork = null
280
+ configurationFailed = true
281
+ if (!closed) reportError(error)
282
+ }).catch(() => {})
283
+ return work
284
+ }
285
+ async function preparePeer (peer, { retry = true } = {}) {
286
+ if (typeof peer !== 'string' || !/^[0-9a-f]{64}$/i.test(peer)) throw new ValidationError('INVALID_PRIVATE_PEER')
287
+ if (closed || !available || !peers.has(peer)) throw new Error('CHAT_UNAVAILABLE')
288
+ const work = preparation.request(peer, { retry })
289
+ work.catch(() => {})
290
+ if (!messenger || configurationFailed) await schedule()
228
291
  return work
229
292
  }
230
293
  async function drain () {
@@ -284,7 +347,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
284
347
  }
285
348
  }
286
349
  async function publish (peer, event, signal) {
287
- if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
350
+ if (preparation.error(peer)) throw preparation.error(peer)
288
351
  if (!available || !peers.has(peer) || !channels.has(peer)) throw unavailableError(new Error('CHAT_UNAVAILABLE'), signal)
289
352
  const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
290
353
  let report
@@ -298,6 +361,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
298
361
  await assertMessagePublished(report, event)
299
362
  }
300
363
  async function sendEntry (entry) {
364
+ const releaseForeground = foreground.enter()
301
365
  entry.status = 'pending'; emit()
302
366
  const controller = new AbortController()
303
367
  sendControllers.set(entry.id, controller)
@@ -320,6 +384,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
320
384
  await storage.put(entry, { existing: true })
321
385
  }
322
386
  }
387
+ if (entry.peer !== owner && !preparation.get(entry.peer)) {
388
+ if (preparation.error(entry.peer)) throw preparation.error(entry.peer)
389
+ preparation.request(entry.peer).catch(() => {})
390
+ return
391
+ }
323
392
  if (entry.peer !== owner && messengerStatus?.paused) {
324
393
  throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
325
394
  }
@@ -355,7 +424,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
355
424
  const event = entry.events[entry.index]
356
425
  if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
357
426
  await active()
358
- await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
427
+ // The final durable operation is removal. Avoid encrypting a checkpoint
428
+ // that would immediately be deleted; earlier context stages still checkpoint.
429
+ if (entry.index + 1 < entry.events.length) await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
359
430
  }
360
431
  await storage.remove(entry.id)
361
432
  entries.delete(entry.id)
@@ -367,16 +438,22 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
367
438
  if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
368
439
  entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
369
440
  await storage.put(entry, { existing: true }).catch(reportError)
370
- if (entry.peer !== owner && error.retryWhenAvailable && messenger.readStatus().paused) pausedSends.add(entry.id)
441
+ if (entry.peer !== owner && error.retryWhenAvailable && messenger?.readStatus().paused) pausedSends.add(entry.id)
442
+ else if (isUnavailableSend(cause) && cause.pauseReasons?.length && available && !closed) {
443
+ // Release can race persistence of the failed attempt. Do not lose
444
+ // its notification and spend a retry delay after the pause is gone.
445
+ entry.failed = false
446
+ queueMicrotask(() => pump())
447
+ }
371
448
  reportError(error)
372
449
  // The failed wire event may be a quote or file chunk. Report the
373
450
  // owning outbox item separately, without mutating the native error.
374
451
  if (error.retryWhenOnline) offlineSends.add(entry.id)
375
- if (error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
452
+ if (entry.failed && error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
376
453
  if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
377
454
  }
378
455
  emit()
379
- } finally { sendControllers.delete(entry.id) }
456
+ } finally { sendControllers.delete(entry.id); releaseForeground() }
380
457
  }
381
458
  function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
382
459
  // Transient availability failures without an active messenger pause retain
@@ -424,7 +501,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
424
501
  })
425
502
  }
426
503
  async function pump () {
427
- if (!available || closed || !messenger || !storage) return
504
+ if (!available || closed || !storage) return
428
505
  // A retry that lands while a pump is already running must not be skipped
429
506
  // just because that pass already walked past the entry.
430
507
  if (sending) { repump = true; return }
@@ -435,6 +512,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
435
512
  for (const entry of entries.values()) {
436
513
  if (closed || !available) break
437
514
  if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
515
+ if (entry.peer !== owner && entry.localSaved.every(Boolean) && !preparation.get(entry.peer) && !preparation.error(entry.peer)) {
516
+ preparation.request(entry.peer).catch(() => {})
517
+ continue
518
+ }
438
519
  if (entry.peer !== owner && entry.files.length && !fileTransfers) continue
439
520
  await sendEntry(entry)
440
521
  }
@@ -449,7 +530,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
449
530
  const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
450
531
  async function download (file, options = {}) {
451
532
  if (file.peer === owner) return file
452
- if (!available || closed || !peers.has(file.peer) || !fileTransfers) throw new Error('CHAT_UNAVAILABLE')
533
+ if (!available || closed || !peers.has(file.peer)) throw new Error('CHAT_UNAVAILABLE')
534
+ await preparePeer(file.peer)
535
+ await fileSetupWork
536
+ if (!fileTransfers || !downloads) throw new Error('CHAT_UNAVAILABLE')
453
537
  const id = intentId(file)
454
538
  if (options.manual) {
455
539
  const epoch = downloadEpochs.get(id) || 0
@@ -474,7 +558,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
474
558
  await writeDownloadIntent(() => downloads?.remove(id))
475
559
  }
476
560
  return {
477
- download, cancelDownload,
561
+ download, cancelDownload, preparePeer,
478
562
  // Peers-based so callers can prefetch as soon as a chat route opens, before
479
563
  // the shared-key channel exists. When group chats get real membership, add
480
564
  // a thin prefetchChannelContentKeys(channelPubkey) wrapper that resolves
@@ -502,20 +586,28 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
502
586
  if (type === 'unread-page' && since === undefined) throw new TypeError('PRIORITY_SINCE_REQUIRED')
503
587
  const request = { since, until, type }
504
588
  const channel = channels.get(peer)
505
- if (channel && typeof messenger?.prioritizeRange === 'function') {
589
+ if (channel && preparation.get(peer) && !pendingPriorityRanges.has(peer) && typeof messenger?.prioritizeRange === 'function') {
506
590
  try { return await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error); return false }
507
591
  }
508
592
  const list = pendingPriorityRanges.get(peer) || []
509
593
  list.push(request)
510
594
  pendingPriorityRanges.set(peer, list)
595
+ if (channel && available) schedule().catch(() => {})
511
596
  return true
512
597
  },
513
598
  async setPeers (values) {
514
599
  const next = new Set([...values].filter(peer => peer !== owner))
515
600
  if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
516
601
  peers.clear(); for (const peer of next) peers.add(peer)
517
- for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
518
- lifecycle++
602
+ preparation.reconcile(peers)
603
+ for (const peer of channels.keys()) {
604
+ if (!peers.has(peer)) {
605
+ channels.delete(peer); pendingPriorityRanges.delete(peer)
606
+ for (const entry of entries.values()) {
607
+ if (entry.peer === peer) sendControllers.get(entry.id)?.abort()
608
+ }
609
+ }
610
+ }
519
611
  return schedule()
520
612
  },
521
613
  async setAvailable (value) {
@@ -526,31 +618,39 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
526
618
  if (available) resetSendRetry()
527
619
  else clearSendRetry()
528
620
  if (changed) lifecycle++
529
- if (!available) { inboxRecovery.stop(); await messenger?.pause('signer'); return }
621
+ if (!available) {
622
+ preparation.setAvailable(false)
623
+ for (const peer of channels.keys()) if (!preparation.get(peer)) channels.delete(peer)
624
+ inboxRecovery.stop(); await messenger?.pause('signer'); return
625
+ }
530
626
  await ready()
531
627
  for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
532
628
  if (storageBlocked) inboxRecovery.start({ immediate: true })
533
- if (!changed && messenger && !configurationFailed) return pump()
629
+ if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
534
630
  return schedule()
535
631
  },
536
632
  async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
537
633
  if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
538
- await ready()
539
- const main = wireEvent(event, owner)
540
- const id = getEventHash(main)
541
- const events = [...context.map(value => wireEvent(value, owner)), main]
542
- const files = events.filter(value => value.kind === 1063).map(value => ({ ...decodeFileMetadata(value), optional: !requiredFiles.includes(getEventHash(value)) })).filter(file => file.service === 'irfs').flatMap(file => [file, ...(file.thumbnail?.root ? [{ ...file.thumbnail, optional: file.optional }] : [])])
543
- const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
544
- await storage.put(entry)
545
- entries.set(id, entry); emit(); pump()
546
- return id
634
+ const releaseForeground = foreground.enter()
635
+ try {
636
+ await ready()
637
+ const main = wireEvent(event, owner)
638
+ const id = getEventHash(main)
639
+ const events = [...context.map(value => wireEvent(value, owner)), main]
640
+ const files = events.filter(value => value.kind === 1063).map(value => ({ ...decodeFileMetadata(value), optional: !requiredFiles.includes(getEventHash(value)) })).filter(file => file.service === 'irfs').flatMap(file => [file, ...(file.thumbnail?.root ? [{ ...file.thumbnail, optional: file.optional }] : [])])
641
+ const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
642
+ await storage.put(entry)
643
+ entries.set(id, entry); emit(); pump()
644
+ if (peer !== owner) preparePeer(peer).catch(() => {})
645
+ return id
646
+ } finally { releaseForeground() }
547
647
  },
548
648
  async retry (id) {
549
649
  offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
550
650
  if (storageBlocked) inboxRecovery.start({ immediate: true })
551
651
  await ready()
552
652
  const entry = entries.get(id)
553
- if (entry) { entry.failed = false; resetSendRetry(); if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
653
+ if (entry) { entry.failed = false; resetSendRetry(); if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {}); return pump() }
554
654
  },
555
655
  async cancel (id) {
556
656
  cancelled.add(id)
@@ -565,9 +665,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
565
665
  return (closeWork ??= (async () => {
566
666
  closed = true; available = false; lifecycle++
567
667
  stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
568
- clearSendRetry(); inboxRecovery.stop(); pausedSends.clear()
668
+ clearSendRetry(); inboxRecovery.stop(); preparation.close(); foreground.close(); pausedSends.clear()
569
669
  for (const controller of sendControllers.values()) controller.abort()
570
- await Promise.allSettled([configuring, initialized])
670
+ await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
571
671
  await messenger?.pause('closed')
572
672
  if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
573
673
  await messenger?.close()
@@ -76,7 +76,11 @@ export function createPublishSettlements (promises, timeout, { onSettled } = {})
76
76
  })
77
77
  }
78
78
 
79
- return { promise, timeout: timeoutPending }
79
+ const cancel = reason => {
80
+ if (isFinished) return
81
+ for (let index = 0; index < settlements.length; index++) settle(index, { status: 'rejected', outcome: 'failed', reason })
82
+ }
83
+ return { promise, timeout: timeoutPending, cancel }
80
84
  }
81
85
 
82
86
  // Turns ordered relay settlements into a stable, caller-facing report. Failed
@@ -186,23 +186,33 @@ export class RelayConnection {
186
186
  }
187
187
 
188
188
  #sendEventOperation (type, event, map, timeoutCode, signal) {
189
- if (signal?.aborted) return Promise.reject(signal.reason || relayTimeoutError(timeoutCode))
190
- if (map.has(event.id)) return map.get(event.id).promise
189
+ if (signal?.aborted) return Promise.reject(signal.reason)
190
+ let pending = map.get(event.id)
191
+ const created = !pending
192
+ if (created) {
193
+ const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
194
+ pending = { timer, waiters: new Set(), cancelSend: null }
195
+ map.set(event.id, pending)
196
+ }
191
197
  const deferred = Promise.withResolvers()
192
- const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
193
- const onAbort = () => this.#settleEvent(map, event.id, signal.reason || relayTimeoutError(timeoutCode))
194
- const stopAbort = () => signal?.removeEventListener('abort', onAbort)
195
- const pending = { ...deferred, timer, promise: deferred.promise, cancelSend: null, stopAbort }
196
- map.set(event.id, pending)
198
+ const onAbort = () => {
199
+ pending.waiters.delete(waiter)
200
+ signal.removeEventListener('abort', onAbort)
201
+ deferred.reject(signal.reason)
202
+ if (!pending.waiters.size && map.get(event.id) === pending) {
203
+ map.delete(event.id)
204
+ pending.cancelSend?.()
205
+ clearTimeout(pending.timer)
206
+ }
207
+ }
208
+ const waiter = { ...deferred, stopAbort: () => signal?.removeEventListener('abort', onAbort) }
209
+ pending.waiters.add(waiter)
197
210
  signal?.addEventListener('abort', onAbort, { once: true })
198
- pending.cancelSend = this.#dispatchWork(() => {
199
- // Once transmitted, keep the existing ACK/report semantics. Before that,
200
- // an operation-wide deadline must cancel the deferred publication.
201
- stopAbort()
202
- this.send(JSON.stringify([type, event]))
203
- }, error => {
204
- this.#settleEvent(map, event.id, error)
205
- })
211
+ if (created) {
212
+ pending.cancelSend = this.#dispatchWork(() => {
213
+ this.send(JSON.stringify([type, event]))
214
+ }, error => this.#settleEvent(map, event.id, error))
215
+ }
206
216
  return deferred.promise
207
217
  }
208
218
 
@@ -225,10 +235,13 @@ export class RelayConnection {
225
235
  if (!pending) return
226
236
  map.delete(id)
227
237
  pending.cancelSend?.()
228
- pending.stopAbort?.()
229
238
  clearTimeout(pending.timer)
230
- if (reason) pending.reject(errorFrom(reason, 'OPERATION_REJECTED'))
231
- else pending.resolve(value)
239
+ for (const waiter of pending.waiters) {
240
+ waiter.stopAbort()
241
+ if (reason) waiter.reject(errorFrom(reason, 'OPERATION_REJECTED'))
242
+ else waiter.resolve(value)
243
+ }
244
+ pending.waiters.clear()
232
245
  }
233
246
 
234
247
  #settleCount (id, payload, reason) {
@@ -1204,8 +1204,10 @@ export class RelayPool {
1204
1204
  timeout = SEND_TIMEOUT_MS,
1205
1205
  timeoutUntilFirstFulfillment = null,
1206
1206
  getAuthEvent,
1207
- onRelayResult
1207
+ onRelayResult,
1208
+ signal
1208
1209
  } = {}) {
1210
+ if (signal != null && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function')) throw new ValidationError('INVALID_RELAY_SIGNAL')
1209
1211
  const urls = normalizedRelayUrls(relays)
1210
1212
  if (!urls.length) {
1211
1213
  const promise = Promise.resolve(publishSummary([], urls, {
@@ -1236,7 +1238,11 @@ export class RelayPool {
1236
1238
  // Resolves after every relay settles (or reaches the operation timeout) as
1237
1239
  // { success, total, fulfilled, succeededRelays, errors },
1238
1240
  // where errors contains { relay, reason } entries for failed relays.
1241
+ const cancel = () => settlement.cancel(signal.reason)
1242
+ if (signal?.aborted) cancel()
1243
+ else signal?.addEventListener('abort', cancel, { once: true })
1239
1244
  const promise = settlement.promise
1245
+ .finally(() => signal?.removeEventListener('abort', cancel))
1240
1246
  .then(settlements => publishSummary(settlements, urls, {
1241
1247
  includeSucceededRelays: true
1242
1248
  }))
@@ -1245,10 +1251,11 @@ export class RelayPool {
1245
1251
  const deferred = sendDeferreds[index]
1246
1252
  ;(async () => {
1247
1253
  try {
1254
+ const operationSignal = sendControllers[index].signal
1255
+ if (operationSignal.aborted) throw operationSignal.reason
1248
1256
  const relay = await this.#getRelay(url)
1249
- const signal = sendControllers[index].signal
1250
- if (signal.aborted) throw signal.reason
1251
- return await this.#publishEvent(relay, eventToSend, getAuthEvent, signal)
1257
+ if (operationSignal.aborted) throw operationSignal.reason
1258
+ return await this.#publishEvent(relay, eventToSend, getAuthEvent, operationSignal)
1252
1259
  } catch (err) {
1253
1260
  const reason = err instanceof Error ? err : new Error(String(err))
1254
1261
  if (reason instanceof Nip42AuthenticationError) throw reason