libp2r2p 0.11.24 → 0.11.26

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.
@@ -233,6 +233,7 @@ export class PrivateMessenger {
233
233
  messageQueueMaxBytes = DEFAULT_MESSAGE_QUEUE_MAX_BYTES,
234
234
  seedQueueMaxBytes = DEFAULT_SEED_QUEUE_MAX_BYTES,
235
235
  seedStorage,
236
+ _pendingSeedRecords = () => [],
236
237
  temporaryStorageArea = globalThis.sessionStorage,
237
238
  autoDeletionCapability = true,
238
239
  _indexedDB = globalThis.indexedDB,
@@ -290,6 +291,7 @@ export class PrivateMessenger {
290
291
  this.messageQueueMaxBytes = messageQueueMaxBytes
291
292
  this.seedQueueMaxBytes = seedQueueMaxBytes
292
293
  this.seedStorage = seedStorage
294
+ this._pendingSeedRecords = _pendingSeedRecords
293
295
  this.temporaryStorageArea = temporaryStorageArea
294
296
  this.autoDeletionCapability = normalizeAutoDeletionCapability(autoDeletionCapability)
295
297
  this._indexedDB = _indexedDB
@@ -887,7 +889,8 @@ export class PrivateMessenger {
887
889
  return uniq([...primary, ...this.fallbackRelays].map(normalizeRelayUrl))
888
890
  }
889
891
 
890
- async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal }) {
892
+ async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug }) {
893
+ if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
891
894
  const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
892
895
  if (relayToReceivers && !this.fallbackRelays.length) return { relayToReceivers, recoveryRelays }
893
896
  const fixed = relayToReceivers ? null : relays?.length ? uniq(relays) : channel.sendRelays.length ? channel.sendRelays : channel.relays.length ? channel.relays : null
@@ -903,12 +906,17 @@ export class PrivateMessenger {
903
906
  peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
904
907
  signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
905
908
  exclusions: this.sendRelayExclusions.get(key).exclusions,
906
- fallbackDelayMs: this.fallbackDelayMs, pauseSignal: this.sendRoutingPause.signal,
909
+ fallbackDelayMs: this.fallbackDelayMs, attemptStartedAt, pauseSignal: this.sendRoutingPause.signal,
907
910
  setTimer: this._setTimeout, clearTimer: this._clearTimeout,
908
911
  pickRelays: this._pickRelaysForPubkeys,
909
912
  publish: this._privateChannel.publish || privateChannel.publish,
910
913
  publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
911
- sendEvent: (...args) => relayPool.sendEvent(...args),
914
+ sendEvent: async (...args) => {
915
+ const startedAt = onDebug ? performance.now() : 0
916
+ const result = await relayPool.sendEvent(...args)
917
+ if (result.success && onDebug) { try { onDebug({ stage: 'first-ack', publicationId: args[0].id, elapsedMs: performance.now() - startedAt }) } catch {} }
918
+ return result
919
+ },
912
920
  isOnline: this._isOnline,
913
921
  isCurrent: () => !this.closePromise && !this.pauseReasons.size && this.channels.has(channel.pubkey)
914
922
  })
@@ -2401,11 +2409,11 @@ export class PrivateMessenger {
2401
2409
  } finally { releaseActivity() }
2402
2410
  }
2403
2411
 
2404
- async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal }) {
2412
+ async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug }) {
2405
2413
  const channel = this.requireWritableChannel(channelPubkey)
2406
2414
  const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2407
2415
  try {
2408
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
2416
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
2409
2417
  this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
2410
2418
  return await this._privateMessage.broadcastRumor({
2411
2419
  senderSigner: this.userSigner,
@@ -2419,17 +2427,18 @@ export class PrivateMessenger {
2419
2427
  deletionPubkey,
2420
2428
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2421
2429
  rumor,
2422
- onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2430
+ onPreparedSeed: onPreparedSeed || this.outgoingSeedHandler(channelPubkey),
2431
+ preparedPublication,
2423
2432
  _getIykcProofs: this.contentKeyLookup()
2424
2433
  })
2425
2434
  } finally { releaseActivity() }
2426
2435
  }
2427
2436
 
2428
- async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal }) {
2437
+ async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug }) {
2429
2438
  const channel = this.requireWritableChannel(channelPubkey)
2430
2439
  const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2431
2440
  try {
2432
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
2441
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
2433
2442
  this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
2434
2443
  return await this._privateMessage.broadcastEvent({
2435
2444
  senderSigner: this.userSigner,
@@ -2443,7 +2452,8 @@ export class PrivateMessenger {
2443
2452
  deletionPubkey,
2444
2453
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2445
2454
  event,
2446
- onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2455
+ onPreparedSeed: onPreparedSeed || this.outgoingSeedHandler(channelPubkey),
2456
+ preparedPublication,
2447
2457
  _getIykcProofs: this.contentKeyLookup()
2448
2458
  })
2449
2459
  } finally { releaseActivity() }
@@ -2782,8 +2792,17 @@ export class PrivateMessenger {
2782
2792
  })
2783
2793
 
2784
2794
  if (this.offlineRecoverySecondsFor(channelPubkey)) {
2795
+ const seen = new Set()
2796
+ const key = seed => seed.router ? routerSeedRowKey(seed) : nymCarrierSeedKey(seed)
2797
+ const pending = this._pendingSeedRecords({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until })
2798
+ for (const seed of pending) {
2799
+ if (seed.channelPubkey !== channelPubkey || (seed.receiverPubkey && seed.receiverPubkey !== message.event?.pubkey) || seed.expiresAt <= nowSeconds()) continue
2800
+ if (since !== undefined && (seed.lastSeenAt || seed.router.created_at) < since) continue
2801
+ if (until !== undefined && (seed.firstSeenAt || seed.router.created_at) > until) continue
2802
+ seen.add(key(seed)); await packer.update(seed)
2803
+ }
2785
2804
  for await (const seed of this.seedStorage ? this.seedStorage.iterate({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until }) : this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
2786
- if (seedRecordTime(seed) < nowSeconds() - this.offlineRecoverySecondsFor(channelPubkey)) continue
2805
+ if (seen.has(key(seed)) || seedRecordTime(seed) < nowSeconds() - this.offlineRecoverySecondsFor(channelPubkey)) continue
2787
2806
  if (this.seedStorage && !await this.seedStorage.has(seed)) continue
2788
2807
  await packer.update(seed)
2789
2808
  }
@@ -170,8 +170,11 @@ Optional `fallbackDelayMs` defaults to `null` (sequential primary exhaustion).
170
170
  A nonnegative integer up to 2147483647 opts automatic NIP-65 routing into early
171
171
  fallback; explicit relay lists/maps retain the sequential policy. With `3000`,
172
172
  start at most two eligible fallback relays after three seconds without complete
173
- acceptance, or immediately after primary exhaustion. The deadline belongs to
174
- one signed outer event; later batches do not renew it. Primary operations retain
173
+ acceptance, or immediately after primary exhaustion. Optional `enqueue({ ..., attemptStartedAt })`
174
+ uses `performance.now()` from the caller's realm to include preparation in the
175
+ budget. Without it, the deadline starts at publication. Later batches do not
176
+ renew it; retries/reloads start a new attempt and uploads budget each chunk.
177
+ Primary operations retain
175
178
  their deadlines and can still win. Failed fallback operations leave primary
176
179
  alternatives available; no relay is tried twice. Multiple recipients require
177
180
  accepted coverage of every encrypted recipient subset. No wrapper, ID, recipient
@@ -249,3 +252,34 @@ event/peer identifiers and elapsed milliseconds for admission, local processing,
249
252
  channel preparation, publication acceptance and cleanup. Callback failures never
250
253
  change delivery. Connect only in development; never log events or crypto buffers.
251
254
  Custom outbox factories also receive this optional callback for storage timings.
255
+
256
+ ## Durable prepared publications
257
+
258
+ Each remote entry may contain optional `prepared` progress in its existing
259
+ encrypted JSON. It holds the signed envelope fragments, compact recovery seeds,
260
+ recipient/channel and inner-event identities, event/chunk position, retention
261
+ and accepted/archived flags. Old entries without it retain `localSaved`, index
262
+ and file/chunk progress and enter the same preparation path; there is no database
263
+ migration or alternate publisher. Replays validate signatures and identity and
264
+ reuse the exact envelopes without repeating cryptography or extending expiry.
265
+
266
+ The existing `onPreparedSeed` callback now also supplies `publication`. The
267
+ session commits that minimal journal before transmitting; direct messengers
268
+ retain their local seed-store durability. Only after that commit can relay
269
+ publication proceed. One independent serial worker converts recovery seeds into
270
+ their configured archive. Pending durable rows participate in recovery reads
271
+ immediately, including during a blocked archive write. No new seeds are enabled
272
+ for modes which did not previously retain them.
273
+
274
+ First acceptance of every required envelope emits saved independently of archive
275
+ or cleanup. ACK progress checkpoints serialize with other entry writes. A crash
276
+ between ACK and its checkpoint may replay the same envelope; acknowledged durable
277
+ progress only archives. Archive failure never becomes `onSendError` or changes
278
+ saved status. Removal waits for archival and outstanding entry writes.
279
+
280
+ At most one unit awaits archival per entry; a stalled upload yields its remote
281
+ slot to other texts rather than accumulating the entire file. Cancel stops wire
282
+ work but retains potentially transmitted seeds until archival finishes. Close
283
+ drains active local operations and leaves durable backoff work for restart.
284
+ Diagnostics add publication/seed IDs, checkpoint time, archive wait/execution and
285
+ first-ACK timing, without message or ciphertext contents.
@@ -0,0 +1,137 @@
1
+ import { compactSeedRouterRows } from '../../index.js'
2
+ import { encodeRecord } from '../../event-store/helpers/codec.js'
3
+ import { isRecoverableStorageFailure } from '../../helpers/pause-recovery.js'
4
+
5
+ export function createPublicationJournal ({ entries, store, archive, changed, onError, debug, setTimer = setTimeout, clearTimer = clearTimeout, random = Math.random }) {
6
+ const writes = new Map()
7
+ const failed = new Set()
8
+ const durable = new WeakSet()
9
+ const queued = new WeakMap()
10
+ let working; let stopped = false; let enabled = false; let timer; let delay = 1000
11
+ function write (entry) {
12
+ const previous = writes.get(entry.id) || Promise.resolve()
13
+ const work = previous.catch(() => {}).then(() => store().put(structuredClone(entry), { existing: true }))
14
+ writes.set(entry.id, work)
15
+ work.finally(() => { if (writes.get(entry.id) === work) writes.delete(entry.id) }).catch(() => {})
16
+ return work
17
+ }
18
+ function rows (entry) {
19
+ const packet = entry.prepared
20
+ if (!packet?.recover) return []
21
+ return packet.publication.seeds.flatMap(seed => compactSeedRouterRows(seed).map(row => {
22
+ const descriptor = packet.target.file
23
+ const value = {
24
+ ...row, channelPubkey: seed.channelPubkey, receivedAt: packet.receivedAt,
25
+ expiresAt: row.router.created_at + packet.retentionSeconds,
26
+ ...(descriptor
27
+ ? {
28
+ root: descriptor.root, controlChannelPubkey: descriptor.controlChannelPubkey,
29
+ fileChannelPubkey: packet.publication.channelPubkey, peerPubkey: descriptor.peerPubkey,
30
+ chunkIndex: packet.target.chunkIndex,
31
+ key: `${descriptor.controlChannelPubkey}:${packet.publication.channelPubkey}:${row.receiverPubkey}:${packet.target.chunkIndex}`
32
+ }
33
+ : {})
34
+ }
35
+ value.recordId = encodeRecord('seed', value).tags[0][1]
36
+ return value
37
+ }))
38
+ }
39
+ function retryLater () {
40
+ if (stopped || !enabled || timer || !failed.size) return
41
+ timer = setTimer(() => { timer = null; failed.clear(); wake() }, Math.min(30000, delay * (0.8 + random() * 0.4)))
42
+ timer?.unref?.()
43
+ delay = Math.min(30000, delay * 2)
44
+ }
45
+ function wake () {
46
+ if (stopped || !enabled || working) return working
47
+ const work = Promise.resolve().then(async () => {
48
+ for (const entry of entries.values()) {
49
+ if (stopped || !enabled) break
50
+ const packet = entry.prepared
51
+ if (!packet || failed.has(entry.id)) continue
52
+ if (packet.archived && !packet.accepted && !entry.cancelled) continue
53
+ const started = Date.now()
54
+ try {
55
+ if (!durable.has(packet)) { await write(entry); durable.add(packet) }
56
+ if (!packet.archived) {
57
+ debug(entry, 'recovery-archive-start', queued.get(packet), { publicationId: packet.publication.events[0].id })
58
+ if (packet.recover) await archive(entry, rows(entry))
59
+ packet.archived = true
60
+ debug(entry, 'recovery-archived', started)
61
+ }
62
+ if (packet.accepted || entry.cancelled) delete entry.prepared
63
+ await write(entry)
64
+ changed(entry)
65
+ } catch (error) {
66
+ failed.add(entry.id)
67
+ onError(error)
68
+ if (isRecoverableStorageFailure(error)) retryLater()
69
+ }
70
+ }
71
+ }).finally(() => {
72
+ if (working === work) working = null
73
+ if (!failed.size) delay = 1000
74
+ // An ACK can arrive while another entry is being archived.
75
+ if (!stopped && [...entries.values()].some(entry => entry.prepared && !failed.has(entry.id) && (!entry.prepared.archived || entry.prepared.accepted || entry.cancelled))) wake()
76
+ })
77
+ working = work
78
+ return work
79
+ }
80
+ return {
81
+ write,
82
+ async flush (entry) { while (writes.has(entry.id)) await writes.get(entry.id) },
83
+ adopt (entry) { if (entry.prepared) { durable.add(entry.prepared); queued.set(entry.prepared, Date.now()) } },
84
+ async stage (entry, seed, target, retentionSeconds) {
85
+ const started = Date.now()
86
+ if (entry.prepared && entry.prepared.publication.events[0].id !== seed.publication.events[0].id) throw new Error('PUBLICATION_ARCHIVE_PENDING')
87
+ entry.prepared ||= { publication: structuredClone(seed.publication), target, receivedAt: Math.floor(Date.now() / 1000), retentionSeconds, recover: retentionSeconds > 0, accepted: false, archived: retentionSeconds <= 0 }
88
+ if (durable.has(entry.prepared)) return
89
+ queued.set(entry.prepared, Date.now())
90
+ await write(entry)
91
+ durable.add(entry.prepared)
92
+ debug(entry, 'publication-checkpoint', started, { publicationId: seed.publication.events[0].id })
93
+ for (const row of rows(entry)) debug(entry, 'recovery-pending', undefined, { seedRecordId: row.recordId, publicationId: seed.publication.events[0].id })
94
+ wake()
95
+ },
96
+ wake,
97
+ accept (entry) {
98
+ entry.prepared.accepted = true
99
+ // An unrelated archive may be blocked. Its worker must not hold this ACK.
100
+ write(entry).catch(error => { failed.add(entry.id); onError(error); retryLater() }).finally(wake)
101
+ wake()
102
+ },
103
+ setAvailable (value) {
104
+ enabled = value
105
+ if (!enabled) { if (timer) clearTimer(timer); timer = null } else { failed.clear(); wake() }
106
+ },
107
+ retry () { failed.clear(); if (timer) clearTimer(timer); timer = null; return wake() },
108
+ pendingRows () { return [...entries.values()].flatMap(rows).filter(row => row.expiresAt > Math.floor(Date.now() / 1000)) },
109
+ async close () {
110
+ stopped = true
111
+ if (timer) clearTimer(timer)
112
+ await working
113
+ await Promise.allSettled([...writes.values()])
114
+ }
115
+ }
116
+ }
117
+
118
+ export function pendingSeedMatches (row, query) {
119
+ for (const key of ['channelPubkey', 'controlChannelPubkey', 'fileChannelPubkey', 'receiverPubkey', 'recordId']) if (query[key] && row[key] !== query[key]) return false
120
+ if (query.indices && !query.indices.includes(row.chunkIndex)) return false
121
+ if (query.since !== undefined && (row.lastSeenAt ?? row.router.created_at) < query.since) return false
122
+ if (query.until !== undefined && (row.firstSeenAt ?? row.router.created_at) > query.until) return false
123
+ return true
124
+ }
125
+
126
+ export function withPendingSeeds (storage, pending) {
127
+ return {
128
+ ...storage,
129
+ async * iterate (query = {}) {
130
+ const seen = new Set()
131
+ // Pending durable rows are immediately usable, even while store.put waits.
132
+ for (const row of pending()) if (pendingSeedMatches(row, query)) { seen.add(row.recordId); yield row }
133
+ for await (const row of storage.iterate(query)) if (!seen.has(row.recordId)) yield row
134
+ },
135
+ has: row => pending().some(value => value.recordId === row.recordId) || storage.has(row)
136
+ }
137
+ }