@peerly/core
v1.16.0
Published
Reusable encrypted realtime core with authenticated Durable Objects, P2P rooms, device identity, signing, media, and presence
Maintainers
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, unlinkableThe 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.
