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.
Files changed (50) hide show
  1. package/README.md +112 -8
  2. package/base16/index.js +6 -3
  3. package/base36/index.js +12 -8
  4. package/base62/index.js +15 -11
  5. package/base64/index.js +18 -2
  6. package/base93/index.js +19 -7
  7. package/content-key/event/index.js +40 -12
  8. package/double-dh/index.js +21 -6
  9. package/ecdh/index.js +12 -1
  10. package/error/index.js +32 -0
  11. package/event/helpers/serialize.js +31 -0
  12. package/event/index.js +116 -0
  13. package/i18n/index.js +15 -13
  14. package/idb/index.js +5 -3
  15. package/idb-queue/index.js +21 -20
  16. package/index.js +10 -0
  17. package/key/index.js +31 -18
  18. package/kind/index.js +244 -0
  19. package/nip04/index.js +47 -0
  20. package/nip05/index.js +61 -0
  21. package/nip19/index.js +176 -43
  22. package/nip44/helpers.js +95 -0
  23. package/nip44/index.js +14 -0
  24. package/nip44-v3/index.js +35 -16
  25. package/nip46/helpers/frame.js +6 -6
  26. package/nip46/helpers/url.js +8 -6
  27. package/nip46/services/bunker-signer.js +7 -6
  28. package/nip46/services/client.js +8 -7
  29. package/nip46/services/server-session.js +8 -7
  30. package/nip46/services/transport.js +9 -8
  31. package/nip96/index.js +285 -0
  32. package/nip98/index.js +56 -0
  33. package/nwt/index.js +241 -0
  34. package/package.json +22 -3
  35. package/private-channel/helpers/chunks.js +7 -6
  36. package/private-channel/helpers/event.js +5 -4
  37. package/private-channel/index.js +63 -61
  38. package/private-channel/services/received-chunks.js +4 -3
  39. package/private-message/index.js +32 -31
  40. package/private-messenger/index.js +313 -54
  41. package/private-messenger/recovery/index.js +15 -14
  42. package/private-messenger/services/channel-state.js +28 -7
  43. package/private-messenger/services/storage-maintenance.js +142 -46
  44. package/relay/helpers/hll.js +3 -1
  45. package/relay/services/query.js +3 -3
  46. package/relay/services/relay-connection.js +222 -121
  47. package/relay/services/relay-pool.js +13 -10
  48. package/temporary-storage/index.js +3 -1
  49. package/url/index.js +131 -0
  50. package/web-storage-queue/index.js +8 -6
@@ -1,156 +1,257 @@
1
- import { Relay } from 'nostr-tools/relay'
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
- function relayError (reason, fallback) {
5
- return reason instanceof Error ? reason : new Error(reason || fallback)
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
- // Relay.auth() caches one authentication promise per connection. NIP-42 permits
9
- // several pubkeys on one connection, so this adapter tracks AUTH replies by
10
- // signed auth-event id instead of using Relay.auth().
11
- export class RelayConnection extends Relay {
12
- #challenge = null
13
- #pendingAuths = new Map()
14
- #countSerial = 0
15
- #pendingCounts = new Map()
16
-
17
- constructor (url) {
18
- super(url)
19
- this.onclose = () => {
20
- this.#challenge = null
21
- this.#rejectPendingAuths(new Error('AUTH_CONNECTION_CLOSED'))
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
- #settleAuth (eventId, { success, reason }) {
27
- const pending = this.#pendingAuths.get(eventId)
28
- if (!pending) return
30
+ function doesEventMatchAnyFilter (filters, event) {
31
+ return filters.some(filter => doesEventMatchFilter(filter, event))
32
+ }
29
33
 
30
- this.#pendingAuths.delete(eventId)
31
- clearTimeout(pending.timer)
32
- if (success) pending.resolve(reason)
33
- else pending.reject(relayError(reason, 'AUTH_REJECTED'))
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
- #rejectPendingAuths (reason) {
37
- for (const eventId of [...this.#pendingAuths.keys()]) {
38
- this.#settleAuth(eventId, { success: false, reason })
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
- #settleCount (id, { payload, reason }) {
43
- const pending = this.#pendingCounts.get(id)
44
- if (!pending) return
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.#pendingCounts.delete(id)
47
- pending.signal?.removeEventListener('abort', pending.onAbort)
48
- if (reason !== undefined) pending.reject(relayError(reason, 'COUNT_REJECTED'))
49
- else pending.resolve(payload)
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
- #rejectPendingCounts (reason) {
53
- for (const id of [...this.#pendingCounts.keys()]) {
54
- this.#settleCount(id, { reason })
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
- const event = await getAuthEvent({
64
- relay: this.url,
65
- challenge: this.#challenge
66
- })
67
- if (typeof event?.id !== 'string' || !event.id) throw new Error('AUTH_EVENT_ID_REQUIRED')
68
- const existing = this.#pendingAuths.get(event.id)
69
- if (existing) return existing.promise
70
-
71
- let resolveAuth
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
- const id = `p2r2p-count:${++this.#countSerial}`
99
- let resolveCount
100
- let rejectCount
101
- const promise = new Promise((resolve, reject) => {
102
- resolveCount = resolve
103
- rejectCount = reject
104
- })
105
- const onAbort = () => {
106
- this.#settleCount(id, { reason: new Error('COUNT_ABORTED') })
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
- this.#pendingCounts.set(id, {
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
- let message
117
- try {
118
- message = JSON.stringify(['COUNT', id, ...filters])
119
- } catch (error) {
120
- this.#settleCount(id, { reason: error })
121
- return promise
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
- Promise.resolve(this.send(message)).catch(reason => {
124
- this.#settleCount(id, { reason })
125
- })
126
- return promise
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
- // Relay assigns this method to its WebSocket handler while connecting. Capture
130
- // AUTH and raw COUNT replies before Relay's parser discards COUNT HLL payloads.
131
- _onmessage (message) {
132
- try {
133
- const data = JSON.parse(message.data)
134
- if (data[0] === 'AUTH' && typeof data[1] === 'string') {
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
- return super._onmessage(message)
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
- this.#rejectPendingAuths(new Error('AUTH_CONNECTION_CLOSED'))
153
- this.#rejectPendingCounts(new Error('COUNT_CONNECTION_CLOSED'))
154
- return super.close()
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 { normalizeURL } from 'nostr-tools/utils'
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 = normalizeURL(relay)
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 = normalizeURL(url)
128
+ const normalizedUrl = normalizeRelayUrl(url)
123
129
  let relay = this.#relays.get(normalizedUrl)
124
130
  if (!relay) {
125
- relay = new RelayConnection(normalizedUrl)
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 = normalizeURL(url)
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 = normalizeURL(url)
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 Error('COUNT_FILTER_REQUIRED')
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 Error('INVALID_TEMPORARY_STORAGE_KEY')
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 Error('QUEUE_PREFIX_REQUIRED')
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 Error('QUEUE_INVALID_EVICTION_POLICY')
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 Error('QUEUE_INDEX_OUT_OF_RANGE')
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 Error('QUEUE_PREDICATE_REQUIRED')
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 Error('QUEUE_PREDICATE_REQUIRED')
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 Error('QUEUE_PREDICATE_REQUIRED')
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)