libp2r2p 0.11.17 → 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 +8 -1
- package/package.json +1 -1
- package/private-messenger/helpers/fallback-race.js +134 -0
- package/private-messenger/helpers/send-routing.js +31 -5
- package/private-messenger/index.js +8 -1
- package/private-messenger/session/README.md +25 -2
- package/private-messenger/session/index.js +4 -3
- 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
|
|
@@ -1133,3 +1133,10 @@ and wake pending sends on release rather than repeatedly publishing while paused
|
|
|
1133
1133
|
Custom session factories must expose `readStatus()` and deliver the initial and
|
|
1134
1134
|
subsequent `onStateChanged` snapshots; missing observation is rejected instead of
|
|
1135
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
|
+
}
|
|
@@ -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) {
|
|
@@ -48,7 +48,7 @@ import * as privateMessage from '../private-message/index.js'
|
|
|
48
48
|
import { isOnline, onOnline } from '../network/index.js'
|
|
49
49
|
import { isRetryableRelayFailure, isReplaceableRelayFailure } from '../relay/index.js'
|
|
50
50
|
import { createPauseRecovery, isRecoverableStorageFailure } from './helpers/pause-recovery.js'
|
|
51
|
-
import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
|
|
51
|
+
import { createSendRelayRouting, normalizeFallbackRelays, normalizeFallbackDelay } from './helpers/send-routing.js'
|
|
52
52
|
import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
|
|
53
53
|
import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
|
|
54
54
|
import { normalizeRelayUrl } from '../url/index.js'
|
|
@@ -212,6 +212,7 @@ export class PrivateMessenger {
|
|
|
212
212
|
|
|
213
213
|
constructor ({
|
|
214
214
|
fallbackRelays = [],
|
|
215
|
+
fallbackDelayMs = null,
|
|
215
216
|
offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
|
|
216
217
|
staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
|
|
217
218
|
identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
|
|
@@ -254,6 +255,7 @@ export class PrivateMessenger {
|
|
|
254
255
|
} = {}) {
|
|
255
256
|
if (onStateChanged != null && typeof onStateChanged !== 'function') throw new ValidationError('INVALID_ON_STATE_CHANGED')
|
|
256
257
|
this.fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
258
|
+
this.fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
257
259
|
this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
|
|
258
260
|
this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
|
|
259
261
|
this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
|
|
@@ -304,6 +306,7 @@ export class PrivateMessenger {
|
|
|
304
306
|
this._isOnline = _isOnline
|
|
305
307
|
this.sendRelayExclusions = new Map()
|
|
306
308
|
this.sendRoutingLifetime = new AbortController()
|
|
309
|
+
this.sendRoutingPause = new AbortController()
|
|
307
310
|
this._setTimeout = _setTimeout
|
|
308
311
|
this._clearTimeout = _clearTimeout
|
|
309
312
|
this._setInterval = _setInterval
|
|
@@ -841,6 +844,8 @@ export class PrivateMessenger {
|
|
|
841
844
|
peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
|
|
842
845
|
signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
|
|
843
846
|
exclusions: this.sendRelayExclusions.get(key).exclusions,
|
|
847
|
+
fallbackDelayMs: this.fallbackDelayMs, pauseSignal: this.sendRoutingPause.signal,
|
|
848
|
+
setTimer: this._setTimeout, clearTimer: this._clearTimeout,
|
|
844
849
|
pickRelays: this._pickRelaysForPubkeys,
|
|
845
850
|
publish: this._privateChannel.publish || privateChannel.publish,
|
|
846
851
|
publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
|
|
@@ -1780,6 +1785,7 @@ export class PrivateMessenger {
|
|
|
1780
1785
|
if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
|
|
1781
1786
|
this.assertOpen()
|
|
1782
1787
|
this.pauseReasons.add(reason)
|
|
1788
|
+
this.sendRoutingPause.abort(this.pausedError())
|
|
1783
1789
|
this.notifyStatus()
|
|
1784
1790
|
for (const extension of this.extensions) extension.pause?.()
|
|
1785
1791
|
this.recordInterruption(this.desiredChannels)
|
|
@@ -1797,6 +1803,7 @@ export class PrivateMessenger {
|
|
|
1797
1803
|
const wasAutomatic = this.automaticPauses.has(reason)
|
|
1798
1804
|
const removed = this.pauseReasons.delete(reason)
|
|
1799
1805
|
this.automaticPauses.delete(reason)
|
|
1806
|
+
if (!this.pauseReasons.size && this.sendRoutingPause.signal.aborted) this.sendRoutingPause = new AbortController()
|
|
1800
1807
|
this.notifyStatus()
|
|
1801
1808
|
if (reason === 'capacity' && removed) {
|
|
1802
1809
|
clearInterval(this.capacityTimer)
|
|
@@ -110,7 +110,7 @@ const session = createPrivateMessageSession({
|
|
|
110
110
|
})
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
By default, automatic sends try each recipient's NIP-65 read relays first, at most two per
|
|
114
114
|
recipient per attempt, then the configured fallbacks in pairs. Explicit
|
|
115
115
|
per-call `relays`, channel `sendRelays`/`relays` and global `relays` remain primary
|
|
116
116
|
and also support configured fallbacks, including fixed multi-recipient sends.
|
|
@@ -122,6 +122,19 @@ and encrypted recipient subsets, without consulting recipient NIP-65 lists.
|
|
|
122
122
|
The session coordinator remains a DM API; multi-recipient channels use
|
|
123
123
|
`createPrivateMessenger` directly.
|
|
124
124
|
|
|
125
|
+
Optional `fallbackDelayMs` defaults to `null` (sequential primary exhaustion).
|
|
126
|
+
A nonnegative integer up to 2147483647 opts automatic NIP-65 routing into early
|
|
127
|
+
fallback; explicit relay lists/maps retain the sequential policy. With `3000`,
|
|
128
|
+
start at most two eligible fallback relays after three seconds without complete
|
|
129
|
+
acceptance, or immediately after primary exhaustion. The deadline belongs to
|
|
130
|
+
one signed outer event; later batches do not renew it. Primary operations retain
|
|
131
|
+
their deadlines and can still win. Failed fallback operations leave primary
|
|
132
|
+
alternatives available; no relay is tried twice. Multiple recipients require
|
|
133
|
+
accepted coverage of every encrypted recipient subset. No wrapper, ID, recipient
|
|
134
|
+
tag or deletion capability is recreated. Confirmed connectivity, exclusions and
|
|
135
|
+
connection cooldowns remain authoritative; a waiting primary is not penalized
|
|
136
|
+
merely because fallback started. Late native failures may inform later sends.
|
|
137
|
+
|
|
125
138
|
On `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or `error`
|
|
126
139
|
rejections, or connection/transport/timeout failures, replacement requires
|
|
127
140
|
`isOnline` to confirm connectivity. Invalid messages, local signer/authentication
|
|
@@ -141,7 +154,8 @@ publication report. A shared relay's first ACK covers all members assigned to
|
|
|
141
154
|
that publication batch. If replacements use different relays for different
|
|
142
155
|
members, every pending member must be covered before the outer event succeeds;
|
|
143
156
|
a partial ACK cannot hide another member's failure. Remaining primary routes are
|
|
144
|
-
exhausted before configured fallbacks
|
|
157
|
+
exhausted before configured fallbacks with the default null delay or explicit
|
|
158
|
+
routes. Redundant acknowledgements may update
|
|
145
159
|
future routing preferences without delaying a successfully covered send. Preferences are channel/recipient-scoped, in-memory,
|
|
146
160
|
expire after five minutes, and never alter receive subscriptions. A fresh attempt
|
|
147
161
|
can recheck an exhausted list. Additional fallback relays come only from the
|
|
@@ -159,3 +173,12 @@ relays. Validation/authorization failures are terminal without relay rotation.
|
|
|
159
173
|
`broadcastRumor`/`broadcastEvent` accept a `signal` to stop further replacement
|
|
160
174
|
publications; the session supplies it and aborts it on cancel/close. An already
|
|
161
175
|
published event cannot be recalled by cancellation.
|
|
176
|
+
|
|
177
|
+
`RelayPool.sendEvent(event, relays, { signal })` supports optional cancellation.
|
|
178
|
+
It returns the existing result/report envelopes with the original abort reason
|
|
179
|
+
for unfinished relays, preserving partial acceptance and avoiding synthetic
|
|
180
|
+
timeouts. Cancellation releases publication waiters and deferred sends without
|
|
181
|
+
closing shared sockets or cancelling another subscriber for the same event ID.
|
|
182
|
+
Operations already transmitted cannot be recalled from relays. A messenger pause
|
|
183
|
+
cancels its outstanding publication work and leaves the outbox pending; resume
|
|
184
|
+
creates a fresh publication lifetime, without treating the pause as a user abort.
|
|
@@ -10,7 +10,7 @@ import { decodeFileMetadata } from '../../nip94/index.js'
|
|
|
10
10
|
import { createChatOutbox } from './helpers/work-storage.js'
|
|
11
11
|
import { messengerSigner } from './helpers/signer.js'
|
|
12
12
|
import { assertMessagePublished } from '../helpers/publication.js'
|
|
13
|
-
import { normalizeFallbackRelays } from '../helpers/send-routing.js'
|
|
13
|
+
import { normalizeFallbackRelays, normalizeFallbackDelay } from '../helpers/send-routing.js'
|
|
14
14
|
import { createPauseRecovery, isRecoverableStorageFailure } from '../helpers/pause-recovery.js'
|
|
15
15
|
|
|
16
16
|
export function wireEvent (value, owner) {
|
|
@@ -41,8 +41,9 @@ function messengerStatusSnapshot (value) {
|
|
|
41
41
|
return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
44
|
+
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
45
45
|
fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
46
|
+
fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
46
47
|
const reportedErrors = new WeakSet()
|
|
47
48
|
const reportError = error => {
|
|
48
49
|
if (error && (typeof error === 'object' || typeof error === 'function')) {
|
|
@@ -131,7 +132,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
131
132
|
let candidate
|
|
132
133
|
try {
|
|
133
134
|
candidate = await Messenger({
|
|
134
|
-
fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
|
|
135
|
+
fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
|
|
135
136
|
onMessageQueued: () => { if (messengerBinding === binding) return drain() },
|
|
136
137
|
onStateChanged: state => {
|
|
137
138
|
if (closed || messengerBinding !== binding) return
|
package/relay/helpers/publish.js
CHANGED
|
@@ -76,7 +76,11 @@ export function createPublishSettlements (promises, timeout, { onSettled } = {})
|
|
|
76
76
|
})
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
const cancel = reason => {
|
|
80
|
+
if (isFinished) return
|
|
81
|
+
for (let index = 0; index < settlements.length; index++) settle(index, { status: 'rejected', outcome: 'failed', reason })
|
|
82
|
+
}
|
|
83
|
+
return { promise, timeout: timeoutPending, cancel }
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
// Turns ordered relay settlements into a stable, caller-facing report. Failed
|
|
@@ -186,23 +186,33 @@ export class RelayConnection {
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
#sendEventOperation (type, event, map, timeoutCode, signal) {
|
|
189
|
-
if (signal?.aborted) return Promise.reject(signal.reason
|
|
190
|
-
|
|
189
|
+
if (signal?.aborted) return Promise.reject(signal.reason)
|
|
190
|
+
let pending = map.get(event.id)
|
|
191
|
+
const created = !pending
|
|
192
|
+
if (created) {
|
|
193
|
+
const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
|
|
194
|
+
pending = { timer, waiters: new Set(), cancelSend: null }
|
|
195
|
+
map.set(event.id, pending)
|
|
196
|
+
}
|
|
191
197
|
const deferred = Promise.withResolvers()
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
198
|
+
const onAbort = () => {
|
|
199
|
+
pending.waiters.delete(waiter)
|
|
200
|
+
signal.removeEventListener('abort', onAbort)
|
|
201
|
+
deferred.reject(signal.reason)
|
|
202
|
+
if (!pending.waiters.size && map.get(event.id) === pending) {
|
|
203
|
+
map.delete(event.id)
|
|
204
|
+
pending.cancelSend?.()
|
|
205
|
+
clearTimeout(pending.timer)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const waiter = { ...deferred, stopAbort: () => signal?.removeEventListener('abort', onAbort) }
|
|
209
|
+
pending.waiters.add(waiter)
|
|
197
210
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}, error => {
|
|
204
|
-
this.#settleEvent(map, event.id, error)
|
|
205
|
-
})
|
|
211
|
+
if (created) {
|
|
212
|
+
pending.cancelSend = this.#dispatchWork(() => {
|
|
213
|
+
this.send(JSON.stringify([type, event]))
|
|
214
|
+
}, error => this.#settleEvent(map, event.id, error))
|
|
215
|
+
}
|
|
206
216
|
return deferred.promise
|
|
207
217
|
}
|
|
208
218
|
|
|
@@ -225,10 +235,13 @@ export class RelayConnection {
|
|
|
225
235
|
if (!pending) return
|
|
226
236
|
map.delete(id)
|
|
227
237
|
pending.cancelSend?.()
|
|
228
|
-
pending.stopAbort?.()
|
|
229
238
|
clearTimeout(pending.timer)
|
|
230
|
-
|
|
231
|
-
|
|
239
|
+
for (const waiter of pending.waiters) {
|
|
240
|
+
waiter.stopAbort()
|
|
241
|
+
if (reason) waiter.reject(errorFrom(reason, 'OPERATION_REJECTED'))
|
|
242
|
+
else waiter.resolve(value)
|
|
243
|
+
}
|
|
244
|
+
pending.waiters.clear()
|
|
232
245
|
}
|
|
233
246
|
|
|
234
247
|
#settleCount (id, payload, reason) {
|
|
@@ -1204,8 +1204,10 @@ export class RelayPool {
|
|
|
1204
1204
|
timeout = SEND_TIMEOUT_MS,
|
|
1205
1205
|
timeoutUntilFirstFulfillment = null,
|
|
1206
1206
|
getAuthEvent,
|
|
1207
|
-
onRelayResult
|
|
1207
|
+
onRelayResult,
|
|
1208
|
+
signal
|
|
1208
1209
|
} = {}) {
|
|
1210
|
+
if (signal != null && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function')) throw new ValidationError('INVALID_RELAY_SIGNAL')
|
|
1209
1211
|
const urls = normalizedRelayUrls(relays)
|
|
1210
1212
|
if (!urls.length) {
|
|
1211
1213
|
const promise = Promise.resolve(publishSummary([], urls, {
|
|
@@ -1236,7 +1238,11 @@ export class RelayPool {
|
|
|
1236
1238
|
// Resolves after every relay settles (or reaches the operation timeout) as
|
|
1237
1239
|
// { success, total, fulfilled, succeededRelays, errors },
|
|
1238
1240
|
// where errors contains { relay, reason } entries for failed relays.
|
|
1241
|
+
const cancel = () => settlement.cancel(signal.reason)
|
|
1242
|
+
if (signal?.aborted) cancel()
|
|
1243
|
+
else signal?.addEventListener('abort', cancel, { once: true })
|
|
1239
1244
|
const promise = settlement.promise
|
|
1245
|
+
.finally(() => signal?.removeEventListener('abort', cancel))
|
|
1240
1246
|
.then(settlements => publishSummary(settlements, urls, {
|
|
1241
1247
|
includeSucceededRelays: true
|
|
1242
1248
|
}))
|
|
@@ -1245,10 +1251,11 @@ export class RelayPool {
|
|
|
1245
1251
|
const deferred = sendDeferreds[index]
|
|
1246
1252
|
;(async () => {
|
|
1247
1253
|
try {
|
|
1254
|
+
const operationSignal = sendControllers[index].signal
|
|
1255
|
+
if (operationSignal.aborted) throw operationSignal.reason
|
|
1248
1256
|
const relay = await this.#getRelay(url)
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
return await this.#publishEvent(relay, eventToSend, getAuthEvent, signal)
|
|
1257
|
+
if (operationSignal.aborted) throw operationSignal.reason
|
|
1258
|
+
return await this.#publishEvent(relay, eventToSend, getAuthEvent, operationSignal)
|
|
1252
1259
|
} catch (err) {
|
|
1253
1260
|
const reason = err instanceof Error ? err : new Error(String(err))
|
|
1254
1261
|
if (reason instanceof Nip42AuthenticationError) throw reason
|