How Session Export Generates HTML and Uses Iterative Tree Helpers in Pi-Web
Session export generates HTML by locating the session file, running the pi-coding-agent CLI or export-html module to build raw HTML, then patching recursive tree helpers into iterative versions to prevent stack overflow on deep trees.
The pi-web repository implements a robust session export endpoint that transforms stored session data into visual HTML reports. According to the agegr/pi-web source code, this process involves a deliberate three-stage pipeline designed to handle arbitrarily deep conversation trees without crashing the browser.
The Three-Stage Export Pipeline
The GET /api/sessions/[id]/export endpoint in app/api/sessions/[id]/export/route.ts orchestrates the complete flow through distinct phases.
Stage 1: Session File Resolution
The handler begins by locating the persisted session on disk. The resolveSessionPath(id) utility from lib/session-reader.ts returns the absolute path to the .jsonl file containing the session's message history.
// app/api/sessions/[id]/export/route.ts#L48-L53
const sessionPath = await resolveSessionPath(id);
if (!sessionPath) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
}
Stage 2: HTML Generation via CLI or Module
The system attempts two generation strategies in sequence:
pi-coding-agent CLI (preferred): If the CLI binary is available, exportSession spawns it via execFileAsync to produce the HTML file.
// app/api/sessions/[id]/export/route.ts#L17-L30
await execFileAsync('pi-coding-agent', ['--export', sessionPath, tempHtmlPath]);
Fallback to export-html module: When the CLI is missing, the code dynamically imports node_modules/@earendil-works/pi-coding-agent/dist/core/export-html/index.js and invokes exportFromFile directly.
// app/api/sessions/[id]/export/route.ts#L33-L38
const { exportFromFile } = await import(
'@earendil-works/pi-coding-agent/dist/core/export-html/index.js'
);
await exportFromFile(sessionPath, tempHtmlPath);
Both paths produce identical HTML containing the session messages and a visual tree rendered by the pi-coding-agent template.
Stage 3: Patching Recursive Helpers to Iterative Forms
Before returning the HTML, patchExportHtml transforms three recursive tree traversal functions into iterative equivalents. This prevents call stack overflow on deeply linear session trees exceeding 5,000 entries.
The Three Iterative Tree Helpers
The patch targets specific recursive implementations embedded in the generated JavaScript. Each replacement maintains identical semantics while eliminating recursion.
sortChildren: DFS Pre-Order with Explicit Stack
| Aspect | Recursive Original | Iterative Replacement |
|---|---|---|
| Purpose | Sort each node's children by timestamp, then recurse | Same sorting logic, stack-based traversal |
| Mechanism | Direct function calls on children | Push children to stack, process in LIFO order |
Iterative implementation:
// Iterative DFS pre-order for sortChildren
const stack = [node];
while (stack.length > 0) {
const current = stack.pop();
current.children.sort((a, b) => a.timestamp - b.timestamp);
// Push children in reverse order to maintain left-to-right processing
for (let i = current.children.length - 1; i >= 0; i--) {
stack.push(current.children[i]);
}
}
As implemented in the patch source (app/api/sessions/[id]/export/route.ts#L49-L60), this uses an explicit stack array where nodes are popped, sorted, and their children pushed for subsequent processing.
mapNodes: Reversed Root Stack for ID Mapping
The recursive mapNodes builds a Map from entry IDs to tree nodes through depth-first traversal.
Iterative replacement (L71-L78):
// Start with reversed tree array to maintain processing order
const stack = [...tree].reverse();
while (stack.length > 0) {
const node = stack.pop();
map.set(node.id, node);
// Push children in reverse to process them in original order
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push(node.children[i]);
}
}
The stack initialization with [...tree].reverse() ensures the iterative version produces identical output to the recursive implementation.
markActive: Two-Stack Post-Order Traversal
The most complex replacement achieves post-order traversal (children processed before parent) without recursion. This is essential for correctly flagging the active path in the UI.
Implementation (L92-L110):
// First stack for traversal, second to reverse order for post-order processing
const stack1 = [node];
const stack2 = [];
while (stack1.length > 0) {
const current = stack1.pop();
stack2.push(current);
// Push children to first stack in normal order
for (const child of current.children) {
stack1.push(child);
}
}
// Process stack2 (now in reverse post-order)
while (stack2.length > 0) {
const current = stack2.pop();
// Mark active logic here: children already processed
}
This classic two-stack technique builds stack2 in root-right-left order, then popping yields left-right-root—the standard post-order sequence.
How the Patch Applies Transformations
The patchExportHtml function performs precise string replacement on the generated HTML:
// app/api/sessions/[id]/export/route.ts#L30-L38
let html = await fs.readFile(tempHtmlPath, 'utf-8');
// Normalize line endings to guarantee single match
html = html.replace(/\r\n/g, '\n');
// Apply three iterative replacements
html = html.replace(recursiveSortChildrenPattern, iterativeSortChildren);
html = html.replace(recursiveMapNodesPattern, iterativeMapNodes);
html = html.replace(recursiveMarkActivePattern, iterativeMarkActive);
Safety checks throw if any pattern fails to match exactly once (L34-L36), ensuring the HTML structure matches expectations.
Returning the Final Response
After patching, the handler sets security headers and streams the result:
// app/api/sessions/[id]/export/route.ts#L66-L74
const headers = new Headers({
'Content-Type': 'text/html; charset=utf-8',
'Content-Security-Policy': "default-src 'self'; script-src 'unsafe-inline'...",
});
if (!inline) {
headers.set('Content-Disposition', `attachment; filename="session-${id}.html"`);
}
return new NextResponse(html, { headers });
Complete Export Handler Example
// Using the export endpoint programmatically
import { GET as exportSessionHandler } from '@/app/api/sessions/[id]/export/route';
const request = new Request(
'http://localhost/api/sessions/abcd1234/export?inline=1'
);
const response = await exportSessionHandler(
request,
{ params: Promise.resolve({ id: 'abcd1234' }) }
);
const safeHtml = await response.text();
// safeHtml contains iterative tree helpers, safe for arbitrary tree depth
Summary
resolveSessionPathlocates the.jsonlsession file inlib/session-reader.ts.- HTML generation prefers the
pi-coding-agentCLI, falling back to theexportFromFilemodule function. patchExportHtmltransforms three recursive helpers (sortChildren,mapNodes,markActive) into iterative versions using explicit stacks and two-stack post-order traversal.- Line ending normalization ensures reliable pattern matching before replacement.
- Strict validation throws on unexpected match counts to prevent silent failures.
Frequently Asked Questions
What causes stack overflow in the original recursive helpers?
The original helpers use JavaScript function recursion to traverse tree structures. On session trees with deeply linear chains (thousands of nested entries), the call stack exceeds V8's ~10,000 frame limit, causing RangeError: Maximum call stack size exceeded. The iterative replacements bound memory usage to heap-allocated arrays instead.
Why use a two-stack approach for post-order traversal?
Post-order processing requires children to complete before their parent. A single stack cannot directly achieve this without recursion. The two-stack method first builds a "reverse post-order" sequence (root, right, left), then reversing via a second stack yields the required left, right, root order—mathematically equivalent but iteratively implementable.
Can the CLI and module generate different HTML output?
No. Both the pi-coding-agent CLI and the exportFromFile module reference the same template code in node_modules/@earendil-works/pi-coding-agent/dist/core/export-html/index.js. The CLI is a thin wrapper for environments where Node.js module resolution is inconvenient; the fallback import executes identical rendering logic directly.
What happens if a helper pattern doesn't match?
The patch throws an explicit error (L34-L36) indicating which helper failed to patch. This defensive design prevents serving HTML with vulnerable recursive implementations when the template structure changes unexpectedly. The error message includes the expected pattern for debugging template version mismatches.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →