libp2r2p 0.10.5 → 0.10.7

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
@@ -329,6 +329,21 @@ the Schnorr signature on every call; it never adds a cache marker to the
329
329
  event. Their `assert…` counterparts return the original event or throw a
330
330
  `ValidationError` with a stable code.
331
331
 
332
+ NIP-27 text references live in `libp2r2p/nip27`. `extractMedia()` splits
333
+ content into text, URL, profile, event, relay, NIP-05 and hashtag items in
334
+ occurrence order, accepting the optional `@` and `nostr:` mention prefixes
335
+ plus NIP-05 in its standard, root and custom compact spellings.
336
+ `decodeReference()` parses a single reference, and `decodeMediaMetadata()`
337
+ reads the file/media metadata carried in a URL fragment
338
+ (`#m=image/png&dim=640x480&...`).
339
+
340
+ User references (`npub`, `nprofile`, hex pubkeys and every NIP-05 spelling)
341
+ are handled by `libp2r2p/nip27`: `decodeUserReference()` returns the decoded
342
+ form with its canonical compact spelling, `encodeUserReference()` returns
343
+ that canonical spelling, and `resolveUserReference()` resolves it to a
344
+ pubkey. `libp2r2p/nip05` keeps only `queryProfile()`, the NIP-05 lookup, which
345
+ accepts the compact custom forms directly.
346
+
332
347
  Public validity checks consistently use a non-throwing `is…` predicate plus an
333
348
  `assert…` counterpart when callers need the exact reason. Strict codecs,
334
349
  decoders, token validation, and malformed public arguments also throw
@@ -341,6 +356,54 @@ Low-level relay sockets, subscriptions, message parsing, and serialization are
341
356
  internal implementation details; use `RelayPool` or the `relayPool` singleton
342
357
  from `libp2r2p/relay`.
343
358
 
359
+ The same public subpath exports `getRelaysByPubkey(pubkeys)`, which discovers
360
+ the latest NIP-65 relay list for every requested pubkey through `seedRelays`,
361
+ normalizes and deduplicates its public relay URLs, and falls back to the first
362
+ two `freeRelays` when no list is available. Its result can be passed directly
363
+ to `pickRelaysForPubkeys(pubkeys, relaysByPubkey)` to batch authors that share
364
+ read or write relays.
365
+
366
+ `getRelaysByPubkey` accepts a few options:
367
+
368
+ - `includeEvents` returns each pubkey's latest kind `10002` event alongside its
369
+ parsed relays (`{ read, write, event }`), for consumers that need the original
370
+ event (storage, re-signing, freshness tracking).
371
+ - `forceRefresh` re-queries `seedRelays` even when the pubkey is cached, without
372
+ regressing a newer cached event when the relay returns an older one.
373
+ - `timeout` / `timeoutAfterFirstEose` tune the relay-list query timing. The
374
+ default opens a short grace window after the first EOSE with events, matching
375
+ `RelayPool.getEvents`.
376
+ - `emptyRelaysFallback` sets the relays returned when a pubkey has no relay
377
+ list (default: the first two `freeRelays`); pass `[]` to return empty
378
+ `read`/`write` arrays instead, keeping the free-relay decision with the
379
+ caller.
380
+ - `relayUrlPolicy` opts into non-default URL validation: `onion` allows
381
+ `ws://`/`wss://` `.onion` hosts, `localRelay` allows the standardized
382
+ `ws://localhost:4869` local relay, and `nostrEntityUrls` stops rejecting
383
+ public URLs that contain `npub1`/`nprofile1`. All other non-public or
384
+ insecure URLs stay rejected.
385
+
386
+ `parseRelayListEvent(event, relayUrlPolicy)` is exported from the same subpath
387
+ for consumers that parse relay-list events they receive outside
388
+ `getRelaysByPubkey`, so routing and persistence always agree on the parsed
389
+ shape.
390
+
391
+ `pickRelaysForPubkeys` also accepts `excludeRelaysByPubkey` (relays already
392
+ queried per pubkey, as an object or `Map`) and `emptyRelaysFallback` (the
393
+ relays used when a pubkey has no typed relays; pass `[]` to leave that pubkey
394
+ unrouted). Together with `maxPerPubkey: Infinity` these express a second,
395
+ exhaustive pass over the relays that were not yet tried for each author.
396
+
397
+ For callers that want the whole two-pass flow, `getLatestEventsByPubkey`
398
+ fetches the latest replaceable events (or addressable events when
399
+ `dTagsByPubkey` maps pubkeys to their `d` tags) through NIP-65 write relays:
400
+ the first pass routes through up to `maxPerPubkey` shared relays, and missing
401
+ authors are retried on every remaining relay plus `fallbackRelays` (default:
402
+ the first three `freeRelays`), excluding what was already queried. It accepts
403
+ `relaysByPubkey` to reuse a previous discovery (only missing pubkeys are then
404
+ discovered) and returns `{ events, byPubkey, relaysByPubkey }` so the merged
405
+ relay map can be passed back on later calls.
406
+
344
407
  ## Binary encodings
345
408
 
346
409
  Base16, Base36, Base62, Base64/Base64URL, and Base93 helpers are available
package/index.js CHANGED
@@ -18,6 +18,7 @@ export * as network from './network/index.js'
18
18
  export * as nip04 from './nip04/index.js'
19
19
  export * as nip05 from './nip05/index.js'
20
20
  export * as nip19 from './nip19/index.js'
21
+ export * as nip27 from './nip27/index.js'
21
22
  export * as nip44 from './nip44/index.js'
22
23
  export * as nip44v3 from './nip44-v3/index.js'
23
24
  export * as nip46 from './nip46/index.js'
package/kind/index.js CHANGED
@@ -88,6 +88,7 @@ export const USER_STATUSES = 30315
88
88
  export const I_TAG_TRUSTED_ASSERTION = 30385
89
89
  export const CLASSIFIED_LISTING = 30402
90
90
  export const DRAFT_CLASSIFIED_LISTING = 30403
91
+ export const SITE_CURATION_SET = 30499
91
92
  export const DATE_BASED_CALENDAR_EVENT = 31922
92
93
  export const TIME_BASED_CALENDAR_EVENT = 31923
93
94
  export const CALENDAR = 31924
@@ -226,6 +227,7 @@ export const eventKinds = /* @__PURE__ */ Object.freeze({
226
227
  I_TAG_TRUSTED_ASSERTION,
227
228
  CLASSIFIED_LISTING,
228
229
  DRAFT_CLASSIFIED_LISTING,
230
+ SITE_CURATION_SET,
229
231
  DATE_BASED_CALENDAR_EVENT,
230
232
  TIME_BASED_CALENDAR_EVENT,
231
233
  CALENDAR,
@@ -0,0 +1,57 @@
1
+ const NIP05_LOCAL = /^[a-z0-9._-]+$/
2
+ const NIP05_DOMAIN = /^[a-z0-9.-]+$/
3
+
4
+ export function isValidNip05Local (local) {
5
+ return typeof local === 'string' &&
6
+ (local === '_' || (local.length <= 64 && NIP05_LOCAL.test(local)))
7
+ }
8
+
9
+ export function isValidNip05Domain (domain) {
10
+ return typeof domain === 'string' &&
11
+ domain.length > 0 &&
12
+ domain.length <= 253 &&
13
+ domain.includes('.') &&
14
+ !domain.startsWith('.') &&
15
+ !domain.endsWith('.') &&
16
+ !domain.includes('..') &&
17
+ NIP05_DOMAIN.test(domain)
18
+ }
19
+
20
+ export function nip05FromLocalDomain (local, domain) {
21
+ if (!isValidNip05Local(local) || !isValidNip05Domain(domain)) return null
22
+ return { local, domain }
23
+ }
24
+
25
+ // Accepts:
26
+ // - `local@domain` (standard NIP-05)
27
+ // - `domain` with exactly one dot -> root `_@domain`
28
+ // - `local.domain...` with more than one dot -> local part + domain (custom extension)
29
+ export function decodeNip05Identifier (value) {
30
+ if (typeof value !== 'string') return null
31
+ const text = value.trim().toLowerCase()
32
+ if (!text) return null
33
+
34
+ const at = text.lastIndexOf('@')
35
+ if (at !== -1) {
36
+ if (at === 0 || at === text.length - 1 || text.includes('@', at + 1)) return null
37
+ return nip05FromLocalDomain(text.slice(0, at), text.slice(at + 1))
38
+ }
39
+
40
+ if (!text.includes('.')) return null
41
+ const firstDot = text.indexOf('.')
42
+ if (text.slice(firstDot + 1).includes('.')) {
43
+ return nip05FromLocalDomain(text.slice(0, firstDot), text.slice(firstDot + 1))
44
+ }
45
+ return nip05FromLocalDomain('_', text)
46
+ }
47
+
48
+ // Returns the most compact unambiguous NIP-05 spelling:
49
+ // - root (`_@domain`) becomes `domain` only when the domain has one dot;
50
+ // - a non-root local part becomes `local.domain` unless the local part
51
+ // itself contains dots, which would make the compact form ambiguous.
52
+ export function compactNip05Raw (local, domain) {
53
+ if (local === '_') {
54
+ return domain.split('.').length === 2 ? domain : `_@${domain}`
55
+ }
56
+ return local.includes('.') ? `${local}@${domain}` : `${local}.${domain}`
57
+ }
package/nip05/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { normalizeRelayUrl } from '../url/index.js'
2
+ import { decodeNip05Identifier } from './helpers/nip05-identifier.js'
2
3
 
3
4
  const PUBKEY = /^[0-9a-f]{64}$/
4
- const LOCAL_PART = /^[a-z0-9._-]+$/
5
5
 
6
6
  export async function queryProfile (identifier, {
7
7
  fetch: fetchImpl = globalThis.fetch,
@@ -9,17 +9,15 @@ export async function queryProfile (identifier, {
9
9
  timeoutMs = 5000
10
10
  } = {}) {
11
11
  if (typeof identifier !== 'string' || typeof fetchImpl !== 'function') return null
12
- const normalized = identifier.trim().toLowerCase()
13
- const separator = normalized.lastIndexOf('@')
14
- if (separator <= 0 || separator === normalized.length - 1) return null
15
- const name = normalized.slice(0, separator)
16
- const domain = normalized.slice(separator + 1)
17
- if (!LOCAL_PART.test(name) || /[/?#@]/.test(domain)) return null
12
+ const nip05 = decodeNip05Identifier(identifier)
13
+ if (!nip05) return null
14
+ const name = nip05.local
15
+ const domain = nip05.domain
18
16
 
19
17
  let url
20
18
  try {
21
19
  url = new URL(`https://${domain}/.well-known/nostr.json`)
22
- if (url.hostname !== domain.split(':')[0] || url.username || url.password) return null
20
+ if (url.hostname !== domain || url.username || url.password) return null
23
21
  url.searchParams.set('name', name)
24
22
  } catch {
25
23
  return null
@@ -0,0 +1,79 @@
1
+ import { ValidationError } from '../../error/index.js'
2
+ import { npubDecode, nprofileDecode } from '../../nip19/index.js'
3
+ import { compactNip05Raw, decodeNip05Identifier } from '../../nip05/helpers/nip05-identifier.js'
4
+
5
+ const HEX_PUBKEY = /^[0-9a-f]{64}$/
6
+
7
+ // Strips the optional mention prefixes accepted by NIP-21 (`nostr:`) and the
8
+ // social `@` handle marker. Either prefix may appear first.
9
+ function stripReferencePrefix (value) {
10
+ let text = value.trim()
11
+ let changed = true
12
+ while (changed && text) {
13
+ changed = false
14
+ if (/^nostr:/i.test(text)) {
15
+ text = text.slice(6)
16
+ changed = true
17
+ }
18
+ if (text.startsWith('@')) {
19
+ text = text.slice(1)
20
+ changed = true
21
+ }
22
+ }
23
+ return text
24
+ }
25
+
26
+ // Decodes a user reference without performing any network lookup.
27
+ // Returns `{ kind: 'pubkey', pubkey, relays, raw }` for npub/nprofile/hex or
28
+ // `{ kind: 'nip05', local, domain, raw }` for NIP-05 (standard or extended),
29
+ // where `raw` is always the most compact canonical spelling.
30
+ export function decodeUserReference (value) {
31
+ if (typeof value !== 'string') return null
32
+ const text = stripReferencePrefix(value)
33
+ if (!text) return null
34
+
35
+ if (HEX_PUBKEY.test(text)) {
36
+ const raw = text.toLowerCase()
37
+ return { kind: 'pubkey', pubkey: raw, relays: [], raw }
38
+ }
39
+
40
+ if (text.toLowerCase().startsWith('npub1')) {
41
+ try {
42
+ const raw = text.toLowerCase()
43
+ return { kind: 'pubkey', pubkey: npubDecode(raw), relays: [], raw }
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ if (text.toLowerCase().startsWith('nprofile1')) {
50
+ try {
51
+ const raw = text.toLowerCase()
52
+ const { pubkey, relays } = nprofileDecode(raw)
53
+ return { kind: 'pubkey', pubkey, relays, raw }
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ const nip05 = decodeNip05Identifier(text)
60
+ if (!nip05) return null
61
+ const raw = compactNip05Raw(nip05.local, nip05.domain)
62
+ return { kind: 'nip05', ...nip05, raw }
63
+ }
64
+
65
+ // Returns the canonical compact spelling for a user reference, either as a
66
+ // string or as a decoded reference object.
67
+ export function encodeUserReference (value) {
68
+ const ref = typeof value === 'string'
69
+ ? decodeUserReference(value)
70
+ : value && typeof value === 'object' &&
71
+ (value.kind === 'pubkey' || value.kind === 'nip05')
72
+ ? value
73
+ : null
74
+ if (!ref) {
75
+ throw new ValidationError('INVALID_USER_REFERENCE', { message: 'Invalid user reference' })
76
+ }
77
+ if (ref.kind === 'pubkey') return ref.raw ?? ref.pubkey
78
+ return ref.raw ?? compactNip05Raw(ref.local, ref.domain)
79
+ }
package/nip27/index.js ADDED
@@ -0,0 +1,360 @@
1
+ import {
2
+ naddrDecode,
3
+ neventDecode,
4
+ noteDecode,
5
+ nrelayDecode
6
+ } from '../nip19/index.js'
7
+ import { queryProfile } from '../nip05/index.js'
8
+ import { normalizeRelayUrl } from '../url/index.js'
9
+ import {
10
+ decodeUserReference,
11
+ encodeUserReference
12
+ } from './helpers/user-reference.js'
13
+
14
+ export { decodeUserReference, encodeUserReference }
15
+
16
+ const BECH32_BODY = '[ac-hj-np-z02-9]'
17
+ const BOUNDARY_PREFIX = /(?<=^|[\s"«„「¡¿:{([])/.source
18
+ const BOUNDARY_SUFFIX = /(?=\.?$|[.,]?\s|\.(?=\.)|\.?["»”」'!?;\])}])/.source
19
+
20
+ const URL_SOURCE =
21
+ '(?<url>' +
22
+ /(?<protocol>https:\/\/)?(?:[-_A-Za-z0-9]{1,30}\.){1,4}[a-z]{2,63}/.source +
23
+ /(?:(?:\/[-._A-Za-z0-9%@#]{1,300}){0,11}(?<ext>\.[a-z-0-9]{3,4})|(?:\/[-._A-Za-z0-9%@#]{1,300}){0,11})\/?/.source +
24
+ /(?:\??&?(?:[-_+.A-Za-z0-9%*]{1,30}=[-_+.A-Za-z0-9%*]{1,300}&?){1,40}|\?)?/.source +
25
+ /(?:(?:#|(?<=#))[-_+.A-Za-z0-9%=&*:~,]{1,4000})?/.source +
26
+ /(?:\/|(?<![.,]))/.source +
27
+ ')'
28
+
29
+ const NIP05_LOCAL = '[a-z0-9._-]{1,64}'
30
+ const NIP05_DOMAIN = '(?:[a-z0-9-]+\\.)+[a-z]{2,63}'
31
+ const NIP05_STANDARD = `(?:@)?(?<nip05>${NIP05_LOCAL}@${NIP05_DOMAIN})`
32
+ const NIP05_AT_ROOT = '@(?<nip05AtRoot>[a-z0-9-]+\\.[a-z]{2,63})'
33
+ const NIP05_AT_CUSTOM = `@(?<nip05AtCustom>${NIP05_LOCAL}\\.${NIP05_DOMAIN})`
34
+ const NIP05_BARE_ROOT = '(?<nip05BareRoot>[a-z0-9-]+\\.[a-z]{2,63})'
35
+ const NIP05_BARE_CUSTOM = `(?<nip05BareCustom>${NIP05_LOCAL}\\.${NIP05_DOMAIN})`
36
+
37
+ function entitySource (name) {
38
+ const bodyLength = name === 'npub' ? '58' : (name === 'nrelay' ? '10,5000' : '58,5000')
39
+ return `(?:@|nostr:)?(?<${name}>${name}1${BECH32_BODY}{${bodyLength}})`
40
+ }
41
+
42
+ const ENTITY_SOURCES = [
43
+ entitySource('nrelay'),
44
+ entitySource('npub'),
45
+ entitySource('nprofile'),
46
+ entitySource('note'),
47
+ entitySource('nevent'),
48
+ entitySource('naddr')
49
+ ]
50
+
51
+ const HASHTAG_SOURCE = /(?:#(?<hashtag>[^\s.!¡?¿@#$%^&*()=+/,[{\]};:'"><]+))/.source
52
+
53
+ let regexCache
54
+ function getReferencesRegex (bareNip05) {
55
+ const key = bareNip05 ? 'bare' : 'handle'
56
+ regexCache ??= {}
57
+ if (regexCache[key]) return regexCache[key]
58
+
59
+ const nip05Compact = [
60
+ NIP05_AT_ROOT,
61
+ NIP05_AT_CUSTOM,
62
+ ...(bareNip05 ? [NIP05_BARE_ROOT, NIP05_BARE_CUSTOM] : [])
63
+ ]
64
+ const alternatives = [
65
+ URL_SOURCE,
66
+ NIP05_STANDARD,
67
+ ...nip05Compact,
68
+ ...ENTITY_SOURCES,
69
+ HASHTAG_SOURCE
70
+ ]
71
+ // Bare compact NIP-05 forms are ambiguous with plain hostnames/URLs, so
72
+ // they are placed first only when explicitly requested.
73
+ if (bareNip05) {
74
+ alternatives.splice(0, 0, NIP05_BARE_ROOT, NIP05_BARE_CUSTOM)
75
+ }
76
+ const source = BOUNDARY_PREFIX + '(?:' + alternatives.join('|') + ')' + BOUNDARY_SUFFIX
77
+ return (regexCache[key] = new RegExp(source, 'gu'))
78
+ }
79
+
80
+ // Strips the optional NIP-21 (`nostr:`) and `@` mention prefixes.
81
+ function stripReferencePrefix (value) {
82
+ let text = value.trim()
83
+ let changed = true
84
+ while (changed && text) {
85
+ changed = false
86
+ if (/^nostr:/i.test(text)) {
87
+ text = text.slice(6)
88
+ changed = true
89
+ }
90
+ if (text.startsWith('@')) {
91
+ text = text.slice(1)
92
+ changed = true
93
+ }
94
+ }
95
+ return text
96
+ }
97
+
98
+ function normalizeRelays (relays) {
99
+ return (relays || [])
100
+ .map(relay => {
101
+ try {
102
+ return normalizeRelayUrl(relay)
103
+ } catch {
104
+ return null
105
+ }
106
+ })
107
+ .filter(Boolean)
108
+ }
109
+
110
+ // Parses a single NIP-27-style reference (with optional `@`/`nostr:`
111
+ // prefixes): NIP-05 (including the custom compact forms), npub/nprofile/hex
112
+ // accounts, note/nevent/naddr events and nrelay relays.
113
+ export function decodeReference (value) {
114
+ if (typeof value !== 'string') return null
115
+ const original = value.trim()
116
+ if (!original) return null
117
+ const text = stripReferencePrefix(original)
118
+ if (!text) return null
119
+
120
+ const account = decodeUserReference(text)
121
+ if (account) {
122
+ return account.kind === 'pubkey'
123
+ ? {
124
+ type: 'pubkey',
125
+ original,
126
+ value: account.raw,
127
+ pubkey: account.pubkey,
128
+ relays: account.relays
129
+ }
130
+ : {
131
+ type: 'nip05',
132
+ original,
133
+ value: account.raw,
134
+ local: account.local,
135
+ domain: account.domain
136
+ }
137
+ }
138
+
139
+ if (text.startsWith('note1')) {
140
+ try {
141
+ return { type: 'note', original, value: text, id: noteDecode(text) }
142
+ } catch {
143
+ return null
144
+ }
145
+ }
146
+ if (text.startsWith('nevent1')) {
147
+ try {
148
+ return { type: 'nevent', original, value: text, ...neventDecode(text) }
149
+ } catch {
150
+ return null
151
+ }
152
+ }
153
+ if (text.startsWith('naddr1')) {
154
+ try {
155
+ return { type: 'naddr', original, value: text, ...naddrDecode(text) }
156
+ } catch {
157
+ return null
158
+ }
159
+ }
160
+ if (text.startsWith('nrelay1')) {
161
+ try {
162
+ return { type: 'nrelay', original, value: text, relay: nrelayDecode(text) }
163
+ } catch {
164
+ return null
165
+ }
166
+ }
167
+ return null
168
+ }
169
+
170
+ const NIP94_TAGS = {
171
+ url: [{ key: 'url', type: 'array' }],
172
+ 'aes-256-gcm': ['key', 'iv'],
173
+ m: ['m'],
174
+ x: [{ key: 'x', type: 'array' }],
175
+ ox: ['ox'],
176
+ size: ['size'],
177
+ dim: ['dim'],
178
+ magnet: ['magnet'],
179
+ i: ['i'],
180
+ blurhash: ['blurhash'],
181
+ thumb: ['thumb'],
182
+ image: ['image'],
183
+ summary: ['summary'],
184
+ alt: ['alt'],
185
+ caption: ['caption']
186
+ }
187
+
188
+ // Decodes the file/media metadata carried in a URL fragment
189
+ // (`#m=image/png&dim=640x480&alt=...`). Kept generic on purpose: the old
190
+ // draft number (54) was taken by an unrelated NIP.
191
+ export function decodeMediaMetadata (url, { extraTags } = {}) {
192
+ if (typeof url !== 'string' || !url) return {}
193
+
194
+ const tags = extraTags ? { ...NIP94_TAGS, ...extraTags } : NIP94_TAGS
195
+ const tagIndexes = {}
196
+ const obj = (url.match(/(?<=#)[-_+.A-Za-z0-9%=&*]{1,4000}/)?.[0] || '')
197
+ .split('&')
198
+ .filter(Boolean)
199
+ .reduce((memo, item) => {
200
+ let [key, value = ''] = item.split('=')
201
+ key = decodeFragmentValue(key)
202
+ value = decodeFragmentValue(value)
203
+ const config = tags[key]
204
+ if (!config || (tagIndexes[key] ??= 0) === config.length) return memo
205
+
206
+ const name = config[tagIndexes[key]]?.key ?? config[tagIndexes[key]]
207
+ const type = config[tagIndexes[key]]?.type ?? 'string'
208
+ switch (type) {
209
+ case 'array': memo[name] ??= []; memo[name].push(value); break
210
+ case 'string': memo[name] = value; tagIndexes[key]++; break
211
+ default: break
212
+ }
213
+ return memo
214
+ }, {})
215
+
216
+ if ('dim' in obj) {
217
+ const { width, height } = obj.dim.match(
218
+ /(?<width>[1-9]{1}[0-9]{0,10})(?:\s*[xX]\s*)(?<height>[1-9]{1}[0-9]{0,10})/
219
+ )?.groups ?? {}
220
+ if (width !== undefined) obj.width = width
221
+ if (height !== undefined) obj.height = height
222
+ }
223
+ return obj
224
+ }
225
+
226
+ function decodeFragmentValue (value) {
227
+ try {
228
+ return decodeURIComponent(value.replace(/\+/g, '%20'))
229
+ } catch {
230
+ return value
231
+ }
232
+ }
233
+
234
+ function getReferenceItem (original, groups, { getMimeType }) {
235
+ if (groups.url) {
236
+ const url = `${groups.protocol ? '' : 'https://'}${groups.url}`
237
+ const urlItem = { value: url, ...(groups.ext && { ext: groups.ext }), ...decodeMediaMetadata(url) }
238
+ if (!urlItem.m && typeof getMimeType === 'function') {
239
+ const mime = getMimeType({ url, ext: groups.ext })
240
+ if (mime) urlItem.m = mime
241
+ }
242
+ return { key: 'url', url: urlItem }
243
+ }
244
+
245
+ if (
246
+ groups.nip05 ||
247
+ groups.nip05AtRoot ||
248
+ groups.nip05AtCustom ||
249
+ groups.nip05BareRoot ||
250
+ groups.nip05BareCustom
251
+ ) {
252
+ const account = decodeUserReference(original)
253
+ if (!account) return null
254
+ return {
255
+ key: 'nip05',
256
+ nip05: {
257
+ original,
258
+ value: account.raw,
259
+ local: account.local,
260
+ domain: account.domain
261
+ }
262
+ }
263
+ }
264
+
265
+ if (groups.hashtag) {
266
+ return { key: 'hashtag', hashtag: { value: groups.hashtag } }
267
+ }
268
+
269
+ const ref = decodeReference(original)
270
+ if (!ref) return null
271
+ switch (ref.type) {
272
+ case 'pubkey': {
273
+ const isNprofile = ref.value.startsWith('nprofile1')
274
+ const profile = {
275
+ pubkey: ref.pubkey,
276
+ relays: normalizeRelays(ref.relays),
277
+ original,
278
+ nip19Type: isNprofile ? 'nprofile' : 'npub'
279
+ }
280
+ if (!isNprofile) profile.npub = ref.value
281
+ return { key: 'profile', profile }
282
+ }
283
+ case 'note':
284
+ return { key: 'event', event: { id: ref.id, relays: [], original, nip19Type: 'note' } }
285
+ case 'nevent':
286
+ return {
287
+ key: 'event',
288
+ event: {
289
+ id: ref.id,
290
+ relays: normalizeRelays(ref.relays),
291
+ original,
292
+ nip19Type: 'nevent',
293
+ ...(ref.author !== undefined && { author: ref.author }),
294
+ ...(ref.kind !== undefined && { kind: ref.kind })
295
+ }
296
+ }
297
+ case 'naddr':
298
+ return {
299
+ key: 'event',
300
+ event: {
301
+ identifier: ref.identifier,
302
+ pubkey: ref.pubkey,
303
+ kind: ref.kind,
304
+ relays: normalizeRelays(ref.relays),
305
+ original,
306
+ nip19Type: 'naddr'
307
+ }
308
+ }
309
+ case 'nrelay':
310
+ return { key: 'relay', relay: { relay: ref.relay, original, value: ref.value } }
311
+ default:
312
+ return null
313
+ }
314
+ }
315
+
316
+ // Modernized NIP-27 text-reference extractor. Accepts optional `@` and
317
+ // `nostr:` mention prefixes, NIP-05 (standard, root and custom compact) and
318
+ // keeps the same item shape as the previous text extractor for URLs/entities.
319
+ // Bare compact NIP-05 spellings (`bob.example.com`) are only recognized when
320
+ // `{ bareNip05: true }` is passed, since they are otherwise indistinguishable
321
+ // from plain hostnames; prefixed forms (`@bob.example.com`) always work.
322
+ export function extractMedia (content, { bareNip05 = false, getMimeType } = {}) {
323
+ if (typeof content !== 'string') return []
324
+ const regex = getReferencesRegex(bareNip05)
325
+ const items = []
326
+ let end = 0
327
+
328
+ for (const match of content.matchAll(regex)) {
329
+ const start = match.index
330
+ if (start > end) {
331
+ items.push({ key: 'text', text: { value: content.slice(end, start) } })
332
+ }
333
+ const original = match[0]
334
+ end = start + original.length
335
+ const item = getReferenceItem(original, match.groups, { getMimeType })
336
+ if (item) items.push(item)
337
+ }
338
+
339
+ if (items.length === 0) {
340
+ items.push({ key: 'text', text: { value: content } })
341
+ } else if (end < content.length) {
342
+ items.push({ key: 'text', text: { value: content.slice(end) } })
343
+ }
344
+ return items
345
+ }
346
+
347
+ // Resolves a user reference to a pubkey and relay hints. NIP-05 lookups go
348
+ // through `queryProfile` (accepting standard, root and compact custom
349
+ // spellings); npub/nprofile/hex are resolved locally.
350
+ export async function resolveUserReference (value, options = {}) {
351
+ const account = decodeUserReference(value)
352
+ if (!account) return null
353
+ if (account.kind === 'pubkey') {
354
+ return { pubkey: account.pubkey, relays: account.relays, label: account.raw }
355
+ }
356
+
357
+ const result = await queryProfile(`${account.local}@${account.domain}`, options)
358
+ if (!result) return null
359
+ return { pubkey: result.pubkey, relays: result.relays, label: account.raw }
360
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.5",
3
+ "version": "0.10.7",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -31,6 +31,7 @@
31
31
  "nip04",
32
32
  "nip05",
33
33
  "nip19",
34
+ "nip27",
34
35
  "nip44",
35
36
  "nip44-v3",
36
37
  "nip46",
@@ -71,6 +72,7 @@
71
72
  "./nip04": "./nip04/index.js",
72
73
  "./nip05": "./nip05/index.js",
73
74
  "./nip19": "./nip19/index.js",
75
+ "./nip27": "./nip27/index.js",
74
76
  "./nip44": "./nip44/index.js",
75
77
  "./nip44-v3": "./nip44-v3/index.js",
76
78
  "./nip46": "./nip46/index.js",
@@ -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/app-url.js ADDED
@@ -0,0 +1,143 @@
1
+ import { ValidationError } from '../error/index.js'
2
+ import {
3
+ compactNip05Raw,
4
+ nip05FromLocalDomain
5
+ } from '../nip05/helpers/nip05-identifier.js'
6
+ import {
7
+ decodeUserReference,
8
+ encodeUserReference
9
+ } from '../nip27/helpers/user-reference.js'
10
+ import {
11
+ NAPP_ENTITY_REGEX,
12
+ appDecode
13
+ } from '../nip19/index.js'
14
+
15
+ export const APP_URL_MIN_ENTITY_BODY_LENGTH = 48
16
+
17
+ const APP_NAME_MAX_LENGTH = 260
18
+ const CHANNEL_BY_PREFIX = { '+': 'main', '++': 'next', '+++': 'draft' }
19
+ const PREFIX_BY_CHANNEL = { main: '+', next: '++', draft: '+++' }
20
+ const KIND_BY_CHANNEL = { main: 35128, next: 35129, draft: 35130 }
21
+ // eslint-disable-next-line no-control-regex
22
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/
23
+
24
+ function safeDecode (value) {
25
+ try {
26
+ return decodeURIComponent(value)
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ function isValidAppName (appName) {
33
+ return typeof appName === 'string' &&
34
+ appName.length > 0 &&
35
+ appName.length <= APP_NAME_MAX_LENGTH &&
36
+ !appName.startsWith('+') &&
37
+ !appName.includes('/') &&
38
+ !CONTROL_CHARS.test(appName)
39
+ }
40
+
41
+ // Decodes a raw (still percent-encoded) first path segment such as
42
+ // `+apps` or `+caf%C3%A9@bob@example.com` or `+3swFhu...`.
43
+ // Returns:
44
+ // - `{ type: 'entity', entity }` for NIP-19 app entities;
45
+ // - `{ type: 'named', prefix, channel, appName, user }` for named URLs
46
+ // (`user` is null when no user part is present or it is invalid);
47
+ // - `null` when the segment is not a valid app URL.
48
+ export function decodeAppUrl (segment) {
49
+ if (typeof segment !== 'string' || !segment) return null
50
+ const prefixMatch = segment.match(/^\+{1,3}/)
51
+ if (!prefixMatch) return null
52
+ const prefix = prefixMatch[0]
53
+
54
+ if (NAPP_ENTITY_REGEX.test(segment)) {
55
+ try {
56
+ appDecode(segment)
57
+ } catch {
58
+ return null
59
+ }
60
+ return { type: 'entity', entity: segment }
61
+ }
62
+
63
+ const remainder = segment.slice(prefix.length)
64
+ if (!remainder) return null
65
+ const parts = remainder.split('@')
66
+ let appName
67
+ let user = null
68
+
69
+ if (parts.length === 1) {
70
+ appName = safeDecode(parts[0])
71
+ } else if (parts.length === 2) {
72
+ const tail = safeDecode(parts[1])
73
+ appName = safeDecode(parts[0])
74
+ user = tail === null ? null : decodeUserReference(tail)
75
+ } else {
76
+ const local = safeDecode(parts[parts.length - 2])
77
+ const domain = safeDecode(parts[parts.length - 1])
78
+ const nip05 = nip05FromLocalDomain(local, domain)
79
+ if (nip05) {
80
+ user = { kind: 'nip05', ...nip05, raw: compactNip05Raw(local, domain) }
81
+ appName = parts.slice(0, -2).map(safeDecode).join('@')
82
+ } else {
83
+ appName = safeDecode(remainder)
84
+ }
85
+ }
86
+
87
+ if (appName === null || !isValidAppName(appName)) return null
88
+ if (!user && appName.length >= APP_URL_MIN_ENTITY_BODY_LENGTH) return null
89
+
90
+ return {
91
+ type: 'named',
92
+ prefix,
93
+ channel: CHANNEL_BY_PREFIX[prefix],
94
+ appName,
95
+ user
96
+ }
97
+ }
98
+
99
+ // Encodes a named app URL segment. `user` is a decoded user reference:
100
+ // NIP-05 (`bob@example.com`, `_@example.com`, `example.com` or the custom
101
+ // `bob.xyz.example.com` form), npub, nprofile or hex pubkey.
102
+ export function encodeAppUrl ({ appName, channel = 'main', user }) {
103
+ if (!isValidAppName(appName)) {
104
+ throw new ValidationError('INVALID_APP_URL_NAME', { message: 'Invalid app URL name' })
105
+ }
106
+ const prefix = PREFIX_BY_CHANNEL[channel]
107
+ if (!prefix) {
108
+ throw new ValidationError('INVALID_APP_URL_CHANNEL', { message: 'Invalid app URL channel' })
109
+ }
110
+ const userRef = decodeUserReference(user)
111
+ if (!userRef) {
112
+ throw new ValidationError('INVALID_APP_URL_USER', { message: 'Invalid app URL user' })
113
+ }
114
+
115
+ let encodedAppName = encodeURIComponent(appName)
116
+ let userText
117
+ if (userRef.kind === 'nip05') {
118
+ // Keep `@` raw inside the app name so the verbose NIP-05 form stays
119
+ // readable (`+my@app@bob@example.com`).
120
+ encodedAppName = encodedAppName.replace(/%40/g, '@')
121
+ const appNameHasAt = appName.includes('@')
122
+ if (userRef.local === '_') {
123
+ userText = userRef.domain.split('.').length === 2 && !appNameHasAt
124
+ ? userRef.domain
125
+ : `_@${userRef.domain}`
126
+ } else {
127
+ // Prefer the shorter single-`@` custom NIP-05 form (`bob.example.com`)
128
+ // unless the app name itself contains `@`, which forces the verbose form.
129
+ userText = appNameHasAt
130
+ ? `${userRef.local}@${userRef.domain}`
131
+ : `${userRef.local}.${userRef.domain}`
132
+ }
133
+ } else {
134
+ userText = encodeUserReference(userRef)
135
+ }
136
+
137
+ return `${prefix}${encodedAppName}@${userText}`
138
+ }
139
+
140
+ // Convenience for callers that need the manifest kind for a decoded channel.
141
+ export function appUrlKindByChannel (channel) {
142
+ return KIND_BY_CHANNEL[channel] || null
143
+ }
package/url/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { ValidationError } from '../error/index.js'
2
2
 
3
+ export * from './app-url.js'
4
+
3
5
  function isIpv4Address (hostname) {
4
6
  const parts = hostname.split('.')
5
7
  return parts.length === 4 && parts.every(part =>
@@ -130,7 +132,14 @@ export function normalizeBlossomServerUrl (value) {
130
132
  return url.origin
131
133
  }
132
134
 
133
- function publicRelayUrlError (value) {
135
+ function isPolicyRelayUrl (url, policy) {
136
+ const hostname = url.hostname.toLowerCase().replace(/\.+$/, '')
137
+ if (policy?.onion && hostname.endsWith('.onion')) return true
138
+ if (policy?.localRelay && hostname === 'localhost' && url.port === '4869' && (url.pathname === '/' || url.pathname === '')) return true
139
+ return false
140
+ }
141
+
142
+ function publicRelayUrlError (value, policy = {}) {
134
143
  if (typeof value !== 'string' || value.trim().length === 0) return 'INVALID_RELAY_URL'
135
144
  let input = value.trim()
136
145
  if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) input = `wss://${input}`
@@ -148,28 +157,33 @@ function publicRelayUrlError (value) {
148
157
  }
149
158
 
150
159
  const url = new URL(normalized)
151
- if (url.protocol !== 'wss:') return 'INSECURE_RELAY_URL'
160
+ const policyRelay = isPolicyRelayUrl(url, policy)
161
+ if (url.protocol !== 'wss:' && !policyRelay) return 'INSECURE_RELAY_URL'
152
162
  if (url.username || url.password) return 'RELAY_URL_CREDENTIALS_NOT_ALLOWED'
153
163
 
154
- const hostError = publicHostError(url, {
155
- invalidHostCode: 'INVALID_RELAY_HOST',
156
- nonPublicHostCode: 'NON_PUBLIC_RELAY_HOST',
157
- nonPublicIpCode: 'NON_PUBLIC_RELAY_IP'
158
- })
159
- if (hostError) return hostError
164
+ if (!policyRelay) {
165
+ const hostError = publicHostError(url, {
166
+ invalidHostCode: 'INVALID_RELAY_HOST',
167
+ nonPublicHostCode: 'NON_PUBLIC_RELAY_HOST',
168
+ nonPublicIpCode: 'NON_PUBLIC_RELAY_IP'
169
+ })
170
+ if (hostError) return hostError
171
+ }
160
172
 
161
- const lowerValue = normalized.toLowerCase()
162
- if (lowerValue.includes('npub1') || lowerValue.includes('nprofile1')) return 'RELAY_URL_NOSTR_ENTITY_NOT_ALLOWED'
173
+ if (!policy?.nostrEntityUrls) {
174
+ const lowerValue = normalized.toLowerCase()
175
+ if (lowerValue.includes('npub1') || lowerValue.includes('nprofile1')) return 'RELAY_URL_NOSTR_ENTITY_NOT_ALLOWED'
176
+ }
163
177
 
164
178
  return null
165
179
  }
166
180
 
167
- export function isValidPublicRelayUrl (value) {
168
- return publicRelayUrlError(value) === null
181
+ export function isValidPublicRelayUrl (value, policy) {
182
+ return publicRelayUrlError(value, policy) === null
169
183
  }
170
184
 
171
- export function assertValidPublicRelayUrl (value) {
172
- const code = publicRelayUrlError(value)
185
+ export function assertValidPublicRelayUrl (value, policy) {
186
+ const code = publicRelayUrlError(value, policy)
173
187
  if (code) throw new ValidationError(code)
174
188
  return value
175
189
  }