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..40c51ed
--- /dev/null
+++ b/e2e/export.spec.ts
@@ -0,0 +1,85 @@
+import { expect, publishParts, test } from "./fixtures.ts";
+
+// The session export is a trusted shell document that embeds every surface as a
+// sandboxed srcdoc iframe. This spec proves the healthy path end-to-end on real
+// Chromium and WebKit: the srcdoc frames lay out and the bridge sizes them (the
+// reason the retry + staggered timers exist), and a script inside an html
+// surface still can't reach the shell — the opaque origin holds even without a
+// /s/:id URL. (The Chrome 149 field trial itself can't be reproduced in
+// Playwright per commit 5e3f292; the retry ships on the in-repo precedent.)
+
+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 session exports to a self-contained HTML shell whose frames lay out and stay isolated", async ({
+ page,
+ server,
+}) => {
+ 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 page.goto(`${server.url}/api/sessions/${first.sessionId}/export`);
+
+ // Two cards, chronological: the probe first, the markdown second.
+ await expect(page.locator(".ss-card")).toHaveCount(2);
+
+ // 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");
+
+ // (a) the markdown frame's bridge reports height, so it grows past the min.
+ await expect
+ .poll(async () => (await page.locator("iframe.mdframe").boundingBox())?.height ?? 0, {
+ timeout: 10_000,
+ })
+ .toBeGreaterThan(60);
+
+ // (b) 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");
+
+ // (c) no CSP / security console errors from the shell or its frames.
+ expect(consoleErrors.filter((e) => /content security policy|security|refused/i.test(e))).toEqual(
+ [],
+ );
+});
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/package-lock.json b/package-lock.json
index 17b98f7..e141fc5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3473,21 +3473,34 @@
"license": "MIT"
},
"node_modules/body-parser": {
- "version": "2.2.2",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
- "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
- "content-type": "^1.0.5",
+ "content-type": "^2.0.0",
"debug": "^4.4.3",
- "http-errors": "^2.0.0",
- "iconv-lite": "^0.7.0",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
- "qs": "^6.14.1",
- "raw-body": "^3.0.1",
- "type-is": "^2.0.1"
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
},
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
"engines": {
"node": ">=18"
},
@@ -4061,20 +4074,6 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
- "node_modules/esprima": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
- "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
- "dev": true,
- "license": "BSD-2-Clause",
- "bin": {
- "esparse": "bin/esparse.js",
- "esvalidate": "bin/esvalidate.js"
- },
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -4526,9 +4525,9 @@
}
},
"node_modules/hono": {
- "version": "4.12.25",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
- "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
+ "version": "4.12.31",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
+ "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -4745,9 +4744,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
- "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+ "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
@@ -5950,16 +5949,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/pify": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
- "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
@@ -6194,43 +6183,17 @@
}
},
"node_modules/read-yaml-file": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz",
- "integrity": "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-2.1.0.tgz",
+ "integrity": "sha512-UkRNRIwnhG+y7hpqnycCL/xbTk7+ia9VuVTC0S+zVbwd65DI9eUpRMfsWIGrCWxTU/mi+JW8cHQCrv+zfCbEPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "graceful-fs": "^4.1.5",
- "js-yaml": "^3.6.1",
- "pify": "^4.0.1",
- "strip-bom": "^3.0.0"
+ "js-yaml": "^4.0.0",
+ "strip-bom": "^4.0.0"
},
"engines": {
- "node": ">=6"
- }
- },
- "node_modules/read-yaml-file/node_modules/argparse": {
- "version": "1.0.10",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
- "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "sprintf-js": "~1.0.2"
- }
- },
- "node_modules/read-yaml-file/node_modules/js-yaml": {
- "version": "3.14.2",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
- "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "argparse": "^1.0.7",
- "esprima": "^4.0.0"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
+ "node": ">=10.13"
}
},
"node_modules/regex": {
@@ -6751,13 +6714,6 @@
"signal-exit": "^4.0.1"
}
},
- "node_modules/sprintf-js": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
- "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
- "dev": true,
- "license": "BSD-3-Clause"
- },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -6825,13 +6781,13 @@
}
},
"node_modules/strip-bom": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
- "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
+ "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=4"
+ "node": ">=8"
}
},
"node_modules/supports-color": {
diff --git a/package.json b/package.json
index 991083f..df9e44b 100644
--- a/package.json
+++ b/package.json
@@ -118,6 +118,7 @@
},
"overrides": {
"esbuild": "^0.28.1",
+ "read-yaml-file": "^2.1.0",
"undici": "^7.28.0",
"ws": "^8.21.0"
},
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
+// `