📝
This commit is contained in:
58
package-lock.json
generated
58
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -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",
|
||||||
|
|||||||
230
src/server/web/auth/apikey-auth.ts
Normal file
230
src/server/web/auth/apikey-auth.ts
Normal 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>`;
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">▾</button>
|
<button id="toggle-bar" title="Show top bar">▾</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>
|
||||||
|
|||||||
@@ -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
243
src/server/web/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/server/web/tsconfig.json
Normal file
8
src/server/web/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["terminal.ts", "styles.css"]
|
||||||
|
}
|
||||||
64
src/server/web/user-store.ts
Normal file
64
src/server/web/user-store.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
web/components/tools/FileIcon.tsx
Normal file
106
web/components/tools/FileIcon.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user