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:
34
src/server/routes/export.ts
Normal file
34
src/server/routes/export.ts
Normal 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" });
|
||||
}
|
||||
});
|
||||
50
src/server/routes/sessions.ts
Normal file
50
src/server/routes/sessions.ts
Normal 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" });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user