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 +98 -27
- package/content-key/services/iykc-proof.js +1 -1
- package/irfs/README.md +35 -0
- package/irfs/index.js +74 -0
- package/nip27/index.js +22 -2
- package/nip46/services/client.js +5 -1
- package/nip46/services/transport.js +4 -2
- package/nip94/README.md +40 -0
- package/nip94/index.js +55 -0
- package/package.json +7 -2
- package/private-channel/index.js +5 -3
- package/relay/services/events.js +1 -1
- package/relay/services/query.js +5 -2
- package/relay/services/relay-pool.js +161 -127
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
|
|
16
|
-
|
|
17
|
-
`RelayPool.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
34
|
-
delivered
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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 `
|
|
482
|
-
|
|
483
|
-
`{ type: 'event', event, relay }
|
|
484
|
-
`{ result, errors, success }`. The option does not
|
|
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.
|
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
|
-
|
|
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 = {}
|
package/nip46/services/client.js
CHANGED
|
@@ -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
|
|
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
|
|
105
|
-
|
|
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)
|
package/nip94/README.md
ADDED
|
@@ -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.
|
|
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",
|
package/private-channel/index.js
CHANGED
|
@@ -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
|
|
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
|
|
944
|
+
for await (const item of events) {
|
|
944
945
|
if (controller.signal.aborted) continue
|
|
945
|
-
|
|
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)
|
package/relay/services/events.js
CHANGED
|
@@ -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
|
|
package/relay/services/query.js
CHANGED
|
@@ -191,7 +191,7 @@ export function subscribeRelayListUpdates (pubkeys, {
|
|
|
191
191
|
|
|
192
192
|
async function consumeRelayListUpdates () {
|
|
193
193
|
try {
|
|
194
|
-
for await (const
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
result: events,
|
|
380
|
-
errors,
|
|
381
|
-
success: events.length > 0 || completed > 0
|
|
382
|
-
})
|
|
381
|
+
reject(error)
|
|
383
382
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
if (pending.size === 0) finish()
|
|
383
|
+
const notify = item => {
|
|
384
|
+
try { callback?.(item) } catch (error) { fail(error) }
|
|
387
385
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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(
|
|
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 ||
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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:
|
|
440
|
-
if (isResolved ||
|
|
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
|
-
|
|
445
|
-
|
|
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 &&
|
|
449
|
+
if (!isResolved && !outcomes.has(url)) checkEarlyClose()
|
|
452
450
|
},
|
|
453
451
|
onclose: error => {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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:
|
|
458
|
+
oneose: () => complete('eose')
|
|
460
459
|
})
|
|
461
|
-
if (isResolved ||
|
|
460
|
+
if (isResolved || outcomes.has(url)) sub.close()
|
|
462
461
|
else subscriptions.set(url, sub)
|
|
463
462
|
}).catch(error => {
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
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
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (
|
|
498
|
-
|
|
499
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
630
|
-
if (
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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 (!
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
848
|
-
if (!seenIds.has(event.id)) {
|
|
849
|
-
seenIds.add(event.id)
|
|
850
|
-
yield
|
|
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
|