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
package/README.md
CHANGED
|
@@ -535,7 +535,8 @@ new channels are watched unless paused. Applications may explicitly watch again.
|
|
|
535
535
|
|
|
536
536
|
Pauses, unwatch and close record the start of the gap, even for channels that
|
|
537
537
|
have never received a message. Rewatch/resume uses live delivery plus historical
|
|
538
|
-
recovery with overlap.
|
|
538
|
+
recovery with overlap. Remote recovery failures never restore the caller's pause
|
|
539
|
+
reason: signer availability belongs exclusively to the account owner.
|
|
539
540
|
Before opening a watch, the messenger persists a pending recovery interval.
|
|
540
541
|
A first watch scans the configured recovery window (seven days by default),
|
|
541
542
|
including messages sent before this identity first opened the channel. Successful
|
|
@@ -546,6 +547,24 @@ This also covers abrupt termination without `close()` and channels with no
|
|
|
546
547
|
messages. Applications still supply signers and desired channels on reopening;
|
|
547
548
|
storage is scoped by origin and user pubkey, not the transient session ID.
|
|
548
549
|
Failed ingestion or incomplete relay fetch reports never complete the gap.
|
|
550
|
+
|
|
551
|
+
`init()` and `createPrivateMessenger()` resolve after local preparation. Likewise,
|
|
552
|
+
`update(options, { waitForBackground = false })` and
|
|
553
|
+
`resume(reason, { waitForBackground = false })` wait for valid local channel
|
|
554
|
+
configuration and mandatory durable writes, allowing sends independently of
|
|
555
|
+
subscriptions, presence and history. Set `waitForBackground: true` to also await
|
|
556
|
+
the initial background tasks associated with that call, not every future retry.
|
|
557
|
+
Both modes share tasks; unchanged configuration does not recreate subscriptions.
|
|
558
|
+
Later errors reach `onError` with the original error as `cause` and
|
|
559
|
+
`operation: 'private-messenger.background'` plus a `phase`. Cancelled or obsolete
|
|
560
|
+
tasks cannot deliver into a new channel generation. Mandatory persistence still
|
|
561
|
+
owns its storage pause; incomplete history remains a pending recovery interval.
|
|
562
|
+
Subscription setup is bounded separately from presence, so a slow announcement
|
|
563
|
+
does not hold the other channels' readers. Sessions prioritize user persistence
|
|
564
|
+
and publication before the next background presence signer call. Signing an outer
|
|
565
|
+
envelope can overlap recovery-seed persistence, but no envelope is yielded for
|
|
566
|
+
publication unless both succeed; only one outer is prepared at a time.
|
|
567
|
+
|
|
549
568
|
`private-channel.fetch` reports incomplete reads as an `AggregateError` with
|
|
550
569
|
stable `code: 'PRIVATE_CHANNEL_FETCH_INCOMPLETE'`. Its message names relays,
|
|
551
570
|
completion statuses and native failure messages, including status-only failures.
|
|
@@ -936,22 +955,26 @@ also admits relay policy refusals (`blocked:`, `restricted:`, `auth-required:`,
|
|
|
936
955
|
Local authentication denials, invalid events and unknown errors qualify for
|
|
937
956
|
neither policy. Predicates classify individual native errors; callers decide
|
|
938
957
|
how to handle mixed per-relay outcomes and application-specific aggregates.
|
|
939
|
-
`parseRelayRetryAdvice(reason,
|
|
958
|
+
`parseRelayRetryAdvice(reason, params, { now = Date.now() })`, exported by
|
|
940
959
|
`libp2r2p/relay`, returns `{ retryAt, retryAfterMs? }` or `null`. It only interprets
|
|
941
|
-
leading `rate-limited:` refusals.
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
960
|
+
leading `rate-limited:` refusals. The optional string `params` is relative
|
|
961
|
+
milliseconds (e.g. "2500") or an absolute Unix timestamp in milliseconds
|
|
962
|
+
prefixed with `@` (e.g. "@1791738154332"). Values must be positive safe integers
|
|
963
|
+
in canonical decimal form, without signs, whitespace, fractions or leading zeros.
|
|
964
|
+
Relative values produce both `retryAfterMs` and `retryAt`; absolute values produce
|
|
965
|
+
only `retryAt` and retain an expired deadline without starting a new wait.
|
|
966
|
+
Future waits are capped at five minutes. Missing or invalid params are ignored
|
|
967
|
+
and do not instruct clients to retry immediately. Schedulers wait until
|
|
948
968
|
max(local backoff deadline, retryAt), never sum the two or renew the deadline on
|
|
949
969
|
online notifications.
|
|
950
970
|
|
|
951
|
-
|
|
952
|
-
`
|
|
953
|
-
|
|
954
|
-
|
|
971
|
+
Rate limits apply only to the client message type that triggered the refusal:
|
|
972
|
+
`EVENT`, `AUTH`, `REQ` and `COUNT` have independent connection cooldowns. Responses
|
|
973
|
+
are correlated with pending operations; unknown IDs never create cooldowns.
|
|
974
|
+
CLOSE cleanup remains immediate. The launcher can replace valid relative params
|
|
975
|
+
with `@` timestamps before queueing bridge delivery because its consumers share
|
|
976
|
+
its clock. No legacy timing objects are interpreted. Params never supply provenance
|
|
977
|
+
or retry eligibility; native messages, causes and codes remain intact.
|
|
955
978
|
|
|
956
979
|
## Binary encodings
|
|
957
980
|
|
|
@@ -1072,10 +1095,11 @@ user-facing send error.
|
|
|
1072
1095
|
|
|
1073
1096
|
### Relay backpressure and bounded synchronization (0.11.7)
|
|
1074
1097
|
|
|
1075
|
-
Optional `
|
|
1076
|
-
responses are preserved as
|
|
1077
|
-
|
|
1078
|
-
|
|
1098
|
+
Optional decimal `params` milliseconds or `@` Unix milliseconds on relay `CLOSED`
|
|
1099
|
+
and rejected `OK` responses are preserved as normalized `retryAt` (and
|
|
1100
|
+
`retryAfterMs` for relative params) on native relay errors. Only `rate-limited:`
|
|
1101
|
+
responses apply this advice; invalid values are ignored
|
|
1102
|
+
and delays are capped at five minutes. New operations of the refused type wait
|
|
1079
1103
|
within their existing deadlines; active subscriptions remain live and `CLOSE`
|
|
1080
1104
|
never waits. Cancelled or timed-out operations cannot be sent later. A rejection
|
|
1081
1105
|
is still reported to the caller; this does not silently republish failed events.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// A bounded FIFO for asynchronous work. Acquired leases release explicitly;
|
|
2
2
|
// queued cancellations never consume capacity or keep idle keyed gates alive.
|
|
3
|
-
export function createAbortableSemaphore (capacity, onIdle = () => {}) {
|
|
3
|
+
export function createAbortableSemaphore (capacity, onIdle = () => {}, { maxQueued = 256 } = {}) {
|
|
4
4
|
const queue = []
|
|
5
5
|
let active = 0
|
|
6
6
|
const drain = () => {
|
|
@@ -21,7 +21,7 @@ export function createAbortableSemaphore (capacity, onIdle = () => {}) {
|
|
|
21
21
|
return {
|
|
22
22
|
acquire (signal) {
|
|
23
23
|
signal?.throwIfAborted()
|
|
24
|
-
if (queue.length >=
|
|
24
|
+
if (queue.length >= maxQueued) return Promise.reject(Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_QUEUE_FULL'), { code: 'PRIVATE_CHANNEL_HISTORY_QUEUE_FULL' }))
|
|
25
25
|
return new Promise((resolve, reject) => {
|
|
26
26
|
const job = {
|
|
27
27
|
signal, resolve, abort: () => {
|
package/package.json
CHANGED
package/private-channel/index.js
CHANGED
|
@@ -158,7 +158,8 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
|
|
|
158
158
|
const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
|
|
159
159
|
const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
|
|
160
160
|
const total = preparedChunkCount(context.preparedRows, rowIndexes)
|
|
161
|
-
|
|
161
|
+
const persistSeeds = async () => {
|
|
162
|
+
if (!onPreparedSeed) return
|
|
162
163
|
const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
|
|
163
164
|
const payloadRow = readPreparedRow(context.preparedRows, 0)
|
|
164
165
|
for (const rowIndex of rowIndexes) {
|
|
@@ -179,12 +180,18 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
|
|
|
179
180
|
content
|
|
180
181
|
}), routerSeckey)
|
|
181
182
|
const createdAt = nowSeconds()
|
|
182
|
-
const
|
|
183
|
+
const prepareOuter = async () => privateChannelSigner.signEvent({
|
|
183
184
|
kind: PRIVATE_BROADCAST_KIND,
|
|
184
185
|
created_at: createdAt,
|
|
185
186
|
tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
|
|
186
187
|
content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
|
|
187
188
|
})
|
|
189
|
+
// These independent steps may run together, but no envelope is yielded
|
|
190
|
+
// until every seed is durable. Settle both before releasing prepared rows.
|
|
191
|
+
const [persistence, prepared] = await Promise.allSettled([index === 1 ? persistSeeds() : undefined, prepareOuter()])
|
|
192
|
+
if (persistence.status === 'rejected') throw persistence.reason
|
|
193
|
+
if (prepared.status === 'rejected') throw prepared.reason
|
|
194
|
+
const outer = prepared.value
|
|
188
195
|
if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
|
|
189
196
|
yield outer
|
|
190
197
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Keep actual permissions and cryptography in the signer. Background presence
|
|
2
|
+
// only yields before its next RPC, without interrupting an operation in flight.
|
|
3
|
+
export function backgroundSigner (signer, wait, signal) {
|
|
4
|
+
if (!signer || !wait) return signer
|
|
5
|
+
return new Proxy(signer, {
|
|
6
|
+
get (target, key) {
|
|
7
|
+
const value = Reflect.get(target, key, target)
|
|
8
|
+
if (typeof value !== 'function') return value
|
|
9
|
+
return async (...args) => {
|
|
10
|
+
await wait(signal)
|
|
11
|
+
signal?.throwIfAborted()
|
|
12
|
+
return value.apply(target, args)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
}
|
|
@@ -48,6 +48,7 @@ import * as privateMessage from '../private-message/index.js'
|
|
|
48
48
|
import { isOnline, onOnline } from '../network/index.js'
|
|
49
49
|
import { isRetryableRelayFailure, isReplaceableRelayFailure } from '../relay/index.js'
|
|
50
50
|
import { createPauseRecovery, isRecoverableStorageFailure } from './helpers/pause-recovery.js'
|
|
51
|
+
import { backgroundSigner } from './helpers/background-signer.js'
|
|
51
52
|
import { createSendRelayRouting, normalizeFallbackRelays, normalizeFallbackDelay } from './helpers/send-routing.js'
|
|
52
53
|
import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
|
|
53
54
|
import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
|
|
@@ -243,6 +244,7 @@ export class PrivateMessenger {
|
|
|
243
244
|
_pickRelaysForPubkeys = pickRelaysForPubkeys,
|
|
244
245
|
_subscribeRelayListUpdates = subscribeRelayListUpdates,
|
|
245
246
|
_isOnline = isOnline,
|
|
247
|
+
_waitForForeground,
|
|
246
248
|
_onOnline = onOnline,
|
|
247
249
|
_random = Math.random,
|
|
248
250
|
_setTimeout = globalThis.setTimeout.bind(globalThis),
|
|
@@ -304,6 +306,7 @@ export class PrivateMessenger {
|
|
|
304
306
|
this._pickRelaysForPubkeys = _pickRelaysForPubkeys
|
|
305
307
|
this._subscribeRelayListUpdates = _subscribeRelayListUpdates
|
|
306
308
|
this._isOnline = _isOnline
|
|
309
|
+
this._waitForForeground = _waitForForeground
|
|
307
310
|
this.sendRelayExclusions = new Map()
|
|
308
311
|
this.sendRoutingLifetime = new AbortController()
|
|
309
312
|
this.sendRoutingPause = new AbortController()
|
|
@@ -341,7 +344,13 @@ export class PrivateMessenger {
|
|
|
341
344
|
this.recoveryRanges = new Map()
|
|
342
345
|
this.recoveryPriority = new Map()
|
|
343
346
|
this.recoveryOnline = { at: 0, value: true }
|
|
344
|
-
this.
|
|
347
|
+
this.channelBackground = new Map()
|
|
348
|
+
// At most one queued job exists per configured channel. Keep setup below
|
|
349
|
+
// foreground demand; history has its own independent admission limits.
|
|
350
|
+
this.channelSetupAdmission = createAbortableSemaphore(3, () => {}, { maxQueued: Infinity })
|
|
351
|
+
this.presenceAdmission = createAbortableSemaphore(1, () => {}, { maxQueued: Infinity })
|
|
352
|
+
this.updateTail = Promise.resolve()
|
|
353
|
+
this.pauseRevisions = new Map()
|
|
345
354
|
this.capacityTimer = null
|
|
346
355
|
this.capacityCheck = null
|
|
347
356
|
this.capacityRequiredBytes = 0
|
|
@@ -350,6 +359,7 @@ export class PrivateMessenger {
|
|
|
350
359
|
this.liveRecoveryTimers = new Map()
|
|
351
360
|
this.watchRevisionByChannel = new Map()
|
|
352
361
|
this.presenceTimers = new Map()
|
|
362
|
+
this.presenceJobs = new Map()
|
|
353
363
|
this.stopRelayListWatcher = null
|
|
354
364
|
this.relayListWatcherPubkey = ''
|
|
355
365
|
this.relayListRefreshPromise = null
|
|
@@ -435,7 +445,6 @@ export class PrivateMessenger {
|
|
|
435
445
|
this.startStorageMaintenance()
|
|
436
446
|
this.state = { channels: await this.stateStore.load() }
|
|
437
447
|
await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
|
|
438
|
-
await this.pruneStoredSeeds()
|
|
439
448
|
this.initialized = true
|
|
440
449
|
await this.refreshAndApplyStoragePolicy()
|
|
441
450
|
this.broadcastStoragePolicyChange()
|
|
@@ -673,7 +682,16 @@ export class PrivateMessenger {
|
|
|
673
682
|
})
|
|
674
683
|
}
|
|
675
684
|
|
|
676
|
-
async update (options = {}) {
|
|
685
|
+
async update (options = {}, { waitForBackground = false } = {}) {
|
|
686
|
+
if (typeof waitForBackground !== 'boolean') throw new ValidationError('INVALID_WAIT_FOR_BACKGROUND')
|
|
687
|
+
const work = this.updateTail.catch(() => {}).then(() => this.applyUpdate(options))
|
|
688
|
+
this.updateTail = work
|
|
689
|
+
const background = await work
|
|
690
|
+
if (waitForBackground) await Promise.all(background)
|
|
691
|
+
return this
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async applyUpdate (options) {
|
|
677
695
|
this.assertOpen()
|
|
678
696
|
const {
|
|
679
697
|
userSigner = this.userSigner,
|
|
@@ -692,15 +710,17 @@ export class PrivateMessenger {
|
|
|
692
710
|
let nextIdentityStorageRetentionSeconds = updatesIdentityPolicy
|
|
693
711
|
? normalizeIdentityStorageRetentionSeconds(options.identityStorageRetentionSeconds)
|
|
694
712
|
: this.identityStorageRetentionSeconds
|
|
695
|
-
if (userSigner) {
|
|
713
|
+
if (userSigner && (userSigner !== this.userSigner || !this.userPubkey)) {
|
|
696
714
|
const userPubkey = await userSigner.getPublicKey?.()
|
|
697
715
|
if (!userPubkey) throw new ValidationError('USER_SIGNER_REQUIRED')
|
|
698
716
|
if (this.userPubkey && userPubkey !== this.userPubkey) throw new ValidationError('USER_SIGNER_MISMATCH')
|
|
699
717
|
this.userSigner = userSigner
|
|
700
718
|
}
|
|
719
|
+
if (contentKeySigner !== this.contentKeySigner || (!this.contentKeyPubkey && contentKeySigner)) {
|
|
720
|
+
this.contentKeyPubkey = await contentKeySigner?.getPublicKey?.() || ''
|
|
721
|
+
}
|
|
701
722
|
this.contentKeySigner = contentKeySigner || null
|
|
702
723
|
this.nymSigner = nymSigner || null
|
|
703
|
-
this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
|
|
704
724
|
const nextChannels = await this.normalizeChannels(channels, { relays, mode })
|
|
705
725
|
this.assertOpen()
|
|
706
726
|
const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
|
|
@@ -709,9 +729,11 @@ export class PrivateMessenger {
|
|
|
709
729
|
return !previous || ['signer', 'readerSigner', 'readerPubkey', 'nymSigner', 'mode', 'usesNip65WatchRelays', 'offlineRecoverySeconds', 'autoDeletionCapability'].some(key => previous[key] !== channel[key]) ||
|
|
710
730
|
['relays', 'sendRelays', 'seeders'].some(key => JSON.stringify(previous[key]) !== JSON.stringify(channel[key]))
|
|
711
731
|
}
|
|
712
|
-
const
|
|
732
|
+
const changed = nextChannels.filter(channel => readerIdentityChanged || updatesStalePolicy || updatesIdentityPolicy || changedChannel(channel))
|
|
733
|
+
const watchPubkeys = changed.filter(channel => !this.channels.has(channel.pubkey) || this.desiredChannels.has(channel.pubkey)).map(channel => channel.pubkey)
|
|
713
734
|
const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
|
|
714
735
|
const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
|
|
736
|
+
if (!changed.length && !removedPubkeys.length && !updatesStoragePolicy) return [...this.channelBackground.values()].map(job => job.work)
|
|
715
737
|
|
|
716
738
|
if (updatesStoragePolicy) {
|
|
717
739
|
const currentPolicy = await this.readStoragePolicySnapshot()
|
|
@@ -743,18 +765,35 @@ export class PrivateMessenger {
|
|
|
743
765
|
if (updatesStoragePolicy) this.broadcastStoragePolicyChange()
|
|
744
766
|
|
|
745
767
|
await this.unwatch(removedPubkeys)
|
|
768
|
+
// Keep an established reader alive until private-message atomically
|
|
769
|
+
// replaces it. Only obsolete setup/presence work is retired here.
|
|
770
|
+
for (const pubkey of watchPubkeys) {
|
|
771
|
+
this.cancelReloadGap(pubkey)
|
|
772
|
+
this.cancelLiveRecovery(pubkey)
|
|
773
|
+
this.channelBackground.get(pubkey)?.controller.abort()
|
|
774
|
+
this.channelBackground.delete(pubkey)
|
|
775
|
+
this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
|
|
776
|
+
this.stopPresencePublisher(pubkey)
|
|
777
|
+
}
|
|
746
778
|
for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
|
|
747
779
|
for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
|
|
748
|
-
for (const channel of
|
|
780
|
+
for (const channel of changed) this.channels.set(channel.pubkey, channel)
|
|
749
781
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
await this.
|
|
753
|
-
|
|
782
|
+
// Incremental registration must not prune persisted channels whose signers
|
|
783
|
+
// are still being prepared. Retention cleanup belongs to maintenance.
|
|
784
|
+
await this.applyRecoveryPolicies(changed, { prune: !this.seedStorage })
|
|
785
|
+
this.assertOpen()
|
|
786
|
+
for (const pubkey of watchPubkeys) {
|
|
787
|
+
this.desiredChannels.add(pubkey)
|
|
788
|
+
this.recordRecoveryWindow(pubkey)
|
|
789
|
+
}
|
|
790
|
+
if (this.pauseReasons.size) this.recordInterruption(watchPubkeys)
|
|
791
|
+
await this.flushStateWrites()
|
|
792
|
+
this.ensureNetworkWatchers()
|
|
754
793
|
if (this.storagePolicyRevision === storageSnapshot.policyRevision) {
|
|
755
794
|
this.storagePolicyNeedsApply = false
|
|
756
795
|
}
|
|
757
|
-
return this
|
|
796
|
+
return this.startChannelBackground(watchPubkeys)
|
|
758
797
|
}
|
|
759
798
|
|
|
760
799
|
async normalizeChannels (channels, defaults) {
|
|
@@ -772,7 +811,7 @@ export class PrivateMessenger {
|
|
|
772
811
|
if (!signer && !readerSigner) throw new ValidationError('CHANNEL_SIGNER_REQUIRED')
|
|
773
812
|
const mode = channel.mode || defaults.mode || 'leecher'
|
|
774
813
|
if (!signer && doesModeStoreRecoverySeeds(mode)) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
|
|
775
|
-
const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || await readerSigner?.getPublicKey?.() || pubkey
|
|
814
|
+
const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || (readerSigner === signer ? pubkey : await readerSigner?.getPublicKey?.()) || pubkey
|
|
776
815
|
const autoDeletionCapability = channel.autoDeletionCapability === undefined
|
|
777
816
|
? undefined
|
|
778
817
|
: normalizeAutoDeletionCapability(channel.autoDeletionCapability)
|
|
@@ -804,8 +843,8 @@ export class PrivateMessenger {
|
|
|
804
843
|
return this._pickRelaysForPubkeys(pubkeys, relaysByPubkey, { relayType: 'read' })
|
|
805
844
|
}
|
|
806
845
|
|
|
807
|
-
async readRelaysForPubkey (pubkey) {
|
|
808
|
-
const relaysByPubkey = await this._getRelaysByPubkey([pubkey])
|
|
846
|
+
async readRelaysForPubkey (pubkey, options) {
|
|
847
|
+
const relaysByPubkey = await this._getRelaysByPubkey([pubkey], options)
|
|
809
848
|
const readRelays = uniq(relaysByPubkey?.[pubkey]?.read)
|
|
810
849
|
if (readRelays.length) return readRelays
|
|
811
850
|
return relayMapRelays(this._pickRelaysForPubkeys([pubkey], relaysByPubkey, { relayType: 'read' }))
|
|
@@ -823,8 +862,8 @@ export class PrivateMessenger {
|
|
|
823
862
|
}
|
|
824
863
|
}
|
|
825
864
|
|
|
826
|
-
async resolveWatchRelays (channel) {
|
|
827
|
-
const primary = !channel.usesNip65WatchRelays && channel.relays.length ? channel.relays : await this.readRelaysForPubkey(this.userPubkey)
|
|
865
|
+
async resolveWatchRelays (channel, options) {
|
|
866
|
+
const primary = !channel.usesNip65WatchRelays && channel.relays.length ? channel.relays : await this.readRelaysForPubkey(this.userPubkey, options)
|
|
828
867
|
return uniq([...primary, ...this.fallbackRelays].map(normalizeRelayUrl))
|
|
829
868
|
}
|
|
830
869
|
|
|
@@ -1124,7 +1163,7 @@ export class PrivateMessenger {
|
|
|
1124
1163
|
this.writeState(state)
|
|
1125
1164
|
}
|
|
1126
1165
|
|
|
1127
|
-
async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true } = {}) {
|
|
1166
|
+
async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true, signal } = {}) {
|
|
1128
1167
|
this.assertOpen()
|
|
1129
1168
|
const channelPubkeys = uniq(channels)
|
|
1130
1169
|
for (const pubkey of channelPubkeys) {
|
|
@@ -1148,13 +1187,15 @@ export class PrivateMessenger {
|
|
|
1148
1187
|
await this.flushStateWrites()
|
|
1149
1188
|
let stop; let watchRelays
|
|
1150
1189
|
try {
|
|
1151
|
-
|
|
1190
|
+
signal?.throwIfAborted()
|
|
1191
|
+
watchRelays = await this.resolveWatchRelays(channel, { signal })
|
|
1152
1192
|
this.assertOpen()
|
|
1153
1193
|
if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) continue
|
|
1154
1194
|
stop = await this._privateMessage.watch({
|
|
1155
1195
|
channels: [pubkey],
|
|
1156
1196
|
relays: watchRelays,
|
|
1157
1197
|
receiverSigner: this.userSigner,
|
|
1198
|
+
receiverPubkey: this.userPubkey,
|
|
1158
1199
|
iykcSigner: this.contentKeySigner,
|
|
1159
1200
|
privateChannelSigner: channel.signer,
|
|
1160
1201
|
privateChannelReaderSigner: channel.readerSigner,
|
|
@@ -1174,6 +1215,7 @@ export class PrivateMessenger {
|
|
|
1174
1215
|
onError: err => this.onError?.(err)
|
|
1175
1216
|
})
|
|
1176
1217
|
} catch (error) {
|
|
1218
|
+
if (signal?.aborted) continue
|
|
1177
1219
|
const transient = isRetryableRelayFailure(error)
|
|
1178
1220
|
const remote = error?.name !== 'Nip42AuthenticationError' && (error?.category === 'relay' || isReplaceableRelayFailure(error))
|
|
1179
1221
|
if (!transient && !remote) throw error
|
|
@@ -1655,6 +1697,8 @@ export class PrivateMessenger {
|
|
|
1655
1697
|
stopWatches (channels = [...this.stopByChannel.keys()]) {
|
|
1656
1698
|
const closing = []
|
|
1657
1699
|
for (const pubkey of channels) {
|
|
1700
|
+
this.channelBackground.get(pubkey)?.controller.abort()
|
|
1701
|
+
this.channelBackground.delete(pubkey)
|
|
1658
1702
|
this.cancelReloadGap(pubkey)
|
|
1659
1703
|
this.cancelLiveRecovery(pubkey)
|
|
1660
1704
|
this.liveInterruptions.delete(pubkey)
|
|
@@ -1785,6 +1829,7 @@ export class PrivateMessenger {
|
|
|
1785
1829
|
if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
|
|
1786
1830
|
this.assertOpen()
|
|
1787
1831
|
this.pauseReasons.add(reason)
|
|
1832
|
+
this.pauseRevisions.set(reason, (this.pauseRevisions.get(reason) || 0) + 1)
|
|
1788
1833
|
this.sendRoutingPause.abort(this.pausedError())
|
|
1789
1834
|
this.notifyStatus()
|
|
1790
1835
|
for (const extension of this.extensions) extension.pause?.()
|
|
@@ -1793,14 +1838,16 @@ export class PrivateMessenger {
|
|
|
1793
1838
|
return Promise.all([this.stopWatches([...this.desiredChannels]), this.flushStateWrites()])
|
|
1794
1839
|
}
|
|
1795
1840
|
|
|
1796
|
-
async resume (reason) {
|
|
1841
|
+
async resume (reason, { waitForBackground = false } = {}) {
|
|
1797
1842
|
if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
|
|
1843
|
+
if (typeof waitForBackground !== 'boolean') throw new ValidationError('INVALID_WAIT_FOR_BACKGROUND')
|
|
1798
1844
|
this.assertOpen()
|
|
1845
|
+
const revision = this.pauseRevisions.get(reason)
|
|
1799
1846
|
// Repair durable state before announcing that publication can resume.
|
|
1800
1847
|
if (reason === 'storage' && this.pendingStorageWrites.size) await this.retryStorageWrites(this.sendRoutingLifetime.signal)
|
|
1801
1848
|
await this.flushStateWrites()
|
|
1802
1849
|
this.assertOpen()
|
|
1803
|
-
|
|
1850
|
+
if (revision !== this.pauseRevisions.get(reason)) return
|
|
1804
1851
|
const removed = this.pauseReasons.delete(reason)
|
|
1805
1852
|
this.automaticPauses.delete(reason)
|
|
1806
1853
|
if (!this.pauseReasons.size && this.sendRoutingPause.signal.aborted) this.sendRoutingPause = new AbortController()
|
|
@@ -1810,29 +1857,75 @@ export class PrivateMessenger {
|
|
|
1810
1857
|
this.capacityTimer = null
|
|
1811
1858
|
this.capacityRequiredBytes = 0
|
|
1812
1859
|
}
|
|
1813
|
-
if (
|
|
1814
|
-
|
|
1815
|
-
if (
|
|
1816
|
-
const work = (async () => {
|
|
1817
|
-
const channels = [...this.desiredChannels]
|
|
1860
|
+
if (this.pauseReasons.size) return
|
|
1861
|
+
const channels = [...this.desiredChannels]
|
|
1862
|
+
if (removed) {
|
|
1818
1863
|
this.closeOpenOfflineRanges(channels)
|
|
1819
|
-
await this.
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
this.resumeWork = work
|
|
1829
|
-
try { await work } catch (err) {
|
|
1830
|
-
if (!this.closePromise) {
|
|
1831
|
-
if (wasAutomatic && (reason === 'network' ? isRetryableRelayFailure(err) : isRecoverableStorageFailure(err))) this.automaticPauses.add(reason)
|
|
1832
|
-
await this.applyPause(reason)
|
|
1864
|
+
await this.flushStateWrites()
|
|
1865
|
+
}
|
|
1866
|
+
if (this.pauseReasons.size || this.closePromise) return
|
|
1867
|
+
const jobs = removed ? this.startChannelBackground(channels, { recover: true }) : [...this.channelBackground.values()].map(job => job.work)
|
|
1868
|
+
if (removed) {
|
|
1869
|
+
for (const extension of this.extensions) {
|
|
1870
|
+
const work = Promise.resolve().then(() => { if (!this.pauseReasons.size && !this.closePromise) return extension.resume?.() })
|
|
1871
|
+
work.catch(error => this.onError?.(error))
|
|
1872
|
+
jobs.push(work)
|
|
1833
1873
|
}
|
|
1834
|
-
|
|
1835
|
-
|
|
1874
|
+
}
|
|
1875
|
+
if (waitForBackground) await Promise.all(jobs)
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
startChannelBackground (channels, { recover = false } = {}) {
|
|
1879
|
+
const jobs = []
|
|
1880
|
+
for (const pubkey of channels) {
|
|
1881
|
+
if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey)) continue
|
|
1882
|
+
let job = this.channelBackground.get(pubkey)
|
|
1883
|
+
if (job) { jobs.push(job.work); continue }
|
|
1884
|
+
const controller = new AbortController()
|
|
1885
|
+
const channel = this.channels.get(pubkey)
|
|
1886
|
+
job = { controller, work: null }
|
|
1887
|
+
this.channelBackground.set(pubkey, job)
|
|
1888
|
+
const active = () => this.initialized && !controller.signal.aborted && !this.closePromise && !this.pauseReasons.size && this.channels.get(pubkey) === channel && this.desiredChannels.has(pubkey)
|
|
1889
|
+
let phase = 'subscribe'
|
|
1890
|
+
let stopAbort
|
|
1891
|
+
const cancelled = new Promise(resolve => {
|
|
1892
|
+
stopAbort = resolve
|
|
1893
|
+
controller.signal.addEventListener('abort', stopAbort, { once: true })
|
|
1894
|
+
})
|
|
1895
|
+
const work = Promise.resolve().then(async () => {
|
|
1896
|
+
if (!this.initialized) await this.initSettledPromise
|
|
1897
|
+
if (!active()) return
|
|
1898
|
+
const release = await this.channelSetupAdmission.acquire(controller.signal)
|
|
1899
|
+
try {
|
|
1900
|
+
if (!active()) return
|
|
1901
|
+
await this.watch([pubkey], { scheduleReloadGap: !recover, signal: controller.signal })
|
|
1902
|
+
} finally { release() }
|
|
1903
|
+
if (!active()) return
|
|
1904
|
+
phase = 'presence'
|
|
1905
|
+
if (doesModeStoreRecoverySeeds(channel.mode)) await this.startPresencePublisher(pubkey)
|
|
1906
|
+
if (!active()) return
|
|
1907
|
+
phase = 'history'
|
|
1908
|
+
if (recover) await this.recoverOfflineRanges([pubkey])
|
|
1909
|
+
if (!active()) return
|
|
1910
|
+
if (recover && this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
|
|
1911
|
+
}).catch(error => {
|
|
1912
|
+
if (!active()) return
|
|
1913
|
+
const diagnostic = Object.assign(new Error(error?.message || 'PRIVATE_MESSENGER_BACKGROUND_FAILED', { cause: error }), {
|
|
1914
|
+
name: error?.name || 'Error', ...(error?.code ? { code: error.code } : {}),
|
|
1915
|
+
...(error?.category ? { category: error.category } : {}),
|
|
1916
|
+
operation: 'private-messenger.background', phase
|
|
1917
|
+
})
|
|
1918
|
+
this.onError?.(diagnostic)
|
|
1919
|
+
throw error
|
|
1920
|
+
})
|
|
1921
|
+
job.work = Promise.race([work, cancelled]).finally(() => {
|
|
1922
|
+
controller.signal.removeEventListener('abort', stopAbort)
|
|
1923
|
+
if (this.channelBackground.get(pubkey) === job) this.channelBackground.delete(pubkey)
|
|
1924
|
+
})
|
|
1925
|
+
job.work.catch(() => {})
|
|
1926
|
+
jobs.push(job.work)
|
|
1927
|
+
}
|
|
1928
|
+
return jobs
|
|
1836
1929
|
}
|
|
1837
1930
|
|
|
1838
1931
|
receive (pubkey, operation, message = {}) {
|
|
@@ -2336,16 +2429,16 @@ export class PrivateMessenger {
|
|
|
2336
2429
|
})
|
|
2337
2430
|
}
|
|
2338
2431
|
|
|
2339
|
-
async publishSeederPresence (channelPubkey = this.defaultChannelPubkey()) {
|
|
2432
|
+
async publishSeederPresence (channelPubkey = this.defaultChannelPubkey(), { signal } = {}) {
|
|
2340
2433
|
const channel = this.requireWritableChannel(channelPubkey)
|
|
2341
2434
|
if (!this.offlineRecoverySecondsFor(channel)) return null
|
|
2342
2435
|
const receiverPubkeys = uniq([...this.knownSeeders(channelPubkey), this.userPubkey])
|
|
2343
|
-
const routing = await this.resolveSendRouting({ channel, receiverPubkeys })
|
|
2436
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, signal })
|
|
2344
2437
|
this.debugSend('yell', channelPubkey, { code: SEEDER_PRESENCE_CODE, receiverPubkeys })
|
|
2345
2438
|
return this._privateMessage.yell({
|
|
2346
|
-
senderSigner: this.userSigner,
|
|
2347
|
-
imkcSigner: this.contentKeySigner,
|
|
2348
|
-
privateChannelSigner: channel.signer,
|
|
2439
|
+
senderSigner: backgroundSigner(this.userSigner, this._waitForForeground, signal),
|
|
2440
|
+
imkcSigner: backgroundSigner(this.contentKeySigner, this._waitForForeground, signal),
|
|
2441
|
+
privateChannelSigner: backgroundSigner(channel.signer, this._waitForForeground, signal),
|
|
2349
2442
|
privateChannelReaderPubkey: channel.readerPubkey,
|
|
2350
2443
|
receiverPubkeys,
|
|
2351
2444
|
...routing,
|
|
@@ -2362,22 +2455,40 @@ export class PrivateMessenger {
|
|
|
2362
2455
|
async startPresencePublisher (channelPubkey) {
|
|
2363
2456
|
if (this.pauseReasons.size || !this.desiredChannels.has(channelPubkey) || !this.offlineRecoverySecondsFor(channelPubkey)) return
|
|
2364
2457
|
if (this.presenceTimers.has(channelPubkey)) return
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2458
|
+
if (this.presenceJobs.has(channelPubkey)) return this.presenceJobs.get(channelPubkey).work
|
|
2459
|
+
const job = { controller: new AbortController(), work: null }
|
|
2460
|
+
const active = () => this.presenceJobs.get(channelPubkey) === job && !this.pauseReasons.size && !this.closePromise && this.desiredChannels.has(channelPubkey)
|
|
2461
|
+
let publishing = false
|
|
2462
|
+
const publish = async () => {
|
|
2463
|
+
if (!active() || publishing) return
|
|
2464
|
+
publishing = true
|
|
2465
|
+
let release
|
|
2466
|
+
try {
|
|
2467
|
+
release = await this.presenceAdmission.acquire(job.controller.signal)
|
|
2468
|
+
job.controller.signal.addEventListener('abort', release, { once: true })
|
|
2469
|
+
if (active()) await this.publishSeederPresence(channelPubkey, { signal: job.controller.signal })
|
|
2470
|
+
} catch (error) {
|
|
2471
|
+
if (active()) this.onError?.(Object.assign(new Error(error?.message, { cause: error }), { operation: 'private-messenger.background', phase: 'presence' }))
|
|
2472
|
+
} finally {
|
|
2473
|
+
if (release) { job.controller.signal.removeEventListener('abort', release); release() }
|
|
2474
|
+
publishing = false
|
|
2475
|
+
}
|
|
2369
2476
|
}
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2477
|
+
const work = Promise.resolve().then(async () => {
|
|
2478
|
+
await publish()
|
|
2479
|
+
if (!active()) return
|
|
2480
|
+
const timer = this._setInterval(publish, this.seederPresenceIntervalMs)
|
|
2481
|
+
timer?.unref?.()
|
|
2482
|
+
this.presenceTimers.set(channelPubkey, timer)
|
|
2483
|
+
})
|
|
2484
|
+
job.work = work
|
|
2485
|
+
this.presenceJobs.set(channelPubkey, job)
|
|
2486
|
+
return work
|
|
2378
2487
|
}
|
|
2379
2488
|
|
|
2380
2489
|
stopPresencePublisher (channelPubkey) {
|
|
2490
|
+
this.presenceJobs.get(channelPubkey)?.controller.abort()
|
|
2491
|
+
this.presenceJobs.delete(channelPubkey)
|
|
2381
2492
|
const timer = this.presenceTimers.get(channelPubkey)
|
|
2382
2493
|
if (timer) this._clearInterval(timer)
|
|
2383
2494
|
this.presenceTimers.delete(channelPubkey)
|
|
@@ -2458,7 +2569,7 @@ export class PrivateMessenger {
|
|
|
2458
2569
|
return seconds ? seconds * 1000 : DEFAULT_RECEIVED_CHUNK_TTL_MS
|
|
2459
2570
|
}
|
|
2460
2571
|
|
|
2461
|
-
async applyRecoveryPolicies (channels) {
|
|
2572
|
+
async applyRecoveryPolicies (channels, { prune = true } = {}) {
|
|
2462
2573
|
return this.runQueueOperation(async () => {
|
|
2463
2574
|
const state = this.readState()
|
|
2464
2575
|
const now = nowSeconds()
|
|
@@ -2484,7 +2595,7 @@ export class PrivateMessenger {
|
|
|
2484
2595
|
for (const channel of channels) {
|
|
2485
2596
|
if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryState(channel.pubkey)
|
|
2486
2597
|
}
|
|
2487
|
-
for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
|
|
2598
|
+
if (prune) for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
|
|
2488
2599
|
})
|
|
2489
2600
|
}
|
|
2490
2601
|
|
|
@@ -3070,6 +3181,7 @@ export class PrivateMessenger {
|
|
|
3070
3181
|
let unwatchError
|
|
3071
3182
|
try { await unwatchPromise } catch (err) { unwatchError = err }
|
|
3072
3183
|
await initSettledPromise
|
|
3184
|
+
await this.updateTail.catch(() => {})
|
|
3073
3185
|
await Promise.all([...this.extensions].map(extension => extension.close?.()))
|
|
3074
3186
|
await this.stampActiveChannelActivity()
|
|
3075
3187
|
await this.queueOperationTail
|
|
@@ -15,10 +15,35 @@ back to sender-content whenever a proof is missing or the lookup fails. Misses
|
|
|
15
15
|
are cached for 5 minutes (found proofs for 40), so a later prefetch picks up a
|
|
16
16
|
key published after an earlier miss. Set it to `false` when the signer owns that discovery. The DM context is `dm`; file contexts are `dm:media:<root>`.
|
|
17
17
|
`createMessengerSigner` adapts this byte signer to the transport's Base64 API.
|
|
18
|
+
Each adapter belongs to an immutable account/channel identity: it shares the
|
|
19
|
+
first successful public-key lookup and retries failed lookups only on a new call.
|
|
20
|
+
Create a new adapter when changing identity. Signing/encryption still uses the
|
|
21
|
+
real signer and its current permissions on every operation.
|
|
18
22
|
|
|
19
|
-
Methods: `setPeers`, `setAvailable(boolean)`, `prefetchContentKeys(peers?)`,
|
|
23
|
+
Methods: `setPeers`, `setAvailable(boolean)`, `preparePeer(peer)`, `prefetchContentKeys(peers?)`,
|
|
20
24
|
`prioritizeRange(peer, options)`, `enqueue`, `retry`, `cancel`, `download`,
|
|
21
25
|
`cancelDownload`, and `close`.
|
|
26
|
+
|
|
27
|
+
The session creates one messenger for the account, independently of contact
|
|
28
|
+
revisions. `setPeers` and `setAvailable` reconcile local configuration without
|
|
29
|
+
awaiting all contacts or remote recovery. Channel preparation is incremental and
|
|
30
|
+
deduplicated: at most four preparations run, with at most three in the background.
|
|
31
|
+
`preparePeer(peer)` promotes an existing contact and resolves when its channel is
|
|
32
|
+
locally writable. Call it when opening a conversation; enqueue, retry and remote
|
|
33
|
+
download also request preparation automatically. An unprepared conversation does
|
|
34
|
+
not block local saves or sends to a ready contact. Resolved identities are reused.
|
|
35
|
+
The session also defers the next optional presence signer call while user outbox
|
|
36
|
+
writes/publications are active. Existing signer calls, subscriptions and historical
|
|
37
|
+
coverage remain intact; releasing foreground work resumes the waiting presence.
|
|
38
|
+
Successful sends remove the final outbox record directly instead of encrypting a
|
|
39
|
+
redundant final checkpoint; intermediate context/file checkpoints remain durable.
|
|
40
|
+
|
|
41
|
+
Known transient preparation failures retry with bounded offline-aware backoff;
|
|
42
|
+
unknown, permission and validation failures remain isolated to that contact until
|
|
43
|
+
explicit preparation/retry or a real availability transition. Removing a contact,
|
|
44
|
+
locking or closing discards obsolete signer results and cancels its queued work.
|
|
45
|
+
Contact changes never invent a `signer` pause; only `setAvailable` controls that
|
|
46
|
+
reason. Storage, capacity, network and explicit pauses keep their own owners.
|
|
22
47
|
`prefetchContentKeys` warms the content-key lookup cache for the owner and the
|
|
23
48
|
given peers (default: current peers); it is best-effort and never rejects.
|
|
24
49
|
`prioritizeRange` registers an in-memory priority window for a conversation:
|