libp2r2p 0.11.16 → 0.11.18
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 +15 -1
- package/package.json +1 -1
- package/private-messenger/helpers/fallback-race.js +134 -0
- package/private-messenger/helpers/pause-recovery.js +80 -0
- package/private-messenger/helpers/send-routing.js +31 -5
- package/private-messenger/index.js +219 -47
- package/private-messenger/session/README.md +60 -9
- package/private-messenger/session/index.js +136 -32
- package/relay/helpers/publish.js +5 -1
- package/relay/services/relay-connection.js +31 -18
- package/relay/services/relay-pool.js +11 -4
package/README.md
CHANGED
|
@@ -1056,7 +1056,7 @@ in pairs per recipient when
|
|
|
1056
1056
|
selected relays reject publication or become unreachable while connectivity is
|
|
1057
1057
|
confirmed. The exact encrypted outer events are reused. Optional `fallbackRelays` on
|
|
1058
1058
|
`createPrivateMessenger` and `createPrivateMessageSession` are tried after the
|
|
1059
|
-
primary destinations, including explicit per-call `relays`, channel `sendRelays`
|
|
1059
|
+
primary destinations by default, including explicit per-call `relays`, channel `sendRelays`
|
|
1060
1060
|
and channel/global `relays`, as well as explicit `relayToReceivers` maps.
|
|
1061
1061
|
Retries preserve each encrypted recipient subset; success requires accepted
|
|
1062
1062
|
publication covering every recipient in that subset. Receive routing adds them from the start for live
|
|
@@ -1126,3 +1126,17 @@ are unchanged; newer live arrivals cannot erase a pending interval.
|
|
|
1126
1126
|
count/bytes, incoming bytes, limits and oldest queued age. Queue bytes are UTF-8
|
|
1127
1127
|
serialized volume, not heap use. Diagnostics never retain event contents or
|
|
1128
1128
|
filters. Existing 1,000-item / 8-MiB live limits are unchanged.
|
|
1129
|
+
|
|
1130
|
+
Private messenger status (`readStatus`, `onStateChanged`) exposes pause reasons
|
|
1131
|
+
without events or credentials. Sessions recover owned network/storage failures
|
|
1132
|
+
and wake pending sends on release rather than repeatedly publishing while paused.
|
|
1133
|
+
Custom session factories must expose `readStatus()` and deliver the initial and
|
|
1134
|
+
subsequent `onStateChanged` snapshots; missing observation is rejected instead of
|
|
1135
|
+
using polling compatibility. See [session lifecycle](private-messenger/session/README.md).
|
|
1136
|
+
|
|
1137
|
+
Private messenger/session constructors accept optional `fallbackDelayMs` (`null`
|
|
1138
|
+
by default). Automatic NIP-65 sends can start configured fallback relays after
|
|
1139
|
+
the chosen delay while primary publications retain their deadlines. Explicit
|
|
1140
|
+
routes keep sequential fallback; every encrypted recipient must be covered.
|
|
1141
|
+
`RelayPool.sendEvent` accepts a consumer `signal` without closing shared sockets.
|
|
1142
|
+
See [fallback and cancellation](private-messenger/session/README.md#relay-fallback).
|
package/package.json
CHANGED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
|
|
2
|
+
|
|
3
|
+
// Each invocation owns one already signed outer event and its encrypted subset.
|
|
4
|
+
// Lanes share coverage and the tried set; only started reports outlive the race.
|
|
5
|
+
export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, now, setTimer, clearTimer }) {
|
|
6
|
+
const pending = new Set(receivers)
|
|
7
|
+
const summaries = []
|
|
8
|
+
const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
|
|
9
|
+
const outcome = Promise.withResolvers()
|
|
10
|
+
let primaryRunning = true
|
|
11
|
+
let fallbackRunning = false
|
|
12
|
+
let fallbackScheduled = true
|
|
13
|
+
let done = false
|
|
14
|
+
let blocked = false
|
|
15
|
+
let offline = false
|
|
16
|
+
let timer = null
|
|
17
|
+
let onlineWork
|
|
18
|
+
const current = () => !transportSignal?.aborted && isCurrent()
|
|
19
|
+
const summary = success => Promise.all(summaries).then(reports => ({
|
|
20
|
+
success, total: tried.size,
|
|
21
|
+
fulfilled: reports.reduce((total, report) => total + (report.fulfilled || 0), 0),
|
|
22
|
+
succeededRelays: [...new Set(reports.flatMap(report => report.succeededRelays || []))],
|
|
23
|
+
errors: reports.flatMap(report => report.errors || [])
|
|
24
|
+
}))
|
|
25
|
+
const finish = success => {
|
|
26
|
+
if (done) return
|
|
27
|
+
done = true
|
|
28
|
+
clearTimer(timer); timer = null
|
|
29
|
+
transportSignal?.removeEventListener('abort', interrupted)
|
|
30
|
+
const interruptedWork = !success && (offline || !current())
|
|
31
|
+
outcome.resolve({
|
|
32
|
+
success, total: tried.size, promise: summary(success),
|
|
33
|
+
...(interruptedWork && !signal?.aborted ? { retryWhenAvailable: true, ...(offline ? { retryWhenOnline: true } : {}) } : {})
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
const check = () => {
|
|
37
|
+
if (done) return
|
|
38
|
+
if (!pending.size) finish(true)
|
|
39
|
+
else if (!primaryRunning && !fallbackRunning && !fallbackScheduled) finish(false)
|
|
40
|
+
}
|
|
41
|
+
const interrupted = () => finish(false)
|
|
42
|
+
const online = async () => {
|
|
43
|
+
if (done || !current()) return false
|
|
44
|
+
try {
|
|
45
|
+
if (!onlineWork) {
|
|
46
|
+
const work = Promise.resolve().then(() => isOnline({ signal: transportSignal })).finally(() => { if (onlineWork === work) onlineWork = null })
|
|
47
|
+
onlineWork = work
|
|
48
|
+
}
|
|
49
|
+
const connected = await onlineWork
|
|
50
|
+
if (done || !current()) return false
|
|
51
|
+
if (!connected) offline = true
|
|
52
|
+
return connected
|
|
53
|
+
} catch { if (!done && current()) offline = true; return false }
|
|
54
|
+
}
|
|
55
|
+
const launch = async (batch, extra = []) => {
|
|
56
|
+
const relays = [...new Set([...batch.relays, ...extra])].filter(relay => !tried.has(relay))
|
|
57
|
+
if (!relays.length) return
|
|
58
|
+
for (const relay of relays) tried.add(relay)
|
|
59
|
+
const slot = Promise.withResolvers()
|
|
60
|
+
summaries.push(slot.promise)
|
|
61
|
+
let result
|
|
62
|
+
try { result = await sendEvent(event, relays, { signal: transportSignal }) } catch (reason) {
|
|
63
|
+
result = { success: false, promise: Promise.resolve({ fulfilled: 0, errors: [{ reason }] }) }
|
|
64
|
+
}
|
|
65
|
+
const settled = Promise.resolve(result.promise).catch(reason => ({ fulfilled: 0, errors: [{ reason }] }))
|
|
66
|
+
slot.resolve(settled)
|
|
67
|
+
settled.then(async report => {
|
|
68
|
+
const failed = (report.errors || []).filter(item => isReplaceableRelayFailure(item.reason))
|
|
69
|
+
// Late redundant failures can inform later sends after this race wins.
|
|
70
|
+
if (failed.length && (result.success || (done && !pending.size)) && !transportSignal?.aborted && isCurrent()) {
|
|
71
|
+
try { if (await isOnline({ signal: transportSignal }) && isCurrent()) remember(failed) } catch {}
|
|
72
|
+
}
|
|
73
|
+
}).catch(() => {})
|
|
74
|
+
if (result.success) {
|
|
75
|
+
if (!done) for (const receiver of batch.receivers) pending.delete(receiver)
|
|
76
|
+
check()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
const report = await settled
|
|
80
|
+
if (done) return
|
|
81
|
+
const errors = report.errors || []
|
|
82
|
+
if (!errors.length || errors.some(item => !isReplaceableRelayFailure(item.reason))) blocked = true
|
|
83
|
+
else if (await online()) remember(errors)
|
|
84
|
+
}
|
|
85
|
+
async function fallback () {
|
|
86
|
+
if (done || fallbackRunning || !fallbackScheduled) return
|
|
87
|
+
clearTimer(timer)
|
|
88
|
+
fallbackScheduled = false
|
|
89
|
+
fallbackRunning = true
|
|
90
|
+
try {
|
|
91
|
+
if (blocked || offline || !pending.size || !current()) return
|
|
92
|
+
let batch = nextFallback(pending)
|
|
93
|
+
if (!batch || !await online()) return
|
|
94
|
+
while (batch) {
|
|
95
|
+
if (done || blocked || offline || !current()) break
|
|
96
|
+
await launch(batch)
|
|
97
|
+
batch = nextFallback(pending)
|
|
98
|
+
}
|
|
99
|
+
} finally { fallbackRunning = false; check() }
|
|
100
|
+
}
|
|
101
|
+
async function primary () {
|
|
102
|
+
try {
|
|
103
|
+
let batch = first || nextPrimary(pending)
|
|
104
|
+
let opening = true
|
|
105
|
+
while (batch) {
|
|
106
|
+
if (done || blocked || offline || !current()) break
|
|
107
|
+
await launch(batch, opening ? mirrors : [])
|
|
108
|
+
opening = false
|
|
109
|
+
batch = nextPrimary(pending)
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
primaryRunning = false
|
|
113
|
+
// Exhaustion before the deadline starts the fallback immediately.
|
|
114
|
+
if (!done && fallbackScheduled) {
|
|
115
|
+
if (!blocked && !offline && current()) await fallback()
|
|
116
|
+
else { clearTimer(timer); fallbackScheduled = false }
|
|
117
|
+
}
|
|
118
|
+
check()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (transportSignal?.aborted || !isCurrent()) { finish(false); return outcome.promise }
|
|
122
|
+
transportSignal?.addEventListener('abort', interrupted, { once: true })
|
|
123
|
+
const deadline = now() + delay
|
|
124
|
+
const failed = reason => {
|
|
125
|
+
if (done) return
|
|
126
|
+
done = true; clearTimer(timer); timer = null
|
|
127
|
+
transportSignal?.removeEventListener('abort', interrupted)
|
|
128
|
+
outcome.reject(reason)
|
|
129
|
+
}
|
|
130
|
+
timer = setTimer(() => { fallback().catch(failed) }, Math.max(0, deadline - now()))
|
|
131
|
+
timer?.unref?.()
|
|
132
|
+
primary().catch(failed)
|
|
133
|
+
return outcome.promise
|
|
134
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Local persistence is not a relay failure. Retry operational storage failures,
|
|
2
|
+
// but leave validation/permission failures to their owner or an explicit retry.
|
|
3
|
+
export const isRecoverableStorageFailure = error => error != null && typeof error === 'object' && error?.name !== 'ValidationError' && error?.category !== 'validation' &&
|
|
4
|
+
!['SecurityError', 'NotAllowedError', 'TypeError', 'RangeError', 'SyntaxError', 'DataError', 'DataCloneError'].includes(error?.name) &&
|
|
5
|
+
!/DENIED|PERMISSION|REVOKED|READ_ONLY|INVALID|NOT_IN_PERSONA/.test(`${error?.code || ''} ${error?.name || ''} ${error?.message || ''}`)
|
|
6
|
+
|
|
7
|
+
// One cancellable job per cause. Waiting offline does not spend a backoff step.
|
|
8
|
+
export function createPauseRecovery ({ attempt, online, onOnline, retryable, onError, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout, random = Math.random }) {
|
|
9
|
+
let current
|
|
10
|
+
function stop () {
|
|
11
|
+
const job = current
|
|
12
|
+
current = null
|
|
13
|
+
if (!job) return
|
|
14
|
+
job.controller.abort()
|
|
15
|
+
if (job.timer != null) clearTimer(job.timer)
|
|
16
|
+
job.stopOnline?.()
|
|
17
|
+
}
|
|
18
|
+
function start ({ immediate = false, retryAt = 0 } = {}) {
|
|
19
|
+
const advice = Number.isFinite(retryAt) && retryAt > 0 ? retryAt : 0
|
|
20
|
+
if (current) {
|
|
21
|
+
if (advice > current.retryAt) { current.retryAt = advice; current.wake?.() }
|
|
22
|
+
return immediate ? current.retry?.() : current.work
|
|
23
|
+
}
|
|
24
|
+
const job = { controller: new AbortController(), delay: 1000, retryAt: advice, timer: null, nextAt: 0, wake: null, retry: null, stopOnline: null, work: null }
|
|
25
|
+
current = job
|
|
26
|
+
const active = () => current === job && !job.controller.signal.aborted
|
|
27
|
+
const schedule = delay => {
|
|
28
|
+
if (!active()) return
|
|
29
|
+
job.nextAt = Math.max(now() + delay, job.retryAt)
|
|
30
|
+
job.timer = setTimer(() => { job.timer = null; return run() }, Math.max(job.nextAt - now(), 0))
|
|
31
|
+
job.timer?.unref?.()
|
|
32
|
+
}
|
|
33
|
+
const run = () => {
|
|
34
|
+
if (!active() || job.work) return job.work
|
|
35
|
+
const work = (async () => {
|
|
36
|
+
if (online && !await online({ signal: job.controller.signal })) {
|
|
37
|
+
if (!active()) return
|
|
38
|
+
job.stopOnline = onOnline(() => {
|
|
39
|
+
if (!active()) return
|
|
40
|
+
job.stopOnline?.(); job.stopOnline = null
|
|
41
|
+
schedule(0)
|
|
42
|
+
})
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
if (!active()) return
|
|
46
|
+
try {
|
|
47
|
+
await attempt(job.controller.signal)
|
|
48
|
+
if (active()) stop()
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (!active()) return
|
|
51
|
+
onError(error)
|
|
52
|
+
if (!retryable(error)) { stop(); return }
|
|
53
|
+
job.retryAt = Math.max(job.retryAt, Number.isFinite(error?.retryAt) ? error.retryAt : 0)
|
|
54
|
+
schedule(Math.min(30000, job.delay * (0.8 + random() * 0.4)))
|
|
55
|
+
job.delay = Math.min(30000, job.delay * 2)
|
|
56
|
+
}
|
|
57
|
+
})().catch(error => { if (active()) { onError(error); stop() } }).finally(() => { if (job.work === work) job.work = null })
|
|
58
|
+
job.work = work
|
|
59
|
+
return work
|
|
60
|
+
}
|
|
61
|
+
job.wake = () => {
|
|
62
|
+
if (!active() || job.work) return job.work
|
|
63
|
+
if (job.timer != null) clearTimer(job.timer)
|
|
64
|
+
job.timer = null
|
|
65
|
+
if (job.nextAt > now()) { schedule(job.nextAt - now()); return }
|
|
66
|
+
return run()
|
|
67
|
+
}
|
|
68
|
+
job.retry = () => {
|
|
69
|
+
if (!active() || job.work) return job.work
|
|
70
|
+
if (job.timer != null) clearTimer(job.timer)
|
|
71
|
+
job.timer = null
|
|
72
|
+
if (job.retryAt > now()) { schedule(job.retryAt - now()); return }
|
|
73
|
+
return run()
|
|
74
|
+
}
|
|
75
|
+
if (immediate) return run()
|
|
76
|
+
schedule(1000)
|
|
77
|
+
job.delay = 2000
|
|
78
|
+
}
|
|
79
|
+
return { start, stop, wake: () => current?.wake?.() }
|
|
80
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { normalizeRelayUrl } from '../../url/index.js'
|
|
2
2
|
import { ValidationError } from '../../error/index.js'
|
|
3
|
+
import { publishWithEarlyFallback } from './fallback-race.js'
|
|
3
4
|
import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
|
|
4
5
|
|
|
5
6
|
const EXCLUSION_MS = 5 * 60 * 1000
|
|
@@ -12,6 +13,11 @@ export function normalizeFallbackRelays (value = []) {
|
|
|
12
13
|
return [...new Set(Array.from(value, normalizeRelayUrl))]
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
export function normalizeFallbackDelay (value = null) {
|
|
17
|
+
if (value !== null && (!Number.isSafeInteger(value) || value < 0 || value > 2147483647)) throw new ValidationError('INVALID_FALLBACK_DELAY')
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
function routeEntries (routes) {
|
|
16
22
|
return routes instanceof Map ? [...routes] : Object.entries(routes || {})
|
|
17
23
|
}
|
|
@@ -26,9 +32,11 @@ function routeGroups (routes) {
|
|
|
26
32
|
return [...groups.values()]
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, now = Date.now }) {
|
|
35
|
+
export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
30
36
|
// Keep discovery's defaults when NIP-65 is unavailable. Explicit maps/lists
|
|
31
37
|
// retain their fanout; configured fallbacks are a separate last-resort stage.
|
|
38
|
+
fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
39
|
+
const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
|
|
32
40
|
const explicit = Boolean(primaryRelays || primaryRelayToReceivers)
|
|
33
41
|
const primaryRoutes = primaryRelayToReceivers || (primaryRelays
|
|
34
42
|
? new Map(primaryRelays.map(relay => [relay, peers]))
|
|
@@ -61,12 +69,12 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
|
|
|
61
69
|
return routes
|
|
62
70
|
}
|
|
63
71
|
const relayToReceivers = primaryRelayToReceivers || select(peers, exclusions.keys())
|
|
64
|
-
const current = () => !
|
|
72
|
+
const current = () => !transportSignal?.aborted && isCurrent()
|
|
65
73
|
const rejected = report => (report?.errors || []).filter(item => candidateSet.has(normalized(item.relay)) && isReplaceableRelayFailure(item.reason))
|
|
66
|
-
const remember = errors => { for (const item of errors) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
|
|
74
|
+
const remember = errors => { for (const item of errors) if (candidateSet.has(normalized(item.relay))) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
|
|
67
75
|
const online = async () => {
|
|
68
76
|
if (!current()) return false
|
|
69
|
-
try { return await isOnline({ signal }) && current() } catch { return false }
|
|
77
|
+
try { return await isOnline({ signal: transportSignal }) && current() } catch { return false }
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
async function send (event, initialRelays, context = {}) {
|
|
@@ -77,6 +85,24 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
|
|
|
77
85
|
const mirrors = initialRelays.filter(relay => !candidateSet.has(normalized(relay)))
|
|
78
86
|
const initialPrimary = context.primaryRelays || (primaryRelays || peers.length === 1 ? routeEntries(relayToReceivers).map(([relay]) => relay) : [])
|
|
79
87
|
const first = [...new Set(initialPrimary.map(normalizeRelayUrl))].filter(relay => !exclusions.has(relay))
|
|
88
|
+
if (!explicit && fallbackDelayMs !== null && fallbackRelays.length) {
|
|
89
|
+
const tried = new Set()
|
|
90
|
+
const pick = (pending, fallback) => {
|
|
91
|
+
const excluded = [...new Set([...exclusions.keys(), ...tried])]
|
|
92
|
+
const routes = pickRelays([...pending], fallback
|
|
93
|
+
? Object.fromEntries([...pending].map(pubkey => [pubkey, { read: fallbackRelays }]))
|
|
94
|
+
: primaryByPeer, { relayType: 'read', maxPerPubkey: 2, excludeRelaysByPubkey: new Map([...pending].map(pubkey => [pubkey, excluded])), emptyRelaysFallback: [] })
|
|
95
|
+
return routeGroups(routes)[0]
|
|
96
|
+
}
|
|
97
|
+
const result = await publishWithEarlyFallback({
|
|
98
|
+
event, receivers, first: first.length ? { receivers, relays: first } : null, mirrors, tried,
|
|
99
|
+
nextPrimary: pending => pick(pending, false), nextFallback: pending => pick(pending, true),
|
|
100
|
+
sendEvent, isOnline, isCurrent, signal, pauseSignal, remember,
|
|
101
|
+
delay: fallbackDelayMs, now, setTimer, clearTimer
|
|
102
|
+
})
|
|
103
|
+
if (!result.success && current()) exclusions.clear()
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
80
106
|
let batch = first.length ? { receivers, relays: first } : null
|
|
81
107
|
const tried = new Set()
|
|
82
108
|
const errors = []
|
|
@@ -106,7 +132,7 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
|
|
|
106
132
|
for (const relay of relays) tried.add(normalized(relay))
|
|
107
133
|
// Every relay in this batch serves the same pending recipient subset.
|
|
108
134
|
// One ACK covers that subset, never unrelated members of the ciphertext.
|
|
109
|
-
result = await sendEvent(event, relays)
|
|
135
|
+
result = await sendEvent(event, relays, { signal: transportSignal })
|
|
110
136
|
const settled = Promise.resolve(result.promise)
|
|
111
137
|
summaries.push(settled)
|
|
112
138
|
if (result.success) {
|