libp2r2p 0.11.2 → 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 +11 -1
- 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 +41 -2
- package/private-messenger/session/index.js +46 -9
package/README.md
CHANGED
|
@@ -912,4 +912,14 @@ Private-message consumers can now substitute recovery persistence through
|
|
|
912
912
|
[`private-messenger/event-store`](private-messenger/event-store/README.md), or use
|
|
913
913
|
local defaults, including a bounded streaming file cache. The optional
|
|
914
914
|
[`private-messenger/session`](private-messenger/session/README.md) coordinator
|
|
915
|
-
provides reusable DM inbox/outbox and attachment lifecycle without UI coupling
|
|
915
|
+
provides reusable DM inbox/outbox and attachment lifecycle without UI coupling,
|
|
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())
|
|
@@ -14,8 +14,16 @@ persistence; absent file storage uses the library cache. `mode` defaults to
|
|
|
14
14
|
|
|
15
15
|
Methods: `setPeers`, `setAvailable(boolean)`, `enqueue`, `retry`, `cancel`,
|
|
16
16
|
`download`, `cancelDownload`, and `close`. Callbacks are `onOutbox`, `onMedia`,
|
|
17
|
-
and `
|
|
18
|
-
to availability and owns view subscriptions. There are no DOM or UI imports.
|
|
17
|
+
`onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
|
|
18
|
+
account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
|
|
19
|
+
|
|
20
|
+
`onSendError` identifies the main outbox message even when its quote, metadata,
|
|
21
|
+
file chunks, or local persistence failed. The original error is also reported to
|
|
22
|
+
`onError` unchanged. It fires once per failed send attempt (including retries),
|
|
23
|
+
not when persisted failures are read, nor for inbox/download/deletion operations
|
|
24
|
+
or cancelled/closed sends. Apps own route visibility and user-intent filtering;
|
|
25
|
+
automatic retries may also fail. Callbacks must not throw. Errors are not copied
|
|
26
|
+
into persisted outbox records.
|
|
19
27
|
|
|
20
28
|
`enqueue({ peer, event, context, requiredFiles, deletion })` preserves prepared
|
|
21
29
|
identities. Local message commits and remote stages have independent checkpoints.
|
|
@@ -37,3 +45,34 @@ passing their established factories, as Zillion does.
|
|
|
37
45
|
External recovery factories are owned by their caller and must be closed after
|
|
38
46
|
the session. Work storage opened by the session is closed by the session.
|
|
39
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 = () => {}, 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
|
}
|
|
@@ -207,9 +213,34 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
207
213
|
entries.delete(entry.id)
|
|
208
214
|
emit()
|
|
209
215
|
} catch (error) {
|
|
210
|
-
if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
|
|
216
|
+
if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
|
|
217
|
+
entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
|
|
218
|
+
await storage.put(entry, { existing: true }).catch(onError)
|
|
219
|
+
onError(error)
|
|
220
|
+
// The failed wire event may be a quote or file chunk. Report the
|
|
221
|
+
// owning outbox item separately, without mutating the native error.
|
|
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 })
|
|
224
|
+
}
|
|
211
225
|
emit()
|
|
212
|
-
}
|
|
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
|
+
})
|
|
213
244
|
}
|
|
214
245
|
async function pump () {
|
|
215
246
|
if (sending || !available || closed || !messenger || !storage) return
|
|
@@ -220,7 +251,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
220
251
|
if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
|
|
221
252
|
await sendEntry(entry)
|
|
222
253
|
}
|
|
223
|
-
} finally { sending = false; settled() }
|
|
254
|
+
} finally { sending = false; watchOfflineSends(); settled() }
|
|
224
255
|
}
|
|
225
256
|
const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
|
|
226
257
|
async function download (file, options = {}) {
|
|
@@ -259,6 +290,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
259
290
|
},
|
|
260
291
|
async setAvailable (value) {
|
|
261
292
|
available = value === true
|
|
293
|
+
stopOnlineRetry(); offlineSends.clear()
|
|
262
294
|
lifecycle++
|
|
263
295
|
if (!available) { await messenger?.pause('signer'); return }
|
|
264
296
|
await ready()
|
|
@@ -279,12 +311,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
279
311
|
return id
|
|
280
312
|
},
|
|
281
313
|
async retry (id) {
|
|
314
|
+
offlineSends.delete(id); watchOfflineSends()
|
|
282
315
|
await ready()
|
|
283
316
|
const entry = entries.get(id)
|
|
284
317
|
if (entry) { entry.failed = false; if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
|
|
285
318
|
},
|
|
286
319
|
async cancel (id) {
|
|
287
320
|
cancelled.add(id)
|
|
321
|
+
sendControllers.get(id)?.abort()
|
|
322
|
+
offlineSends.delete(id); watchOfflineSends()
|
|
288
323
|
await ready()
|
|
289
324
|
entries.delete(id)
|
|
290
325
|
await storage.remove(id)
|
|
@@ -292,6 +327,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
292
327
|
},
|
|
293
328
|
async close () {
|
|
294
329
|
closed = true; available = false; lifecycle++
|
|
330
|
+
stopOnlineRetry(); offlineSends.clear()
|
|
331
|
+
for (const controller of sendControllers.values()) controller.abort()
|
|
295
332
|
await Promise.allSettled([configuring, initialized])
|
|
296
333
|
await messenger?.pause('closed')
|
|
297
334
|
if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
|