perf(ui): ship the HTML viewer bridge script as an asset (opt-in bridgeScriptUrl) - #1398
Merged
Conversation
…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
force-pushed
the
perf/ui-bridge-asset
branch
from
August 27, 2026 15:34
c2c07f2 to
c6d2e25
Compare
backnotprop
added a commit
that referenced
this pull request
Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ships the last item of the
@plannotator/uibundle 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
.htmldocs 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 thegit archivebaseline 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, theprotocolVersionfield added to thereadymessage. 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, whichtests/entry-assets.test.tsnow pins. guides.showcheck:manifestreports the viewer hash unchanged andcheck:budgetspasses.What a host gets
HtmlViewergrowsbridgeScriptUrl,bridgeReadyTimeoutMs(default 5000) andonBridgeUnavailable. 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_VERSIONis embedded in the bridge and stamped on itsready; 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.prepacknow 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(exactlyBRIDGE_SCRIPT) andcomponents/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-runlists them). The TS module remains the source of truth because the CLI and Pi import it under Bun.Host wiring:
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 nocrossoriginattribute is set. A CSP HTTP header on the host page is inherited by the frame: hosts with their own CSP must allowscript-srcfor the asset origin. Documented in README and HANDOFF.Verification
bun run typecheckclean;bun test packages/ui packages/editor856 pass;DOM_TESTS=1on the HTML-surface suites plus the two new ones 213 pass;tests/entry-assets.test.ts26 pass on the built bundles;build:review,build:hook, guides.showbuild:viewer,check:manifest,check:budgetsall pass. No server changes, no new dependencies.AI-assisted (Claude) under maintainer direction.