Skip to content

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.

Save this file as pty-server.ts in an empty directory. Bun’s terminal option creates the PTY. Each WebSocket gets its own shell.

pty-server.ts
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')
}
Terminal window
bun pty-server.ts

The server prints Shell WebSocket listening at ws://127.0.0.1:8080/pty.

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.

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.

Use a Bun release with the PTY API on Linux or macOS. This tutorial requires Bash on the server’s PATH.

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.

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.