libp2r2p 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -278,6 +278,12 @@ NIP-44 v2 uses the interoperable `nip44-v2` salt by default. A custom UTF-8
278
278
  salt of at most 32 bytes may be passed to `getConversationKey()`, but messages
279
279
  derived with it are not interoperable with standard NIP-44 implementations.
280
280
 
281
+ NIP-46 clients and bunker signers use a 30-second operation timeout by
282
+ default. Set `timeout` in the `Nip46Client`/`BunkerSigner` constructor to
283
+ choose another default, override it for an individual `connect()` or RPC, or
284
+ pass `timeout: null` explicitly when an operation is intentionally allowed to
285
+ wait indefinitely.
286
+
281
287
  Nostr Web Tokens are available from `libp2r2p/nwt`. Creation returns a signed
282
288
  kind `27519` event, while transport encoding is kept separate:
283
289
 
@@ -129,7 +129,9 @@ export class Nip46Client {
129
129
  get pointer () { return { ...this.#pointer, relays: [...this.#pointer.relays] } }
130
130
 
131
131
  // Connects and immediately asks the remote signer for its preferred relays.
132
- async connect ({ requestedPermissions = [], clientMetadata, timeout = null, signal } = {}) {
132
+ // An omitted timeout inherits the configurable constructor timeout; null
133
+ // explicitly disables the response deadline.
134
+ async connect ({ requestedPermissions = [], clientMetadata, timeout, signal } = {}) {
133
135
  const permissions = Array.isArray(requestedPermissions)
134
136
  ? requestedPermissions.filter(permission => typeof permission === 'string' && permission).join(',')
135
137
  : ''
@@ -59,7 +59,7 @@ export class Nip46Transport {
59
59
  #secretKey
60
60
  #pubkey
61
61
  #relayPool
62
- #networkTimeout
62
+ #operationTimeout
63
63
  #timeoutAfterFirstEose
64
64
  #onError
65
65
  #contexts = new Set()
@@ -80,7 +80,7 @@ export class Nip46Transport {
80
80
  this.#secretKey = secretKey
81
81
  this.#pubkey = getPublicKey(secretKey)
82
82
  this.#relayPool = relayPool
83
- this.#networkTimeout = networkTimeout
83
+ this.#operationTimeout = networkTimeout
84
84
  this.#timeoutAfterFirstEose = timeoutAfterFirstEose
85
85
  this.#onError = onError
86
86
  }
@@ -111,7 +111,7 @@ export class Nip46Transport {
111
111
  return context
112
112
  }
113
113
 
114
- async awaitContextReady (context, { timeout = this.#networkTimeout, signal } = {}) {
114
+ async awaitContextReady (context, { timeout = this.#operationTimeout, signal } = {}) {
115
115
  const report = await waitForNip46(context.stream.ready, {
116
116
  timeout,
117
117
  signal,
@@ -159,11 +159,14 @@ export class Nip46Transport {
159
159
  return true
160
160
  }
161
161
 
162
- async sendRequest (peerPubkey, method, params = [], {
163
- timeout = null,
164
- signal,
165
- extension
166
- } = {}) {
162
+ async sendRequest (peerPubkey, method, params = [], options = {}) {
163
+ const {
164
+ // Undefined inherits the constructor-wide operation timeout. Passing
165
+ // null explicitly is the opt-out for RPCs that may wait indefinitely.
166
+ timeout = this.#operationTimeout,
167
+ signal,
168
+ extension
169
+ } = options
167
170
  if (this.#closed) throw new Error('NIP46_CLOSED')
168
171
  if (typeof method !== 'string' || !method) throw new ValidationError('NIP46_METHOD_REQUIRED')
169
172
  if (!Array.isArray(params) || !params.every(param => typeof param === 'string')) {
@@ -229,7 +232,7 @@ export class Nip46Transport {
229
232
  if (!relays.length) throw new Error('NIP46_NO_READY_RELAYS')
230
233
  const event = createNip46Event({ secretKey: this.#secretKey, recipientPubkey: peerPubkey, payload })
231
234
  const published = await this.#relayPool.sendEvent(event, relays, {
232
- timeout: this.#networkTimeout,
235
+ timeout: this.#operationTimeout,
233
236
  timeoutUntilFirstFulfillment: null
234
237
  })
235
238
  if (!published.success) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -211,6 +211,7 @@ export class PrivateMessenger {
211
211
  _pickRelaysForPubkeys = pickRelaysForPubkeys,
212
212
  _subscribeRelayListUpdates = subscribeRelayListUpdates,
213
213
  _setTimeout = globalThis.setTimeout.bind(globalThis),
214
+ _clearTimeout = globalThis.clearTimeout.bind(globalThis),
214
215
  _setInterval = globalThis.setInterval.bind(globalThis),
215
216
  _clearInterval = globalThis.clearInterval.bind(globalThis),
216
217
  _storageSetInterval = globalThis.setInterval.bind(globalThis),
@@ -241,6 +242,7 @@ export class PrivateMessenger {
241
242
  this._pickRelaysForPubkeys = _pickRelaysForPubkeys
242
243
  this._subscribeRelayListUpdates = _subscribeRelayListUpdates
243
244
  this._setTimeout = _setTimeout
245
+ this._clearTimeout = _clearTimeout
244
246
  this._setInterval = _setInterval
245
247
  this._clearInterval = _clearInterval
246
248
  this._storageSetInterval = _storageSetInterval
@@ -260,6 +262,8 @@ export class PrivateMessenger {
260
262
  this.stateWriteTail = Promise.resolve()
261
263
  this.channels = new Map()
262
264
  this.stopByChannel = new Map()
265
+ this.reloadGapTimers = new Map()
266
+ this.watchRevisionByChannel = new Map()
263
267
  this.presenceTimers = new Map()
264
268
  this.stopRelayListWatcher = null
265
269
  this.relayListWatcherPubkey = ''
@@ -642,13 +646,13 @@ export class PrivateMessenger {
642
646
  this.lastStorageTouch = Date.now()
643
647
  if (updatesStoragePolicy) this.broadcastStoragePolicyChange()
644
648
 
645
- await this.unwatch([...this.channels.keys()])
646
- this.channels.clear()
649
+ await this.unwatch(removedPubkeys)
650
+ for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
647
651
  for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
648
652
 
649
653
  await this.cleanupStaleChannels({ storageSnapshot })
650
654
  await this.applyRecoveryPolicies(nextChannels)
651
- await this.watch()
655
+ await this.watch([...nextPubkeys])
652
656
  await this.reconcilePresencePublishers()
653
657
  if (this.storagePolicyRevision === storageSnapshot.policyRevision) {
654
658
  this.storagePolicyNeedsApply = false
@@ -1023,6 +1027,7 @@ export class PrivateMessenger {
1023
1027
  this.assertOpen()
1024
1028
  }
1025
1029
  this.stopByChannel.set(pubkey, stop)
1030
+ this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
1026
1031
  this.updateChannelState(pubkey, {
1027
1032
  lastWatchedAt: nowSeconds(),
1028
1033
  mode: channel.mode,
@@ -1048,6 +1053,8 @@ export class PrivateMessenger {
1048
1053
  const channelPubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.stopByChannel.keys()]
1049
1054
  const closing = []
1050
1055
  for (const pubkey of channelPubkeys) {
1056
+ this.cancelReloadGap(pubkey)
1057
+ this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
1051
1058
  const close = this.stopByChannel.get(pubkey)?.()
1052
1059
  if (close && typeof close.then === 'function') closing.push(close)
1053
1060
  this.stopByChannel.delete(pubkey)
@@ -1570,20 +1577,40 @@ export class PrivateMessenger {
1570
1577
  }
1571
1578
 
1572
1579
  scheduleReloadGap (pubkey) {
1580
+ this.cancelReloadGap(pubkey)
1573
1581
  if (!this.offlineRecoverySecondsFor(pubkey)) return
1574
1582
  const current = this.readState().channels[pubkey]
1575
1583
  const start = current?.openOfflineStart || current?.lastSeenAt
1576
1584
  if (!start) return
1577
- this._setTimeout(async () => {
1585
+ const revision = this.watchRevisionByChannel.get(pubkey) || 0
1586
+ const token = {}
1587
+ const timer = this._setTimeout(async () => {
1588
+ const scheduled = this.reloadGapTimers.get(pubkey)
1589
+ if (scheduled?.token !== token) return
1590
+ this.reloadGapTimers.delete(pubkey)
1591
+ if (this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey)) return
1592
+ if ((this.watchRevisionByChannel.get(pubkey) || 0) !== revision) return
1578
1593
  this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
1579
1594
  await this.recoverOfflineRanges([pubkey])
1580
1595
  }, this.reloadGapDelayMs)
1596
+ this.reloadGapTimers.set(pubkey, { timer, token, revision })
1597
+ }
1598
+
1599
+ cancelReloadGap (pubkey) {
1600
+ const scheduled = this.reloadGapTimers.get(pubkey)
1601
+ if (!scheduled) return
1602
+ this.reloadGapTimers.delete(pubkey)
1603
+ this._clearTimeout(scheduled.timer)
1581
1604
  }
1582
1605
 
1583
1606
  // Browser-offline recovery owns durable gaps. Stop only the child live reads;
1584
1607
  // unwatch() would also stop seeder-presence publishing and alter channel state.
1585
1608
  #pauseLiveWatches () {
1586
- for (const stop of this.stopByChannel.values()) stop?.()
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
+ }
1587
1614
  this.stopByChannel.clear()
1588
1615
  }
1589
1616
 
@@ -1623,32 +1650,49 @@ export class PrivateMessenger {
1623
1650
  }
1624
1651
 
1625
1652
  async askSeedersForMissingRange (channelPubkey, since, until) {
1626
- if (!this.offlineRecoverySecondsFor(channelPubkey)) return []
1627
- if (!this.channels.get(channelPubkey)?.signer) return []
1653
+ const { asks } = await this.#askSeedersForMissingRangeAttempt(channelPubkey, since, until)
1654
+ return asks
1655
+ }
1656
+
1657
+ async #askSeedersForMissingRangeAttempt (channelPubkey, since, until) {
1658
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return { asks: [], failures: [] }
1659
+ if (!this.channels.get(channelPubkey)?.signer) return { asks: [], failures: [] }
1628
1660
  const seeders = this.recoverySeeders(channelPubkey)
1629
- if (!seeders.length || until < since) return []
1661
+ if (!seeders.length || until < since) return { asks: [], failures: [] }
1630
1662
 
1631
1663
  const asks = []
1664
+ const failures = []
1632
1665
  for (const seeder of seeders) {
1633
1666
  try {
1634
- asks.push(await this.ask({
1667
+ const ask = await this.ask({
1635
1668
  channelPubkey,
1636
1669
  receiverPubkey: seeder,
1637
1670
  code: MISSING_MESSAGES_ASK_CODE,
1638
1671
  payload: { since, until }
1639
- }))
1672
+ })
1673
+ asks.push(ask)
1674
+ const reports = ask?.delivery?.reports
1675
+ if (!Array.isArray(reports) || !reports.length || reports.some(report => report?.success !== true)) {
1676
+ throw new Error('PRIVATE_MESSAGE_NOT_PUBLISHED')
1677
+ }
1640
1678
  } catch (err) {
1679
+ failures.push({ seeder, error: err })
1641
1680
  console.warn('private-messenger seeder recovery ask failed', seeder, err?.message ?? err)
1642
1681
  }
1643
1682
  }
1644
- return asks
1683
+ return { asks, failures }
1645
1684
  }
1646
1685
 
1647
1686
  async askSeedersForRelayLeftEdge (channelPubkey, range, fetchedEvents) {
1687
+ const { asks } = await this.#askSeedersForRelayLeftEdgeAttempt(channelPubkey, range, fetchedEvents)
1688
+ return asks
1689
+ }
1690
+
1691
+ async #askSeedersForRelayLeftEdgeAttempt (channelPubkey, range, fetchedEvents) {
1648
1692
  const oldest = oldestCreatedAt(fetchedEvents)
1649
1693
  const until = oldest == null ? range.end : Math.min(range.end, oldest)
1650
- if (until < range.start) return []
1651
- return this.askSeedersForMissingRange(channelPubkey, range.start, until)
1694
+ if (until < range.start) return { asks: [], failures: [] }
1695
+ return this.#askSeedersForMissingRangeAttempt(channelPubkey, range.start, until)
1652
1696
  }
1653
1697
 
1654
1698
  async replyWithStoredSeeds (channelPubkey, message) {
@@ -1759,10 +1803,12 @@ export class PrivateMessenger {
1759
1803
  const recoverySeconds = this.offlineRecoverySecondsFor(channel)
1760
1804
  if (!recoverySeconds) continue
1761
1805
  const minStart = now - recoverySeconds
1806
+ const processedRanges = new Set(current.offlineRanges.map(range => `${range.start}:${range.end}`))
1762
1807
 
1763
1808
  const remaining = []
1764
1809
  for (const range of current.offlineRanges) {
1765
1810
  if (range.end < minStart) continue
1811
+ const watchRevision = this.watchRevisionByChannel.get(pubkey) || 0
1766
1812
  try {
1767
1813
  const fetchRelays = await this.resolveWatchRelays(channel)
1768
1814
  const fetchedEvents = await this._privateChannel.fetch({
@@ -1786,16 +1832,21 @@ export class PrivateMessenger {
1786
1832
  onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
1787
1833
  onError: err => { throw err }
1788
1834
  }) || []
1789
- await this.askSeedersForRelayLeftEdge(pubkey, range, fetchedEvents)
1835
+ const attempt = await this.#askSeedersForRelayLeftEdgeAttempt(pubkey, range, fetchedEvents)
1836
+ const lifecycleChanged = this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
1837
+ (this.watchRevisionByChannel.get(pubkey) || 0) !== watchRevision
1838
+ if (lifecycleChanged || attempt.failures.length) remaining.push(range)
1790
1839
  } catch (err) {
1791
1840
  this.onError?.(err)
1792
1841
  remaining.push(range)
1793
1842
  }
1794
1843
  }
1795
1844
  const fresh = this.readState()
1845
+ const concurrentRanges = (fresh.channels[pubkey]?.offlineRanges || [])
1846
+ .filter(range => !processedRanges.has(`${range.start}:${range.end}`))
1796
1847
  fresh.channels[pubkey] = {
1797
1848
  ...(fresh.channels[pubkey] || {}),
1798
- offlineRanges: remaining
1849
+ offlineRanges: mergeRanges(concurrentRanges.concat(remaining))
1799
1850
  }
1800
1851
  this.writeState(fresh)
1801
1852
  }