libp2r2p 0.10.19 → 0.10.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,10 @@
15
15
  // - Split reader channel: { signer, readerPubkey } signs as the channel key but encrypts/decrypts the outer router with the reader pubkey.
16
16
  // - Reader-secret channel: { signer, readerSigner } is also valid; the reader signer decrypts the router.
17
17
  // - Reader-only channel: { pubkey, readerSigner } can watch/fetch/drain messages but cannot send or seed recovery replies.
18
- // for await (const msg of messenger.messages()) handlePrivateMessage(msg)
18
+ // for await (const { message, ack } of messenger.messages()) {
19
+ // await persistPrivateMessage(message)
20
+ // await ack()
21
+ // }
19
22
  // await messenger.ask({ receiverPubkey, payload: { ping: true } })
20
23
  // await messenger.reply({ question: msg.question, payload: { ok: true } })
21
24
  // await messenger.tell({ receiverPubkey, payload: { note: 'hello' } })
@@ -29,7 +32,7 @@
29
32
  //
30
33
  // Missed-message recovery:
31
34
  // - Each watched channel stores lastSeenAt/lastWatchedAt in IndexedDB.
32
- // - Re-watching after reload fetches the gap from lastSeenAt to now.
35
+ // - First watch scans the recovery window; reload resumes persisted gaps/checkpoints.
33
36
  // - Browser offline/online events add explicit offline ranges with a small skew.
34
37
  // - Recovery defaults to 7 days and can be overridden per channel; zero disables durable recovery.
35
38
  // - Channel state not actively leased or watched within the configured stale window is pruned.
@@ -40,6 +43,7 @@
40
43
  // - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
41
44
 
42
45
  import * as privateMessage from '../private-message/index.js'
46
+ import { deliveryInfo } from '../private-channel/helpers/rumor.js'
43
47
  import { bytesToBase64 } from '../base64/index.js'
44
48
  import { ValidationError } from '../error/index.js'
45
49
  import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates } from '../relay/index.js'
@@ -100,7 +104,7 @@ const SEED_TIME = '__p2r2pSeedTime'
100
104
  const MESSAGE_QUEUE_INDEXES = {
101
105
  byChannel: 'channelPubkey',
102
106
  byChannelTypeEventId: {
103
- keyPath: ['channelPubkey', 'type', 'event.id'],
107
+ keyPath: ['channelPubkey', 'type', 'event.id', 'provenance'],
104
108
  unique: true
105
109
  }
106
110
  }
@@ -236,7 +240,7 @@ export class PrivateMessenger {
236
240
  this.onMessageQueued = onMessageQueued
237
241
  this.onDebug = onDebug
238
242
  this.onError = onError
239
- this._privateMessage = _privateMessage
243
+ this._privateMessage = _privateMessage.createPrivateMessageSession?.() ?? _privateMessage
240
244
  this._privateChannel = _privateChannel
241
245
  this._getRelaysByPubkey = _getRelaysByPubkey
242
246
  this._pickRelaysForPubkeys = _pickRelaysForPubkeys
@@ -260,8 +264,20 @@ export class PrivateMessenger {
260
264
  this.stateStore = null
261
265
  this.state = { channels: {} }
262
266
  this.stateWriteTail = Promise.resolve()
267
+ this.stateWriteError = null
263
268
  this.channels = new Map()
264
269
  this.stopByChannel = new Map()
270
+ this.desiredChannels = new Set()
271
+ this.pauseReasons = new Set()
272
+ this.deliveries = new Set()
273
+ this.deliveryReads = new Set()
274
+ this.deliveryWaiters = new Set()
275
+ this.recoveries = new Map()
276
+ this.recoveryControllers = new Set()
277
+ this.resumeWork = null
278
+ this.capacityTimer = null
279
+ this.capacityCheck = null
280
+ this.capacityRequiredBytes = 0
265
281
  this.reloadGapTimers = new Map()
266
282
  this.watchRevisionByChannel = new Map()
267
283
  this.presenceTimers = new Map()
@@ -327,7 +343,7 @@ export class PrivateMessenger {
327
343
  prefix: this.prefix,
328
344
  indexes: MESSAGE_QUEUE_INDEXES,
329
345
  maxBytes: this.messageQueueMaxBytes,
330
- evictionPolicy: 'fifo',
346
+ evictionPolicy: 'reject',
331
347
  indexedDB: this._indexedDB
332
348
  })
333
349
  this.assertOpen()
@@ -453,9 +469,9 @@ export class PrivateMessenger {
453
469
  const channels = [...this.channels.values()]
454
470
  await this.applyRecoveryPolicies(channels)
455
471
  await this.cleanupStaleChannels()
456
- const pubkeys = [...this.channels.keys()]
472
+ const pubkeys = [...this.desiredChannels]
457
473
  if (pubkeys.length) {
458
- await this.unwatch(pubkeys)
474
+ await this.stopWatches(pubkeys)
459
475
  await this.watch(pubkeys)
460
476
  }
461
477
  await this.reconcilePresencePublishers()
@@ -560,7 +576,7 @@ export class PrivateMessenger {
560
576
  }
561
577
 
562
578
  queueIncoming (operation) {
563
- return this.runQueueOperation(operation).catch(() => undefined)
579
+ return this.runQueueOperation(operation)
564
580
  }
565
581
 
566
582
  debug (action, detail = {}) {
@@ -615,6 +631,7 @@ export class PrivateMessenger {
615
631
  const nextChannels = await this.normalizeChannels(channels, { relays, mode })
616
632
  this.assertOpen()
617
633
  const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
634
+ const watchPubkeys = [...nextPubkeys].filter(pubkey => !this.channels.has(pubkey) || this.desiredChannels.has(pubkey))
618
635
  const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
619
636
  const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
620
637
 
@@ -652,7 +669,7 @@ export class PrivateMessenger {
652
669
 
653
670
  await this.cleanupStaleChannels({ storageSnapshot })
654
671
  await this.applyRecoveryPolicies(nextChannels)
655
- await this.watch([...nextPubkeys])
672
+ await this.watch(watchPubkeys)
656
673
  await this.reconcilePresencePublishers()
657
674
  if (this.storagePolicyRevision === storageSnapshot.policyRevision) {
658
675
  this.storagePolicyNeedsApply = false
@@ -760,6 +777,7 @@ export class PrivateMessenger {
760
777
  const snapshot = structuredClone(changed)
761
778
  const write = this.stateWriteTail.then(() => this.stateStore.update(snapshot, removed))
762
779
  this.stateWriteTail = write.catch(err => {
780
+ this.stateWriteError = err
763
781
  try { this.onError?.(err) } catch {}
764
782
  })
765
783
  return write
@@ -767,6 +785,15 @@ export class PrivateMessenger {
767
785
 
768
786
  async flushStateWrites () {
769
787
  await this.stateWriteTail
788
+ if (!this.stateWriteError) return
789
+ const repair = this.stateWriteTail.then(async () => {
790
+ const stored = await this.stateStore.load()
791
+ const snapshot = structuredClone(this.state.channels)
792
+ await this.stateStore.update(snapshot, Object.keys(stored).filter(pubkey => !Object.hasOwn(snapshot, pubkey)))
793
+ this.stateWriteError = null
794
+ })
795
+ this.stateWriteTail = repair.catch(err => { this.stateWriteError = err })
796
+ await repair
770
797
  }
771
798
 
772
799
  async stampChannelActivity (pubkeys) {
@@ -782,6 +809,7 @@ export class PrivateMessenger {
782
809
  }
783
810
  const write = this.stateWriteTail.then(() => this.stateStore.touch(pubkeys, lastWatchedAt))
784
811
  this.stateWriteTail = write.catch(err => {
812
+ this.stateWriteError = err
785
813
  try { this.onError?.(err) } catch {}
786
814
  })
787
815
  await write
@@ -811,6 +839,7 @@ export class PrivateMessenger {
811
839
  this.touchStorageActivity().catch(err => this.onError?.(err))
812
840
  const write = this.stateWriteTail.then(() => this.stateStore.update({}, pubkeys))
813
841
  this.stateWriteTail = write.catch(err => {
842
+ this.stateWriteError = err
814
843
  try { this.onError?.(err) } catch {}
815
844
  })
816
845
  return write
@@ -865,7 +894,8 @@ export class PrivateMessenger {
865
894
  }
866
895
 
867
896
  trackSeederActivity (channelPubkey, message) {
868
- const senderPubkey = message.event?.pubkey
897
+ if (message.provenance === 'hearsay') return false
898
+ const senderPubkey = message.senderPubkey || message.event?.pubkey
869
899
  if (!senderPubkey) return false
870
900
 
871
901
  const channel = this.channels.get(channelPubkey)
@@ -955,7 +985,7 @@ export class PrivateMessenger {
955
985
  start: Math.max(0, Math.floor(start)),
956
986
  end: Math.floor(end)
957
987
  }
958
- if (normalized.end <= normalized.start || normalized.end < minStart) return
988
+ if (normalized.end < normalized.start || normalized.end < minStart) return
959
989
  normalized.start = Math.max(normalized.start, minStart)
960
990
 
961
991
  const state = this.readState()
@@ -969,12 +999,12 @@ export class PrivateMessenger {
969
999
  this.writeState(state)
970
1000
  }
971
1001
 
972
- closeOpenOfflineRanges () {
1002
+ closeOpenOfflineRanges (channels = [...this.desiredChannels]) {
973
1003
  const state = this.readState()
974
1004
  const end = nowSeconds()
975
- for (const pubkey of Object.keys(state.channels)) {
1005
+ for (const pubkey of channels) {
976
1006
  const current = state.channels[pubkey]
977
- if (!current.openOfflineStart) continue
1007
+ if (current?.openOfflineStart == null) continue
978
1008
  const recoverySeconds = this.offlineRecoverySecondsFor(pubkey)
979
1009
  if (!recoverySeconds) {
980
1010
  delete current.openOfflineStart
@@ -984,7 +1014,7 @@ export class PrivateMessenger {
984
1014
  }
985
1015
  const minStart = end - recoverySeconds
986
1016
  const start = Math.max(minStart, Math.max(0, current.openOfflineStart))
987
- if (end > start) {
1017
+ if (end >= start) {
988
1018
  current.offlineRanges = mergeRanges((current.offlineRanges || []).concat([{ start, end }]))
989
1019
  }
990
1020
  delete current.openOfflineStart
@@ -996,11 +1026,28 @@ export class PrivateMessenger {
996
1026
  async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true } = {}) {
997
1027
  this.assertOpen()
998
1028
  const channelPubkeys = uniq(channels)
1029
+ for (const pubkey of channelPubkeys) {
1030
+ this.requireChannel(pubkey)
1031
+ this.desiredChannels.add(pubkey)
1032
+ }
1033
+ this.ensureNetworkWatchers()
1034
+ if (this.pauseReasons.size) {
1035
+ this.recordInterruption(channelPubkeys)
1036
+ await this.flushStateWrites()
1037
+ return this
1038
+ }
999
1039
  for (const pubkey of channelPubkeys) {
1000
1040
  const channel = this.channels.get(pubkey)
1001
1041
  if (!channel) throw new ValidationError('UNKNOWN_CHANNEL')
1042
+ const revision = (this.watchRevisionByChannel.get(pubkey) || 0) + 1
1043
+ this.watchRevisionByChannel.set(pubkey, revision)
1044
+ // Persist the pending interval before live delivery can advance lastSeenAt.
1045
+ // Neither a crash nor a newly received message may erase the initial scan.
1046
+ this.recordRecoveryWindow(pubkey)
1047
+ await this.flushStateWrites()
1002
1048
  const watchRelays = await this.resolveWatchRelays(channel)
1003
1049
  this.assertOpen()
1050
+ if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) continue
1004
1051
  const stop = await this._privateMessage.watch({
1005
1052
  channels: [pubkey],
1006
1053
  relays: watchRelays,
@@ -1010,24 +1057,25 @@ export class PrivateMessenger {
1010
1057
  privateChannelReaderSigner: channel.readerSigner,
1011
1058
  privateChannelReaderPubkey: channel.readerPubkey,
1012
1059
  mode: channel.mode,
1013
- onAsk: message => this.queueIncoming(() => this.handleAsk(pubkey, message)),
1014
- onReply: message => this.queueIncoming(() => this.handleReply(pubkey, message)),
1015
- onTell: message => this.queueIncoming(() => this.handleTell(pubkey, message)),
1016
- onYell: message => this.queueIncoming(() => this.handleYell(pubkey, message)),
1017
- onNym: message => this.queueIncoming(() => this.handleNym(pubkey, message)),
1018
- onMessage: message => this.queueIncoming(() => this.handleMessage(pubkey, message)),
1019
- onSeed: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
1060
+ onAsk: message => this.receive(pubkey, () => this.handleAsk(pubkey, message), message),
1061
+ onReply: message => this.receive(pubkey, () => this.handleReply(pubkey, message), message),
1062
+ onTell: message => this.receive(pubkey, () => this.handleTell(pubkey, message), message),
1063
+ onYell: message => this.receive(pubkey, () => this.handleYell(pubkey, message), message),
1064
+ onNym: message => this.receive(pubkey, () => this.handleNym(pubkey, message), message),
1065
+ onMessage: message => this.receive(pubkey, () => this.handleMessage(pubkey, message), message),
1066
+ onSeed: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
1020
1067
  onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
1021
1068
  receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1022
1069
  receivedChunkIndexedDB: this._indexedDB,
1023
1070
  onError: err => this.onError?.(err)
1024
1071
  })
1025
- if (this.closePromise) {
1072
+ if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) {
1026
1073
  await stop?.()
1027
1074
  this.assertOpen()
1075
+ continue
1028
1076
  }
1029
1077
  this.stopByChannel.set(pubkey, stop)
1030
- this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
1078
+ this.watchRevisionByChannel.set(pubkey, revision)
1031
1079
  this.updateChannelState(pubkey, {
1032
1080
  lastWatchedAt: nowSeconds(),
1033
1081
  mode: channel.mode,
@@ -1042,21 +1090,35 @@ export class PrivateMessenger {
1042
1090
  seeders: channel.seeders,
1043
1091
  seederCount: channel.seeders.length
1044
1092
  })
1045
- if (scheduleReloadGap) this.scheduleReloadGap(pubkey)
1093
+ if (scheduleReloadGap) {
1094
+ this.closeOpenOfflineRanges([pubkey])
1095
+ this.scheduleReloadGap(pubkey)
1096
+ }
1046
1097
  }
1047
1098
  this.ensureNetworkWatchers()
1048
1099
  this.ensureRelayListWatcher()
1049
1100
  return this
1050
1101
  }
1051
1102
 
1052
- unwatch (channels) {
1053
- const channelPubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.stopByChannel.keys()]
1103
+ recordInterruption (channels, at = nowSeconds()) {
1104
+ const state = this.readState()
1105
+ for (const pubkey of channels) {
1106
+ if (!this.offlineRecoverySecondsFor(pubkey)) continue
1107
+ const current = state.channels[pubkey] || {}
1108
+ const start = Math.max(0, at - this.offlineSkewSeconds)
1109
+ current.openOfflineStart = Math.min(current.openOfflineStart ?? start, start)
1110
+ state.channels[pubkey] = current
1111
+ }
1112
+ this.writeState(state)
1113
+ }
1114
+
1115
+ stopWatches (channels = [...this.stopByChannel.keys()]) {
1054
1116
  const closing = []
1055
- for (const pubkey of channelPubkeys) {
1117
+ for (const pubkey of channels) {
1056
1118
  this.cancelReloadGap(pubkey)
1057
1119
  this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
1058
1120
  const close = this.stopByChannel.get(pubkey)?.()
1059
- if (close && typeof close.then === 'function') closing.push(close)
1121
+ if (close?.then) closing.push(close)
1060
1122
  this.stopByChannel.delete(pubkey)
1061
1123
  this.stopPresencePublisher(pubkey)
1062
1124
  }
@@ -1064,6 +1126,66 @@ export class PrivateMessenger {
1064
1126
  return Promise.allSettled(closing)
1065
1127
  }
1066
1128
 
1129
+ unwatch (channels) {
1130
+ const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
1131
+ for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
1132
+ this.recordInterruption(pubkeys)
1133
+ for (const controller of this.recoveryControllers) {
1134
+ if (pubkeys.includes(controller.channelPubkey)) controller.abort()
1135
+ }
1136
+ return Promise.all([this.stopWatches(pubkeys), this.flushStateWrites()])
1137
+ }
1138
+
1139
+ pause (reason) {
1140
+ if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1141
+ this.assertOpen()
1142
+ this.pauseReasons.add(reason)
1143
+ this.recordInterruption(this.desiredChannels)
1144
+ for (const controller of this.recoveryControllers) controller.abort()
1145
+ return Promise.all([this.stopWatches([...this.desiredChannels]), this.flushStateWrites()])
1146
+ }
1147
+
1148
+ async resume (reason) {
1149
+ if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1150
+ this.assertOpen()
1151
+ const removed = this.pauseReasons.delete(reason)
1152
+ if (reason === 'capacity' && removed) {
1153
+ clearInterval(this.capacityTimer)
1154
+ this.capacityTimer = null
1155
+ this.capacityRequiredBytes = 0
1156
+ }
1157
+ if (!removed || this.pauseReasons.size) return
1158
+ if (this.resumeWork) await this.resumeWork
1159
+ if (this.pauseReasons.size || this.closePromise) return
1160
+ const work = (async () => {
1161
+ const channels = [...this.desiredChannels]
1162
+ this.closeOpenOfflineRanges(channels)
1163
+ await this.watch(channels, { scheduleReloadGap: false })
1164
+ if (this.pauseReasons.size || this.closePromise) return
1165
+ await this.reconcilePresencePublishers()
1166
+ await this.recoverOfflineRanges(channels)
1167
+ })()
1168
+ this.resumeWork = work
1169
+ try { await work } catch (err) {
1170
+ if (!this.closePromise) await this.pause(reason)
1171
+ throw err
1172
+ } finally { if (this.resumeWork === work) this.resumeWork = null }
1173
+ }
1174
+
1175
+ receive (pubkey, operation, message = {}) {
1176
+ if (this.closePromise) {
1177
+ if (this.storageActive) this.recordInterruption([pubkey], messageTime(message))
1178
+ return Promise.reject(new Error('PRIVATE_MESSENGER_CLOSED'))
1179
+ }
1180
+ return this.queueIncoming(async () => {
1181
+ if (this.pauseReasons.size || !this.desiredChannels.has(pubkey)) {
1182
+ this.recordInterruption([pubkey], messageTime(message))
1183
+ throw new Error('PRIVATE_MESSENGER_PAUSED')
1184
+ }
1185
+ return operation()
1186
+ })
1187
+ }
1188
+
1067
1189
  nip65WatchChannelPubkeys () {
1068
1190
  return [...this.channels.values()]
1069
1191
  .filter(channel => channel.usesNip65WatchRelays && this.stopByChannel.has(channel.pubkey))
@@ -1114,6 +1236,7 @@ export class PrivateMessenger {
1114
1236
  }
1115
1237
 
1116
1238
  async handleAsk (channelPubkey, message) {
1239
+ if (message.provenance === 'hearsay') return this.enqueueRumor('message', channelPubkey, message)
1117
1240
  this.trackSeederActivity(channelPubkey, message)
1118
1241
  if (doesModeStoreRecoverySeeds(this.channels.get(channelPubkey)?.mode) && messageCode(message) === MISSING_MESSAGES_ASK_CODE) {
1119
1242
  await this.replyWithStoredSeeds(channelPubkey, message)
@@ -1123,6 +1246,7 @@ export class PrivateMessenger {
1123
1246
  }
1124
1247
 
1125
1248
  async handleReply (channelPubkey, message) {
1249
+ if (message.provenance === 'hearsay') return this.enqueueRumor('message', channelPubkey, message)
1126
1250
  this.trackSeederActivity(channelPubkey, message)
1127
1251
  if (messageCode(message) === MISSING_MESSAGES_REPLY_CODE) {
1128
1252
  await this.consumeMissingMessagesReply(channelPubkey, message)
@@ -1137,6 +1261,7 @@ export class PrivateMessenger {
1137
1261
  }
1138
1262
 
1139
1263
  async handleYell (channelPubkey, message) {
1264
+ if (message.provenance === 'hearsay') return this.enqueueRumor('message', channelPubkey, message)
1140
1265
  this.trackSeederActivity(channelPubkey, message)
1141
1266
  if (messageCode(message) === SEEDER_PRESENCE_CODE) return
1142
1267
  await this.enqueueRumor('yell', channelPubkey, message)
@@ -1147,7 +1272,7 @@ export class PrivateMessenger {
1147
1272
  }
1148
1273
 
1149
1274
  async handleMessage (channelPubkey, message) {
1150
- if (eventType(message.event) !== 'message') return
1275
+ if (eventType(message.event) !== 'message' && message.provenance !== 'hearsay' && (!message.senderPubkey || message.senderPubkey === message.event.pubkey)) return
1151
1276
  this.trackSeederActivity(channelPubkey, message)
1152
1277
  await this.enqueueRumor('message', channelPubkey, message)
1153
1278
  }
@@ -1155,10 +1280,12 @@ export class PrivateMessenger {
1155
1280
  async enqueueRumor (type, channelPubkey, message) {
1156
1281
  const channel = this.channels.get(channelPubkey)
1157
1282
  if (channel?.mode === 'watchtower' && type !== 'ask') return
1158
- this.markSeen(channelPubkey, message.outer?.created_at || message.event?.created_at || nowSeconds())
1283
+ const info = deliveryInfo(message.event, message.senderPubkey ?? message.meta?.senderPubkey ?? message.meta?.router?.tags?.find(tag => tag[0] === 'f')?.[1])
1159
1284
  const eventId = message.event?.id || ''
1160
- const dedupeKey = eventId ? [channelPubkey, type, eventId] : null
1285
+ // Stronger provenance must reach the app even while hearsay is pending.
1286
+ const dedupeKey = eventId ? [channelPubkey, type, eventId, info.provenance] : null
1161
1287
  if (dedupeKey && await this.queue.someBy('byChannelTypeEventId', dedupeKey)) {
1288
+ this.markSeen(channelPubkey, message.outer?.created_at || message.event?.created_at || nowSeconds())
1162
1289
  this.debug('dedupe', debugMessageInfo(type, channelPubkey, message))
1163
1290
  return
1164
1291
  }
@@ -1168,6 +1295,7 @@ export class PrivateMessenger {
1168
1295
  channelPubkey,
1169
1296
  receivedAt: nowSeconds(),
1170
1297
  event: message.event,
1298
+ ...info,
1171
1299
  payload: message.payload,
1172
1300
  question: message.question || null,
1173
1301
  questionId: message.questionId || null,
@@ -1177,11 +1305,26 @@ export class PrivateMessenger {
1177
1305
  } catch (err) {
1178
1306
  // The unique index closes the small cross-instance race after `someBy`.
1179
1307
  if (dedupeKey && err?.name === 'ConstraintError') {
1308
+ this.markSeen(channelPubkey, message.outer?.created_at || message.event?.created_at || nowSeconds())
1180
1309
  this.debug('dedupe', debugMessageInfo(type, channelPubkey, message))
1181
1310
  return
1182
1311
  }
1312
+ this.recordInterruption([channelPubkey], message.outer?.created_at || message.event?.created_at || nowSeconds())
1313
+ // Do not await stop from inside its own transport callback.
1314
+ const atCapacity = err.message === 'QUEUE_CAPACITY_EXCEEDED'
1315
+ this.pause(atCapacity ? 'capacity' : 'storage').catch(error => this.onError?.(error))
1316
+ if (atCapacity) {
1317
+ this.capacityRequiredBytes = Math.max(this.capacityRequiredBytes, err.requiredBytes || 0)
1318
+ if (!this.capacityTimer) {
1319
+ this.capacityTimer = setInterval(() => this.checkCapacity().catch(error => this.onError?.(error)), 1000)
1320
+ this.capacityTimer.unref?.()
1321
+ }
1322
+ }
1323
+ await this.flushStateWrites()
1183
1324
  throw err
1184
1325
  }
1326
+ this.markSeen(channelPubkey, message.outer?.created_at || message.event?.created_at || nowSeconds())
1327
+ this.wakeDeliveries()
1185
1328
  this.debug('enqueue', debugMessageInfo(type, channelPubkey, message))
1186
1329
  this.onMessageQueued?.()
1187
1330
  }
@@ -1192,7 +1335,6 @@ export class PrivateMessenger {
1192
1335
  if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
1193
1336
  const carriers = compactSeedNymCarriers(seed.carriers)
1194
1337
  const recordTime = nymCarrierRecordTime({ carriers }) || seed.outer?.created_at || receivedAt
1195
- this.markSeen(channelPubkey, recordTime)
1196
1338
  const key = nymCarrierSeedKey({ channelPubkey, carriers })
1197
1339
  const seedKey = key ? `nym:${key}` : ''
1198
1340
  if (seedKey && await this.seedQueue.someBy('bySeedKey', seedKey)) return
@@ -1211,10 +1353,8 @@ export class PrivateMessenger {
1211
1353
  }
1212
1354
 
1213
1355
  const rows = compactSeedRouterRows(seed)
1214
- let newest = seed.outer?.created_at || receivedAt
1215
1356
  for (const row of rows) {
1216
1357
  const rowTime = row.lastSeenAt || row.router?.created_at || receivedAt
1217
- newest = Math.max(newest, rowTime)
1218
1358
  const rowKey = routerSeedRowKey({ ...row, channelPubkey })
1219
1359
  const seedKey = `router:${rowKey}`
1220
1360
  const [previous] = await this.seedQueue.removeBy('bySeedKey', seedKey)
@@ -1233,20 +1373,105 @@ export class PrivateMessenger {
1233
1373
  [SEED_TIME]: lastSeenAt || rowTime
1234
1374
  })
1235
1375
  }
1236
- this.markSeen(channelPubkey, newest)
1237
1376
  await this.pruneStoredSeeds(channelPubkey)
1238
1377
  }
1239
1378
 
1240
- async * messages () {
1241
- // seedQueue is retained replay material for recovery replies, not an app-message stream.
1242
- for await (const item of this.queue.items()) yield withoutQueueMetadata(item)
1379
+ checkCapacity () {
1380
+ if (this.capacityCheck) return this.capacityCheck
1381
+ const work = (async () => {
1382
+ if (this.closePromise || !this.pauseReasons.has('capacity')) return
1383
+ const { usedBytes, maxBytes } = await this.queue.getCapacity()
1384
+ if (!this.closePromise && usedBytes + this.capacityRequiredBytes <= maxBytes) await this.resume('capacity')
1385
+ })()
1386
+ this.capacityCheck = work
1387
+ work.then(() => { this.capacityCheck = null }, () => { this.capacityCheck = null })
1388
+ return work
1389
+ }
1390
+
1391
+ wakeDeliveries () {
1392
+ for (const wake of this.deliveryWaiters) wake()
1393
+ this.deliveryWaiters.clear()
1394
+ }
1395
+
1396
+ // A cancellable iterator, including while next() is waiting on an empty queue.
1397
+ messages () {
1398
+ let cancelled = false
1399
+ const held = new Set()
1400
+ let tail = Promise.resolve()
1401
+ const next = async () => {
1402
+ while (true) {
1403
+ if (cancelled || this.closePromise) break
1404
+ const delivery = await this.nextMessage()
1405
+ if (delivery) {
1406
+ if (cancelled || this.closePromise) { await delivery.nack(); break }
1407
+ held.add(delivery)
1408
+ delivery.settled.then(() => held.delete(delivery))
1409
+ return { value: delivery, done: false }
1410
+ }
1411
+ await new Promise(resolve => {
1412
+ const wake = () => { clearTimeout(timer); this.deliveryWaiters.delete(wake); resolve() }
1413
+ const timer = setTimeout(wake, 250)
1414
+ this.deliveryWaiters.add(wake)
1415
+ if (cancelled || this.closePromise) wake()
1416
+ })
1417
+ }
1418
+ return { done: true }
1419
+ }
1420
+ return {
1421
+ [Symbol.asyncIterator] () { return this },
1422
+ next: () => { const work = tail.then(next); tail = work.catch(() => {}); return work },
1423
+ return: async () => {
1424
+ cancelled = true
1425
+ this.wakeDeliveries()
1426
+ await tail
1427
+ await Promise.all([...held].map(delivery => delivery.nack()))
1428
+ return { done: true }
1429
+ }
1430
+ }
1243
1431
  }
1244
1432
 
1245
- async nextMessage () {
1246
- // seedQueue is retained replay material for recovery replies, not an app-message stream.
1433
+ nextMessage () {
1434
+ const work = this.reserveMessage()
1435
+ this.deliveryReads.add(work)
1436
+ work.then(() => this.deliveryReads.delete(work), () => this.deliveryReads.delete(work))
1437
+ return work
1438
+ }
1439
+
1440
+ async reserveMessage () {
1441
+ this.assertOpen()
1247
1442
  this.touchStorageActivity().catch(err => this.onError?.(err))
1248
1443
  await this.queueOperationTail
1249
- return withoutQueueMetadata(await this.queue.shift())
1444
+ const reservation = await this.queue.reserve()
1445
+ if (!reservation) return null
1446
+ if (this.closePromise) { await reservation.nack(); return null }
1447
+ let finish
1448
+ const settled = new Promise(resolve => { finish = resolve })
1449
+ const done = () => {
1450
+ clearInterval(timer)
1451
+ this.deliveries.delete(delivery)
1452
+ finish()
1453
+ this.wakeDeliveries()
1454
+ }
1455
+ const settle = async method => {
1456
+ const result = await reservation[method]()
1457
+ done()
1458
+ if (method === 'ack' && result && !this.closePromise && this.pauseReasons.has('capacity')) {
1459
+ this.checkCapacity().catch(err => this.onError?.(err))
1460
+ }
1461
+ return result
1462
+ }
1463
+ const delivery = {
1464
+ message: withoutQueueMetadata(reservation.item),
1465
+ ack: () => settle('ack'),
1466
+ nack: () => settle('nack')
1467
+ }
1468
+ Object.defineProperty(delivery, 'settled', { value: settled })
1469
+ this.deliveries.add(delivery)
1470
+ const timer = setInterval(() => {
1471
+ reservation.renew().then(ok => { if (!ok) done() }, err => { done(); this.onError?.(err) })
1472
+ }, 10000)
1473
+ timer.unref?.()
1474
+ return delivery
1250
1475
  }
1251
1476
 
1252
1477
  async ask ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkey, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
@@ -1444,13 +1669,14 @@ export class PrivateMessenger {
1444
1669
  }
1445
1670
 
1446
1671
  async startPresencePublisher (channelPubkey) {
1447
- if (!this.offlineRecoverySecondsFor(channelPubkey)) return
1672
+ if (this.pauseReasons.size || !this.desiredChannels.has(channelPubkey) || !this.offlineRecoverySecondsFor(channelPubkey)) return
1448
1673
  if (this.presenceTimers.has(channelPubkey)) return
1449
1674
  try {
1450
1675
  await this.publishSeederPresence(channelPubkey)
1451
1676
  } catch (err) {
1452
1677
  console.warn('private-messenger seeder presence failed', err?.message ?? err)
1453
1678
  }
1679
+ if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(channelPubkey)) return
1454
1680
  const timer = this._setInterval(() => {
1455
1681
  return this.publishSeederPresence(channelPubkey).catch(err => {
1456
1682
  console.warn('private-messenger seeder presence failed', err?.message ?? err)
@@ -1469,10 +1695,10 @@ export class PrivateMessenger {
1469
1695
  async reconcilePresencePublishers () {
1470
1696
  const starts = []
1471
1697
  for (const pubkey of [...this.presenceTimers.keys()]) {
1472
- if (!doesModeStoreRecoverySeeds(this.channels.get(pubkey)?.mode) || !this.offlineRecoverySecondsFor(pubkey)) this.stopPresencePublisher(pubkey)
1698
+ if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || !doesModeStoreRecoverySeeds(this.channels.get(pubkey)?.mode) || !this.offlineRecoverySecondsFor(pubkey)) this.stopPresencePublisher(pubkey)
1473
1699
  }
1474
1700
  for (const [pubkey, channel] of this.channels) {
1475
- if (doesModeStoreRecoverySeeds(channel.mode) && this.offlineRecoverySecondsFor(channel)) starts.push(this.startPresencePublisher(pubkey))
1701
+ if (!this.pauseReasons.size && this.desiredChannels.has(pubkey) && doesModeStoreRecoverySeeds(channel.mode) && this.offlineRecoverySecondsFor(channel)) starts.push(this.startPresencePublisher(pubkey))
1476
1702
  else this.stopPresencePublisher(pubkey)
1477
1703
  }
1478
1704
  await Promise.all(starts)
@@ -1497,6 +1723,8 @@ export class PrivateMessenger {
1497
1723
  }
1498
1724
 
1499
1725
  requireWritableChannel (pubkey) {
1726
+ this.assertOpen()
1727
+ if (this.pauseReasons.size) throw new Error('PRIVATE_MESSENGER_PAUSED')
1500
1728
  const channel = this.requireChannel(pubkey)
1501
1729
  if (!channel.signer) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
1502
1730
  return channel
@@ -1576,12 +1804,22 @@ export class PrivateMessenger {
1576
1804
  return this.useContentKeys ? undefined : noContentKeys
1577
1805
  }
1578
1806
 
1807
+ recordRecoveryWindow (pubkey) {
1808
+ const seconds = this.offlineRecoverySecondsFor(pubkey)
1809
+ if (!seconds) return
1810
+ const now = nowSeconds()
1811
+ const current = this.readState().channels[pubkey] || {}
1812
+ const checkpoint = Math.max(current.lastSeenAt || 0, current.recoveredThrough || 0)
1813
+ const since = checkpoint ? checkpoint - this.offlineSkewSeconds : now - seconds
1814
+ this.addOfflineRange(pubkey, Math.max(0, now - seconds, since), now)
1815
+ }
1816
+
1579
1817
  scheduleReloadGap (pubkey) {
1580
1818
  this.cancelReloadGap(pubkey)
1581
1819
  if (!this.offlineRecoverySecondsFor(pubkey)) return
1582
1820
  const current = this.readState().channels[pubkey]
1583
- const start = current?.openOfflineStart || current?.lastSeenAt
1584
- if (!start) return
1821
+ const start = current?.openOfflineStart ?? current?.lastSeenAt
1822
+ if ((start == null && !current?.offlineRanges?.length) || this.pauseReasons.size) return
1585
1823
  const revision = this.watchRevisionByChannel.get(pubkey) || 0
1586
1824
  const token = {}
1587
1825
  const timer = this._setTimeout(async () => {
@@ -1590,8 +1828,8 @@ export class PrivateMessenger {
1590
1828
  this.reloadGapTimers.delete(pubkey)
1591
1829
  if (this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey)) return
1592
1830
  if ((this.watchRevisionByChannel.get(pubkey) || 0) !== revision) return
1593
- this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
1594
- await this.recoverOfflineRanges([pubkey])
1831
+ if (start != null) this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
1832
+ try { await this.recoverOfflineRanges([pubkey]) } catch (err) { this.onError?.(err) }
1595
1833
  }, this.reloadGapDelayMs)
1596
1834
  this.reloadGapTimers.set(pubkey, { timer, token, revision })
1597
1835
  }
@@ -1603,47 +1841,15 @@ export class PrivateMessenger {
1603
1841
  this._clearTimeout(scheduled.timer)
1604
1842
  }
1605
1843
 
1606
- // Browser-offline recovery owns durable gaps. Stop only the child live reads;
1607
- // unwatch() would also stop seeder-presence publishing and alter channel state.
1608
- #pauseLiveWatches () {
1609
- for (const [pubkey, stop] of this.stopByChannel) {
1610
- this.cancelReloadGap(pubkey)
1611
- this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
1612
- stop?.()
1613
- }
1614
- this.stopByChannel.clear()
1615
- }
1616
-
1617
- async #resumeLiveWatches () {
1618
- const channelPubkeys = [...this.channels.keys()]
1619
- await this.watch(channelPubkeys, { scheduleReloadGap: false })
1620
- return channelPubkeys
1621
- }
1622
-
1623
1844
  ensureNetworkWatchers () {
1624
1845
  if (typeof window === 'undefined') return
1625
1846
  if (!this.stopOffline) {
1626
- const offline = () => {
1627
- const state = this.readState()
1628
- const start = Math.max(0, nowSeconds() - this.offlineSkewSeconds)
1629
- for (const pubkey of this.channels.keys()) {
1630
- if (!this.offlineRecoverySecondsFor(pubkey)) continue
1631
- const current = state.channels[pubkey] || {}
1632
- current.openOfflineStart ||= start
1633
- state.channels[pubkey] = current
1634
- }
1635
- this.writeState(state)
1636
- this.#pauseLiveWatches()
1637
- }
1847
+ const offline = () => { this.pause('network').catch(err => this.onError?.(err)) }
1638
1848
  window.addEventListener('offline', offline)
1639
1849
  this.stopOffline = () => window.removeEventListener('offline', offline)
1640
1850
  }
1641
1851
  if (!this.stopOnline) {
1642
- const online = async () => {
1643
- this.closeOpenOfflineRanges()
1644
- const channelPubkeys = await this.#resumeLiveWatches()
1645
- await this.recoverOfflineRanges(channelPubkeys)
1646
- }
1852
+ const online = () => { this.resume('network').catch(err => this.onError?.(err)) }
1647
1853
  window.addEventListener('online', online)
1648
1854
  this.stopOnline = () => window.removeEventListener('online', online)
1649
1855
  }
@@ -1729,6 +1935,7 @@ export class PrivateMessenger {
1729
1935
  await this.enqueueRumor(recovered.type, channelPubkey, {
1730
1936
  event: recovered.event,
1731
1937
  outer: recovered.outer,
1938
+ ...deliveryInfo(recovered.event, recovered.meta?.senderPubkey),
1732
1939
  meta: { ...(recovered.meta || {}), channelPubkey, recoveredFromSeeder: message.event?.pubkey || '' },
1733
1940
  payload: recovered.payload
1734
1941
  })
@@ -1742,7 +1949,7 @@ export class PrivateMessenger {
1742
1949
  type: 'nym',
1743
1950
  event,
1744
1951
  outer: { id: '', created_at: nymCarrierRecordTime(record) },
1745
- meta: { channelPubkey, carriers: record.carriers },
1952
+ meta: { channelPubkey, carriers: record.carriers, ...deliveryInfo(event, record.carriers[0]?.pubkey) },
1746
1953
  payload: parseEventContent(event)
1747
1954
  }
1748
1955
  }
@@ -1787,12 +1994,24 @@ export class PrivateMessenger {
1787
1994
  type: eventType(event),
1788
1995
  event,
1789
1996
  outer,
1790
- meta: { channelPubkey },
1997
+ meta: { channelPubkey, ...deliveryInfo(event, router.tags.find(tag => tag[0] === 'f')?.[1]) },
1791
1998
  payload: parseEventContent(event)
1792
1999
  }
1793
2000
  }
1794
2001
 
1795
2002
  async recoverOfflineRanges (channels = [...this.stopByChannel.keys()]) {
2003
+ for (const pubkey of uniq(channels)) {
2004
+ if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) continue
2005
+ let work = this.recoveries.get(pubkey)
2006
+ if (!work) {
2007
+ work = this.recoverChannelRanges([pubkey])
2008
+ this.recoveries.set(pubkey, work)
2009
+ }
2010
+ try { await work } finally { if (this.recoveries.get(pubkey) === work) this.recoveries.delete(pubkey) }
2011
+ }
2012
+ }
2013
+
2014
+ async recoverChannelRanges (channels) {
1796
2015
  const state = this.readState()
1797
2016
  const now = nowSeconds()
1798
2017
 
@@ -1806,12 +2025,20 @@ export class PrivateMessenger {
1806
2025
  const processedRanges = new Set(current.offlineRanges.map(range => `${range.start}:${range.end}`))
1807
2026
 
1808
2027
  const remaining = []
2028
+ let recoveredThrough = current.recoveredThrough || 0
1809
2029
  for (const range of current.offlineRanges) {
1810
2030
  if (range.end < minStart) continue
1811
2031
  const watchRevision = this.watchRevisionByChannel.get(pubkey) || 0
2032
+ const controller = new AbortController()
2033
+ controller.channelPubkey = pubkey
2034
+ this.recoveryControllers.add(controller)
1812
2035
  try {
2036
+ if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw new Error('PRIVATE_MESSENGER_PAUSED')
1813
2037
  const fetchRelays = await this.resolveWatchRelays(channel)
2038
+ controller.signal.throwIfAborted()
1814
2039
  const fetchedEvents = await this._privateChannel.fetch({
2040
+ signal: controller.signal,
2041
+ receivedChunkScope: this.storageLeaseId,
1815
2042
  receiverSigner: this.userSigner,
1816
2043
  iykcSigner: this.contentKeySigner,
1817
2044
  privateChannelSigner: channel.signer,
@@ -1826,36 +2053,40 @@ export class PrivateMessenger {
1826
2053
  modeByPubkey: { [pubkey]: channel.mode },
1827
2054
  receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1828
2055
  receivedChunkIndexedDB: this._indexedDB,
1829
- onEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) })),
1830
- onNymEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) })),
1831
- onSeedEvent: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
2056
+ onEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2057
+ onNymEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2058
+ onSeedEvent: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
1832
2059
  onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
1833
2060
  onError: err => { throw err }
1834
2061
  }) || []
2062
+ controller.signal.throwIfAborted()
1835
2063
  const attempt = await this.#askSeedersForRelayLeftEdgeAttempt(pubkey, range, fetchedEvents)
1836
2064
  const lifecycleChanged = this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
1837
2065
  (this.watchRevisionByChannel.get(pubkey) || 0) !== watchRevision
1838
2066
  if (lifecycleChanged || attempt.failures.length) remaining.push(range)
2067
+ else recoveredThrough = Math.max(recoveredThrough, range.end)
1839
2068
  } catch (err) {
1840
2069
  this.onError?.(err)
1841
2070
  remaining.push(range)
1842
- }
2071
+ } finally { this.recoveryControllers.delete(controller) }
1843
2072
  }
1844
2073
  const fresh = this.readState()
1845
2074
  const concurrentRanges = (fresh.channels[pubkey]?.offlineRanges || [])
1846
2075
  .filter(range => !processedRanges.has(`${range.start}:${range.end}`))
1847
2076
  fresh.channels[pubkey] = {
1848
2077
  ...(fresh.channels[pubkey] || {}),
2078
+ recoveredThrough: Math.max(fresh.channels[pubkey]?.recoveredThrough || 0, recoveredThrough),
1849
2079
  offlineRanges: mergeRanges(concurrentRanges.concat(remaining))
1850
2080
  }
1851
2081
  this.writeState(fresh)
2082
+ await this.flushStateWrites()
1852
2083
  }
1853
2084
  }
1854
2085
 
1855
2086
  async clearChannel (pubkey) {
2087
+ await this.unwatch(pubkey)
2088
+ await this._privateMessage.clearChannelState?.(pubkey)
1856
2089
  return this.runQueueOperation(async () => {
1857
- await this.unwatch(pubkey)
1858
- await this._privateMessage.clearChannelState?.(pubkey)
1859
2090
  this.channels.delete(pubkey)
1860
2091
  this.removeChannelState(pubkey)
1861
2092
  await this.flushStateWrites()
@@ -1938,6 +2169,9 @@ export class PrivateMessenger {
1938
2169
  this.stopOnline = null
1939
2170
  this.stopStorageMaintenance()
1940
2171
  this.stopStoragePolicyBroadcast()
2172
+ clearInterval(this.capacityTimer)
2173
+ this.capacityTimer = null
2174
+ this.wakeDeliveries()
1941
2175
 
1942
2176
  this.closePromise = (async () => {
1943
2177
  let unwatchError
@@ -1945,6 +2179,9 @@ export class PrivateMessenger {
1945
2179
  await initSettledPromise
1946
2180
  await this.stampActiveChannelActivity()
1947
2181
  await this.queueOperationTail
2182
+ await Promise.allSettled([...this.recoveries.values()])
2183
+ await Promise.allSettled([...this.deliveryReads])
2184
+ await Promise.all([...this.deliveries].map(delivery => delivery.nack()))
1948
2185
  await this.stateWriteTail
1949
2186
  try { await this.storageTouchPromise } catch {}
1950
2187
  await this.storageMaintenancePromise