A real shell in the browser
You will run Bash in a browser tab. This local tutorial uses Bun 1.4.2 or newer on Linux or macOS, Bash, and the client from the first terminal.
1. Start a PTY server
Section titled “1. Start a PTY server”Save this file as pty-server.ts in an empty directory. Bun’s terminal option creates the PTY. Each WebSocket gets its own shell.
interface ShellConnection { process?: ReturnType<typeof Bun.spawn>}
export function serveShell(port = 8080) { return Bun.serve<ShellConnection>({ hostname: '127.0.0.1', port, fetch(request, server) { if (new URL(request.url).pathname !== '/pty') return new Response('Missing route', { status: 404 }) const origin = URL.parse(request.headers.get('origin') ?? '') if (origin?.protocol !== 'http:' || !['localhost', '127.0.0.1'].includes(origin.hostname)) return new Response('Loopback browser origin required', { status: 403 }) if (server.upgrade(request, { data: {} })) return return new Response('WebSocket required', { status: 400 }) }, websocket: { open(socket) { socket.data.process = Bun.spawn(['bash', '--noprofile', '--norc'], { env: { ...process.env, TERM: 'xterm-256color' }, terminal: { cols: 80, rows: 24, data(_terminal, bytes) { socket.send(bytes) }, }, onExit() { socket.close() }, }) }, message(socket, message) { const terminal = socket.data.process?.terminal if (!terminal) return if (typeof message !== 'string') { terminal.write(message) return } let size: unknown try { size = JSON.parse(message) } catch { socket.close(1003, 'Resize message must be JSON') return } if (!size || typeof size !== 'object' || !('type' in size) || size.type !== 'resize') return if (!('columns' in size) || !('rows' in size)) return const { columns, rows } = size if (typeof columns !== 'number' || typeof rows !== 'number') return if (!Number.isInteger(columns) || !Number.isInteger(rows)) return if (columns < 1 || columns > 500 || rows < 1 || rows > 200) return terminal.resize(columns, rows) }, close(socket) { socket.data.process?.kill() socket.data.process?.terminal?.close() }, }, })}
if (import.meta.main) { serveShell() console.log('Shell WebSocket listening at ws://127.0.0.1:8080/pty')}bun pty-server.tsThe server prints Shell WebSocket listening at ws://127.0.0.1:8080/pty.
2. Connect the browser
Section titled “2. Connect the browser”Replace the echo client with the complete client in Connect to a PTY. Call connectShell(host, 'ws://127.0.0.1:8080/pty') on your sized host element.
The client sends keyboard bytes as binary messages and sends grid changes as JSON. The server writes the bytes into Bash and changes the PTY size. Bash’s output returns as binary messages.
3. Try a command
Section titled “3. Try a command”Type printf 'Hello from Bash\n' and press Enter. The terminal prints Hello from Bash. Resize the page, then run stty size. Its rows and columns should match the fitted terminal grid.
You built a browser client, a WebSocket transport and a real shell backed by a PTY.
If it doesn’t work
Section titled “If it doesn’t work”Bun cannot create a terminal
Section titled “Bun cannot create a terminal”Use a Bun release with the PTY API on Linux or macOS. This tutorial requires Bash on the server’s PATH.
The browser blocks the connection
Section titled “The browser blocks the connection”An HTTPS page needs a wss:// endpoint. Run this local tutorial from a loopback HTTP origin. In production, put the PTY server behind an authenticated HTTPS endpoint.
Before deploying
Section titled “Before deploying”This server starts a shell with your account’s permissions. Keep it on loopback for the tutorial. It accepts HTTP browser origins on localhost or 127.0.0.1, validates resize dimensions and closes malformed JSON connections. A deployed service also needs authentication, an explicit Origin allowlist, per-user isolation, rate limits and bounded output queues.
The PTY guide covers flow control and reconnecting.