libp2r2p 0.10.10 → 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 +24 -0
- package/network/README.md +20 -0
- package/network/index.js +150 -36
- 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
|
@@ -8,6 +8,10 @@ the transport and discovery surface. The package was born to distribute the
|
|
|
8
8
|
private messenger reference implementation, and it also carries a few Nostr
|
|
9
9
|
power-ups used by that messenger.
|
|
10
10
|
|
|
11
|
+
For remote-work scheduling, see [`libp2r2p/network`](network/README.md):
|
|
12
|
+
`isOnline` probes connectivity and `onOnline` shares recovery monitoring,
|
|
13
|
+
including retries when the browser omits its native `online` event.
|
|
14
|
+
|
|
11
15
|
## Private Messenger
|
|
12
16
|
|
|
13
17
|
The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
|
|
@@ -360,6 +364,26 @@ Low-level relay sockets, subscriptions, message parsing, and serialization are
|
|
|
360
364
|
internal implementation details; use `RelayPool` or the `relayPool` singleton
|
|
361
365
|
from `libp2r2p/relay`.
|
|
362
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
|
+
|
|
363
387
|
The same public subpath exports `getRelaysByPubkey(pubkeys)`, which discovers
|
|
364
388
|
the latest NIP-65 relay list for every requested pubkey through `seedRelays`,
|
|
365
389
|
normalizes and deduplicates its public relay URLs, and falls back to the first
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Connectivity
|
|
2
|
+
|
|
3
|
+
`isOnline({ signal } = {})` returns a boolean after probing public connectivity
|
|
4
|
+
endpoints, or immediately returns `false` when the browser reports offline.
|
|
5
|
+
Concurrent calls without a signal share one check. A supplied abort signal
|
|
6
|
+
cancels the caller's independent check. Each endpoint has a five-second timeout.
|
|
7
|
+
|
|
8
|
+
`onOnline(handler)` returns an idempotent unsubscribe function. It notifies
|
|
9
|
+
asynchronously after confirmed initial connectivity and each detected recovery.
|
|
10
|
+
The native `online` event only triggers a check; it is not sufficient evidence.
|
|
11
|
+
Listeners share a monitor with retries at 5, 15, 30, then 60 seconds (20% jitter),
|
|
12
|
+
checks on focus/visibility/network changes, and a 60-second interval while online.
|
|
13
|
+
Unsubscribing the last listener removes timers/listeners and aborts its probe.
|
|
14
|
+
Handler failures are isolated and logged. `createConnectivityMonitor(options)`
|
|
15
|
+
creates an independent monitor with an optional custom `check({ signal })`.
|
|
16
|
+
|
|
17
|
+
Successful probes do not guarantee a particular relay or HTTP server is reachable.
|
|
18
|
+
Handle request failures, cancellations, and timeouts separately. Browsers can
|
|
19
|
+
suspend background tabs, so recovery notification has no wall-clock guarantee.
|
|
20
|
+
Consumers should keep their own work queues but should not duplicate probe loops.
|
package/network/index.js
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') {
|
|
3
|
-
if (!navigator.onLine) return false
|
|
4
|
-
}
|
|
5
|
-
return hasInternetConnectivity()
|
|
6
|
-
}
|
|
1
|
+
import { ValidationError } from '../error/index.js'
|
|
7
2
|
|
|
3
|
+
const RETRY_DELAYS = [5000, 15000, 30000, 60000]
|
|
8
4
|
const CONNECTIVITY_PROBE_URLS = [
|
|
9
5
|
{ url: 'https://www.gstatic.com/generate_204' },
|
|
10
6
|
{ url: 'https://connectivitycheck.gstatic.com/generate_204' },
|
|
11
7
|
{ url: 'https://captive.apple.com/hotspot-detect.html' },
|
|
12
8
|
{ method: 'GET', url: 'https://connectivity-check.ubuntu.com' }
|
|
13
9
|
]
|
|
10
|
+
let sharedCheck
|
|
11
|
+
|
|
12
|
+
// Treat the browser's offline flag as a fast failure; confirm online status with a probe.
|
|
13
|
+
export async function isOnline ({ signal } = {}) {
|
|
14
|
+
if (signal?.aborted) throw signal.reason
|
|
15
|
+
if (globalThis.navigator?.onLine === false) return false
|
|
16
|
+
if (signal) return hasInternetConnectivity(signal)
|
|
17
|
+
sharedCheck ??= hasInternetConnectivity().finally(() => { sharedCheck = null })
|
|
18
|
+
return sharedCheck
|
|
19
|
+
}
|
|
14
20
|
|
|
15
|
-
async function hasInternetConnectivity () {
|
|
16
|
-
const
|
|
17
|
-
|
|
21
|
+
async function hasInternetConnectivity (signal) {
|
|
22
|
+
for (const candidate of shuffle(CONNECTIVITY_PROBE_URLS)) {
|
|
23
|
+
if (signal?.aborted) throw signal.reason
|
|
18
24
|
try {
|
|
19
|
-
await ping(candidate.url, { method: candidate.method })
|
|
25
|
+
await ping(candidate.url, { method: candidate.method, signal })
|
|
20
26
|
return true
|
|
21
|
-
} catch
|
|
22
|
-
|
|
27
|
+
} catch {
|
|
28
|
+
if (signal?.aborted) throw signal.reason
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
return false
|
|
@@ -34,35 +40,143 @@ function shuffle (list) {
|
|
|
34
40
|
return copy
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (timerId != null) clearTimeout(timerId)
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
54
|
-
timerId = setTimeout(() => {
|
|
55
|
-
if (abortController) abortController.abort()
|
|
43
|
+
// Bound each probe and release its timer and abort listener on every exit path.
|
|
44
|
+
async function ping (url, { method = 'HEAD', timeout = 5000, signal } = {}) {
|
|
45
|
+
const controller = new AbortController()
|
|
46
|
+
let timer
|
|
47
|
+
let onAbort
|
|
48
|
+
const stopped = new Promise((_resolve, reject) => {
|
|
49
|
+
onAbort = () => {
|
|
50
|
+
controller.abort(signal.reason)
|
|
51
|
+
reject(signal.reason)
|
|
52
|
+
}
|
|
53
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
54
|
+
timer = setTimeout(() => {
|
|
55
|
+
controller.abort()
|
|
56
56
|
reject(new Error('PING_TIMEOUT'))
|
|
57
57
|
}, timeout)
|
|
58
|
+
if (signal?.aborted) onAbort()
|
|
58
59
|
})
|
|
60
|
+
try {
|
|
61
|
+
await Promise.race([
|
|
62
|
+
fetch(url, { method, mode: 'no-cors', cache: 'no-store', redirect: 'follow', signal: controller.signal }),
|
|
63
|
+
stopped
|
|
64
|
+
])
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timer)
|
|
67
|
+
signal?.removeEventListener('abort', onAbort)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
59
70
|
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
// A monitor owns one probe loop, regardless of how many consumers subscribe.
|
|
72
|
+
export function createConnectivityMonitor ({
|
|
73
|
+
check = isOnline,
|
|
74
|
+
eventTarget = globalThis.window,
|
|
75
|
+
document = globalThis.document,
|
|
76
|
+
_setTimeout = globalThis.setTimeout,
|
|
77
|
+
_clearTimeout = globalThis.clearTimeout,
|
|
78
|
+
_random = Math.random,
|
|
79
|
+
reportError = error => console.error('Online listener failed', error)
|
|
80
|
+
} = {}) {
|
|
81
|
+
if (typeof check !== 'function') throw new ValidationError('INVALID_CONNECTIVITY_CHECK')
|
|
82
|
+
const listeners = new Set()
|
|
83
|
+
const setTimer = (...args) => Reflect.apply(_setTimeout, globalThis, args)
|
|
84
|
+
const clearTimer = (...args) => Reflect.apply(_clearTimeout, globalThis, args)
|
|
85
|
+
let session
|
|
86
|
+
|
|
87
|
+
function deliver (entry, current) {
|
|
88
|
+
if (entry.delivered || !listeners.has(entry) || session !== current) return
|
|
89
|
+
entry.delivered = true
|
|
90
|
+
try { Promise.resolve(entry.handler()).catch(reportError) } catch (error) { reportError(error) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function schedule (current) {
|
|
94
|
+
if (session !== current || !listeners.size) return
|
|
95
|
+
const delay = current.online ? 60000 : RETRY_DELAYS[Math.min(current.retry++, RETRY_DELAYS.length - 1)]
|
|
96
|
+
current.timer = setTimer(() => {
|
|
97
|
+
current.timer = null
|
|
98
|
+
return probe(current)
|
|
99
|
+
}, Math.round(delay * (0.8 + _random() * 0.4)))
|
|
100
|
+
current.timer?.unref?.()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function probe (current) {
|
|
104
|
+
if (session !== current || current.pending) return
|
|
105
|
+
if (current.timer != null) clearTimer(current.timer)
|
|
106
|
+
current.timer = null
|
|
107
|
+
current.pending = true
|
|
108
|
+
try {
|
|
109
|
+
const online = await check({ signal: current.controller.signal })
|
|
110
|
+
if (session !== current) return
|
|
111
|
+
current.online = online === true && globalThis.navigator?.onLine !== false
|
|
112
|
+
if (current.online) {
|
|
113
|
+
current.retry = 0
|
|
114
|
+
for (const entry of [...listeners]) deliver(entry, current)
|
|
115
|
+
} else {
|
|
116
|
+
for (const entry of listeners) entry.delivered = false
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
if (session !== current) return
|
|
120
|
+
current.online = false
|
|
121
|
+
for (const entry of listeners) entry.delivered = false
|
|
122
|
+
} finally {
|
|
123
|
+
current.pending = false
|
|
124
|
+
schedule(current)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function start () {
|
|
129
|
+
const current = { online: false, retry: 0, timer: null, pending: false, controller: new AbortController() }
|
|
130
|
+
session = current
|
|
131
|
+
current.wake = () => { probe(current) }
|
|
132
|
+
current.connectionChanged = () => {
|
|
133
|
+
current.online = false
|
|
134
|
+
current.retry = 0
|
|
135
|
+
for (const entry of listeners) entry.delivered = false
|
|
136
|
+
current.wake()
|
|
137
|
+
}
|
|
138
|
+
current.visible = () => { if (document?.visibilityState !== 'hidden') current.wake() }
|
|
139
|
+
eventTarget?.addEventListener('online', current.connectionChanged)
|
|
140
|
+
eventTarget?.addEventListener('offline', current.connectionChanged)
|
|
141
|
+
eventTarget?.addEventListener('focus', current.wake)
|
|
142
|
+
document?.addEventListener('visibilitychange', current.visible)
|
|
143
|
+
queueMicrotask(current.wake)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stop () {
|
|
147
|
+
const current = session
|
|
148
|
+
session = null
|
|
149
|
+
if (current.timer != null) clearTimer(current.timer)
|
|
150
|
+
current.controller.abort()
|
|
151
|
+
eventTarget?.removeEventListener('online', current.connectionChanged)
|
|
152
|
+
eventTarget?.removeEventListener('offline', current.connectionChanged)
|
|
153
|
+
eventTarget?.removeEventListener('focus', current.wake)
|
|
154
|
+
document?.removeEventListener('visibilitychange', current.visible)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Notify once on confirmed initial connectivity, then after each detected reconnection.
|
|
158
|
+
function onOnline (handler) {
|
|
159
|
+
if (typeof handler !== 'function') throw new ValidationError('INVALID_ONLINE_HANDLER')
|
|
160
|
+
const entry = { handler, delivered: false }
|
|
161
|
+
listeners.add(entry)
|
|
162
|
+
if (!session) start()
|
|
163
|
+
else {
|
|
164
|
+
const current = session
|
|
165
|
+
queueMicrotask(() => { probe(current) })
|
|
166
|
+
}
|
|
167
|
+
return () => {
|
|
168
|
+
if (!listeners.delete(entry)) return
|
|
169
|
+
if (!listeners.size) stop()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { onOnline }
|
|
62
174
|
}
|
|
63
175
|
|
|
176
|
+
let defaultMonitor
|
|
177
|
+
|
|
178
|
+
// Share probes, capped backoff and wake-up listeners across callers in this realm.
|
|
64
179
|
export function onOnline (handler) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return () => window.removeEventListener('online', listener)
|
|
180
|
+
defaultMonitor ??= createConnectivityMonitor()
|
|
181
|
+
return defaultMonitor.onOnline(handler)
|
|
68
182
|
}
|
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
|
})
|