libp2r2p 0.11.18 → 0.11.20
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 +41 -17
- 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/index.js +173 -61
- package/private-messenger/session/README.md +26 -1
- 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 +151 -52
- package/relay/helpers/error.js +2 -2
- package/relay/helpers/retry-advice.js +9 -14
- package/relay/services/relay-connection.js +16 -13
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Local outbox persistence and user publications take precedence over the next
|
|
2
|
+
// optional presence signer call. Already-started signer calls are not recalled.
|
|
3
|
+
export function createForegroundWork () {
|
|
4
|
+
let active = 0
|
|
5
|
+
const waiters = new Set()
|
|
6
|
+
const lifetime = new AbortController()
|
|
7
|
+
return {
|
|
8
|
+
enter () {
|
|
9
|
+
active++
|
|
10
|
+
let released = false
|
|
11
|
+
return () => {
|
|
12
|
+
if (released) return
|
|
13
|
+
released = true
|
|
14
|
+
if (--active === 0) for (const wake of [...waiters]) wake()
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
wait (signal) {
|
|
18
|
+
const combined = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal
|
|
19
|
+
combined.throwIfAborted()
|
|
20
|
+
if (!active) return Promise.resolve()
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const finish = () => { waiters.delete(finish); combined.removeEventListener('abort', finish); combined.aborted ? reject(combined.reason) : resolve() }
|
|
23
|
+
waiters.add(finish)
|
|
24
|
+
combined.addEventListener('abort', finish, { once: true })
|
|
25
|
+
})
|
|
26
|
+
},
|
|
27
|
+
close () { lifetime.abort(); waiters.clear() }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -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,7 +4,10 @@ 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'
|
|
@@ -41,7 +44,7 @@ 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 = [], 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, _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)
|
|
46
49
|
fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
47
50
|
const reportedErrors = new WeakSet()
|
|
@@ -53,11 +56,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
53
56
|
onError(error)
|
|
54
57
|
}
|
|
55
58
|
const userSigner = messengerSigner(signer)
|
|
59
|
+
const foreground = createForegroundWork()
|
|
56
60
|
messageStorage ||= createEventStoreMessageStorage({ eventStore })
|
|
57
61
|
chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
|
|
58
62
|
const peers = new Set()
|
|
59
63
|
const channels = new Map()
|
|
60
|
-
const deniedPeers = new Set()
|
|
61
64
|
const entries = new Map()
|
|
62
65
|
const cancelled = new Set()
|
|
63
66
|
const sendControllers = new Map()
|
|
@@ -113,6 +116,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
113
116
|
let lifecycle = 0
|
|
114
117
|
let configuring = Promise.resolve()
|
|
115
118
|
let configurationFailed = false
|
|
119
|
+
let configureQueued = false
|
|
120
|
+
let configureWork
|
|
121
|
+
let messengerWork
|
|
122
|
+
let fileSetupWork
|
|
116
123
|
let sending
|
|
117
124
|
let repump
|
|
118
125
|
let draining
|
|
@@ -132,7 +139,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
132
139
|
let candidate
|
|
133
140
|
try {
|
|
134
141
|
candidate = await Messenger({
|
|
135
|
-
fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
|
|
142
|
+
fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait,
|
|
136
143
|
onMessageQueued: () => { if (messengerBinding === binding) return drain() },
|
|
137
144
|
onStateChanged: state => {
|
|
138
145
|
if (closed || messengerBinding !== binding) return
|
|
@@ -154,28 +161,41 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
154
161
|
}
|
|
155
162
|
}
|
|
156
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
|
+
}
|
|
157
187
|
async function configure () {
|
|
158
188
|
if (closed || !available) return
|
|
159
|
-
await ready()
|
|
160
189
|
const version = lifecycle
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
let channel = channels.get(peer)
|
|
165
|
-
if (!channel) {
|
|
166
|
-
try {
|
|
167
|
-
const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
|
|
168
|
-
channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
|
|
169
|
-
channels.set(peer, channel)
|
|
170
|
-
} catch (error) { if (!retryable(error)) deniedPeers.add(peer); reportError(error); continue }
|
|
171
|
-
}
|
|
172
|
-
if (closed || !available || version !== lifecycle) return
|
|
173
|
-
values.push(channel)
|
|
174
|
-
}
|
|
175
|
-
if (!messenger) await createMessenger()
|
|
190
|
+
await ready()
|
|
191
|
+
if (closed || !available || version !== lifecycle) return
|
|
192
|
+
await ensureMessenger()
|
|
176
193
|
if (!messenger) return
|
|
177
|
-
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)
|
|
178
197
|
await messenger.update({ channels: values })
|
|
198
|
+
if (closed || !available || version !== lifecycle) return
|
|
179
199
|
if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
|
|
180
200
|
for (const [peer, list] of [...pendingPriorityRanges]) {
|
|
181
201
|
const channel = channels.get(peer)
|
|
@@ -187,7 +207,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
187
207
|
}
|
|
188
208
|
}
|
|
189
209
|
await messenger.resume('signer')
|
|
210
|
+
if (closed || !available || version !== lifecycle) return
|
|
211
|
+
preparation.setAvailable(true)
|
|
190
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 () {
|
|
191
220
|
if (!fileTransfers) {
|
|
192
221
|
fileTransfers = FileTransfer({
|
|
193
222
|
messenger, onError: reportError,
|
|
@@ -206,9 +235,17 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
206
235
|
}
|
|
207
236
|
}
|
|
208
237
|
})
|
|
209
|
-
downloads = await openDownloads({ owner, signer })
|
|
210
|
-
for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
|
|
211
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 () {
|
|
212
249
|
for (const entry of downloadIntents.values()) {
|
|
213
250
|
if (!peers.has(entry.file.peer) || activeDownloads.has(entry.id)) continue
|
|
214
251
|
const work = download(entry.file, { manual: true })
|
|
@@ -220,12 +257,37 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
220
257
|
.finally(() => { if (activeDownloads.get(entry.id) === work) activeDownloads.delete(entry.id) })
|
|
221
258
|
activeDownloads.set(entry.id, work)
|
|
222
259
|
}
|
|
223
|
-
|
|
260
|
+
pump()
|
|
224
261
|
}
|
|
225
262
|
function schedule () {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
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()
|
|
229
291
|
return work
|
|
230
292
|
}
|
|
231
293
|
async function drain () {
|
|
@@ -285,7 +347,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
285
347
|
}
|
|
286
348
|
}
|
|
287
349
|
async function publish (peer, event, signal) {
|
|
288
|
-
if (
|
|
350
|
+
if (preparation.error(peer)) throw preparation.error(peer)
|
|
289
351
|
if (!available || !peers.has(peer) || !channels.has(peer)) throw unavailableError(new Error('CHAT_UNAVAILABLE'), signal)
|
|
290
352
|
const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
|
|
291
353
|
let report
|
|
@@ -299,6 +361,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
299
361
|
await assertMessagePublished(report, event)
|
|
300
362
|
}
|
|
301
363
|
async function sendEntry (entry) {
|
|
364
|
+
const releaseForeground = foreground.enter()
|
|
302
365
|
entry.status = 'pending'; emit()
|
|
303
366
|
const controller = new AbortController()
|
|
304
367
|
sendControllers.set(entry.id, controller)
|
|
@@ -321,6 +384,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
321
384
|
await storage.put(entry, { existing: true })
|
|
322
385
|
}
|
|
323
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
|
+
}
|
|
324
392
|
if (entry.peer !== owner && messengerStatus?.paused) {
|
|
325
393
|
throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
|
|
326
394
|
}
|
|
@@ -356,7 +424,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
356
424
|
const event = entry.events[entry.index]
|
|
357
425
|
if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
|
|
358
426
|
await active()
|
|
359
|
-
|
|
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 })
|
|
360
430
|
}
|
|
361
431
|
await storage.remove(entry.id)
|
|
362
432
|
entries.delete(entry.id)
|
|
@@ -368,16 +438,22 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
368
438
|
if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
|
|
369
439
|
entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
|
|
370
440
|
await storage.put(entry, { existing: true }).catch(reportError)
|
|
371
|
-
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
|
+
}
|
|
372
448
|
reportError(error)
|
|
373
449
|
// The failed wire event may be a quote or file chunk. Report the
|
|
374
450
|
// owning outbox item separately, without mutating the native error.
|
|
375
451
|
if (error.retryWhenOnline) offlineSends.add(entry.id)
|
|
376
|
-
if (error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
|
|
452
|
+
if (entry.failed && error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
|
|
377
453
|
if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
|
|
378
454
|
}
|
|
379
455
|
emit()
|
|
380
|
-
} finally { sendControllers.delete(entry.id) }
|
|
456
|
+
} finally { sendControllers.delete(entry.id); releaseForeground() }
|
|
381
457
|
}
|
|
382
458
|
function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
|
|
383
459
|
// Transient availability failures without an active messenger pause retain
|
|
@@ -425,7 +501,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
425
501
|
})
|
|
426
502
|
}
|
|
427
503
|
async function pump () {
|
|
428
|
-
if (!available || closed || !
|
|
504
|
+
if (!available || closed || !storage) return
|
|
429
505
|
// A retry that lands while a pump is already running must not be skipped
|
|
430
506
|
// just because that pass already walked past the entry.
|
|
431
507
|
if (sending) { repump = true; return }
|
|
@@ -436,6 +512,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
436
512
|
for (const entry of entries.values()) {
|
|
437
513
|
if (closed || !available) break
|
|
438
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
|
+
}
|
|
439
519
|
if (entry.peer !== owner && entry.files.length && !fileTransfers) continue
|
|
440
520
|
await sendEntry(entry)
|
|
441
521
|
}
|
|
@@ -450,7 +530,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
450
530
|
const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
|
|
451
531
|
async function download (file, options = {}) {
|
|
452
532
|
if (file.peer === owner) return file
|
|
453
|
-
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')
|
|
454
537
|
const id = intentId(file)
|
|
455
538
|
if (options.manual) {
|
|
456
539
|
const epoch = downloadEpochs.get(id) || 0
|
|
@@ -475,7 +558,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
475
558
|
await writeDownloadIntent(() => downloads?.remove(id))
|
|
476
559
|
}
|
|
477
560
|
return {
|
|
478
|
-
download, cancelDownload,
|
|
561
|
+
download, cancelDownload, preparePeer,
|
|
479
562
|
// Peers-based so callers can prefetch as soon as a chat route opens, before
|
|
480
563
|
// the shared-key channel exists. When group chats get real membership, add
|
|
481
564
|
// a thin prefetchChannelContentKeys(channelPubkey) wrapper that resolves
|
|
@@ -503,20 +586,28 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
503
586
|
if (type === 'unread-page' && since === undefined) throw new TypeError('PRIORITY_SINCE_REQUIRED')
|
|
504
587
|
const request = { since, until, type }
|
|
505
588
|
const channel = channels.get(peer)
|
|
506
|
-
if (channel && typeof messenger?.prioritizeRange === 'function') {
|
|
589
|
+
if (channel && preparation.get(peer) && !pendingPriorityRanges.has(peer) && typeof messenger?.prioritizeRange === 'function') {
|
|
507
590
|
try { return await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error); return false }
|
|
508
591
|
}
|
|
509
592
|
const list = pendingPriorityRanges.get(peer) || []
|
|
510
593
|
list.push(request)
|
|
511
594
|
pendingPriorityRanges.set(peer, list)
|
|
595
|
+
if (channel && available) schedule().catch(() => {})
|
|
512
596
|
return true
|
|
513
597
|
},
|
|
514
598
|
async setPeers (values) {
|
|
515
599
|
const next = new Set([...values].filter(peer => peer !== owner))
|
|
516
600
|
if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
|
|
517
601
|
peers.clear(); for (const peer of next) peers.add(peer)
|
|
518
|
-
|
|
519
|
-
|
|
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
|
+
}
|
|
520
611
|
return schedule()
|
|
521
612
|
},
|
|
522
613
|
async setAvailable (value) {
|
|
@@ -527,31 +618,39 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
527
618
|
if (available) resetSendRetry()
|
|
528
619
|
else clearSendRetry()
|
|
529
620
|
if (changed) lifecycle++
|
|
530
|
-
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
|
+
}
|
|
531
626
|
await ready()
|
|
532
627
|
for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
|
|
533
628
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
534
|
-
if (!changed && messenger && !configurationFailed)
|
|
629
|
+
if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
|
|
535
630
|
return schedule()
|
|
536
631
|
},
|
|
537
632
|
async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
|
|
538
633
|
if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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() }
|
|
548
647
|
},
|
|
549
648
|
async retry (id) {
|
|
550
649
|
offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
|
|
551
650
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
552
651
|
await ready()
|
|
553
652
|
const entry = entries.get(id)
|
|
554
|
-
if (entry) { entry.failed = false; resetSendRetry(); if (
|
|
653
|
+
if (entry) { entry.failed = false; resetSendRetry(); if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {}); return pump() }
|
|
555
654
|
},
|
|
556
655
|
async cancel (id) {
|
|
557
656
|
cancelled.add(id)
|
|
@@ -566,9 +665,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
566
665
|
return (closeWork ??= (async () => {
|
|
567
666
|
closed = true; available = false; lifecycle++
|
|
568
667
|
stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
|
|
569
|
-
clearSendRetry(); inboxRecovery.stop(); pausedSends.clear()
|
|
668
|
+
clearSendRetry(); inboxRecovery.stop(); preparation.close(); foreground.close(); pausedSends.clear()
|
|
570
669
|
for (const controller of sendControllers.values()) controller.abort()
|
|
571
|
-
await Promise.allSettled([configuring, initialized])
|
|
670
|
+
await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
|
|
572
671
|
await messenger?.pause('closed')
|
|
573
672
|
if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
|
|
574
673
|
await messenger?.close()
|
package/relay/helpers/error.js
CHANGED
|
@@ -33,8 +33,8 @@ export function relayCloseError (event, category, cause) {
|
|
|
33
33
|
|
|
34
34
|
// Keep native diagnostics and attach only bounded timing advice. An absolute
|
|
35
35
|
// deadline survives delayed delivery through a launcher-owned relay bridge.
|
|
36
|
-
export function relayRejectionError (reason,
|
|
36
|
+
export function relayRejectionError (reason, params, fallback) {
|
|
37
37
|
const error = categorizeRelayError(reason, 'relay', fallback)
|
|
38
|
-
Object.assign(error, parseRelayRetryAdvice(error.message,
|
|
38
|
+
Object.assign(error, parseRelayRetryAdvice(error.message, params))
|
|
39
39
|
return error
|
|
40
40
|
}
|
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
// Nostr
|
|
1
|
+
// Nostr params use relative milliseconds or @Unix milliseconds. Advice never
|
|
2
2
|
// supplies an error category, provenance or a routing/retry policy.
|
|
3
|
-
export function parseRelayRetryAdvice (reason,
|
|
3
|
+
export function parseRelayRetryAdvice (reason, params, { now = Date.now() } = {}) {
|
|
4
4
|
if (typeof reason !== 'string' || !reason.startsWith('rate-limited:') ||
|
|
5
|
-
|
|
5
|
+
typeof params !== 'string' || params.match(/^@?[1-9]\d*$/)?.[0] !== params || !Number.isFinite(now)) return null
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
? Math.min(at * 1000, now + 300000)
|
|
14
|
-
: undefined
|
|
15
|
-
const retryAt = absolute ?? (retryAfterMs === undefined ? undefined : now + retryAfterMs)
|
|
16
|
-
if (retryAt === undefined) return null
|
|
17
|
-
return { ...(retryAfterMs === undefined ? {} : { retryAfterMs }), retryAt }
|
|
7
|
+
const absolute = params.startsWith('@')
|
|
8
|
+
const value = Number(absolute ? params.slice(1) : params)
|
|
9
|
+
if (!Number.isSafeInteger(value)) return null
|
|
10
|
+
if (absolute) return { retryAt: Math.min(value, now + 300000) }
|
|
11
|
+
const retryAfterMs = Math.min(value, 300000)
|
|
12
|
+
return { retryAfterMs, retryAt: now + retryAfterMs }
|
|
18
13
|
}
|