Skip to content

feat(viewer): render rich parts server-side, served from /s/:id#125

Merged
benvinegar merged 7 commits into
mainfrom
fix/rich-parts-blob-url
Jun 24, 2026
Merged

feat(viewer): render rich parts server-side, served from /s/:id#125
benvinegar merged 7 commits into
mainfrom
fix/rich-parts-blob-url

Conversation

@benvinegar

@benvinegar benvinegar commented Jun 24, 2026

Copy link
Copy Markdown
Member

Problem

Rich parts (markdown/code/diff/terminal/mermaid) intermittently render blank or
clipped on reload under a Chrome 149 field trial. Root cause: opaque-origin
in-memory iframe documents (srcdoc, blob:) defer layout on the affected
profile, so the resize bridge measures 0 and the frame stays collapsed. The
async-rendered parts (diff, mermaid) are hit hardest — they never lay out.

Approach

Keep the opaque-origin isolation; change how the document is produced and
delivered. Rich parts now render server-side and load the same way html
parts already do
— from /s/:id?part=N by real URL with a sandbox CSP
response header — instead of an in-memory srcdoc/blob: document. A real
navigation lays out normally on the affected Chrome while staying opaque-origin.

The earlier iterations on this branch (blob: URL, then a POST /api/frames
/f/:id viewer→server staging round-trip) are superseded: a spike confirmed the
text renderers run on Workers with no DOM/WASM, so there's no reason to render in
the viewer and ship the string back.

  • server/richRender.ts renders markdown/code/diff/terminal server-side:
    shiki on the JS regex engine (no oniguruma WASM), @pierre/diffs SSR via
    shiki-js, markdown-it (html:false), ansi_up — all runtime-agnostic, so
    they run on the Worker DO too.
  • /s/:id?part=N serves each part under Content-Security-Policy: sandbox allow-scripts. renderSandboxedPart wraps rich bodies under a tighter
    in-doc CSP than html parts: no connect-src, no CDN script source.
  • mermaid needs a DOM, so renderMermaidPage emits a self-rendering doc
    that imports mermaid from the CDN inside the sandbox (html-part CSP).
  • Versioned + themed /s/:id responses are immutable: long-lived
    Cache-Control plus an in-memory (id,part,version,theme,mode) LRU render
    cache (single-instance DO; swap for KV/Cache API if multi-instance).
  • Removes POST /api/frames, GET /f/:id, the transient frame store, and the
    related auth/public-read exceptions. Drops the dead viewer render components,
    highlight.ts, and mermaid/shiki from the client bundle (12.7MB → 86KB).

Why this keeps the security model

The core invariant is untouched: rich frames are opaque-origin via the same
sandbox response header /s/:id relies on — no allow-same-origin, ever.
The rich CSP deliberately allows 'unsafe-inline' (the bridge runs without a
nonce), so the opaque origin is the only thing containing a script — and its
CSP has no connect-src and no CDN/board script source, so even a hypothetical
escaped script could neither phone home nor reach the board. Defense in depth
behind the markdown-it escape.

e2e (isolation.spec.ts): publish a markdown part with an embedded <script>,
assert it's escaped to text, the /s/:id response carries the sandbox header,
window.origin is the opaque "null", the in-doc CSP has no connect-src, and
script-src is 'unsafe-inline' only. mermaid.spec.ts stubs the CDN import to
exercise our loader wiring (render → inject SVG, and the error fallback)
hermetically.

Validation

  • npm test — 207/207 pass
  • npm run typecheck / lint — pass
  • Chromium e2e (rich-part renders, resize bridge, isolation, theme switch,
    mermaid, removed endpoints) — pass
  • Verified on real workerd (Durable Object + SqlStore)

🤖 Generated with Claude Code

benvinegar and others added 3 commits June 24, 2026 08:42
… bug

Prototype alternative to #101. Keeps the opaque-origin security model
(sandbox="allow-scripts", no allow-same-origin, unchanged rich-part CSP)
but loads the doc from a blob: URL instead of srcdoc, sidestepping the
opaque-origin *srcdoc* layout path the Chrome 149 field trial breaks.

Removes the #85 reparse retry so the blob path is tested on its own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…an't reach board)

The rich-part CSP allows 'unsafe-inline', so the opaque origin is the only
thing containing a script. This injects a <script> as if a sanitizer bypass
let it through, lets it run, and asserts window.origin is "null" and its
write to the parent board is blocked — the same guarantee srcdoc gave.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
benvinegar and others added 3 commits June 24, 2026 09:04
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supersedes the blob: URL approach. blob: kept the opaque origin but is still
an in-memory iframe document, so the async-rendered parts (diff, mermaid) never
laid out under the Chrome 149 field trial. Instead stage each rich frame's
rendered doc at POST /api/frames and load it by real URL from GET /f/:id —
exactly how html parts load from /s/:id. The response carries the same
`sandbox` CSP header, so the frame is opaque-origin on any load (a real
navigation, not srcdoc/blob), which the affected Chrome lays out normally.

- New bounded, FIFO-evicted in-memory frame store (runtime-agnostic).
- /f/:id is public-read like /s/:id; POST /api/frames is allowed for
  public-read viewers so rich parts still render on read-only boards.
- e2e proves /f/:id is opaque (a script that runs can't reach the board) and
  that the response carries the sandbox header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@benvinegar benvinegar changed the title fix(viewer): load rich parts via blob: URL to keep them visible under Chrome 149 field trials fix(viewer): serve rich parts from /f/:id with a sandbox header (keep opaque-origin isolation) Jun 24, 2026
Render markdown/code/diff/terminal on the server and serve each from
/s/:id?part=N by real URL under a sandbox CSP header — the opaque-origin,
real-navigation load path html parts already use, which the Chrome 149 field
trial doesn't break (it defers layout only for in-memory srcdoc/blob: docs).

- server/richRender.ts: shiki (JS regex engine), @pierre/diffs (shiki-js SSR),
  markdown-it (html:false), ansi_up — runtime-agnostic, run on the Worker DO.
- renderSandboxedPart wraps rich bodies under a tighter CSP than html parts:
  no connect-src, no CDN script source — no exfil even if markup escaped.
- mermaid needs a DOM, so renderMermaidPage emits a self-rendering doc that
  loads mermaid from the CDN inside the sandbox (html-part CSP).
- Versioned+themed /s/:id responses are immutable: long-lived Cache-Control
  plus an in-memory (id,part,version,theme,mode) LRU render cache.
- Remove POST /api/frames, GET /f/:id, the transient frame store, and the
  related auth/public-read exceptions; drop the dead viewer render components,
  highlight.ts, and mermaid/shiki from the client bundle (12.7MB -> 86KB).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@benvinegar benvinegar changed the title fix(viewer): serve rich parts from /f/:id with a sandbox header (keep opaque-origin isolation) feat(viewer): render rich parts server-side, served from /s/:id Jun 24, 2026
@benvinegar benvinegar merged commit bd3ea88 into main Jun 24, 2026
9 checks passed
benvinegar added a commit that referenced this pull request Jun 24, 2026
Mermaid diagrams didn't fully re-theme on a light/dark flip — some of the
diagram changed color, but not all of it. After authoring in light mode and
toggling to dark, arrowheads stayed dark while the edges they cap flipped, and
mermaid-derived fills read as light-mode.

Root cause: the mermaid renderer drove mermaid's `base` theme from the design
tokens but only set the box/line/text variables explicitly, leaving mermaid to
derive the rest. Two derivation paths stayed stuck in light mode:

1. `darkMode` was never passed. Mermaid's base theme branches on a `darkMode`
   flag for the colors it computes itself (edge-label background, row stripes,
   the cScale/surface ramps). Unset → all computed for a light canvas.
2. `background` was never set → it defaulted to a hardcoded `#f4f4f4`. Anything
   derived as `invert(background)` — most visibly arrowheads — came out
   near-black in both schemes, so an edge flipped but its arrowhead didn't.

Fix, ported to the server-side renderer (mermaidThemeVars in surfacePage.ts,
where rich-part rendering now lives after #125):

- Pass `darkMode: mode === "dark"` so mermaid's own derivations resolve to the
  active scheme. The page is server-rendered pinned to the chrome-resolved mode
  (the iframe src carries it), and re-fetched on flip — so no client re-render
  is needed.
- Pass `background: surface` (the real card backdrop) so invert-derived colors
  track the theme.
- Pin the previously invert-derived colors to the viewer's tokens:
  `arrowheadColor` → muted (matches the edges), and nodeTextColor/titleColor/
  classText/secondary/tertiaryTextColor → the text token.

Adds a hermetic regression test (test/surfacePage.test.ts) asserting the
mermaid loader pins darkMode/background/arrowhead/text to the resolved scheme
in both modes — no CDN or real mermaid needed, matching the stubbed e2e path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
benvinegar added a commit that referenced this pull request Jun 24, 2026
Mermaid diagrams didn't fully re-theme on a light/dark flip — some of the
diagram changed color, but not all of it. After authoring in light mode and
toggling to dark, arrowheads stayed dark while the edges they cap flipped, and
mermaid-derived fills read as light-mode.

Root cause: the mermaid renderer drove mermaid's `base` theme from the design
tokens but only set the box/line/text variables explicitly, leaving mermaid to
derive the rest. Two derivation paths stayed stuck in light mode:

1. `darkMode` was never passed. Mermaid's base theme branches on a `darkMode`
   flag for the colors it computes itself (edge-label background, row stripes,
   the cScale/surface ramps). Unset → all computed for a light canvas.
2. `background` was never set → it defaulted to a hardcoded `#f4f4f4`. Anything
   derived as `invert(background)` — most visibly arrowheads — came out
   near-black in both schemes, so an edge flipped but its arrowhead didn't.

Fix, ported to the server-side renderer (mermaidThemeVars in surfacePage.ts,
where rich-part rendering now lives after #125):

- Pass `darkMode: mode === "dark"` so mermaid's own derivations resolve to the
  active scheme. The page is server-rendered pinned to the chrome-resolved mode
  (the iframe src carries it), and re-fetched on flip — so no client re-render
  is needed.
- Pass `background: surface` (the real card backdrop) so invert-derived colors
  track the theme.
- Pin the previously invert-derived colors to the viewer's tokens:
  `arrowheadColor` → muted (matches the edges), and nodeTextColor/titleColor/
  classText/secondary/tertiaryTextColor → the text token.

Adds a hermetic regression test (test/surfacePage.test.ts) asserting the
mermaid loader pins darkMode/background/arrowhead/text to the resolved scheme
in both modes — no CDN or real mermaid needed, matching the stubbed e2e path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
benvinegar added a commit that referenced this pull request Jun 25, 2026
Visiting a bare /s/:id URL now opens a full-page standalone view of that
one surface — title and parts only, no sidebar, session feed, or comment
thread — with a small "made with sideshow" watermark beneath it, instead
of resolving the link into its session's stream.

The route used to render a standalone surface page; #125 (multi-part
surfaces) and #130 (link-preview SPA shell) left bare /s/:id resolving to
the feed. This restores the standalone view within the new architecture:

- state: a `standaloneSurface` signal + `bootstrap()` entry point. A bare
  surface route (surfaceId, no session) fetches the surface and enters
  standalone mode; `applyRoute` toggles it on back/forward. Falls back to
  the board if the surface can't be fetched.
- App: renders `StandaloneView` (the one surface + watermark) instead of
  the board chrome, and titles the tab after the surface.
- Card: a `standalone` prop strips the version dropdown, "updated" meta,
  comment thread, and scroll/URL observer — keeping the iframe
  registration so parts render in the same sandboxed frames sized by the
  same resize bridge.
- styles: centered single-surface layout + watermark.

No server change: bare /s/:id still serves the SPA shell with link-preview
metadata (#130); the viewer picks the layout from the route. Works under
the deployed /u/:user prefix via appPath/basePath.

Updated the url-routing e2e test (it pinned the feed behavior) and added a
changeset.

Co-Authored-By: Claude Opus 4.8 (1M context) <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.

1 participant