diff --git a/.changeset/session-html-export.md b/.changeset/session-html-export.md
new file mode 100644
index 0000000..75c234c
--- /dev/null
+++ b/.changeset/session-html-export.md
@@ -0,0 +1,5 @@
+---
+"sideshow": minor
+---
+
+Add session HTML export. `GET /api/sessions/:id/export` and the new `sideshow export` command render a whole session into one self-contained, shareable HTML file styled like the viewer's card column. Every surface that becomes HTML is embedded as a sandboxed `srcdoc` iframe using the exact `/s/:id` renderers, so the isolation rule holds inside the saved file; image surfaces are inlined as data URIs (allowlisted raster types only, capped at 32 MB of image bytes per export — further images degrade to a note), and sessions over 4 MB of surface text are rejected with a 413. Supports `?theme=`/`?mode=` pinning and `?download=1` for an attachment download.
diff --git a/bin/sideshow.js b/bin/sideshow.js
index 55083da..cd9e326 100755
--- a/bin/sideshow.js
+++ b/bin/sideshow.js
@@ -129,6 +129,9 @@ usage:
--surface is a deprecated alias)
--author defaults to agent name
sideshow list [--session |--all] list posts
+ sideshow export [--session ] [--out ] [--theme ] [--mode ]
+ export a session as one self-contained HTML file
+ (default: auto session, never created; stdout)
sideshow show show a single post (surfaces, indexes, ids, version, history)
sideshow sessions list sessions
sideshow demo seed two example sessions to explore the viewer
@@ -155,13 +158,15 @@ function fail(msg) {
process.exit(1);
}
-async function api(path, init = {}) {
+// Raw fetch with the CLI's standard failure handling — unreachable server and
+// non-2xx (JSON error body) both exit via fail(). Callers own the body read:
+// api() parses JSON; export reads HTML text; uploads send raw bytes.
+async function rawFetch(path, init = {}) {
let res;
try {
res = await fetch(`${BASE}${path}`, {
...init,
headers: {
- "content-type": "application/json",
...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
...init.headers,
},
@@ -169,9 +174,19 @@ async function api(path, init = {}) {
} catch {
fail(`server not reachable at ${BASE} — start it with: sideshow serve`);
}
- const body = await res.json().catch(() => ({}));
- if (!res.ok) fail(body.error ?? `${res.status} ${res.statusText}`);
- return body;
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ fail(body.error ?? `${res.status} ${res.statusText}`);
+ }
+ return res;
+}
+
+async function api(path, init = {}) {
+ const res = await rawFetch(path, {
+ ...init,
+ headers: { "content-type": "application/json", ...init.headers },
+ });
+ return res.json().catch(() => ({}));
}
// Like api(), but throws instead of exiting the process — for callers that must
@@ -452,22 +467,12 @@ async function uploadFile(file, { session, kind } = {}) {
params.set("filename", file.split(/[\\/]/).pop() ?? "upload");
if (session) params.set("session", session);
if (kind) params.set("kind", kind);
- let res;
- try {
- res = await fetch(`${BASE}/api/assets?${params}`, {
- method: "POST",
- headers: {
- "content-type": contentTypeFor(file),
- ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
- },
- body: bytes,
- });
- } catch {
- fail(`server not reachable at ${BASE} — start it with: sideshow serve`);
- }
- const body = await res.json().catch(() => ({}));
- if (!res.ok) fail(body.error ?? `${res.status} ${res.statusText}`);
- return body;
+ const res = await rawFetch(`/api/assets?${params}`, {
+ method: "POST",
+ headers: { "content-type": contentTypeFor(file) },
+ body: bytes,
+ });
+ return res.json().catch(() => ({}));
}
// Normalize repeated/comma-joined --kit flags into a deduped id list (or
@@ -1541,6 +1546,37 @@ const commands = {
out(await api(`/api/posts/${id}`));
},
+ // Export a whole session as one self-contained HTML file (every surface
+ // embedded as a sandboxed srcdoc iframe). resolveSession WITHOUT create — an
+ // export must never mint a session — and rawFetch (not api(), which
+ // JSON-parses) since the body is HTML.
+ async export() {
+ const { values: flags } = parse({
+ options: {
+ session: { type: "string" },
+ out: { type: "string" },
+ theme: { type: "string" },
+ mode: { type: "string" },
+ },
+ });
+ const session = await resolveSession(flags);
+ if (!session) {
+ fail("no session to export — pass --session (export never creates a session)");
+ }
+ const q = new URLSearchParams();
+ if (flags.theme) q.set("theme", flags.theme);
+ if (flags.mode) q.set("mode", flags.mode);
+ const qs = q.toString();
+ const res = await rawFetch(`/api/sessions/${session}/export${qs ? `?${qs}` : ""}`);
+ const html = await res.text();
+ if (flags.out) {
+ writeFileSync(flags.out, html);
+ console.log(`Wrote ${flags.out} (${html.length} bytes)`);
+ } else {
+ process.stdout.write(html);
+ }
+ },
+
async sessions() {
parse();
out(await api("/api/sessions"));
diff --git a/e2e/export.spec.ts b/e2e/export.spec.ts
new file mode 100644
index 0000000..ec28deb
--- /dev/null
+++ b/e2e/export.spec.ts
@@ -0,0 +1,258 @@
+import { type Page } from "@playwright/test";
+import { writeFile } from "node:fs/promises";
+import { pathToFileURL } from "node:url";
+import { expect, publishParts, test, TINY_PNG_B64, upload } from "./fixtures.ts";
+
+// Export tests open the response from disk instead of exercising only the HTTP
+// preview. That is the user-visible portability boundary: the trusted shell and
+// every srcdoc surface must still render when the top-level document is file://.
+async function openSavedExport(page: Page, exportUrl: string, filePath: string): Promise {
+ const response = await fetch(exportUrl);
+ expect(response.status).toBe(200);
+ await writeFile(filePath, await response.text(), "utf8");
+ await page.goto(pathToFileURL(filePath).href);
+ await expect(page.locator(".ss-shell")).toBeVisible();
+}
+
+const MD = [
+ "## Exported plan",
+ "",
+ "Prose that wraps across several lines so the rendered markdown is clearly",
+ "taller than the 24px minimum frame height once the bridge measures it.",
+ "",
+ "- one",
+ "- two",
+ "- three",
+].join("\n");
+
+// A probe that tries to read the shell document across the sandbox boundary.
+// The frame is sandboxed WITHOUT allow-same-origin, so `parent.document` throws
+// a SecurityError — the frame is at an opaque origin. It self-reports into its
+// own DOM (the only channel it has).
+const PROBE = `
running
+`;
+
+test("a saved export lays out html and markdown while keeping frames isolated", async ({
+ page,
+ server,
+}, testInfo) => {
+ const consoleErrors: string[] = [];
+ page.on("console", (msg) => {
+ if (msg.type() === "error") consoleErrors.push(msg.text());
+ });
+
+ const first = await publishParts(server.url, {
+ title: "Probe card",
+ agent: "e2e",
+ parts: [{ kind: "html", html: PROBE }],
+ });
+ await publishParts(server.url, {
+ title: "Markdown card",
+ agent: "e2e",
+ session: first.sessionId,
+ parts: [{ kind: "markdown", markdown: MD }],
+ });
+
+ await openSavedExport(
+ page,
+ `${server.url}/api/sessions/${first.sessionId}/export`,
+ testInfo.outputPath("html-markdown-export.html"),
+ );
+
+ // Two cards, chronological: the probe first, the markdown second.
+ await expect(page.locator(".ss-card")).toHaveCount(2);
+ await expect(page.locator(".ss-card h2")).toHaveText(["Probe card", "Markdown card"]);
+
+ // Every embedded surface is sandboxed with no allow-same-origin.
+ await expect(page.locator("iframe.ss-frame:not(.mdframe)")).toHaveAttribute(
+ "sandbox",
+ "allow-scripts",
+ );
+ await expect(page.locator("iframe.mdframe")).toHaveAttribute("sandbox", "allow-scripts");
+
+ const markdown = page.frameLocator("iframe.mdframe");
+ await expect(markdown.locator("h2")).toHaveText("Exported plan");
+ await expect(markdown.locator("li")).toHaveCount(3);
+
+ // The markdown frame's bridge reports height, so it grows past the minimum.
+ await expect
+ .poll(async () => (await page.locator("iframe.mdframe").boundingBox())?.height ?? 0, {
+ timeout: 10_000,
+ })
+ .toBeGreaterThan(60);
+
+ // The html surface's script cannot reach the shell — opaque origin holds.
+ const probe = page.frameLocator("iframe.ss-frame:not(.mdframe)").locator("#r");
+ await expect(probe).toHaveText("blocked", { timeout: 10_000 });
+ await expect(probe).not.toContainText("LEAKED");
+
+ expect(consoleErrors.filter((error) => /content security policy|refused/i.test(error))).toEqual(
+ [],
+ );
+});
+
+test("a saved export renders diff, terminal, and code surfaces", async ({
+ page,
+ server,
+}, testInfo) => {
+ const published = await publishParts(server.url, {
+ title: "Rich renderer export",
+ agent: "e2e",
+ parts: [
+ {
+ kind: "diff",
+ files: [
+ {
+ filename: "greet.ts",
+ before: "export const greet = () => 'hi';\n",
+ after: "export const greet = (name: string) => `hi ${name}`;\n",
+ },
+ ],
+ },
+ {
+ kind: "terminal",
+ title: "export check",
+ text: "\u001b[32mPASS\u001b[0m exported terminal\n",
+ },
+ {
+ kind: "code",
+ title: "exported.ts",
+ language: "ts",
+ lineStart: 40,
+ code: "export const exported = true;\nconsole.log(exported);",
+ },
+ ],
+ });
+
+ await openSavedExport(
+ page,
+ `${server.url}/api/sessions/${published.sessionId}/export?theme=gruvbox&mode=dark`,
+ testInfo.outputPath("rich-surfaces-export.html"),
+ );
+
+ const frameSelectors = ["iframe.diffframe", "iframe.termframe", "iframe.codeframe"];
+ for (const selector of frameSelectors) {
+ await expect(page.locator(selector)).toHaveAttribute("sandbox", "allow-scripts");
+ }
+
+ const diff = page.frameLocator("iframe.diffframe");
+ await expect(diff.locator("diffs-container")).toBeVisible();
+ await expect(diff.locator("body")).toContainText("greet.ts");
+ await expect(diff.locator("body")).toContainText("name");
+
+ const terminal = page.frameLocator("iframe.termframe");
+ await expect(terminal.locator(".term-title")).toHaveText("export check");
+ await expect(terminal.locator(".term-body span[style*='color']")).toContainText("PASS");
+ await expect(terminal.locator(".term-body")).toContainText("");
+ await expect(terminal.locator("img")).toHaveCount(0);
+
+ const code = page.frameLocator("iframe.codeframe");
+ await expect(code.locator(".code-filename")).toHaveText("exported.ts:40-41");
+ await expect(code.locator(".code-lang")).toHaveText("ts");
+ await expect(code.locator("pre.shiki, pre.plain")).toContainText("exported = true");
+});
+
+test("a saved export inlines image and JSON surfaces as inert data", async ({
+ page,
+ server,
+}, testInfo) => {
+ const asset = await upload(server.url, {
+ data: TINY_PNG_B64,
+ contentType: "image/png",
+ filename: "export-pixel.png",
+ kind: "image",
+ });
+ const published = await publishParts(server.url, {
+ title: "Native data export",
+ agent: "e2e",
+ session: asset.sessionId,
+ parts: [
+ {
+ kind: "image",
+ assetId: asset.id,
+ alt: "one transparent pixel",
+ caption: "inlined image surface",
+ },
+ {
+ kind: "json",
+ data: { status: "exported", danger: "" },
+ },
+ {
+ kind: "trace",
+ title: "Experimental trace",
+ steps: [{ kind: "tool", label: "kept outside the product export" }],
+ },
+ ],
+ });
+
+ const commentResponse = await fetch(`${server.url}/api/comments`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ surface: published.id,
+ text: "Looks in the saved file",
+ author: "user",
+ }),
+ });
+ expect(commentResponse.status).toBe(201);
+
+ await openSavedExport(
+ page,
+ `${server.url}/api/sessions/${published.sessionId}/export`,
+ testInfo.outputPath("native-data-export.html"),
+ );
+
+ const image = page.locator(".ss-image img");
+ await expect(image).toHaveAttribute("src", /^data:image\/png;base64,/);
+ await expect(image).toHaveAttribute("alt", "one transparent pixel");
+ await expect(page.locator(".ss-caption")).toHaveText("inlined image surface");
+ await expect
+ .poll(() => image.evaluate((element) => element.complete && element.naturalWidth))
+ .toBe(1);
+
+ const json = page.locator(".ss-json");
+ await expect(json).toContainText('"status": "exported"');
+ await expect(json).toContainText("");
+ await expect(json.locator("script")).toHaveCount(0);
+ await expect(page.locator(".ss-thread")).toContainText("Looks in the saved file");
+ await expect(page.locator(".ss-note")).toHaveText("Trace surface omitted from export.");
+});
+
+test("a saved export runs the Mermaid loader inside its sandbox", async ({
+ page,
+ server,
+}, testInfo) => {
+ const diagram = "flowchart LR\n Agent[Agent] --> Export[Saved HTML]";
+ await page.route("https://esm.sh/**", (route) =>
+ route.fulfill({
+ contentType: "application/javascript",
+ body:
+ "export default { initialize(){}, render(id, src){ " +
+ "return Promise.resolve({ svg: '' }); } };",
+ }),
+ );
+ const published = await publishParts(server.url, {
+ title: "Mermaid export",
+ agent: "e2e",
+ parts: [{ kind: "mermaid", mermaid: diagram }],
+ });
+
+ await openSavedExport(
+ page,
+ `${server.url}/api/sessions/${published.sessionId}/export`,
+ testInfo.outputPath("mermaid-export.html"),
+ );
+
+ await expect(page.locator("iframe.mermaidframe")).toHaveAttribute("sandbox", "allow-scripts");
+ const mermaid = page.frameLocator("iframe.mermaidframe");
+ await expect(mermaid.locator("svg")).toBeVisible();
+ await expect(mermaid.locator("body")).toContainText("Agent");
+ await expect(mermaid.locator("body")).toContainText("Saved HTML");
+});
diff --git a/guide/AGENT_HOWTO.md b/guide/AGENT_HOWTO.md
index 9fe8e08..55576c9 100644
--- a/guide/AGENT_HOWTO.md
+++ b/guide/AGENT_HOWTO.md
@@ -81,6 +81,17 @@ Feedback reaches you four ways — prefer them in this order:
Comments attach to a post (`postId`); behavior is otherwise unchanged. When comments arrive, acknowledge briefly with `sideshow comment "..." --post ` when useful; do substantial changes as post updates, then re-arm the watcher or continue checkpoint-draining.
+## Exporting a session
+
+Export a whole session as one self-contained HTML file the user can save and share — every post rendered into the viewer's card column, each surface still sandboxed:
+
+```sh
+sideshow export --session --out session.html # or omit --out for stdout
+curl -s "${SIDESHOW_URL:-http://localhost:8228}/api/sessions//export" > session.html
+```
+
+Add `?download=1` to the URL for an attachment download, `?theme=` / `?mode=light|dark` to pin the look (default follows the reader's OS). The file opens straight from disk with no server; three things still need the network when present — `/a/:id` assets referenced _inside_ agent-authored html/markdown (image _surfaces_ are inlined, up to 32 MB of image bytes per export; further images degrade to a note), and Mermaid diagrams (CDN). A session over 4 MB of surface text is rejected with a 413 instead of producing an unloadable file. There is no MCP tool for this — megabytes of HTML don't belong in a tool result, so MCP agents use the HTTP route.
+
## Remote surfaces
A deployed sideshow needs `SIDESHOW_URL` and `SIDESHOW_TOKEN` set in your environment; the CLI and MCP server send the token automatically. For raw curl, add `-H "Authorization: Bearer $SIDESHOW_TOKEN"`.
diff --git a/server/app.ts b/server/app.ts
index 0dae938..fd3a87d 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -15,24 +15,17 @@ import {
import { EventBus, type FeedEvent } from "./events.ts";
import { kitSummaries } from "./kits.ts";
import { registerMcp } from "./mcpHttp.ts";
-import {
- escapeHtml,
- renderHtmlPage,
- renderMermaidPage,
- renderSandboxedPart,
-} from "./surfacePage.ts";
-import { renderCode, renderDiff, renderMarkdown, renderTerminal } from "./richRender.ts";
-import { DEFAULT_THEME_ID, themeById, themeOptions } from "./themes.ts";
+import { escapeHtml, renderSurfaceDocument } from "./surfacePage.ts";
+import { MAX_EXPORT_SURFACE_BYTES, renderSessionExport } from "./exportPage.ts";
+import { DEFAULT_THEME_ID, type Mode, themeOptions } from "./themes.ts";
import {
type Asset,
type AssetKind,
- type CodeSurface,
type Comment,
type CommentAnchor,
- type DiffSurface,
htmlSurface,
isSandboxedSurfaceKind,
- type MarkdownSurface,
+ INLINE_IMAGE_TYPES,
MAX_ASSET_BYTES,
surfacesByteLength,
type Session,
@@ -40,7 +33,6 @@ import {
type Post,
type Surface,
SURFACE_CONTENT_FIELDS,
- type TerminalSurface,
type TraceStep,
} from "./types.ts";
import { validateSurfaces } from "./postSurfaces.ts";
@@ -89,18 +81,12 @@ const MAX_TITLE = 500;
// exercise the cap cheaply.
const DEFAULT_MAX_HOLD_CONNECTIONS = 32;
-// Asset serving policy: only raster images are served inline; everything else
-// (incl. svg, json, text, the octet-stream catch-all) is an attachment, so a
-// top-level open of /a/:id can never execute an uploaded document as a live
-// same-origin script. /fetch ignore Content-Disposition, so embedding and
-// inline trace rendering keep working regardless.
-const INLINE_IMAGE_TYPES = new Set([
- "image/png",
- "image/jpeg",
- "image/gif",
- "image/webp",
- "image/avif",
-]);
+// Asset serving policy: only raster images (INLINE_IMAGE_TYPES, types.ts) are
+// served inline; everything else (incl. svg, json, text, the octet-stream
+// catch-all) is an attachment, so a top-level open of /a/:id can never execute
+// an uploaded document as a live same-origin script. /fetch ignore
+// Content-Disposition, so embedding and inline trace rendering keep working
+// regardless.
const ATTACH_SAFE_TYPES = new Set([
"image/svg+xml",
"application/json",
@@ -1077,6 +1063,81 @@ export function createApp({
app.get("/api/sessions/:id/posts", listSessionPosts);
app.get("/api/sessions/:id/snippets", listSessionPosts); // legacy alias
+ // Resolve the theme/scheme a rendered document should bake in: an explicit
+ // ?theme= (the viewer keys iframe srcs by it so a switch reloads the frame)
+ // wins over the persisted workspace theme. ?mode= must be an explicit scheme
+ // — the viewer passes the one it resolved so the frame can't diverge from the
+ // chrome; absent/invalid → follow the OS.
+ const resolveThemeMode = async (c: any): Promise<{ themeId: string; mode?: Mode }> => {
+ const themeId = c.req.query("theme") ?? (await store.getSetting("theme")) ?? DEFAULT_THEME_ID;
+ const modeParam = c.req.query("mode");
+ return {
+ themeId,
+ mode: modeParam === "light" || modeParam === "dark" ? modeParam : undefined,
+ };
+ };
+
+ // Export a whole session as one self-contained, shareable HTML file. Every
+ // surface is rendered with the exact /s/:id renderers and embedded as a
+ // sandboxed srcdoc iframe (see exportPage.ts) so the core isolation rule holds
+ // inside the saved file. Under /api/sessions/, so auth + publicRead: "session"
+ // gating (isPublicReadAllowed) come free. No load-restricting CSP on the
+ // response: srcdoc children inherit the parent's CSP, so a policy here would
+ // break the CDN loads html/mermaid frames need; frame-ancestors is a header
+ // (doesn't restrict subresource loads, and is meaningless in the saved file).
+ app.get("/api/sessions/:id/export", async (c) => {
+ const session = await store.getSession(c.req.param("id"));
+ if (!session) return c.json({ error: "session not found" }, 404);
+ const posts = await store.listPosts(session.id); // createdAt ASC → chronological
+ // Reject oversized sessions before any rendering: per-post text is capped
+ // (MAX_SURFACE_BYTES) but post count isn't, so without an aggregate bound
+ // one export could be made to render unbounded input — unauthenticated on a
+ // publicRead workspace. The check is a cheap string-length sum.
+ let inputBytes = 0;
+ for (const post of posts) inputBytes += surfacesByteLength(post.surfaces);
+ if (inputBytes > MAX_EXPORT_SURFACE_BYTES) {
+ return c.json(
+ { error: `session too large to export (over ${MAX_EXPORT_SURFACE_BYTES} surface bytes)` },
+ 413,
+ );
+ }
+ // One comments query for the whole session, grouped by post — per-post
+ // listComments calls were N+1 (and JsonFileStore rescans every comment per
+ // call, so quadratic). Session-level comments (postId null) are excluded
+ // in v1 (a documented no-op).
+ const byPost = new Map();
+ for (const comment of await store.listComments({ sessionId: session.id })) {
+ if (!comment.postId) continue;
+ const list = byPost.get(comment.postId);
+ if (list) list.push(comment);
+ else byPost.set(comment.postId, [comment]);
+ }
+ const items = posts.map((post) => ({ post, comments: byPost.get(post.id) ?? [] }));
+ const { themeId, mode } = await resolveThemeMode(c);
+ const origin = new URL(c.req.url).origin;
+ const html = await renderSessionExport({
+ session,
+ items,
+ origin,
+ themeId,
+ mode,
+ generatedAt: new Date().toISOString(),
+ getAsset: (id) => store.getAsset(id),
+ });
+ c.header("X-Content-Type-Options", "nosniff");
+ c.header("Content-Security-Policy", "frame-ancestors 'self'");
+ c.header("Cache-Control", "private, no-cache");
+ if (c.req.query("download") === "1") {
+ const slug =
+ (session.title || session.id)
+ .toLowerCase()
+ .replace(/[^a-z0-9-]+/g, "-")
+ .replace(/^-+|-+$/g, "") || session.id;
+ c.header("Content-Disposition", `attachment; filename="sideshow-${slug}.html"`);
+ }
+ return c.html(html);
+ });
+
// --- session trace ---
app.get("/api/sessions/:id/trace", async (c) => {
@@ -1520,15 +1581,7 @@ export function createApp({
// allow-scripts so the bridge still runs, but no allow-same-origin, so agent
// code can never touch the workspace origin. Mirrors the iframe's sandbox flags.
c.header("Content-Security-Policy", "sandbox allow-scripts");
- // Theme: an explicit ?theme= (the viewer keys iframe srcs by it so a switch
- // reloads the frame) wins; otherwise the persisted workspace theme; else default.
- const themeId = c.req.query("theme") ?? (await store.getSetting("theme")) ?? DEFAULT_THEME_ID;
- const theme = themeById(themeId);
- // Scheme: the viewer passes the light/dark mode it resolved so the iframe is
- // pinned to it rather than re-deriving from the OS (which can diverge from
- // the chrome across the frame boundary). Absent/invalid → follow the OS.
- const modeParam = c.req.query("mode");
- const mode = modeParam === "light" || modeParam === "dark" ? modeParam : undefined;
+ const { themeId, mode } = await resolveThemeMode(c);
const origin = new URL(c.req.url).origin;
// Cache the finished document. The key pins everything the output depends
@@ -1540,35 +1593,9 @@ export function createApp({
if (immutable) c.header("Cache-Control", "public, max-age=31536000, immutable");
else c.header("Cache-Control", "private, no-cache");
- const doc = await cachedRender(cacheKey, async () => {
- if (surface.kind === "html") {
- return renderHtmlPage({
- title,
- html: surface.html,
- origin,
- theme,
- mode,
- kits: surface.kits,
- });
- }
- if (surface.kind === "mermaid") {
- return renderMermaidPage({ mermaid: surface.mermaid, origin, theme, mode });
- }
- const rendered =
- surface.kind === "markdown"
- ? await renderMarkdown(surface as MarkdownSurface, { theme: themeId, mode })
- : surface.kind === "code"
- ? await renderCode(surface as CodeSurface, { theme: themeId, mode })
- : surface.kind === "terminal"
- ? renderTerminal(surface as TerminalSurface)
- : await renderDiff(surface as DiffSurface, { theme: themeId, mode }).catch((e) => ({
- body: `
`,
- css: `.rich-error{color:var(--danger);font:13px/1.5 ui-monospace,monospace;padding:8px 12px;}`,
- }));
- return renderSandboxedPart({ body: rendered.body, css: rendered.css, origin, theme, mode });
- });
+ const doc = await cachedRender(cacheKey, () =>
+ renderSurfaceDocument(surface, { title, origin, themeId, mode }),
+ );
return c.html(doc);
};
app.get("/s/:id", renderPostPage); // legacy alias
diff --git a/server/base64.ts b/server/base64.ts
index fee7755..6a84944 100644
--- a/server/base64.ts
+++ b/server/base64.ts
@@ -12,3 +12,17 @@ export function decodeBase64(b64: string): Uint8Array {
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
+
+// bytes -> base64, runtime-agnostic (btoa is a global in Node and Workers, same
+// as the btoa server/types.ts already relies on for id generation). Chunked
+// String.fromCharCode: spreading a whole multi-megabyte asset in one call blows
+// the argument-count limit (RangeError), so build the binary string in 32 KiB
+// slices. No Buffer — that's Node-only and would break the Worker DO.
+export function encodeBase64(bytes: Uint8Array): string {
+ const CHUNK = 0x8000;
+ let bin = "";
+ for (let i = 0; i < bytes.length; i += CHUNK) {
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
+ }
+ return btoa(bin);
+}
diff --git a/server/exportPage.ts b/server/exportPage.ts
new file mode 100644
index 0000000..e1541cc
--- /dev/null
+++ b/server/exportPage.ts
@@ -0,0 +1,365 @@
+// Session HTML export — render a whole session into ONE self-contained,
+// shareable HTML document. Runtime-agnostic (no `node:` imports, no DOM
+// globals) so the Worker DO serves it too; enforced via the app.ts import chain
+// under tsconfig.workers.json.
+//
+// The core isolation rule holds INSIDE the exported file, opened from disk on a
+// teammate's machine where no server serves /s/:id: every surface that becomes
+// HTML is rendered with the EXACT same sandboxed-document dispatch /s/:id
+// serves (renderSurfaceDocument), then embedded as
+// `