libp2r2p 0.11.4 → 0.11.5
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 +9 -3
- package/package.json +1 -1
- package/private-channel/index.js +3 -3
- package/private-messenger/helpers/publication.js +2 -1
- package/private-messenger/helpers/send-routing.js +94 -40
- package/private-messenger/index.js +37 -32
- package/private-messenger/session/README.md +43 -11
- package/private-messenger/session/index.js +4 -2
package/README.md
CHANGED
|
@@ -915,10 +915,16 @@ local defaults, including a bounded streaming file cache. The optional
|
|
|
915
915
|
provides reusable DM inbox/outbox and attachment lifecycle without UI coupling,
|
|
916
916
|
including an optional `onSendError` callback identifying the failed outbox message.
|
|
917
917
|
|
|
918
|
-
|
|
918
|
+
Private messages, including multi-recipient channels, retry NIP-65 read relays
|
|
919
|
+
in pairs per recipient when
|
|
919
920
|
selected relays reject publication or become unreachable while connectivity is
|
|
920
|
-
confirmed. The exact encrypted outer events are reused
|
|
921
|
-
|
|
921
|
+
confirmed. The exact encrypted outer events are reused. Optional `fallbackRelays` on
|
|
922
|
+
`createPrivateMessenger` and `createPrivateMessageSession` are tried after the
|
|
923
|
+
primary destinations, including explicit per-call `relays`, channel `sendRelays`
|
|
924
|
+
and channel/global `relays`, as well as explicit `relayToReceivers` maps.
|
|
925
|
+
Retries preserve each encrypted recipient subset; success requires accepted
|
|
926
|
+
publication covering every recipient in that subset. Receive routing adds them from the start for live
|
|
927
|
+
messages, history and files while preserving automatic NIP-65 updates. Successful sends keep first-ACK
|
|
922
928
|
latency, and short-lived channel/recipient preferences avoid recently refused
|
|
923
929
|
relays without changing receive subscriptions. See the
|
|
924
930
|
[session coordinator](private-messenger/session/README.md) for deferred offline
|
package/package.json
CHANGED
package/private-channel/index.js
CHANGED
|
@@ -37,7 +37,7 @@ const encoder = new TextEncoder()
|
|
|
37
37
|
const decoder = new TextDecoder()
|
|
38
38
|
const NIP44_V3_SCOPE = ''
|
|
39
39
|
|
|
40
|
-
const sendToRelays = (
|
|
40
|
+
const sendToRelays = (event, relays) => relayPool.sendEvent(event, relays)
|
|
41
41
|
const getEvents = (...args) => relayPool.getEvents(...args)
|
|
42
42
|
const getLiveEventsGenerator = (...args) => relayPool.getLiveEventsGenerator(...args)
|
|
43
43
|
const getEventsFeedGenerator = (...args) => relayPool.getEventsFeedGenerator(...args)
|
|
@@ -540,7 +540,7 @@ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner
|
|
|
540
540
|
try {
|
|
541
541
|
for (const group of groups) {
|
|
542
542
|
for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })) {
|
|
543
|
-
results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays)))
|
|
543
|
+
results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(group.receivers), primaryRelays: group.relays }))
|
|
544
544
|
}
|
|
545
545
|
}
|
|
546
546
|
} finally {
|
|
@@ -550,7 +550,7 @@ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner
|
|
|
550
550
|
}
|
|
551
551
|
|
|
552
552
|
for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs })) {
|
|
553
|
-
results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays)))
|
|
553
|
+
results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(receivers), primaryRelays: relays }))
|
|
554
554
|
}
|
|
555
555
|
return results
|
|
556
556
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { getEventHash } from '../../event/index.js'
|
|
2
2
|
|
|
3
3
|
// A report describes one outer event, not one relay. Each outer event needs
|
|
4
|
-
//
|
|
4
|
+
// accepted publication covering its routed recipients. Keep the successful fast
|
|
5
|
+
// path immediate; a partially covered multi-recipient report remains a failure.
|
|
5
6
|
export async function assertMessagePublished (result, event) {
|
|
6
7
|
const reports = result?.delivery?.reports
|
|
7
8
|
const present = Array.isArray(reports) && reports.length > 0
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeRelayUrl } from '../../url/index.js'
|
|
2
|
+
import { ValidationError } from '../../error/index.js'
|
|
2
3
|
|
|
3
4
|
const REJECTION_PREFIXES = new Set(['blocked', 'restricted', 'auth-required', 'pow', 'rate-limited', 'error'])
|
|
4
5
|
const TRANSPORT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
|
|
@@ -14,17 +15,60 @@ export function isReplaceableRelayFailure (error) {
|
|
|
14
15
|
return REJECTION_PREFIXES.has(/^([a-z-]+):/.exec(error?.message || '')?.[1])
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
export function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
export function normalizeFallbackRelays (value = []) {
|
|
19
|
+
if (!Array.isArray(value)) throw new ValidationError('INVALID_FALLBACK_RELAYS')
|
|
20
|
+
return [...new Set(Array.from(value, normalizeRelayUrl))]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function routeEntries (routes) {
|
|
24
|
+
return routes instanceof Map ? [...routes] : Object.entries(routes || {})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function routeGroups (routes) {
|
|
28
|
+
const groups = new Map()
|
|
29
|
+
for (const [relay, receivers] of routes) {
|
|
30
|
+
const key = [...receivers].sort().join(',')
|
|
31
|
+
if (!groups.has(key)) groups.set(key, { receivers, relays: [] })
|
|
32
|
+
groups.get(key).relays.push(relay)
|
|
33
|
+
}
|
|
34
|
+
return [...groups.values()]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, now = Date.now }) {
|
|
38
|
+
// Keep discovery's defaults when NIP-65 is unavailable. Explicit maps/lists
|
|
39
|
+
// retain their fanout; configured fallbacks are a separate last-resort stage.
|
|
40
|
+
const explicit = Boolean(primaryRelays || primaryRelayToReceivers)
|
|
41
|
+
const primaryRoutes = primaryRelayToReceivers || (primaryRelays
|
|
42
|
+
? new Map(primaryRelays.map(relay => [relay, peers]))
|
|
43
|
+
: pickRelays(peers, relaysByPubkey, { relayType: 'read', maxPerPubkey: Infinity }))
|
|
44
|
+
const primaryByPeer = Object.fromEntries(peers.map(pubkey => [pubkey, { read: [] }]))
|
|
45
|
+
for (const [relay, receivers] of routeEntries(primaryRoutes)) {
|
|
46
|
+
for (const pubkey of Array.isArray(receivers) ? receivers : [receivers]) primaryByPeer[pubkey]?.read.push(normalizeRelayUrl(relay))
|
|
47
|
+
}
|
|
48
|
+
const candidates = [...new Set([...Object.values(primaryByPeer).flatMap(value => value.read), ...fallbackRelays])]
|
|
21
49
|
const candidateSet = new Set(candidates)
|
|
22
50
|
for (const [relay, expires] of exclusions) if (expires <= now() || !candidateSet.has(relay)) exclusions.delete(relay)
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
51
|
+
// A fresh attempt can recheck an exhausted list; each outer event still owns
|
|
52
|
+
// a tried set so concurrent sends/fragments cannot create a retry loop.
|
|
53
|
+
if (peers.some(pubkey => {
|
|
54
|
+
const available = [...primaryByPeer[pubkey].read, ...fallbackRelays]
|
|
55
|
+
return available.length && available.every(relay => exclusions.has(relay))
|
|
56
|
+
})) exclusions.clear()
|
|
57
|
+
const select = (receivers, excluded, staged = false) => {
|
|
58
|
+
const excludedRelays = [...excluded]
|
|
59
|
+
const excludeRelaysByPubkey = new Map(receivers.map(pubkey => [pubkey, excludedRelays]))
|
|
60
|
+
const options = { relayType: 'read', excludeRelaysByPubkey, emptyRelaysFallback: [] }
|
|
61
|
+
const routes = pickRelays(receivers, primaryByPeer, { ...options, ...(explicit ? { maxPerPubkey: Infinity } : {}) })
|
|
62
|
+
// For one ciphertext, exhaust the remaining members' primary routes before
|
|
63
|
+
// using a common fallback. Initial wrapping must nevertheless cover everyone.
|
|
64
|
+
if (staged && routes.size) return routes
|
|
65
|
+
const covered = new Set([...routes.values()].flat())
|
|
66
|
+
const missing = receivers.filter(pubkey => !covered.has(pubkey))
|
|
67
|
+
const fallback = pickRelays(missing, Object.fromEntries(missing.map(pubkey => [pubkey, { read: fallbackRelays }])), options)
|
|
68
|
+
for (const [relay, pubkeys] of fallback) routes.set(relay, [...new Set([...(routes.get(relay) || []), ...pubkeys])])
|
|
69
|
+
return routes
|
|
70
|
+
}
|
|
71
|
+
const relayToReceivers = primaryRelayToReceivers || select(peers, exclusions.keys())
|
|
28
72
|
const current = () => !signal?.aborted && isCurrent()
|
|
29
73
|
const rejected = report => (report?.errors || []).filter(item => candidateSet.has(normalized(item.relay)) && isReplaceableRelayFailure(item.reason))
|
|
30
74
|
const remember = errors => { for (const item of errors) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
|
|
@@ -33,58 +77,68 @@ export function createSendRelayRouting ({ peer, relaysByPubkey, exclusions, pick
|
|
|
33
77
|
try { return await isOnline({ signal }) && current() } catch { return false }
|
|
34
78
|
}
|
|
35
79
|
|
|
36
|
-
async function send (event, initialRelays) {
|
|
80
|
+
async function send (event, initialRelays, context = {}) {
|
|
81
|
+
// This context describes the already encrypted recipient subset. It stays
|
|
82
|
+
// local: no recipient tags or routing metadata are added to the outer event.
|
|
83
|
+
const receivers = context.receiverPubkeys || peers
|
|
84
|
+
const pending = new Set(receivers)
|
|
37
85
|
const mirrors = initialRelays.filter(relay => !candidateSet.has(normalized(relay)))
|
|
38
|
-
|
|
86
|
+
const initialPrimary = context.primaryRelays || (primaryRelays || peers.length === 1 ? routeEntries(relayToReceivers).map(([relay]) => relay) : [])
|
|
87
|
+
const first = [...new Set(initialPrimary.map(normalizeRelayUrl))].filter(relay => !exclusions.has(relay))
|
|
88
|
+
let batch = first.length ? { receivers, relays: first } : null
|
|
39
89
|
const tried = new Set()
|
|
40
90
|
const errors = []
|
|
41
91
|
const summaries = []
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
92
|
+
const next = () => routeGroups(select([...pending], new Set([...exclusions.keys(), ...tried]), true))[0]
|
|
93
|
+
const finish = result => ({
|
|
94
|
+
...result,
|
|
95
|
+
success: summaries.length > 0 && pending.size === 0,
|
|
96
|
+
total: tried.size,
|
|
97
|
+
promise: Promise.all(summaries).then(reports => ({
|
|
46
98
|
total: tried.size,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
while (true) {
|
|
99
|
+
success: summaries.length > 0 && pending.size === 0,
|
|
100
|
+
fulfilled: reports.reduce((total, report) => total + (report.fulfilled || 0), 0),
|
|
101
|
+
succeededRelays: [...new Set(reports.flatMap(report => report.succeededRelays || []))],
|
|
102
|
+
errors: reports.flatMap(report => report.errors || [])
|
|
103
|
+
}))
|
|
104
|
+
})
|
|
105
|
+
let result = { success: false }
|
|
106
|
+
while ((batch ||= next())) {
|
|
57
107
|
signal?.throwIfAborted()
|
|
108
|
+
const relays = [...new Set([...batch.relays, ...(summaries.length ? [] : mirrors)])]
|
|
58
109
|
for (const relay of relays) tried.add(normalized(relay))
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
110
|
+
// Every relay in this batch serves the same pending recipient subset.
|
|
111
|
+
// One ACK covers that subset, never unrelated members of the ciphertext.
|
|
112
|
+
result = await sendEvent(event, relays)
|
|
62
113
|
const settled = Promise.resolve(result.promise)
|
|
63
114
|
summaries.push(settled)
|
|
64
115
|
if (result.success) {
|
|
116
|
+
for (const pubkey of batch.receivers) pending.delete(pubkey)
|
|
65
117
|
// Keep the first ACK immediate; learn about redundant failures later.
|
|
66
118
|
settled.then(async report => {
|
|
67
119
|
const failed = rejected(report)
|
|
68
120
|
if (failed.length && await online()) remember(failed)
|
|
69
121
|
}).catch(() => {})
|
|
70
|
-
return finish(result)
|
|
122
|
+
if (!pending.size) return finish(result)
|
|
123
|
+
} else {
|
|
124
|
+
const report = await settled
|
|
125
|
+
const failed = rejected(report)
|
|
126
|
+
errors.push(...(report?.errors || []))
|
|
127
|
+
if (!failed.length || errors.some(item => !isReplaceableRelayFailure(item.reason))) return finish(result)
|
|
128
|
+
if (!next()) { exclusions.clear(); return finish(result) }
|
|
129
|
+
if (!await online()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: current() }
|
|
130
|
+
remember(failed)
|
|
71
131
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
errors.push(...(report?.errors || []))
|
|
75
|
-
if (!failed.length || errors.some(item => !isReplaceableRelayFailure(item.reason))) return finish(result)
|
|
76
|
-
const next = [...select(new Set([...exclusions.keys(), ...tried])).keys()]
|
|
77
|
-
if (!next.length) { exclusions.clear(); return finish(result) }
|
|
78
|
-
if (!await online()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: current() }
|
|
79
|
-
remember(failed)
|
|
80
|
-
relays = next
|
|
132
|
+
if (!current()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: false }
|
|
133
|
+
batch = null
|
|
81
134
|
}
|
|
135
|
+
return finish(result)
|
|
82
136
|
}
|
|
83
137
|
|
|
84
138
|
return {
|
|
85
139
|
relayToReceivers,
|
|
86
|
-
// A recovery mirror must not reintroduce
|
|
87
|
-
recoveryRelays: recoveryRelays.filter(relay => !candidateSet.has(relay) || !exclusions.has(relay)),
|
|
140
|
+
// A recovery mirror must not reintroduce refused relays or promote a fallback.
|
|
141
|
+
recoveryRelays: recoveryRelays.filter(relay => !candidateSet.has(normalized(relay)) || !exclusions.has(normalized(relay))),
|
|
88
142
|
_publish: options => (options.nymSigner ? publishNymEvent : publish)({ ...options, _publish: send })
|
|
89
143
|
}
|
|
90
144
|
}
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
|
|
45
45
|
import * as privateMessage from '../private-message/index.js'
|
|
46
46
|
import { isOnline } from '../network/index.js'
|
|
47
|
-
import { createSendRelayRouting } from './helpers/send-routing.js'
|
|
47
|
+
import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
|
|
48
|
+
import { normalizeRelayUrl } from '../url/index.js'
|
|
48
49
|
import { deliveryInfo } from '../private-channel/helpers/rumor.js'
|
|
49
50
|
import { bytesToBase64 } from '../base64/index.js'
|
|
50
51
|
import { ValidationError } from '../error/index.js'
|
|
@@ -193,6 +194,7 @@ export class PrivateMessenger {
|
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
constructor ({
|
|
197
|
+
fallbackRelays = [],
|
|
196
198
|
offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
|
|
197
199
|
staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
|
|
198
200
|
identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
|
|
@@ -226,6 +228,7 @@ export class PrivateMessenger {
|
|
|
226
228
|
_storageClearInterval = globalThis.clearInterval.bind(globalThis),
|
|
227
229
|
_BroadcastChannel = _indexedDB === globalThis.indexedDB ? globalThis.BroadcastChannel : undefined
|
|
228
230
|
} = {}) {
|
|
231
|
+
this.fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
229
232
|
this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
|
|
230
233
|
this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
|
|
231
234
|
this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
|
|
@@ -756,42 +759,38 @@ export class PrivateMessenger {
|
|
|
756
759
|
}
|
|
757
760
|
|
|
758
761
|
async resolveWatchRelays (channel) {
|
|
759
|
-
|
|
760
|
-
return this.
|
|
762
|
+
const primary = !channel.usesNip65WatchRelays && channel.relays.length ? channel.relays : await this.readRelaysForPubkey(this.userPubkey)
|
|
763
|
+
return uniq([...primary, ...this.fallbackRelays].map(normalizeRelayUrl))
|
|
761
764
|
}
|
|
762
765
|
|
|
763
766
|
async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal }) {
|
|
764
767
|
const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
|
|
765
|
-
if (relayToReceivers) return { relayToReceivers, recoveryRelays }
|
|
766
|
-
|
|
767
|
-
if (
|
|
768
|
-
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
if (!this.sendRelayExclusions.has(key)) {
|
|
775
|
-
if (this.sendRelayExclusions.size >= 256) this.sendRelayExclusions.delete(this.sendRelayExclusions.keys().next().value)
|
|
776
|
-
this.sendRelayExclusions.set(key, { channelPubkey: channel.pubkey, exclusions: new Map() })
|
|
777
|
-
}
|
|
778
|
-
const routing = createSendRelayRouting({
|
|
779
|
-
peer, relaysByPubkey, recoveryRelays,
|
|
780
|
-
signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
|
|
781
|
-
exclusions: this.sendRelayExclusions.get(key).exclusions,
|
|
782
|
-
pickRelays: this._pickRelaysForPubkeys,
|
|
783
|
-
publish: this._privateChannel.publish || privateChannel.publish,
|
|
784
|
-
publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
|
|
785
|
-
sendEvent: (...args) => relayPool.sendEvent(...args),
|
|
786
|
-
isOnline: this._isOnline,
|
|
787
|
-
isCurrent: () => !this.closePromise && !this.pauseReasons.size && this.channels.has(channel.pubkey)
|
|
788
|
-
})
|
|
789
|
-
if (!relayMapRelays(routing.relayToReceivers).length) throw new ValidationError('NO_RELAYS')
|
|
790
|
-
return routing
|
|
768
|
+
if (relayToReceivers && !this.fallbackRelays.length) return { relayToReceivers, recoveryRelays }
|
|
769
|
+
const fixed = relayToReceivers ? null : relays?.length ? uniq(relays) : channel.sendRelays.length ? channel.sendRelays : channel.relays.length ? channel.relays : null
|
|
770
|
+
if (fixed && !this.fallbackRelays.length) return { relays: fixed, recoveryRelays }
|
|
771
|
+
const recipients = uniq(receiverPubkeys?.length ? receiverPubkeys : relayMapReceivers(relayToReceivers))
|
|
772
|
+
const relaysByPubkey = fixed || relayToReceivers ? undefined : await this._getRelaysByPubkey(recipients)
|
|
773
|
+
const key = `${channel.pubkey}:${[...recipients].sort().join(',')}`
|
|
774
|
+
if (!this.sendRelayExclusions.has(key)) {
|
|
775
|
+
if (this.sendRelayExclusions.size >= 256) this.sendRelayExclusions.delete(this.sendRelayExclusions.keys().next().value)
|
|
776
|
+
this.sendRelayExclusions.set(key, { channelPubkey: channel.pubkey, exclusions: new Map() })
|
|
791
777
|
}
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
778
|
+
const routing = createSendRelayRouting({
|
|
779
|
+
peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
|
|
780
|
+
signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
|
|
781
|
+
exclusions: this.sendRelayExclusions.get(key).exclusions,
|
|
782
|
+
pickRelays: this._pickRelaysForPubkeys,
|
|
783
|
+
publish: this._privateChannel.publish || privateChannel.publish,
|
|
784
|
+
publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
|
|
785
|
+
sendEvent: (...args) => relayPool.sendEvent(...args),
|
|
786
|
+
isOnline: this._isOnline,
|
|
787
|
+
isCurrent: () => !this.closePromise && !this.pauseReasons.size && this.channels.has(channel.pubkey)
|
|
788
|
+
})
|
|
789
|
+
if (!relayMapRelays(routing.relayToReceivers).length) throw new ValidationError('NO_RELAYS')
|
|
790
|
+
// Fixed lists address the original receiver set as a whole, preserving the
|
|
791
|
+
// existing encryption grouping instead of inventing per-recipient routes.
|
|
792
|
+
if (fixed) return { relays: fixed, recoveryRelays: routing.recoveryRelays, _publish: routing._publish }
|
|
793
|
+
return routing
|
|
795
794
|
}
|
|
796
795
|
|
|
797
796
|
readState () {
|
|
@@ -2330,6 +2329,12 @@ function oldestCreatedAt (events) {
|
|
|
2330
2329
|
return oldest
|
|
2331
2330
|
}
|
|
2332
2331
|
|
|
2332
|
+
function relayMapReceivers (relayToReceivers) {
|
|
2333
|
+
if (!relayToReceivers) return []
|
|
2334
|
+
const values = relayToReceivers instanceof Map ? [...relayToReceivers.values()] : Object.values(relayToReceivers)
|
|
2335
|
+
return uniq(values.flat())
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2333
2338
|
function relayMapRelays (relayToReceivers) {
|
|
2334
2339
|
if (!relayToReceivers) return []
|
|
2335
2340
|
const entries = relayToReceivers instanceof Map ? relayToReceivers.entries() : Object.entries(relayToReceivers)
|
|
@@ -48,21 +48,53 @@ This is a DM profile; group membership/key derivation is not implemented.
|
|
|
48
48
|
|
|
49
49
|
## Relay fallback
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
Both `createPrivateMessenger` and `createPrivateMessageSession` accept an optional
|
|
52
|
+
`fallbackRelays` array (default `[]`) at construction. URLs are normalized,
|
|
53
|
+
deduplicated and copied; invalid values throw `ValidationError`. For example:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
const session = createPrivateMessageSession({
|
|
57
|
+
owner, signer, eventStore,
|
|
58
|
+
fallbackRelays: ['wss://relay.44billion.net']
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Automatic sends try each recipient's NIP-65 read relays first, at most two per
|
|
63
|
+
recipient per attempt, then the configured fallbacks in pairs. Explicit
|
|
64
|
+
per-call `relays`, channel `sendRelays`/`relays` and global `relays` remain primary
|
|
65
|
+
and also support configured fallbacks, including fixed multi-recipient sends.
|
|
66
|
+
Explicit lists retain their requested initial fanout; no recipient relay lookup
|
|
67
|
+
is added. A relay present in both lists is attempted only once per outer event.
|
|
68
|
+
Automatic multi-recipient sends and explicit `relayToReceivers` maps also support
|
|
69
|
+
fallbacks. Explicit maps retain their precedence over fixed lists, initial fanout
|
|
70
|
+
and encrypted recipient subsets, without consulting recipient NIP-65 lists.
|
|
71
|
+
The session coordinator remains a DM API; multi-recipient channels use
|
|
72
|
+
`createPrivateMessenger` directly.
|
|
73
|
+
|
|
74
|
+
On `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or `error`
|
|
75
|
+
rejections, or connection/transport/timeout failures, replacement requires
|
|
76
|
+
`isOnline` to confirm connectivity. Invalid messages, local signer/authentication
|
|
77
|
+
failures and unknown error text retain their original diagnosis.
|
|
78
|
+
|
|
79
|
+
Receive routing always adds the configured fallback relays to the effective
|
|
80
|
+
primary list (explicit `relays` or the owner's NIP-65 read relays). Listen there
|
|
81
|
+
from the start, even when primary relays are healthy: a sender may have used a
|
|
82
|
+
fallback after publication was refused elsewhere. Live watches, historical
|
|
83
|
+
recovery and file-channel reads share this policy. NIP-65 refreshes retain the
|
|
84
|
+
fallbacks; outgoing exclusions never remove them from receive subscriptions.
|
|
85
|
+
No public recipient tags or changes to a user's NIP-65 event are introduced.
|
|
58
86
|
|
|
59
87
|
Each signed outer event is reused verbatim, including router/carrier fragments
|
|
60
88
|
and deletion capabilities. Native errors from exhausted attempts remain in its
|
|
61
|
-
publication report.
|
|
62
|
-
|
|
63
|
-
|
|
89
|
+
publication report. A shared relay's first ACK covers all members assigned to
|
|
90
|
+
that publication batch. If replacements use different relays for different
|
|
91
|
+
members, every pending member must be covered before the outer event succeeds;
|
|
92
|
+
a partial ACK cannot hide another member's failure. Remaining primary routes are
|
|
93
|
+
exhausted before configured fallbacks. Redundant acknowledgements may update
|
|
94
|
+
future routing preferences without delaying a successfully covered send. Preferences are channel/recipient-scoped, in-memory,
|
|
64
95
|
expire after five minutes, and never alter receive subscriptions. A fresh attempt
|
|
65
|
-
can recheck an exhausted list.
|
|
96
|
+
can recheck an exhausted list. Additional fallback relays come only from the
|
|
97
|
+
caller configuration; absent NIP-65 lists retain the existing discovery defaults.
|
|
66
98
|
|
|
67
99
|
When offline/interrupted with alternatives remaining, reports carry
|
|
68
100
|
`retryWhenAvailable: true`. The outbox remains pending and emits no `onSendError`.
|
|
@@ -8,6 +8,7 @@ import { decodeFileMetadata } from '../../nip94/index.js'
|
|
|
8
8
|
import { createChatOutbox } from './helpers/work-storage.js'
|
|
9
9
|
import { messengerSigner } from './helpers/signer.js'
|
|
10
10
|
import { assertMessagePublished } from '../helpers/publication.js'
|
|
11
|
+
import { normalizeFallbackRelays } from '../helpers/send-routing.js'
|
|
11
12
|
|
|
12
13
|
export function wireEvent (value, owner) {
|
|
13
14
|
const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
|
|
@@ -17,7 +18,8 @@ export function wireEvent (value, owner) {
|
|
|
17
18
|
// Relay rejection text must not be interpreted as a local signer denial.
|
|
18
19
|
const retryable = error => error?.code === 'MESSAGE_NOT_PUBLISHED' || !/DENIED|PERMISSION|REVOKED|READ_ONLY|INVALID|BLOCKED|EXPIRED|NOT_IN_PERSONA/i.test(`${error?.code || ''} ${error?.message || ''}`)
|
|
19
20
|
|
|
20
|
-
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
21
|
+
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
22
|
+
fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
21
23
|
const userSigner = messengerSigner(signer)
|
|
22
24
|
messageStorage ||= createEventStoreMessageStorage({ eventStore })
|
|
23
25
|
chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
|
|
@@ -74,7 +76,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
74
76
|
if (closed || !available || version !== lifecycle) return
|
|
75
77
|
values.push(channel)
|
|
76
78
|
}
|
|
77
|
-
if (!messenger) messenger = await Messenger({ seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys: false, onMessageQueued: () => drain(), onError })
|
|
79
|
+
if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys: false, onMessageQueued: () => drain(), onError })
|
|
78
80
|
if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
|
|
79
81
|
await messenger.update({ channels: values })
|
|
80
82
|
await messenger.resume('signer')
|