libp2r2p 0.10.11 → 0.10.12
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 +20 -0
- package/package.json +1 -1
- package/relay/constants/index.js +3 -1
- package/relay/helpers/error.js +30 -0
- package/relay/helpers/publish.js +2 -1
- package/relay/services/relay-connection.js +28 -12
- package/relay/services/relay-pool.js +16 -8
package/README.md
CHANGED
|
@@ -364,6 +364,26 @@ Low-level relay sockets, subscriptions, message parsing, and serialization are
|
|
|
364
364
|
internal implementation details; use `RelayPool` or the `relayPool` singleton
|
|
365
365
|
from `libp2r2p/relay`.
|
|
366
366
|
|
|
367
|
+
`getEvents` and `getEventsGenerator` accept `deduplicateAcrossRelays` (boolean,
|
|
368
|
+
default `true`). With `false`, a matching event is delivered once per relay,
|
|
369
|
+
while repeated IDs from the same relay remain suppressed. Each occurrence owns
|
|
370
|
+
its `meta.relay`; callbacks still run immediately and deadlines, EOSE handling,
|
|
371
|
+
and per-relay filter limits are unchanged. The callback/generator item remains
|
|
372
|
+
`{ type: 'event', event, relay }`, and the completed query remains
|
|
373
|
+
`{ result, errors, success }`. The option does not extend to the live or feed
|
|
374
|
+
generators. Callers that need replication coverage can aggregate the returned
|
|
375
|
+
copies by event ID; missing responses do not prove absence from a relay.
|
|
376
|
+
|
|
377
|
+
Publication errors retain their existing `reason` objects and may expose
|
|
378
|
+
`category`: `connection` (WebSocket establishment), `transport` (socket send or
|
|
379
|
+
close), `relay` (an explicit negative `OK`), or `timeout` (missing confirmation).
|
|
380
|
+
Native messages, codes, nested causes and aggregate errors remain available;
|
|
381
|
+
WebSocket closure details use `closeCode`, `closeReason`, and `wasClean` rather
|
|
382
|
+
than overwriting a native `code`. A timeout can retain a preceding socket error
|
|
383
|
+
as its cause without claiming that the relay rejected the event. Authentication
|
|
384
|
+
wrappers preserve this context. Local failures can remain uncategorized.
|
|
385
|
+
Event metadata is internal and is removed by `sendEvent` before serialization.
|
|
386
|
+
|
|
367
387
|
The same public subpath exports `getRelaysByPubkey(pubkeys)`, which discovers
|
|
368
388
|
the latest NIP-65 relay list for every requested pubkey through `seedRelays`,
|
|
369
389
|
normalizes and deduplicates its public relay URLs, and falls back to the first
|
package/package.json
CHANGED
package/relay/constants/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Used only to discover users' NIP-65 relay lists (kind:10002).
|
|
2
2
|
export const seedRelays = [
|
|
3
3
|
'wss://relay.44billion.net',
|
|
4
|
-
|
|
4
|
+
// Disabled 2026-09-08: offline for some days
|
|
5
|
+
// 'wss://purplepag.es',
|
|
5
6
|
'wss://user.kindpag.es',
|
|
6
7
|
'wss://relay.nos.social',
|
|
7
8
|
// Disabled 2026-08-05: accepted kind:10002 with OK but did not broadcast it
|
|
@@ -15,6 +16,7 @@ export const seedRelays = [
|
|
|
15
16
|
export const freeRelays = [
|
|
16
17
|
'wss://relay.44billion.net',
|
|
17
18
|
'wss://nos.lol',
|
|
19
|
+
'wss://relay.dreamith.to',
|
|
18
20
|
'wss://relay.primal.net'
|
|
19
21
|
]
|
|
20
22
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Adds transport context without replacing native messages, codes or causes.
|
|
2
|
+
export function categorizeRelayError (reason, category, fallback = 'RELAY_OPERATION_FAILED') {
|
|
3
|
+
const error = reason instanceof Error ? reason : new Error(String(reason || fallback))
|
|
4
|
+
try {
|
|
5
|
+
Object.defineProperty(error, 'category', { value: category, enumerable: true, configurable: true })
|
|
6
|
+
return error
|
|
7
|
+
} catch {
|
|
8
|
+
const wrapped = error instanceof AggregateError
|
|
9
|
+
? new AggregateError(error.errors, error.message, { cause: error })
|
|
10
|
+
: new Error(error.message, { cause: error })
|
|
11
|
+
wrapped.name = error.name
|
|
12
|
+
if (error.code !== undefined) wrapped.code = error.code
|
|
13
|
+
wrapped.category = category
|
|
14
|
+
return wrapped
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A timeout indicates missing confirmation, even when a socket error preceded it.
|
|
19
|
+
export function relayTimeoutError (message, cause) {
|
|
20
|
+
return categorizeRelayError(new Error(message, cause ? { cause } : undefined), 'timeout')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// WebSocket close codes are distinct from native system error codes.
|
|
24
|
+
export function relayCloseError (event, category, cause) {
|
|
25
|
+
const error = new Error(event?.reason || 'CONNECTION_CLOSED', cause ? { cause } : undefined)
|
|
26
|
+
if (event?.code !== undefined) error.closeCode = event.code
|
|
27
|
+
if (event?.reason !== undefined) error.closeReason = event.reason
|
|
28
|
+
if (event?.wasClean !== undefined) error.wasClean = event.wasClean
|
|
29
|
+
return categorizeRelayError(error, category)
|
|
30
|
+
}
|
package/relay/helpers/publish.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { maybeUnref } from './timer.js'
|
|
2
|
+
import { relayTimeoutError } from './error.js'
|
|
2
3
|
|
|
3
4
|
function publishTimeoutError () {
|
|
4
|
-
return
|
|
5
|
+
return relayTimeoutError('PUBLISH_TIMEOUT')
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
// Resolves once any relay accepts the event, all relays reject, an optional
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isValidEvent } from '../../event/index.js'
|
|
2
2
|
import { ValidationError } from '../../error/index.js'
|
|
3
3
|
import { maybeUnref } from '../helpers/timer.js'
|
|
4
|
+
import { categorizeRelayError, relayCloseError, relayTimeoutError } from '../helpers/error.js'
|
|
4
5
|
|
|
5
6
|
const DEFAULT_CONNECT_TIMEOUT = 3000
|
|
6
7
|
const DEFAULT_OPERATION_TIMEOUT = 30000
|
|
@@ -44,6 +45,7 @@ export class RelayConnection {
|
|
|
44
45
|
#connectPromise = null
|
|
45
46
|
#challenge = null
|
|
46
47
|
#serial = 0
|
|
48
|
+
#lastTransportError = null
|
|
47
49
|
#subscriptions = new Map()
|
|
48
50
|
#publishes = new Map()
|
|
49
51
|
#authentications = new Map()
|
|
@@ -60,14 +62,22 @@ export class RelayConnection {
|
|
|
60
62
|
this.onauth = null
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
// Exposes socket context to the pool's operation-wide publication deadline.
|
|
66
|
+
get lastTransportError () { return this.#lastTransportError }
|
|
67
|
+
|
|
63
68
|
async connect ({ timeout = DEFAULT_CONNECT_TIMEOUT, signal } = {}) {
|
|
64
69
|
if (this.ws?.readyState === 1) return
|
|
65
70
|
if (this.#connectPromise) return await this.#connectPromise
|
|
66
71
|
if (signal?.aborted) throw new Error('CONNECT_ABORTED')
|
|
67
|
-
if (typeof this.#WebSocket !== 'function') throw new Error('WEBSOCKET_UNAVAILABLE')
|
|
72
|
+
if (typeof this.#WebSocket !== 'function') throw categorizeRelayError(new Error('WEBSOCKET_UNAVAILABLE'), 'connection')
|
|
68
73
|
|
|
74
|
+
this.#lastTransportError = null
|
|
69
75
|
this.#connectPromise = new Promise((resolve, reject) => {
|
|
70
|
-
|
|
76
|
+
let socket
|
|
77
|
+
try { socket = new this.#WebSocket(this.url) } catch (error) {
|
|
78
|
+
reject(categorizeRelayError(error, 'connection'))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
71
81
|
this.ws = socket
|
|
72
82
|
let settled = false
|
|
73
83
|
const finish = (reason) => {
|
|
@@ -81,18 +91,21 @@ export class RelayConnection {
|
|
|
81
91
|
} else resolve()
|
|
82
92
|
}
|
|
83
93
|
const onAbort = () => finish(new Error('CONNECT_ABORTED'))
|
|
84
|
-
const timer = timeout === null ? null : maybeUnref(setTimeout(() => finish(
|
|
94
|
+
const timer = timeout === null ? null : maybeUnref(setTimeout(() => finish(relayTimeoutError('CONNECT_TIMEOUT')), timeout))
|
|
85
95
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
86
96
|
|
|
87
97
|
socket.onopen = () => finish()
|
|
88
98
|
socket.onerror = event => {
|
|
89
|
-
const reason =
|
|
99
|
+
const reason = categorizeRelayError(event?.error, settled ? 'transport' : 'connection', 'CONNECTION_ERROR')
|
|
90
100
|
if (!settled) finish(reason)
|
|
91
|
-
else
|
|
101
|
+
else {
|
|
102
|
+
this.#lastTransportError = reason
|
|
103
|
+
this.onerror?.(reason)
|
|
104
|
+
}
|
|
92
105
|
}
|
|
93
106
|
socket.onmessage = event => { this.#handleMessage(event).catch(reason => this.onerror?.(reason)) }
|
|
94
107
|
socket.onclose = event => {
|
|
95
|
-
if (!settled) finish(
|
|
108
|
+
if (!settled) finish(relayCloseError(event, 'connection'))
|
|
96
109
|
if (this.ws === socket) this.ws = null
|
|
97
110
|
this.#handleClose(event)
|
|
98
111
|
}
|
|
@@ -101,8 +114,11 @@ export class RelayConnection {
|
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
send (message) {
|
|
104
|
-
if (this.ws?.readyState !== 1) throw
|
|
105
|
-
this.ws.send(message)
|
|
117
|
+
if (this.ws?.readyState !== 1) throw relayCloseError(null, 'transport', this.#lastTransportError)
|
|
118
|
+
try { this.ws.send(message) } catch (error) {
|
|
119
|
+
this.#lastTransportError = categorizeRelayError(error, 'transport')
|
|
120
|
+
throw this.#lastTransportError
|
|
121
|
+
}
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
subscribe (filters, handlers = {}) {
|
|
@@ -141,7 +157,7 @@ export class RelayConnection {
|
|
|
141
157
|
#sendEventOperation (type, event, map, timeoutCode) {
|
|
142
158
|
if (map.has(event.id)) return map.get(event.id).promise
|
|
143
159
|
const deferred = Promise.withResolvers()
|
|
144
|
-
const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id,
|
|
160
|
+
const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
|
|
145
161
|
map.set(event.id, { ...deferred, timer, promise: deferred.promise })
|
|
146
162
|
try { this.send(JSON.stringify([type, event])) } catch (error) {
|
|
147
163
|
this.#settleEvent(map, event.id, error)
|
|
@@ -210,7 +226,7 @@ export class RelayConnection {
|
|
|
210
226
|
return
|
|
211
227
|
}
|
|
212
228
|
if (data[0] === 'OK') {
|
|
213
|
-
const reason = data[2] === true ? null :
|
|
229
|
+
const reason = data[2] === true ? null : categorizeRelayError(data[3], 'relay', 'EVENT_REJECTED')
|
|
214
230
|
this.#settleEvent(this.#publishes, data[1], reason, data[3])
|
|
215
231
|
this.#settleEvent(this.#authentications, data[1], reason, data[3])
|
|
216
232
|
return
|
|
@@ -229,7 +245,7 @@ export class RelayConnection {
|
|
|
229
245
|
|
|
230
246
|
#handleClose (event) {
|
|
231
247
|
this.#challenge = null
|
|
232
|
-
const reason =
|
|
248
|
+
const reason = relayCloseError(event, 'transport', this.#lastTransportError)
|
|
233
249
|
for (const [id, subscription] of this.#subscriptions) {
|
|
234
250
|
this.#subscriptions.delete(id)
|
|
235
251
|
subscription.handlers.onclose?.(reason)
|
|
@@ -244,7 +260,7 @@ export class RelayConnection {
|
|
|
244
260
|
const socket = this.ws
|
|
245
261
|
this.ws = null
|
|
246
262
|
this.#challenge = null
|
|
247
|
-
const reason =
|
|
263
|
+
const reason = relayCloseError(null, 'transport', this.#lastTransportError)
|
|
248
264
|
for (const [id, subscription] of this.#subscriptions) {
|
|
249
265
|
this.#subscriptions.delete(id)
|
|
250
266
|
subscription.handlers.onclose?.()
|
|
@@ -2,6 +2,7 @@ import { ValidationError } from '../../error/index.js'
|
|
|
2
2
|
import { decodeHll, encodeHll, estimateHllCount, mergeHll } from '../helpers/hll.js'
|
|
3
3
|
import { createPublishSettlements, firstFulfillment, publishSummary } from '../helpers/publish.js'
|
|
4
4
|
import { maybeUnref } from '../helpers/timer.js'
|
|
5
|
+
import { categorizeRelayError } from '../helpers/error.js'
|
|
5
6
|
import { normalizeRelayUrl } from '../../url/index.js'
|
|
6
7
|
import { RelayConnection } from './relay-connection.js'
|
|
7
8
|
|
|
@@ -102,6 +103,8 @@ class Nip42AuthenticationError extends Error {
|
|
|
102
103
|
constructor (reason) {
|
|
103
104
|
super(reason.message, { cause: reason })
|
|
104
105
|
this.name = 'Nip42AuthenticationError'
|
|
106
|
+
if (reason.category) this.category = reason.category
|
|
107
|
+
if (reason.code !== undefined) this.code = reason.code
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
110
|
|
|
@@ -143,7 +146,7 @@ export class RelayPool {
|
|
|
143
146
|
try {
|
|
144
147
|
await relay.close()
|
|
145
148
|
} catch {}
|
|
146
|
-
throw error
|
|
149
|
+
throw categorizeRelayError(error, error?.category ?? 'connection')
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
// Only reset idle timeout when no live subscriptions are holding this relay open.
|
|
@@ -335,8 +338,10 @@ export class RelayPool {
|
|
|
335
338
|
|
|
336
339
|
// Collects a one-shot relay read. The first EOSE with events opens a short
|
|
337
340
|
// grace window; null disables that window so callers wait for every relay or
|
|
338
|
-
// the operation deadline.
|
|
339
|
-
|
|
341
|
+
// the operation deadline. Disabling cross-relay deduplication still suppresses
|
|
342
|
+
// repeated ids from the same relay; callbacks remain immediate in both modes.
|
|
343
|
+
async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal, deduplicateAcrossRelays = true } = {}) {
|
|
344
|
+
if (typeof deduplicateAcrossRelays !== 'boolean') throw new ValidationError('INVALID_DEDUPLICATE_ACROSS_RELAYS')
|
|
340
345
|
const urls = normalizedRelayUrls(relays)
|
|
341
346
|
if (!urls.length) return { result: [], errors: [], success: false }
|
|
342
347
|
if (signal?.aborted) throw new Error('Aborted')
|
|
@@ -346,7 +351,7 @@ export class RelayPool {
|
|
|
346
351
|
const normalCloseUrls = new Set()
|
|
347
352
|
const errors = []
|
|
348
353
|
const events = []
|
|
349
|
-
const eventIds = new Set()
|
|
354
|
+
const eventIds = deduplicateAcrossRelays ? new Set() : null
|
|
350
355
|
let completed = 0
|
|
351
356
|
let isResolved = false
|
|
352
357
|
let eoseTimer = null
|
|
@@ -411,6 +416,7 @@ export class RelayPool {
|
|
|
411
416
|
if (timeout !== null) timeoutTimer = maybeUnref(setTimeout(timeoutPending, timeout))
|
|
412
417
|
|
|
413
418
|
for (const url of urls) {
|
|
419
|
+
const seenIds = eventIds ?? new Set()
|
|
414
420
|
this.#getRelay(url).then(relay => {
|
|
415
421
|
if (isResolved || !pending.has(url)) return
|
|
416
422
|
let hasEvents = false
|
|
@@ -432,10 +438,8 @@ export class RelayPool {
|
|
|
432
438
|
onevent: (event) => {
|
|
433
439
|
if (isResolved || !pending.has(url)) return
|
|
434
440
|
hasEvents = true
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (!event?.id || !eventIds.has(event.id)) {
|
|
438
|
-
if (event?.id) eventIds.add(event.id)
|
|
441
|
+
if (!event?.id || !seenIds.has(event.id)) {
|
|
442
|
+
if (event?.id) seenIds.add(event.id)
|
|
439
443
|
event.meta = { relay: url }
|
|
440
444
|
events.push(event)
|
|
441
445
|
if (callback) callback({ type: 'event', event, relay: url })
|
|
@@ -859,6 +863,10 @@ export class RelayPool {
|
|
|
859
863
|
// Starts before connection work so every relay shares one real deadline.
|
|
860
864
|
const settlement = createPublishSettlements(sendPromises, timeout, {
|
|
861
865
|
onSettled: (settlement, index) => {
|
|
866
|
+
if (settlement.reason?.category === 'timeout' && !settlement.reason.cause) {
|
|
867
|
+
const relay = this.#relays.get(normalizeRelayUrl(urls[index]))
|
|
868
|
+
if (relay?.lastTransportError) settlement.reason.cause = relay.lastTransportError
|
|
869
|
+
}
|
|
862
870
|
notifyRelayResult(onRelayResult, relayResultForSettlement(urls[index], settlement))
|
|
863
871
|
}
|
|
864
872
|
})
|