libp2r2p 0.11.22 → 0.11.24
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 +30 -1
- package/package.json +1 -1
- package/private-channel/index.js +46 -35
- package/private-message/index.js +3 -2
- package/private-messenger/index.js +257 -177
- package/private-messenger/services/decryption-scheduler.js +135 -0
- package/private-messenger/services/pending-decryption.js +6 -0
- package/private-messenger/session/README.md +37 -1
- package/private-messenger/session/helpers/foreground-work.js +6 -1
- package/private-messenger/session/index.js +128 -28
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Admission only: never wait while holding an inbox operation, IDB transaction
|
|
2
|
+
// or the cross-instance retry lock. A busy channel cannot stall another one.
|
|
3
|
+
export function createDecryptionScheduler ({ ready = true, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
|
4
|
+
const channels = new Map()
|
|
5
|
+
const readyWaiters = new Set()
|
|
6
|
+
let timer
|
|
7
|
+
let running = false
|
|
8
|
+
let closed
|
|
9
|
+
let foreground = false
|
|
10
|
+
let foregroundAt = now()
|
|
11
|
+
let priority = null
|
|
12
|
+
let previous
|
|
13
|
+
let batch = 0
|
|
14
|
+
const stateFor = pubkey => {
|
|
15
|
+
if (!channels.has(pubkey)) channels.set(pubkey, { active: 0, at: now(), deadline: null, jobs: [] })
|
|
16
|
+
return channels.get(pubkey)
|
|
17
|
+
}
|
|
18
|
+
const quietAt = state => state.active || foreground ? Infinity : Math.max(state.at, foregroundAt) + 250
|
|
19
|
+
const schedule = () => {
|
|
20
|
+
if (timer !== undefined) clearTimer(timer)
|
|
21
|
+
timer = undefined
|
|
22
|
+
if (closed || !ready || running) return
|
|
23
|
+
const pending = [...channels.values()].filter(state => state.jobs.length)
|
|
24
|
+
if (!pending.length) return
|
|
25
|
+
const at = Math.min(...pending.map(state => Math.min(quietAt(state), state.deadline)))
|
|
26
|
+
timer = setTimer(pump, Math.max(0, at - now()))
|
|
27
|
+
}
|
|
28
|
+
async function pump () {
|
|
29
|
+
timer = undefined
|
|
30
|
+
if (closed || !ready || running) return
|
|
31
|
+
const time = now()
|
|
32
|
+
const eligible = [...channels].filter(([, state]) => state.jobs.length && Math.min(quietAt(state), state.deadline) <= time)
|
|
33
|
+
eligible.sort(([a, x], [b, y]) => {
|
|
34
|
+
const overdue = Number(y.deadline <= time) - Number(x.deadline <= time)
|
|
35
|
+
if (overdue) return overdue
|
|
36
|
+
if (x.deadline <= time && x.deadline !== y.deadline) return x.deadline - y.deadline
|
|
37
|
+
if (a === previous && batch >= 4) return 1
|
|
38
|
+
if (b === previous && batch >= 4) return -1
|
|
39
|
+
return Number(b === priority) - Number(a === priority)
|
|
40
|
+
})
|
|
41
|
+
if (!eligible.length) { schedule(); return }
|
|
42
|
+
const [pubkey, state] = eligible[0]
|
|
43
|
+
const job = state.jobs.shift()
|
|
44
|
+
running = true
|
|
45
|
+
if (previous === pubkey) batch++
|
|
46
|
+
else { previous = pubkey; batch = 1 }
|
|
47
|
+
try {
|
|
48
|
+
job.signal?.throwIfAborted()
|
|
49
|
+
job.resolve(await job.work())
|
|
50
|
+
} catch (error) { job.reject(error) } finally {
|
|
51
|
+
job.signal?.removeEventListener('abort', job.abort)
|
|
52
|
+
state.deadline = now() + 5000
|
|
53
|
+
running = false
|
|
54
|
+
// Always use a new task, including between records in the same batch.
|
|
55
|
+
schedule()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const rejectJobs = (state, error) => {
|
|
59
|
+
for (const job of state.jobs.splice(0)) {
|
|
60
|
+
job.signal?.removeEventListener('abort', job.abort)
|
|
61
|
+
job.reject(error)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
activity (pubkey) {
|
|
66
|
+
if (closed) return () => {}
|
|
67
|
+
const state = stateFor(pubkey)
|
|
68
|
+
state.active++; state.at = now(); schedule()
|
|
69
|
+
let released = false
|
|
70
|
+
return () => {
|
|
71
|
+
if (released) return
|
|
72
|
+
released = true
|
|
73
|
+
state.active--; state.at = now(); schedule()
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
foreground (active) { foreground = active; foregroundAt = now(); schedule() },
|
|
77
|
+
priority (pubkey) { priority = pubkey; schedule() },
|
|
78
|
+
setReady (value) {
|
|
79
|
+
if (ready === value || closed) return
|
|
80
|
+
ready = value
|
|
81
|
+
if (ready) {
|
|
82
|
+
for (const state of channels.values()) { state.at = now(); state.deadline = state.jobs.length ? now() + 5000 : null }
|
|
83
|
+
for (const wake of [...readyWaiters]) wake()
|
|
84
|
+
}
|
|
85
|
+
schedule()
|
|
86
|
+
},
|
|
87
|
+
async waitReady (signal) {
|
|
88
|
+
while (true) {
|
|
89
|
+
signal?.throwIfAborted()
|
|
90
|
+
if (closed) throw closed
|
|
91
|
+
if (ready) return
|
|
92
|
+
await new Promise((resolve, reject) => {
|
|
93
|
+
const finish = () => {
|
|
94
|
+
readyWaiters.delete(finish); signal?.removeEventListener('abort', finish)
|
|
95
|
+
if (closed || signal?.aborted) reject(closed || signal.reason)
|
|
96
|
+
else resolve()
|
|
97
|
+
}
|
|
98
|
+
readyWaiters.add(finish); signal?.addEventListener('abort', finish, { once: true })
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
run (pubkey, work, signal) {
|
|
103
|
+
signal?.throwIfAborted()
|
|
104
|
+
if (closed) return Promise.reject(closed)
|
|
105
|
+
const state = stateFor(pubkey)
|
|
106
|
+
state.deadline ??= now() + 5000
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const job = { work, resolve, reject, signal }
|
|
109
|
+
job.abort = () => {
|
|
110
|
+
const index = state.jobs.indexOf(job)
|
|
111
|
+
if (index < 0) return
|
|
112
|
+
state.jobs.splice(index, 1); reject(signal.reason); schedule()
|
|
113
|
+
}
|
|
114
|
+
signal?.addEventListener('abort', job.abort, { once: true })
|
|
115
|
+
state.jobs.push(job); schedule()
|
|
116
|
+
})
|
|
117
|
+
},
|
|
118
|
+
remove (pubkey) {
|
|
119
|
+
const state = channels.get(pubkey)
|
|
120
|
+
if (state) rejectJobs(state, new Error('PRIVATE_CHANNEL_CHANGED'))
|
|
121
|
+
channels.delete(pubkey); schedule()
|
|
122
|
+
},
|
|
123
|
+
finish (pubkey) {
|
|
124
|
+
const state = channels.get(pubkey)
|
|
125
|
+
if (state && !state.jobs.length) state.deadline = null
|
|
126
|
+
},
|
|
127
|
+
close () {
|
|
128
|
+
closed ||= new Error('PRIVATE_MESSENGER_CLOSED')
|
|
129
|
+
if (timer !== undefined) clearTimer(timer)
|
|
130
|
+
for (const state of channels.values()) rejectJobs(state, closed)
|
|
131
|
+
channels.clear()
|
|
132
|
+
for (const wake of [...readyWaiters]) wake()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -98,6 +98,12 @@ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = P
|
|
|
98
98
|
for await (const item of queue.storedItems()) if (force || item.macAttempts < 3) records.push(item)
|
|
99
99
|
return records
|
|
100
100
|
},
|
|
101
|
+
current (item, { force = false } = {}) {
|
|
102
|
+
return serial(async () => {
|
|
103
|
+
const stored = await queue.getBy('byId', item.id)
|
|
104
|
+
return stored && (force || stored.macAttempts < 3) ? stored : null
|
|
105
|
+
})
|
|
106
|
+
},
|
|
101
107
|
recovered (item) {
|
|
102
108
|
return serial(async () => {
|
|
103
109
|
await queue.removeBy('byId', item.id)
|
|
@@ -10,6 +10,19 @@ ordinary availability recovery already requests a pass. Ciphertext stays in the
|
|
|
10
10
|
separate bounded FIFO until admitted to the normal inbox, evicted or explicitly
|
|
11
11
|
cleared. This does not retry application storage/permission failures as crypto.
|
|
12
12
|
|
|
13
|
+
Construction accepts `pendingDecryptionReady` (default `true`). Pass `false` to
|
|
14
|
+
hold pending queue reads/replay until `setPendingDecryptionReady(true)` releases
|
|
15
|
+
them after initial presentation. This never pauses live reception, sends or
|
|
16
|
+
channel preparation. Recovery waits for 250 ms of channel inactivity; a five-second
|
|
17
|
+
maximum admits one packet during sustained activity. It yields between records,
|
|
18
|
+
with at most four consecutive preferred records before another eligible channel.
|
|
19
|
+
`setPendingDecryptionPriority(peer | null)` retains a peer preference through
|
|
20
|
+
preparation; self-chat/`null` or removal clear it. Deadlines prevent preference
|
|
21
|
+
from starving other channels. Account unavailability/close still cancel recovery;
|
|
22
|
+
`force` bypasses only the MAC attempt limit. Awaiting the retry API awaits its
|
|
23
|
+
scheduled pass, so do not await it on the interface's critical startup path.
|
|
24
|
+
|
|
25
|
+
|
|
13
26
|
Provide `owner`, a byte-based NIP-44 v3 `signer` with `withSharedKey`, and either
|
|
14
27
|
`eventStore` or a `messageStorage.save(event, { peerPubkey, hearsay })` adapter.
|
|
15
28
|
Optional `chunkStorage` and `recoveryStorage` (`seeds`, `authorizations`) select
|
|
@@ -59,7 +72,7 @@ The coordinator resolves the peer's channel and queues the request until the
|
|
|
59
72
|
channel exists. Priorities expire after `priorityTtlMs` (10 minutes) or once
|
|
60
73
|
covered, and are dropped with recovery state. They are process-local and reset
|
|
61
74
|
on reload.
|
|
62
|
-
Callbacks are `onOutbox`, `onMedia`,
|
|
75
|
+
Callbacks are optional `onDebug`, `onOutbox`, `onMedia`,
|
|
63
76
|
`onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
|
|
64
77
|
account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
|
|
65
78
|
|
|
@@ -213,3 +226,26 @@ closing shared sockets or cancelling another subscriber for the same event ID.
|
|
|
213
226
|
Operations already transmitted cannot be recalled from relays. A messenger pause
|
|
214
227
|
cancels its outstanding publication work and leaves the outbox pending; resume
|
|
215
228
|
creates a fresh publication lifetime, without treating the pause as a user abort.
|
|
229
|
+
|
|
230
|
+
## Independent local persistence and publication
|
|
231
|
+
|
|
232
|
+
One encrypted outbox feeds two independent serial processors. Local persistence
|
|
233
|
+
never waits for peer preparation or an in-flight publication. Remote entries
|
|
234
|
+
become publishable only after their local progress checkpoint commits; failed
|
|
235
|
+
checkpoints are retried even if the copies have already committed. Text may
|
|
236
|
+
continue passing uploads between chunks after its local stage is ready.
|
|
237
|
+
|
|
238
|
+
Self entries emit `status: 'saved'` as soon as local persistence succeeds. They
|
|
239
|
+
do not write a redundant localSaved checkpoint before removal. Remote entries
|
|
240
|
+
emit that status after the first valid relay ACK for every required publication,
|
|
241
|
+
without waiting for redundant relays. Outbox cleanup runs independently; failures
|
|
242
|
+
retain saved status and retry only removal with the session backoff, never
|
|
243
|
+
`onSendError`. Consumers must accept saved entries while removal is pending.
|
|
244
|
+
A crash before removal replays the original event identity into idempotent storage.
|
|
245
|
+
Account shutdown fences late admissions and waits for both processors and cleanup.
|
|
246
|
+
|
|
247
|
+
`onDebug(record)` is disabled by default. Records carry fixed stage names, public
|
|
248
|
+
event/peer identifiers and elapsed milliseconds for admission, local processing,
|
|
249
|
+
channel preparation, publication acceptance and cleanup. Callback failures never
|
|
250
|
+
change delivery. Connect only in development; never log events or crypto buffers.
|
|
251
|
+
Custom outbox factories also receive this optional callback for storage timings.
|
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
export function createForegroundWork () {
|
|
4
4
|
let active = 0
|
|
5
5
|
const waiters = new Set()
|
|
6
|
+
const observers = new Set()
|
|
7
|
+
const notify = () => { for (const listener of observers) listener(active > 0) }
|
|
6
8
|
const lifetime = new AbortController()
|
|
7
9
|
return {
|
|
8
10
|
enter () {
|
|
9
11
|
active++
|
|
12
|
+
notify()
|
|
10
13
|
let released = false
|
|
11
14
|
return () => {
|
|
12
15
|
if (released) return
|
|
13
16
|
released = true
|
|
14
17
|
if (--active === 0) for (const wake of [...waiters]) wake()
|
|
18
|
+
notify()
|
|
15
19
|
}
|
|
16
20
|
},
|
|
17
21
|
wait (signal) {
|
|
@@ -24,6 +28,7 @@ export function createForegroundWork () {
|
|
|
24
28
|
combined.addEventListener('abort', finish, { once: true })
|
|
25
29
|
})
|
|
26
30
|
},
|
|
27
|
-
|
|
31
|
+
observe (listener) { observers.add(listener); listener(active > 0); return () => observers.delete(listener) },
|
|
32
|
+
close () { lifetime.abort(); waiters.clear(); observers.clear() }
|
|
28
33
|
}
|
|
29
34
|
}
|
|
@@ -44,9 +44,11 @@ function messengerStatusSnapshot (value) {
|
|
|
44
44
|
return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
47
|
+
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, pendingDecryptionReady = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, onDebug, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
48
48
|
fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
49
49
|
fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
50
|
+
if (typeof pendingDecryptionReady !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
|
|
51
|
+
let pendingDecryptionPeer = null
|
|
50
52
|
const reportedErrors = new WeakSet()
|
|
51
53
|
const reportError = error => {
|
|
52
54
|
if (error && (typeof error === 'object' || typeof error === 'function')) {
|
|
@@ -64,6 +66,14 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
64
66
|
const entries = new Map()
|
|
65
67
|
const cancelled = new Set()
|
|
66
68
|
const sendControllers = new Map()
|
|
69
|
+
const localReady = new Set()
|
|
70
|
+
const localIdleWaiters = new Set()
|
|
71
|
+
const cleanups = new Map()
|
|
72
|
+
const queuedAt = new WeakMap()
|
|
73
|
+
const debug = (entry, stage, startedAt) => {
|
|
74
|
+
if (!onDebug) return
|
|
75
|
+
try { onDebug({ stage, eventId: entry.id, peer: entry.peer, at: Date.now(), ...(startedAt != null ? { elapsedMs: Date.now() - startedAt } : {}) }) } catch {}
|
|
76
|
+
}
|
|
67
77
|
const offlineSends = new Set()
|
|
68
78
|
const pausedSends = new Set()
|
|
69
79
|
let messengerStatus
|
|
@@ -121,15 +131,22 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
121
131
|
let messengerWork
|
|
122
132
|
let fileSetupWork
|
|
123
133
|
let sending
|
|
134
|
+
let enqueuing = 0
|
|
135
|
+
let localSaving = false
|
|
136
|
+
let relocal = false
|
|
124
137
|
let repump
|
|
125
138
|
let draining
|
|
126
139
|
let redrain = false
|
|
127
140
|
const idleWaiters = new Set()
|
|
128
|
-
const settled = () => { if (!sending && !draining) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
|
|
141
|
+
const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
|
|
129
142
|
const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
|
|
130
143
|
const ready = () => (initialized ??= (async () => {
|
|
131
|
-
storage = await openOutbox({ owner, signer })
|
|
132
|
-
for (const entry of await storage.list())
|
|
144
|
+
storage = await openOutbox({ owner, signer, onDebug })
|
|
145
|
+
for (const entry of await storage.list()) {
|
|
146
|
+
entries.set(entry.id, entry)
|
|
147
|
+
if (entry.localSaved.every(Boolean)) localReady.add(entry.id)
|
|
148
|
+
queuedAt.set(entry, Date.now())
|
|
149
|
+
}
|
|
133
150
|
emit()
|
|
134
151
|
})().catch(async error => { await storage?.close(); storage = null; initialized = null; throw error }))
|
|
135
152
|
async function createMessenger () {
|
|
@@ -139,7 +156,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
139
156
|
let candidate
|
|
140
157
|
try {
|
|
141
158
|
candidate = await Messenger({
|
|
142
|
-
fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait,
|
|
159
|
+
fallbackRelays, fallbackDelayMs, pendingDecryptionReady, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait, _observeForeground: foreground.observe,
|
|
143
160
|
onMessageQueued: () => { if (messengerBinding === binding) return drain() },
|
|
144
161
|
onStateChanged: state => {
|
|
145
162
|
if (closed || messengerBinding !== binding) return
|
|
@@ -152,6 +169,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
152
169
|
if (typeof candidate?.readStatus !== 'function' || !notified) throw new ValidationError('MESSENGER_STATE_CONTRACT_REQUIRED')
|
|
153
170
|
const status = messengerStatusSnapshot(candidate.readStatus())
|
|
154
171
|
messenger = candidate
|
|
172
|
+
messenger.setPendingDecryptionReady?.(pendingDecryptionReady)
|
|
173
|
+
messenger.setPendingDecryptionPriority?.(channels.get(pendingDecryptionPeer)?.pubkey ?? null)
|
|
155
174
|
stateChanged(status)
|
|
156
175
|
} catch (error) {
|
|
157
176
|
messengerBinding = null
|
|
@@ -163,13 +182,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
163
182
|
|
|
164
183
|
const preparation = createPeerPreparation({
|
|
165
184
|
prepare: async (peer, signal) => {
|
|
185
|
+
const startedAt = Date.now()
|
|
166
186
|
const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
|
|
167
187
|
const pubkey = await scoped.getPublicKey()
|
|
168
188
|
signal.throwIfAborted()
|
|
169
189
|
const channel = { signer: scoped, pubkey, readerPubkey: pubkey, mode, seeders: seedersForPeer(peer) }
|
|
170
190
|
channels.set(peer, channel)
|
|
191
|
+
if (peer === pendingDecryptionPeer) messenger?.setPendingDecryptionPriority?.(channel.pubkey)
|
|
171
192
|
await schedule()
|
|
172
193
|
signal.throwIfAborted()
|
|
194
|
+
if (onDebug) { try { onDebug({ stage: 'channel-ready', peer, elapsedMs: Date.now() - startedAt }) } catch {} }
|
|
173
195
|
return channel
|
|
174
196
|
},
|
|
175
197
|
onReady: () => { drain(); pump() }, onError: reportError,
|
|
@@ -360,7 +382,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
360
382
|
// Published is relay acceptance, never a peer receipt.
|
|
361
383
|
await assertMessagePublished(report, event)
|
|
362
384
|
}
|
|
363
|
-
async function
|
|
385
|
+
async function cleanupEntry (entry) {
|
|
386
|
+
if (cleanups.has(entry.id)) return cleanups.get(entry.id)
|
|
387
|
+
const startedAt = Date.now()
|
|
388
|
+
const work = Promise.resolve().then(() => storage.remove(entry.id)).then(() => {
|
|
389
|
+
entries.delete(entry.id); localReady.delete(entry.id)
|
|
390
|
+
debug(entry, 'outbox-cleanup', startedAt)
|
|
391
|
+
emit()
|
|
392
|
+
}).catch(error => {
|
|
393
|
+
// Delivery has already succeeded. Cleanup must never become a send error.
|
|
394
|
+
entry.failed = true; entry.retryable = true
|
|
395
|
+
reportError(error)
|
|
396
|
+
if (!closed && available) scheduleSendRetry()
|
|
397
|
+
}).finally(() => { cleanups.delete(entry.id); if (![...entries.values()].some(pendingRetryable) && !cleanups.size) resetSendRetry(); settled() })
|
|
398
|
+
cleanups.set(entry.id, work)
|
|
399
|
+
return work
|
|
400
|
+
}
|
|
401
|
+
async function sendEntry (entry, localOnly = false) {
|
|
364
402
|
const releaseForeground = foreground.enter()
|
|
365
403
|
entry.status = 'pending'; emit()
|
|
366
404
|
const controller = new AbortController()
|
|
@@ -370,19 +408,32 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
370
408
|
if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
|
|
371
409
|
if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
|
|
372
410
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
411
|
+
if (localOnly) {
|
|
412
|
+
debug(entry, 'local-start', queuedAt.get(entry))
|
|
413
|
+
const startedAt = Date.now()
|
|
414
|
+
for (let index = 0; index < entry.events.length; index++) {
|
|
415
|
+
await active()
|
|
416
|
+
if (entry.localSaved[index]) continue
|
|
417
|
+
const event = entry.events[index]
|
|
379
418
|
const hearsay = !event.sig && event.pubkey !== owner
|
|
380
419
|
const result = await messageStorage.save(event, { peerPubkey: entry.peer, hearsay })
|
|
381
420
|
if (result?.result?.code === 'blocked') { cancelled.add(entry.id); await active() }
|
|
382
421
|
if (!result?.result?.ok) throw Object.assign(new Error('MESSAGE_STORAGE_FAILED'), { code: result?.result?.code?.toUpperCase() })
|
|
383
422
|
entry.localSaved[index] = true
|
|
423
|
+
}
|
|
424
|
+
debug(entry, 'local-stored', startedAt)
|
|
425
|
+
await active()
|
|
426
|
+
if (entry.peer === owner) {
|
|
427
|
+
entry.status = 'saved'; emit()
|
|
428
|
+
cleanupEntry(entry)
|
|
429
|
+
} else {
|
|
430
|
+
// Retry this checkpoint even when a preceding attempt saved all copies
|
|
431
|
+
// but failed to persist progress. Only then admit remote publication.
|
|
384
432
|
await storage.put(entry, { existing: true })
|
|
433
|
+
await active()
|
|
434
|
+
localReady.add(entry.id)
|
|
385
435
|
}
|
|
436
|
+
return
|
|
386
437
|
}
|
|
387
438
|
if (entry.peer !== owner && !preparation.get(entry.peer)) {
|
|
388
439
|
if (preparation.error(entry.peer)) throw preparation.error(entry.peer)
|
|
@@ -409,11 +460,17 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
409
460
|
emit()
|
|
410
461
|
await active()
|
|
411
462
|
await storage.put({ ...entry, chunkIndex: entry.chunkIndex + 1 }, { existing: true })
|
|
463
|
+
// Let the independent local stage finish newly admitted texts before
|
|
464
|
+
// selecting publications at this chunk boundary. It never awaits us.
|
|
465
|
+
if ([...entries.values()].some(pending => !pending.files.length && pending.peer !== owner && !pending.failed && !localReady.has(pending.id))) {
|
|
466
|
+
await pumpLocal()
|
|
467
|
+
if (localSaving) await new Promise(resolve => localIdleWaiters.add(resolve))
|
|
468
|
+
}
|
|
412
469
|
// Newly queued text and deletion controls can pass an in-flight file.
|
|
413
470
|
let admitted = 0
|
|
414
471
|
for (const pending of entries.values()) {
|
|
415
472
|
if (closed || !available || admitted >= 8) break
|
|
416
|
-
if (pending.files.length || pending.failed || cancelled.has(pending.id) || (pending.peer !== owner && !peers.has(pending.peer))) continue
|
|
473
|
+
if (!localReady.has(pending.id) || pending.peer === owner || pending.status === 'saved' || pending.files.length || pending.failed || cancelled.has(pending.id) || (pending.peer !== owner && !peers.has(pending.peer))) continue
|
|
417
474
|
admitted++
|
|
418
475
|
await sendEntry(pending)
|
|
419
476
|
}
|
|
@@ -422,15 +479,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
422
479
|
for (; entry.index < entry.events.length; entry.index++) {
|
|
423
480
|
await active()
|
|
424
481
|
const event = entry.events[entry.index]
|
|
425
|
-
|
|
482
|
+
const startedAt = Date.now()
|
|
483
|
+
await publish(entry.peer, event, controller.signal)
|
|
484
|
+
debug(entry, 'publication-accepted', startedAt)
|
|
426
485
|
await active()
|
|
427
486
|
// The final durable operation is removal. Avoid encrypting a checkpoint
|
|
428
487
|
// that would immediately be deleted; earlier context stages still checkpoint.
|
|
429
488
|
if (entry.index + 1 < entry.events.length) await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
|
|
430
489
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
emit()
|
|
490
|
+
entry.status = 'saved'; emit()
|
|
491
|
+
cleanupEntry(entry)
|
|
434
492
|
} catch (cause) {
|
|
435
493
|
// Pauses can surface at any stage (text publication, file authorization
|
|
436
494
|
// or chunk delivery). Normalize all of them into the retryable path.
|
|
@@ -478,6 +536,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
478
536
|
for (const entry of entries.values()) {
|
|
479
537
|
if (!pendingRetryable(entry) || pausedSends.has(entry.id)) continue
|
|
480
538
|
entry.failed = false
|
|
539
|
+
if (entry.status === 'saved') { cleanupEntry(entry); continue }
|
|
481
540
|
retried = true
|
|
482
541
|
}
|
|
483
542
|
if (retried) pump()
|
|
@@ -500,10 +559,26 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
500
559
|
return schedule()
|
|
501
560
|
})
|
|
502
561
|
}
|
|
503
|
-
async function
|
|
562
|
+
async function pumpLocal () {
|
|
504
563
|
if (!available || closed || !storage) return
|
|
505
|
-
|
|
506
|
-
|
|
564
|
+
if (localSaving) { relocal = true; return }
|
|
565
|
+
localSaving = true
|
|
566
|
+
try {
|
|
567
|
+
do {
|
|
568
|
+
relocal = false
|
|
569
|
+
for (const entry of entries.values()) {
|
|
570
|
+
if (closed || !available) break
|
|
571
|
+
if (entry.failed || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) continue
|
|
572
|
+
if (entry.status === 'saved') { cleanupEntry(entry); continue }
|
|
573
|
+
if (localReady.has(entry.id) && entry.peer !== owner) continue
|
|
574
|
+
await sendEntry(entry, true)
|
|
575
|
+
pumpRemote()
|
|
576
|
+
}
|
|
577
|
+
} while (relocal)
|
|
578
|
+
} finally { localSaving = false; for (const resolve of localIdleWaiters) resolve(); localIdleWaiters.clear(); settled() }
|
|
579
|
+
}
|
|
580
|
+
async function pumpRemote () {
|
|
581
|
+
if (!available || closed || !storage || !messenger) return
|
|
507
582
|
if (sending) { repump = true; return }
|
|
508
583
|
sending = true
|
|
509
584
|
try {
|
|
@@ -511,22 +586,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
511
586
|
repump = false
|
|
512
587
|
for (const entry of entries.values()) {
|
|
513
588
|
if (closed || !available) break
|
|
514
|
-
if (
|
|
515
|
-
if (
|
|
589
|
+
if (entry.peer === owner || entry.status === 'saved' || !localReady.has(entry.id) || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
|
|
590
|
+
if (!preparation.get(entry.peer) && !preparation.error(entry.peer)) {
|
|
516
591
|
preparation.request(entry.peer).catch(() => {})
|
|
517
592
|
continue
|
|
518
593
|
}
|
|
519
|
-
if (entry.
|
|
594
|
+
if (entry.files.length && !fileTransfers) continue
|
|
520
595
|
await sendEntry(entry)
|
|
521
596
|
}
|
|
522
597
|
} while (repump)
|
|
523
598
|
} finally {
|
|
524
599
|
sending = false
|
|
525
600
|
watchOfflineSends()
|
|
526
|
-
if (![...entries.values()].some(pendingRetryable)) resetSendRetry()
|
|
601
|
+
if (![...entries.values()].some(pendingRetryable) && !cleanups.size) resetSendRetry()
|
|
527
602
|
settled()
|
|
528
603
|
}
|
|
529
604
|
}
|
|
605
|
+
async function pump () { await Promise.all([pumpLocal(), pumpRemote()]) }
|
|
530
606
|
const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
|
|
531
607
|
async function download (file, options = {}) {
|
|
532
608
|
if (file.peer === owner) return file
|
|
@@ -558,6 +634,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
558
634
|
await writeDownloadIntent(() => downloads?.remove(id))
|
|
559
635
|
}
|
|
560
636
|
return {
|
|
637
|
+
setPendingDecryptionReady (ready) {
|
|
638
|
+
if (typeof ready !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
|
|
639
|
+
pendingDecryptionReady = ready
|
|
640
|
+
if (!closed) messenger?.setPendingDecryptionReady?.(ready)
|
|
641
|
+
},
|
|
642
|
+
setPendingDecryptionPriority (peer) {
|
|
643
|
+
if (peer !== null && !/^[0-9a-f]{64}$/.test(peer || '')) throw new ValidationError('INVALID_PENDING_DECRYPTION_PRIORITY')
|
|
644
|
+
pendingDecryptionPeer = peer === owner ? null : peer
|
|
645
|
+
if (!closed) messenger?.setPendingDecryptionPriority?.(channels.get(pendingDecryptionPeer)?.pubkey ?? null)
|
|
646
|
+
},
|
|
561
647
|
async retryPendingDecryptions (options) {
|
|
562
648
|
if (closed || !available) return
|
|
563
649
|
await ensureMessenger()
|
|
@@ -605,6 +691,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
605
691
|
if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
|
|
606
692
|
peers.clear(); for (const peer of next) peers.add(peer)
|
|
607
693
|
preparation.reconcile(peers)
|
|
694
|
+
if (pendingDecryptionPeer && !peers.has(pendingDecryptionPeer)) {
|
|
695
|
+
pendingDecryptionPeer = null
|
|
696
|
+
messenger?.setPendingDecryptionPriority?.(null)
|
|
697
|
+
}
|
|
608
698
|
for (const peer of channels.keys()) {
|
|
609
699
|
if (!peers.has(peer)) {
|
|
610
700
|
channels.delete(peer); pendingPriorityRanges.delete(peer)
|
|
@@ -629,6 +719,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
629
719
|
inboxRecovery.stop(); await messenger?.pause('signer'); return
|
|
630
720
|
}
|
|
631
721
|
await ready()
|
|
722
|
+
pumpLocal()
|
|
632
723
|
for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
|
|
633
724
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
634
725
|
if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
|
|
@@ -637,6 +728,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
637
728
|
async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
|
|
638
729
|
if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
|
|
639
730
|
const releaseForeground = foreground.enter()
|
|
731
|
+
enqueuing++
|
|
640
732
|
try {
|
|
641
733
|
await ready()
|
|
642
734
|
const main = wireEvent(event, owner)
|
|
@@ -644,11 +736,19 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
644
736
|
const events = [...context.map(value => wireEvent(value, owner)), main]
|
|
645
737
|
const files = events.filter(value => value.kind === 1063).map(value => ({ ...decodeFileMetadata(value), optional: !requiredFiles.includes(getEventHash(value)) })).filter(file => file.service === 'irfs').flatMap(file => [file, ...(file.thumbnail?.root ? [{ ...file.thumbnail, optional: file.optional }] : [])])
|
|
646
738
|
const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
|
|
739
|
+
const startedAt = Date.now()
|
|
740
|
+
debug(entry, 'outbox-start')
|
|
647
741
|
await storage.put(entry)
|
|
742
|
+
debug(entry, 'outbox-stored', startedAt)
|
|
743
|
+
queuedAt.set(entry, Date.now())
|
|
744
|
+
if (cancelled.has(id)) { await storage.remove(id); throw new Error('CHAT_UNAVAILABLE') }
|
|
745
|
+
// An accepted durable intent survives shutdown/locking for replay. Only
|
|
746
|
+
// explicit cancellation removes it; no late callback or work is admitted.
|
|
747
|
+
if (closed) return id
|
|
648
748
|
entries.set(id, entry); emit(); pump()
|
|
649
|
-
if (peer !== owner) preparePeer(peer).catch(() => {})
|
|
749
|
+
if (available && peer !== owner) preparePeer(peer).catch(() => {})
|
|
650
750
|
return id
|
|
651
|
-
} finally { releaseForeground() }
|
|
751
|
+
} finally { releaseForeground(); enqueuing--; settled() }
|
|
652
752
|
},
|
|
653
753
|
async retry (id) {
|
|
654
754
|
offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
|
|
@@ -662,7 +762,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
662
762
|
sendControllers.get(id)?.abort()
|
|
663
763
|
offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
|
|
664
764
|
await ready()
|
|
665
|
-
entries.delete(id)
|
|
765
|
+
entries.delete(id); localReady.delete(id)
|
|
666
766
|
await storage.remove(id)
|
|
667
767
|
emit()
|
|
668
768
|
},
|
|
@@ -674,7 +774,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
674
774
|
for (const controller of sendControllers.values()) controller.abort()
|
|
675
775
|
await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
|
|
676
776
|
await messenger?.pause('closed')
|
|
677
|
-
if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
|
|
777
|
+
if (sending || localSaving || enqueuing || draining || cleanups.size) await new Promise(resolve => idleWaiters.add(resolve))
|
|
678
778
|
await messenger?.close()
|
|
679
779
|
await storage?.close()
|
|
680
780
|
await downloadWriteTail.catch(reportError)
|