libp2r2p 0.11.3 → 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 +15 -0
- package/package.json +1 -1
- package/private-channel/index.js +3 -3
- package/private-messenger/file/README.md +3 -1
- package/private-messenger/file/index.js +6 -10
- package/private-messenger/{session/helpers → helpers}/publication.js +6 -2
- package/private-messenger/helpers/send-routing.js +144 -0
- package/private-messenger/index.js +52 -15
- package/private-messenger/session/README.md +63 -0
- package/private-messenger/session/index.js +43 -11
package/README.md
CHANGED
|
@@ -914,3 +914,18 @@ local defaults, including a bounded streaming file cache. The optional
|
|
|
914
914
|
[`private-messenger/session`](private-messenger/session/README.md) coordinator
|
|
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
|
+
|
|
918
|
+
Private messages, including multi-recipient channels, retry NIP-65 read relays
|
|
919
|
+
in pairs per recipient when
|
|
920
|
+
selected relays reject publication or become unreachable while connectivity is
|
|
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
|
|
928
|
+
latency, and short-lived channel/recipient preferences avoid recently refused
|
|
929
|
+
relays without changing receive subscriptions. See the
|
|
930
|
+
[session coordinator](private-messenger/session/README.md) for deferred offline
|
|
931
|
+
attempts and final failure notifications.
|
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
|
}
|
|
@@ -21,8 +21,10 @@ append it to the thumbnail URL. This is not a group-key derivation scheme.
|
|
|
21
21
|
The application calls it for an explicit share, using its persisted message
|
|
22
22
|
timestamp (standalone 1063 uses its own timestamp). Registration, cache hits
|
|
23
23
|
and incoming requests never grant access. Missing router `p` is not a wildcard.
|
|
24
|
-
- `publishChunk(descriptor, event)` checks a 34601 proof and the descriptor,
|
|
24
|
+
- `publishChunk(descriptor, event, { signal } = {})` checks a 34601 proof and the descriptor,
|
|
25
25
|
captures ciphertext only in watchtower mode, and publishes to the recipient's relays.
|
|
26
|
+
It shares the parent channel's automatic relay fallback and full publication
|
|
27
|
+
diagnostics; `signal` stops further replacement attempts.
|
|
26
28
|
Every wrapper must receive at least one relay acceptance. Yield between calls
|
|
27
29
|
so text/control sends can run. Publish all file chunks before announcing 1063/9.
|
|
28
30
|
- `download(descriptor, { manual, thumbnail, signal })` returns the verified,
|
|
@@ -3,6 +3,7 @@ import { chunkStream } from './helpers/stream.js'
|
|
|
3
3
|
import { createFileCache } from './helpers/cache.js'
|
|
4
4
|
export { createFileCache, DEFAULT_FILE_CACHE_BYTES } from './helpers/cache.js'
|
|
5
5
|
import { createFileCatalog } from './helpers/catalog.js'
|
|
6
|
+
import { assertMessagePublished } from '../helpers/publication.js'
|
|
6
7
|
import { ValidationError } from '../../error/index.js'
|
|
7
8
|
import { IRFS_CHUNK_BYTES, decodeIrfsChunk } from '../../irfs/index.js'
|
|
8
9
|
import { createQueue } from '../../idb-queue/index.js'
|
|
@@ -48,11 +49,6 @@ function rangesFor (indices) {
|
|
|
48
49
|
return ranges
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
function assertPublished (result) {
|
|
52
|
-
const reports = result?.delivery?.reports
|
|
53
|
-
if (!Array.isArray(reports) || !reports.length || !reports.every(report => report?.success === true)) throw Object.assign(new Error('FILE_NOT_PUBLISHED'), { code: 'MESSAGE_NOT_PUBLISHED' })
|
|
54
|
-
}
|
|
55
|
-
|
|
56
52
|
function waitForTransfer (promise, signal) {
|
|
57
53
|
signal?.throwIfAborted()
|
|
58
54
|
if (!signal) return promise
|
|
@@ -148,14 +144,14 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
148
144
|
if (decoded.root !== file.root || (index !== undefined && decoded.index !== index) || (file.size !== undefined && (decoded.total !== Math.ceil(file.size / IRFS_CHUNK_BYTES) || (decoded.index === decoded.total - 1 && decoded.contentBytes.length !== file.size - decoded.index * IRFS_CHUNK_BYTES)))) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
|
|
149
145
|
return decoded
|
|
150
146
|
}
|
|
151
|
-
async function routing (file, receiverPubkey) {
|
|
147
|
+
async function routing (file, receiverPubkey, signal) {
|
|
152
148
|
const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
|
|
153
|
-
return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey] })
|
|
149
|
+
return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey], signal })
|
|
154
150
|
}
|
|
155
151
|
function sendOptions (file, routes) {
|
|
156
152
|
return { senderSigner: messenger.userSigner, imkcSigner: messenger.contentKeySigner, privateChannelSigner: file.signer, ...routes, temporaryStorageArea: messenger.temporaryStorageArea, expirationSeconds: messenger.eventExpirationSecondsFor(file.controlChannelPubkey), _getIykcProofs: messenger.contentKeyLookup() }
|
|
157
153
|
}
|
|
158
|
-
async function publishChunk (descriptor, event) {
|
|
154
|
+
async function publishChunk (descriptor, event, { signal } = {}) {
|
|
159
155
|
const file = await register(descriptor)
|
|
160
156
|
usable(file)
|
|
161
157
|
const decoded = checked(file, event)
|
|
@@ -166,9 +162,9 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
166
162
|
}
|
|
167
163
|
let result
|
|
168
164
|
try {
|
|
169
|
-
const options = { ...sendOptions(file, await routing(file, file.peerPubkey)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, ...(messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
|
|
165
|
+
const options = { ...sendOptions(file, await routing(file, file.peerPubkey, signal)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, ...(messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
|
|
170
166
|
result = event.sig ? await _messages.broadcastEvent({ ...options, event }) : await _messages.broadcastRumor({ ...options, rumor: event })
|
|
171
|
-
|
|
167
|
+
await assertMessagePublished(result, result?.rumor || result?.event || { ...event, pubkey: event.pubkey || messenger.userPubkey })
|
|
172
168
|
} catch (error) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root); throw error }
|
|
173
169
|
if (decoded.index === decoded.total - 1) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root) }
|
|
174
170
|
// A scheduler turn between chunks lets queued text/control publications run.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { getEventHash } from '
|
|
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
|
|
@@ -17,6 +18,8 @@ export async function assertMessagePublished (result, event) {
|
|
|
17
18
|
return {
|
|
18
19
|
index,
|
|
19
20
|
total: settled?.total ?? report?.total,
|
|
21
|
+
...(report?.retryWhenAvailable ? { retryWhenAvailable: true } : {}),
|
|
22
|
+
...(report?.retryWhenOnline ? { retryWhenOnline: true } : {}),
|
|
20
23
|
fulfilled: settled?.fulfilled,
|
|
21
24
|
errors: (settled?.errors || []).map(({ relay, reason }) => ({ relay, reason }))
|
|
22
25
|
}
|
|
@@ -32,6 +35,7 @@ export async function assertMessagePublished (result, event) {
|
|
|
32
35
|
})
|
|
33
36
|
throw Object.assign(new Error(`MESSAGE_NOT_PUBLISHED: ${details.join(' | ') || reason}`), {
|
|
34
37
|
code: 'MESSAGE_NOT_PUBLISHED',
|
|
38
|
+
...(failures.length && failures.every(report => report.retryWhenAvailable) ? { retryWhenAvailable: true, ...(failures.some(report => report.retryWhenOnline) ? { retryWhenOnline: true } : {}) } : {}),
|
|
35
39
|
reason,
|
|
36
40
|
eventId: getEventHash(event),
|
|
37
41
|
eventKind: event.kind,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { normalizeRelayUrl } from '../../url/index.js'
|
|
2
|
+
import { ValidationError } from '../../error/index.js'
|
|
3
|
+
|
|
4
|
+
const REJECTION_PREFIXES = new Set(['blocked', 'restricted', 'auth-required', 'pow', 'rate-limited', 'error'])
|
|
5
|
+
const TRANSPORT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
|
|
6
|
+
const EXCLUSION_MS = 5 * 60 * 1000
|
|
7
|
+
const normalized = relay => { try { return normalizeRelayUrl(relay) } catch { return '' } }
|
|
8
|
+
|
|
9
|
+
// Only machine-readable relay policy/server failures qualify. Invalid events,
|
|
10
|
+
// local signer denials and unknown prose must not rotate relays.
|
|
11
|
+
export function isReplaceableRelayFailure (error) {
|
|
12
|
+
if (error?.name === 'Nip42AuthenticationError') return false
|
|
13
|
+
if (TRANSPORT_CATEGORIES.has(error?.category)) return true
|
|
14
|
+
if (error?.category && error.category !== 'relay') return false
|
|
15
|
+
return REJECTION_PREFIXES.has(/^([a-z-]+):/.exec(error?.message || '')?.[1])
|
|
16
|
+
}
|
|
17
|
+
|
|
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])]
|
|
49
|
+
const candidateSet = new Set(candidates)
|
|
50
|
+
for (const [relay, expires] of exclusions) if (expires <= now() || !candidateSet.has(relay)) exclusions.delete(relay)
|
|
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())
|
|
72
|
+
const current = () => !signal?.aborted && isCurrent()
|
|
73
|
+
const rejected = report => (report?.errors || []).filter(item => candidateSet.has(normalized(item.relay)) && isReplaceableRelayFailure(item.reason))
|
|
74
|
+
const remember = errors => { for (const item of errors) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
|
|
75
|
+
const online = async () => {
|
|
76
|
+
if (!current()) return false
|
|
77
|
+
try { return await isOnline({ signal }) && current() } catch { return false }
|
|
78
|
+
}
|
|
79
|
+
|
|
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)
|
|
85
|
+
const mirrors = initialRelays.filter(relay => !candidateSet.has(normalized(relay)))
|
|
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
|
|
89
|
+
const tried = new Set()
|
|
90
|
+
const errors = []
|
|
91
|
+
const summaries = []
|
|
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 => ({
|
|
98
|
+
total: tried.size,
|
|
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())) {
|
|
107
|
+
signal?.throwIfAborted()
|
|
108
|
+
const relays = [...new Set([...batch.relays, ...(summaries.length ? [] : mirrors)])]
|
|
109
|
+
for (const relay of relays) tried.add(normalized(relay))
|
|
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)
|
|
113
|
+
const settled = Promise.resolve(result.promise)
|
|
114
|
+
summaries.push(settled)
|
|
115
|
+
if (result.success) {
|
|
116
|
+
for (const pubkey of batch.receivers) pending.delete(pubkey)
|
|
117
|
+
// Keep the first ACK immediate; learn about redundant failures later.
|
|
118
|
+
settled.then(async report => {
|
|
119
|
+
const failed = rejected(report)
|
|
120
|
+
if (failed.length && await online()) remember(failed)
|
|
121
|
+
}).catch(() => {})
|
|
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)
|
|
131
|
+
}
|
|
132
|
+
if (!current()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: false }
|
|
133
|
+
batch = null
|
|
134
|
+
}
|
|
135
|
+
return finish(result)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
relayToReceivers,
|
|
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))),
|
|
142
|
+
_publish: options => (options.nymSigner ? publishNymEvent : publish)({ ...options, _publish: send })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -43,10 +43,13 @@
|
|
|
43
43
|
// - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
|
|
44
44
|
|
|
45
45
|
import * as privateMessage from '../private-message/index.js'
|
|
46
|
+
import { isOnline } from '../network/index.js'
|
|
47
|
+
import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
|
|
48
|
+
import { normalizeRelayUrl } from '../url/index.js'
|
|
46
49
|
import { deliveryInfo } from '../private-channel/helpers/rumor.js'
|
|
47
50
|
import { bytesToBase64 } from '../base64/index.js'
|
|
48
51
|
import { ValidationError } from '../error/index.js'
|
|
49
|
-
import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates } from '../relay/index.js'
|
|
52
|
+
import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates, relayPool } from '../relay/index.js'
|
|
50
53
|
import * as privateChannel from '../private-channel/index.js'
|
|
51
54
|
import { DEFAULT_RECEIVED_CHUNK_TTL_MS } from '../private-channel/services/received-chunks.js'
|
|
52
55
|
import { createQueue } from '../idb-queue/index.js'
|
|
@@ -191,6 +194,7 @@ export class PrivateMessenger {
|
|
|
191
194
|
}
|
|
192
195
|
|
|
193
196
|
constructor ({
|
|
197
|
+
fallbackRelays = [],
|
|
194
198
|
offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
|
|
195
199
|
staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
|
|
196
200
|
identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
|
|
@@ -215,6 +219,7 @@ export class PrivateMessenger {
|
|
|
215
219
|
_getRelaysByPubkey = getRelaysByPubkey,
|
|
216
220
|
_pickRelaysForPubkeys = pickRelaysForPubkeys,
|
|
217
221
|
_subscribeRelayListUpdates = subscribeRelayListUpdates,
|
|
222
|
+
_isOnline = isOnline,
|
|
218
223
|
_setTimeout = globalThis.setTimeout.bind(globalThis),
|
|
219
224
|
_clearTimeout = globalThis.clearTimeout.bind(globalThis),
|
|
220
225
|
_setInterval = globalThis.setInterval.bind(globalThis),
|
|
@@ -223,6 +228,7 @@ export class PrivateMessenger {
|
|
|
223
228
|
_storageClearInterval = globalThis.clearInterval.bind(globalThis),
|
|
224
229
|
_BroadcastChannel = _indexedDB === globalThis.indexedDB ? globalThis.BroadcastChannel : undefined
|
|
225
230
|
} = {}) {
|
|
231
|
+
this.fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
226
232
|
this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
|
|
227
233
|
this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
|
|
228
234
|
this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
|
|
@@ -247,6 +253,9 @@ export class PrivateMessenger {
|
|
|
247
253
|
this._getRelaysByPubkey = _getRelaysByPubkey
|
|
248
254
|
this._pickRelaysForPubkeys = _pickRelaysForPubkeys
|
|
249
255
|
this._subscribeRelayListUpdates = _subscribeRelayListUpdates
|
|
256
|
+
this._isOnline = _isOnline
|
|
257
|
+
this.sendRelayExclusions = new Map()
|
|
258
|
+
this.sendRoutingLifetime = new AbortController()
|
|
250
259
|
this._setTimeout = _setTimeout
|
|
251
260
|
this._clearTimeout = _clearTimeout
|
|
252
261
|
this._setInterval = _setInterval
|
|
@@ -670,6 +679,7 @@ export class PrivateMessenger {
|
|
|
670
679
|
|
|
671
680
|
await this.unwatch(removedPubkeys)
|
|
672
681
|
for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
|
|
682
|
+
for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
|
|
673
683
|
for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
|
|
674
684
|
|
|
675
685
|
await this.cleanupStaleChannels({ storageSnapshot })
|
|
@@ -749,19 +759,38 @@ export class PrivateMessenger {
|
|
|
749
759
|
}
|
|
750
760
|
|
|
751
761
|
async resolveWatchRelays (channel) {
|
|
752
|
-
|
|
753
|
-
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))
|
|
754
764
|
}
|
|
755
765
|
|
|
756
|
-
async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers }) {
|
|
766
|
+
async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal }) {
|
|
757
767
|
const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
|
|
758
|
-
if (relayToReceivers) return { relayToReceivers, recoveryRelays }
|
|
759
|
-
|
|
760
|
-
if (
|
|
761
|
-
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
|
|
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() })
|
|
777
|
+
}
|
|
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
|
|
765
794
|
}
|
|
766
795
|
|
|
767
796
|
readState () {
|
|
@@ -1600,9 +1629,9 @@ export class PrivateMessenger {
|
|
|
1600
1629
|
})
|
|
1601
1630
|
}
|
|
1602
1631
|
|
|
1603
|
-
async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey }) {
|
|
1632
|
+
async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal }) {
|
|
1604
1633
|
const channel = this.requireWritableChannel(channelPubkey)
|
|
1605
|
-
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
|
|
1634
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
|
|
1606
1635
|
this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
|
|
1607
1636
|
return this._privateMessage.broadcastRumor({
|
|
1608
1637
|
senderSigner: this.userSigner,
|
|
@@ -1621,9 +1650,9 @@ export class PrivateMessenger {
|
|
|
1621
1650
|
})
|
|
1622
1651
|
}
|
|
1623
1652
|
|
|
1624
|
-
async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey }) {
|
|
1653
|
+
async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal }) {
|
|
1625
1654
|
const channel = this.requireWritableChannel(channelPubkey)
|
|
1626
|
-
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
|
|
1655
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
|
|
1627
1656
|
this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
|
|
1628
1657
|
return this._privateMessage.broadcastEvent({
|
|
1629
1658
|
senderSigner: this.userSigner,
|
|
@@ -2187,6 +2216,8 @@ export class PrivateMessenger {
|
|
|
2187
2216
|
close () {
|
|
2188
2217
|
if (this.closePromise) return this.closePromise
|
|
2189
2218
|
const initSettledPromise = this.initSettledPromise
|
|
2219
|
+
this.sendRoutingLifetime.abort()
|
|
2220
|
+
this.sendRelayExclusions.clear()
|
|
2190
2221
|
let unwatchPromise
|
|
2191
2222
|
try {
|
|
2192
2223
|
unwatchPromise = Promise.resolve(this.unwatch())
|
|
@@ -2298,6 +2329,12 @@ function oldestCreatedAt (events) {
|
|
|
2298
2329
|
return oldest
|
|
2299
2330
|
}
|
|
2300
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
|
+
|
|
2301
2338
|
function relayMapRelays (relayToReceivers) {
|
|
2302
2339
|
if (!relayToReceivers) return []
|
|
2303
2340
|
const entries = relayToReceivers instanceof Map ? relayToReceivers.entries() : Object.entries(relayToReceivers)
|
|
@@ -45,3 +45,66 @@ passing their established factories, as Zillion does.
|
|
|
45
45
|
External recovery factories are owned by their caller and must be closed after
|
|
46
46
|
the session. Work storage opened by the session is closed by the session.
|
|
47
47
|
This is a DM profile; group membership/key derivation is not implemented.
|
|
48
|
+
|
|
49
|
+
## Relay fallback
|
|
50
|
+
|
|
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.
|
|
86
|
+
|
|
87
|
+
Each signed outer event is reused verbatim, including router/carrier fragments
|
|
88
|
+
and deletion capabilities. Native errors from exhausted attempts remain in its
|
|
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,
|
|
95
|
+
expire after five minutes, and never alter receive subscriptions. A fresh attempt
|
|
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.
|
|
98
|
+
|
|
99
|
+
When offline/interrupted with alternatives remaining, reports carry
|
|
100
|
+
`retryWhenAvailable: true`. The outbox remains pending and emits no `onSendError`.
|
|
101
|
+
Offline reports also carry `retryWhenOnline: true`: the session owns a temporary
|
|
102
|
+
`onOnline` subscription to retry those entries after confirmed connectivity,
|
|
103
|
+
including brief outages the app's existing monitor might have missed. Cancel,
|
|
104
|
+
close and signer unavailability release the subscription. Account recovery still
|
|
105
|
+
uses `setAvailable(true)`; network recovery never overrides signer availability.
|
|
106
|
+
`onSendError` is emitted only after final failure, including exhausted eligible
|
|
107
|
+
relays. Validation/authorization failures are terminal without relay rotation.
|
|
108
|
+
`broadcastRumor`/`broadcastEvent` accept a `signal` to stop further replacement
|
|
109
|
+
publications; the session supplies it and aborts it on cancel/close. An already
|
|
110
|
+
published event cannot be recalled by cancellation.
|
|
@@ -3,10 +3,12 @@ import { createPrivateFileTransfer } from '../file/index.js'
|
|
|
3
3
|
import { createPrivateMessenger } from '../index.js'
|
|
4
4
|
import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/index.js'
|
|
5
5
|
import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
|
|
6
|
+
import { onOnline } from '../../network/index.js'
|
|
6
7
|
import { decodeFileMetadata } from '../../nip94/index.js'
|
|
7
8
|
import { createChatOutbox } from './helpers/work-storage.js'
|
|
8
9
|
import { messengerSigner } from './helpers/signer.js'
|
|
9
|
-
import { assertMessagePublished } from '
|
|
10
|
+
import { assertMessagePublished } from '../helpers/publication.js'
|
|
11
|
+
import { normalizeFallbackRelays } from '../helpers/send-routing.js'
|
|
10
12
|
|
|
11
13
|
export function wireEvent (value, owner) {
|
|
12
14
|
const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
|
|
@@ -16,7 +18,8 @@ export function wireEvent (value, owner) {
|
|
|
16
18
|
// Relay rejection text must not be interpreted as a local signer denial.
|
|
17
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 || ''}`)
|
|
18
20
|
|
|
19
|
-
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, 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)
|
|
20
23
|
const userSigner = messengerSigner(signer)
|
|
21
24
|
messageStorage ||= createEventStoreMessageStorage({ eventStore })
|
|
22
25
|
chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
|
|
@@ -25,6 +28,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
25
28
|
const deniedPeers = new Set()
|
|
26
29
|
const entries = new Map()
|
|
27
30
|
const cancelled = new Set()
|
|
31
|
+
const sendControllers = new Map()
|
|
32
|
+
const offlineSends = new Set()
|
|
33
|
+
let stopOnline
|
|
28
34
|
let messenger
|
|
29
35
|
let fileTransfers
|
|
30
36
|
let downloads
|
|
@@ -70,7 +76,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
70
76
|
if (closed || !available || version !== lifecycle) return
|
|
71
77
|
values.push(channel)
|
|
72
78
|
}
|
|
73
|
-
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 })
|
|
74
80
|
if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
|
|
75
81
|
await messenger.update({ channels: values })
|
|
76
82
|
await messenger.resume('signer')
|
|
@@ -140,16 +146,18 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
140
146
|
}
|
|
141
147
|
} finally { await Promise.all(deferred.map(nack => nack())); draining = false; settled() }
|
|
142
148
|
}
|
|
143
|
-
async function publish (peer, event) {
|
|
149
|
+
async function publish (peer, event, signal) {
|
|
144
150
|
if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
|
|
145
151
|
if (!available || !peers.has(peer) || !channels.has(peer)) throw new Error('CHAT_UNAVAILABLE')
|
|
146
|
-
const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer] }
|
|
152
|
+
const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
|
|
147
153
|
const report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
|
|
148
154
|
// Published is relay acceptance, never a peer receipt.
|
|
149
155
|
await assertMessagePublished(report, event)
|
|
150
156
|
}
|
|
151
157
|
async function sendEntry (entry) {
|
|
152
158
|
entry.status = 'pending'; emit()
|
|
159
|
+
const controller = new AbortController()
|
|
160
|
+
sendControllers.set(entry.id, controller)
|
|
153
161
|
try {
|
|
154
162
|
const active = async () => {
|
|
155
163
|
if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
|
|
@@ -180,7 +188,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
180
188
|
const chunk = chunkStorage ? await chunkStorage.read(file.root, entry.chunkIndex, file) : await fileTransfers.readChunk(file.root, entry.chunkIndex)
|
|
181
189
|
if (!chunk || decodeIrfsChunk(chunk).root !== file.root) { if (file.optional) break; throw new Error('FILE_UNAVAILABLE') }
|
|
182
190
|
if (!fileTransfers) throw new Error('FILE_TRANSFER_UNAVAILABLE')
|
|
183
|
-
await fileTransfers.publishChunk({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, wireEvent(chunk, owner))
|
|
191
|
+
await fileTransfers.publishChunk({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, wireEvent(chunk, owner), { signal: controller.signal })
|
|
184
192
|
const completed = entry.files.slice(0, entry.fileIndex).reduce((sum, value) => sum + value.size, 0) + Math.min(file.size, (entry.chunkIndex + 1) * IRFS_CHUNK_BYTES)
|
|
185
193
|
entry.uploadProgress = { completed, total: entry.files.reduce((sum, value) => sum + value.size, 0) }
|
|
186
194
|
emit()
|
|
@@ -199,7 +207,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
199
207
|
for (; entry.index < entry.events.length; entry.index++) {
|
|
200
208
|
await active()
|
|
201
209
|
const event = entry.events[entry.index]
|
|
202
|
-
if (entry.peer !== owner) await publish(entry.peer, event)
|
|
210
|
+
if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
|
|
203
211
|
await active()
|
|
204
212
|
await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
|
|
205
213
|
}
|
|
@@ -208,15 +216,33 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
208
216
|
emit()
|
|
209
217
|
} catch (error) {
|
|
210
218
|
if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
|
|
211
|
-
entry.status = 'error'; entry.failed = true; entry.retryable = retryable(error)
|
|
219
|
+
entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
|
|
212
220
|
await storage.put(entry, { existing: true }).catch(onError)
|
|
213
221
|
onError(error)
|
|
214
222
|
// The failed wire event may be a quote or file chunk. Report the
|
|
215
223
|
// owning outbox item separately, without mutating the native error.
|
|
216
|
-
if (
|
|
224
|
+
if (error.retryWhenOnline) offlineSends.add(entry.id)
|
|
225
|
+
if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
|
|
217
226
|
}
|
|
218
227
|
emit()
|
|
219
|
-
}
|
|
228
|
+
} finally { sendControllers.delete(entry.id) }
|
|
229
|
+
}
|
|
230
|
+
function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
|
|
231
|
+
function watchOfflineSends () {
|
|
232
|
+
if (closed || !available || !offlineSends.size) { stopOnlineRetry(); return }
|
|
233
|
+
if (stopOnline) return
|
|
234
|
+
// A fresh listener receives confirmed connectivity even when a brief outage
|
|
235
|
+
// occurred between the app's long-lived monitor checks.
|
|
236
|
+
stopOnline = _onOnline(() => {
|
|
237
|
+
stopOnlineRetry()
|
|
238
|
+
if (closed || !available) return
|
|
239
|
+
for (const id of offlineSends) {
|
|
240
|
+
const entry = entries.get(id)
|
|
241
|
+
if (entry?.retryable) entry.failed = false
|
|
242
|
+
}
|
|
243
|
+
offlineSends.clear()
|
|
244
|
+
return schedule()
|
|
245
|
+
})
|
|
220
246
|
}
|
|
221
247
|
async function pump () {
|
|
222
248
|
if (sending || !available || closed || !messenger || !storage) return
|
|
@@ -227,7 +253,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
227
253
|
if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
|
|
228
254
|
await sendEntry(entry)
|
|
229
255
|
}
|
|
230
|
-
} finally { sending = false; settled() }
|
|
256
|
+
} finally { sending = false; watchOfflineSends(); settled() }
|
|
231
257
|
}
|
|
232
258
|
const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
|
|
233
259
|
async function download (file, options = {}) {
|
|
@@ -266,6 +292,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
266
292
|
},
|
|
267
293
|
async setAvailable (value) {
|
|
268
294
|
available = value === true
|
|
295
|
+
stopOnlineRetry(); offlineSends.clear()
|
|
269
296
|
lifecycle++
|
|
270
297
|
if (!available) { await messenger?.pause('signer'); return }
|
|
271
298
|
await ready()
|
|
@@ -286,12 +313,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
286
313
|
return id
|
|
287
314
|
},
|
|
288
315
|
async retry (id) {
|
|
316
|
+
offlineSends.delete(id); watchOfflineSends()
|
|
289
317
|
await ready()
|
|
290
318
|
const entry = entries.get(id)
|
|
291
319
|
if (entry) { entry.failed = false; if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
|
|
292
320
|
},
|
|
293
321
|
async cancel (id) {
|
|
294
322
|
cancelled.add(id)
|
|
323
|
+
sendControllers.get(id)?.abort()
|
|
324
|
+
offlineSends.delete(id); watchOfflineSends()
|
|
295
325
|
await ready()
|
|
296
326
|
entries.delete(id)
|
|
297
327
|
await storage.remove(id)
|
|
@@ -299,6 +329,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
299
329
|
},
|
|
300
330
|
async close () {
|
|
301
331
|
closed = true; available = false; lifecycle++
|
|
332
|
+
stopOnlineRetry(); offlineSends.clear()
|
|
333
|
+
for (const controller of sendControllers.values()) controller.abort()
|
|
302
334
|
await Promise.allSettled([configuring, initialized])
|
|
303
335
|
await messenger?.pause('closed')
|
|
304
336
|
if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
|