libp2r2p 0.11.5 → 0.11.6
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 +36 -0
- package/helpers/abortable-semaphore.js +41 -0
- package/package.json +2 -1
- package/private-channel/helpers/history.js +121 -0
- package/private-channel/index.js +12 -0
- package/private-messenger/index.js +8 -6
package/README.md
CHANGED
|
@@ -534,6 +534,42 @@ returned events; received messages may already have been queued. This is a
|
|
|
534
534
|
history completeness failure, not evidence that an outgoing message failed.
|
|
535
535
|
Existing rewatch/resume and relay-list refresh paths retry pending intervals;
|
|
536
536
|
there is no dedicated periodic retry timer for a failed history fetch.
|
|
537
|
+
`private-channel.fetchHistory(options)` (0.11.6) is the complete-interval API
|
|
538
|
+
used by messenger recovery. It accepts the signers, processor/fragment options,
|
|
539
|
+
callbacks and abort signal of `fetch`, plus inclusive `since`/`until` bounds
|
|
540
|
+
(defaults: 0 and the start time of the call). It returns only
|
|
541
|
+
`{ oldestCreatedAt, receivedEventCount, relays }`; an empty scan has a null oldest
|
|
542
|
+
time. No complete-history event array is retained. `fetch` keeps its existing
|
|
543
|
+
single-query array contract and optional limit.
|
|
544
|
+
|
|
545
|
+
History queries each relay independently with limit 16. A response with 16 events
|
|
546
|
+
or status `satisfied` is saturated, even if it already reports EOSE or contains
|
|
547
|
+
fewer valid events. Saturated intervals split into nonoverlapping inclusive
|
|
548
|
+
halves; completed pages process chronologically, oldest half first. Single-second
|
|
549
|
+
intervals grow through 16, 32, 64, 128 and 256. Each response is also bounded to
|
|
550
|
+
4 MiB of serialized UTF-8 event data. At the dense-second or volume ceiling,
|
|
551
|
+
`PRIVATE_CHANNEL_FETCH_INCOMPLETE` retains the operational cause
|
|
552
|
+
`PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT`; the interval stays pending. There is no
|
|
553
|
+
nonstandard cursor, disk spool, dropped excess or successful incomplete scan.
|
|
554
|
+
|
|
555
|
+
A fragment processor and the existing scoped fragment storage span the entire
|
|
556
|
+
call, including pages and relays. Callbacks are awaited before requesting the
|
|
557
|
+
next page. Processing/persistence failures propagate; completed pages may already
|
|
558
|
+
be ingested and retries remain subject to the existing idempotence contract.
|
|
559
|
+
The summary count includes processed outer events per relay, including replicas,
|
|
560
|
+
not unique decrypted messages or discarded saturated probes. It does not include
|
|
561
|
+
all events observed in those probes.
|
|
562
|
+
|
|
563
|
+
Recovery admits at most two pages per PrivateMessenger and one per normalized
|
|
564
|
+
relay across the same library instance, including page processing time. Continuous
|
|
565
|
+
live watches are outside those recovery gates. Waiters are abortable and FIFO,
|
|
566
|
+
with at most 256 queued per gate (`PRIVATE_CHANNEL_HISTORY_QUEUE_FULL` on excess).
|
|
567
|
+
The five-second network timeout begins after admission; reported read time also
|
|
568
|
+
includes admission but excludes processing. Seeders still supplement the oldest
|
|
569
|
+
relay result. Checkpoints advance only after the entire interval and existing
|
|
570
|
+
seeder stages succeed; interruption preserves its original pending range. No
|
|
571
|
+
persisted state format changes or migrations are needed.
|
|
572
|
+
|
|
537
573
|
Subscription relay errors forwarded to `onError` include `relay` when known
|
|
538
574
|
and `operation: 'private-channel.subscribe'`. A per-notification wrapper retains
|
|
539
575
|
the native message, name, code, category and WebSocket close fields; `cause`
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A bounded FIFO for asynchronous work. Acquired leases release explicitly;
|
|
2
|
+
// queued cancellations never consume capacity or keep idle keyed gates alive.
|
|
3
|
+
export function createAbortableSemaphore (capacity, onIdle = () => {}) {
|
|
4
|
+
const queue = []
|
|
5
|
+
let active = 0
|
|
6
|
+
const drain = () => {
|
|
7
|
+
while (active < capacity && queue.length) {
|
|
8
|
+
const job = queue.shift()
|
|
9
|
+
job.signal?.removeEventListener('abort', job.abort)
|
|
10
|
+
active++
|
|
11
|
+
let released = false
|
|
12
|
+
job.resolve(() => {
|
|
13
|
+
if (released) return
|
|
14
|
+
released = true
|
|
15
|
+
active--
|
|
16
|
+
drain()
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
if (!active && !queue.length) onIdle()
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
acquire (signal) {
|
|
23
|
+
signal?.throwIfAborted()
|
|
24
|
+
if (queue.length >= 256) return Promise.reject(Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_QUEUE_FULL'), { code: 'PRIVATE_CHANNEL_HISTORY_QUEUE_FULL' }))
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const job = {
|
|
27
|
+
signal, resolve, abort: () => {
|
|
28
|
+
const index = queue.indexOf(job)
|
|
29
|
+
if (index < 0) return
|
|
30
|
+
queue.splice(index, 1)
|
|
31
|
+
reject(signal.reason)
|
|
32
|
+
drain()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
signal?.addEventListener('abort', job.abort, { once: true })
|
|
36
|
+
queue.push(job)
|
|
37
|
+
drain()
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { ValidationError } from '../../error/index.js'
|
|
2
|
+
import { normalizeRelayUrl } from '../../url/index.js'
|
|
3
|
+
import { createAbortableSemaphore } from '../../helpers/abortable-semaphore.js'
|
|
4
|
+
import { incompleteFetchError } from './fetch-error.js'
|
|
5
|
+
|
|
6
|
+
const PAGE_SIZE = 16
|
|
7
|
+
const MAX_PAGE_SIZE = 256
|
|
8
|
+
const MAX_PAGE_BYTES = 4 * 1024 * 1024
|
|
9
|
+
const TIMEOUT_MS = 5000
|
|
10
|
+
const relayGates = new Map()
|
|
11
|
+
const encoder = new TextEncoder()
|
|
12
|
+
const pageLimitError = () => Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT'), { code: 'PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT' })
|
|
13
|
+
|
|
14
|
+
function acquireRelay (relay, signal) {
|
|
15
|
+
signal?.throwIfAborted()
|
|
16
|
+
let gate = relayGates.get(relay)
|
|
17
|
+
if (!gate) {
|
|
18
|
+
gate = createAbortableSemaphore(1, () => { if (relayGates.get(relay) === gate) relayGates.delete(relay) })
|
|
19
|
+
relayGates.set(relay, gate)
|
|
20
|
+
}
|
|
21
|
+
return gate.acquire(signal)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Queries bounded temporal leaves, oldest first. A saturated one-second leaf
|
|
25
|
+
// cannot be split using standard NIP-01; expand it only up to the safe ceiling.
|
|
26
|
+
export async function readHistory ({ filter, relays, receiverPubkey, signal, getEvents, processEvent, acquirePage }) {
|
|
27
|
+
const since = filter.since ?? 0
|
|
28
|
+
const until = filter.until ?? Math.floor(Date.now() / 1000)
|
|
29
|
+
if (![since, until].every(value => Number.isSafeInteger(value) && value >= 0) || since > until) throw new ValidationError('INVALID_PRIVATE_CHANNEL_HISTORY_RANGE')
|
|
30
|
+
if (!Array.isArray(relays) || !relays.length) throw new ValidationError('NO_RELAYS')
|
|
31
|
+
const urls = [...new Set(relays.map(normalizeRelayUrl))]
|
|
32
|
+
const report = []
|
|
33
|
+
const errors = []
|
|
34
|
+
let oldestCreatedAt = null
|
|
35
|
+
let receivedEventCount = 0
|
|
36
|
+
let elapsedMs = 0
|
|
37
|
+
const process = async result => {
|
|
38
|
+
result.sort((a, b) => a.event.created_at - b.event.created_at || String(a.event.id).localeCompare(String(b.event.id)))
|
|
39
|
+
for (const { event } of result) {
|
|
40
|
+
signal?.throwIfAborted()
|
|
41
|
+
await processEvent(event)
|
|
42
|
+
receivedEventCount++
|
|
43
|
+
oldestCreatedAt = Math.min(oldestCreatedAt ?? event.created_at, event.created_at)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const relay of urls) {
|
|
47
|
+
const intervals = [{ since, until, limit: PAGE_SIZE }]
|
|
48
|
+
let outcome = { relay, status: 'eose' }
|
|
49
|
+
while (intervals.length) {
|
|
50
|
+
signal?.throwIfAborted()
|
|
51
|
+
const interval = intervals.pop()
|
|
52
|
+
const started = performance.now()
|
|
53
|
+
let releaseRelay
|
|
54
|
+
let releasePage
|
|
55
|
+
let page
|
|
56
|
+
try {
|
|
57
|
+
let bytes = 0
|
|
58
|
+
try {
|
|
59
|
+
releaseRelay = await acquireRelay(relay, signal)
|
|
60
|
+
releasePage = await acquirePage?.(signal)
|
|
61
|
+
signal?.throwIfAborted()
|
|
62
|
+
page = await getEvents({ ...filter, ...interval }, [relay], {
|
|
63
|
+
signal, timeout: TIMEOUT_MS, timeoutAfterFirstEose: null,
|
|
64
|
+
callback: item => {
|
|
65
|
+
if (item.type !== 'event') return
|
|
66
|
+
bytes += encoder.encode(JSON.stringify(item.event)).byteLength
|
|
67
|
+
if (bytes > MAX_PAGE_BYTES) throw pageLimitError()
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
// Also enforce the contract for injected readers that omit callbacks.
|
|
71
|
+
let resultBytes = 0
|
|
72
|
+
for (const { event } of page.result) {
|
|
73
|
+
resultBytes += encoder.encode(JSON.stringify(event)).byteLength
|
|
74
|
+
if (resultBytes > MAX_PAGE_BYTES) throw pageLimitError()
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
signal?.throwIfAborted()
|
|
78
|
+
errors.push({ relay, reason: error })
|
|
79
|
+
outcome = { relay, status: 'error', error }
|
|
80
|
+
break
|
|
81
|
+
} finally { elapsedMs += performance.now() - started }
|
|
82
|
+
signal?.throwIfAborted()
|
|
83
|
+
const status = page.relays?.find(item => item.relay === relay)
|
|
84
|
+
if (page.errors?.length || !status || !['eose', 'satisfied'].includes(status.status)) {
|
|
85
|
+
await process(page.result)
|
|
86
|
+
errors.push(...(page.errors ?? []))
|
|
87
|
+
outcome = status ?? { relay, status: 'error' }
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
if (status.status === 'satisfied' || page.result.length >= interval.limit) {
|
|
91
|
+
if (interval.since < interval.until) {
|
|
92
|
+
const middle = Math.floor(interval.since + (interval.until - interval.since) / 2)
|
|
93
|
+
intervals.push({ since: middle + 1, until: interval.until, limit: PAGE_SIZE }, { since: interval.since, until: middle, limit: PAGE_SIZE })
|
|
94
|
+
} else if (interval.limit < MAX_PAGE_SIZE) {
|
|
95
|
+
intervals.push({ ...interval, limit: interval.limit * 2 })
|
|
96
|
+
} else {
|
|
97
|
+
await process(page.result)
|
|
98
|
+
const error = pageLimitError()
|
|
99
|
+
errors.push({ relay, reason: error })
|
|
100
|
+
outcome = { relay, status: 'error', error }
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
await process(page.result)
|
|
105
|
+
}
|
|
106
|
+
signal?.throwIfAborted()
|
|
107
|
+
} finally {
|
|
108
|
+
releasePage?.()
|
|
109
|
+
releaseRelay?.()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
report.push(outcome)
|
|
113
|
+
}
|
|
114
|
+
signal?.throwIfAborted()
|
|
115
|
+
if (errors.length || report.some(item => item.status !== 'eose')) {
|
|
116
|
+
const error = incompleteFetchError({ errors, report, receivedEventCount, elapsedMs: Math.round(elapsedMs), request: { relays: urls, channelPubkeys: filter.authors ?? [], receiverPubkey, since, until, limit: PAGE_SIZE, timeoutMs: TIMEOUT_MS } })
|
|
117
|
+
error.operation = 'private-channel.fetchHistory'
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
return { oldestCreatedAt, receivedEventCount, relays: report }
|
|
121
|
+
}
|
package/private-channel/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { getIykcProofs } from '../content-key/index.js'
|
|
|
7
7
|
import { ValidationError } from '../error/index.js'
|
|
8
8
|
import { normalizeRumor, deliveryInfo } from './helpers/rumor.js'
|
|
9
9
|
import { incompleteFetchError } from './helpers/fetch-error.js'
|
|
10
|
+
import { readHistory } from './helpers/history.js'
|
|
10
11
|
import { subscriptionError } from './helpers/subscription-error.js'
|
|
11
12
|
import * as nip44v3 from '../nip44-v3/index.js'
|
|
12
13
|
import { relayPool } from '../relay/index.js'
|
|
@@ -955,6 +956,17 @@ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChanne
|
|
|
955
956
|
}
|
|
956
957
|
}
|
|
957
958
|
|
|
959
|
+
// Complete interval recovery with bounded pages and one fragment processor.
|
|
960
|
+
// Existing fetch() retains its one-query/event-array contract.
|
|
961
|
+
export async function fetchHistory ({ receiverSigner, privateChannelSigner = receiverSigner, _getEvents = getEvents, _acquirePage, ...options }) {
|
|
962
|
+
const authors = privateChannelPubkeyList(options)
|
|
963
|
+
const filter = { kinds: [PRIVATE_BROADCAST_KIND], ...(authors.length ? { authors } : {}), since: options.since, until: options.until }
|
|
964
|
+
const processOuterEvent = createProcessor({ ...options, receiverSigner, privateChannelSigner, onError: error => { options.onError?.(error); throw error } })
|
|
965
|
+
try {
|
|
966
|
+
return await readHistory({ filter, relays: options.relays, receiverPubkey: options.receiverPubkey, signal: options.signal, getEvents: _getEvents, processEvent: processOuterEvent, acquirePage: _acquirePage })
|
|
967
|
+
} finally { processOuterEvent.close() }
|
|
968
|
+
}
|
|
969
|
+
|
|
958
970
|
export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since = nowSeconds() - 5, limit, liveOnly = false, mode = 'leecher', modeByPubkey, receivedChunkScope = '', receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _liveEventsGenerator = getLiveEventsGenerator, _eventsFeedGenerator = getEventsFeedGenerator }) {
|
|
959
971
|
if (!relays?.length) throw new ValidationError('NO_RELAYS')
|
|
960
972
|
if (receiverSigner && !receiverSigner?.nip44DecryptDoubleDH && !receiverSigner?.nip44v3Decrypt) throw new ValidationError('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
// - Seeder replies stream compact routers with createMissingMessageReplyPacker({ messenger, question }).update(seed), then finalize(optionalLastSeed).
|
|
43
43
|
// - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
|
|
44
44
|
|
|
45
|
+
import { createAbortableSemaphore } from '../helpers/abortable-semaphore.js'
|
|
45
46
|
import * as privateMessage from '../private-message/index.js'
|
|
46
47
|
import { isOnline } from '../network/index.js'
|
|
47
48
|
import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
|
|
@@ -285,6 +286,7 @@ export class PrivateMessenger {
|
|
|
285
286
|
this.deliveryReads = new Set()
|
|
286
287
|
this.deliveryWaiters = new Set()
|
|
287
288
|
this.recoveries = new Map()
|
|
289
|
+
this.recoveryAdmission = createAbortableSemaphore(2)
|
|
288
290
|
this.recoveryControllers = new Set()
|
|
289
291
|
this.resumeWork = null
|
|
290
292
|
this.capacityTimer = null
|
|
@@ -1950,12 +1952,11 @@ export class PrivateMessenger {
|
|
|
1950
1952
|
}
|
|
1951
1953
|
|
|
1952
1954
|
async askSeedersForRelayLeftEdge (channelPubkey, range, fetchedEvents) {
|
|
1953
|
-
const { asks } = await this.#askSeedersForRelayLeftEdgeAttempt(channelPubkey, range, fetchedEvents)
|
|
1955
|
+
const { asks } = await this.#askSeedersForRelayLeftEdgeAttempt(channelPubkey, range, oldestCreatedAt(fetchedEvents))
|
|
1954
1956
|
return asks
|
|
1955
1957
|
}
|
|
1956
1958
|
|
|
1957
|
-
async #askSeedersForRelayLeftEdgeAttempt (channelPubkey, range,
|
|
1958
|
-
const oldest = oldestCreatedAt(fetchedEvents)
|
|
1959
|
+
async #askSeedersForRelayLeftEdgeAttempt (channelPubkey, range, oldest) {
|
|
1959
1960
|
const until = oldest == null ? range.end : Math.min(range.end, oldest)
|
|
1960
1961
|
if (until < range.start) return { asks: [], failures: [] }
|
|
1961
1962
|
return this.#askSeedersForMissingRangeAttempt(channelPubkey, range.start, until)
|
|
@@ -2098,7 +2099,8 @@ export class PrivateMessenger {
|
|
|
2098
2099
|
if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw new Error('PRIVATE_MESSENGER_PAUSED')
|
|
2099
2100
|
const fetchRelays = await this.resolveWatchRelays(channel)
|
|
2100
2101
|
controller.signal.throwIfAborted()
|
|
2101
|
-
const
|
|
2102
|
+
const history = await this._privateChannel.fetchHistory({
|
|
2103
|
+
_acquirePage: signal => this.recoveryAdmission.acquire(signal),
|
|
2102
2104
|
signal: controller.signal,
|
|
2103
2105
|
receivedChunkScope: this.storageLeaseId,
|
|
2104
2106
|
receiverSigner: this.userSigner,
|
|
@@ -2120,9 +2122,9 @@ export class PrivateMessenger {
|
|
|
2120
2122
|
onSeedEvent: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
|
|
2121
2123
|
onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
|
|
2122
2124
|
onError: err => { throw err }
|
|
2123
|
-
})
|
|
2125
|
+
})
|
|
2124
2126
|
controller.signal.throwIfAborted()
|
|
2125
|
-
const attempt = await this.#askSeedersForRelayLeftEdgeAttempt(pubkey, range,
|
|
2127
|
+
const attempt = await this.#askSeedersForRelayLeftEdgeAttempt(pubkey, range, history.oldestCreatedAt)
|
|
2126
2128
|
const lifecycleChanged = this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
|
|
2127
2129
|
(this.watchRevisionByChannel.get(pubkey) || 0) !== watchRevision
|
|
2128
2130
|
if (lifecycleChanged || attempt.failures.length) remaining.push(range)
|