libp2r2p 0.10.9 → 0.10.11
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 +11 -3
- package/network/README.md +20 -0
- package/network/index.js +150 -36
- package/nip05/helpers/nip05-identifier.js +23 -8
- package/nip05/index.js +5 -2
- package/nip27/helpers/user-reference.js +30 -9
- package/nip27/index.js +96 -38
- package/package.json +1 -1
- package/relay/services/events.js +4 -1
- package/url/app-url.js +54 -19
package/README.md
CHANGED
|
@@ -8,6 +8,10 @@ the transport and discovery surface. The package was born to distribute the
|
|
|
8
8
|
private messenger reference implementation, and it also carries a few Nostr
|
|
9
9
|
power-ups used by that messenger.
|
|
10
10
|
|
|
11
|
+
For remote-work scheduling, see [`libp2r2p/network`](network/README.md):
|
|
12
|
+
`isOnline` probes connectivity and `onOnline` shares recovery monitoring,
|
|
13
|
+
including retries when the browser omits its native `online` event.
|
|
14
|
+
|
|
11
15
|
## Private Messenger
|
|
12
16
|
|
|
13
17
|
The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
|
|
@@ -342,13 +346,17 @@ are handled by `libp2r2p/nip27`: `decodeUserReference()` returns the decoded
|
|
|
342
346
|
form with its canonical compact spelling, `encodeUserReference()` returns
|
|
343
347
|
that canonical spelling, and `resolveUserReference()` resolves it to a
|
|
344
348
|
pubkey. `libp2r2p/nip05` keeps only `queryProfile()`, the NIP-05 lookup, which
|
|
345
|
-
accepts the compact custom forms directly.
|
|
349
|
+
accepts the compact custom forms directly. The decoders (`decodeReference`,
|
|
350
|
+
`decodeMediaMetadata`, `decodeUserReference`, `decodeAppUrl`) throw
|
|
351
|
+
`ValidationError` with a stable code; each has a `tryDecode…` counterpart
|
|
352
|
+
that returns `null` when the value cannot be decoded.
|
|
346
353
|
|
|
347
354
|
Public validity checks consistently use a non-throwing `is…` predicate plus an
|
|
348
355
|
`assert…` counterpart when callers need the exact reason. Strict codecs,
|
|
349
356
|
decoders, token validation, and malformed public arguments also throw
|
|
350
|
-
`ValidationError` from `libp2r2p/error
|
|
351
|
-
|
|
357
|
+
`ValidationError` from `libp2r2p/error`; probing code can use the
|
|
358
|
+
non-throwing `tryDecode…` variants instead of catching. Network, timeout,
|
|
359
|
+
abort, quota, and closed-state failures remain ordinary operational errors.
|
|
352
360
|
|
|
353
361
|
NIP-04 remains available at
|
|
354
362
|
`libp2r2p/nip04` only for compatibility with older Nostr applications.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Connectivity
|
|
2
|
+
|
|
3
|
+
`isOnline({ signal } = {})` returns a boolean after probing public connectivity
|
|
4
|
+
endpoints, or immediately returns `false` when the browser reports offline.
|
|
5
|
+
Concurrent calls without a signal share one check. A supplied abort signal
|
|
6
|
+
cancels the caller's independent check. Each endpoint has a five-second timeout.
|
|
7
|
+
|
|
8
|
+
`onOnline(handler)` returns an idempotent unsubscribe function. It notifies
|
|
9
|
+
asynchronously after confirmed initial connectivity and each detected recovery.
|
|
10
|
+
The native `online` event only triggers a check; it is not sufficient evidence.
|
|
11
|
+
Listeners share a monitor with retries at 5, 15, 30, then 60 seconds (20% jitter),
|
|
12
|
+
checks on focus/visibility/network changes, and a 60-second interval while online.
|
|
13
|
+
Unsubscribing the last listener removes timers/listeners and aborts its probe.
|
|
14
|
+
Handler failures are isolated and logged. `createConnectivityMonitor(options)`
|
|
15
|
+
creates an independent monitor with an optional custom `check({ signal })`.
|
|
16
|
+
|
|
17
|
+
Successful probes do not guarantee a particular relay or HTTP server is reachable.
|
|
18
|
+
Handle request failures, cancellations, and timeouts separately. Browsers can
|
|
19
|
+
suspend background tabs, so recovery notification has no wall-clock guarantee.
|
|
20
|
+
Consumers should keep their own work queues but should not duplicate probe loops.
|
package/network/index.js
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') {
|
|
3
|
-
if (!navigator.onLine) return false
|
|
4
|
-
}
|
|
5
|
-
return hasInternetConnectivity()
|
|
6
|
-
}
|
|
1
|
+
import { ValidationError } from '../error/index.js'
|
|
7
2
|
|
|
3
|
+
const RETRY_DELAYS = [5000, 15000, 30000, 60000]
|
|
8
4
|
const CONNECTIVITY_PROBE_URLS = [
|
|
9
5
|
{ url: 'https://www.gstatic.com/generate_204' },
|
|
10
6
|
{ url: 'https://connectivitycheck.gstatic.com/generate_204' },
|
|
11
7
|
{ url: 'https://captive.apple.com/hotspot-detect.html' },
|
|
12
8
|
{ method: 'GET', url: 'https://connectivity-check.ubuntu.com' }
|
|
13
9
|
]
|
|
10
|
+
let sharedCheck
|
|
11
|
+
|
|
12
|
+
// Treat the browser's offline flag as a fast failure; confirm online status with a probe.
|
|
13
|
+
export async function isOnline ({ signal } = {}) {
|
|
14
|
+
if (signal?.aborted) throw signal.reason
|
|
15
|
+
if (globalThis.navigator?.onLine === false) return false
|
|
16
|
+
if (signal) return hasInternetConnectivity(signal)
|
|
17
|
+
sharedCheck ??= hasInternetConnectivity().finally(() => { sharedCheck = null })
|
|
18
|
+
return sharedCheck
|
|
19
|
+
}
|
|
14
20
|
|
|
15
|
-
async function hasInternetConnectivity () {
|
|
16
|
-
const
|
|
17
|
-
|
|
21
|
+
async function hasInternetConnectivity (signal) {
|
|
22
|
+
for (const candidate of shuffle(CONNECTIVITY_PROBE_URLS)) {
|
|
23
|
+
if (signal?.aborted) throw signal.reason
|
|
18
24
|
try {
|
|
19
|
-
await ping(candidate.url, { method: candidate.method })
|
|
25
|
+
await ping(candidate.url, { method: candidate.method, signal })
|
|
20
26
|
return true
|
|
21
|
-
} catch
|
|
22
|
-
|
|
27
|
+
} catch {
|
|
28
|
+
if (signal?.aborted) throw signal.reason
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
return false
|
|
@@ -34,35 +40,143 @@ function shuffle (list) {
|
|
|
34
40
|
return copy
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (timerId != null) clearTimeout(timerId)
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
54
|
-
timerId = setTimeout(() => {
|
|
55
|
-
if (abortController) abortController.abort()
|
|
43
|
+
// Bound each probe and release its timer and abort listener on every exit path.
|
|
44
|
+
async function ping (url, { method = 'HEAD', timeout = 5000, signal } = {}) {
|
|
45
|
+
const controller = new AbortController()
|
|
46
|
+
let timer
|
|
47
|
+
let onAbort
|
|
48
|
+
const stopped = new Promise((_resolve, reject) => {
|
|
49
|
+
onAbort = () => {
|
|
50
|
+
controller.abort(signal.reason)
|
|
51
|
+
reject(signal.reason)
|
|
52
|
+
}
|
|
53
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
54
|
+
timer = setTimeout(() => {
|
|
55
|
+
controller.abort()
|
|
56
56
|
reject(new Error('PING_TIMEOUT'))
|
|
57
57
|
}, timeout)
|
|
58
|
+
if (signal?.aborted) onAbort()
|
|
58
59
|
})
|
|
60
|
+
try {
|
|
61
|
+
await Promise.race([
|
|
62
|
+
fetch(url, { method, mode: 'no-cors', cache: 'no-store', redirect: 'follow', signal: controller.signal }),
|
|
63
|
+
stopped
|
|
64
|
+
])
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timer)
|
|
67
|
+
signal?.removeEventListener('abort', onAbort)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
59
70
|
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
// A monitor owns one probe loop, regardless of how many consumers subscribe.
|
|
72
|
+
export function createConnectivityMonitor ({
|
|
73
|
+
check = isOnline,
|
|
74
|
+
eventTarget = globalThis.window,
|
|
75
|
+
document = globalThis.document,
|
|
76
|
+
_setTimeout = globalThis.setTimeout,
|
|
77
|
+
_clearTimeout = globalThis.clearTimeout,
|
|
78
|
+
_random = Math.random,
|
|
79
|
+
reportError = error => console.error('Online listener failed', error)
|
|
80
|
+
} = {}) {
|
|
81
|
+
if (typeof check !== 'function') throw new ValidationError('INVALID_CONNECTIVITY_CHECK')
|
|
82
|
+
const listeners = new Set()
|
|
83
|
+
const setTimer = (...args) => Reflect.apply(_setTimeout, globalThis, args)
|
|
84
|
+
const clearTimer = (...args) => Reflect.apply(_clearTimeout, globalThis, args)
|
|
85
|
+
let session
|
|
86
|
+
|
|
87
|
+
function deliver (entry, current) {
|
|
88
|
+
if (entry.delivered || !listeners.has(entry) || session !== current) return
|
|
89
|
+
entry.delivered = true
|
|
90
|
+
try { Promise.resolve(entry.handler()).catch(reportError) } catch (error) { reportError(error) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function schedule (current) {
|
|
94
|
+
if (session !== current || !listeners.size) return
|
|
95
|
+
const delay = current.online ? 60000 : RETRY_DELAYS[Math.min(current.retry++, RETRY_DELAYS.length - 1)]
|
|
96
|
+
current.timer = setTimer(() => {
|
|
97
|
+
current.timer = null
|
|
98
|
+
return probe(current)
|
|
99
|
+
}, Math.round(delay * (0.8 + _random() * 0.4)))
|
|
100
|
+
current.timer?.unref?.()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function probe (current) {
|
|
104
|
+
if (session !== current || current.pending) return
|
|
105
|
+
if (current.timer != null) clearTimer(current.timer)
|
|
106
|
+
current.timer = null
|
|
107
|
+
current.pending = true
|
|
108
|
+
try {
|
|
109
|
+
const online = await check({ signal: current.controller.signal })
|
|
110
|
+
if (session !== current) return
|
|
111
|
+
current.online = online === true && globalThis.navigator?.onLine !== false
|
|
112
|
+
if (current.online) {
|
|
113
|
+
current.retry = 0
|
|
114
|
+
for (const entry of [...listeners]) deliver(entry, current)
|
|
115
|
+
} else {
|
|
116
|
+
for (const entry of listeners) entry.delivered = false
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
if (session !== current) return
|
|
120
|
+
current.online = false
|
|
121
|
+
for (const entry of listeners) entry.delivered = false
|
|
122
|
+
} finally {
|
|
123
|
+
current.pending = false
|
|
124
|
+
schedule(current)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function start () {
|
|
129
|
+
const current = { online: false, retry: 0, timer: null, pending: false, controller: new AbortController() }
|
|
130
|
+
session = current
|
|
131
|
+
current.wake = () => { probe(current) }
|
|
132
|
+
current.connectionChanged = () => {
|
|
133
|
+
current.online = false
|
|
134
|
+
current.retry = 0
|
|
135
|
+
for (const entry of listeners) entry.delivered = false
|
|
136
|
+
current.wake()
|
|
137
|
+
}
|
|
138
|
+
current.visible = () => { if (document?.visibilityState !== 'hidden') current.wake() }
|
|
139
|
+
eventTarget?.addEventListener('online', current.connectionChanged)
|
|
140
|
+
eventTarget?.addEventListener('offline', current.connectionChanged)
|
|
141
|
+
eventTarget?.addEventListener('focus', current.wake)
|
|
142
|
+
document?.addEventListener('visibilitychange', current.visible)
|
|
143
|
+
queueMicrotask(current.wake)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stop () {
|
|
147
|
+
const current = session
|
|
148
|
+
session = null
|
|
149
|
+
if (current.timer != null) clearTimer(current.timer)
|
|
150
|
+
current.controller.abort()
|
|
151
|
+
eventTarget?.removeEventListener('online', current.connectionChanged)
|
|
152
|
+
eventTarget?.removeEventListener('offline', current.connectionChanged)
|
|
153
|
+
eventTarget?.removeEventListener('focus', current.wake)
|
|
154
|
+
document?.removeEventListener('visibilitychange', current.visible)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Notify once on confirmed initial connectivity, then after each detected reconnection.
|
|
158
|
+
function onOnline (handler) {
|
|
159
|
+
if (typeof handler !== 'function') throw new ValidationError('INVALID_ONLINE_HANDLER')
|
|
160
|
+
const entry = { handler, delivered: false }
|
|
161
|
+
listeners.add(entry)
|
|
162
|
+
if (!session) start()
|
|
163
|
+
else {
|
|
164
|
+
const current = session
|
|
165
|
+
queueMicrotask(() => { probe(current) })
|
|
166
|
+
}
|
|
167
|
+
return () => {
|
|
168
|
+
if (!listeners.delete(entry)) return
|
|
169
|
+
if (!listeners.size) stop()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { onOnline }
|
|
62
174
|
}
|
|
63
175
|
|
|
176
|
+
let defaultMonitor
|
|
177
|
+
|
|
178
|
+
// Share probes, capped backoff and wake-up listeners across callers in this realm.
|
|
64
179
|
export function onOnline (handler) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return () => window.removeEventListener('online', listener)
|
|
180
|
+
defaultMonitor ??= createConnectivityMonitor()
|
|
181
|
+
return defaultMonitor.onOnline(handler)
|
|
68
182
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ValidationError } from '../../error/index.js'
|
|
2
|
+
|
|
1
3
|
const NIP05_LOCAL = /^[a-z0-9._-]+$/
|
|
2
4
|
const NIP05_DOMAIN = /^[a-z0-9.-]+$/
|
|
3
5
|
|
|
@@ -26,23 +28,36 @@ export function nip05FromLocalDomain (local, domain) {
|
|
|
26
28
|
// - `local@domain` (standard NIP-05)
|
|
27
29
|
// - `domain` with exactly one dot -> root `_@domain`
|
|
28
30
|
// - `local.domain...` with more than one dot -> local part + domain (custom extension)
|
|
31
|
+
// Throws `ValidationError('INVALID_NIP05_IDENTIFIER')` for malformed input.
|
|
29
32
|
export function decodeNip05Identifier (value) {
|
|
30
|
-
if (typeof value !== 'string')
|
|
33
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
34
|
+
throw new ValidationError('INVALID_NIP05_IDENTIFIER', { message: 'IDENTIFIER_SHOULD_BE_A_NON_EMPTY_STRING' })
|
|
35
|
+
}
|
|
31
36
|
const text = value.trim().toLowerCase()
|
|
32
|
-
if (!text) return null
|
|
33
37
|
|
|
34
38
|
const at = text.lastIndexOf('@')
|
|
35
39
|
if (at !== -1) {
|
|
36
|
-
if (at === 0 || at === text.length - 1 || text.includes('@', at + 1))
|
|
37
|
-
|
|
40
|
+
if (at === 0 || at === text.length - 1 || text.includes('@', at + 1)) {
|
|
41
|
+
throw new ValidationError('INVALID_NIP05_IDENTIFIER', { message: 'INVALID_NIP05_FORMAT' })
|
|
42
|
+
}
|
|
43
|
+
const result = nip05FromLocalDomain(text.slice(0, at), text.slice(at + 1))
|
|
44
|
+
if (!result) {
|
|
45
|
+
throw new ValidationError('INVALID_NIP05_IDENTIFIER', { message: 'INVALID_NIP05_LOCAL_OR_DOMAIN' })
|
|
46
|
+
}
|
|
47
|
+
return result
|
|
38
48
|
}
|
|
39
49
|
|
|
40
|
-
if (!text.includes('.'))
|
|
50
|
+
if (!text.includes('.')) {
|
|
51
|
+
throw new ValidationError('INVALID_NIP05_IDENTIFIER', { message: 'INVALID_NIP05_FORMAT' })
|
|
52
|
+
}
|
|
41
53
|
const firstDot = text.indexOf('.')
|
|
42
|
-
|
|
43
|
-
|
|
54
|
+
const result = text.slice(firstDot + 1).includes('.')
|
|
55
|
+
? nip05FromLocalDomain(text.slice(0, firstDot), text.slice(firstDot + 1))
|
|
56
|
+
: nip05FromLocalDomain('_', text)
|
|
57
|
+
if (!result) {
|
|
58
|
+
throw new ValidationError('INVALID_NIP05_IDENTIFIER', { message: 'INVALID_NIP05_LOCAL_OR_DOMAIN' })
|
|
44
59
|
}
|
|
45
|
-
return
|
|
60
|
+
return result
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
// Returns the most compact unambiguous NIP-05 spelling:
|
package/nip05/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ValidationError } from '../error/index.js'
|
|
1
2
|
import { normalizeRelayUrl } from '../url/index.js'
|
|
2
3
|
import { decodeNip05Identifier } from './helpers/nip05-identifier.js'
|
|
3
4
|
|
|
@@ -8,9 +9,11 @@ export async function queryProfile (identifier, {
|
|
|
8
9
|
signal,
|
|
9
10
|
timeoutMs = 5000
|
|
10
11
|
} = {}) {
|
|
11
|
-
if (typeof identifier !== 'string' ||
|
|
12
|
+
if (typeof identifier !== 'string' || !identifier.trim()) {
|
|
13
|
+
throw new ValidationError('INVALID_NIP05_IDENTIFIER', { message: 'IDENTIFIER_SHOULD_BE_A_NON_EMPTY_STRING' })
|
|
14
|
+
}
|
|
15
|
+
if (typeof fetchImpl !== 'function') return null
|
|
12
16
|
const nip05 = decodeNip05Identifier(identifier)
|
|
13
|
-
if (!nip05) return null
|
|
14
17
|
const name = nip05.local
|
|
15
18
|
const domain = nip05.domain
|
|
16
19
|
|
|
@@ -26,11 +26,17 @@ function stripReferencePrefix (value) {
|
|
|
26
26
|
// Decodes a user reference without performing any network lookup.
|
|
27
27
|
// Returns `{ type: 'pubkey', pubkey, relays, raw }` for npub/nprofile/hex or
|
|
28
28
|
// `{ type: 'nip05', local, domain, raw }` for NIP-05 (standard or extended),
|
|
29
|
-
// where `raw` is always the most compact canonical spelling.
|
|
29
|
+
// where `raw` is always the most compact canonical spelling. Throws
|
|
30
|
+
// `ValidationError('INVALID_USER_REFERENCE')` when the value cannot be
|
|
31
|
+
// decoded; use `tryDecodeUserReference` when a null result is preferred.
|
|
30
32
|
export function decodeUserReference (value) {
|
|
31
|
-
if (typeof value !== 'string')
|
|
33
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
34
|
+
throw new ValidationError('INVALID_USER_REFERENCE', { message: 'USER_REFERENCE_SHOULD_BE_A_NON_EMPTY_STRING' })
|
|
35
|
+
}
|
|
32
36
|
const text = stripReferencePrefix(value)
|
|
33
|
-
if (!text)
|
|
37
|
+
if (!text) {
|
|
38
|
+
throw new ValidationError('INVALID_USER_REFERENCE', { message: 'EMPTY_USER_REFERENCE' })
|
|
39
|
+
}
|
|
34
40
|
|
|
35
41
|
if (HEX_PUBKEY.test(text)) {
|
|
36
42
|
const raw = text.toLowerCase()
|
|
@@ -41,8 +47,8 @@ export function decodeUserReference (value) {
|
|
|
41
47
|
try {
|
|
42
48
|
const raw = text.toLowerCase()
|
|
43
49
|
return { type: 'pubkey', pubkey: npubDecode(raw), relays: [], raw }
|
|
44
|
-
} catch {
|
|
45
|
-
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
throw new ValidationError('INVALID_USER_REFERENCE', { message: 'INVALID_NPUB', cause })
|
|
46
52
|
}
|
|
47
53
|
}
|
|
48
54
|
|
|
@@ -51,17 +57,32 @@ export function decodeUserReference (value) {
|
|
|
51
57
|
const raw = text.toLowerCase()
|
|
52
58
|
const { pubkey, relays } = nprofileDecode(raw)
|
|
53
59
|
return { type: 'pubkey', pubkey, relays, raw }
|
|
54
|
-
} catch {
|
|
55
|
-
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
throw new ValidationError('INVALID_USER_REFERENCE', { message: 'INVALID_NPROFILE', cause })
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
let nip05
|
|
66
|
+
try {
|
|
67
|
+
nip05 = decodeNip05Identifier(text)
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
throw new ValidationError('INVALID_USER_REFERENCE', { message: 'INVALID_NIP05', cause })
|
|
70
|
+
}
|
|
61
71
|
const raw = compactNip05Raw(nip05.local, nip05.domain)
|
|
62
72
|
return { type: 'nip05', ...nip05, raw }
|
|
63
73
|
}
|
|
64
74
|
|
|
75
|
+
// Non-throwing variant of `decodeUserReference`: returns the decoded
|
|
76
|
+
// reference or `null` when the value is not a valid user reference.
|
|
77
|
+
export function tryDecodeUserReference (value) {
|
|
78
|
+
try {
|
|
79
|
+
return decodeUserReference(value)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof ValidationError) return null
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
// Returns the canonical compact spelling for a user reference, either as a
|
|
66
87
|
// string or as a decoded reference object.
|
|
67
88
|
export function encodeUserReference (value) {
|
package/nip27/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ValidationError } from '../error/index.js'
|
|
1
2
|
import {
|
|
2
3
|
naddrDecode,
|
|
3
4
|
neventDecode,
|
|
@@ -8,10 +9,11 @@ import { queryProfile } from '../nip05/index.js'
|
|
|
8
9
|
import { normalizeRelayUrl } from '../url/index.js'
|
|
9
10
|
import {
|
|
10
11
|
decodeUserReference,
|
|
11
|
-
encodeUserReference
|
|
12
|
+
encodeUserReference,
|
|
13
|
+
tryDecodeUserReference
|
|
12
14
|
} from './helpers/user-reference.js'
|
|
13
15
|
|
|
14
|
-
export { decodeUserReference, encodeUserReference }
|
|
16
|
+
export { decodeUserReference, encodeUserReference, tryDecodeUserReference }
|
|
15
17
|
|
|
16
18
|
const BECH32_BODY = '[ac-hj-np-z02-9]'
|
|
17
19
|
const BOUNDARY_PREFIX = /(?<=^|[\s"«„「¡¿:{([])/.source
|
|
@@ -107,18 +109,30 @@ function normalizeRelays (relays) {
|
|
|
107
109
|
.filter(Boolean)
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
function looksLikeUserReference (text) {
|
|
113
|
+
return /^[0-9a-f]{64}$/i.test(text) ||
|
|
114
|
+
/^(?:npub1|nprofile1)/i.test(text) ||
|
|
115
|
+
text.includes('@') ||
|
|
116
|
+
/^[a-z0-9._-]+(?:\.[a-z0-9-]+)+$/i.test(text)
|
|
117
|
+
}
|
|
118
|
+
|
|
110
119
|
// Parses a single NIP-27-style reference (with optional `@`/`nostr:`
|
|
111
120
|
// prefixes): NIP-05 (including the custom compact forms), npub/nprofile/hex
|
|
112
|
-
// accounts, note/nevent/naddr events and nrelay relays.
|
|
121
|
+
// accounts, note/nevent/naddr events and nrelay relays. Throws
|
|
122
|
+
// `ValidationError` when the value cannot be decoded; use
|
|
123
|
+
// `tryDecodeReference` when a null result is preferred.
|
|
113
124
|
export function decodeReference (value) {
|
|
114
|
-
if (typeof value !== 'string')
|
|
125
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
126
|
+
throw new ValidationError('INVALID_REFERENCE', { message: 'REFERENCE_SHOULD_BE_A_NON_EMPTY_STRING' })
|
|
127
|
+
}
|
|
115
128
|
const original = value.trim()
|
|
116
|
-
if (!original) return null
|
|
117
129
|
const text = stripReferencePrefix(original)
|
|
118
|
-
if (!text)
|
|
130
|
+
if (!text) {
|
|
131
|
+
throw new ValidationError('INVALID_REFERENCE', { message: 'EMPTY_REFERENCE' })
|
|
132
|
+
}
|
|
119
133
|
|
|
120
|
-
|
|
121
|
-
|
|
134
|
+
if (looksLikeUserReference(text)) {
|
|
135
|
+
const account = decodeUserReference(text)
|
|
122
136
|
return account.type === 'pubkey'
|
|
123
137
|
? {
|
|
124
138
|
type: 'pubkey',
|
|
@@ -137,34 +151,29 @@ export function decodeReference (value) {
|
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
if (text.startsWith('note1')) {
|
|
140
|
-
|
|
141
|
-
return { type: 'note', original, value: text, id: noteDecode(text) }
|
|
142
|
-
} catch {
|
|
143
|
-
return null
|
|
144
|
-
}
|
|
154
|
+
return { type: 'note', original, value: text, id: noteDecode(text) }
|
|
145
155
|
}
|
|
146
156
|
if (text.startsWith('nevent1')) {
|
|
147
|
-
|
|
148
|
-
return { type: 'nevent', original, value: text, ...neventDecode(text) }
|
|
149
|
-
} catch {
|
|
150
|
-
return null
|
|
151
|
-
}
|
|
157
|
+
return { type: 'nevent', original, value: text, ...neventDecode(text) }
|
|
152
158
|
}
|
|
153
159
|
if (text.startsWith('naddr1')) {
|
|
154
|
-
|
|
155
|
-
return { type: 'naddr', original, value: text, ...naddrDecode(text) }
|
|
156
|
-
} catch {
|
|
157
|
-
return null
|
|
158
|
-
}
|
|
160
|
+
return { type: 'naddr', original, value: text, ...naddrDecode(text) }
|
|
159
161
|
}
|
|
160
162
|
if (text.startsWith('nrelay1')) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
return { type: 'nrelay', original, value: text, relay: nrelayDecode(text) }
|
|
164
|
+
}
|
|
165
|
+
throw new ValidationError('INVALID_REFERENCE', { message: 'UNRECOGNIZED_REFERENCE' })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Non-throwing variant of `decodeReference`: returns the decoded reference
|
|
169
|
+
// or `null` when the value is not a valid reference.
|
|
170
|
+
export function tryDecodeReference (value) {
|
|
171
|
+
try {
|
|
172
|
+
return decodeReference(value)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error instanceof ValidationError) return null
|
|
175
|
+
throw error
|
|
166
176
|
}
|
|
167
|
-
return null
|
|
168
177
|
}
|
|
169
178
|
|
|
170
179
|
const NIP94_TAGS = {
|
|
@@ -185,11 +194,33 @@ const NIP94_TAGS = {
|
|
|
185
194
|
caption: ['caption']
|
|
186
195
|
}
|
|
187
196
|
|
|
197
|
+
function validateTagConfigs (extraTags) {
|
|
198
|
+
if (!extraTags || typeof extraTags !== 'object' || Array.isArray(extraTags)) {
|
|
199
|
+
throw new ValidationError('INVALID_MEDIA_METADATA_TAGS', { message: 'EXTRA_TAGS_SHOULD_BE_AN_OBJECT' })
|
|
200
|
+
}
|
|
201
|
+
for (const [key, config] of Object.entries(extraTags)) {
|
|
202
|
+
const valid = Array.isArray(config) && config.length > 0 && config.every(entry =>
|
|
203
|
+
typeof entry === 'string' ||
|
|
204
|
+
(entry && typeof entry === 'object' && typeof entry.key === 'string' &&
|
|
205
|
+
(entry.type === undefined || entry.type === 'string' || entry.type === 'array'))
|
|
206
|
+
)
|
|
207
|
+
if (!valid) {
|
|
208
|
+
throw new ValidationError('INVALID_MEDIA_METADATA_TAGS', { message: `INVALID_TAG_CONFIG:${key}` })
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
188
213
|
// Decodes the file/media metadata carried in a URL fragment
|
|
189
214
|
// (`#m=image/png&dim=640x480&alt=...`). Kept generic on purpose: the old
|
|
190
|
-
// draft number (54) was taken by an unrelated NIP.
|
|
215
|
+
// draft number (54) was taken by an unrelated NIP. Throws
|
|
216
|
+
// `ValidationError` for invalid URLs, tag configs or malformed `dim` values;
|
|
217
|
+
// use `tryDecodeMediaMetadata` when a null result is preferred. A URL
|
|
218
|
+
// without a metadata fragment still decodes to `{}`.
|
|
191
219
|
export function decodeMediaMetadata (url, { extraTags } = {}) {
|
|
192
|
-
if (typeof url !== 'string' || !url)
|
|
220
|
+
if (typeof url !== 'string' || !url.trim()) {
|
|
221
|
+
throw new ValidationError('INVALID_MEDIA_METADATA_URL', { message: 'URL_SHOULD_BE_A_NON_EMPTY_STRING' })
|
|
222
|
+
}
|
|
223
|
+
if (extraTags !== undefined) validateTagConfigs(extraTags)
|
|
193
224
|
|
|
194
225
|
const tags = extraTags ? { ...NIP94_TAGS, ...extraTags } : NIP94_TAGS
|
|
195
226
|
const tagIndexes = {}
|
|
@@ -217,12 +248,26 @@ export function decodeMediaMetadata (url, { extraTags } = {}) {
|
|
|
217
248
|
const { width, height } = obj.dim.match(
|
|
218
249
|
/(?<width>[1-9]{1}[0-9]{0,10})(?:\s*[xX]\s*)(?<height>[1-9]{1}[0-9]{0,10})/
|
|
219
250
|
)?.groups ?? {}
|
|
220
|
-
if (width
|
|
221
|
-
|
|
251
|
+
if (width === undefined || height === undefined) {
|
|
252
|
+
throw new ValidationError('INVALID_MEDIA_METADATA_DIM', { message: 'DIM_SHOULD_BE_WIDTHxHEIGHT' })
|
|
253
|
+
}
|
|
254
|
+
obj.width = width
|
|
255
|
+
obj.height = height
|
|
222
256
|
}
|
|
223
257
|
return obj
|
|
224
258
|
}
|
|
225
259
|
|
|
260
|
+
// Non-throwing variant of `decodeMediaMetadata`: returns the decoded
|
|
261
|
+
// metadata or `null` when the URL/tags/dim cannot be decoded.
|
|
262
|
+
export function tryDecodeMediaMetadata (url, options) {
|
|
263
|
+
try {
|
|
264
|
+
return decodeMediaMetadata(url, options)
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error instanceof ValidationError) return null
|
|
267
|
+
throw error
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
226
271
|
function decodeFragmentValue (value) {
|
|
227
272
|
try {
|
|
228
273
|
return decodeURIComponent(value.replace(/\+/g, '%20'))
|
|
@@ -234,7 +279,13 @@ function decodeFragmentValue (value) {
|
|
|
234
279
|
function getReferenceItem (original, groups, { getMimeType }) {
|
|
235
280
|
if (groups.url) {
|
|
236
281
|
const url = `${groups.protocol ? '' : 'https://'}${groups.url}`
|
|
237
|
-
|
|
282
|
+
let mediaMetadata = {}
|
|
283
|
+
try {
|
|
284
|
+
mediaMetadata = decodeMediaMetadata(url)
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (!(error instanceof ValidationError)) throw error
|
|
287
|
+
}
|
|
288
|
+
const urlItem = { value: url, ...(groups.ext && { ext: groups.ext }), ...mediaMetadata }
|
|
238
289
|
if (!urlItem.m && typeof getMimeType === 'function') {
|
|
239
290
|
const mime = getMimeType({ url, ext: groups.ext })
|
|
240
291
|
if (mime) urlItem.m = mime
|
|
@@ -249,7 +300,7 @@ function getReferenceItem (original, groups, { getMimeType }) {
|
|
|
249
300
|
groups.nip05BareRoot ||
|
|
250
301
|
groups.nip05BareCustom
|
|
251
302
|
) {
|
|
252
|
-
const account =
|
|
303
|
+
const account = tryDecodeUserReference(original)
|
|
253
304
|
if (!account) return null
|
|
254
305
|
return {
|
|
255
306
|
key: 'nip05',
|
|
@@ -266,7 +317,13 @@ function getReferenceItem (original, groups, { getMimeType }) {
|
|
|
266
317
|
return { key: 'hashtag', hashtag: { value: groups.hashtag } }
|
|
267
318
|
}
|
|
268
319
|
|
|
269
|
-
|
|
320
|
+
let ref
|
|
321
|
+
try {
|
|
322
|
+
ref = decodeReference(original)
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (!(error instanceof ValidationError)) throw error
|
|
325
|
+
return null
|
|
326
|
+
}
|
|
270
327
|
if (!ref) return null
|
|
271
328
|
switch (ref.type) {
|
|
272
329
|
case 'pubkey': {
|
|
@@ -320,7 +377,9 @@ function getReferenceItem (original, groups, { getMimeType }) {
|
|
|
320
377
|
// `{ bareNip05: true }` is passed, since they are otherwise indistinguishable
|
|
321
378
|
// from plain hostnames; prefixed forms (`@bob.example.com`) always work.
|
|
322
379
|
export function extractMedia (content, { bareNip05 = false, getMimeType } = {}) {
|
|
323
|
-
if (typeof content !== 'string')
|
|
380
|
+
if (typeof content !== 'string') {
|
|
381
|
+
throw new ValidationError('INVALID_MEDIA_CONTENT', { message: 'CONTENT_SHOULD_BE_A_STRING' })
|
|
382
|
+
}
|
|
324
383
|
const regex = getReferencesRegex(bareNip05)
|
|
325
384
|
const items = []
|
|
326
385
|
let end = 0
|
|
@@ -349,7 +408,6 @@ export function extractMedia (content, { bareNip05 = false, getMimeType } = {})
|
|
|
349
408
|
// spellings); npub/nprofile/hex are resolved locally.
|
|
350
409
|
export async function resolveUserReference (value, options = {}) {
|
|
351
410
|
const account = decodeUserReference(value)
|
|
352
|
-
if (!account) return null
|
|
353
411
|
if (account.type === 'pubkey') {
|
|
354
412
|
return { pubkey: account.pubkey, relays: account.relays, label: account.raw }
|
|
355
413
|
}
|
package/package.json
CHANGED
package/relay/services/events.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ValidationError } from '../../error/index.js'
|
|
1
2
|
import { freeRelays } from '../constants/index.js'
|
|
2
3
|
import { pickRelaysForPubkeys } from '../helpers/routing.js'
|
|
3
4
|
import { relayPool } from './relay-pool.js'
|
|
@@ -99,7 +100,9 @@ export async function getLatestEventsByPubkey (pubkeys, {
|
|
|
99
100
|
} = {}) {
|
|
100
101
|
const authors = [...new Set(pubkeys || [])].filter(Boolean)
|
|
101
102
|
if (!authors.length) return { events: [], byPubkey: {}, relaysByPubkey: {} }
|
|
102
|
-
if (!Array.isArray(kinds) || kinds.length === 0)
|
|
103
|
+
if (!Array.isArray(kinds) || kinds.length === 0) {
|
|
104
|
+
throw new ValidationError('MISSING_EVENT_KINDS', { message: 'Missing kinds' })
|
|
105
|
+
}
|
|
103
106
|
const type = relayType === 'read' ? 'read' : 'write'
|
|
104
107
|
|
|
105
108
|
const relaysByAuthor = { ...(relaysByPubkey || {}) }
|
package/url/app-url.js
CHANGED
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
} from '../nip05/helpers/nip05-identifier.js'
|
|
6
6
|
import {
|
|
7
7
|
decodeUserReference,
|
|
8
|
-
encodeUserReference
|
|
8
|
+
encodeUserReference,
|
|
9
|
+
tryDecodeUserReference
|
|
9
10
|
} from '../nip27/helpers/user-reference.js'
|
|
10
11
|
import {
|
|
11
12
|
NAPP_ENTITY_REGEX,
|
|
@@ -55,8 +56,10 @@ function isValidAppName (appName) {
|
|
|
55
56
|
// `naddr` already carries the event kind, so it does not need the `+`/`++`/
|
|
56
57
|
// `+++` channel prefix (a leading prefix is still tolerated). Only site
|
|
57
58
|
// manifests are app URLs; the result is canonicalized to the `appEncode`
|
|
58
|
-
// entity so downstream code keeps working unchanged.
|
|
59
|
-
|
|
59
|
+
// entity so downstream code keeps working unchanged. Throws
|
|
60
|
+
// `ValidationError('INVALID_APP_URL_NADDR')` for malformed naddr or
|
|
61
|
+
// naddr that is not a site manifest.
|
|
62
|
+
function decodeNaddrSegment (value) {
|
|
60
63
|
if (typeof value !== 'string' || !value) return null
|
|
61
64
|
const body = value.replace(/^\+{1,3}/, '')
|
|
62
65
|
if (!body.startsWith(NADDR_PREFIX)) return null
|
|
@@ -64,10 +67,12 @@ function tryDecodeNaddr (value) {
|
|
|
64
67
|
let decoded
|
|
65
68
|
try {
|
|
66
69
|
decoded = naddrDecode(body)
|
|
67
|
-
} catch {
|
|
68
|
-
|
|
70
|
+
} catch (cause) {
|
|
71
|
+
throw new ValidationError('INVALID_APP_URL_NADDR', { message: 'INVALID_NADDR', cause })
|
|
72
|
+
}
|
|
73
|
+
if (!SITE_MANIFEST_KINDS.has(decoded.kind)) {
|
|
74
|
+
throw new ValidationError('INVALID_APP_URL_NADDR', { message: 'NOT_SITE_MANIFEST' })
|
|
69
75
|
}
|
|
70
|
-
if (!SITE_MANIFEST_KINDS.has(decoded.kind)) return null
|
|
71
76
|
|
|
72
77
|
try {
|
|
73
78
|
return {
|
|
@@ -79,8 +84,8 @@ function tryDecodeNaddr (value) {
|
|
|
79
84
|
relays: decoded.relays
|
|
80
85
|
})
|
|
81
86
|
}
|
|
82
|
-
} catch {
|
|
83
|
-
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
throw new ValidationError('INVALID_APP_URL_NADDR', { message: 'INVALID_APP_ENTITY', cause })
|
|
84
89
|
}
|
|
85
90
|
}
|
|
86
91
|
|
|
@@ -90,27 +95,34 @@ function tryDecodeNaddr (value) {
|
|
|
90
95
|
// - `{ type: 'entity', entity }` for NIP-19 app entities;
|
|
91
96
|
// - `{ type: 'named', prefix, channel, appName, user }` for named URLs
|
|
92
97
|
// (`user` is null when no user part is present or it is invalid);
|
|
93
|
-
//
|
|
98
|
+
// Throws `ValidationError` when the segment is not a valid app URL; use
|
|
99
|
+
// `tryDecodeAppUrl` when a null result is preferred.
|
|
94
100
|
export function decodeAppUrl (segment) {
|
|
95
|
-
if (typeof segment !== 'string' || !segment)
|
|
96
|
-
|
|
101
|
+
if (typeof segment !== 'string' || !segment) {
|
|
102
|
+
throw new ValidationError('INVALID_APP_URL', { message: 'URL_SEGMENT_SHOULD_BE_A_NON_EMPTY_STRING' })
|
|
103
|
+
}
|
|
104
|
+
const decodedNaddr = decodeNaddrSegment(segment)
|
|
97
105
|
if (decodedNaddr) return decodedNaddr
|
|
98
106
|
|
|
99
107
|
const prefixMatch = segment.match(/^\+{1,3}/)
|
|
100
|
-
if (!prefixMatch)
|
|
108
|
+
if (!prefixMatch) {
|
|
109
|
+
throw new ValidationError('INVALID_APP_URL', { message: 'MISSING_APP_URL_PREFIX' })
|
|
110
|
+
}
|
|
101
111
|
const prefix = prefixMatch[0]
|
|
102
112
|
|
|
103
113
|
if (NAPP_ENTITY_REGEX.test(segment)) {
|
|
104
114
|
try {
|
|
105
115
|
appDecode(segment)
|
|
106
|
-
} catch {
|
|
107
|
-
|
|
116
|
+
} catch (cause) {
|
|
117
|
+
throw new ValidationError('INVALID_APP_URL_ENTITY', { message: 'INVALID_APP_ENTITY', cause })
|
|
108
118
|
}
|
|
109
119
|
return { type: 'entity', entity: segment }
|
|
110
120
|
}
|
|
111
121
|
|
|
112
122
|
const remainder = segment.slice(prefix.length)
|
|
113
|
-
if (!remainder)
|
|
123
|
+
if (!remainder) {
|
|
124
|
+
throw new ValidationError('INVALID_APP_URL', { message: 'MISSING_APP_NAME' })
|
|
125
|
+
}
|
|
114
126
|
const parts = remainder.split('@')
|
|
115
127
|
let appName
|
|
116
128
|
let user = null
|
|
@@ -120,7 +132,7 @@ export function decodeAppUrl (segment) {
|
|
|
120
132
|
} else if (parts.length === 2) {
|
|
121
133
|
const tail = safeDecode(parts[1])
|
|
122
134
|
appName = safeDecode(parts[0])
|
|
123
|
-
user = tail === null ? null :
|
|
135
|
+
user = tail === null ? null : tryDecodeUserReference(tail)
|
|
124
136
|
} else {
|
|
125
137
|
const local = safeDecode(parts[parts.length - 2])
|
|
126
138
|
const domain = safeDecode(parts[parts.length - 1])
|
|
@@ -133,8 +145,12 @@ export function decodeAppUrl (segment) {
|
|
|
133
145
|
}
|
|
134
146
|
}
|
|
135
147
|
|
|
136
|
-
if (appName === null || !isValidAppName(appName))
|
|
137
|
-
|
|
148
|
+
if (appName === null || !isValidAppName(appName)) {
|
|
149
|
+
throw new ValidationError('INVALID_APP_URL_NAME', { message: 'Invalid app URL name' })
|
|
150
|
+
}
|
|
151
|
+
if (!user && appName.length >= APP_URL_MIN_ENTITY_BODY_LENGTH) {
|
|
152
|
+
throw new ValidationError('INVALID_APP_URL_ENTITY', { message: 'ENTITY_LIKE_URL_WITHOUT_USER' })
|
|
153
|
+
}
|
|
138
154
|
|
|
139
155
|
return {
|
|
140
156
|
type: 'named',
|
|
@@ -145,6 +161,17 @@ export function decodeAppUrl (segment) {
|
|
|
145
161
|
}
|
|
146
162
|
}
|
|
147
163
|
|
|
164
|
+
// Non-throwing variant of `decodeAppUrl`: returns the decoded app URL or
|
|
165
|
+
// `null` when the segment is not a valid app URL.
|
|
166
|
+
export function tryDecodeAppUrl (segment) {
|
|
167
|
+
try {
|
|
168
|
+
return decodeAppUrl(segment)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof ValidationError) return null
|
|
171
|
+
throw error
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
148
175
|
// Encodes a named app URL segment. `user` is a decoded user reference:
|
|
149
176
|
// NIP-05 (`bob@example.com`, `_@example.com`, `example.com` or the custom
|
|
150
177
|
// `bob.xyz.example.com` form), npub, nprofile or hex pubkey.
|
|
@@ -156,7 +183,15 @@ export function encodeAppUrl ({ appName, channel = 'main', user }) {
|
|
|
156
183
|
if (!prefix) {
|
|
157
184
|
throw new ValidationError('INVALID_APP_URL_CHANNEL', { message: 'Invalid app URL channel' })
|
|
158
185
|
}
|
|
159
|
-
|
|
186
|
+
let userRef
|
|
187
|
+
try {
|
|
188
|
+
userRef = decodeUserReference(user)
|
|
189
|
+
} catch (cause) {
|
|
190
|
+
if (cause instanceof ValidationError) {
|
|
191
|
+
throw new ValidationError('INVALID_APP_URL_USER', { message: cause.message ?? 'Invalid app URL user', cause })
|
|
192
|
+
}
|
|
193
|
+
throw cause
|
|
194
|
+
}
|
|
160
195
|
if (!userRef) {
|
|
161
196
|
throw new ValidationError('INVALID_APP_URL_USER', { message: 'Invalid app URL user' })
|
|
162
197
|
}
|