libp2r2p 0.10.21 → 0.10.22

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
@@ -21,6 +21,7 @@ never decorated with `meta`; relay provenance belongs to its enclosing item:
21
21
  { type: 'event', event, relay }
22
22
  { type: 'error', relay, error }
23
23
  { type: 'eose', relays: [{ relay, status, error }] } // error is optional
24
+ { type: 'live-progress', relay, epoch, since, until } // live and live-enabled feeds only
24
25
  ```
25
26
 
26
27
  `eose` means the **initial read attempt is complete**, including empty reads and
@@ -101,6 +102,51 @@ content-key, private-channel and NIP-46 helpers unwrap pool results internally
101
102
  and retain their higher-level event contracts. Count, publication and disconnect
102
103
  return formats are unchanged.
103
104
 
105
+ ### Live overlap and progress (0.10.22)
106
+
107
+ Every live subscription opens with `since = max(filter.since ?? 0, now - 600, 0)`,
108
+ including its first connection. This accepts events that arrive after opening
109
+ but carry a timestamp up to ten minutes earlier. Explicit filter boundaries still
110
+ apply, and retained events before EOSE remain excluded from strictly-live output.
111
+
112
+ Reconnect recovery overlaps each relay's last received timestamp by 600 seconds,
113
+ without going below the caller's `since` or zero. Empty connections use their
114
+ opening time instead. Cursors advance independently even for events deduplicated
115
+ across relays; future timestamps are capped at their local receipt time. Failed
116
+ or interrupted recoveries retain their earlier baseline for the next reconnect.
117
+
118
+ Live readers automatically enqueue a `live-progress` envelope every 60 seconds
119
+ while ready, including when no events arrive. There is no enabling option.
120
+ `epoch` is a numeric connection-attempt identifier scoped to this iterator;
121
+ compare it together with `relay`, not across separate iterators. `since` is the
122
+ local EOSE time (or the explicit filter lower bound if later), and `until` is the
123
+ local time at the tick, both inclusive Unix seconds. Repeated markers describe
124
+ the same continuous epoch with an increasing `until`.
125
+
126
+ Progress starts only after that relay's EOSE and successful completion of any
127
+ pending recovery. It pauses on disconnection, does not span connection epochs,
128
+ and stops on cancellation, draining or `until`. History-only and empty-relay
129
+ reads do not emit it. Timers never issue extra network queries.
130
+
131
+ Markers share the bounded queue with events and follow all earlier received
132
+ items. A feed preserves that ordering behind its initial history and queued live
133
+ events. Consumers must finish persisting preceding events before persisting
134
+ progress, and must not treat a marker as proof of historical completeness or as
135
+ coverage of a disconnected interval. This is client-observed continuity, not a
136
+ relay acknowledgement; delayed relay delivery or clock skew beyond the overlap
137
+ can still leave gaps. A backward local clock cannot regress an emitted boundary.
138
+
139
+ Every involuntary live close emits an error and still permits automatic recovery.
140
+ A close without an explicit cause uses `error.code === 'RELAY_LIVE_INTERRUPTED'`
141
+ and `category === 'transport'`; a pre-EOSE close retains status `closed` in the
142
+ initial report. Explicit causes are preserved. Caller cancellation, draining and
143
+ expiry of `filter.until` do not manufacture interruption errors.
144
+
145
+ Process Nostr events only under `item.type === 'event'`. Handle controls you need
146
+ and ignore unknown types; a new control is neither an event nor stream completion.
147
+ The internal query, private-channel and NIP-46 consumers retain their existing
148
+ higher-level event-only contracts.
149
+
104
150
  ### Read admission and bounded snapshots (0.10.21)
105
151
 
106
152
  Each pool coordinates event reads by normalized connection URL. Constructor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.21",
3
+ "version": "0.10.22",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -13,6 +13,8 @@ const COUNT_TIMEOUT_MS = 5000
13
13
  const COUNT_TIMEOUT_AFTER_FIRST_COUNT_MS = 500
14
14
  const SEND_TIMEOUT_UNTIL_FIRST_FULFILLMENT_MS = 3000
15
15
  const SEND_TIMEOUT_MS = 30000
16
+ const LIVE_OVERLAP_SECONDS = 600
17
+ const LIVE_PROGRESS_INTERVAL_MS = 60000
16
18
 
17
19
  // Returns a function that should be called for each received event (valid or invalid).
18
20
  // Calls onSatisfied() and stops counting once the filter is fully satisfied per relay:
@@ -607,8 +609,34 @@ export class RelayPool {
607
609
  // Preserve until for forwarding to the live sub filter and the teardown timer
608
610
  const filterUntil = filter.until > 0 ? filter.until : null
609
611
 
610
- // lastSeenAt: the highest created_at received so far; used as since on reconnect gap fill
611
- let lastSeenAt = (filter.since > 0) ? filter.since : null
612
+ // Recovery uses each relay's received timestamps, or its opening time when empty.
613
+ const lastSeenAt = new Map()
614
+ const openedAt = new Map()
615
+ const pendingGaps = new Map()
616
+ const attempts = new Map()
617
+ let nextEpoch = 0
618
+ const sinceFloor = Math.max(0, filter.since ?? 0)
619
+ const overlappingSince = timestamp => Math.max(sinceFloor, timestamp - LIVE_OVERLAP_SECONDS)
620
+ const observeEvent = (event, url) => {
621
+ // Every relay advances independently, including cross-relay duplicates.
622
+ // Future-dated events cannot hide an offline interval during recovery.
623
+ const timestamp = Math.min(event.created_at, Math.floor(Date.now() / 1000))
624
+ if (Number.isFinite(timestamp) && timestamp > (lastSeenAt.get(url) ?? -Infinity)) lastSeenAt.set(url, timestamp)
625
+ }
626
+ const scheduleProgress = attempt => {
627
+ if (isDone || attempt.closed || attempts.get(attempt.url) !== attempt ||
628
+ attempt.since === null || !attempt.recovered || attempt.timer) return
629
+ attempt.timer = maybeUnref(setTimeout(() => {
630
+ attempt.timer = null
631
+ if (isDone || attempt.closed || attempts.get(attempt.url) !== attempt) return
632
+ const until = Math.min(filterUntil ?? Infinity, Math.floor(Date.now() / 1000))
633
+ if (until > attempt.lastProgress && until >= attempt.since) {
634
+ enqueue({ type: 'live-progress', relay: attempt.url, epoch: attempt.epoch, since: attempt.since, until })
635
+ attempt.lastProgress = until
636
+ }
637
+ scheduleProgress(attempt)
638
+ }, LIVE_PROGRESS_INTERVAL_MS))
639
+ }
612
640
 
613
641
  // Bounded dedup set to handle overlap between reconnect gap fill and live sub.
614
642
  const seenIds = new Set()
@@ -661,6 +689,10 @@ export class RelayPool {
661
689
  clearTimeout(untilTimer)
662
690
  finishReady('closed', false)
663
691
  gapAc.abort()
692
+ for (const attempt of attempts.values()) {
693
+ clearTimeout(attempt.timer)
694
+ attempt.stop.abort()
695
+ }
664
696
  for (const timer of retryTimers.values()) clearTimeout(timer)
665
697
  retryTimers.clear()
666
698
  liveSubs.forEach(sub => sub.close())
@@ -677,7 +709,6 @@ export class RelayPool {
677
709
  if (seenIds.size >= 500) seenIds.delete(seenIds.values().next().value) // evict oldest
678
710
  seenIds.add(event.id)
679
711
  }
680
- if (event.created_at > (lastSeenAt ?? 0)) lastSeenAt = event.created_at
681
712
  enqueue({ type: 'event', event, relay: url })
682
713
  }
683
714
 
@@ -714,10 +745,10 @@ export class RelayPool {
714
745
  maybeFinishInitialReady()
715
746
  }
716
747
  }
717
- const reportClosed = url => {
748
+ const reportClosed = (url, error) => {
718
749
  if (!isReady && initialPending.delete(url)) {
719
750
  clearTimeout(readyTimeouts.get(url))
720
- initialOutcomes.set(url, { relay: url, status: 'closed' })
751
+ initialOutcomes.set(url, { relay: url, status: 'closed', error })
721
752
  maybeFinishInitialReady()
722
753
  }
723
754
  }
@@ -727,7 +758,7 @@ export class RelayPool {
727
758
  const nextDelay = Math.min(reconnectDelay * 2, 5 * 60_000)
728
759
  const timer = maybeUnref(setTimeout(() => {
729
760
  retryTimers.delete(url)
730
- subscribeToRelay(url, lastSeenAt, nextDelay)
761
+ subscribeToRelay(url, pendingGaps.get(url) ?? lastSeenAt.get(url) ?? openedAt.get(url) ?? null, nextDelay)
731
762
  }, reconnectDelay))
732
763
  retryTimers.set(url, timer)
733
764
  }
@@ -740,24 +771,35 @@ export class RelayPool {
740
771
 
741
772
  // Runs a reconnect gap fill for a single relay and returns a promise that resolves
742
773
  // when it completes. now is shared with the live sub so both use the same boundary.
743
- const runReconnectGapFill = (url, gapSince, now, slots) => {
774
+ const runReconnectGapFill = (url, gapSince, now, slots, attempt) => {
744
775
  const gapUntil = filterUntil !== null ? Math.min(now, filterUntil) : now
745
- const gapFilter = { ...baseFilter, since: gapSince, until: gapUntil }
776
+ const gapFilter = { ...baseFilter, since: overlappingSince(gapSince), until: gapUntil }
746
777
  const gapGen = _gapEventsGenerator(gapFilter, [url], {
747
778
  _admissions: new Map([[url, Promise.resolve(slots)]]),
748
779
  queueTimeout,
749
780
  timeout: timeoutForReconnectGap,
750
781
  timeoutAfterFirstEose: timeoutAfterFirstReconnectGapEose,
751
782
  signal,
752
- _stopSignal: gapAc.signal
783
+ _stopSignal: AbortSignal.any([gapAc.signal, attempt.stop.signal])
753
784
  })
754
785
  return (async () => {
786
+ let completed = false
787
+ let failed = false
755
788
  for await (const item of gapGen) {
756
- if (item?.type === 'event') pushEvent(item.event, url, true)
757
- else if (item?.type === 'error' && !isDone) enqueue(item)
789
+ if (item?.type === 'event') {
790
+ observeEvent(item.event, url)
791
+ pushEvent(item.event, url, true)
792
+ } else if (item?.type === 'error') {
793
+ failed = true
794
+ if (!isDone) enqueue(item)
795
+ } else if (item?.type === 'eose') {
796
+ completed = item.relays?.length === 1 && ['eose', 'satisfied'].includes(item.relays[0].status)
797
+ }
758
798
  }
799
+ return completed && !failed
759
800
  })().catch(err => {
760
801
  reportFailure(url, asError(err))
802
+ return false
761
803
  }).finally(() => slots.history.release())
762
804
  }
763
805
 
@@ -766,7 +808,10 @@ export class RelayPool {
766
808
  // Don't reconnect if we're past the until boundary
767
809
  if (filterUntil !== null && now >= filterUntil) return
768
810
  const reservation = !initialAdmissions.has(url) && _admissions?.get(url)
769
- const recovering = gapSince !== null && gapSince > 0
811
+ const recovering = gapSince !== null
812
+ if (recovering) pendingGaps.set(url, gapSince)
813
+ const attempt = { url, epoch: ++nextEpoch, since: null, lastProgress: -Infinity, recovered: !recovering, timer: null, closed: false, stop: new AbortController() }
814
+ attempts.set(url, attempt)
770
815
  let recoveryLease
771
816
  initialAdmissions.add(url)
772
817
  Promise.resolve(reservation || this.#admission.acquire(normalizeRelayUrl(url), { live: true, history: recovering, signal: gapAc.signal, queueTimeout })).then(async slots => {
@@ -790,20 +835,22 @@ export class RelayPool {
790
835
 
791
836
  // Buffer post-EOSE live events while a reconnect gap fill is running so
792
837
  // the historical gap is yielded before newer socket events.
793
- let liveBuffer = (gapSince !== null && gapSince > 0) ? [] : null
838
+ let liveBuffer = recovering ? [] : null
794
839
  let liveEose = false
795
840
  let bufferedBytes = 0
796
841
 
797
842
  // Open the live sub first so the relay starts buffering incoming events
798
843
  // before we scan its database for the reconnect gap fill.
799
844
  // Forward until to the relay so it can enforce the boundary server-side.
800
- const liveFilter = { ...baseFilter, since: now, limit: 0 }
845
+ if (!openedAt.has(url)) openedAt.set(url, now)
846
+ const liveFilter = { ...baseFilter, since: overlappingSince(now), limit: 0 }
801
847
  if (filterUntil !== null) liveFilter.until = filterUntil
802
848
  const liveSub = relay.subscribe([liveFilter], {
803
849
  onevent: (event) => {
804
850
  // A limit:0 relay may still send retained events before EOSE. Do not
805
851
  // expose them from a strictly-live stream.
806
- if (isDone || liveSubs.get(url) !== liveSub || !liveEose) return
852
+ if (isDone || liveSubs.get(url) !== liveSub || attempt.closed || !liveEose) return
853
+ observeEvent(event, url)
807
854
  if (liveBuffer) {
808
855
  const bytes = encoder.encode(JSON.stringify(event)).byteLength
809
856
  if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
@@ -815,33 +862,44 @@ export class RelayPool {
815
862
  } else pushEvent(event, url)
816
863
  },
817
864
  onclose: error => {
865
+ if (attempt.closed || attempts.get(url) !== attempt) return
866
+ attempt.closed = true
867
+ clearTimeout(attempt.timer)
868
+ attempt.stop.abort()
818
869
  if (liveSubs.get(url) === liveSub) liveSubs.delete(url)
819
- else if (liveSubs.has(url)) return
820
870
  lease.release()
821
871
  if (liveLeases.get(url) === lease) liveLeases.delete(url)
822
872
  readyRelays.delete(url)
823
- if (!isDone) {
824
- if (error !== undefined) reportFailure(url, asError(error))
825
- else if (!liveEose) reportClosed(url)
826
- }
827
873
  if (isDone) return
874
+ if (error !== undefined) reportFailure(url, asError(error))
875
+ else {
876
+ const interruption = Object.assign(categorizeRelayError(new Error('RELAY_LIVE_INTERRUPTED'), 'transport'), { code: 'RELAY_LIVE_INTERRUPTED' })
877
+ enqueue({ type: 'error', relay: url, error: interruption })
878
+ reportClosed(url, interruption)
879
+ }
828
880
  scheduleReconnect(url, reconnectDelay)
829
881
  },
830
882
  oneose: () => {
831
- if (isDone || (liveSubs.has(url) && liveSubs.get(url) !== liveSub)) return
883
+ if (isDone || attempt.closed || attempts.get(url) !== attempt || liveEose) return
832
884
  liveEose = true
885
+ attempt.since = Math.max(sinceFloor, Math.floor(Date.now() / 1000))
833
886
  markInitialEose(url)
887
+ scheduleProgress(attempt)
834
888
  }
835
889
  })
836
890
  if (isDone) { liveSub.close(); recoveryLease?.release(); return }
837
891
  liveSubs.set(url, liveSub)
838
892
 
839
- if (gapSince !== null && gapSince > 0) {
840
- const task = runReconnectGapFill(url, gapSince, now, slots).finally(() => {
893
+ if (recovering) {
894
+ const task = runReconnectGapFill(url, gapSince, now, slots, attempt).then(recovered => {
895
+ attempt.recovered = recovered
896
+ if (recovered && !attempt.closed && attempts.get(url) === attempt) pendingGaps.delete(url)
897
+ }).finally(() => {
841
898
  const buf = liveBuffer
842
899
  liveBuffer = null
843
900
  for (const event of buf) pushEvent(event, url, true)
844
901
  gapTasks.delete(task)
902
+ scheduleProgress(attempt)
845
903
  p.resolve()
846
904
  })
847
905
  gapTasks.add(task)
@@ -939,7 +997,7 @@ export class RelayPool {
939
997
 
940
998
  // limit:0 is a live-only stream and reports that stream's initial window.
941
999
  if (filter.limit === 0) {
942
- yield * _liveGenerator(filter, relays, options)
1000
+ yield * _liveGenerator(filter, relays, { ...options, maxBufferedLiveEvents, maxBufferedLiveBytes })
943
1001
  return
944
1002
  }
945
1003