npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

libp2r2p

v0.10.4

Published

Peer-to-relay-to-peer

Readme

libp2r2p

Peer-to-relay-to-peer utilities for Nostr apps.

libp2r2p focuses on flows where one peer talks to another peer with Nostr relays in the middle. It is not pure peer-to-peer networking; relays provide the transport and discovery surface. The package was born to distribute the private messenger reference implementation, and it also carries a few Nostr power-ups used by that messenger.

Private Messenger

The main API is createPrivateMessenger from libp2r2p/private-messenger. It coordinates private-channel wrapping, relay watching, recovery state, and content-key lookup for direct or group-style private app messages.

import { createPrivateMessenger } from 'libp2r2p/private-messenger'

const messenger = await createPrivateMessenger({
  userSigner,
  contentKeySigner,
  offlineRecoverySeconds: 7 * 24 * 60 * 60,
  staleChannelSeconds: 45 * 24 * 60 * 60,
  identityStorageRetentionSeconds: 60 * 24 * 60 * 60,
  channels: [{
    signer: privateChannelSigner,
    relays: ['wss://relay.example'],
    mode: 'leecher',
    offlineRecoverySeconds: 30 * 24 * 60 * 60
  }],
  onError: err => console.warn('private messenger failed', err)
})

async function logMessages () {
  for await (const message of messenger.messages()) {
    console.log(message.type, message.payload)
  }
}

logMessages().catch(err => console.warn('private messenger messages failed', err))

await messenger.tell({
  receiverPubkey,
  payload: { text: 'hello' }
})

Deleting Private Broadcasts

By default, each high-level private-message send creates one fresh deletion keypair for its logical message. Every outer kind 3560 event produced for that send, including router chunks, recipient subsets, and nym carriers, carries the same public key in its s tag. The result always contains delivery.reports. When libp2r2p generated the keypair, it also contains delivery.deletionSeckey; the public key can be derived from that secret.

This shared s value deliberately makes the outer events for one logical send linkable to relay operators and other observers. Disable automatic capabilities when that tradeoff is not acceptable. The messenger-wide setting defaults to true, and a channel setting takes precedence:

const messenger = await createPrivateMessenger({
  userSigner,
  autoDeletionCapability: false,
  channels: [{
    signer: privateChannelSigner,
    relays: ['wss://relay.example'],
    autoDeletionCapability: true
  }]
})

With automatic capabilities disabled and no caller-supplied key, the outer events have no s tag. They are not deliberately linkable through this extension, but cannot later be deleted with it. A caller that already owns a deletion key can supply its public key on an individual send; libp2r2p then does not generate or return a key. Use a fresh caller-owned key for each logical message unless cross-message linkability is intentional:

import { generateKeypair } from 'libp2r2p/key'

const deletionKey = generateKeypair()
await messenger.tell({
  receiverPubkey,
  payload: { text: 'remove this later' },
  deletionPubkey: deletionKey.pubkey
})
import { finalizeEvent } from 'libp2r2p/event'
import { keypairFromSeckey } from 'libp2r2p/key'
import { relayPool } from 'libp2r2p/relay'

const sent = await messenger.tell({
  receiverPubkey,
  payload: { text: 'remove this later' }
})

if (sent.delivery.deletionSeckey) {
  const deletionKey = keypairFromSeckey(sent.delivery.deletionSeckey)
  // Persist this secret with the application's copy of the logical message.

  const { result: outerEvents } = await relayPool.getEvents({
    kinds: [3560],
    authors: [channelPubkey],
    '#s': [deletionKey.pubkey]
  }, relays)
  for (let offset = 0; offset < outerEvents.length; offset += 100) {
    const deletion = finalizeEvent({
      kind: 5,
      created_at: Math.floor(Date.now() / 1000),
      tags: [['k', '3560'], ...outerEvents.slice(offset, offset + 100).map(event => ['e', event.id])],
      content: ''
    }, deletionKey.secretKey)
    await relayPool.sendEvent(deletion, relays)
  }
}

The s tag is public metadata and a deletion capability, not the channel key or sender identity. libp2r2p does not delete anything automatically.

Relay support for this capability is not universal. A relay that implements it should accept only a kind 5 request signed by the matching s key with exactly one ['k', '3560'] tag, explicit matching e targets, and no a tags. A regular NIP-09 kind 5 request signed by the outer event's private-channel key must not delete a kind 3560 event, whether or not that event has an s tag.

Storage Maintenance

While an outgoing private message is being assembled, the messenger keeps encrypted envelope rows and router chunks in sessionStorage. They are removed when the send finishes, but an interrupted browser operation can leave them behind until cleanup runs.

PrivateMessenger.init() awaits storage maintenance automatically. Call PrivateMessenger.maintainStorage() during app startup when messenger initialization may be delayed, such as while an account is locked:

import { PrivateMessenger } from 'libp2r2p/private-messenger'

PrivateMessenger.maintainStorage().catch(console.warn)

Maintenance removes interrupted-send staging, expired receive chunks, and storage belonging to inactive principal identities. It also resumes any interrupted storage-set deletion. An application does not need to know database names or enumerate IndexedDB. Pass temporaryStorageArea only when the messenger was configured to use a Storage area other than the default sessionStorage.

Each principal signer owns an internal storage set containing message, recovery-seed, and channel-state databases. The messenger updates its activity lease while it is open and closes all handles in await messenger.close(). The complete set is removed after identityStorageRetentionSeconds without use (60 days by default), including messages that were never consumed. Maintenance runs on every initialization and every six hours while a messenger is active; failed deletions remain journaled and retry automatically.

Channel recovery state inside an otherwise active identity uses the separate staleChannelSeconds cleanup policy (45 days by default). Active instances record the channels they administer, so a channel remains protected while any instance or tab still uses it. Offline recovery defaults to seven days. Set offlineRecoverySeconds on an individual channel to override the messenger default for its recovery seeds, offline ranges, new outer-event expiration, and new incomplete receive groups. Updating a channel applies a shorter window immediately to its stored seeds and ranges; increasing it does not recreate data already removed.

The effective recovery duration is capped by both staleChannelSeconds and identityStorageRetentionSeconds. The requested per-channel value remains stored separately, so raising a cap affects new retention decisions without recreating data already removed. Both retention policies can be changed by an instance at runtime; omitted fields retain their current persisted values:

await messenger.update({
  staleChannelSeconds: 30 * 24 * 60 * 60,
  identityStorageRetentionSeconds: 90 * 24 * 60 * 60
})

The policies are persisted per principal identity. If multiple instances use the same identity, the last confirmed policy update wins and is propagated to the others. A zero policy disables durable recovery immediately. Identity storage itself remains protected until the final active lease closes.

Set a channel's offlineRecoverySeconds to 0 to disable durable recovery for that channel. The messenger then stores no recovery seeds, tracks no offline ranges, contacts no seeders, publishes no seeder presence, and uses no recovery mirror relays. Live delivery remains usable: new outer events retain the private-channel two-day technical expiration and incomplete receive groups use the one-hour technical TTL. Existing signed events and receive groups retain the deadlines chosen when they were created.

Recovery metadata is separate from that temporary send staging. Per-channel lastSeenAt, offline ranges, and related state are stored in IndexedDB. Raw incomplete receive chunks are also stored in IndexedDB and share a 16 MiB logical budget. Direct private-channel calls give each new group a one-hour TTL; PrivateMessenger groups use the effective recovery window of their channel, or one hour when durable recovery is disabled. The TTL is persisted per group, so another caller opening the shared database or a later channel configuration update cannot change it. Capacity eviction removes whole least-recently-used message groups so a partial group is never mistaken for a complete one. receivedChunkTtlMs, receivedChunkMaxBytes, and receivedChunkIndexedDB may be supplied to the private-channel APIs when an embedding environment needs different limits or an injected IDB factory. Legacy Web Storage recovery records are neither read nor migrated.

The recovery-seed queue has a shared 64 MiB logical budget by default and uses FIFO eviction. A channel recovery duration is therefore a maximum retention window, not a guarantee that every seed remains available until its deadline.

Signers are expected to expose the Nostr-style methods used by the messenger, including getPublicKey(), signEvent(event), and the NIP-44 v3 methods needed by private channels. For double-DH content-key use, pass a contentKeySigner or a signer implementation that handles content keys internally.

Messages are stored in a bounded, durable IndexedDB queue until consumed or until the principal identity has been inactive for 60 days:

async function handleMessages () {
  for await (const message of messenger.messages()) {
    if (message.type === 'message') {
      console.log(message.payload)
    }
  }
}

handleMessages().catch(err => console.warn('private messenger messages failed', err))

For one-at-a-time consumption, use await messenger.nextMessage(). Queue clearing is asynchronous too: await messenger.clearChannel(channelPubkey).

Use explicit subpath imports for bundle size. The package root re-exports the main messenger API for convenience, but applications that only need one piece should import that subpath directly.

Nostr primitives

The modern stack can use the package without nostr-tools. Its intentionally small public surface includes strict, non-caching NIP-01 helpers, NIP-04 for legacy interoperability, NIP-44 v2, key helpers, event-kind classification, NIP-05 lookup, NIP-96 compatibility, NIP-98 authorization, Nostr Web Tokens, and relay URL normalization:

import {
  assertSerializableEvent,
  assertValidEvent,
  finalizeEvent,
  isSerializableEvent,
  isValidEvent
} from 'libp2r2p/event'
import { generateSecretKey, getPublicKey } from 'libp2r2p/key'
import { eventKinds, classifyKind } from 'libp2r2p/kind'
import * as nip44 from 'libp2r2p/nip44'
import { assertValidPublicRelayUrl, normalizeRelayUrl } from 'libp2r2p/url'

classifyEvent() from libp2r2p/event combines the exact NIP-01 kind ranges with tag-defined behavior. The first d tag may add replaceable or addressable, while an expiration tag equal to created_at adds ephemeral. An event is also regular when it is neither replaceable nor addressable. Classifications are additive and callers can disable the legacy kind ranges with { includeLegacyKindRanges: false }.

NIP-44 v2 uses the interoperable nip44-v2 salt by default. A custom UTF-8 salt of at most 32 bytes may be passed to getConversationKey(), but messages derived with it are not interoperable with standard NIP-44 implementations.

NIP-46 clients and bunker signers use a 30-second operation timeout by default. Set timeout in the Nip46Client/BunkerSigner constructor to choose another default, override it for an individual connect() or RPC, or pass timeout: null explicitly when an operation is intentionally allowed to wait indefinitely.

Nostr Web Tokens are available from libp2r2p/nwt. Creation returns a signed kind 27519 event, while transport encoding is kept separate:

import { createToken, encodeToken, validateToken } from 'libp2r2p/nwt'

const event = await createToken({
  signEvent,
  audience: ['api.example.com'],
  expiration: Math.floor(Date.now() / 1000) + 300,
  claims: [['action', 'upload']],
  content: 'Authorize an upload'
})
const authorization = encodeToken(event, { includeAuthorizationScheme: true })
const claims = validateToken(authorization, { audience: 'api.example.com' })

Transport decoding requires canonical unpadded Base64URL. Validation verifies the Nostr signature on every call, enforces registered-claim cardinality and time bounds, and requires the verifier to provide its identity whenever an aud claim is present. Tokens without aud or exp retain the draft specification's public/unbounded defaults; servers can reject those forms with requireAudience and requireExpiration.

The NIP-96 module is provided only for interoperability with older file servers. New applications should prefer NIP-B7. Its upload API accepts an AbortSignal and a ProgressEvent-compatible callback; browsers use XHR for real upload progress when available, while the fetch fallback reports only estimated start and successful completion.

isSerializableEvent() checks only the NIP-01 fields used during serialization. isValidEvent() additionally recalculates the ID and verifies the Schnorr signature on every call; it never adds a cache marker to the event. Their assert… counterparts return the original event or throw a ValidationError with a stable code.

Public validity checks consistently use a non-throwing is… predicate plus an assert… counterpart when callers need the exact reason. Strict codecs, decoders, token validation, and malformed public arguments also throw ValidationError from libp2r2p/error. Network, timeout, abort, quota, and closed-state failures remain ordinary operational errors.

NIP-04 remains available at libp2r2p/nip04 only for compatibility with older Nostr applications. Low-level relay sockets, subscriptions, message parsing, and serialization are internal implementation details; use RelayPool or the relayPool singleton from libp2r2p/relay.

Binary encodings

Base16, Base36, Base62, Base64/Base64URL, and Base93 helpers are available through their matching libp2r2p/<encoding> subpaths. Base36 exposes both a binary-safe variable-width codec and the canonical 32-byte/50-character Base36Nsite representation from NIP-5A. Base62 uses the same case-sensitive alphabet as app NIP-19 entities; its default byte mode preserves leading zero bytes, while integer mode supports fixed-width identifiers.

In NIP-5A, "no padding" means that no separate padding character such as = is used. Leading 0 digits are nevertheless required to make every Nsite Base36 value exactly 50 characters long.