libp2r2p 0.11.6 → 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
 
@@ -965,3 +965,23 @@ latency, and short-lived channel/recipient preferences avoid recently refused
965
965
  relays without changing receive subscriptions. See the
966
966
  [session coordinator](private-messenger/session/README.md) for deferred offline
967
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.6",
3
+ "version": "0.11.7",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -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
  }
@@ -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'
@@ -13,16 +13,15 @@ import * as nip44v3 from '../nip44-v3/index.js'
13
13
  import { relayPool } from '../relay/index.js'
14
14
  import { JSONL_CHUNK_BYTES, NYM_CARRIER_CHUNK_CHARS } from './helpers/chunk-size.js'
15
15
  import {
16
- cleanupChunks,
17
16
  cleanupEnvelopeRows,
18
17
  decodeChunkLines,
19
18
  prepareEnvelopeRows,
20
19
  preparedRowIndexesForReceivers,
21
- readChunkContent,
22
20
  readPreparedRow,
23
21
  receiverPubkeys,
24
22
  receiverPubkeysWithoutContentKeys,
25
- writeChunksFromPreparedRows
23
+ preparedChunkCount,
24
+ preparedChunks
26
25
  } from './helpers/chunks.js'
27
26
  import { EXPIRATION_SECONDS, MAX_EVENT_BYTES, NYM_CARRIER_KIND, PRIVATE_BROADCAST_KIND, ROUTER_KIND } from './constants/index.js'
28
27
  import { eventByteLength, hasImkcTag, makeNymCarrierEvent, makeRouterEvent, nowSeconds, readChunkTag, readFileChunkIndex, readIdTag, readImkcProof, readImkcTag, readReceiverTag, readSenderTag } from './helpers/event.js'
@@ -121,15 +120,20 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
121
120
  rowScope: channelPubkey,
122
121
  temporaryStorageArea
123
122
  })
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
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
133
137
  }
134
138
  }
135
139
 
@@ -139,45 +143,36 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
139
143
  const receiverPubkeyList = receiverPubkeys(receivers)
140
144
  const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
141
145
  const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
142
- const {
143
- id,
144
- total
145
- } = writeChunksFromPreparedRows(context.preparedRows, rowIndexes)
146
- const temporaryStorage = context.preparedRows.temporaryStorage
147
-
148
- try {
149
- if (onPreparedSeed) {
150
- const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
151
- const payloadRow = readPreparedRow(context.preparedRows, 0)
152
- for (const rowIndex of rowIndexes) {
153
- await onPreparedSeed({ channelPubkey: context.channelPubkey, router, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` })
154
- }
155
- }
156
- for (let index = 0; index < total; index++) {
157
- const content = readChunkContent(id, index, temporaryStorage)
158
- const router = finalizeEvent(makeRouterEvent({
159
- pubkey: routerPubkey,
160
- senderPubkey: context.senderPubkey,
161
- imkcPubkey: context.imkcPubkey,
162
- imkcProof: context.imkcProof,
163
- receiverPubkey: routerReceiverTag,
164
- fileChunkIndex,
165
- chunkIndex: index,
166
- chunkTotal: total,
167
- content
168
- }), routerSeckey)
169
- const createdAt = nowSeconds()
170
- const outer = await privateChannelSigner.signEvent({
171
- kind: PRIVATE_BROADCAST_KIND,
172
- created_at: createdAt,
173
- tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
174
- content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
175
- })
176
- if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
177
- yield outer
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` })
178
152
  }
179
- } finally {
180
- 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
181
176
  }
182
177
  }
183
178
 
@@ -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) {