Skip to content

feat(export): export a session as one self-contained HTML file - #227

Open
tridha643 wants to merge 7 commits into
mainfrom
stitch-html-snippets-issue
Open

feat(export): export a session as one self-contained HTML file#227
tridha643 wants to merge 7 commits into
mainfrom
stitch-html-snippets-issue

Conversation

@tridha643

@tridha643 tridha643 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

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.

sideshow export --session <id> --out session.html                  # CLI
curl -s "$SIDESHOW_URL/api/sessions/<id>/export" > session.html    # raw HTTP

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 and publicRead: "session" gating come free.
  • Isolation holds inside the saved file — each surface is rendered by the exact /s/:id code path and embedded as a sandboxed srcdoc iframe (no allow-same-origin). A shared renderSurfaceDocument dispatch (server/surfacePage.ts) backs both callers, so the two can't drift.
  • Shared chrome, shared policy — the card CSS (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.
  • Images travel with the file — image surfaces inline as data: URIs, allowlisted raster types only (INLINE_IMAGE_TYPES), 32 MB per export; past the cap they degrade to a visible note.
  • Options?theme= / ?mode=light|dark pin the look (default follows the reader's OS); ?download=1 forces an attachment download.
  • Deps — cleared the high-severity advisories that were failing the npm audit gate (js-yaml, brace-expansion, fast-uri, ip-address, undici via wrangler/miniflare).

Guardrails

Guardrail Behavior
Session over 4 MB of surface text 413 before any rendering
No resolvable session in the CLI Fails with a hint — export never creates a session
Repeated reference to a bad asset Fetched once per export, not once per surface
Comments One query per session (no N+1); post comments included, session-level skipped in v1
MCP No tool on purpose — megabytes of HTML don't belong in a tool result; MCP agents use the HTTP route

Performance

The export renders through the cache /s/:id already populates. For every kind but html the 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. html surfaces take a distinct cache key because the export pins a <base href>.

Review guide

Suggested reading order:

  1. server/exportPage.ts — the whole export path: surface → sandboxed frame, asset inlining and its budget, the shell document.
  2. server/surfacePage.tsrenderSurfaceDocument, the single kind→renderer dispatch now shared with /s/:id.
  3. server/app.ts — the route: size gate, one-query comment grouping, cache wiring.
  4. server/bridgePolicy.ts + server/cardChrome.ts — the two shared-source seams, and how viewer/src/{App,Card,main,embed} consume them.
  5. bin/sideshow.js — the CLI command and the rawFetch split 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.
  • Full gates green: 464 unit tests, 178 e2e, three typecheck programs, lint, format, coverage floors.

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/:id assets 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.

@tridha643

Copy link
Copy Markdown
Contributor Author

@greptile

@tridha643
tridha643 requested a review from benvinegar July 21, 2026 19:15
tridha643 and others added 2 commits July 27, 2026 14:27
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>
@tridha643
tridha643 force-pushed the stitch-html-snippets-issue branch from 9914fea to 9af51ac Compare July 27, 2026 18:35
@benvinegar

Copy link
Copy Markdown
Member

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>
@tridha643

Copy link
Copy Markdown
Contributor Author

Good catch — fixed in 44266f1.

The card container/head/title/meta rules now live in server/cardChrome.ts and are injected by all three consumers: main.tsx (self-hosted viewer), embed.tsx (embedded engine), and the export shell. Same runtime-agnostic shared-string seam themes.ts already uses, so it works on the Worker DO too.

The export also emits the viewer's own .card / .card-head / .card-title / .card-meta markup now instead of parallel ss- classes, and keeps only the handful of rules that adapt the column to a static document (no hover actions, no version picker). Full e2e suite passes on chromium + webkit.

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>
@socket-security

socket-security Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​modelcontextprotocol/​sdk@​1.30.09910010095100
Added@​hono/​node-server@​1.19.171009910096100
Updatedhono@​4.12.32 ⏵ 4.13.0100 +1100 +297 +196100

View full report

tridha643 and others added 3 commits August 6, 2026 10:53
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants