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 +58 -1
- 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 +105 -23
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 {
|
|
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
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
128
|
-
const code =
|
|
209
|
+
export function assertValidPublicBlossomServerUrl (value) {
|
|
210
|
+
const code = publicBlossomServerUrlError(value)
|
|
129
211
|
if (code) throw new ValidationError(code)
|
|
130
212
|
return value
|
|
131
213
|
}
|