libp2r2p 0.10.4 → 0.10.6

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 CHANGED
@@ -264,9 +264,18 @@ import {
264
264
  import { generateSecretKey, getPublicKey } from 'libp2r2p/key'
265
265
  import { eventKinds, classifyKind } from 'libp2r2p/kind'
266
266
  import * as nip44 from 'libp2r2p/nip44'
267
- import { assertValidPublicRelayUrl, normalizeRelayUrl } from 'libp2r2p/url'
267
+ import {
268
+ assertValidPublicBlossomServerUrl,
269
+ assertValidPublicRelayUrl,
270
+ normalizeBlossomServerUrl,
271
+ normalizeRelayUrl
272
+ } from 'libp2r2p/url'
268
273
  ```
269
274
 
275
+ Blossom server normalization accepts root `http:` origins for local
276
+ development. Public Blossom validation additionally requires `https:` and a
277
+ public host, and rejects credentials, paths, query strings and fragments.
278
+
270
279
  `classifyEvent()` from `libp2r2p/event` combines the exact NIP-01 kind
271
280
  ranges with tag-defined behavior. The first `d` tag may add `replaceable` or
272
281
  `addressable`, while an `expiration` tag equal to `created_at` adds
@@ -332,6 +341,54 @@ Low-level relay sockets, subscriptions, message parsing, and serialization are
332
341
  internal implementation details; use `RelayPool` or the `relayPool` singleton
333
342
  from `libp2r2p/relay`.
334
343
 
344
+ The same public subpath exports `getRelaysByPubkey(pubkeys)`, which discovers
345
+ the latest NIP-65 relay list for every requested pubkey through `seedRelays`,
346
+ normalizes and deduplicates its public relay URLs, and falls back to the first
347
+ two `freeRelays` when no list is available. Its result can be passed directly
348
+ to `pickRelaysForPubkeys(pubkeys, relaysByPubkey)` to batch authors that share
349
+ read or write relays.
350
+
351
+ `getRelaysByPubkey` accepts a few options:
352
+
353
+ - `includeEvents` returns each pubkey's latest kind `10002` event alongside its
354
+ parsed relays (`{ read, write, event }`), for consumers that need the original
355
+ event (storage, re-signing, freshness tracking).
356
+ - `forceRefresh` re-queries `seedRelays` even when the pubkey is cached, without
357
+ regressing a newer cached event when the relay returns an older one.
358
+ - `timeout` / `timeoutAfterFirstEose` tune the relay-list query timing. The
359
+ default opens a short grace window after the first EOSE with events, matching
360
+ `RelayPool.getEvents`.
361
+ - `emptyRelaysFallback` sets the relays returned when a pubkey has no relay
362
+ list (default: the first two `freeRelays`); pass `[]` to return empty
363
+ `read`/`write` arrays instead, keeping the free-relay decision with the
364
+ caller.
365
+ - `relayUrlPolicy` opts into non-default URL validation: `onion` allows
366
+ `ws://`/`wss://` `.onion` hosts, `localRelay` allows the standardized
367
+ `ws://localhost:4869` local relay, and `nostrEntityUrls` stops rejecting
368
+ public URLs that contain `npub1`/`nprofile1`. All other non-public or
369
+ insecure URLs stay rejected.
370
+
371
+ `parseRelayListEvent(event, relayUrlPolicy)` is exported from the same subpath
372
+ for consumers that parse relay-list events they receive outside
373
+ `getRelaysByPubkey`, so routing and persistence always agree on the parsed
374
+ shape.
375
+
376
+ `pickRelaysForPubkeys` also accepts `excludeRelaysByPubkey` (relays already
377
+ queried per pubkey, as an object or `Map`) and `emptyRelaysFallback` (the
378
+ relays used when a pubkey has no typed relays; pass `[]` to leave that pubkey
379
+ unrouted). Together with `maxPerPubkey: Infinity` these express a second,
380
+ exhaustive pass over the relays that were not yet tried for each author.
381
+
382
+ For callers that want the whole two-pass flow, `getLatestEventsByPubkey`
383
+ fetches the latest replaceable events (or addressable events when
384
+ `dTagsByPubkey` maps pubkeys to their `d` tags) through NIP-65 write relays:
385
+ the first pass routes through up to `maxPerPubkey` shared relays, and missing
386
+ authors are retried on every remaining relay plus `fallbackRelays` (default:
387
+ the first three `freeRelays`), excluding what was already queried. It accepts
388
+ `relaysByPubkey` to reuse a previous discovery (only missing pubkeys are then
389
+ discovered) and returns `{ events, byPubkey, relaysByPubkey }` so the merged
390
+ relay map can be passed back on later calls.
391
+
335
392
  ## Binary encodings
336
393
 
337
394
  Base16, Base36, Base62, Base64/Base64URL, and Base93 helpers are available
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -2,15 +2,30 @@ import { freeRelays } from '../constants/index.js'
2
2
 
3
3
  const DEFAULT_RELAYS_PER_PUBKEY = 2
4
4
 
5
+ function excludedRelaysFor (excludeRelaysByPubkey, pubkey) {
6
+ if (!excludeRelaysByPubkey) return []
7
+ return excludeRelaysByPubkey instanceof Map
8
+ ? excludeRelaysByPubkey.get(pubkey) || []
9
+ : excludeRelaysByPubkey[pubkey] || []
10
+ }
11
+
5
12
  // Given pubkeys and their relay mappings, picks the minimum set of relays
6
13
  // that covers all pubkeys (up to maxPerPubkey relays each), preferring
7
14
  // relays shared by more pubkeys. Returns Map<relayUrl, pubkey[]>.
8
- export function pickRelaysForPubkeys (pubkeys, relaysByPubkey, { maxPerPubkey = DEFAULT_RELAYS_PER_PUBKEY, relayType = 'write' } = {}) {
15
+ export function pickRelaysForPubkeys (pubkeys, relaysByPubkey, {
16
+ maxPerPubkey = DEFAULT_RELAYS_PER_PUBKEY,
17
+ relayType = 'write',
18
+ excludeRelaysByPubkey,
19
+ emptyRelaysFallback = freeRelays.slice(0, DEFAULT_RELAYS_PER_PUBKEY)
20
+ } = {}) {
9
21
  const type = relayType === 'read' ? 'read' : 'write'
10
22
  const pkToPossibleRelays = new Map()
11
23
  for (const pk of pubkeys) {
12
24
  const relays = relaysByPubkey[pk]?.[type] || []
13
- pkToPossibleRelays.set(pk, new Set(relays.length ? relays : freeRelays.slice(0, DEFAULT_RELAYS_PER_PUBKEY)))
25
+ const excluded = new Set(excludedRelaysFor(excludeRelaysByPubkey, pk))
26
+ const candidates = (relays.length ? relays : emptyRelaysFallback)
27
+ .filter(relay => !excluded.has(relay))
28
+ pkToPossibleRelays.set(pk, new Set(candidates))
14
29
  }
15
30
 
16
31
  const relayCounts = new Map()
package/relay/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { freeRelays, nappRelays, seedRelays } from './constants/index.js'
2
2
  export { pickRelaysForPubkeys } from './helpers/routing.js'
3
3
  export { RelayPool, relayPool } from './services/relay-pool.js'
4
- export { getRelaysByPubkey, subscribeRelayListUpdates } from './services/query.js'
4
+ export { getLatestEventsByPubkey } from './services/events.js'
5
+ export { getRelaysByPubkey, parseRelayListEvent, subscribeRelayListUpdates } from './services/query.js'
@@ -0,0 +1,154 @@
1
+ import { freeRelays } from '../constants/index.js'
2
+ import { pickRelaysForPubkeys } from '../helpers/routing.js'
3
+ import { relayPool } from './relay-pool.js'
4
+ import { getRelaysByPubkey } from './query.js'
5
+
6
+ const DEFAULT_MAX_PER_PUBKEY = 2
7
+ const DEFAULT_FALLBACK_RELAY_COUNT = 3
8
+
9
+ const getEvents = (...args) => relayPool.getEvents(...args)
10
+
11
+ function hasOwn (object, key) {
12
+ return Object.prototype.hasOwnProperty.call(object, key)
13
+ }
14
+
15
+ function eventAddress (event) {
16
+ const dTag = event.tags?.find(tag => tag[0] === 'd')
17
+ return `${event.kind}:${event.pubkey}:${dTag?.[1] ?? ''}`
18
+ }
19
+
20
+ // NIP-01 ordering for replaceable and addressable events.
21
+ function isNewerEvent (candidate, current) {
22
+ if (!candidate) return false
23
+ if (!current) return true
24
+ if (candidate.created_at !== current.created_at) return candidate.created_at > current.created_at
25
+ if (typeof candidate.id !== 'string') return false
26
+ return typeof current.id !== 'string' || candidate.id < current.id
27
+ }
28
+
29
+ function mergeLatestEvents (target, events) {
30
+ for (const event of events) {
31
+ const address = eventAddress(event)
32
+ if (isNewerEvent(event, target[address])) target[address] = event
33
+ }
34
+ return target
35
+ }
36
+
37
+ // Records which relays were already queried for each pubkey.
38
+ function getSelectedRelaysByPubkey (pubkeys, relayToAuthors) {
39
+ const selected = Object.fromEntries(pubkeys.map(pubkey => [pubkey, new Set()]))
40
+ for (const [relay, authors] of relayToAuthors) {
41
+ for (const pubkey of authors) selected[pubkey]?.add(relay)
42
+ }
43
+ return selected
44
+ }
45
+
46
+ // Fetches batched events and keeps the newest event per address.
47
+ async function fetchLatestEventsByRelay (relayToAuthors, { kinds, dTagsByPubkey, getEvents }) {
48
+ const requests = []
49
+ for (const [relay, authors] of relayToAuthors) {
50
+ const authorsByD = new Map()
51
+ for (const pubkey of authors) {
52
+ const d = dTagsByPubkey?.[pubkey] ?? ''
53
+ if (!authorsByD.has(d)) authorsByD.set(d, [])
54
+ authorsByD.get(d).push(pubkey)
55
+ }
56
+ for (const [d, dAuthors] of authorsByD) {
57
+ const filter = { kinds, authors: dAuthors }
58
+ if (d) filter['#d'] = [d]
59
+ requests.push(getEvents(filter, [relay])
60
+ .then(response => ({ requested: new Set(dAuthors), events: response.result || [] })))
61
+ }
62
+ }
63
+
64
+ const results = await Promise.allSettled(requests)
65
+ const latestByAddress = {}
66
+ for (const result of results) {
67
+ if (result.status !== 'fulfilled') continue
68
+ const { requested, events } = result.value
69
+ mergeLatestEvents(latestByAddress, events.filter(event =>
70
+ kinds.includes(event?.kind) && requested.has(event?.pubkey)
71
+ ))
72
+ }
73
+ return latestByAddress
74
+ }
75
+
76
+ /**
77
+ * Fetches the latest replaceable (or addressable, with `dTagsByPubkey`) events
78
+ * for many pubkeys through their NIP-65 relays in two batched passes.
79
+ *
80
+ * The first pass routes through up to `maxPerPubkey` relays per author,
81
+ * preferring relays shared by more authors. Authors still missing after that
82
+ * pass are retried on every remaining relay plus `fallbackRelays`, excluding
83
+ * relays already queried for them.
84
+ *
85
+ * When `relaysByPubkey` is provided, it is reused as-is for the pubkeys it
86
+ * covers; only missing pubkeys are discovered, and the merged map is returned
87
+ * so it can be passed back on a later call.
88
+ */
89
+ export async function getLatestEventsByPubkey (pubkeys, {
90
+ kinds,
91
+ dTagsByPubkey,
92
+ relayType = 'write',
93
+ maxPerPubkey = DEFAULT_MAX_PER_PUBKEY,
94
+ fallbackRelays = freeRelays.slice(0, DEFAULT_FALLBACK_RELAY_COUNT),
95
+ relaysByPubkey,
96
+ relayListOptions,
97
+ _getRelaysByPubkey = getRelaysByPubkey,
98
+ _getEvents = getEvents
99
+ } = {}) {
100
+ const authors = [...new Set(pubkeys || [])].filter(Boolean)
101
+ if (!authors.length) return { events: [], byPubkey: {}, relaysByPubkey: {} }
102
+ if (!Array.isArray(kinds) || kinds.length === 0) throw new Error('Missing kinds')
103
+ const type = relayType === 'read' ? 'read' : 'write'
104
+
105
+ const relaysByAuthor = { ...(relaysByPubkey || {}) }
106
+ const missingRelayAuthors = authors.filter(pubkey => !hasOwn(relaysByAuthor, pubkey))
107
+ if (missingRelayAuthors.length) {
108
+ let discovered
109
+ try {
110
+ discovered = await _getRelaysByPubkey(missingRelayAuthors, relayListOptions)
111
+ } catch (error) {
112
+ console.error('Failed to discover publisher relays:', error)
113
+ discovered = Object.fromEntries(missingRelayAuthors.map(pubkey => [pubkey, { read: [], write: [] }]))
114
+ }
115
+ for (const pubkey of missingRelayAuthors) {
116
+ relaysByAuthor[pubkey] = discovered?.[pubkey] || { read: [], write: [] }
117
+ }
118
+ }
119
+
120
+ const primaryAuthors = authors.filter(pubkey => relaysByAuthor[pubkey]?.[type]?.length)
121
+ const primaryRoutes = pickRelaysForPubkeys(primaryAuthors, relaysByAuthor, { maxPerPubkey, relayType })
122
+ const selectedRelays = getSelectedRelaysByPubkey(authors, primaryRoutes)
123
+ const latestByAddress = await fetchLatestEventsByRelay(primaryRoutes, {
124
+ kinds,
125
+ dTagsByPubkey,
126
+ getEvents: _getEvents
127
+ })
128
+ const foundPubkeys = new Set(Object.values(latestByAddress).map(event => event.pubkey))
129
+ const missingAuthors = authors.filter(pubkey => !foundPubkeys.has(pubkey))
130
+
131
+ if (missingAuthors.length) {
132
+ const fallbackRelaysByPubkey = Object.fromEntries(missingAuthors.map(pubkey => [
133
+ pubkey, { [type]: [...new Set([...(relaysByAuthor[pubkey]?.[type] || []), ...fallbackRelays])] }
134
+ ]))
135
+ const fallbackRoutes = pickRelaysForPubkeys(missingAuthors, fallbackRelaysByPubkey, {
136
+ maxPerPubkey: Infinity,
137
+ relayType,
138
+ excludeRelaysByPubkey: selectedRelays,
139
+ emptyRelaysFallback: []
140
+ })
141
+ mergeLatestEvents(latestByAddress, Object.values(
142
+ await fetchLatestEventsByRelay(fallbackRoutes, {
143
+ kinds,
144
+ dTagsByPubkey,
145
+ getEvents: _getEvents
146
+ })
147
+ ))
148
+ }
149
+
150
+ const events = Object.values(latestByAddress)
151
+ const byPubkey = {}
152
+ for (const event of events) byPubkey[event.pubkey] = event
153
+ return { events, byPubkey, relaysByPubkey: relaysByAuthor }
154
+ }
@@ -1,5 +1,6 @@
1
1
  import { freeRelays, seedRelays } from '../constants/index.js'
2
2
  import { relayPool } from './relay-pool.js'
3
+ import { isValidPublicRelayUrl, normalizeRelayUrl } from '../../url/index.js'
3
4
 
4
5
  const QUERY_CACHE_MS = 40 * 60 * 1000
5
6
  const RELAY_CACHE_MAX_ITEMS = 500
@@ -8,9 +9,14 @@ const relaysByPubkey = Object.create(null)
8
9
  const relayCacheTimersByPubkey = Object.create(null)
9
10
  const relayCacheAddedAtByPubkey = Object.create(null)
10
11
  const relayCacheEventCreatedAtByPubkey = Object.create(null)
12
+ const relayCacheEventIdByPubkey = Object.create(null)
13
+ const relayCacheEventByPubkey = Object.create(null)
14
+ const relayRequestsByPubkey = new Map()
11
15
 
12
16
  const getEvents = (...args) => relayPool.getEvents(...args)
13
17
  const getEventsFeedGenerator = (...args) => relayPool.getEventsFeedGenerator(...args)
18
+ const RELAY_LIST_QUERY_TIMEOUT_MS = 5000
19
+ const RELAY_LIST_QUERY_TIMEOUT_AFTER_FIRST_EOSE_MS = 500
14
20
 
15
21
  function hasCachedKey (cache, key) {
16
22
  return Object.prototype.hasOwnProperty.call(cache, key)
@@ -28,15 +34,27 @@ function cloneRelays (relays) {
28
34
  }
29
35
  }
30
36
 
37
+ function cloneRelayListEvent (event) {
38
+ if (!event) return null
39
+ return { ...event, tags: [...(event.tags || [])] }
40
+ }
41
+
31
42
  // NIP-65 relay-list tags without a marker apply to both read and write use.
32
- function parseRelayListEvent (event) {
43
+ export function parseRelayListEvent (event, relayUrlPolicy) {
33
44
  const out = { read: [], write: [] }
34
45
  if (!event || event.kind !== 10002) return out
35
- for (const tag of event.tags) {
46
+ for (const tag of event.tags || []) {
36
47
  if (tag[0] !== 'r' || typeof tag[1] !== 'string') continue
37
- if (tag[2] === 'read') out.read.push(tag[1])
38
- else if (tag[2] === 'write') out.write.push(tag[1])
39
- else { out.read.push(tag[1]); out.write.push(tag[1]) }
48
+ let relay
49
+ try {
50
+ relay = normalizeRelayUrl(tag[1])
51
+ } catch {
52
+ continue
53
+ }
54
+ if (!isValidPublicRelayUrl(relay, relayUrlPolicy)) continue
55
+ if (tag[2] === 'read') out.read.push(relay)
56
+ else if (tag[2] === 'write') out.write.push(relay)
57
+ else { out.read.push(relay); out.write.push(relay) }
40
58
  }
41
59
  out.read = [...new Set(out.read)]
42
60
  out.write = [...new Set(out.write)]
@@ -52,6 +70,16 @@ function relayListCreatedAt (event) {
52
70
  return Number.isFinite(event?.created_at) ? event.created_at : 0
53
71
  }
54
72
 
73
+ function isNewerRelayListEvent (candidate, current) {
74
+ if (!candidate) return false
75
+ if (!current) return true
76
+ const candidateCreatedAt = relayListCreatedAt(candidate)
77
+ const currentCreatedAt = relayListCreatedAt(current)
78
+ if (candidateCreatedAt !== currentCreatedAt) return candidateCreatedAt > currentCreatedAt
79
+ if (typeof candidate.id !== 'string') return false
80
+ return typeof current.id !== 'string' || candidate.id < current.id
81
+ }
82
+
55
83
  function areRelaySetsEqual (a, b) {
56
84
  const left = new Set(a || [])
57
85
  const right = new Set(b || [])
@@ -82,12 +110,16 @@ function deleteCachedRelay (pubkey) {
82
110
  delete relayCacheTimersByPubkey[pubkey]
83
111
  delete relayCacheAddedAtByPubkey[pubkey]
84
112
  delete relayCacheEventCreatedAtByPubkey[pubkey]
113
+ delete relayCacheEventIdByPubkey[pubkey]
114
+ delete relayCacheEventByPubkey[pubkey]
85
115
  }
86
116
 
87
- function setCachedRelays (pubkey, relays, createdAt, cacheMs) {
117
+ function setCachedRelays (pubkey, relays, event, cacheMs) {
88
118
  relaysByPubkey[pubkey] = cloneRelays(relays)
89
119
  relayCacheAddedAtByPubkey[pubkey] = Date.now()
90
- relayCacheEventCreatedAtByPubkey[pubkey] = createdAt
120
+ relayCacheEventCreatedAtByPubkey[pubkey] = relayListCreatedAt(event)
121
+ relayCacheEventIdByPubkey[pubkey] = typeof event?.id === 'string' ? event.id : null
122
+ relayCacheEventByPubkey[pubkey] = event || null
91
123
  clearTimeout(relayCacheTimersByPubkey[pubkey])
92
124
  if (cacheMs > 0) {
93
125
  relayCacheTimersByPubkey[pubkey] = maybeUnref(setTimeout(() => {
@@ -114,20 +146,24 @@ export function clearRelayQueryCache () {
114
146
  for (const key of Object.keys(relayCacheTimersByPubkey)) delete relayCacheTimersByPubkey[key]
115
147
  for (const key of Object.keys(relayCacheAddedAtByPubkey)) delete relayCacheAddedAtByPubkey[key]
116
148
  for (const key of Object.keys(relayCacheEventCreatedAtByPubkey)) delete relayCacheEventCreatedAtByPubkey[key]
149
+ for (const key of Object.keys(relayCacheEventIdByPubkey)) delete relayCacheEventIdByPubkey[key]
150
+ for (const key of Object.keys(relayCacheEventByPubkey)) delete relayCacheEventByPubkey[key]
117
151
  }
118
152
 
119
- export function cacheRelayListEvent (event, { cacheMs = QUERY_CACHE_MS } = {}) {
153
+ export function cacheRelayListEvent (event, { cacheMs = QUERY_CACHE_MS, relayUrlPolicy } = {}) {
120
154
  if (!event || event.kind !== 10002 || !event.pubkey) return null
121
- const createdAt = relayListCreatedAt(event)
122
155
  const previousCreatedAt = relayCacheEventCreatedAtByPubkey[event.pubkey]
123
- if (previousCreatedAt != null && createdAt <= previousCreatedAt) return null
156
+ const previousEvent = previousCreatedAt == null
157
+ ? null
158
+ : { created_at: previousCreatedAt, id: relayCacheEventIdByPubkey[event.pubkey] }
159
+ if (!isNewerRelayListEvent(event, previousEvent)) return null
124
160
 
125
161
  const previousRelays = hasCachedKey(relaysByPubkey, event.pubkey)
126
162
  ? cloneRelays(relaysByPubkey[event.pubkey])
127
163
  : null
128
- const relays = parseRelayListEvent(event)
164
+ const relays = parseRelayListEvent(event, relayUrlPolicy)
129
165
  const changes = relaySetChanges(previousRelays, relays)
130
- setCachedRelays(event.pubkey, relays, createdAt, cacheMs)
166
+ setCachedRelays(event.pubkey, relays, event, cacheMs)
131
167
  pruneRelayCache()
132
168
 
133
169
  return {
@@ -144,6 +180,7 @@ export function subscribeRelayListUpdates (pubkeys, {
144
180
  onChange,
145
181
  relays = seedRelays,
146
182
  cacheMs = QUERY_CACHE_MS,
183
+ relayUrlPolicy,
147
184
  _eventsFeedGenerator = getEventsFeedGenerator
148
185
  } = {}) {
149
186
  const authors = uniquePubkeys(pubkeys, { requireHex: _eventsFeedGenerator === getEventsFeedGenerator })
@@ -163,7 +200,7 @@ export function subscribeRelayListUpdates (pubkeys, {
163
200
  timeoutAfterFirstEose: null
164
201
  })) {
165
202
  if (closed || !authors.includes(event.pubkey)) continue
166
- const update = cacheRelayListEvent(event, { cacheMs })
203
+ const update = cacheRelayListEvent(event, { cacheMs, relayUrlPolicy })
167
204
  if (!update || !relayTypeChanged(update.changes, relayType)) continue
168
205
  onChange?.({
169
206
  ...update,
@@ -183,42 +220,83 @@ export function subscribeRelayListUpdates (pubkeys, {
183
220
  }
184
221
  }
185
222
 
186
- export async function getRelaysByPubkey (pubkeys, { _getEvents = getEvents, cacheMs = QUERY_CACHE_MS } = {}) {
187
- const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
188
- if (!pubkeyList.length) return {}
189
-
190
- const out = {}
191
- const missingPubkeys = []
192
- for (const pubkey of pubkeyList) {
193
- if (hasCachedKey(relaysByPubkey, pubkey)) out[pubkey] = cloneRelays(relaysByPubkey[pubkey])
194
- else missingPubkeys.push(pubkey)
195
- }
196
- if (!missingPubkeys.length) return out
197
-
198
- const { result: events } = await _getEvents({
223
+ async function loadMissingRelays (missingPubkeys, {
224
+ getEvents,
225
+ cacheMs,
226
+ timeout = RELAY_LIST_QUERY_TIMEOUT_MS,
227
+ timeoutAfterFirstEose = RELAY_LIST_QUERY_TIMEOUT_AFTER_FIRST_EOSE_MS,
228
+ relayUrlPolicy,
229
+ emptyRelaysFallback = freeRelays.slice(0, 2)
230
+ }) {
231
+ const { result: events } = await getEvents({
199
232
  kinds: [10002],
200
233
  authors: missingPubkeys,
201
234
  limit: missingPubkeys.length
202
235
  }, seedRelays, {
203
- timeout: 5000,
204
- timeoutAfterFirstEose: null
236
+ timeout,
237
+ timeoutAfterFirstEose
205
238
  })
206
239
 
207
240
  const latestByPubkey = {}
208
- for (const event of events) {
241
+ for (const event of events || []) {
209
242
  if (!missingPubkeys.includes(event.pubkey)) continue
210
- if (!latestByPubkey[event.pubkey] || event.created_at > latestByPubkey[event.pubkey].created_at) {
211
- latestByPubkey[event.pubkey] = event
212
- }
243
+ if (isNewerRelayListEvent(event, latestByPubkey[event.pubkey])) latestByPubkey[event.pubkey] = event
213
244
  }
214
245
 
215
246
  for (const pubkey of missingPubkeys) {
216
- const relays = latestByPubkey[pubkey]
217
- ? parseRelayListEvent(latestByPubkey[pubkey])
218
- : { read: freeRelays.slice(0, 2), write: freeRelays.slice(0, 2) }
219
- setCachedRelays(pubkey, relays, relayListCreatedAt(latestByPubkey[pubkey]), cacheMs)
220
- out[pubkey] = relays
247
+ const fetchedEvent = latestByPubkey[pubkey]
248
+ const cachedEvent = relayCacheEventByPubkey[pubkey] || null
249
+ const event = isNewerRelayListEvent(fetchedEvent, cachedEvent) ? fetchedEvent : cachedEvent
250
+ const relays = event
251
+ ? parseRelayListEvent(event, relayUrlPolicy)
252
+ : { read: [...emptyRelaysFallback], write: [...emptyRelaysFallback] }
253
+ setCachedRelays(pubkey, relays, event, cacheMs)
221
254
  }
222
255
  pruneRelayCache()
223
- return out
256
+ }
257
+
258
+ export async function getRelaysByPubkey (pubkeys, {
259
+ _getEvents = getEvents,
260
+ cacheMs = QUERY_CACHE_MS,
261
+ includeEvents = false,
262
+ forceRefresh = false,
263
+ timeout = RELAY_LIST_QUERY_TIMEOUT_MS,
264
+ timeoutAfterFirstEose = RELAY_LIST_QUERY_TIMEOUT_AFTER_FIRST_EOSE_MS,
265
+ relayUrlPolicy,
266
+ emptyRelaysFallback = freeRelays.slice(0, 2)
267
+ } = {}) {
268
+ const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
269
+ if (!pubkeyList.length) return {}
270
+
271
+ const loadPubkeys = forceRefresh
272
+ ? pubkeyList
273
+ : pubkeyList.filter(pubkey => !hasCachedKey(relaysByPubkey, pubkey))
274
+ const pubkeysToLoad = loadPubkeys.filter(pubkey => !relayRequestsByPubkey.has(pubkey))
275
+ if (pubkeysToLoad.length) {
276
+ const request = loadMissingRelays(pubkeysToLoad, {
277
+ getEvents: _getEvents,
278
+ cacheMs,
279
+ timeout,
280
+ timeoutAfterFirstEose,
281
+ relayUrlPolicy,
282
+ emptyRelaysFallback
283
+ }).finally(() => {
284
+ for (const pubkey of pubkeysToLoad) {
285
+ if (relayRequestsByPubkey.get(pubkey) === request) relayRequestsByPubkey.delete(pubkey)
286
+ }
287
+ })
288
+ for (const pubkey of pubkeysToLoad) relayRequestsByPubkey.set(pubkey, request)
289
+ }
290
+
291
+ await Promise.all([...new Set(
292
+ loadPubkeys.map(pubkey => relayRequestsByPubkey.get(pubkey)).filter(Boolean)
293
+ )])
294
+
295
+ return Object.fromEntries(pubkeyList
296
+ .filter(pubkey => hasCachedKey(relaysByPubkey, pubkey))
297
+ .map(pubkey => {
298
+ const entry = cloneRelays(relaysByPubkey[pubkey])
299
+ if (includeEvents) entry.event = cloneRelayListEvent(relayCacheEventByPubkey[pubkey])
300
+ return [pubkey, entry]
301
+ }))
224
302
  }
package/url/index.js CHANGED
@@ -53,6 +53,30 @@ function removeEmptyQuerySegments (url) {
53
53
  url.searchParams.sort()
54
54
  }
55
55
 
56
+ function publicHostError (url, {
57
+ invalidHostCode,
58
+ nonPublicHostCode,
59
+ nonPublicIpCode
60
+ }) {
61
+ const hostname = url.hostname.toLowerCase().replace(/\.+$/, '')
62
+ if (
63
+ hostname === 'localhost' ||
64
+ hostname.endsWith('.localhost') ||
65
+ hostname.endsWith('.local') ||
66
+ hostname.endsWith('.onion')
67
+ ) return nonPublicHostCode
68
+
69
+ const unwrappedHostname = hostname.replace(/^\[|\]$/g, '')
70
+ if (isIpv4Address(unwrappedHostname)) {
71
+ if (!isPublicIpv4Address(unwrappedHostname)) return nonPublicIpCode
72
+ } else if (unwrappedHostname.includes(':')) {
73
+ if (!isPublicIpv6Address(unwrappedHostname)) return nonPublicIpCode
74
+ } else if (!hostname.includes('.')) {
75
+ return invalidHostCode
76
+ }
77
+ return null
78
+ }
79
+
56
80
  export function normalizeRelayUrl (value) {
57
81
  if (typeof value !== 'string' || value.trim().length === 0) {
58
82
  throw new ValidationError('INVALID_RELAY_URL', { message: 'URL_SHOULD_BE_A_STRING' })
@@ -76,7 +100,44 @@ export function normalizeRelayUrl (value) {
76
100
  return url.toString().replace(/^(wss?:\/\/[^/?#]+)\/(?=[?#]|$)/, '$1')
77
101
  }
78
102
 
79
- function publicRelayUrlError (value) {
103
+ // Canonicalizes the origin used by the root-level Blossom HTTP endpoints.
104
+ export function normalizeBlossomServerUrl (value) {
105
+ if (typeof value !== 'string' || value.trim().length === 0) {
106
+ throw new ValidationError('INVALID_BLOSSOM_SERVER_URL', { message: 'URL_SHOULD_BE_A_STRING' })
107
+ }
108
+ const input = value.trim()
109
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) {
110
+ throw new ValidationError('INVALID_BLOSSOM_SERVER_URL', { message: 'ABSOLUTE_URL_REQUIRED' })
111
+ }
112
+ let url
113
+ try {
114
+ url = new URL(input)
115
+ } catch (cause) {
116
+ throw new ValidationError('INVALID_BLOSSOM_SERVER_URL', { cause })
117
+ }
118
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
119
+ throw new ValidationError('INVALID_BLOSSOM_SERVER_PROTOCOL')
120
+ }
121
+ if (url.username || url.password) {
122
+ throw new ValidationError('BLOSSOM_SERVER_URL_CREDENTIALS_NOT_ALLOWED')
123
+ }
124
+ if (!/^\/*$/.test(url.pathname)) {
125
+ throw new ValidationError('BLOSSOM_SERVER_URL_PATH_NOT_ALLOWED')
126
+ }
127
+ if (url.search) throw new ValidationError('BLOSSOM_SERVER_URL_QUERY_NOT_ALLOWED')
128
+ if (url.hash) throw new ValidationError('BLOSSOM_SERVER_URL_FRAGMENT_NOT_ALLOWED')
129
+ url.hostname = url.hostname.replace(/\.+$/, '')
130
+ return url.origin
131
+ }
132
+
133
+ function isPolicyRelayUrl (url, policy) {
134
+ const hostname = url.hostname.toLowerCase().replace(/\.+$/, '')
135
+ if (policy?.onion && hostname.endsWith('.onion')) return true
136
+ if (policy?.localRelay && hostname === 'localhost' && url.port === '4869' && (url.pathname === '/' || url.pathname === '')) return true
137
+ return false
138
+ }
139
+
140
+ function publicRelayUrlError (value, policy = {}) {
80
141
  if (typeof value !== 'string' || value.trim().length === 0) return 'INVALID_RELAY_URL'
81
142
  let input = value.trim()
82
143
  if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) input = `wss://${input}`
@@ -94,38 +155,59 @@ function publicRelayUrlError (value) {
94
155
  }
95
156
 
96
157
  const url = new URL(normalized)
97
- if (url.protocol !== 'wss:') return 'INSECURE_RELAY_URL'
158
+ const policyRelay = isPolicyRelayUrl(url, policy)
159
+ if (url.protocol !== 'wss:' && !policyRelay) return 'INSECURE_RELAY_URL'
98
160
  if (url.username || url.password) return 'RELAY_URL_CREDENTIALS_NOT_ALLOWED'
99
161
 
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'
162
+ if (!policyRelay) {
163
+ const hostError = publicHostError(url, {
164
+ invalidHostCode: 'INVALID_RELAY_HOST',
165
+ nonPublicHostCode: 'NON_PUBLIC_RELAY_HOST',
166
+ nonPublicIpCode: 'NON_PUBLIC_RELAY_IP'
167
+ })
168
+ if (hostError) return hostError
115
169
  }
116
170
 
117
- const lowerValue = normalized.toLowerCase()
118
- if (lowerValue.includes('npub1') || lowerValue.includes('nprofile1')) return 'RELAY_URL_NOSTR_ENTITY_NOT_ALLOWED'
171
+ if (!policy?.nostrEntityUrls) {
172
+ const lowerValue = normalized.toLowerCase()
173
+ if (lowerValue.includes('npub1') || lowerValue.includes('nprofile1')) return 'RELAY_URL_NOSTR_ENTITY_NOT_ALLOWED'
174
+ }
119
175
 
120
176
  return null
121
177
  }
122
178
 
123
- export function isValidPublicRelayUrl (value) {
124
- return publicRelayUrlError(value) === null
179
+ export function isValidPublicRelayUrl (value, policy) {
180
+ return publicRelayUrlError(value, policy) === null
181
+ }
182
+
183
+ export function assertValidPublicRelayUrl (value, policy) {
184
+ const code = publicRelayUrlError(value, policy)
185
+ if (code) throw new ValidationError(code)
186
+ return value
187
+ }
188
+
189
+ function publicBlossomServerUrlError (value) {
190
+ let normalized
191
+ try {
192
+ normalized = normalizeBlossomServerUrl(value)
193
+ } catch (error) {
194
+ return error instanceof ValidationError ? error.code : 'INVALID_BLOSSOM_SERVER_URL'
195
+ }
196
+ const url = new URL(normalized)
197
+ if (url.protocol !== 'https:') return 'INSECURE_BLOSSOM_SERVER_URL'
198
+ return publicHostError(url, {
199
+ invalidHostCode: 'INVALID_BLOSSOM_SERVER_HOST',
200
+ nonPublicHostCode: 'NON_PUBLIC_BLOSSOM_SERVER_HOST',
201
+ nonPublicIpCode: 'NON_PUBLIC_BLOSSOM_SERVER_IP'
202
+ })
203
+ }
204
+
205
+ export function isValidPublicBlossomServerUrl (value) {
206
+ return publicBlossomServerUrlError(value) === null
125
207
  }
126
208
 
127
- export function assertValidPublicRelayUrl (value) {
128
- const code = publicRelayUrlError(value)
209
+ export function assertValidPublicBlossomServerUrl (value) {
210
+ const code = publicBlossomServerUrlError(value)
129
211
  if (code) throw new ValidationError(code)
130
212
  return value
131
213
  }