libp2r2p 0.10.12 → 0.10.14

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
@@ -12,6 +12,35 @@ For remote-work scheduling, see [`libp2r2p/network`](network/README.md):
12
12
  `isOnline` probes connectivity and `onOnline` shares recovery monitoring,
13
13
  including retries when the browser omits its native `online` event.
14
14
 
15
+ ## Relay feed lifecycle
16
+
17
+ `RelayPool.getEventsFeedGenerator(filter, relays, options)` yields events directly,
18
+ including the existing `event.meta.relay` field. By default it combines an initial
19
+ historical query with a live subscription. `live: false` selects history only;
20
+ `filter.limit: 0` selects live only. `getLiveEventsGenerator` exposes the same
21
+ lifecycle controls along with its existing `ready` and `readyRelays` properties.
22
+
23
+ The returned iterator has a synchronous, idempotent `stopAndDrain()` method. It
24
+ closes subscription input and cancels reconnections and outstanding historical
25
+ queries, while retaining events already accepted by the library's receive
26
+ callbacks. Continue consuming the iterator to obtain those events and observe
27
+ completion. This includes initial history, buffered live events, and both history
28
+ and live buffers from reconnect recovery. Calling it before the first `next()`
29
+ prevents subscriptions from opening. No reception timestamp or new event metadata
30
+ is added. Events rejected by ordinary filtering/deduplication remain excluded.
31
+
32
+ Aborting `options.signal`, calling `return()` (including a `for await` break), or
33
+ calling `throw()` cancels input and pending delivery instead. An event already
34
+ delivered to the caller cannot be recalled. These operations also interrupt a
35
+ drain. `stopAndDrain()` does not consume the iterator or return a completion
36
+ promise: completion is the iterator's `{ done: true }` result.
37
+
38
+ ```js
39
+ const stream = relayPool.getEventsFeedGenerator({ authors: [pubkey] }, [relay], { signal })
40
+ // When this relay is removed, call stream.stopAndDrain() from the list handler.
41
+ for await (const event of stream) await store(event)
42
+ ```
43
+
15
44
  ## Private Messenger
16
45
 
17
46
  The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
@@ -291,6 +320,14 @@ NIP-44 v2 uses the interoperable `nip44-v2` salt by default. A custom UTF-8
291
320
  salt of at most 32 bytes may be passed to `getConversationKey()`, but messages
292
321
  derived with it are not interoperable with standard NIP-44 implementations.
293
322
 
323
+ NIP-44 v3 separates binary (`encryptBytes`/`decryptBytes`), UTF-8 text
324
+ (`encrypt`/`decrypt`), and standard-Base64 plaintext
325
+ (`encryptBase64`/`decryptBase64`) interfaces.
326
+ The suffix describes plaintext; ciphertext is always
327
+ standard Base64. NIP-46 uses the Base64 helpers, while browser NIP-07 APIs use
328
+ `ArrayBuffer` plaintext; see [the interface guide](nip44-v3/README.md) before
329
+ adapting a signer.
330
+
294
331
  NIP-46 clients and bunker signers use a 30-second operation timeout by
295
332
  default. Set `timeout` in the `Nip46Client`/`BunkerSigner` constructor to
296
333
  choose another default, override it for an individual `connect()` or RPC, or
package/kind/index.js CHANGED
@@ -7,6 +7,7 @@ export const DELETION = 5
7
7
  export const REPOST = 6
8
8
  export const REACTION = 7
9
9
  export const BADGE_AWARD = 8
10
+ export const CHAT_MESSAGE = 9
10
11
  export const SEAL = 13
11
12
  export const PRIVATE_DIRECT_MESSAGE = 14
12
13
  export const GENERIC_REPOST = 16
@@ -146,6 +147,7 @@ export const eventKinds = /* @__PURE__ */ Object.freeze({
146
147
  REPOST,
147
148
  REACTION,
148
149
  BADGE_AWARD,
150
+ CHAT_MESSAGE,
149
151
  SEAL,
150
152
  PRIVATE_DIRECT_MESSAGE,
151
153
  GENERIC_REPOST,
@@ -0,0 +1,32 @@
1
+ # NIP-44 v3 interfaces
2
+
3
+ These public functions separate bytes, UTF-8 text and Base64 convenience.
4
+ Suffixes describe the plaintext input/output format; ciphertexts are standard
5
+ Base64 strings throughout.
6
+
7
+ | Functions | Plaintext input/output | Scope |
8
+ | --- | --- | --- |
9
+ | `encryptBytes` / `decryptBytes` | `Uint8Array` | UTF-8 bytes (`Uint8Array`) |
10
+ | `encryptWithConversationKeyBytes` / `decryptWithConversationKeyBytes` | `Uint8Array` | UTF-8 bytes (`Uint8Array`) |
11
+ | `encrypt` / `decrypt` | UTF-8 text (`string`) | UTF-8 text (`string`) |
12
+ | `encryptWithConversationKey` / `decryptWithConversationKey` | UTF-8 text (`string`) | UTF-8 text (`string`) |
13
+ | `encryptBase64` / `decryptBase64` | Standard Base64 (`string`) | UTF-8 text (`string`) |
14
+
15
+ The Base64 helpers decode plaintext before encryption and encode decrypted
16
+ bytes before returning them. Base64URL is not this
17
+ format, and arbitrary bytes must not pass through a UTF-8 text helper.
18
+
19
+ The [NIP-07 extension](https://github.com/nostr-land/nip44v3/blob/master/extensions/nip07.md)
20
+ defines `window.nostr.nip44v3.encrypt` with an `ArrayBuffer` plaintext and
21
+ `decrypt` with an `ArrayBuffer` result. Browser signer bridges should adapt
22
+ that boundary to their binary implementation or to the Base64
23
+ [NIP-46 transport](https://github.com/nostr-land/nip44v3/blob/master/extensions/nip46.md).
24
+ The library's text convenience functions are not implementations of that
25
+ browser interface. Do not guess whether a string is literal text or Base64.
26
+
27
+ The [implementation guide](https://github.com/nostr-land/nip44v3/blob/master/implementing.md)
28
+ requires binary support and explicit expected context. Callers choose kind and
29
+ scope from their protocol/event; decrypt verifies them. The
30
+ [NIP-17 extension](https://github.com/nostr-land/nip44v3/blob/master/extensions/nip17.md)
31
+ specifies kind 1059 for gift wraps and 13 for seals, each with empty scope;
32
+ these are protocol choices, not automatic defaults for other event types.
package/nip44-v3/index.js CHANGED
@@ -113,6 +113,8 @@ export function encryptWithConversationKeyBytes (conversationKey, kind, scope, p
113
113
  return bytesToBase64(concatBytes(new Uint8Array([VERSION]), nonce, mac, stuffing))
114
114
  }
115
115
 
116
+ // seckey/expectedScope: Uint8Array, pubkey: hex, ciphertext: standard Base64 string.
117
+ // Verifies the expected kind/scope and returns plaintext bytes as Uint8Array.
116
118
  export function decryptBytes (seckey, pubkey, expectedKind, expectedScope, ciphertext) {
117
119
  return decryptWithConversationKeyBytes(deriveSharedConversationKey(seckey, pubkey), expectedKind, expectedScope, ciphertext)
118
120
  }
@@ -162,8 +164,7 @@ export function normalizeKind (kind) {
162
164
  return n
163
165
  }
164
166
 
165
- // String-oriented helpers for app-facing methods. Plaintext travels as
166
- // base64 on the NIP-07/46 wire so callers can encrypt arbitrary bytes.
167
+ // UTF-8 convenience helpers. Binary callers use encryptBytes/decryptBytes.
167
168
  export function encrypt (seckey, pubkey, kind, scope, plaintext) {
168
169
  return encryptBytes(seckey, pubkey, normalizeKind(kind), utf8ToBytes(scope || ''), utf8ToBytes(plaintext))
169
170
  }
@@ -180,11 +181,14 @@ export function decryptWithConversationKey (conversationKey, kind, scope, cipher
180
181
  return textDecoder.decode(decryptWithConversationKeyBytes(conversationKey, normalizeKind(kind), utf8ToBytes(scope || ''), ciphertext))
181
182
  }
182
183
 
183
- export function nip07Encrypt (seckey, pubkey, kind, scope, plaintextB64) {
184
+ // Base64 convenience helpers: the suffix describes plaintext input/output.
185
+ // Ciphertext is always standard Base64. NIP-46 uses this plaintext format;
186
+ // NIP-07 instead exposes ArrayBuffer plaintext at the browser API boundary.
187
+ export function encryptBase64 (seckey, pubkey, kind, scope, plaintextB64) {
184
188
  return encryptBytes(seckey, pubkey, normalizeKind(kind), utf8ToBytes(scope || ''), base64ToBytes(plaintextB64))
185
189
  }
186
190
 
187
- export function nip07Decrypt (seckey, pubkey, kind, scope, ciphertext) {
191
+ export function decryptBase64 (seckey, pubkey, kind, scope, ciphertext) {
188
192
  return bytesToBase64(decryptBytes(seckey, pubkey, normalizeKind(kind), utf8ToBytes(scope || ''), ciphertext))
189
193
  }
190
194
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.12",
3
+ "version": "0.10.14",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -0,0 +1,17 @@
1
+ // Stopping closes network input but keeps accepted events available to next().
2
+ // Returning/throwing, or aborting the caller signal, cancels consumption as well.
3
+ export function drainableStream (create, options = {}) {
4
+ const cancel = new AbortController()
5
+ const stop = new AbortController()
6
+ const signal = options.signal ? AbortSignal.any([options.signal, cancel.signal]) : cancel.signal
7
+ const stopSignal = options._stopSignal ? AbortSignal.any([options._stopSignal, stop.signal]) : stop.signal
8
+ const stream = create({ ...options, signal, _stopSignal: stopSignal })
9
+ const returnStream = stream.return.bind(stream)
10
+ const throwStream = stream.throw.bind(stream)
11
+ Object.defineProperties(stream, {
12
+ stopAndDrain: { value: () => stop.abort() },
13
+ return: { value: value => { cancel.abort(); return returnStream(value) } },
14
+ throw: { value: error => { cancel.abort(); return throwStream(error) } }
15
+ })
16
+ return stream
17
+ }
@@ -1,6 +1,7 @@
1
1
  import { ValidationError } from '../../error/index.js'
2
2
  import { decodeHll, encodeHll, estimateHllCount, mergeHll } from '../helpers/hll.js'
3
3
  import { createPublishSettlements, firstFulfillment, publishSummary } from '../helpers/publish.js'
4
+ import { drainableStream } from '../helpers/drainable-stream.js'
4
5
  import { maybeUnref } from '../helpers/timer.js'
5
6
  import { categorizeRelayError } from '../helpers/error.js'
6
7
  import { normalizeRelayUrl } from '../../url/index.js'
@@ -480,7 +481,11 @@ export class RelayPool {
480
481
  p = Promise.withResolvers()
481
482
  }
482
483
 
483
- const methodPromise = this.getEvents(filter, relays, { ...options, callback })
484
+ // A drain stops receive callbacks without aborting consumption of this queue.
485
+ const networkSignal = options._stopSignal
486
+ ? AbortSignal.any([options._stopSignal, ...(options.signal ? [options.signal] : [])])
487
+ : options.signal
488
+ const methodPromise = this.getEvents(filter, relays, { ...options, signal: networkSignal, callback })
484
489
  .catch(err => { if (err?.message !== 'Aborted') console.error('Error in getEvents:', err) })
485
490
  .finally(() => {
486
491
  isDone = true
@@ -489,6 +494,7 @@ export class RelayPool {
489
494
 
490
495
  // eslint-disable-next-line no-unmodified-loop-condition
491
496
  while (!isDone || queue.length > 0) {
497
+ if (options.signal?.aborted) break
492
498
  if (queue.length > 0) yield queue.shift()
493
499
  else await p.promise
494
500
  }
@@ -501,7 +507,7 @@ export class RelayPool {
501
507
  getLiveEventsGenerator (filter, relays, options = {}) {
502
508
  const ready = Promise.withResolvers()
503
509
  const readyRelays = new Set()
504
- const stream = this.#getLiveEventsGenerator(filter, relays, options, { ready, readyRelays })
510
+ const stream = drainableStream(options => this.#getLiveEventsGenerator(filter, relays, options, { ready, readyRelays }), options)
505
511
 
506
512
  Object.defineProperties(stream, {
507
513
  ready: {
@@ -521,6 +527,7 @@ export class RelayPool {
521
527
  // suppressing already-live events from another relay.
522
528
  async * #getLiveEventsGenerator (filter, relays, {
523
529
  signal,
530
+ _stopSignal,
524
531
  timeoutAfterFirstEose = 500,
525
532
  timeoutForReconnectGap = 5000,
526
533
  timeoutAfterFirstReconnectGapEose = 500,
@@ -530,6 +537,8 @@ export class RelayPool {
530
537
  const queue = []
531
538
  let p = Promise.withResolvers()
532
539
  let isDone = false
540
+ let draining = false
541
+ const gapTasks = new Set()
533
542
  const liveSubs = new Map() // url → live sub
534
543
  const retryTimers = new Map()
535
544
  const initialPending = new Set(urls)
@@ -537,7 +546,7 @@ export class RelayPool {
537
546
  let readyTimer = null
538
547
  let isReady = false
539
548
 
540
- // Internal abort controller to cancel any in-flight reconnect gap fills on teardown
549
+ // Stop recovery input on teardown, preserving its accepted history for a drain.
541
550
  const gapAc = new AbortController()
542
551
 
543
552
  // Strip time-range fields — we manage them internally
@@ -565,9 +574,11 @@ export class RelayPool {
565
574
  }))
566
575
  }
567
576
 
568
- const teardown = () => {
569
- if (isDone) return
577
+ const teardown = (drain = false) => {
578
+ if (isDone && (drain || !draining)) return
579
+ draining = drain
570
580
  isDone = true
581
+ if (!drain) queue.length = 0
571
582
  clearTimeout(untilTimer)
572
583
  finishReady()
573
584
  gapAc.abort()
@@ -578,8 +589,9 @@ export class RelayPool {
578
589
  p.resolve()
579
590
  }
580
591
 
581
- const pushEvent = (event, url) => {
582
- if (isDone || (event.id && seenIds.has(event.id))) return
592
+ const pushEvent = (event, url, accepted = false) => {
593
+ // Recovery queues accepted these events before input was stopped.
594
+ if ((isDone && !(draining && accepted)) || (event.id && seenIds.has(event.id))) return
583
595
  if (event.id) {
584
596
  if (seenIds.size >= 500) seenIds.delete(seenIds.values().next().value) // evict oldest
585
597
  seenIds.add(event.id)
@@ -591,11 +603,14 @@ export class RelayPool {
591
603
  p = Promise.withResolvers()
592
604
  }
593
605
 
594
- if (signal?.aborted) {
606
+ if (signal?.aborted || _stopSignal?.aborted) {
595
607
  finishReady()
596
608
  return
597
609
  }
598
- signal?.addEventListener('abort', teardown, { once: true })
610
+ const abort = () => teardown()
611
+ const stop = () => teardown(true)
612
+ signal?.addEventListener('abort', abort, { once: true })
613
+ _stopSignal?.addEventListener('abort', stop, { once: true })
599
614
 
600
615
  const maybeFinishInitialReady = () => {
601
616
  if (initialPending.size === 0) finishReady()
@@ -630,7 +645,7 @@ export class RelayPool {
630
645
  // Schedule teardown when the wall clock reaches filter.until
631
646
  if (filterUntil !== null) {
632
647
  const msUntil = filterUntil * 1000 - Date.now()
633
- untilTimer = maybeUnref(setTimeout(teardown, Math.max(0, msUntil)))
648
+ untilTimer = maybeUnref(setTimeout(() => teardown(true), Math.max(0, msUntil)))
634
649
  }
635
650
 
636
651
  // Runs a reconnect gap fill for a single relay and returns a promise that resolves
@@ -641,11 +656,12 @@ export class RelayPool {
641
656
  const gapGen = _gapEventsGenerator(gapFilter, [url], {
642
657
  timeout: timeoutForReconnectGap,
643
658
  timeoutAfterFirstEose: timeoutAfterFirstReconnectGapEose,
644
- signal: gapAc.signal
659
+ signal,
660
+ _stopSignal: gapAc.signal
645
661
  })
646
662
  return (async () => {
647
663
  for await (const item of gapGen) {
648
- if (item?.type === 'event') pushEvent(item.event, url)
664
+ if (item?.type === 'event') pushEvent(item.event, url, true)
649
665
  }
650
666
  })().catch(err => {
651
667
  if (!isDone) console.error(`Reconnect gap fill error for ${url}:`, err)
@@ -673,7 +689,7 @@ export class RelayPool {
673
689
  onevent: (event) => {
674
690
  // A limit:0 relay may still send retained events before EOSE. Do not
675
691
  // expose them from a strictly-live stream.
676
- if (!liveEose) return
692
+ if (isDone || liveSubs.get(url) !== liveSub || !liveEose) return
677
693
  if (liveBuffer) liveBuffer.push(event)
678
694
  else pushEvent(event, url)
679
695
  },
@@ -700,12 +716,14 @@ export class RelayPool {
700
716
  liveSubs.set(url, liveSub)
701
717
 
702
718
  if (gapSince !== null && gapSince > 0) {
703
- runReconnectGapFill(url, gapSince, now).then(() => {
704
- if (isDone) return
719
+ const task = runReconnectGapFill(url, gapSince, now).finally(() => {
705
720
  const buf = liveBuffer
706
721
  liveBuffer = null
707
- for (const event of buf) pushEvent(event, url)
722
+ for (const event of buf) pushEvent(event, url, true)
723
+ gapTasks.delete(task)
724
+ p.resolve()
708
725
  })
726
+ gapTasks.add(task)
709
727
  }
710
728
  }).catch(err => {
711
729
  readyRelays.delete(url)
@@ -729,12 +747,14 @@ export class RelayPool {
729
747
 
730
748
  try {
731
749
  // eslint-disable-next-line no-unmodified-loop-condition
732
- while (!isDone || queue.length > 0) {
750
+ while (!isDone || (draining && gapTasks.size > 0) || queue.length > 0) {
751
+ if (signal?.aborted) break
733
752
  if (queue.length > 0) yield queue.shift()
734
- else await p.promise
753
+ else { await p.promise; p = Promise.withResolvers() }
735
754
  }
736
755
  } finally {
737
- signal?.removeEventListener('abort', teardown)
756
+ signal?.removeEventListener('abort', abort)
757
+ _stopSignal?.removeEventListener('abort', stop)
738
758
  for (const url of urls) this.#decrementLiveSub(url)
739
759
  teardown()
740
760
  }
@@ -752,17 +772,24 @@ export class RelayPool {
752
772
  // relays when null.
753
773
  //
754
774
  // All underlying generators are injectable for testing.
755
- async * getEventsFeedGenerator (filter, relays, {
775
+ getEventsFeedGenerator (filter, relays, options = {}) {
776
+ return drainableStream(options => this.#getEventsFeedGenerator(filter, relays, options), options)
777
+ }
778
+
779
+ async * #getEventsFeedGenerator (filter, relays, {
756
780
  signal,
781
+ _stopSignal,
757
782
  live = true,
758
783
  timeout = 5000,
759
784
  timeoutAfterFirstEose = 500,
760
785
  _liveGenerator = (...args) => this.getLiveEventsGenerator(...args),
761
786
  _eventsGenerator = (...args) => this.getEventsGenerator(...args)
762
787
  } = {}) {
788
+ if (signal.aborted || _stopSignal.aborted) return
763
789
  if (!live) {
764
- const gen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal })
790
+ const gen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
765
791
  for await (const item of gen) {
792
+ if (signal.aborted) return
766
793
  if (item?.type === 'event') yield item.event
767
794
  }
768
795
  return
@@ -770,7 +797,8 @@ export class RelayPool {
770
797
 
771
798
  // limit:0 means "no stored events, live only" — skip the initial fetch.
772
799
  if (filter.limit === 0) {
773
- for await (const event of _liveGenerator(filter, relays, { signal })) {
800
+ for await (const event of _liveGenerator(filter, relays, { signal, _stopSignal })) {
801
+ if (signal.aborted) return
774
802
  yield event
775
803
  }
776
804
  return
@@ -780,7 +808,7 @@ export class RelayPool {
780
808
  // buffering incoming events before we query stored ones.
781
809
  // Relays always send stored matching events before EOSE (unless limit:0),
782
810
  // so the initial fetch + buffering is always needed.
783
- const liveGen = _liveGenerator(filter, relays, { signal })
811
+ const liveGen = _liveGenerator(filter, relays, { signal, _stopSignal })
784
812
  const liveBuffer = []
785
813
  let liveDone = false
786
814
  let liveWake = Promise.withResolvers()
@@ -788,6 +816,7 @@ export class RelayPool {
788
816
  const bgLoop = (async () => {
789
817
  try {
790
818
  for await (const event of liveGen) {
819
+ if (signal.aborted) break
791
820
  liveBuffer.push(event)
792
821
  liveWake.resolve()
793
822
  liveWake = Promise.withResolvers()
@@ -800,10 +829,11 @@ export class RelayPool {
800
829
 
801
830
  try {
802
831
  // Yield stored events from the initial one-shot fetch
803
- const fetchGen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal })
832
+ const fetchGen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
804
833
 
805
834
  const seenIds = new Set()
806
835
  for await (const item of fetchGen) {
836
+ if (signal.aborted) return
807
837
  if (item?.type === 'event' && !seenIds.has(item.event.id)) {
808
838
  seenIds.add(item.event.id)
809
839
  yield item.event
@@ -813,6 +843,7 @@ export class RelayPool {
813
843
  // Flush buffered live events that arrived during the initial fetch, deduping
814
844
  // against stored ones (overlap is possible around the fetch's until boundary)
815
845
  while (liveBuffer.length > 0) {
846
+ if (signal.aborted) return
816
847
  const event = liveBuffer.shift()
817
848
  if (!seenIds.has(event.id)) {
818
849
  seenIds.add(event.id)
@@ -823,11 +854,14 @@ export class RelayPool {
823
854
  // Yield subsequent live events directly — no more overlap with stored events
824
855
  // eslint-disable-next-line no-unmodified-loop-condition
825
856
  while (!liveDone || liveBuffer.length > 0) {
826
- while (liveBuffer.length > 0) yield liveBuffer.shift()
857
+ while (liveBuffer.length > 0) {
858
+ if (signal.aborted) return
859
+ yield liveBuffer.shift()
860
+ }
827
861
  if (!liveDone) await liveWake.promise
828
862
  }
829
863
  } finally {
830
- liveGen.return()
864
+ await liveGen.return()
831
865
  await bgLoop
832
866
  }
833
867
  }