feat(export): export a session as one self-contained HTML file - #227
feat(export): export a session as one self-contained HTML file#227tridha643 wants to merge 7 commits into
Conversation
GET /api/sessions/:id/export and the new `sideshow export` command render a whole session into a single shareable HTML file styled like the viewer's card column. Every surface that becomes HTML is embedded as a sandboxed srcdoc iframe via the same renderSurfaceDocument dispatch /s/:id uses, so the isolation rule holds inside the saved file. Image surfaces inline as data URIs (allowlisted raster types, 32 MB cap per export); sessions over 4 MB of surface text are rejected with a 413. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9914fea to
9af51ac
Compare
|
It looks like this duplicates the CSS - can we somehow import the CSS that already exists? Otherwise they will drift and this export won't look like the real thing. |
The export shell hand-copied the card container/head/title/meta rules, so the saved file would slowly stop looking like the real thing as the viewer's cards evolved. Move those four rules into server/cardChrome.ts — the same runtime-agnostic shared-string seam themes.ts already uses — and inject them in main.tsx, embed.tsx, and the export shell. The export now emits the viewer's own .card/.card-head/.card-title/.card-meta markup and keeps only the rules that adapt it to a static document. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Good catch — fixed in 44266f1. The card container/head/title/meta rules now live in The export also emits the viewer's own |
npm audit's high gate started failing on brace-expansion (GHSA-rgw5-rvv9-x895), fast-uri (GHSA-7p8r-x3mc-p8w7), and ip-address (three SSRF/trust-boundary advisories), plus undici's response-desync and cache-disclosure set reaching us through wrangler/miniflare. Take the in-range fixes and bump the undici override to ^7.29.0, the first 7.x past the vulnerable range, so wrangler stays on its current major. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
…erence Only successful inlines were memoized, so a missing or non-image asset was re-fetched for every image surface that referenced it — a full byte clone in JsonFileStore, a full blob SELECT in SqlStore. Surface COUNT is unbounded (only per-post and per-session TEXT bytes are capped), so a session cheaply salted with many references to one 5 MB bad asset made every export re-read it hundreds of thousands of times, unauthenticated on a publicRead workspace. Memoize rejections alongside successes; the size-limit path stays per-reference since it depends on the shrinking budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two hosts implement the sandboxed-surface bridge — the viewer (bundled TS) and the export's shell (a JS string baked into the saved file) — and they can't share an implementation, so they restated the same security policy: the http(s) allowlist, the confirm text, and the height clamp. Move that policy into server/bridgePolicy.ts; the viewer imports it and the shell interpolates it, so neither can drift. Tests now assert against the shared constants instead of copies of them. The export also renders through the same cache /s/:id populates. For every kind but html the two produce identical bytes, so exporting a session someone just viewed no longer re-runs shiki and the diff SSR per surface; html surfaces take a distinct key since the export pins a <base href>. Drive-by: clampFrameHeight floors a NaN height to the minimum. The viewer's Math.max(NaN, MIN) returned NaN and wrote "NaNpx"; the export shell already guarded this, and now both share the guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bridge policy extracted in 8950d89 had no Node coverage — the viewer exercises it under Vitest, which is a separate report — so the coverage gate dropped below its function floor. Test it directly instead: the scheme allowlist (javascript:/data:/file:/blob: all rejected), href normalization so validation and navigation can't diverge, and the height clamp's NaN guard. Also covers JsonFileStore's pre-0.5.0 `snippets` lift, the JSON-side counterpart of the migrateToPosts test it sits beside — a documented invariant (old workspaces open with content and history intact) that had no test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Save a whole sideshow session as one self-contained HTML file and share it. The file opens straight from disk with no server running, and every surface renders exactly as it does in the live viewer — still sandboxed.
Changes
GET /api/sessions/:id/export+sideshow export— render a session into the viewer's card column as a single document. Sits under/api/sessions/, so auth andpublicRead: "session"gating come free./s/:idcode path and embedded as a sandboxedsrcdociframe (noallow-same-origin). A sharedrenderSurfaceDocumentdispatch (server/surfacePage.ts) backs both callers, so the two can't drift.server/cardChrome.ts) and the link/resize bridge policy (server/bridgePolicy.ts) are single sources the viewer and the export both consume; the export interpolates the policy rather than restating it.data:URIs, allowlisted raster types only (INLINE_IMAGE_TYPES), 32 MB per export; past the cap they degrade to a visible note.?theme=/?mode=light|darkpin the look (default follows the reader's OS);?download=1forces an attachment download.npm auditgate (js-yaml, brace-expansion, fast-uri, ip-address, undici via wrangler/miniflare).Guardrails
413before any renderingPerformance
The export renders through the cache
/s/:idalready populates. For every kind buthtmlthe two produce byte-identical documents, so exporting a session someone just viewed skips re-running shiki and the diff SSR per surface, and a second export is nearly free.htmlsurfaces take a distinct cache key because the export pins a<base href>.Review guide
Suggested reading order:
server/exportPage.ts— the whole export path: surface → sandboxed frame, asset inlining and its budget, the shell document.server/surfacePage.ts—renderSurfaceDocument, the single kind→renderer dispatch now shared with/s/:id.server/app.ts— the route: size gate, one-query comment grouping, cache wiring.server/bridgePolicy.ts+server/cardChrome.ts— the two shared-source seams, and howviewer/src/{App,Card,main,embed}consume them.bin/sideshow.js— the CLI command and therawFetchsplit that lets it read HTML instead of JSON.Testing
test/export.test.ts— 16 tests including hostile html/title, crafted MIME types, the asset budget, and the rejected-asset fetch count.test/bridgePolicy.test.ts— scheme allowlist (javascript:,data:,file:,blob:rejected), href normalization, height-clamp NaN guard.test/cli.test.ts,test/migrateToPosts.test.ts— the CLI command; the JSON legacy snippet lift.e2e/export.spec.ts— saves a real export to disk and drives it in chromium and webkit, including a cross-origin leak probe.Risks / rollout
Additive — one new route, one new CLI command, no schema change and no migration. The refactors it pulls in (
renderSurfaceDocument,cardChrome,bridgePolicy) do touch the live viewer's render and bridge paths, which is what the e2e suite covers as the parity oracle.Portability boundary:
/a/:idassets referenced inside agent-authored html or markdown, and Mermaid diagrams (CDN), still need the network. Image surfaces are inlined and need nothing.Breaking changes
None.