libp2r2p 0.11.18 → 0.11.20

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,29 @@
1
+ // Local outbox persistence and user publications take precedence over the next
2
+ // optional presence signer call. Already-started signer calls are not recalled.
3
+ export function createForegroundWork () {
4
+ let active = 0
5
+ const waiters = new Set()
6
+ const lifetime = new AbortController()
7
+ return {
8
+ enter () {
9
+ active++
10
+ let released = false
11
+ return () => {
12
+ if (released) return
13
+ released = true
14
+ if (--active === 0) for (const wake of [...waiters]) wake()
15
+ }
16
+ },
17
+ wait (signal) {
18
+ const combined = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal
19
+ combined.throwIfAborted()
20
+ if (!active) return Promise.resolve()
21
+ return new Promise((resolve, reject) => {
22
+ const finish = () => { waiters.delete(finish); combined.removeEventListener('abort', finish); combined.aborted ? reject(combined.reason) : resolve() }
23
+ waiters.add(finish)
24
+ combined.addEventListener('abort', finish, { once: true })
25
+ })
26
+ },
27
+ close () { lifetime.abort(); waiters.clear() }
28
+ }
29
+ }
@@ -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,7 +4,10 @@ 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'
@@ -41,7 +44,7 @@ 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 = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
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)
46
49
  fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
47
50
  const reportedErrors = new WeakSet()
@@ -53,11 +56,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
53
56
  onError(error)
54
57
  }
55
58
  const userSigner = messengerSigner(signer)
59
+ const foreground = createForegroundWork()
56
60
  messageStorage ||= createEventStoreMessageStorage({ eventStore })
57
61
  chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
58
62
  const peers = new Set()
59
63
  const channels = new Map()
60
- const deniedPeers = new Set()
61
64
  const entries = new Map()
62
65
  const cancelled = new Set()
63
66
  const sendControllers = new Map()
@@ -113,6 +116,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
113
116
  let lifecycle = 0
114
117
  let configuring = Promise.resolve()
115
118
  let configurationFailed = false
119
+ let configureQueued = false
120
+ let configureWork
121
+ let messengerWork
122
+ let fileSetupWork
116
123
  let sending
117
124
  let repump
118
125
  let draining
@@ -132,7 +139,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
132
139
  let candidate
133
140
  try {
134
141
  candidate = await Messenger({
135
- fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
142
+ fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait,
136
143
  onMessageQueued: () => { if (messengerBinding === binding) return drain() },
137
144
  onStateChanged: state => {
138
145
  if (closed || messengerBinding !== binding) return
@@ -154,28 +161,41 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
154
161
  }
155
162
  }
156
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
+ }
157
187
  async function configure () {
158
188
  if (closed || !available) return
159
- await ready()
160
189
  const version = lifecycle
161
- const values = []
162
- for (const peer of peers) {
163
- if (deniedPeers.has(peer)) continue
164
- let channel = channels.get(peer)
165
- if (!channel) {
166
- try {
167
- const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
168
- channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
169
- channels.set(peer, channel)
170
- } catch (error) { if (!retryable(error)) deniedPeers.add(peer); reportError(error); continue }
171
- }
172
- if (closed || !available || version !== lifecycle) return
173
- values.push(channel)
174
- }
175
- if (!messenger) await createMessenger()
190
+ await ready()
191
+ if (closed || !available || version !== lifecycle) return
192
+ await ensureMessenger()
176
193
  if (!messenger) return
177
- 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)
178
197
  await messenger.update({ channels: values })
198
+ if (closed || !available || version !== lifecycle) return
179
199
  if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
180
200
  for (const [peer, list] of [...pendingPriorityRanges]) {
181
201
  const channel = channels.get(peer)
@@ -187,7 +207,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
187
207
  }
188
208
  }
189
209
  await messenger.resume('signer')
210
+ if (closed || !available || version !== lifecycle) return
211
+ preparation.setAvailable(true)
190
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 () {
191
220
  if (!fileTransfers) {
192
221
  fileTransfers = FileTransfer({
193
222
  messenger, onError: reportError,
@@ -206,9 +235,17 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
206
235
  }
207
236
  }
208
237
  })
209
- downloads = await openDownloads({ owner, signer })
210
- for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
211
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 () {
212
249
  for (const entry of downloadIntents.values()) {
213
250
  if (!peers.has(entry.file.peer) || activeDownloads.has(entry.id)) continue
214
251
  const work = download(entry.file, { manual: true })
@@ -220,12 +257,37 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
220
257
  .finally(() => { if (activeDownloads.get(entry.id) === work) activeDownloads.delete(entry.id) })
221
258
  activeDownloads.set(entry.id, work)
222
259
  }
223
- drain(); pump()
260
+ pump()
224
261
  }
225
262
  function schedule () {
226
- const work = configuring.catch(() => {}).then(configure)
227
- configuring = work
228
- 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()
229
291
  return work
230
292
  }
231
293
  async function drain () {
@@ -285,7 +347,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
285
347
  }
286
348
  }
287
349
  async function publish (peer, event, signal) {
288
- if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
350
+ if (preparation.error(peer)) throw preparation.error(peer)
289
351
  if (!available || !peers.has(peer) || !channels.has(peer)) throw unavailableError(new Error('CHAT_UNAVAILABLE'), signal)
290
352
  const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
291
353
  let report
@@ -299,6 +361,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
299
361
  await assertMessagePublished(report, event)
300
362
  }
301
363
  async function sendEntry (entry) {
364
+ const releaseForeground = foreground.enter()
302
365
  entry.status = 'pending'; emit()
303
366
  const controller = new AbortController()
304
367
  sendControllers.set(entry.id, controller)
@@ -321,6 +384,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
321
384
  await storage.put(entry, { existing: true })
322
385
  }
323
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
+ }
324
392
  if (entry.peer !== owner && messengerStatus?.paused) {
325
393
  throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
326
394
  }
@@ -356,7 +424,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
356
424
  const event = entry.events[entry.index]
357
425
  if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
358
426
  await active()
359
- 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 })
360
430
  }
361
431
  await storage.remove(entry.id)
362
432
  entries.delete(entry.id)
@@ -368,16 +438,22 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
368
438
  if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
369
439
  entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
370
440
  await storage.put(entry, { existing: true }).catch(reportError)
371
- 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
+ }
372
448
  reportError(error)
373
449
  // The failed wire event may be a quote or file chunk. Report the
374
450
  // owning outbox item separately, without mutating the native error.
375
451
  if (error.retryWhenOnline) offlineSends.add(entry.id)
376
- if (error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
452
+ if (entry.failed && error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
377
453
  if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
378
454
  }
379
455
  emit()
380
- } finally { sendControllers.delete(entry.id) }
456
+ } finally { sendControllers.delete(entry.id); releaseForeground() }
381
457
  }
382
458
  function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
383
459
  // Transient availability failures without an active messenger pause retain
@@ -425,7 +501,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
425
501
  })
426
502
  }
427
503
  async function pump () {
428
- if (!available || closed || !messenger || !storage) return
504
+ if (!available || closed || !storage) return
429
505
  // A retry that lands while a pump is already running must not be skipped
430
506
  // just because that pass already walked past the entry.
431
507
  if (sending) { repump = true; return }
@@ -436,6 +512,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
436
512
  for (const entry of entries.values()) {
437
513
  if (closed || !available) break
438
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
+ }
439
519
  if (entry.peer !== owner && entry.files.length && !fileTransfers) continue
440
520
  await sendEntry(entry)
441
521
  }
@@ -450,7 +530,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
450
530
  const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
451
531
  async function download (file, options = {}) {
452
532
  if (file.peer === owner) return file
453
- 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')
454
537
  const id = intentId(file)
455
538
  if (options.manual) {
456
539
  const epoch = downloadEpochs.get(id) || 0
@@ -475,7 +558,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
475
558
  await writeDownloadIntent(() => downloads?.remove(id))
476
559
  }
477
560
  return {
478
- download, cancelDownload,
561
+ download, cancelDownload, preparePeer,
479
562
  // Peers-based so callers can prefetch as soon as a chat route opens, before
480
563
  // the shared-key channel exists. When group chats get real membership, add
481
564
  // a thin prefetchChannelContentKeys(channelPubkey) wrapper that resolves
@@ -503,20 +586,28 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
503
586
  if (type === 'unread-page' && since === undefined) throw new TypeError('PRIORITY_SINCE_REQUIRED')
504
587
  const request = { since, until, type }
505
588
  const channel = channels.get(peer)
506
- if (channel && typeof messenger?.prioritizeRange === 'function') {
589
+ if (channel && preparation.get(peer) && !pendingPriorityRanges.has(peer) && typeof messenger?.prioritizeRange === 'function') {
507
590
  try { return await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error); return false }
508
591
  }
509
592
  const list = pendingPriorityRanges.get(peer) || []
510
593
  list.push(request)
511
594
  pendingPriorityRanges.set(peer, list)
595
+ if (channel && available) schedule().catch(() => {})
512
596
  return true
513
597
  },
514
598
  async setPeers (values) {
515
599
  const next = new Set([...values].filter(peer => peer !== owner))
516
600
  if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
517
601
  peers.clear(); for (const peer of next) peers.add(peer)
518
- for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
519
- 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
+ }
520
611
  return schedule()
521
612
  },
522
613
  async setAvailable (value) {
@@ -527,31 +618,39 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
527
618
  if (available) resetSendRetry()
528
619
  else clearSendRetry()
529
620
  if (changed) lifecycle++
530
- 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
+ }
531
626
  await ready()
532
627
  for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
533
628
  if (storageBlocked) inboxRecovery.start({ immediate: true })
534
- if (!changed && messenger && !configurationFailed) return pump()
629
+ if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
535
630
  return schedule()
536
631
  },
537
632
  async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
538
633
  if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
539
- await ready()
540
- const main = wireEvent(event, owner)
541
- const id = getEventHash(main)
542
- const events = [...context.map(value => wireEvent(value, owner)), main]
543
- 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 }] : [])])
544
- const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
545
- await storage.put(entry)
546
- entries.set(id, entry); emit(); pump()
547
- 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() }
548
647
  },
549
648
  async retry (id) {
550
649
  offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
551
650
  if (storageBlocked) inboxRecovery.start({ immediate: true })
552
651
  await ready()
553
652
  const entry = entries.get(id)
554
- 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() }
555
654
  },
556
655
  async cancel (id) {
557
656
  cancelled.add(id)
@@ -566,9 +665,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
566
665
  return (closeWork ??= (async () => {
567
666
  closed = true; available = false; lifecycle++
568
667
  stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
569
- clearSendRetry(); inboxRecovery.stop(); pausedSends.clear()
668
+ clearSendRetry(); inboxRecovery.stop(); preparation.close(); foreground.close(); pausedSends.clear()
570
669
  for (const controller of sendControllers.values()) controller.abort()
571
- await Promise.allSettled([configuring, initialized])
670
+ await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
572
671
  await messenger?.pause('closed')
573
672
  if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
574
673
  await messenger?.close()
@@ -33,8 +33,8 @@ export function relayCloseError (event, category, cause) {
33
33
 
34
34
  // Keep native diagnostics and attach only bounded timing advice. An absolute
35
35
  // deadline survives delayed delivery through a launcher-owned relay bridge.
36
- export function relayRejectionError (reason, extra, fallback) {
36
+ export function relayRejectionError (reason, params, fallback) {
37
37
  const error = categorizeRelayError(reason, 'relay', fallback)
38
- Object.assign(error, parseRelayRetryAdvice(error.message, extra))
38
+ Object.assign(error, parseRelayRetryAdvice(error.message, params))
39
39
  return error
40
40
  }
@@ -1,18 +1,13 @@
1
- // Nostr transport extensions, in seconds. Timing advice is bounded and never
1
+ // Nostr params use relative milliseconds or @Unix milliseconds. Advice never
2
2
  // supplies an error category, provenance or a routing/retry policy.
3
- export function parseRelayRetryAdvice (reason, extra, { now = Date.now() } = {}) {
3
+ export function parseRelayRetryAdvice (reason, params, { now = Date.now() } = {}) {
4
4
  if (typeof reason !== 'string' || !reason.startsWith('rate-limited:') ||
5
- !extra || typeof extra !== 'object' || Array.isArray(extra) || !Number.isFinite(now)) return null
5
+ typeof params !== 'string' || params.match(/^@?[1-9]\d*$/)?.[0] !== params || !Number.isFinite(now)) return null
6
6
 
7
- const after = extra.retry_after
8
- const retryAfterMs = typeof after === 'number' && Number.isFinite(after) && after > 0
9
- ? Math.min(after, 300) * 1000
10
- : undefined
11
- const at = extra.retry_at
12
- const absolute = typeof at === 'number' && Number.isFinite(at) && at > 0 && Number.isFinite(at * 1000)
13
- ? Math.min(at * 1000, now + 300000)
14
- : undefined
15
- const retryAt = absolute ?? (retryAfterMs === undefined ? undefined : now + retryAfterMs)
16
- if (retryAt === undefined) return null
17
- return { ...(retryAfterMs === undefined ? {} : { retryAfterMs }), retryAt }
7
+ const absolute = params.startsWith('@')
8
+ const value = Number(absolute ? params.slice(1) : params)
9
+ if (!Number.isSafeInteger(value)) return null
10
+ if (absolute) return { retryAt: Math.min(value, now + 300000) }
11
+ const retryAfterMs = Math.min(value, 300000)
12
+ return { retryAfterMs, retryAt: now + retryAfterMs }
18
13
  }