libp2r2p 0.10.17 → 0.10.19

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
@@ -12,35 +12,95 @@ For remote-work scheduling, see [`libp2r2p/network`](network/README.md):
12
12
  `isOnline` probes connectivity and `onOnline` shares recovery monitoring,
13
13
  including retries when the browser omits its native `online` event.
14
14
 
15
- ## Relay feed lifecycle
16
-
17
- `RelayPool.getEventsFeedGenerator(filter, relays, options)` yields events directly,
18
- including the existing `event.meta.relay` field. By default it combines an initial
19
- historical query with a live subscription. `live: false` selects history only;
20
- `filter.limit: 0` selects live only. `getLiveEventsGenerator` exposes the same
21
- lifecycle controls along with its existing `ready` and `readyRelays` properties.
22
-
23
- The returned iterator has a synchronous, idempotent `stopAndDrain()` method. It
24
- closes subscription input and cancels reconnections and outstanding historical
25
- queries, while retaining events already accepted by the library's receive
26
- callbacks. Continue consuming the iterator to obtain those events and observe
27
- completion. This includes initial history, buffered live events, and both history
28
- and live buffers from reconnect recovery. Calling it before the first `next()`
29
- prevents subscriptions from opening. No reception timestamp or new event metadata
30
- is added. Events rejected by ordinary filtering/deduplication remain excluded.
15
+ ## Relay reads and lifecycle
16
+
17
+ All three `RelayPool` read generators emit typed envelopes. The Nostr event is
18
+ never decorated with `meta`; relay provenance belongs to its enclosing item:
19
+
20
+ ```js
21
+ { type: 'event', event, relay }
22
+ { type: 'error', relay, error }
23
+ { type: 'eose', relays: [{ relay, status, error }] } // error is optional
24
+ ```
25
+
26
+ `eose` means the **initial read attempt is complete**, including empty reads and
27
+ partial results. Its report distinguishes actual relay EOSE from other outcomes:
28
+
29
+ | Status | Meaning |
30
+ | --- | --- |
31
+ | `eose` | The relay sent EOSE. |
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. |
34
+ | `cutoff` | The grace period after the first qualifying EOSE/early completion elapsed. |
35
+ | `closed` | The subscription ended without EOSE or an explicit error. |
36
+ | `error` | Connection or subscription failed. Also emits an error item. |
37
+
38
+ Each normalized relay URL gets one report entry, preserving the first spelling
39
+ provided by the caller. An empty relay list emits `{ type: 'eose', relays: [] }`.
40
+ Caller cancellation never fabricates an initial completion. Individual relay
41
+ failures do not prevent other relays from delivering; malformed arguments and
42
+ general operation failures reject the call or iterator read.
43
+
44
+ `getEvents(filter, relays, options)` resolves to
45
+ `{ result: [{ event, relay }], errors: [{ relay, reason }], success, relays }`.
46
+ Its optional `callback` receives the same event/error/EOSE envelopes immediately.
47
+ `success` retains its existing meaning: an event was received or at least one
48
+ relay completed without an error; it does not mean every relay sent EOSE.
49
+ `getEventsGenerator` emits those envelopes and retains that report as its final
50
+ iterator return value (not visible inside a `for await` loop).
51
+
52
+ `getEventsFeedGenerator(filter, relays, options)` combines history and live
53
+ subscription by default: history items, one historical `eose`, buffered live
54
+ items, then ongoing live delivery. `live: false` ends after history and its marker;
55
+ `filter.limit: 0` skips history and forwards the live stream's initial marker.
56
+ Internal live/reconnect EOSEs do not produce additional feed markers.
57
+
58
+ `getLiveEventsGenerator` discards retained events received before each relay's
59
+ EOSE. A ready relay can deliver live events before the aggregate marker. Initial
60
+ `timeout` defaults to 5000 ms (`null` disables it); expiration reports pending
61
+ relays without stopping their connections or subsequent recovery. The grace
62
+ period `timeoutAfterFirstEose` defaults to 500 ms (`null` disables it). Historical
63
+ queries use these same defaults; their grace period starts only when a relay
64
+ with events EOSEs or satisfies its filter. Reconnect-gap reads keep their separate
65
+ `timeoutForReconnectGap` and `timeoutAfterFirstReconnectGapEose` options.
66
+
67
+ The live iterator retains `ready: Promise<{ relays, errors }>` and the
68
+ `readyRelays` getter. `ready` is the initial readiness snapshot, derived from the
69
+ same outcomes as the marker; its errors use `{ relay, reason }`. `readyRelays`
70
+ tracks currently ready relays and can change after that snapshot. Timeouts and
71
+ cutoffs are not acknowledgements of readiness. Reconnections do not repeat the
72
+ initial marker. Both APIs require consuming the iterator to start its work.
73
+
74
+ Live and feed iterators have a synchronous, idempotent `stopAndDrain()` method.
75
+ It closes subscription input and cancels reconnections and outstanding historical
76
+ queries, while retaining items already accepted by receive callbacks. Continue
77
+ consuming to obtain those items and completion. This includes initial history,
78
+ buffered live events, and reconnect recovery buffers. Calling it before the
79
+ first `next()` prevents subscriptions from opening. It does not synthesize EOSE.
31
80
 
32
81
  Aborting `options.signal`, calling `return()` (including a `for await` break), or
33
- calling `throw()` cancels input and pending delivery instead. An event already
34
- delivered to the caller cannot be recalled. These operations also interrupt a
35
- drain. `stopAndDrain()` does not consume the iterator or return a completion
36
- promise: completion is the iterator's `{ done: true }` result.
82
+ calling `throw()` cancels input and pending delivery instead. An item already
83
+ delivered cannot be recalled. These operations can also interrupt a drain.
84
+ `stopAndDrain()` does not consume the iterator or return a completion promise:
85
+ completion is the iterator's `{ done: true }` result.
37
86
 
38
87
  ```js
39
88
  const stream = relayPool.getEventsFeedGenerator({ authors: [pubkey] }, [relay], { signal })
40
89
  // When this relay is removed, call stream.stopAndDrain() from the list handler.
41
- for await (const event of stream) await store(event)
90
+ for await (const item of stream) {
91
+ if (item.type === 'event') await store(item.event)
92
+ else if (item.type === 'error') reportError(item.relay, item.error)
93
+ else if (item.type === 'eose') initialReadFinished(item.relays)
94
+ }
42
95
  ```
43
96
 
97
+ Migration from 0.10.18: replace raw live/feed event reads with `item.event` after
98
+ checking `item.type`, replace `event.meta.relay` with the envelope's `relay`, and
99
+ unwrap each `getEvents().result` entry. There is no compatibility flag. Query,
100
+ content-key, private-channel and NIP-46 helpers unwrap pool results internally
101
+ and retain their higher-level event contracts. Count, publication and disconnect
102
+ return formats are unchanged.
103
+
44
104
  ## Private Messenger
45
105
 
46
106
  The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
@@ -146,7 +206,7 @@ if (sent.delivery.deletionSeckey) {
146
206
  const deletion = finalizeEvent({
147
207
  kind: 5,
148
208
  created_at: Math.floor(Date.now() / 1000),
149
- tags: [['k', '3560'], ...outerEvents.slice(offset, offset + 100).map(event => ['e', event.id])],
209
+ tags: [['k', '3560'], ...outerEvents.slice(offset, offset + 100).map(({ event }) => ['e', event.id])],
150
210
  content: ''
151
211
  }, deletionKey.secretKey)
152
212
  await relayPool.sendEvent(deletion, relays)
@@ -478,11 +538,11 @@ from `libp2r2p/relay`.
478
538
  `getEvents` and `getEventsGenerator` accept `deduplicateAcrossRelays` (boolean,
479
539
  default `true`). With `false`, a matching event is delivered once per relay,
480
540
  while repeated IDs from the same relay remain suppressed. Each occurrence owns
481
- its `meta.relay`; callbacks still run immediately and deadlines, EOSE handling,
482
- and per-relay filter limits are unchanged. The callback/generator item remains
483
- `{ type: 'event', event, relay }`, and the completed query remains
484
- `{ result, errors, success }`. The option does not extend to the live or feed
485
- generators. Callers that need replication coverage can aggregate the returned
541
+ its envelope's `relay`; callbacks still run immediately and per-relay filter
542
+ limits are unchanged. The callback/generator event item is
543
+ `{ type: 'event', event, relay }`; the completed query is
544
+ `{ result: [{ event, relay }], errors, success, relays }`. The option does not
545
+ extend to the live or feed generators. Callers that need replication coverage can aggregate the returned
486
546
  copies by event ID; missing responses do not prove absence from a relay.
487
547
 
488
548
  Publication errors retain their existing `reason` objects and may expose
@@ -555,3 +615,14 @@ bytes, while integer mode supports fixed-width identifiers.
555
615
  In NIP-5A, "no padding" means that no separate padding character such as `=`
556
616
  is used. Leading `0` digits are nevertheless required to make every Nsite
557
617
  Base36 value exactly 50 characters long.
618
+
619
+ ## Files
620
+
621
+ [`libp2r2p/irfs`](irfs/README.md) prepares retryable chunk templates from files,
622
+ with explicit cancellation and resource release. [`libp2r2p/nip94`](nip94/README.md)
623
+ builds/interprets file metadata, including the local IRFS profile. NIP-27 extracts
624
+ `https://nostr.alt/nfile1…?localOnly=1` up to the NIP-19 codec's 5,000-character
625
+ limit, retaining the full URL and exposing decoded `url.nfile` and MIME `url.m`.
626
+
627
+ The NIP-94 extension also carries optional `download` intent; see
628
+ [nip94/README.md](nip94/README.md#download-intent) for event and inline URL forms.
@@ -103,7 +103,7 @@ export async function getIykcProofs (pubkeys, {
103
103
  timeout: 5000,
104
104
  timeoutAfterFirstEose: null
105
105
  })
106
- return result
106
+ return result.map(({ event }) => event)
107
107
  })
108
108
  )
109
109
 
package/irfs/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # IRFS preparation
2
+
3
+ `libp2r2p/irfs` prepares an immutable browser `File`/`Blob` or `Uint8Array`.
4
+ It has no signer, publisher, relay pool or launcher dependency.
5
+
6
+ ```js
7
+ import { prepareIrfsFile, decodeIrfsChunk } from 'libp2r2p/irfs'
8
+ const prepared = await prepareIrfsFile(file, { signal })
9
+ try {
10
+ const created_at = Math.floor(Date.now() / 1000)
11
+ for await (const template of prepared.chunks({ created_at, signal })) {
12
+ // Sign/store/publish according to the consuming application's policy.
13
+ const verified = decodeIrfsChunk(template)
14
+ }
15
+ // prepared.chunks({ created_at }) may be iterated again for retry.
16
+ } finally {
17
+ prepared.close()
18
+ }
19
+ ```
20
+
21
+ The result exposes `root` (hex MMR root), `size`, `total`, `chunks()` and
22
+ idempotent `close()`. Blocks contain at most 51,000 bytes; only the final block
23
+ may be smaller. Templates use kind 34601, deterministic `d` identifiers, an
24
+ `mmr` tag containing decimal index/total and Base93 proof, and Base93 content.
25
+ Use a stable timestamp when identical templates are required across retries.
26
+ `decodeIrfsChunk()` validates the proof, identifier, root and block size.
27
+
28
+ Preparation retains the immutable input and an in-memory NMMR hash tree, not
29
+ a second copy of file bytes. It creates **no temporary IndexedDB database**.
30
+ `close()` drops its input/tree references and abort-listener registration;
31
+ abort also closes preparation. In-flight Blob reads finish but cannot yield
32
+ another chunk after cancellation. Consumers own any object URLs they create.
33
+ Hash memory scales with chunk count. `onProgress({ completed, total })` reports
34
+ bytes hashed; preparation yields regularly for input/cancellation.
35
+ Empty input raises `ValidationError('EMPTY_IRFS_FILE')` in this version.
package/irfs/index.js ADDED
@@ -0,0 +1,74 @@
1
+ import NMMR, { InMemoryMMR } from 'nmmr'
2
+ import { encode, decode } from '../base93/index.js'
3
+ import { bytesToBase16 } from '../base16/index.js'
4
+ import { ValidationError } from '../error/index.js'
5
+
6
+ export const IRFS_CHUNK_BYTES = 51000
7
+ export const IRFS_CHUNK_KIND = 34601
8
+
9
+ // Blob/File input is immutable and seekable: retain it instead of copying its
10
+ // bytes to temporary IndexedDB. Only tree hashes and leaf positions stay in RAM.
11
+ export async function prepareIrfsFile (input, { signal, onProgress } = {}) {
12
+ let blob = input instanceof Uint8Array ? new Blob([input]) : input
13
+ if (!(blob instanceof Blob)) throw new ValidationError('INVALID_IRFS_FILE')
14
+ if (!blob.size) throw new ValidationError('EMPTY_IRFS_FILE')
15
+ let tree = new InMemoryMMR()
16
+ let positions = []
17
+ const size = blob.size
18
+ const total = Math.ceil(size / IRFS_CHUNK_BYTES)
19
+ let closed = false
20
+ const check = currentSignal => {
21
+ signal?.throwIfAborted()
22
+ currentSignal?.throwIfAborted()
23
+ if (closed) throw new Error('IRFS preparation closed')
24
+ }
25
+ const close = () => {
26
+ closed = true
27
+ blob = tree = positions = null
28
+ signal?.removeEventListener('abort', close)
29
+ }
30
+ signal?.addEventListener('abort', close, { once: true })
31
+ try {
32
+ for (let index = 0; index < total; index++) {
33
+ check()
34
+ const bytes = new Uint8Array(await blob.slice(index * IRFS_CHUNK_BYTES, (index + 1) * IRFS_CHUNK_BYTES).arrayBuffer())
35
+ check()
36
+ positions.push(Number(tree.append(bytes).leafIdx))
37
+ onProgress?.({ completed: Math.min(size, (index + 1) * IRFS_CHUNK_BYTES), total: size })
38
+ // Yield to input and cancellation even when a Blob read resolves immediately.
39
+ if (index % 32 === 31) await new Promise(resolve => setTimeout(resolve, 0))
40
+ }
41
+ const root = bytesToBase16(tree.bagThePeaks())
42
+ return {
43
+ root, size, total, close,
44
+ async * chunks ({ created_at: createdAt = Math.floor(Date.now() / 1000), signal: readSignal } = {}) {
45
+ if (!Number.isSafeInteger(createdAt) || createdAt < 0) throw new ValidationError('INVALID_IRFS_TIMESTAMP')
46
+ for (let index = 0; index < total; index++) {
47
+ check(readSignal)
48
+ const bytes = new Uint8Array(await blob.slice(index * IRFS_CHUNK_BYTES, (index + 1) * IRFS_CHUNK_BYTES).arrayBuffer())
49
+ check(readSignal)
50
+ const hashes = tree.getProofArray(positions[index])
51
+ const proof = new Uint8Array(hashes.length * 32)
52
+ hashes.forEach((hash, offset) => proof.set(hash, offset * 32))
53
+ yield { kind: IRFS_CHUNK_KIND, created_at: createdAt, tags: [['d', NMMR.deriveChunkId(root, index)], ['mmr', String(index), String(total), encode(proof)]], content: encode(bytes) }
54
+ }
55
+ }
56
+ }
57
+ } catch (error) { close(); throw error }
58
+ }
59
+
60
+ export function decodeIrfsChunk (event) {
61
+ try {
62
+ if (event?.kind !== IRFS_CHUNK_KIND || !Array.isArray(event.tags)) throw new Error('Invalid kind or tags')
63
+ const d = event.tags.filter(tag => tag[0] === 'd')
64
+ const mmr = event.tags.filter(tag => tag[0] === 'mmr')
65
+ if (d.length !== 1 || d[0].length !== 2 || mmr.length !== 1 || mmr[0].length !== 4) throw new Error('Invalid chunk tags')
66
+ const [, index, total, encodedProof] = mmr[0]
67
+ const contentBytes = decode(event.content)
68
+ const proof = decode(encodedProof)
69
+ const root = NMMR.calculateRoot({ contentBytes, index, total, proof })
70
+ if (!contentBytes.length || contentBytes.length > IRFS_CHUNK_BYTES || (Number(index) < Number(total) - 1 && contentBytes.length !== IRFS_CHUNK_BYTES)) throw new Error('Invalid chunk length')
71
+ if (NMMR.deriveChunkId(root, index) !== d[0][1]) throw new Error('Invalid chunk ID')
72
+ return { root, index: Number(index), total: Number(total), contentBytes, proof }
73
+ } catch (cause) { throw new ValidationError('INVALID_IRFS_CHUNK', { cause }) }
74
+ }
package/nip27/index.js CHANGED
@@ -2,6 +2,7 @@ import { ValidationError } from '../error/index.js'
2
2
  import {
3
3
  NAPP_ENTITY_REGEX,
4
4
  naddrDecode,
5
+ nfileDecode,
5
6
  neventDecode,
6
7
  noteDecode,
7
8
  nrelayDecode
@@ -75,6 +76,7 @@ function getReferencesRegex (bareNip05) {
75
76
  ...(bareNip05 ? [NIP05_BARE_ROOT, NIP05_BARE_CUSTOM] : [])
76
77
  ]
77
78
  const alternatives = [
79
+ '(?<nfileUrl>https://nostr[.]alt/(?<nfileEntity>nfile1[ac-hj-np-z02-9]{1,4994})(?:[?]localOnly=1)?(?:#[-_+.A-Za-z0-9%=&*]{1,4000})?)',
78
80
  URL_SOURCE,
79
81
  APP_SOURCE,
80
82
  NIP05_STANDARD,
@@ -203,7 +205,8 @@ const NIP94_TAGS = {
203
205
  image: ['image'],
204
206
  summary: ['summary'],
205
207
  alt: ['alt'],
206
- caption: ['caption']
208
+ caption: ['caption'],
209
+ download: ['download']
207
210
  }
208
211
 
209
212
  function validateTagConfigs (extraTags) {
@@ -236,11 +239,20 @@ export function decodeMediaMetadata (url, { extraTags } = {}) {
236
239
 
237
240
  const tags = extraTags ? { ...NIP94_TAGS, ...extraTags } : NIP94_TAGS
238
241
  const tagIndexes = {}
242
+ // Validate against the complete fragment before the generic bounded parser:
243
+ // punctuation must not turn an invalid value such as 1/foo into a valid 1.
244
+ const fragment = url.includes('#') ? url.slice(url.indexOf('#') + 1) : ''
245
+ const downloads = fragment.split('&').filter(item => decodeFragmentValue(item.split('=')[0]) === 'download')
246
+ if (downloads.length > 1 || downloads.some(item => {
247
+ const parts = item.split('=')
248
+ return parts.length !== 2 || !['0', '1'].includes(decodeFragmentValue(parts[1]))
249
+ })) throw new ValidationError('INVALID_MEDIA_METADATA_DOWNLOAD')
239
250
  const obj = (url.match(/(?<=#)[-_+.A-Za-z0-9%=&*]{1,4000}/)?.[0] || '')
240
251
  .split('&')
241
252
  .filter(Boolean)
242
253
  .reduce((memo, item) => {
243
- let [key, value = ''] = item.split('=')
254
+ const parts = item.split('=')
255
+ let [key, value = ''] = parts
244
256
  key = decodeFragmentValue(key)
245
257
  value = decodeFragmentValue(value)
246
258
  const config = tags[key]
@@ -299,6 +311,14 @@ function getReferenceItem (original, groups, { getMimeType, defaultAppUser }) {
299
311
  : { key: 'text', text: { value: original } }
300
312
  }
301
313
 
314
+ if (groups.nfileUrl) {
315
+ try {
316
+ const file = nfileDecode(groups.nfileEntity)
317
+ const metadata = tryDecodeMediaMetadata(groups.nfileUrl) || {}
318
+ return { key: 'url', url: { ...metadata, value: groups.nfileUrl, nfile: file, ...(file.mime ? { m: file.mime } : {}) } }
319
+ } catch { return { key: 'text', text: { value: original } } }
320
+ }
321
+
302
322
  if (groups.url) {
303
323
  const url = `${groups.protocol ? '' : 'https://'}${groups.url}`
304
324
  let mediaMetadata = {}
@@ -75,12 +75,16 @@ export class Nip46Client {
75
75
  limit: 0
76
76
  }, parsed.relays, {
77
77
  signal: controller.signal,
78
+ timeout: options.timeout ?? DEFAULT_TIMEOUT,
78
79
  timeoutAfterFirstEose: options.timeoutAfterFirstEose ?? DEFAULT_TIMEOUT_AFTER_FIRST_EOSE
79
80
  })
80
81
  const found = Promise.withResolvers()
81
82
  const consume = (async () => {
82
83
  try {
83
- for await (const event of stream) {
84
+ for await (const item of stream) {
85
+ if (item.type === 'error') { options.onError?.(item.error); continue }
86
+ if (item.type !== 'event') continue
87
+ const { event } = item
84
88
  if (!isNip46EventFor(event, clientPubkey)) continue
85
89
  const response = decodeNip46Frame(event, clientSecretKey)
86
90
  if (response?.result === parsed.secret) {
@@ -95,14 +95,16 @@ export class Nip46Transport {
95
95
  const controller = new AbortController()
96
96
  const stream = this.#relayPool.getLiveEventsGenerator(filter, relays, {
97
97
  signal: controller.signal,
98
+ timeout: this.#operationTimeout,
98
99
  timeoutAfterFirstEose: this.#timeoutAfterFirstEose
99
100
  })
100
101
  const context = { controller, stream, relays: [...relays], consume: null }
101
102
  this.#contexts.add(context)
102
103
  context.consume = (async () => {
103
104
  try {
104
- for await (const event of stream) {
105
- Promise.resolve(onEvent(event)).catch(error => this.#reportError(error))
105
+ for await (const item of stream) {
106
+ if (item.type === 'error') this.#reportError(item.error)
107
+ else if (item.type === 'event') Promise.resolve(onEvent(item.event)).catch(error => this.#reportError(error))
106
108
  }
107
109
  } catch (error) {
108
110
  if (!this.#closed && error?.message !== 'Aborted') this.#reportError(error)
@@ -0,0 +1,40 @@
1
+ # File metadata
2
+
3
+ `libp2r2p/nip94` exports `createFileMetadata(options)` and
4
+ `decodeFileMetadata(event)`. The former creates an unsigned kind-1063 template;
5
+ the latter reads and validates its tags without parsing the caption as a URL.
6
+
7
+ This is an **adaptation/extension of NIP-94**, not a claim that every emitted
8
+ event meets its SHA-256 requirements. `url` and `mime` are required. Options
9
+ include `caption`, `created_at`, `size`, `width`/`height`, `alt`, `root`, `service`,
10
+ `thumbhash`, `sha256`, `originalSha256`, and additional `tags` (e.g. replies).
11
+ The caption becomes `.content`. `root` maps to `r`, dimensions to `dim`, hashes
12
+ to `x`/`ox`; hashes are optional and never synthesized. ThumbHash is transported
13
+ unchanged as Base64, independently of image decoding. No `blurhash` is generated.
14
+
15
+ For local IRFS files use `service: 'irfs'` and
16
+ `https://nostr.alt/nfile1…?localOnly=1`. Encode the MMR root, MIME and filename
17
+ with `nfileEncode`; relay/author hints are unnecessary. The decoder verifies
18
+ agreement between `r`/`m` and nfile metadata and exposes its `filename`.
19
+ The `r` reference allows storage owners to retain the associated local chunks.
20
+ The module does not encrypt, sign, store or download data.
21
+
22
+ ## Download intent
23
+
24
+ `download` is an optional extension expressing the author's intended action:
25
+ `'1'` asks clients to download on activation rather than open/play the media.
26
+ It does not prevent thumbnails or enforce server behavior.
27
+
28
+ For kind 1063, no tag and `['download', '0']` both decode to `download: '0'`;
29
+ `['download']` and `['download', '1']` both decode to `download: '1'`.
30
+ Empty/other values, extra fields and duplicate download tags are invalid.
31
+ `createFileMetadata({ ..., download: '0' | '1' })` emits an explicit value;
32
+ omitting the option emits no download tag. Boolean/numeric options are invalid.
33
+
34
+ For inline URLs, `nip27.decodeMediaMetadata()` requires an explicit
35
+ `#download=0` or `#download=1` (or `&download=...` after other fragment fields).
36
+ A bare/empty/duplicate/invalid value throws `INVALID_MEDIA_METADATA_DOWNLOAD`;
37
+ `tryDecodeMediaMetadata()` returns null. No fragment means no download property.
38
+ `extractMedia()` carries the string flag for ordinary URLs and nfile URLs,
39
+ including long nfile entities and `?localOnly=1`. Metadata fragments do not
40
+ change the bytes or root of a file; clients strip them from download routes.
package/nip94/index.js ADDED
@@ -0,0 +1,55 @@
1
+ import { FILE_METADATA } from '../kind/index.js'
2
+ import { nfileDecode } from '../nip19/index.js'
3
+ import { ValidationError } from '../error/index.js'
4
+
5
+ // NIP-94 fields plus the IRFS r/service and ThumbHash extensions. SHA-256
6
+ // x/ox are optional in this profile; an MMR root is never substituted for x.
7
+ export function decodeFileMetadata (event) {
8
+ if (event?.kind !== FILE_METADATA || typeof event.content !== 'string' || !Array.isArray(event.tags)) throw new ValidationError('INVALID_FILE_METADATA')
9
+ const field = name => {
10
+ const tags = event.tags.filter(tag => Array.isArray(tag) && tag[0] === name)
11
+ if (tags.length > 1 || (tags.length && (tags[0].length < 2 || typeof tags[0][1] !== 'string'))) throw new ValidationError('INVALID_FILE_METADATA_TAG')
12
+ return tags[0]?.[1]
13
+ }
14
+ const downloadTags = event.tags.filter(tag => Array.isArray(tag) && tag[0] === 'download')
15
+ const download = downloadTags[0]
16
+ if (downloadTags.length > 1 || (download && (download.length > 2 || (download.length === 2 && !['0', '1'].includes(download[1]))))) throw new ValidationError('INVALID_FILE_METADATA_DOWNLOAD')
17
+ const result = { url: field('url'), mime: field('m'), caption: event.content, download: download ? download.length === 1 ? '1' : download[1] : '0' }
18
+ let url
19
+ try { url = new URL(result.url) } catch { throw new ValidationError('INVALID_FILE_METADATA_URL') }
20
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new ValidationError('INVALID_FILE_METADATA_URL')
21
+ if (typeof result.mime !== 'string' || !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(result.mime)) throw new ValidationError('INVALID_FILE_METADATA_MIME')
22
+ for (const [tag, key] of [['r', 'root'], ['service', 'service'], ['thumbhash', 'thumbhash'], ['x', 'sha256'], ['ox', 'originalSha256'], ['alt', 'alt']]) {
23
+ const value = field(tag)
24
+ if (value !== undefined) result[key] = value
25
+ }
26
+ for (const key of ['root', 'sha256', 'originalSha256']) if (result[key] !== undefined && !/^[0-9a-f]{64}$/.test(result[key])) throw new ValidationError('INVALID_FILE_METADATA_HASH')
27
+ const size = field('size')
28
+ if (size !== undefined) {
29
+ if (!/^(0|[1-9][0-9]*)$/.test(size) || !Number.isSafeInteger(Number(size))) throw new ValidationError('INVALID_FILE_METADATA_SIZE')
30
+ result.size = Number(size)
31
+ }
32
+ const dim = field('dim')
33
+ if (dim !== undefined) {
34
+ if (!/^[1-9][0-9]*x[1-9][0-9]*$/.test(dim)) throw new ValidationError('INVALID_FILE_METADATA_DIMENSIONS')
35
+ ;[result.width, result.height] = dim.split('x').map(Number)
36
+ if (![result.width, result.height].every(Number.isSafeInteger)) throw new ValidationError('INVALID_FILE_METADATA_DIMENSIONS')
37
+ }
38
+ if (result.thumbhash !== undefined && !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(result.thumbhash)) throw new ValidationError('INVALID_FILE_METADATA_THUMBHASH')
39
+ if (url.origin === 'https://nostr.alt') {
40
+ const file = nfileDecode(url.pathname.slice(1))
41
+ if ((result.root && result.root !== file.root) || (file.mime && file.mime !== result.mime)) throw new ValidationError('FILE_METADATA_NFILE_MISMATCH')
42
+ result.filename = file.filename
43
+ }
44
+ return result
45
+ }
46
+
47
+ export function createFileMetadata ({ caption = '', created_at: createdAt = Math.floor(Date.now() / 1000), tags = [], ...metadata }) {
48
+ if (!Number.isSafeInteger(createdAt) || createdAt < 0 || !Array.isArray(tags)) throw new ValidationError('INVALID_FILE_METADATA')
49
+ if (metadata.download !== undefined && !['0', '1'].includes(metadata.download)) throw new ValidationError('INVALID_FILE_METADATA_DOWNLOAD')
50
+ const fields = [['download', metadata.download], ['url', metadata.url], ['m', metadata.mime], ['r', metadata.root], ['size', metadata.size], ['service', metadata.service], ['thumbhash', metadata.thumbhash], ['x', metadata.sha256], ['ox', metadata.originalSha256], ['alt', metadata.alt]]
51
+ if (metadata.width !== undefined || metadata.height !== undefined) fields.push(['dim', `${metadata.width}x${metadata.height}`])
52
+ const event = { kind: FILE_METADATA, created_at: createdAt, content: caption, tags: [...fields.filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)]), ...tags.map(tag => [...tag])] }
53
+ decodeFileMetadata(event)
54
+ return event
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.17",
3
+ "version": "0.10.19",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -25,6 +25,7 @@
25
25
  "idb",
26
26
  "idb-queue",
27
27
  "index.js",
28
+ "irfs",
28
29
  "key",
29
30
  "kind",
30
31
  "network",
@@ -35,6 +36,7 @@
35
36
  "nip44",
36
37
  "nip44-v3",
37
38
  "nip46",
39
+ "nip94",
38
40
  "nip96",
39
41
  "nip98",
40
42
  "nwt",
@@ -66,6 +68,7 @@
66
68
  "./event": "./event/index.js",
67
69
  "./idb": "./idb/index.js",
68
70
  "./idb-queue": "./idb-queue/index.js",
71
+ "./irfs": "./irfs/index.js",
69
72
  "./key": "./key/index.js",
70
73
  "./kind": "./kind/index.js",
71
74
  "./network": "./network/index.js",
@@ -76,6 +79,7 @@
76
79
  "./nip44": "./nip44/index.js",
77
80
  "./nip44-v3": "./nip44-v3/index.js",
78
81
  "./nip46": "./nip46/index.js",
82
+ "./nip94": "./nip94/index.js",
79
83
  "./nip96": "./nip96/index.js",
80
84
  "./nip98": "./nip98/index.js",
81
85
  "./nwt": "./nwt/index.js",
@@ -92,7 +96,8 @@
92
96
  "@noble/ciphers": "2.2.0",
93
97
  "@noble/curves": "2.2.0",
94
98
  "@noble/hashes": "2.2.0",
95
- "@scure/base": "2.0.0"
99
+ "@scure/base": "2.0.0",
100
+ "nmmr": "2.0.0"
96
101
  },
97
102
  "devDependencies": {
98
103
  "eslint": "^9.39.5",
@@ -903,10 +903,11 @@ export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner
903
903
  if (until != null) filter.until = until
904
904
  if (limit != null) filter.limit = limit
905
905
 
906
- const { result: events } = await _getEvents(filter, relays, {
906
+ const { result } = await _getEvents(filter, relays, {
907
907
  timeout: 5000,
908
908
  timeoutAfterFirstEose: null
909
909
  })
910
+ const events = result.map(({ event }) => event)
910
911
  events.sort((a, b) => a.created_at - b.created_at)
911
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 })
912
913
  try {
@@ -940,9 +941,10 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
940
941
 
941
942
  async function consumeEvents () {
942
943
  try {
943
- for await (const outer of events) {
944
+ for await (const item of events) {
944
945
  if (controller.signal.aborted) continue
945
- await processOuterEvent(outer)
946
+ if (item.type === 'error') onError?.(item.error)
947
+ else if (item.type === 'event') await processOuterEvent(item.event)
946
948
  }
947
949
  } catch (error) {
948
950
  if (!controller.signal.aborted && error?.message !== 'Aborted') onError?.(error)
@@ -58,7 +58,7 @@ async function fetchLatestEventsByRelay (relayToAuthors, { kinds, dTagsByPubkey,
58
58
  const filter = { kinds, authors: dAuthors }
59
59
  if (d) filter['#d'] = [d]
60
60
  requests.push(getEvents(filter, [relay])
61
- .then(response => ({ requested: new Set(dAuthors), events: response.result || [] })))
61
+ .then(response => ({ requested: new Set(dAuthors), events: (response.result || []).map(({ event }) => event) })))
62
62
  }
63
63
  }
64
64
 
@@ -191,7 +191,7 @@ export function subscribeRelayListUpdates (pubkeys, {
191
191
 
192
192
  async function consumeRelayListUpdates () {
193
193
  try {
194
- for await (const event of _eventsFeedGenerator({
194
+ for await (const item of _eventsFeedGenerator({
195
195
  kinds: [10002],
196
196
  authors
197
197
  }, relays, {
@@ -199,6 +199,9 @@ export function subscribeRelayListUpdates (pubkeys, {
199
199
  timeout: 5000,
200
200
  timeoutAfterFirstEose: null
201
201
  })) {
202
+ if (item.type === 'error') { console.error('relay-list watch failed:', item.error); continue }
203
+ if (item.type !== 'event') continue
204
+ const { event } = item
202
205
  if (closed || !authors.includes(event.pubkey)) continue
203
206
  const update = cacheRelayListEvent(event, { cacheMs, relayUrlPolicy })
204
207
  if (!update || !relayTypeChanged(update.changes, relayType)) continue
@@ -238,7 +241,7 @@ async function loadMissingRelays (missingPubkeys, {
238
241
  })
239
242
 
240
243
  const latestByPubkey = {}
241
- for (const event of events || []) {
244
+ for (const { event } of events || []) {
242
245
  if (!missingPubkeys.includes(event.pubkey)) continue
243
246
  if (isNewerRelayListEvent(event, latestByPubkey[event.pubkey])) latestByPubkey[event.pubkey] = event
244
247
  }
@@ -80,6 +80,17 @@ function getEventsTimeoutError () {
80
80
  return new Error('GET_EVENTS_TIMEOUT')
81
81
  }
82
82
 
83
+ function asError (error) {
84
+ return error instanceof Error ? error : new Error(String(error))
85
+ }
86
+
87
+ function assertReadOptions (filter, timeouts) {
88
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter)) throw new ValidationError('INVALID_FILTER')
89
+ for (const value of Object.values(timeouts)) {
90
+ if (value !== null && (!Number.isFinite(value) || value < 0)) throw new ValidationError('INVALID_RELAY_TIMEOUT')
91
+ }
92
+ }
93
+
83
94
  function normalizedRelayUrls (relays) {
84
95
  const urls = []
85
96
  const seen = new Set()
@@ -88,7 +99,7 @@ function normalizedRelayUrls (relays) {
88
99
  const normalizedUrl = normalizeRelayUrl(relay)
89
100
  if (seen.has(normalizedUrl)) continue
90
101
  seen.add(normalizedUrl)
91
- // Keep the caller's first spelling in reports and metadata while using the
102
+ // Keep the caller's first spelling in reports and envelopes while using the
92
103
  // canonical spelling for pooled connection ownership.
93
104
  urls.push(relay)
94
105
  }
@@ -342,127 +353,115 @@ export class RelayPool {
342
353
  // the operation deadline. Disabling cross-relay deduplication still suppresses
343
354
  // repeated ids from the same relay; callbacks remain immediate in both modes.
344
355
  async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal, deduplicateAcrossRelays = true } = {}) {
356
+ assertReadOptions(filter, { timeout, timeoutAfterFirstEose })
345
357
  if (typeof deduplicateAcrossRelays !== 'boolean') throw new ValidationError('INVALID_DEDUPLICATE_ACROSS_RELAYS')
346
- const urls = normalizedRelayUrls(relays)
347
- if (!urls.length) return { result: [], errors: [], success: false }
348
358
  if (signal?.aborted) throw new Error('Aborted')
349
-
359
+ const urls = normalizedRelayUrls(relays)
350
360
  const subscriptions = new Map()
351
- const pending = new Set(urls)
352
- const normalCloseUrls = new Set()
361
+ const outcomes = new Map()
353
362
  const errors = []
354
363
  const events = []
355
364
  const eventIds = deduplicateAcrossRelays ? new Set() : null
356
- let completed = 0
357
365
  let isResolved = false
358
366
  let eoseTimer = null
359
367
  let timeoutTimer = null
360
368
 
361
369
  return await new Promise((resolve, reject) => {
362
- const closeSubscriptions = () => {
363
- for (const sub of subscriptions.values()) sub.close()
364
- subscriptions.clear()
365
- }
366
-
367
370
  const cleanup = () => {
368
371
  clearTimeout(timeoutTimer)
369
372
  clearTimeout(eoseTimer)
370
373
  signal?.removeEventListener('abort', onAbort)
371
- closeSubscriptions()
374
+ for (const sub of subscriptions.values()) sub.close()
375
+ subscriptions.clear()
372
376
  }
373
-
374
- const finish = () => {
377
+ const fail = error => {
375
378
  if (isResolved) return
376
379
  isResolved = true
377
380
  cleanup()
378
- resolve({
379
- result: events,
380
- errors,
381
- success: events.length > 0 || completed > 0
382
- })
381
+ reject(error)
383
382
  }
384
-
385
- const finishIfComplete = () => {
386
- if (pending.size === 0) finish()
383
+ const notify = item => {
384
+ try { callback?.(item) } catch (error) { fail(error) }
387
385
  }
388
-
389
- const settleRelay = (url, reason) => {
390
- if (isResolved || !pending.delete(url)) return
391
- subscriptions.delete(url)
392
- if (reason) {
393
- errors.push({ reason, relay: url })
394
- if (callback) callback({ type: 'error', error: reason, relay: url })
395
- } else {
396
- completed++
386
+ const settleRelay = (relay, status, error) => {
387
+ if (isResolved || outcomes.has(relay)) return
388
+ outcomes.set(relay, { relay, status, ...(error ? { error } : {}) })
389
+ if (error) {
390
+ errors.push({ relay, reason: error })
391
+ notify({ type: 'error', relay, error })
397
392
  }
398
- finishIfComplete()
399
393
  }
400
-
401
- const onAbort = () => {
394
+ const finish = (pendingStatus = 'cutoff') => {
395
+ if (isResolved) return
396
+ for (const url of urls) {
397
+ if (!outcomes.has(url)) settleRelay(url, pendingStatus, pendingStatus === 'timeout' ? getEventsTimeoutError() : undefined)
398
+ }
399
+ if (isResolved) return
400
+ const relays = urls.map(url => outcomes.get(url))
401
+ notify({ type: 'eose', relays })
402
402
  if (isResolved) return
403
403
  isResolved = true
404
404
  cleanup()
405
- reject(new Error('Aborted'))
406
- }
407
-
408
- const timeoutPending = () => {
409
- if (isResolved) return
410
- for (const url of pending) {
411
- errors.push({ reason: getEventsTimeoutError(), relay: url })
412
- }
413
- finish()
405
+ resolve({
406
+ result: events,
407
+ errors,
408
+ success: events.length > 0 || relays.some(({ status }) => ['eose', 'satisfied', 'closed'].includes(status)),
409
+ relays
410
+ })
414
411
  }
415
-
412
+ const finishIfComplete = () => { if (outcomes.size === urls.length) finish() }
413
+ const onAbort = () => fail(new Error('Aborted'))
416
414
  signal?.addEventListener('abort', onAbort, { once: true })
417
- if (timeout !== null) timeoutTimer = maybeUnref(setTimeout(timeoutPending, timeout))
415
+ if (timeout !== null) timeoutTimer = maybeUnref(setTimeout(() => finish('timeout'), timeout))
416
+ if (!urls.length) { finish(); return }
418
417
 
419
418
  for (const url of urls) {
420
419
  const seenIds = eventIds ?? new Set()
421
420
  this.#getRelay(url).then(relay => {
422
- if (isResolved || !pending.has(url)) return
421
+ if (isResolved || outcomes.has(url)) return
423
422
  let hasEvents = false
423
+ // Subscription callbacks may run before subscribe() returns.
424
424
  // eslint-disable-next-line prefer-const
425
425
  let sub
426
-
427
- // Actual EOSE and filter satisfaction share the same graceful close path.
428
- const handleEose = () => {
429
- if (isResolved || !pending.has(url)) return
430
- normalCloseUrls.add(url)
431
- sub.close()
426
+ const complete = status => {
427
+ if (isResolved || outcomes.has(url)) return
428
+ settleRelay(url, status)
429
+ sub?.close()
430
+ subscriptions.delete(url)
432
431
  if (hasEvents && timeoutAfterFirstEose !== null && !eoseTimer && !isResolved) {
433
- eoseTimer = maybeUnref(setTimeout(finish, timeoutAfterFirstEose))
432
+ eoseTimer = maybeUnref(setTimeout(() => finish('cutoff'), timeoutAfterFirstEose))
434
433
  }
434
+ finishIfComplete()
435
435
  }
436
-
437
- const checkEarlyClose = makeEarlyCloseChecker(filter, handleEose)
436
+ const checkEarlyClose = makeEarlyCloseChecker(filter, () => complete('satisfied'))
438
437
  sub = relay.subscribe([filter], {
439
- onevent: (event) => {
440
- if (isResolved || !pending.has(url)) return
438
+ onevent: event => {
439
+ if (isResolved || outcomes.has(url)) return
441
440
  hasEvents = true
442
441
  if (!event?.id || !seenIds.has(event.id)) {
443
442
  if (event?.id) seenIds.add(event.id)
444
- event.meta = { relay: url }
445
- events.push(event)
446
- if (callback) callback({ type: 'event', event, relay: url })
443
+ events.push({ event, relay: url })
444
+ notify({ type: 'event', event, relay: url })
447
445
  }
448
446
  checkEarlyClose(event)
449
447
  },
450
448
  oninvalidevent: () => {
451
- if (!isResolved && pending.has(url)) checkEarlyClose()
449
+ if (!isResolved && !outcomes.has(url)) checkEarlyClose()
452
450
  },
453
451
  onclose: error => {
454
- const reason = normalCloseUrls.delete(url) || error === undefined
455
- ? null
456
- : error instanceof Error ? error : new Error(String(error))
457
- settleRelay(url, reason)
452
+ if (isResolved || outcomes.has(url)) return
453
+ const reason = error === undefined ? undefined : asError(error)
454
+ settleRelay(url, reason ? 'error' : 'closed', reason)
455
+ subscriptions.delete(url)
456
+ finishIfComplete()
458
457
  },
459
- oneose: handleEose
458
+ oneose: () => complete('eose')
460
459
  })
461
- if (isResolved || !pending.has(url)) sub.close()
460
+ if (isResolved || outcomes.has(url)) sub.close()
462
461
  else subscriptions.set(url, sub)
463
462
  }).catch(error => {
464
- const reason = error instanceof Error ? error : new Error(String(error))
465
- settleRelay(url, reason)
463
+ settleRelay(url, 'error', asError(error))
464
+ finishIfComplete()
466
465
  })
467
466
  }
468
467
  })
@@ -472,39 +471,40 @@ export class RelayPool {
472
471
  const queue = []
473
472
  let p = Promise.withResolvers()
474
473
  let isDone = false
475
-
476
- const userCallback = options.callback
474
+ let failure
475
+ const controller = new AbortController()
477
476
  const callback = item => {
478
477
  queue.push(item)
479
- if (userCallback) userCallback(item)
480
478
  p.resolve()
481
479
  p = Promise.withResolvers()
480
+ options.callback?.(item)
482
481
  }
483
-
484
- // A drain stops receive callbacks without aborting consumption of this queue.
485
- const networkSignal = options._stopSignal
486
- ? AbortSignal.any([options._stopSignal, ...(options.signal ? [options.signal] : [])])
487
- : options.signal
488
- const methodPromise = this.getEvents(filter, relays, { ...options, signal: networkSignal, callback })
489
- .catch(err => { if (err?.message !== 'Aborted') console.error('Error in getEvents:', err) })
490
- .finally(() => {
491
- isDone = true
492
- p.resolve()
493
- })
494
-
495
- // eslint-disable-next-line no-unmodified-loop-condition
496
- while (!isDone || queue.length > 0) {
497
- if (options.signal?.aborted) break
498
- if (queue.length > 0) yield queue.shift()
499
- else await p.promise
482
+ const signal = AbortSignal.any([controller.signal, ...[options.signal, options._stopSignal].filter(Boolean)])
483
+ // Attach rejection handling immediately, but propagate general failures to
484
+ // the consumer after accepted deliveries instead of swallowing them.
485
+ const methodPromise = this.getEvents(filter, relays, { ...options, signal, callback })
486
+ .catch(error => { if (!signal.aborted) failure = error })
487
+ .finally(() => { isDone = true; p.resolve() })
488
+ try {
489
+ // eslint-disable-next-line no-unmodified-loop-condition
490
+ while (!isDone || queue.length > 0) {
491
+ if (options.signal?.aborted) return
492
+ if (queue.length > 0) yield queue.shift()
493
+ else await p.promise
494
+ }
495
+ const report = await methodPromise
496
+ if (failure) throw failure
497
+ return report
498
+ } finally {
499
+ controller.abort()
500
+ queue.length = 0
500
501
  }
501
-
502
- return await methodPromise
503
502
  }
504
503
 
505
504
  // Returns a strictly-live stream. `ready` reports the first initial EOSE window,
506
505
  // while `readyRelays` follows relays that are currently past their own EOSE.
507
506
  getLiveEventsGenerator (filter, relays, options = {}) {
507
+ assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose })
508
508
  const ready = Promise.withResolvers()
509
509
  const readyRelays = new Set()
510
510
  const stream = drainableStream(options => this.#getLiveEventsGenerator(filter, relays, options, { ready, readyRelays }), options)
@@ -528,6 +528,7 @@ export class RelayPool {
528
528
  async * #getLiveEventsGenerator (filter, relays, {
529
529
  signal,
530
530
  _stopSignal,
531
+ timeout = 5000,
531
532
  timeoutAfterFirstEose = 500,
532
533
  timeoutForReconnectGap = 5000,
533
534
  timeoutAfterFirstReconnectGapEose = 500,
@@ -542,7 +543,8 @@ export class RelayPool {
542
543
  const liveSubs = new Map() // url → live sub
543
544
  const retryTimers = new Map()
544
545
  const initialPending = new Set(urls)
545
- const initialErrors = []
546
+ const initialOutcomes = new Map()
547
+ let readyTimeout = null
546
548
  let readyTimer = null
547
549
  let isReady = false
548
550
 
@@ -564,14 +566,28 @@ export class RelayPool {
564
566
  const seenIds = new Set()
565
567
 
566
568
  let untilTimer = null
567
- const finishReady = () => {
569
+ const enqueue = item => {
570
+ queue.push(item)
571
+ p.resolve()
572
+ p = Promise.withResolvers()
573
+ }
574
+ const finishReady = (pendingStatus = 'cutoff', emit = true) => {
568
575
  if (isReady) return
569
576
  isReady = true
570
577
  clearTimeout(readyTimer)
578
+ clearTimeout(readyTimeout)
579
+ for (const relay of initialPending) {
580
+ const error = pendingStatus === 'timeout' ? getEventsTimeoutError() : undefined
581
+ initialOutcomes.set(relay, { relay, status: pendingStatus, ...(error ? { error } : {}) })
582
+ if (error && emit) enqueue({ type: 'error', relay, error })
583
+ }
584
+ initialPending.clear()
585
+ const relays = urls.map(url => initialOutcomes.get(url))
571
586
  ready.resolve(Object.freeze({
572
587
  relays: Object.freeze([...readyRelays]),
573
- errors: Object.freeze([...initialErrors])
588
+ errors: Object.freeze(relays.filter(item => item.error).map(({ relay, error }) => ({ relay, reason: error })))
574
589
  }))
590
+ if (emit) enqueue({ type: 'eose', relays })
575
591
  }
576
592
 
577
593
  const teardown = (drain = false) => {
@@ -580,7 +596,7 @@ export class RelayPool {
580
596
  isDone = true
581
597
  if (!drain) queue.length = 0
582
598
  clearTimeout(untilTimer)
583
- finishReady()
599
+ finishReady('closed', false)
584
600
  gapAc.abort()
585
601
  for (const timer of retryTimers.values()) clearTimeout(timer)
586
602
  retryTimers.clear()
@@ -597,14 +613,11 @@ export class RelayPool {
597
613
  seenIds.add(event.id)
598
614
  }
599
615
  if (event.created_at > (lastSeenAt ?? 0)) lastSeenAt = event.created_at
600
- event.meta = { relay: url }
601
- queue.push(event)
602
- p.resolve()
603
- p = Promise.withResolvers()
616
+ enqueue({ type: 'event', event, relay: url })
604
617
  }
605
618
 
606
619
  if (signal?.aborted || _stopSignal?.aborted) {
607
- finishReady()
620
+ finishReady('closed', false)
608
621
  return
609
622
  }
610
623
  const abort = () => teardown()
@@ -612,24 +625,35 @@ export class RelayPool {
612
625
  signal?.addEventListener('abort', abort, { once: true })
613
626
  _stopSignal?.addEventListener('abort', stop, { once: true })
614
627
 
628
+ if (timeout !== null) readyTimeout = maybeUnref(setTimeout(() => finishReady('timeout'), timeout))
629
+
615
630
  const maybeFinishInitialReady = () => {
616
631
  if (initialPending.size === 0) finishReady()
617
632
  }
618
633
 
619
634
  const markInitialEose = (url) => {
620
635
  readyRelays.add(url)
621
- if (isReady) return
622
- initialPending.delete(url)
636
+ if (isReady || !initialPending.delete(url)) return
637
+ initialOutcomes.set(url, { relay: url, status: 'eose' })
623
638
  if (timeoutAfterFirstEose !== null && !readyTimer) {
624
- readyTimer = maybeUnref(setTimeout(finishReady, timeoutAfterFirstEose))
639
+ readyTimer = maybeUnref(setTimeout(() => finishReady('cutoff'), timeoutAfterFirstEose))
625
640
  }
626
641
  maybeFinishInitialReady()
627
642
  }
628
643
 
629
- const markInitialError = (url, reason) => {
630
- if (!initialPending.delete(url)) return
631
- initialErrors.push({ relay: url, reason })
632
- maybeFinishInitialReady()
644
+ const reportFailure = (url, error) => {
645
+ if (isDone) return
646
+ enqueue({ type: 'error', relay: url, error })
647
+ if (!isReady && initialPending.delete(url)) {
648
+ initialOutcomes.set(url, { relay: url, status: 'error', error })
649
+ maybeFinishInitialReady()
650
+ }
651
+ }
652
+ const reportClosed = url => {
653
+ if (!isReady && initialPending.delete(url)) {
654
+ initialOutcomes.set(url, { relay: url, status: 'closed' })
655
+ maybeFinishInitialReady()
656
+ }
633
657
  }
634
658
 
635
659
  const scheduleReconnect = (url, reconnectDelay) => {
@@ -645,7 +669,7 @@ export class RelayPool {
645
669
  // Schedule teardown when the wall clock reaches filter.until
646
670
  if (filterUntil !== null) {
647
671
  const msUntil = filterUntil * 1000 - Date.now()
648
- untilTimer = maybeUnref(setTimeout(() => teardown(true), Math.max(0, msUntil)))
672
+ untilTimer = maybeUnref(setTimeout(() => { finishReady('closed'); teardown(true) }, Math.max(0, msUntil)))
649
673
  }
650
674
 
651
675
  // Runs a reconnect gap fill for a single relay and returns a promise that resolves
@@ -662,9 +686,10 @@ export class RelayPool {
662
686
  return (async () => {
663
687
  for await (const item of gapGen) {
664
688
  if (item?.type === 'event') pushEvent(item.event, url, true)
689
+ else if (item?.type === 'error' && !isDone) enqueue(item)
665
690
  }
666
691
  })().catch(err => {
667
- if (!isDone) console.error(`Reconnect gap fill error for ${url}:`, err)
692
+ reportFailure(url, asError(err))
668
693
  })
669
694
  }
670
695
 
@@ -697,11 +722,9 @@ export class RelayPool {
697
722
  if (liveSubs.get(url) === liveSub) liveSubs.delete(url)
698
723
  else if (liveSubs.has(url)) return
699
724
  readyRelays.delete(url)
700
- if (!liveEose && !isDone) {
701
- const reason = error instanceof Error
702
- ? error
703
- : new Error(error ? String(error) : 'LIVE_SUBSCRIPTION_CLOSED')
704
- markInitialError(url, reason)
725
+ if (!isDone) {
726
+ if (error !== undefined) reportFailure(url, asError(error))
727
+ else if (!liveEose) reportClosed(url)
705
728
  }
706
729
  if (isDone) return
707
730
  scheduleReconnect(url, reconnectDelay)
@@ -728,15 +751,19 @@ export class RelayPool {
728
751
  }).catch(err => {
729
752
  readyRelays.delete(url)
730
753
  const reason = err instanceof Error ? err : new Error(String(err))
731
- markInitialError(url, reason)
754
+ reportFailure(url, reason)
732
755
  if (isDone) return
733
- console.error(`Live subscription error at ${url}:`, reason)
734
756
  scheduleReconnect(url, reconnectDelay)
735
757
  })
736
758
  }
737
759
 
738
760
  if (!urls.length) {
739
761
  finishReady()
762
+ try { yield queue.shift() } finally {
763
+ teardown()
764
+ signal?.removeEventListener('abort', abort)
765
+ _stopSignal?.removeEventListener('abort', stop)
766
+ }
740
767
  return
741
768
  }
742
769
 
@@ -773,6 +800,7 @@ export class RelayPool {
773
800
  //
774
801
  // All underlying generators are injectable for testing.
775
802
  getEventsFeedGenerator (filter, relays, options = {}) {
803
+ assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose })
776
804
  return drainableStream(options => this.#getEventsFeedGenerator(filter, relays, options), options)
777
805
  }
778
806
 
@@ -790,14 +818,14 @@ export class RelayPool {
790
818
  const gen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
791
819
  for await (const item of gen) {
792
820
  if (signal.aborted) return
793
- if (item?.type === 'event') yield item.event
821
+ yield item
794
822
  }
795
823
  return
796
824
  }
797
825
 
798
826
  // limit:0 means "no stored events, live only" — skip the initial fetch.
799
827
  if (filter.limit === 0) {
800
- for await (const event of _liveGenerator(filter, relays, { signal, _stopSignal })) {
828
+ for await (const event of _liveGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })) {
801
829
  if (signal.aborted) return
802
830
  yield event
803
831
  }
@@ -808,19 +836,22 @@ export class RelayPool {
808
836
  // buffering incoming events before we query stored ones.
809
837
  // Relays always send stored matching events before EOSE (unless limit:0),
810
838
  // so the initial fetch + buffering is always needed.
811
- const liveGen = _liveGenerator(filter, relays, { signal, _stopSignal })
839
+ const liveGen = _liveGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
812
840
  const liveBuffer = []
813
841
  let liveDone = false
842
+ let liveFailure
814
843
  let liveWake = Promise.withResolvers()
815
844
 
816
845
  const bgLoop = (async () => {
817
846
  try {
818
847
  for await (const event of liveGen) {
819
848
  if (signal.aborted) break
820
- liveBuffer.push(event)
849
+ if (event.type !== 'eose') liveBuffer.push(event)
821
850
  liveWake.resolve()
822
851
  liveWake = Promise.withResolvers()
823
852
  }
853
+ } catch (error) {
854
+ liveFailure = error
824
855
  } finally {
825
856
  liveDone = true
826
857
  liveWake.resolve()
@@ -836,21 +867,23 @@ export class RelayPool {
836
867
  if (signal.aborted) return
837
868
  if (item?.type === 'event' && !seenIds.has(item.event.id)) {
838
869
  seenIds.add(item.event.id)
839
- yield item.event
840
- }
870
+ yield item
871
+ } else if (item?.type !== 'event') yield item
841
872
  }
842
873
 
843
874
  // Flush buffered live events that arrived during the initial fetch, deduping
844
875
  // against stored ones (overlap is possible around the fetch's until boundary)
845
876
  while (liveBuffer.length > 0) {
846
877
  if (signal.aborted) return
847
- const event = liveBuffer.shift()
848
- if (!seenIds.has(event.id)) {
849
- seenIds.add(event.id)
850
- yield event
878
+ const item = liveBuffer.shift()
879
+ if (item.type !== 'event' || !seenIds.has(item.event.id)) {
880
+ if (item.type === 'event') seenIds.add(item.event.id)
881
+ yield item
851
882
  }
852
883
  }
853
884
 
885
+ seenIds.clear()
886
+
854
887
  // Yield subsequent live events directly — no more overlap with stored events
855
888
  // eslint-disable-next-line no-unmodified-loop-condition
856
889
  while (!liveDone || liveBuffer.length > 0) {
@@ -860,6 +893,7 @@ export class RelayPool {
860
893
  }
861
894
  if (!liveDone) await liveWake.promise
862
895
  }
896
+ if (liveFailure) throw liveFailure
863
897
  } finally {
864
898
  await liveGen.return()
865
899
  await bgLoop