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.11.36

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.

For remote-work scheduling, see libp2r2p/network: isOnline probes connectivity and onOnline shares recovery monitoring, including retries when the browser omits its native online event and optional 30-second expedited windows for foreground remote sends.

Relay reads and lifecycle

All three RelayPool read generators emit typed envelopes. The Nostr event is never decorated with meta; relay provenance belongs to its enclosing item:

{ type: 'event', event, relay }
{ type: 'error', relay, error }
{ type: 'eose', relays: [{ relay, status, error }] } // error is optional
{ type: 'live-progress', relay, epoch, since, until } // live and live-enabled feeds only

eose means the initial read attempt is complete, including empty reads and partial results. Its report distinguishes actual relay EOSE from other outcomes:

| Status | Meaning | | --- | --- | | eose | The relay sent EOSE. | | satisfied | The requested limit or IDs were satisfied, so the read closed early. | | timeout | The initial relay attempt deadline elapsed. Also emits an error item. | | cutoff | The grace period after the first qualifying EOSE/early completion elapsed. | | closed | The subscription ended without EOSE or an explicit error. | | error | Connection or subscription failed. Also emits an error item. |

Each normalized relay URL gets one report entry, preserving the first spelling provided by the caller. An empty relay list emits { type: 'eose', relays: [] }. Caller cancellation never fabricates an initial completion. Individual relay failures do not prevent other relays from delivering; malformed arguments and general operation failures reject the call or iterator read.

getEvents(filter, relays, options) resolves to { result: [{ event, relay }], errors: [{ relay, reason }], success, relays }. Its optional callback receives the same event/error/EOSE envelopes immediately. success retains its existing meaning: an event was received or at least one relay completed without an error; it does not mean every relay sent EOSE. getEventsGenerator emits those envelopes and retains that report as its final iterator return value (not visible inside a for await loop).

getEventsFeedGenerator(filter, relays, options) combines history and live subscription by default: history items, one historical eose, buffered live items, then ongoing live delivery. live: false ends after history and its marker; filter.limit: 0 skips history and forwards the live stream's initial marker. Internal live/reconnect EOSEs do not produce additional feed markers.

getLiveEventsGenerator discards retained events received before each relay's EOSE. A ready relay can deliver live events before the aggregate marker. Initial timeout defaults to 5000 ms (null disables it); expiration reports pending relays without stopping their connections or subsequent recovery. The grace period timeoutAfterFirstEose defaults to 500 ms (null disables it). Historical queries use these same defaults; their grace period starts only when a relay with events EOSEs or satisfies its filter. Reconnect-gap reads keep their separate timeoutForReconnectGap and timeoutAfterFirstReconnectGapEose options.

The live iterator retains ready: Promise<{ relays, errors }> and the readyRelays getter. ready is the initial readiness snapshot, derived from the same outcomes as the marker; its errors use { relay, reason }. readyRelays tracks currently ready relays and can change after that snapshot. Timeouts and cutoffs are not acknowledgements of readiness. Reconnections do not repeat the initial marker. Both APIs require consuming the iterator to start its work.

Read iterators have a synchronous, idempotent stopAndDrain() method. It closes subscription input and cancels reconnections and outstanding historical queries, while retaining items already accepted by receive callbacks. Continue consuming to obtain those items and completion. This includes initial history, buffered live events, and reconnect recovery buffers. Calling it before the first next() prevents subscriptions from opening. It does not synthesize EOSE.

Aborting options.signal, calling return() (including a for await break), or calling throw() cancels input and pending delivery instead. An item already delivered cannot be recalled. These operations can also interrupt a drain. stopAndDrain() does not consume the iterator or return a completion promise: completion is the iterator's { done: true } result.

const stream = relayPool.getEventsFeedGenerator({ authors: [pubkey] }, [relay], { signal })
// When this relay is removed, call stream.stopAndDrain() from the list handler.
for await (const item of stream) {
  if (item.type === 'event') await store(item.event)
  else if (item.type === 'error') reportError(item.relay, item.error)
  else if (item.type === 'eose') initialReadFinished(item.relays)
}

Migration from 0.10.18: replace raw live/feed event reads with item.event after checking item.type, replace event.meta.relay with the envelope's relay, and unwrap each getEvents().result entry. There is no compatibility flag. Query, content-key, private-channel and NIP-46 helpers unwrap pool results internally and retain their higher-level event contracts. Count, publication and disconnect return formats are unchanged.

Live overlap and progress (0.10.22)

Every live subscription opens with since = max(filter.since ?? 0, now - 600, 0), including its first connection. This accepts events that arrive after opening but carry a timestamp up to ten minutes earlier. Explicit filter boundaries still apply, and retained events before EOSE remain excluded from strictly-live output.

Reconnect recovery overlaps each relay's last received timestamp by 600 seconds, without going below the caller's since or zero. Empty connections use their opening time instead. Cursors advance independently even for events deduplicated across relays; future timestamps are capped at their local receipt time. Failed or interrupted recoveries retain their earlier baseline for the next reconnect.

Live readers automatically enqueue a live-progress envelope every 60 seconds while ready, including when no events arrive. There is no enabling option. epoch is a numeric connection-attempt identifier scoped to this iterator; compare it together with relay, not across separate iterators. since is the local EOSE time (or the explicit filter lower bound if later), and until is the local time at the tick, both inclusive Unix seconds. Repeated markers describe the same continuous epoch with an increasing until.

Progress starts only after that relay's EOSE and successful completion of any pending recovery. It pauses on disconnection, does not span connection epochs, and stops on cancellation, draining or until. History-only and empty-relay reads do not emit it. Timers never issue extra network queries.

Markers share the bounded queue with events and follow all earlier received items. A feed preserves that ordering behind its initial history and queued live events. Consumers must finish persisting preceding events before persisting progress, and must not treat a marker as proof of historical completeness or as coverage of a disconnected interval. This is client-observed continuity, not a relay acknowledgement; delayed relay delivery or clock skew beyond the overlap can still leave gaps. A backward local clock cannot regress an emitted boundary.

Every involuntary live close emits an error. Recoverable failures permit automatic recovery. A close without an explicit cause uses error.code === 'RELAY_LIVE_INTERRUPTED' and category === 'transport'; a pre-EOSE close retains status closed in the initial report. Explicit causes are preserved. Caller cancellation, draining and expiry of filter.until do not manufacture interruption errors.

Failure-aware live recovery (0.11.15)

Live readers retry connection/transport/timeouts and structured rate-limited: or error: refusals. Policy refusals (blocked:, restricted:, auth-required:, pow:), local authentication/validation failures and unknown errors stop that relay for this iterator. Other relays continue. When every route has stopped, the iterator finishes after draining accepted events and original diagnostics; its readiness snapshot never claims a failed route sent EOSE. A new explicit reader starts a new operation; refusals do not blacklist a relay across readers.

Retry delays are 1, 2, 4... seconds, capped at five minutes without an attempt limit. The next attempt waits until max(backoff deadline, relay retryAt). A confirmed offline failure spends no backoff step: it waits for the existing shared onOnline monitor, without holding read-admission slots. Connectivity checks are bounded and shared per pool; structured relay responses need no new internet probe unless the browser indicates offline. Cancelled readers release their own waits without cancelling another reader's recovery.

Successful live EOSE and any required reconnect history reset the delay. Transient reconnect-history failures retry while retaining their original gap baseline; definitive failures stop that route without certifying its gap. Accepted history and buffered live remain deliverable. Local queue-full, queue-timeout and disconnect admission failures retain local recovery; impossible capacity is terminal. Caller cancellation is silent. Native timeout messages stay unchanged and now consistently carry category timeout. Constructor hooks _isOnline/_onOnline are internal test injections, not an app bridge API.

Process Nostr events only under item.type === 'event'. Handle controls you need and ignore unknown types; a new control is neither an event nor stream completion. The internal query, private-channel and NIP-46 consumers retain their existing higher-level event-only contracts.

Read admission and bounded snapshots (0.10.21)

Each pool coordinates event reads by normalized connection URL. Constructor options are maxSubscriptionsPerRelay: 28, maxConcurrentHistoryPerRelay: 2, and maxQueuedReadsPerRelay: 256. These are local budgets, not negotiated relay limits. A stricter relay can still reject subscriptions. Count and publication contracts are unchanged and do not consume event-read slots.

Admission is FIFO per connection. A history+live feed reserves two slots atomically before either REQ opens; completing history releases only its slot. One-shot queries and reconnect gap reads share this budget. Reconnection reserves live+history together when recovery is needed. Caller cancellation removes queued work; disconnect(url) also rejects queued work for that connection. Existing live streams retain recovery for eligible failures until cancelled by their owner.

All event reads accept queueTimeout: 30000 (milliseconds, null disables). The network timeout starts after admission for each relay, including its connection setup; time in the queue does not consume it. The first-EOSE grace can still cut short other relay attempts. Admission failures are ordinary error envelopes/report entries, with error.code, error.relay and error.phase === 'admission': RELAY_READ_QUEUE_FULL, RELAY_READ_QUEUE_TIMEOUT, RELAY_READ_CAPACITY, or RELAY_DISCONNECTED. Cancellation rejects an outstanding direct getEvents() call; cancelled iterators stop delivering. Failures on one relay continue to allow results from others.

Live and feed readers limit each pending live buffer to 1,000 envelopes and 8 MiB of serialized UTF-8 data. Options maxBufferedLiveEvents and maxBufferedLiveBytes customize those positive limits. They cover consumer backlog and reconnect buffers as well as live events waiting for history. These are bounds on queued data, not a measurement of total JavaScript heap usage. Overflow fails the attempt with RELAY_LIVE_BUFFER_FULL (phase: 'live-buffer'), closes input and discards pending live data. Callers decide whether to retry; events already delivered are not recalled. Ordinary relay failures retain the existing partial-result behavior; they do not automatically discard the feed.

With snapshot: true, a feed waits for its initial live readiness window before capturing the historical until. filter.since defaults to zero; filter.until can provide an earlier cutoff but cannot extend a snapshot into the future. The historical completion includes the actual inclusive bounds:

const stream = relayPool.getEventsFeedGenerator(
  { authors: [pubkey], kinds: [1], since: lastConfirmed - 600, limit: 200 },
  [relay],
  { snapshot: true, timeoutAfterFirstEose: null, signal }
)
// Historical completion:
// { type: 'eose', relays: [...], snapshot: { since, until } }

Only history is bounded: the live REQ has no until in this mode. Events around the boundary can appear in both sources and are deduplicated by ID. Empty history still reports its bounds. With live: false, the cutoff is captured when the iterator starts; limit: 0 remains live-only, without a historical snapshot. Default snapshot: false retains the existing filter time-range behavior.

This is a bounded time interval, not a transactional relay snapshot or proof of complete coverage. Check each relay's outcome, exhaust pagination and commit received events before checkpointing. satisfied can mean a truncated page; EOSE can also terminate a limited response. A consumer that rejects a historical attempt can call return()/abort when it receives its report, before the buffered live events are released. Use stopAndDrain() only when those pending events should be preserved. Slow/failed live readiness is still reported by the live stream according to the existing partial-failure rules.

Private Messenger

Pending ciphertext recovery (0.11.22)

The known three-field message deduplication index is migrated automatically to include provenance, preserving positions, records and byte accounting. Other incompatible queue definitions still fail explicitly; no database reset is needed.

PrivateMessenger defers CONTENT_KEY_NOT_FOUND and INVALID_MAC packets to its separate ${prefix}:pending-decryption:idb-queue: 16 MiB FIFO, ciphertext only, no per-item TTL. Duplicate packets and attempts preserve their queue positions. Explicit channel removal and identity storage maintenance remove their pending records. Oversized records and capacity eviction are reported as discards. Stale channel metadata cleanup does not expire pending ciphertext. History continues only after deferral commits; storage/permission failures are not converted into successful coverage. Deferral is not message delivery.

Call retryPendingDecryptions() after new content keys become available. Initial readiness and resume also request a pass. A single background worker coalesces requests, uses an origin/identity Web Lock when available, and stops on pause or close. Missing-key retries have no polling timer. MAC failures permit three total attempts, persisted across reloads; exhausted packets remain until FIFO eviction. retryPendingDecryptions({ force: true }) explicitly retries exhausted packets. Success enters the normal acknowledged inbox before removing the pending item. The session coordinator forwards this API.

Pending recovery waits for 250 ms without channel setup, live/history processing or publication activity. Session foreground persistence/publication also delays it. After at most five seconds of eligible waiting, a busy channel may advance one packet, then receives a fresh deadline. Idle channels take batches of up to four records, yielding a task between every record. A busy channel does not hold up other channels; overdue channels precede the preferred foreground channel. Only requested passes run: a missing key never starts a polling loop. Timer throttling or an in-flight signer/storage call can extend actual elapsed time.

pendingDecryptionReady defaults to true on messenger/session construction. Apps may pass false and call setPendingDecryptionReady(true) after their initial interface is presented; false holds only pending recovery, not live reception, publication or channel setup. The idle/deadline windows start after release. setPendingDecryptionPriority(pubkey | null) selects a channel on the messenger or a peer on the session; self-chat/null clear the session preference. The session retains a peer preference until preparation finishes and clears it on peer removal. Keep startup release and account lifecycle fencing in the app.

retryPendingDecryptions() resolves after the requested scheduled pass (including an aggregated follow-up). force: true only overrides the MAC attempt ceiling; it does not bypass availability or admission. Requests before the first snapshot share that pass; requests during it aggregate into one following pass. Queue reads wait for initial release. Each packet is revalidated under the identity Web Lock when available; waits hold neither that lock nor the normal inbox/IDB queues. Fragment assembly survives scheduling boundaries. Pause/close cancel waits; channel removal fences its queued and in-flight results.

Private-channel consumers may opt in with onDecryptionFailure({ error, record }), returning true only after durable handling, and replay records with replayDeferredDecryptions. Without the hook, standalone readers retain their existing error behavior. The optional isDecryptionPending(record) hook skips an already persisted packet before decryption; PrivateMessenger uses it for live/history/seeder duplicates so retransmission cannot bypass the MAC attempt budget.

For standalone replay, pass records: [{ id, channelPubkey, packet }], where packet is the ciphertext record received by the failure hook, together with the current receiver/channel signers and receiver pubkey. onEvent and onNymEvent admit normal deliveries; onRecovered(record) removes durable pending ciphertext only after admission, and onFailure(record, error) retains it and records the failed attempt. Both settlement callbacks are required. An optional AbortSignal fences processing; one batch shares a fragment processor.

rememberContentKeyEvent(event) from libp2r2p/content-key validates and remembers a confirmed local announcement. Newest timestamp, then lowest ID, wins. Pending discovery queries cannot overwrite or return an older result; normal positive and negative cache windows remain unchanged.

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, ack } of messenger.messages()) {
    console.log(message.type, message.payload)
    await ack()
  }
}

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 in sessionStorage (router fragments are encoded on demand, without a second stored copy). 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 acknowledged or until the principal identity has been inactive for 60 days:

async function handleMessages () {
  for await (const { message, ack } of messenger.messages()) {
    await persistMessageIdempotently(message)
    await ack() // only after the destination commits
  }
}

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

For one-at-a-time consumption, await messenger.nextMessage() returns { message, ack, nack } or null if no record is currently available. ack() removes the reserved record; nack() releases it for another attempt. Both return a boolean and are idempotent for the same reservation. An expired or superseded reservation returns false and cannot delete a later delivery.

Delivery leases last 30 seconds and renew every 10 seconds while held. Closing the messenger or returning the iterator releases its unacknowledged deliveries; returning an iterator also cancels an empty pending read. A crashed/suspended consumer's expired leases are reclaimable by another instance. Apps must save idempotently: a crash between their commit and ack() can repeat delivery. The queue is shared by identity, so concurrent consumers claim distinct records.

The app-message queue defaults to 16 MiB and rejects writes at capacity, without evicting pending messages. Capacity pressure pauses ingestion and records a recovery gap; successful acknowledgments resume it when possible. While capacity-paused, a one-second capacity check also detects space released by another instance. Oversized records and storage failures report errors and pause with reason storage; after correcting the cause, call resume('storage'). Retention cleanup and explicit clearQueue()/clearChannel() remain destructive. The queue's budget bounds serialized storage, not the size of every intermediate decoded message.

Forwarded authors and isolated consumers

broadcastRumor({ rumor, ... }) accepts an optional rumor.pubkey. Omission means the sender; a different pubkey forwards that claimed author and requires the original created_at. A supplied ID must match the reconstructed event. The wire retains the original fields; recipients never overwrite an explicit author with the transport sender. Signed events retain their signature.

Received messages expose senderPubkey and provenance alongside event, outer, meta and payload. Provenance is direct, hearsay, or signed, calculated by the receiver. Pending deduplication includes provenance, so an original can upgrade a queued hearsay copy of the same event. Private-channel callbacks also expose these fields in their metadata. Nothing is attached to the Nostr event. A rumor naming the recipient is still hearsay unless independently matched to a known original. Forwarded controls cannot invoke ask/reply/presence/recovery handlers in the name of another author. Direct rumors are not transferable signed proof.

Each PrivateMessenger owns a createPrivateMessageSession() from libp2r2p/private-message. A session groups relay reads for one receiver and owns its watches, callbacks, and scoped receive-fragment progress. Independent sessions can watch the same channel, including with the same receiver, without overwriting or closing each other's reads. The module-level watch helpers are a convenience scope per receiver; use explicit sessions for separate consumers. receivedChunkScope can also scope lower-level private-channel reads; receiver pubkeys are included in fragment keys. Expiration/cleanup still use the shared receive-chunk database and budget.

Pause, resume and recovery

pause(reason) and resume(reason) accept nonempty strings. Reasons compose: removing network cannot override an application's vault pause. Pause stops live watches, recovery fetches and presence publishing, but retains channel configuration and pending deliveries. Already-started operations can finish; failed ingestion remains recoverable. Send attempts while paused reject.

watch(channels) declares desired subscriptions. unwatch(channels) removes that intent while retaining configuration and history. Browser online only clears the internal network pause, never restarts explicitly unwatched channels. Updating retained channel configuration preserves explicit unwatch; new channels are watched unless paused. Applications may explicitly watch again.

Pauses, unwatch and close record the start of the gap, even for channels that have never received a message. Rewatch/resume uses live delivery plus historical recovery with overlap. Remote recovery failures never restore the caller's pause reason: signer availability belongs exclusively to the account owner. Before opening a watch, the messenger persists a pending recovery interval. A first watch scans the configured recovery window (seven days by default), including messages sent before this identity first opened the channel. Successful scans persist recoveredThrough, even when empty; later watches resume from the latest persisted message or scan checkpoint with overlap. Pending older ranges remain until successfully recovered, even if newer live messages arrive. This also covers abrupt termination without close() and channels with no messages. Applications still supply signers and desired channels on reopening; storage is scoped by origin and user pubkey, not the transient session ID. Failed ingestion or incomplete relay fetch reports never complete the gap.

init() and createPrivateMessenger() resolve after local preparation. Likewise, update(options, { waitForBackground = false }) and resume(reason, { waitForBackground = false }) wait for valid local channel configuration and mandatory durable writes, allowing sends independently of subscriptions, presence and history. Set waitForBackground: true to also await the initial background tasks associated with that call, not every future retry. Both modes share tasks; unchanged configuration does not recreate subscriptions. Later errors reach onError with the original error as cause and operation: 'private-messenger.background' plus a phase. Cancelled or obsolete tasks cannot deliver into a new channel generation. Mandatory persistence still owns its storage pause; incomplete history remains a pending recovery interval. Subscription setup is bounded separately from presence, so a slow announcement does not hold the other channels' readers. Sessions prioritize user persistence and publication before the next background presence signer call. onPreparedSeed receives its existing router/jsonl fields plus publication, containing all signed fragments, recipient/channel identities and recovery rows for that logical event. The callback commits durable recovery before publication: a direct messenger uses its seed store; the session uses its encrypted outbox journal and archives indexed personal copies independently. No alternative legacy publisher is retained. Only one logical event/chunk is prepared per session entry at a time.

private-channel.fetch reports incomplete reads as an AggregateError with stable code: 'PRIVATE_CHANNEL_FETCH_INCOMPLETE'. Its message names relays, completion statuses and native failure messages, including status-only failures. relays retains all { relay, status, error? } outcomes; relayErrors preserves { relay, reason } attribution and errors retains unique native causes from both sources. request records selected relays, channel pubkeys, receiver pubkey, since/until/limit and the 5000ms read timeout. elapsedMs measures the read including admission wait, excluding decryption/storage; receivedEventCount counts fetched outer events, not delivered messages. No event bodies or signers are attached. These diagnostics reach the messenger's onError unchanged. A single incomplete relay keeps the interval pending even if another relay returned events; received messages may already have been queued. This is a history completeness failure, not evidence that an outgoing message failed. Rewatch/resume, relay-list refresh and reload-gap paths schedule bounded retries for pending intervals (1s doubling to 30s). Recovery tracks per-relay coverage in memory: a tier where some relay returned events and EOSE retries the remaining relays a few times (3), an empty-EOSE tier gets more attempts (6), and an all-failed tier gets the most (10) before coverage from EOSE relays is accepted and the uncovered subranges plus the pre-oldest left edge are requested from seeders. Offline attempts never consume the budget, mark coverage or contact seeders; they wait for connectivity. The per-relay tracker is process-local, so a restart falls back to the persisted whole interval.

prioritizeRange(channelPubkey, { since, until, type }) registers an application-directed priority window for the next recovery pass. unread-page requires since (a read anchor); tail derives a bounded window from the newest persisted range using priorityTailSeconds (six hours by default). Priority subranges are fetched before older history; relays are ordered by recent EOSE, consecutive failures and latency; and the first priority page may hedge up to priorityHedgeRelays (two) relays with priorityHedgeDelayMs (500 ms), aborting the loser. When relays do not cover the window, seeders with recent presence (announcedAt/lastActiveAt within seederOnlineSeconds) are asked only for that subrange; a successful ask counts as coverage. Priority probes never consume the relay tier budget, and priorities/statistics are process-local: they expire after priorityTtlMs (ten minutes) or once covered, and clear with channel/recovery state. Presence payloads and persisted ranges are unchanged.

private-channel.fetchHistory(options) (0.11.6) is the complete-interval API used by messenger recovery. It accepts the signers, processor/fragment options, callbacks and abort signal of fetch, plus inclusive since/until bounds (defaults: 0 and the start time of the call). It returns only { oldestCreatedAt, receivedEventCount, relays }; an empty scan has a null oldest time. Passing partial: true (used by messenger recovery) returns per-relay covered/pending/events plus pendingByRelay, anyEose, anyEoseWithEvents and allFailed instead of throwing on partial failure, and resume restricts each relay to the given pending subranges. No complete-history event array is retained. fetch keeps its existing single-query array contract and optional limit.

History queries each relay independently with limit 16. A response with 16 events or status satisfied is saturated, even if it already reports EOSE or contains fewer valid events. Saturated intervals split into nonoverlapping inclusive halves; completed pages process chronologically, oldest half first. Single-second intervals grow through 16, 32, 64, 128 and 256. Each response is also bounded to 4 MiB of serialized UTF-8 event data. At the dense-second or volume ceiling, PRIVATE_CHANNEL_FETCH_INCOMPLETE retains the operational cause PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT; the interval stays pending. There is no nonstandard cursor, disk spool, dropped excess or successful incomplete scan.

A fragment processor and the existing scoped fragment storage span the entire call, including pages and relays. Callbacks are awaited before requesting the next page. Processing/persistence failures propagate; completed pages may already be ingested and retries remain subject to the existing idempotence contract. The summary count includes processed outer events per relay, including replicas, not unique decrypted messages or discarded saturated probes. It does not include all events observed in those probes.

Recovery admits at most two pages per PrivateMessenger and one per normalized relay across the same library instance, including page processing time. Continuous live watches are outside those recovery gates. Waiters are abortable and FIFO, with at most 256 queued per gate (PRIVATE_CHANNEL_HISTORY_QUEUE_FULL on excess). The five-second network timeout begins after admission; reported read time also includes admission but excludes processing. Seeders still supplement the oldest relay result. Checkpoints advance only after the entire interval and existing seeder stages succeed; interruption preserves its original pending range. No persisted state format changes or migrations are needed.

Subscription relay errors forwarded to onError include relay when known and operation: 'private-channel.subscribe'. A per-notification wrapper retains the native message, name, code, category and WebSocket close fields; cause references the untouched original error, including its stack and causes. Aggregate errors keep their nested errors. This also works with frozen/shared errors without leaking attribution between subscribers. Missing relay metadata is not inferred from the selected relay list. This does not change reconnection, delivery or error classification; processing errors keep their existing path. lastSeenAt tracks messages persisted in the incoming queue, not app commits. The default recovery window is seven days; recovery still depends on available relay/seeder data, and retention limits cannot guarantee indefinite delivery.

Queue clearing remains asynchronous: 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 {
  assertValidPublicBlossomServerUrl,
  assertValidPublicRelayUrl,
  normalizeBlossomServerUrl,
  normalizeRelayUrl
} from 'libp2r2p/url'

Blossom server normalization accepts root http: origins for local development. Public Blossom validation additionally requires https: and a public host, and rejects credentials, paths, query strings and fragments.

Starting with 0.11.21, import CONTENT_KEY_ANNOUNCEMENT (18716) from libp2r2p/kind. It is also included in eventKinds and used by content-key builders, proof validation and relay discovery. The announcement publishes a public content key for private communication and can be shared across apps. The former CONTENT_KEY_KIND export from libp2r2p/content-key/event has been removed; consumers must update that import.

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-44 v3 separates binary (encryptBytes/decryptBytes), UTF-8 text (encrypt/decrypt), and standard-Base64 plaintext (encryptBase64/decryptBase64) interfaces. The suffix describes plaintext; ciphertext is always standard Base64. NIP-46 uses the Base64 helpers, while browser NIP-07 APIs use ArrayBuffer plaintext; see the interface guide before adapting a signer.

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.

NIP-27 text references live in libp2r2p/nip27. extractMedia() splits content into text, URL, profile, event, relay, NIP-05, app and hashtag items in occurrence order, accepting the optional @ and nostr: mention prefixes plus NIP-05 in its standard, root and custom compact spellings. decodeReference() parses a single reference, and decodeMediaMetadata() reads the file/media metadata carried in a URL fragment (#m=image/png&dim=640x480&...). URLs can contain literal + in their paths, including launcher links such as https://44billion.net/+apps?by=fiatjaf.com. They produce a single URL item with the full address, rather than an app item for the embedded reference.

App references are a library extension to NIP-27. extractMedia() recognizes encoded +… app entities and named references such as [email protected], ++myapp@[email protected] and +++myapp@npub1…, with optional nostr: before the entire reference. Named authors accept the NIP-05 spellings above, npub, nprofile and hex pubkeys; app names may be URL-encoded. The one-to-three + prefix selects main, next or draft respectively.

Bare app names use the defaultAppAuthor option, which defaults to '44billion.net': +apps identifies the same app as [email protected]. The option accepts a NIP-05 reference (including compact spellings), npub, nprofile or hex pubkey and is validated locally on every call. An invalid value throws ValidationError with code INVALID_DEFAULT_APP_AUTHOR. Explicit authors and encoded app entities are never overridden. Malformed explicit authors remain text instead of falling back to the default.

extractMedia('+apps', { defaultAppAuthor: '[email protected]' })
// app.user: { type: 'nip05', local: 'bob', domain: 'example.com', raw: 'bob.example.com' }
// app.original remains '+apps'.

These references produce { key: 'app', app: { original, ...decoded } }, where decoded is the result of decodeAppUrl() from libp2r2p/url, with a missing named author filled from defaultAppAuthor as described above. Named apps include type: 'named', prefix, channel, appName (the manifest's d tag) and the decoded user; entities include type: 'entity' and entity, usable with appDecode() from libp2r2p/nip19. Extraction performs no network lookup and does not verify that the app exists. For example:

extractMedia('Open nostr:++myapp@[email protected]')
// [
//   { key: 'text', text: { value: 'Open ' } },
//   { key: 'app', app: {
//     original: 'nostr:++myapp@[email protected]', type: 'named',
//     prefix: '++', channel: 'next', appName: 'myapp',
//     user: { type: 'nip05', local: 'bob', domain: 'example.com', raw: 'bob.example.com' }
//   } }
// ]

App references must be standalone inline tokens, not concatenated to event pointers or embedded in URL paths. Invalid app candidates remain text. Bare names can use letters, numbers, dots, underscores, hyphens and tildes; URL-encode spaces, @ and other punctuation in them. +naddr1… is recognized as an app only for site-manifest kinds; unprefixed naddr1… and nostr:naddr1… keep their existing event item shape.

compactWhitespace(text, options?) is also exported from libp2r2p/nip27 as an opt-in display helper. It removes carriage returns, collapses spaces and tabs, removes spaces around line breaks, limits consecutive line breaks to two, keeps the first eight line breaks (replacing subsequent runs with a space), and trims the result. Empty strings are accepted; non-string inputs throw ValidationError with code INVALID_WHITESPACE_TEXT. It is not applied automatically by extractMedia().

The option maxLineBreaks (default 8) sets the total limit and accepts a non-negative safe integer. Setting it to 0 replaces all line-break runs with spaces. consecutiveLineBreakThreshold (default 3) sets the minimum run length that collapses to two line breaks; shorter runs are preserved. It accepts safe integers of at least 3. Either option accepts Infinity to disable its rule. Runs are collapsed before applying the total limit, and trimming happens last. Invalid options throw ValidationError with code INVALID_WHITESPACE_OPTIONS.

import { compactWhitespace } from 'libp2r2p/nip27'

compactWhitespace(text, { maxLineBreaks: 12, consecutiveLineBreakThreshold: 5 })

User references (npub, nprofile, hex pubkeys and every NIP-05 spelling) are handled by libp2r2p/nip27: decodeUserReference() returns the decoded form with its canonical compact spelling, encodeUserReference() returns that canonical spelling, and resolveUserReference() resolves it to a pubkey. libp2r2p/nip05 keeps only queryProfile(), the NIP-05 lookup, which accepts the compact custom forms directly. The decoders (decodeReference, decodeMediaMetadata, decodeUserReference, decodeAppUrl) throw ValidationError with a stable code; each has a tryDecode… counterpart that returns null when the value cannot be decoded.

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; probing code can use the non-throwing tryDecode… variants instead of catching. 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.

getEvents and getEventsGenerator accept deduplicateAcrossRelays (boolean, default true). With false, a matching event is delivered once per relay, while repeated IDs from the same relay remain suppressed. Each occurrence owns its envelope's relay; callbacks still run immediately and per-relay filter limits are unchanged. The callback/generator event item is { type: 'event', event, relay }; the completed query is { result: [{ event, relay }], errors, success, relays }. The option does not extend to the live or feed generators. Callers that need replication coverage can aggregate the returned copies by event ID; missing responses do not prove absence from a relay.

Publication errors retain their existing reason objects and may expose category: connection (WebSocket establishment), transport (socket send or close), relay (an explicit negative OK), or timeout (missing confirmation). Native messages, codes, nested causes and aggregate errors remain available; WebSocket closure details use closeCode, closeReason, and wasClean rather than overwriting a native code. A timeout can retain a preceding socket error as its cause without claiming that the relay rejected the event. Authentication wrappers preserve this context. Local failures can remain uncategorized. Event metadata is internal and is removed by sendEvent before serialization.

The same public subpath exports getRelaysByPubkey(pubkeys), which discovers the latest NIP-65 relay list for every requested pubkey through seedRelays, normalizes and deduplicates its public relay URLs, and falls back to the first two freeRelays when no list is available. Its result can be passed directly to pickRelaysForPubkeys(pubkeys, relaysByPubkey) to batch authors that share read or write relays.

getRelaysByPubkey accepts a few options:

  • includeEvents returns each pubkey's latest kind 10002 event alongside its parsed relays ({ read, write, event }), for consumers that need the original event (storage, re-signing, freshness tracking).
  • forceRefresh re-queries seedRelays even when the pubkey is cached, without regressing a newer cached event when the relay returns an older one.
  • timeout / timeoutAfterFirstEose tune the relay-list query timing. The default opens a short grace window after the first EOSE with events, matching RelayPool.getEvents.
  • emptyRelaysFallback sets the relays returned when a pubkey has no relay list (default: the first two freeRelays); pass [] to return empty read/write arrays instead, keeping the free-relay decision with the caller.
  • relayUrlPolicy opts into non-default URL validation: onion allows ws:///wss:// .onion hosts, localRelay allows the standardized ws://localhost:4869 local relay, and nostrEntityUrls stops rejecting public URLs that contain npub1/nprofile1. All other non-public or insecure URLs stay rejected.

parseRelayListEvent(event, relayUrlPolicy) is exported from the same subpath for consumers that parse relay-list events they receive outside getRelaysByPubkey, so routing and persistence always agree on the parsed shape.

pickRelaysForPubkeys also accepts excludeRelaysByPubkey (relays already queried per pubkey, as an object or Map) and emptyRelaysFallback (the relays used when a pubkey has no typed relays; pass [] to leave that pubkey unrouted). Together with maxPerPubkey: Infinity these express a second, exhaustive pass over the relays that were not yet tried for each author.

For callers that want the whole two-pass flow, getLatestEventsByPubkey fetches the latest replaceable events (or addressable events when dTagsByPubkey maps pubkeys to their d tags) through NIP-65 write relays: the first pass routes through up to maxPerPubkey shared relays, and missing authors are retried on every remaining relay plus fallbackRelays (default: the first three freeRelays), excluding what was already queried. It accepts relaysByPubkey to reuse a previous discovery (only missing pubkeys are then discovered) and returns { events, byPubkey, relaysByPubkey, requests } so the merged relay map can be passed back on later calls.

Read diagnostics preserve native failures rather than treating an empty result as proof that every relay completed successfully. requests contains records { phase, authors, relays, errors?, error? }: phase is discovery, primary or fallback; relays uses the pool's { relay, status, error? } outcomes. An operation-wide exception is retained as error. Events remain in the existing result fields, never in diagnostic records. onQueryResult(record) receives each record as the underlying query settles, including before a later cancellation.

Both discovery and latest-event queries accept signal. Cancelling one consumer of shared NIP-65 discovery releases only its interest; the underlying query is aborted when its last consumer leaves. Discovery's optional onQueryResult receives the same native report for every joining caller. Incomplete/failed lookups can use fallback relays for that call but never negatively cache a missing relay list for forty minutes. Actual lists and completely empty EOSE responses retain their normal cache behavior.

getLatestEventsByPubkey accepts excludeRelaysByPubkey (object or Map) for both passes. getRelaysByPubkey accepts excludeRelays for seed discovery; pass it through relayListOptions when using the two-pass query. Different exclusion sets do not share in-flight discovery that could contact a refused relay.

isRetryableRelayFailure(error) identifies connection/transport/timeouts and leading rate-limited:/error: relay failures. isReplaceableRelayFailure(error) also admits relay policy refusals (blocked:, restricted:, auth-required:, pow:) for routing to alternatives, preserving the messenger's existing policy. Local authentication denials, invalid events and unknown errors qualify for neither policy. Predicates classify individual native errors; callers decide how to handle mixed per-relay outcomes and application-specific aggregates. parseRelayRetryAdvice(reason, params, { now = Date.now() }), exported by libp2r2p/relay, returns { retryAt, retryAfterMs? } or null. It only interprets leading rate-limited: refusals. The optional string params is relative milliseconds (e.g. "2500") or an absolute Unix timestamp in milliseconds prefixed with @ (e.g. "@1791738154332"). Values must be positive safe integers in canonical decimal form, without signs, whitespace, fractions or leading zeros. Relative values produce both retryAfterMs and retryAt; absolute values produce only retryAt and retain an expired deadline without starting a new wait. Future waits are capped at five minutes. Missing or invalid params are ignored and do not instruct clients to retry immediately. Schedulers wait until max(local backoff deadline, retryAt), never sum the two or renew the deadline on online notifications.

Rate limits apply only to the client message type that triggered the refusal: EVENT, AUTH, REQ and COUNT have independent connection cooldowns. Responses are correlated with pending operations; unknown IDs never create cooldowns. CLOSE cleanup remains immediate. The launcher can replace valid relative params with @ timestamps before queueing bridge delivery because its consumers share its clock. No legacy timing objects are interpreted. Params never supply provenance or retry eligibility; native messages, causes and codes remain intact.

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.

Files

libp2r2p/irfs prepares retryable chunk templates from files, with explicit cancellation and resource release. libp2r2p/nip94 builds/interprets file metadata, including the local IRFS profile. NIP-27 extracts https://nostr.alt/nfile1…?localOnly=1 up to the NIP-19 codec's 5,000-character limit, retaining the full URL and exposing decoded url.nfile and MIME url.m.

The NIP-94 extension also carries optional download intent; see nip94/README.md for event and inline URL forms.

Reliable IndexedDB queue reservations

queue.putBy(indexName, item, { existingOnly = false }) atomically inserts or replaces an item through a declared unique, non-multiEntry index. Its key is extracted from the item's index keyPath, including nested paths and compound keys. Existing items retain their position; new keys append to the tail. It returns true after commit, or false without inserting when existingOnly is true and the key is absent. This allows late progress checkpoints to avoid recreating a cancelled record. Values are cloned before asynchronous work.

Unknown, non-unique or multiEntry indexes throw ValidationError with QUEUE_PUT_INDEX_INVALID; missing or invalid IndexedDB keys use QUEUE_PUT_KEY_INVALID. Cloning, storage and other unique-index failures retain their operational errors. Lookup, replacement and byte accounting share one transaction and roll back together. Capacity policy matches setAt for a replacement and push for an insertion. Replacing a reserved item invalidates the old token: its ack, nack and renew return false. Successful writes wake local waiting consumers. Like the other indexed methods, putBy is exclusive to idb-queue; the Web Storage queue's common API is unchanged.

createQueue({ prefix, evictionPolicy: 'reject', maxBytes }) opts out of capacity eviction. Oversized items reject with QUEUE_ITEM_TOO_LARGE, full queues with QUEUE_CAPACITY_EXCEEDED; browser quota errors propagate without trimming existing records. Other eviction policies retain their cache behavior. Opening a reject-policy queue under a smaller budget preserves existing items.

queue.reserve({ leaseMs = 30000 }) returns null or { item, ack, nack, renew }. Reservation token/deadline are internal record metadata, persisted atomically in the same IndexedDB transaction as selection. renew() extends a still-valid lease. Low-level callers own renewal and release; PrivateMessenger supplies both. Do not mix reservations with destructive or position-shifting queue operations on the same queue except explicit removal. A stale token cannot acknowledge a newly reserved or replaced record.

queue.getCapacity() returns { usedBytes, maxBytes }; capacity rejections also expose requiredBytes and maxBytes, without retaining the rejected item.

Publication acknowledgement deadlines

RelayPool.sendEvent returns immediately when the first relay accepts an event, when all relays fail, or when the operation's timeout expires (30 seconds by default, including connection work). timeoutUntilFirstFulfillment now defaults to null, removing the former 3-second cutoff. Explicit shorter deadlines remain supported and still finalize pending reports as timeouts. promise provides the complete per-relay report without delaying the first-success return. Connection establishment retains its separate 3-second deadline. PrivateMessenger inherits this publication policy, including seeder/recovery messages. A timeout indicates missing acknowledgement and cannot prove that the recipient did not receive it.

Private file channels (0.11)

libp2r2p/private-messenger/file provides createPrivateFileTransfer for separate per-root media channels, bounded indexed recovery and persisted-byte progress. The file API and protocol document signer/storage adapters and lifecycle. All original and thumbnail chunks belong to dm:media:<root>; small authenticated recovery requests stay on dm. NIP-94 supports thumbnail metadata with unordered mark/size root extensions.

This release changes the encrypted router recipient tag from r to p without legacy fallback. Upgrade communicating consumers together. Inner ask/reply/tell r tags, file-reference r tags, and carrier 26400 are unchanged. Seeders now capture outgoing ordinary-message ciphertext before publishing, even without relay echo. For files, seeders instead keep expiring recipient authorizations and serve verified local chunks as irfsChunk_v1; watchtowers retain ciphertext seeds. No group derivation or multi-device seed replication is introduced.

Private-message consumers can now substitute recovery persistence through private-messenger/event-store, or use local defaults, including a bounded streaming file cache. The optional private-messenger/session coordinator provides reusable DM inbox/outbox and attachment lifecycle without