From 6d57808b4f946eb6ac1e7d2f8ed07d8f43c32430 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 10:54:56 -0400 Subject: [PATCH 01/38] docs: add SPEC and ADR for wtty bootstrap --- docs/adrs/001.wtty.bootstrap.md | 56 +++++++++++++++++++++++++++++++++ docs/development.md | 15 ++++----- docs/specs/cli.md | 25 +++++++++++++++ docs/specs/ui.md | 24 ++++++++++++++ docs/specs/wtty.md | 31 ++++++++++++++++++ 5 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 docs/adrs/001.wtty.bootstrap.md create mode 100644 docs/specs/cli.md create mode 100644 docs/specs/ui.md create mode 100644 docs/specs/wtty.md diff --git a/docs/adrs/001.wtty.bootstrap.md b/docs/adrs/001.wtty.bootstrap.md new file mode 100644 index 0000000..e5b7fc8 --- /dev/null +++ b/docs/adrs/001.wtty.bootstrap.md @@ -0,0 +1,56 @@ +# ADR 001: Bootstrap — Port ghostty-web demo + +**SPEC:** wtty +**Status:** Proposed +**Date:** 2026-03-21 + +--- + +## Context + +The first slice of wtty is a direct port of the `ghostty-web` demo. The demo already proves the full round-trip — browser renders a terminal, user types, a real PTY responds — so rather than building from scratch, we take that working implementation and make it the foundation of wtty. Everything else (config, multi-session, CLI) is deferred. + +## Decision + +Port `ghostty-web/demo` into a single Node.js entry point (`src/server.ts`). The server serves everything on one port — HTML, assets, and WebSocket — with all config hardcoded inline. + +**Server (`src/server.ts`):** +- HTTP server on port `8080` +- Serves the terminal HTML inline as a template string at `/` +- Serves `/dist/` assets (ghostty-web JS + WASM) from the installed `ghostty-web` package +- WebSocket endpoint at `/ws?cols=&rows=` — spawns one PTY per connection +- PTY: auto-detects shell (`$SHELL` on macOS/Linux, `cmd.exe` on Windows), cwd `$HOME` +- WebSocket message framing: raw string for PTY input; JSON `{ type: "resize", cols, rows }` for resize +- On PTY exit: sends exit message and closes WebSocket + +**Browser (inline HTML template in `src/server.ts`):** +- Full-viewport terminal, no surrounding chrome +- `ghostty-web` (`init` + `Terminal` + `FitAddon`) as the terminal renderer +- Hardcoded config: `fontSize: 14`, `fontFamily: Monaco/Menlo/monospace`, dark theme `#1e1e1e`/`#d4d4d4` +- `FitAddon.fit()` + `observeResize()` for auto-resize +- WebSocket connects to same origin at `/ws` +- On resize: sends JSON resize message +- On close: reconnects after 2s + +**Dependencies added:** +- `@lydell/node-pty` — cross-platform PTY (fork of `node-pty` with better prebuilt binaries) +- `ws` — WebSocket server +- `ghostty-web` — WASM terminal emulator, ported directly from the demo + +## Considered Options + +**Option A: xterm.js instead of ghostty-web** +xterm.js is the industry standard (used by ttyd, VS Code, wetty). More documentation and addons. Rejected for this slice — ghostty-web is already the working reference, uses the same `Terminal`/`FitAddon` API, and porting it is the fastest path to a running terminal. + +**Option B: Separate frontend build (Vite/esbuild)** +Would enable TypeScript in the browser and hot reload. Deferred — the demo uses plain ` + +`; + +function serveFile(filePath: string, res: http.ServerResponse): void { + const ext = path.extname(filePath); + const contentType = MIME[ext] ?? 'application/octet-stream'; + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not Found'); + return; + } + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + const pathname = url.pathname; + + if (pathname === '/' || pathname === '/index.html') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(HTML); + return; + } + + if (pathname.startsWith('/dist/')) { + serveFile(path.join(distPath, pathname.slice(6)), res); + return; + } + + if (pathname === '/ghostty-vt.wasm') { + serveFile(wasmPath, res); + return; + } + + res.writeHead(404); + res.end('Not Found'); +}); + +function getShell(): string { + return process.platform === 'win32' + ? (process.env.COMSPEC ?? 'cmd.exe') + : (process.env.SHELL ?? '/bin/bash'); +} + +const wss = new WebSocketServer({ noServer: true }); +const sessions = new Map>(); + +server.on('upgrade', (req, socket, head) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + if (url.pathname === '/ws') { + wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); + } else { + socket.destroy(); + } +}); + +wss.on('connection', (ws, req) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + const cols = Number.parseInt(url.searchParams.get('cols') ?? '80', 10); + const rows = Number.parseInt(url.searchParams.get('rows') ?? '24', 10); + + const shell = getShell(); + const ptyProcess = pty.spawn(shell, [], { + name: 'xterm-256color', + cols, + rows, + cwd: homedir(), + env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, + }); + + sessions.set(ws, ptyProcess); + + ptyProcess.onData((data: string) => { + if (ws.readyState === ws.OPEN) ws.send(data); + }); + + ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { + if (ws.readyState === ws.OPEN) { + ws.send(`\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`); + ws.close(); + } + }); + + ws.on('message', (data) => { + const msg = data.toString('utf8'); + if (msg.startsWith('{')) { + try { + const parsed = JSON.parse(msg) as { type: string; cols: number; rows: number }; + if (parsed.type === 'resize') { + ptyProcess.resize(parsed.cols, parsed.rows); + return; + } + } catch { + // fall through + } + } + ptyProcess.write(msg); + }); + + ws.on('close', () => { + sessions.get(ws)?.kill(); + sessions.delete(ws); + }); + + ws.on('error', () => {}); +}); + +process.on('SIGINT', () => { + for (const [ws, ptyProcess] of sessions) { + ptyProcess.kill(); + ws.close(); + } + process.exit(0); +}); + +server.listen(PORT, () => { + console.log(`wtty listening on http://localhost:${PORT}`); +}); From 4e6b86dd52cc2202dc3bbd99a6d516dcb1147490 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 11:13:16 -0400 Subject: [PATCH 06/38] chore: change default port to 2346 --- src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 606a5e5..8622f0d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,7 +10,7 @@ import { WebSocketServer } from 'ws'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const PORT = Number(process.env.PORT) || 8080; +const PORT = Number(process.env.PORT) || 2346; const ghosttyRoot = path.resolve(__dirname, '../node_modules/ghostty-web'); const distPath = path.join(ghosttyRoot, 'dist'); From 650fbcbc125aa998cdcc50f0075f0ab177bd8859 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 11:14:08 -0400 Subject: [PATCH 07/38] chore: replace start with dev and preview scripts --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9412948..144cadf 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "private": true, "scripts": { "preinstall": "bun scripts/check-pkg-manager.ts", - "start": "bun run src/server.ts", + "dev": "bun run src/server.ts", + "preview": "bun run dist/server.js", "lint": "tsc --noEmit -p tsconfig.lint.json && biome check .", "lint:fix": "tsc --noEmit -p tsconfig.lint.json && bunx biome check --write .", "build": "bun run scripts/build.ts", From 0411e104cab192acbfb6215ed848d2882fcc6bcf Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 11:16:58 -0400 Subject: [PATCH 08/38] fix: use named spawn import from node-pty --- src/server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server.ts b/src/server.ts index 8622f0d..0743b71 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,7 +3,7 @@ import http from 'node:http'; import { homedir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import pty from '@lydell/node-pty'; +import { spawn as ptySpawn } from '@lydell/node-pty'; import type { WebSocket } from 'ws'; import { WebSocketServer } from 'ws'; @@ -145,7 +145,7 @@ function getShell(): string { } const wss = new WebSocketServer({ noServer: true }); -const sessions = new Map>(); +const sessions = new Map>(); server.on('upgrade', (req, socket, head) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); @@ -162,7 +162,7 @@ wss.on('connection', (ws, req) => { const rows = Number.parseInt(url.searchParams.get('rows') ?? '24', 10); const shell = getShell(); - const ptyProcess = pty.spawn(shell, [], { + const ptyProcess = ptySpawn(shell, [], { name: 'xterm-256color', cols, rows, From 35d27f2854565186d368ceb8bb7ed6a533459079 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 11:18:32 -0400 Subject: [PATCH 09/38] fix: correct FitAddon import and await term.open --- src/server.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/server.ts b/src/server.ts index 0743b71..1865200 100644 --- a/src/server.ts +++ b/src/server.ts @@ -40,8 +40,7 @@ const HTML = `
`; @@ -117,24 +244,21 @@ function serveFile(filePath: string, res: http.ServerResponse): void { const server = http.createServer((req, res) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); - const pathname = url.pathname; + const { pathname } = url; if (pathname === '/' || pathname === '/index.html') { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(HTML); return; } - if (pathname.startsWith('/dist/')) { serveFile(path.join(distPath, pathname.slice(6)), res); return; } - if (pathname === '/ghostty-vt.wasm') { serveFile(wasmPath, res); return; } - res.writeHead(404); res.end('Not Found'); }); @@ -145,8 +269,12 @@ function getShell(): string { : (process.env.SHELL ?? '/bin/bash'); } +interface Session { + pty: ReturnType; +} + const wss = new WebSocketServer({ noServer: true }); -const sessions = new Map>(); +const sessions = new Map(); server.on('upgrade', (req, socket, head) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); @@ -157,13 +285,12 @@ server.on('upgrade', (req, socket, head) => { } }); -wss.on('connection', (ws, req) => { +wss.on('connection', (ws: WS, req: http.IncomingMessage) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); const cols = Number.parseInt(url.searchParams.get('cols') ?? '80', 10); const rows = Number.parseInt(url.searchParams.get('rows') ?? '24', 10); - const shell = getShell(); - const ptyProcess = pty.spawn(shell, [], { + const ptyProcess = pty.spawn(getShell(), [], { name: 'xterm-256color', cols, rows, @@ -171,7 +298,7 @@ wss.on('connection', (ws, req) => { env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, }); - sessions.set(ws, ptyProcess); + sessions.set(ws, { pty: ptyProcess }); ptyProcess.onData((data: string) => { if (ws.readyState === ws.OPEN) ws.send(data); @@ -184,7 +311,7 @@ wss.on('connection', (ws, req) => { } }); - ws.on('message', (data) => { + ws.on('message', (data: Buffer) => { const msg = data.toString('utf8'); if (msg.startsWith('{')) { try { @@ -201,16 +328,15 @@ wss.on('connection', (ws, req) => { }); ws.on('close', () => { - sessions.get(ws)?.kill(); + sessions.get(ws)?.pty.kill(); sessions.delete(ws); }); - ws.on('error', () => {}); }); process.on('SIGINT', () => { - for (const [ws, ptyProcess] of sessions) { - ptyProcess.kill(); + for (const [ws, { pty: p }] of sessions) { + p.kill(); ws.close(); } process.exit(0); From 70e69155f71e159882247b273483d18713382747 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 11:28:48 -0400 Subject: [PATCH 13/38] fix: upgrade ghostty-web to next (0.4.0-next.14) to fix blank render --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index b205c37..91b723f 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "wtty", "dependencies": { "@lydell/node-pty": "1.2.0-beta.3", - "ghostty-web": "0.4.0", + "ghostty-web": "0.4.0-next.14.g6a1a50d", "ws": "8.19.0", }, "devDependencies": { @@ -63,7 +63,7 @@ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], - "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], + "ghostty-web": ["ghostty-web@0.4.0-next.14.g6a1a50d", "", {}, "sha512-01H59pAKq43Y8ZZiIhH98cu0ahZaoGoPQPtW4KnZPT0JFdQYen2djsXrOnt27JnMv64D4eLeAq8D6h72mo52xQ=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], diff --git a/package.json b/package.json index 144cadf..6425dd3 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@lydell/node-pty": "1.2.0-beta.3", - "ghostty-web": "0.4.0", + "ghostty-web": "0.4.0-next.14.g6a1a50d", "ws": "8.19.0" }, "devDependencies": { From 7ec38b5bb693ed17a4164bba2c0fc85f965de303 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 11:30:56 -0400 Subject: [PATCH 14/38] fix: match demo exact deps (ghostty-web@0.2.1, node-pty@1.1.0, ws@8.18.3) and port 8080 --- bun.lock | 24 ++++++++++++------------ package.json | 6 +++--- src/server.ts | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index 91b723f..e4dc41f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,9 @@ "": { "name": "wtty", "dependencies": { - "@lydell/node-pty": "1.2.0-beta.3", - "ghostty-web": "0.4.0-next.14.g6a1a50d", - "ws": "8.19.0", + "@lydell/node-pty": "1.1.0", + "ghostty-web": "0.2.1", + "ws": "8.18.3", }, "devDependencies": { "@biomejs/biome": "2.4.4", @@ -37,19 +37,19 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="], - "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.3", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.3", "@lydell/node-pty-darwin-x64": "1.2.0-beta.3", "@lydell/node-pty-linux-arm64": "1.2.0-beta.3", "@lydell/node-pty-linux-x64": "1.2.0-beta.3", "@lydell/node-pty-win32-arm64": "1.2.0-beta.3", "@lydell/node-pty-win32-x64": "1.2.0-beta.3" } }, "sha512-ngGAItlRhmJXrhspxt8kX13n1dVFqzETOq0m/+gqSkO8NJBvNMwP7FZckMwps2UFySdr4yxCXNGu/bumg5at6A=="], + "@lydell/node-pty": ["@lydell/node-pty@1.1.0", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", "@lydell/node-pty-linux-arm64": "1.1.0", "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0" } }, "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw=="], - "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-owcv+e1/OSu3bf9ZBdUQqJsQF888KyuSIiPYFNn0fLhgkhm9F3Pvha76Kj5mCPnodf7hh3suDe7upw7GPRXftQ=="], + "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w=="], - "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-k38O+UviWrWdxtqZBBc/D8NJU11Rey8Y2YMwSWNxLv3eXZZdF5IVpbBkI/2RmLsV5nCcciqLPbukxeZnEfPlwA=="], + "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA=="], - "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-HUwRpGu3O+4sv9DAQFKnyW5LYhyYu2SDUa/bdFO/t4dIFCM4uDJEq47wfRM7+aYtJTi1b3lakN8SlWeuFQqJQQ=="], + "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg=="], - "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.3", "", { "os": "linux", "cpu": "x64" }, "sha512-+RRY0PoCUeQaCvPR7/UnkGbxulwbFtoTWJfe+o4T1RcNtngrgaI55I9nl8CD8uqhGrB3smKuyvPM5UtwGhASUw=="], + "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA=="], - "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-UEDd9ASp2M3iIYpIzfmfBlpyn4+K1G4CAjYcHWStptCkefoSVXWTiUBIa1KjBjZi3/xmsHIDpBEYTkGWuvLt2Q=="], + "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w=="], - "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.3", "", { "os": "win32", "cpu": "x64" }, "sha512-TpdqSFYx7/Rj+68tuP6F/lkRYrHCYAIJgaS1bx3SctTkb5QAQCFwOKHd4xlsivmEOMT2LdhkJggPxwX9PAO5pQ=="], + "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw=="], "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], @@ -63,7 +63,7 @@ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], - "ghostty-web": ["ghostty-web@0.4.0-next.14.g6a1a50d", "", {}, "sha512-01H59pAKq43Y8ZZiIhH98cu0ahZaoGoPQPtW4KnZPT0JFdQYen2djsXrOnt27JnMv64D4eLeAq8D6h72mo52xQ=="], + "ghostty-web": ["ghostty-web@0.2.1", "", {}, "sha512-wrovbPlHcl+nIkp7S7fY7vOTsmBjwMFihZEe2PJe/M6G4/EwuyJnwaWTTzNfuY7RcM/lVlN+PvGWqJIhKSB5hw=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], @@ -83,6 +83,6 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], } } diff --git a/package.json b/package.json index 6425dd3..450d21b 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ "clean": "rimraf dist node_modules" }, "dependencies": { - "@lydell/node-pty": "1.2.0-beta.3", - "ghostty-web": "0.4.0-next.14.g6a1a50d", - "ws": "8.19.0" + "@lydell/node-pty": "1.1.0", + "ghostty-web": "0.2.1", + "ws": "8.18.3" }, "devDependencies": { "@biomejs/biome": "2.4.4", diff --git a/src/server.ts b/src/server.ts index 18f0a9b..e43eb44 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,7 +11,7 @@ import { WebSocketServer } from 'ws'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const PORT = Number(process.env.PORT) || 2346; +const PORT = Number(process.env.PORT) || 8080; const require = createRequire(import.meta.url); const ghosttyWebMain = require.resolve('ghostty-web') as string; From 1180a1464d439cfbcdbff2f50c583ac16737bf1e Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 11:32:48 -0400 Subject: [PATCH 15/38] fix: use ghostty-web@next which has init() export --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index e4dc41f..6489627 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "wtty", "dependencies": { "@lydell/node-pty": "1.1.0", - "ghostty-web": "0.2.1", + "ghostty-web": "0.4.0-next.14.g6a1a50d", "ws": "8.18.3", }, "devDependencies": { @@ -63,7 +63,7 @@ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], - "ghostty-web": ["ghostty-web@0.2.1", "", {}, "sha512-wrovbPlHcl+nIkp7S7fY7vOTsmBjwMFihZEe2PJe/M6G4/EwuyJnwaWTTzNfuY7RcM/lVlN+PvGWqJIhKSB5hw=="], + "ghostty-web": ["ghostty-web@0.4.0-next.14.g6a1a50d", "", {}, "sha512-01H59pAKq43Y8ZZiIhH98cu0ahZaoGoPQPtW4KnZPT0JFdQYen2djsXrOnt27JnMv64D4eLeAq8D6h72mo52xQ=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], diff --git a/package.json b/package.json index 450d21b..82073ef 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@lydell/node-pty": "1.1.0", - "ghostty-web": "0.2.1", + "ghostty-web": "0.4.0-next.14.g6a1a50d", "ws": "8.18.3" }, "devDependencies": { From 705ec6fb88e4801bfe8a6f8d64726d84f2751ccd Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 11:33:48 -0400 Subject: [PATCH 16/38] chore: change default port to 2346 --- src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index e43eb44..18f0a9b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,7 +11,7 @@ import { WebSocketServer } from 'ws'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const PORT = Number(process.env.PORT) || 8080; +const PORT = Number(process.env.PORT) || 2346; const require = createRequire(import.meta.url); const ghosttyWebMain = require.resolve('ghostty-web') as string; From 95378ef44dae13383fb5ecf05c59db3dee9774c3 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 11:36:17 -0400 Subject: [PATCH 17/38] fix: register term.onData before connect so DA responses reach PTY --- src/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index 18f0a9b..0e60dcc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -191,13 +191,13 @@ const HTML = ` }; } - connect(); - term.onData((data) => { if (ws?.readyState === WebSocket.OPEN) ws.send(data); }); term.onResize(({ cols, rows }) => { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows })); }); + connect(); + window.addEventListener('resize', () => fitAddon.fit()); if (window.visualViewport) { From e36f89bd37541097d395b39217351576b5358978 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 11:38:34 -0400 Subject: [PATCH 18/38] fix: faithful 1:1 TypeScript port of ghostty-web demo server --- src/server.ts | 296 +++++++++++++++++++++++++++++++------------------- 1 file changed, 182 insertions(+), 114 deletions(-) diff --git a/src/server.ts b/src/server.ts index 0e60dcc..7541611 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,34 +11,44 @@ import { WebSocketServer } from 'ws'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const PORT = Number(process.env.PORT) || 2346; +const HTTP_PORT = Number(process.env.PORT) || 2346; const require = createRequire(import.meta.url); -const ghosttyWebMain = require.resolve('ghostty-web') as string; -const ghosttyWebRoot = ghosttyWebMain.replace(/[/\\]dist[/\\].*$/, ''); -const distPath = path.join(ghosttyWebRoot, 'dist'); -const wasmPath = path.join(ghosttyWebRoot, 'ghostty-vt.wasm'); -const MIME: Record = { - '.html': 'text/html', - '.js': 'application/javascript', - '.mjs': 'application/javascript', - '.css': 'text/css', - '.json': 'application/json', - '.wasm': 'application/wasm', -}; +function findGhosttyWeb(): { distPath: string; wasmPath: string } { + try { + const ghosttyWebMain = require.resolve('ghostty-web') as string; + const ghosttyWebRoot = ghosttyWebMain.replace(/[/\\]dist[/\\].*$/, ''); + const distPath = path.join(ghosttyWebRoot, 'dist'); + const wasmPath = path.join(ghosttyWebRoot, 'ghostty-vt.wasm'); + if (fs.existsSync(path.join(distPath, 'ghostty-web.js')) && fs.existsSync(wasmPath)) { + return { distPath, wasmPath }; + } + } catch { + // fall through + } + console.error('Error: Could not find ghostty-web package.'); + process.exit(1); +} + +const { distPath, wasmPath } = findGhosttyWeb(); -const HTML = ` +const HTML_TEMPLATE = ` wtty @@ -127,35 +161,14 @@ const HTML = ` import { init, Terminal, FitAddon } from '/dist/ghostty-web.js'; await init(); - const term = new Terminal({ cols: 80, rows: 24, - cursorBlink: true, + fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace', fontSize: 14, - fontFamily: "'FiraMono Nerd Font', Menlo, Monaco, 'Courier New', monospace", - scrollback: 10000, theme: { - background: '#282A36', - foreground: '#F8F8F2', - cursor: '#F8F8F2', - selection: '#44475A', - black: '#21222C', - red: '#FF5555', - green: '#50FA7B', - yellow: '#F1FA8C', - blue: '#BD93F9', - purple: '#FF79C6', - cyan: '#8BE9FD', - white: '#F8F8F2', - brightBlack: '#6272A4', - brightRed: '#FF6E6E', - brightGreen: '#69FF94', - brightYellow: '#FFFFA5', - brightBlue: '#D6ACFF', - brightPurple: '#FF92DF', - brightCyan: '#A4FFFF', - brightWhite: '#FFFFFF', + background: '#1e1e1e', + foreground: '#d4d4d4', }, }); @@ -167,8 +180,9 @@ const HTML = ` fitAddon.fit(); fitAddon.observeResize(); - const statusDot = document.getElementById('status-dot'); + const statusDot = document.getElementById('status-dot'); const statusText = document.getElementById('status-text'); + function setStatus(status, text) { statusDot.className = 'status-dot ' + status; statusText.textContent = text; @@ -181,44 +195,64 @@ const HTML = ` function connect() { setStatus('connecting', 'Connecting...'); ws = new WebSocket(wsUrl); - ws.onopen = () => setStatus('connected', 'Connected'); - ws.onmessage = (e) => term.write(e.data); - ws.onerror = () => setStatus('disconnected', 'Error'); + + ws.onopen = () => { + setStatus('connected', 'Connected'); + }; + + ws.onmessage = (event) => { + term.write(event.data); + }; + ws.onclose = () => { setStatus('disconnected', 'Disconnected'); term.write('\\r\\n\\x1b[31mConnection closed. Reconnecting in 2s...\\x1b[0m\\r\\n'); setTimeout(connect, 2000); }; + + ws.onerror = () => { + setStatus('disconnected', 'Error'); + }; } - term.onData((data) => { if (ws?.readyState === WebSocket.OPEN) ws.send(data); }); - term.onResize(({ cols, rows }) => { - if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows })); + connect(); + + term.onData((data) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(data); + } }); - connect(); + term.onResize(({ cols, rows }) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'resize', cols, rows })); + } + }); - window.addEventListener('resize', () => fitAddon.fit()); + window.addEventListener('resize', () => { + fitAddon.fit(); + }); if (window.visualViewport) { const terminalContent = document.querySelector('.terminal-content'); - const terminalWindow = document.querySelector('.terminal-window'); - const originalHeight = terminalContent.style.height; + const terminalWindow = document.querySelector('.terminal-window'); + const originalHeight = terminalContent.style.height; + const body = document.body; window.visualViewport.addEventListener('resize', () => { const keyboardHeight = window.innerHeight - window.visualViewport.height; if (keyboardHeight > 100) { - document.body.style.padding = '0'; - document.body.style.alignItems = 'flex-start'; + body.style.padding = '0'; + body.style.alignItems = 'flex-start'; terminalWindow.style.borderRadius = '0'; terminalWindow.style.maxWidth = '100%'; terminalContent.style.height = (window.visualViewport.height - 60) + 'px'; window.scrollTo(0, 0); } else { - document.body.style.padding = '40px 20px'; - document.body.style.alignItems = 'center'; + body.style.padding = '40px 20px'; + body.style.alignItems = 'center'; terminalWindow.style.borderRadius = '12px'; - terminalWindow.style.maxWidth = '1200px'; + terminalWindow.style.maxWidth = '1000px'; terminalContent.style.height = originalHeight || '600px'; } fitAddon.fit(); @@ -228,55 +262,78 @@ const HTML = ` `; -function serveFile(filePath: string, res: http.ServerResponse): void { - const ext = path.extname(filePath); - const contentType = MIME[ext] ?? 'application/octet-stream'; - fs.readFile(filePath, (err, data) => { - if (err) { - res.writeHead(404); - res.end('Not Found'); - return; - } - res.writeHead(200, { 'Content-Type': contentType }); - res.end(data); - }); -} +const MIME_TYPES: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.wasm': 'application/wasm', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; -const server = http.createServer((req, res) => { +const httpServer = http.createServer((req, res) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); - const { pathname } = url; + const pathname = url.pathname; if (pathname === '/' || pathname === '/index.html') { res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(HTML); + res.end(HTML_TEMPLATE); return; } + if (pathname.startsWith('/dist/')) { serveFile(path.join(distPath, pathname.slice(6)), res); return; } + if (pathname === '/ghostty-vt.wasm') { serveFile(wasmPath, res); return; } + res.writeHead(404); res.end('Not Found'); }); +function serveFile(filePath: string, res: http.ServerResponse): void { + const ext = path.extname(filePath); + const contentType = MIME_TYPES[ext] ?? 'application/octet-stream'; + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not Found'); + return; + } + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); +} + +const sessions = new Map }>(); + function getShell(): string { - return process.platform === 'win32' - ? (process.env.COMSPEC ?? 'cmd.exe') - : (process.env.SHELL ?? '/bin/bash'); + if (process.platform === 'win32') { + return process.env.COMSPEC ?? 'cmd.exe'; + } + return process.env.SHELL ?? '/bin/bash'; } -interface Session { - pty: ReturnType; +function createPtySession(cols: number, rows: number) { + return pty.spawn(getShell(), [], { + name: 'xterm-256color', + cols, + rows, + cwd: homedir(), + env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, + }); } const wss = new WebSocketServer({ noServer: true }); -const sessions = new Map(); -server.on('upgrade', (req, socket, head) => { +httpServer.on('upgrade', (req, socket, head) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); if (url.pathname === '/ws') { wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); @@ -290,14 +347,7 @@ wss.on('connection', (ws: WS, req: http.IncomingMessage) => { const cols = Number.parseInt(url.searchParams.get('cols') ?? '80', 10); const rows = Number.parseInt(url.searchParams.get('rows') ?? '24', 10); - const ptyProcess = pty.spawn(getShell(), [], { - name: 'xterm-256color', - cols, - rows, - cwd: homedir(), - env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, - }); - + const ptyProcess = createPtySession(cols, rows); sessions.set(ws, { pty: ptyProcess }); ptyProcess.onData((data: string) => { @@ -312,36 +362,54 @@ wss.on('connection', (ws: WS, req: http.IncomingMessage) => { }); ws.on('message', (data: Buffer) => { - const msg = data.toString('utf8'); - if (msg.startsWith('{')) { + const message = data.toString('utf8'); + if (message.startsWith('{')) { try { - const parsed = JSON.parse(msg) as { type: string; cols: number; rows: number }; - if (parsed.type === 'resize') { - ptyProcess.resize(parsed.cols, parsed.rows); + const msg = JSON.parse(message) as { type: string; cols: number; rows: number }; + if (msg.type === 'resize') { + ptyProcess.resize(msg.cols, msg.rows); return; } } catch { // fall through } } - ptyProcess.write(msg); + ptyProcess.write(message); }); ws.on('close', () => { sessions.get(ws)?.pty.kill(); sessions.delete(ws); }); + ws.on('error', () => {}); + + const C = '\x1b[1;36m'; + const G = '\x1b[1;32m'; + const Y = '\x1b[1;33m'; + const R = '\x1b[0m'; + ws.send(`${C}╔══════════════════════════════════════════════════════════════╗${R}\r\n`); + ws.send( + `${C}║${R} ${G}Welcome to wtty!${R} ${C}║${R}\r\n`, + ); + ws.send(`${C}║${R} ${C}║${R}\r\n`); + ws.send(`${C}║${R} You have a real shell session with full PTY support. ${C}║${R}\r\n`); + ws.send( + `${C}║${R} Try: ${Y}ls${R}, ${Y}cd${R}, ${Y}top${R}, ${Y}vim${R}, or any command! ${C}║${R}\r\n`, + ); + ws.send(`${C}╚══════════════════════════════════════════════════════════════╝${R}\r\n\r\n`); }); process.on('SIGINT', () => { - for (const [ws, { pty: p }] of sessions) { - p.kill(); + console.log('\n\nShutting down...'); + for (const [ws, session] of sessions.entries()) { + session.pty.kill(); ws.close(); } + wss.close(); process.exit(0); }); -server.listen(PORT, () => { - console.log(`wtty listening on http://localhost:${PORT}`); +httpServer.listen(HTTP_PORT, () => { + console.log(`wtty listening on http://localhost:${HTTP_PORT}`); }); From c3d56472a29cecde244fea1ea18b8dcb6a1cfcc4 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 11:42:49 -0400 Subject: [PATCH 19/38] fix: send PTY data as text frames (binary: false) to browser --- src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 7541611..2ad79b0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -351,7 +351,7 @@ wss.on('connection', (ws: WS, req: http.IncomingMessage) => { sessions.set(ws, { pty: ptyProcess }); ptyProcess.onData((data: string) => { - if (ws.readyState === ws.OPEN) ws.send(data); + if (ws.readyState === ws.OPEN) ws.send(data, { binary: false }); }); ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { From d8eaed2636118b7158a99085441013f790592803 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 11:46:58 -0400 Subject: [PATCH 20/38] fix: run server with node+tsx instead of bun (pty compat) --- bun.lock | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 6 +++-- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 6489627..87212d3 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@types/bun": "1.3.9", "@types/ws": "8.5.14", "rimraf": "6.1.3", + "tsx": "4.21.0", "typescript": "5.9.3", }, }, @@ -37,6 +38,58 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], + "@lydell/node-pty": ["@lydell/node-pty@1.1.0", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", "@lydell/node-pty-linux-arm64": "1.1.0", "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0" } }, "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw=="], "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w=="], @@ -63,6 +116,12 @@ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "ghostty-web": ["ghostty-web@0.4.0-next.14.g6a1a50d", "", {}, "sha512-01H59pAKq43Y8ZZiIhH98cu0ahZaoGoPQPtW4KnZPT0JFdQYen2djsXrOnt27JnMv64D4eLeAq8D6h72mo52xQ=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], @@ -77,8 +136,12 @@ "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "rimraf": ["rimraf@6.1.3", "", { "dependencies": { "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA=="], + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/package.json b/package.json index 82073ef..e515345 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "version": "0.0.0", "description": "Web TTY for running CLI/TUI applications in a browser tab, across platforms", "private": true, + "type": "module", "scripts": { "preinstall": "bun scripts/check-pkg-manager.ts", - "dev": "bun run src/server.ts", - "preview": "bun run dist/server.js", + "dev": "node --import tsx/esm src/server.ts", + "preview": "node dist/server.js", "lint": "tsc --noEmit -p tsconfig.lint.json && biome check .", "lint:fix": "tsc --noEmit -p tsconfig.lint.json && bunx biome check --write .", "build": "bun run scripts/build.ts", @@ -23,6 +24,7 @@ "@types/bun": "1.3.9", "@types/ws": "8.5.14", "rimraf": "6.1.3", + "tsx": "4.21.0", "typescript": "5.9.3" } } From 00004df8bcd059035d29cdae472af32cbd271e5c Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 12:01:40 -0400 Subject: [PATCH 21/38] =?UTF-8?q?feat:=20dual=20PTY=20adapter=20=E2=80=94?= =?UTF-8?q?=20Bun.Terminal=20on=20bun,=20node-pty=20on=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- src/server.ts | 93 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index e515345..c350fd8 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "type": "module", "scripts": { "preinstall": "bun scripts/check-pkg-manager.ts", - "dev": "node --import tsx/esm src/server.ts", + "dev": "bun run src/server.ts", + "dev:node": "node --import tsx/esm src/server.ts", "preview": "node dist/server.js", "lint": "tsc --noEmit -p tsconfig.lint.json && biome check .", "lint:fix": "tsc --noEmit -p tsconfig.lint.json && bunx biome check --write .", diff --git a/src/server.ts b/src/server.ts index 2ad79b0..963a6a9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,10 +4,87 @@ import { createRequire } from 'node:module'; import { homedir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import pty from '@lydell/node-pty'; import type { WebSocket as WS } from 'ws'; import { WebSocketServer } from 'ws'; +interface PtyProcess { + onData(cb: (data: string) => void): void; + onExit(cb: (e: { exitCode: number }) => void): void; + write(data: string): void; + resize(cols: number, rows: number): void; + kill(): void; +} + +function spawnPty(shell: string, cols: number, rows: number): PtyProcess { + if (process.versions.bun) { + const proc = Bun.spawn([shell], { + terminal: { + cols, + rows, + data(_term: unknown, data: Uint8Array) { + onDataCb?.(Buffer.from(data).toString('utf8')); + }, + }, + cwd: homedir(), + env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, + }); + + let onDataCb: ((data: string) => void) | undefined; + + proc.exited.then((exitCode) => { + onExitCb?.({ exitCode: exitCode ?? 0 }); + }); + + let onExitCb: ((e: { exitCode: number }) => void) | undefined; + + return { + onData(cb) { + onDataCb = cb; + }, + onExit(cb) { + onExitCb = cb; + }, + write(data) { + proc.terminal?.write(data); + }, + resize(c, r) { + proc.terminal?.resize(c, r); + }, + kill() { + proc.kill(); + }, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const nodePty = require('@lydell/node-pty') as typeof import('@lydell/node-pty'); + const ptyProc = nodePty.spawn(shell, [], { + name: 'xterm-256color', + cols, + rows, + cwd: homedir(), + env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, + }); + + return { + onData(cb) { + ptyProc.onData(cb); + }, + onExit(cb) { + ptyProc.onExit(cb); + }, + write(data) { + ptyProc.write(data); + }, + resize(c, r) { + ptyProc.resize(c, r); + }, + kill() { + ptyProc.kill(); + }, + }; +} + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -312,7 +389,7 @@ function serveFile(filePath: string, res: http.ServerResponse): void { }); } -const sessions = new Map }>(); +const sessions = new Map(); function getShell(): string { if (process.platform === 'win32') { @@ -321,14 +398,8 @@ function getShell(): string { return process.env.SHELL ?? '/bin/bash'; } -function createPtySession(cols: number, rows: number) { - return pty.spawn(getShell(), [], { - name: 'xterm-256color', - cols, - rows, - cwd: homedir(), - env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, - }); +function createPtySession(cols: number, rows: number): PtyProcess { + return spawnPty(getShell(), cols, rows); } const wss = new WebSocketServer({ noServer: true }); @@ -354,7 +425,7 @@ wss.on('connection', (ws: WS, req: http.IncomingMessage) => { if (ws.readyState === ws.OPEN) ws.send(data, { binary: false }); }); - ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { + ptyProcess.onExit(({ exitCode }) => { if (ws.readyState === ws.OPEN) { ws.send(`\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`); ws.close(); From be8fde27d79048a647068b67376e3866349a9eae Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 13:52:20 -0400 Subject: [PATCH 22/38] chore: log PTY backend on startup --- src/server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 963a6a9..0c164ec 100644 --- a/src/server.ts +++ b/src/server.ts @@ -482,5 +482,6 @@ process.on('SIGINT', () => { }); httpServer.listen(HTTP_PORT, () => { - console.log(`wtty listening on http://localhost:${HTTP_PORT}`); + const ptyBackend = process.versions.bun ? 'Bun.Terminal' : 'node-pty'; + console.log(`wtty listening on http://localhost:${HTTP_PORT} (pty: ${ptyBackend})`); }); From f6d54031e50f6788298698f9468d8730fa1fd00f Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 13:55:31 -0400 Subject: [PATCH 23/38] chore: print pty backend at module load, not per-request --- src/server.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/server.ts b/src/server.ts index 0c164ec..9f63c7b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,8 +15,11 @@ interface PtyProcess { kill(): void; } +const USE_BUN_TERMINAL = !!process.versions.bun; +console.log(`pty: ${USE_BUN_TERMINAL ? 'Bun.Terminal' : 'node-pty'}`); + function spawnPty(shell: string, cols: number, rows: number): PtyProcess { - if (process.versions.bun) { + if (USE_BUN_TERMINAL) { const proc = Bun.spawn([shell], { terminal: { cols, @@ -482,6 +485,5 @@ process.on('SIGINT', () => { }); httpServer.listen(HTTP_PORT, () => { - const ptyBackend = process.versions.bun ? 'Bun.Terminal' : 'node-pty'; - console.log(`wtty listening on http://localhost:${HTTP_PORT} (pty: ${ptyBackend})`); + console.log(`wtty listening on http://localhost:${HTTP_PORT}`); }); From 2230ce819e01e2a23ec5f2a7c0cefb2b6ebeec2b Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:00:31 -0400 Subject: [PATCH 24/38] refactor: split PTY into src/pty/bun.ts and src/pty/node.ts, dynamic import --- src/pty/bun.ts | 41 +++++++++++++++++++++ src/pty/index.ts | 14 +++++++ src/pty/node.ts | 31 ++++++++++++++++ src/server.ts | 96 +++--------------------------------------------- tsconfig.json | 3 +- 5 files changed, 94 insertions(+), 91 deletions(-) create mode 100644 src/pty/bun.ts create mode 100644 src/pty/index.ts create mode 100644 src/pty/node.ts diff --git a/src/pty/bun.ts b/src/pty/bun.ts new file mode 100644 index 0000000..51b11e1 --- /dev/null +++ b/src/pty/bun.ts @@ -0,0 +1,41 @@ +import { homedir } from 'node:os'; +import type { PtyProcess } from './index.ts'; + +export function spawn(shell: string, cols: number, rows: number): PtyProcess { + let onDataCb: ((data: string) => void) | undefined; + let onExitCb: ((e: { exitCode: number }) => void) | undefined; + + const proc = Bun.spawn([shell], { + terminal: { + cols, + rows, + data(_term: unknown, data: Uint8Array) { + onDataCb?.(Buffer.from(data).toString('utf8')); + }, + }, + cwd: homedir(), + env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, + }); + + proc.exited.then((exitCode) => { + onExitCb?.({ exitCode: exitCode ?? 0 }); + }); + + return { + onData(cb) { + onDataCb = cb; + }, + onExit(cb) { + onExitCb = cb; + }, + write(data) { + proc.terminal?.write(data); + }, + resize(cols, rows) { + proc.terminal?.resize(cols, rows); + }, + kill() { + proc.kill(); + }, + }; +} diff --git a/src/pty/index.ts b/src/pty/index.ts new file mode 100644 index 0000000..34627bc --- /dev/null +++ b/src/pty/index.ts @@ -0,0 +1,14 @@ +export interface PtyProcess { + onData(cb: (data: string) => void): void; + onExit(cb: (e: { exitCode: number }) => void): void; + write(data: string): void; + resize(cols: number, rows: number): void; + kill(): void; +} + +const isBun = !!process.versions.bun; +console.log(`pty: ${isBun ? 'Bun.Terminal' : 'node-pty'}`); + +const { spawn: _spawn } = await (isBun ? import('./bun.ts') : import('./node.ts')); + +export const spawn = _spawn; diff --git a/src/pty/node.ts b/src/pty/node.ts new file mode 100644 index 0000000..7fd961f --- /dev/null +++ b/src/pty/node.ts @@ -0,0 +1,31 @@ +import { homedir } from 'node:os'; +import nodePty from '@lydell/node-pty'; +import type { PtyProcess } from './index.ts'; + +export function spawn(shell: string, cols: number, rows: number): PtyProcess { + const ptyProc = nodePty.spawn(shell, [], { + name: 'xterm-256color', + cols, + rows, + cwd: homedir(), + env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, + }); + + return { + onData(cb) { + ptyProc.onData(cb); + }, + onExit(cb) { + ptyProc.onExit(cb); + }, + write(data) { + ptyProc.write(data); + }, + resize(cols, rows) { + ptyProc.resize(cols, rows); + }, + kill() { + ptyProc.kill(); + }, + }; +} diff --git a/src/server.ts b/src/server.ts index 9f63c7b..ee6c72c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,92 +1,11 @@ import fs from 'node:fs'; import http from 'node:http'; import { createRequire } from 'node:module'; -import { homedir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { WebSocket as WS } from 'ws'; import { WebSocketServer } from 'ws'; - -interface PtyProcess { - onData(cb: (data: string) => void): void; - onExit(cb: (e: { exitCode: number }) => void): void; - write(data: string): void; - resize(cols: number, rows: number): void; - kill(): void; -} - -const USE_BUN_TERMINAL = !!process.versions.bun; -console.log(`pty: ${USE_BUN_TERMINAL ? 'Bun.Terminal' : 'node-pty'}`); - -function spawnPty(shell: string, cols: number, rows: number): PtyProcess { - if (USE_BUN_TERMINAL) { - const proc = Bun.spawn([shell], { - terminal: { - cols, - rows, - data(_term: unknown, data: Uint8Array) { - onDataCb?.(Buffer.from(data).toString('utf8')); - }, - }, - cwd: homedir(), - env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, - }); - - let onDataCb: ((data: string) => void) | undefined; - - proc.exited.then((exitCode) => { - onExitCb?.({ exitCode: exitCode ?? 0 }); - }); - - let onExitCb: ((e: { exitCode: number }) => void) | undefined; - - return { - onData(cb) { - onDataCb = cb; - }, - onExit(cb) { - onExitCb = cb; - }, - write(data) { - proc.terminal?.write(data); - }, - resize(c, r) { - proc.terminal?.resize(c, r); - }, - kill() { - proc.kill(); - }, - }; - } - - // eslint-disable-next-line @typescript-eslint/no-require-imports - const nodePty = require('@lydell/node-pty') as typeof import('@lydell/node-pty'); - const ptyProc = nodePty.spawn(shell, [], { - name: 'xterm-256color', - cols, - rows, - cwd: homedir(), - env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, - }); - - return { - onData(cb) { - ptyProc.onData(cb); - }, - onExit(cb) { - ptyProc.onExit(cb); - }, - write(data) { - ptyProc.write(data); - }, - resize(c, r) { - ptyProc.resize(c, r); - }, - kill() { - ptyProc.kill(); - }, - }; -} +import { type PtyProcess, spawn as spawnPty } from './pty/index.ts'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -394,15 +313,12 @@ function serveFile(filePath: string, res: http.ServerResponse): void { const sessions = new Map(); -function getShell(): string { - if (process.platform === 'win32') { - return process.env.COMSPEC ?? 'cmd.exe'; - } - return process.env.SHELL ?? '/bin/bash'; -} - function createPtySession(cols: number, rows: number): PtyProcess { - return spawnPty(getShell(), cols, rows); + const shell = + process.platform === 'win32' + ? (process.env.COMSPEC ?? 'cmd.exe') + : (process.env.SHELL ?? '/bin/bash'); + return spawnPty(shell, cols, rows); } const wss = new WebSocketServer({ noServer: true }); diff --git a/tsconfig.json b/tsconfig.json index 8a80c42..fa64555 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "isolatedModules": true, "sourceMap": true, - "outDir": "./dist" + "allowImportingTsExtensions": true, + "noEmit": true }, "include": ["src"], "exclude": ["node_modules", "dist"] From b833fc0334a5f5bae97b2e1b30da9ad91031abfa Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:02:16 -0400 Subject: [PATCH 25/38] refactor: drop allowImportingTsExtensions, use extensionless imports --- src/pty/bun.ts | 2 +- src/pty/index.ts | 2 +- src/pty/node.ts | 2 +- src/server.ts | 2 +- tsconfig.json | 1 - 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/pty/bun.ts b/src/pty/bun.ts index 51b11e1..e3f6975 100644 --- a/src/pty/bun.ts +++ b/src/pty/bun.ts @@ -1,5 +1,5 @@ import { homedir } from 'node:os'; -import type { PtyProcess } from './index.ts'; +import type { PtyProcess } from './index'; export function spawn(shell: string, cols: number, rows: number): PtyProcess { let onDataCb: ((data: string) => void) | undefined; diff --git a/src/pty/index.ts b/src/pty/index.ts index 34627bc..b29dab2 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -9,6 +9,6 @@ export interface PtyProcess { const isBun = !!process.versions.bun; console.log(`pty: ${isBun ? 'Bun.Terminal' : 'node-pty'}`); -const { spawn: _spawn } = await (isBun ? import('./bun.ts') : import('./node.ts')); +const { spawn: _spawn } = await (isBun ? import('./bun') : import('./node')); export const spawn = _spawn; diff --git a/src/pty/node.ts b/src/pty/node.ts index 7fd961f..491e3bf 100644 --- a/src/pty/node.ts +++ b/src/pty/node.ts @@ -1,6 +1,6 @@ import { homedir } from 'node:os'; import nodePty from '@lydell/node-pty'; -import type { PtyProcess } from './index.ts'; +import type { PtyProcess } from './index'; export function spawn(shell: string, cols: number, rows: number): PtyProcess { const ptyProc = nodePty.spawn(shell, [], { diff --git a/src/server.ts b/src/server.ts index ee6c72c..2c95d95 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { WebSocket as WS } from 'ws'; import { WebSocketServer } from 'ws'; -import { type PtyProcess, spawn as spawnPty } from './pty/index.ts'; +import { type PtyProcess, spawn as spawnPty } from './pty'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/tsconfig.json b/tsconfig.json index fa64555..8be0206 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,6 @@ "esModuleInterop": true, "isolatedModules": true, "sourceMap": true, - "allowImportingTsExtensions": true, "noEmit": true }, "include": ["src"], From 525582ad64c7d35a5abad139f4a6d1c116f7b30f Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:03:06 -0400 Subject: [PATCH 26/38] refactor: move PtyProcess interface to src/pty/types.ts --- src/pty/bun.ts | 2 +- src/pty/index.ts | 8 +------- src/pty/node.ts | 2 +- src/pty/types.ts | 7 +++++++ 4 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 src/pty/types.ts diff --git a/src/pty/bun.ts b/src/pty/bun.ts index e3f6975..31df2eb 100644 --- a/src/pty/bun.ts +++ b/src/pty/bun.ts @@ -1,5 +1,5 @@ import { homedir } from 'node:os'; -import type { PtyProcess } from './index'; +import type { PtyProcess } from './types'; export function spawn(shell: string, cols: number, rows: number): PtyProcess { let onDataCb: ((data: string) => void) | undefined; diff --git a/src/pty/index.ts b/src/pty/index.ts index b29dab2..6fb8ec7 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -1,10 +1,4 @@ -export interface PtyProcess { - onData(cb: (data: string) => void): void; - onExit(cb: (e: { exitCode: number }) => void): void; - write(data: string): void; - resize(cols: number, rows: number): void; - kill(): void; -} +export type { PtyProcess } from './types'; const isBun = !!process.versions.bun; console.log(`pty: ${isBun ? 'Bun.Terminal' : 'node-pty'}`); diff --git a/src/pty/node.ts b/src/pty/node.ts index 491e3bf..8eae6db 100644 --- a/src/pty/node.ts +++ b/src/pty/node.ts @@ -1,6 +1,6 @@ import { homedir } from 'node:os'; import nodePty from '@lydell/node-pty'; -import type { PtyProcess } from './index'; +import type { PtyProcess } from './types'; export function spawn(shell: string, cols: number, rows: number): PtyProcess { const ptyProc = nodePty.spawn(shell, [], { diff --git a/src/pty/types.ts b/src/pty/types.ts new file mode 100644 index 0000000..c726cd2 --- /dev/null +++ b/src/pty/types.ts @@ -0,0 +1,7 @@ +export interface PtyProcess { + onData(cb: (data: string) => void): void; + onExit(cb: (e: { exitCode: number }) => void): void; + write(data: string): void; + resize(cols: number, rows: number): void; + kill(): void; +} From c8ee6ce434dc9525cfa2bec3b3d06d100a5d9721 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:08:31 -0400 Subject: [PATCH 27/38] docs: update ADR 001 with dual PTY adapter and mark Accepted feat: apply full-viewport layout, Dracula theme and FiraMono font --- docs/adrs/001.wtty.bootstrap.md | 33 ++++++--- src/server.ts | 118 +++++++++++++------------------- 2 files changed, 71 insertions(+), 80 deletions(-) diff --git a/docs/adrs/001.wtty.bootstrap.md b/docs/adrs/001.wtty.bootstrap.md index 6f6f43d..682f386 100644 --- a/docs/adrs/001.wtty.bootstrap.md +++ b/docs/adrs/001.wtty.bootstrap.md @@ -1,7 +1,7 @@ # ADR 001: Bootstrap — Port ghostty-web demo **SPEC:** wtty -**Status:** Proposed +**Status:** Accepted **Date:** 2026-03-21 --- @@ -12,19 +12,25 @@ The first slice of wtty is a direct port of the `ghostty-web` demo. The demo alr ## Decision -Port `ghostty-web/demo` into a single Node.js entry point (`src/server.ts`). The server serves everything on one port — HTML, assets, and WebSocket — with all config hardcoded inline. +Port `ghostty-web/demo` into a single entry point (`src/server.ts`). The server serves everything on one port — HTML, assets, and WebSocket — with all config hardcoded inline. **Server (`src/server.ts`):** -- HTTP server on port `8080` +- HTTP server on port `2346` - Serves the terminal HTML inline as a template string at `/` -- Serves `/dist/` assets (ghostty-web JS + WASM) from the installed `ghostty-web` package +- Serves `/dist/` assets (ghostty-web JS + WASM) from the installed `ghostty-web` package via `require.resolve` - WebSocket endpoint at `/ws?cols=&rows=` — spawns one PTY per connection - PTY: auto-detects shell (`$SHELL` on macOS/Linux, `cmd.exe` on Windows), cwd `$HOME` - WebSocket message framing: raw string for PTY input; JSON `{ type: "resize", cols, rows }` for resize - On PTY exit: sends exit message and closes WebSocket +**PTY layer (`src/pty/`):** +- `types.ts` — `PtyProcess` interface shared by both adapters +- `bun.ts` — `Bun.Terminal` adapter (Bun v1.3.5+, native, no native addon) +- `node.ts` — `@lydell/node-pty` adapter (Node.js) +- `index.ts` — detects runtime via `process.versions.bun`, dynamically imports the correct adapter + **Browser (inline HTML template in `src/server.ts`):** -- Full-viewport terminal, no surrounding chrome +- Full-viewport terminal with macOS-style title bar, no surrounding chrome - `ghostty-web` (`init` + `Terminal` + `FitAddon`) as the terminal renderer - Hardcoded config: `fontSize: 14`, `fontFamily: 'FiraMono Nerd Font, Menlo, Monaco, Courier New, monospace'` - Theme: Dracula (official) — `background: #282A36`, `foreground: #F8F8F2`, `cursor: #F8F8F2`, `selection: #44475A`, `black: #21222C`, `red: #FF5555`, `green: #50FA7B`, `yellow: #F1FA8C`, `blue: #BD93F9`, `purple: #FF79C6`, `cyan: #8BE9FD`, `white: #F8F8F2`, `brightBlack: #6272A4`, `brightRed: #FF6E6E`, `brightGreen: #69FF94`, `brightYellow: #FFFFA5`, `brightBlue: #D6ACFF`, `brightPurple: #FF92DF`, `brightCyan: #A4FFFF`, `brightWhite: #FFFFFF` @@ -33,10 +39,10 @@ Port `ghostty-web/demo` into a single Node.js entry point (`src/server.ts`). The - On resize: sends JSON resize message - On close: reconnects after 2s -**Dependencies added:** -- `@lydell/node-pty` — cross-platform PTY (fork of `node-pty` with better prebuilt binaries) -- `ws` — WebSocket server -- `ghostty-web` — WASM terminal emulator, ported directly from the demo +**Dependencies:** +- `@lydell/node-pty@1.1.0` — cross-platform PTY for Node.js (fork with prebuilt binaries, no node-gyp) +- `ws@8.18.3` — WebSocket server +- `ghostty-web@0.4.0-next` — WASM terminal emulator, ported directly from the demo ## Considered Options @@ -49,9 +55,16 @@ Would enable TypeScript in the browser and hot reload. Deferred — the demo use **Option C: Serve `index.html` as a file on disk** Keeps HTML separate from server code. Rejected — inline template keeps everything in one deployable file, consistent with the ghostty-web/demo pattern. +**Option D: `Bun.Terminal` only (drop node-pty)** +`Bun.Terminal` is native to Bun, zero-dependency, and faster. Rejected as the sole option — would make Bun a hard runtime requirement for users. The dual-adapter pattern keeps Node.js as a valid runtime. + +**Option E: `node-pty` (original microsoft/node-pty)** +The original package requires `node-gyp` compilation on install. `@lydell/node-pty` is a maintained fork with prebuilt binaries that eliminates this friction while keeping full API compatibility. + ## Consequences -- Full round-trip working immediately — types, colors, resize, TUI apps all function +- Full round-trip working on both Bun and Node.js runtimes +- `bun run dev` uses `Bun.Terminal` (native); `npm run dev:node` uses `node-pty` - No config file yet — all values hardcoded; acceptable for this slice - No auth — localhost-only, same security posture as ghostty-web/demo - Frontend is plain HTML/JS — no TypeScript in browser until a build step is added diff --git a/src/server.ts b/src/server.ts index 2c95d95..5f627af 100644 --- a/src/server.ts +++ b/src/server.ts @@ -39,104 +39,62 @@ const HTML_TEMPLATE = ` wtty @@ -163,11 +121,31 @@ const HTML_TEMPLATE = ` const term = new Terminal({ cols: 80, rows: 24, - fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace', + cursorBlink: true, fontSize: 14, + fontFamily: "'FiraMono Nerd Font', Menlo, Monaco, 'Courier New', monospace", + scrollback: 10000, theme: { - background: '#1e1e1e', - foreground: '#d4d4d4', + background: '#282A36', + foreground: '#F8F8F2', + cursor: '#F8F8F2', + selection: '#44475A', + black: '#21222C', + red: '#FF5555', + green: '#50FA7B', + yellow: '#F1FA8C', + blue: '#BD93F9', + purple: '#FF79C6', + cyan: '#8BE9FD', + white: '#F8F8F2', + brightBlack: '#6272A4', + brightRed: '#FF6E6E', + brightGreen: '#69FF94', + brightYellow: '#FFFFA5', + brightBlue: '#D6ACFF', + brightPurple: '#FF92DF', + brightCyan: '#A4FFFF', + brightWhite: '#FFFFFF', }, }); @@ -401,5 +379,5 @@ process.on('SIGINT', () => { }); httpServer.listen(HTTP_PORT, () => { - console.log(`wtty listening on http://localhost:${HTTP_PORT}`); + console.log(`listening on http://localhost:${HTTP_PORT}`); }); From c83a30ab43f5180deab9e20e8ad07b2e4557448b Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:11:30 -0400 Subject: [PATCH 28/38] feat: remove title bar, full-screen terminal only --- src/server.ts | 112 +++----------------------------------------------- 1 file changed, 6 insertions(+), 106 deletions(-) diff --git a/src/server.ts b/src/server.ts index 5f627af..0a3bed8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,78 +41,13 @@ const HTML_TEMPLATE = ` -
-
-
-
-
-
-
- wtty -
-
- Connecting... -
-
-
-
+
`; From 7d41b49b7c478a754924db7a0d3a86d4f8584400 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:14:02 -0400 Subject: [PATCH 29/38] chore: simplify dev:node to tsx src/server.ts --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c350fd8..f4e44fa 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "preinstall": "bun scripts/check-pkg-manager.ts", "dev": "bun run src/server.ts", - "dev:node": "node --import tsx/esm src/server.ts", + "dev:bun": "bun run src/server.ts", + "dev:node": "tsx src/server.ts", "preview": "node dist/server.js", "lint": "tsc --noEmit -p tsconfig.lint.json && biome check .", "lint:fix": "tsc --noEmit -p tsconfig.lint.json && bunx biome check --write .", From 31eca0e29b65f3947458a26358466924b6a6897c Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:15:11 -0400 Subject: [PATCH 30/38] fix: update preview script to use bun for running the server --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f4e44fa..6265598 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "bun run src/server.ts", "dev:bun": "bun run src/server.ts", "dev:node": "tsx src/server.ts", - "preview": "node dist/server.js", + "preview": "bun run dist/server.js", "lint": "tsc --noEmit -p tsconfig.lint.json && biome check .", "lint:fix": "tsc --noEmit -p tsconfig.lint.json && bunx biome check --write .", "build": "bun run scripts/build.ts", From 5b7a5eedf0d472cdfb26e751f449070193e3d390 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:16:41 -0400 Subject: [PATCH 31/38] chore: update deps to latest --- biome.json | 2 +- bun.lock | 50 +++++++++++++++++++++++++------------------------- package.json | 10 +++++----- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/biome.json b/biome.json index f6a62d8..85f7eed 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/bun.lock b/bun.lock index 87212d3..ad98f81 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,14 @@ "": { "name": "wtty", "dependencies": { - "@lydell/node-pty": "1.1.0", + "@lydell/node-pty": "1.2.0-beta.3", "ghostty-web": "0.4.0-next.14.g6a1a50d", - "ws": "8.18.3", + "ws": "8.20.0", }, "devDependencies": { - "@biomejs/biome": "2.4.4", - "@types/bun": "1.3.9", - "@types/ws": "8.5.14", + "@biomejs/biome": "2.4.8", + "@types/bun": "1.3.11", + "@types/ws": "8.18.1", "rimraf": "6.1.3", "tsx": "4.21.0", "typescript": "5.9.3", @@ -20,23 +20,23 @@ }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="], + "@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], @@ -90,31 +90,31 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], - "@lydell/node-pty": ["@lydell/node-pty@1.1.0", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", "@lydell/node-pty-linux-arm64": "1.1.0", "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0" } }, "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw=="], + "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.3", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.3", "@lydell/node-pty-darwin-x64": "1.2.0-beta.3", "@lydell/node-pty-linux-arm64": "1.2.0-beta.3", "@lydell/node-pty-linux-x64": "1.2.0-beta.3", "@lydell/node-pty-win32-arm64": "1.2.0-beta.3", "@lydell/node-pty-win32-x64": "1.2.0-beta.3" } }, "sha512-ngGAItlRhmJXrhspxt8kX13n1dVFqzETOq0m/+gqSkO8NJBvNMwP7FZckMwps2UFySdr4yxCXNGu/bumg5at6A=="], - "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w=="], + "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-owcv+e1/OSu3bf9ZBdUQqJsQF888KyuSIiPYFNn0fLhgkhm9F3Pvha76Kj5mCPnodf7hh3suDe7upw7GPRXftQ=="], - "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA=="], + "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-k38O+UviWrWdxtqZBBc/D8NJU11Rey8Y2YMwSWNxLv3eXZZdF5IVpbBkI/2RmLsV5nCcciqLPbukxeZnEfPlwA=="], - "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg=="], + "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-HUwRpGu3O+4sv9DAQFKnyW5LYhyYu2SDUa/bdFO/t4dIFCM4uDJEq47wfRM7+aYtJTi1b3lakN8SlWeuFQqJQQ=="], - "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA=="], + "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.3", "", { "os": "linux", "cpu": "x64" }, "sha512-+RRY0PoCUeQaCvPR7/UnkGbxulwbFtoTWJfe+o4T1RcNtngrgaI55I9nl8CD8uqhGrB3smKuyvPM5UtwGhASUw=="], - "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w=="], + "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-UEDd9ASp2M3iIYpIzfmfBlpyn4+K1G4CAjYcHWStptCkefoSVXWTiUBIa1KjBjZi3/xmsHIDpBEYTkGWuvLt2Q=="], - "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw=="], + "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.3", "", { "os": "win32", "cpu": "x64" }, "sha512-TpdqSFYx7/Rj+68tuP6F/lkRYrHCYAIJgaS1bx3SctTkb5QAQCFwOKHd4xlsivmEOMT2LdhkJggPxwX9PAO5pQ=="], - "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], @@ -146,6 +146,6 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], } } diff --git a/package.json b/package.json index 6265598..a5017c9 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,14 @@ "clean": "rimraf dist node_modules" }, "dependencies": { - "@lydell/node-pty": "1.1.0", + "@lydell/node-pty": "1.2.0-beta.3", "ghostty-web": "0.4.0-next.14.g6a1a50d", - "ws": "8.18.3" + "ws": "8.20.0" }, "devDependencies": { - "@biomejs/biome": "2.4.4", - "@types/bun": "1.3.9", - "@types/ws": "8.5.14", + "@biomejs/biome": "2.4.8", + "@types/bun": "1.3.11", + "@types/ws": "8.18.1", "rimraf": "6.1.3", "tsx": "4.21.0", "typescript": "5.9.3" From eb474b6f63387261c3518b36d01382e9c4a07736 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:22:51 -0400 Subject: [PATCH 32/38] chore: mark runtime deps as external in build, clean up scripts --- package.json | 2 +- scripts/build.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index a5017c9..63ad188 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "scripts": { "preinstall": "bun scripts/check-pkg-manager.ts", "dev": "bun run src/server.ts", - "dev:bun": "bun run src/server.ts", "dev:node": "tsx src/server.ts", "preview": "bun run dist/server.js", + "preview:node": "node dist/server.js", "lint": "tsc --noEmit -p tsconfig.lint.json && biome check .", "lint:fix": "tsc --noEmit -p tsconfig.lint.json && bunx biome check --write .", "build": "bun run scripts/build.ts", diff --git a/scripts/build.ts b/scripts/build.ts index b1cd1b4..377fbbf 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -8,6 +8,7 @@ const result = await Bun.build({ target: 'node', format: 'esm', sourcemap: 'external', + external: ['@lydell/node-pty', 'ws', 'ghostty-web'], }); if (!result.success) { From 82d9b50d58131ca5c4b5ff37730ee7ebcdf6110d Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:30:49 -0400 Subject: [PATCH 33/38] chore: remove PR approval requirement, CI checks sufficient --- .github/settings.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/settings.yml b/.github/settings.yml index 3f71f2b..c2dc833 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -1,9 +1,7 @@ # GitHub repository settings reference. -# These rules must be applied manually in GitHub → Settings → Branches. +# These rules must be applied manually via: gh api repos/jesse23/wtty/branches/main/protection # They are documented here so the intended configuration is version-controlled. # -# To apply: Settings → Branches → Add branch ruleset → target "main" -# # Branch protection for: main # # required_status_checks: @@ -13,10 +11,7 @@ # - build # - test # -# required_pull_request_reviews: -# required_approving_review_count: 1 -# dismiss_stale_reviews: true # re-review required after new push -# require_code_owner_reviews: true # enforces CODEOWNERS (jesse23 only) +# required_pull_request_reviews: null # no approval required (sole contributor) # # allow_force_pushes: false # allow_deletions: false From c71aa5de430875ac4d87b363340e0643aac2cac0 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:34:57 -0400 Subject: [PATCH 34/38] ci: add Copilot review workflow, restore 1 required approval --- .github/settings.yml | 12 ++++++------ .github/workflows/copilot-review.yml | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/copilot-review.yml diff --git a/.github/settings.yml b/.github/settings.yml index c2dc833..57a04d3 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -5,13 +5,13 @@ # Branch protection for: main # # required_status_checks: -# strict: true # branch must be up to date before merge -# contexts: -# - lint -# - build -# - test +# strict: true +# contexts: [lint, build, test] # -# required_pull_request_reviews: null # no approval required (sole contributor) +# required_pull_request_reviews: +# required_approving_review_count: 1 +# dismiss_stale_reviews: true +# require_code_owner_reviews: true # CODEOWNERS: @jesse23 + Copilot # # allow_force_pushes: false # allow_deletions: false diff --git a/.github/workflows/copilot-review.yml b/.github/workflows/copilot-review.yml new file mode 100644 index 0000000..258d682 --- /dev/null +++ b/.github/workflows/copilot-review.yml @@ -0,0 +1,19 @@ +name: Copilot Review + +on: + pull_request: + branches: + - main + +jobs: + copilot-review: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v4 + - name: Request Copilot review + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr edit ${{ github.event.pull_request.number }} --add-reviewer "Copilot" From 629183b758e57751fb0dc58ea7c08ae1e4e0a2d5 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:36:24 -0400 Subject: [PATCH 35/38] ci: allow self-approval (disable require_code_owner_reviews) --- .github/settings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/settings.yml b/.github/settings.yml index 57a04d3..341f921 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -11,7 +11,7 @@ # required_pull_request_reviews: # required_approving_review_count: 1 # dismiss_stale_reviews: true -# require_code_owner_reviews: true # CODEOWNERS: @jesse23 + Copilot +# require_code_owner_reviews: false # author can self-approve # # allow_force_pushes: false # allow_deletions: false From 191dbdcdd0672f23f39d41220561a2cedad9083d Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:38:45 -0400 Subject: [PATCH 36/38] ci: restore require_code_owner_reviews --- .github/settings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/settings.yml b/.github/settings.yml index 341f921..133949f 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -11,7 +11,7 @@ # required_pull_request_reviews: # required_approving_review_count: 1 # dismiss_stale_reviews: true -# require_code_owner_reviews: false # author can self-approve +# require_code_owner_reviews: true # # allow_force_pushes: false # allow_deletions: false From bb80f9e0591af2d266acd70e8c1954b3ee7801ac Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:40:48 -0400 Subject: [PATCH 37/38] ci: remove unnecessary checkout from copilot-review workflow --- .github/workflows/copilot-review.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/copilot-review.yml b/.github/workflows/copilot-review.yml index 258d682..80ab057 100644 --- a/.github/workflows/copilot-review.yml +++ b/.github/workflows/copilot-review.yml @@ -11,7 +11,6 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/checkout@v4 - name: Request Copilot review env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From fe92eac782b425243ec7f603e834136f4d594d5c Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 21 Mar 2026 14:45:01 -0400 Subject: [PATCH 38/38] test: add unit tests for mimeType and ghosttyWebRootFromMain --- package.json | 2 +- src/server.ts | 18 +++--------------- src/utils.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 22 ++++++++++++++++++++++ 4 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 src/utils.test.ts create mode 100644 src/utils.ts diff --git a/package.json b/package.json index 63ad188..f4bb28a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "lint": "tsc --noEmit -p tsconfig.lint.json && biome check .", "lint:fix": "tsc --noEmit -p tsconfig.lint.json && bunx biome check --write .", "build": "bun run scripts/build.ts", - "test": "bun test; exit 0", + "test": "bun test", "clean": "rimraf dist node_modules" }, "dependencies": { diff --git a/src/server.ts b/src/server.ts index 0a3bed8..26c45a5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import type { WebSocket as WS } from 'ws'; import { WebSocketServer } from 'ws'; import { type PtyProcess, spawn as spawnPty } from './pty'; +import { ghosttyWebRootFromMain, mimeType } from './utils'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -17,7 +18,7 @@ const require = createRequire(import.meta.url); function findGhosttyWeb(): { distPath: string; wasmPath: string } { try { const ghosttyWebMain = require.resolve('ghostty-web') as string; - const ghosttyWebRoot = ghosttyWebMain.replace(/[/\\]dist[/\\].*$/, ''); + const ghosttyWebRoot = ghosttyWebRootFromMain(ghosttyWebMain); const distPath = path.join(ghosttyWebRoot, 'dist'); const wasmPath = path.join(ghosttyWebRoot, 'ghostty-vt.wasm'); if (fs.existsSync(path.join(distPath, 'ghostty-web.js')) && fs.existsSync(wasmPath)) { @@ -139,18 +140,6 @@ const HTML_TEMPLATE = ` `; -const MIME_TYPES: Record = { - '.html': 'text/html', - '.js': 'application/javascript', - '.mjs': 'application/javascript', - '.css': 'text/css', - '.json': 'application/json', - '.wasm': 'application/wasm', - '.png': 'image/png', - '.svg': 'image/svg+xml', - '.ico': 'image/x-icon', -}; - const httpServer = http.createServer((req, res) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); const pathname = url.pathname; @@ -176,8 +165,7 @@ const httpServer = http.createServer((req, res) => { }); function serveFile(filePath: string, res: http.ServerResponse): void { - const ext = path.extname(filePath); - const contentType = MIME_TYPES[ext] ?? 'application/octet-stream'; + const contentType = mimeType(filePath); fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404); diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 0000000..7485561 --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test'; +import { ghosttyWebRootFromMain, mimeType } from './utils'; + +describe('mimeType', () => { + test('returns correct type for known extensions', () => { + expect(mimeType('index.html')).toBe('text/html'); + expect(mimeType('app.js')).toBe('application/javascript'); + expect(mimeType('app.mjs')).toBe('application/javascript'); + expect(mimeType('style.css')).toBe('text/css'); + expect(mimeType('data.json')).toBe('application/json'); + expect(mimeType('module.wasm')).toBe('application/wasm'); + expect(mimeType('image.png')).toBe('image/png'); + expect(mimeType('icon.svg')).toBe('image/svg+xml'); + expect(mimeType('favicon.ico')).toBe('image/x-icon'); + }); + + test('returns octet-stream for unknown extensions', () => { + expect(mimeType('file.xyz')).toBe('application/octet-stream'); + expect(mimeType('binary.bin')).toBe('application/octet-stream'); + }); + + test('works with full paths', () => { + expect(mimeType('/dist/ghostty-web.js')).toBe('application/javascript'); + expect(mimeType('/dist/ghostty-vt.wasm')).toBe('application/wasm'); + }); +}); + +describe('ghosttyWebRootFromMain', () => { + test('strips dist/ and filename on posix path', () => { + expect(ghosttyWebRootFromMain('/node_modules/ghostty-web/dist/index.js')).toBe( + '/node_modules/ghostty-web', + ); + }); + + test('strips nested dist/ path', () => { + expect(ghosttyWebRootFromMain('/node_modules/ghostty-web/dist/ghostty-web.js')).toBe( + '/node_modules/ghostty-web', + ); + }); + + test('strips windows-style path', () => { + expect(ghosttyWebRootFromMain('C:\\node_modules\\ghostty-web\\dist\\index.js')).toBe( + 'C:\\node_modules\\ghostty-web', + ); + }); +}); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..9334dad --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,22 @@ +import path from 'node:path'; + +export const MIME_TYPES: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.wasm': 'application/wasm', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; + +export function mimeType(filePath: string): string { + const ext = path.extname(filePath); + return MIME_TYPES[ext] ?? 'application/octet-stream'; +} + +export function ghosttyWebRootFromMain(mainPath: string): string { + return mainPath.replace(/[/\\]dist[/\\].*$/, ''); +}