libp2r2p 0.11.24 → 0.11.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/package.json +1 -1
- package/private-channel/index.js +75 -67
- package/private-message/index.js +26 -7
- package/private-messenger/file/index.js +5 -5
- package/private-messenger/helpers/fallback-race.js +3 -2
- package/private-messenger/helpers/send-routing.js +3 -2
- package/private-messenger/index.js +29 -10
- package/private-messenger/session/README.md +36 -2
- package/private-messenger/session/helpers/publication-journal.js +137 -0
- package/private-messenger/session/index.js +92 -38
package/README.md
CHANGED
|
@@ -633,9 +633,13 @@ tasks cannot deliver into a new channel generation. Mandatory persistence still
|
|
|
633
633
|
owns its storage pause; incomplete history remains a pending recovery interval.
|
|
634
634
|
Subscription setup is bounded separately from presence, so a slow announcement
|
|
635
635
|
does not hold the other channels' readers. Sessions prioritize user persistence
|
|
636
|
-
and publication before the next background presence signer call.
|
|
637
|
-
|
|
638
|
-
|
|
636
|
+
and publication before the next background presence signer call. `onPreparedSeed`
|
|
637
|
+
receives its existing router/jsonl fields plus `publication`, containing all
|
|
638
|
+
signed fragments, recipient/channel identities and recovery rows for that logical
|
|
639
|
+
event. The callback commits durable recovery before publication: a direct
|
|
640
|
+
messenger uses its seed store; the session uses its encrypted outbox journal and
|
|
641
|
+
archives indexed personal copies independently. No alternative legacy publisher
|
|
642
|
+
is retained. Only one logical event/chunk is prepared per session entry at a time.
|
|
639
643
|
|
|
640
644
|
`private-channel.fetch` reports incomplete reads as an `AggregateError` with
|
|
641
645
|
stable `code: 'PRIVATE_CHANNEL_FETCH_INCOMPLETE'`. Its message names relays,
|
|
@@ -1241,5 +1245,9 @@ Private messenger/session constructors accept optional `fallbackDelayMs` (`null`
|
|
|
1241
1245
|
by default). Automatic NIP-65 sends can start configured fallback relays after
|
|
1242
1246
|
the chosen delay while primary publications retain their deadlines. Explicit
|
|
1243
1247
|
routes keep sequential fallback; every encrypted recipient must be covered.
|
|
1248
|
+
Session `enqueue` accepts optional monotonic `attemptStartedAt`; supplied values
|
|
1249
|
+
make preparation consume that delay. Retries/reloads start a fresh attempt and
|
|
1250
|
+
uploads budget each chunk independently. Monotonic clocks never enter storage or
|
|
1251
|
+
Nostr events. Omitted timestamps keep the publication-local delay.
|
|
1244
1252
|
`RelayPool.sendEvent` accepts a consumer `signal` without closing shared sockets.
|
|
1245
1253
|
See [fallback and cancellation](private-messenger/session/README.md#relay-fallback).
|
package/package.json
CHANGED
package/private-channel/index.js
CHANGED
|
@@ -143,6 +143,7 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
|
|
|
143
143
|
channelPubkey,
|
|
144
144
|
channelReaderPubkey,
|
|
145
145
|
preparedRows,
|
|
146
|
+
innerEventId: getEventHash({ ...event, pubkey: event.pubkey || senderPubkey }),
|
|
146
147
|
imkcPubkey,
|
|
147
148
|
imkcProof
|
|
148
149
|
}
|
|
@@ -152,69 +153,74 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
|
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context }) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
156
|
+
async function * wrapPreparedEvents ({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context, preparedPublication }) {
|
|
157
|
+
let publication = preparedPublication
|
|
158
|
+
if (publication) {
|
|
159
|
+
const channelPubkey = await privateChannelSigner.getPublicKey()
|
|
160
|
+
const expected = receiverPubkeys(receivers).sort().join(',')
|
|
161
|
+
const innerEventId = getEventHash({ ...event, pubkey: event.pubkey || await senderSigner.getPublicKey() })
|
|
162
|
+
if (publication.innerEventId !== innerEventId || publication.channelPubkey !== channelPubkey || !Array.isArray(publication.receiverPubkeys) || publication.receiverPubkeys.slice().sort().join(',') !== expected ||
|
|
163
|
+
!Array.isArray(publication.events) || !publication.events.length || !Array.isArray(publication.seeds) ||
|
|
164
|
+
publication.events.some(event => !isValidEvent(event) || event.pubkey !== channelPubkey || event.kind !== PRIVATE_BROADCAST_KIND)) {
|
|
165
|
+
throw new ValidationError('INVALID_PREPARED_PUBLICATION')
|
|
166
|
+
}
|
|
167
|
+
if (publication.events.some(event => Number(event.tags.find(tag => tag[0] === 'expiration')?.[1]) <= nowSeconds())) throw new ValidationError('PREPARED_PUBLICATION_EXPIRED')
|
|
168
|
+
} else {
|
|
169
|
+
const routerSeckey = generateSecretKey()
|
|
170
|
+
const routerPubkey = getPublicKey(routerSeckey)
|
|
171
|
+
const receiverPubkeyList = receiverPubkeys(receivers)
|
|
172
|
+
const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
|
|
173
|
+
const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
|
|
174
|
+
const total = preparedChunkCount(context.preparedRows, rowIndexes)
|
|
175
|
+
const seedRouter = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
|
|
165
176
|
const payloadRow = readPreparedRow(context.preparedRows, 0)
|
|
166
|
-
|
|
167
|
-
|
|
177
|
+
const seeds = rowIndexes.map(rowIndex => ({ channelPubkey: context.channelPubkey, router: seedRouter, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` }))
|
|
178
|
+
const events = []
|
|
179
|
+
let index = 0
|
|
180
|
+
for (const content of preparedChunks(context.preparedRows, rowIndexes)) {
|
|
181
|
+
const router = finalizeEvent(makeRouterEvent({
|
|
182
|
+
pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey,
|
|
183
|
+
imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, fileChunkIndex,
|
|
184
|
+
chunkIndex: index++, chunkTotal: total, content
|
|
185
|
+
}), routerSeckey)
|
|
186
|
+
const createdAt = nowSeconds()
|
|
187
|
+
const outer = await privateChannelSigner.signEvent({
|
|
188
|
+
kind: PRIVATE_BROADCAST_KIND, created_at: createdAt,
|
|
189
|
+
tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
|
|
190
|
+
content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
|
|
191
|
+
})
|
|
192
|
+
if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
|
|
193
|
+
events.push(outer)
|
|
168
194
|
}
|
|
195
|
+
publication = { innerEventId: context.innerEventId, channelPubkey: context.channelPubkey, receiverPubkeys: receiverPubkeyList, events, seeds }
|
|
169
196
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
senderPubkey: context.senderPubkey,
|
|
175
|
-
imkcPubkey: context.imkcPubkey,
|
|
176
|
-
imkcProof: context.imkcProof,
|
|
177
|
-
receiverPubkey: routerReceiverTag,
|
|
178
|
-
fileChunkIndex,
|
|
179
|
-
chunkIndex: index++,
|
|
180
|
-
chunkTotal: total,
|
|
181
|
-
content
|
|
182
|
-
}), routerSeckey)
|
|
183
|
-
const createdAt = nowSeconds()
|
|
184
|
-
const prepareOuter = async () => privateChannelSigner.signEvent({
|
|
185
|
-
kind: PRIVATE_BROADCAST_KIND,
|
|
186
|
-
created_at: createdAt,
|
|
187
|
-
tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
|
|
188
|
-
content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
|
|
189
|
-
})
|
|
190
|
-
// These independent steps may run together, but no envelope is yielded
|
|
191
|
-
// until every seed is durable. Settle both before releasing prepared rows.
|
|
192
|
-
const [persistence, prepared] = await Promise.allSettled([index === 1 ? persistSeeds() : undefined, prepareOuter()])
|
|
193
|
-
if (persistence.status === 'rejected') throw persistence.reason
|
|
194
|
-
if (prepared.status === 'rejected') throw prepared.reason
|
|
195
|
-
const outer = prepared.value
|
|
196
|
-
if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
|
|
197
|
-
yield outer
|
|
197
|
+
// The callback commits recovery data before any bytes leave this publisher.
|
|
198
|
+
// A session commits its encrypted journal; a direct messenger commits seeds.
|
|
199
|
+
if (onPreparedSeed) {
|
|
200
|
+
for (const seed of publication.seeds) await onPreparedSeed({ ...seed, publication })
|
|
198
201
|
}
|
|
202
|
+
yield * publication.events
|
|
199
203
|
}
|
|
200
204
|
|
|
201
205
|
// Streaming version of wrapEvent
|
|
202
|
-
export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs }) {
|
|
206
|
+
export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs, preparedPublication }) {
|
|
203
207
|
const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
|
|
204
|
-
const context =
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
208
|
+
const context = preparedPublication
|
|
209
|
+
? null
|
|
210
|
+
: await prepareRoutedMessage({
|
|
211
|
+
senderSigner,
|
|
212
|
+
imkcSigner,
|
|
213
|
+
privateChannelSigner,
|
|
214
|
+
privateChannelReaderPubkey,
|
|
215
|
+
receivers,
|
|
216
|
+
event,
|
|
217
|
+
temporaryStorageArea,
|
|
218
|
+
_getIykcProofs
|
|
219
|
+
})
|
|
214
220
|
try {
|
|
215
|
-
yield * wrapPreparedEvents({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })
|
|
221
|
+
yield * wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication })
|
|
216
222
|
} finally {
|
|
217
|
-
cleanupEnvelopeRows(context.preparedRows)
|
|
223
|
+
if (context) cleanupEnvelopeRows(context.preparedRows)
|
|
218
224
|
}
|
|
219
225
|
}
|
|
220
226
|
|
|
@@ -552,35 +558,37 @@ function withRecoveryRelays (relays, recoveryRelays) {
|
|
|
552
558
|
return uniq([...(relays || []), ...(recoveryRelays || [])])
|
|
553
559
|
}
|
|
554
560
|
|
|
555
|
-
export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays }) {
|
|
561
|
+
export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays, preparedPublication }) {
|
|
556
562
|
const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
|
|
557
563
|
const results = []
|
|
558
564
|
// Relays are grouped only when they have the exact same recipient pubkey set
|
|
559
565
|
const groups = groupedRelayReceivers({ relayToReceivers, receivers })
|
|
560
566
|
if (groups) {
|
|
561
|
-
const context =
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
567
|
+
const context = preparedPublication
|
|
568
|
+
? null
|
|
569
|
+
: await prepareRoutedMessage({
|
|
570
|
+
senderSigner,
|
|
571
|
+
imkcSigner,
|
|
572
|
+
privateChannelSigner,
|
|
573
|
+
privateChannelReaderPubkey,
|
|
574
|
+
receivers,
|
|
575
|
+
event,
|
|
576
|
+
temporaryStorageArea,
|
|
577
|
+
_getIykcProofs
|
|
578
|
+
})
|
|
571
579
|
try {
|
|
572
580
|
for (const group of groups) {
|
|
573
|
-
for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })) {
|
|
581
|
+
for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication })) {
|
|
574
582
|
results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(group.receivers), primaryRelays: group.relays }))
|
|
575
583
|
}
|
|
576
584
|
}
|
|
577
585
|
} finally {
|
|
578
|
-
cleanupEnvelopeRows(context.preparedRows)
|
|
586
|
+
if (context) cleanupEnvelopeRows(context.preparedRows)
|
|
579
587
|
}
|
|
580
588
|
return results
|
|
581
589
|
}
|
|
582
590
|
|
|
583
|
-
for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs })) {
|
|
591
|
+
for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs, preparedPublication })) {
|
|
584
592
|
results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(receivers), primaryRelays: relays }))
|
|
585
593
|
}
|
|
586
594
|
return results
|
package/private-message/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { normalizeRelayUrl } from '../url/index.js'
|
|
2
2
|
import { isValidEvent } from '../event/index.js'
|
|
3
3
|
import { ValidationError } from '../error/index.js'
|
|
4
|
-
import { generateKeypair } from '../key/index.js'
|
|
4
|
+
import { generateKeypair, keypairFromSeckey } from '../key/index.js'
|
|
5
5
|
import * as privateChannel from '../private-channel/index.js'
|
|
6
6
|
import { normalizeRumor, deliveryInfo } from '../private-channel/helpers/rumor.js'
|
|
7
7
|
|
|
@@ -41,11 +41,19 @@ function normalizeDeletionPubkey (deletionPubkey) {
|
|
|
41
41
|
return deletionPubkey.toLowerCase()
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeletionCapability = true }) {
|
|
44
|
+
function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeletionCapability = true, preparedPublication }) {
|
|
45
45
|
if (deletionSeckey !== undefined) throw new ValidationError('DELETION_SECKEY_NOT_ACCEPTED')
|
|
46
46
|
if (typeof autoDeletionCapability !== 'boolean') throw new ValidationError('AUTO_DELETION_CAPABILITY_BOOLEAN_REQUIRED')
|
|
47
47
|
|
|
48
48
|
const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
|
|
49
|
+
if (preparedPublication) {
|
|
50
|
+
const capability = preparedPublication.deletionCapability
|
|
51
|
+
if (!capability || (capability.deletionPubkey !== undefined && normalizeDeletionPubkey(capability.deletionPubkey) !== capability.deletionPubkey) ||
|
|
52
|
+
(normalizedDeletionPubkey && normalizedDeletionPubkey !== capability.deletionPubkey) ||
|
|
53
|
+
(capability.deletionSeckey !== undefined && keypairFromSeckey(capability.deletionSeckey).pubkey !== capability.deletionPubkey) ||
|
|
54
|
+
preparedPublication.events?.some(event => event.tags.find(tag => tag[0] === 's')?.[1] !== capability.deletionPubkey)) throw new ValidationError('INVALID_PREPARED_PUBLICATION')
|
|
55
|
+
return { ...capability }
|
|
56
|
+
}
|
|
49
57
|
if (normalizedDeletionPubkey) return { deletionPubkey: normalizedDeletionPubkey }
|
|
50
58
|
if (!autoDeletionCapability) return {}
|
|
51
59
|
|
|
@@ -53,6 +61,14 @@ function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeleti
|
|
|
53
61
|
return { deletionPubkey: pubkey, deletionSeckey: seckey }
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
function preparedSeedHandler (onPreparedSeed, deletion) {
|
|
65
|
+
if (!onPreparedSeed) return undefined
|
|
66
|
+
return seed => {
|
|
67
|
+
seed.publication.deletionCapability = deletion
|
|
68
|
+
return onPreparedSeed(seed)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
function withDelivery (result, reports, deletionSeckey) {
|
|
57
73
|
const delivery = { reports }
|
|
58
74
|
if (deletionSeckey !== undefined) delivery.deletionSeckey = deletionSeckey
|
|
@@ -652,10 +668,11 @@ async function sendPrivateMessage ({
|
|
|
652
668
|
_getIykcProofs,
|
|
653
669
|
fileChunkIndex,
|
|
654
670
|
onPreparedSeed,
|
|
671
|
+
preparedPublication,
|
|
655
672
|
_publish = privateChannel.publish
|
|
656
673
|
}) {
|
|
657
674
|
if (!privateChannelSigner?.getPublicKey) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
|
|
658
|
-
return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs })
|
|
675
|
+
return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, preparedPublication, _getIykcProofs })
|
|
659
676
|
}
|
|
660
677
|
|
|
661
678
|
async function sendNymMessage ({
|
|
@@ -860,13 +877,14 @@ export async function broadcastRumor ({
|
|
|
860
877
|
autoDeletionCapability = true,
|
|
861
878
|
fileChunkIndex,
|
|
862
879
|
onPreparedSeed,
|
|
880
|
+
preparedPublication,
|
|
863
881
|
_publish = privateChannel.publish
|
|
864
882
|
}) {
|
|
865
883
|
const receivers = uniq(receiverPubkeys)
|
|
866
884
|
if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
|
|
867
885
|
const { event, wireEvent } = await makeOutgoingRumor({ senderSigner, rumor })
|
|
868
|
-
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
|
|
869
|
-
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
|
|
886
|
+
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublication })
|
|
887
|
+
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublication, _getIykcProofs, _publish })
|
|
870
888
|
return withDelivery({ rumor: event }, reports, deletion.deletionSeckey)
|
|
871
889
|
}
|
|
872
890
|
|
|
@@ -888,13 +906,14 @@ export async function broadcastEvent ({
|
|
|
888
906
|
autoDeletionCapability = true,
|
|
889
907
|
fileChunkIndex,
|
|
890
908
|
onPreparedSeed,
|
|
909
|
+
preparedPublication,
|
|
891
910
|
_publish = privateChannel.publish
|
|
892
911
|
}) {
|
|
893
912
|
const receivers = uniq(receiverPubkeys)
|
|
894
913
|
if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
|
|
895
914
|
const wireEvent = assertValidSignedEvent({ ...event, tags: cloneTags(event?.tags) })
|
|
896
|
-
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
|
|
897
|
-
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
|
|
915
|
+
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublication })
|
|
916
|
+
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublication, _getIykcProofs, _publish })
|
|
898
917
|
return withDelivery({ event: wireEvent }, reports, deletion.deletionSeckey)
|
|
899
918
|
}
|
|
900
919
|
|
|
@@ -144,14 +144,14 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
144
144
|
if (decoded.root !== file.root || (index !== undefined && decoded.index !== index) || (file.size !== undefined && (decoded.total !== Math.ceil(file.size / IRFS_CHUNK_BYTES) || (decoded.index === decoded.total - 1 && decoded.contentBytes.length !== file.size - decoded.index * IRFS_CHUNK_BYTES)))) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
|
|
145
145
|
return decoded
|
|
146
146
|
}
|
|
147
|
-
async function routing (file, receiverPubkey, signal) {
|
|
147
|
+
async function routing (file, receiverPubkey, signal, attemptStartedAt, onDebug) {
|
|
148
148
|
const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
|
|
149
|
-
return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey], signal })
|
|
149
|
+
return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey], signal, attemptStartedAt, onDebug })
|
|
150
150
|
}
|
|
151
151
|
function sendOptions (file, routes) {
|
|
152
152
|
return { senderSigner: messenger.userSigner, imkcSigner: messenger.contentKeySigner, privateChannelSigner: file.signer, ...routes, temporaryStorageArea: messenger.temporaryStorageArea, expirationSeconds: messenger.eventExpirationSecondsFor(file.controlChannelPubkey), _getIykcProofs: messenger.contentKeyLookup() }
|
|
153
153
|
}
|
|
154
|
-
async function publishChunk (descriptor, event, { signal } = {}) {
|
|
154
|
+
async function publishChunk (descriptor, event, { signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug } = {}) {
|
|
155
155
|
const file = await register(descriptor)
|
|
156
156
|
usable(file)
|
|
157
157
|
const decoded = checked(file, event)
|
|
@@ -162,7 +162,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
162
162
|
}
|
|
163
163
|
let result
|
|
164
164
|
try {
|
|
165
|
-
const options = { ...sendOptions(file, await routing(file, file.peerPubkey, signal)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, ...(messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
|
|
165
|
+
const options = { ...sendOptions(file, await routing(file, file.peerPubkey, signal, attemptStartedAt, onDebug)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, preparedPublication, ...(onPreparedSeed ? { onPreparedSeed } : messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
|
|
166
166
|
result = event.sig ? await _messages.broadcastEvent({ ...options, event }) : await _messages.broadcastRumor({ ...options, rumor: event })
|
|
167
167
|
await assertMessagePublished(result, result?.rumor || result?.event || { ...event, pubkey: event.pubkey || messenger.userPubkey })
|
|
168
168
|
} catch (error) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root); throw error }
|
|
@@ -463,5 +463,5 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
463
463
|
const timer = setInterval(() => { prune().catch(onError) }, 60000)
|
|
464
464
|
timer.unref?.()
|
|
465
465
|
messenger.extensions.add(extension)
|
|
466
|
-
return { register, authorizeSeeding, publishChunk, download, readChunk: (root, index) => storage.read(root, index), stream: (descriptor, options = {}) => storage.stream ? storage.stream(descriptor, options) : chunkStream({ read: storage.read.bind(storage), ...descriptor, ...options }), close: extension.close, cancel (control, root) { const job = jobs.get(keyFor(control, root)); if (job) { job.pauseRequested = false; cancelJob(job) } }, observe (listener) { listeners.add(listener); for (const job of jobs.values()) emit(job); return () => listeners.delete(listener) } }
|
|
466
|
+
return { register, authorizeSeeding, publishChunk, archiveSeed: async (descriptor, seed) => saveSeed(await register(descriptor), seed), download, readChunk: (root, index) => storage.read(root, index), stream: (descriptor, options = {}) => storage.stream ? storage.stream(descriptor, options) : chunkStream({ read: storage.read.bind(storage), ...descriptor, ...options }), close: extension.close, cancel (control, root) { const job = jobs.get(keyFor(control, root)); if (job) { job.pauseRequested = false; cancelJob(job) } }, observe (listener) { listeners.add(listener); for (const job of jobs.values()) emit(job); return () => listeners.delete(listener) } }
|
|
467
467
|
}
|
|
@@ -2,7 +2,7 @@ import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
|
|
|
2
2
|
|
|
3
3
|
// Each invocation owns one already signed outer event and its encrypted subset.
|
|
4
4
|
// Lanes share coverage and the tried set; only started reports outlive the race.
|
|
5
|
-
export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, now, setTimer, clearTimer }) {
|
|
5
|
+
export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, attemptStartedAt, monotonicNow = () => performance.now(), now, setTimer, clearTimer }) {
|
|
6
6
|
const pending = new Set(receivers)
|
|
7
7
|
const summaries = []
|
|
8
8
|
const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
|
|
@@ -120,7 +120,8 @@ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tr
|
|
|
120
120
|
}
|
|
121
121
|
if (transportSignal?.aborted || !isCurrent()) { finish(false); return outcome.promise }
|
|
122
122
|
transportSignal?.addEventListener('abort', interrupted, { once: true })
|
|
123
|
-
const
|
|
123
|
+
const remaining = attemptStartedAt === undefined ? delay : Math.max(0, delay - Math.max(0, monotonicNow() - attemptStartedAt))
|
|
124
|
+
const deadline = now() + remaining
|
|
124
125
|
const failed = reason => {
|
|
125
126
|
if (done) return
|
|
126
127
|
done = true; clearTimer(timer); timer = null
|
|
@@ -32,10 +32,11 @@ function routeGroups (routes) {
|
|
|
32
32
|
return [...groups.values()]
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
35
|
+
export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, attemptStartedAt, monotonicNow = () => performance.now(), now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
36
36
|
// Keep discovery's defaults when NIP-65 is unavailable. Explicit maps/lists
|
|
37
37
|
// retain their fanout; configured fallbacks are a separate last-resort stage.
|
|
38
38
|
fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
39
|
+
if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
|
|
39
40
|
const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
|
|
40
41
|
const explicit = Boolean(primaryRelays || primaryRelayToReceivers)
|
|
41
42
|
const primaryRoutes = primaryRelayToReceivers || (primaryRelays
|
|
@@ -98,7 +99,7 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
|
|
|
98
99
|
event, receivers, first: first.length ? { receivers, relays: first } : null, mirrors, tried,
|
|
99
100
|
nextPrimary: pending => pick(pending, false), nextFallback: pending => pick(pending, true),
|
|
100
101
|
sendEvent, isOnline, isCurrent, signal, pauseSignal, remember,
|
|
101
|
-
delay: fallbackDelayMs, now, setTimer, clearTimer
|
|
102
|
+
delay: fallbackDelayMs, attemptStartedAt, monotonicNow, now, setTimer, clearTimer
|
|
102
103
|
})
|
|
103
104
|
if (!result.success && current()) exclusions.clear()
|
|
104
105
|
return result
|
|
@@ -233,6 +233,7 @@ export class PrivateMessenger {
|
|
|
233
233
|
messageQueueMaxBytes = DEFAULT_MESSAGE_QUEUE_MAX_BYTES,
|
|
234
234
|
seedQueueMaxBytes = DEFAULT_SEED_QUEUE_MAX_BYTES,
|
|
235
235
|
seedStorage,
|
|
236
|
+
_pendingSeedRecords = () => [],
|
|
236
237
|
temporaryStorageArea = globalThis.sessionStorage,
|
|
237
238
|
autoDeletionCapability = true,
|
|
238
239
|
_indexedDB = globalThis.indexedDB,
|
|
@@ -290,6 +291,7 @@ export class PrivateMessenger {
|
|
|
290
291
|
this.messageQueueMaxBytes = messageQueueMaxBytes
|
|
291
292
|
this.seedQueueMaxBytes = seedQueueMaxBytes
|
|
292
293
|
this.seedStorage = seedStorage
|
|
294
|
+
this._pendingSeedRecords = _pendingSeedRecords
|
|
293
295
|
this.temporaryStorageArea = temporaryStorageArea
|
|
294
296
|
this.autoDeletionCapability = normalizeAutoDeletionCapability(autoDeletionCapability)
|
|
295
297
|
this._indexedDB = _indexedDB
|
|
@@ -887,7 +889,8 @@ export class PrivateMessenger {
|
|
|
887
889
|
return uniq([...primary, ...this.fallbackRelays].map(normalizeRelayUrl))
|
|
888
890
|
}
|
|
889
891
|
|
|
890
|
-
async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal }) {
|
|
892
|
+
async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug }) {
|
|
893
|
+
if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
|
|
891
894
|
const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
|
|
892
895
|
if (relayToReceivers && !this.fallbackRelays.length) return { relayToReceivers, recoveryRelays }
|
|
893
896
|
const fixed = relayToReceivers ? null : relays?.length ? uniq(relays) : channel.sendRelays.length ? channel.sendRelays : channel.relays.length ? channel.relays : null
|
|
@@ -903,12 +906,17 @@ export class PrivateMessenger {
|
|
|
903
906
|
peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
|
|
904
907
|
signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
|
|
905
908
|
exclusions: this.sendRelayExclusions.get(key).exclusions,
|
|
906
|
-
fallbackDelayMs: this.fallbackDelayMs, pauseSignal: this.sendRoutingPause.signal,
|
|
909
|
+
fallbackDelayMs: this.fallbackDelayMs, attemptStartedAt, pauseSignal: this.sendRoutingPause.signal,
|
|
907
910
|
setTimer: this._setTimeout, clearTimer: this._clearTimeout,
|
|
908
911
|
pickRelays: this._pickRelaysForPubkeys,
|
|
909
912
|
publish: this._privateChannel.publish || privateChannel.publish,
|
|
910
913
|
publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
|
|
911
|
-
sendEvent: (...args) =>
|
|
914
|
+
sendEvent: async (...args) => {
|
|
915
|
+
const startedAt = onDebug ? performance.now() : 0
|
|
916
|
+
const result = await relayPool.sendEvent(...args)
|
|
917
|
+
if (result.success && onDebug) { try { onDebug({ stage: 'first-ack', publicationId: args[0].id, elapsedMs: performance.now() - startedAt }) } catch {} }
|
|
918
|
+
return result
|
|
919
|
+
},
|
|
912
920
|
isOnline: this._isOnline,
|
|
913
921
|
isCurrent: () => !this.closePromise && !this.pauseReasons.size && this.channels.has(channel.pubkey)
|
|
914
922
|
})
|
|
@@ -2401,11 +2409,11 @@ export class PrivateMessenger {
|
|
|
2401
2409
|
} finally { releaseActivity() }
|
|
2402
2410
|
}
|
|
2403
2411
|
|
|
2404
|
-
async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal }) {
|
|
2412
|
+
async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug }) {
|
|
2405
2413
|
const channel = this.requireWritableChannel(channelPubkey)
|
|
2406
2414
|
const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
|
|
2407
2415
|
try {
|
|
2408
|
-
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
|
|
2416
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
|
|
2409
2417
|
this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
|
|
2410
2418
|
return await this._privateMessage.broadcastRumor({
|
|
2411
2419
|
senderSigner: this.userSigner,
|
|
@@ -2419,17 +2427,18 @@ export class PrivateMessenger {
|
|
|
2419
2427
|
deletionPubkey,
|
|
2420
2428
|
autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
|
|
2421
2429
|
rumor,
|
|
2422
|
-
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
2430
|
+
onPreparedSeed: onPreparedSeed || this.outgoingSeedHandler(channelPubkey),
|
|
2431
|
+
preparedPublication,
|
|
2423
2432
|
_getIykcProofs: this.contentKeyLookup()
|
|
2424
2433
|
})
|
|
2425
2434
|
} finally { releaseActivity() }
|
|
2426
2435
|
}
|
|
2427
2436
|
|
|
2428
|
-
async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal }) {
|
|
2437
|
+
async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug }) {
|
|
2429
2438
|
const channel = this.requireWritableChannel(channelPubkey)
|
|
2430
2439
|
const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
|
|
2431
2440
|
try {
|
|
2432
|
-
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
|
|
2441
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
|
|
2433
2442
|
this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
|
|
2434
2443
|
return await this._privateMessage.broadcastEvent({
|
|
2435
2444
|
senderSigner: this.userSigner,
|
|
@@ -2443,7 +2452,8 @@ export class PrivateMessenger {
|
|
|
2443
2452
|
deletionPubkey,
|
|
2444
2453
|
autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
|
|
2445
2454
|
event,
|
|
2446
|
-
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
2455
|
+
onPreparedSeed: onPreparedSeed || this.outgoingSeedHandler(channelPubkey),
|
|
2456
|
+
preparedPublication,
|
|
2447
2457
|
_getIykcProofs: this.contentKeyLookup()
|
|
2448
2458
|
})
|
|
2449
2459
|
} finally { releaseActivity() }
|
|
@@ -2782,8 +2792,17 @@ export class PrivateMessenger {
|
|
|
2782
2792
|
})
|
|
2783
2793
|
|
|
2784
2794
|
if (this.offlineRecoverySecondsFor(channelPubkey)) {
|
|
2795
|
+
const seen = new Set()
|
|
2796
|
+
const key = seed => seed.router ? routerSeedRowKey(seed) : nymCarrierSeedKey(seed)
|
|
2797
|
+
const pending = this._pendingSeedRecords({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until })
|
|
2798
|
+
for (const seed of pending) {
|
|
2799
|
+
if (seed.channelPubkey !== channelPubkey || (seed.receiverPubkey && seed.receiverPubkey !== message.event?.pubkey) || seed.expiresAt <= nowSeconds()) continue
|
|
2800
|
+
if (since !== undefined && (seed.lastSeenAt || seed.router.created_at) < since) continue
|
|
2801
|
+
if (until !== undefined && (seed.firstSeenAt || seed.router.created_at) > until) continue
|
|
2802
|
+
seen.add(key(seed)); await packer.update(seed)
|
|
2803
|
+
}
|
|
2785
2804
|
for await (const seed of this.seedStorage ? this.seedStorage.iterate({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until }) : this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
|
|
2786
|
-
if (seedRecordTime(seed) < nowSeconds() - this.offlineRecoverySecondsFor(channelPubkey)) continue
|
|
2805
|
+
if (seen.has(key(seed)) || seedRecordTime(seed) < nowSeconds() - this.offlineRecoverySecondsFor(channelPubkey)) continue
|
|
2787
2806
|
if (this.seedStorage && !await this.seedStorage.has(seed)) continue
|
|
2788
2807
|
await packer.update(seed)
|
|
2789
2808
|
}
|
|
@@ -170,8 +170,11 @@ Optional `fallbackDelayMs` defaults to `null` (sequential primary exhaustion).
|
|
|
170
170
|
A nonnegative integer up to 2147483647 opts automatic NIP-65 routing into early
|
|
171
171
|
fallback; explicit relay lists/maps retain the sequential policy. With `3000`,
|
|
172
172
|
start at most two eligible fallback relays after three seconds without complete
|
|
173
|
-
acceptance, or immediately after primary exhaustion.
|
|
174
|
-
|
|
173
|
+
acceptance, or immediately after primary exhaustion. Optional `enqueue({ ..., attemptStartedAt })`
|
|
174
|
+
uses `performance.now()` from the caller's realm to include preparation in the
|
|
175
|
+
budget. Without it, the deadline starts at publication. Later batches do not
|
|
176
|
+
renew it; retries/reloads start a new attempt and uploads budget each chunk.
|
|
177
|
+
Primary operations retain
|
|
175
178
|
their deadlines and can still win. Failed fallback operations leave primary
|
|
176
179
|
alternatives available; no relay is tried twice. Multiple recipients require
|
|
177
180
|
accepted coverage of every encrypted recipient subset. No wrapper, ID, recipient
|
|
@@ -249,3 +252,34 @@ event/peer identifiers and elapsed milliseconds for admission, local processing,
|
|
|
249
252
|
channel preparation, publication acceptance and cleanup. Callback failures never
|
|
250
253
|
change delivery. Connect only in development; never log events or crypto buffers.
|
|
251
254
|
Custom outbox factories also receive this optional callback for storage timings.
|
|
255
|
+
|
|
256
|
+
## Durable prepared publications
|
|
257
|
+
|
|
258
|
+
Each remote entry may contain optional `prepared` progress in its existing
|
|
259
|
+
encrypted JSON. It holds the signed envelope fragments, compact recovery seeds,
|
|
260
|
+
recipient/channel and inner-event identities, event/chunk position, retention
|
|
261
|
+
and accepted/archived flags. Old entries without it retain `localSaved`, index
|
|
262
|
+
and file/chunk progress and enter the same preparation path; there is no database
|
|
263
|
+
migration or alternate publisher. Replays validate signatures and identity and
|
|
264
|
+
reuse the exact envelopes without repeating cryptography or extending expiry.
|
|
265
|
+
|
|
266
|
+
The existing `onPreparedSeed` callback now also supplies `publication`. The
|
|
267
|
+
session commits that minimal journal before transmitting; direct messengers
|
|
268
|
+
retain their local seed-store durability. Only after that commit can relay
|
|
269
|
+
publication proceed. One independent serial worker converts recovery seeds into
|
|
270
|
+
their configured archive. Pending durable rows participate in recovery reads
|
|
271
|
+
immediately, including during a blocked archive write. No new seeds are enabled
|
|
272
|
+
for modes which did not previously retain them.
|
|
273
|
+
|
|
274
|
+
First acceptance of every required envelope emits saved independently of archive
|
|
275
|
+
or cleanup. ACK progress checkpoints serialize with other entry writes. A crash
|
|
276
|
+
between ACK and its checkpoint may replay the same envelope; acknowledged durable
|
|
277
|
+
progress only archives. Archive failure never becomes `onSendError` or changes
|
|
278
|
+
saved status. Removal waits for archival and outstanding entry writes.
|
|
279
|
+
|
|
280
|
+
At most one unit awaits archival per entry; a stalled upload yields its remote
|
|
281
|
+
slot to other texts rather than accumulating the entire file. Cancel stops wire
|
|
282
|
+
work but retains potentially transmitted seeds until archival finishes. Close
|
|
283
|
+
drains active local operations and leaves durable backoff work for restart.
|
|
284
|
+
Diagnostics add publication/seed IDs, checkpoint time, archive wait/execution and
|
|
285
|
+
first-ACK timing, without message or ciphertext contents.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { compactSeedRouterRows } from '../../index.js'
|
|
2
|
+
import { encodeRecord } from '../../event-store/helpers/codec.js'
|
|
3
|
+
import { isRecoverableStorageFailure } from '../../helpers/pause-recovery.js'
|
|
4
|
+
|
|
5
|
+
export function createPublicationJournal ({ entries, store, archive, changed, onError, debug, setTimer = setTimeout, clearTimer = clearTimeout, random = Math.random }) {
|
|
6
|
+
const writes = new Map()
|
|
7
|
+
const failed = new Set()
|
|
8
|
+
const durable = new WeakSet()
|
|
9
|
+
const queued = new WeakMap()
|
|
10
|
+
let working; let stopped = false; let enabled = false; let timer; let delay = 1000
|
|
11
|
+
function write (entry) {
|
|
12
|
+
const previous = writes.get(entry.id) || Promise.resolve()
|
|
13
|
+
const work = previous.catch(() => {}).then(() => store().put(structuredClone(entry), { existing: true }))
|
|
14
|
+
writes.set(entry.id, work)
|
|
15
|
+
work.finally(() => { if (writes.get(entry.id) === work) writes.delete(entry.id) }).catch(() => {})
|
|
16
|
+
return work
|
|
17
|
+
}
|
|
18
|
+
function rows (entry) {
|
|
19
|
+
const packet = entry.prepared
|
|
20
|
+
if (!packet?.recover) return []
|
|
21
|
+
return packet.publication.seeds.flatMap(seed => compactSeedRouterRows(seed).map(row => {
|
|
22
|
+
const descriptor = packet.target.file
|
|
23
|
+
const value = {
|
|
24
|
+
...row, channelPubkey: seed.channelPubkey, receivedAt: packet.receivedAt,
|
|
25
|
+
expiresAt: row.router.created_at + packet.retentionSeconds,
|
|
26
|
+
...(descriptor
|
|
27
|
+
? {
|
|
28
|
+
root: descriptor.root, controlChannelPubkey: descriptor.controlChannelPubkey,
|
|
29
|
+
fileChannelPubkey: packet.publication.channelPubkey, peerPubkey: descriptor.peerPubkey,
|
|
30
|
+
chunkIndex: packet.target.chunkIndex,
|
|
31
|
+
key: `${descriptor.controlChannelPubkey}:${packet.publication.channelPubkey}:${row.receiverPubkey}:${packet.target.chunkIndex}`
|
|
32
|
+
}
|
|
33
|
+
: {})
|
|
34
|
+
}
|
|
35
|
+
value.recordId = encodeRecord('seed', value).tags[0][1]
|
|
36
|
+
return value
|
|
37
|
+
}))
|
|
38
|
+
}
|
|
39
|
+
function retryLater () {
|
|
40
|
+
if (stopped || !enabled || timer || !failed.size) return
|
|
41
|
+
timer = setTimer(() => { timer = null; failed.clear(); wake() }, Math.min(30000, delay * (0.8 + random() * 0.4)))
|
|
42
|
+
timer?.unref?.()
|
|
43
|
+
delay = Math.min(30000, delay * 2)
|
|
44
|
+
}
|
|
45
|
+
function wake () {
|
|
46
|
+
if (stopped || !enabled || working) return working
|
|
47
|
+
const work = Promise.resolve().then(async () => {
|
|
48
|
+
for (const entry of entries.values()) {
|
|
49
|
+
if (stopped || !enabled) break
|
|
50
|
+
const packet = entry.prepared
|
|
51
|
+
if (!packet || failed.has(entry.id)) continue
|
|
52
|
+
if (packet.archived && !packet.accepted && !entry.cancelled) continue
|
|
53
|
+
const started = Date.now()
|
|
54
|
+
try {
|
|
55
|
+
if (!durable.has(packet)) { await write(entry); durable.add(packet) }
|
|
56
|
+
if (!packet.archived) {
|
|
57
|
+
debug(entry, 'recovery-archive-start', queued.get(packet), { publicationId: packet.publication.events[0].id })
|
|
58
|
+
if (packet.recover) await archive(entry, rows(entry))
|
|
59
|
+
packet.archived = true
|
|
60
|
+
debug(entry, 'recovery-archived', started)
|
|
61
|
+
}
|
|
62
|
+
if (packet.accepted || entry.cancelled) delete entry.prepared
|
|
63
|
+
await write(entry)
|
|
64
|
+
changed(entry)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
failed.add(entry.id)
|
|
67
|
+
onError(error)
|
|
68
|
+
if (isRecoverableStorageFailure(error)) retryLater()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}).finally(() => {
|
|
72
|
+
if (working === work) working = null
|
|
73
|
+
if (!failed.size) delay = 1000
|
|
74
|
+
// An ACK can arrive while another entry is being archived.
|
|
75
|
+
if (!stopped && [...entries.values()].some(entry => entry.prepared && !failed.has(entry.id) && (!entry.prepared.archived || entry.prepared.accepted || entry.cancelled))) wake()
|
|
76
|
+
})
|
|
77
|
+
working = work
|
|
78
|
+
return work
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
write,
|
|
82
|
+
async flush (entry) { while (writes.has(entry.id)) await writes.get(entry.id) },
|
|
83
|
+
adopt (entry) { if (entry.prepared) { durable.add(entry.prepared); queued.set(entry.prepared, Date.now()) } },
|
|
84
|
+
async stage (entry, seed, target, retentionSeconds) {
|
|
85
|
+
const started = Date.now()
|
|
86
|
+
if (entry.prepared && entry.prepared.publication.events[0].id !== seed.publication.events[0].id) throw new Error('PUBLICATION_ARCHIVE_PENDING')
|
|
87
|
+
entry.prepared ||= { publication: structuredClone(seed.publication), target, receivedAt: Math.floor(Date.now() / 1000), retentionSeconds, recover: retentionSeconds > 0, accepted: false, archived: retentionSeconds <= 0 }
|
|
88
|
+
if (durable.has(entry.prepared)) return
|
|
89
|
+
queued.set(entry.prepared, Date.now())
|
|
90
|
+
await write(entry)
|
|
91
|
+
durable.add(entry.prepared)
|
|
92
|
+
debug(entry, 'publication-checkpoint', started, { publicationId: seed.publication.events[0].id })
|
|
93
|
+
for (const row of rows(entry)) debug(entry, 'recovery-pending', undefined, { seedRecordId: row.recordId, publicationId: seed.publication.events[0].id })
|
|
94
|
+
wake()
|
|
95
|
+
},
|
|
96
|
+
wake,
|
|
97
|
+
accept (entry) {
|
|
98
|
+
entry.prepared.accepted = true
|
|
99
|
+
// An unrelated archive may be blocked. Its worker must not hold this ACK.
|
|
100
|
+
write(entry).catch(error => { failed.add(entry.id); onError(error); retryLater() }).finally(wake)
|
|
101
|
+
wake()
|
|
102
|
+
},
|
|
103
|
+
setAvailable (value) {
|
|
104
|
+
enabled = value
|
|
105
|
+
if (!enabled) { if (timer) clearTimer(timer); timer = null } else { failed.clear(); wake() }
|
|
106
|
+
},
|
|
107
|
+
retry () { failed.clear(); if (timer) clearTimer(timer); timer = null; return wake() },
|
|
108
|
+
pendingRows () { return [...entries.values()].flatMap(rows).filter(row => row.expiresAt > Math.floor(Date.now() / 1000)) },
|
|
109
|
+
async close () {
|
|
110
|
+
stopped = true
|
|
111
|
+
if (timer) clearTimer(timer)
|
|
112
|
+
await working
|
|
113
|
+
await Promise.allSettled([...writes.values()])
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function pendingSeedMatches (row, query) {
|
|
119
|
+
for (const key of ['channelPubkey', 'controlChannelPubkey', 'fileChannelPubkey', 'receiverPubkey', 'recordId']) if (query[key] && row[key] !== query[key]) return false
|
|
120
|
+
if (query.indices && !query.indices.includes(row.chunkIndex)) return false
|
|
121
|
+
if (query.since !== undefined && (row.lastSeenAt ?? row.router.created_at) < query.since) return false
|
|
122
|
+
if (query.until !== undefined && (row.firstSeenAt ?? row.router.created_at) > query.until) return false
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function withPendingSeeds (storage, pending) {
|
|
127
|
+
return {
|
|
128
|
+
...storage,
|
|
129
|
+
async * iterate (query = {}) {
|
|
130
|
+
const seen = new Set()
|
|
131
|
+
// Pending durable rows are immediately usable, even while store.put waits.
|
|
132
|
+
for (const row of pending()) if (pendingSeedMatches(row, query)) { seen.add(row.recordId); yield row }
|
|
133
|
+
for await (const row of storage.iterate(query)) if (!seen.has(row.recordId)) yield row
|
|
134
|
+
},
|
|
135
|
+
has: row => pending().some(value => value.recordId === row.recordId) || storage.has(row)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -14,6 +14,7 @@ import { createChatOutbox } from './helpers/work-storage.js'
|
|
|
14
14
|
import { messengerSigner } from './helpers/signer.js'
|
|
15
15
|
import { assertMessagePublished } from '../helpers/publication.js'
|
|
16
16
|
import { normalizeFallbackRelays, normalizeFallbackDelay } from '../helpers/send-routing.js'
|
|
17
|
+
import { createPublicationJournal, withPendingSeeds } from './helpers/publication-journal.js'
|
|
17
18
|
import { createPauseRecovery, isRecoverableStorageFailure } from '../helpers/pause-recovery.js'
|
|
18
19
|
|
|
19
20
|
export function wireEvent (value, owner) {
|
|
@@ -65,14 +66,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
65
66
|
const channels = new Map()
|
|
66
67
|
const entries = new Map()
|
|
67
68
|
const cancelled = new Set()
|
|
69
|
+
const attemptStarts = new Map()
|
|
68
70
|
const sendControllers = new Map()
|
|
69
71
|
const localReady = new Set()
|
|
70
72
|
const localIdleWaiters = new Set()
|
|
71
73
|
const cleanups = new Map()
|
|
72
74
|
const queuedAt = new WeakMap()
|
|
73
|
-
const debug = (entry, stage, startedAt) => {
|
|
75
|
+
const debug = (entry, stage, startedAt, coordinates = {}) => {
|
|
74
76
|
if (!onDebug) return
|
|
75
|
-
try { onDebug({ stage, eventId: entry.id, peer: entry.peer, at: Date.now(), ...(startedAt != null ? { elapsedMs: Date.now() - startedAt } : {}) }) } catch {}
|
|
77
|
+
try { onDebug({ stage, eventId: entry.id, peer: entry.peer, at: Date.now(), ...coordinates, ...(startedAt != null ? { elapsedMs: Date.now() - startedAt } : {}) }) } catch {}
|
|
76
78
|
}
|
|
77
79
|
const offlineSends = new Set()
|
|
78
80
|
const pausedSends = new Set()
|
|
@@ -97,7 +99,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
97
99
|
return
|
|
98
100
|
}
|
|
99
101
|
clearSendRetry()
|
|
100
|
-
for (const id of pausedSends) { const entry = entries.get(id); if (entry) entry.failed = false }
|
|
102
|
+
for (const id of pausedSends) { const entry = entries.get(id); if (entry) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) } }
|
|
101
103
|
pausedSends.clear()
|
|
102
104
|
// The callback can run inside construction/resume, before configure settles.
|
|
103
105
|
queueMicrotask(() => pump())
|
|
@@ -139,11 +141,30 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
139
141
|
let redrain = false
|
|
140
142
|
const idleWaiters = new Set()
|
|
141
143
|
const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
|
|
142
|
-
const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
|
|
144
|
+
const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion && !entry.cancelled))
|
|
145
|
+
const journal = createPublicationJournal({
|
|
146
|
+
entries, store: () => storage, onError: reportError, debug, setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random,
|
|
147
|
+
archive: async (entry, rows) => {
|
|
148
|
+
if (recoveryStorage?.seeds) { for (const row of rows) await recoveryStorage.seeds.put(row) } else if (entry.prepared.target.file) {
|
|
149
|
+
for (const seed of entry.prepared.publication.seeds) await fileTransfers.archiveSeed(entry.prepared.target.file, seed)
|
|
150
|
+
} else {
|
|
151
|
+
for (const seed of entry.prepared.publication.seeds) await messenger.enqueueSeed(seed.channelPubkey, seed)
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
changed: entry => {
|
|
155
|
+
if (!entry.prepared && (entry.status === 'saved' || entry.cancelled)) cleanupEntry(entry)
|
|
156
|
+
else if (!closed && available) pumpRemote()
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
const seedStorage = recoveryStorage?.seeds ? withPendingSeeds(recoveryStorage.seeds, () => journal.pendingRows()) : undefined
|
|
143
160
|
const ready = () => (initialized ??= (async () => {
|
|
144
161
|
storage = await openOutbox({ owner, signer, onDebug })
|
|
145
162
|
for (const entry of await storage.list()) {
|
|
163
|
+
// Old encrypted entries retain progress and enter this same pipeline.
|
|
164
|
+
if (entry.cancelled) cancelled.add(entry.id)
|
|
146
165
|
entries.set(entry.id, entry)
|
|
166
|
+
journal.adopt(entry)
|
|
167
|
+
attemptStarts.set(entry.id, performance.now())
|
|
147
168
|
if (entry.localSaved.every(Boolean)) localReady.add(entry.id)
|
|
148
169
|
queuedAt.set(entry, Date.now())
|
|
149
170
|
}
|
|
@@ -156,7 +177,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
156
177
|
let candidate
|
|
157
178
|
try {
|
|
158
179
|
candidate = await Messenger({
|
|
159
|
-
fallbackRelays, fallbackDelayMs, pendingDecryptionReady, seedStorage:
|
|
180
|
+
fallbackRelays, fallbackDelayMs, pendingDecryptionReady, seedStorage, _pendingSeedRecords: () => journal.pendingRows(), userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait, _observeForeground: foreground.observe,
|
|
160
181
|
onMessageQueued: () => { if (messengerBinding === binding) return drain() },
|
|
161
182
|
onStateChanged: state => {
|
|
162
183
|
if (closed || messengerBinding !== binding) return
|
|
@@ -236,7 +257,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
236
257
|
fileSetupWork = setupFiles().catch(error => { fileSetupWork = null; if (!closed) reportError(error) })
|
|
237
258
|
}
|
|
238
259
|
fileSetupWork.then(() => { if (!closed && available) restoreDownloads() }).catch(reportError)
|
|
239
|
-
drain(); pump()
|
|
260
|
+
journal.wake(); drain(); pump()
|
|
240
261
|
}
|
|
241
262
|
async function setupFiles () {
|
|
242
263
|
if (!fileTransfers) {
|
|
@@ -244,7 +265,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
244
265
|
messenger, onError: reportError,
|
|
245
266
|
resolveChannel: ({ peerPubkey, info }) => messengerSigner(signer.withSharedKey(peerPubkey, info)),
|
|
246
267
|
storage: chunkStorage,
|
|
247
|
-
seedStorage
|
|
268
|
+
seedStorage,
|
|
248
269
|
authorizationStorage: recoveryStorage?.authorizations
|
|
249
270
|
})
|
|
250
271
|
fileTransfers.observe(state => {
|
|
@@ -368,10 +389,24 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
368
389
|
if (again && !storageBlocked && !closed && available) queueMicrotask(() => drain())
|
|
369
390
|
}
|
|
370
391
|
}
|
|
371
|
-
async function publish (
|
|
392
|
+
async function publish (entry, event, signal) {
|
|
393
|
+
const peer = entry.peer
|
|
372
394
|
if (preparation.error(peer)) throw preparation.error(peer)
|
|
373
395
|
if (!available || !peers.has(peer) || !channels.has(peer)) throw unavailableError(new Error('CHAT_UNAVAILABLE'), signal)
|
|
374
|
-
const
|
|
396
|
+
const channelPubkey = channels.get(peer)?.pubkey
|
|
397
|
+
const retention = mode === 'seeder' || mode === 'watchtower' ? messenger.offlineRecoverySecondsFor?.(channelPubkey) ?? 0 : 0
|
|
398
|
+
const preparationStartedAt = Date.now()
|
|
399
|
+
const options = {
|
|
400
|
+
channelPubkey, receiverPubkeys: [peer], signal, attemptStartedAt: attemptStarts.get(entry.id),
|
|
401
|
+
onDebug: onDebug ? value => debug(entry, value.stage, undefined, value) : undefined,
|
|
402
|
+
preparedPublication: entry.prepared?.publication,
|
|
403
|
+
onPreparedSeed: async seed => {
|
|
404
|
+
debug(entry, 'envelope-prepared', preparationStartedAt, { publicationId: seed.publication.events[0].id })
|
|
405
|
+
await journal.stage(entry, seed, { index: entry.index }, retention)
|
|
406
|
+
signal.throwIfAborted()
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
debug(entry, 'envelope-preparation')
|
|
375
410
|
let report
|
|
376
411
|
try {
|
|
377
412
|
report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
|
|
@@ -383,10 +418,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
383
418
|
await assertMessagePublished(report, event)
|
|
384
419
|
}
|
|
385
420
|
async function cleanupEntry (entry) {
|
|
421
|
+
if (entry.prepared) { journal.wake(); return }
|
|
386
422
|
if (cleanups.has(entry.id)) return cleanups.get(entry.id)
|
|
387
423
|
const startedAt = Date.now()
|
|
388
|
-
const work = Promise.resolve().then(() => storage.remove(entry.id)).then(() => {
|
|
389
|
-
entries.delete(entry.id); localReady.delete(entry.id)
|
|
424
|
+
const work = Promise.resolve().then(async () => { await journal.flush(entry); await storage.remove(entry.id) }).then(() => {
|
|
425
|
+
entries.delete(entry.id); localReady.delete(entry.id); attemptStarts.delete(entry.id)
|
|
390
426
|
debug(entry, 'outbox-cleanup', startedAt)
|
|
391
427
|
emit()
|
|
392
428
|
}).catch(error => {
|
|
@@ -399,6 +435,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
399
435
|
return work
|
|
400
436
|
}
|
|
401
437
|
async function sendEntry (entry, localOnly = false) {
|
|
438
|
+
if (entry.status === 'saved') return
|
|
402
439
|
const releaseForeground = foreground.enter()
|
|
403
440
|
entry.status = 'pending'; emit()
|
|
404
441
|
const controller = new AbortController()
|
|
@@ -429,7 +466,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
429
466
|
} else {
|
|
430
467
|
// Retry this checkpoint even when a preceding attempt saved all copies
|
|
431
468
|
// but failed to persist progress. Only then admit remote publication.
|
|
432
|
-
await
|
|
469
|
+
await journal.write(entry)
|
|
433
470
|
await active()
|
|
434
471
|
localReady.add(entry.id)
|
|
435
472
|
}
|
|
@@ -443,49 +480,55 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
443
480
|
if (entry.peer !== owner && messengerStatus?.paused) {
|
|
444
481
|
throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
|
|
445
482
|
}
|
|
446
|
-
|
|
447
|
-
//
|
|
448
|
-
for (; entry.
|
|
483
|
+
if (entry.prepared?.accepted) { journal.wake(); return }
|
|
484
|
+
// One prepared unit per entry. Archive waits yield this entry, not the pump.
|
|
485
|
+
for (; entry.fileIndex < entry.files.length; entry.fileIndex++, entry.chunkIndex = 0) {
|
|
449
486
|
const file = entry.files[entry.fileIndex]
|
|
487
|
+
const descriptor = { controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }
|
|
450
488
|
await active()
|
|
451
|
-
await fileTransfers.authorizeSeeding(
|
|
452
|
-
|
|
489
|
+
await fileTransfers.authorizeSeeding(descriptor, { receiverPubkeys: [entry.peer], sharedAt: entry.event.created_at })
|
|
490
|
+
while (entry.chunkIndex < Math.ceil(file.size / IRFS_CHUNK_BYTES)) {
|
|
491
|
+
const chunkStartedAt = performance.now()
|
|
453
492
|
await active()
|
|
454
493
|
const chunk = chunkStorage ? await chunkStorage.read(file.root, entry.chunkIndex, file) : await fileTransfers.readChunk(file.root, entry.chunkIndex)
|
|
455
494
|
if (!chunk || decodeIrfsChunk(chunk).root !== file.root) { if (file.optional) break; throw new Error('FILE_UNAVAILABLE') }
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
495
|
+
const recover = mode === 'watchtower' ? messenger.offlineRecoverySecondsFor?.(descriptor.controlChannelPubkey) ?? 0 : 0
|
|
496
|
+
const target = { file: descriptor, fileIndex: entry.fileIndex, chunkIndex: entry.chunkIndex }
|
|
497
|
+
await fileTransfers.publishChunk(descriptor, wireEvent(chunk, owner), {
|
|
498
|
+
signal: controller.signal, attemptStartedAt: chunkStartedAt, onDebug: onDebug ? value => debug(entry, value.stage, undefined, value) : undefined, preparedPublication: entry.prepared?.publication,
|
|
499
|
+
onPreparedSeed: async seed => { await journal.stage(entry, seed, target, recover); controller.signal.throwIfAborted() }
|
|
500
|
+
})
|
|
501
|
+
entry.chunkIndex++
|
|
502
|
+
const completed = entry.files.slice(0, entry.fileIndex).reduce((sum, value) => sum + value.size, 0) + Math.min(file.size, entry.chunkIndex * IRFS_CHUNK_BYTES)
|
|
459
503
|
entry.uploadProgress = { completed, total: entry.files.reduce((sum, value) => sum + value.size, 0) }
|
|
460
504
|
emit()
|
|
461
505
|
await active()
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
// selecting publications at this chunk boundary. It never awaits us.
|
|
506
|
+
if (entry.prepared) { journal.accept(entry); return }
|
|
507
|
+
await journal.write(entry)
|
|
465
508
|
if ([...entries.values()].some(pending => !pending.files.length && pending.peer !== owner && !pending.failed && !localReady.has(pending.id))) {
|
|
466
509
|
await pumpLocal()
|
|
467
510
|
if (localSaving) await new Promise(resolve => localIdleWaiters.add(resolve))
|
|
468
511
|
}
|
|
469
|
-
// Newly queued text and deletion controls can pass an in-flight file.
|
|
470
512
|
let admitted = 0
|
|
471
513
|
for (const pending of entries.values()) {
|
|
472
514
|
if (closed || !available || admitted >= 8) break
|
|
473
|
-
if (!localReady.has(pending.id) || pending.peer === owner || pending.status === 'saved' || pending.files.length || pending.failed || cancelled.has(pending.id) ||
|
|
515
|
+
if (!localReady.has(pending.id) || pending.peer === owner || pending.status === 'saved' || pending.files.length || pending.failed || cancelled.has(pending.id) || pending.prepared?.accepted || !peers.has(pending.peer)) continue
|
|
474
516
|
admitted++
|
|
475
517
|
await sendEntry(pending)
|
|
476
518
|
}
|
|
477
519
|
}
|
|
478
520
|
}
|
|
479
|
-
|
|
521
|
+
while (entry.index < entry.events.length) {
|
|
480
522
|
await active()
|
|
481
523
|
const event = entry.events[entry.index]
|
|
482
524
|
const startedAt = Date.now()
|
|
483
|
-
await publish(entry
|
|
525
|
+
await publish(entry, event, controller.signal)
|
|
484
526
|
debug(entry, 'publication-accepted', startedAt)
|
|
485
527
|
await active()
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (entry.
|
|
528
|
+
entry.index++
|
|
529
|
+
if (entry.index === entry.events.length) { entry.status = 'saved'; emit() }
|
|
530
|
+
if (entry.prepared) { journal.accept(entry); return }
|
|
531
|
+
if (entry.index < entry.events.length) await journal.write(entry)
|
|
489
532
|
}
|
|
490
533
|
entry.status = 'saved'; emit()
|
|
491
534
|
cleanupEntry(entry)
|
|
@@ -493,9 +536,12 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
493
536
|
// Pauses can surface at any stage (text publication, file authorization
|
|
494
537
|
// or chunk delivery). Normalize all of them into the retryable path.
|
|
495
538
|
const error = cause?.retryWhenAvailable ? cause : (isUnavailableSend(cause) ? unavailableError(cause, controller.signal) : cause)
|
|
496
|
-
if (cancelled.has(entry.id)) {
|
|
539
|
+
if (cancelled.has(entry.id)) {
|
|
540
|
+
entry.cancelled = true
|
|
541
|
+
if (entry.prepared) { await journal.write(entry); journal.wake() } else { await storage.remove(entry.id); entries.delete(entry.id) }
|
|
542
|
+
} else {
|
|
497
543
|
entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
|
|
498
|
-
await
|
|
544
|
+
await journal.write(entry).catch(reportError)
|
|
499
545
|
if (entry.peer !== owner && error.retryWhenAvailable && messenger?.readStatus().paused) pausedSends.add(entry.id)
|
|
500
546
|
else if (isUnavailableSend(cause) && cause.pauseReasons?.length && available && !closed) {
|
|
501
547
|
// Release can race persistence of the failed attempt. Do not lose
|
|
@@ -536,6 +582,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
536
582
|
for (const entry of entries.values()) {
|
|
537
583
|
if (!pendingRetryable(entry) || pausedSends.has(entry.id)) continue
|
|
538
584
|
entry.failed = false
|
|
585
|
+
attemptStarts.set(entry.id, performance.now())
|
|
539
586
|
if (entry.status === 'saved') { cleanupEntry(entry); continue }
|
|
540
587
|
retried = true
|
|
541
588
|
}
|
|
@@ -553,7 +600,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
553
600
|
if (closed || !available) return
|
|
554
601
|
for (const id of offlineSends) {
|
|
555
602
|
const entry = entries.get(id)
|
|
556
|
-
if (entry?.retryable) entry.failed = false
|
|
603
|
+
if (entry?.retryable) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
|
|
557
604
|
}
|
|
558
605
|
offlineSends.clear()
|
|
559
606
|
return schedule()
|
|
@@ -586,6 +633,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
586
633
|
repump = false
|
|
587
634
|
for (const entry of entries.values()) {
|
|
588
635
|
if (closed || !available) break
|
|
636
|
+
if (entry.prepared?.accepted) { journal.wake(); continue }
|
|
589
637
|
if (entry.peer === owner || entry.status === 'saved' || !localReady.has(entry.id) || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
|
|
590
638
|
if (!preparation.get(entry.peer) && !preparation.error(entry.peer)) {
|
|
591
639
|
preparation.request(entry.peer).catch(() => {})
|
|
@@ -714,19 +762,22 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
714
762
|
else clearSendRetry()
|
|
715
763
|
if (changed) lifecycle++
|
|
716
764
|
if (!available) {
|
|
765
|
+
journal.setAvailable(false)
|
|
717
766
|
preparation.setAvailable(false)
|
|
718
767
|
for (const peer of channels.keys()) if (!preparation.get(peer)) channels.delete(peer)
|
|
719
768
|
inboxRecovery.stop(); await messenger?.pause('signer'); return
|
|
720
769
|
}
|
|
721
770
|
await ready()
|
|
771
|
+
journal.setAvailable(true)
|
|
722
772
|
pumpLocal()
|
|
723
|
-
for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
|
|
773
|
+
for (const entry of entries.values()) if (pendingRetryable(entry)) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
|
|
724
774
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
725
775
|
if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
|
|
726
776
|
return schedule()
|
|
727
777
|
},
|
|
728
|
-
async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
|
|
778
|
+
async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false, attemptStartedAt }) {
|
|
729
779
|
if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
|
|
780
|
+
if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
|
|
730
781
|
const releaseForeground = foreground.enter()
|
|
731
782
|
enqueuing++
|
|
732
783
|
try {
|
|
@@ -741,6 +792,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
741
792
|
await storage.put(entry)
|
|
742
793
|
debug(entry, 'outbox-stored', startedAt)
|
|
743
794
|
queuedAt.set(entry, Date.now())
|
|
795
|
+
if (attemptStartedAt !== undefined) attemptStarts.set(id, attemptStartedAt)
|
|
744
796
|
if (cancelled.has(id)) { await storage.remove(id); throw new Error('CHAT_UNAVAILABLE') }
|
|
745
797
|
// An accepted durable intent survives shutdown/locking for replay. Only
|
|
746
798
|
// explicit cancellation removes it; no late callback or work is admitted.
|
|
@@ -755,15 +807,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
755
807
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
756
808
|
await ready()
|
|
757
809
|
const entry = entries.get(id)
|
|
758
|
-
if (entry) { entry.failed = false; resetSendRetry(); if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {}); return pump() }
|
|
810
|
+
if (entry) { attemptStarts.set(id, performance.now()); journal.retry(); entry.failed = false; resetSendRetry(); if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {}); return pump() }
|
|
759
811
|
},
|
|
760
812
|
async cancel (id) {
|
|
761
813
|
cancelled.add(id)
|
|
762
814
|
sendControllers.get(id)?.abort()
|
|
763
815
|
offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
|
|
764
816
|
await ready()
|
|
765
|
-
entries.
|
|
766
|
-
await storage.remove(id)
|
|
817
|
+
const entry = entries.get(id)
|
|
818
|
+
if (entry?.prepared) { entry.cancelled = true; await journal.write(entry); journal.wake() } else { entries.delete(id); localReady.delete(id); await storage.remove(id) }
|
|
767
819
|
emit()
|
|
768
820
|
},
|
|
769
821
|
close () {
|
|
@@ -775,6 +827,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
775
827
|
await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
|
|
776
828
|
await messenger?.pause('closed')
|
|
777
829
|
if (sending || localSaving || enqueuing || draining || cleanups.size) await new Promise(resolve => idleWaiters.add(resolve))
|
|
830
|
+
await journal.close()
|
|
831
|
+
if (cleanups.size) await new Promise(resolve => idleWaiters.add(resolve))
|
|
778
832
|
await messenger?.close()
|
|
779
833
|
await storage?.close()
|
|
780
834
|
await downloadWriteTail.catch(reportError)
|