libp2r2p 0.11.17 → 0.11.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -2
- package/helpers/abortable-semaphore.js +2 -2
- package/package.json +1 -1
- package/private-channel/index.js +9 -2
- package/private-messenger/helpers/background-signer.js +16 -0
- package/private-messenger/helpers/fallback-race.js +134 -0
- package/private-messenger/helpers/send-routing.js +31 -5
- package/private-messenger/index.js +181 -62
- package/private-messenger/session/README.md +51 -3
- package/private-messenger/session/helpers/foreground-work.js +29 -0
- package/private-messenger/session/helpers/peer-preparation.js +111 -0
- package/private-messenger/session/helpers/signer.js +5 -1
- package/private-messenger/session/index.js +153 -53
- package/relay/helpers/publish.js +5 -1
- package/relay/services/relay-connection.js +31 -18
- package/relay/services/relay-pool.js +11 -4
|
@@ -48,7 +48,8 @@ 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 {
|
|
51
|
+
import { backgroundSigner } from './helpers/background-signer.js'
|
|
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'
|
|
54
55
|
import { normalizeRelayUrl } from '../url/index.js'
|
|
@@ -212,6 +213,7 @@ export class PrivateMessenger {
|
|
|
212
213
|
|
|
213
214
|
constructor ({
|
|
214
215
|
fallbackRelays = [],
|
|
216
|
+
fallbackDelayMs = null,
|
|
215
217
|
offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
|
|
216
218
|
staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
|
|
217
219
|
identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
|
|
@@ -242,6 +244,7 @@ export class PrivateMessenger {
|
|
|
242
244
|
_pickRelaysForPubkeys = pickRelaysForPubkeys,
|
|
243
245
|
_subscribeRelayListUpdates = subscribeRelayListUpdates,
|
|
244
246
|
_isOnline = isOnline,
|
|
247
|
+
_waitForForeground,
|
|
245
248
|
_onOnline = onOnline,
|
|
246
249
|
_random = Math.random,
|
|
247
250
|
_setTimeout = globalThis.setTimeout.bind(globalThis),
|
|
@@ -254,6 +257,7 @@ export class PrivateMessenger {
|
|
|
254
257
|
} = {}) {
|
|
255
258
|
if (onStateChanged != null && typeof onStateChanged !== 'function') throw new ValidationError('INVALID_ON_STATE_CHANGED')
|
|
256
259
|
this.fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
260
|
+
this.fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
257
261
|
this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
|
|
258
262
|
this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
|
|
259
263
|
this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
|
|
@@ -302,8 +306,10 @@ export class PrivateMessenger {
|
|
|
302
306
|
this._pickRelaysForPubkeys = _pickRelaysForPubkeys
|
|
303
307
|
this._subscribeRelayListUpdates = _subscribeRelayListUpdates
|
|
304
308
|
this._isOnline = _isOnline
|
|
309
|
+
this._waitForForeground = _waitForForeground
|
|
305
310
|
this.sendRelayExclusions = new Map()
|
|
306
311
|
this.sendRoutingLifetime = new AbortController()
|
|
312
|
+
this.sendRoutingPause = new AbortController()
|
|
307
313
|
this._setTimeout = _setTimeout
|
|
308
314
|
this._clearTimeout = _clearTimeout
|
|
309
315
|
this._setInterval = _setInterval
|
|
@@ -338,7 +344,13 @@ export class PrivateMessenger {
|
|
|
338
344
|
this.recoveryRanges = new Map()
|
|
339
345
|
this.recoveryPriority = new Map()
|
|
340
346
|
this.recoveryOnline = { at: 0, value: true }
|
|
341
|
-
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()
|
|
342
354
|
this.capacityTimer = null
|
|
343
355
|
this.capacityCheck = null
|
|
344
356
|
this.capacityRequiredBytes = 0
|
|
@@ -347,6 +359,7 @@ export class PrivateMessenger {
|
|
|
347
359
|
this.liveRecoveryTimers = new Map()
|
|
348
360
|
this.watchRevisionByChannel = new Map()
|
|
349
361
|
this.presenceTimers = new Map()
|
|
362
|
+
this.presenceJobs = new Map()
|
|
350
363
|
this.stopRelayListWatcher = null
|
|
351
364
|
this.relayListWatcherPubkey = ''
|
|
352
365
|
this.relayListRefreshPromise = null
|
|
@@ -432,7 +445,6 @@ export class PrivateMessenger {
|
|
|
432
445
|
this.startStorageMaintenance()
|
|
433
446
|
this.state = { channels: await this.stateStore.load() }
|
|
434
447
|
await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
|
|
435
|
-
await this.pruneStoredSeeds()
|
|
436
448
|
this.initialized = true
|
|
437
449
|
await this.refreshAndApplyStoragePolicy()
|
|
438
450
|
this.broadcastStoragePolicyChange()
|
|
@@ -670,7 +682,16 @@ export class PrivateMessenger {
|
|
|
670
682
|
})
|
|
671
683
|
}
|
|
672
684
|
|
|
673
|
-
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) {
|
|
674
695
|
this.assertOpen()
|
|
675
696
|
const {
|
|
676
697
|
userSigner = this.userSigner,
|
|
@@ -689,15 +710,17 @@ export class PrivateMessenger {
|
|
|
689
710
|
let nextIdentityStorageRetentionSeconds = updatesIdentityPolicy
|
|
690
711
|
? normalizeIdentityStorageRetentionSeconds(options.identityStorageRetentionSeconds)
|
|
691
712
|
: this.identityStorageRetentionSeconds
|
|
692
|
-
if (userSigner) {
|
|
713
|
+
if (userSigner && (userSigner !== this.userSigner || !this.userPubkey)) {
|
|
693
714
|
const userPubkey = await userSigner.getPublicKey?.()
|
|
694
715
|
if (!userPubkey) throw new ValidationError('USER_SIGNER_REQUIRED')
|
|
695
716
|
if (this.userPubkey && userPubkey !== this.userPubkey) throw new ValidationError('USER_SIGNER_MISMATCH')
|
|
696
717
|
this.userSigner = userSigner
|
|
697
718
|
}
|
|
719
|
+
if (contentKeySigner !== this.contentKeySigner || (!this.contentKeyPubkey && contentKeySigner)) {
|
|
720
|
+
this.contentKeyPubkey = await contentKeySigner?.getPublicKey?.() || ''
|
|
721
|
+
}
|
|
698
722
|
this.contentKeySigner = contentKeySigner || null
|
|
699
723
|
this.nymSigner = nymSigner || null
|
|
700
|
-
this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
|
|
701
724
|
const nextChannels = await this.normalizeChannels(channels, { relays, mode })
|
|
702
725
|
this.assertOpen()
|
|
703
726
|
const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
|
|
@@ -706,9 +729,11 @@ export class PrivateMessenger {
|
|
|
706
729
|
return !previous || ['signer', 'readerSigner', 'readerPubkey', 'nymSigner', 'mode', 'usesNip65WatchRelays', 'offlineRecoverySeconds', 'autoDeletionCapability'].some(key => previous[key] !== channel[key]) ||
|
|
707
730
|
['relays', 'sendRelays', 'seeders'].some(key => JSON.stringify(previous[key]) !== JSON.stringify(channel[key]))
|
|
708
731
|
}
|
|
709
|
-
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)
|
|
710
734
|
const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
|
|
711
735
|
const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
|
|
736
|
+
if (!changed.length && !removedPubkeys.length && !updatesStoragePolicy) return [...this.channelBackground.values()].map(job => job.work)
|
|
712
737
|
|
|
713
738
|
if (updatesStoragePolicy) {
|
|
714
739
|
const currentPolicy = await this.readStoragePolicySnapshot()
|
|
@@ -740,18 +765,35 @@ export class PrivateMessenger {
|
|
|
740
765
|
if (updatesStoragePolicy) this.broadcastStoragePolicyChange()
|
|
741
766
|
|
|
742
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
|
+
}
|
|
743
778
|
for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
|
|
744
779
|
for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
|
|
745
|
-
for (const channel of
|
|
780
|
+
for (const channel of changed) this.channels.set(channel.pubkey, channel)
|
|
746
781
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
await this.
|
|
750
|
-
|
|
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()
|
|
751
793
|
if (this.storagePolicyRevision === storageSnapshot.policyRevision) {
|
|
752
794
|
this.storagePolicyNeedsApply = false
|
|
753
795
|
}
|
|
754
|
-
return this
|
|
796
|
+
return this.startChannelBackground(watchPubkeys)
|
|
755
797
|
}
|
|
756
798
|
|
|
757
799
|
async normalizeChannels (channels, defaults) {
|
|
@@ -769,7 +811,7 @@ export class PrivateMessenger {
|
|
|
769
811
|
if (!signer && !readerSigner) throw new ValidationError('CHANNEL_SIGNER_REQUIRED')
|
|
770
812
|
const mode = channel.mode || defaults.mode || 'leecher'
|
|
771
813
|
if (!signer && doesModeStoreRecoverySeeds(mode)) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
|
|
772
|
-
const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || await readerSigner?.getPublicKey?.() || pubkey
|
|
814
|
+
const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || (readerSigner === signer ? pubkey : await readerSigner?.getPublicKey?.()) || pubkey
|
|
773
815
|
const autoDeletionCapability = channel.autoDeletionCapability === undefined
|
|
774
816
|
? undefined
|
|
775
817
|
: normalizeAutoDeletionCapability(channel.autoDeletionCapability)
|
|
@@ -801,8 +843,8 @@ export class PrivateMessenger {
|
|
|
801
843
|
return this._pickRelaysForPubkeys(pubkeys, relaysByPubkey, { relayType: 'read' })
|
|
802
844
|
}
|
|
803
845
|
|
|
804
|
-
async readRelaysForPubkey (pubkey) {
|
|
805
|
-
const relaysByPubkey = await this._getRelaysByPubkey([pubkey])
|
|
846
|
+
async readRelaysForPubkey (pubkey, options) {
|
|
847
|
+
const relaysByPubkey = await this._getRelaysByPubkey([pubkey], options)
|
|
806
848
|
const readRelays = uniq(relaysByPubkey?.[pubkey]?.read)
|
|
807
849
|
if (readRelays.length) return readRelays
|
|
808
850
|
return relayMapRelays(this._pickRelaysForPubkeys([pubkey], relaysByPubkey, { relayType: 'read' }))
|
|
@@ -820,8 +862,8 @@ export class PrivateMessenger {
|
|
|
820
862
|
}
|
|
821
863
|
}
|
|
822
864
|
|
|
823
|
-
async resolveWatchRelays (channel) {
|
|
824
|
-
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)
|
|
825
867
|
return uniq([...primary, ...this.fallbackRelays].map(normalizeRelayUrl))
|
|
826
868
|
}
|
|
827
869
|
|
|
@@ -841,6 +883,8 @@ export class PrivateMessenger {
|
|
|
841
883
|
peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
|
|
842
884
|
signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
|
|
843
885
|
exclusions: this.sendRelayExclusions.get(key).exclusions,
|
|
886
|
+
fallbackDelayMs: this.fallbackDelayMs, pauseSignal: this.sendRoutingPause.signal,
|
|
887
|
+
setTimer: this._setTimeout, clearTimer: this._clearTimeout,
|
|
844
888
|
pickRelays: this._pickRelaysForPubkeys,
|
|
845
889
|
publish: this._privateChannel.publish || privateChannel.publish,
|
|
846
890
|
publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
|
|
@@ -1119,7 +1163,7 @@ export class PrivateMessenger {
|
|
|
1119
1163
|
this.writeState(state)
|
|
1120
1164
|
}
|
|
1121
1165
|
|
|
1122
|
-
async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true } = {}) {
|
|
1166
|
+
async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true, signal } = {}) {
|
|
1123
1167
|
this.assertOpen()
|
|
1124
1168
|
const channelPubkeys = uniq(channels)
|
|
1125
1169
|
for (const pubkey of channelPubkeys) {
|
|
@@ -1143,13 +1187,15 @@ export class PrivateMessenger {
|
|
|
1143
1187
|
await this.flushStateWrites()
|
|
1144
1188
|
let stop; let watchRelays
|
|
1145
1189
|
try {
|
|
1146
|
-
|
|
1190
|
+
signal?.throwIfAborted()
|
|
1191
|
+
watchRelays = await this.resolveWatchRelays(channel, { signal })
|
|
1147
1192
|
this.assertOpen()
|
|
1148
1193
|
if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) continue
|
|
1149
1194
|
stop = await this._privateMessage.watch({
|
|
1150
1195
|
channels: [pubkey],
|
|
1151
1196
|
relays: watchRelays,
|
|
1152
1197
|
receiverSigner: this.userSigner,
|
|
1198
|
+
receiverPubkey: this.userPubkey,
|
|
1153
1199
|
iykcSigner: this.contentKeySigner,
|
|
1154
1200
|
privateChannelSigner: channel.signer,
|
|
1155
1201
|
privateChannelReaderSigner: channel.readerSigner,
|
|
@@ -1169,6 +1215,7 @@ export class PrivateMessenger {
|
|
|
1169
1215
|
onError: err => this.onError?.(err)
|
|
1170
1216
|
})
|
|
1171
1217
|
} catch (error) {
|
|
1218
|
+
if (signal?.aborted) continue
|
|
1172
1219
|
const transient = isRetryableRelayFailure(error)
|
|
1173
1220
|
const remote = error?.name !== 'Nip42AuthenticationError' && (error?.category === 'relay' || isReplaceableRelayFailure(error))
|
|
1174
1221
|
if (!transient && !remote) throw error
|
|
@@ -1650,6 +1697,8 @@ export class PrivateMessenger {
|
|
|
1650
1697
|
stopWatches (channels = [...this.stopByChannel.keys()]) {
|
|
1651
1698
|
const closing = []
|
|
1652
1699
|
for (const pubkey of channels) {
|
|
1700
|
+
this.channelBackground.get(pubkey)?.controller.abort()
|
|
1701
|
+
this.channelBackground.delete(pubkey)
|
|
1653
1702
|
this.cancelReloadGap(pubkey)
|
|
1654
1703
|
this.cancelLiveRecovery(pubkey)
|
|
1655
1704
|
this.liveInterruptions.delete(pubkey)
|
|
@@ -1780,6 +1829,8 @@ export class PrivateMessenger {
|
|
|
1780
1829
|
if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
|
|
1781
1830
|
this.assertOpen()
|
|
1782
1831
|
this.pauseReasons.add(reason)
|
|
1832
|
+
this.pauseRevisions.set(reason, (this.pauseRevisions.get(reason) || 0) + 1)
|
|
1833
|
+
this.sendRoutingPause.abort(this.pausedError())
|
|
1783
1834
|
this.notifyStatus()
|
|
1784
1835
|
for (const extension of this.extensions) extension.pause?.()
|
|
1785
1836
|
this.recordInterruption(this.desiredChannels)
|
|
@@ -1787,45 +1838,94 @@ export class PrivateMessenger {
|
|
|
1787
1838
|
return Promise.all([this.stopWatches([...this.desiredChannels]), this.flushStateWrites()])
|
|
1788
1839
|
}
|
|
1789
1840
|
|
|
1790
|
-
async resume (reason) {
|
|
1841
|
+
async resume (reason, { waitForBackground = false } = {}) {
|
|
1791
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')
|
|
1792
1844
|
this.assertOpen()
|
|
1845
|
+
const revision = this.pauseRevisions.get(reason)
|
|
1793
1846
|
// Repair durable state before announcing that publication can resume.
|
|
1794
1847
|
if (reason === 'storage' && this.pendingStorageWrites.size) await this.retryStorageWrites(this.sendRoutingLifetime.signal)
|
|
1795
1848
|
await this.flushStateWrites()
|
|
1796
1849
|
this.assertOpen()
|
|
1797
|
-
|
|
1850
|
+
if (revision !== this.pauseRevisions.get(reason)) return
|
|
1798
1851
|
const removed = this.pauseReasons.delete(reason)
|
|
1799
1852
|
this.automaticPauses.delete(reason)
|
|
1853
|
+
if (!this.pauseReasons.size && this.sendRoutingPause.signal.aborted) this.sendRoutingPause = new AbortController()
|
|
1800
1854
|
this.notifyStatus()
|
|
1801
1855
|
if (reason === 'capacity' && removed) {
|
|
1802
1856
|
clearInterval(this.capacityTimer)
|
|
1803
1857
|
this.capacityTimer = null
|
|
1804
1858
|
this.capacityRequiredBytes = 0
|
|
1805
1859
|
}
|
|
1806
|
-
if (
|
|
1807
|
-
|
|
1808
|
-
if (
|
|
1809
|
-
const work = (async () => {
|
|
1810
|
-
const channels = [...this.desiredChannels]
|
|
1860
|
+
if (this.pauseReasons.size) return
|
|
1861
|
+
const channels = [...this.desiredChannels]
|
|
1862
|
+
if (removed) {
|
|
1811
1863
|
this.closeOpenOfflineRanges(channels)
|
|
1812
|
-
await this.
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
this.resumeWork = work
|
|
1822
|
-
try { await work } catch (err) {
|
|
1823
|
-
if (!this.closePromise) {
|
|
1824
|
-
if (wasAutomatic && (reason === 'network' ? isRetryableRelayFailure(err) : isRecoverableStorageFailure(err))) this.automaticPauses.add(reason)
|
|
1825
|
-
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)
|
|
1826
1873
|
}
|
|
1827
|
-
|
|
1828
|
-
|
|
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
|
|
1829
1929
|
}
|
|
1830
1930
|
|
|
1831
1931
|
receive (pubkey, operation, message = {}) {
|
|
@@ -2329,16 +2429,16 @@ export class PrivateMessenger {
|
|
|
2329
2429
|
})
|
|
2330
2430
|
}
|
|
2331
2431
|
|
|
2332
|
-
async publishSeederPresence (channelPubkey = this.defaultChannelPubkey()) {
|
|
2432
|
+
async publishSeederPresence (channelPubkey = this.defaultChannelPubkey(), { signal } = {}) {
|
|
2333
2433
|
const channel = this.requireWritableChannel(channelPubkey)
|
|
2334
2434
|
if (!this.offlineRecoverySecondsFor(channel)) return null
|
|
2335
2435
|
const receiverPubkeys = uniq([...this.knownSeeders(channelPubkey), this.userPubkey])
|
|
2336
|
-
const routing = await this.resolveSendRouting({ channel, receiverPubkeys })
|
|
2436
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, signal })
|
|
2337
2437
|
this.debugSend('yell', channelPubkey, { code: SEEDER_PRESENCE_CODE, receiverPubkeys })
|
|
2338
2438
|
return this._privateMessage.yell({
|
|
2339
|
-
senderSigner: this.userSigner,
|
|
2340
|
-
imkcSigner: this.contentKeySigner,
|
|
2341
|
-
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),
|
|
2342
2442
|
privateChannelReaderPubkey: channel.readerPubkey,
|
|
2343
2443
|
receiverPubkeys,
|
|
2344
2444
|
...routing,
|
|
@@ -2355,22 +2455,40 @@ export class PrivateMessenger {
|
|
|
2355
2455
|
async startPresencePublisher (channelPubkey) {
|
|
2356
2456
|
if (this.pauseReasons.size || !this.desiredChannels.has(channelPubkey) || !this.offlineRecoverySecondsFor(channelPubkey)) return
|
|
2357
2457
|
if (this.presenceTimers.has(channelPubkey)) return
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
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
|
+
}
|
|
2362
2476
|
}
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
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
|
|
2371
2487
|
}
|
|
2372
2488
|
|
|
2373
2489
|
stopPresencePublisher (channelPubkey) {
|
|
2490
|
+
this.presenceJobs.get(channelPubkey)?.controller.abort()
|
|
2491
|
+
this.presenceJobs.delete(channelPubkey)
|
|
2374
2492
|
const timer = this.presenceTimers.get(channelPubkey)
|
|
2375
2493
|
if (timer) this._clearInterval(timer)
|
|
2376
2494
|
this.presenceTimers.delete(channelPubkey)
|
|
@@ -2451,7 +2569,7 @@ export class PrivateMessenger {
|
|
|
2451
2569
|
return seconds ? seconds * 1000 : DEFAULT_RECEIVED_CHUNK_TTL_MS
|
|
2452
2570
|
}
|
|
2453
2571
|
|
|
2454
|
-
async applyRecoveryPolicies (channels) {
|
|
2572
|
+
async applyRecoveryPolicies (channels, { prune = true } = {}) {
|
|
2455
2573
|
return this.runQueueOperation(async () => {
|
|
2456
2574
|
const state = this.readState()
|
|
2457
2575
|
const now = nowSeconds()
|
|
@@ -2477,7 +2595,7 @@ export class PrivateMessenger {
|
|
|
2477
2595
|
for (const channel of channels) {
|
|
2478
2596
|
if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryState(channel.pubkey)
|
|
2479
2597
|
}
|
|
2480
|
-
for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
|
|
2598
|
+
if (prune) for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
|
|
2481
2599
|
})
|
|
2482
2600
|
}
|
|
2483
2601
|
|
|
@@ -3063,6 +3181,7 @@ export class PrivateMessenger {
|
|
|
3063
3181
|
let unwatchError
|
|
3064
3182
|
try { await unwatchPromise } catch (err) { unwatchError = err }
|
|
3065
3183
|
await initSettledPromise
|
|
3184
|
+
await this.updateTail.catch(() => {})
|
|
3066
3185
|
await Promise.all([...this.extensions].map(extension => extension.close?.()))
|
|
3067
3186
|
await this.stampActiveChannelActivity()
|
|
3068
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:
|
|
@@ -110,7 +135,7 @@ const session = createPrivateMessageSession({
|
|
|
110
135
|
})
|
|
111
136
|
```
|
|
112
137
|
|
|
113
|
-
|
|
138
|
+
By default, automatic sends try each recipient's NIP-65 read relays first, at most two per
|
|
114
139
|
recipient per attempt, then the configured fallbacks in pairs. Explicit
|
|
115
140
|
per-call `relays`, channel `sendRelays`/`relays` and global `relays` remain primary
|
|
116
141
|
and also support configured fallbacks, including fixed multi-recipient sends.
|
|
@@ -122,6 +147,19 @@ and encrypted recipient subsets, without consulting recipient NIP-65 lists.
|
|
|
122
147
|
The session coordinator remains a DM API; multi-recipient channels use
|
|
123
148
|
`createPrivateMessenger` directly.
|
|
124
149
|
|
|
150
|
+
Optional `fallbackDelayMs` defaults to `null` (sequential primary exhaustion).
|
|
151
|
+
A nonnegative integer up to 2147483647 opts automatic NIP-65 routing into early
|
|
152
|
+
fallback; explicit relay lists/maps retain the sequential policy. With `3000`,
|
|
153
|
+
start at most two eligible fallback relays after three seconds without complete
|
|
154
|
+
acceptance, or immediately after primary exhaustion. The deadline belongs to
|
|
155
|
+
one signed outer event; later batches do not renew it. Primary operations retain
|
|
156
|
+
their deadlines and can still win. Failed fallback operations leave primary
|
|
157
|
+
alternatives available; no relay is tried twice. Multiple recipients require
|
|
158
|
+
accepted coverage of every encrypted recipient subset. No wrapper, ID, recipient
|
|
159
|
+
tag or deletion capability is recreated. Confirmed connectivity, exclusions and
|
|
160
|
+
connection cooldowns remain authoritative; a waiting primary is not penalized
|
|
161
|
+
merely because fallback started. Late native failures may inform later sends.
|
|
162
|
+
|
|
125
163
|
On `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or `error`
|
|
126
164
|
rejections, or connection/transport/timeout failures, replacement requires
|
|
127
165
|
`isOnline` to confirm connectivity. Invalid messages, local signer/authentication
|
|
@@ -141,7 +179,8 @@ publication report. A shared relay's first ACK covers all members assigned to
|
|
|
141
179
|
that publication batch. If replacements use different relays for different
|
|
142
180
|
members, every pending member must be covered before the outer event succeeds;
|
|
143
181
|
a partial ACK cannot hide another member's failure. Remaining primary routes are
|
|
144
|
-
exhausted before configured fallbacks
|
|
182
|
+
exhausted before configured fallbacks with the default null delay or explicit
|
|
183
|
+
routes. Redundant acknowledgements may update
|
|
145
184
|
future routing preferences without delaying a successfully covered send. Preferences are channel/recipient-scoped, in-memory,
|
|
146
185
|
expire after five minutes, and never alter receive subscriptions. A fresh attempt
|
|
147
186
|
can recheck an exhausted list. Additional fallback relays come only from the
|
|
@@ -159,3 +198,12 @@ relays. Validation/authorization failures are terminal without relay rotation.
|
|
|
159
198
|
`broadcastRumor`/`broadcastEvent` accept a `signal` to stop further replacement
|
|
160
199
|
publications; the session supplies it and aborts it on cancel/close. An already
|
|
161
200
|
published event cannot be recalled by cancellation.
|
|
201
|
+
|
|
202
|
+
`RelayPool.sendEvent(event, relays, { signal })` supports optional cancellation.
|
|
203
|
+
It returns the existing result/report envelopes with the original abort reason
|
|
204
|
+
for unfinished relays, preserving partial acceptance and avoiding synthetic
|
|
205
|
+
timeouts. Cancellation releases publication waiters and deferred sends without
|
|
206
|
+
closing shared sockets or cancelling another subscriber for the same event ID.
|
|
207
|
+
Operations already transmitted cannot be recalled from relays. A messenger pause
|
|
208
|
+
cancels its outstanding publication work and leaves the outbox pending; resume
|
|
209
|
+
creates a fresh publication lifetime, without treating the pause as a user abort.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Local outbox persistence and user publications take precedence over the next
|
|
2
|
+
// optional presence signer call. Already-started signer calls are not recalled.
|
|
3
|
+
export function createForegroundWork () {
|
|
4
|
+
let active = 0
|
|
5
|
+
const waiters = new Set()
|
|
6
|
+
const lifetime = new AbortController()
|
|
7
|
+
return {
|
|
8
|
+
enter () {
|
|
9
|
+
active++
|
|
10
|
+
let released = false
|
|
11
|
+
return () => {
|
|
12
|
+
if (released) return
|
|
13
|
+
released = true
|
|
14
|
+
if (--active === 0) for (const wake of [...waiters]) wake()
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
wait (signal) {
|
|
18
|
+
const combined = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal
|
|
19
|
+
combined.throwIfAborted()
|
|
20
|
+
if (!active) return Promise.resolve()
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const finish = () => { waiters.delete(finish); combined.removeEventListener('abort', finish); combined.aborted ? reject(combined.reason) : resolve() }
|
|
23
|
+
waiters.add(finish)
|
|
24
|
+
combined.addEventListener('abort', finish, { once: true })
|
|
25
|
+
})
|
|
26
|
+
},
|
|
27
|
+
close () { lifetime.abort(); waiters.clear() }
|
|
28
|
+
}
|
|
29
|
+
}
|