wsh
@johnhenry/wsh (“Web Shell”) is a browser-native remote command
execution client: open PTY and exec sessions on a remote host from a web page
or Node.js, authenticated with Ed25519 keys, over either WebTransport or
WebSocket with an identical API. It speaks a compact CBOR wire protocol with
97 message types, and includes file transfer (list, read, write, rename,
upload, download), session recording (its own JSON schema), a remote-MCP
bridge, and reverse (peer-accept) mode.
The package root is the client and stays browser-safe. A separate subpath,
@johnhenry/wsh/server, is a Node host for the same protocol: WebSocket
and optional WebTransport listeners, with exec, pty, fs, MCP tools,
resumable sessions, a pinnable host key, password auth and a relay all opt-in.
See Node server.
Two things distinguish the current releases. The WebSocket transport
multiplexes with QMux (draft-ietf-quic-qmux-02) — real QUIC-v1 frames
with windowed flow control and backpressure, not an ad-hoc mux — and the
primitives are exported so alternate servers can speak the same framing. And
the security story runs deeper than the handshake: auth signs a transcript
that binds the username, reverse-mode peer registrations are self-signed and
verifiable, session traffic can be sealed end-to-end with a hybrid
post-quantum (X25519+ML-KEM-768) key exchange feeding real AES-256-GCM frame
encryption (session.enableE2E(sharedSecret, { role }) — see the
guide), and host identity can be pinned: the Node server
advertises a signed host key, and the client checks it with expectHostKey or
a WshKnownHosts trust-on-first-use store.
A parallel Rust implementation (crates/: wsh-core, wsh-client,
wsh-cli, wsh-server) speaks the same wire protocol and ships a wsh
CLI binary (wsh connect/scp/sftp/ls/reverse/agent/copy-id). It
is not feature-identical to the Node server: the Rust wsh-server advertises
no host key, refuses file write and rename, and replays its whole output ring
on resume regardless of last_seq. The repo README’s
capability matrix lists every
difference; this site documents the JS client and the Node server.
Previously published as
wsh-upon-star(last release 0.1.1, now deprecated), in the repojohnhenry/wsh-upon-star. Renamed to@johnhenry/wshon import into the @johnhenry family. Unlike most of the family it did not restart its version numbers on adoption: it was already a mature release, and the numbering continues forward (0.23.0 at this writing). Since the rename it has reworked auth, the mux, file transfer, session resumption, and E2E frame encryption, and gained a Node server, host-key pinning and a relay.
Install
Section titled “Install”npm install @johnhenry/wsh<!-- In <head>, before any <script type="module"> or modulepreload. --><script type="importmap"> { "imports": { "@johnhenry/wsh": "https://cdn.jsdelivr.net/npm/@johnhenry/[email protected]/src/index.mjs" } }</script><script type="module"> import * as wsh from "@johnhenry/wsh";</script>Requires Node.js 26+ (the package’s engines floor) or a browser with Web
Crypto Ed25519 support (roughly Safari 17+, Chrome/Edge 137+, Firefox 130+).
WebTransport is optional: it needs roughly Chrome/Edge 97+, Firefox 114+ or Safari 26+,
and the WebSocket transport works everywhere and presents the same API. The
library has zero required runtime dependencies. For the hybrid post-quantum
key exchange it prefers native WebCrypto ML-KEM-768 (Node 24.7+) and falls
back to the optional @noble/post-quantum dependency where native support is
absent; no browser tested has native ML-KEM yet, so on the web the fallback is
what runs.
@johnhenry/wsh/server additionally needs the optional peer ws
(npm install ws), and its WebTransport listener the optional peers
@fails-components/webtransport and
@fails-components/webtransport-transport-http3-quiche.
Two transports, one API
Section titled “Two transports, one API”The single most important thing to know: you choose the transport, the rest
of your code doesn’t change. WebTransport gives you native multiplexed
streams; WebSocket multiplexes QMux streams over one connection. Same
client, same sessions, same methods — only the transport option differs.
import { WshClient, generateKeyPair } from '@johnhenry/wsh';
const keyPair = await generateKeyPair(true);const client = new WshClient();await client.connect('wss://shell.example.com', { username: 'alice', keyPair, transport: 'ws', // or 'webtransport' — nothing else changes});The pages here
Section titled “The pages here”- Guide — connect, PTY sessions, one-shot exec, detach/resume, files, host-key pinning, keys
- Node server — host the protocol with
@johnhenry/wsh/server: exec, pty, fs, MCP tools, sessions, WebTransport, relay - Tutorial — handshake to exec over real wire frames, no network
- API — every export, grouped
Source: github.com/johnhenry/wsh ·
Runnable examples in examples/ —
each named for the behavior it proves (frames surviving a fragmented
transport, a tampered challenge failing auth, …). The
protocol is specified machine-readably in the repo’s spec/wsh-v1.yaml, from
which the message constants are code-generated — the wire format is a contract,
not folklore.