libp2r2p 0.10.5 → 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 +48 -0
- package/package.json +1 -1
- package/relay/helpers/routing.js +17 -2
- package/relay/index.js +2 -1
- package/relay/services/events.js +154 -0
- package/relay/services/query.js +116 -38
- package/url/index.js +26 -14
package/README.md
CHANGED
|
@@ -341,6 +341,54 @@ Low-level relay sockets, subscriptions, message parsing, and serialization are
|
|
|
341
341
|
internal implementation details; use `RelayPool` or the `relayPool` singleton
|
|
342
342
|
from `libp2r2p/relay`.
|
|
343
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
|
+
|
|
344
392
|
## Binary encodings
|
|
345
393
|
|
|
346
394
|
Base16, Base36, Base62, Base64/Base64URL, and Base93 helpers are available
|
package/package.json
CHANGED
package/relay/helpers/routing.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|
package/relay/services/query.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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,
|
|
117
|
+
function setCachedRelays (pubkey, relays, event, cacheMs) {
|
|
88
118
|
relaysByPubkey[pubkey] = cloneRelays(relays)
|
|
89
119
|
relayCacheAddedAtByPubkey[pubkey] = Date.now()
|
|
90
|
-
relayCacheEventCreatedAtByPubkey[pubkey] =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
204
|
-
timeoutAfterFirstEose
|
|
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 (
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
@@ -130,7 +130,14 @@ export function normalizeBlossomServerUrl (value) {
|
|
|
130
130
|
return url.origin
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
function
|
|
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 = {}) {
|
|
134
141
|
if (typeof value !== 'string' || value.trim().length === 0) return 'INVALID_RELAY_URL'
|
|
135
142
|
let input = value.trim()
|
|
136
143
|
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) input = `wss://${input}`
|
|
@@ -148,28 +155,33 @@ function publicRelayUrlError (value) {
|
|
|
148
155
|
}
|
|
149
156
|
|
|
150
157
|
const url = new URL(normalized)
|
|
151
|
-
|
|
158
|
+
const policyRelay = isPolicyRelayUrl(url, policy)
|
|
159
|
+
if (url.protocol !== 'wss:' && !policyRelay) return 'INSECURE_RELAY_URL'
|
|
152
160
|
if (url.username || url.password) return 'RELAY_URL_CREDENTIALS_NOT_ALLOWED'
|
|
153
161
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
169
|
+
}
|
|
160
170
|
|
|
161
|
-
|
|
162
|
-
|
|
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
|
+
}
|
|
163
175
|
|
|
164
176
|
return null
|
|
165
177
|
}
|
|
166
178
|
|
|
167
|
-
export function isValidPublicRelayUrl (value) {
|
|
168
|
-
return publicRelayUrlError(value) === null
|
|
179
|
+
export function isValidPublicRelayUrl (value, policy) {
|
|
180
|
+
return publicRelayUrlError(value, policy) === null
|
|
169
181
|
}
|
|
170
182
|
|
|
171
|
-
export function assertValidPublicRelayUrl (value) {
|
|
172
|
-
const code = publicRelayUrlError(value)
|
|
183
|
+
export function assertValidPublicRelayUrl (value, policy) {
|
|
184
|
+
const code = publicRelayUrlError(value, policy)
|
|
173
185
|
if (code) throw new ValidationError(code)
|
|
174
186
|
return value
|
|
175
187
|
}
|