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 +174 -10
- package/idb-queue/index.js +66 -2
- package/package.json +1 -1
- package/private-channel/helpers/rumor.js +26 -0
- package/private-channel/index.js +24 -12
- package/private-channel/services/received-chunks.js +3 -2
- package/private-message/index.js +333 -281
- package/private-messenger/index.js +328 -91
- package/private-messenger/services/channel-state.js +1 -1
- package/relay/helpers/read-admission.js +92 -0
- package/relay/services/relay-pool.js +210 -58
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
326
|
-
|
|
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,
|
|
335
|
-
|
|
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.
|
package/idb-queue/index.js
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|
package/private-channel/index.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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) {
|