libp2r2p 0.10.19 → 0.10.21

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
@@ -30,7 +30,7 @@ partial results. Its report distinguishes actual relay EOSE from other outcomes:
30
30
  | --- | --- |
31
31
  | `eose` | The relay sent EOSE. |
32
32
  | `satisfied` | The requested limit or IDs were satisfied, so the read closed early. |
33
- | `timeout` | The initial operation deadline elapsed. Also emits an error item. |
33
+ | `timeout` | The initial relay attempt deadline elapsed. Also emits an error item. |
34
34
  | `cutoff` | The grace period after the first qualifying EOSE/early completion elapsed. |
35
35
  | `closed` | The subscription ended without EOSE or an explicit error. |
36
36
  | `error` | Connection or subscription failed. Also emits an error item. |
@@ -71,7 +71,7 @@ tracks currently ready relays and can change after that snapshot. Timeouts and
71
71
  cutoffs are not acknowledgements of readiness. Reconnections do not repeat the
72
72
  initial marker. Both APIs require consuming the iterator to start its work.
73
73
 
74
- Live and feed iterators have a synchronous, idempotent `stopAndDrain()` method.
74
+ Read iterators have a synchronous, idempotent `stopAndDrain()` method.
75
75
  It closes subscription input and cancels reconnections and outstanding historical
76
76
  queries, while retaining items already accepted by receive callbacks. Continue
77
77
  consuming to obtain those items and completion. This includes initial history,
@@ -101,6 +101,72 @@ content-key, private-channel and NIP-46 helpers unwrap pool results internally
101
101
  and retain their higher-level event contracts. Count, publication and disconnect
102
102
  return formats are unchanged.
103
103
 
104
+ ### Read admission and bounded snapshots (0.10.21)
105
+
106
+ Each pool coordinates event reads by normalized connection URL. Constructor
107
+ options are `maxSubscriptionsPerRelay: 28`,
108
+ `maxConcurrentHistoryPerRelay: 2`, and `maxQueuedReadsPerRelay: 256`.
109
+ These are local budgets, not negotiated relay limits. A stricter relay can still
110
+ reject subscriptions. Count and publication contracts are unchanged and do not
111
+ consume event-read slots.
112
+
113
+ Admission is FIFO per connection. A history+live feed reserves two slots
114
+ atomically before either REQ opens; completing history releases only its slot.
115
+ One-shot queries and reconnect gap reads share this budget. Reconnection reserves
116
+ live+history together when recovery is needed. Caller cancellation removes queued
117
+ work; `disconnect(url)` also rejects queued work for that connection. Existing
118
+ live streams retain their reconnect behavior until cancelled by their owner.
119
+
120
+ All event reads accept `queueTimeout: 30000` (milliseconds, `null` disables).
121
+ The network `timeout` starts **after admission for each relay**, including its
122
+ connection setup; time in the queue does not consume it. The first-EOSE grace
123
+ can still cut short other relay attempts. Admission failures are ordinary error
124
+ envelopes/report entries, with `error.code`, `error.relay` and
125
+ `error.phase === 'admission'`: `RELAY_READ_QUEUE_FULL`,
126
+ `RELAY_READ_QUEUE_TIMEOUT`, `RELAY_READ_CAPACITY`, or `RELAY_DISCONNECTED`.
127
+ Cancellation rejects an outstanding direct `getEvents()` call; cancelled iterators
128
+ stop delivering. Failures on one relay continue to allow results from others.
129
+
130
+ Live and feed readers limit each pending live buffer to 1,000 envelopes and
131
+ 8 MiB of serialized UTF-8 data. Options `maxBufferedLiveEvents` and
132
+ `maxBufferedLiveBytes` customize those positive limits. They cover consumer
133
+ backlog and reconnect buffers as well as live events waiting for history. These
134
+ are bounds on queued data, not a measurement of total JavaScript heap usage.
135
+ Overflow fails the attempt with `RELAY_LIVE_BUFFER_FULL` (`phase: 'live-buffer'`),
136
+ closes input and discards pending live data. Callers decide whether to retry;
137
+ events already delivered are not recalled. Ordinary relay failures retain the
138
+ existing partial-result behavior; they do not automatically discard the feed.
139
+
140
+ With `snapshot: true`, a feed waits for its initial live readiness window before
141
+ capturing the historical `until`. `filter.since` defaults to zero; `filter.until`
142
+ can provide an earlier cutoff but cannot extend a snapshot into the future.
143
+ The historical completion includes the actual inclusive bounds:
144
+
145
+ ```js
146
+ const stream = relayPool.getEventsFeedGenerator(
147
+ { authors: [pubkey], kinds: [1], since: lastConfirmed - 600, limit: 200 },
148
+ [relay],
149
+ { snapshot: true, timeoutAfterFirstEose: null, signal }
150
+ )
151
+ // Historical completion:
152
+ // { type: 'eose', relays: [...], snapshot: { since, until } }
153
+ ```
154
+
155
+ Only history is bounded: the live REQ has no `until` in this mode. Events around
156
+ the boundary can appear in both sources and are deduplicated by ID. Empty history
157
+ still reports its bounds. With `live: false`, the cutoff is captured when the
158
+ iterator starts; `limit: 0` remains live-only, without a historical snapshot.
159
+ Default `snapshot: false` retains the existing filter time-range behavior.
160
+
161
+ This is a bounded time interval, not a transactional relay snapshot or proof of
162
+ complete coverage. Check each relay's outcome, exhaust pagination and commit
163
+ received events before checkpointing. `satisfied` can mean a truncated page;
164
+ EOSE can also terminate a limited response. A consumer that rejects a historical
165
+ attempt can call `return()`/abort when it receives its report, before the buffered
166
+ live events are released. Use `stopAndDrain()` only when those pending events
167
+ should be preserved. Slow/failed live readiness is still reported by the live
168
+ stream according to the existing partial-failure rules.
169
+
104
170
  ## Private Messenger
105
171
 
106
172
  The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
@@ -126,8 +192,9 @@ const messenger = await createPrivateMessenger({
126
192
  })
127
193
 
128
194
  async function logMessages () {
129
- for await (const message of messenger.messages()) {
195
+ for await (const { message, ack } of messenger.messages()) {
130
196
  console.log(message.type, message.payload)
197
+ await ack()
131
198
  }
132
199
  }
133
200
 
@@ -316,23 +383,101 @@ needed by private channels. For double-DH content-key use, pass a
316
383
  `contentKeySigner` or a signer implementation that handles content keys
317
384
  internally.
318
385
 
319
- Messages are stored in a bounded, durable IndexedDB queue until consumed or
386
+ Messages are stored in a bounded, durable IndexedDB queue until acknowledged or
320
387
  until the principal identity has been inactive for 60 days:
321
388
 
322
389
  ```js
323
390
  async function handleMessages () {
324
- for await (const message of messenger.messages()) {
325
- if (message.type === 'message') {
326
- console.log(message.payload)
327
- }
391
+ for await (const { message, ack } of messenger.messages()) {
392
+ await persistMessageIdempotently(message)
393
+ await ack() // only after the destination commits
328
394
  }
329
395
  }
330
396
 
331
397
  handleMessages().catch(err => console.warn('private messenger messages failed', err))
332
398
  ```
333
399
 
334
- For one-at-a-time consumption, use `await messenger.nextMessage()`. Queue
335
- clearing is asynchronous too: `await messenger.clearChannel(channelPubkey)`.
400
+ For one-at-a-time consumption, `await messenger.nextMessage()` returns
401
+ `{ message, ack, nack }` or `null` if no record is currently available. `ack()`
402
+ removes the reserved record; `nack()` releases it for another attempt. Both
403
+ return a boolean and are idempotent for the same reservation. An expired or
404
+ superseded reservation returns `false` and cannot delete a later delivery.
405
+
406
+ Delivery leases last 30 seconds and renew every 10 seconds while held. Closing
407
+ the messenger or returning the iterator releases its unacknowledged deliveries;
408
+ returning an iterator also cancels an empty pending read. A crashed/suspended
409
+ consumer's expired leases are reclaimable by another instance. Apps must save
410
+ idempotently: a crash between their commit and `ack()` can repeat delivery.
411
+ The queue is shared by identity, so concurrent consumers claim distinct records.
412
+
413
+ The app-message queue defaults to 16 MiB and rejects writes at capacity, without
414
+ evicting pending messages. Capacity pressure pauses ingestion and records a
415
+ recovery gap; successful acknowledgments resume it when possible. While capacity-paused, a
416
+ one-second capacity check also detects space released by another instance. Oversized
417
+ records and storage failures report errors and pause with reason `storage`;
418
+ after correcting the cause, call `resume('storage')`. Retention cleanup and
419
+ explicit `clearQueue()`/`clearChannel()` remain destructive. The queue's budget
420
+ bounds serialized storage, not the size of every intermediate decoded message.
421
+
422
+ ### Forwarded authors and isolated consumers
423
+
424
+ `broadcastRumor({ rumor, ... })` accepts an optional `rumor.pubkey`. Omission
425
+ means the sender; a different pubkey forwards that claimed author and requires
426
+ the original `created_at`. A supplied ID must match the reconstructed event.
427
+ The wire retains the original fields; recipients never overwrite an explicit
428
+ author with the transport sender. Signed events retain their signature.
429
+
430
+ Received messages expose `senderPubkey` and `provenance` alongside `event`,
431
+ `outer`, `meta` and `payload`. Provenance is `direct`, `hearsay`, or `signed`,
432
+ calculated by the receiver. Pending deduplication includes provenance, so an original can upgrade a queued
433
+ hearsay copy of the same event. Private-channel callbacks also expose these fields
434
+ in their metadata. Nothing is attached to the Nostr event. A rumor naming the
435
+ recipient is still hearsay unless independently matched to a known original.
436
+ Forwarded controls cannot invoke ask/reply/presence/recovery handlers in the
437
+ name of another author. Direct rumors are not transferable signed proof.
438
+
439
+ Each PrivateMessenger owns a `createPrivateMessageSession()` from
440
+ `libp2r2p/private-message`. A session groups relay reads for one receiver and
441
+ owns its watches, callbacks, and scoped receive-fragment progress. Independent
442
+ sessions can watch the same channel, including with the same receiver, without
443
+ overwriting or closing each other's reads. The module-level watch helpers are
444
+ a convenience scope per receiver; use explicit sessions for separate consumers.
445
+ `receivedChunkScope` can also scope lower-level private-channel reads; receiver
446
+ pubkeys are included in fragment keys. Expiration/cleanup still use the shared
447
+ receive-chunk database and budget.
448
+
449
+ ### Pause, resume and recovery
450
+
451
+ `pause(reason)` and `resume(reason)` accept nonempty strings. Reasons compose:
452
+ removing `network` cannot override an application's `vault` pause. Pause stops
453
+ live watches, recovery fetches and presence publishing, but retains channel
454
+ configuration and pending deliveries. Already-started operations can finish;
455
+ failed ingestion remains recoverable. Send attempts while paused reject.
456
+
457
+ `watch(channels)` declares desired subscriptions. `unwatch(channels)` removes
458
+ that intent while retaining configuration and history. Browser `online` only
459
+ clears the internal `network` pause, never restarts explicitly unwatched
460
+ channels. Updating retained channel configuration preserves explicit unwatch;
461
+ new channels are watched unless paused. Applications may explicitly watch again.
462
+
463
+ Pauses, unwatch and close record the start of the gap, even for channels that
464
+ have never received a message. Rewatch/resume uses live delivery plus historical
465
+ recovery with overlap. A failed resume remains retryable under its pause reason.
466
+ Before opening a watch, the messenger persists a pending recovery interval.
467
+ A first watch scans the configured recovery window (seven days by default),
468
+ including messages sent before this identity first opened the channel. Successful
469
+ scans persist `recoveredThrough`, even when empty; later watches resume from the
470
+ latest persisted message or scan checkpoint with overlap. Pending older ranges
471
+ remain until successfully recovered, even if newer live messages arrive.
472
+ This also covers abrupt termination without `close()` and channels with no
473
+ messages. Applications still supply signers and desired channels on reopening;
474
+ storage is scoped by origin and user pubkey, not the transient session ID.
475
+ Failed ingestion or incomplete relay fetch reports never complete the gap.
476
+ `lastSeenAt` tracks messages persisted in the incoming queue, not app commits.
477
+ The default recovery window is seven days; recovery still depends on available
478
+ relay/seeder data, and retention limits cannot guarantee indefinite delivery.
479
+
480
+ Queue clearing remains asynchronous: `await messenger.clearChannel(channelPubkey)`.
336
481
 
337
482
  Use explicit subpath imports for bundle size. The package root re-exports the
338
483
  main messenger API for convenience, but applications that only need one piece
@@ -626,3 +771,22 @@ limit, retaining the full URL and exposing decoded `url.nfile` and MIME `url.m`.
626
771
 
627
772
  The NIP-94 extension also carries optional `download` intent; see
628
773
  [nip94/README.md](nip94/README.md#download-intent) for event and inline URL forms.
774
+
775
+ ## Reliable IndexedDB queue reservations
776
+
777
+ `createQueue({ prefix, evictionPolicy: 'reject', maxBytes })` opts out of
778
+ capacity eviction. Oversized items reject with `QUEUE_ITEM_TOO_LARGE`, full
779
+ queues with `QUEUE_CAPACITY_EXCEEDED`; browser quota errors propagate without
780
+ trimming existing records. Other eviction policies retain their cache behavior.
781
+ Opening a reject-policy queue under a smaller budget preserves existing items.
782
+
783
+ `queue.reserve({ leaseMs = 30000 })` returns `null` or
784
+ `{ item, ack, nack, renew }`. Reservation token/deadline are internal record
785
+ metadata, persisted atomically in the same IndexedDB transaction as selection.
786
+ `renew()` extends a still-valid lease. Low-level callers own renewal and release;
787
+ PrivateMessenger supplies both. Do not mix reservations with destructive or
788
+ position-shifting queue operations on the same queue except explicit removal.
789
+ A stale token cannot acknowledge a newly reserved or replaced record.
790
+
791
+ `queue.getCapacity()` returns `{ usedBytes, maxBytes }`; capacity rejections
792
+ also expose `requiredBytes` and `maxBytes`, without retaining the rejected item.
@@ -56,6 +56,7 @@ function byteLength (value) {
56
56
 
57
57
  function normalizeEvictionPolicy (policy) {
58
58
  if (policy === 'opposite-end' || policy === undefined || policy === null) return 'opposite-end'
59
+ if (policy === 'reject') return 'reject'
59
60
  if (policy === 'fifo' || policy === 'head') return 'head'
60
61
  if (policy === 'lifo' || policy === 'tail') return 'tail'
61
62
  throw new ValidationError('QUEUE_INVALID_EVICTION_POLICY')
@@ -468,6 +469,12 @@ export async function createQueue ({
468
469
  async function evictToFit (tx, state, requiredBytes, options = {}) {
469
470
  if (!hasByteLimit()) return
470
471
  if (requiredBytes > sessionMaxBytes) throw new Error('QUEUE_ITEM_TOO_LARGE')
472
+ if (configuredEvictionPolicy === 'reject') {
473
+ if (state.usedBytes + requiredBytes > sessionMaxBytes) {
474
+ throw Object.assign(new Error('QUEUE_CAPACITY_EXCEEDED'), { requiredBytes, maxBytes: sessionMaxBytes })
475
+ }
476
+ return
477
+ }
471
478
  // Make room for the write and retain eviction headroom for the next one.
472
479
  const targetBytes = targetBytesAfterWrite(requiredBytes)
473
480
  while (state.usedBytes + requiredBytes > targetBytes) {
@@ -477,7 +484,7 @@ export async function createQueue ({
477
484
  }
478
485
 
479
486
  async function evictToBytes (tx, state, targetBytes, options = {}) {
480
- if (!hasByteLimit()) return
487
+ if (!hasByteLimit() || configuredEvictionPolicy === 'reject') return
481
488
  while (state.usedBytes > targetBytes) {
482
489
  if (!await evictOne(tx, state, options)) break
483
490
  }
@@ -590,7 +597,7 @@ export async function createQueue ({
590
597
  if (wakeWaiters) wake()
591
598
  return value
592
599
  } catch (err) {
593
- if (retried || !hasByteLimit() || !isQuotaExceeded(err)) throw err
600
+ if (configuredEvictionPolicy === 'reject' || retried || !hasByteLimit() || !isQuotaExceeded(err)) throw err
594
601
  // Browser quota can be lower than the configured logical budget.
595
602
  // Retry once with a smaller in-memory budget after atomic rollback.
596
603
  lowerSessionMaxBytes(requiredBytes)
@@ -748,6 +755,61 @@ export async function createQueue ({
748
755
  })
749
756
  }
750
757
 
758
+ // Reservations are stored atomically with the item; another tab/process can
759
+ // reclaim an expired lease after a crash. Destructive/positional queue APIs
760
+ // must not be mixed with reservations on the same queue.
761
+ async function reserve ({ leaseMs = 30000, now = Date.now() } = {}) {
762
+ if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0 || !Number.isSafeInteger(now) || now < 0) throw new ValidationError('QUEUE_INVALID_LEASE')
763
+ const token = globalThis.crypto.randomUUID()
764
+ const reserved = await mutate(async tx => {
765
+ const p = deferred()
766
+ let cursor = (await run('openCursor', [], ITEMS_STORE, null, { tx, p })).result
767
+ while (cursor) {
768
+ const record = cursor.value
769
+ if (!record.reservation || record.reservation.until <= now) {
770
+ record.reservation = { token, until: now + leaseMs }
771
+ await putRecord(tx, record)
772
+ return record
773
+ }
774
+ cursor = await nextCursor(cursor, p)
775
+ }
776
+ return null
777
+ })
778
+ if (!reserved) return null
779
+ let settled = false
780
+ let completedAction
781
+ let completedResult
782
+ let tail = Promise.resolve()
783
+ const update = (action, at = Date.now()) => {
784
+ const operation = tail.then(async () => {
785
+ if (settled) return action === completedAction ? completedResult : false
786
+ const result = await mutate(async (tx, state) => {
787
+ const record = await getRecord(tx, reserved.position)
788
+ if (record?.reservation?.token !== token || record.reservation.until <= at) return false
789
+ if (action === 'ack') {
790
+ await deleteRecord(tx, record.position)
791
+ state.usedBytes = Math.max(0, state.usedBytes - record.byteSize)
792
+ await trimBounds(tx, state)
793
+ } else {
794
+ if (action === 'renew') record.reservation.until = at + leaseMs
795
+ else delete record.reservation
796
+ await putRecord(tx, record)
797
+ }
798
+ return true
799
+ }, { wakeWaiters: action !== 'renew' })
800
+ if (action !== 'renew' || !result) {
801
+ settled = true
802
+ completedAction = action
803
+ completedResult = result
804
+ }
805
+ return result
806
+ })
807
+ tail = operation.catch(() => {})
808
+ return operation
809
+ }
810
+ return { item: reserved.item, ack: () => update('ack'), nack: () => update('nack'), renew: () => update('renew') }
811
+ }
812
+
751
813
  async function snapshotStoredItems () {
752
814
  return snapshot(async tx => {
753
815
  const state = await readState(tx)
@@ -819,6 +881,8 @@ export async function createQueue ({
819
881
  })
820
882
 
821
883
  return {
884
+ reserve,
885
+ getCapacity: () => snapshot(async tx => ({ usedBytes: (await readState(tx)).usedBytes, maxBytes: sessionMaxBytes })),
822
886
  enqueue: push,
823
887
  push,
824
888
  pop,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.19",
3
+ "version": "0.10.21",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -0,0 +1,26 @@
1
+ import { getEventHash, isSerializableEvent } from '../../event/index.js'
2
+ import { ValidationError } from '../../error/index.js'
3
+
4
+ // Transport sender and claimed author are distinct. This also validates a
5
+ // supplied ID before discarding it from the unsigned wire representation.
6
+ export function normalizeRumor (rumor, senderPubkey) {
7
+ if (!rumor || typeof rumor !== 'object' || Array.isArray(rumor) || 'sig' in rumor) throw new ValidationError('INVALID_RUMOR')
8
+ const event = {
9
+ pubkey: rumor.pubkey === undefined ? senderPubkey : rumor.pubkey,
10
+ kind: rumor.kind,
11
+ created_at: rumor.created_at,
12
+ tags: rumor.tags,
13
+ content: rumor.content
14
+ }
15
+ if (!isSerializableEvent(event)) throw new ValidationError('INVALID_RUMOR')
16
+ const id = getEventHash(event)
17
+ if (rumor.id !== undefined && rumor.id !== id) throw new ValidationError('INVALID_RUMOR_ID')
18
+ return { ...event, id }
19
+ }
20
+
21
+ export function deliveryInfo (event, senderPubkey) {
22
+ return {
23
+ senderPubkey,
24
+ provenance: event.sig ? 'signed' : event.pubkey === senderPubkey ? 'direct' : 'hearsay'
25
+ }
26
+ }
@@ -5,6 +5,7 @@ import { hexToBytes } from '../base16/index.js'
5
5
  import { isValidContentKeyProof, isValidIykcProof, makeContentKeyEventForPubkey, parseContentKeyEvent } from '../content-key/event/index.js'
6
6
  import { getIykcProofs } from '../content-key/index.js'
7
7
  import { ValidationError } from '../error/index.js'
8
+ import { normalizeRumor, deliveryInfo } from './helpers/rumor.js'
8
9
  import * as nip44v3 from '../nip44-v3/index.js'
9
10
  import { relayPool } from '../relay/index.js'
10
11
  import { JSONL_CHUNK_BYTES, NYM_CARRIER_CHUNK_CHARS } from './helpers/chunk-size.js'
@@ -371,8 +372,7 @@ function eventFromPayload ({ payloadCiphertext, messageSeckey, senderPubkey }) {
371
372
  const messagePubkey = getPublicKey(messageSecretKey)
372
373
  const decrypted = JSON.parse(nip44v3.decrypt(messageSecretKey, messagePubkey, ROUTER_KIND, NIP44_V3_SCOPE, payloadCiphertext))
373
374
  if (hasEventSignature(decrypted)) return assertValidSignedInnerEvent(decrypted)
374
- const normalized = { ...decrypted, pubkey: senderPubkey }
375
- return { ...normalized, id: getEventHash(normalized) }
375
+ return normalizeRumor(decrypted, senderPubkey)
376
376
  }
377
377
 
378
378
  async function unwrapRecipientEnvelope ({ payloadCiphertext, envelope, receiverSigner, receiverPubkey, senderPubkey, imkcPubkey, rowScope = '' }) {
@@ -670,6 +670,7 @@ function createProcessor ({
670
670
  onSeedEvent,
671
671
  onContentKeyUsage,
672
672
  onError,
673
+ receivedChunkScope = '',
673
674
  receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
674
675
  receivedChunkTtlMsByPubkey,
675
676
  receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES,
@@ -678,6 +679,7 @@ function createProcessor ({
678
679
  ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES
679
680
  }) {
680
681
  const receivedChunks = createReceivedChunkStore({
682
+ scope: `${receivedChunkScope}:${receiverPubkey || ''}`,
681
683
  ttlMs: receivedChunkTtlMs,
682
684
  maxBytes: receivedChunkMaxBytes,
683
685
  indexedDB: receivedChunkIndexedDB
@@ -750,7 +752,8 @@ function createProcessor ({
750
752
  await onNymEvent?.(event, outer, {
751
753
  carrier: carriers[0],
752
754
  carriers,
753
- channelPubkey
755
+ channelPubkey,
756
+ ...deliveryInfo(event, carriers[0].pubkey)
754
757
  })
755
758
  await receivedChunks.removeGroup(groupKey)
756
759
  return
@@ -833,7 +836,7 @@ function createProcessor ({
833
836
  })
834
837
 
835
838
  if (event && !mustScanWholeBundle) {
836
- await onEvent?.(event, outer, { router: joinedRouter(router), channelPubkey })
839
+ await onEvent?.(event, outer, { router: joinedRouter(router), channelPubkey, ...deliveryInfo(event, senderPubkey) })
837
840
  ignoredGroups.add(groupKey)
838
841
  await receivedChunks.removeGroup(groupKey)
839
842
  return
@@ -855,7 +858,7 @@ function createProcessor ({
855
858
  onContentKeyUsage
856
859
  })
857
860
  if (shouldSeed) await onSeedEvent?.({ recordType: 'routerRow_v1', outer, router: completeRouter, channelPubkey, jsonl, innerEventIdsByRowIndex })
858
- if (event) await onEvent?.(event, outer, { router: completeRouter, channelPubkey, jsonl })
861
+ if (event) await onEvent?.(event, outer, { router: completeRouter, channelPubkey, jsonl, ...deliveryInfo(event, senderPubkey) })
859
862
 
860
863
  await receivedChunks.removeGroup(groupKey)
861
864
  } catch (err) {
@@ -863,6 +866,7 @@ function createProcessor ({
863
866
  ignoredGroups.add(groupKey)
864
867
  await receivedChunks.removeGroup(groupKey).catch(() => {})
865
868
  }
869
+ if (groupKey && !shouldIgnoreGroupError(err)) await receivedChunks.removeGroup(groupKey).catch(() => {})
866
870
  onError?.(err)
867
871
  }
868
872
  }
@@ -894,7 +898,7 @@ function shouldIgnoreGroupError (err) {
894
898
  ].includes(err?.message)
895
899
  }
896
900
 
897
- export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since, until, limit, mode = 'leecher', modeByPubkey, 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, _getEvents = getEvents }) {
901
+ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since, until, limit, 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, _getEvents = getEvents }) {
898
902
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
899
903
  const authors = privateChannelPubkeyList({ privateChannelPubkey, privateChannelPubkeys })
900
904
  const filter = { kinds: [PRIVATE_BROADCAST_KIND] }
@@ -903,22 +907,30 @@ export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner
903
907
  if (until != null) filter.until = until
904
908
  if (limit != null) filter.limit = limit
905
909
 
906
- const { result } = await _getEvents(filter, relays, {
910
+ const { result, errors = [], relays: report = [] } = await _getEvents(filter, relays, {
907
911
  timeout: 5000,
908
- timeoutAfterFirstEose: null
912
+ timeoutAfterFirstEose: null,
913
+ ...(signal ? { signal } : {})
909
914
  })
910
915
  const events = result.map(({ event }) => event)
911
916
  events.sort((a, b) => a.created_at - b.created_at)
912
- const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
917
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
913
918
  try {
914
- for (const event of events) await processOuterEvent(event)
919
+ for (const event of events) {
920
+ signal?.throwIfAborted()
921
+ await processOuterEvent(event)
922
+ }
923
+ signal?.throwIfAborted()
924
+ if (errors.length || report.some(entry => !['eose', 'satisfied'].includes(entry.status))) {
925
+ throw new AggregateError(errors.map(entry => entry.reason), 'PRIVATE_CHANNEL_FETCH_INCOMPLETE')
926
+ }
915
927
  return events
916
928
  } finally {
917
929
  processOuterEvent.close()
918
930
  }
919
931
  }
920
932
 
921
- 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, 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 }) {
933
+ 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 }) {
922
934
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
923
935
  if (receiverSigner && !receiverSigner?.nip44DecryptDoubleDH && !receiverSigner?.nip44v3Decrypt) throw new ValidationError('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
924
936
  if (!privateChannelReaderSigner && !privateChannelReaderSignersByPubkey && !privateChannelSigner && !privateChannelSignersByPubkey) throw new ValidationError('PRIVATE_CHANNEL_READER_REQUIRED')
@@ -927,7 +939,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
927
939
  const filter = { kinds: [PRIVATE_BROADCAST_KIND], since }
928
940
  if (authors.length) filter.authors = authors
929
941
  if (limit != null) filter.limit = limit
930
- const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
942
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
931
943
  const controller = new AbortController()
932
944
  const events = liveOnly
933
945
  ? _liveEventsGenerator(filter, relays, {
@@ -20,7 +20,7 @@ IndexedDB schema, scoped per received-chunk prefix:
20
20
  database `${prefix}:idb`, version 2
21
21
 
22
22
  groups, keyPath "groupKey"
23
- groupKey `${channelPubkey}:${routerPubkey}`
23
+ groupKey optional consumer scope + `${channelPubkey}:${routerPubkey}`
24
24
  channelPubkey/routerPubkey private-channel coordinates
25
25
  total expected chunk count
26
26
  received/receivedCount sparse received-index map and its count
@@ -181,6 +181,7 @@ function wait (ms) {
181
181
 
182
182
  export function createReceivedChunkStore ({
183
183
  prefix = DEFAULT_PREFIX,
184
+ scope = '',
184
185
  indexedDB = globalThis.indexedDB,
185
186
  ttlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
186
187
  maxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES
@@ -220,7 +221,7 @@ export function createReceivedChunkStore ({
220
221
  }
221
222
 
222
223
  function groupKeyFor (channelPubkey, routerPubkey) {
223
- return `${channelPubkey}:${routerPubkey}`
224
+ return `${scope ? `${scope}:` : ''}${channelPubkey}:${routerPubkey}`
224
225
  }
225
226
 
226
227
  async function readUsage (tx) {