libp2r2p 0.11.6 → 0.11.8

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,59 @@ 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.
988
+
989
+
990
+ ### Private subscription recovery (0.11.8)
991
+
992
+ `privateChannel.subscribe()` returns `{ ready, done, close }`. `done` always
993
+ resolves to `{ status: 'closed' | 'ended' | 'failed', error? }` after processing
994
+ stops. Explicit cancellation is `closed`; a naturally exhausted stream is
995
+ `ended`; a terminal error is `failed` and still reaches `onError`.
996
+
997
+ `private-message.watch()` accepts an optional asynchronous
998
+ `onSubscriptionState({ state, relay, since, until?, error? })` callback.
999
+ `interrupted` identifies the conservative lower recovery boundary; after a
1000
+ buffer overflow, every affected channel must finish this callback before its
1001
+ shared relay subscription reopens. `ready` follows successful initial relay
1002
+ readiness on the replacement. Callbacks and completions are generation-fenced.
1003
+ Equal watch settings cannot keep a dead subscription registered as active.
1004
+
1005
+ Overflow recovery keeps one timer per relay with exponential 1–30 second
1006
+ backoff and 20% jitter, reset after a stable minute. Other relays remain live.
1007
+ Removal, pause and close cancel pending work. Fragment storage stays scoped to
1008
+ the same session across replacement subscriptions. Permanent refusals remain
1009
+ reported and do not trigger this automatic retry loop.
1010
+
1011
+ PrivateMessenger persists each interrupted interval before reopening, then
1012
+ uses paged `fetchHistory` to repair the gap while listening for new messages.
1013
+ Transient history failures retry with bounded backoff and honor `retryAt`;
1014
+ dense-page limits, permanent refusals and unknown failures stay pending without
1015
+ an automatic loop. Disabled historical recovery restores only live input.
1016
+ Existing persisted ranges, checkpoints, recovery windows and ack/nack semantics
1017
+ are unchanged; newer live arrivals cannot erase a pending interval.
1018
+
1019
+ `RELAY_LIVE_BUFFER_FULL` retains `relay`, `phase`, `recoverySince` and a bounded
1020
+ `buffer` diagnostic: stage (`delivery`, `reconnect`, `history-wait`), queued
1021
+ count/bytes, incoming bytes, limits and oldest queued age. Queue bytes are UTF-8
1022
+ serialized volume, not heap use. Diagnostics never retain event contents or
1023
+ filters. Existing 1,000-item / 8-MiB live limits are unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.6",
3
+ "version": "0.11.8",
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', 'phase', 'buffer', 'recoverySince']) {
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
 
@@ -989,6 +984,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
989
984
  })
990
985
 
991
986
  async function consumeEvents () {
987
+ let result = { status: 'ended' }
992
988
  try {
993
989
  for await (const item of events) {
994
990
  if (controller.signal.aborted) continue
@@ -996,19 +992,26 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
996
992
  else if (item.type === 'event') await processOuterEvent(item.event)
997
993
  }
998
994
  } catch (error) {
999
- if (!controller.signal.aborted && error?.message !== 'Aborted') onError?.(error)
995
+ if (!controller.signal.aborted) {
996
+ const diagnostic = subscriptionError(error, error?.relay)
997
+ result = { status: 'failed', error: diagnostic }
998
+ // Consumer error callbacks must not turn `done` into an unhandled rejection.
999
+ try { onError?.(diagnostic) } catch {}
1000
+ }
1000
1001
  } finally {
1001
1002
  processOuterEvent.close()
1002
1003
  }
1004
+ return controller.signal.aborted ? { status: 'closed' } : result
1003
1005
  }
1004
1006
 
1005
- const consumePromise = consumeEvents()
1007
+ const done = consumeEvents()
1006
1008
 
1007
1009
  return {
1008
1010
  ready: events.ready || Promise.resolve(),
1011
+ done,
1009
1012
  close () {
1010
1013
  controller.abort()
1011
- return consumePromise
1014
+ return done
1012
1015
  }
1013
1016
  }
1014
1017
  }
@@ -1,3 +1,4 @@
1
+ import { normalizeRelayUrl } from '../url/index.js'
1
2
  import { isValidEvent } from '../event/index.js'
2
3
  import { ValidationError } from '../error/index.js'
3
4
  import { generateKeypair } from '../key/index.js'
@@ -144,9 +145,10 @@ async function ownPrivateChannelPubkey (signer) {
144
145
 
145
146
  // A session owns its callbacks and relay reads. Other consumers, including
146
147
  // the other side of the same shared channel, cannot overwrite or stop them.
147
- export function createPrivateMessageSession () {
148
+ export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearTimeout = clearTimeout, _now = Date.now, _random = Math.random } = {}) {
148
149
  const watchesByChannel = new Map()
149
150
  const subsByRelay = new Map()
151
+ const relayRecoveries = new Map()
150
152
  let nextWatchRevision = 1
151
153
  let requestSequence = 0
152
154
  const watchRequests = new Map()
@@ -301,11 +303,113 @@ export function createPrivateMessageSession () {
301
303
  }
302
304
  }
303
305
 
304
- function rebuildSubscriptions ({ _subscribe = privateChannel.subscribe, gracefulClose = true } = {}) {
306
+ function reportSubscriptionError (channels, error) {
307
+ try { watchCallbacks([...channels][0]).onError?.(error) } catch {}
308
+ }
309
+
310
+ function cancelRelayRecovery (relay) {
311
+ const recovery = relayRecoveries.get(relay)
312
+ if (recovery?.timer != null) _clearTimeout(recovery.timer)
313
+ relayRecoveries.delete(relay)
314
+ }
315
+
316
+ async function notifyInterruption (relay, recovery) {
317
+ const channels = desiredRelayState().get(relay) || []
318
+ // Each consumer must persist its gap before this shared subscription restarts.
319
+ // Keep acknowledgments on partial failure; new/replaced watches participate too.
320
+ const outcomes = await Promise.allSettled([...channels].map(async channel => {
321
+ const watch = watchesByChannel.get(channel)
322
+ if (recovery.notified.get(channel) === watch) return
323
+ await watch.callbacks.onSubscriptionState?.({ state: 'interrupted', relay, error: recovery.error, since: recovery.since })
324
+ recovery.notified.set(channel, watch)
325
+ }))
326
+ const failed = outcomes.find(result => result.status === 'rejected')
327
+ if (failed) throw failed.reason
328
+ if (relayRecoveries.get(relay) === recovery && [...(desiredRelayState().get(relay) || [])].some(channel => recovery.notified.get(channel) !== watchesByChannel.get(channel))) {
329
+ await notifyInterruption(relay, recovery)
330
+ }
331
+ }
332
+
333
+ function scheduleRelayRecovery (relay, recovery) {
334
+ if (relayRecoveries.get(relay) !== recovery || recovery.timer || !desiredRelayState().has(relay)) return
335
+ const delay = Math.min(30000, recovery.delay * (0.8 + _random() * 0.4))
336
+ recovery.delay = Math.min(30000, recovery.delay * 2)
337
+ recovery.timer = _setTimeout(async () => {
338
+ recovery.timer = null
339
+ try {
340
+ await notifyInterruption(relay, recovery)
341
+ if (relayRecoveries.get(relay) !== recovery || !desiredRelayState().has(relay)) return
342
+ await rebuildSubscriptions({ retryRelay: relay })
343
+ } catch (error) {
344
+ reportSubscriptionError(desiredRelayState().get(relay) || [], error)
345
+ scheduleRelayRecovery(relay, recovery)
346
+ }
347
+ }, delay)
348
+ recovery.timer?.unref?.()
349
+ }
350
+
351
+ function subscriptionEnded (relay, entry, result) {
352
+ if (subsByRelay.get(relay) !== entry) return
353
+ subsByRelay.delete(relay)
354
+ if (result.status === 'closed' || !desiredRelayState().has(relay)) return
355
+ const error = result.error || Object.assign(new Error('PRIVATE_CHANNEL_SUBSCRIPTION_ENDED'), { code: 'PRIVATE_CHANNEL_SUBSCRIPTION_ENDED', relay })
356
+ const previous = relayRecoveries.get(relay)
357
+ const recovery = {
358
+ error,
359
+ since: Math.min(entry.since, error.recoverySince ?? entry.since, previous?.since ?? Infinity),
360
+ delay: entry.readyAt !== undefined && _now() - entry.readyAt >= 60000 ? 1000 : (previous?.delay || entry.retryDelay || 1000),
361
+ notified: new Map(), timer: null
362
+ }
363
+ if (/^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(error.message || '') || error.name === 'ValidationError' ||
364
+ (error.code !== 'RELAY_LIVE_BUFFER_FULL' && !previous)) {
365
+ cancelRelayRecovery(relay)
366
+ notifyInterruption(relay, recovery).catch(error => reportSubscriptionError(entry.channels, error))
367
+ return
368
+ }
369
+ cancelRelayRecovery(relay)
370
+ relayRecoveries.set(relay, recovery)
371
+ // Start persistence immediately, without overlapping a later retry callback.
372
+ recovery.preparing = notifyInterruption(relay, recovery).catch(error => reportSubscriptionError(entry.channels, error))
373
+ recovery.preparing.finally(() => scheduleRelayRecovery(relay, recovery))
374
+ }
375
+
376
+ async function subscriptionReady (relay, entry, ready) {
377
+ if (subsByRelay.get(relay) !== entry) return
378
+ const recovery = relayRecoveries.get(relay)
379
+ if (!ready?.relays?.includes(relay)) {
380
+ if (recovery) {
381
+ const error = ready?.errors?.[0]?.reason || Object.assign(new Error('RELAY_LIVE_NOT_READY'), { code: 'RELAY_LIVE_NOT_READY', relay })
382
+ subscriptionEnded(relay, entry, { status: 'failed', error })
383
+ await entry.sub.close()
384
+ }
385
+ return
386
+ }
387
+ entry.readyAt = _now()
388
+ if (!recovery) return
389
+ try {
390
+ const until = Math.floor(_now() / 1000)
391
+ await Promise.all([...entry.channels].map(async channel => {
392
+ if (subsByRelay.get(relay) !== entry || !watchesByChannel.has(channel)) return
393
+ await watchCallbacks(channel).onSubscriptionState?.({ state: 'ready', relay, since: recovery.since, until })
394
+ }))
395
+ if (subsByRelay.get(relay) === entry && relayRecoveries.get(relay) === recovery) {
396
+ entry.retryDelay = recovery.delay
397
+ cancelRelayRecovery(relay)
398
+ await rebuildSubscriptions()
399
+ }
400
+ } catch (error) {
401
+ reportSubscriptionError(entry.channels, error)
402
+ subscriptionEnded(relay, entry, { status: 'failed', error })
403
+ await entry.sub.close()
404
+ }
405
+ }
406
+
407
+ function rebuildSubscriptions ({ _subscribe = privateChannel.subscribe, gracefulClose = true, retryRelay } = {}) {
305
408
  subscribe = _subscribe === privateChannel.subscribe ? subscribe : _subscribe
306
409
  _subscribe = subscribe
307
410
  const desired = desiredRelayState()
308
411
  const closing = []
412
+ for (const relay of relayRecoveries.keys()) if (!desired.has(relay)) cancelRelayRecovery(relay)
309
413
 
310
414
  for (const [relay, current] of subsByRelay) {
311
415
  const nextChannels = desired.get(relay)
@@ -321,6 +425,8 @@ export function createPrivateMessageSession () {
321
425
  const current = subsByRelay.get(relay)
322
426
  if (doesSubscriptionMatch(current, channels)) continue
323
427
 
428
+ if (relayRecoveries.has(relay) && retryRelay !== relay) continue
429
+ const since = nowSeconds()
324
430
  const channelList = [...channels]
325
431
  const firstWatch = watchesByChannel.get(channelList[0])
326
432
  const sub = _subscribe({
@@ -345,7 +451,7 @@ export function createPrivateMessageSession () {
345
451
  ignoredGroupTtlMs: maxWatchNumber(channelList, 'ignoredGroupTtlMs'),
346
452
  ignoredGroupMaxEntries: maxWatchNumber(channelList, 'ignoredGroupMaxEntries'),
347
453
  limit: 0,
348
- since: nowSeconds(),
454
+ since,
349
455
  liveOnly: true,
350
456
  onChunk: handleChunk,
351
457
  onEvent: (event, outer, meta) => {
@@ -358,14 +464,24 @@ export function createPrivateMessageSession () {
358
464
  return dispatchSeedEvent(seed)
359
465
  },
360
466
  onContentKeyUsage: dispatchContentKeyUsage,
361
- onError: err => firstWatch.callbacks.onError?.(err)
467
+ onError: err => reportSubscriptionError(channels, err)
362
468
  })
363
469
 
364
- subsByRelay.set(relay, {
470
+ const entry = {
471
+ since,
365
472
  channels: new Set(channels),
366
473
  revisions: watchRevisionsForChannels(channelList),
367
474
  sub
368
- })
475
+ }
476
+ subsByRelay.set(relay, entry)
477
+ if (sub.done) sub.done.then(result => subscriptionEnded(relay, entry, result)).catch(error => reportSubscriptionError(channels, error))
478
+ if (sub.ready) {
479
+ sub.ready.then(ready => subscriptionReady(relay, entry, ready)).catch(error => {
480
+ reportSubscriptionError(channels, error)
481
+ subscriptionEnded(relay, entry, { status: 'failed', error })
482
+ Promise.resolve(sub.close()).catch(() => {})
483
+ })
484
+ }
369
485
  if (current) {
370
486
  const close = closeSubscription(current.sub, gracefulClose)
371
487
  if (close) closing.push(close)
@@ -393,6 +509,7 @@ export function createPrivateMessageSession () {
393
509
  onSeed,
394
510
  onChunk,
395
511
  onContentKeyUsage,
512
+ onSubscriptionState,
396
513
  onError,
397
514
  receivedChunkTtlMs,
398
515
  receivedChunkMaxBytes,
@@ -403,6 +520,7 @@ export function createPrivateMessageSession () {
403
520
  _subscribe = privateChannel.subscribe
404
521
  }) {
405
522
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
523
+ relays = uniq(relays.map(normalizeRelayUrl))
406
524
  const request = ++requestSequence
407
525
  const channelList = uniq(channels?.length ? channels : [await ownPrivateChannelPubkey(privateChannelSigner)])
408
526
  for (const channel of channelList) {
@@ -411,9 +529,8 @@ export function createPrivateMessageSession () {
411
529
  const ownPubkey = receiverPubkey || await receiverSigner?.getPublicKey?.()
412
530
  if (identity !== undefined && identity !== ownPubkey) throw new ValidationError('PRIVATE_MESSAGE_IDENTITY_MISMATCH')
413
531
  identity = ownPubkey
414
- const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onError }
532
+ const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onSubscriptionState, onError }
415
533
 
416
- let changed = false
417
534
  for (const channel of channelList) {
418
535
  if (watchRequests.get(channel) !== request) continue
419
536
  const next = {
@@ -457,10 +574,9 @@ export function createPrivateMessageSession () {
457
574
  continue
458
575
  }
459
576
  watchesByChannel.set(channel, next)
460
- changed = true
461
577
  }
462
578
 
463
- if (changed) await rebuildSubscriptions({ _subscribe })
579
+ await rebuildSubscriptions({ _subscribe })
464
580
  return () => {
465
581
  const owned = channelList.filter(channel => watchRequests.get(channel) === request)
466
582
  return unwatch(owned)
@@ -0,0 +1,26 @@
1
+ // Retry operational interruptions, never dense-page limits or permanent refusals.
2
+ export function recoveryRetryDelay (errors, delay) {
3
+ let retry = false
4
+ let blocked = false
5
+ let retryAt = 0
6
+ const seen = new Set()
7
+ function visit (error) {
8
+ if (!error || seen.has(error)) return
9
+ seen.add(error)
10
+ if (error.code === 'PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT' || error.name === 'ValidationError' ||
11
+ /^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(error.message || '')) blocked = true
12
+ if (['transport', 'timeout'].includes(error.category) ||
13
+ ['QuotaExceededError', 'InvalidStateError', 'UnknownError'].includes(error.name) ||
14
+ /^(rate-limited:|error:)/.test(error.message || '') ||
15
+ ['RELAY_LIVE_BUFFER_FULL', 'RELAY_LIVE_NOT_READY'].includes(error.code)) retry = true
16
+ if (Number.isFinite(error.retryAt)) retryAt = Math.max(retryAt, Math.min(Date.now() + 300000, error.retryAt))
17
+ for (const child of error.errors || []) visit(child)
18
+ for (const entry of error.relays || []) {
19
+ if (['timeout', 'cutoff', 'closed'].includes(entry.status)) retry = true
20
+ visit(entry.error)
21
+ }
22
+ visit(error.cause)
23
+ }
24
+ errors.forEach(visit)
25
+ return retry && !blocked ? Math.max(delay, retryAt - Date.now()) : null
26
+ }
@@ -1,3 +1,4 @@
1
+ import { recoveryRetryDelay } from './helpers/recovery-retry.js'
1
2
  // Expected use:
2
3
  // const messenger = await createPrivateMessenger({
3
4
  // userSigner,
@@ -293,6 +294,8 @@ export class PrivateMessenger {
293
294
  this.capacityCheck = null
294
295
  this.capacityRequiredBytes = 0
295
296
  this.reloadGapTimers = new Map()
297
+ this.liveInterruptions = new Map()
298
+ this.liveRecoveryTimers = new Map()
296
299
  this.watchRevisionByChannel = new Map()
297
300
  this.presenceTimers = new Map()
298
301
  this.stopRelayListWatcher = null
@@ -1103,6 +1106,7 @@ export class PrivateMessenger {
1103
1106
  onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
1104
1107
  receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1105
1108
  receivedChunkIndexedDB: this._indexedDB,
1109
+ onSubscriptionState: state => this.handleSubscriptionState(pubkey, state),
1106
1110
  onError: err => this.onError?.(err)
1107
1111
  })
1108
1112
  if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) {
@@ -1136,6 +1140,76 @@ export class PrivateMessenger {
1136
1140
  return this
1137
1141
  }
1138
1142
 
1143
+ async handleSubscriptionState (pubkey, { state, relay, since, until }) {
1144
+ if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey)) return
1145
+ if (!this.offlineRecoverySecondsFor(pubkey)) return
1146
+ let interrupted = this.liveInterruptions.get(pubkey)
1147
+ if (state === 'interrupted') {
1148
+ if (!interrupted) { interrupted = new Map(); this.liveInterruptions.set(pubkey, interrupted) }
1149
+ const start = Math.min(interrupted.get(relay) ?? Infinity, since)
1150
+ interrupted.set(relay, start)
1151
+ this.cancelReloadGap(pubkey)
1152
+ for (const controller of this.recoveryControllers) {
1153
+ if (controller.channelPubkey === pubkey) { controller.liveInterrupted = true; controller.abort() }
1154
+ }
1155
+ this.recordInterruption([pubkey], start)
1156
+ this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
1157
+ // This rejection prevents the session from reopening the shared relay.
1158
+ await this.flushStateWrites()
1159
+ } else if (state === 'ready' && interrupted?.has(relay)) {
1160
+ const start = interrupted.get(relay)
1161
+ this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), until)
1162
+ if (interrupted.size === 1) this.closeOpenOfflineRanges([pubkey])
1163
+ await this.flushStateWrites()
1164
+ if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey) || this.liveInterruptions.get(pubkey) !== interrupted) return
1165
+ interrupted.delete(relay)
1166
+ if (!interrupted.size) this.liveInterruptions.delete(pubkey)
1167
+ this.scheduleLiveRecovery(pubkey)
1168
+ }
1169
+ }
1170
+
1171
+ cancelLiveRecovery (pubkey) {
1172
+ const work = this.liveRecoveryTimers.get(pubkey)
1173
+ if (work?.timer != null) this._clearTimeout(work.timer)
1174
+ this.liveRecoveryTimers.delete(pubkey)
1175
+ }
1176
+
1177
+ scheduleLiveRecovery (pubkey) {
1178
+ const current = this.liveRecoveryTimers.get(pubkey)
1179
+ if (current) { current.again = true; return }
1180
+ const work = { delay: 1000, timer: null, again: false }
1181
+ this.liveRecoveryTimers.set(pubkey, work)
1182
+ const isCurrent = () => this.liveRecoveryTimers.get(pubkey) === work && !this.closePromise &&
1183
+ !this.pauseReasons.size && this.desiredChannels.has(pubkey)
1184
+ const schedule = delay => {
1185
+ work.timer = this._setTimeout(async () => {
1186
+ work.timer = null
1187
+ if (!isCurrent()) return
1188
+ work.again = false
1189
+ let errors
1190
+ try {
1191
+ // A ready notification may overtake cancellation of the previous scan.
1192
+ // Let that scan retain its range, then start a fresh recovery attempt.
1193
+ await this.recoveries.get(pubkey)
1194
+ if (!isCurrent()) return
1195
+ errors = await this.recoverOfflineRanges([pubkey])
1196
+ } catch (error) {
1197
+ this.onError?.(error)
1198
+ errors = [error]
1199
+ }
1200
+ if (!isCurrent()) return
1201
+ const pending = this.readState().channels[pubkey]?.offlineRanges?.length
1202
+ const retry = pending ? recoveryRetryDelay(errors, Math.min(30000, work.delay * (0.8 + Math.random() * 0.4))) : null
1203
+ if (work.again || retry !== null) {
1204
+ schedule(work.again ? 0 : retry)
1205
+ work.delay = Math.min(30000, work.delay * 2)
1206
+ } else this.liveRecoveryTimers.delete(pubkey)
1207
+ }, delay)
1208
+ work.timer?.unref?.()
1209
+ }
1210
+ schedule(0)
1211
+ }
1212
+
1139
1213
  recordInterruption (channels, at = nowSeconds()) {
1140
1214
  const state = this.readState()
1141
1215
  for (const pubkey of channels) {
@@ -1152,6 +1226,8 @@ export class PrivateMessenger {
1152
1226
  const closing = []
1153
1227
  for (const pubkey of channels) {
1154
1228
  this.cancelReloadGap(pubkey)
1229
+ this.cancelLiveRecovery(pubkey)
1230
+ this.liveInterruptions.delete(pubkey)
1155
1231
  this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
1156
1232
  const close = this.stopByChannel.get(pubkey)?.()
1157
1233
  if (close?.then) closing.push(close)
@@ -2063,6 +2139,7 @@ export class PrivateMessenger {
2063
2139
  }
2064
2140
 
2065
2141
  async recoverOfflineRanges (channels = [...this.stopByChannel.keys()]) {
2142
+ const failures = []
2066
2143
  for (const pubkey of uniq(channels)) {
2067
2144
  if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) continue
2068
2145
  let work = this.recoveries.get(pubkey)
@@ -2070,11 +2147,13 @@ export class PrivateMessenger {
2070
2147
  work = this.recoverChannelRanges([pubkey])
2071
2148
  this.recoveries.set(pubkey, work)
2072
2149
  }
2073
- try { await work } finally { if (this.recoveries.get(pubkey) === work) this.recoveries.delete(pubkey) }
2150
+ try { failures.push(...await work) } finally { if (this.recoveries.get(pubkey) === work) this.recoveries.delete(pubkey) }
2074
2151
  }
2152
+ return failures
2075
2153
  }
2076
2154
 
2077
2155
  async recoverChannelRanges (channels) {
2156
+ const failures = []
2078
2157
  const state = this.readState()
2079
2158
  const now = nowSeconds()
2080
2159
 
@@ -2125,12 +2204,14 @@ export class PrivateMessenger {
2125
2204
  })
2126
2205
  controller.signal.throwIfAborted()
2127
2206
  const attempt = await this.#askSeedersForRelayLeftEdgeAttempt(pubkey, range, history.oldestCreatedAt)
2128
- const lifecycleChanged = this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
2207
+ const lifecycleChanged = controller.signal.aborted || this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
2129
2208
  (this.watchRevisionByChannel.get(pubkey) || 0) !== watchRevision
2209
+ failures.push(...attempt.failures.map(failure => failure.error))
2130
2210
  if (lifecycleChanged || attempt.failures.length) remaining.push(range)
2131
2211
  else recoveredThrough = Math.max(recoveredThrough, range.end)
2132
2212
  } catch (err) {
2133
- this.onError?.(err)
2213
+ if (!(controller.liveInterrupted && err === controller.signal.reason)) this.onError?.(err)
2214
+ failures.push(err)
2134
2215
  remaining.push(range)
2135
2216
  } finally { this.recoveryControllers.delete(controller) }
2136
2217
  }
@@ -2145,6 +2226,7 @@ export class PrivateMessenger {
2145
2226
  this.writeState(fresh)
2146
2227
  await this.flushStateWrites()
2147
2228
  }
2229
+ return failures
2148
2230
  }
2149
2231
 
2150
2232
  async clearChannel (pubkey) {
@@ -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
+ }
@@ -0,0 +1,15 @@
1
+ // Bounded diagnostics only: never attach queued payloads or subscription filters.
2
+ export function liveBufferError ({ relay, stage, queue, queuedBytes, incoming, incomingBytes, maxEvents, maxBytes, since }) {
3
+ const now = Date.now()
4
+ const boundaries = [since, incoming?.event?.created_at, ...queue.map(entry => entry.item?.event?.created_at)]
5
+ .filter(value => Number.isFinite(value) && value >= 0)
6
+ return Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), {
7
+ code: 'RELAY_LIVE_BUFFER_FULL', relay, phase: 'live-buffer',
8
+ recoverySince: Math.min(Math.floor(now / 1000), ...boundaries),
9
+ buffer: {
10
+ stage, queuedEvents: queue.length, queuedBytes, incomingBytes,
11
+ limits: { events: maxEvents, bytes: maxBytes },
12
+ oldestQueuedMs: queue.length ? Math.max(0, now - queue[0].receivedAt) : 0
13
+ }
14
+ })
15
+ }
@@ -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)
@@ -1,3 +1,4 @@
1
+ import { liveBufferError } from '../helpers/live-buffer-error.js'
1
2
  import { ValidationError } from '../../error/index.js'
2
3
  import { decodeHll, encodeHll, estimateHllCount, mergeHll } from '../helpers/hll.js'
3
4
  import { createPublishSettlements, firstFulfillment, publishSummary } from '../helpers/publish.js'
@@ -233,21 +234,21 @@ export class RelayPool {
233
234
 
234
235
  // NIP-42 retries happen inside one relay attempt, so sendEvent still reports
235
236
  // exactly one terminal outcome for each relay URL.
236
- async #publishEvent (relay, event, getAuthEvent) {
237
+ async #publishEvent (relay, event, getAuthEvent, signal) {
237
238
  try {
238
- await relay.publish(event)
239
+ await relay.publish(event, { signal })
239
240
  return 'published'
240
241
  } catch (error) {
241
242
  const reason = error instanceof Error ? error : new Error(String(error))
242
243
  if (!getAuthEvent || !requiresNip42Auth(reason)) throw reason
243
244
 
244
245
  try {
245
- await relay.authenticate(getAuthEvent)
246
+ await relay.authenticate(getAuthEvent, { signal })
246
247
  } catch (error) {
247
248
  const authReason = error instanceof Error ? error : new Error(String(error))
248
249
  throw new Nip42AuthenticationError(authReason)
249
250
  }
250
- await relay.publish(event)
251
+ await relay.publish(event, { signal })
251
252
  return 'published'
252
253
  }
253
254
  }
@@ -651,18 +652,23 @@ export class RelayPool {
651
652
  const seenIds = new Set()
652
653
 
653
654
  let untilTimer = null
654
- const overflow = relay => {
655
- failure = Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), { code: 'RELAY_LIVE_BUFFER_FULL', relay, phase: 'live-buffer' })
655
+ const overflow = (item, bytes, stage = 'delivery', entries = queue, volume = queuedBytes) => {
656
+ if (isDone) return
657
+ failure = liveBufferError({
658
+ relay: item.relay, stage, queue: entries, queuedBytes: volume, incoming: item, incomingBytes: bytes,
659
+ maxEvents: maxBufferedLiveEvents, maxBytes: maxBufferedLiveBytes,
660
+ since: Math.min(sinceFloor, ...pendingGaps.values(), ...queue.map(entry => entry.item?.event?.created_at).filter(Number.isFinite))
661
+ })
656
662
  teardown()
657
663
  }
658
664
  const enqueue = item => {
659
665
  const bytes = encoder.encode(JSON.stringify(item)).byteLength
660
666
  if (queue.length >= maxBufferedLiveEvents || queuedBytes + bytes > maxBufferedLiveBytes) {
661
- overflow(item.relay)
667
+ overflow(item, bytes)
662
668
  return
663
669
  }
664
670
  queuedBytes += bytes
665
- queue.push({ item, bytes })
671
+ queue.push({ item, bytes, receivedAt: Date.now() })
666
672
  p.resolve()
667
673
  p = Promise.withResolvers()
668
674
  }
@@ -863,11 +869,11 @@ export class RelayPool {
863
869
  if (liveBuffer) {
864
870
  const bytes = encoder.encode(JSON.stringify(event)).byteLength
865
871
  if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
866
- overflow(url)
872
+ overflow({ event, relay: url }, bytes, 'reconnect', liveBuffer, bufferedBytes)
867
873
  return
868
874
  }
869
875
  bufferedBytes += bytes
870
- liveBuffer.push(event)
876
+ liveBuffer.push({ item: { event, relay: url }, receivedAt: Date.now() })
871
877
  } else pushEvent(event, url)
872
878
  },
873
879
  onclose: error => {
@@ -906,7 +912,7 @@ export class RelayPool {
906
912
  }).finally(() => {
907
913
  const buf = liveBuffer
908
914
  liveBuffer = null
909
- for (const event of buf) pushEvent(event, url, true)
915
+ for (const entry of buf) pushEvent(entry.item.event, url, true)
910
916
  gapTasks.delete(task)
911
917
  scheduleProgress(attempt)
912
918
  p.resolve()
@@ -1052,9 +1058,9 @@ export class RelayPool {
1052
1058
  else {
1053
1059
  const bytes = encoder.encode(JSON.stringify(item)).byteLength
1054
1060
  if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
1055
- throw Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), { code: 'RELAY_LIVE_BUFFER_FULL', relay: item.relay, phase: 'live-buffer' })
1061
+ throw liveBufferError({ relay: item.relay, stage: 'history-wait', queue: liveBuffer, queuedBytes: bufferedBytes, incoming: item, incomingBytes: bytes, maxEvents: maxBufferedLiveEvents, maxBytes: maxBufferedLiveBytes, since: filter.since ?? 0 })
1056
1062
  }
1057
- liveBuffer.push({ item, bytes })
1063
+ liveBuffer.push({ item, bytes, receivedAt: Date.now() })
1058
1064
  bufferedBytes += bytes
1059
1065
  }
1060
1066
  liveWake.resolve()
@@ -1144,12 +1150,14 @@ export class RelayPool {
1144
1150
  const eventToSend = event.meta ? { ...event } : event
1145
1151
  if (eventToSend.meta) delete eventToSend.meta
1146
1152
 
1153
+ const sendControllers = urls.map(() => new AbortController())
1147
1154
  const sendDeferreds = urls.map(() => Promise.withResolvers())
1148
1155
  const sendPromises = sendDeferreds.map(({ promise }) => promise)
1149
1156
 
1150
1157
  // Starts before connection work so every relay shares one real deadline.
1151
1158
  const settlement = createPublishSettlements(sendPromises, timeout, {
1152
1159
  onSettled: (settlement, index) => {
1160
+ sendControllers[index].abort(settlement.reason)
1153
1161
  if (settlement.reason?.category === 'timeout' && !settlement.reason.cause) {
1154
1162
  const relay = this.#relays.get(normalizeRelayUrl(urls[index]))
1155
1163
  if (relay?.lastTransportError) settlement.reason.cause = relay.lastTransportError
@@ -1171,7 +1179,9 @@ export class RelayPool {
1171
1179
  ;(async () => {
1172
1180
  try {
1173
1181
  const relay = await this.#getRelay(url)
1174
- return await this.#publishEvent(relay, eventToSend, getAuthEvent)
1182
+ const signal = sendControllers[index].signal
1183
+ if (signal.aborted) throw signal.reason
1184
+ return await this.#publishEvent(relay, eventToSend, getAuthEvent, signal)
1175
1185
  } catch (err) {
1176
1186
  const reason = err instanceof Error ? err : new Error(String(err))
1177
1187
  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) {