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

nostr-swarm

v0.4.1

Published

A fully peer-to-peer Nostr relay that syncs events over Hyperswarm

Readme

nostr-swarm

A fully peer-to-peer Nostr relay that syncs events over Hyperswarm -- no central server, hosting provider, or TLS required.

Built on the Holepunch stack: Hyperswarm for connectivity, Autobase for multi-writer consensus, and Hyperbee for indexed storage.

How it works

  • One node per swarm (the founder) starts without --bootstrap, creates the shared multi-writer Autobase, and logs a checksummed invite (nsw1…) at startup
  • Every other node (a joiner) starts with --bootstrap <invite> and deterministically opens the founder's base -- peers discover each other on a shared Hyperswarm topic and replicate over encrypted connections
  • Joiners replicate and serve full reads immediately, but stay read-only until admitted: the operator of any existing writer runs --admit <writerKey> to grant write access
  • Autobase linearizes all admitted writers' operations into a deterministic Hyperbee view -- every node of one base converges to the same events, indexes, and deletions
  • A WebSocket server exposes the standard Nostr relay protocol (NIP-01) for traditional clients
  • Pear Runtime apps can replicate directly over Hyperswarm as read-mostly peers (writes go through a relay's WebSocket endpoint)

Requirements

  • Node.js >= 20

Install

# Run directly (no install needed)
npx nostr-swarm

# Or install globally
npm install -g nostr-swarm
nostr-swarm

# Or as a library
npm install nostr-swarm

From source

git clone https://github.com/sovITxyz/nostr-swarm.git
cd nostr-swarm
npm install
npm run build

Usage

# Run with defaults (port 3000, storage ./nostr-swarm-data) -- founds a new base
nostr-swarm

# Custom options
nostr-swarm --port 4000 --storage ./data --topic my-relay

# Join an existing relay's shared base (paste its nsw1… invite)
nostr-swarm --bootstrap nsw1...

# Admit a joiner as a writer (run on any existing writer)
nostr-swarm --admit <joinerWriterKeyHex>

# Development (from source)
npm run dev

Multi-writer workflow (founder/joiner)

Two nodes only converge on the same data when they share one Autobase. That is explicit, not automatic:

  1. Found -- exactly one node per swarm starts without --bootstrap. It founds the shared base and logs its invite (nsw1…), raw base key, and local writer key at startup (also written to <storage>/keys.json).
  2. Join -- every other node starts with --bootstrap <invite> (or the raw 64-hex base key). A joiner replicates and serves reads immediately, but stays read-only -- EVENT gets ["OK", id, false, "blocked: read-only replica awaiting writer admission"] -- until admitted. Each joiner logs its own writer key at startup.
  3. Admit -- send the joiner's writer key (out-of-band) to the operator of any existing writer, who restarts with --admit <writerKeyHex>. Once the admission replicates, the joiner becomes writable automatically -- no restart on its side.

In-band admission (optional). To skip the key-copying step, start an existing writer with --auto-admit and the joiner with --request-writer. The joiner proves it holds the invite over the swarm (an HMAC over base.key bound to the Noise session) and is admitted automatically. Both sides are opt-in and default off, so existing deployments are unchanged. Treat --auto-admit as turning the invite into a write capability: enable it only when everyone you hand the invite to is trusted to write. Admissions are still capped at 64 writers and rate-limited (16/hour).

Optimistic writes (optional). For light/Pear peers that hold only the read invite and never become writers, start the founder with --accept-optimistic. Such peers may then append a self-verifying event (a fully-signed Nostr event) as an optimistic block; the base re-validates its signature and keeps it durably (via autobase ackWriter) without admitting the peer as a writer. It is a base-wide consensus policy the founder sets, off by default; like --auto-admit it lets invite-holders contribute events, so enable it only when you trust them. This is part of the v2 consensus rules (CONSENSUS_VERSION = 2) — a breaking change, so every node on a base must run a v2 build.

Safety properties:

  • The invite is checksummed: a typo'd --bootstrap is a hard startup error, never a silently re-founded empty node.
  • The base identity is pinned per storage directory (<storage>/bootstrap-key): once a node has started, configuring a different bootstrap key is a fatal error. To join a different base, use a fresh storage path and migrate events with export/import.
  • Writers are capped at 64 per base; duplicate admissions are no-ops, so --admit flags can be left in place across restarts.
  • The founder is the sole indexer -- keep backups of its storage directory (writes still merge if it is lost, but checkpoints stall).

To merge two relays that were both founded independently (each owning its own base): nostr-swarm export --storage <old-dir> > events.jsonl, restart one of them on a fresh storage path with --bootstrap, get it admitted, then nostr-swarm import --url ws://127.0.0.1:3000 < events.jsonl. Events are self-certifying and deduped by id, so re-running the import is safe.

CLI options

-p, --port <number>         WebSocket port (default: 3000)
-s, --storage <path>        Storage directory (default: ./nostr-swarm-data)
-t, --topic <name>          Swarm topic (default: nostr)
    --bootstrap <invite>    Join an existing base: the founder's nsw1… invite
                            or a raw 64-hex base key (omit to found a new base)
    --admit <hex64>         Writer key to admit (repeatable); run on any
                            existing writer to grant write access
    --request-writer        Joiner: request writer admission in-band over the
                            swarm instead of waiting for an operator --admit
    --auto-admit            Granter: honor in-band admission requests proven
                            by invite possession (opt-in; off by default)
    --accept-optimistic     Founder: accept self-verifying optimistic writes
                            from non-admitted invite-holders (opt-in; off)
    --relay-name <name>     Relay name for NIP-11
    --relay-contact <addr>  Admin contact for NIP-11
    --wot-pubkey <hex>      Owner pubkey for Web of Trust filtering
    --wot-depth <number>    Max WoT hops (default: 3)
    --light-client          Enable light client mode (WoT filtering)
    --no-discovery          Disable discovery tier for unknown pubkeys
    --discovery-ttl <secs>  TTL for discovery events (default: 7200)
    --discovery-max-events <n>  Max events per unknown pubkey (default: 5)
-v, --verbose               Enable debug logging
-h, --help                  Show help

Subcommands: nostr-swarm export --storage <dir> (dump all valid events as JSONL to stdout) and nostr-swarm import --url <ws-url> (replay JSONL from stdin through the normal validated WebSocket path).

Environment variables

All config can also be set via environment variables. Note: environment variables take precedence over CLI flags (BOOTSTRAP_KEY beats --bootstrap, ADMIT_WRITERS beats --admit).

| Variable | Description | Default | |---|---|---| | WS_PORT | WebSocket port | 3000 | | WS_HOST | Bind address | 0.0.0.0 | | STORAGE_PATH | Data directory | ./nostr-swarm-data | | SWARM_TOPIC | Swarm topic name | nostr | | BOOTSTRAP_KEY | nsw1… invite or 64-hex base key to join (empty = found) | (empty) | | ADMIT_WRITERS | Comma-separated 64-hex writer keys to admit | (empty) | | REQUEST_WRITER | Joiner: request writer admission in-band (1/true) | false | | AUTO_ADMIT | Granter: honor in-band admission requests (1/true) | false | | ACCEPT_OPTIMISTIC | Founder: accept self-verifying optimistic writes from non-admitted peers (1/true) | false | | RELAY_NAME | Relay name (NIP-11) | nostr-swarm | | RELAY_DESCRIPTION | Relay description (NIP-11) | | | RELAY_CONTACT | Admin contact (NIP-11) | | | RELAY_PUBKEY | Admin pubkey (NIP-11) | | | MAX_MESSAGE_SIZE | Max message bytes | 131072 | | MAX_SUBS | Max subscriptions per connection | 20 | | MAX_FILTERS | Max filters per REQ | 10 | | EVENT_RATE | Events per second limit | 10 | | REQ_RATE | REQs per second limit | 20 | | WOT_OWNER_PUBKEY | Owner pubkey for WoT (enables filtering) | | | WOT_MAX_DEPTH | Trust graph max hops | 3 | | WOT_REFRESH_MS | WoT graph refresh interval (ms) | 300000 | | WOT_DISCOVERY | Enable discovery tier for unknown pubkeys | true | | WOT_DISCOVERY_TTL | TTL for discovery events (seconds) | 7200 | | WOT_DISCOVERY_MAX_EVENTS | Max events per unknown pubkey | 5 | | LIGHT_CLIENT | Enable light client mode | false | | LIGHT_MAX_STORAGE | Max storage target (bytes); enforced on a writable (founder) light client | 524288000 | | LIGHT_PRUNE_MS | Pruning interval (ms) | 600000 |

Note: light-client pruning enforces LIGHT_MAX_STORAGE by evicting the oldest events (profiles/contact/mute lists exempt) via founder-authored prune_delete consensus ops, so the view stays convergent. It therefore only runs on a writable (founder/personal-relay) base — a read-only replica cannot soundly mutate the shared, autobase-materialized view, so there it stays a no-op and storage is bounded only by WoT ingest filtering.

Deployment

Peer-to-peer (native)

Since nostr-swarm uses Hyperswarm for NAT hole-punching, nodes connect directly -- no server infrastructure, TLS, or reverse proxy required. Just run the process:

# First node: founds the base, logs its nsw1… invite
nostr-swarm --topic my-relay --storage /var/lib/nostr-swarm

# Every other node: joins with the founder's invite
nostr-swarm --topic my-relay --storage /var/lib/nostr-swarm --bootstrap nsw1...

Nodes on the same topic automatically discover each other and replicate connections, but they only share one database when they share one base: pass the founder's invite via --bootstrap on every other node, then admit each joiner's writer key with --admit (see the multi-writer workflow above). A node started without --bootstrap founds its own independent base.

Process manager

# pm2
npx pm2 start nostr-swarm -- --port 3000

# systemd
sudo systemctl enable --now nostr-swarm

Example systemd unit (/etc/systemd/system/nostr-swarm.service):

[Unit]
Description=nostr-swarm relay
After=network.target

[Service]
Type=simple
User=nostr
WorkingDirectory=/opt/nostr-swarm
ExecStart=/usr/bin/env nostr-swarm
Environment=WS_PORT=3000
Environment=STORAGE_PATH=/var/lib/nostr-swarm
Restart=always

[Install]
WantedBy=multi-user.target

Start9 (StartOS)

nostr-swarm includes a Start9 service package for sovereign self-hosting. See Start9 Deployment for full details.

cd start9
make
# Produces nostr-swarm.s9pk for sideloading or marketplace submission

Your Start9 node becomes an always-on relay peer with Tor and LAN access. To join another relay's base, paste its nsw1… invite into the Bootstrap Key (Invite) config field; your node's own invite and writer key are shown in the service properties for sharing. Writer admission is done by pasting a joiner's writer key into Admit Writers (saving config restarts the service, which performs the admission).

Docker

FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
VOLUME /data
CMD ["node", "dist/cli.js", "--storage", "/data"]

Traditional WebSocket clients

If you need wss:// for browser-based Nostr clients, put the relay behind a reverse proxy with TLS:

server {
    listen 443 ssl;
    server_name relay.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Pear Runtime client

Pear apps connect directly to the swarm -- no WebSocket layer, no server. A Pear client is a read-mostly peer: it replicates the shared base and reads the full view locally, but it is not admitted as a writer -- writes go through a relay's WebSocket endpoint.

import Hyperswarm from 'hyperswarm'
import Corestore from 'corestore'
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import { createHash } from 'crypto'
// decodeInvite turns the relay's nsw1… invite into the 32-byte base key
// (see src/util/invite.ts -- 'nsw1' + z32(version || key || checksum))
import { decodeInvite } from 'nostr-swarm'

const store = new Corestore('./pear-nostr-data')
const swarm = new Hyperswarm()

// Same topic derivation as the relay
const topic = createHash('sha256').update('nostr-swarm:nostr').digest()

// The invite comes from a relay operator (logged at startup / keys.json /
// Start9 properties). Pasting it is what joins this peer to the shared base.
const baseKey = decodeInvite('nsw1...')

// Open the shared Autobase view
const base = new Autobase(store, baseKey, {
  open: (store) => new Hyperbee(store.get('view'), {
    keyEncoding: 'utf-8',
    valueEncoding: 'json',
  }),
  apply,   // must match the relay's apply exactly (src/storage/store.ts)
  valueEncoding: 'json',
})
await base.ready()

// Join and replicate through the base (not the raw corestore)
swarm.on('connection', (socket) => base.replicate(socket))
swarm.join(topic, { server: true, client: true })

// Read Nostr events locally through base.view (Hyperbee).
// To publish, send EVENT to any admitted relay's WebSocket endpoint.

The invite is shared operator-to-operator: it is checksummed (a typo fails loudly instead of silently founding an empty base) and the raw 64-hex base key is also accepted for scripting. Light/Pear clients are never admitted as writers in this release -- appending directly to the base would be rolled back as un-admitted. Operators who would rather skip the manual key copy can opt into the in-band admission channel (--auto-admit on a writer, --request-writer on the joiner; contract in src/swarm/protocol.ts, implementation in src/swarm/admission.ts).

Architecture

Nostr clients (WebSocket)
        |
   [WS Server] --- NIP-01 protocol
        |
   [EventStore] --- Autobase + Hyperbee (indexed storage)
        |
   [SwarmNetwork] --- Hyperswarm (P2P replication)
        |
   Other relay peers / Pear clients
  • EventStore -- Autobase-backed Hyperbee with secondary indexes for kind, author, tags, and timestamps. Its apply() function is a versioned, deterministic consensus protocol: it re-verifies every event's structure and signature, scopes deletion tombstones to the deleter's own pubkey, and processes operator-approved add_writer admissions (capped at 64 writers, founder stays sole indexer).
  • SwarmNetwork -- joins a Hyperswarm topic and replicates the Autobase over encrypted connections
  • WS Server -- standard Nostr relay WebSocket interface (NIP-01, NIP-09, NIP-11, NIP-40, NIP-42, NIP-45). Protected events (NIP-70) are rejected, and writes are gated until this node is an admitted writer.

Web of Trust

Enable WoT filtering to keep your relay focused on socially relevant content:

nostr-swarm --wot-pubkey <your-64-char-hex-pubkey>

The relay builds a trust graph from follow lists (kind 3) and mute lists (kind 10000), then filters events by social distance:

| Degree | Who | Kept for | |--------|-----|----------| | 0 | You | Forever | | 1 | Direct follows | Forever | | 2 | Follows-of-follows | 7 days | | 3 | Third degree | 1 day | | -- | Unknown (discovery) | 2 hours (cap: 5 events) | | -- | Muted | Rejected |

See Web of Trust for full details on scoring, muting, discovery, and customization.

Key Management with keytr

keytr provides passkey-based Nostr private key management. Instead of copying nsec strings between devices, keytr encrypts your private key with a WebAuthn passkey (Face ID, fingerprint, or hardware key) and publishes the encrypted blob as a kind:30079 event to Nostr relays -- including nostr-swarm peers.

How it works with nostr-swarm

  1. Register -- keytr encrypts your nsec using a passkey and publishes a kind:30079 event to a writable (admitted) nostr-swarm node
  2. Store -- every node sharing that relay's base replicates and stores the encrypted event
  3. Login -- on any device with the synced passkey, fetch the event from any node of the base and decrypt your nsec

Since every node of a shared base replicates all of its events (including read-only joiners), your encrypted key blob is available from any of them -- no single relay dependency within the base.

Setup

npm install @sovit.xyz/keytr
import { setupKeytr, publishKeytrEvent } from '@sovit.xyz/keytr'
import { finalizeEvent } from 'nostr-tools'

// Generate a new nsec and encrypt it with a passkey
const { credential, encryptedBlob, eventTemplate, nsecBytes, npub } = await setupKeytr({
  userName: 'alice',
  userDisplayName: 'Alice',
})

// Publish the encrypted key event to your nostr-swarm relay
const signedEvent = finalizeEvent(eventTemplate, nsecBytes)
await publishKeytrEvent(signedEvent, ['ws://localhost:3000'])

Login from another device

import { loginWithKeytr, fetchKeytrEvents } from '@sovit.xyz/keytr'

// Fetch your encrypted key event from the swarm relay
const events = await fetchKeytrEvents(pubkey, ['ws://localhost:3000'])

// Authenticate with your passkey to decrypt
const { nsecBytes, npub } = await loginWithKeytr(events)

NIP-07 signing with keytr-connect

keytr-connect bridges keytr to any Nostr client that supports window.nostr (NIP-07):

import { KeytrProvider } from '@sovit.xyz/keytr-connect'

const provider = new KeytrProvider({
  relayUrls: ['ws://localhost:3000'],
  rpId: 'keytr.org'
})

await provider.signup({ userName: 'alice', userDisplayName: 'Alice' })
provider.install() // Sets window.nostr -- clients can now sign events via passkey

Backup and recovery

Register backup passkeys on separate gateways (e.g., keytr.org and nostkey.org) so losing one device doesn't mean losing your key. See the keytr docs for details on the federated gateway model and recovery flows.

Primal web UI

The open-source Primal web app can be used as a UI for a nostr-swarm relay. Primal's client doesn't read from relays directly -- it speaks to Primal's proprietary caching service -- so nostr-swarm ships a protocol adapter that impersonates that service and answers it from this relay:

# Terminal 1: the relay
nostr-swarm

# Terminal 2: the cache shim (ws://localhost:8801 → ws://127.0.0.1:3000)
nostr-swarm primal-shim

Point a swarm-client checkout (our primal-web-app fork) at it (.env: PRIMAL_CACHE_URL = "ws://localhost:8801", PRIMAL_PRIORITY_RELAYS = "ws://localhost:3000", then npm run dev -- --port 5174). Feeds, threads, profiles, publishing, search, notifications, explore/trending, and direct messages work against the relay; Primal-cloud-only surfaces (Premium, wallet, DVM feeds, live streams) degrade to empty states. See Primal cache shim for details, and Deployment for running the relay + shim together (Procfile / docker-compose / nginx).

Documentation

  • Architecture -- Internal design, storage layer, replication, and protocol details
  • Client Architecture -- How WebSocket clients and Pear Runtime clients connect and differ
  • Primal cache shim -- Using the Primal web app as a UI for this relay
  • Deployment -- Running the relay + shim together (Procfile, docker-compose, nginx/TLS)
  • Web of Trust -- Trust graph filtering, scoring tiers, and pruning
  • Start9 Deployment -- Packaging and running on StartOS

Supported NIPs

  • NIP-01 -- Basic protocol flow (events, subscriptions, filters)
  • NIP-09 -- Event deletion (author-scoped: only the original author's deletions remove or block their events)
  • NIP-11 -- Relay information document
  • NIP-40 -- Expiration timestamp
  • NIP-42 -- Authentication
  • NIP-45 -- Event counts (COUNT)
  • NIP-50 -- Search (case-insensitive substring match over event content)

NIP-70 (protected events) is deliberately not supported. A replicated multi-writer store cannot honor "don't propagate", so events carrying the ["-"] tag are rejected at the WebSocket edge (blocked: protected events (NIP-70) are not accepted by replicated relays) and skipped by the consensus apply function -- never accept-then-drop. NIP-70 explicitly blesses rejection. This applies to single-node deployments too.

License

MIT