libp2r2p 0.7.0 → 0.9.0
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 +112 -8
- package/base16/index.js +6 -3
- package/base36/index.js +12 -8
- package/base62/index.js +15 -11
- package/base64/index.js +18 -2
- package/base93/index.js +19 -7
- package/content-key/event/index.js +40 -12
- package/double-dh/index.js +21 -6
- package/ecdh/index.js +12 -1
- package/error/index.js +32 -0
- package/event/helpers/serialize.js +31 -0
- package/event/index.js +116 -0
- package/i18n/index.js +15 -13
- package/idb/index.js +5 -3
- package/idb-queue/index.js +21 -20
- package/index.js +10 -0
- package/key/index.js +31 -18
- package/kind/index.js +244 -0
- package/nip04/index.js +47 -0
- package/nip05/index.js +61 -0
- package/nip19/index.js +176 -43
- package/nip44/helpers.js +95 -0
- package/nip44/index.js +14 -0
- package/nip44-v3/index.js +35 -16
- package/nip46/helpers/frame.js +6 -6
- package/nip46/helpers/url.js +8 -6
- package/nip46/services/bunker-signer.js +7 -6
- package/nip46/services/client.js +8 -7
- package/nip46/services/server-session.js +8 -7
- package/nip46/services/transport.js +9 -8
- package/nip96/index.js +285 -0
- package/nip98/index.js +56 -0
- package/nwt/index.js +241 -0
- package/package.json +22 -3
- package/private-channel/helpers/chunks.js +7 -6
- package/private-channel/helpers/event.js +5 -4
- package/private-channel/index.js +63 -61
- package/private-channel/services/received-chunks.js +4 -3
- package/private-message/index.js +32 -31
- package/private-messenger/index.js +313 -54
- package/private-messenger/recovery/index.js +15 -14
- package/private-messenger/services/channel-state.js +28 -7
- package/private-messenger/services/storage-maintenance.js +142 -46
- package/relay/helpers/hll.js +3 -1
- package/relay/services/query.js +3 -3
- package/relay/services/relay-connection.js +222 -121
- package/relay/services/relay-pool.js +13 -10
- package/temporary-storage/index.js +3 -1
- package/url/index.js +131 -0
- package/web-storage-queue/index.js +8 -6
|
@@ -1,156 +1,257 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isValidEvent } from '../../event/index.js'
|
|
2
|
+
import { ValidationError } from '../../error/index.js'
|
|
2
3
|
import { maybeUnref } from '../helpers/timer.js'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
const DEFAULT_CONNECT_TIMEOUT = 3000
|
|
6
|
+
const DEFAULT_OPERATION_TIMEOUT = 30000
|
|
7
|
+
|
|
8
|
+
function errorFrom (reason, fallback) {
|
|
9
|
+
return reason instanceof Error ? reason : new Error(String(reason || fallback))
|
|
6
10
|
}
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
this.#rejectPendingCounts(new Error('COUNT_CONNECTION_CLOSED'))
|
|
23
|
-
}
|
|
12
|
+
function hasMatchingPrefix (values, candidate) {
|
|
13
|
+
return !values || values.some(value => typeof value === 'string' && candidate.startsWith(value))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function doesEventMatchFilter (filter, event) {
|
|
17
|
+
if (filter.ids && !hasMatchingPrefix(filter.ids, event.id)) return false
|
|
18
|
+
if (filter.authors && !hasMatchingPrefix(filter.authors, event.pubkey)) return false
|
|
19
|
+
if (filter.kinds && !filter.kinds.includes(event.kind)) return false
|
|
20
|
+
if (filter.since != null && event.created_at < filter.since) return false
|
|
21
|
+
if (filter.until != null && event.created_at > filter.until) return false
|
|
22
|
+
for (const [key, values] of Object.entries(filter)) {
|
|
23
|
+
if (!key.startsWith('#') || !Array.isArray(values)) continue
|
|
24
|
+
const name = key.slice(1)
|
|
25
|
+
if (!event.tags.some(tag => tag[0] === name && values.includes(tag[1]))) return false
|
|
24
26
|
}
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
function doesEventMatchAnyFilter (filters, event) {
|
|
31
|
+
return filters.some(filter => doesEventMatchFilter(filter, event))
|
|
32
|
+
}
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
async function messageText (data) {
|
|
35
|
+
if (typeof data === 'string') return data
|
|
36
|
+
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data)
|
|
37
|
+
if (ArrayBuffer.isView(data)) return new TextDecoder().decode(data)
|
|
38
|
+
if (typeof data?.text === 'function') return await data.text()
|
|
39
|
+
throw new ValidationError('INVALID_RELAY_MESSAGE')
|
|
40
|
+
}
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
export class RelayConnection {
|
|
43
|
+
#WebSocket
|
|
44
|
+
#connectPromise = null
|
|
45
|
+
#challenge = null
|
|
46
|
+
#serial = 0
|
|
47
|
+
#subscriptions = new Map()
|
|
48
|
+
#publishes = new Map()
|
|
49
|
+
#authentications = new Map()
|
|
50
|
+
#counts = new Map()
|
|
51
|
+
|
|
52
|
+
constructor (url, { WebSocket: WebSocketImpl = globalThis.WebSocket } = {}) {
|
|
53
|
+
this.url = url
|
|
54
|
+
this.#WebSocket = WebSocketImpl
|
|
55
|
+
this.ws = null
|
|
56
|
+
this.publishTimeout = DEFAULT_OPERATION_TIMEOUT
|
|
57
|
+
this.onnotice = null
|
|
58
|
+
this.onerror = null
|
|
59
|
+
this.onclose = null
|
|
60
|
+
this.onauth = null
|
|
40
61
|
}
|
|
41
62
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
63
|
+
async connect ({ timeout = DEFAULT_CONNECT_TIMEOUT, signal } = {}) {
|
|
64
|
+
if (this.ws?.readyState === 1) return
|
|
65
|
+
if (this.#connectPromise) return await this.#connectPromise
|
|
66
|
+
if (signal?.aborted) throw new Error('CONNECT_ABORTED')
|
|
67
|
+
if (typeof this.#WebSocket !== 'function') throw new Error('WEBSOCKET_UNAVAILABLE')
|
|
45
68
|
|
|
46
|
-
this.#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
69
|
+
this.#connectPromise = new Promise((resolve, reject) => {
|
|
70
|
+
const socket = new this.#WebSocket(this.url)
|
|
71
|
+
this.ws = socket
|
|
72
|
+
let settled = false
|
|
73
|
+
const finish = (reason) => {
|
|
74
|
+
if (settled) return
|
|
75
|
+
settled = true
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
signal?.removeEventListener('abort', onAbort)
|
|
78
|
+
if (reason) {
|
|
79
|
+
try { socket.close() } catch {}
|
|
80
|
+
reject(reason)
|
|
81
|
+
} else resolve()
|
|
82
|
+
}
|
|
83
|
+
const onAbort = () => finish(new Error('CONNECT_ABORTED'))
|
|
84
|
+
const timer = timeout === null ? null : maybeUnref(setTimeout(() => finish(new Error('CONNECT_TIMEOUT')), timeout))
|
|
85
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
86
|
+
|
|
87
|
+
socket.onopen = () => finish()
|
|
88
|
+
socket.onerror = event => {
|
|
89
|
+
const reason = errorFrom(event?.error, 'CONNECTION_ERROR')
|
|
90
|
+
if (!settled) finish(reason)
|
|
91
|
+
else this.onerror?.(reason)
|
|
92
|
+
}
|
|
93
|
+
socket.onmessage = event => { this.#handleMessage(event).catch(reason => this.onerror?.(reason)) }
|
|
94
|
+
socket.onclose = event => {
|
|
95
|
+
if (!settled) finish(new Error('CONNECTION_CLOSED'))
|
|
96
|
+
if (this.ws === socket) this.ws = null
|
|
97
|
+
this.#handleClose(event)
|
|
98
|
+
}
|
|
99
|
+
}).finally(() => { this.#connectPromise = null })
|
|
100
|
+
return await this.#connectPromise
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
send (message) {
|
|
104
|
+
if (this.ws?.readyState !== 1) throw new Error('CONNECTION_CLOSED')
|
|
105
|
+
this.ws.send(message)
|
|
50
106
|
}
|
|
51
107
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
108
|
+
subscribe (filters, handlers = {}) {
|
|
109
|
+
if (!Array.isArray(filters) || !filters.length) throw new ValidationError('SUBSCRIPTION_FILTERS_REQUIRED')
|
|
110
|
+
const id = `p2r2p-sub:${++this.#serial}`
|
|
111
|
+
let closed = false
|
|
112
|
+
const close = () => {
|
|
113
|
+
if (closed) return
|
|
114
|
+
closed = true
|
|
115
|
+
const subscription = this.#subscriptions.get(id)
|
|
116
|
+
if (!subscription) return
|
|
117
|
+
this.#subscriptions.delete(id)
|
|
118
|
+
try { this.send(JSON.stringify(['CLOSE', id])) } catch {}
|
|
119
|
+
handlers.onclose?.()
|
|
120
|
+
}
|
|
121
|
+
this.#subscriptions.set(id, { filters, handlers, close })
|
|
122
|
+
try { this.send(JSON.stringify(['REQ', id, ...filters])) } catch (error) {
|
|
123
|
+
this.#subscriptions.delete(id)
|
|
124
|
+
throw error
|
|
55
125
|
}
|
|
126
|
+
return { id, close }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
publish (event) {
|
|
130
|
+
if (!isValidEvent(event)) return Promise.reject(new Error('INVALID_EVENT'))
|
|
131
|
+
return this.#sendEventOperation('EVENT', event, this.#publishes, 'PUBLISH_TIMEOUT')
|
|
56
132
|
}
|
|
57
133
|
|
|
58
|
-
// Sends a caller-signed AUTH event and waits for the matching NIP-42 OK.
|
|
59
|
-
// Each auth event is independent, so different pubkeys can share this socket.
|
|
60
134
|
async authenticate (getAuthEvent) {
|
|
61
135
|
if (!this.#challenge) throw new Error('AUTH_CHALLENGE_MISSING')
|
|
136
|
+
const event = await getAuthEvent({ relay: this.url, challenge: this.#challenge })
|
|
137
|
+
if (!isValidEvent(event)) throw new ValidationError('INVALID_AUTH_EVENT')
|
|
138
|
+
return await this.#sendEventOperation('AUTH', event, this.#authentications, 'AUTH_TIMEOUT')
|
|
139
|
+
}
|
|
62
140
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let rejectAuth
|
|
73
|
-
const promise = new Promise((resolve, reject) => {
|
|
74
|
-
resolveAuth = resolve
|
|
75
|
-
rejectAuth = reject
|
|
76
|
-
})
|
|
77
|
-
const timer = maybeUnref(setTimeout(() => {
|
|
78
|
-
this.#settleAuth(event.id, { success: false, reason: new Error('AUTH_TIMEOUT') })
|
|
79
|
-
}, this.publishTimeout))
|
|
80
|
-
this.#pendingAuths.set(event.id, {
|
|
81
|
-
resolve: resolveAuth,
|
|
82
|
-
reject: rejectAuth,
|
|
83
|
-
timer,
|
|
84
|
-
promise
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
Promise.resolve(this.send(JSON.stringify(['AUTH', event]))).catch(reason => {
|
|
88
|
-
this.#settleAuth(event.id, { success: false, reason })
|
|
89
|
-
})
|
|
90
|
-
return promise
|
|
141
|
+
#sendEventOperation (type, event, map, timeoutCode) {
|
|
142
|
+
if (map.has(event.id)) return map.get(event.id).promise
|
|
143
|
+
const deferred = Promise.withResolvers()
|
|
144
|
+
const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, new Error(timeoutCode)), this.publishTimeout))
|
|
145
|
+
map.set(event.id, { ...deferred, timer, promise: deferred.promise })
|
|
146
|
+
try { this.send(JSON.stringify([type, event])) } catch (error) {
|
|
147
|
+
this.#settleEvent(map, event.id, error)
|
|
148
|
+
}
|
|
149
|
+
return deferred.promise
|
|
91
150
|
}
|
|
92
151
|
|
|
93
|
-
// nostr-tools@2.23.5 exposes only Relay.count(), which discards the NIP-45
|
|
94
|
-
// HLL payload. Keep these requests separate so callers can aggregate it.
|
|
95
152
|
countWithHll (filters, { signal } = {}) {
|
|
96
153
|
if (signal?.aborted) return Promise.reject(new Error('COUNT_ABORTED'))
|
|
154
|
+
const id = `p2r2p-count:${++this.#serial}`
|
|
155
|
+
const deferred = Promise.withResolvers()
|
|
156
|
+
const onAbort = () => this.#settleCount(id, null, new Error('COUNT_ABORTED'))
|
|
157
|
+
this.#counts.set(id, { ...deferred, signal, onAbort })
|
|
158
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
159
|
+
try { this.send(JSON.stringify(['COUNT', id, ...filters])) } catch (error) {
|
|
160
|
+
this.#settleCount(id, null, error)
|
|
161
|
+
}
|
|
162
|
+
return deferred.promise
|
|
163
|
+
}
|
|
97
164
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
165
|
+
#settleEvent (map, id, reason, value) {
|
|
166
|
+
const pending = map.get(id)
|
|
167
|
+
if (!pending) return
|
|
168
|
+
map.delete(id)
|
|
169
|
+
clearTimeout(pending.timer)
|
|
170
|
+
if (reason) pending.reject(errorFrom(reason, 'OPERATION_REJECTED'))
|
|
171
|
+
else pending.resolve(value)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#settleCount (id, payload, reason) {
|
|
175
|
+
const pending = this.#counts.get(id)
|
|
176
|
+
if (!pending) return
|
|
177
|
+
this.#counts.delete(id)
|
|
178
|
+
pending.signal?.removeEventListener('abort', pending.onAbort)
|
|
179
|
+
if (reason) pending.reject(errorFrom(reason, 'COUNT_REJECTED'))
|
|
180
|
+
else pending.resolve(payload)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async #handleMessage (message) {
|
|
184
|
+
let data
|
|
185
|
+
try { data = JSON.parse(await messageText(message.data)) } catch (cause) {
|
|
186
|
+
if (cause instanceof ValidationError) throw cause
|
|
187
|
+
throw new ValidationError('INVALID_RELAY_MESSAGE', { cause })
|
|
107
188
|
}
|
|
108
|
-
|
|
109
|
-
resolve: resolveCount,
|
|
110
|
-
reject: rejectCount,
|
|
111
|
-
signal,
|
|
112
|
-
onAbort
|
|
113
|
-
})
|
|
114
|
-
signal?.addEventListener('abort', onAbort, { once: true })
|
|
189
|
+
if (!Array.isArray(data) || typeof data[0] !== 'string') throw new ValidationError('INVALID_RELAY_MESSAGE')
|
|
115
190
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
191
|
+
if (data[0] === 'EVENT') {
|
|
192
|
+
const subscription = this.#subscriptions.get(data[1])
|
|
193
|
+
if (!subscription) return
|
|
194
|
+
const event = data[2]
|
|
195
|
+
if (!isValidEvent(event) || !doesEventMatchAnyFilter(subscription.filters, event)) subscription.handlers.oninvalidevent?.(event)
|
|
196
|
+
else subscription.handlers.onevent?.(event)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if (data[0] === 'EOSE') {
|
|
200
|
+
this.#subscriptions.get(data[1])?.handlers.oneose?.()
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
if (data[0] === 'CLOSED') {
|
|
204
|
+
const id = data[1]
|
|
205
|
+
const subscription = this.#subscriptions.get(id)
|
|
206
|
+
if (subscription) {
|
|
207
|
+
this.#subscriptions.delete(id)
|
|
208
|
+
subscription.handlers.onclose?.(errorFrom(data[2], 'SUBSCRIPTION_CLOSED'))
|
|
209
|
+
} else this.#settleCount(id, null, errorFrom(data[2], 'COUNT_CLOSED'))
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
if (data[0] === 'OK') {
|
|
213
|
+
const reason = data[2] === true ? null : errorFrom(data[3], 'EVENT_REJECTED')
|
|
214
|
+
this.#settleEvent(this.#publishes, data[1], reason, data[3])
|
|
215
|
+
this.#settleEvent(this.#authentications, data[1], reason, data[3])
|
|
216
|
+
return
|
|
122
217
|
}
|
|
123
|
-
|
|
124
|
-
this.#
|
|
125
|
-
|
|
126
|
-
|
|
218
|
+
if (data[0] === 'AUTH' && typeof data[1] === 'string') {
|
|
219
|
+
this.#challenge = data[1]
|
|
220
|
+
this.onauth?.(data[1])
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
if (data[0] === 'COUNT') {
|
|
224
|
+
this.#settleCount(data[1], data[2])
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
if (data[0] === 'NOTICE') this.onnotice?.(String(data[1] ?? ''))
|
|
127
228
|
}
|
|
128
229
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
this.#challenge = data[1]
|
|
136
|
-
} else if (data[0] === 'OK' && typeof data[1] === 'string') {
|
|
137
|
-
this.#settleAuth(data[1], { success: data[2] === true, reason: data[3] })
|
|
138
|
-
} else if (data[0] === 'COUNT' && typeof data[1] === 'string') {
|
|
139
|
-
this.#settleCount(data[1], { payload: data[2] })
|
|
140
|
-
} else if (data[0] === 'CLOSED' && typeof data[1] === 'string') {
|
|
141
|
-
this.#settleCount(data[1], { reason: data[2] || 'COUNT_CLOSED' })
|
|
142
|
-
}
|
|
143
|
-
} catch {
|
|
144
|
-
// Relay's own parser below reports malformed relay messages.
|
|
230
|
+
#handleClose (event) {
|
|
231
|
+
this.#challenge = null
|
|
232
|
+
const reason = errorFrom(event?.reason, 'CONNECTION_CLOSED')
|
|
233
|
+
for (const [id, subscription] of this.#subscriptions) {
|
|
234
|
+
this.#subscriptions.delete(id)
|
|
235
|
+
subscription.handlers.onclose?.(reason)
|
|
145
236
|
}
|
|
146
|
-
|
|
147
|
-
|
|
237
|
+
for (const id of [...this.#publishes.keys()]) this.#settleEvent(this.#publishes, id, reason)
|
|
238
|
+
for (const id of [...this.#authentications.keys()]) this.#settleEvent(this.#authentications, id, reason)
|
|
239
|
+
for (const id of [...this.#counts.keys()]) this.#settleCount(id, null, reason)
|
|
240
|
+
this.onclose?.(reason)
|
|
148
241
|
}
|
|
149
242
|
|
|
150
|
-
close () {
|
|
243
|
+
async close () {
|
|
244
|
+
const socket = this.ws
|
|
245
|
+
this.ws = null
|
|
151
246
|
this.#challenge = null
|
|
152
|
-
|
|
153
|
-
this.#
|
|
154
|
-
|
|
247
|
+
const reason = new Error('CONNECTION_CLOSED')
|
|
248
|
+
for (const [id, subscription] of this.#subscriptions) {
|
|
249
|
+
this.#subscriptions.delete(id)
|
|
250
|
+
subscription.handlers.onclose?.()
|
|
251
|
+
}
|
|
252
|
+
for (const id of [...this.#publishes.keys()]) this.#settleEvent(this.#publishes, id, reason)
|
|
253
|
+
for (const id of [...this.#authentications.keys()]) this.#settleEvent(this.#authentications, id, reason)
|
|
254
|
+
for (const id of [...this.#counts.keys()]) this.#settleCount(id, null, reason)
|
|
255
|
+
if (socket && socket.readyState < 2) socket.close()
|
|
155
256
|
}
|
|
156
257
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { ValidationError } from '../../error/index.js'
|
|
1
2
|
import { decodeHll, encodeHll, estimateHllCount, mergeHll } from '../helpers/hll.js'
|
|
2
3
|
import { createPublishSettlements, firstFulfillment, publishSummary } from '../helpers/publish.js'
|
|
3
4
|
import { maybeUnref } from '../helpers/timer.js'
|
|
4
|
-
import {
|
|
5
|
+
import { normalizeRelayUrl } from '../../url/index.js'
|
|
5
6
|
import { RelayConnection } from './relay-connection.js'
|
|
6
7
|
|
|
7
8
|
const CONNECTION_TIMEOUT_MS = 3000
|
|
@@ -82,7 +83,7 @@ function normalizedRelayUrls (relays) {
|
|
|
82
83
|
const seen = new Set()
|
|
83
84
|
|
|
84
85
|
for (const relay of relays || []) {
|
|
85
|
-
const normalizedUrl =
|
|
86
|
+
const normalizedUrl = normalizeRelayUrl(relay)
|
|
86
87
|
if (seen.has(normalizedUrl)) continue
|
|
87
88
|
seen.add(normalizedUrl)
|
|
88
89
|
// Keep the caller's first spelling in reports and metadata while using the
|
|
@@ -110,6 +111,11 @@ export class RelayPool {
|
|
|
110
111
|
#relayTimeouts = new Map()
|
|
111
112
|
#liveSubCounts = new Map() // url -> number of active live subscriptions
|
|
112
113
|
#timeout = 30000 // 30 seconds
|
|
114
|
+
#createRelay
|
|
115
|
+
|
|
116
|
+
constructor ({ _createRelay = url => new RelayConnection(url) } = {}) {
|
|
117
|
+
this.#createRelay = _createRelay
|
|
118
|
+
}
|
|
113
119
|
|
|
114
120
|
#scheduleIdleDisconnect (url) {
|
|
115
121
|
clearTimeout(this.#relayTimeouts.get(url))
|
|
@@ -119,15 +125,14 @@ export class RelayPool {
|
|
|
119
125
|
// Opens a normalized pooled connection. Failed connects are evicted so a later
|
|
120
126
|
// retry creates a fresh RelayConnection instead of reusing broken socket state.
|
|
121
127
|
async #getRelay (url) {
|
|
122
|
-
const normalizedUrl =
|
|
128
|
+
const normalizedUrl = normalizeRelayUrl(url)
|
|
123
129
|
let relay = this.#relays.get(normalizedUrl)
|
|
124
130
|
if (!relay) {
|
|
125
|
-
relay =
|
|
131
|
+
relay = this.#createRelay(normalizedUrl)
|
|
126
132
|
this.#relays.set(normalizedUrl, relay)
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
try {
|
|
130
|
-
// nostr-tools cancels its socket attempt when this deadline elapses.
|
|
131
136
|
await relay.connect({ timeout: CONNECTION_TIMEOUT_MS })
|
|
132
137
|
} catch (error) {
|
|
133
138
|
if (this.#relays.get(normalizedUrl) === relay) {
|
|
@@ -147,7 +152,7 @@ export class RelayPool {
|
|
|
147
152
|
}
|
|
148
153
|
|
|
149
154
|
#incrementLiveSub (url) {
|
|
150
|
-
const normalizedUrl =
|
|
155
|
+
const normalizedUrl = normalizeRelayUrl(url)
|
|
151
156
|
this.#liveSubCounts.set(normalizedUrl, (this.#liveSubCounts.get(normalizedUrl) ?? 0) + 1)
|
|
152
157
|
// Cancel any pending idle timeout — this relay must stay open
|
|
153
158
|
clearTimeout(this.#relayTimeouts.get(normalizedUrl))
|
|
@@ -155,7 +160,7 @@ export class RelayPool {
|
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
#decrementLiveSub (url) {
|
|
158
|
-
const normalizedUrl =
|
|
163
|
+
const normalizedUrl = normalizeRelayUrl(url)
|
|
159
164
|
const next = (this.#liveSubCounts.get(normalizedUrl) ?? 1) - 1
|
|
160
165
|
if (next <= 0) {
|
|
161
166
|
this.#liveSubCounts.delete(normalizedUrl)
|
|
@@ -217,7 +222,7 @@ export class RelayPool {
|
|
|
217
222
|
signal
|
|
218
223
|
} = {}) {
|
|
219
224
|
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
|
220
|
-
throw new
|
|
225
|
+
throw new ValidationError('COUNT_FILTER_REQUIRED')
|
|
221
226
|
}
|
|
222
227
|
if (signal?.aborted) throw new Error('Aborted')
|
|
223
228
|
|
|
@@ -414,8 +419,6 @@ export class RelayPool {
|
|
|
414
419
|
// Actual EOSE and filter satisfaction share the same graceful close path.
|
|
415
420
|
const handleEose = () => {
|
|
416
421
|
if (isResolved || !pending.has(url)) return
|
|
417
|
-
// nostr-tools reports "closed by caller" through onclose(). This is a
|
|
418
|
-
// successful local completion, not a relay failure.
|
|
419
422
|
normalCloseUrls.add(url)
|
|
420
423
|
sub.close()
|
|
421
424
|
if (hasEvents && timeoutAfterFirstEose !== null && !eoseTimer && !isResolved) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ValidationError } from '../error/index.js'
|
|
2
|
+
|
|
1
3
|
export const TEMPORARY_STORAGE_KEYS_KEY = 'libp2r2p:temporary-storage:keys'
|
|
2
4
|
|
|
3
5
|
function normalizeKeys (keys) {
|
|
@@ -54,7 +56,7 @@ export function createTemporaryStorage ({ storageArea = globalThis.sessionStorag
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
function setItem (key, value) {
|
|
57
|
-
if (typeof key !== 'string' || !key || key === TEMPORARY_STORAGE_KEYS_KEY) throw new
|
|
59
|
+
if (typeof key !== 'string' || !key || key === TEMPORARY_STORAGE_KEYS_KEY) throw new ValidationError('INVALID_TEMPORARY_STORAGE_KEY')
|
|
58
60
|
trackTemporaryKey(key)
|
|
59
61
|
storage().setItem(key, value)
|
|
60
62
|
}
|
package/url/index.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ValidationError } from '../error/index.js'
|
|
2
|
+
|
|
3
|
+
function isIpv4Address (hostname) {
|
|
4
|
+
const parts = hostname.split('.')
|
|
5
|
+
return parts.length === 4 && parts.every(part =>
|
|
6
|
+
/^(?:0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255
|
|
7
|
+
)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isPublicIpv4Address (hostname) {
|
|
11
|
+
const [a, b, c] = hostname.split('.').map(Number)
|
|
12
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224) return false
|
|
13
|
+
if (a === 100 && b >= 64 && b <= 127) return false
|
|
14
|
+
if (a === 169 && b === 254) return false
|
|
15
|
+
if (a === 172 && b >= 16 && b <= 31) return false
|
|
16
|
+
if (a === 192 && b === 168) return false
|
|
17
|
+
if (a === 192 && b === 0 && (c === 0 || c === 2)) return false
|
|
18
|
+
if (a === 192 && b === 88 && c === 99) return false
|
|
19
|
+
if (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) return false
|
|
20
|
+
if (a === 203 && b === 0 && c === 113) return false
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseIpv6Words (hostname) {
|
|
25
|
+
const value = hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
|
26
|
+
if (!/^[0-9a-f:]+$/.test(value) || value.split('::').length > 2) return null
|
|
27
|
+
const [left = '', right = ''] = value.split('::')
|
|
28
|
+
const leftWords = left ? left.split(':') : []
|
|
29
|
+
const rightWords = right ? right.split(':') : []
|
|
30
|
+
const missing = 8 - leftWords.length - rightWords.length
|
|
31
|
+
if ((value.includes('::') && missing < 1) || (!value.includes('::') && missing !== 0)) return null
|
|
32
|
+
const words = [...leftWords, ...Array(missing).fill('0'), ...rightWords]
|
|
33
|
+
if (words.length !== 8 || words.some(word => !/^[0-9a-f]{1,4}$/.test(word))) return null
|
|
34
|
+
return words.map(word => Number.parseInt(word, 16))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isPublicIpv6Address (hostname) {
|
|
38
|
+
const words = parseIpv6Words(hostname)
|
|
39
|
+
if (!words) return false
|
|
40
|
+
const first = words[0]
|
|
41
|
+
if (first === 0) return false
|
|
42
|
+
if ((first & 0xfe00) === 0xfc00) return false
|
|
43
|
+
if ((first & 0xffc0) === 0xfe80) return false
|
|
44
|
+
if ((first & 0xff00) === 0xff00) return false
|
|
45
|
+
if (first === 0x2001 && words[1] === 0x0db8) return false
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function removeEmptyQuerySegments (url) {
|
|
50
|
+
const entries = [...url.searchParams]
|
|
51
|
+
url.search = ''
|
|
52
|
+
for (const [key, value] of entries) url.searchParams.append(key, value)
|
|
53
|
+
url.searchParams.sort()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function normalizeRelayUrl (value) {
|
|
57
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
58
|
+
throw new ValidationError('INVALID_RELAY_URL', { message: 'URL_SHOULD_BE_A_STRING' })
|
|
59
|
+
}
|
|
60
|
+
let input = value.trim()
|
|
61
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) input = `wss://${input}`
|
|
62
|
+
let url
|
|
63
|
+
try {
|
|
64
|
+
url = new URL(input)
|
|
65
|
+
} catch (cause) {
|
|
66
|
+
throw new ValidationError('INVALID_RELAY_URL', { cause })
|
|
67
|
+
}
|
|
68
|
+
if (url.protocol === 'http:') url.protocol = 'ws:'
|
|
69
|
+
else if (url.protocol === 'https:') url.protocol = 'wss:'
|
|
70
|
+
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw new ValidationError('INVALID_RELAY_PROTOCOL')
|
|
71
|
+
url.pathname = url.pathname.replace(/\/{2,}/g, '/').replace(/\/+$/, '')
|
|
72
|
+
if (url.pathname === '/') url.pathname = ''
|
|
73
|
+
if ((url.protocol === 'ws:' && url.port === '80') || (url.protocol === 'wss:' && url.port === '443')) url.port = ''
|
|
74
|
+
removeEmptyQuerySegments(url)
|
|
75
|
+
url.hash = ''
|
|
76
|
+
return url.toString().replace(/^(wss?:\/\/[^/?#]+)\/(?=[?#]|$)/, '$1')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function publicRelayUrlError (value) {
|
|
80
|
+
if (typeof value !== 'string' || value.trim().length === 0) return 'INVALID_RELAY_URL'
|
|
81
|
+
let input = value.trim()
|
|
82
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) input = `wss://${input}`
|
|
83
|
+
try {
|
|
84
|
+
if (decodeURIComponent(new URL(input).pathname).includes('://')) return 'RELAY_URL_NESTED_SCHEME'
|
|
85
|
+
} catch {
|
|
86
|
+
return 'INVALID_RELAY_URL'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let normalized
|
|
90
|
+
try {
|
|
91
|
+
normalized = normalizeRelayUrl(value)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return error instanceof ValidationError ? error.code : 'INVALID_RELAY_URL'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const url = new URL(normalized)
|
|
97
|
+
if (url.protocol !== 'wss:') return 'INSECURE_RELAY_URL'
|
|
98
|
+
if (url.username || url.password) return 'RELAY_URL_CREDENTIALS_NOT_ALLOWED'
|
|
99
|
+
|
|
100
|
+
const hostname = url.hostname.toLowerCase()
|
|
101
|
+
if (
|
|
102
|
+
hostname === 'localhost' ||
|
|
103
|
+
hostname.endsWith('.localhost') ||
|
|
104
|
+
hostname.endsWith('.local') ||
|
|
105
|
+
hostname.endsWith('.onion')
|
|
106
|
+
) return 'NON_PUBLIC_RELAY_HOST'
|
|
107
|
+
|
|
108
|
+
const unwrappedHostname = hostname.replace(/^\[|\]$/g, '')
|
|
109
|
+
if (isIpv4Address(unwrappedHostname)) {
|
|
110
|
+
if (!isPublicIpv4Address(unwrappedHostname)) return 'NON_PUBLIC_RELAY_IP'
|
|
111
|
+
} else if (unwrappedHostname.includes(':')) {
|
|
112
|
+
if (!isPublicIpv6Address(unwrappedHostname)) return 'NON_PUBLIC_RELAY_IP'
|
|
113
|
+
} else if (!hostname.includes('.')) {
|
|
114
|
+
return 'INVALID_RELAY_HOST'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const lowerValue = normalized.toLowerCase()
|
|
118
|
+
if (lowerValue.includes('npub1') || lowerValue.includes('nprofile1')) return 'RELAY_URL_NOSTR_ENTITY_NOT_ALLOWED'
|
|
119
|
+
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isValidPublicRelayUrl (value) {
|
|
124
|
+
return publicRelayUrlError(value) === null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function assertValidPublicRelayUrl (value) {
|
|
128
|
+
const code = publicRelayUrlError(value)
|
|
129
|
+
if (code) throw new ValidationError(code)
|
|
130
|
+
return value
|
|
131
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ValidationError } from '../error/index.js'
|
|
2
|
+
|
|
1
3
|
const encoder = new TextEncoder()
|
|
2
4
|
const DEFAULT_EVICTION_HEADROOM_RATIO = 0.1
|
|
3
5
|
const MAX_EVICTION_HEADROOM_BYTES = 64 * 1024 // 64 KiB
|
|
@@ -8,7 +10,7 @@ export function createQueue ({
|
|
|
8
10
|
maxBytes,
|
|
9
11
|
evictionPolicy = 'opposite-end' // 'opposite-end' = push evicts from head, unshift evicts from tail
|
|
10
12
|
} = {}) {
|
|
11
|
-
if (!prefix) throw new
|
|
13
|
+
if (!prefix) throw new ValidationError('QUEUE_PREFIX_REQUIRED')
|
|
12
14
|
const stateKey = `${prefix}:queue`
|
|
13
15
|
const operationKey = `${prefix}:queue:operation`
|
|
14
16
|
const itemPrefix = `${prefix}:queue:item:`
|
|
@@ -44,7 +46,7 @@ export function createQueue ({
|
|
|
44
46
|
if (policy === 'opposite-end' || policy === undefined || policy === null) return 'opposite-end'
|
|
45
47
|
if (policy === 'fifo' || policy === 'head') return 'head'
|
|
46
48
|
if (policy === 'lifo' || policy === 'tail') return 'tail'
|
|
47
|
-
throw new
|
|
49
|
+
throw new ValidationError('QUEUE_INVALID_EVICTION_POLICY')
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
function evictionDirectionFor (operation, { index = 0, length = 0 } = {}) {
|
|
@@ -251,7 +253,7 @@ export function createQueue ({
|
|
|
251
253
|
|
|
252
254
|
function assertIndex (index, length, { allowEnd = false } = {}) {
|
|
253
255
|
const max = allowEnd ? length : length - 1
|
|
254
|
-
if (!Number.isSafeInteger(index) || index < 0 || index > max) throw new
|
|
256
|
+
if (!Number.isSafeInteger(index) || index < 0 || index > max) throw new ValidationError('QUEUE_INDEX_OUT_OF_RANGE')
|
|
255
257
|
}
|
|
256
258
|
|
|
257
259
|
function itemForStorage (item) {
|
|
@@ -514,7 +516,7 @@ export function createQueue ({
|
|
|
514
516
|
}
|
|
515
517
|
|
|
516
518
|
function insertWhere (predicate, item, { appendIfMissing = false } = {}) {
|
|
517
|
-
if (typeof predicate !== 'function') throw new
|
|
519
|
+
if (typeof predicate !== 'function') throw new ValidationError('QUEUE_PREDICATE_REQUIRED')
|
|
518
520
|
const state = readState()
|
|
519
521
|
const length = lengthFromState(state)
|
|
520
522
|
|
|
@@ -591,7 +593,7 @@ export function createQueue ({
|
|
|
591
593
|
}
|
|
592
594
|
|
|
593
595
|
function removeWhere (predicate) {
|
|
594
|
-
if (typeof predicate !== 'function') throw new
|
|
596
|
+
if (typeof predicate !== 'function') throw new ValidationError('QUEUE_PREDICATE_REQUIRED')
|
|
595
597
|
const state = readState()
|
|
596
598
|
// Bulk predicate removal leaves holes on purpose; shift/pop skip them, and
|
|
597
599
|
// callers that need contiguous positions can use removeAt for compaction.
|
|
@@ -613,7 +615,7 @@ export function createQueue ({
|
|
|
613
615
|
}
|
|
614
616
|
|
|
615
617
|
function some (predicate) {
|
|
616
|
-
if (typeof predicate !== 'function') throw new
|
|
618
|
+
if (typeof predicate !== 'function') throw new ValidationError('QUEUE_PREDICATE_REQUIRED')
|
|
617
619
|
const state = readState()
|
|
618
620
|
for (let id = state.head; id < state.tail; id++) {
|
|
619
621
|
const item = readItem(id)
|