libp2r2p 0.5.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
  // })
@@ -26,10 +26,11 @@
26
26
  // await messenger.clearChannel(channelPubkey)
27
27
  //
28
28
  // Missed-message recovery:
29
- // - Each watched channel stores lastSeenAt/lastWatchedAt in localStorage.
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,9 +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'
46
+ import { createChannelStateStore } from './services/channel-state.js'
45
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'
46
55
  import {
47
56
  compactSeedNymCarriers,
48
57
  compactSeedRouterRows,
@@ -71,6 +80,7 @@ export {
71
80
  } from './recovery/index.js'
72
81
 
73
82
  const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
83
+ const MAX_OFFLINE_RECOVERY_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
74
84
  const DEFAULT_OFFLINE_SKEW_SECONDS = 30
75
85
  const DEFAULT_RELOAD_GAP_DELAY_MS = 500
76
86
  const DEFAULT_SEEDER_PRESENCE_INTERVAL_MS = 10 * 60 * 1000
@@ -108,6 +118,13 @@ function nowSeconds () {
108
118
  return Math.floor(Date.now() / 1000)
109
119
  }
110
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
+
111
128
  function uniq (values) {
112
129
  return [...new Set((values || []).filter(Boolean))]
113
130
  }
@@ -121,17 +138,27 @@ function isPlainObject (value) {
121
138
  return value && typeof value === 'object' && !Array.isArray(value)
122
139
  }
123
140
 
141
+ function parseJson (raw, fallback) {
142
+ try { return JSON.parse(raw || '') } catch { return fallback }
143
+ }
144
+
124
145
  function storesRecoverySeeds (mode) {
125
146
  return mode === 'seeder' || mode === 'watchtower'
126
147
  }
127
148
 
128
- function parseJson (raw, fallback) {
129
- try { return JSON.parse(raw || '') } catch { return fallback }
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)}`
130
154
  }
131
155
 
132
156
  export class PrivateMessenger {
133
- static cleanupTemporaryStorage ({ storageArea = globalThis.sessionStorage } = {}) {
134
- cleanupTemporaryChannelStorage({ storageArea })
157
+ static maintainStorage ({
158
+ indexedDB = globalThis.indexedDB,
159
+ temporaryStorageArea = globalThis.sessionStorage
160
+ } = {}) {
161
+ return maintainPrivateMessengerStorage({ indexedDB, temporaryStorageArea })
135
162
  }
136
163
 
137
164
  constructor ({
@@ -159,9 +186,11 @@ export class PrivateMessenger {
159
186
  _subscribeRelayListUpdates = subscribeRelayListUpdates,
160
187
  _setTimeout = globalThis.setTimeout.bind(globalThis),
161
188
  _setInterval = globalThis.setInterval.bind(globalThis),
162
- _clearInterval = globalThis.clearInterval.bind(globalThis)
189
+ _clearInterval = globalThis.clearInterval.bind(globalThis),
190
+ _storageSetInterval = globalThis.setInterval.bind(globalThis),
191
+ _storageClearInterval = globalThis.clearInterval.bind(globalThis)
163
192
  } = {}) {
164
- this.offlineRecoverySeconds = offlineRecoverySeconds
193
+ this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
165
194
  this.staleChannelSeconds = staleChannelSeconds
166
195
  this.offlineSkewSeconds = offlineSkewSeconds
167
196
  this.reloadGapDelayMs = reloadGapDelayMs
@@ -186,6 +215,8 @@ export class PrivateMessenger {
186
215
  this._setTimeout = _setTimeout
187
216
  this._setInterval = _setInterval
188
217
  this._clearInterval = _clearInterval
218
+ this._storageSetInterval = _storageSetInterval
219
+ this._storageClearInterval = _storageClearInterval
189
220
 
190
221
  this.userSigner = null
191
222
  this.contentKeySigner = null
@@ -195,6 +226,9 @@ export class PrivateMessenger {
195
226
  this.prefix = ''
196
227
  this.queue = null
197
228
  this.seedQueue = null
229
+ this.stateStore = null
230
+ this.state = { channels: {} }
231
+ this.stateWriteTail = Promise.resolve()
198
232
  this.channels = new Map()
199
233
  this.stopByChannel = new Map()
200
234
  this.presenceTimers = new Map()
@@ -204,37 +238,170 @@ export class PrivateMessenger {
204
238
  this.stopOnline = null
205
239
  this.stopOffline = null
206
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
207
251
  }
208
252
 
209
253
  async init ({ userSigner, contentKeySigner, nymSigner, channels = [], relays = [], mode = 'leecher' }) {
210
254
  if (!userSigner?.getPublicKey) throw new Error('USER_SIGNER_REQUIRED')
211
- PrivateMessenger.cleanupTemporaryStorage({ storageArea: this.temporaryStorageArea })
212
- this.userSigner = userSigner
213
- this.contentKeySigner = contentKeySigner || null
214
- this.nymSigner = nymSigner || null
215
- this.userPubkey = await userSigner.getPublicKey()
216
- this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
217
- this.prefix = `libp2r2p:private-messenger:${this.userPubkey}`
218
- this.queue = await createQueue({
219
- prefix: this.prefix,
220
- indexes: MESSAGE_QUEUE_INDEXES,
221
- maxBytes: this.messageQueueMaxBytes,
222
- evictionPolicy: 'fifo',
223
- 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
224
375
  })
225
- this.seedQueue = await createQueue({
226
- prefix: `${this.prefix}:seeds`,
227
- indexes: SEED_QUEUE_INDEXES,
228
- maxBytes: this.seedQueueMaxBytes,
229
- evictionPolicy: 'fifo',
230
- 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
231
397
  })
232
- await this.cleanupStaleChannels()
233
- await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
234
- return this
398
+ this.storageTouchPromise = touch
399
+ return touch
235
400
  }
236
401
 
237
402
  runQueueOperation (operation) {
403
+ if (this.closePromise) return Promise.reject(new Error('PRIVATE_MESSENGER_CLOSED'))
404
+ this.touchStorageActivity().catch(err => this.onError?.(err))
238
405
  const run = this.queueOperationTail.then(operation)
239
406
  this.queueOperationTail = run.catch(err => {
240
407
  try { this.onError?.(err) } catch {}
@@ -269,6 +436,7 @@ export class PrivateMessenger {
269
436
  }
270
437
 
271
438
  async update ({ userSigner = this.userSigner, contentKeySigner = this.contentKeySigner, nymSigner = this.nymSigner, channels = [...this.channels.values()], relays = [], mode = 'leecher' } = {}) {
439
+ this.assertOpen()
272
440
  if (userSigner) {
273
441
  const userPubkey = await userSigner.getPublicKey?.()
274
442
  if (!userPubkey) throw new Error('USER_SIGNER_REQUIRED')
@@ -279,17 +447,19 @@ export class PrivateMessenger {
279
447
  this.nymSigner = nymSigner || null
280
448
  this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
281
449
  const nextChannels = await this.normalizeChannels(channels, { relays, mode })
450
+ this.assertOpen()
282
451
  const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
283
452
 
284
453
  for (const pubkey of [...this.channels.keys()]) {
285
454
  if (!nextPubkeys.has(pubkey)) {
286
- this.unwatch(pubkey)
455
+ await this.unwatch(pubkey)
287
456
  this.channels.delete(pubkey)
288
457
  }
289
458
  }
290
459
  for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
291
460
 
292
461
  await this.cleanupStaleChannels()
462
+ await this.applyRecoveryPolicies(nextChannels)
293
463
  await this.watch()
294
464
  await this.reconcilePresencePublishers()
295
465
  return this
@@ -314,6 +484,9 @@ export class PrivateMessenger {
314
484
  const autoDeletionCapability = channel.autoDeletionCapability === undefined
315
485
  ? undefined
316
486
  : normalizeAutoDeletionCapability(channel.autoDeletionCapability)
487
+ const offlineRecoverySeconds = normalizeOfflineRecoverySeconds(
488
+ channel.offlineRecoverySeconds ?? this.offlineRecoverySeconds
489
+ )
317
490
  out.push({
318
491
  pubkey,
319
492
  signer,
@@ -325,6 +498,7 @@ export class PrivateMessenger {
325
498
  usesNip65WatchRelays: !hasChannelRelays && !hasDefaultRelays,
326
499
  mode,
327
500
  seeders: uniq(channel.seeders),
501
+ offlineRecoverySeconds,
328
502
  autoDeletionCapability
329
503
  })
330
504
  }
@@ -346,6 +520,7 @@ export class PrivateMessenger {
346
520
  }
347
521
 
348
522
  async recoveryMirrorRelays (channelPubkey) {
523
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return []
349
524
  const seeders = this.recoverySeeders(channelPubkey)
350
525
  if (!seeders.length) return []
351
526
  try {
@@ -372,18 +547,26 @@ export class PrivateMessenger {
372
547
  return { relayToReceivers: derived, recoveryRelays }
373
548
  }
374
549
 
375
- stateKey () {
376
- return `${this.prefix}:state`
377
- }
378
-
379
550
  readState () {
380
- const state = parseJson(localStorage.getItem(this.stateKey()), { channels: {} })
381
- if (!isPlainObject(state.channels)) state.channels = {}
382
- return state
551
+ return structuredClone(this.state)
383
552
  }
384
553
 
385
554
  writeState (state) {
386
- localStorage.setItem(this.stateKey(), JSON.stringify(state))
555
+ this.touchStorageActivity().catch(err => this.onError?.(err))
556
+ const next = {
557
+ channels: isPlainObject(state?.channels) ? structuredClone(state.channels) : {}
558
+ }
559
+ this.state = next
560
+ const snapshot = structuredClone(next.channels)
561
+ const write = this.stateWriteTail.then(() => this.stateStore.replace(snapshot))
562
+ this.stateWriteTail = write.catch(err => {
563
+ try { this.onError?.(err) } catch {}
564
+ })
565
+ return write
566
+ }
567
+
568
+ async flushStateWrites () {
569
+ await this.stateWriteTail
387
570
  }
388
571
 
389
572
  updateChannelState (pubkey, patch) {
@@ -416,6 +599,7 @@ export class PrivateMessenger {
416
599
  }
417
600
 
418
601
  recoverySeeders (pubkey) {
602
+ if (!this.offlineRecoverySecondsFor(pubkey)) return []
419
603
  const channel = this.channels.get(pubkey)
420
604
  const configuredSeeders = channel?.seeders || []
421
605
  if (configuredSeeders.length) return configuredSeeders.filter(seeder => seeder !== this.userPubkey)
@@ -430,6 +614,7 @@ export class PrivateMessenger {
430
614
  }
431
615
 
432
616
  markSeederActive (channelPubkey, seederPubkey, { announced = false, at = nowSeconds() } = {}) {
617
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return false
433
618
  const state = this.readState()
434
619
  const current = state.channels[channelPubkey] || {}
435
620
  const activity = current.seederActivity || {}
@@ -443,6 +628,7 @@ export class PrivateMessenger {
443
628
  current.seederActivity = activity
444
629
  state.channels[channelPubkey] = current
445
630
  this.writeState(state)
631
+ return true
446
632
  }
447
633
 
448
634
  trackSeederActivity (channelPubkey, message) {
@@ -528,8 +714,10 @@ export class PrivateMessenger {
528
714
  }
529
715
 
530
716
  addOfflineRange (pubkey, start, end) {
717
+ const recoverySeconds = this.offlineRecoverySecondsFor(pubkey)
718
+ if (!recoverySeconds) return
531
719
  const now = nowSeconds()
532
- const minStart = now - this.offlineRecoverySeconds
720
+ const minStart = now - recoverySeconds
533
721
  const normalized = {
534
722
  start: Math.max(0, Math.floor(start)),
535
723
  end: Math.floor(end)
@@ -551,10 +739,17 @@ export class PrivateMessenger {
551
739
  closeOpenOfflineRanges () {
552
740
  const state = this.readState()
553
741
  const end = nowSeconds()
554
- const minStart = end - this.offlineRecoverySeconds
555
742
  for (const pubkey of Object.keys(state.channels)) {
556
743
  const current = state.channels[pubkey]
557
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
558
753
  const start = Math.max(minStart, Math.max(0, current.openOfflineStart))
559
754
  if (end > start) {
560
755
  current.offlineRanges = mergeRanges((current.offlineRanges || []).concat([{ start, end }]))
@@ -566,11 +761,13 @@ export class PrivateMessenger {
566
761
  }
567
762
 
568
763
  async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true } = {}) {
764
+ this.assertOpen()
569
765
  const channelPubkeys = uniq(channels)
570
766
  for (const pubkey of channelPubkeys) {
571
767
  const channel = this.channels.get(pubkey)
572
768
  if (!channel) throw new Error('UNKNOWN_CHANNEL')
573
769
  const watchRelays = await this.resolveWatchRelays(channel)
770
+ this.assertOpen()
574
771
  const stop = await this._privateMessage.watch({
575
772
  channels: [pubkey],
576
773
  relays: watchRelays,
@@ -588,15 +785,21 @@ export class PrivateMessenger {
588
785
  onMessage: message => this.queueIncoming(() => this.handleMessage(pubkey, message)),
589
786
  onSeed: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
590
787
  onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
591
- receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
788
+ receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
789
+ receivedChunkIndexedDB: this._indexedDB,
592
790
  onError: err => this.onError?.(err)
593
791
  })
792
+ if (this.closePromise) {
793
+ await stop?.()
794
+ this.assertOpen()
795
+ }
594
796
  this.stopByChannel.set(pubkey, stop)
595
797
  this.updateChannelState(pubkey, {
596
798
  lastWatchedAt: nowSeconds(),
597
799
  mode: channel.mode,
598
800
  relays: watchRelays,
599
- seeders: channel.seeders
801
+ seeders: channel.seeders,
802
+ offlineRecoverySeconds: channel.offlineRecoverySeconds
600
803
  })
601
804
  this.debug('watch', {
602
805
  channelPubkey: pubkey,
@@ -614,12 +817,15 @@ export class PrivateMessenger {
614
817
 
615
818
  unwatch (channels) {
616
819
  const channelPubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.stopByChannel.keys()]
820
+ const closing = []
617
821
  for (const pubkey of channelPubkeys) {
618
- this.stopByChannel.get(pubkey)?.()
822
+ const close = this.stopByChannel.get(pubkey)?.()
823
+ if (close && typeof close.then === 'function') closing.push(close)
619
824
  this.stopByChannel.delete(pubkey)
620
825
  this.stopPresencePublisher(pubkey)
621
826
  }
622
827
  this.ensureRelayListWatcher()
828
+ return Promise.allSettled(closing)
623
829
  }
624
830
 
625
831
  nip65WatchChannelPubkeys () {
@@ -745,6 +951,7 @@ export class PrivateMessenger {
745
951
  }
746
952
 
747
953
  async enqueueSeed (channelPubkey, seed) {
954
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return
748
955
  const receivedAt = nowSeconds()
749
956
  if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
750
957
  const carriers = compactSeedNymCarriers(seed.carriers)
@@ -801,6 +1008,7 @@ export class PrivateMessenger {
801
1008
 
802
1009
  async nextMessage () {
803
1010
  // seedQueue is retained replay material for recovery replies, not an app-message stream.
1011
+ this.touchStorageActivity().catch(err => this.onError?.(err))
804
1012
  await this.queueOperationTail
805
1013
  return withoutQueueMetadata(await this.queue.shift())
806
1014
  }
@@ -816,7 +1024,7 @@ export class PrivateMessenger {
816
1024
  privateChannelReaderPubkey: channel.readerPubkey,
817
1025
  receiverPubkey,
818
1026
  ...routing,
819
- expirationSeconds: this.offlineRecoverySeconds,
1027
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
820
1028
  temporaryStorageArea: this.temporaryStorageArea,
821
1029
  deletionPubkey,
822
1030
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -842,7 +1050,7 @@ export class PrivateMessenger {
842
1050
  question,
843
1051
  receiverPubkey,
844
1052
  ...routing,
845
- expirationSeconds: this.offlineRecoverySeconds,
1053
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
846
1054
  temporaryStorageArea: this.temporaryStorageArea,
847
1055
  deletionPubkey,
848
1056
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -866,7 +1074,7 @@ export class PrivateMessenger {
866
1074
  privateChannelReaderPubkey: channel.readerPubkey,
867
1075
  receiverPubkey,
868
1076
  ...routing,
869
- expirationSeconds: this.offlineRecoverySeconds,
1077
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
870
1078
  temporaryStorageArea: this.temporaryStorageArea,
871
1079
  deletionPubkey,
872
1080
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -890,7 +1098,7 @@ export class PrivateMessenger {
890
1098
  privateChannelReaderPubkey: channel.readerPubkey,
891
1099
  receiverPubkeys,
892
1100
  ...routing,
893
- expirationSeconds: this.offlineRecoverySeconds,
1101
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
894
1102
  temporaryStorageArea: this.temporaryStorageArea,
895
1103
  deletionPubkey,
896
1104
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -914,7 +1122,7 @@ export class PrivateMessenger {
914
1122
  privateChannelReaderPubkey: channel.readerPubkey,
915
1123
  receiverPubkeys,
916
1124
  ...routing,
917
- expirationSeconds: this.offlineRecoverySeconds,
1125
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
918
1126
  temporaryStorageArea: this.temporaryStorageArea,
919
1127
  deletionPubkey,
920
1128
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -934,7 +1142,7 @@ export class PrivateMessenger {
934
1142
  privateChannelReaderPubkey: channel.readerPubkey,
935
1143
  receiverPubkeys,
936
1144
  ...routing,
937
- expirationSeconds: this.offlineRecoverySeconds,
1145
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
938
1146
  temporaryStorageArea: this.temporaryStorageArea,
939
1147
  deletionPubkey,
940
1148
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -953,7 +1161,7 @@ export class PrivateMessenger {
953
1161
  privateChannelSigner: channel.signer,
954
1162
  privateChannelReaderPubkey: channel.readerPubkey,
955
1163
  ...routing,
956
- expirationSeconds: this.offlineRecoverySeconds,
1164
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
957
1165
  deletionPubkey,
958
1166
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
959
1167
  rumor
@@ -970,7 +1178,7 @@ export class PrivateMessenger {
970
1178
  privateChannelSigner: channel.signer,
971
1179
  privateChannelReaderPubkey: channel.readerPubkey,
972
1180
  ...routing,
973
- expirationSeconds: this.offlineRecoverySeconds,
1181
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
974
1182
  deletionPubkey,
975
1183
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
976
1184
  event
@@ -979,6 +1187,7 @@ export class PrivateMessenger {
979
1187
 
980
1188
  async publishSeederPresence (channelPubkey = this.defaultChannelPubkey()) {
981
1189
  const channel = this.requireWritableChannel(channelPubkey)
1190
+ if (!this.offlineRecoverySecondsFor(channel)) return null
982
1191
  const receiverPubkeys = uniq([...this.knownSeeders(channelPubkey), this.userPubkey])
983
1192
  const routing = await this.resolveSendRouting({ channel, receiverPubkeys })
984
1193
  this.debugSend('yell', channelPubkey, { code: SEEDER_PRESENCE_CODE, receiverPubkeys })
@@ -989,7 +1198,7 @@ export class PrivateMessenger {
989
1198
  privateChannelReaderPubkey: channel.readerPubkey,
990
1199
  receiverPubkeys,
991
1200
  ...routing,
992
- expirationSeconds: this.offlineRecoverySeconds,
1201
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
993
1202
  temporaryStorageArea: this.temporaryStorageArea,
994
1203
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
995
1204
  code: SEEDER_PRESENCE_CODE,
@@ -999,6 +1208,7 @@ export class PrivateMessenger {
999
1208
  }
1000
1209
 
1001
1210
  async startPresencePublisher (channelPubkey) {
1211
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return
1002
1212
  if (this.presenceTimers.has(channelPubkey)) return
1003
1213
  try {
1004
1214
  await this.publishSeederPresence(channelPubkey)
@@ -1023,10 +1233,10 @@ export class PrivateMessenger {
1023
1233
  async reconcilePresencePublishers () {
1024
1234
  const starts = []
1025
1235
  for (const pubkey of [...this.presenceTimers.keys()]) {
1026
- if (!storesRecoverySeeds(this.channels.get(pubkey)?.mode)) this.stopPresencePublisher(pubkey)
1236
+ if (!storesRecoverySeeds(this.channels.get(pubkey)?.mode) || !this.offlineRecoverySecondsFor(pubkey)) this.stopPresencePublisher(pubkey)
1027
1237
  }
1028
1238
  for (const [pubkey, channel] of this.channels) {
1029
- if (storesRecoverySeeds(channel.mode)) starts.push(this.startPresencePublisher(pubkey))
1239
+ if (storesRecoverySeeds(channel.mode) && this.offlineRecoverySecondsFor(channel)) starts.push(this.startPresencePublisher(pubkey))
1030
1240
  else this.stopPresencePublisher(pubkey)
1031
1241
  }
1032
1242
  await Promise.all(starts)
@@ -1060,6 +1270,53 @@ export class PrivateMessenger {
1060
1270
  return channel.autoDeletionCapability ?? this.autoDeletionCapability
1061
1271
  }
1062
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
+
1063
1320
  requireNymSigner (channel, override) {
1064
1321
  const signer = override || channel?.nymSigner || this.nymSigner
1065
1322
  if (!signer?.getPublicKey) throw new Error('NYM_SIGNER_REQUIRED')
@@ -1071,6 +1328,7 @@ export class PrivateMessenger {
1071
1328
  }
1072
1329
 
1073
1330
  scheduleReloadGap (pubkey) {
1331
+ if (!this.offlineRecoverySecondsFor(pubkey)) return
1074
1332
  const current = this.readState().channels[pubkey]
1075
1333
  const start = current?.openOfflineStart || current?.lastSeenAt
1076
1334
  if (!start) return
@@ -1100,6 +1358,7 @@ export class PrivateMessenger {
1100
1358
  const state = this.readState()
1101
1359
  const start = Math.max(0, nowSeconds() - this.offlineSkewSeconds)
1102
1360
  for (const pubkey of this.channels.keys()) {
1361
+ if (!this.offlineRecoverySecondsFor(pubkey)) continue
1103
1362
  const current = state.channels[pubkey] || {}
1104
1363
  current.openOfflineStart ||= start
1105
1364
  state.channels[pubkey] = current
@@ -1122,6 +1381,7 @@ export class PrivateMessenger {
1122
1381
  }
1123
1382
 
1124
1383
  async askSeedersForMissingRange (channelPubkey, since, until) {
1384
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return []
1125
1385
  if (!this.channels.get(channelPubkey)?.signer) return []
1126
1386
  const seeders = this.recoverySeeders(channelPubkey)
1127
1387
  if (!seeders.length || until < since) return []
@@ -1158,11 +1418,14 @@ export class PrivateMessenger {
1158
1418
  question: message.event,
1159
1419
  receiverPubkey: message.event?.pubkey,
1160
1420
  since,
1161
- until
1421
+ until,
1422
+ sendEmptyReply: !this.offlineRecoverySecondsFor(channelPubkey)
1162
1423
  })
1163
1424
 
1164
- for await (const seed of this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
1165
- 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
+ }
1166
1429
  }
1167
1430
  await packer.finalize()
1168
1431
  }
@@ -1246,12 +1509,14 @@ export class PrivateMessenger {
1246
1509
  async recoverOfflineRanges (channels = [...this.stopByChannel.keys()]) {
1247
1510
  const state = this.readState()
1248
1511
  const now = nowSeconds()
1249
- const minStart = now - this.offlineRecoverySeconds
1250
1512
 
1251
1513
  for (const pubkey of uniq(channels)) {
1252
1514
  const channel = this.channels.get(pubkey)
1253
1515
  const current = state.channels[pubkey]
1254
1516
  if (!channel || !current?.offlineRanges?.length) continue
1517
+ const recoverySeconds = this.offlineRecoverySecondsFor(channel)
1518
+ if (!recoverySeconds) continue
1519
+ const minStart = now - recoverySeconds
1255
1520
 
1256
1521
  const remaining = []
1257
1522
  for (const range of current.offlineRanges) {
@@ -1271,7 +1536,8 @@ export class PrivateMessenger {
1271
1536
  until: range.end,
1272
1537
  mode: channel.mode,
1273
1538
  modeByPubkey: { [pubkey]: channel.mode },
1274
- receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
1539
+ receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1540
+ receivedChunkIndexedDB: this._indexedDB,
1275
1541
  onEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) })),
1276
1542
  onNymEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) })),
1277
1543
  onSeedEvent: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
@@ -1295,10 +1561,11 @@ export class PrivateMessenger {
1295
1561
 
1296
1562
  async clearChannel (pubkey) {
1297
1563
  return this.runQueueOperation(async () => {
1298
- this.unwatch(pubkey)
1299
- this._privateMessage.clearChannelState?.(pubkey)
1564
+ await this.unwatch(pubkey)
1565
+ await this._privateMessage.clearChannelState?.(pubkey)
1300
1566
  this.channels.delete(pubkey)
1301
1567
  this.removeChannelState(pubkey)
1568
+ await this.flushStateWrites()
1302
1569
  await this.queue.removeBy('byChannel', pubkey)
1303
1570
  await this.seedQueue.removeBy('byChannel', pubkey)
1304
1571
  this.ensureRelayListWatcher()
@@ -1321,29 +1588,45 @@ export class PrivateMessenger {
1321
1588
  await this.seedQueue?.removeBy('byChannel', pubkey)
1322
1589
  }
1323
1590
  this.writeState(state)
1591
+ await this.flushStateWrites()
1324
1592
  })
1325
1593
  }
1326
1594
 
1327
1595
  async pruneStoredSeeds (channelPubkey) {
1328
1596
  if (!this.seedQueue) return
1329
- const cutoff = nowSeconds() - this.offlineRecoverySeconds
1330
1597
  const keyRange = globalThis.IDBKeyRange
1331
- if (channelPubkey && keyRange?.bound) {
1332
- 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))
1602
+ return
1603
+ }
1604
+ const recoverySeconds = this.offlineRecoverySecondsFor(channelPubkey)
1605
+ if (!recoverySeconds) {
1606
+ await this.seedQueue.removeBy('byChannel', channelPubkey)
1333
1607
  return
1334
1608
  }
1335
- if (!channelPubkey && keyRange?.upperBound) {
1336
- await this.seedQueue.removeBy('byTime', keyRange.upperBound(cutoff, true))
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))
1337
1613
  return
1338
1614
  }
1339
1615
  await this.seedQueue.removeWhere(item => {
1340
- if (channelPubkey && item.channelPubkey !== channelPubkey) return false
1616
+ if (item.channelPubkey !== channelPubkey) return false
1341
1617
  return (seedRecordTime(item) || item.receivedAt || 0) < cutoff
1342
1618
  })
1343
1619
  }
1344
1620
 
1345
1621
  close () {
1346
- 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
+ }
1347
1630
  for (const pubkey of [...this.presenceTimers.keys()]) this.stopPresencePublisher(pubkey)
1348
1631
  this.stopRelayListWatcher?.()
1349
1632
  this.stopRelayListWatcher = null
@@ -1352,6 +1635,32 @@ export class PrivateMessenger {
1352
1635
  this.stopOnline?.()
1353
1636
  this.stopOffline = null
1354
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
1355
1664
  }
1356
1665
  }
1357
1666
 
@@ -1432,11 +1741,9 @@ function nymCarrierSeedKey (record) {
1432
1741
 
1433
1742
  function withoutQueueMetadata (item) {
1434
1743
  if (!item) return null
1435
- const {
1436
- [SEED_KEY]: ignoredSeedKey,
1437
- [SEED_TIME]: ignoredSeedTime,
1438
- ...value
1439
- } = item
1744
+ const value = { ...item }
1745
+ delete value[SEED_KEY]
1746
+ delete value[SEED_TIME]
1440
1747
  return value
1441
1748
  }
1442
1749