← All guides

2026-08-15

Build a Webterminal for Your VMs in TypeScript

A webterminal is one of those features that looks like an afternoon of work and turns into a security review. The terminal part genuinely is easy: the Heyo SDK gives you a PTY-attached shell over a WebSocket, and xterm.js renders it. The part worth thinking about is the API key.

This guide builds the whole thing with Bun and the TypeScript SDK: a user pastes a heyvm API key, picks one of their networks and a VM, registers the VM into the network, and gets an interactive shell in the browser. Roughly 500 lines.

The finished example lives at Heyo-Computer/demo-webterminal — clone it if you'd rather read the whole thing than follow along.

The catch

The obvious design is to let the browser talk straight to the Heyo cloud. It doesn't work, for a reason worth understanding.

Browsers cannot set headers on a WebSocket. There is no third argument to the constructor, no way to send Authorization: Bearer. The SDK works around this the only way anyone can — by putting the token in the query string:

// node_modules/@heyocomputer/sdk/src/shell.ts
const url = new URL(client.wsUrl(path))
const auth = client.wsHeaders().Authorization
if (auth?.startsWith('Bearer ')) {
  url.searchParams.set('token', auth.slice('Bearer '.length))
}

That is fine when the caller is your own server. It is not fine in a browser: a URL is not a secret. It lands in devtools, in the browser's own network log, and in the access log of every proxy between there and the cloud.

So the browser never talks to the cloud. Your server does. It holds the key in memory and proxies the byte stream. That single decision shapes everything else.

Shape

browser  ──HttpOnly cookie──▶  your Bun server  ──Bearer key──▶  Heyo cloud
   xterm.js                      session vault                     PTY
        ◀──── raw PTY bytes ────      (memory only)   ◀─── WS ───

The browser gets an opaque session id in an HttpOnly cookie. The key itself never goes back over the wire after login.

To follow along from scratch:

bun init
bun add @heyocomputer/sdk @xterm/xterm @xterm/addon-fit react react-dom

Or start from the finished example and read the code as you go:

git clone https://github.com/Heyo-Computer/demo-webterminal
cd demo-webterminal
bun install
bun run dev          # http://localhost:3000

Step 1: the vault

This is the only file that ever sees a key, so it's the one to get right. The trick worth stealing: keep keys in a separate map from session metadata.

// src/sessions.ts
export interface Session {
  readonly id: string
  readonly createdAt: number
  lastSeenAt: number
  readonly shells: Set<ClosableShell>
}

const keys = new Map<string, string>() // id → API key. Module-private.
const sessions = new Map<string, Session>() // id → metadata. No credential.

The Session object that gets passed around the rest of the app has no apiKey field at all. A stray console.log(session) or JSON.stringify(session) structurally cannot leak a key — you don't have to remember to redact it, because there's nothing there to redact. Only getApiKey(id) can reach the other map, and it's called in exactly two places.

Session ids are 256 bits of real randomness, not randomUUID (122 bits, with version and variant structure you don't want in something bearer-equivalent):

function newSessionId (): string {
  const bytes = new Uint8Array(32)
  crypto.getRandomValues(bytes)
  return Buffer.from(bytes).toString('base64url')
}

Two clocks: idle (refreshed on each request) and absolute. touch() folds "unknown id" and "expired id" into the same answer so callers just return 401:

export function touch (id: string | undefined | null): Session | undefined {
  if (!id) return undefined
  const session = sessions.get(id)
  if (!session) return undefined
  const now = clock()
  if (isExpired(session, now)) {
    void destroy(id)
    return undefined
  }
  session.lastSeenAt = now
  return session
}

destroy() kills the session's live shells before forgetting it, and a setInterval sweeper calls it for anything that aged out. Nothing is written to disk — restarting the process invalidates every session, which is a feature.

Step 2: the cookie

// src/http.ts
export function sessionCookie (
  req: Request,
  id: string,
  maxAge: number,
): string {
  const parts = [
    `hwt_sid=${encodeURIComponent(id)}`,
    'HttpOnly', // document.cookie can't read it — XSS can't steal it
    'SameSite=Strict', // no cross-site request rides it
    'Path=/',
    `Max-Age=${maxAge}`,
  ]
  if (isSecureRequest(req)) parts.push('Secure')
  return parts.join('; ')
}

SameSite=Strict handles most CSRF, but a WebSocket upgrade is a GET and browsers have historically treated those less strictly, so check Origin explicitly too — including on the upgrade:

export function isSameOrigin (req: Request): boolean {
  const origin = req.headers.get('origin')
  if (!origin) return false // browsers always send one here
  const host = req.headers.get('host')
  if (!host) return false
  return origin === `${isSecureRequest(req) ? 'https' : 'http'}://${host}`
}

Login validates the key against the cloud before minting anything, so a bad key never produces a cookie:

"/api/session": {
  async POST(req) {
    if (!isSameOrigin(req)) return httpError(403, "Cross-origin request rejected");
    const { apiKey } = await req.json();
    if (typeof apiKey !== "string" || apiKey.length < 8) {
      return httpError(400, "Missing or malformed apiKey");
    }
    if (!(await heyo.validateKey(apiKey))) {
      return httpError(401, "The Heyo cloud rejected that key");
    }
    const sessionId = sessions.create(apiKey);
    return json({ ok: true }, {           // note: no key material in the body
      headers: { "set-cookie": sessionCookie(req, sessionId, 1800) },
    });
  },
}

validateKey is just the cheapest authenticated call there is:

export async function validateKey (apiKey: string): Promise<boolean> {
  try {
    await Sandbox.list({ apiKey })
    return true
  } catch (err) {
    if (err instanceof AuthenticationError) return false
    if (err instanceof ApiError && (err.status === 401 || err.status === 403))
      return false
    throw err // a real outage isn't a bad key — don't swallow it
  }
}

From here every route resolves the caller from the cookie and nothing else. No route accepts a key in a header, body, or query string. That's what makes one browser session structurally incapable of seeing another's VMs.

Step 3: listing VMs

Your VMs live in two places: cloud-deployed sandboxes and sandboxes on daemons you run yourself. Both are worth showing.

const [cloudResult, daemonsResult] = await Promise.allSettled([
  Sandbox.list({ apiKey }),
  Daemons.list({ apiKey }),
])

allSettled, not all — a laptop that went to sleep shouldn't blank the page. Same for the per-daemon fan-out, where a 502 becomes a warning row:

const daemonListings = await Promise.allSettled(
  daemons.map(d => Daemons.listSandboxes(d.id, { apiKey })),
)

daemonListings.forEach((result, i) => {
  const daemon = daemons[i]!
  if (result.status === 'rejected') {
    warnings.push({
      source: daemon.id,
      sourceLabel: daemon.name ?? daemon.id,
      message: `${describe(result.reason)} (daemon is ${daemon.status})`,
    })
    return
  }
  for (const sb of result.value.sandboxes) {
    vms.push({ /* … */ kind: sb.isDeployed ? 'deployed' : 'local' })
  }
})

That kind matters for the next step. Cloud sandboxes register as deployed; daemon-native ones as local.

Step 4: into a network

Networks are per-account. The default one is created lazily — but only by Network.default(), which maps to GET /networks/me. Network.list() maps to GET /networks, which returns the rows that already exist and creates nothing. On an account that has never touched networks that's an empty array, and an empty picker with no way to register anything. Touch the default first:

await Network.default({ apiKey }) // creates it if it isn't there
const infos = await Network.list({ apiKey }) // now guaranteed non-empty
const net = await Network.get(networkId, { apiKey })
await net.addMember({
  sandboxKind: vm.kind,
  sandboxRef: vm.id,
  deviceName: vm.name,
})

addMember is idempotent on (networkId, kind, ref), so a double-click just updates the device name.

One thing to be deliberate about: derive sandboxKind server-side. It's tempting to send it from the client alongside vmId, since the client already has it. Don't — look it up from your own listing instead:

const { vms } = await listVms(apiKey)
const vm = vms.find(v => v.id === vmId)
if (!vm) throw new NotFoundError(`No VM ${vmId} is visible to this session`)

That one lookup means a client can't register an arbitrary ref under an arbitrary kind, and it doubles as the authorization check — a VM the session can't see is a 404.

Step 5: the proxy

Now the actual terminal. Validate the session before upgrading:

"/ws/shell": (req, srv) => {
  if (!isSameOrigin(req)) return httpError(403, "Cross-origin request rejected");
  const auth = requireSession(req);
  if (auth instanceof Response) return auth;

  const url = new URL(req.url);
  const vmId = url.searchParams.get("vm");
  if (!vmId) return httpError(400, "Missing ?vm");

  // Note what's NOT in here: the API key.
  const data = {
    sessionId: auth.sessionId, vmId, cols, rows,
    shell: null, slot: null, closed: false,
  };
  if (srv.upgrade(req, { data })) return undefined;
  return httpError(400, "WebSocket upgrade failed");
},

The socket carries the session id, not the key. The key gets read from the vault once, in open, and stays there.

websocket: {
  data: {} as ShellSocketData,
  idleTimeout: 600,                       // a PTY idles between keystrokes
  backpressureLimit: 8 * 1024 * 1024,     // don't let a slow client balloon us
  closeOnBackpressureLimit: true,

  async open(ws) {
    const apiKey = sessions.getApiKey(ws.data.sessionId);
    if (!apiKey) return ws.close(4401, "Session expired");

    const shell = await Sandbox.connect(ws.data.vmId, { apiKey })
      .shell({ cols: ws.data.cols, rows: ws.data.rows });

    ws.data.shell = shell;
    shell.onData((chunk) => { if (!ws.data.closed) ws.send(chunk); });
    shell.on((evt) => {
      ws.send(JSON.stringify(serializeEvent(evt)));
      if (evt.type === "closed") ws.close(1000, "Shell closed");
    });
    ws.send(JSON.stringify({ type: "ready", vmId: ws.data.vmId }));
  },

  async message(ws, message) {
    const shell = ws.data.shell;
    if (!shell) return;
    if (typeof message === "string") {
      const msg = JSON.parse(message);
      if (msg.type === "resize") await shell.resize(msg.cols, msg.rows);
      return;
    }
    await shell.write(message);
  },

  async close(ws) {
    ws.data.closed = true;
    await ws.data.shell?.kill();
  },
}

Keystrokes are binary frames, control messages are text. Pick a convention like this and the two can never be confused — no length prefixes, no escaping, no if (looksLikeJson(msg)).

sandbox.shell() is not one endpoint

That Sandbox.connect(vmId).shell() above is right for cloud VMs and wrong for daemon-hosted ones, and the failure is not obvious. shell() always targets /deployed-sandboxes/{id}/shell-stream, which resolves ids against the deployed sandbox table. Hand it a daemon-native sb-… id and the lookup misses, the upgrade is rejected, and the socket closes before the ready frame:

shell-stream socket closed before ready (code=1006) — the server may not
support /deployed-sandboxes/{id}/shell-stream (check the cloud version)

Your cloud is fine. You asked the wrong route. Daemon sandboxes stream from their daemon's own endpoint, which the cloud proxies into heyvmd over iroh — reachable via the pathOverride that Daemons.hostShell uses internally:

import { HeyoClient, ShellSession } from '@heyocomputer/sdk'

if (vm.source === 'cloud') {
  return await Sandbox.connect(vm.id, { apiKey }).shell(options)
}

const session = new ShellSession(new HeyoClient({ apiKey }), vm.id, {
  ...options,
  pathOverride: `/me/daemons/${vm.source}/sandboxes/${vm.id}/shell-stream`,
})
await session.open()
return session

Note where vm.source comes from: your own listing, looked up server-side by id — the same re-derivation as sandboxKind in step 4, and the same payoff. A VM the session can't see is a 404 before any socket is opened.

The daemon route additionally requires the sandbox to be exposed for remote access. That's the same list Daemons.listSandboxes() returns, so if a VM made it into your picker it is already shellable — the two checks agree by construction.

Three details that only show up under load:

Serialize your events by hand. ShellEvent carries Error objects, and JSON.stringify(new Error("boom")) is {}. Pull the message out explicitly or your UI shows an empty box where the failure reason should be.

Reserve the slot before you await. Capping shells per session is easy to get subtly wrong — check the cap after the await and N parallel connects all sail past it. Register a placeholder first:

const slot = {
  kill: async () => {
    await ws.data.shell?.kill()
  },
}
if (!sessions.registerShell(ws.data.sessionId, slot)) {
  return ws.close(4429, 'Shell limit reached')
}
ws.data.slot = slot
// …now it's safe to await the upstream connect

Handle the client leaving mid-connect. close can fire while open is still awaiting upstream, so check the flag afterwards or you leak a PTY:

if (ws.data.closed) {
  sessions.unregisterShell(ws.data.sessionId, slot)
  await shell.kill()
  return
}

Reconnects you get for free — the SDK retries with backoff and the cloud holds the PTY for ~60s, replaying output past the last acked sequence number. Forward the reconnecting / reconnected events so the UI can say so.

Step 6: the browser

const term = new Terminal({ cursorBlink: true, fontSize: 13 })
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
fit.fit()

const ws = new WebSocket(
  `${proto}//${location.host}/ws/shell?vm=${vm.id}&cols=${term.cols}&rows=${term.rows}`,
)
ws.binaryType = 'arraybuffer'

const encoder = new TextEncoder()
term.onData(chunk => ws.send(encoder.encode(chunk))) // binary out

ws.onmessage = event => {
  if (event.data instanceof ArrayBuffer) {
    term.write(new Uint8Array(event.data)) // PTY bytes in
    return
  }
  handleControlFrame(JSON.parse(String(event.data))) // text = control
}

new ResizeObserver(() => {
  fit.fit()
  ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}).observe(host)

Set binaryType = "arraybuffer" or you get Blobs and have to await every chunk. Buffer keystrokes typed while the socket is still CONNECTING — people start typing before the prompt lands.

On the login screen, the only rule is that the key goes nowhere but the POST:

await api('/api/session', postJson({ apiKey: key }))
setKey('') // drop it the moment it's been exchanged

No localStorage, no sessionStorage, no query param, no document.cookie. After login the page holds no credential at all — just a cookie it can't read.

Check your work

The negative paths are the ones worth testing, and they're all curl:

curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/api/vms
# 401 — no cookie

curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'Cookie: hwt_sid=totally-made-up' localhost:3000/api/vms
# 401 — forged id

curl -s -X POST -H 'Origin: http://evil.test' \
  -H 'content-type: application/json' -d '{"vmId":"dep-x"}' \
  localhost:3000/api/networks/nw-1/members
# 403 — cross-origin rejected

curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'Origin: http://localhost:3000' -H 'Upgrade: websocket' \
  -H 'Connection: Upgrade' -H 'Sec-WebSocket-Version: 13' \
  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  'localhost:3000/ws/shell?vm=dep-abc'
# 401 — upgrade needs a session too

Then in the browser: document.cookie should not show hwt_sid, and the WebSocket URL in the network tab should contain a VM id and nothing else. Grep your own bundle for apiKey — there should be exactly one hit, the login POST.

The session vault is pure logic, so test it with an injectable clock rather than sleeping through a 30-minute TTL:

let now = 1_000_000
sessions.__setClock(() => now)

const id = sessions.create('key')
sessions.registerShell(id, fakeShell)
now += sessions.IDLE_TTL_MS

expect(sessions.sweep()).toBe(1)
expect(fakeShell.killed).toBe(true)
expect(sessions.getApiKey(id)).toBeUndefined()

And assert the property the whole design rests on, explicitly:

const session = sessions.touch(id)!
expect(JSON.stringify(session)).not.toContain('super-secret-key')

Where to take it

The natural next step is dropping the cloud out of the data path entirely. Daemons.connectP2p(daemonId) dials your machine directly over iroh and hands back a client bound to a local tunnel — same Sandbox and ShellSession API, peer-to-peer transport. It needs the optional @number0/iroh native package.

Past that: multiple tabs against one session (the shell cap is already there for it), Daemons.hostShell() for a terminal on the host machine rather than a sandbox, and Network.registerService() to expose a port from the VM you're sitting in.

But the core is the boring part, and it's worth repeating: the browser never holds the key. Everything else is xterm.js.

The full source — server, frontend, and the test suite for the session vault — is at Heyo-Computer/demo-webterminal.