libp2r2p 0.11.23 → 0.11.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.23",
3
+ "version": "0.11.24",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -72,7 +72,7 @@ The coordinator resolves the peer's channel and queues the request until the
72
72
  channel exists. Priorities expire after `priorityTtlMs` (10 minutes) or once
73
73
  covered, and are dropped with recovery state. They are process-local and reset
74
74
  on reload.
75
- Callbacks are `onOutbox`, `onMedia`,
75
+ Callbacks are optional `onDebug`, `onOutbox`, `onMedia`,
76
76
  `onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
77
77
  account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
78
78
 
@@ -226,3 +226,26 @@ closing shared sockets or cancelling another subscriber for the same event ID.
226
226
  Operations already transmitted cannot be recalled from relays. A messenger pause
227
227
  cancels its outstanding publication work and leaves the outbox pending; resume
228
228
  creates a fresh publication lifetime, without treating the pause as a user abort.
229
+
230
+ ## Independent local persistence and publication
231
+
232
+ One encrypted outbox feeds two independent serial processors. Local persistence
233
+ never waits for peer preparation or an in-flight publication. Remote entries
234
+ become publishable only after their local progress checkpoint commits; failed
235
+ checkpoints are retried even if the copies have already committed. Text may
236
+ continue passing uploads between chunks after its local stage is ready.
237
+
238
+ Self entries emit `status: 'saved'` as soon as local persistence succeeds. They
239
+ do not write a redundant localSaved checkpoint before removal. Remote entries
240
+ emit that status after the first valid relay ACK for every required publication,
241
+ without waiting for redundant relays. Outbox cleanup runs independently; failures
242
+ retain saved status and retry only removal with the session backoff, never
243
+ `onSendError`. Consumers must accept saved entries while removal is pending.
244
+ A crash before removal replays the original event identity into idempotent storage.
245
+ Account shutdown fences late admissions and waits for both processors and cleanup.
246
+
247
+ `onDebug(record)` is disabled by default. Records carry fixed stage names, public
248
+ event/peer identifiers and elapsed milliseconds for admission, local processing,
249
+ channel preparation, publication acceptance and cleanup. Callback failures never
250
+ change delivery. Connect only in development; never log events or crypto buffers.
251
+ Custom outbox factories also receive this optional callback for storage timings.
@@ -44,7 +44,7 @@ function messengerStatusSnapshot (value) {
44
44
  return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
45
45
  }
46
46
 
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, pendingDecryptionReady = 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' }) }) {
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, pendingDecryptionReady = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, onDebug, 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' }) }) {
48
48
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
49
49
  fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
50
50
  if (typeof pendingDecryptionReady !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
@@ -66,6 +66,14 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
66
66
  const entries = new Map()
67
67
  const cancelled = new Set()
68
68
  const sendControllers = new Map()
69
+ const localReady = new Set()
70
+ const localIdleWaiters = new Set()
71
+ const cleanups = new Map()
72
+ const queuedAt = new WeakMap()
73
+ const debug = (entry, stage, startedAt) => {
74
+ if (!onDebug) return
75
+ try { onDebug({ stage, eventId: entry.id, peer: entry.peer, at: Date.now(), ...(startedAt != null ? { elapsedMs: Date.now() - startedAt } : {}) }) } catch {}
76
+ }
69
77
  const offlineSends = new Set()
70
78
  const pausedSends = new Set()
71
79
  let messengerStatus
@@ -123,15 +131,22 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
123
131
  let messengerWork
124
132
  let fileSetupWork
125
133
  let sending
134
+ let enqueuing = 0
135
+ let localSaving = false
136
+ let relocal = false
126
137
  let repump
127
138
  let draining
128
139
  let redrain = false
129
140
  const idleWaiters = new Set()
130
- const settled = () => { if (!sending && !draining) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
141
+ const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
131
142
  const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
132
143
  const ready = () => (initialized ??= (async () => {
133
- storage = await openOutbox({ owner, signer })
134
- for (const entry of await storage.list()) entries.set(entry.id, entry)
144
+ storage = await openOutbox({ owner, signer, onDebug })
145
+ for (const entry of await storage.list()) {
146
+ entries.set(entry.id, entry)
147
+ if (entry.localSaved.every(Boolean)) localReady.add(entry.id)
148
+ queuedAt.set(entry, Date.now())
149
+ }
135
150
  emit()
136
151
  })().catch(async error => { await storage?.close(); storage = null; initialized = null; throw error }))
137
152
  async function createMessenger () {
@@ -167,6 +182,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
167
182
 
168
183
  const preparation = createPeerPreparation({
169
184
  prepare: async (peer, signal) => {
185
+ const startedAt = Date.now()
170
186
  const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
171
187
  const pubkey = await scoped.getPublicKey()
172
188
  signal.throwIfAborted()
@@ -175,6 +191,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
175
191
  if (peer === pendingDecryptionPeer) messenger?.setPendingDecryptionPriority?.(channel.pubkey)
176
192
  await schedule()
177
193
  signal.throwIfAborted()
194
+ if (onDebug) { try { onDebug({ stage: 'channel-ready', peer, elapsedMs: Date.now() - startedAt }) } catch {} }
178
195
  return channel
179
196
  },
180
197
  onReady: () => { drain(); pump() }, onError: reportError,
@@ -365,7 +382,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
365
382
  // Published is relay acceptance, never a peer receipt.
366
383
  await assertMessagePublished(report, event)
367
384
  }
368
- async function sendEntry (entry) {
385
+ async function cleanupEntry (entry) {
386
+ if (cleanups.has(entry.id)) return cleanups.get(entry.id)
387
+ const startedAt = Date.now()
388
+ const work = Promise.resolve().then(() => storage.remove(entry.id)).then(() => {
389
+ entries.delete(entry.id); localReady.delete(entry.id)
390
+ debug(entry, 'outbox-cleanup', startedAt)
391
+ emit()
392
+ }).catch(error => {
393
+ // Delivery has already succeeded. Cleanup must never become a send error.
394
+ entry.failed = true; entry.retryable = true
395
+ reportError(error)
396
+ if (!closed && available) scheduleSendRetry()
397
+ }).finally(() => { cleanups.delete(entry.id); if (![...entries.values()].some(pendingRetryable) && !cleanups.size) resetSendRetry(); settled() })
398
+ cleanups.set(entry.id, work)
399
+ return work
400
+ }
401
+ async function sendEntry (entry, localOnly = false) {
369
402
  const releaseForeground = foreground.enter()
370
403
  entry.status = 'pending'; emit()
371
404
  const controller = new AbortController()
@@ -375,19 +408,32 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
375
408
  if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
376
409
  if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
377
410
  }
378
- // Personal copies must commit even if publishing attachment bytes
379
- // fails offline. Remote progress remains independently retryable.
380
- for (let index = 0; index < entry.events.length; index++) {
381
- await active()
382
- const event = entry.events[index]
383
- if (!entry.localSaved[index]) {
411
+ if (localOnly) {
412
+ debug(entry, 'local-start', queuedAt.get(entry))
413
+ const startedAt = Date.now()
414
+ for (let index = 0; index < entry.events.length; index++) {
415
+ await active()
416
+ if (entry.localSaved[index]) continue
417
+ const event = entry.events[index]
384
418
  const hearsay = !event.sig && event.pubkey !== owner
385
419
  const result = await messageStorage.save(event, { peerPubkey: entry.peer, hearsay })
386
420
  if (result?.result?.code === 'blocked') { cancelled.add(entry.id); await active() }
387
421
  if (!result?.result?.ok) throw Object.assign(new Error('MESSAGE_STORAGE_FAILED'), { code: result?.result?.code?.toUpperCase() })
388
422
  entry.localSaved[index] = true
423
+ }
424
+ debug(entry, 'local-stored', startedAt)
425
+ await active()
426
+ if (entry.peer === owner) {
427
+ entry.status = 'saved'; emit()
428
+ cleanupEntry(entry)
429
+ } else {
430
+ // Retry this checkpoint even when a preceding attempt saved all copies
431
+ // but failed to persist progress. Only then admit remote publication.
389
432
  await storage.put(entry, { existing: true })
433
+ await active()
434
+ localReady.add(entry.id)
390
435
  }
436
+ return
391
437
  }
392
438
  if (entry.peer !== owner && !preparation.get(entry.peer)) {
393
439
  if (preparation.error(entry.peer)) throw preparation.error(entry.peer)
@@ -414,11 +460,17 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
414
460
  emit()
415
461
  await active()
416
462
  await storage.put({ ...entry, chunkIndex: entry.chunkIndex + 1 }, { existing: true })
463
+ // Let the independent local stage finish newly admitted texts before
464
+ // selecting publications at this chunk boundary. It never awaits us.
465
+ if ([...entries.values()].some(pending => !pending.files.length && pending.peer !== owner && !pending.failed && !localReady.has(pending.id))) {
466
+ await pumpLocal()
467
+ if (localSaving) await new Promise(resolve => localIdleWaiters.add(resolve))
468
+ }
417
469
  // Newly queued text and deletion controls can pass an in-flight file.
418
470
  let admitted = 0
419
471
  for (const pending of entries.values()) {
420
472
  if (closed || !available || admitted >= 8) break
421
- if (pending.files.length || pending.failed || cancelled.has(pending.id) || (pending.peer !== owner && !peers.has(pending.peer))) continue
473
+ if (!localReady.has(pending.id) || pending.peer === owner || pending.status === 'saved' || pending.files.length || pending.failed || cancelled.has(pending.id) || (pending.peer !== owner && !peers.has(pending.peer))) continue
422
474
  admitted++
423
475
  await sendEntry(pending)
424
476
  }
@@ -427,15 +479,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
427
479
  for (; entry.index < entry.events.length; entry.index++) {
428
480
  await active()
429
481
  const event = entry.events[entry.index]
430
- if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
482
+ const startedAt = Date.now()
483
+ await publish(entry.peer, event, controller.signal)
484
+ debug(entry, 'publication-accepted', startedAt)
431
485
  await active()
432
486
  // The final durable operation is removal. Avoid encrypting a checkpoint
433
487
  // that would immediately be deleted; earlier context stages still checkpoint.
434
488
  if (entry.index + 1 < entry.events.length) await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
435
489
  }
436
- await storage.remove(entry.id)
437
- entries.delete(entry.id)
438
- emit()
490
+ entry.status = 'saved'; emit()
491
+ cleanupEntry(entry)
439
492
  } catch (cause) {
440
493
  // Pauses can surface at any stage (text publication, file authorization
441
494
  // or chunk delivery). Normalize all of them into the retryable path.
@@ -483,6 +536,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
483
536
  for (const entry of entries.values()) {
484
537
  if (!pendingRetryable(entry) || pausedSends.has(entry.id)) continue
485
538
  entry.failed = false
539
+ if (entry.status === 'saved') { cleanupEntry(entry); continue }
486
540
  retried = true
487
541
  }
488
542
  if (retried) pump()
@@ -505,10 +559,26 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
505
559
  return schedule()
506
560
  })
507
561
  }
508
- async function pump () {
562
+ async function pumpLocal () {
509
563
  if (!available || closed || !storage) return
510
- // A retry that lands while a pump is already running must not be skipped
511
- // just because that pass already walked past the entry.
564
+ if (localSaving) { relocal = true; return }
565
+ localSaving = true
566
+ try {
567
+ do {
568
+ relocal = false
569
+ for (const entry of entries.values()) {
570
+ if (closed || !available) break
571
+ if (entry.failed || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) continue
572
+ if (entry.status === 'saved') { cleanupEntry(entry); continue }
573
+ if (localReady.has(entry.id) && entry.peer !== owner) continue
574
+ await sendEntry(entry, true)
575
+ pumpRemote()
576
+ }
577
+ } while (relocal)
578
+ } finally { localSaving = false; for (const resolve of localIdleWaiters) resolve(); localIdleWaiters.clear(); settled() }
579
+ }
580
+ async function pumpRemote () {
581
+ if (!available || closed || !storage || !messenger) return
512
582
  if (sending) { repump = true; return }
513
583
  sending = true
514
584
  try {
@@ -516,22 +586,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
516
586
  repump = false
517
587
  for (const entry of entries.values()) {
518
588
  if (closed || !available) break
519
- if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
520
- if (entry.peer !== owner && entry.localSaved.every(Boolean) && !preparation.get(entry.peer) && !preparation.error(entry.peer)) {
589
+ if (entry.peer === owner || entry.status === 'saved' || !localReady.has(entry.id) || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
590
+ if (!preparation.get(entry.peer) && !preparation.error(entry.peer)) {
521
591
  preparation.request(entry.peer).catch(() => {})
522
592
  continue
523
593
  }
524
- if (entry.peer !== owner && entry.files.length && !fileTransfers) continue
594
+ if (entry.files.length && !fileTransfers) continue
525
595
  await sendEntry(entry)
526
596
  }
527
597
  } while (repump)
528
598
  } finally {
529
599
  sending = false
530
600
  watchOfflineSends()
531
- if (![...entries.values()].some(pendingRetryable)) resetSendRetry()
601
+ if (![...entries.values()].some(pendingRetryable) && !cleanups.size) resetSendRetry()
532
602
  settled()
533
603
  }
534
604
  }
605
+ async function pump () { await Promise.all([pumpLocal(), pumpRemote()]) }
535
606
  const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
536
607
  async function download (file, options = {}) {
537
608
  if (file.peer === owner) return file
@@ -648,6 +719,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
648
719
  inboxRecovery.stop(); await messenger?.pause('signer'); return
649
720
  }
650
721
  await ready()
722
+ pumpLocal()
651
723
  for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
652
724
  if (storageBlocked) inboxRecovery.start({ immediate: true })
653
725
  if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
@@ -656,6 +728,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
656
728
  async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
657
729
  if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
658
730
  const releaseForeground = foreground.enter()
731
+ enqueuing++
659
732
  try {
660
733
  await ready()
661
734
  const main = wireEvent(event, owner)
@@ -663,11 +736,19 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
663
736
  const events = [...context.map(value => wireEvent(value, owner)), main]
664
737
  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 }] : [])])
665
738
  const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
739
+ const startedAt = Date.now()
740
+ debug(entry, 'outbox-start')
666
741
  await storage.put(entry)
742
+ debug(entry, 'outbox-stored', startedAt)
743
+ queuedAt.set(entry, Date.now())
744
+ if (cancelled.has(id)) { await storage.remove(id); throw new Error('CHAT_UNAVAILABLE') }
745
+ // An accepted durable intent survives shutdown/locking for replay. Only
746
+ // explicit cancellation removes it; no late callback or work is admitted.
747
+ if (closed) return id
667
748
  entries.set(id, entry); emit(); pump()
668
- if (peer !== owner) preparePeer(peer).catch(() => {})
749
+ if (available && peer !== owner) preparePeer(peer).catch(() => {})
669
750
  return id
670
- } finally { releaseForeground() }
751
+ } finally { releaseForeground(); enqueuing--; settled() }
671
752
  },
672
753
  async retry (id) {
673
754
  offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
@@ -681,7 +762,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
681
762
  sendControllers.get(id)?.abort()
682
763
  offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
683
764
  await ready()
684
- entries.delete(id)
765
+ entries.delete(id); localReady.delete(id)
685
766
  await storage.remove(id)
686
767
  emit()
687
768
  },
@@ -693,7 +774,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
693
774
  for (const controller of sendControllers.values()) controller.abort()
694
775
  await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
695
776
  await messenger?.pause('closed')
696
- if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
777
+ if (sending || localSaving || enqueuing || draining || cleanups.size) await new Promise(resolve => idleWaiters.add(resolve))
697
778
  await messenger?.close()
698
779
  await storage?.close()
699
780
  await downloadWriteTail.catch(reportError)