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 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 and router chunks in `sessionStorage`. They are
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.5",
3
+ "version": "0.11.7",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -22,6 +22,7 @@
22
22
  "ecdh",
23
23
  "error",
24
24
  "event",
25
+ "helpers",
25
26
  "idb",
26
27
  "idb-queue",
27
28
  "index.js",
@@ -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
- ;({ chunk, chunkIndex } = appendRow(chunk, readPreparedRow(preparedRows, 0), id, chunkIndex, temporaryStorage))
259
- for (const rowIndex of rowIndexes) {
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 + 1, temporaryStorage)
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'
@@ -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
- writeChunksFromPreparedRows
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
- const imkcPubkey = preparedRows.ownContentPubkey || ''
124
- const imkcProof = imkcPubkey ? await makeImkcProof({ senderSigner, senderPubkey, imkcPubkey }) : ''
125
- return {
126
- senderPubkey,
127
- channelPubkey,
128
- channelReaderPubkey,
129
- preparedRows,
130
- imkcPubkey,
131
- imkcProof
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
- id,
143
- total
144
- } = writeChunksFromPreparedRows(context.preparedRows, rowIndexes)
145
- const temporaryStorage = context.preparedRows.temporaryStorage
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
- for (let index = 0; index < total; index++) {
156
- const content = readChunkContent(id, index, temporaryStorage)
157
- const router = finalizeEvent(makeRouterEvent({
158
- pubkey: routerPubkey,
159
- senderPubkey: context.senderPubkey,
160
- imkcPubkey: context.imkcPubkey,
161
- imkcProof: context.imkcProof,
162
- receiverPubkey: routerReceiverTag,
163
- fileChunkIndex,
164
- chunkIndex: index,
165
- chunkTotal: total,
166
- content
167
- }), routerSeckey)
168
- const createdAt = nowSeconds()
169
- const outer = await privateChannelSigner.signEvent({
170
- kind: PRIVATE_BROADCAST_KIND,
171
- created_at: createdAt,
172
- tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
173
- content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
174
- })
175
- if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
176
- yield outer
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, fetchedEvents) {
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 fetchedEvents = await this._privateChannel.fetch({
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, fetchedEvents)
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: 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
- chunk += `${JSON.stringify(record)}\n`
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
- if (flush && chunkEvents >= eventsPerChunk) await publish(false)
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
  })
@@ -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
- try { this.send(JSON.stringify(['CLOSE', id])) } catch {}
160
+ subscription.cancelSend?.()
161
+ if (sent) { try { this.send(JSON.stringify(['CLOSE', id])) } catch {} }
135
162
  handlers.onclose?.()
136
163
  }
137
- this.#subscriptions.set(id, { filters, handlers, close })
138
- try { this.send(JSON.stringify(['REQ', id, ...filters])) } catch (error) {
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
- throw error
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
- map.set(event.id, { ...deferred, timer, promise: deferred.promise })
162
- try { this.send(JSON.stringify([type, event])) } catch (error) {
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
- this.#counts.set(id, { ...deferred, signal, onAbort })
214
+ const pending = { ...deferred, signal, onAbort, cancelSend: null }
215
+ this.#counts.set(id, pending)
174
216
  signal?.addEventListener('abort', onAbort, { once: true })
175
- try { this.send(JSON.stringify(['COUNT', id, ...filters])) } catch (error) {
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.handlers.onclose?.(errorFrom(data[2], 'SUBSCRIPTION_CLOSED'))
225
- } else this.#settleCount(id, null, errorFrom(data[2], 'COUNT_CLOSED'))
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 : categorizeRelayError(data[3], 'relay', 'EVENT_REJECTED')
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
- return await this.#publishEvent(relay, eventToSend, getAuthEvent)
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
- storage().setItem(key, value)
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) {