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

@peerly/core

v1.16.0

Published

Reusable encrypted realtime core with authenticated Durable Objects, P2P rooms, device identity, signing, media, and presence

Readme

@peerly/core

The P2P room core of Peerly — join encrypted Trystero rooms by high-entropy room codes, with device identity, signing primitives, and signaling-strategy selection. No application server by default: signaling (Nostr by default) is used only so browsers can find each other; everything after the handshake is direct WebRTC. An optional durable-objects strategy (below) instead uses a Cloudflare Worker + Durable Objects control plane for coordination — see "Signaling strategies".

Powers Peerly and any app that joins the same style of encrypted Trystero rooms. MIT.

Install

npm install @peerly/core

@trystero-p2p/supabase, @trystero-p2p/ws-relay, and react are optional peer dependencies — install the ones your app actually uses. All signaling strategies are imported dynamically, so your bundle only carries the transport you select.

The model

A room code is a 128-bit random hex string that doubles as the room's Trystero password: whoever holds the code can address the room and decrypt its WebRTC handshake, and nobody else can. Sharing the code is granting access; there is nothing to configure server-side because there is no server.

import { generateRoomCode, joinRoomByCode, resolveSignalingStrategy } from '@peerly/core'

const code = generateRoomCode() // e.g. "9f8f3a…" — share it out-of-band

const room = await joinRoomByCode({
  strategy: resolveSignalingStrategy(import.meta.env),
  appId: 'my-app-v1',
  roomId: code,
  password: code,
  env: import.meta.env,
})

// Trystero action API (object form — not the older tuple form)
const hello = room.makeAction<string>('hello')
hello.onMessage = (msg, { peerId }) => console.log(peerId, msg)
room.onPeerJoin = peerId => void hello.send('hi', { target: peerId })

Functions never read import.meta.env themselves — Vite substitutes that per-bundle, so a library cannot see the app's values. Pass your env (or any plain object) where a function takes env.

selfId is re-exported from @trystero-p2p/core so apps can use one peer-id source without depending on Trystero directly.

React

import { useRoom, useLatest } from '@peerly/core/react'

function Chat({ code }: { code: string }) {
  const { room } = useRoom({
    appId: 'my-app-v1',
    roomId: code,
    password: code,
    env: import.meta.env,
    onError: message => toast(message),
  })
  // room is null until joined; stable across re-renders; leaves on unmount.
  // An empty roomId means "no room yet" and joins nothing — safe for callers
  // whose room is still being negotiated (hooks must run unconditionally).
  // Optional: onPeerHandshake, errorText overrides per RoomErrorKind.
}

// useLatest(value) — ref always pointing at the latest value (stale-closure helper)

Shared message actions and durable content channels

useMessageActionMenu() is the headless reaction/action controller shared by Peerly and HeyHubs. It owns reaction search, fixed/portal panel positioning, Escape/outside-click closing, and first-safe-link discovery while each product keeps its own icons and CSS.

openDurableChannel() exposes the small makeAction/getPeers/onPeerJoin surface used by room features. A consumer supplies its own authorization command and Durable Object route; passing a high-entropy encryptionSecret encrypts every action payload with browser-side AES-GCM before storage. Product schemas and Durable Object classes remain in the consumer repository.

Use send(payload, { messageId }) to retry the same logical message after a lost acknowledgement; use a new transport ID for each edit. For encrypted durable metadata, a channel factory can declare stateEvents, and callers send { state: { key, revision, deleted } }. The transport hides entity keys with a workspace-secret-derived digest. Current state and tombstones are kept outside rolling event retention; the 1,000-entity limit rejects additions instead of evicting existing state. Existing encrypted metadata is migrated without needing its plaintext.

Both channel factories now require authenticated x-realtime-uid and x-realtime-sid headers, APP_ID, and USER_GATEWAYS with the current gateway implementation. The content/lobby bindings are CONTENT_CHANNELS and LOBBY_CHANNELS. They register session subscribers with the account gateway so a revocation closes existing sockets before ACK. Deploy the channel and gateway implementations together; clients with old socket attachments must reconnect.

For public discovery, createLobbyIdentityClient() uses same-origin /api/rendezvous/presence and /api/rendezvous/verify endpoints. Mount issueLobbyIdentity and verifyLobbyIdentity from @peerly/core/worker/lobby-identity with the existing OIDC configuration, RENDEZVOUS_SECRET, and RENDEZVOUS_RATE_LIMITER. The short-lived certificate contains only pseudonymous user/device/discovery IDs and expiry. Its issuer is trusted to verify the provider token; keep full provider attestations inside recipient-encrypted invitations, never public presence.

The hook carries Peerly's hard-won teardown handling: leave() is async and Nostr batches relay subscriptions across rooms, so a leave landing after the next join silently kills that room's signaling. The hook serializes them — StrictMode remounts and room switches just work. It also auto-rejoins when ICE fails after SDP exchange (strict NAT / flaky path).

Signaling strategies

| Strategy | When | Config | |----------|------|--------| | nostr (default) | No server, public relays | none — curated DEFAULT_NOSTR_RELAYS, override VITE_NOSTR_RELAYS | | ws-relay | Offline / CI / local relay | VITE_SIGNALING=ws-relay, resolveRelayUrls(env) | | supabase | A relay you control | VITE_SIGNALING=supabase + VITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEY | | durable-objects | Cloudflare Worker + Durable Objects control plane | VITE_SIGNALING=durable-objects + VITE_APP_ID; the Worker must separately run with COORDINATION_BACKEND=durable-objects, matching DO bindings/migrations, and its own secrets — a client-only flag against an unconfigured Worker fails closed (503) rather than falling back |

Related helpers: signalingLabel(), buildRelayUrls(), getNostrRelayConfig(), getSupabaseRoomConfig(), resolveRelayPort().

durable-objects also replaces /api/network/credentials with /api/network/session for TURN credentials, and replaces the relay's own transport for presence/directory/DM notifications and WebRTC signaling (SignalScopeDO) — see worker/realtime and src/realtime, and the Durable Objects architecture doc for the full design. It currently backs only Peerly's stable preview deployment (preview.peerly.cc); production still runs the relay-based strategies above.

TURN for strict NATs uses VITE_TURN_URLS plus short-lived credentials from /api/network/credentials (see resolveIceServers). Do not embed long-lived TURN credentials in VITE_* variables.

Relay diagnostics

import { probeNostrRelay, probeNostrRelays, createRelayHealth } from '@peerly/core'

// End-to-end: subscribe + publish + require echo (open socket ≠ healthy)
const results = await probeNostrRelays(DEFAULT_NOSTR_RELAYS)

// Live WebSocket visibility while a room is connected
const health = createRelayHealth()

Call probes on demand (diagnostics UI / re-check), not on a tight interval — public relays throttle that.

Identity primitives

import { DeviceIdentity, verifyWithDeviceKeyId, deriveUserId } from '@peerly/core'

const device = new DeviceIdentity()          // non-extractable P-256 key, persisted in IndexedDB
const keyId = await device.publicKeyId()     // "P-256:<x>:<y>" — carries the full public key
const signature = await device.sign(bytes)
await verifyWithDeviceKeyId(keyId, bytes, signature) // no key distribution needed

const userId = await deriveUserId(jwt.iss, jwt.sub)  // durable, provider-namespaced, unlinkable

The private key is generated non-extractable and never leaves WebCrypto — an XSS can sign while it runs, but cannot copy the key out.

Also: canonicalizePublicKey.

Shared chat, merge, media, attention

Reusable primitives for apps built on this core. App-specific wire schemes stay in the app; encoding and crypto are shared.

import {
  // Canonical signing bytes (newline-joined fields; free text last)
  encodeCanonicalLines,
  // Simple text-room wires. Pass your scheme string for wire stability.
  signTextChat, verifyTextChat, signTextReaction, verifyTextReaction,
  // Pure merge rules for edits/deletes/reactions
  isAcceptableRevision, mergeReactionsByActorKey, applyToggleReaction,
  // Progressive media (text-first: no capture until enableMic / enableCamera)
  createRoomMedia,
  // Attention (gesture-primed AudioContext)
  primeAttentionAudio, playMatchChime, playNotificationChime, formatUnreadTitle,
} from '@peerly/core'
import { useRoomMedia } from '@peerly/core/react'

Peerly workspaces keep a richer signed history format (peerly-msg-v1/v2) in the app, but build those bytes with encodeCanonicalLines and reuse the same revision/reaction merge helpers.

OIDC verification (browser-only)

import {
  renderGoogleSignInButton,
  verifyGoogleIdToken, // preferred for Google — issuers + JWKS pinned
  verifyOidcIdToken,   // generic IdP
  GOOGLE_ISSUERS,
  GOOGLE_JWKS_URL,
} from '@peerly/core'

const nonce = await device.publicKeyId() // bind the token to this device key
const token = await renderGoogleSignInButton(container, nonce, clientId)

// Google path (simplest)
const claims = await verifyGoogleIdToken(token, {
  expectedAudience: clientId,
  expectedNonce: nonce,
})

// Or any OIDC issuer
// await verifyOidcIdToken(token, { expectedAudience, expectedNonce, issuers, fetchJwks })

Both verifiers check the RS256 signature against the issuer's JWKS, pin exact issuers, require a verified email claim, and reject tokens whose nonce doesn't match the presenting device key — all client-side; the token never leaves the browser.

UI / storage helpers

| Export | Purpose | |--------|---------| | getPeerColor(seed), PEER_COLORS, avatarInitial(name) | Deterministic peer bubble color + initials | | isSafeAvatarUrl / safeAvatarUrl | Allowlist for rendering peer avatars (data:image/* only — blocks remote URL tracking) | | processAvatarBlob / processAvatarImage | Resize/compress uploads to a safe data URL | | formatClockTime(timestamp) | Locale-aware HH:MM for message timestamps | | createKvStore(name) | Small IndexedDB key-value store | | createBlobStore / openIndexedDb | Shared IndexedDB helpers for apps that cache blobs | | probeP2pCapability() | WebRTC self-test (P2pCapability) | | classifyJoinError(message) | Map Trystero errors → password-mismatch / needs-turn / unknown | | base64UrlToBytes / bytesToBase64Url / utf8ToBase64Url / base64UrlToUtf8 | Base64url codecs | | encodeCanonicalLines | Shared signed-payload encoding | | isAcceptableRevision / mergeReactionsByActorKey / applyToggleReaction | Message revision + reaction merge | | signTextChat / verifyTextChat / … | Simple text-room signed wires | | createRoomMedia / useRoomMedia | Progressive mic/camera over a room | | primeAttentionAudio / playMatchChime / formatUnreadTitle | Tab + sound attention | | createPeopleAttestation / loadPeopleList / encodeSharedPeopleList / … | Signed personal lists (block / friend) with optional email + share codes |

Apps that also need remote OIDC photos (e.g. Google) should layer their own host allowlist on top of isSafeAvatarUrl — the core helper deliberately refuses remote https:// avatars so a peer cannot force every client to phone home to an attacker-controlled URL.

Signed people lists (block / friend)

import {
  createPeopleAttestation,
  loadPeopleList,
  savePeopleList,
  encodeSharedPeopleList,
  type DeviceSigner,
} from '@peerly/core'

// App-owned scheme keeps wire formats stable across products.
const SCHEME = 'peerly-friend-v1'
const list = loadPeopleList('my-friends-v1', 'my-friends-subs-v1')
const entry = await createPeopleAttestation(signer, SCHEME, {
  kind: 'friend',
  ownerUserId: me,
  subjectUserId: them,
  subjectName: 'Ada',
  subjectEmail: '[email protected]', // optional — Peerly captures this at handshake
})

Consumer apps can use the same primitive for blocks and friends without email. Inviting someone to a Peerly workspace needs a verified email, which only Peerly's handshake exposes; an email-less external friend becomes invitable only after connecting in Peerly and sharing email through that handshake.

What this package is not

It carries no opinion about who may join a room: Peerly's creator-signed allow-lists, peer handshakes, and message-history sanitization stay in the app layer (OIDC token verification is provided, but what a verified identity is allowed to do is your app's decision). Room-code possession is the only access control here — treat codes like credentials and share them in URL fragments, never query strings.

For direct messages, never derive a room password from public user identifiers alone. generateDmSecret() creates a random per-friend credential and dmRoomCode(userA, userB, scheme, sharedSecret) namespaces that credential for an app and pair. Exchange the secret only during an authenticated friendship handshake. DM ring payloads intentionally contain no room credential. Use createDmRing({ scheme, grants }) to sign and verify rings, then call authorizedBy to bind the verified sending device to the friend record. The deprecated dmRingBytes, signDmRing, and verifyDmRing helpers retain the grant-free 1.x wire format. They cannot authorize secondary devices: verifyDmRing rejects attached grants, and its callers must still bind deviceKeyId to the friend record before accepting a ring.

The shared presence and text-history helpers clean reverse indexes and bound untrusted envelopes, but applications remain responsible for authenticating presence identities and verifying message signatures before display or storage.

recordSyncActivity, subscribeSyncActivities, and getSyncActivities provide a shared metadata-only activity feed for P2P applications. Callers supply a relationship-aware peer descriptor plus category, count, and byte estimate; the bounded in-memory log intentionally has no payload-content field and is never persisted.

Publishing (maintainers)

Releases go through the release-core.yml GitHub Actions workflow (manual trigger): it lints and tests the repo, bumps peerly and @peerly/core in lockstep (patch/minor/major), publishes with npm Trusted Publishing, then opens a release PR (main is protected — no direct push) and pushes core-v* / v* tags. Merge the PR so main matches npm.

| Input | Real release example | |-------|----------------------| | dry_run | false (default is true — dry-run only) | | bump | patch, minor (e.g. 1.0.0 → 1.1.0), or major |

Dry-run still applies the bump in the runner workspace so the tarball is a new version (re-publishing an existing version fails). For a local sanity check: cd packages/core && npm run build && npm pack --dry-run (prepack builds automatically).

Shared messaging and identity

Peerly and HeyHubs use these implementations directly; application wrappers supply storage names, signed wire types, UI text, and product authorization rules.

| API | Responsibility | | --- | --- | | createIndexedDbOutboxStorage(name) / createMessageOutbox(storage) | Persist immutable signed messages with stable IDs; ordered retry and cancellation of future attempts. | | useMessageOutbox(storage, scope, enabled, deliver) from /react | Pending/failed state, reconnect/online retry, and navigation guards. deliver must reject until delivery is confirmed. In P2P mode, enable only when a recipient is present unless peer history replay guarantees delivery. | | useConversationState(scope, initial) from /react | Prevent reads/writes across conversations, including stale async work after A → B → A. Use the returned generation-specific setter in callback dependency arrays. | | useHistoryPersistence(scope, ready, value, save) from /react | Report local history write failures and retry dirty snapshots. Gate writes until history hydration completes. | | createTextChatHistoryStore(config) | Shared history merge/load/save. save returns boolean; a failed write remains readable in this process for retry/export. It does not survive closing the page. | | createIdentityClient() | Obtain and verify short-lived, origin-bound account/device certificates without disclosing an OIDC token to other clients. | | createIdentityRoutes(options) from /worker/identity | Verify the provider token and device proof; issue/verify a minimal certificate. Configure a secret and rate limiter. | | UserGatewayDO.validateChannelSession({sid, dk}) | One authoritative session-validity decision for both applications' channel adapters. |

The identity Worker is an identity authority: applications trust it to attest an account/device binding. HMACs are scoped to the protocol and origin. Certificates contain identifiers and validity timestamps; they contain no email, profile, or provider token. The provider token is sent only to the issuing Worker. A legacy Peerly discovery wrapper also includes the opaque rendezvous identifier.

For signed historical entries, verify(certificate, signedEntryTimestamp) checks validity at that timestamp. Callers must first verify the entry's device signature and use its signed timestamp. Live handshakes must call verify(certificate) with no historical timestamp; an expired certificate must not authenticate a live peer.

SignedControl.attestation and PeerIdentityAttestation accept either the legacy OIDC shape or { certificate }. Applications that promise private identity must emit certificates and reject raw provider attestations for live traffic. HeyHubs keeps legacy board entries readable locally but does not redistribute their tokens.

An outbox acknowledgement means the configured transport accepted the message; it does not mean that a person read it. Cancelling retry cannot recall an attempt already delivered. Applications own that wording and their retention policy.

A registration that evicts a device invalidates its channel subscribers before returning. Failed invalidations keep their subscriber records for retry. DO channel adapters, room/workspace membership, discovery, and app-specific migrations remain separate from these shared session rules.