How pi-web Exports a Session to HTML: A Deep Dive into the Server-Side Implementation
Pi-web converts Pi session files (.jsonl) to self-contained HTML documents through a server-side API route that uses either the Pi CLI or a fallback dynamic module, with defensive patching to prevent stack overflows on deep session trees.
The export session to HTML feature in pi-web transforms raw session data into shareable, standalone web pages. This article explores the complete flow—from file resolution to HTTP response—based on the actual source code in the agegr/pi-web repository.
Session File Resolution
The export process begins by locating the session data on disk. The GET handler in app/api/sessions/[id]/export/route.ts receives a session ID from the URL parameter and calls resolveSessionPath(id) from lib/session-reader.ts (lines 35-42).
This function maps the session ID to an absolute path pointing to the .jsonl file containing the session's serialized state. The resolver handles path validation and ensures the file exists before proceeding.
HTML Generation: Two Export Paths
Once the session file path is determined, exportSession() generates the raw HTML through one of two strategies:
Primary: Pi CLI Execution
The code first attempts to locate cli.js via getPiCliPath() (lines 44-78 in route.ts). When found, it spawns the CLI using execFileAsync with these parameters:
PI_OFFLINE=1 PI_SKIP_VERSION_CHECK=1 node cli.js --export <session> <output>
The environment flags ensure a pure-local export without network calls or version checks. A 30-second hard timeout prevents hanging processes.
Fallback: Dynamic Module Import
If the CLI cannot be located, the system dynamically imports @earendil-works/pi-coding-agent/dist/core/export-html/index.js and invokes exportFromFile. This provides resilience when the CLI isn't available in the deployment environment.
Both paths write output to os.tmpdir()/pi-web-export before the next stage begins.
Patching Recursive Functions for Deep Trees
Raw HTML from Pi contains three recursive helper functions that fail on large sessions:
sortChildren— recursive tree sortingmapNodes— recursive node mappingmarkActive— recursive active-state marking
The patchExportHtml() function (lines 81-115) transforms each into iterative equivalents:
// Original pattern (recursive, causes stack overflow)
function mapNodes(node, fn) {
fn(node);
node.children?.forEach(c => mapNodes(c, fn));
}
// Patched replacement (explicit stack, DFS)
function mapNodes(node, fn) {
const stack = [node];
while (stack.length) {
const n = stack.pop();
fn(n);
if (n.children) stack.push(...n.children.reverse());
}
}
For markActive, the patch uses a two-stack post-order walk to preserve the original traversal semantics. Each replacement validates that exactly one match exists—throwing an error otherwise to detect template drift.
Secure HTTP Response Delivery
The final stage constructs a properly-typed HTTP response with security headers and flexible disposition:
Content-Disposition Handling
getContentDisposition() (lines 38-42) respects the inline=1 query flag:
| Query Parameter | Behavior |
|---|---|
inline=1 |
Display in browser (inline) |
absent or 0 |
Force download (attachment) |
The function safely encodes non-ASCII filenames per RFC 5987, with ASCII fallback when needed.
Security Headers
The response includes protective headers (lines 64-75):
'Content-Security-Policy': "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
'X-Frame-Options': 'DENY'
'X-Content-Type-Options': 'nosniff'
These prevent clickjacking via framing and block MIME-type sniffing attacks.
Complete API Request Examples
Inline View (Browser)
fetch('/api/sessions/3f5b2c4a-9e1d-4a6e-8c2f/export?inline=1')
.then(r => r.text())
.then(html => {
const w = window.open();
w.document.write(html);
});
Download via cURL
curl -L -O \
"http://127.0.0.1:30141/api/sessions/3f5b2c4a-9e1d-4a6e-8c2f/export"
Programmatic Node.js (Attachment)
const https = require('https');
https.get('http://127.0.0.1:30141/api/sessions/abc123/export', res => {
const chunks = [];
res.on('data', d => chunks.push(d));
res.on('end', () => {
const html = Buffer.concat(chunks);
require('fs').writeFileSync('session.html', html);
});
});
Key Implementation Files
| Component | Location |
|---|---|
| Export route handler | app/api/sessions/[id]/export/route.ts |
| Session path resolver | lib/session-reader.ts (lines 35-42) |
| CLI discovery logic | route.ts lines 44-78 |
| HTML patching (iterative recursion) | route.ts lines 81-115 |
| Content-Disposition builder | route.ts lines 38-42 |
| External export core (dynamic import) | @earendil-works/pi-coding-agent/dist/core/export-html/index.js |
Summary
- File resolution uses
resolveSessionPath()to locate.jsonlsession files - Dual export strategy tries Pi CLI first, falls back to dynamic module import
- Stack-overflow protection replaces recursive tree functions with iterative DFS implementations
- Secure delivery includes RFC 5987 filename encoding and multiple security headers
- Flexible disposition supports both inline viewing and forced download via query parameter
Frequently Asked Questions
What causes stack overflow in the original Pi HTML export?
The raw HTML contains recursive implementations of sortChildren, mapNodes, and markActive. Sessions with over 5,000 linear entries exceed JavaScript's call stack limit. The patchExportHtml() function replaces these with explicit-stack iterative versions.
Why does pi-web prefer the Pi CLI over the dynamic module?
The CLI path provides better isolation and aligns with Pi's official export tooling. The dynamic import exists as a fallback when the CLI isn't present in the deployment environment, ensuring the feature works across different installation scenarios.
How does the inline query parameter affect behavior?
When inline=1 is present, the Content-Disposition header uses inline mode, allowing browsers to display the HTML directly. Without this flag, the header uses attachment mode, triggering a file download. The filename is safely encoded for non-ASCII characters per RFC 5987.
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 →