v1.10: booterm container — xterm.js + tmux + node-pty

This commit is contained in:
2026-05-18 14:06:46 +00:00
parent d85b17081e
commit 7486e7d3e0
25 changed files with 1515 additions and 29 deletions

41
apps/booterm/Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1.7
# ---- Build stage: compile TypeScript ----
FROM node:20-alpine AS builder
ENV COREPACK_DEFAULT_TO_LATEST=0
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
RUN apk add --no-cache python3 make g++
WORKDIR /build
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
COPY apps/server/package.json ./apps/server/
COPY apps/web/package.json ./apps/web/
COPY apps/booterm/package.json ./apps/booterm/
RUN pnpm install --frozen-lockfile
COPY apps/booterm ./apps/booterm
RUN pnpm --filter=@boocode/booterm build
# ---- Prod-deps stage: hoisted, native built via npm rebuild ----
FROM node:20-alpine AS proddeps
ENV COREPACK_DEFAULT_TO_LATEST=0
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
RUN apk add --no-cache python3 make g++
WORKDIR /prod
COPY apps/booterm/package.json ./package.json
RUN pnpm install --prod --config.node-linker=hoisted --config.strict-peer-dependencies=false
# pnpm 10 ignores build scripts; force compile with npm directly.
# node-gyp is bundled with npm in the node:20-alpine image.
RUN cd node_modules/node-pty && npm run install
# Sanity check — fail the build if the artifact still isn't there
RUN test -f node_modules/node-pty/build/Release/pty.node && echo "pty.node OK" || (echo "pty.node MISSING" && exit 1)
# ---- Runtime ----
FROM node:20-alpine AS runtime
RUN apk add --no-cache tmux libstdc++
WORKDIR /app
COPY --from=builder /build/apps/booterm/dist ./dist
COPY --from=proddeps /prod/package.json ./package.json
COPY --from=proddeps /prod/node_modules ./node_modules
COPY apps/booterm/tmux.conf /etc/booterm/tmux.conf
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"]

27
apps/booterm/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@boocode/booterm",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/websocket": "^10.0.1",
"fastify": "^4.28.1",
"node-pty": "^1.0.0",
"pg": "^8.13.0",
"tslib": "^2.6.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.14.10",
"@types/pg": "^8.11.10",
"tsx": "^4.16.2",
"typescript": "^5.5.0"
}
}

11
apps/booterm/src/auth.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { FastifyRequest } from 'fastify';
// Mirrors the boocode pattern: there is no app-layer auth — Authelia handles
// it at the reverse proxy (CLAUDE.md). All broker.publishUser calls use
// 'default' as the user key. We accept Remote-User when present (set by the
// proxy in prod) and fall back to 'default' on direct Tailscale access.
export function getUser(req: FastifyRequest): string {
const header = req.headers['remote-user'];
if (typeof header === 'string' && header.length > 0) return header;
return 'default';
}

View File

@@ -0,0 +1,26 @@
import { z } from 'zod';
const ConfigSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
HOST: z.string().default('0.0.0.0'),
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.string().default('info'),
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'),
});
export type Config = z.infer<typeof ConfigSchema>;
let cached: Config | null = null;
export function loadConfig(): Config {
if (cached) return cached;
const parsed = ConfigSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:');
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
cached = parsed.data;
return cached;
}

46
apps/booterm/src/db.ts Normal file
View File

@@ -0,0 +1,46 @@
import pg from 'pg';
const { Pool } = pg;
let pool: pg.Pool | null = null;
export function getPool(databaseUrl: string): pg.Pool {
if (pool) return pool;
pool = new Pool({ connectionString: databaseUrl, max: 5, idleTimeoutMillis: 30_000 });
return pool;
}
export interface SessionInfo {
id: string;
project_id: string;
project_path: string;
}
export async function getSessionInfo(sessionId: string): Promise<SessionInfo | null> {
if (!pool) throw new Error('db pool not initialized');
const res = await pool.query<SessionInfo>(
`SELECT s.id, s.project_id, p.path AS project_path
FROM sessions s
JOIN projects p ON p.id = s.project_id
WHERE s.id = $1`,
[sessionId],
);
return res.rows[0] ?? null;
}
export async function pingDb(): Promise<boolean> {
if (!pool) return false;
try {
await pool.query('SELECT 1');
return true;
} catch {
return false;
}
}
export async function closeDb(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}

60
apps/booterm/src/index.ts Normal file
View File

@@ -0,0 +1,60 @@
import Fastify from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import { loadConfig } from './config.js';
import { getPool, closeDb } from './db.js';
import { registerHealthRoutes } from './routes/health.js';
import { registerTerminalRoutes } from './routes/terminals.js';
import { registerWsAttachRoute } from './ws/attach.js';
async function main(): Promise<void> {
const config = loadConfig();
const app = Fastify({
logger: { level: config.LOG_LEVEL },
});
app.removeContentTypeParser(['application/json']);
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const str = (body as string) ?? '';
if (str.trim().length === 0) {
done(null, {});
return;
}
try {
done(null, JSON.parse(str));
} catch (err) {
done(err as Error, undefined);
}
});
getPool(config.DATABASE_URL);
await app.register(fastifyWebsocket);
registerHealthRoutes(app);
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
const shutdown = async (signal: string) => {
app.log.info(`received ${signal}, shutting down`);
try {
await app.close();
await closeDb();
process.exit(0);
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
await app.listen({ port: config.PORT, host: config.HOST });
app.log.info(`booterm listening on http://${config.HOST}:${config.PORT}`);
}
main().catch((err) => {
console.error('Fatal startup error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,102 @@
import { spawn } from 'node:child_process';
import type { FastifyBaseLogger } from 'fastify';
// UUIDs already match [0-9a-f-]; allow uppercase and longer just in case.
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
export function sanitizeId(raw: string): string | null {
if (!ID_RE.test(raw)) return null;
return raw.toLowerCase();
}
export function tmuxSessionName(sessionId: string): string {
return `bc-${sessionId}`;
}
export function tmuxWindowName(paneId: string): string {
return `term-${paneId}`;
}
interface CmdResult {
stdout: string;
stderr: string;
code: number;
}
// Wrap child_process.spawn with shell:false so each argv element is passed
// as a separate argument — no shell interpolation, no injection surface.
function runTmux(tmuxConfPath: string, args: string[]): Promise<CmdResult> {
return new Promise((resolve) => {
const child = spawn('tmux', ['-f', tmuxConfPath, ...args], { shell: false });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); });
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
child.on('error', (err) => {
resolve({ stdout, stderr: stderr + String(err), code: 1 });
});
child.on('close', (code) => {
resolve({ stdout, stderr, code: code ?? 0 });
});
});
}
export async function hasSession(tmuxConfPath: string, sessionName: string): Promise<boolean> {
const res = await runTmux(tmuxConfPath, ['has-session', '-t', `=${sessionName}`]);
return res.code === 0;
}
export async function listWindows(tmuxConfPath: string, sessionName: string): Promise<string[]> {
const res = await runTmux(tmuxConfPath, ['list-windows', '-t', sessionName, '-F', '#{window_name}']);
if (res.code !== 0) return [];
return res.stdout.trim().split('\n').filter(Boolean);
}
export async function killWindow(
tmuxConfPath: string,
sessionName: string,
windowName: string,
): Promise<boolean> {
const res = await runTmux(tmuxConfPath, ['kill-window', '-t', `${sessionName}:${windowName}`]);
return res.code === 0;
}
// Idempotent. Creates the tmux session if it doesn't exist, then ensures the
// named window is present. The session's initial window is created with the
// target name (via `-n`) so we don't need a separate rename step.
export async function ensureWindow(
tmuxConfPath: string,
sessionName: string,
windowName: string,
projectRoot: string,
log: FastifyBaseLogger,
): Promise<void> {
if (!(await hasSession(tmuxConfPath, sessionName))) {
log.info({ sessionName, windowName, projectRoot }, 'creating tmux session');
const res = await runTmux(tmuxConfPath, [
'new-session', '-d',
'-s', sessionName,
'-n', windowName,
'-c', projectRoot,
]);
if (res.code !== 0) {
log.error({ res }, 'tmux new-session failed');
throw new Error(`tmux new-session failed: ${res.stderr}`);
}
return;
}
const windows = await listWindows(tmuxConfPath, sessionName);
if (windows.includes(windowName)) return;
const res = await runTmux(tmuxConfPath, [
'new-window',
'-t', sessionName,
'-n', windowName,
'-c', projectRoot,
]);
if (res.code !== 0) {
log.error({ res }, 'tmux new-window failed');
throw new Error(`tmux new-window failed: ${res.stderr}`);
}
}

View File

@@ -0,0 +1,41 @@
import * as pty from 'node-pty';
import type { IPty } from 'node-pty';
export interface AttachPtyOptions {
sessionName: string;
windowName: string;
projectRoot: string;
cols: number;
rows: number;
tmuxConfPath: string;
}
function cleanEnv(): { [key: string]: string } {
const out: { [key: string]: string } = {};
for (const [k, v] of Object.entries(process.env)) {
if (typeof v === 'string') out[k] = v;
}
out['TERM'] = 'screen-256color';
return out;
}
// Spawns a tmux client attached to the given session+window. `-d` detaches any
// other client so a browser refresh takes over the same window without
// duplicate input. tmux server (and the window) persists across PTY exits.
export function attachPty(opts: AttachPtyOptions): IPty {
return pty.spawn(
'tmux',
[
'-f', opts.tmuxConfPath,
'attach-session', '-d',
'-t', `${opts.sessionName}:${opts.windowName}`,
],
{
name: 'xterm-256color',
cols: opts.cols,
rows: opts.rows,
cwd: opts.projectRoot,
env: cleanEnv(),
},
);
}

View File

@@ -0,0 +1,9 @@
import type { FastifyInstance } from 'fastify';
import { pingDb } from '../db.js';
export function registerHealthRoutes(app: FastifyInstance): void {
app.get('/api/term/health', async () => {
const dbOk = await pingDb();
return { ok: true, db: dbOk };
});
}

View File

@@ -0,0 +1,88 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getSessionInfo } from '../db.js';
import {
sanitizeId,
tmuxSessionName,
tmuxWindowName,
ensureWindow,
killWindow,
hasSession,
listWindows,
} from '../pty/manager.js';
import { resizePane } from '../ws/attach.js';
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
const ResizeBodySchema = z.object({
cols: z.coerce.number().int().min(1).max(2000),
rows: z.coerce.number().int().min(1).max(2000),
});
export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: string): void {
app.post<{ Params: { sid: string; pid: string } }>(
'/api/term/sessions/:sid/panes/:pid/start',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
const sid = sanitizeId(p.data.sid);
const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const session = await getSessionInfo(sid);
if (!session) return reply.code(404).send({ error: 'unknown_session' });
const sessionName = tmuxSessionName(sid);
const windowName = tmuxWindowName(pid);
try {
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
} catch (err) {
req.log.error({ err }, 'ensureWindow failed');
return reply.code(500).send({ error: 'tmux_failed' });
}
return reply.code(200).send({ tmux_window: windowName });
},
);
app.post<{ Params: { sid: string; pid: string }; Body: { cols: number; rows: number } }>(
'/api/term/sessions/:sid/panes/:pid/resize',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
const b = ResizeBodySchema.safeParse(req.body);
if (!b.success) return reply.code(400).send({ error: 'bad_body' });
const sid = sanitizeId(p.data.sid);
const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const ok = resizePane(pid, b.data.cols, b.data.rows);
if (!ok) return reply.code(404).send({ error: 'no_active_pty' });
return reply.code(200).send({ ok: true });
},
);
app.post<{ Params: { sid: string; pid: string } }>(
'/api/term/sessions/:sid/panes/:pid/kill',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
const sid = sanitizeId(p.data.sid);
const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const sessionName = tmuxSessionName(sid);
const windowName = tmuxWindowName(pid);
if (!(await hasSession(tmuxConfPath, sessionName))) {
return reply.code(404).send({ error: 'unknown_session' });
}
const windows = await listWindows(tmuxConfPath, sessionName);
if (!windows.includes(windowName)) {
return reply.code(404).send({ error: 'unknown_pane' });
}
const killed = await killWindow(tmuxConfPath, sessionName, windowName);
if (!killed) return reply.code(500).send({ error: 'tmux_kill_failed' });
return reply.code(200).send({ ok: true });
},
);
}

View File

@@ -0,0 +1,128 @@
import type { FastifyInstance } from 'fastify';
import type { IPty } from 'node-pty';
import { getSessionInfo } from '../db.js';
import { sanitizeId, tmuxSessionName, tmuxWindowName, ensureWindow } from '../pty/manager.js';
import { attachPty } from '../pty/pty.js';
import { getUser } from '../auth.js';
// Registry of currently-attached PTYs keyed by paneId. Used by the resize REST
// route to find the active node-pty handle so it can call pty.resize(cols, rows).
const active = new Map<string, IPty>();
export function resizePane(paneId: string, cols: number, rows: number): boolean {
const handle = active.get(paneId);
if (!handle) return false;
try {
handle.resize(cols, rows);
return true;
} catch {
return false;
}
}
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
app.get<{
Params: { sid: string; pid: string };
Querystring: { cols?: string; rows?: string };
}>(
'/ws/term/sessions/:sid/panes/:pid',
{ websocket: true },
async (socket, req) => {
const sid = sanitizeId(req.params.sid);
const pid = sanitizeId(req.params.pid);
if (!sid || !pid) {
socket.close(1008, 'bad_id_format');
return;
}
const user = getUser(req);
req.log.info({ user, sid, pid }, 'ws attach');
const session = await getSessionInfo(sid);
if (!session) {
socket.close(1008, 'unknown_session');
return;
}
const sessionName = tmuxSessionName(sid);
const windowName = tmuxWindowName(pid);
try {
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
} catch (err) {
req.log.error({ err }, 'ensureWindow failed in WS handler');
socket.close(1011, 'tmux_failed');
return;
}
const cols = parseInt(req.query.cols ?? '', 10) || 80;
const rows = parseInt(req.query.rows ?? '', 10) || 24;
let handle: IPty;
try {
handle = attachPty({
sessionName,
windowName,
projectRoot: session.project_path,
cols,
rows,
tmuxConfPath,
});
} catch (err) {
req.log.error({ err }, 'attachPty failed');
socket.close(1011, 'pty_spawn_failed');
return;
}
active.set(pid, handle);
const onData = (data: string) => {
if (socket.readyState !== socket.OPEN) return;
try {
socket.send(Buffer.from(data, 'utf8'), { binary: true });
} catch (err) {
req.log.warn({ err }, 'ws send failed');
}
};
handle.onData(onData);
socket.on('message', (data: Buffer | string) => {
try {
if (typeof data === 'string') {
handle.write(data);
} else {
handle.write(data.toString('utf8'));
}
} catch (err) {
req.log.warn({ err }, 'pty write failed');
}
});
handle.onExit(({ exitCode }) => {
try {
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify({ type: 'exit', code: exitCode }));
}
} catch {
/* ignore */
}
try {
socket.close(1000);
} catch {
/* ignore */
}
if (active.get(pid) === handle) active.delete(pid);
});
// WS close kills the local PTY (the tmux client). The tmux server and
// window persist so a refresh resumes with full scrollback.
socket.on('close', () => {
if (active.get(pid) === handle) active.delete(pid);
try {
handle.kill();
} catch {
/* ignore */
}
});
},
);
}

6
apps/booterm/tmux.conf Normal file
View File

@@ -0,0 +1,6 @@
set -g default-terminal "screen-256color"
set -g history-limit 50000
set -g mouse on
setw -g mode-keys vi
set -g status off
set -g destroy-unattached off

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022"],
"types": ["node"],
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts"]
}