libp2r2p 0.11.9 → 0.11.11
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 -4
- package/content-key/services/iykc-proof.js +77 -34
- package/helpers/ranges.js +31 -0
- package/package.json +1 -1
- package/private-channel/helpers/history.js +47 -3
- package/private-channel/helpers/lookup-warning.js +29 -0
- package/private-channel/index.js +23 -4
- package/private-messenger/file/index.js +39 -5
- package/private-messenger/helpers/recovery-retry.js +16 -0
- package/private-messenger/index.js +322 -44
- package/private-messenger/session/README.md +10 -3
- package/private-messenger/session/index.js +51 -13
package/README.md
CHANGED
|
@@ -532,15 +532,26 @@ are attached. These diagnostics reach the messenger's `onError` unchanged.
|
|
|
532
532
|
A single incomplete relay keeps the interval pending even if another relay
|
|
533
533
|
returned events; received messages may already have been queued. This is a
|
|
534
534
|
history completeness failure, not evidence that an outgoing message failed.
|
|
535
|
-
|
|
536
|
-
|
|
535
|
+
Rewatch/resume, relay-list refresh and reload-gap paths schedule bounded retries
|
|
536
|
+
for pending intervals (1s doubling to 30s). Recovery tracks per-relay coverage in
|
|
537
|
+
memory: a tier where some relay returned events and EOSE retries the remaining
|
|
538
|
+
relays a few times (3), an empty-EOSE tier gets more attempts (6), and an
|
|
539
|
+
all-failed tier gets the most (10) before coverage from EOSE relays is accepted
|
|
540
|
+
and the uncovered subranges plus the pre-oldest left edge are requested from
|
|
541
|
+
seeders. Offline attempts never consume the budget, mark coverage or contact
|
|
542
|
+
seeders; they wait for connectivity. The per-relay tracker is process-local, so a
|
|
543
|
+
restart falls back to the persisted whole interval.
|
|
537
544
|
`private-channel.fetchHistory(options)` (0.11.6) is the complete-interval API
|
|
538
545
|
used by messenger recovery. It accepts the signers, processor/fragment options,
|
|
539
546
|
callbacks and abort signal of `fetch`, plus inclusive `since`/`until` bounds
|
|
540
547
|
(defaults: 0 and the start time of the call). It returns only
|
|
541
548
|
`{ oldestCreatedAt, receivedEventCount, relays }`; an empty scan has a null oldest
|
|
542
|
-
time.
|
|
543
|
-
|
|
549
|
+
time. Passing `partial: true` (used by messenger recovery) returns per-relay
|
|
550
|
+
`covered`/`pending`/`events` plus `pendingByRelay`, `anyEose`, `anyEoseWithEvents`
|
|
551
|
+
and `allFailed` instead of throwing on partial failure, and `resume` restricts
|
|
552
|
+
each relay to the given pending subranges. No complete-history event array is
|
|
553
|
+
retained. `fetch` keeps its existing single-query array contract and optional
|
|
554
|
+
limit.
|
|
544
555
|
|
|
545
556
|
History queries each relay independently with limit 16. A response with 16 events
|
|
546
557
|
or status `satisfied` is saturated, even if it already reports EOSE or contains
|
|
@@ -2,11 +2,13 @@ import { CONTENT_KEY_KIND, parseContentKeyEvent } from '../event/index.js'
|
|
|
2
2
|
import { pickRelaysForPubkeys, getRelaysByPubkey, relayPool } from '../../relay/index.js'
|
|
3
3
|
|
|
4
4
|
const QUERY_CACHE_MS = 40 * 60 * 1000
|
|
5
|
+
const NEGATIVE_CACHE_MS = 5 * 60 * 1000
|
|
5
6
|
const IYKC_CACHE_MAX_ITEMS = 10000
|
|
6
7
|
const HEX_PUBKEY = /^[0-9a-f]{64}$/i
|
|
7
8
|
const contentKeysByPubkey = Object.create(null)
|
|
8
9
|
const iykcCacheTimersByPubkey = Object.create(null)
|
|
9
10
|
const iykcCacheAddedAtByPubkey = Object.create(null)
|
|
11
|
+
const inFlightByPubkey = new Map()
|
|
10
12
|
|
|
11
13
|
const getEvents = (...args) => relayPool.getEvents(...args)
|
|
12
14
|
|
|
@@ -68,42 +70,30 @@ export function clearContentKeyCache () {
|
|
|
68
70
|
for (const key of Object.keys(contentKeysByPubkey)) delete contentKeysByPubkey[key]
|
|
69
71
|
for (const key of Object.keys(iykcCacheTimersByPubkey)) delete iykcCacheTimersByPubkey[key]
|
|
70
72
|
for (const key of Object.keys(iykcCacheAddedAtByPubkey)) delete iykcCacheAddedAtByPubkey[key]
|
|
73
|
+
inFlightByPubkey.clear()
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
cacheMs = QUERY_CACHE_MS
|
|
77
|
-
} = {}) {
|
|
78
|
-
const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
|
|
79
|
-
if (!pubkeyList.length) return {}
|
|
80
|
-
|
|
81
|
-
const out = {}
|
|
82
|
-
const missingPubkeys = []
|
|
83
|
-
for (const pubkey of pubkeyList) {
|
|
84
|
-
if (!hasCachedKey(contentKeysByPubkey, pubkey)) {
|
|
85
|
-
missingPubkeys.push(pubkey)
|
|
86
|
-
continue
|
|
87
|
-
}
|
|
88
|
-
const cached = cloneContentKey(contentKeysByPubkey[pubkey])
|
|
89
|
-
if (cached) out[pubkey] = cached
|
|
90
|
-
}
|
|
91
|
-
if (!missingPubkeys.length) return out
|
|
92
|
-
|
|
93
|
-
const relaysByPubkey = await _getRelaysByPubkey(missingPubkeys, { _getEvents, cacheMs })
|
|
94
|
-
const relayToAuthors = pickRelaysForPubkeys(missingPubkeys, relaysByPubkey)
|
|
76
|
+
async function queryContentKeyProofs (pubkeyList, { _getEvents, _getRelaysByPubkey, cacheMs, negativeCacheMs }) {
|
|
77
|
+
const relaysByPubkey = await _getRelaysByPubkey(pubkeyList, { _getEvents, cacheMs })
|
|
78
|
+
const relayToAuthors = pickRelaysForPubkeys(pubkeyList, relaysByPubkey)
|
|
95
79
|
const eventGroups = await Promise.all(
|
|
96
80
|
[...relayToAuthors.entries()]
|
|
97
81
|
.map(async ([relay, authors]) => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
82
|
+
try {
|
|
83
|
+
const { result } = await _getEvents({
|
|
84
|
+
kinds: [CONTENT_KEY_KIND],
|
|
85
|
+
authors,
|
|
86
|
+
limit: authors.length
|
|
87
|
+
}, [relay], {
|
|
88
|
+
timeout: 5000,
|
|
89
|
+
timeoutAfterFirstEose: null
|
|
90
|
+
})
|
|
91
|
+
return result.map(({ event }) => event)
|
|
92
|
+
} catch {
|
|
93
|
+
// Discovery is best-effort: a failing relay group must not fail an
|
|
94
|
+
// otherwise publishable message.
|
|
95
|
+
return []
|
|
96
|
+
}
|
|
107
97
|
})
|
|
108
98
|
)
|
|
109
99
|
|
|
@@ -116,14 +106,67 @@ export async function getIykcProofs (pubkeys, {
|
|
|
116
106
|
}
|
|
117
107
|
}
|
|
118
108
|
|
|
119
|
-
|
|
109
|
+
const out = {}
|
|
110
|
+
for (const pubkey of pubkeyList) {
|
|
120
111
|
const entry = latestByPubkey[pubkey]
|
|
121
112
|
const proof = entry
|
|
122
113
|
? { iykcPubkey: entry.iykcPubkey, iykcProof: entry.iykcProof }
|
|
123
114
|
: null
|
|
124
|
-
|
|
125
|
-
|
|
115
|
+
if (proof) {
|
|
116
|
+
setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, cloneContentKey(proof), cacheMs)
|
|
117
|
+
out[pubkey] = proof
|
|
118
|
+
} else if (negativeCacheMs > 0) {
|
|
119
|
+
// Misses expire quickly so a peer that publishes a key right after a
|
|
120
|
+
// prefetch is picked up without waiting for the positive cache window.
|
|
121
|
+
setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, null, negativeCacheMs)
|
|
122
|
+
}
|
|
126
123
|
}
|
|
127
124
|
pruneCache(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, IYKC_CACHE_MAX_ITEMS)
|
|
128
125
|
return out
|
|
129
126
|
}
|
|
127
|
+
|
|
128
|
+
export async function getIykcProofs (pubkeys, {
|
|
129
|
+
_getEvents = getEvents,
|
|
130
|
+
_getRelaysByPubkey = getRelaysByPubkey,
|
|
131
|
+
cacheMs = QUERY_CACHE_MS,
|
|
132
|
+
negativeCacheMs = NEGATIVE_CACHE_MS
|
|
133
|
+
} = {}) {
|
|
134
|
+
const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
|
|
135
|
+
if (!pubkeyList.length) return {}
|
|
136
|
+
|
|
137
|
+
const out = {}
|
|
138
|
+
const waiting = []
|
|
139
|
+
const toQuery = []
|
|
140
|
+
for (const pubkey of pubkeyList) {
|
|
141
|
+
if (hasCachedKey(contentKeysByPubkey, pubkey)) {
|
|
142
|
+
const cached = cloneContentKey(contentKeysByPubkey[pubkey])
|
|
143
|
+
if (cached) out[pubkey] = cached
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
const pending = inFlightByPubkey.get(pubkey)
|
|
147
|
+
if (pending) waiting.push([pubkey, pending])
|
|
148
|
+
else toQuery.push(pubkey)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (toQuery.length) {
|
|
152
|
+
const query = queryContentKeyProofs(toQuery, { _getEvents, _getRelaysByPubkey, cacheMs, negativeCacheMs })
|
|
153
|
+
for (const pubkey of toQuery) inFlightByPubkey.set(pubkey, query)
|
|
154
|
+
try {
|
|
155
|
+
Object.assign(out, await query)
|
|
156
|
+
} finally {
|
|
157
|
+
for (const pubkey of toQuery) {
|
|
158
|
+
if (inFlightByPubkey.get(pubkey) === query) inFlightByPubkey.delete(pubkey)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const [pubkey, pending] of waiting) {
|
|
164
|
+
const found = await pending.catch(() => null)
|
|
165
|
+
if (found?.[pubkey]) out[pubkey] = found[pubkey]
|
|
166
|
+
else {
|
|
167
|
+
const cached = cloneContentKey(contentKeysByPubkey[pubkey])
|
|
168
|
+
if (cached) out[pubkey] = cached
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out
|
|
172
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Inclusive integer ranges [start, end]. Merge treats adjacent ranges as one.
|
|
2
|
+
export function mergeRanges (ranges = []) {
|
|
3
|
+
const sorted = (ranges || [])
|
|
4
|
+
.filter(range => range && Number.isFinite(range.start) && Number.isFinite(range.end) && range.end >= range.start)
|
|
5
|
+
.map(range => ({ start: range.start, end: range.end }))
|
|
6
|
+
.sort((a, b) => a.start - b.start || a.end - b.end)
|
|
7
|
+
const out = []
|
|
8
|
+
for (const range of sorted) {
|
|
9
|
+
const last = out[out.length - 1]
|
|
10
|
+
if (!last || range.start > last.end + 1) out.push(range)
|
|
11
|
+
else last.end = Math.max(last.end, range.end)
|
|
12
|
+
}
|
|
13
|
+
return out
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Parts of `range` not covered by `covered`.
|
|
17
|
+
export function subtractRanges (range, covered = []) {
|
|
18
|
+
if (!range || range.end < range.start) return []
|
|
19
|
+
const merged = mergeRanges(covered)
|
|
20
|
+
const out = []
|
|
21
|
+
let cursor = range.start
|
|
22
|
+
for (const entry of merged) {
|
|
23
|
+
if (entry.end < cursor) continue
|
|
24
|
+
if (entry.start > range.end) break
|
|
25
|
+
if (entry.start > cursor) out.push({ start: cursor, end: Math.min(entry.start - 1, range.end) })
|
|
26
|
+
cursor = Math.max(cursor, entry.end + 1)
|
|
27
|
+
if (cursor > range.end) break
|
|
28
|
+
}
|
|
29
|
+
if (cursor <= range.end) out.push({ start: cursor, end: range.end })
|
|
30
|
+
return out
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ValidationError } from '../../error/index.js'
|
|
2
2
|
import { normalizeRelayUrl } from '../../url/index.js'
|
|
3
3
|
import { createAbortableSemaphore } from '../../helpers/abortable-semaphore.js'
|
|
4
|
+
import { mergeRanges } from '../../helpers/ranges.js'
|
|
4
5
|
import { incompleteFetchError } from './fetch-error.js'
|
|
5
6
|
|
|
6
7
|
const PAGE_SIZE = 16
|
|
@@ -23,7 +24,7 @@ function acquireRelay (relay, signal) {
|
|
|
23
24
|
|
|
24
25
|
// Queries bounded temporal leaves, oldest first. A saturated one-second leaf
|
|
25
26
|
// cannot be split using standard NIP-01; expand it only up to the safe ceiling.
|
|
26
|
-
export async function readHistory ({ filter, relays, receiverPubkey, signal, getEvents, processEvent, acquirePage }) {
|
|
27
|
+
export async function readHistory ({ filter, relays, receiverPubkey, signal, getEvents, processEvent, acquirePage, partial = false, resume = null }) {
|
|
27
28
|
const since = filter.since ?? 0
|
|
28
29
|
const until = filter.until ?? Math.floor(Date.now() / 1000)
|
|
29
30
|
if (![since, until].every(value => Number.isSafeInteger(value) && value >= 0) || since > until) throw new ValidationError('INVALID_PRIVATE_CHANNEL_HISTORY_RANGE')
|
|
@@ -44,7 +45,10 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
for (const relay of urls) {
|
|
47
|
-
const
|
|
48
|
+
const relayStartCount = receivedEventCount
|
|
49
|
+
const intervals = resumeIntervalsFor({ resume, relay, since, until })
|
|
50
|
+
.map(interval => ({ since: interval.start, until: interval.end, limit: PAGE_SIZE }))
|
|
51
|
+
const covered = []
|
|
48
52
|
let outcome = { relay, status: 'eose' }
|
|
49
53
|
while (intervals.length) {
|
|
50
54
|
signal?.throwIfAborted()
|
|
@@ -77,6 +81,7 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
77
81
|
signal?.throwIfAborted()
|
|
78
82
|
errors.push({ relay, reason: error })
|
|
79
83
|
outcome = { relay, status: 'error', error }
|
|
84
|
+
intervals.push(interval)
|
|
80
85
|
break
|
|
81
86
|
} finally { elapsedMs += performance.now() - started }
|
|
82
87
|
signal?.throwIfAborted()
|
|
@@ -85,6 +90,7 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
85
90
|
await process(page.result)
|
|
86
91
|
errors.push(...(page.errors ?? []))
|
|
87
92
|
outcome = status ?? { relay, status: 'error' }
|
|
93
|
+
intervals.push(interval)
|
|
88
94
|
break
|
|
89
95
|
}
|
|
90
96
|
if (status.status === 'satisfied' || page.result.length >= interval.limit) {
|
|
@@ -98,10 +104,12 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
98
104
|
const error = pageLimitError()
|
|
99
105
|
errors.push({ relay, reason: error })
|
|
100
106
|
outcome = { relay, status: 'error', error }
|
|
107
|
+
intervals.push(interval)
|
|
101
108
|
break
|
|
102
109
|
}
|
|
103
110
|
} else {
|
|
104
111
|
await process(page.result)
|
|
112
|
+
covered.push({ start: interval.since, end: interval.until })
|
|
105
113
|
}
|
|
106
114
|
signal?.throwIfAborted()
|
|
107
115
|
} finally {
|
|
@@ -109,9 +117,34 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
109
117
|
releaseRelay?.()
|
|
110
118
|
}
|
|
111
119
|
}
|
|
112
|
-
report.push(
|
|
120
|
+
report.push({
|
|
121
|
+
...outcome,
|
|
122
|
+
...(partial
|
|
123
|
+
? {
|
|
124
|
+
covered: mergeRanges(covered),
|
|
125
|
+
pending: mergeRanges(intervals.map(interval => ({ start: interval.since, end: interval.until }))),
|
|
126
|
+
events: receivedEventCount - relayStartCount
|
|
127
|
+
}
|
|
128
|
+
: {})
|
|
129
|
+
})
|
|
113
130
|
}
|
|
114
131
|
signal?.throwIfAborted()
|
|
132
|
+
if (partial) {
|
|
133
|
+
const pendingByRelay = {}
|
|
134
|
+
for (const entry of report) {
|
|
135
|
+
if (entry.pending?.length) pendingByRelay[entry.relay] = entry.pending
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
oldestCreatedAt,
|
|
139
|
+
receivedEventCount,
|
|
140
|
+
elapsedMs: Math.round(elapsedMs),
|
|
141
|
+
relays: report,
|
|
142
|
+
pendingByRelay,
|
|
143
|
+
anyEose: report.some(entry => entry.status === 'eose'),
|
|
144
|
+
anyEoseWithEvents: report.some(entry => entry.status === 'eose' && (entry.events ?? 0) > 0),
|
|
145
|
+
allFailed: report.every(entry => entry.status !== 'eose')
|
|
146
|
+
}
|
|
147
|
+
}
|
|
115
148
|
if (errors.length || report.some(item => item.status !== 'eose')) {
|
|
116
149
|
const error = incompleteFetchError({ errors, report, receivedEventCount, elapsedMs: Math.round(elapsedMs), request: { relays: urls, channelPubkeys: filter.authors ?? [], receiverPubkey, since, until, limit: PAGE_SIZE, timeoutMs: TIMEOUT_MS } })
|
|
117
150
|
error.operation = 'private-channel.fetchHistory'
|
|
@@ -119,3 +152,14 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
119
152
|
}
|
|
120
153
|
return { oldestCreatedAt, receivedEventCount, relays: report }
|
|
121
154
|
}
|
|
155
|
+
|
|
156
|
+
function resumeIntervalsFor ({ resume, relay, since, until }) {
|
|
157
|
+
const pending = resume && Object.prototype.hasOwnProperty.call(resume, relay) ? resume[relay] : null
|
|
158
|
+
const intervals = Array.isArray(pending) ? pending : [{ start: since, end: until }]
|
|
159
|
+
return mergeRanges(intervals
|
|
160
|
+
.map(interval => ({
|
|
161
|
+
start: Math.max(since, Math.floor(Number(interval?.start))),
|
|
162
|
+
end: Math.min(until, Math.floor(Number(interval?.end)))
|
|
163
|
+
}))
|
|
164
|
+
.filter(interval => Number.isSafeInteger(interval.start) && Number.isSafeInteger(interval.end) && interval.end >= interval.start))
|
|
165
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const LOOKUP_WARNING_INTERVAL_MS = 30_000
|
|
2
|
+
|
|
3
|
+
// Best-effort discovery can fail on every message while a relay or the
|
|
4
|
+
// network is unavailable. Keep the first failure visible, then debounce
|
|
5
|
+
// repeats and report how many were suppressed on the next log.
|
|
6
|
+
export function createLookupWarningLogger ({
|
|
7
|
+
intervalMs = LOOKUP_WARNING_INTERVAL_MS,
|
|
8
|
+
label = 'private-messenger content-key lookup failed',
|
|
9
|
+
warn = (...args) => console.warn(...args),
|
|
10
|
+
now = Date.now
|
|
11
|
+
} = {}) {
|
|
12
|
+
let warnedAt = null
|
|
13
|
+
let suppressed = 0
|
|
14
|
+
return (error, context = '') => {
|
|
15
|
+
const current = now()
|
|
16
|
+
if (warnedAt !== null && current - warnedAt < intervalMs) {
|
|
17
|
+
suppressed++
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
const count = suppressed
|
|
21
|
+
warnedAt = current
|
|
22
|
+
suppressed = 0
|
|
23
|
+
warn(
|
|
24
|
+
`${label}${context ? ` (${context})` : ''}${count ? ` [${count} repeated failures suppressed]` : ''}`,
|
|
25
|
+
error?.message ?? error
|
|
26
|
+
)
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
}
|
package/private-channel/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { normalizeRumor, deliveryInfo } from './helpers/rumor.js'
|
|
|
9
9
|
import { incompleteFetchError } from './helpers/fetch-error.js'
|
|
10
10
|
import { readHistory } from './helpers/history.js'
|
|
11
11
|
import { subscriptionError } from './helpers/subscription-error.js'
|
|
12
|
+
import { createLookupWarningLogger } from './helpers/lookup-warning.js'
|
|
12
13
|
import * as nip44v3 from '../nip44-v3/index.js'
|
|
13
14
|
import { relayPool } from '../relay/index.js'
|
|
14
15
|
import { JSONL_CHUNK_BYTES, NYM_CARRIER_CHUNK_CHARS } from './helpers/chunk-size.js'
|
|
@@ -35,6 +36,7 @@ const HEX_SECKEY = /^[0-9a-f]{64}$/i
|
|
|
35
36
|
const HEX_PUBKEY = /^[0-9a-f]{64}$/i
|
|
36
37
|
const encoder = new TextEncoder()
|
|
37
38
|
const decoder = new TextDecoder()
|
|
39
|
+
const warnLookupFailure = createLookupWarningLogger()
|
|
38
40
|
const NIP44_V3_SCOPE = ''
|
|
39
41
|
|
|
40
42
|
const sendToRelays = (event, relays) => relayPool.sendEvent(event, relays)
|
|
@@ -110,7 +112,19 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
|
|
|
110
112
|
const useDoubleDh = typeof senderSigner.nip44EncryptDoubleDH === 'function'
|
|
111
113
|
const channelPubkey = await privateChannelSigner.getPublicKey()
|
|
112
114
|
const channelReaderPubkey = privateChannelReaderPubkey || channelPubkey
|
|
113
|
-
|
|
115
|
+
let receiverContentKeys = {}
|
|
116
|
+
if (useDoubleDh) {
|
|
117
|
+
const receiversWithoutContentKeys = receiverPubkeysWithoutContentKeys(receivers)
|
|
118
|
+
try {
|
|
119
|
+
receiverContentKeys = await _getIykcProofs(receiversWithoutContentKeys) || {}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// Content-key discovery is best-effort; without a proof the row still
|
|
122
|
+
// works in sender-content mode instead of failing the send. Keep
|
|
123
|
+
// failures visible without logging on every message during an outage.
|
|
124
|
+
warnLookupFailure(error, `recipients: ${receiversWithoutContentKeys.length}`)
|
|
125
|
+
receiverContentKeys = {}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
114
128
|
const preparedRows = await prepareEnvelopeRows({
|
|
115
129
|
senderSigner,
|
|
116
130
|
imkcSigner: useDoubleDh ? imkcSigner : null,
|
|
@@ -390,13 +404,18 @@ async function unwrapRecipientEnvelope ({ payloadCiphertext, envelope, receiverS
|
|
|
390
404
|
if (envelope.iykcPubkey) {
|
|
391
405
|
assertValidEnvelopeIykcProof(envelope)
|
|
392
406
|
}
|
|
407
|
+
// In self-encryption the advertised sender content key is also our own.
|
|
408
|
+
// Rows written without a content-key lookup (useContentKeys: false) carry
|
|
409
|
+
// no iykc pubkey, so hand imkc to the signer as the receiver's own key.
|
|
410
|
+
const ownContentPubkey = envelope.iykcPubkey ||
|
|
411
|
+
(senderPubkey && receiverPubkey && senderPubkey === receiverPubkey ? imkcPubkey : '')
|
|
393
412
|
messageSeckey = base64ToText(await receiverSigner.nip44DecryptDoubleDH(
|
|
394
413
|
senderPubkey,
|
|
395
414
|
ROUTER_KIND,
|
|
396
415
|
rowScope,
|
|
397
416
|
envelope.ciphertext,
|
|
398
417
|
imkcPubkey,
|
|
399
|
-
|
|
418
|
+
ownContentPubkey
|
|
400
419
|
))
|
|
401
420
|
} else {
|
|
402
421
|
if (!receiverSigner?.nip44v3Decrypt) throw new ValidationError('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
|
|
@@ -953,12 +972,12 @@ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChanne
|
|
|
953
972
|
|
|
954
973
|
// Complete interval recovery with bounded pages and one fragment processor.
|
|
955
974
|
// Existing fetch() retains its one-query/event-array contract.
|
|
956
|
-
export async function fetchHistory ({ receiverSigner, privateChannelSigner = receiverSigner, _getEvents = getEvents, _acquirePage, ...options }) {
|
|
975
|
+
export async function fetchHistory ({ receiverSigner, privateChannelSigner = receiverSigner, _getEvents = getEvents, _acquirePage, partial = false, resume = null, ...options }) {
|
|
957
976
|
const authors = privateChannelPubkeyList(options)
|
|
958
977
|
const filter = { kinds: [PRIVATE_BROADCAST_KIND], ...(authors.length ? { authors } : {}), since: options.since, until: options.until }
|
|
959
978
|
const processOuterEvent = createProcessor({ ...options, receiverSigner, privateChannelSigner, onError: error => { options.onError?.(error); throw error } })
|
|
960
979
|
try {
|
|
961
|
-
return await readHistory({ filter, relays: options.relays, receiverPubkey: options.receiverPubkey, signal: options.signal, getEvents: _getEvents, processEvent: processOuterEvent, acquirePage: _acquirePage })
|
|
980
|
+
return await readHistory({ filter, relays: options.relays, receiverPubkey: options.receiverPubkey, signal: options.signal, getEvents: _getEvents, processEvent: processOuterEvent, acquirePage: _acquirePage, partial, resume })
|
|
962
981
|
} finally { processOuterEvent.close() }
|
|
963
982
|
}
|
|
964
983
|
|
|
@@ -243,6 +243,16 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
243
243
|
const requestedSeeders = new Set()
|
|
244
244
|
const validSeeders = new Set([file.peerPubkey, ...messenger.recoverySeeders(file.controlChannelPubkey)])
|
|
245
245
|
let terminalError
|
|
246
|
+
let lastError = null
|
|
247
|
+
const reportError = error => {
|
|
248
|
+
lastError = {
|
|
249
|
+
name: error?.name || 'Error',
|
|
250
|
+
message: error?.message ?? String(error),
|
|
251
|
+
code: error?.code || '',
|
|
252
|
+
at: Date.now()
|
|
253
|
+
}
|
|
254
|
+
onError(error)
|
|
255
|
+
}
|
|
246
256
|
const fail = error => { terminalError ||= error }
|
|
247
257
|
async function accept (event, index, { save = true } = {}) {
|
|
248
258
|
signal.throwIfAborted()
|
|
@@ -297,7 +307,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
297
307
|
})
|
|
298
308
|
// Malformed remote records do not poison the ingestion chain. Operational
|
|
299
309
|
// persistence failures stop the job so completion cannot outrun storage.
|
|
300
|
-
ingest = task.catch(error => { if (error instanceof ValidationError)
|
|
310
|
+
ingest = task.catch(error => { if (error instanceof ValidationError) reportError(error); else if (!signal.aborted) fail(error) })
|
|
301
311
|
return ingest
|
|
302
312
|
}
|
|
303
313
|
try {
|
|
@@ -312,7 +322,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
312
322
|
if (total !== undefined && persisted.size === total) return
|
|
313
323
|
const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
|
|
314
324
|
const relays = await messenger.resolveWatchRelays(parent)
|
|
315
|
-
const options = { receiverSigner: messenger.userSigner, privateChannelSigner: file.signer, privateChannelPubkey: file.fileChannelPubkey, receiverPubkey: messenger.userPubkey, relays, receivedChunkIndexedDB: messenger._indexedDB, receivedChunkScope: `${messenger.userPubkey}:file:${file.fileChannelPubkey}`, onEvent: consume, onSeedEvent: seed => saveSeed(file, seed), mode: messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? 'watchtower' : 'leecher', onError }
|
|
325
|
+
const options = { receiverSigner: messenger.userSigner, privateChannelSigner: file.signer, privateChannelPubkey: file.fileChannelPubkey, receiverPubkey: messenger.userPubkey, relays, receivedChunkIndexedDB: messenger._indexedDB, receivedChunkScope: `${messenger.userPubkey}:file:${file.fileChannelPubkey}`, onEvent: consume, onSeedEvent: seed => saveSeed(file, seed), mode: messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? 'watchtower' : 'leecher', onError: reportError }
|
|
316
326
|
sub = _transport.subscribe({ ...options, liveOnly: true })
|
|
317
327
|
const stop = () => { sub?.close() }
|
|
318
328
|
signal.addEventListener('abort', stop, { once: true })
|
|
@@ -323,7 +333,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
323
333
|
const history = _transport.fetch({ ...options, signal: AbortSignal.any([signal, historyController.signal]), since: 0 }).catch(error => {
|
|
324
334
|
// Completion aborts the remaining history read in finally. Only its
|
|
325
335
|
// own cancellation is expected; genuine history failures stay visible.
|
|
326
|
-
if (!signal.aborted && !(historyController.signal.aborted && error === historyController.signal.reason))
|
|
336
|
+
if (!signal.aborted && !(historyController.signal.aborted && error === historyController.signal.reason)) reportError(error)
|
|
327
337
|
})
|
|
328
338
|
work.add(history); history.finally(() => work.delete(history))
|
|
329
339
|
const seeders = [...validSeeders].sort((a, b) => (successes.get(b) || 0) - (successes.get(a) || 0) || Math.random() - 0.5)
|
|
@@ -340,9 +350,33 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
340
350
|
requestedSeeders.add(peer)
|
|
341
351
|
requestRoundAt = Date.now() + _hedgeMs
|
|
342
352
|
// The request is on dm; its compact reply is on the file channel.
|
|
343
|
-
await messenger.ask({ channelPubkey: file.controlChannelPubkey, receiverPubkey: peer, code: FILE_CHUNKS_REQUEST_CODE, payload: { fileChannelPubkey: file.fileChannelPubkey, missingRanges: rangesFor(missing) } }).catch(
|
|
353
|
+
await messenger.ask({ channelPubkey: file.controlChannelPubkey, receiverPubkey: peer, code: FILE_CHUNKS_REQUEST_CODE, payload: { fileChannelPubkey: file.fileChannelPubkey, missingRanges: rangesFor(missing) } }).catch(reportError)
|
|
354
|
+
}
|
|
355
|
+
if (!remaining.length && Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs) >= _idleMs) {
|
|
356
|
+
const details = {
|
|
357
|
+
operation: 'file-download-stall',
|
|
358
|
+
ownerPubkey: messenger.userPubkey,
|
|
359
|
+
root: file.root,
|
|
360
|
+
peerPubkey: file.peerPubkey,
|
|
361
|
+
total,
|
|
362
|
+
persisted: persisted.size,
|
|
363
|
+
missingRanges: rangesFor(missing),
|
|
364
|
+
missingCount: total === undefined ? null : total - persisted.size,
|
|
365
|
+
seeders: [...validSeeders],
|
|
366
|
+
requestedSeeders: [...requestedSeeders],
|
|
367
|
+
lastProgressAt: new Date(lastProgress).toISOString(),
|
|
368
|
+
lastProgressAgoMs: Date.now() - lastProgress,
|
|
369
|
+
elapsedMs: Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs),
|
|
370
|
+
lastError
|
|
371
|
+
}
|
|
372
|
+
const error = new Error('FILE_DOWNLOAD_STALLED')
|
|
373
|
+
error.code = 'FILE_DOWNLOAD_STALLED'
|
|
374
|
+
error.operation = details.operation
|
|
375
|
+
error.elapsedMs = details.elapsedMs
|
|
376
|
+
error.details = details
|
|
377
|
+
console.warn('private-messenger file download stalled', details)
|
|
378
|
+
throw error
|
|
344
379
|
}
|
|
345
|
-
if (!remaining.length && Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs) >= _idleMs) throw new Error('FILE_DOWNLOAD_STALLED')
|
|
346
380
|
// Start a fresh batch when all positions of the previous batch arrived.
|
|
347
381
|
if (job.batch?.every(index => persisted.has(index))) { requestedSeeders.clear(); requestRoundAt = Date.now(); job.batch = null }
|
|
348
382
|
job.batch ||= missing
|
|
@@ -24,3 +24,19 @@ export function recoveryRetryDelay (errors, delay) {
|
|
|
24
24
|
errors.forEach(visit)
|
|
25
25
|
return retry && !blocked ? Math.max(delay, retryAt - Date.now()) : null
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
// Permanent per-relay failures must stop that relay's retries instead of
|
|
29
|
+
// consuming the range retry budget.
|
|
30
|
+
export function isPermanentRecoveryError (error) {
|
|
31
|
+
const seen = new Set()
|
|
32
|
+
const visit = value => {
|
|
33
|
+
if (!value || seen.has(value)) return false
|
|
34
|
+
seen.add(value)
|
|
35
|
+
if (value.code === 'PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT' || value.name === 'ValidationError' ||
|
|
36
|
+
/^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(value.message || '')) return true
|
|
37
|
+
for (const child of value.errors || []) if (visit(child)) return true
|
|
38
|
+
for (const entry of value.relays || []) if (visit(entry.error)) return true
|
|
39
|
+
return visit(value.cause)
|
|
40
|
+
}
|
|
41
|
+
return visit(error)
|
|
42
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { recoveryRetryDelay } from './helpers/recovery-retry.js'
|
|
2
1
|
// Expected use:
|
|
3
2
|
// const messenger = await createPrivateMessenger({
|
|
4
3
|
// userSigner,
|
|
@@ -44,9 +43,12 @@ import { recoveryRetryDelay } from './helpers/recovery-retry.js'
|
|
|
44
43
|
// - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
|
|
45
44
|
|
|
46
45
|
import { createAbortableSemaphore } from '../helpers/abortable-semaphore.js'
|
|
46
|
+
import { mergeRanges, subtractRanges } from '../helpers/ranges.js'
|
|
47
47
|
import * as privateMessage from '../private-message/index.js'
|
|
48
48
|
import { isOnline } from '../network/index.js'
|
|
49
49
|
import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
|
|
50
|
+
import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
|
|
51
|
+
import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
|
|
50
52
|
import { normalizeRelayUrl } from '../url/index.js'
|
|
51
53
|
import { deliveryInfo } from '../private-channel/helpers/rumor.js'
|
|
52
54
|
import { bytesToBase64 } from '../base64/index.js'
|
|
@@ -122,6 +124,13 @@ const SEED_QUEUE_INDEXES = {
|
|
|
122
124
|
const encoder = new TextEncoder()
|
|
123
125
|
const noContentKeys = async () => ({})
|
|
124
126
|
|
|
127
|
+
export const RECOVERY_RELAY_RETRY_LIMITS = Object.freeze({
|
|
128
|
+
partial: 3,
|
|
129
|
+
emptyEose: 6,
|
|
130
|
+
allFailed: 10
|
|
131
|
+
})
|
|
132
|
+
const RECOVERY_ONLINE_CACHE_MS = 5000
|
|
133
|
+
|
|
125
134
|
function textToBase64 (text) {
|
|
126
135
|
return bytesToBase64(encoder.encode(text))
|
|
127
136
|
}
|
|
@@ -289,6 +298,8 @@ export class PrivateMessenger {
|
|
|
289
298
|
this.recoveries = new Map()
|
|
290
299
|
this.recoveryAdmission = createAbortableSemaphore(2)
|
|
291
300
|
this.recoveryControllers = new Set()
|
|
301
|
+
this.recoveryRanges = new Map()
|
|
302
|
+
this.recoveryOnline = { at: 0, value: true }
|
|
292
303
|
this.resumeWork = null
|
|
293
304
|
this.capacityTimer = null
|
|
294
305
|
this.capacityCheck = null
|
|
@@ -664,6 +675,7 @@ export class PrivateMessenger {
|
|
|
664
675
|
|
|
665
676
|
if (removedPubkeys.length) {
|
|
666
677
|
await this.stampChannelActivity(removedPubkeys)
|
|
678
|
+
for (const pubkey of removedPubkeys) this.dropRecoveryRanges(pubkey)
|
|
667
679
|
}
|
|
668
680
|
|
|
669
681
|
const storageSnapshot = await activatePrivateMessengerStorage({
|
|
@@ -1174,7 +1186,211 @@ export class PrivateMessenger {
|
|
|
1174
1186
|
this.liveRecoveryTimers.delete(pubkey)
|
|
1175
1187
|
}
|
|
1176
1188
|
|
|
1189
|
+
async checkRecoveryOnline () {
|
|
1190
|
+
const now = Date.now()
|
|
1191
|
+
if (now - this.recoveryOnline.at < RECOVERY_ONLINE_CACHE_MS) return this.recoveryOnline.value
|
|
1192
|
+
let value = true
|
|
1193
|
+
try {
|
|
1194
|
+
if (globalThis.navigator?.onLine === false) value = false
|
|
1195
|
+
else if (this._isOnline !== isOnline || typeof globalThis.document !== 'undefined') {
|
|
1196
|
+
value = (await this._isOnline()) !== false
|
|
1197
|
+
}
|
|
1198
|
+
} catch { value = false }
|
|
1199
|
+
this.recoveryOnline = { at: Date.now(), value }
|
|
1200
|
+
return value
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
recoveryRangeKey (channelPubkey, range) {
|
|
1204
|
+
return `${channelPubkey}:${range.start}:${range.end}`
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
recoveryRangeRecord (channelPubkey, range) {
|
|
1208
|
+
const key = this.recoveryRangeKey(channelPubkey, range)
|
|
1209
|
+
let record = this.recoveryRanges.get(key)
|
|
1210
|
+
if (!record) {
|
|
1211
|
+
record = {
|
|
1212
|
+
key,
|
|
1213
|
+
channelPubkey,
|
|
1214
|
+
start: range.start,
|
|
1215
|
+
end: range.end,
|
|
1216
|
+
covered: [],
|
|
1217
|
+
oldestCreatedAt: null,
|
|
1218
|
+
relays: new Map(),
|
|
1219
|
+
tier: '',
|
|
1220
|
+
everEose: false,
|
|
1221
|
+
everEoseWithEvents: false,
|
|
1222
|
+
attempts: 0,
|
|
1223
|
+
lastErrors: []
|
|
1224
|
+
}
|
|
1225
|
+
this.recoveryRanges.set(key, record)
|
|
1226
|
+
}
|
|
1227
|
+
return record
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
reconcileRecoveryRelays (record, relays) {
|
|
1231
|
+
const current = new Set(relays)
|
|
1232
|
+
for (const relay of [...record.relays.keys()]) {
|
|
1233
|
+
if (!current.has(relay)) record.relays.delete(relay)
|
|
1234
|
+
}
|
|
1235
|
+
for (const relay of current) {
|
|
1236
|
+
if (record.relays.has(relay)) continue
|
|
1237
|
+
record.relays.set(relay, {
|
|
1238
|
+
pending: [{ start: record.start, end: record.end }],
|
|
1239
|
+
covered: [],
|
|
1240
|
+
attempts: 0,
|
|
1241
|
+
dead: false,
|
|
1242
|
+
eose: false,
|
|
1243
|
+
events: 0,
|
|
1244
|
+
error: null
|
|
1245
|
+
})
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
recoveryRelayPlan (record) {
|
|
1250
|
+
const plan = {}
|
|
1251
|
+
let hasWork = false
|
|
1252
|
+
for (const [relay, state] of record.relays) {
|
|
1253
|
+
if (state.dead || state.eose || !state.pending?.length) continue
|
|
1254
|
+
plan[relay] = state.pending
|
|
1255
|
+
hasWork = true
|
|
1256
|
+
}
|
|
1257
|
+
return hasWork ? plan : null
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
hasActiveRecoveryRelays (channelPubkey) {
|
|
1261
|
+
for (const record of this.recoveryRanges.values()) {
|
|
1262
|
+
if (record.channelPubkey !== channelPubkey) continue
|
|
1263
|
+
if (this.recoveryRecordActive(record)) return true
|
|
1264
|
+
}
|
|
1265
|
+
return false
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
recoveryRecordActive (record) {
|
|
1269
|
+
for (const state of record.relays.values()) {
|
|
1270
|
+
if (!state.dead && !state.eose && state.pending?.length) return true
|
|
1271
|
+
}
|
|
1272
|
+
return false
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
hasPendingRecovery (channelPubkey) {
|
|
1276
|
+
return (this.readState().channels[channelPubkey]?.offlineRanges || []).length > 0
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
dropRecoveryRanges (channelPubkey) {
|
|
1280
|
+
for (const [key, record] of this.recoveryRanges) {
|
|
1281
|
+
if (record.channelPubkey === channelPubkey) this.recoveryRanges.delete(key)
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
pruneRecoveryRanges (channelPubkey, ranges) {
|
|
1286
|
+
const keep = new Set((ranges || []).map(range => this.recoveryRangeKey(channelPubkey, range)))
|
|
1287
|
+
for (const [key, record] of this.recoveryRanges) {
|
|
1288
|
+
if (record.channelPubkey === channelPubkey && !keep.has(key)) this.recoveryRanges.delete(key)
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
updateRecoveryRecord (record, history) {
|
|
1293
|
+
record.covered = mergeRanges([
|
|
1294
|
+
...record.covered,
|
|
1295
|
+
...(history.relays || []).flatMap(entry => entry.covered || [])
|
|
1296
|
+
])
|
|
1297
|
+
if (history.oldestCreatedAt != null) {
|
|
1298
|
+
record.oldestCreatedAt = record.oldestCreatedAt == null
|
|
1299
|
+
? history.oldestCreatedAt
|
|
1300
|
+
: Math.min(record.oldestCreatedAt, history.oldestCreatedAt)
|
|
1301
|
+
}
|
|
1302
|
+
const byRelay = new Map((history.relays || []).map(entry => [entry.relay, entry]))
|
|
1303
|
+
for (const [relay, state] of record.relays) {
|
|
1304
|
+
const entry = byRelay.get(relay)
|
|
1305
|
+
if (!entry) continue
|
|
1306
|
+
const relayErrors = [...(entry.errors || [])]
|
|
1307
|
+
if (entry.error) relayErrors.push(entry.error)
|
|
1308
|
+
state.error = relayErrors[0] || null
|
|
1309
|
+
const done = entry.status === 'eose' && !(entry.pending?.length)
|
|
1310
|
+
state.covered = mergeRanges([...(state.covered || []), ...(entry.covered || [])])
|
|
1311
|
+
state.eose = done
|
|
1312
|
+
state.pending = done ? [] : subtractRanges({ start: record.start, end: record.end }, state.covered)
|
|
1313
|
+
state.events += entry.events || 0
|
|
1314
|
+
if (done) {
|
|
1315
|
+
state.attempts = 0
|
|
1316
|
+
record.everEose = true
|
|
1317
|
+
if (state.events > 0) record.everEoseWithEvents = true
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
record.tier = record.everEoseWithEvents ? 'partial' : record.everEose ? 'emptyEose' : 'allFailed'
|
|
1321
|
+
record.lastErrors = (history.relays || []).flatMap(entry => [...(entry.errors || []), ...(entry.error ? [entry.error] : [])])
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
applyRecoveryRetryBudget (record) {
|
|
1325
|
+
const limit = RECOVERY_RELAY_RETRY_LIMITS[record.tier] ?? RECOVERY_RELAY_RETRY_LIMITS.allFailed
|
|
1326
|
+
for (const state of record.relays.values()) {
|
|
1327
|
+
if (state.eose || state.dead || !state.pending?.length) continue
|
|
1328
|
+
state.attempts++
|
|
1329
|
+
if (state.attempts >= limit || isPermanentRecoveryError(state.error)) state.dead = true
|
|
1330
|
+
}
|
|
1331
|
+
record.attempts++
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
recoveryIncompleteError (record, history, range) {
|
|
1335
|
+
const errors = []
|
|
1336
|
+
for (const entry of history.relays || []) {
|
|
1337
|
+
for (const reason of entry.errors || []) errors.push({ relay: entry.relay, reason })
|
|
1338
|
+
if (entry.error) errors.push({ relay: entry.relay, reason: entry.error })
|
|
1339
|
+
}
|
|
1340
|
+
const error = incompleteFetchError({
|
|
1341
|
+
errors,
|
|
1342
|
+
report: (history.relays || []).map(entry => ({
|
|
1343
|
+
relay: entry.relay,
|
|
1344
|
+
status: entry.status,
|
|
1345
|
+
...(entry.error ? { error: entry.error } : {})
|
|
1346
|
+
})),
|
|
1347
|
+
receivedEventCount: history.receivedEventCount || 0,
|
|
1348
|
+
elapsedMs: history.elapsedMs || 0,
|
|
1349
|
+
request: {
|
|
1350
|
+
relays: [...record.relays.keys()],
|
|
1351
|
+
channelPubkeys: [record.channelPubkey],
|
|
1352
|
+
receiverPubkey: this.userPubkey,
|
|
1353
|
+
since: range.start,
|
|
1354
|
+
until: range.end,
|
|
1355
|
+
limit: 16,
|
|
1356
|
+
timeoutMs: 5000
|
|
1357
|
+
}
|
|
1358
|
+
})
|
|
1359
|
+
error.operation = 'private-channel.fetchHistory'
|
|
1360
|
+
return error
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
isPartialRecoveryHistory (history) {
|
|
1364
|
+
return Boolean(history) && !Array.isArray(history) &&
|
|
1365
|
+
Array.isArray(history.relays) &&
|
|
1366
|
+
history.pendingByRelay !== undefined &&
|
|
1367
|
+
typeof history.anyEose === 'boolean'
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
async finalizeRecoveryRange ({ channelPubkey, range, record }) {
|
|
1371
|
+
if (!await this.checkRecoveryOnline()) return { complete: false, failures: [], offline: true }
|
|
1372
|
+
const uncovered = subtractRanges({ start: range.start, end: range.end }, record.covered)
|
|
1373
|
+
const oldest = record.oldestCreatedAt
|
|
1374
|
+
const leftEdge = oldest == null
|
|
1375
|
+
? [{ start: range.start, end: range.end }]
|
|
1376
|
+
: oldest > range.start
|
|
1377
|
+
? [{ start: range.start, end: Math.min(oldest, range.end) }]
|
|
1378
|
+
: []
|
|
1379
|
+
const seedRanges = mergeRanges([...uncovered, ...leftEdge])
|
|
1380
|
+
const failures = []
|
|
1381
|
+
for (const seedRange of seedRanges) {
|
|
1382
|
+
const attempt = await this.#askSeedersForMissingRangeAttempt(channelPubkey, seedRange.start, seedRange.end)
|
|
1383
|
+
failures.push(...attempt.failures.map(failure => failure.error))
|
|
1384
|
+
}
|
|
1385
|
+
record.lastErrors = failures
|
|
1386
|
+
return { complete: failures.length === 0, failures }
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1177
1389
|
scheduleLiveRecovery (pubkey) {
|
|
1390
|
+
return this.scheduleOfflineRecovery(pubkey)
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
scheduleOfflineRecovery (pubkey, { startDelayMs = 0 } = {}) {
|
|
1178
1394
|
const current = this.liveRecoveryTimers.get(pubkey)
|
|
1179
1395
|
if (current) { current.again = true; return }
|
|
1180
1396
|
const work = { delay: 1000, timer: null, again: false }
|
|
@@ -1199,7 +1415,11 @@ export class PrivateMessenger {
|
|
|
1199
1415
|
}
|
|
1200
1416
|
if (!isCurrent()) return
|
|
1201
1417
|
const pending = this.readState().channels[pubkey]?.offlineRanges?.length
|
|
1202
|
-
|
|
1418
|
+
let retry = null
|
|
1419
|
+
if (pending) {
|
|
1420
|
+
retry = recoveryRetryDelay(errors, Math.min(30000, work.delay * (0.8 + Math.random() * 0.4)))
|
|
1421
|
+
if (retry === null && this.hasActiveRecoveryRelays(pubkey)) retry = Math.min(30000, work.delay)
|
|
1422
|
+
}
|
|
1203
1423
|
if (work.again || retry !== null) {
|
|
1204
1424
|
schedule(work.again ? 0 : retry)
|
|
1205
1425
|
work.delay = Math.min(30000, work.delay * 2)
|
|
@@ -1207,7 +1427,7 @@ export class PrivateMessenger {
|
|
|
1207
1427
|
}, delay)
|
|
1208
1428
|
work.timer?.unref?.()
|
|
1209
1429
|
}
|
|
1210
|
-
schedule(
|
|
1430
|
+
schedule(startDelayMs)
|
|
1211
1431
|
}
|
|
1212
1432
|
|
|
1213
1433
|
recordInterruption (channels, at = nowSeconds()) {
|
|
@@ -1242,6 +1462,7 @@ export class PrivateMessenger {
|
|
|
1242
1462
|
const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
|
|
1243
1463
|
for (const extension of this.extensions) extension.unwatch?.(pubkeys)
|
|
1244
1464
|
for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
|
|
1465
|
+
for (const pubkey of pubkeys) this.dropRecoveryRanges(pubkey)
|
|
1245
1466
|
this.recordInterruption(pubkeys)
|
|
1246
1467
|
for (const controller of this.recoveryControllers) {
|
|
1247
1468
|
if (pubkeys.includes(controller.channelPubkey)) controller.abort()
|
|
@@ -1278,6 +1499,9 @@ export class PrivateMessenger {
|
|
|
1278
1499
|
if (this.pauseReasons.size || this.closePromise) return
|
|
1279
1500
|
await this.reconcilePresencePublishers()
|
|
1280
1501
|
await this.recoverOfflineRanges(channels)
|
|
1502
|
+
for (const pubkey of channels) {
|
|
1503
|
+
if (this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
|
|
1504
|
+
}
|
|
1281
1505
|
for (const extension of this.extensions) await extension.resume?.()
|
|
1282
1506
|
})()
|
|
1283
1507
|
this.resumeWork = work
|
|
@@ -1348,6 +1572,9 @@ export class PrivateMessenger {
|
|
|
1348
1572
|
}
|
|
1349
1573
|
await this.watch(channelPubkeys, { scheduleReloadGap: false })
|
|
1350
1574
|
await this.recoverOfflineRanges(channelPubkeys)
|
|
1575
|
+
for (const pubkey of channelPubkeys) {
|
|
1576
|
+
if (this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
|
|
1577
|
+
}
|
|
1351
1578
|
}
|
|
1352
1579
|
|
|
1353
1580
|
async handleAsk (channelPubkey, message) {
|
|
@@ -1928,6 +2155,9 @@ export class PrivateMessenger {
|
|
|
1928
2155
|
}
|
|
1929
2156
|
this.writeState(state)
|
|
1930
2157
|
await this.flushStateWrites()
|
|
2158
|
+
for (const channel of channels) {
|
|
2159
|
+
if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryRanges(channel.pubkey)
|
|
2160
|
+
}
|
|
1931
2161
|
for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
|
|
1932
2162
|
})
|
|
1933
2163
|
}
|
|
@@ -1968,6 +2198,9 @@ export class PrivateMessenger {
|
|
|
1968
2198
|
if ((this.watchRevisionByChannel.get(pubkey) || 0) !== revision) return
|
|
1969
2199
|
if (start != null) this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
|
|
1970
2200
|
try { await this.recoverOfflineRanges([pubkey]) } catch (err) { this.onError?.(err) }
|
|
2201
|
+
if (!this.closePromise && !this.pauseReasons.size && this.desiredChannels.has(pubkey) && this.hasPendingRecovery(pubkey)) {
|
|
2202
|
+
this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
|
|
2203
|
+
}
|
|
1971
2204
|
}, this.reloadGapDelayMs)
|
|
1972
2205
|
this.reloadGapTimers.set(pubkey, { timer, token, revision })
|
|
1973
2206
|
}
|
|
@@ -2156,13 +2389,20 @@ export class PrivateMessenger {
|
|
|
2156
2389
|
const failures = []
|
|
2157
2390
|
const state = this.readState()
|
|
2158
2391
|
const now = nowSeconds()
|
|
2392
|
+
const online = await this.checkRecoveryOnline()
|
|
2159
2393
|
|
|
2160
2394
|
for (const pubkey of uniq(channels)) {
|
|
2161
2395
|
const channel = this.channels.get(pubkey)
|
|
2162
2396
|
const current = state.channels[pubkey]
|
|
2163
|
-
if (!channel || !current?.offlineRanges?.length)
|
|
2397
|
+
if (!channel || !current?.offlineRanges?.length) {
|
|
2398
|
+
this.dropRecoveryRanges(pubkey)
|
|
2399
|
+
continue
|
|
2400
|
+
}
|
|
2164
2401
|
const recoverySeconds = this.offlineRecoverySecondsFor(channel)
|
|
2165
|
-
if (!recoverySeconds)
|
|
2402
|
+
if (!recoverySeconds) {
|
|
2403
|
+
this.dropRecoveryRanges(pubkey)
|
|
2404
|
+
continue
|
|
2405
|
+
}
|
|
2166
2406
|
const minStart = now - recoverySeconds
|
|
2167
2407
|
const processedRanges = new Set(current.offlineRanges.map(range => `${range.start}:${range.end}`))
|
|
2168
2408
|
|
|
@@ -2170,6 +2410,7 @@ export class PrivateMessenger {
|
|
|
2170
2410
|
let recoveredThrough = current.recoveredThrough || 0
|
|
2171
2411
|
for (const range of current.offlineRanges) {
|
|
2172
2412
|
if (range.end < minStart) continue
|
|
2413
|
+
if (!online) { remaining.push(range); continue }
|
|
2173
2414
|
const watchRevision = this.watchRevisionByChannel.get(pubkey) || 0
|
|
2174
2415
|
const controller = new AbortController()
|
|
2175
2416
|
controller.channelPubkey = pubkey
|
|
@@ -2178,38 +2419,81 @@ export class PrivateMessenger {
|
|
|
2178
2419
|
if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw new Error('PRIVATE_MESSENGER_PAUSED')
|
|
2179
2420
|
const fetchRelays = await this.resolveWatchRelays(channel)
|
|
2180
2421
|
controller.signal.throwIfAborted()
|
|
2181
|
-
const
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2422
|
+
const record = this.recoveryRangeRecord(pubkey, range)
|
|
2423
|
+
this.reconcileRecoveryRelays(record, fetchRelays)
|
|
2424
|
+
const plan = this.recoveryRelayPlan(record)
|
|
2425
|
+
if (plan) {
|
|
2426
|
+
const history = await this._privateChannel.fetchHistory({
|
|
2427
|
+
_acquirePage: signal => this.recoveryAdmission.acquire(signal),
|
|
2428
|
+
signal: controller.signal,
|
|
2429
|
+
receivedChunkScope: this.storageLeaseId,
|
|
2430
|
+
receiverSigner: this.userSigner,
|
|
2431
|
+
iykcSigner: this.contentKeySigner,
|
|
2432
|
+
privateChannelSigner: channel.signer,
|
|
2433
|
+
privateChannelReaderSigner: channel.readerSigner,
|
|
2434
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
2435
|
+
privateChannelPubkeys: [pubkey],
|
|
2436
|
+
receiverPubkey: this.userPubkey,
|
|
2437
|
+
relays: Object.keys(plan),
|
|
2438
|
+
since: Math.max(0, range.start),
|
|
2439
|
+
until: range.end,
|
|
2440
|
+
partial: true,
|
|
2441
|
+
resume: plan,
|
|
2442
|
+
mode: channel.mode,
|
|
2443
|
+
modeByPubkey: { [pubkey]: channel.mode },
|
|
2444
|
+
receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
|
|
2445
|
+
receivedChunkIndexedDB: this._indexedDB,
|
|
2446
|
+
onEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
|
|
2447
|
+
onNymEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
|
|
2448
|
+
onSeedEvent: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
|
|
2449
|
+
onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
|
|
2450
|
+
onError: err => { throw err }
|
|
2451
|
+
})
|
|
2452
|
+
controller.signal.throwIfAborted()
|
|
2453
|
+
if (this.isPartialRecoveryHistory(history)) {
|
|
2454
|
+
this.updateRecoveryRecord(record, history)
|
|
2455
|
+
this.applyRecoveryRetryBudget(record)
|
|
2456
|
+
if ((history.relays || []).some(entry => entry.status !== 'eose' || entry.pending?.length)) {
|
|
2457
|
+
const error = this.recoveryIncompleteError(record, history, range)
|
|
2458
|
+
this.onError?.(error)
|
|
2459
|
+
failures.push(error)
|
|
2460
|
+
}
|
|
2461
|
+
} else {
|
|
2462
|
+
// Legacy fetch seam (tests/custom channels): resolves only when
|
|
2463
|
+
// the whole range was read, so treat it as fully relay-covered.
|
|
2464
|
+
record.covered = mergeRanges([...record.covered, { start: range.start, end: range.end }])
|
|
2465
|
+
if (history?.oldestCreatedAt != null) {
|
|
2466
|
+
record.oldestCreatedAt = record.oldestCreatedAt == null
|
|
2467
|
+
? history.oldestCreatedAt
|
|
2468
|
+
: Math.min(record.oldestCreatedAt, history.oldestCreatedAt)
|
|
2469
|
+
}
|
|
2470
|
+
for (const state of record.relays.values()) {
|
|
2471
|
+
state.eose = true
|
|
2472
|
+
state.pending = []
|
|
2473
|
+
}
|
|
2474
|
+
record.tier = 'partial'
|
|
2475
|
+
record.lastErrors = []
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2207
2478
|
const lifecycleChanged = controller.signal.aborted || this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
|
|
2208
2479
|
(this.watchRevisionByChannel.get(pubkey) || 0) !== watchRevision
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2480
|
+
if (lifecycleChanged) {
|
|
2481
|
+
remaining.push(range)
|
|
2482
|
+
continue
|
|
2483
|
+
}
|
|
2484
|
+
if (this.recoveryRecordActive(record)) {
|
|
2485
|
+
remaining.push(range)
|
|
2486
|
+
continue
|
|
2487
|
+
}
|
|
2488
|
+
const result = await this.finalizeRecoveryRange({ channelPubkey: pubkey, range, record })
|
|
2489
|
+
failures.push(...result.failures)
|
|
2490
|
+
if (result.complete) recoveredThrough = Math.max(recoveredThrough, range.end)
|
|
2491
|
+
else remaining.push(range)
|
|
2212
2492
|
} catch (err) {
|
|
2493
|
+
const record = this.recoveryRanges.get(this.recoveryRangeKey(pubkey, range))
|
|
2494
|
+
if (record && isPermanentRecoveryError(err)) {
|
|
2495
|
+
for (const state of record.relays.values()) state.dead = true
|
|
2496
|
+
}
|
|
2213
2497
|
if (!(controller.liveInterrupted && err === controller.signal.reason)) this.onError?.(err)
|
|
2214
2498
|
failures.push(err)
|
|
2215
2499
|
remaining.push(range)
|
|
@@ -2223,6 +2507,7 @@ export class PrivateMessenger {
|
|
|
2223
2507
|
recoveredThrough: Math.max(fresh.channels[pubkey]?.recoveredThrough || 0, recoveredThrough),
|
|
2224
2508
|
offlineRanges: mergeRanges(concurrentRanges.concat(remaining))
|
|
2225
2509
|
}
|
|
2510
|
+
this.pruneRecoveryRanges(pubkey, remaining)
|
|
2226
2511
|
this.writeState(fresh)
|
|
2227
2512
|
await this.flushStateWrites()
|
|
2228
2513
|
}
|
|
@@ -2232,6 +2517,7 @@ export class PrivateMessenger {
|
|
|
2232
2517
|
async clearChannel (pubkey) {
|
|
2233
2518
|
await this.unwatch(pubkey)
|
|
2234
2519
|
await this._privateMessage.clearChannelState?.(pubkey)
|
|
2520
|
+
this.dropRecoveryRanges(pubkey)
|
|
2235
2521
|
return this.runQueueOperation(async () => {
|
|
2236
2522
|
this.channels.delete(pubkey)
|
|
2237
2523
|
this.removeChannelState(pubkey)
|
|
@@ -2265,6 +2551,7 @@ export class PrivateMessenger {
|
|
|
2265
2551
|
await this.queue?.removeBy('byChannel', pubkey)
|
|
2266
2552
|
await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue?.removeBy('byChannel', pubkey))
|
|
2267
2553
|
}
|
|
2554
|
+
for (const pubkey of stalePubkeys) this.dropRecoveryRanges(pubkey)
|
|
2268
2555
|
this.state = state
|
|
2269
2556
|
if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
|
|
2270
2557
|
})
|
|
@@ -2355,22 +2642,13 @@ export class PrivateMessenger {
|
|
|
2355
2642
|
temporaryStorageArea: this.temporaryStorageArea
|
|
2356
2643
|
})
|
|
2357
2644
|
}
|
|
2645
|
+
this.recoveryRanges.clear()
|
|
2358
2646
|
if (unwatchError) throw unwatchError
|
|
2359
2647
|
})()
|
|
2360
2648
|
return this.closePromise
|
|
2361
2649
|
}
|
|
2362
2650
|
}
|
|
2363
2651
|
|
|
2364
|
-
function mergeRanges (ranges) {
|
|
2365
|
-
const out = []
|
|
2366
|
-
for (const range of ranges) {
|
|
2367
|
-
const last = out[out.length - 1]
|
|
2368
|
-
if (!last || range.start > last.end + 1) out.push({ ...range })
|
|
2369
|
-
else last.end = Math.max(last.end, range.end)
|
|
2370
|
-
}
|
|
2371
|
-
return out
|
|
2372
|
-
}
|
|
2373
|
-
|
|
2374
2652
|
function eventType (event) {
|
|
2375
2653
|
if (event.kind === privateMessage.ASK_KIND) return 'ask'
|
|
2376
2654
|
if (event.kind === privateMessage.REPLY_KIND) return 'reply'
|
|
@@ -9,11 +9,18 @@ Provide `owner`, a byte-based NIP-44 v3 `signer` with `withSharedKey`, and eithe
|
|
|
9
9
|
Optional `chunkStorage` and `recoveryStorage` (`seeds`, `authorizations`) select
|
|
10
10
|
persistence; absent file storage uses the library cache. `mode` defaults to
|
|
11
11
|
`seeder`, `seedersForPeer(peer)` to `[peer]`, and `allowedKinds` to
|
|
12
|
-
`[5, 9, 1063, 34601]`.
|
|
12
|
+
`[5, 9, 1063, 34601]`. `useContentKeys` defaults to `true`: the coordinator looks
|
|
13
|
+
up recipients' published content-key proofs (kind 18716) while sending and falls
|
|
14
|
+
back to sender-content whenever a proof is missing or the lookup fails. Misses
|
|
15
|
+
are cached for 5 minutes (found proofs for 40), so a later prefetch picks up a
|
|
16
|
+
key published after an earlier miss. Set it to `false` when the signer owns that discovery. The DM context is `dm`; file contexts are `dm:media:<root>`.
|
|
13
17
|
`createMessengerSigner` adapts this byte signer to the transport's Base64 API.
|
|
14
18
|
|
|
15
|
-
Methods: `setPeers`, `setAvailable(boolean)`, `
|
|
16
|
-
`download`, `cancelDownload`, and `close`.
|
|
19
|
+
Methods: `setPeers`, `setAvailable(boolean)`, `prefetchContentKeys(peers?)`,
|
|
20
|
+
`enqueue`, `retry`, `cancel`, `download`, `cancelDownload`, and `close`.
|
|
21
|
+
`prefetchContentKeys` warms the content-key lookup cache for the owner and the
|
|
22
|
+
given peers (default: current peers); it is best-effort and never rejects.
|
|
23
|
+
Callbacks are `onOutbox`, `onMedia`,
|
|
17
24
|
`onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
|
|
18
25
|
account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
|
|
19
26
|
|
|
@@ -3,6 +3,7 @@ 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 { getIykcProofs } from '../../content-key/index.js'
|
|
6
7
|
import { onOnline } from '../../network/index.js'
|
|
7
8
|
import { decodeFileMetadata } from '../../nip94/index.js'
|
|
8
9
|
import { createChatOutbox } from './helpers/work-storage.js'
|
|
@@ -18,8 +19,16 @@ export function wireEvent (value, owner) {
|
|
|
18
19
|
// Relay rejection text must not be interpreted as a local signer denial.
|
|
19
20
|
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 || ''}`)
|
|
20
21
|
|
|
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
|
+
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
22
23
|
fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
24
|
+
const reportedErrors = new WeakSet()
|
|
25
|
+
const reportError = error => {
|
|
26
|
+
if (error && (typeof error === 'object' || typeof error === 'function')) {
|
|
27
|
+
if (reportedErrors.has(error)) return
|
|
28
|
+
reportedErrors.add(error)
|
|
29
|
+
}
|
|
30
|
+
onError(error)
|
|
31
|
+
}
|
|
23
32
|
const userSigner = messengerSigner(signer)
|
|
24
33
|
messageStorage ||= createEventStoreMessageStorage({ eventStore })
|
|
25
34
|
chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
|
|
@@ -36,6 +45,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
36
45
|
let downloads
|
|
37
46
|
const downloadIntents = new Map()
|
|
38
47
|
const downloadEpochs = new Map()
|
|
48
|
+
const activeDownloads = new Map()
|
|
39
49
|
let downloadWriteTail = Promise.resolve()
|
|
40
50
|
const writeDownloadIntent = operation => {
|
|
41
51
|
const work = downloadWriteTail.catch(() => {}).then(operation)
|
|
@@ -71,18 +81,18 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
71
81
|
const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
|
|
72
82
|
channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
|
|
73
83
|
channels.set(peer, channel)
|
|
74
|
-
} catch (error) { if (!retryable(error)) deniedPeers.add(peer);
|
|
84
|
+
} catch (error) { if (!retryable(error)) deniedPeers.add(peer); reportError(error); continue }
|
|
75
85
|
}
|
|
76
86
|
if (closed || !available || version !== lifecycle) return
|
|
77
87
|
values.push(channel)
|
|
78
88
|
}
|
|
79
|
-
if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys
|
|
89
|
+
if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError: reportError })
|
|
80
90
|
if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
|
|
81
91
|
await messenger.update({ channels: values })
|
|
82
92
|
await messenger.resume('signer')
|
|
83
93
|
if (!fileTransfers) {
|
|
84
94
|
fileTransfers = FileTransfer({
|
|
85
|
-
messenger, onError,
|
|
95
|
+
messenger, onError: reportError,
|
|
86
96
|
resolveChannel: ({ peerPubkey, info }) => messengerSigner(signer.withSharedKey(peerPubkey, info)),
|
|
87
97
|
storage: chunkStorage,
|
|
88
98
|
seedStorage: recoveryStorage?.seeds,
|
|
@@ -94,20 +104,30 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
94
104
|
onMedia({ ...state, peer })
|
|
95
105
|
if (state.status === 'complete') {
|
|
96
106
|
const id = intentId({ peer, root: state.root })
|
|
97
|
-
if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(
|
|
107
|
+
if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(reportError)
|
|
98
108
|
}
|
|
99
109
|
}
|
|
100
110
|
})
|
|
101
111
|
downloads = await openDownloads({ owner, signer })
|
|
102
112
|
for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
|
|
103
113
|
}
|
|
104
|
-
for (const entry of downloadIntents.values())
|
|
114
|
+
for (const entry of downloadIntents.values()) {
|
|
115
|
+
if (!peers.has(entry.file.peer) || activeDownloads.has(entry.id)) continue
|
|
116
|
+
const work = download(entry.file, { manual: true })
|
|
117
|
+
.catch(error => {
|
|
118
|
+
// A failed intent stays queued for the next configure; report each
|
|
119
|
+
// real attempt once even if configure runs while it is in flight.
|
|
120
|
+
if (downloadIntents.has(entry.id)) reportError(error)
|
|
121
|
+
})
|
|
122
|
+
.finally(() => { if (activeDownloads.get(entry.id) === work) activeDownloads.delete(entry.id) })
|
|
123
|
+
activeDownloads.set(entry.id, work)
|
|
124
|
+
}
|
|
105
125
|
drain(); pump()
|
|
106
126
|
}
|
|
107
127
|
function schedule () {
|
|
108
128
|
const work = configuring.catch(() => {}).then(configure)
|
|
109
129
|
configuring = work
|
|
110
|
-
work.catch(
|
|
130
|
+
work.catch(reportError)
|
|
111
131
|
return work
|
|
112
132
|
}
|
|
113
133
|
async function drain () {
|
|
@@ -129,7 +149,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
129
149
|
const valid = event.pubkey === peer || message.provenance === 'hearsay' || message.provenance === 'signed'
|
|
130
150
|
if (!valid || !isSerializableEvent(event) || !allowedKinds.includes(event.kind) || (event.sig && !isValidEvent(event)) || getEventHash(event) !== message.event.id) { await ack(); continue }
|
|
131
151
|
if (event.kind === 34601) {
|
|
132
|
-
try { decodeIrfsChunk(event) } catch (error) {
|
|
152
|
+
try { decodeIrfsChunk(event) } catch (error) { reportError(error); await ack(); continue }
|
|
133
153
|
}
|
|
134
154
|
if (event.kind === 5 && (message.provenance === 'hearsay' || event.pubkey !== peer)) { await ack(); continue }
|
|
135
155
|
if (!available || closed) { await nack(); break }
|
|
@@ -138,7 +158,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
138
158
|
await ack()
|
|
139
159
|
} catch (error) {
|
|
140
160
|
await nack()
|
|
141
|
-
|
|
161
|
+
reportError(error)
|
|
142
162
|
// Retain the failed record and suspend network ingestion until recovery.
|
|
143
163
|
await messenger.pause('storage')
|
|
144
164
|
break
|
|
@@ -217,8 +237,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
217
237
|
} catch (error) {
|
|
218
238
|
if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
|
|
219
239
|
entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
|
|
220
|
-
await storage.put(entry, { existing: true }).catch(
|
|
221
|
-
|
|
240
|
+
await storage.put(entry, { existing: true }).catch(reportError)
|
|
241
|
+
reportError(error)
|
|
222
242
|
// The failed wire event may be a quote or file chunk. Report the
|
|
223
243
|
// owning outbox item separately, without mutating the native error.
|
|
224
244
|
if (error.retryWhenOnline) offlineSends.add(entry.id)
|
|
@@ -284,6 +304,24 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
284
304
|
}
|
|
285
305
|
return {
|
|
286
306
|
download, cancelDownload,
|
|
307
|
+
// Peers-based so callers can prefetch as soon as a chat route opens, before
|
|
308
|
+
// the shared-key channel exists. When group chats get real membership, add
|
|
309
|
+
// a thin prefetchChannelContentKeys(channelPubkey) wrapper that resolves
|
|
310
|
+
// the channel's receivers and delegates here.
|
|
311
|
+
async prefetchContentKeys (values = [...peers]) {
|
|
312
|
+
if (closed || !useContentKeys) return {}
|
|
313
|
+
const requested = [...new Set([owner, ...(Array.isArray(values) ? values : [values])])]
|
|
314
|
+
.filter(value => typeof value === 'string' && value)
|
|
315
|
+
if (!requested.length) return {}
|
|
316
|
+
try {
|
|
317
|
+
return await _getIykcProofs(requested)
|
|
318
|
+
} catch (error) {
|
|
319
|
+
// Prefetch only warms the lookup cache; callers must keep working with
|
|
320
|
+
// sender-content until a proof is available. Keep the failure visible.
|
|
321
|
+
console.warn('private-messenger content-key prefetch failed', error?.message ?? error)
|
|
322
|
+
return {}
|
|
323
|
+
}
|
|
324
|
+
},
|
|
287
325
|
async setPeers (values) {
|
|
288
326
|
peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
|
|
289
327
|
for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
|
|
@@ -329,14 +367,14 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
329
367
|
},
|
|
330
368
|
async close () {
|
|
331
369
|
closed = true; available = false; lifecycle++
|
|
332
|
-
stopOnlineRetry(); offlineSends.clear()
|
|
370
|
+
stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear()
|
|
333
371
|
for (const controller of sendControllers.values()) controller.abort()
|
|
334
372
|
await Promise.allSettled([configuring, initialized])
|
|
335
373
|
await messenger?.pause('closed')
|
|
336
374
|
if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
|
|
337
375
|
await messenger?.close()
|
|
338
376
|
await storage?.close()
|
|
339
|
-
await downloadWriteTail.catch(
|
|
377
|
+
await downloadWriteTail.catch(reportError)
|
|
340
378
|
await downloads?.close()
|
|
341
379
|
}
|
|
342
380
|
}
|