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

j8chat

v1.0.7

Published

Ephemeral, end-to-end encrypted chat in a terminal. No database, no history.

Downloads

895

Readme

SecureChat

Ephemeral, end-to-end encrypted chat rooms. A short code to meet, no database, and rooms that dispose of themselves five minutes after the last person leaves.

  • Encryption happens in the browser using only the Web Crypto API. No third-party cryptographic code is shipped to users.
  • The relay holds rooms in one in-memory Map. It writes nothing to disk and logs no room IDs, addresses, or payloads.
  • A session with no live connection is destroyed after five minutes. Rejoining inside that window cancels the disposal.
  • A transcript keeps its most recent 1000 entries. Older ones leave the page, so a room that runs for hours cannot grow the tab without bound, and a trimmed message is gone: there is no store behind the view to scroll back into.
  • Images travel the same encrypted path as text, in chunks, and are never shown until the whole of one has arrived and verified. Metadata is stripped from everything either client sends, so a phone photo's GPS coordinates do not go with it.

Quick start

npm install
npm run build
npm start

Open http://localhost:8080. For development with rebuild-and-restart on change:

npm run dev

Run the checks:

npm run check     # typecheck, then tests, then build

Terminal client

The same client, without a browser. It is published to npm as j8chat, and npx means nothing has to be installed:

npx j8chat --relay wss://chat.example.com

Or permanently:

npm install -g j8chat
j8chat --relay wss://chat.example.com

In this repository it shares every part of the browser client that matters: src/client/crypto.ts for key derivation, src/client/session.ts for the peer table and the framing, src/shared/ for the protocol and the session codes. Only the input and the rendering differ, so there is one implementation of the parts that have to be right rather than two that can drift apart.

The npm package ships the compiled CLI bundle and nothing else: no source, no relay, no web client. It has no runtime dependencies, importing only Node built-ins.

Inside a session: /peers lists participants with their safety codes, /code shows the session code again, /send <path> and /save <n> handle images, /clear wipes the screen and the scrollback, and /quit leaves.

The relay address has no default

--relay is required, or JCHAT_RELAY in the environment. A published package carrying a built-in relay address would point every reader of the registry at one particular machine, and CREATE_PHRASE would be left doing that work alone. A bare host is read as wss://. Plain ws:// is refused unless it points at this machine, which is the same rule the browser gets from isSecureContext, written out so it holds outside a browser too.

Secrets are never arguments

Neither the session code nor the access phrase can be passed on the command line, because a command line is visible to every other process on the machine through the process table. Both are typed at a prompt. The phrase is not echoed back, since it outlives any one conversation and is shared by everyone on the relay; the code is echoed, because it appears on screen the moment a session starts anyway, and typing twenty characters blind is how people join the wrong room.

Running your own relay means this repository, not the package.

How the session code works

A full code is 20 characters and splits in half.

| Half | Example | Goes to the server | Role | |---|---|---|---| | Routing | SA8X92EVQK | Yes | Finds the room | | Secret | MXKZFT4H01 | Never | Feeds key derivation |

The browser generates the secret half locally and never transmits it. It is an input to HKDF, so a relay that substituted public keys during a join still could not derive a message key. Someone who learns only the routing half can enter the room but cannot read anything, and both sides see a warning saying so.

Codes use a 32-symbol alphabet with I, L, O and U removed, so they survive being read aloud. Input is case-insensitive and dashes are ignored.

An invite link carries the full code in the URL fragment, which browsers never send to a server. The fragment is stripped from the address bar immediately after it is read, so the secret does not linger in browser history. A consequence: refreshing the page ends your participation, by design.

The room can show that link as a QR code, drawn in the browser by the encoder in src/client/qr.ts. Handing the link to a QR service would post the secret half of the code to a stranger, and a library from a CDN would put third party code in a bundle whose readability is the point; the content security policy refuses both. The QR is exactly as sensitive as the code itself, so it stays hidden until asked for. Anyone who scans it is in.

Encryption

Per pair of participants:

ECDH P-256 (ephemeral, one key pair per tab)
  -> shared secret
  -> HKDF-SHA256, salt = SHA-256(roomId | roomSecret)
                  info = "securechat-v1|pair|" + sorted(pubA, pubB)
  -> AES-256-GCM key

Every message is encrypted once per recipient with a fresh 96-bit nonce. The relay receives { to, payload } where the payload is iv.ciphertext.

Everyone gives a name before entering a room, whether they start the session, type a code, or follow an invite link. The buttons stay disabled until they do, so nobody arrives as Peer 4D64.

Nicknames travel inside the ciphertext, so the relay never learns display names. Each pair also gets a 12-digit safety code; matching codes on both screens confirm the two sides derived the same key.

Pairwise keys rather than a shared group key: with a cap of ten participants the fan-out is cheap, there is no group key to rotate when someone leaves, and a departed peer cannot read later messages because nobody encrypts to them.

What this protects, and what it does not

Protects against:

  • Network observers. TLS in transit, AES-GCM ciphertext underneath.
  • The operator reading messages. The relay holds ciphertext and public keys only, and never the secret half of any code.
  • A malicious relay intercepting a join. Substituting public keys does not help without the secret half of the code.
  • Seizure after the fact. Nothing is stored, so there is nothing to take.
  • Guessing a room. About 50 bits in each half, plus per-address rate limits.
  • Former participants. Once a peer leaves, nobody encrypts to them.

Does not protect against:

  • A compromised server shipping altered client JavaScript. Inherent to every web-delivered end-to-end app. Mitigations are reproducible builds and a published hash of the client bundle, which this version does not do.
  • Compromised endpoints. Malware, browser extensions, shoulder surfing, screenshots.
  • Terminal scrollback. In the CLI the transcript is scrollback, which is a weaker promise than a browser tab that forgets on reload. Some terminals log it to disk, and VS Code's integrated terminal keeps sessions across window reloads. /clear clears the screen and the scrollback; closing the terminal is the thorough version.
  • Metadata. The relay knows how many people are in a room, when, and from which addresses. It does not know who they are or what they say.
  • Someone you gave the code to. The code is the entire access control for a conversation.
  • A malformed image from a participant. Neither client re-encodes what it sends, so the bytes a recipient decodes are the bytes the sender chose. The format allowlist, the magic-byte check and the absence of SVG are what stand in the way; the decoder doing the work is the browser's own.
  • Someone you gave the access phrase to. Where CREATE_PHRASE is set it decides who may start a session, nothing more. It is shared by a group, so it is only as private as the least careful person holding it, and it takes no part in key derivation: knowing it reveals nothing about any conversation.

Images

JPEG, PNG and WebP, up to 4 MB. Anything else is refused, and so is anything over the cap: downscaling would need an image decoder, which the browser has and Node does not, so doing it would either add a dependency to the published CLI or make the two clients behave differently. A refusal names the size, the cap and a target edge, since you have to do the resizing.

Metadata is always stripped, by rewriting the container rather than re-encoding the pixels. JPEG APP1 and APP2, PNG eXIf, tEXt, iTXt, zTXt and tIME, and WebP EXIF and XMP are all removed, and anything trailing a JPEG's end-of-image marker is dropped, since that is a favourite place for maker notes and second thumbnails. Chunks are kept by allowlist, so a format that gains one we have not considered loses it rather than carrying it. The orientation is read out before the metadata goes and travels in the manifest instead, so a portrait photo is not left sideways.

Nothing partial is ever shown. An image arrives as a manifest and then as numbered 13 KiB chunks, each encrypted separately under the same pairwise key as a message. It is drawn only once every chunk has arrived and the SHA-256 in the manifest matches, because AES-GCM authenticates each chunk on its own and binds none of them to another: the hash is what makes the whole tamper-evident.

Cost scales with the room, because every participant gets their own encrypted copy. A 4 MB image is about 8 seconds to one person and about 70 to a full room of ten. Above a twenty second estimate the browser asks before starting. Transfers are paced at 40 of the 60 frames per second the relay allows, so a picture on its way does not starve the conversation: both spend the same budget.

The relay learns more from an image than from a message. A burst of a few hundred similarly sized frames is unmistakably a picture, and reveals its size to within 13 KiB. It still cannot read any of it.

In the terminal

j8chat sends and receives images too, and /save <n> writes a received one, which is the only time it writes anything to disk.

There are four ways to pick what to send, because typing a path is the worst of them:

| | | |---|---| | Drag a file onto the window | The terminal inserts the path, escaping and all | | /send with no path | Opens your own file browser, where there is one | | Tab after /send | Completes paths, offering only formats that will be accepted | | /paste | Sends the picture on the clipboard, with no file involved |

/paste is its own command rather than a mode of /send on purpose. The two answer different questions, "which file" and "the thing I just copied", and folding them together would mean guessing which was meant.

Take a screenshot and say /paste and it goes, without ever touching the disk. The file browser needs one to exist: macOS always has one, Linux needs zenity or kdialog, and where there is none /send says so and asks for a path. It is also skipped when input is piped, since a modal window would hang a scripted session on a dialog nobody can answer.

Images are drawn in the terminal where the terminal can draw them: iTerm2 and WezTerm via OSC 1337, kitty and Ghostty via the kitty graphics protocol. kitty only accepts PNG, so a JPEG or a WebP is listed as a line there instead. Everywhere else, and through a multiplexer, and whenever output is piped rather than shown, you get the line and can /save it. --images inline|save|off overrides the guess.

Configuration

Every limit is an environment variable.

| Variable | Default | Meaning | |---|---|---| | PORT | 8080 | Listen port | | HOST | 0.0.0.0 | Bind address | | MAX_PEERS_PER_ROOM | 10 | Participant cap | | MAX_ROOMS | 300 | Live room cap, then SERVER_FULL | | MAX_CONNECTIONS | 2000 | Concurrent sockets, then the upgrade gets a 503 | | MAX_CONNECTIONS_PER_IP | 20 | Concurrent sockets from one address | | JOIN_DEADLINE_MS | 30000 | Grace period to create or join before the socket is dropped | | MAX_BUFFERED_BYTES | 1048576 | Unsent bytes queued for a peer before its socket is closed | | ROOM_DRAIN_MS | 300000 | How long an empty room survives | | ROOM_ALONE_MS | 1800000 | How long a room with one participant survives | | MAX_ROOM_AGE_MS | 86400000 | Absolute room age ceiling | | CREATE_BURST | 10 | Create and join burst per address | | CREATE_PER_MINUTE | 10 | Create and join refill per address | | RELAY_BURST | 180 | Relay frame burst per connection | | RELAY_PER_SECOND | 60 | Relay frame refill per connection | | MAX_FRAME_BYTES | 32768 | WebSocket frame ceiling | | HEARTBEAT_MS | 30000 | Ping interval; two misses drops the socket | | IDLE_TIMEOUT_MS | 21600000 | Idle socket timeout | | TRUST_PROXY | false | Read the client address from proxy headers | | CREATE_PHRASE | unset | Shared phrase required to start a session; unset leaves creation open |

Image limits are fixed in src/shared/protocol.ts rather than configurable: 4 MB per image, 13 KiB per chunk, a 192 KiB send window, and 40 frames per second. They are chosen against the relay's own ceilings, and moving one without the others produces a client that disconnects its recipients.

Restricting who can start a session

A public relay will eventually be found and used by strangers. Setting CREATE_PHRASE requires a shared phrase before the relay will hand out a new room:

CREATE_PHRASE="correct horse battery staple"

Case, surrounding space and runs of space are ignored, so it can be something spoken aloud and retyped from memory rather than copied. The client asks the relay whether a phrase is needed and shows the field only where it is, so an open deployment is unchanged.

Joining is deliberately not gated. Anyone following an invite link already holds the session code, which is the stronger secret, and asking them for the phrase as well would only spread it further. A wrong phrase costs the same rate-limit budget a real attempt does, so guessing is bounded by CREATE_PER_MINUTE.

Relay limits are per connection and account for fan-out: one chat message in a full room becomes nine relay frames.

Per-address limits key on the whole IPv4 address, and on the /64 for IPv6. A /64 is the smallest block a customer is routinely handed, so keying on the full address would let one machine present 2^64 identities and walk through every per-address limit here.

The defaults assume a 1 GB VM. Sockets, not rooms, are what fills it: budget roughly 100 KB per connection across the relay and the TLS terminator together.

Deploying

Rooms live in one process's memory, so the relay must run as a single instance. More than one instance splits rooms across processes and joins begin to fail. Serverless platforms whose functions cannot hold a WebSocket open are not suitable.

TLS is not optional. Browsers expose Web Crypto only in a secure context, so the app refuses to run over plain HTTP except on localhost. That means a hostname, because certificate authorities will not issue for a bare IP. You do not have to buy one; DEPLOY.md covers the free routes.

Static and serverless hosts cannot run this. Netlify, Vercel, Cloudflare Pages and the like serve files and short-lived functions, and the relay needs a process that holds sockets open and keeps rooms in memory between messages.

On your own VM (Vultr, Hetzner, DigitalOcean, EC2): see DEPLOY.md. Caddy terminates TLS, gets its own certificate, and proxies WebSockets with no extra configuration.

With no domain and no open ports: a Cloudflare quick tunnel, at the cost of a URL that changes on every restart.

docker compose -f docker-compose.tunnel.yml up -d --build

On Azure: Container Apps, which supplies an HTTPS hostname and certificate so there is no DNS to configure. Azure Functions cannot run this app; see DEPLOY.md for why, and for the cost, which is higher than buying a domain outright.

az login && ./deploy/azure.sh
echo "DOMAIN=chat.example.com" > .env
docker compose up -d --build

On Fly.io:

fly launch --no-deploy --copy-config
fly deploy
fly scale count 1

Any container host:

docker build -t securechat .
docker run -p 8080:8080 securechat

Set TRUST_PROXY=1 only when the relay actually sits behind a trusted reverse proxy and its own port is not reachable from the internet. Otherwise a client can forge X-Forwarded-For and escape the rate limits.

A restart drops every live room. That is the intended behaviour for a system that promises to store nothing, and it is worth saying out loud before you deploy over a busy evening.

That is also why .github/workflows/deploy.yml deploys on a tag and not on a merge. Merging to main runs the checks and changes nothing on the relay, so shipping stays a decision somebody makes deliberately. GET /healthz says who would be dropped: peers is the headcount. To deploy something that is not a release, run the workflow by hand from the Actions tab.

Publishing the CLI

Releases happen by tag, through .github/workflows/publish.yml. Nothing is published from a laptop.

npm version patch    # or minor, major
git push --follow-tags

The same tag deploys the relay, so a release ships both and restarts the relay once.

The workflow runs npm run check, refuses a tag that disagrees with package.json, and then stages the release.

The published package is a minified bundle. It carries no source and no provenance attestation, and the repository is private, so someone installing it cannot read the cryptography, cannot confirm what it was built from, and is trusting the author rather than the code. That is a deliberate choice and worth stating rather than implying otherwise: the verifiability this project offers a browser user, who receives readable JavaScript from a relay they chose, is not offered to an npm user.

Minification is not what makes it unreadable, either. The scheme names and the HKDF info strings survive it intact. Anyone determined to read the protocol still can.

Staged means nothing is live yet. Approve it to release:

npm stage list
npm stage approve <stage-id>

Needs npm 12 or newer, or use the package page on npmjs.com. The approval is not ceremony: it puts a person between a compromised workflow and everyone who installs the result, so the trusted publisher keeps its default stage-only permission and nothing in CI can publish on its own. It is a narrower guarantee than provenance would be, covering who released a tarball rather than where it was built.

There is no NPM_TOKEN. Publishing authenticates over OIDC using npm's trusted publishing, which has to be configured once on npmjs.com under the package's Settings, as a Trusted Publisher pointing at this repository and this workflow file. Enable 2FA on the npm account while you are there: an account takeover is the realistic way a package like this ends up shipping something it should not.

To see exactly what the tarball contains:

npm pack j8chat

Layout

src/shared/     protocol types and validation, session codes, image containers
src/server/     http and websocket bootstrap, room manager, rate limits
src/client/     crypto, session, image transfer, ui, qr encoder, static assets
src/cli/        terminal entry point, prompts, rendering, terminal graphics
test/           room lifecycle, crypto, relay urls, session, images, integration
deploy/         bootstrap, the Azure deploy script, a systemd unit
Caddyfile       TLS terminator config, domain comes from .env
docker-compose.yml          Caddy plus the relay, for a VM
docker-compose.tunnel.yml   no domain, no open ports
azure-containerapp.yaml     Azure Container Apps settings

Health

GET /healthz returns counts only:

{ "ok": true, "rooms": 1, "peers": 3, "draining": 0, "alone": 0, "uptime": 125 }

Room IDs are deliberately absent from both the health output and the logs. A logged room ID would be enough for whoever reads the logs to walk into a live session.