libp2r2p 0.11.5 → 0.11.7
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 +57 -1
- package/helpers/abortable-semaphore.js +41 -0
- package/package.json +2 -1
- package/private-channel/helpers/chunks.js +28 -23
- package/private-channel/helpers/history.js +121 -0
- package/private-channel/helpers/subscription-error.js +1 -1
- package/private-channel/index.js +57 -50
- package/private-messenger/index.js +8 -6
- package/private-messenger/recovery/index.js +21 -8
- package/relay/helpers/error.js +12 -0
- package/relay/services/relay-connection.js +69 -20
- package/relay/services/relay-pool.js +9 -5
- package/temporary-storage/index.js +10 -3
package/README.md
CHANGED
|
@@ -339,7 +339,7 @@ must not delete a kind `3560` event, whether or not that event has an `s` tag.
|
|
|
339
339
|
### Storage Maintenance
|
|
340
340
|
|
|
341
341
|
While an outgoing private message is being assembled, the messenger keeps
|
|
342
|
-
encrypted envelope rows
|
|
342
|
+
encrypted envelope rows in `sessionStorage` (router fragments are encoded on demand, without a second stored copy). They are
|
|
343
343
|
removed when the send finishes, but an interrupted browser operation can leave
|
|
344
344
|
them behind until cleanup runs.
|
|
345
345
|
|
|
@@ -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`
|
|
@@ -929,3 +965,23 @@ latency, and short-lived channel/recipient preferences avoid recently refused
|
|
|
929
965
|
relays without changing receive subscriptions. See the
|
|
930
966
|
[session coordinator](private-messenger/session/README.md) for deferred offline
|
|
931
967
|
attempts and final failure notifications.
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
### Relay backpressure and bounded synchronization (0.11.7)
|
|
971
|
+
|
|
972
|
+
Optional numeric `retry_after` seconds on relay `CLOSED` and rejected `OK`
|
|
973
|
+
responses are preserved as `retryAfterMs` and `retryAt` on native relay errors.
|
|
974
|
+
Only `rate-limited:` responses apply this advice; invalid values are ignored
|
|
975
|
+
and delays are capped at five minutes. New operations on that connection wait
|
|
976
|
+
within their existing deadlines; active subscriptions remain live and `CLOSE`
|
|
977
|
+
never waits. Cancelled or timed-out operations cannot be sent later. A rejection
|
|
978
|
+
is still reported to the caller; this does not silently republish failed events.
|
|
979
|
+
|
|
980
|
+
`createEventReplyPacker` and `createMissingMessageReplyPacker` accept
|
|
981
|
+
`bytesPerChunk` (default 128 KiB of UTF-8 JSONL), in addition to the existing
|
|
982
|
+
100-record maximum. One indivisible oversized record is sent alone. Consumers
|
|
983
|
+
must keep failed requests pending; no record is truncated or discarded. Final
|
|
984
|
+
publication failures retain the payload/index for retry. Outbound channel
|
|
985
|
+
fragmentation streams from prepared encrypted rows, cleans partial writes and
|
|
986
|
+
removes failed new keys from the temporary-storage registry. It does not raise
|
|
987
|
+
the browser's Web Storage quota or delete unrelated application data.
|
|
@@ -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
|
@@ -78,23 +78,6 @@ function encryptedPayload ({ messageSecretKey, event }) {
|
|
|
78
78
|
return nip44v3.encrypt(messageSecretKey, messagePubkey, ROUTER_KIND, '', JSON.stringify(event))
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
function appendLine (chunk, line, id, chunkIndex, temporaryStorage) {
|
|
82
|
-
while (line.length) {
|
|
83
|
-
const available = JSONL_CHUNK_BYTES - chunk.length
|
|
84
|
-
chunk = appendBytes(chunk, line.slice(0, available))
|
|
85
|
-
line = line.slice(available)
|
|
86
|
-
if (chunk.length === JSONL_CHUNK_BYTES) {
|
|
87
|
-
temporaryStorage.setItem(tempKey(id, chunkIndex++), bytesToBase64(chunk))
|
|
88
|
-
chunk = new Uint8Array()
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return { chunk, chunkIndex }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function appendRow (chunk, row, id, chunkIndex, temporaryStorage) {
|
|
95
|
-
return appendLine(chunk, encoder.encode(`${row}\n`), id, chunkIndex, temporaryStorage)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
81
|
function joinByteChunks (parts) {
|
|
99
82
|
let length = 0
|
|
100
83
|
const decoded = parts.map(part => {
|
|
@@ -249,20 +232,42 @@ export function preparedRowIndexesForReceivers (preparedRows, receivers) {
|
|
|
249
232
|
return indexes
|
|
250
233
|
}
|
|
251
234
|
|
|
235
|
+
// Keep only one encoded row and one fragment in memory; do not stage a
|
|
236
|
+
// second, Base64-expanded copy of the entire message in Web Storage.
|
|
237
|
+
export function preparedChunkCount (preparedRows, rowIndexes = preparedRows.rowIndexes) {
|
|
238
|
+
let bytes = 0
|
|
239
|
+
for (const index of [0, ...rowIndexes]) bytes += encoder.encode(readPreparedRow(preparedRows, index)).length + 1
|
|
240
|
+
return Math.max(1, Math.ceil(bytes / JSONL_CHUNK_BYTES))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function * preparedChunks (preparedRows, rowIndexes = preparedRows.rowIndexes) {
|
|
244
|
+
let chunk = new Uint8Array()
|
|
245
|
+
for (const index of [0, ...rowIndexes]) {
|
|
246
|
+
const row = encoder.encode(`${readPreparedRow(preparedRows, index)}\n`)
|
|
247
|
+
for (let offset = 0; offset < row.length;) {
|
|
248
|
+
const size = Math.min(JSONL_CHUNK_BYTES - chunk.length, row.length - offset)
|
|
249
|
+
chunk = appendBytes(chunk, row.subarray(offset, offset + size))
|
|
250
|
+
offset += size
|
|
251
|
+
if (chunk.length === JSONL_CHUNK_BYTES) {
|
|
252
|
+
yield bytesToBase64(chunk)
|
|
253
|
+
chunk = new Uint8Array()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (chunk.length) yield bytesToBase64(chunk)
|
|
258
|
+
}
|
|
259
|
+
|
|
252
260
|
export function writeChunksFromPreparedRows (preparedRows, rowIndexes = preparedRows?.rowIndexes || []) {
|
|
253
261
|
const temporaryStorage = storageFor(preparedRows?.temporaryStorage)
|
|
254
262
|
const id = temporaryId()
|
|
255
|
-
let chunk = new Uint8Array()
|
|
256
263
|
let chunkIndex = 0
|
|
257
264
|
try {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
;({ chunk, chunkIndex } = appendRow(chunk, readPreparedRow(preparedRows, rowIndex), id, chunkIndex, temporaryStorage))
|
|
265
|
+
for (const content of preparedChunks(preparedRows, rowIndexes)) {
|
|
266
|
+
temporaryStorage.setItem(tempKey(id, chunkIndex++), content)
|
|
261
267
|
}
|
|
262
|
-
if (chunk.length || chunkIndex === 0) temporaryStorage.setItem(tempKey(id, chunkIndex++), bytesToBase64(chunk))
|
|
263
268
|
return { id, total: chunkIndex, ownContentPubkey: preparedRows.ownContentPubkey || '' }
|
|
264
269
|
} catch (err) {
|
|
265
|
-
cleanupChunks(id, chunkIndex
|
|
270
|
+
cleanupChunks(id, chunkIndex, temporaryStorage)
|
|
266
271
|
throw err
|
|
267
272
|
}
|
|
268
273
|
}
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@ export function subscriptionError (reason, relay) {
|
|
|
5
5
|
const error = reason instanceof AggregateError
|
|
6
6
|
? new AggregateError(reason.errors, message, { cause: reason })
|
|
7
7
|
: new Error(message, { cause: reason })
|
|
8
|
-
for (const key of ['name', 'code', 'category', 'closeCode', 'closeReason', 'wasClean']) {
|
|
8
|
+
for (const key of ['name', 'code', 'category', 'retryAfterMs', 'retryAt', 'closeCode', 'closeReason', 'wasClean']) {
|
|
9
9
|
if (reason?.[key] !== undefined) error[key] = reason[key]
|
|
10
10
|
}
|
|
11
11
|
error.operation = 'private-channel.subscribe'
|
package/private-channel/index.js
CHANGED
|
@@ -7,21 +7,21 @@ 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'
|
|
13
14
|
import { JSONL_CHUNK_BYTES, NYM_CARRIER_CHUNK_CHARS } from './helpers/chunk-size.js'
|
|
14
15
|
import {
|
|
15
|
-
cleanupChunks,
|
|
16
16
|
cleanupEnvelopeRows,
|
|
17
17
|
decodeChunkLines,
|
|
18
18
|
prepareEnvelopeRows,
|
|
19
19
|
preparedRowIndexesForReceivers,
|
|
20
|
-
readChunkContent,
|
|
21
20
|
readPreparedRow,
|
|
22
21
|
receiverPubkeys,
|
|
23
22
|
receiverPubkeysWithoutContentKeys,
|
|
24
|
-
|
|
23
|
+
preparedChunkCount,
|
|
24
|
+
preparedChunks
|
|
25
25
|
} from './helpers/chunks.js'
|
|
26
26
|
import { EXPIRATION_SECONDS, MAX_EVENT_BYTES, NYM_CARRIER_KIND, PRIVATE_BROADCAST_KIND, ROUTER_KIND } from './constants/index.js'
|
|
27
27
|
import { eventByteLength, hasImkcTag, makeNymCarrierEvent, makeRouterEvent, nowSeconds, readChunkTag, readFileChunkIndex, readIdTag, readImkcProof, readImkcTag, readReceiverTag, readSenderTag } from './helpers/event.js'
|
|
@@ -120,15 +120,20 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
|
|
|
120
120
|
rowScope: channelPubkey,
|
|
121
121
|
temporaryStorageArea
|
|
122
122
|
})
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
try {
|
|
124
|
+
const imkcPubkey = preparedRows.ownContentPubkey || ''
|
|
125
|
+
const imkcProof = imkcPubkey ? await makeImkcProof({ senderSigner, senderPubkey, imkcPubkey }) : ''
|
|
126
|
+
return {
|
|
127
|
+
senderPubkey,
|
|
128
|
+
channelPubkey,
|
|
129
|
+
channelReaderPubkey,
|
|
130
|
+
preparedRows,
|
|
131
|
+
imkcPubkey,
|
|
132
|
+
imkcProof
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
cleanupEnvelopeRows(preparedRows)
|
|
136
|
+
throw error
|
|
132
137
|
}
|
|
133
138
|
}
|
|
134
139
|
|
|
@@ -138,45 +143,36 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
|
|
|
138
143
|
const receiverPubkeyList = receiverPubkeys(receivers)
|
|
139
144
|
const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
|
|
140
145
|
const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
try {
|
|
148
|
-
if (onPreparedSeed) {
|
|
149
|
-
const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
|
|
150
|
-
const payloadRow = readPreparedRow(context.preparedRows, 0)
|
|
151
|
-
for (const rowIndex of rowIndexes) {
|
|
152
|
-
await onPreparedSeed({ channelPubkey: context.channelPubkey, router, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` })
|
|
153
|
-
}
|
|
146
|
+
const total = preparedChunkCount(context.preparedRows, rowIndexes)
|
|
147
|
+
if (onPreparedSeed) {
|
|
148
|
+
const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
|
|
149
|
+
const payloadRow = readPreparedRow(context.preparedRows, 0)
|
|
150
|
+
for (const rowIndex of rowIndexes) {
|
|
151
|
+
await onPreparedSeed({ channelPubkey: context.channelPubkey, router, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` })
|
|
154
152
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
} finally {
|
|
179
|
-
cleanupChunks(id, total, temporaryStorage)
|
|
153
|
+
}
|
|
154
|
+
let index = 0
|
|
155
|
+
for (const content of preparedChunks(context.preparedRows, rowIndexes)) {
|
|
156
|
+
const router = finalizeEvent(makeRouterEvent({
|
|
157
|
+
pubkey: routerPubkey,
|
|
158
|
+
senderPubkey: context.senderPubkey,
|
|
159
|
+
imkcPubkey: context.imkcPubkey,
|
|
160
|
+
imkcProof: context.imkcProof,
|
|
161
|
+
receiverPubkey: routerReceiverTag,
|
|
162
|
+
fileChunkIndex,
|
|
163
|
+
chunkIndex: index++,
|
|
164
|
+
chunkTotal: total,
|
|
165
|
+
content
|
|
166
|
+
}), routerSeckey)
|
|
167
|
+
const createdAt = nowSeconds()
|
|
168
|
+
const outer = await privateChannelSigner.signEvent({
|
|
169
|
+
kind: PRIVATE_BROADCAST_KIND,
|
|
170
|
+
created_at: createdAt,
|
|
171
|
+
tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
|
|
172
|
+
content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
|
|
173
|
+
})
|
|
174
|
+
if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
|
|
175
|
+
yield outer
|
|
180
176
|
}
|
|
181
177
|
}
|
|
182
178
|
|
|
@@ -955,6 +951,17 @@ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChanne
|
|
|
955
951
|
}
|
|
956
952
|
}
|
|
957
953
|
|
|
954
|
+
// Complete interval recovery with bounded pages and one fragment processor.
|
|
955
|
+
// Existing fetch() retains its one-query/event-array contract.
|
|
956
|
+
export async function fetchHistory ({ receiverSigner, privateChannelSigner = receiverSigner, _getEvents = getEvents, _acquirePage, ...options }) {
|
|
957
|
+
const authors = privateChannelPubkeyList(options)
|
|
958
|
+
const filter = { kinds: [PRIVATE_BROADCAST_KIND], ...(authors.length ? { authors } : {}), since: options.since, until: options.until }
|
|
959
|
+
const processOuterEvent = createProcessor({ ...options, receiverSigner, privateChannelSigner, onError: error => { options.onError?.(error); throw error } })
|
|
960
|
+
try {
|
|
961
|
+
return await readHistory({ filter, relays: options.relays, receiverPubkey: options.receiverPubkey, signal: options.signal, getEvents: _getEvents, processEvent: processOuterEvent, acquirePage: _acquirePage })
|
|
962
|
+
} finally { processOuterEvent.close() }
|
|
963
|
+
}
|
|
964
|
+
|
|
958
965
|
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
966
|
if (!relays?.length) throw new ValidationError('NO_RELAYS')
|
|
960
967
|
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)
|
|
@@ -9,6 +9,7 @@ export const MISSING_MESSAGES_REPLY_CODE = 'missingMessages_reply_8mj8'
|
|
|
9
9
|
export const ROUTER_SEED_RECORD_TYPE = 'routerEnvelopeRow_v1'
|
|
10
10
|
export const NYM_CARRIER_SEED_RECORD_TYPE = 'nymCarrier_v1'
|
|
11
11
|
|
|
12
|
+
export const DEFAULT_REPLY_BYTES_PER_CHUNK = 128 * 1024
|
|
12
13
|
const DEFAULT_EVENTS_PER_CHUNK = 100
|
|
13
14
|
const encoder = new TextEncoder()
|
|
14
15
|
const decoder = new TextDecoder()
|
|
@@ -224,6 +225,7 @@ export function createEventReplyPacker ({
|
|
|
224
225
|
code,
|
|
225
226
|
payload = {},
|
|
226
227
|
eventsPerChunk = DEFAULT_EVENTS_PER_CHUNK,
|
|
228
|
+
bytesPerChunk = DEFAULT_REPLY_BYTES_PER_CHUNK,
|
|
227
229
|
recordsFromInput = eventRecordFromInput,
|
|
228
230
|
sendEmptyReply = false
|
|
229
231
|
}) {
|
|
@@ -231,18 +233,17 @@ export function createEventReplyPacker ({
|
|
|
231
233
|
if (!question?.id) throw new ValidationError('QUESTION_REQUIRED')
|
|
232
234
|
if (!receiverPubkey) throw new ValidationError('RECEIVER_PUBKEY_REQUIRED')
|
|
233
235
|
if (!Number.isSafeInteger(eventsPerChunk) || eventsPerChunk < 1) throw new ValidationError('INVALID_EVENTS_PER_CHUNK')
|
|
236
|
+
if (!Number.isSafeInteger(bytesPerChunk) || bytesPerChunk < 1) throw new ValidationError('INVALID_BYTES_PER_CHUNK')
|
|
234
237
|
|
|
235
238
|
let chunk = ''
|
|
236
239
|
let chunkEvents = 0
|
|
240
|
+
let chunkBytes = 0
|
|
237
241
|
let index = 0
|
|
238
242
|
let finalized = false
|
|
239
243
|
let published = false
|
|
240
244
|
|
|
241
245
|
async function publish (isLast) {
|
|
242
246
|
const jsonl = chunk
|
|
243
|
-
chunk = ''
|
|
244
|
-
chunkEvents = 0
|
|
245
|
-
published = true
|
|
246
247
|
await messenger.reply({
|
|
247
248
|
channelPubkey,
|
|
248
249
|
question,
|
|
@@ -250,17 +251,27 @@ export function createEventReplyPacker ({
|
|
|
250
251
|
code,
|
|
251
252
|
payload: {
|
|
252
253
|
...payload,
|
|
253
|
-
index
|
|
254
|
+
index,
|
|
254
255
|
isLast,
|
|
255
256
|
jsonl
|
|
256
257
|
}
|
|
257
258
|
})
|
|
259
|
+
index++
|
|
260
|
+
chunk = ''
|
|
261
|
+
chunkEvents = 0
|
|
262
|
+
chunkBytes = 0
|
|
263
|
+
published = true
|
|
258
264
|
}
|
|
259
265
|
|
|
260
266
|
async function appendRecord (record, { flush = true } = {}) {
|
|
261
|
-
|
|
267
|
+
const line = `${JSON.stringify(record)}\n`
|
|
268
|
+
const bytes = encoder.encode(line).length
|
|
269
|
+
if (chunk && (chunkBytes + bytes > bytesPerChunk || chunkEvents >= eventsPerChunk)) await publish(false)
|
|
270
|
+
chunk += line
|
|
271
|
+
chunkBytes += bytes
|
|
262
272
|
chunkEvents++
|
|
263
|
-
|
|
273
|
+
// A record is indivisible: an oversized one travels alone, never truncated.
|
|
274
|
+
if (flush && (chunkEvents >= eventsPerChunk || chunkBytes >= bytesPerChunk)) await publish(false)
|
|
264
275
|
}
|
|
265
276
|
|
|
266
277
|
async function appendRecords (records, { final = false } = {}) {
|
|
@@ -278,9 +289,8 @@ export function createEventReplyPacker ({
|
|
|
278
289
|
async function finalize (input) {
|
|
279
290
|
if (finalized) return
|
|
280
291
|
if (input != null) await appendRecords(await recordsFromInput(input), { final: true })
|
|
292
|
+
if (chunk || sendEmptyReply || published) await publish(true)
|
|
281
293
|
finalized = true
|
|
282
|
-
if (!chunk && !sendEmptyReply && !published) return
|
|
283
|
-
await publish(true)
|
|
284
294
|
}
|
|
285
295
|
|
|
286
296
|
return {
|
|
@@ -297,12 +307,14 @@ export function createMissingMessageReplyPacker ({
|
|
|
297
307
|
since,
|
|
298
308
|
until,
|
|
299
309
|
eventsPerChunk = DEFAULT_EVENTS_PER_CHUNK,
|
|
310
|
+
bytesPerChunk = DEFAULT_REPLY_BYTES_PER_CHUNK,
|
|
300
311
|
sendEmptyReply = false
|
|
301
312
|
}) {
|
|
302
313
|
if (!messenger?.reply) throw new ValidationError('MESSENGER_REQUIRED')
|
|
303
314
|
if (!question?.id) throw new ValidationError('QUESTION_REQUIRED')
|
|
304
315
|
if (!receiverPubkey) throw new ValidationError('RECEIVER_PUBKEY_REQUIRED')
|
|
305
316
|
if (!Number.isSafeInteger(eventsPerChunk) || eventsPerChunk < 1) throw new ValidationError('INVALID_EVENTS_PER_CHUNK')
|
|
317
|
+
if (!Number.isSafeInteger(bytesPerChunk) || bytesPerChunk < 1) throw new ValidationError('INVALID_BYTES_PER_CHUNK')
|
|
306
318
|
|
|
307
319
|
const range = backfillRequestRange(question, since, until)
|
|
308
320
|
return createEventReplyPacker({
|
|
@@ -313,6 +325,7 @@ export function createMissingMessageReplyPacker ({
|
|
|
313
325
|
code: MISSING_MESSAGES_REPLY_CODE,
|
|
314
326
|
payload: { since: range.since, until: range.until },
|
|
315
327
|
eventsPerChunk,
|
|
328
|
+
bytesPerChunk,
|
|
316
329
|
sendEmptyReply,
|
|
317
330
|
recordsFromInput: seed => compactRecordsFromSeed(seed, { receiverPubkey, since: range.since, until: range.until })
|
|
318
331
|
})
|
package/relay/helpers/error.js
CHANGED
|
@@ -28,3 +28,15 @@ export function relayCloseError (event, category, cause) {
|
|
|
28
28
|
if (event?.wasClean !== undefined) error.wasClean = event.wasClean
|
|
29
29
|
return categorizeRelayError(error, category)
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
// retry_after is an optional relay extension, in seconds. Bound remote advice
|
|
33
|
+
// to five minutes; invalid metadata must not stall unrelated operations.
|
|
34
|
+
export function relayRejectionError (reason, extra, fallback) {
|
|
35
|
+
const error = categorizeRelayError(reason, 'relay', fallback)
|
|
36
|
+
const seconds = extra?.retry_after
|
|
37
|
+
if (error.message.startsWith('rate-limited:') && typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0) {
|
|
38
|
+
error.retryAfterMs = Math.min(seconds, 300) * 1000
|
|
39
|
+
error.retryAt = Date.now() + error.retryAfterMs
|
|
40
|
+
}
|
|
41
|
+
return error
|
|
42
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isValidEvent } from '../../event/index.js'
|
|
2
2
|
import { ValidationError } from '../../error/index.js'
|
|
3
3
|
import { maybeUnref } from '../helpers/timer.js'
|
|
4
|
-
import { categorizeRelayError, relayCloseError, relayTimeoutError } from '../helpers/error.js'
|
|
4
|
+
import { categorizeRelayError, relayCloseError, relayTimeoutError, relayRejectionError } from '../helpers/error.js'
|
|
5
5
|
|
|
6
6
|
const DEFAULT_CONNECT_TIMEOUT = 3000
|
|
7
7
|
const DEFAULT_OPERATION_TIMEOUT = 30000
|
|
@@ -50,6 +50,7 @@ export class RelayConnection {
|
|
|
50
50
|
#publishes = new Map()
|
|
51
51
|
#authentications = new Map()
|
|
52
52
|
#counts = new Map()
|
|
53
|
+
#retryAt = 0
|
|
53
54
|
|
|
54
55
|
constructor (url, { WebSocket: WebSocketImpl = globalThis.WebSocket } = {}) {
|
|
55
56
|
this.url = url
|
|
@@ -121,47 +122,87 @@ export class RelayConnection {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
// New work respects relay cooldowns; cleanup uses send() directly. These
|
|
126
|
+
// waits stay within the caller's existing deadline and are cancellable.
|
|
127
|
+
#dispatchWork (send, fail) {
|
|
128
|
+
let timer = null
|
|
129
|
+
let cancelled = false
|
|
130
|
+
const dispatch = () => {
|
|
131
|
+
if (cancelled) return
|
|
132
|
+
const remaining = this.#retryAt - Date.now()
|
|
133
|
+
if (remaining > 0) {
|
|
134
|
+
timer = maybeUnref(setTimeout(dispatch, remaining))
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
try { send() } catch (error) { fail(error) }
|
|
138
|
+
}
|
|
139
|
+
dispatch()
|
|
140
|
+
return () => { cancelled = true; clearTimeout(timer) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#rejection (reason, extra, fallback) {
|
|
144
|
+
const error = relayRejectionError(reason, extra, fallback)
|
|
145
|
+
this.#retryAt = Math.max(this.#retryAt, error.retryAt || 0)
|
|
146
|
+
return error
|
|
147
|
+
}
|
|
148
|
+
|
|
124
149
|
subscribe (filters, handlers = {}) {
|
|
125
150
|
if (!Array.isArray(filters) || !filters.length) throw new ValidationError('SUBSCRIPTION_FILTERS_REQUIRED')
|
|
126
151
|
const id = `p2r2p-sub:${++this.#serial}`
|
|
127
152
|
let closed = false
|
|
153
|
+
let sent = false
|
|
128
154
|
const close = () => {
|
|
129
155
|
if (closed) return
|
|
130
156
|
closed = true
|
|
131
157
|
const subscription = this.#subscriptions.get(id)
|
|
132
158
|
if (!subscription) return
|
|
133
159
|
this.#subscriptions.delete(id)
|
|
134
|
-
|
|
160
|
+
subscription.cancelSend?.()
|
|
161
|
+
if (sent) { try { this.send(JSON.stringify(['CLOSE', id])) } catch {} }
|
|
135
162
|
handlers.onclose?.()
|
|
136
163
|
}
|
|
137
|
-
|
|
138
|
-
|
|
164
|
+
const subscription = { filters, handlers, close, cancelSend: null }
|
|
165
|
+
this.#subscriptions.set(id, subscription)
|
|
166
|
+
subscription.cancelSend = this.#dispatchWork(() => {
|
|
167
|
+
this.send(JSON.stringify(['REQ', id, ...filters]))
|
|
168
|
+
sent = true
|
|
169
|
+
}, error => {
|
|
139
170
|
this.#subscriptions.delete(id)
|
|
140
|
-
|
|
141
|
-
}
|
|
171
|
+
handlers.onclose?.(error)
|
|
172
|
+
})
|
|
142
173
|
return { id, close }
|
|
143
174
|
}
|
|
144
175
|
|
|
145
|
-
publish (event) {
|
|
176
|
+
publish (event, { signal } = {}) {
|
|
146
177
|
if (!isValidEvent(event)) return Promise.reject(new Error('INVALID_EVENT'))
|
|
147
|
-
return this.#sendEventOperation('EVENT', event, this.#publishes, 'PUBLISH_TIMEOUT')
|
|
178
|
+
return this.#sendEventOperation('EVENT', event, this.#publishes, 'PUBLISH_TIMEOUT', signal)
|
|
148
179
|
}
|
|
149
180
|
|
|
150
|
-
async authenticate (getAuthEvent) {
|
|
181
|
+
async authenticate (getAuthEvent, { signal } = {}) {
|
|
151
182
|
if (!this.#challenge) throw new Error('AUTH_CHALLENGE_MISSING')
|
|
152
183
|
const event = await getAuthEvent({ relay: this.url, challenge: this.#challenge })
|
|
153
184
|
if (!isValidEvent(event)) throw new ValidationError('INVALID_AUTH_EVENT')
|
|
154
|
-
return await this.#sendEventOperation('AUTH', event, this.#authentications, 'AUTH_TIMEOUT')
|
|
185
|
+
return await this.#sendEventOperation('AUTH', event, this.#authentications, 'AUTH_TIMEOUT', signal)
|
|
155
186
|
}
|
|
156
187
|
|
|
157
|
-
#sendEventOperation (type, event, map, timeoutCode) {
|
|
188
|
+
#sendEventOperation (type, event, map, timeoutCode, signal) {
|
|
189
|
+
if (signal?.aborted) return Promise.reject(signal.reason || relayTimeoutError(timeoutCode))
|
|
158
190
|
if (map.has(event.id)) return map.get(event.id).promise
|
|
159
191
|
const deferred = Promise.withResolvers()
|
|
160
192
|
const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
|
|
161
|
-
|
|
162
|
-
|
|
193
|
+
const onAbort = () => this.#settleEvent(map, event.id, signal.reason || relayTimeoutError(timeoutCode))
|
|
194
|
+
const stopAbort = () => signal?.removeEventListener('abort', onAbort)
|
|
195
|
+
const pending = { ...deferred, timer, promise: deferred.promise, cancelSend: null, stopAbort }
|
|
196
|
+
map.set(event.id, pending)
|
|
197
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
198
|
+
pending.cancelSend = this.#dispatchWork(() => {
|
|
199
|
+
// Once transmitted, keep the existing ACK/report semantics. Before that,
|
|
200
|
+
// an operation-wide deadline must cancel the deferred publication.
|
|
201
|
+
stopAbort()
|
|
202
|
+
this.send(JSON.stringify([type, event]))
|
|
203
|
+
}, error => {
|
|
163
204
|
this.#settleEvent(map, event.id, error)
|
|
164
|
-
}
|
|
205
|
+
})
|
|
165
206
|
return deferred.promise
|
|
166
207
|
}
|
|
167
208
|
|
|
@@ -170,11 +211,12 @@ export class RelayConnection {
|
|
|
170
211
|
const id = `p2r2p-count:${++this.#serial}`
|
|
171
212
|
const deferred = Promise.withResolvers()
|
|
172
213
|
const onAbort = () => this.#settleCount(id, null, new Error('COUNT_ABORTED'))
|
|
173
|
-
|
|
214
|
+
const pending = { ...deferred, signal, onAbort, cancelSend: null }
|
|
215
|
+
this.#counts.set(id, pending)
|
|
174
216
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
175
|
-
|
|
217
|
+
pending.cancelSend = this.#dispatchWork(() => this.send(JSON.stringify(['COUNT', id, ...filters])), error => {
|
|
176
218
|
this.#settleCount(id, null, error)
|
|
177
|
-
}
|
|
219
|
+
})
|
|
178
220
|
return deferred.promise
|
|
179
221
|
}
|
|
180
222
|
|
|
@@ -182,6 +224,8 @@ export class RelayConnection {
|
|
|
182
224
|
const pending = map.get(id)
|
|
183
225
|
if (!pending) return
|
|
184
226
|
map.delete(id)
|
|
227
|
+
pending.cancelSend?.()
|
|
228
|
+
pending.stopAbort?.()
|
|
185
229
|
clearTimeout(pending.timer)
|
|
186
230
|
if (reason) pending.reject(errorFrom(reason, 'OPERATION_REJECTED'))
|
|
187
231
|
else pending.resolve(value)
|
|
@@ -191,6 +235,7 @@ export class RelayConnection {
|
|
|
191
235
|
const pending = this.#counts.get(id)
|
|
192
236
|
if (!pending) return
|
|
193
237
|
this.#counts.delete(id)
|
|
238
|
+
pending.cancelSend?.()
|
|
194
239
|
pending.signal?.removeEventListener('abort', pending.onAbort)
|
|
195
240
|
if (reason) pending.reject(errorFrom(reason, 'COUNT_REJECTED'))
|
|
196
241
|
else pending.resolve(payload)
|
|
@@ -219,14 +264,16 @@ export class RelayConnection {
|
|
|
219
264
|
if (data[0] === 'CLOSED') {
|
|
220
265
|
const id = data[1]
|
|
221
266
|
const subscription = this.#subscriptions.get(id)
|
|
267
|
+
const reason = this.#rejection(data[2], data[3], subscription ? 'SUBSCRIPTION_CLOSED' : 'COUNT_CLOSED')
|
|
222
268
|
if (subscription) {
|
|
223
269
|
this.#subscriptions.delete(id)
|
|
224
|
-
subscription.
|
|
225
|
-
|
|
270
|
+
subscription.cancelSend?.()
|
|
271
|
+
subscription.handlers.onclose?.(reason)
|
|
272
|
+
} else this.#settleCount(id, null, reason)
|
|
226
273
|
return
|
|
227
274
|
}
|
|
228
275
|
if (data[0] === 'OK') {
|
|
229
|
-
const reason = data[2] === true ? null :
|
|
276
|
+
const reason = data[2] === true ? null : this.#rejection(data[3], data[4], 'EVENT_REJECTED')
|
|
230
277
|
this.#settleEvent(this.#publishes, data[1], reason, data[3])
|
|
231
278
|
this.#settleEvent(this.#authentications, data[1], reason, data[3])
|
|
232
279
|
return
|
|
@@ -248,6 +295,7 @@ export class RelayConnection {
|
|
|
248
295
|
const reason = relayCloseError(event, 'transport', this.#lastTransportError)
|
|
249
296
|
for (const [id, subscription] of this.#subscriptions) {
|
|
250
297
|
this.#subscriptions.delete(id)
|
|
298
|
+
subscription.cancelSend?.()
|
|
251
299
|
subscription.handlers.onclose?.(reason)
|
|
252
300
|
}
|
|
253
301
|
for (const id of [...this.#publishes.keys()]) this.#settleEvent(this.#publishes, id, reason)
|
|
@@ -263,6 +311,7 @@ export class RelayConnection {
|
|
|
263
311
|
const reason = relayCloseError(null, 'transport', this.#lastTransportError)
|
|
264
312
|
for (const [id, subscription] of this.#subscriptions) {
|
|
265
313
|
this.#subscriptions.delete(id)
|
|
314
|
+
subscription.cancelSend?.()
|
|
266
315
|
subscription.handlers.onclose?.()
|
|
267
316
|
}
|
|
268
317
|
for (const id of [...this.#publishes.keys()]) this.#settleEvent(this.#publishes, id, reason)
|
|
@@ -233,21 +233,21 @@ export class RelayPool {
|
|
|
233
233
|
|
|
234
234
|
// NIP-42 retries happen inside one relay attempt, so sendEvent still reports
|
|
235
235
|
// exactly one terminal outcome for each relay URL.
|
|
236
|
-
async #publishEvent (relay, event, getAuthEvent) {
|
|
236
|
+
async #publishEvent (relay, event, getAuthEvent, signal) {
|
|
237
237
|
try {
|
|
238
|
-
await relay.publish(event)
|
|
238
|
+
await relay.publish(event, { signal })
|
|
239
239
|
return 'published'
|
|
240
240
|
} catch (error) {
|
|
241
241
|
const reason = error instanceof Error ? error : new Error(String(error))
|
|
242
242
|
if (!getAuthEvent || !requiresNip42Auth(reason)) throw reason
|
|
243
243
|
|
|
244
244
|
try {
|
|
245
|
-
await relay.authenticate(getAuthEvent)
|
|
245
|
+
await relay.authenticate(getAuthEvent, { signal })
|
|
246
246
|
} catch (error) {
|
|
247
247
|
const authReason = error instanceof Error ? error : new Error(String(error))
|
|
248
248
|
throw new Nip42AuthenticationError(authReason)
|
|
249
249
|
}
|
|
250
|
-
await relay.publish(event)
|
|
250
|
+
await relay.publish(event, { signal })
|
|
251
251
|
return 'published'
|
|
252
252
|
}
|
|
253
253
|
}
|
|
@@ -1144,12 +1144,14 @@ export class RelayPool {
|
|
|
1144
1144
|
const eventToSend = event.meta ? { ...event } : event
|
|
1145
1145
|
if (eventToSend.meta) delete eventToSend.meta
|
|
1146
1146
|
|
|
1147
|
+
const sendControllers = urls.map(() => new AbortController())
|
|
1147
1148
|
const sendDeferreds = urls.map(() => Promise.withResolvers())
|
|
1148
1149
|
const sendPromises = sendDeferreds.map(({ promise }) => promise)
|
|
1149
1150
|
|
|
1150
1151
|
// Starts before connection work so every relay shares one real deadline.
|
|
1151
1152
|
const settlement = createPublishSettlements(sendPromises, timeout, {
|
|
1152
1153
|
onSettled: (settlement, index) => {
|
|
1154
|
+
sendControllers[index].abort(settlement.reason)
|
|
1153
1155
|
if (settlement.reason?.category === 'timeout' && !settlement.reason.cause) {
|
|
1154
1156
|
const relay = this.#relays.get(normalizeRelayUrl(urls[index]))
|
|
1155
1157
|
if (relay?.lastTransportError) settlement.reason.cause = relay.lastTransportError
|
|
@@ -1171,7 +1173,9 @@ export class RelayPool {
|
|
|
1171
1173
|
;(async () => {
|
|
1172
1174
|
try {
|
|
1173
1175
|
const relay = await this.#getRelay(url)
|
|
1174
|
-
|
|
1176
|
+
const signal = sendControllers[index].signal
|
|
1177
|
+
if (signal.aborted) throw signal.reason
|
|
1178
|
+
return await this.#publishEvent(relay, eventToSend, getAuthEvent, signal)
|
|
1175
1179
|
} catch (err) {
|
|
1176
1180
|
const reason = err instanceof Error ? err : new Error(String(err))
|
|
1177
1181
|
if (reason instanceof Nip42AuthenticationError) throw reason
|
|
@@ -36,8 +36,9 @@ export function createTemporaryStorage ({ storageArea = globalThis.sessionStorag
|
|
|
36
36
|
|
|
37
37
|
function trackTemporaryKey (key) {
|
|
38
38
|
const tracked = readTrackedKeys()
|
|
39
|
-
if (tracked.includes(key)) return
|
|
39
|
+
if (tracked.includes(key)) return false
|
|
40
40
|
writeTrackedKeys(tracked.concat(key))
|
|
41
|
+
return true
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
function untrackTemporaryKeys (keys) {
|
|
@@ -57,8 +58,14 @@ export function createTemporaryStorage ({ storageArea = globalThis.sessionStorag
|
|
|
57
58
|
|
|
58
59
|
function setItem (key, value) {
|
|
59
60
|
if (typeof key !== 'string' || !key || key === TEMPORARY_STORAGE_KEYS_KEY) throw new ValidationError('INVALID_TEMPORARY_STORAGE_KEY')
|
|
60
|
-
trackTemporaryKey(key)
|
|
61
|
-
|
|
61
|
+
const newlyTracked = trackTemporaryKey(key)
|
|
62
|
+
try {
|
|
63
|
+
storage().setItem(key, value)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// A failed write is atomic; keep tracking any previous value.
|
|
66
|
+
if (newlyTracked && storage().getItem(key) === null) untrackTemporaryKeys([key])
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
function removeItems (keys) {
|