libp2r2p 0.11.17 → 0.11.19
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 +28 -2
- package/helpers/abortable-semaphore.js +2 -2
- package/package.json +1 -1
- package/private-channel/index.js +9 -2
- package/private-messenger/helpers/background-signer.js +16 -0
- package/private-messenger/helpers/fallback-race.js +134 -0
- package/private-messenger/helpers/send-routing.js +31 -5
- package/private-messenger/index.js +181 -62
- package/private-messenger/session/README.md +51 -3
- package/private-messenger/session/helpers/foreground-work.js +29 -0
- package/private-messenger/session/helpers/peer-preparation.js +111 -0
- package/private-messenger/session/helpers/signer.js +5 -1
- package/private-messenger/session/index.js +153 -53
- package/relay/helpers/publish.js +5 -1
- package/relay/services/relay-connection.js +31 -18
- package/relay/services/relay-pool.js +11 -4
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createPauseRecovery } from '../../helpers/pause-recovery.js'
|
|
2
|
+
|
|
3
|
+
// One account owns the queue. Background work may occupy only three of the
|
|
4
|
+
// four slots so a newly opened conversation never sits behind the whole list.
|
|
5
|
+
export function createPeerPreparation ({ prepare, onReady, onError, isRetryable, online, onOnline, setTimer, clearTimer, random }) {
|
|
6
|
+
const entries = new Map()
|
|
7
|
+
let enabled = false
|
|
8
|
+
let closed = false
|
|
9
|
+
let running = 0
|
|
10
|
+
let background = 0
|
|
11
|
+
const unavailable = () => new Error('CHAT_UNAVAILABLE')
|
|
12
|
+
function entryFor (peer) {
|
|
13
|
+
const deferred = Promise.withResolvers()
|
|
14
|
+
deferred.promise.catch(() => {})
|
|
15
|
+
return { peer, ...deferred, controller: new AbortController(), state: 'queued', priority: false, value: null, error: null, recovery: null }
|
|
16
|
+
}
|
|
17
|
+
const current = entry => !closed && enabled && entries.get(entry.peer) === entry && !entry.controller.signal.aborted
|
|
18
|
+
function cancel (entry) {
|
|
19
|
+
entry.recovery?.stop()
|
|
20
|
+
entry.controller.abort(unavailable())
|
|
21
|
+
entry.reject(entry.controller.signal.reason)
|
|
22
|
+
entry.retryResult?.resolve()
|
|
23
|
+
entry.retryResult = null
|
|
24
|
+
}
|
|
25
|
+
function drain () {
|
|
26
|
+
if (!enabled || closed) return
|
|
27
|
+
while (running < 4) {
|
|
28
|
+
const queued = [...entries.values()].filter(entry => entry.state === 'queued')
|
|
29
|
+
const entry = queued.find(entry => entry.priority) || (background < 3 && queued[0])
|
|
30
|
+
if (!entry) break
|
|
31
|
+
const isBackground = !entry.priority
|
|
32
|
+
entry.state = 'running'
|
|
33
|
+
running++
|
|
34
|
+
if (isBackground) background++
|
|
35
|
+
Promise.resolve().then(() => {
|
|
36
|
+
entry.controller.signal.throwIfAborted()
|
|
37
|
+
return prepare(entry.peer, entry.controller.signal)
|
|
38
|
+
}).then(value => {
|
|
39
|
+
if (!current(entry)) return
|
|
40
|
+
entry.state = 'ready'; entry.value = value
|
|
41
|
+
entry.resolve(value)
|
|
42
|
+
onReady(entry.peer, value)
|
|
43
|
+
}, error => {
|
|
44
|
+
if (!current(entry)) return
|
|
45
|
+
entry.error = error
|
|
46
|
+
onError(error)
|
|
47
|
+
if (!isRetryable(error)) {
|
|
48
|
+
entry.state = 'failed'; entry.reject(error)
|
|
49
|
+
entry.retryResult?.reject(error); entry.retryResult = null
|
|
50
|
+
onReady(entry.peer)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
entry.state = 'waiting'
|
|
54
|
+
entry.recovery ||= createPauseRecovery({
|
|
55
|
+
attempt: async signal => {
|
|
56
|
+
signal.throwIfAborted()
|
|
57
|
+
if (!current(entry)) return
|
|
58
|
+
entry.state = 'queued'; drain()
|
|
59
|
+
// Recovery waits for the actual preparation; no slot is held
|
|
60
|
+
// during backoff or a connectivity wait.
|
|
61
|
+
await new Promise((resolve, reject) => { entry.retryResult = { resolve, reject } })
|
|
62
|
+
},
|
|
63
|
+
retryable: isRetryable, onError: () => {}, online, onOnline, setTimer, clearTimer, random
|
|
64
|
+
})
|
|
65
|
+
if (entry.retryResult) { entry.retryResult.reject(error); entry.retryResult = null } else entry.recovery.start({ retryAt: error.retryAt })
|
|
66
|
+
}).finally(() => {
|
|
67
|
+
if (entry.state === 'ready') { entry.retryResult?.resolve(); entry.retryResult = null }
|
|
68
|
+
if (!current(entry)) { entry.retryResult?.resolve(); entry.retryResult = null }
|
|
69
|
+
running--
|
|
70
|
+
if (isBackground) background--
|
|
71
|
+
drain()
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
get: peer => entries.get(peer)?.value,
|
|
77
|
+
error: peer => entries.get(peer)?.state === 'failed' ? entries.get(peer).error : null,
|
|
78
|
+
reconcile (peers) {
|
|
79
|
+
const wanted = new Set(peers)
|
|
80
|
+
for (const [peer, entry] of entries) if (!wanted.has(peer)) { entries.delete(peer); cancel(entry) }
|
|
81
|
+
for (const peer of wanted) if (!entries.has(peer)) entries.set(peer, entryFor(peer))
|
|
82
|
+
drain()
|
|
83
|
+
},
|
|
84
|
+
setAvailable (value) {
|
|
85
|
+
enabled = value === true
|
|
86
|
+
if (!enabled) {
|
|
87
|
+
for (const [peer, entry] of entries) {
|
|
88
|
+
if (entry.state === 'ready') continue
|
|
89
|
+
cancel(entry)
|
|
90
|
+
const replacement = entryFor(peer)
|
|
91
|
+
replacement.priority = entry.priority
|
|
92
|
+
entries.set(peer, replacement)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
drain()
|
|
96
|
+
},
|
|
97
|
+
request (peer, { retry = false } = {}) {
|
|
98
|
+
if (closed || !entries.has(peer)) return Promise.reject(unavailable())
|
|
99
|
+
let entry = entries.get(peer)
|
|
100
|
+
if (retry && entry.state === 'failed') { cancel(entry); entries.set(peer, (entry = entryFor(peer))) }
|
|
101
|
+
entry.priority = true
|
|
102
|
+
drain()
|
|
103
|
+
return entry.promise
|
|
104
|
+
},
|
|
105
|
+
close () {
|
|
106
|
+
closed = true; enabled = false
|
|
107
|
+
for (const entry of entries.values()) cancel(entry)
|
|
108
|
+
entries.clear()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -3,8 +3,12 @@ import { base64ToBytes, bytesToBase64 } from '../../../base64/index.js'
|
|
|
3
3
|
// The library transport uses its Base64 signer contract. Conversion stays at
|
|
4
4
|
// this adapter; the injected launcher/vault channel always carries raw bytes.
|
|
5
5
|
export function messengerSigner (signer) {
|
|
6
|
+
// The adapter belongs to one account/channel generation. Share both the
|
|
7
|
+
// lookup in flight and its immutable result; failed permissions are retryable
|
|
8
|
+
// only when the owning session explicitly asks again.
|
|
9
|
+
let publicKey
|
|
6
10
|
const adapter = {
|
|
7
|
-
getPublicKey: () => signer.getPublicKey(),
|
|
11
|
+
getPublicKey: () => (publicKey ??= Promise.resolve().then(() => signer.getPublicKey()).catch(error => { publicKey = null; throw error })),
|
|
8
12
|
getRelays: () => signer.getRelays(),
|
|
9
13
|
signEvent: event => signer.signEvent(event),
|
|
10
14
|
nip44v3Encrypt: (peer, kind, scope, text) => signer.nip44v3.encrypt(peer, kind, scope, base64ToBytes(text).buffer),
|
|
@@ -4,13 +4,16 @@ 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
6
|
import { getIykcProofs } from '../../content-key/index.js'
|
|
7
|
-
import { onOnline } from '../../network/index.js'
|
|
7
|
+
import { isOnline, onOnline } from '../../network/index.js'
|
|
8
|
+
import { createPeerPreparation } from './helpers/peer-preparation.js'
|
|
9
|
+
import { createForegroundWork } from './helpers/foreground-work.js'
|
|
10
|
+
import { isRetryableRelayFailure } from '../../relay/index.js'
|
|
8
11
|
import { ValidationError } from '../../error/index.js'
|
|
9
12
|
import { decodeFileMetadata } from '../../nip94/index.js'
|
|
10
13
|
import { createChatOutbox } from './helpers/work-storage.js'
|
|
11
14
|
import { messengerSigner } from './helpers/signer.js'
|
|
12
15
|
import { assertMessagePublished } from '../helpers/publication.js'
|
|
13
|
-
import { normalizeFallbackRelays } from '../helpers/send-routing.js'
|
|
16
|
+
import { normalizeFallbackRelays, normalizeFallbackDelay } from '../helpers/send-routing.js'
|
|
14
17
|
import { createPauseRecovery, isRecoverableStorageFailure } from '../helpers/pause-recovery.js'
|
|
15
18
|
|
|
16
19
|
export function wireEvent (value, owner) {
|
|
@@ -41,8 +44,9 @@ function messengerStatusSnapshot (value) {
|
|
|
41
44
|
return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
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, _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, 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' }) }) {
|
|
45
48
|
fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
49
|
+
fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
46
50
|
const reportedErrors = new WeakSet()
|
|
47
51
|
const reportError = error => {
|
|
48
52
|
if (error && (typeof error === 'object' || typeof error === 'function')) {
|
|
@@ -52,11 +56,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
52
56
|
onError(error)
|
|
53
57
|
}
|
|
54
58
|
const userSigner = messengerSigner(signer)
|
|
59
|
+
const foreground = createForegroundWork()
|
|
55
60
|
messageStorage ||= createEventStoreMessageStorage({ eventStore })
|
|
56
61
|
chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
|
|
57
62
|
const peers = new Set()
|
|
58
63
|
const channels = new Map()
|
|
59
|
-
const deniedPeers = new Set()
|
|
60
64
|
const entries = new Map()
|
|
61
65
|
const cancelled = new Set()
|
|
62
66
|
const sendControllers = new Map()
|
|
@@ -112,6 +116,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
112
116
|
let lifecycle = 0
|
|
113
117
|
let configuring = Promise.resolve()
|
|
114
118
|
let configurationFailed = false
|
|
119
|
+
let configureQueued = false
|
|
120
|
+
let configureWork
|
|
121
|
+
let messengerWork
|
|
122
|
+
let fileSetupWork
|
|
115
123
|
let sending
|
|
116
124
|
let repump
|
|
117
125
|
let draining
|
|
@@ -131,7 +139,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
131
139
|
let candidate
|
|
132
140
|
try {
|
|
133
141
|
candidate = await Messenger({
|
|
134
|
-
fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
|
|
142
|
+
fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait,
|
|
135
143
|
onMessageQueued: () => { if (messengerBinding === binding) return drain() },
|
|
136
144
|
onStateChanged: state => {
|
|
137
145
|
if (closed || messengerBinding !== binding) return
|
|
@@ -153,28 +161,41 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
153
161
|
}
|
|
154
162
|
}
|
|
155
163
|
|
|
164
|
+
const preparation = createPeerPreparation({
|
|
165
|
+
prepare: async (peer, signal) => {
|
|
166
|
+
const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
|
|
167
|
+
const pubkey = await scoped.getPublicKey()
|
|
168
|
+
signal.throwIfAborted()
|
|
169
|
+
const channel = { signer: scoped, pubkey, readerPubkey: pubkey, mode, seeders: seedersForPeer(peer) }
|
|
170
|
+
channels.set(peer, channel)
|
|
171
|
+
await schedule()
|
|
172
|
+
signal.throwIfAborted()
|
|
173
|
+
return channel
|
|
174
|
+
},
|
|
175
|
+
onReady: () => { drain(); pump() }, onError: reportError,
|
|
176
|
+
isRetryable: error => ['connection', 'transport', 'timeout'].includes(error?.category) && isRetryableRelayFailure(error),
|
|
177
|
+
online: _isOnline, onOnline: _onOnline, setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random
|
|
178
|
+
})
|
|
179
|
+
async function ensureMessenger () {
|
|
180
|
+
if (!messenger && !messengerWork) {
|
|
181
|
+
const work = createMessenger()
|
|
182
|
+
messengerWork = work
|
|
183
|
+
work.finally(() => { if (messengerWork === work) messengerWork = null }).catch(() => {})
|
|
184
|
+
}
|
|
185
|
+
await messengerWork
|
|
186
|
+
}
|
|
156
187
|
async function configure () {
|
|
157
188
|
if (closed || !available) return
|
|
158
|
-
await ready()
|
|
159
189
|
const version = lifecycle
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
let channel = channels.get(peer)
|
|
164
|
-
if (!channel) {
|
|
165
|
-
try {
|
|
166
|
-
const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
|
|
167
|
-
channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
|
|
168
|
-
channels.set(peer, channel)
|
|
169
|
-
} catch (error) { if (!retryable(error)) deniedPeers.add(peer); reportError(error); continue }
|
|
170
|
-
}
|
|
171
|
-
if (closed || !available || version !== lifecycle) return
|
|
172
|
-
values.push(channel)
|
|
173
|
-
}
|
|
174
|
-
if (!messenger) await createMessenger()
|
|
190
|
+
await ready()
|
|
191
|
+
if (closed || !available || version !== lifecycle) return
|
|
192
|
+
await ensureMessenger()
|
|
175
193
|
if (!messenger) return
|
|
176
|
-
if (closed || !available
|
|
194
|
+
if (closed || !available) { await messenger.pause('signer'); return }
|
|
195
|
+
if (version !== lifecycle) { configureQueued = true; return }
|
|
196
|
+
const values = [...channels].filter(([peer]) => peers.has(peer)).map(([, channel]) => channel)
|
|
177
197
|
await messenger.update({ channels: values })
|
|
198
|
+
if (closed || !available || version !== lifecycle) return
|
|
178
199
|
if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
|
|
179
200
|
for (const [peer, list] of [...pendingPriorityRanges]) {
|
|
180
201
|
const channel = channels.get(peer)
|
|
@@ -186,7 +207,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
186
207
|
}
|
|
187
208
|
}
|
|
188
209
|
await messenger.resume('signer')
|
|
210
|
+
if (closed || !available || version !== lifecycle) return
|
|
211
|
+
preparation.setAvailable(true)
|
|
189
212
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
213
|
+
if (!fileSetupWork) {
|
|
214
|
+
fileSetupWork = setupFiles().catch(error => { fileSetupWork = null; if (!closed) reportError(error) })
|
|
215
|
+
}
|
|
216
|
+
fileSetupWork.then(() => { if (!closed && available) restoreDownloads() }).catch(reportError)
|
|
217
|
+
drain(); pump()
|
|
218
|
+
}
|
|
219
|
+
async function setupFiles () {
|
|
190
220
|
if (!fileTransfers) {
|
|
191
221
|
fileTransfers = FileTransfer({
|
|
192
222
|
messenger, onError: reportError,
|
|
@@ -205,9 +235,17 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
205
235
|
}
|
|
206
236
|
}
|
|
207
237
|
})
|
|
208
|
-
downloads = await openDownloads({ owner, signer })
|
|
209
|
-
for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
|
|
210
238
|
}
|
|
239
|
+
if (!downloads) {
|
|
240
|
+
const candidate = await openDownloads({ owner, signer })
|
|
241
|
+
try {
|
|
242
|
+
const intents = await candidate.list()
|
|
243
|
+
downloads = candidate
|
|
244
|
+
for (const entry of intents) downloadIntents.set(entry.id, entry)
|
|
245
|
+
} catch (error) { await candidate.close?.(); throw error }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function restoreDownloads () {
|
|
211
249
|
for (const entry of downloadIntents.values()) {
|
|
212
250
|
if (!peers.has(entry.file.peer) || activeDownloads.has(entry.id)) continue
|
|
213
251
|
const work = download(entry.file, { manual: true })
|
|
@@ -219,12 +257,37 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
219
257
|
.finally(() => { if (activeDownloads.get(entry.id) === work) activeDownloads.delete(entry.id) })
|
|
220
258
|
activeDownloads.set(entry.id, work)
|
|
221
259
|
}
|
|
222
|
-
|
|
260
|
+
pump()
|
|
223
261
|
}
|
|
224
262
|
function schedule () {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
work.then(
|
|
263
|
+
configureQueued = true
|
|
264
|
+
if (configureWork) return configureWork
|
|
265
|
+
const work = Promise.resolve().then(async () => {
|
|
266
|
+
try {
|
|
267
|
+
do {
|
|
268
|
+
configureQueued = false
|
|
269
|
+
await configure()
|
|
270
|
+
if (closed || !available) break
|
|
271
|
+
} while (configureQueued)
|
|
272
|
+
} finally { if (configureWork === work) configureWork = null }
|
|
273
|
+
})
|
|
274
|
+
configureWork = configuring = work
|
|
275
|
+
work.then(() => {
|
|
276
|
+
if (configureWork === work) configureWork = null
|
|
277
|
+
configurationFailed = false
|
|
278
|
+
}, error => {
|
|
279
|
+
if (configureWork === work) configureWork = null
|
|
280
|
+
configurationFailed = true
|
|
281
|
+
if (!closed) reportError(error)
|
|
282
|
+
}).catch(() => {})
|
|
283
|
+
return work
|
|
284
|
+
}
|
|
285
|
+
async function preparePeer (peer, { retry = true } = {}) {
|
|
286
|
+
if (typeof peer !== 'string' || !/^[0-9a-f]{64}$/i.test(peer)) throw new ValidationError('INVALID_PRIVATE_PEER')
|
|
287
|
+
if (closed || !available || !peers.has(peer)) throw new Error('CHAT_UNAVAILABLE')
|
|
288
|
+
const work = preparation.request(peer, { retry })
|
|
289
|
+
work.catch(() => {})
|
|
290
|
+
if (!messenger || configurationFailed) await schedule()
|
|
228
291
|
return work
|
|
229
292
|
}
|
|
230
293
|
async function drain () {
|
|
@@ -284,7 +347,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
284
347
|
}
|
|
285
348
|
}
|
|
286
349
|
async function publish (peer, event, signal) {
|
|
287
|
-
if (
|
|
350
|
+
if (preparation.error(peer)) throw preparation.error(peer)
|
|
288
351
|
if (!available || !peers.has(peer) || !channels.has(peer)) throw unavailableError(new Error('CHAT_UNAVAILABLE'), signal)
|
|
289
352
|
const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
|
|
290
353
|
let report
|
|
@@ -298,6 +361,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
298
361
|
await assertMessagePublished(report, event)
|
|
299
362
|
}
|
|
300
363
|
async function sendEntry (entry) {
|
|
364
|
+
const releaseForeground = foreground.enter()
|
|
301
365
|
entry.status = 'pending'; emit()
|
|
302
366
|
const controller = new AbortController()
|
|
303
367
|
sendControllers.set(entry.id, controller)
|
|
@@ -320,6 +384,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
320
384
|
await storage.put(entry, { existing: true })
|
|
321
385
|
}
|
|
322
386
|
}
|
|
387
|
+
if (entry.peer !== owner && !preparation.get(entry.peer)) {
|
|
388
|
+
if (preparation.error(entry.peer)) throw preparation.error(entry.peer)
|
|
389
|
+
preparation.request(entry.peer).catch(() => {})
|
|
390
|
+
return
|
|
391
|
+
}
|
|
323
392
|
if (entry.peer !== owner && messengerStatus?.paused) {
|
|
324
393
|
throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
|
|
325
394
|
}
|
|
@@ -355,7 +424,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
355
424
|
const event = entry.events[entry.index]
|
|
356
425
|
if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
|
|
357
426
|
await active()
|
|
358
|
-
|
|
427
|
+
// The final durable operation is removal. Avoid encrypting a checkpoint
|
|
428
|
+
// that would immediately be deleted; earlier context stages still checkpoint.
|
|
429
|
+
if (entry.index + 1 < entry.events.length) await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
|
|
359
430
|
}
|
|
360
431
|
await storage.remove(entry.id)
|
|
361
432
|
entries.delete(entry.id)
|
|
@@ -367,16 +438,22 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
367
438
|
if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
|
|
368
439
|
entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
|
|
369
440
|
await storage.put(entry, { existing: true }).catch(reportError)
|
|
370
|
-
if (entry.peer !== owner && error.retryWhenAvailable && messenger
|
|
441
|
+
if (entry.peer !== owner && error.retryWhenAvailable && messenger?.readStatus().paused) pausedSends.add(entry.id)
|
|
442
|
+
else if (isUnavailableSend(cause) && cause.pauseReasons?.length && available && !closed) {
|
|
443
|
+
// Release can race persistence of the failed attempt. Do not lose
|
|
444
|
+
// its notification and spend a retry delay after the pause is gone.
|
|
445
|
+
entry.failed = false
|
|
446
|
+
queueMicrotask(() => pump())
|
|
447
|
+
}
|
|
371
448
|
reportError(error)
|
|
372
449
|
// The failed wire event may be a quote or file chunk. Report the
|
|
373
450
|
// owning outbox item separately, without mutating the native error.
|
|
374
451
|
if (error.retryWhenOnline) offlineSends.add(entry.id)
|
|
375
|
-
if (error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
|
|
452
|
+
if (entry.failed && error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
|
|
376
453
|
if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
|
|
377
454
|
}
|
|
378
455
|
emit()
|
|
379
|
-
} finally { sendControllers.delete(entry.id) }
|
|
456
|
+
} finally { sendControllers.delete(entry.id); releaseForeground() }
|
|
380
457
|
}
|
|
381
458
|
function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
|
|
382
459
|
// Transient availability failures without an active messenger pause retain
|
|
@@ -424,7 +501,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
424
501
|
})
|
|
425
502
|
}
|
|
426
503
|
async function pump () {
|
|
427
|
-
if (!available || closed || !
|
|
504
|
+
if (!available || closed || !storage) return
|
|
428
505
|
// A retry that lands while a pump is already running must not be skipped
|
|
429
506
|
// just because that pass already walked past the entry.
|
|
430
507
|
if (sending) { repump = true; return }
|
|
@@ -435,6 +512,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
435
512
|
for (const entry of entries.values()) {
|
|
436
513
|
if (closed || !available) break
|
|
437
514
|
if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
|
|
515
|
+
if (entry.peer !== owner && entry.localSaved.every(Boolean) && !preparation.get(entry.peer) && !preparation.error(entry.peer)) {
|
|
516
|
+
preparation.request(entry.peer).catch(() => {})
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
438
519
|
if (entry.peer !== owner && entry.files.length && !fileTransfers) continue
|
|
439
520
|
await sendEntry(entry)
|
|
440
521
|
}
|
|
@@ -449,7 +530,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
449
530
|
const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
|
|
450
531
|
async function download (file, options = {}) {
|
|
451
532
|
if (file.peer === owner) return file
|
|
452
|
-
if (!available || closed || !peers.has(file.peer)
|
|
533
|
+
if (!available || closed || !peers.has(file.peer)) throw new Error('CHAT_UNAVAILABLE')
|
|
534
|
+
await preparePeer(file.peer)
|
|
535
|
+
await fileSetupWork
|
|
536
|
+
if (!fileTransfers || !downloads) throw new Error('CHAT_UNAVAILABLE')
|
|
453
537
|
const id = intentId(file)
|
|
454
538
|
if (options.manual) {
|
|
455
539
|
const epoch = downloadEpochs.get(id) || 0
|
|
@@ -474,7 +558,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
474
558
|
await writeDownloadIntent(() => downloads?.remove(id))
|
|
475
559
|
}
|
|
476
560
|
return {
|
|
477
|
-
download, cancelDownload,
|
|
561
|
+
download, cancelDownload, preparePeer,
|
|
478
562
|
// Peers-based so callers can prefetch as soon as a chat route opens, before
|
|
479
563
|
// the shared-key channel exists. When group chats get real membership, add
|
|
480
564
|
// a thin prefetchChannelContentKeys(channelPubkey) wrapper that resolves
|
|
@@ -502,20 +586,28 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
502
586
|
if (type === 'unread-page' && since === undefined) throw new TypeError('PRIORITY_SINCE_REQUIRED')
|
|
503
587
|
const request = { since, until, type }
|
|
504
588
|
const channel = channels.get(peer)
|
|
505
|
-
if (channel && typeof messenger?.prioritizeRange === 'function') {
|
|
589
|
+
if (channel && preparation.get(peer) && !pendingPriorityRanges.has(peer) && typeof messenger?.prioritizeRange === 'function') {
|
|
506
590
|
try { return await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error); return false }
|
|
507
591
|
}
|
|
508
592
|
const list = pendingPriorityRanges.get(peer) || []
|
|
509
593
|
list.push(request)
|
|
510
594
|
pendingPriorityRanges.set(peer, list)
|
|
595
|
+
if (channel && available) schedule().catch(() => {})
|
|
511
596
|
return true
|
|
512
597
|
},
|
|
513
598
|
async setPeers (values) {
|
|
514
599
|
const next = new Set([...values].filter(peer => peer !== owner))
|
|
515
600
|
if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
|
|
516
601
|
peers.clear(); for (const peer of next) peers.add(peer)
|
|
517
|
-
|
|
518
|
-
|
|
602
|
+
preparation.reconcile(peers)
|
|
603
|
+
for (const peer of channels.keys()) {
|
|
604
|
+
if (!peers.has(peer)) {
|
|
605
|
+
channels.delete(peer); pendingPriorityRanges.delete(peer)
|
|
606
|
+
for (const entry of entries.values()) {
|
|
607
|
+
if (entry.peer === peer) sendControllers.get(entry.id)?.abort()
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
519
611
|
return schedule()
|
|
520
612
|
},
|
|
521
613
|
async setAvailable (value) {
|
|
@@ -526,31 +618,39 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
526
618
|
if (available) resetSendRetry()
|
|
527
619
|
else clearSendRetry()
|
|
528
620
|
if (changed) lifecycle++
|
|
529
|
-
if (!available) {
|
|
621
|
+
if (!available) {
|
|
622
|
+
preparation.setAvailable(false)
|
|
623
|
+
for (const peer of channels.keys()) if (!preparation.get(peer)) channels.delete(peer)
|
|
624
|
+
inboxRecovery.stop(); await messenger?.pause('signer'); return
|
|
625
|
+
}
|
|
530
626
|
await ready()
|
|
531
627
|
for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
|
|
532
628
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
533
|
-
if (!changed && messenger && !configurationFailed)
|
|
629
|
+
if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
|
|
534
630
|
return schedule()
|
|
535
631
|
},
|
|
536
632
|
async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
|
|
537
633
|
if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
634
|
+
const releaseForeground = foreground.enter()
|
|
635
|
+
try {
|
|
636
|
+
await ready()
|
|
637
|
+
const main = wireEvent(event, owner)
|
|
638
|
+
const id = getEventHash(main)
|
|
639
|
+
const events = [...context.map(value => wireEvent(value, owner)), main]
|
|
640
|
+
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 }] : [])])
|
|
641
|
+
const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
|
|
642
|
+
await storage.put(entry)
|
|
643
|
+
entries.set(id, entry); emit(); pump()
|
|
644
|
+
if (peer !== owner) preparePeer(peer).catch(() => {})
|
|
645
|
+
return id
|
|
646
|
+
} finally { releaseForeground() }
|
|
547
647
|
},
|
|
548
648
|
async retry (id) {
|
|
549
649
|
offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
|
|
550
650
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
551
651
|
await ready()
|
|
552
652
|
const entry = entries.get(id)
|
|
553
|
-
if (entry) { entry.failed = false; resetSendRetry(); if (
|
|
653
|
+
if (entry) { entry.failed = false; resetSendRetry(); if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {}); return pump() }
|
|
554
654
|
},
|
|
555
655
|
async cancel (id) {
|
|
556
656
|
cancelled.add(id)
|
|
@@ -565,9 +665,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
565
665
|
return (closeWork ??= (async () => {
|
|
566
666
|
closed = true; available = false; lifecycle++
|
|
567
667
|
stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
|
|
568
|
-
clearSendRetry(); inboxRecovery.stop(); pausedSends.clear()
|
|
668
|
+
clearSendRetry(); inboxRecovery.stop(); preparation.close(); foreground.close(); pausedSends.clear()
|
|
569
669
|
for (const controller of sendControllers.values()) controller.abort()
|
|
570
|
-
await Promise.allSettled([configuring, initialized])
|
|
670
|
+
await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
|
|
571
671
|
await messenger?.pause('closed')
|
|
572
672
|
if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
|
|
573
673
|
await messenger?.close()
|
package/relay/helpers/publish.js
CHANGED
|
@@ -76,7 +76,11 @@ export function createPublishSettlements (promises, timeout, { onSettled } = {})
|
|
|
76
76
|
})
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
const cancel = reason => {
|
|
80
|
+
if (isFinished) return
|
|
81
|
+
for (let index = 0; index < settlements.length; index++) settle(index, { status: 'rejected', outcome: 'failed', reason })
|
|
82
|
+
}
|
|
83
|
+
return { promise, timeout: timeoutPending, cancel }
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
// Turns ordered relay settlements into a stable, caller-facing report. Failed
|
|
@@ -186,23 +186,33 @@ export class RelayConnection {
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
#sendEventOperation (type, event, map, timeoutCode, signal) {
|
|
189
|
-
if (signal?.aborted) return Promise.reject(signal.reason
|
|
190
|
-
|
|
189
|
+
if (signal?.aborted) return Promise.reject(signal.reason)
|
|
190
|
+
let pending = map.get(event.id)
|
|
191
|
+
const created = !pending
|
|
192
|
+
if (created) {
|
|
193
|
+
const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
|
|
194
|
+
pending = { timer, waiters: new Set(), cancelSend: null }
|
|
195
|
+
map.set(event.id, pending)
|
|
196
|
+
}
|
|
191
197
|
const deferred = Promise.withResolvers()
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
198
|
+
const onAbort = () => {
|
|
199
|
+
pending.waiters.delete(waiter)
|
|
200
|
+
signal.removeEventListener('abort', onAbort)
|
|
201
|
+
deferred.reject(signal.reason)
|
|
202
|
+
if (!pending.waiters.size && map.get(event.id) === pending) {
|
|
203
|
+
map.delete(event.id)
|
|
204
|
+
pending.cancelSend?.()
|
|
205
|
+
clearTimeout(pending.timer)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const waiter = { ...deferred, stopAbort: () => signal?.removeEventListener('abort', onAbort) }
|
|
209
|
+
pending.waiters.add(waiter)
|
|
197
210
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}, error => {
|
|
204
|
-
this.#settleEvent(map, event.id, error)
|
|
205
|
-
})
|
|
211
|
+
if (created) {
|
|
212
|
+
pending.cancelSend = this.#dispatchWork(() => {
|
|
213
|
+
this.send(JSON.stringify([type, event]))
|
|
214
|
+
}, error => this.#settleEvent(map, event.id, error))
|
|
215
|
+
}
|
|
206
216
|
return deferred.promise
|
|
207
217
|
}
|
|
208
218
|
|
|
@@ -225,10 +235,13 @@ export class RelayConnection {
|
|
|
225
235
|
if (!pending) return
|
|
226
236
|
map.delete(id)
|
|
227
237
|
pending.cancelSend?.()
|
|
228
|
-
pending.stopAbort?.()
|
|
229
238
|
clearTimeout(pending.timer)
|
|
230
|
-
|
|
231
|
-
|
|
239
|
+
for (const waiter of pending.waiters) {
|
|
240
|
+
waiter.stopAbort()
|
|
241
|
+
if (reason) waiter.reject(errorFrom(reason, 'OPERATION_REJECTED'))
|
|
242
|
+
else waiter.resolve(value)
|
|
243
|
+
}
|
|
244
|
+
pending.waiters.clear()
|
|
232
245
|
}
|
|
233
246
|
|
|
234
247
|
#settleCount (id, payload, reason) {
|
|
@@ -1204,8 +1204,10 @@ export class RelayPool {
|
|
|
1204
1204
|
timeout = SEND_TIMEOUT_MS,
|
|
1205
1205
|
timeoutUntilFirstFulfillment = null,
|
|
1206
1206
|
getAuthEvent,
|
|
1207
|
-
onRelayResult
|
|
1207
|
+
onRelayResult,
|
|
1208
|
+
signal
|
|
1208
1209
|
} = {}) {
|
|
1210
|
+
if (signal != null && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function')) throw new ValidationError('INVALID_RELAY_SIGNAL')
|
|
1209
1211
|
const urls = normalizedRelayUrls(relays)
|
|
1210
1212
|
if (!urls.length) {
|
|
1211
1213
|
const promise = Promise.resolve(publishSummary([], urls, {
|
|
@@ -1236,7 +1238,11 @@ export class RelayPool {
|
|
|
1236
1238
|
// Resolves after every relay settles (or reaches the operation timeout) as
|
|
1237
1239
|
// { success, total, fulfilled, succeededRelays, errors },
|
|
1238
1240
|
// where errors contains { relay, reason } entries for failed relays.
|
|
1241
|
+
const cancel = () => settlement.cancel(signal.reason)
|
|
1242
|
+
if (signal?.aborted) cancel()
|
|
1243
|
+
else signal?.addEventListener('abort', cancel, { once: true })
|
|
1239
1244
|
const promise = settlement.promise
|
|
1245
|
+
.finally(() => signal?.removeEventListener('abort', cancel))
|
|
1240
1246
|
.then(settlements => publishSummary(settlements, urls, {
|
|
1241
1247
|
includeSucceededRelays: true
|
|
1242
1248
|
}))
|
|
@@ -1245,10 +1251,11 @@ export class RelayPool {
|
|
|
1245
1251
|
const deferred = sendDeferreds[index]
|
|
1246
1252
|
;(async () => {
|
|
1247
1253
|
try {
|
|
1254
|
+
const operationSignal = sendControllers[index].signal
|
|
1255
|
+
if (operationSignal.aborted) throw operationSignal.reason
|
|
1248
1256
|
const relay = await this.#getRelay(url)
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
return await this.#publishEvent(relay, eventToSend, getAuthEvent, signal)
|
|
1257
|
+
if (operationSignal.aborted) throw operationSignal.reason
|
|
1258
|
+
return await this.#publishEvent(relay, eventToSend, getAuthEvent, operationSignal)
|
|
1252
1259
|
} catch (err) {
|
|
1253
1260
|
const reason = err instanceof Error ? err : new Error(String(err))
|
|
1254
1261
|
if (reason instanceof Nip42AuthenticationError) throw reason
|