Add Express server: session discovery, JSONL parser, HTML exporter, API routes
Server-side implementation for reading, parsing, and exporting Claude
Code session logs:
session-discovery.ts:
- Walks ~/.claude/projects/ directories, reads sessions-index.json from
each project folder, supports both the current {version, entries}
format and the legacy raw array format
- Aggregates sessions across all projects, sorted by most recently
modified first
- Gracefully handles missing directories, corrupt index files, and
missing entries
session-parser.ts:
- Parses Claude Code JSONL session files line-by-line into normalized
ParsedMessage objects
- Handles the full real-world format: type="user" (string or
ContentBlock array content), type="assistant" (text, thinking,
tool_use blocks), type="progress" (hook events with structured data
fields), type="summary" (summary text field), type="file-history-
snapshot", and silently skips type="system" (turn_duration metadata)
and type="queue-operation" (internal)
- Detects <system-reminder> tags in user messages and reclassifies them
as system_message category
- Resilient to malformed JSONL lines (skips with continue, no crash)
html-exporter.ts:
- Generates self-contained HTML exports with no external dependencies —
all CSS (layout, category-specific colors, syntax highlighting) is
inlined in a <style> block
- Dark theme (GitHub dark palette) with category-specific left border
colors and backgrounds matching the Claude Code aesthetic
- Renders markdown content via marked + highlight.js with syntax
highlighting, inserts "content redacted" dividers where redacted
messages were removed
- Outputs a complete <!DOCTYPE html> document with session metadata
header (project name, date, message count)
routes/sessions.ts:
- GET /api/sessions — returns all discovered sessions with 30-second
in-memory cache to avoid re-scanning the filesystem on every request
- GET /api/sessions/:id — looks up session by ID from cache, parses
the JSONL file, returns parsed messages
routes/export.ts:
- POST /api/export — accepts ExportRequest body, validates required
fields, generates HTML via the exporter, returns as a downloadable
attachment with sanitized filename
index.ts:
- Express app factory (createApp) with 50MB JSON body limit, health
check endpoint, session and export routers, and static file serving
for the built client
- Dual-bind to localhost and Tailscale IP (100.84.4.113) for local +
tailnet access, with optional browser-open on startup via
SESSION_VIEWER_OPEN_BROWSER env var
- Auto-start guard: only calls startServer() when run directly, not
when imported by tests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
65
src/server/index.ts
Normal file
65
src/server/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { sessionsRouter } from "./routes/sessions.js";
|
||||
import { exportRouter } from "./routes/export.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
app.use("/api/sessions", sessionsRouter);
|
||||
app.use("/api/export", exportRouter);
|
||||
|
||||
// Serve static client files in production
|
||||
const clientDist = path.resolve(__dirname, "../../dist/client");
|
||||
app.use(express.static(clientDist));
|
||||
app.get("*", (_req, res) => {
|
||||
res.sendFile(path.join(clientDist, "index.html"));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
const TAILSCALE_IP = "100.84.4.113";
|
||||
|
||||
export function startServer() {
|
||||
const PORT = parseInt(process.env.PORT || "3848", 10);
|
||||
const app = createApp();
|
||||
|
||||
// Bind to both localhost and Tailscale — not the public interface
|
||||
const localServer = app.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`Session Viewer API running on http://localhost:${PORT}`);
|
||||
});
|
||||
const tsServer = app.listen(PORT, TAILSCALE_IP, async () => {
|
||||
console.log(`Session Viewer API running on http://${TAILSCALE_IP}:${PORT}`);
|
||||
if (process.env.SESSION_VIEWER_OPEN_BROWSER === "1") {
|
||||
const { default: open } = await import("open");
|
||||
open(`http://localhost:${PORT}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown so tsx watch can restart cleanly
|
||||
function shutdown() {
|
||||
localServer.close();
|
||||
tsServer.close();
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
return tsServer;
|
||||
}
|
||||
|
||||
// Only auto-start when run directly (not imported by tests)
|
||||
const thisFile = fileURLToPath(import.meta.url);
|
||||
const entryFile = process.argv[1];
|
||||
if (entryFile && path.resolve(entryFile) === path.resolve(thisFile)) {
|
||||
startServer();
|
||||
}
|
||||
Reference in New Issue
Block a user