libp2r2p 0.6.0 → 0.7.0

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.
@@ -3,7 +3,7 @@
3
3
  // userSigner,
4
4
  // contentKeySigner, // optional when userSigner handles content keys internally
5
5
  // nymSigner: optionalDefaultNymSigner,
6
- // channels: [{ signer: privateChannelSigner, relays, mode: 'leecher', seeders: optionalSeederPubkeys }],
6
+ // channels: [{ signer: privateChannelSigner, relays, mode: 'leecher', seeders: optionalSeederPubkeys, offlineRecoverySeconds: optionalChannelOverride }],
7
7
  // onContentKeyChange: event => reviewContentKeyUse(event),
8
8
  // onError: err => reportPrivateMessengerError(err)
9
9
  // })
@@ -29,7 +29,8 @@
29
29
  // - Each watched channel stores lastSeenAt/lastWatchedAt in IndexedDB.
30
30
  // - Re-watching after reload fetches the gap from lastSeenAt to now.
31
31
  // - Browser offline/online events add explicit offline ranges with a small skew.
32
- // - Ranges older than 7 days are ignored; channel state not watched for 45 days is pruned.
32
+ // - Recovery defaults to 7 days and can be overridden per channel; zero disables durable recovery.
33
+ // - Channel state not watched for 45 days is pruned.
33
34
  // - Seeders announce presence every 10min and are used for the relay-uncovered left edge of a missed range.
34
35
  // - Configured seeders are all asked; auto-discovered seeders are capped to the 8 most recently active.
35
36
  // - Seeder/watchtower channels store reconstructed router events in a separate IndexedDB queue and auto-reply to recovery asks.
@@ -40,10 +41,17 @@ import * as privateMessage from '../private-message/index.js'
40
41
  import { bytesToBase64 } from '../base64/index.js'
41
42
  import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates } from '../relay/index.js'
42
43
  import * as privateChannel from '../private-channel/index.js'
43
- import { cleanupTemporaryStorage as cleanupTemporaryChannelStorage } from '../temporary-storage/index.js'
44
+ import { DEFAULT_RECEIVED_CHUNK_TTL_MS } from '../private-channel/services/received-chunks.js'
44
45
  import { createQueue } from '../idb-queue/index.js'
45
46
  import { createChannelStateStore } from './services/channel-state.js'
46
47
  import { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
48
+ import {
49
+ activatePrivateMessengerStorage,
50
+ maintainPrivateMessengerStorage,
51
+ PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS,
52
+ PRIVATE_MESSENGER_STORAGE_MAINTENANCE_MS,
53
+ releasePrivateMessengerStorage
54
+ } from './services/storage-maintenance.js'
47
55
  import {
48
56
  compactSeedNymCarriers,
49
57
  compactSeedRouterRows,
@@ -72,6 +80,7 @@ export {
72
80
  } from './recovery/index.js'
73
81
 
74
82
  const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
83
+ const MAX_OFFLINE_RECOVERY_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
75
84
  const DEFAULT_OFFLINE_SKEW_SECONDS = 30
76
85
  const DEFAULT_RELOAD_GAP_DELAY_MS = 500
77
86
  const DEFAULT_SEEDER_PRESENCE_INTERVAL_MS = 10 * 60 * 1000
@@ -109,6 +118,13 @@ function nowSeconds () {
109
118
  return Math.floor(Date.now() / 1000)
110
119
  }
111
120
 
121
+ function normalizeOfflineRecoverySeconds (value) {
122
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_OFFLINE_RECOVERY_SECONDS) {
123
+ throw new Error('INVALID_OFFLINE_RECOVERY_SECONDS')
124
+ }
125
+ return value
126
+ }
127
+
112
128
  function uniq (values) {
113
129
  return [...new Set((values || []).filter(Boolean))]
114
130
  }
@@ -130,9 +146,19 @@ function storesRecoverySeeds (mode) {
130
146
  return mode === 'seeder' || mode === 'watchtower'
131
147
  }
132
148
 
149
+ function randomStorageLeaseId () {
150
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
151
+ const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16))
152
+ if (bytes) return [...bytes].map(value => value.toString(16).padStart(2, '0')).join('')
153
+ return `${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`
154
+ }
155
+
133
156
  export class PrivateMessenger {
134
- static cleanupTemporaryStorage ({ storageArea = globalThis.sessionStorage } = {}) {
135
- cleanupTemporaryChannelStorage({ storageArea })
157
+ static maintainStorage ({
158
+ indexedDB = globalThis.indexedDB,
159
+ temporaryStorageArea = globalThis.sessionStorage
160
+ } = {}) {
161
+ return maintainPrivateMessengerStorage({ indexedDB, temporaryStorageArea })
136
162
  }
137
163
 
138
164
  constructor ({
@@ -160,9 +186,11 @@ export class PrivateMessenger {
160
186
  _subscribeRelayListUpdates = subscribeRelayListUpdates,
161
187
  _setTimeout = globalThis.setTimeout.bind(globalThis),
162
188
  _setInterval = globalThis.setInterval.bind(globalThis),
163
- _clearInterval = globalThis.clearInterval.bind(globalThis)
189
+ _clearInterval = globalThis.clearInterval.bind(globalThis),
190
+ _storageSetInterval = globalThis.setInterval.bind(globalThis),
191
+ _storageClearInterval = globalThis.clearInterval.bind(globalThis)
164
192
  } = {}) {
165
- this.offlineRecoverySeconds = offlineRecoverySeconds
193
+ this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
166
194
  this.staleChannelSeconds = staleChannelSeconds
167
195
  this.offlineSkewSeconds = offlineSkewSeconds
168
196
  this.reloadGapDelayMs = reloadGapDelayMs
@@ -187,6 +215,8 @@ export class PrivateMessenger {
187
215
  this._setTimeout = _setTimeout
188
216
  this._setInterval = _setInterval
189
217
  this._clearInterval = _clearInterval
218
+ this._storageSetInterval = _storageSetInterval
219
+ this._storageClearInterval = _storageClearInterval
190
220
 
191
221
  this.userSigner = null
192
222
  this.contentKeySigner = null
@@ -208,42 +238,170 @@ export class PrivateMessenger {
208
238
  this.stopOnline = null
209
239
  this.stopOffline = null
210
240
  this.queueOperationTail = Promise.resolve()
241
+ this.storageActive = false
242
+ this.storageLeaseId = randomStorageLeaseId()
243
+ this.lastStorageTouch = 0
244
+ this.storageTouchPromise = null
245
+ this.storageHeartbeatTimer = null
246
+ this.storageMaintenanceTimer = null
247
+ this.storageMaintenancePromise = null
248
+ this.closePromise = null
249
+ this.initSettledPromise = null
250
+ this.initialized = false
211
251
  }
212
252
 
213
253
  async init ({ userSigner, contentKeySigner, nymSigner, channels = [], relays = [], mode = 'leecher' }) {
214
254
  if (!userSigner?.getPublicKey) throw new Error('USER_SIGNER_REQUIRED')
215
- PrivateMessenger.cleanupTemporaryStorage({ storageArea: this.temporaryStorageArea })
216
- this.userSigner = userSigner
217
- this.contentKeySigner = contentKeySigner || null
218
- this.nymSigner = nymSigner || null
219
- this.userPubkey = await userSigner.getPublicKey()
220
- this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
221
- this.prefix = `libp2r2p:private-messenger:${this.userPubkey}`
222
- this.queue = await createQueue({
223
- prefix: this.prefix,
224
- indexes: MESSAGE_QUEUE_INDEXES,
225
- maxBytes: this.messageQueueMaxBytes,
226
- evictionPolicy: 'fifo',
227
- indexedDB: this._indexedDB
228
- })
229
- this.seedQueue = await createQueue({
230
- prefix: `${this.prefix}:seeds`,
231
- indexes: SEED_QUEUE_INDEXES,
232
- maxBytes: this.seedQueueMaxBytes,
233
- evictionPolicy: 'fifo',
234
- indexedDB: this._indexedDB
255
+ this.assertOpen()
256
+ if (this.initSettledPromise) throw new Error('PRIVATE_MESSENGER_INIT_IN_PROGRESS')
257
+ if (this.initialized) throw new Error('PRIVATE_MESSENGER_ALREADY_INITIALIZED')
258
+ let settleInit
259
+ const initSettledPromise = new Promise(resolve => { settleInit = resolve })
260
+ this.initSettledPromise = initSettledPromise
261
+ try {
262
+ this.userSigner = userSigner
263
+ this.contentKeySigner = contentKeySigner || null
264
+ this.nymSigner = nymSigner || null
265
+ this.userPubkey = await userSigner.getPublicKey()
266
+ this.prefix = `libp2r2p:private-messenger:${this.userPubkey}`
267
+ await activatePrivateMessengerStorage({
268
+ userPubkey: this.userPubkey,
269
+ leaseId: this.storageLeaseId,
270
+ indexedDB: this._indexedDB
271
+ })
272
+ this.storageActive = true
273
+ this.lastStorageTouch = Date.now()
274
+ this.assertOpen()
275
+ await PrivateMessenger.maintainStorage({
276
+ indexedDB: this._indexedDB,
277
+ temporaryStorageArea: this.temporaryStorageArea
278
+ })
279
+ this.assertOpen()
280
+ this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
281
+ this.assertOpen()
282
+ this.queue = await createQueue({
283
+ prefix: this.prefix,
284
+ indexes: MESSAGE_QUEUE_INDEXES,
285
+ maxBytes: this.messageQueueMaxBytes,
286
+ evictionPolicy: 'fifo',
287
+ indexedDB: this._indexedDB
288
+ })
289
+ this.assertOpen()
290
+ this.seedQueue = await createQueue({
291
+ prefix: `${this.prefix}:seeds`,
292
+ indexes: SEED_QUEUE_INDEXES,
293
+ maxBytes: this.seedQueueMaxBytes,
294
+ evictionPolicy: 'fifo',
295
+ indexedDB: this._indexedDB
296
+ })
297
+ this.assertOpen()
298
+ this.stateStore = await createChannelStateStore({
299
+ prefix: this.prefix,
300
+ indexedDB: this._indexedDB
301
+ })
302
+ this.assertOpen()
303
+ this.startStorageMaintenance()
304
+ this.state = { channels: await this.stateStore.load() }
305
+ await this.cleanupStaleChannels()
306
+ await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
307
+ await this.pruneStoredSeeds()
308
+ this.initialized = true
309
+ return this
310
+ } catch (err) {
311
+ this.stopStorageMaintenance()
312
+ try { await this.queueOperationTail } catch {}
313
+ try { await this.stateWriteTail } catch {}
314
+ await Promise.allSettled([
315
+ this.queue?.close?.(),
316
+ this.seedQueue?.close?.(),
317
+ this.stateStore?.close?.()
318
+ ])
319
+ if (this.storageActive) {
320
+ try {
321
+ await releasePrivateMessengerStorage({
322
+ userPubkey: this.userPubkey,
323
+ leaseId: this.storageLeaseId,
324
+ indexedDB: this._indexedDB
325
+ })
326
+ } catch {}
327
+ }
328
+ this.storageActive = false
329
+ this.initialized = false
330
+ throw err
331
+ } finally {
332
+ settleInit()
333
+ if (this.initSettledPromise === initSettledPromise) this.initSettledPromise = null
334
+ }
335
+ }
336
+
337
+ assertOpen () {
338
+ if (this.closePromise) throw new Error('PRIVATE_MESSENGER_CLOSED')
339
+ }
340
+
341
+ startStorageMaintenance () {
342
+ if (this.storageHeartbeatTimer || this.storageMaintenanceTimer) return
343
+ this.storageHeartbeatTimer = this._storageSetInterval(() => (
344
+ this.touchStorageActivity({ force: true }).catch(err => {
345
+ try { this.onError?.(err) } catch {}
346
+ })
347
+ ), PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS)
348
+ this.storageMaintenanceTimer = this._storageSetInterval(
349
+ () => this.runStorageMaintenance(),
350
+ PRIVATE_MESSENGER_STORAGE_MAINTENANCE_MS
351
+ )
352
+ this.storageHeartbeatTimer?.unref?.()
353
+ this.storageMaintenanceTimer?.unref?.()
354
+ }
355
+
356
+ stopStorageMaintenance () {
357
+ if (this.storageHeartbeatTimer) this._storageClearInterval(this.storageHeartbeatTimer)
358
+ if (this.storageMaintenanceTimer) this._storageClearInterval(this.storageMaintenanceTimer)
359
+ this.storageHeartbeatTimer = null
360
+ this.storageMaintenanceTimer = null
361
+ }
362
+
363
+ runStorageMaintenance () {
364
+ if (this.storageMaintenancePromise) return this.storageMaintenancePromise
365
+ const maintenance = Promise.all([
366
+ PrivateMessenger.maintainStorage({
367
+ indexedDB: this._indexedDB,
368
+ temporaryStorageArea: this.temporaryStorageArea
369
+ }),
370
+ this.pruneStoredSeeds()
371
+ ]).catch(err => {
372
+ try { this.onError?.(err) } catch {}
373
+ }).finally(() => {
374
+ if (this.storageMaintenancePromise === maintenance) this.storageMaintenancePromise = null
235
375
  })
236
- this.stateStore = await createChannelStateStore({
237
- prefix: this.prefix,
238
- indexedDB: this._indexedDB
376
+ this.storageMaintenancePromise = maintenance
377
+ return maintenance
378
+ }
379
+
380
+ async touchStorageActivity ({ force = false } = {}) {
381
+ if (!this.storageActive || this.closePromise) return false
382
+ if (this.storageTouchPromise) return this.storageTouchPromise
383
+ const now = Date.now()
384
+ if (!force && now - this.lastStorageTouch < PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS) return false
385
+ const previousTouch = this.lastStorageTouch
386
+ this.lastStorageTouch = now
387
+ const touch = activatePrivateMessengerStorage({
388
+ userPubkey: this.userPubkey,
389
+ leaseId: this.storageLeaseId,
390
+ indexedDB: this._indexedDB,
391
+ now
392
+ }).then(() => true, err => {
393
+ this.lastStorageTouch = previousTouch
394
+ throw err
395
+ }).finally(() => {
396
+ if (this.storageTouchPromise === touch) this.storageTouchPromise = null
239
397
  })
240
- this.state = { channels: await this.stateStore.load() }
241
- await this.cleanupStaleChannels()
242
- await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
243
- return this
398
+ this.storageTouchPromise = touch
399
+ return touch
244
400
  }
245
401
 
246
402
  runQueueOperation (operation) {
403
+ if (this.closePromise) return Promise.reject(new Error('PRIVATE_MESSENGER_CLOSED'))
404
+ this.touchStorageActivity().catch(err => this.onError?.(err))
247
405
  const run = this.queueOperationTail.then(operation)
248
406
  this.queueOperationTail = run.catch(err => {
249
407
  try { this.onError?.(err) } catch {}
@@ -278,6 +436,7 @@ export class PrivateMessenger {
278
436
  }
279
437
 
280
438
  async update ({ userSigner = this.userSigner, contentKeySigner = this.contentKeySigner, nymSigner = this.nymSigner, channels = [...this.channels.values()], relays = [], mode = 'leecher' } = {}) {
439
+ this.assertOpen()
281
440
  if (userSigner) {
282
441
  const userPubkey = await userSigner.getPublicKey?.()
283
442
  if (!userPubkey) throw new Error('USER_SIGNER_REQUIRED')
@@ -288,17 +447,19 @@ export class PrivateMessenger {
288
447
  this.nymSigner = nymSigner || null
289
448
  this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
290
449
  const nextChannels = await this.normalizeChannels(channels, { relays, mode })
450
+ this.assertOpen()
291
451
  const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
292
452
 
293
453
  for (const pubkey of [...this.channels.keys()]) {
294
454
  if (!nextPubkeys.has(pubkey)) {
295
- this.unwatch(pubkey)
455
+ await this.unwatch(pubkey)
296
456
  this.channels.delete(pubkey)
297
457
  }
298
458
  }
299
459
  for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
300
460
 
301
461
  await this.cleanupStaleChannels()
462
+ await this.applyRecoveryPolicies(nextChannels)
302
463
  await this.watch()
303
464
  await this.reconcilePresencePublishers()
304
465
  return this
@@ -323,6 +484,9 @@ export class PrivateMessenger {
323
484
  const autoDeletionCapability = channel.autoDeletionCapability === undefined
324
485
  ? undefined
325
486
  : normalizeAutoDeletionCapability(channel.autoDeletionCapability)
487
+ const offlineRecoverySeconds = normalizeOfflineRecoverySeconds(
488
+ channel.offlineRecoverySeconds ?? this.offlineRecoverySeconds
489
+ )
326
490
  out.push({
327
491
  pubkey,
328
492
  signer,
@@ -334,6 +498,7 @@ export class PrivateMessenger {
334
498
  usesNip65WatchRelays: !hasChannelRelays && !hasDefaultRelays,
335
499
  mode,
336
500
  seeders: uniq(channel.seeders),
501
+ offlineRecoverySeconds,
337
502
  autoDeletionCapability
338
503
  })
339
504
  }
@@ -355,6 +520,7 @@ export class PrivateMessenger {
355
520
  }
356
521
 
357
522
  async recoveryMirrorRelays (channelPubkey) {
523
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return []
358
524
  const seeders = this.recoverySeeders(channelPubkey)
359
525
  if (!seeders.length) return []
360
526
  try {
@@ -386,6 +552,7 @@ export class PrivateMessenger {
386
552
  }
387
553
 
388
554
  writeState (state) {
555
+ this.touchStorageActivity().catch(err => this.onError?.(err))
389
556
  const next = {
390
557
  channels: isPlainObject(state?.channels) ? structuredClone(state.channels) : {}
391
558
  }
@@ -432,6 +599,7 @@ export class PrivateMessenger {
432
599
  }
433
600
 
434
601
  recoverySeeders (pubkey) {
602
+ if (!this.offlineRecoverySecondsFor(pubkey)) return []
435
603
  const channel = this.channels.get(pubkey)
436
604
  const configuredSeeders = channel?.seeders || []
437
605
  if (configuredSeeders.length) return configuredSeeders.filter(seeder => seeder !== this.userPubkey)
@@ -446,6 +614,7 @@ export class PrivateMessenger {
446
614
  }
447
615
 
448
616
  markSeederActive (channelPubkey, seederPubkey, { announced = false, at = nowSeconds() } = {}) {
617
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return false
449
618
  const state = this.readState()
450
619
  const current = state.channels[channelPubkey] || {}
451
620
  const activity = current.seederActivity || {}
@@ -459,6 +628,7 @@ export class PrivateMessenger {
459
628
  current.seederActivity = activity
460
629
  state.channels[channelPubkey] = current
461
630
  this.writeState(state)
631
+ return true
462
632
  }
463
633
 
464
634
  trackSeederActivity (channelPubkey, message) {
@@ -544,8 +714,10 @@ export class PrivateMessenger {
544
714
  }
545
715
 
546
716
  addOfflineRange (pubkey, start, end) {
717
+ const recoverySeconds = this.offlineRecoverySecondsFor(pubkey)
718
+ if (!recoverySeconds) return
547
719
  const now = nowSeconds()
548
- const minStart = now - this.offlineRecoverySeconds
720
+ const minStart = now - recoverySeconds
549
721
  const normalized = {
550
722
  start: Math.max(0, Math.floor(start)),
551
723
  end: Math.floor(end)
@@ -567,10 +739,17 @@ export class PrivateMessenger {
567
739
  closeOpenOfflineRanges () {
568
740
  const state = this.readState()
569
741
  const end = nowSeconds()
570
- const minStart = end - this.offlineRecoverySeconds
571
742
  for (const pubkey of Object.keys(state.channels)) {
572
743
  const current = state.channels[pubkey]
573
744
  if (!current.openOfflineStart) continue
745
+ const recoverySeconds = this.offlineRecoverySecondsFor(pubkey)
746
+ if (!recoverySeconds) {
747
+ delete current.openOfflineStart
748
+ current.offlineRanges = []
749
+ state.channels[pubkey] = current
750
+ continue
751
+ }
752
+ const minStart = end - recoverySeconds
574
753
  const start = Math.max(minStart, Math.max(0, current.openOfflineStart))
575
754
  if (end > start) {
576
755
  current.offlineRanges = mergeRanges((current.offlineRanges || []).concat([{ start, end }]))
@@ -582,11 +761,13 @@ export class PrivateMessenger {
582
761
  }
583
762
 
584
763
  async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true } = {}) {
764
+ this.assertOpen()
585
765
  const channelPubkeys = uniq(channels)
586
766
  for (const pubkey of channelPubkeys) {
587
767
  const channel = this.channels.get(pubkey)
588
768
  if (!channel) throw new Error('UNKNOWN_CHANNEL')
589
769
  const watchRelays = await this.resolveWatchRelays(channel)
770
+ this.assertOpen()
590
771
  const stop = await this._privateMessage.watch({
591
772
  channels: [pubkey],
592
773
  relays: watchRelays,
@@ -604,16 +785,21 @@ export class PrivateMessenger {
604
785
  onMessage: message => this.queueIncoming(() => this.handleMessage(pubkey, message)),
605
786
  onSeed: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
606
787
  onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
607
- receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
788
+ receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
608
789
  receivedChunkIndexedDB: this._indexedDB,
609
790
  onError: err => this.onError?.(err)
610
791
  })
792
+ if (this.closePromise) {
793
+ await stop?.()
794
+ this.assertOpen()
795
+ }
611
796
  this.stopByChannel.set(pubkey, stop)
612
797
  this.updateChannelState(pubkey, {
613
798
  lastWatchedAt: nowSeconds(),
614
799
  mode: channel.mode,
615
800
  relays: watchRelays,
616
- seeders: channel.seeders
801
+ seeders: channel.seeders,
802
+ offlineRecoverySeconds: channel.offlineRecoverySeconds
617
803
  })
618
804
  this.debug('watch', {
619
805
  channelPubkey: pubkey,
@@ -631,12 +817,15 @@ export class PrivateMessenger {
631
817
 
632
818
  unwatch (channels) {
633
819
  const channelPubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.stopByChannel.keys()]
820
+ const closing = []
634
821
  for (const pubkey of channelPubkeys) {
635
- this.stopByChannel.get(pubkey)?.()
822
+ const close = this.stopByChannel.get(pubkey)?.()
823
+ if (close && typeof close.then === 'function') closing.push(close)
636
824
  this.stopByChannel.delete(pubkey)
637
825
  this.stopPresencePublisher(pubkey)
638
826
  }
639
827
  this.ensureRelayListWatcher()
828
+ return Promise.allSettled(closing)
640
829
  }
641
830
 
642
831
  nip65WatchChannelPubkeys () {
@@ -762,6 +951,7 @@ export class PrivateMessenger {
762
951
  }
763
952
 
764
953
  async enqueueSeed (channelPubkey, seed) {
954
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return
765
955
  const receivedAt = nowSeconds()
766
956
  if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
767
957
  const carriers = compactSeedNymCarriers(seed.carriers)
@@ -818,6 +1008,7 @@ export class PrivateMessenger {
818
1008
 
819
1009
  async nextMessage () {
820
1010
  // seedQueue is retained replay material for recovery replies, not an app-message stream.
1011
+ this.touchStorageActivity().catch(err => this.onError?.(err))
821
1012
  await this.queueOperationTail
822
1013
  return withoutQueueMetadata(await this.queue.shift())
823
1014
  }
@@ -833,7 +1024,7 @@ export class PrivateMessenger {
833
1024
  privateChannelReaderPubkey: channel.readerPubkey,
834
1025
  receiverPubkey,
835
1026
  ...routing,
836
- expirationSeconds: this.offlineRecoverySeconds,
1027
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
837
1028
  temporaryStorageArea: this.temporaryStorageArea,
838
1029
  deletionPubkey,
839
1030
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -859,7 +1050,7 @@ export class PrivateMessenger {
859
1050
  question,
860
1051
  receiverPubkey,
861
1052
  ...routing,
862
- expirationSeconds: this.offlineRecoverySeconds,
1053
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
863
1054
  temporaryStorageArea: this.temporaryStorageArea,
864
1055
  deletionPubkey,
865
1056
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -883,7 +1074,7 @@ export class PrivateMessenger {
883
1074
  privateChannelReaderPubkey: channel.readerPubkey,
884
1075
  receiverPubkey,
885
1076
  ...routing,
886
- expirationSeconds: this.offlineRecoverySeconds,
1077
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
887
1078
  temporaryStorageArea: this.temporaryStorageArea,
888
1079
  deletionPubkey,
889
1080
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -907,7 +1098,7 @@ export class PrivateMessenger {
907
1098
  privateChannelReaderPubkey: channel.readerPubkey,
908
1099
  receiverPubkeys,
909
1100
  ...routing,
910
- expirationSeconds: this.offlineRecoverySeconds,
1101
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
911
1102
  temporaryStorageArea: this.temporaryStorageArea,
912
1103
  deletionPubkey,
913
1104
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -931,7 +1122,7 @@ export class PrivateMessenger {
931
1122
  privateChannelReaderPubkey: channel.readerPubkey,
932
1123
  receiverPubkeys,
933
1124
  ...routing,
934
- expirationSeconds: this.offlineRecoverySeconds,
1125
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
935
1126
  temporaryStorageArea: this.temporaryStorageArea,
936
1127
  deletionPubkey,
937
1128
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -951,7 +1142,7 @@ export class PrivateMessenger {
951
1142
  privateChannelReaderPubkey: channel.readerPubkey,
952
1143
  receiverPubkeys,
953
1144
  ...routing,
954
- expirationSeconds: this.offlineRecoverySeconds,
1145
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
955
1146
  temporaryStorageArea: this.temporaryStorageArea,
956
1147
  deletionPubkey,
957
1148
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -970,7 +1161,7 @@ export class PrivateMessenger {
970
1161
  privateChannelSigner: channel.signer,
971
1162
  privateChannelReaderPubkey: channel.readerPubkey,
972
1163
  ...routing,
973
- expirationSeconds: this.offlineRecoverySeconds,
1164
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
974
1165
  deletionPubkey,
975
1166
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
976
1167
  rumor
@@ -987,7 +1178,7 @@ export class PrivateMessenger {
987
1178
  privateChannelSigner: channel.signer,
988
1179
  privateChannelReaderPubkey: channel.readerPubkey,
989
1180
  ...routing,
990
- expirationSeconds: this.offlineRecoverySeconds,
1181
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
991
1182
  deletionPubkey,
992
1183
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
993
1184
  event
@@ -996,6 +1187,7 @@ export class PrivateMessenger {
996
1187
 
997
1188
  async publishSeederPresence (channelPubkey = this.defaultChannelPubkey()) {
998
1189
  const channel = this.requireWritableChannel(channelPubkey)
1190
+ if (!this.offlineRecoverySecondsFor(channel)) return null
999
1191
  const receiverPubkeys = uniq([...this.knownSeeders(channelPubkey), this.userPubkey])
1000
1192
  const routing = await this.resolveSendRouting({ channel, receiverPubkeys })
1001
1193
  this.debugSend('yell', channelPubkey, { code: SEEDER_PRESENCE_CODE, receiverPubkeys })
@@ -1006,7 +1198,7 @@ export class PrivateMessenger {
1006
1198
  privateChannelReaderPubkey: channel.readerPubkey,
1007
1199
  receiverPubkeys,
1008
1200
  ...routing,
1009
- expirationSeconds: this.offlineRecoverySeconds,
1201
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
1010
1202
  temporaryStorageArea: this.temporaryStorageArea,
1011
1203
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
1012
1204
  code: SEEDER_PRESENCE_CODE,
@@ -1016,6 +1208,7 @@ export class PrivateMessenger {
1016
1208
  }
1017
1209
 
1018
1210
  async startPresencePublisher (channelPubkey) {
1211
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return
1019
1212
  if (this.presenceTimers.has(channelPubkey)) return
1020
1213
  try {
1021
1214
  await this.publishSeederPresence(channelPubkey)
@@ -1040,10 +1233,10 @@ export class PrivateMessenger {
1040
1233
  async reconcilePresencePublishers () {
1041
1234
  const starts = []
1042
1235
  for (const pubkey of [...this.presenceTimers.keys()]) {
1043
- if (!storesRecoverySeeds(this.channels.get(pubkey)?.mode)) this.stopPresencePublisher(pubkey)
1236
+ if (!storesRecoverySeeds(this.channels.get(pubkey)?.mode) || !this.offlineRecoverySecondsFor(pubkey)) this.stopPresencePublisher(pubkey)
1044
1237
  }
1045
1238
  for (const [pubkey, channel] of this.channels) {
1046
- if (storesRecoverySeeds(channel.mode)) starts.push(this.startPresencePublisher(pubkey))
1239
+ if (storesRecoverySeeds(channel.mode) && this.offlineRecoverySecondsFor(channel)) starts.push(this.startPresencePublisher(pubkey))
1047
1240
  else this.stopPresencePublisher(pubkey)
1048
1241
  }
1049
1242
  await Promise.all(starts)
@@ -1077,6 +1270,53 @@ export class PrivateMessenger {
1077
1270
  return channel.autoDeletionCapability ?? this.autoDeletionCapability
1078
1271
  }
1079
1272
 
1273
+ offlineRecoverySecondsFor (channelOrPubkey) {
1274
+ const channel = typeof channelOrPubkey === 'string'
1275
+ ? this.channels.get(channelOrPubkey)
1276
+ : channelOrPubkey
1277
+ if (channel?.offlineRecoverySeconds !== undefined) return channel.offlineRecoverySeconds
1278
+ const pubkey = typeof channelOrPubkey === 'string' ? channelOrPubkey : channelOrPubkey?.pubkey
1279
+ const persisted = pubkey ? this.state.channels[pubkey]?.offlineRecoverySeconds : undefined
1280
+ return persisted === undefined
1281
+ ? this.offlineRecoverySeconds
1282
+ : normalizeOfflineRecoverySeconds(persisted)
1283
+ }
1284
+
1285
+ eventExpirationSecondsFor (channel) {
1286
+ return this.offlineRecoverySecondsFor(channel) || privateChannel.EXPIRATION_SECONDS
1287
+ }
1288
+
1289
+ receivedChunkTtlMsFor (channel) {
1290
+ const seconds = this.offlineRecoverySecondsFor(channel)
1291
+ return seconds ? seconds * 1000 : DEFAULT_RECEIVED_CHUNK_TTL_MS
1292
+ }
1293
+
1294
+ async applyRecoveryPolicies (channels) {
1295
+ return this.runQueueOperation(async () => {
1296
+ const state = this.readState()
1297
+ const now = nowSeconds()
1298
+ for (const channel of channels) {
1299
+ const current = state.channels[channel.pubkey] || {}
1300
+ const seconds = channel.offlineRecoverySeconds
1301
+ current.offlineRecoverySeconds = seconds
1302
+ if (!seconds) {
1303
+ delete current.openOfflineStart
1304
+ current.offlineRanges = []
1305
+ } else {
1306
+ const cutoff = now - seconds
1307
+ current.offlineRanges = mergeRanges((current.offlineRanges || [])
1308
+ .filter(range => range.end >= cutoff)
1309
+ .map(range => ({ ...range, start: Math.max(range.start, cutoff) })))
1310
+ if (current.openOfflineStart) current.openOfflineStart = Math.max(current.openOfflineStart, cutoff)
1311
+ }
1312
+ state.channels[channel.pubkey] = current
1313
+ }
1314
+ this.writeState(state)
1315
+ await this.flushStateWrites()
1316
+ for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
1317
+ })
1318
+ }
1319
+
1080
1320
  requireNymSigner (channel, override) {
1081
1321
  const signer = override || channel?.nymSigner || this.nymSigner
1082
1322
  if (!signer?.getPublicKey) throw new Error('NYM_SIGNER_REQUIRED')
@@ -1088,6 +1328,7 @@ export class PrivateMessenger {
1088
1328
  }
1089
1329
 
1090
1330
  scheduleReloadGap (pubkey) {
1331
+ if (!this.offlineRecoverySecondsFor(pubkey)) return
1091
1332
  const current = this.readState().channels[pubkey]
1092
1333
  const start = current?.openOfflineStart || current?.lastSeenAt
1093
1334
  if (!start) return
@@ -1117,6 +1358,7 @@ export class PrivateMessenger {
1117
1358
  const state = this.readState()
1118
1359
  const start = Math.max(0, nowSeconds() - this.offlineSkewSeconds)
1119
1360
  for (const pubkey of this.channels.keys()) {
1361
+ if (!this.offlineRecoverySecondsFor(pubkey)) continue
1120
1362
  const current = state.channels[pubkey] || {}
1121
1363
  current.openOfflineStart ||= start
1122
1364
  state.channels[pubkey] = current
@@ -1139,6 +1381,7 @@ export class PrivateMessenger {
1139
1381
  }
1140
1382
 
1141
1383
  async askSeedersForMissingRange (channelPubkey, since, until) {
1384
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return []
1142
1385
  if (!this.channels.get(channelPubkey)?.signer) return []
1143
1386
  const seeders = this.recoverySeeders(channelPubkey)
1144
1387
  if (!seeders.length || until < since) return []
@@ -1175,11 +1418,14 @@ export class PrivateMessenger {
1175
1418
  question: message.event,
1176
1419
  receiverPubkey: message.event?.pubkey,
1177
1420
  since,
1178
- until
1421
+ until,
1422
+ sendEmptyReply: !this.offlineRecoverySecondsFor(channelPubkey)
1179
1423
  })
1180
1424
 
1181
- for await (const seed of this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
1182
- await packer.update(seed)
1425
+ if (this.offlineRecoverySecondsFor(channelPubkey)) {
1426
+ for await (const seed of this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
1427
+ await packer.update(seed)
1428
+ }
1183
1429
  }
1184
1430
  await packer.finalize()
1185
1431
  }
@@ -1263,12 +1509,14 @@ export class PrivateMessenger {
1263
1509
  async recoverOfflineRanges (channels = [...this.stopByChannel.keys()]) {
1264
1510
  const state = this.readState()
1265
1511
  const now = nowSeconds()
1266
- const minStart = now - this.offlineRecoverySeconds
1267
1512
 
1268
1513
  for (const pubkey of uniq(channels)) {
1269
1514
  const channel = this.channels.get(pubkey)
1270
1515
  const current = state.channels[pubkey]
1271
1516
  if (!channel || !current?.offlineRanges?.length) continue
1517
+ const recoverySeconds = this.offlineRecoverySecondsFor(channel)
1518
+ if (!recoverySeconds) continue
1519
+ const minStart = now - recoverySeconds
1272
1520
 
1273
1521
  const remaining = []
1274
1522
  for (const range of current.offlineRanges) {
@@ -1288,7 +1536,7 @@ export class PrivateMessenger {
1288
1536
  until: range.end,
1289
1537
  mode: channel.mode,
1290
1538
  modeByPubkey: { [pubkey]: channel.mode },
1291
- receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
1539
+ receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1292
1540
  receivedChunkIndexedDB: this._indexedDB,
1293
1541
  onEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) })),
1294
1542
  onNymEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) })),
@@ -1313,8 +1561,8 @@ export class PrivateMessenger {
1313
1561
 
1314
1562
  async clearChannel (pubkey) {
1315
1563
  return this.runQueueOperation(async () => {
1316
- this.unwatch(pubkey)
1317
- this._privateMessage.clearChannelState?.(pubkey)
1564
+ await this.unwatch(pubkey)
1565
+ await this._privateMessage.clearChannelState?.(pubkey)
1318
1566
  this.channels.delete(pubkey)
1319
1567
  this.removeChannelState(pubkey)
1320
1568
  await this.flushStateWrites()
@@ -1346,24 +1594,39 @@ export class PrivateMessenger {
1346
1594
 
1347
1595
  async pruneStoredSeeds (channelPubkey) {
1348
1596
  if (!this.seedQueue) return
1349
- const cutoff = nowSeconds() - this.offlineRecoverySeconds
1350
1597
  const keyRange = globalThis.IDBKeyRange
1351
- if (channelPubkey && keyRange?.bound) {
1352
- await this.seedQueue.removeBy('byChannelTime', keyRange.bound([channelPubkey, 0], [channelPubkey, cutoff], false, true))
1598
+ if (!channelPubkey) {
1599
+ const pubkeys = new Set([...Object.keys(this.state.channels), ...this.channels.keys()])
1600
+ for (const pubkey of pubkeys) await this.pruneStoredSeeds(pubkey)
1601
+ await this.seedQueue.removeWhere(item => !pubkeys.has(item.channelPubkey))
1353
1602
  return
1354
1603
  }
1355
- if (!channelPubkey && keyRange?.upperBound) {
1356
- await this.seedQueue.removeBy('byTime', keyRange.upperBound(cutoff, true))
1604
+ const recoverySeconds = this.offlineRecoverySecondsFor(channelPubkey)
1605
+ if (!recoverySeconds) {
1606
+ await this.seedQueue.removeBy('byChannel', channelPubkey)
1607
+ return
1608
+ }
1609
+ const cutoff = nowSeconds() - recoverySeconds
1610
+ if (cutoff <= 0) return
1611
+ if (keyRange?.bound) {
1612
+ await this.seedQueue.removeBy('byChannelTime', keyRange.bound([channelPubkey, 0], [channelPubkey, cutoff], false, true))
1357
1613
  return
1358
1614
  }
1359
1615
  await this.seedQueue.removeWhere(item => {
1360
- if (channelPubkey && item.channelPubkey !== channelPubkey) return false
1616
+ if (item.channelPubkey !== channelPubkey) return false
1361
1617
  return (seedRecordTime(item) || item.receivedAt || 0) < cutoff
1362
1618
  })
1363
1619
  }
1364
1620
 
1365
1621
  close () {
1366
- this.unwatch()
1622
+ if (this.closePromise) return this.closePromise
1623
+ const initSettledPromise = this.initSettledPromise
1624
+ let unwatchPromise
1625
+ try {
1626
+ unwatchPromise = Promise.resolve(this.unwatch())
1627
+ } catch (err) {
1628
+ unwatchPromise = Promise.reject(err)
1629
+ }
1367
1630
  for (const pubkey of [...this.presenceTimers.keys()]) this.stopPresencePublisher(pubkey)
1368
1631
  this.stopRelayListWatcher?.()
1369
1632
  this.stopRelayListWatcher = null
@@ -1372,6 +1635,32 @@ export class PrivateMessenger {
1372
1635
  this.stopOnline?.()
1373
1636
  this.stopOffline = null
1374
1637
  this.stopOnline = null
1638
+ this.stopStorageMaintenance()
1639
+
1640
+ this.closePromise = (async () => {
1641
+ let unwatchError
1642
+ try { await unwatchPromise } catch (err) { unwatchError = err }
1643
+ await initSettledPromise
1644
+ await this.queueOperationTail
1645
+ await this.stateWriteTail
1646
+ try { await this.storageTouchPromise } catch {}
1647
+ await this.storageMaintenancePromise
1648
+ await Promise.all([
1649
+ this.queue?.close?.(),
1650
+ this.seedQueue?.close?.(),
1651
+ this.stateStore?.close?.()
1652
+ ])
1653
+ if (this.storageActive) {
1654
+ await releasePrivateMessengerStorage({
1655
+ userPubkey: this.userPubkey,
1656
+ leaseId: this.storageLeaseId,
1657
+ indexedDB: this._indexedDB
1658
+ })
1659
+ }
1660
+ this.storageActive = false
1661
+ if (unwatchError) throw unwatchError
1662
+ })()
1663
+ return this.closePromise
1375
1664
  }
1376
1665
  }
1377
1666
 
@@ -1452,11 +1741,9 @@ function nymCarrierSeedKey (record) {
1452
1741
 
1453
1742
  function withoutQueueMetadata (item) {
1454
1743
  if (!item) return null
1455
- const {
1456
- [SEED_KEY]: ignoredSeedKey,
1457
- [SEED_TIME]: ignoredSeedTime,
1458
- ...value
1459
- } = item
1744
+ const value = { ...item }
1745
+ delete value[SEED_KEY]
1746
+ delete value[SEED_TIME]
1460
1747
  return value
1461
1748
  }
1462
1749