# How Session Export Generates Iterative Tree Helpers to Prevent Stack Overflow in pi-web

> Learn how pi-web session export prevents stack overflow by patching recursive tree traversal with iterative stack-based helpers for rendering deeply nested sessions.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: internals
- Published: 2026-08-18

---

**TLDR:** The pi-web session export endpoint patches recursive tree traversal functions in generated HTML with iterative stack-based equivalents, eliminating call-stack overflow when rendering sessions with thousands of nested entries.

Deep session trees pose a significant challenge for browser-based rendering. When users export sessions via `GET /api/sessions/[id]/export`, the **pi-coding-agent** package generates an HTML visualization containing recursive helper functions. These functions—`sortChildren`, `mapNodes`, and `markActive`—can overflow the JavaScript call stack on deeply nested sessions with 5000+ entries. The `app/api/sessions/[id]/export/route.ts` handler solves this by transforming the generated HTML before streaming it to the client, replacing recursive implementations with iterative alternatives that use explicit stacks.

## The Stack Overflow Problem in Session Export

The original [`template.js`](https://github.com/agegr/pi-web/blob/main/template.js) from **pi-coding-agent** inlines three recursive helpers into every exported HTML file:

| Helper | Purpose | Recursive Pattern |
|--------|---------|-------------------|
| `sortChildren` | Sorts each node's children by timestamp | Calls itself on every child after sorting |
| `mapNodes` | Builds a Map of node IDs to node objects | Recurses into each child to populate the map |
| `markActive` | Marks nodes on the active path | Traverses depth-first to propagate active flags |

For a linear session chain of 5000 entries, these functions recurse 5000 levels deep—far exceeding typical browser stack limits (~1000–10,000 frames depending on engine and available memory).

Since the template is bundled in an upstream dependency, the export route cannot modify the source directly. Instead, it applies a runtime **HTML patch** after generation but before the response streams to the client.

## How the Patch Mechanism Works

The `patchExportHtml` function in [`route.ts`](https://github.com/agegr/pi-web/blob/main/route.ts) performs four coordinated steps to transform the HTML safely and deterministically.

### Normalizing Cross-Platform Line Endings

The source file uses CRLF (`\r\n`) while the pi-coding-agent template uses LF (`\n`). A small helper ensures consistent matching:

```typescript
// Lines 25-28 in route.ts
function n(s: string) {
  return s.replace(/\r\n/g, '\n');
}

```

This normalization prevents pattern-matching failures across different operating systems.

### Enforcing Single-Replacement Safety

The `replaceRequired` utility guarantees each pattern appears exactly once, throwing an explicit error if assumptions fail:

```typescript
// Lines 30-38 in route.ts
function replaceRequired(html: string, search: string, replace: string) {
  const normalizedSearch = n(search);
  if (html.split(normalizedSearch).length !== 2) {
    throw new Error(`Expected exactly one occurrence of: ${search.slice(0, 50)}...`);
  }
  return html.replace(normalizedSearch, n(replace));
}

```

This defensive programming catches upstream template changes that would silently bypass the patch.

### Iterative Replacement: sortChildren

The recursive `sortChildren` function transforms into a depth-first stack loop. Children are pushed in reverse order so they pop in original order, preserving traversal sequence:

```typescript
// Replaces recursive sortChildren (lines 49-60)
function sortChildren(root) {
  const stack = [root];
  while (stack.length) {
    const node = stack.pop();
    node.children.sort((a, b) =>
      new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
    );
    for (let i = node.children.length - 1; i >= 0; i--) {
      stack.push(node.children[i]);
    }
  }
}

```

The `O(n)` stack space replaces `O(depth)` call-stack frames, bounded only by available heap memory rather than engine stack limits.

### Iterative Replacement: mapNodes

The `mapNodes` helper builds `treeNodeMap` without recursion using a reversed initial stack:

```typescript
// Replaces recursive mapNodes (lines 71-78)
const stack = [...tree].reverse();
while (stack.length) {
  const node = stack.pop();
  treeNodeMap.set(node.entry.id, node);
  for (let i = node.children.length - 1; i >= 0; i--) {
    stack.push(node.children[i]);
  }
}

```

Reversing the initial tree array ensures the first element processes first despite LIFO stack behavior.

### Iterative Replacement: markActive

The most complex transformation, `markActive` requires **post-order traversal**—processing children before parents to correctly propagate active flags upward. A two-stack technique achieves this without recursion:

```typescript
// Replaces recursive markActive (lines 92-108)
function markActive(root) {
  const stack1 = [root];  // Build reverse-order list
  const stack2 = [];
  
  // First pass: populate stack2 in reverse-preorder
  while (stack1.length) {
    const node = stack1.pop();
    stack2.push(node);
    for (const child of node.children) {
      stack1.push(child);
    }
  }
  
  // Second pass: process from leaves to root
  while (stack2.length) {
    const node = stack2.pop();
    let has = activePathIds.has(node.entry.id);
    for (const child of node.children) {
      if (containsActive.get(child)) has = true;
    }
    containsActive.set(node, has);
  }
}

```

**Why two stacks?** Post-order traversal (children before parents) is inherently stateful. The first stack captures nodes in root-to-leaves order; reversing via `stack2` yields leaves-to-root processing. This mirrors the recursive pattern of "process children, then compute parent's value" without the call-stack overhead.

## Complete Export Handler Flow

After patching, the route attaches proper HTTP semantics:

```typescript
// Simplified flow from route.ts
export async function GET(request: Request, { params }: { params: { id: string } }) {
  const html = await generateExportHtml(params.id);  // From pi-coding-agent
  const patchedHtml = patchExportHtml(html);          // Apply iterative replacements
  
  return new Response(patchedHtml, {
    headers: {
      'Content-Type': 'text/html; charset=utf-8',
      'Content-Disposition': `attachment; filename="session-${params.id}.html"`,
      'Cache-Control': 'private, max-age=300',
    },
  });
}

```

The response includes **Content-Disposition** for downloads, short-term **private caching**, and correct charset negotiation.

## Client-Side Usage

Consuming the endpoint from a browser environment:

```typescript
// Example client-side call
async function downloadSession(sessionId: string) {
  const resp = await fetch(`/api/sessions/${sessionId}/export?inline=0`);
  if (!resp.ok) throw new Error('Export failed');
  
  const blob = await resp.blob();
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = resp.headers.get('Content-Disposition')
    ?.match(/filename="([^"]+)"/)?.[1] 
    ?? 'session.html';
  a.click();
  
  URL.revokeObjectURL(url);
}

```

The `inline=0` query parameter forces attachment disposition; omitting it allows browser inline rendering for preview modes.

## Key Files and Responsibilities

| File | Role |
|------|------|
| `app/api/sessions/[id]/export/route.ts` | Main export handler; implements `patchExportHtml` with iterative replacements |
| `@earendil-works/pi-coding-agent` | Upstream package providing [`template.js`](https://github.com/agegr/pi-web/blob/main/template.js) with original recursive helpers |
| [`lib/session-reader.ts`](https://github.com/agegr/pi-web/blob/main/lib/session-reader.ts) | Resolves absolute session file paths via `resolveSessionPath` |
| [`lib/agent-client.ts`](https://github.com/agegr/pi-web/blob/main/lib/agent-client.ts) | Lower-level API client for other session interactions |

## Summary

- **Recursive helpers** in pi-coding-agent's template can overflow on deep sessions (5000+ entries)
- **HTML patching** at the export boundary transforms three recursive functions into iterative stack-based implementations
- **`sortChildren`** uses single-stack depth-first traversal with reversed child pushing
- **`mapNodes`** builds node maps with simple iteration over an explicit stack
- **`markActive`** requires two-stack post-order traversal to propagate active flags bottom-up
- **`replaceRequired`** enforces exact single-match semantics, preventing silent patch failures on upstream changes

## Frequently Asked Questions

### What causes stack overflow in session export?

Deeply nested session trees trigger excessive recursion in the original `sortChildren`, `mapNodes`, and `markActive` helpers from pi-coding-agent. Each nested level consumes a call-stack frame; browsers typically limit this to roughly 10,000 frames, which deep linear sessions can exhaust. The HTML patch replaces these with iterative loops using heap-allocated stacks instead.

### Why patch the HTML instead of fixing pi-coding-agent directly?

The pi-coding-agent package generates self-contained HTML files designed for standalone use without a server. Patching at the export boundary allows pi-web to address the stack issue without forking or modifying the upstream dependency, preserving compatibility and reducing maintenance burden.

### How does the two-stack technique in markActive work?

The first stack performs a preorder traversal (root, then children), pushing nodes onto a second stack as they're visited. Because children are pushed after their parent, they appear above the parent on stack2. Popping from stack2 thus yields children before their parent—exactly the post-order sequence needed to compute active flags from leaves upward.

### What happens if the upstream template changes?

The `replaceRequired` utility validates that each pattern occurs exactly once. If pi-coding-agent updates [`template.js`](https://github.com/agegr/pi-web/blob/main/template.js) such that a helper's implementation no longer matches the expected recursive pattern, the export route throws an explicit error rather than producing silently broken HTML. This forces immediate attention and patch updates.