Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/session-html-export.md
Original file line number Diff line number Diff line change
@@ -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.
78 changes: 57 additions & 21 deletions bin/sideshow.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ usage:
--surface is a deprecated alias)
--author <name> defaults to agent name
sideshow list [--session <id>|--all] list posts
sideshow export [--session <id>] [--out <file>] [--theme <id>] [--mode <m>]
export a session as one self-contained HTML file
(default: auto session, never created; stdout)
sideshow show <id> show a single post (surfaces, indexes, ids, version, history)
sideshow sessions list sessions
sideshow demo seed two example sessions to explore the viewer
Expand All @@ -155,23 +158,35 @@ 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,
},
});
} 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <id> (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"));
Expand Down
85 changes: 85 additions & 0 deletions e2e/export.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `<div id="r">running</div>
<script>
try {
var leaked = parent.document && parent.document.querySelector('.ss-card');
document.getElementById('r').textContent = leaked ? 'LEAKED' : 'blocked';
} catch (e) {
document.getElementById('r').textContent = 'blocked';
}
</script>`;

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(
[],
);
});
11 changes: 11 additions & 0 deletions guide/AGENT_HOWTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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 <sessionId> --out session.html # or omit --out for stdout
curl -s "${SIDESHOW_URL:-http://localhost:8228}/api/sessions/<sessionId>/export" > session.html
```

Add `?download=1` to the URL for an attachment download, `?theme=<id>` / `?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"`.
Loading
Loading