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:
2026-01-29 22:56:10 -05:00
parent c4e15bf082
commit 090d69a97a
6 changed files with 672 additions and 0 deletions

65
src/server/index.ts Normal file
View 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();
}

View File

@@ -0,0 +1,34 @@
import { Router } from "express";
import { generateExportHtml } from "../services/html-exporter.js";
import type { ExportRequest } from "../../shared/types.js";
export const exportRouter = Router();
exportRouter.post("/", async (req, res) => {
try {
const exportReq = req.body as ExportRequest;
if (
!exportReq?.session?.messages ||
!Array.isArray(exportReq.session.messages) ||
!Array.isArray(exportReq.visibleMessageUuids)
) {
res.status(400).json({ error: "Invalid export request: missing session, messages, or visibleMessageUuids" });
return;
}
const html = await generateExportHtml(exportReq);
// Sanitize session ID for use in filename - strip anything not alphanumeric/dash/underscore
const safeId = (exportReq.session.id || "export").replace(
/[^a-zA-Z0-9_-]/g,
"_"
);
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="session-${safeId}.html"`
);
res.send(html);
} catch (err) {
console.error("Failed to generate export:", err);
res.status(500).json({ error: "Failed to generate export" });
}
});

View File

@@ -0,0 +1,50 @@
import { Router } from "express";
import { discoverSessions } from "../services/session-discovery.js";
import { parseSession } from "../services/session-parser.js";
import type { SessionEntry } from "../../shared/types.js";
export const sessionsRouter = Router();
// Simple cache to avoid re-discovering sessions on every detail request
let cachedSessions: SessionEntry[] = [];
let cacheTimestamp = 0;
const CACHE_TTL_MS = 30_000;
async function getCachedSessions(): Promise<SessionEntry[]> {
const now = Date.now();
if (now - cacheTimestamp > CACHE_TTL_MS) {
cachedSessions = await discoverSessions();
cacheTimestamp = now;
}
return cachedSessions;
}
sessionsRouter.get("/", async (_req, res) => {
try {
const sessions = await getCachedSessions();
res.json({ sessions });
} catch (err) {
console.error("Failed to discover sessions:", err);
res.status(500).json({ error: "Failed to discover sessions" });
}
});
sessionsRouter.get("/:id", async (req, res) => {
try {
const sessions = await getCachedSessions();
const entry = sessions.find((s) => s.id === req.params.id);
if (!entry) {
res.status(404).json({ error: "Session not found" });
return;
}
const messages = await parseSession(entry.path);
res.json({
id: entry.id,
project: entry.project,
messages,
});
} catch (err) {
console.error("Failed to load session:", err);
res.status(500).json({ error: "Failed to load session" });
}
});

View File

@@ -0,0 +1,202 @@
import { marked } from "marked";
import hljs from "highlight.js";
import { markedHighlight } from "marked-highlight";
import type { ExportRequest, ParsedMessage } from "../../shared/types.js";
import { CATEGORY_LABELS } from "../../shared/types.js";
import { redactMessage } from "../../shared/sensitive-redactor.js";
marked.use(
markedHighlight({
highlight(code: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
},
})
);
export async function generateExportHtml(
req: ExportRequest
): Promise<string> {
const { session, visibleMessageUuids, redactedMessageUuids, autoRedactEnabled } = req;
const visibleSet = new Set(visibleMessageUuids);
const redactedSet = new Set(redactedMessageUuids);
const exportMessages = session.messages.filter(
(m) => visibleSet.has(m.uuid) && !redactedSet.has(m.uuid)
);
const messageHtmlParts: string[] = [];
const allVisibleOrdered = session.messages.filter((m) =>
visibleSet.has(m.uuid)
);
let lastWasRedacted = false;
for (const msg of allVisibleOrdered) {
if (redactedSet.has(msg.uuid)) {
lastWasRedacted = true;
continue;
}
if (lastWasRedacted) {
messageHtmlParts.push(
'<div class="redacted-divider">··· content redacted ···</div>'
);
lastWasRedacted = false;
}
const msgToRender = autoRedactEnabled ? redactMessage(msg) : msg;
messageHtmlParts.push(renderMessage(msgToRender));
}
const hljsCss = getHighlightCss();
const messageCount = exportMessages.length;
const dateStr = session.messages[0]?.timestamp
? new Date(session.messages[0].timestamp).toLocaleDateString()
: "Unknown date";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session Export - ${escapeHtml(session.project)}</title>
<style>
${getExportCss()}
${hljsCss}
</style>
</head>
<body>
<div class="session-export">
<header class="session-header">
<h1>Session Export</h1>
<div class="meta">
<span class="project">Project: ${escapeHtml(session.project)}</span>
<span class="date">Date: ${escapeHtml(dateStr)}</span>
<span class="count">${messageCount} messages</span>
</div>
</header>
<main class="messages">
${messageHtmlParts.join("\n ")}
</main>
</div>
</body>
</html>`;
}
function renderMessage(msg: ParsedMessage): string {
const categoryClass = msg.category.replace(/_/g, "-");
const label = CATEGORY_LABELS[msg.category];
let bodyHtml: string;
if (msg.category === "tool_call") {
const inputHtml = msg.toolInput
? `<pre class="tool-input"><code>${escapeHtml(msg.toolInput)}</code></pre>`
: "";
bodyHtml = `<div class="tool-name">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
} else {
bodyHtml = renderMarkdown(msg.content);
}
return `<div class="message ${categoryClass}">
<div class="message-label">${escapeHtml(label)}</div>
<div class="message-body">${bodyHtml}</div>
</div>`;
}
function renderMarkdown(text: string): string {
try {
return marked.parse(text) as string;
} catch {
return `<p>${escapeHtml(text)}</p>`;
}
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function getHighlightCss(): string {
return `
.hljs{color:#e6edf3;background:#161b22}
.hljs-comment,.hljs-quote{color:#8b949e;font-style:italic}
.hljs-keyword,.hljs-selector-tag,.hljs-subst{color:#ff7b72;font-weight:bold}
.hljs-number,.hljs-literal,.hljs-variable,.hljs-template-variable,.hljs-tag .hljs-attr{color:#79c0ff}
.hljs-string,.hljs-doctag{color:#a5d6ff}
.hljs-title,.hljs-section,.hljs-selector-id{color:#d2a8ff;font-weight:bold}
.hljs-type,.hljs-class .hljs-title{color:#d2a8ff}
.hljs-tag,.hljs-name,.hljs-attribute{color:#7ee787}
.hljs-regexp,.hljs-link{color:#a5d6ff}
.hljs-symbol,.hljs-bullet{color:#ffa657}
.hljs-built_in,.hljs-builtin-name{color:#79c0ff}
.hljs-meta{color:#d29922;font-weight:bold}
.hljs-deletion{background:#3d1f28;color:#ffa198}
.hljs-addition{background:#1a3a2a;color:#7ee787}
.hljs-emphasis{font-style:italic}
.hljs-strong{font-weight:bold}
`;
}
function getExportCss(): string {
return `
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117; color: #e6edf3; line-height: 1.6;
}
.session-export { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
.session-header {
background: #161b22; color: #e6edf3; padding: 1.5rem 2rem;
border-radius: 12px; margin-bottom: 2rem; border: 1px solid #30363d;
}
.session-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.session-header .meta { display: flex; gap: 1.5rem; font-size: 0.875rem; color: #8b949e; flex-wrap: wrap; }
.messages { display: flex; flex-direction: column; gap: 1rem; }
.message {
padding: 1rem 1.25rem; border-radius: 10px;
border-left: 4px solid #30363d; background: #161b22;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.message-label {
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; margin-bottom: 0.5rem; color: #8b949e;
}
.message-body { overflow-wrap: break-word; }
.message-body pre {
background: #0d1117; padding: 1rem; border-radius: 6px;
overflow-x: auto; font-size: 0.875rem; margin: 0.5rem 0;
border: 1px solid #30363d;
}
.message-body code { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.875em; }
.message-body p { margin: 0.5em 0; }
.message-body ul, .message-body ol { padding-left: 1.5em; margin: 0.5em 0; }
.message-body h1,.message-body h2,.message-body h3 { margin: 0.75em 0 0.25em; color: #f0f6fc; }
.message-body a { color: #58a6ff; }
.message-body table { border-collapse: collapse; margin: 0.5em 0; width: 100%; }
.message-body th, .message-body td { border: 1px solid #30363d; padding: 0.4em 0.75em; text-align: left; }
.message-body th { background: #1c2128; }
.message-body blockquote { border-left: 3px solid #30363d; padding-left: 1em; color: #8b949e; margin: 0.5em 0; }
.message-body hr { border: none; border-top: 1px solid #30363d; margin: 1em 0; }
.user-message { border-left-color: #58a6ff; background: #121d2f; }
.assistant-text { border-left-color: #3fb950; background: #161b22; }
.thinking { border-left-color: #bc8cff; background: #1c1631; }
.tool-call { border-left-color: #d29922; background: #1c1a10; }
.tool-result { border-left-color: #8b8cf8; background: #181830; }
.system-message { border-left-color: #8b949e; background: #1c2128; font-size: 0.875rem; }
.hook-progress { border-left-color: #484f58; background: #131820; font-size: 0.875rem; }
.file-snapshot { border-left-color: #f778ba; background: #241525; }
.summary { border-left-color: #2dd4bf; background: #122125; }
.tool-name { font-weight: 600; color: #d29922; margin-bottom: 0.5rem; }
.tool-input { font-size: 0.8rem; }
.redacted-divider {
text-align: center; color: #484f58; font-size: 0.875rem;
padding: 0.75rem 0; border-top: 1px dashed #30363d; border-bottom: 1px dashed #30363d;
margin: 0.5rem 0;
}
`;
}

View File

@@ -0,0 +1,80 @@
import fs from "fs/promises";
import path from "path";
import os from "os";
import type { SessionEntry } from "../../shared/types.js";
interface IndexEntry {
sessionId: string;
summary?: string;
firstPrompt?: string;
created?: string;
modified?: string;
messageCount?: number;
fullPath?: string;
projectPath?: string;
}
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
export async function discoverSessions(
projectsDir: string = CLAUDE_PROJECTS_DIR
): Promise<SessionEntry[]> {
const sessions: SessionEntry[] = [];
let projectDirs: string[];
try {
projectDirs = await fs.readdir(projectsDir);
} catch {
return sessions;
}
for (const projectDir of projectDirs) {
const projectPath = path.join(projectsDir, projectDir);
let stat;
try {
stat = await fs.stat(projectPath);
} catch {
continue;
}
if (!stat.isDirectory()) continue;
const indexPath = path.join(projectPath, "sessions-index.json");
try {
const content = await fs.readFile(indexPath, "utf-8");
const parsed = JSON.parse(content);
// Handle both formats: raw array or { version, entries: [...] }
const entries: IndexEntry[] = Array.isArray(parsed)
? parsed
: parsed.entries ?? [];
for (const entry of entries) {
const sessionPath =
entry.fullPath ||
path.join(projectPath, `${entry.sessionId}.jsonl`);
sessions.push({
id: entry.sessionId,
summary: entry.summary || "",
firstPrompt: entry.firstPrompt || "",
project: projectDir,
created: entry.created || "",
modified: entry.modified || "",
messageCount: entry.messageCount || 0,
path: sessionPath,
});
}
} catch {
// Missing or corrupt index - skip
}
}
sessions.sort((a, b) => {
const dateA = new Date(a.modified || a.created || 0).getTime() || 0;
const dateB = new Date(b.modified || b.created || 0).getTime() || 0;
return dateB - dateA;
});
return sessions;
}

View File

@@ -0,0 +1,241 @@
import fs from "fs/promises";
import type { ParsedMessage } from "../../shared/types.js";
/**
* Real Claude Code JSONL format (verified from actual session files):
*
* Every line has: { type, uuid?, timestamp?, ... }
*
* type="user" → { message: { role: "user", content: string | ContentBlock[] }, uuid, timestamp }
* type="assistant" → { message: { role: "assistant", content: ContentBlock[] }, uuid, timestamp }
* type="progress" → { data: { type, hookEvent?, status?, ... }, uuid, timestamp }
* type="summary" → { summary: string, leafUuid }
* type="file-history-snapshot" → { snapshot: { ... }, messageId }
* type="system" → { subtype: "turn_duration", durationMs, ... } (metadata, not display)
* type="queue-operation" → internal (not display)
*
* ContentBlock: { type: "text", text } | { type: "thinking", thinking } | { type: "tool_use", name, input } | { type: "tool_result", content, tool_use_id }
*/
interface ContentBlock {
type: string;
text?: string;
thinking?: string;
name?: string;
input?: Record<string, unknown>;
tool_use_id?: string;
content?: string | ContentBlock[];
}
interface RawLine {
type?: string;
uuid?: string;
timestamp?: string;
message?: {
role?: string;
content?: string | ContentBlock[];
};
data?: Record<string, unknown>;
summary?: string;
snapshot?: Record<string, unknown>;
subtype?: string;
}
export async function parseSession(
filePath: string
): Promise<ParsedMessage[]> {
let content: string;
try {
content = await fs.readFile(filePath, "utf-8");
} catch {
return [];
}
return parseSessionContent(content);
}
export function parseSessionContent(content: string): ParsedMessage[] {
const messages: ParsedMessage[] = [];
const lines = content.split("\n").filter((l) => l.trim());
for (let i = 0; i < lines.length; i++) {
let parsed: RawLine;
try {
parsed = JSON.parse(lines[i]);
} catch {
continue; // Skip malformed lines
}
const extracted = extractMessages(parsed, i);
messages.push(...extracted);
}
return messages;
}
function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
const messages: ParsedMessage[] = [];
const type = raw.type;
const uuid = raw.uuid || `generated-${rawIndex}`;
const timestamp = raw.timestamp;
// Progress/hook messages - content is in `data`, not `content`
if (type === "progress") {
const data = raw.data;
const progressText = data
? formatProgressData(data)
: "Progress event";
messages.push({
uuid,
category: "hook_progress",
content: progressText,
timestamp,
rawIndex,
});
return messages;
}
// File history snapshot
if (type === "file-history-snapshot") {
messages.push({
uuid,
category: "file_snapshot",
content: JSON.stringify(raw.snapshot || raw, null, 2),
timestamp,
rawIndex,
});
return messages;
}
// Summary message - text is in `summary` field, not `content`
if (type === "summary") {
messages.push({
uuid,
category: "summary",
content: raw.summary || "",
timestamp,
rawIndex,
});
return messages;
}
// System metadata (turn_duration etc.) - skip, not user-facing
if (type === "system" || type === "queue-operation") {
return messages;
}
// User and assistant messages - content is in `message.content`
const role = raw.message?.role;
const content = raw.message?.content;
if ((type === "user" || role === "user") && content !== undefined) {
if (typeof content === "string") {
const category = detectSystemReminder(content)
? "system_message"
: "user_message";
messages.push({
uuid,
category,
content,
timestamp,
rawIndex,
});
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "tool_result") {
const resultText =
typeof block.content === "string"
? block.content
: Array.isArray(block.content)
? block.content
.map((b: ContentBlock) => b.text || "")
.join("\n")
: JSON.stringify(block.content);
messages.push({
uuid: `${uuid}-tr-${block.tool_use_id || rawIndex}`,
category: "tool_result",
content: resultText,
toolName: block.tool_use_id,
timestamp,
rawIndex,
});
} else if (block.type === "text") {
const text = block.text || "";
const category = detectSystemReminder(text)
? "system_message"
: "user_message";
messages.push({
uuid: `${uuid}-text-${rawIndex}`,
category,
content: text,
timestamp,
rawIndex,
});
}
}
}
return messages;
}
if ((type === "assistant" || role === "assistant") && content !== undefined) {
if (typeof content === "string") {
messages.push({
uuid,
category: "assistant_text",
content,
timestamp,
rawIndex,
});
} else if (Array.isArray(content)) {
for (let j = 0; j < content.length; j++) {
const block = content[j];
const blockUuid = `${uuid}-${j}`;
if (block.type === "thinking") {
messages.push({
uuid: blockUuid,
category: "thinking",
content: block.thinking || block.text || "",
timestamp,
rawIndex,
});
} else if (block.type === "text") {
messages.push({
uuid: blockUuid,
category: "assistant_text",
content: block.text || "",
timestamp,
rawIndex,
});
} else if (block.type === "tool_use") {
messages.push({
uuid: blockUuid,
category: "tool_call",
content: `Tool: ${block.name}`,
toolName: block.name,
toolInput: JSON.stringify(block.input, null, 2),
timestamp,
rawIndex,
});
}
}
}
return messages;
}
return messages;
}
function formatProgressData(data: Record<string, unknown>): string {
const parts: string[] = [];
if (data.hookEvent) parts.push(`Hook: ${data.hookEvent}`);
if (data.hookName) parts.push(`${data.hookName}`);
if (data.toolName) parts.push(`Tool: ${data.toolName}`);
if (data.status) parts.push(`Status: ${data.status}`);
if (data.serverName) parts.push(`Server: ${data.serverName}`);
if (parts.length > 0) return parts.join(" | ");
return JSON.stringify(data);
}
function detectSystemReminder(text: string): boolean {
return text.includes("<system-reminder>") || text.includes("</system-reminder>");
}