Skip to content

perf(ui): ship the HTML viewer bridge script as an asset (opt-in bridgeScriptUrl) - #1398

Merged
backnotprop merged 7 commits into
mainfrom
perf/ui-bridge-asset
Aug 27, 2026
Merged

perf(ui): ship the HTML viewer bridge script as an asset (opt-in bridgeScriptUrl)#1398
backnotprop merged 7 commits into
mainfrom
perf/ui-bridge-asset

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Ships the last item of the @plannotator/ui bundle work (DESIGN_ui-bundle-optimization.md, item 4): the raw-HTML viewer's 185 KB bridge script can now be served as a separately cached asset, as an opt-in for hosts. Plannotator's own behavior is unchanged.

The byte-identical guarantee

Plannotator passes nothing and stays on the inline path everywhere: the annotate srcdoc surface, the version diff, PR HTML artifacts, linked .html docs and the share portal still inline the string; the live-app proxy still composes and injects the same inline bridge from its own route; the Pi and OpenCode copies build from the same code. No timer runs and no banner can render on the inline path.

Proof, in a real browser (Playwright 1.62.1, Chrome for Testing): a Plannotator HTML annotate session on an origin/main build against this branch's build, sandboxed data dir, same fixture, same first-run dialog dismissed, same pinpoint comment. App shell DOM identical (the one difference is the <title> project name, which comes from the directory name of the git archive baseline versus the git remote). Srcdoc document with the bridge literal blanked: identical. Rendered frame DOM after the bridge ran: identical. Network (8 requests) and console: identical. One overlay marker on both. The bridge literal itself differs by exactly one line, the protocolVersion field added to the ready message. Single-file bundles: hook +2,679 bytes raw (+590 gzip), review +2,487 bytes raw (+498 gzip), the new viewer code and its two messages; the bridge literal marker count stays at 1 in both, which tests/entry-assets.test.ts now pins. guides.show check:manifest reports the viewer hash unchanged and check:budgets passes.

What a host gets

HtmlViewer grows bridgeScriptUrl, bridgeReadyTimeoutMs (default 5000) and onBridgeUnavailable. With the URL set, the srcdoc carries one classic <script src> in the exact position the inline <script> occupied (one injection point, buildBridgeScriptTag, for both paths), so ordering relative to the page's own scripts is unchanged. BRIDGE_PROTOCOL_VERSION is embedded in the bridge and stamped on its ready; a stale cached asset (no stamp or another version) produces one console warning naming both versions, an in-surface error banner and the callback, while the ready is still honored. No ready within the timeout shows a timeout banner naming the URL; a late ready clears it.

prepack now also generates two gitignored files beside the source module, deterministic and verified byte-for-byte against the module's exports by test: components/html-viewer/bridge-script.asset.js (exactly BRIDGE_SCRIPT) and components/html-viewer/bridge-script.lite.ts (an optional alias target with the literal stubbed). Both have exports subpaths and ship in the tarball (bun pm pack --dry-run lists them). The TS module remains the source of truth because the CLI and Pi import it under Bun.

Host wiring:

import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-script.asset.js?url";

<HtmlViewer rawHtml={html} bridgeScriptUrl={bridgeScriptUrl} onBridgeUnavailable={...} />

Verified on a harness that installs the packed tarballs and builds with Vite: the asset is emitted as a hashed file, ready carries protocolVersion: 1, pinpoint and drag annotations work (markers, anchors, composer), a deliberately stale asset triggers the warning and the version-mismatch banner, and an unavailable asset triggers the timeout banner with the page still rendered. With the optional lite alias the host chunk drops from 557 kB to 371 kB (168 kB to 118 kB gzip).

CSP

The package sets no CSP <meta> in the srcdoc document (the injection is one <style> and one <script>; an author-written CSP meta is still neutralized as before), and the bridge writes none at runtime, both pinned by test. The srcdoc frame is an opaque origin, so the classic script needs no CORS and no crossorigin attribute is set. A CSP HTTP header on the host page is inherited by the frame: hosts with their own CSP must allow script-src for the asset origin. Documented in README and HANDOFF.

Verification

bun run typecheck clean; bun test packages/ui packages/editor 856 pass; DOM_TESTS=1 on the HTML-surface suites plus the two new ones 213 pass; tests/entry-assets.test.ts 26 pass on the built bundles; build:review, build:hook, guides.show build:viewer, check:manifest, check:budgets all pass. No server changes, no new dependencies.

AI-assisted (Claude) under maintainer direction.

…and ready timeout

Add an opt-in bridgeScriptUrl prop to HtmlViewer: the srcdoc document then
loads the bridge through a classic <script src> in the exact position the
inline script occupies (one injection point, buildBridgeScriptTag, for both
paths). The bridge stamps BRIDGE_PROTOCOL_VERSION on its ready message; the
parent warns once on a missing or mismatched stamp and, on the URL path
only, shows an error banner and calls onBridgeUnavailable. A ready timeout
(bridgeReadyTimeoutMs, default 5000) covers a blocked or missing asset.

Plannotator passes nothing and keeps the inline literal on every surface;
no timer and no banner exist on the inline path. The simulated ready posts
in the existing DOM suites now carry the stamp the real bridge sends.
scripts/build-bridge-assets.ts derives two gitignored files from the source
module beside it: bridge-script.asset.js (byte-for-byte BRIDGE_SCRIPT, for
hosts that serve it as a hashed static file and pass its URL to HtmlViewer)
and bridge-script.lite.ts (the CSS, protocol version and live bootstrap with
the literal stubbed, an optional bundler alias target that drops the 185 KB
string from a host's viewer chunk). Both get exports subpaths and ship in the
tarball; bridgeAsset.test.ts regenerates them into a temp dir and compares
them to the module, pins the single injection point and the absence of any
CSP meta, and runs the real bridge in an isolated window to assert its ready
carries the stamp.
…idge

The live-app proxy keeps composing and injecting the inline bridge from its
own route on both transports and both runtimes, and never references the
srcdoc URL seam or the generated files. The built hook and review bundles
carry the bridge literal exactly once, as on main.
… ready timer once, make the mismatch banner dismissible

The srcdoc injection lands at the end of the page's <head>, after any <base
href> the page declares, so a relative bridge URL written into the srcdoc
would resolve against an attacker-chosen base and load an attacker-served
bridge, defeating the version check. HtmlViewer now resolves the URL against
its own document.baseURI (resolveBridgeScriptUrl) before it is written;
pinned with a <base href> document on both the pure and the DOM suite.

The ready timer is armed once per document load and reads the timeout
through a ref, so a host changing bridgeReadyTimeoutMs after a successful
ready can no longer re-arm a timer nothing clears. The version-mismatch
banner gains a Dismiss button (the older bridge keeps working); the timeout
banner stays, since that surface is dead. Prop docs now say the tag lands at
the end of <head>, before the body.
…add CORP and base-href notes

Release label matches the neighbouring 0.32.0 headings after the version
bump; the tag is injected at the end of <head>, before the body, not before
the page's own scripts; hosts serving the asset with a same-origin CORP
(common with COEP) must relax it for the opaque-origin frame; the URL is
resolved against the parent document, never the framed page; the HANDOFF
notes BRIDGE_SCRIPT's first interpolation for the design record and points
at the PR description instead of a REPORT.md that does not ship.
…ides.show viewer stylesheet stays unchanged

The viewer build scans this file for utility classes; the button's new
classes changed the viewer CSS hash pinned in guide-viewer-manifest.ts.
Inline styles keep the stylesheet, and the manifest, as on main.
@backnotprop
backnotprop force-pushed the perf/ui-bridge-asset branch from c2c07f2 to c6d2e25 Compare August 27, 2026 15:34
@backnotprop
backnotprop merged commit 7d6dd29 into main Aug 27, 2026
28 checks passed
backnotprop added a commit that referenced this pull request Aug 27, 2026
Version bump, lockfile refresh, and docs pass for the ui 0.33.0 release carrying the bridge-as-asset seam (#1398) and the 0.32.0 adoption feedback fixes (#1399). Core stays 0.25.0.

AI-assisted (Claude) under maintainer direction.
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