libp2r2p 0.11.3 → 0.11.4
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 -0
- package/package.json +1 -1
- package/private-messenger/file/README.md +3 -1
- package/private-messenger/file/index.js +6 -10
- package/private-messenger/{session/helpers → helpers}/publication.js +4 -1
- package/private-messenger/helpers/send-routing.js +90 -0
- package/private-messenger/index.js +39 -7
- package/private-messenger/session/README.md +31 -0
- package/private-messenger/session/index.js +40 -10
package/README.md
CHANGED
|
@@ -914,3 +914,12 @@ 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
|
+
Single-recipient private messages now retry NIP-65 read relays in pairs when
|
|
919
|
+
selected relays reject publication or become unreachable while connectivity is
|
|
920
|
+
confirmed. The exact encrypted outer events are reused; explicit per-call and
|
|
921
|
+
channel relay overrides remain authoritative. Successful sends keep first-ACK
|
|
922
|
+
latency, and short-lived channel/recipient preferences avoid recently refused
|
|
923
|
+
relays without changing receive subscriptions. See the
|
|
924
|
+
[session coordinator](private-messenger/session/README.md) for deferred offline
|
|
925
|
+
attempts and final failure notifications.
|
package/package.json
CHANGED
|
@@ -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,4 +1,4 @@
|
|
|
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
|
// at least one relay acknowledgement. Keep the successful fast path immediate.
|
|
@@ -17,6 +17,8 @@ export async function assertMessagePublished (result, event) {
|
|
|
17
17
|
return {
|
|
18
18
|
index,
|
|
19
19
|
total: settled?.total ?? report?.total,
|
|
20
|
+
...(report?.retryWhenAvailable ? { retryWhenAvailable: true } : {}),
|
|
21
|
+
...(report?.retryWhenOnline ? { retryWhenOnline: true } : {}),
|
|
20
22
|
fulfilled: settled?.fulfilled,
|
|
21
23
|
errors: (settled?.errors || []).map(({ relay, reason }) => ({ relay, reason }))
|
|
22
24
|
}
|
|
@@ -32,6 +34,7 @@ export async function assertMessagePublished (result, event) {
|
|
|
32
34
|
})
|
|
33
35
|
throw Object.assign(new Error(`MESSAGE_NOT_PUBLISHED: ${details.join(' | ') || reason}`), {
|
|
34
36
|
code: 'MESSAGE_NOT_PUBLISHED',
|
|
37
|
+
...(failures.length && failures.every(report => report.retryWhenAvailable) ? { retryWhenAvailable: true, ...(failures.some(report => report.retryWhenOnline) ? { retryWhenOnline: true } : {}) } : {}),
|
|
35
38
|
reason,
|
|
36
39
|
eventId: getEventHash(event),
|
|
37
40
|
eventKind: event.kind,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { normalizeRelayUrl } from '../../url/index.js'
|
|
2
|
+
|
|
3
|
+
const REJECTION_PREFIXES = new Set(['blocked', 'restricted', 'auth-required', 'pow', 'rate-limited', 'error'])
|
|
4
|
+
const TRANSPORT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
|
|
5
|
+
const EXCLUSION_MS = 5 * 60 * 1000
|
|
6
|
+
const normalized = relay => { try { return normalizeRelayUrl(relay) } catch { return '' } }
|
|
7
|
+
|
|
8
|
+
// Only machine-readable relay policy/server failures qualify. Invalid events,
|
|
9
|
+
// local signer denials and unknown prose must not rotate relays.
|
|
10
|
+
export function isReplaceableRelayFailure (error) {
|
|
11
|
+
if (error?.name === 'Nip42AuthenticationError') return false
|
|
12
|
+
if (TRANSPORT_CATEGORIES.has(error?.category)) return true
|
|
13
|
+
if (error?.category && error.category !== 'relay') return false
|
|
14
|
+
return REJECTION_PREFIXES.has(/^([a-z-]+):/.exec(error?.message || '')?.[1])
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createSendRelayRouting ({ peer, relaysByPubkey, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, now = Date.now }) {
|
|
18
|
+
// One finite snapshot, including the existing default pair if NIP-65 is
|
|
19
|
+
// unavailable. Never invent additional fallback relays.
|
|
20
|
+
const candidates = [...pickRelays([peer], relaysByPubkey, { relayType: 'read', maxPerPubkey: Infinity }).keys()]
|
|
21
|
+
const candidateSet = new Set(candidates)
|
|
22
|
+
for (const [relay, expires] of exclusions) if (expires <= now() || !candidateSet.has(relay)) exclusions.delete(relay)
|
|
23
|
+
// New attempts may recheck an exhausted list. Each in-flight outer event has
|
|
24
|
+
// its own tried set, preventing loops during concurrent sends.
|
|
25
|
+
if (candidates.length && candidates.every(relay => exclusions.has(relay))) exclusions.clear()
|
|
26
|
+
const select = excluded => pickRelays([peer], relaysByPubkey, { relayType: 'read', excludeRelaysByPubkey: new Map([[peer, [...excluded]]]) })
|
|
27
|
+
const relayToReceivers = select(exclusions.keys())
|
|
28
|
+
const current = () => !signal?.aborted && isCurrent()
|
|
29
|
+
const rejected = report => (report?.errors || []).filter(item => candidateSet.has(normalized(item.relay)) && isReplaceableRelayFailure(item.reason))
|
|
30
|
+
const remember = errors => { for (const item of errors) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
|
|
31
|
+
const online = async () => {
|
|
32
|
+
if (!current()) return false
|
|
33
|
+
try { return await isOnline({ signal }) && current() } catch { return false }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function send (event, initialRelays) {
|
|
37
|
+
const mirrors = initialRelays.filter(relay => !candidateSet.has(normalized(relay)))
|
|
38
|
+
let relays = [...new Set([...select(exclusions.keys()).keys(), ...mirrors])]
|
|
39
|
+
const tried = new Set()
|
|
40
|
+
const errors = []
|
|
41
|
+
const summaries = []
|
|
42
|
+
const finish = result => {
|
|
43
|
+
if (summaries.length === 1) return result
|
|
44
|
+
return {
|
|
45
|
+
...result,
|
|
46
|
+
total: tried.size,
|
|
47
|
+
promise: Promise.all(summaries).then(reports => ({
|
|
48
|
+
total: tried.size,
|
|
49
|
+
success: reports.some(report => report.success),
|
|
50
|
+
fulfilled: reports.reduce((total, report) => total + (report.fulfilled || 0), 0),
|
|
51
|
+
succeededRelays: [...new Set(reports.flatMap(report => report.succeededRelays || []))],
|
|
52
|
+
errors: reports.flatMap(report => report.errors || [])
|
|
53
|
+
}))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
while (true) {
|
|
57
|
+
signal?.throwIfAborted()
|
|
58
|
+
for (const relay of relays) tried.add(normalized(relay))
|
|
59
|
+
// Republish the exact signed outer event, preserving encryption,
|
|
60
|
+
// fragments, timestamps and deletion capabilities.
|
|
61
|
+
const result = await sendEvent(event, relays)
|
|
62
|
+
const settled = Promise.resolve(result.promise)
|
|
63
|
+
summaries.push(settled)
|
|
64
|
+
if (result.success) {
|
|
65
|
+
// Keep the first ACK immediate; learn about redundant failures later.
|
|
66
|
+
settled.then(async report => {
|
|
67
|
+
const failed = rejected(report)
|
|
68
|
+
if (failed.length && await online()) remember(failed)
|
|
69
|
+
}).catch(() => {})
|
|
70
|
+
return finish(result)
|
|
71
|
+
}
|
|
72
|
+
const report = await settled
|
|
73
|
+
const failed = rejected(report)
|
|
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
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
relayToReceivers,
|
|
86
|
+
// A recovery mirror must not reintroduce the recipient's refused relays.
|
|
87
|
+
recoveryRelays: recoveryRelays.filter(relay => !candidateSet.has(relay) || !exclusions.has(relay)),
|
|
88
|
+
_publish: options => (options.nymSigner ? publishNymEvent : publish)({ ...options, _publish: send })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -43,10 +43,12 @@
|
|
|
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 } from './helpers/send-routing.js'
|
|
46
48
|
import { deliveryInfo } from '../private-channel/helpers/rumor.js'
|
|
47
49
|
import { bytesToBase64 } from '../base64/index.js'
|
|
48
50
|
import { ValidationError } from '../error/index.js'
|
|
49
|
-
import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates } from '../relay/index.js'
|
|
51
|
+
import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates, relayPool } from '../relay/index.js'
|
|
50
52
|
import * as privateChannel from '../private-channel/index.js'
|
|
51
53
|
import { DEFAULT_RECEIVED_CHUNK_TTL_MS } from '../private-channel/services/received-chunks.js'
|
|
52
54
|
import { createQueue } from '../idb-queue/index.js'
|
|
@@ -215,6 +217,7 @@ export class PrivateMessenger {
|
|
|
215
217
|
_getRelaysByPubkey = getRelaysByPubkey,
|
|
216
218
|
_pickRelaysForPubkeys = pickRelaysForPubkeys,
|
|
217
219
|
_subscribeRelayListUpdates = subscribeRelayListUpdates,
|
|
220
|
+
_isOnline = isOnline,
|
|
218
221
|
_setTimeout = globalThis.setTimeout.bind(globalThis),
|
|
219
222
|
_clearTimeout = globalThis.clearTimeout.bind(globalThis),
|
|
220
223
|
_setInterval = globalThis.setInterval.bind(globalThis),
|
|
@@ -247,6 +250,9 @@ export class PrivateMessenger {
|
|
|
247
250
|
this._getRelaysByPubkey = _getRelaysByPubkey
|
|
248
251
|
this._pickRelaysForPubkeys = _pickRelaysForPubkeys
|
|
249
252
|
this._subscribeRelayListUpdates = _subscribeRelayListUpdates
|
|
253
|
+
this._isOnline = _isOnline
|
|
254
|
+
this.sendRelayExclusions = new Map()
|
|
255
|
+
this.sendRoutingLifetime = new AbortController()
|
|
250
256
|
this._setTimeout = _setTimeout
|
|
251
257
|
this._clearTimeout = _clearTimeout
|
|
252
258
|
this._setInterval = _setInterval
|
|
@@ -670,6 +676,7 @@ export class PrivateMessenger {
|
|
|
670
676
|
|
|
671
677
|
await this.unwatch(removedPubkeys)
|
|
672
678
|
for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
|
|
679
|
+
for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
|
|
673
680
|
for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
|
|
674
681
|
|
|
675
682
|
await this.cleanupStaleChannels({ storageSnapshot })
|
|
@@ -753,13 +760,36 @@ export class PrivateMessenger {
|
|
|
753
760
|
return this.readRelaysForPubkey(this.userPubkey)
|
|
754
761
|
}
|
|
755
762
|
|
|
756
|
-
async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers }) {
|
|
763
|
+
async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal }) {
|
|
757
764
|
const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
|
|
758
765
|
if (relayToReceivers) return { relayToReceivers, recoveryRelays }
|
|
759
766
|
if (relays?.length) return { relays: uniq(relays), recoveryRelays }
|
|
760
767
|
if (channel.sendRelays.length) return { relays: channel.sendRelays, recoveryRelays }
|
|
761
768
|
if (channel.relays.length) return { relays: channel.relays, recoveryRelays }
|
|
762
|
-
const
|
|
769
|
+
const recipients = uniq(receiverPubkeys)
|
|
770
|
+
if (recipients.length === 1) {
|
|
771
|
+
const peer = recipients[0]
|
|
772
|
+
const relaysByPubkey = await this._getRelaysByPubkey(recipients)
|
|
773
|
+
const key = `${channel.pubkey}:${peer}`
|
|
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
|
|
791
|
+
}
|
|
792
|
+
const derived = await this.readRelayToReceivers(recipients)
|
|
763
793
|
if (!relayMapRelays(derived).length) throw new ValidationError('NO_RELAYS')
|
|
764
794
|
return { relayToReceivers: derived, recoveryRelays }
|
|
765
795
|
}
|
|
@@ -1600,9 +1630,9 @@ export class PrivateMessenger {
|
|
|
1600
1630
|
})
|
|
1601
1631
|
}
|
|
1602
1632
|
|
|
1603
|
-
async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey }) {
|
|
1633
|
+
async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal }) {
|
|
1604
1634
|
const channel = this.requireWritableChannel(channelPubkey)
|
|
1605
|
-
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
|
|
1635
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
|
|
1606
1636
|
this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
|
|
1607
1637
|
return this._privateMessage.broadcastRumor({
|
|
1608
1638
|
senderSigner: this.userSigner,
|
|
@@ -1621,9 +1651,9 @@ export class PrivateMessenger {
|
|
|
1621
1651
|
})
|
|
1622
1652
|
}
|
|
1623
1653
|
|
|
1624
|
-
async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey }) {
|
|
1654
|
+
async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal }) {
|
|
1625
1655
|
const channel = this.requireWritableChannel(channelPubkey)
|
|
1626
|
-
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
|
|
1656
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
|
|
1627
1657
|
this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
|
|
1628
1658
|
return this._privateMessage.broadcastEvent({
|
|
1629
1659
|
senderSigner: this.userSigner,
|
|
@@ -2187,6 +2217,8 @@ export class PrivateMessenger {
|
|
|
2187
2217
|
close () {
|
|
2188
2218
|
if (this.closePromise) return this.closePromise
|
|
2189
2219
|
const initSettledPromise = this.initSettledPromise
|
|
2220
|
+
this.sendRoutingLifetime.abort()
|
|
2221
|
+
this.sendRelayExclusions.clear()
|
|
2190
2222
|
let unwatchPromise
|
|
2191
2223
|
try {
|
|
2192
2224
|
unwatchPromise = Promise.resolve(this.unwatch())
|
|
@@ -45,3 +45,34 @@ 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
|
+
Automatic single-recipient routing uses the recipient's NIP-65 read relays, at
|
|
52
|
+
most two per attempt. On `blocked`, `restricted`, `auth-required`, `pow`,
|
|
53
|
+
`rate-limited`, or `error` rejections, or connection/transport/timeout failures,
|
|
54
|
+
it can try remaining read relays after `isOnline` confirms connectivity. Invalid
|
|
55
|
+
messages, local signer/authentication failures and unknown error text retain their
|
|
56
|
+
original diagnosis. Explicit per-call `relays`/`relayToReceivers`, channel
|
|
57
|
+
`sendRelays`/`relays`, and multi-recipient sends do not enable automatic fallback.
|
|
58
|
+
|
|
59
|
+
Each signed outer event is reused verbatim, including router/carrier fragments
|
|
60
|
+
and deletion capabilities. Native errors from exhausted attempts remain in its
|
|
61
|
+
publication report. One successful relay acknowledgement completes that outer
|
|
62
|
+
event immediately; other acknowledgements may update future routing preferences
|
|
63
|
+
without delaying the send. Preferences are channel/recipient-scoped, in-memory,
|
|
64
|
+
expire after five minutes, and never alter receive subscriptions. A fresh attempt
|
|
65
|
+
can recheck an exhausted list. No additional public fallback relays are invented.
|
|
66
|
+
|
|
67
|
+
When offline/interrupted with alternatives remaining, reports carry
|
|
68
|
+
`retryWhenAvailable: true`. The outbox remains pending and emits no `onSendError`.
|
|
69
|
+
Offline reports also carry `retryWhenOnline: true`: the session owns a temporary
|
|
70
|
+
`onOnline` subscription to retry those entries after confirmed connectivity,
|
|
71
|
+
including brief outages the app's existing monitor might have missed. Cancel,
|
|
72
|
+
close and signer unavailability release the subscription. Account recovery still
|
|
73
|
+
uses `setAvailable(true)`; network recovery never overrides signer availability.
|
|
74
|
+
`onSendError` is emitted only after final failure, including exhausted eligible
|
|
75
|
+
relays. Validation/authorization failures are terminal without relay rotation.
|
|
76
|
+
`broadcastRumor`/`broadcastEvent` accept a `signal` to stop further replacement
|
|
77
|
+
publications; the session supplies it and aborts it on cancel/close. An already
|
|
78
|
+
published event cannot be recalled by cancellation.
|
|
@@ -3,10 +3,11 @@ 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'
|
|
10
11
|
|
|
11
12
|
export function wireEvent (value, owner) {
|
|
12
13
|
const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
|
|
@@ -16,7 +17,7 @@ export function wireEvent (value, owner) {
|
|
|
16
17
|
// Relay rejection text must not be interpreted as a local signer denial.
|
|
17
18
|
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
19
|
|
|
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' }) }) {
|
|
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' }) }) {
|
|
20
21
|
const userSigner = messengerSigner(signer)
|
|
21
22
|
messageStorage ||= createEventStoreMessageStorage({ eventStore })
|
|
22
23
|
chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
|
|
@@ -25,6 +26,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
25
26
|
const deniedPeers = new Set()
|
|
26
27
|
const entries = new Map()
|
|
27
28
|
const cancelled = new Set()
|
|
29
|
+
const sendControllers = new Map()
|
|
30
|
+
const offlineSends = new Set()
|
|
31
|
+
let stopOnline
|
|
28
32
|
let messenger
|
|
29
33
|
let fileTransfers
|
|
30
34
|
let downloads
|
|
@@ -140,16 +144,18 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
140
144
|
}
|
|
141
145
|
} finally { await Promise.all(deferred.map(nack => nack())); draining = false; settled() }
|
|
142
146
|
}
|
|
143
|
-
async function publish (peer, event) {
|
|
147
|
+
async function publish (peer, event, signal) {
|
|
144
148
|
if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
|
|
145
149
|
if (!available || !peers.has(peer) || !channels.has(peer)) throw new Error('CHAT_UNAVAILABLE')
|
|
146
|
-
const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer] }
|
|
150
|
+
const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
|
|
147
151
|
const report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
|
|
148
152
|
// Published is relay acceptance, never a peer receipt.
|
|
149
153
|
await assertMessagePublished(report, event)
|
|
150
154
|
}
|
|
151
155
|
async function sendEntry (entry) {
|
|
152
156
|
entry.status = 'pending'; emit()
|
|
157
|
+
const controller = new AbortController()
|
|
158
|
+
sendControllers.set(entry.id, controller)
|
|
153
159
|
try {
|
|
154
160
|
const active = async () => {
|
|
155
161
|
if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
|
|
@@ -180,7 +186,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
180
186
|
const chunk = chunkStorage ? await chunkStorage.read(file.root, entry.chunkIndex, file) : await fileTransfers.readChunk(file.root, entry.chunkIndex)
|
|
181
187
|
if (!chunk || decodeIrfsChunk(chunk).root !== file.root) { if (file.optional) break; throw new Error('FILE_UNAVAILABLE') }
|
|
182
188
|
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))
|
|
189
|
+
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
190
|
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
191
|
entry.uploadProgress = { completed, total: entry.files.reduce((sum, value) => sum + value.size, 0) }
|
|
186
192
|
emit()
|
|
@@ -199,7 +205,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
199
205
|
for (; entry.index < entry.events.length; entry.index++) {
|
|
200
206
|
await active()
|
|
201
207
|
const event = entry.events[entry.index]
|
|
202
|
-
if (entry.peer !== owner) await publish(entry.peer, event)
|
|
208
|
+
if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
|
|
203
209
|
await active()
|
|
204
210
|
await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
|
|
205
211
|
}
|
|
@@ -208,15 +214,33 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
208
214
|
emit()
|
|
209
215
|
} catch (error) {
|
|
210
216
|
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)
|
|
217
|
+
entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
|
|
212
218
|
await storage.put(entry, { existing: true }).catch(onError)
|
|
213
219
|
onError(error)
|
|
214
220
|
// The failed wire event may be a quote or file chunk. Report the
|
|
215
221
|
// owning outbox item separately, without mutating the native error.
|
|
216
|
-
if (
|
|
222
|
+
if (error.retryWhenOnline) offlineSends.add(entry.id)
|
|
223
|
+
if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
|
|
217
224
|
}
|
|
218
225
|
emit()
|
|
219
|
-
}
|
|
226
|
+
} finally { sendControllers.delete(entry.id) }
|
|
227
|
+
}
|
|
228
|
+
function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
|
|
229
|
+
function watchOfflineSends () {
|
|
230
|
+
if (closed || !available || !offlineSends.size) { stopOnlineRetry(); return }
|
|
231
|
+
if (stopOnline) return
|
|
232
|
+
// A fresh listener receives confirmed connectivity even when a brief outage
|
|
233
|
+
// occurred between the app's long-lived monitor checks.
|
|
234
|
+
stopOnline = _onOnline(() => {
|
|
235
|
+
stopOnlineRetry()
|
|
236
|
+
if (closed || !available) return
|
|
237
|
+
for (const id of offlineSends) {
|
|
238
|
+
const entry = entries.get(id)
|
|
239
|
+
if (entry?.retryable) entry.failed = false
|
|
240
|
+
}
|
|
241
|
+
offlineSends.clear()
|
|
242
|
+
return schedule()
|
|
243
|
+
})
|
|
220
244
|
}
|
|
221
245
|
async function pump () {
|
|
222
246
|
if (sending || !available || closed || !messenger || !storage) return
|
|
@@ -227,7 +251,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
227
251
|
if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
|
|
228
252
|
await sendEntry(entry)
|
|
229
253
|
}
|
|
230
|
-
} finally { sending = false; settled() }
|
|
254
|
+
} finally { sending = false; watchOfflineSends(); settled() }
|
|
231
255
|
}
|
|
232
256
|
const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
|
|
233
257
|
async function download (file, options = {}) {
|
|
@@ -266,6 +290,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
266
290
|
},
|
|
267
291
|
async setAvailable (value) {
|
|
268
292
|
available = value === true
|
|
293
|
+
stopOnlineRetry(); offlineSends.clear()
|
|
269
294
|
lifecycle++
|
|
270
295
|
if (!available) { await messenger?.pause('signer'); return }
|
|
271
296
|
await ready()
|
|
@@ -286,12 +311,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
286
311
|
return id
|
|
287
312
|
},
|
|
288
313
|
async retry (id) {
|
|
314
|
+
offlineSends.delete(id); watchOfflineSends()
|
|
289
315
|
await ready()
|
|
290
316
|
const entry = entries.get(id)
|
|
291
317
|
if (entry) { entry.failed = false; if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
|
|
292
318
|
},
|
|
293
319
|
async cancel (id) {
|
|
294
320
|
cancelled.add(id)
|
|
321
|
+
sendControllers.get(id)?.abort()
|
|
322
|
+
offlineSends.delete(id); watchOfflineSends()
|
|
295
323
|
await ready()
|
|
296
324
|
entries.delete(id)
|
|
297
325
|
await storage.remove(id)
|
|
@@ -299,6 +327,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
299
327
|
},
|
|
300
328
|
async close () {
|
|
301
329
|
closed = true; available = false; lifecycle++
|
|
330
|
+
stopOnlineRetry(); offlineSends.clear()
|
|
331
|
+
for (const controller of sendControllers.values()) controller.abort()
|
|
302
332
|
await Promise.allSettled([configuring, initialized])
|
|
303
333
|
await messenger?.pause('closed')
|
|
304
334
|
if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
|