This commit is contained in:
nirholas
2026-03-31 12:36:42 +00:00
parent 38648ae5f4
commit da6c5e1ed7
10 changed files with 929 additions and 140 deletions

58
package-lock.json generated
View File

@@ -19,6 +19,12 @@
"@opentelemetry/sdk-logs": "^0.57.0", "@opentelemetry/sdk-logs": "^0.57.0",
"@opentelemetry/sdk-metrics": "^1.30.0", "@opentelemetry/sdk-metrics": "^1.30.0",
"@opentelemetry/sdk-trace-base": "^1.30.0", "@opentelemetry/sdk-trace-base": "^1.30.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"auto-bind": "^5.0.1", "auto-bind": "^5.0.1",
"axios": "^1.7.0", "axios": "^1.7.0",
"chalk": "^5.4.0", "chalk": "^5.4.0",
@@ -1014,6 +1020,58 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-search": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.8.0.tgz",
"integrity": "sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-web-links": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-webgl": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
"integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
},
"node_modules/abort-controller": { "node_modules/abort-controller": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",

View File

@@ -33,6 +33,12 @@
"@opentelemetry/sdk-logs": "^0.57.0", "@opentelemetry/sdk-logs": "^0.57.0",
"@opentelemetry/sdk-metrics": "^1.30.0", "@opentelemetry/sdk-metrics": "^1.30.0",
"@opentelemetry/sdk-trace-base": "^1.30.0", "@opentelemetry/sdk-trace-base": "^1.30.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"auto-bind": "^5.0.1", "auto-bind": "^5.0.1",
"axios": "^1.7.0", "axios": "^1.7.0",
"chalk": "^5.4.0", "chalk": "^5.4.0",
@@ -65,13 +71,7 @@
"wrap-ansi": "^9.0.0", "wrap-ansi": "^9.0.0",
"ws": "^8.18.0", "ws": "^8.18.0",
"yaml": "^2.6.0", "yaml": "^2.6.0",
"zod": "^3.24.0", "zod": "^3.24.0"
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-webgl": "^0.18.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.0", "@biomejs/biome": "^1.9.0",

View File

@@ -0,0 +1,230 @@
import { createHash } from "crypto";
import { readFileSync } from "fs";
import { join } from "path";
import type { IncomingMessage } from "http";
import type { Application, Request, Response, NextFunction } from "express";
import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js";
import { SessionStore } from "./adapter.js";
/**
* API-key authentication adapter.
*
* Each user provides their own Anthropic API key on the login page.
* The key is stored encrypted in the server-side session and is injected
* as `ANTHROPIC_API_KEY` into every PTY spawned for that user.
* The plaintext key is never sent to the browser after the login form POST.
*
* User identity is derived from the key itself (SHA-256 prefix), so two
* sessions using the same key share the same userId and home directory.
*
* Optional env vars:
* ADMIN_USERS — comma-separated user IDs (SHA-256 prefixes) or API-key
* prefixes that receive the admin role
*/
export class ApiKeyAdapter implements AuthAdapter {
private readonly store: SessionStore;
private readonly adminUsers: ReadonlySet<string>;
constructor(store: SessionStore) {
this.store = store;
this.adminUsers = new Set(
(process.env.ADMIN_USERS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
}
authenticate(req: IncomingMessage): AuthUser | null {
const session = this.store.getFromRequest(req);
if (!session || !session.encryptedApiKey) return null;
const apiKey = this.store.decrypt(session.encryptedApiKey);
if (!apiKey) return null;
return {
id: session.userId,
email: session.email,
name: session.name,
isAdmin:
session.isAdmin ||
this.adminUsers.has(session.userId),
apiKey,
};
}
setupRoutes(app: Application): void {
const loginHtml = this.loadLoginPage();
// GET /auth/login — serve the API key login form
app.get("/auth/login", (_req, res) => {
res.setHeader("Content-Type", "text/html");
res.send(loginHtml);
});
// POST /auth/login — validate key, create encrypted session
app.post(
"/auth/login",
// express.urlencoded is registered in pty-server.ts before setupRoutes
(req: Request, res: Response) => {
const apiKey = (req.body as Record<string, string>)?.api_key?.trim() ?? "";
if (!apiKey.startsWith("sk-ant-")) {
res.setHeader("Content-Type", "text/html");
res.status(400).send(
loginHtml.replace(
"<!--ERROR-->",
`<p class="error">Invalid API key format. Keys must start with <code>sk-ant-</code>.</p>`,
),
);
return;
}
const userId = deriveUserId(apiKey);
const isAdmin = this.adminUsers.has(userId);
const encryptedApiKey = this.store.encrypt(apiKey);
const sessionId = this.store.create({
userId,
isAdmin,
encryptedApiKey,
});
this.store.setCookie(res as unknown as import("http").ServerResponse, sessionId);
const next = (req.query as Record<string, string>)?.next;
res.redirect(next && next.startsWith("/") ? next : "/");
},
);
// POST /auth/logout — destroy session
app.post("/auth/logout", (req, res) => {
const id = this.store.getIdFromRequest(req as unknown as IncomingMessage);
if (id) this.store.delete(id);
this.store.clearCookie(res as unknown as import("http").ServerResponse);
res.redirect("/auth/login");
});
}
requireAuth(req: Request, res: Response, next: NextFunction): void {
const user = this.authenticate(req as unknown as IncomingMessage);
if (!user) {
const accept = req.headers["accept"] ?? "";
if (accept.includes("application/json")) {
res.status(401).json({ error: "Unauthorized" });
} else {
res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
}
return;
}
(req as AuthenticatedRequest).user = user;
next();
}
// ── Internals ─────────────────────────────────────────────────────────────
private loadLoginPage(): string {
// Serve from the public directory at build time; fall back to inline HTML.
try {
const p = join(import.meta.dirname, "../public/login.html");
return readFileSync(p, "utf8");
} catch {
return INLINE_LOGIN_HTML;
}
}
}
/**
* Derives a stable, opaque user ID from an API key.
* Uses the first 16 hex chars of SHA-256(key) — short enough to be readable,
* long enough to be unique.
*/
function deriveUserId(apiKey: string): string {
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
}
// Fallback inline login page used when public/login.html is not present.
const INLINE_LOGIN_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Claude Code — Sign In</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #0d1117;
color: #e6edf3;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 400px;
}
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
p.subtitle { color: #8b949e; font-size: 0.875rem; margin-bottom: 1.5rem; }
label { display: block; font-size: 0.875rem; margin-bottom: 0.4rem; color: #8b949e; }
input[type="password"] {
width: 100%;
padding: 0.5rem 0.75rem;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 0.9rem;
margin-bottom: 1rem;
}
input[type="password"]:focus {
outline: none;
border-color: #58a6ff;
}
button {
width: 100%;
padding: 0.6rem;
background: #238636;
border: 1px solid #2ea043;
border-radius: 6px;
color: #fff;
font-size: 0.95rem;
cursor: pointer;
}
button:hover { background: #2ea043; }
.error { color: #f85149; font-size: 0.875rem; margin-bottom: 1rem; }
code { background: #21262d; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.8rem; }
.hint { color: #8b949e; font-size: 0.75rem; margin-top: 1rem; }
.hint a { color: #58a6ff; }
</style>
</head>
<body>
<div class="card">
<h1>Claude Code</h1>
<p class="subtitle">Enter your Anthropic API key to start a session.</p>
<!--ERROR-->
<form method="POST" action="/auth/login">
<label for="api_key">Anthropic API Key</label>
<input
type="password"
id="api_key"
name="api_key"
placeholder="sk-ant-..."
autocomplete="off"
required
autofocus
>
<button type="submit">Sign In</button>
</form>
<p class="hint">
Your key is stored encrypted on the server and never sent to the browser.
Get a key at <a href="https://console.anthropic.com" target="_blank" rel="noopener">console.anthropic.com</a>.
</p>
</div>
</body>
</html>`;

View File

@@ -11,14 +11,43 @@ const PORT = parseInt(process.env.PORT ?? "3000", 10);
const HOST = process.env.HOST ?? "0.0.0.0"; const HOST = process.env.HOST ?? "0.0.0.0";
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS ?? "10", 10); const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS ?? "10", 10);
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") ?? []; const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") ?? [];
const SHELL = process.env.SHELL ?? "bash"; const GRACE_PERIOD_MS = parseInt(
process.env.SESSION_GRACE_MS ?? String(5 * 60_000),
10,
);
const SCROLLBACK_BYTES = parseInt(
process.env.SCROLLBACK_BYTES ?? String(100 * 1024),
10,
);
// Resolve the claude CLI binary // Resolve the claude CLI binary
const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude"; const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude";
const app = express(); const app = express();
app.use(express.json());
const server = createServer(app); const server = createServer(app);
// --- Session Manager ---
const sessionManager = new SessionManager(
MAX_SESSIONS,
(cols, rows) =>
spawn(CLAUDE_BIN, [], {
name: "xterm-256color",
cols,
rows,
cwd: process.env.WORK_DIR ?? process.cwd(),
env: {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
},
}),
GRACE_PERIOD_MS,
SCROLLBACK_BYTES,
);
// --- HTTP routes --- // --- HTTP routes ---
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
@@ -29,6 +58,21 @@ app.get("/health", (_req, res) => {
}); });
}); });
app.get("/api/sessions", (_req, res) => {
res.json(sessionManager.listSessions());
});
app.delete("/api/sessions/:token", (req, res) => {
const { token } = req.params;
const session = sessionManager.getSession(token);
if (!session) {
res.status(404).json({ error: "Session not found" });
return;
}
sessionManager.destroySession(token);
res.status(204).end();
});
// Serve static frontend // Serve static frontend
const publicDir = path.join(import.meta.dirname, "public"); const publicDir = path.join(import.meta.dirname, "public");
app.use(express.static(publicDir)); app.use(express.static(publicDir));
@@ -37,22 +81,6 @@ app.get("/", (_req, res) => {
res.sendFile(path.join(publicDir, "index.html")); res.sendFile(path.join(publicDir, "index.html"));
}); });
// --- Session Manager ---
const sessionManager = new SessionManager(MAX_SESSIONS, (cols, rows) =>
spawn(CLAUDE_BIN, [], {
name: "xterm-256color",
cols,
rows,
cwd: process.env.WORK_DIR ?? process.cwd(),
env: {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
},
}),
);
// --- WebSocket server --- // --- WebSocket server ---
const rateLimiter = new ConnectionRateLimiter(); const rateLimiter = new ConnectionRateLimiter();
@@ -100,6 +128,27 @@ wss.on("connection", (ws, req) => {
"unknown"; "unknown";
console.log(`New WebSocket connection from ${ip}`); console.log(`New WebSocket connection from ${ip}`);
const url = new URL(
req.url ?? "/",
`http://${req.headers.host ?? "localhost"}`,
);
const cols = parseInt(url.searchParams.get("cols") ?? "80", 10);
const rows = parseInt(url.searchParams.get("rows") ?? "24", 10);
const resumeToken = url.searchParams.get("resume");
// Try to resume an existing session
if (resumeToken) {
const resumed = sessionManager.resume(resumeToken, ws, cols, rows);
if (resumed) {
return;
}
// Session expired or not found — fall through to create a new one
console.log(
`[resume] Session ${resumeToken.slice(0, 8)}… not found — starting fresh`,
);
}
// Capacity check only applies to new sessions
if (sessionManager.isFull) { if (sessionManager.isFull) {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@@ -111,14 +160,9 @@ wss.on("connection", (ws, req) => {
return; return;
} }
// Parse initial size from query params const token = sessionManager.create(ws, cols, rows);
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); if (token) {
const cols = parseInt(url.searchParams.get("cols") ?? "80", 10); ws.send(JSON.stringify({ type: "session", token }));
const rows = parseInt(url.searchParams.get("rows") ?? "24", 10);
const session = sessionManager.create(ws, cols, rows);
if (session) {
ws.send(JSON.stringify({ type: "connected", sessionId: session.id }));
} }
}); });
@@ -151,6 +195,12 @@ server.listen(PORT, HOST, () => {
console.log(`PTY server listening on http://${HOST}:${PORT}`); console.log(`PTY server listening on http://${HOST}:${PORT}`);
console.log(` WebSocket: ws://${HOST}:${PORT}/ws`); console.log(` WebSocket: ws://${HOST}:${PORT}/ws`);
console.log(` Max sessions: ${MAX_SESSIONS}`); console.log(` Max sessions: ${MAX_SESSIONS}`);
console.log(
` Session grace period: ${GRACE_PERIOD_MS / 1000}s`,
);
console.log(
` Scrollback buffer: ${Math.round(SCROLLBACK_BYTES / 1024)}KB per session`,
);
if (process.env.AUTH_TOKEN) { if (process.env.AUTH_TOKEN) {
console.log(" Auth: token required"); console.log(" Auth: token required");
} }

View File

@@ -4,22 +4,14 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#1a1b26" media="(prefers-color-scheme: dark)"> <meta name="theme-color" content="#1a1b26" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f5" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#f5f5f5" media="(prefers-color-scheme: light)">
<title>Claude Code</title> <title>Claude Code</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml"> <link rel="icon" href="favicon.svg" type="image/svg+xml">
<!-- terminal.css is emitted by esbuild: xterm.css + styles.css bundled together -->
<!-- xterm.js from CDN -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-search@0.15.0/lib/addon-search.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.8.0/lib/addon-unicode11.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
<link rel="stylesheet" href="terminal.css"> <link rel="stylesheet" href="terminal.css">
</head> </head>
<body> <body>
<!-- Top bar --> <!-- Top bar -->
<div id="top-bar"> <div id="top-bar">
<div class="left"> <div class="left">
@@ -31,24 +23,26 @@
<button id="bar-btn">Reconnect</button> <button id="bar-btn">Reconnect</button>
</div> </div>
</div> </div>
<!-- Re-expand button shown when top bar is collapsed -->
<button id="toggle-bar" title="Show top bar">&#9662;</button> <button id="toggle-bar" title="Show top bar">&#9662;</button>
<!-- Terminal --> <!-- Terminal mount point -->
<div id="terminal-container"></div> <div id="terminal-container"></div>
<!-- Loading overlay --> <!-- Loading overlay (initial connect) -->
<div id="loading-overlay"> <div id="loading-overlay">
<div class="spinner"></div> <div class="spinner"></div>
<div class="message">Connecting to Claude Code...</div> <div class="overlay-msg">Connecting to Claude Code</div>
</div> </div>
<!-- Reconnect overlay --> <!-- Reconnect overlay (shown on disconnect) -->
<div id="reconnect-overlay"> <div id="reconnect-overlay">
<div class="spinner"></div> <div class="spinner"></div>
<div class="message">Connection lost. Reconnecting...</div> <div class="overlay-msg">Connection lost. Reconnecting</div>
<div class="sub" id="reconnect-sub">Retrying in 1s...</div> <div class="overlay-sub" id="reconnect-sub">Retrying in 1s</div>
</div> </div>
<script src="terminal.js"></script> <!-- terminal.js is the esbuild bundle of terminal.ts + all xterm addons -->
<script type="module" src="terminal.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,50 +1,54 @@
import type { IPty } from "node-pty"; import type { IPty } from "node-pty";
import type { WebSocket } from "ws"; import type { WebSocket } from "ws";
import { SessionStore } from "./session-store.js";
export type Session = { export type { SessionInfo } from "./session-store.js";
id: string;
ws: WebSocket;
pty: IPty;
createdAt: number;
};
export class SessionManager { export class SessionManager {
private sessions = new Map<string, Session>(); private store: SessionStore;
private maxSessions: number; private maxSessions: number;
private spawnPty: (cols: number, rows: number) => IPty; private spawnPty: (cols: number, rows: number) => IPty;
// Tracks which sessions have already had their PTY event listeners wired,
// so we don't double-register on reconnect.
private wiredPtys = new Set<string>();
constructor( constructor(
maxSessions: number, maxSessions: number,
spawnPty: (cols: number, rows: number) => IPty, spawnPty: (cols: number, rows: number) => IPty,
gracePeriodMs?: number,
scrollbackBytes?: number,
) { ) {
this.maxSessions = maxSessions; this.maxSessions = maxSessions;
this.spawnPty = spawnPty; this.spawnPty = spawnPty;
this.store = new SessionStore(gracePeriodMs, scrollbackBytes);
} }
get activeCount(): number { get activeCount(): number {
return this.sessions.size; return this.store.size;
} }
get isFull(): boolean { get isFull(): boolean {
return this.sessions.size >= this.maxSessions; return this.store.size >= this.maxSessions;
} }
getSession(id: string): Session | undefined { getSession(token: string) {
return this.sessions.get(id); return this.store.get(token);
}
listSessions() {
return this.store.list();
} }
/** /**
* Creates a new PTY session bound to the given WebSocket. * Spawns a new PTY, registers it in the session store, and wires up all
* Returns the session or null if at capacity. * event plumbing between the PTY and the WebSocket.
*
* Returns the session token, or null if at capacity or PTY spawn fails.
*/ */
create(ws: WebSocket, cols = 80, rows = 24): Session | null { create(ws: WebSocket, cols = 80, rows = 24): string | null {
if (this.isFull) { if (this.isFull) return null;
return null;
}
const id = crypto.randomUUID();
let pty: IPty; let pty: IPty;
try { try {
pty = this.spawnPty(cols, rows); pty = this.spawnPty(cols, rows);
} catch (err) { } catch (err) {
@@ -57,39 +61,102 @@ export class SessionManager {
return null; return null;
} }
const session: Session = { id, ws, pty, createdAt: Date.now() }; const session = this.store.register(pty);
this.sessions.set(id, session); session.ws = ws;
const { token } = session;
this.wirePtyEvents(token, pty);
this.wireWsEvents(token, ws, pty);
console.log(
`[session ${token.slice(0, 8)}] Created (active: ${this.store.size}/${this.maxSessions})`,
);
return token;
}
/**
* Attaches a new WebSocket to an existing session identified by `token`.
*
* - Cancels the grace timer
* - Sends `{ type: "resumed", token }` to the client
* - Replays the scrollback buffer so the user sees their conversation
* - Resizes the PTY to the client's current terminal dimensions
*
* Returns true if the session was found, false otherwise.
*/
resume(token: string, ws: WebSocket, cols: number, rows: number): boolean {
const session = this.store.reattach(token, ws);
if (!session) return false;
console.log(
`[session ${token.slice(0, 8)}] Resumed (active: ${this.store.size}/${this.maxSessions})`,
);
// Tell the client it's a resumed session BEFORE sending scrollback bytes.
// The client uses this to clear the terminal first.
ws.send(JSON.stringify({ type: "resumed", token }));
// Replay buffered output
const scrollback = session.scrollback.read();
if (scrollback.length > 0) {
ws.send(scrollback);
}
// Sync PTY dimensions to the reconnected client
try {
session.pty.resize(cols, rows);
} catch {
// PTY may have exited
}
this.wireWsEvents(token, ws, session.pty);
return true;
}
/**
* Wire PTY → scrollback + WebSocket.
* Called once per session lifetime (idempotent via `wiredPtys` guard).
*/
private wirePtyEvents(token: string, pty: IPty): void {
if (this.wiredPtys.has(token)) return;
this.wiredPtys.add(token);
const session = this.store.get(token);
if (!session) return;
// PTY output -> WebSocket
pty.onData((data: string) => { pty.onData((data: string) => {
if (ws.readyState === ws.OPEN) { // Always capture to scrollback for future replay
session.scrollback.write(data);
// Forward to the currently attached WebSocket, if any
const ws = session.ws;
if (ws && ws.readyState === 1 /* OPEN */) {
ws.send(data); ws.send(data);
} }
}); });
// PTY exit -> clean up
pty.onExit(({ exitCode, signal }) => { pty.onExit(({ exitCode, signal }) => {
this.wiredPtys.delete(token);
console.log( console.log(
`[session ${id}] PTY exited: code=${exitCode}, signal=${signal}`, `[session ${token.slice(0, 8)}] PTY exited: code=${exitCode}, signal=${signal}`,
); );
this.sessions.delete(id); const ws = session.ws;
if (ws.readyState === ws.OPEN) { if (ws && ws.readyState === 1 /* OPEN */) {
ws.send( ws.send(JSON.stringify({ type: "exit", exitCode, signal }));
JSON.stringify({
type: "exit",
exitCode,
signal,
}),
);
ws.close(1000, "PTY exited"); ws.close(1000, "PTY exited");
} }
this.store.destroy(token);
}); });
}
// WebSocket messages -> PTY stdin (or resize) /**
* Wire WebSocket → PTY (input, resize, ping).
* On close/error, start the grace period instead of immediately destroying
* the session — this keeps the PTY alive for reconnection.
* Called once per WebSocket connection (safe to call again on reconnect).
*/
private wireWsEvents(token: string, ws: WebSocket, pty: IPty): void {
ws.on("message", (data: Buffer | string) => { ws.on("message", (data: Buffer | string) => {
const str = data.toString(); const str = data.toString();
// Try to parse as JSON for control messages
if (str.startsWith("{")) { if (str.startsWith("{")) {
try { try {
const msg = JSON.parse(str) as Record<string, unknown>; const msg = JSON.parse(str) as Record<string, unknown>;
@@ -102,75 +169,44 @@ export class SessionManager {
return; return;
} }
if (msg.type === "ping") { if (msg.type === "ping") {
if (ws.readyState === ws.OPEN) { if (ws.readyState === 1 /* OPEN */) {
ws.send(JSON.stringify({ type: "pong" })); ws.send(JSON.stringify({ type: "pong" }));
} }
return; return;
} }
} catch { } catch {
// Not JSON, treat as terminal input // Not JSON treat as terminal input
} }
} }
pty.write(str); pty.write(str);
}); });
// WebSocket close -> kill PTY const handleClose = () => {
ws.on("close", () => { console.log(`[session ${token.slice(0, 8)}] WebSocket closed`);
console.log(`[session ${id}] WebSocket closed`); const session = this.store.get(token);
this.destroySession(id); // Only start grace if this WS is still the one attached to the session
}); if (session && session.ws === ws) {
this.store.startGrace(token, () => {
ws.on("error", (err) => { /* logged inside startGrace */
console.error(`[session ${id}] WebSocket error:`, err.message); });
this.destroySession(id);
});
console.log(
`[session ${id}] Created (active: ${this.sessions.size}/${this.maxSessions})`,
);
return session;
}
/**
* Gracefully destroys a session: SIGHUP, then SIGKILL after timeout.
*/
destroySession(id: string): void {
const session = this.sessions.get(id);
if (!session) return;
this.sessions.delete(id);
const { pty, ws } = session;
try {
pty.kill("SIGHUP");
} catch {
// PTY may already be dead
}
// Force kill after 5 seconds if still alive
const killTimer = setTimeout(() => {
try {
pty.kill("SIGKILL");
} catch {
// Already dead
} }
}, 5000); };
// If PTY exits before the timer, clear it ws.on("close", handleClose);
pty.onExit(() => clearTimeout(killTimer)); ws.on("error", (err) => {
console.error(`[session ${token.slice(0, 8)}] WebSocket error:`, err.message);
if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) { handleClose();
ws.close(1000, "Session destroyed"); });
}
} }
/** /**
* Destroys all sessions. Used during server shutdown. * Force-kill a session immediately (used by the REST API).
*/ */
destroySession(token: string): void {
this.store.destroy(token);
}
destroyAll(): void { destroyAll(): void {
for (const id of [...this.sessions.keys()]) { this.store.destroyAll();
this.destroySession(id);
}
} }
} }

243
src/server/web/styles.css Normal file
View File

@@ -0,0 +1,243 @@
/* ── CSS custom properties (theme tokens) ───────────────────────────────── */
:root {
--term-bg: #1a1b26;
--term-fg: #c0caf5;
--term-cursor: #c0caf5;
--term-selection: rgba(130, 170, 255, 0.3);
--term-black: #15161e;
--term-red: #f7768e;
--term-green: #9ece6a;
--term-yellow: #e0af68;
--term-blue: #7aa2f7;
--term-magenta: #bb9af7;
--term-cyan: #7dcfff;
--term-white: #a9b1d6;
--term-bright-black: #414868;
--term-bright-white: #c0caf5;
--bar-bg: #16161e;
--bar-fg: #565f89;
--bar-accent: #7aa2f7;
--overlay-bg: rgba(26, 27, 38, 0.92);
}
@media (prefers-color-scheme: light) {
:root {
--term-bg: #f5f5f5;
--term-fg: #343b58;
--term-cursor: #343b58;
--term-selection: rgba(52, 59, 88, 0.2);
--term-black: #0f0f14;
--term-red: #8c4351;
--term-green: #485e30;
--term-yellow: #8f5e15;
--term-blue: #34548a;
--term-magenta: #5a4a78;
--term-cyan: #0f4b6e;
--term-white: #343b58;
--term-bright-black: #9699a3;
--term-bright-white: #343b58;
--bar-bg: #e8e8e8;
--bar-fg: #6c7086;
--bar-accent: #34548a;
--overlay-bg: rgba(245, 245, 245, 0.92);
}
}
/* ── Reset ──────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--term-bg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
/* ── Top bar ────────────────────────────────────────────────────────────── */
#top-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 12px;
background: var(--bar-bg);
color: var(--bar-fg);
font-size: 12px;
user-select: none;
-webkit-user-select: none;
}
#top-bar.collapsed {
display: none;
}
#top-bar .left,
#top-bar .right {
display: flex;
align-items: center;
gap: 10px;
}
/* Re-expand button — only visible when bar is collapsed */
#toggle-bar {
display: none;
position: fixed;
top: 4px;
right: 4px;
z-index: 20;
width: 20px;
height: 20px;
background: var(--bar-bg);
border: 1px solid var(--bar-fg);
border-radius: 3px;
color: var(--bar-fg);
cursor: pointer;
font-size: 10px;
align-items: center;
justify-content: center;
}
#top-bar.collapsed ~ #toggle-bar {
display: flex;
}
/* ── Status dot ─────────────────────────────────────────────────────────── */
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--term-green);
transition: background 0.3s;
}
.status-dot.disconnected {
background: var(--term-red);
}
.status-dot.connecting {
background: var(--term-yellow);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* ── Bar button ─────────────────────────────────────────────────────────── */
#bar-btn {
background: none;
border: 1px solid var(--bar-fg);
color: var(--bar-fg);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
transition: color 0.15s, border-color 0.15s;
}
#bar-btn:hover {
color: var(--bar-accent);
border-color: var(--bar-accent);
}
/* ── Terminal container ─────────────────────────────────────────────────── */
#terminal-container {
width: 100%;
height: calc(100vh - 32px);
background: var(--term-bg);
}
#top-bar.collapsed ~ #terminal-container {
height: 100vh;
}
/* Give xterm a little breathing room */
#terminal-container .xterm {
padding: 4px;
}
/* ── Overlays (loading + reconnect) ─────────────────────────────────────── */
#loading-overlay,
#reconnect-overlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
background: var(--overlay-bg);
z-index: 100;
}
#loading-overlay {
transition: opacity 0.3s ease;
}
#loading-overlay.hidden {
opacity: 0;
pointer-events: none;
}
#reconnect-overlay {
display: none;
}
#reconnect-overlay.visible {
display: flex;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--term-bright-black);
border-top-color: var(--bar-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.overlay-msg {
color: var(--term-fg);
font-size: 14px;
opacity: 0.85;
}
.overlay-sub {
margin-top: 6px;
color: var(--bar-fg);
font-size: 12px;
}
/* ── Scrollbar ──────────────────────────────────────────────────────────── */
.xterm-viewport::-webkit-scrollbar { width: 8px; }
.xterm-viewport::-webkit-scrollbar-track { background: transparent; }
.xterm-viewport::-webkit-scrollbar-thumb { background: var(--term-bright-black); border-radius: 4px; }
.xterm-viewport::-webkit-scrollbar-thumb:hover { background: var(--bar-fg); }
/* ── Mobile ─────────────────────────────────────────────────────────────── */
@media (max-width: 600px) {
#top-bar {
height: 28px;
font-size: 11px;
padding: 0 8px;
}
#terminal-container {
height: calc(100vh - 28px);
}
#top-bar.collapsed ~ #terminal-container {
height: 100vh;
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": []
},
"include": ["terminal.ts", "styles.css"]
}

View File

@@ -0,0 +1,64 @@
/**
* UserStore — tracks which users are connected and how many sessions each has.
*
* This is a lightweight in-memory view derived from the SessionManager; it
* does not persist across restarts. The admin dashboard and admin API read
* from this store to enumerate users and their activity.
*/
export interface UserRecord {
id: string;
email?: string;
name?: string;
firstSeenAt: number;
lastSeenAt: number;
sessionCount: number;
}
export class UserStore {
private readonly users = new Map<string, UserRecord>();
/**
* Called when a session is created for a user.
* Creates the user record if it doesn't exist yet; increments sessionCount.
*/
touch(userId: string, meta?: { email?: string; name?: string }): void {
const existing = this.users.get(userId);
if (existing) {
existing.lastSeenAt = Date.now();
existing.sessionCount += 1;
if (meta?.email && !existing.email) existing.email = meta.email;
if (meta?.name && !existing.name) existing.name = meta.name;
} else {
this.users.set(userId, {
id: userId,
email: meta?.email,
name: meta?.name,
firstSeenAt: Date.now(),
lastSeenAt: Date.now(),
sessionCount: 1,
});
}
}
/**
* Called when a session is destroyed for a user.
* Decrements sessionCount; removes the record when it reaches zero.
*/
release(userId: string): void {
const record = this.users.get(userId);
if (!record) return;
record.sessionCount = Math.max(0, record.sessionCount - 1);
if (record.sessionCount === 0) {
this.users.delete(userId);
}
}
/** Returns all currently connected users (sessionCount > 0). */
list(): UserRecord[] {
return [...this.users.values()];
}
get(userId: string): UserRecord | undefined {
return this.users.get(userId);
}
}

View File

@@ -0,0 +1,106 @@
"use client";
import {
FileText,
FileCode,
FileJson,
FileImage,
File,
Database,
Settings,
Package,
Globe,
BookOpen,
type LucideIcon,
} from "lucide-react";
const EXT_MAP: Record<string, LucideIcon> = {
// JavaScript / TypeScript
js: FileCode,
jsx: FileCode,
ts: FileCode,
tsx: FileCode,
mjs: FileCode,
cjs: FileCode,
// Web
html: Globe,
htm: Globe,
css: FileCode,
scss: FileCode,
sass: FileCode,
less: FileCode,
// Data
json: FileJson,
jsonc: FileJson,
yaml: FileJson,
yml: FileJson,
toml: FileJson,
xml: FileJson,
csv: Database,
// Config
env: Settings,
gitignore: Settings,
eslintrc: Settings,
prettierrc: Settings,
editorconfig: Settings,
// Docs
md: BookOpen,
mdx: BookOpen,
txt: FileText,
rst: FileText,
// Images
png: FileImage,
jpg: FileImage,
jpeg: FileImage,
gif: FileImage,
svg: FileImage,
ico: FileImage,
webp: FileImage,
// Package
lock: Package,
// Python
py: FileCode,
pyc: FileCode,
// Ruby
rb: FileCode,
// Go
go: FileCode,
// Rust
rs: FileCode,
// Java / Kotlin
java: FileCode,
kt: FileCode,
// C / C++
c: FileCode,
cpp: FileCode,
h: FileCode,
hpp: FileCode,
// Shell
sh: FileCode,
bash: FileCode,
zsh: FileCode,
fish: FileCode,
// SQL
sql: Database,
};
function getExtension(filePath: string): string {
const parts = filePath.split(".");
if (parts.length < 2) return "";
return parts[parts.length - 1].toLowerCase();
}
export function getFileIcon(filePath: string): LucideIcon {
const ext = getExtension(filePath);
return EXT_MAP[ext] ?? File;
}
interface FileIconProps {
filePath: string;
className?: string;
}
export function FileIcon({ filePath, className }: FileIconProps) {
const Icon = getFileIcon(filePath);
return <Icon className={className} />;
}