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.
- package/README.md +13 -4
- package/network/README.md +31 -0
- package/network/index.js +55 -13
- package/package.json +1 -1
- package/private-channel/index.js +75 -67
- package/private-message/index.js +26 -7
- package/private-messenger/file/index.js +5 -5
- package/private-messenger/helpers/fallback-race.js +3 -2
- package/private-messenger/helpers/pause-recovery.js +4 -1
- package/private-messenger/helpers/send-routing.js +3 -2
- package/private-messenger/index.js +29 -10
- package/private-messenger/session/README.md +36 -2
- package/private-messenger/session/helpers/publication-journal.js +137 -0
- package/private-messenger/session/index.js +154 -48
|
@@ -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) =>
|
|
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.
|
|
174
|
-
|
|
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
|
+
}
|