diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2504566ec..f4b90cdd7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -86,6 +86,7 @@ jobs:
packages/ui/components/html-viewer/htmlPinpointProtocol.test.tsx
packages/ui/components/html-viewer/htmlLiveProtocol.test.tsx
packages/ui/components/html-viewer/HtmlViewer.vimHud.test.tsx
+ packages/ui/components/html-viewer/HtmlViewer.bridgeAsset.test.tsx
packages/ui/components/Viewer.vimMode.integration.test.tsx
packages/ui/hooks/useVimSelection.test.tsx
packages/ui/utils/codeHighlight.test.ts
diff --git a/.gitignore b/.gitignore
index 1b5b2d2b8..ac5312dc7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,4 +74,7 @@ security-results/
# @plannotator/ui CSS build artifacts (generated by prepack — not committed)
packages/ui/styles.css
packages/ui/styles.js
+# HTML viewer bridge assets, generated at prepack from bridge-script.ts
+packages/ui/components/html-viewer/bridge-script.asset.js
+packages/ui/components/html-viewer/bridge-script.lite.ts
.wrangler/
diff --git a/packages/shared/live-proxy-bridge-inline.test.ts b/packages/shared/live-proxy-bridge-inline.test.ts
new file mode 100644
index 000000000..bd743b7cc
--- /dev/null
+++ b/packages/shared/live-proxy-bridge-inline.test.ts
@@ -0,0 +1,61 @@
+/**
+ * Live app annotation is untouched by the HtmlViewer `bridgeScriptUrl` seam:
+ * the proxy keeps composing the INLINE bridge body it serves from its own
+ * route and injecting that route's tag into proxied HTML. A later change
+ * that routed the live path through the package asset (or its URL prop)
+ * would move a per-session token-bearing body onto a host-served file, so
+ * this pins the current shape at source level, on both transports and both
+ * runtimes' composers.
+ */
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+// Relative on purpose: @plannotator/ui is not a dependency of shared; only the
+// CLI and Pi import the bridge module, and this test reads the same file.
+import { BRIDGE_SCRIPT, LIVE_BRIDGE_BOOTSTRAP } from "../ui/components/html-viewer/bridge-script";
+import {
+ LIVE_PROXY_BRIDGE_PATH,
+ LIVE_PROXY_BRIDGE_TAG,
+ composeLiveBridgeJs,
+} from "./live-proxy-core";
+
+const root = resolve(import.meta.dir, "../..");
+const read = (path: string) => readFileSync(resolve(root, path), "utf8");
+
+describe("live proxy bridge delivery", () => {
+ test("the proxy-served body carries the inline bridge verbatim", () => {
+ const body = composeLiveBridgeJs({
+ token: "tok",
+ editorOrigins: ["http://localhost:1"],
+ annotationCss: ".x{}",
+ bridgeBootstrap: LIVE_BRIDGE_BOOTSTRAP,
+ bridgeScript: BRIDGE_SCRIPT,
+ });
+ expect(body.endsWith(BRIDGE_SCRIPT)).toBe(true);
+ expect(body).toContain(LIVE_BRIDGE_BOOTSTRAP);
+ // The injected tag names the proxy's own route, never a host asset.
+ expect(LIVE_PROXY_BRIDGE_TAG).toBe(``);
+ expect(LIVE_PROXY_BRIDGE_PATH).toBe("/__plannotator__/bridge.js");
+ });
+
+ test.each([
+ "packages/shared/live-proxy-core.ts",
+ "packages/shared/live-proxy-node.ts",
+ "packages/server/live-proxy.ts",
+ "packages/server/annotate.ts",
+ "apps/hook/server/index.ts",
+ "apps/pi-extension/plannotator-browser.ts",
+ "apps/pi-extension/server/serverAnnotate.ts",
+ ])("%s never references the srcdoc URL seam or the generated asset", (path) => {
+ const source = read(path);
+ expect(source).not.toContain("bridgeScriptUrl");
+ expect(source).not.toContain("bridge-script.asset");
+ expect(source).not.toContain("bridge-script.lite");
+ });
+
+ test("both runtimes still hand the inline string exports to the composer", () => {
+ expect(read("apps/hook/server/index.ts")).toContain("bridgeScript: BRIDGE_SCRIPT,");
+ expect(read("apps/pi-extension/plannotator-browser.ts")).toContain("bridgeScript: bridge.BRIDGE_SCRIPT,");
+ expect(read("packages/shared/live-proxy-core.ts")).toContain("+ sources.bridgeScript");
+ });
+});
diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md
index 56fade0c8..a52a7c532 100644
--- a/packages/ui/HANDOFF.md
+++ b/packages/ui/HANDOFF.md
@@ -471,12 +471,61 @@ Four modules that used to ride every document read for a host that bundles by ro
3. **Identity: a generator slot, filled eagerly by Plannotator.** `utils/generateIdentity` no longer imports `unique-username-generator`. It holds a synchronous generator slot (`setIdentityGenerator`, `getIdentityGenerator`) with a built-in fallback that produces the same `adjective-noun-tater` shape from a 16 x 16 pool. `utils/identity-tater` registers the full dictionary as a side effect and is what Plannotator's entries import. A host with `identityProvider` never calls the generator and, with the static import gone, no longer ships the word lists; delete any dictionary shim. A host that wants the full dictionary without its own provider imports `@plannotator/ui/utils/identity-tater`, or passes its own `identityGenerator` to `configurePlannotatorUI`. The slot is synchronous on purpose: `configStore` persists the first generated name to the identity cookie during the first render-time settings read, so a name that arrived later would be a visible identity change.
-4. **What did not ship (deliberately).** The raw-HTML bridge script as a separately served asset and a lazy table popout are not in this release; both are tracked in the design record for a follow-up.
+4. **What did not ship (deliberately).** A lazy table popout is not in this release; it is tracked in the design record for a follow-up. The raw-HTML bridge script as a separately served asset shipped afterwards, see "HTML viewer bridge as an asset" below.
Pinned by `utils/math.test.ts`, `components/MathBlock.firstPaint.test.tsx`, `utils/generateIdentity.test.ts`, `components/MermaidBlock.test.ts`, and the eager-entry and built-HTML marker guards in `tests/entry-assets.test.ts`.
---
+## HTML viewer bridge as an asset (0.33.0)
+
+`HtmlViewer` injects a 185 KB bridge script (`BRIDGE_SCRIPT`, `components/html-viewer/bridge-script.ts`) into every srcdoc document it renders. For a host that bundles by route that literal rode in the viewer chunk and was re-parsed by the browser per document. This release adds an opt-in, `bridgeScriptUrl`, and leaves the default untouched: Plannotator passes nothing, every Plannotator surface (the annotate srcdoc path, the version diff, PR HTML artifacts, linked `.html` docs, the share portal) still inlines the string, the live-app proxy still serves the same inline bridge from its own `/__plannotator__/bridge.js` route, the Pi and OpenCode copies are built from the same code, and the single-file bundles carry the literal exactly once as before (`tests/entry-assets.test.ts` counts it; the A/B of a Plannotator HTML annotate session on a main build against this build found identical DOM, requests and console).
+
+**What the package ships.** `prepack` now also runs `scripts/build-bridge-assets.ts`, which derives two gitignored files beside the source module, both deterministic and both verified against the module's exports by `components/html-viewer/bridgeAsset.test.ts`:
+
+- `components/html-viewer/bridge-script.asset.js`: byte-for-byte `BRIDGE_SCRIPT`, the runnable IIFE. Export subpath `@plannotator/ui/components/html-viewer/bridge-script.asset.js`. The `.asset.js` name is deliberate: a plain `bridge-script.js` next to `bridge-script.ts` would be picked first by Vite's extension probe for the package's own `./bridge-script` imports and break every consumer build.
+- `components/html-viewer/bridge-script.lite.ts`: the same `ANNOTATION_HIGHLIGHT_CSS`, `BRIDGE_PROTOCOL_VERSION` and `LIVE_BRIDGE_BOOTSTRAP` with `BRIDGE_SCRIPT = ""`. Export subpath `@plannotator/ui/components/html-viewer/bridge-script.lite`. An alias target only (below).
+
+The TS module stays the source of truth because the Plannotator CLI and the Pi extension import its string exports under Bun.
+
+**Host wiring (Workspaces).** Serve the asset same-origin as a hashed file through a Vite `?url` import and pass the URL to the viewer:
+
+```ts
+import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-script.asset.js?url";
+
+ ...} // { kind: 'timeout' | 'version-mismatch', url, ... }
+ ...
+/>
+```
+
+With the prop set, `buildSrcdocInjection` emits `` in the exact position the inline ``);
+ expect(srcdoc).not.toContain(BRIDGE_SCRIPT);
+ // The sandbox is unchanged by the delivery path.
+ expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
+ });
+
+ test("a relative URL is resolved against the PARENT document, so a page's own cannot redirect it", async () => {
+ // happy-dom's document lives at about:blank; give the PARENT a real base
+ // the way a host page has one, and remove it afterwards.
+ const parentBase = document.createElement('base');
+ parentBase.setAttribute('href', 'http://host.test:4000/workspace/');
+ document.head.appendChild(parentBase);
+ try {
+ expect(document.baseURI).toBe('http://host.test:4000/workspace/');
+ const { iframe } = await mount({
+ bridgeScriptUrl: '/assets/bridge-script.deadbeef.js',
+ rawHtml: 't
x
',
+ });
+ const srcdoc = iframe.getAttribute('srcdoc') ?? '';
+ expect(srcdoc).toContain('');
+ // The page's base tag still precedes the injected tag (end of ),
+ // which is exactly why the src must already be absolute.
+ expect(srcdoc.indexOf('` from this URL (the package's generated
+ * `components/html-viewer/bridge-script.asset.js`, served by the host)
+ * instead of inlining the 185 KB script into every document. Absent (the
+ * default, and Plannotator's only path): inline, unchanged. The tag lands
+ * where the inline script does, at the end of ``, before the body.
+ * The URL is resolved against THIS document's base (`document.baseURI`)
+ * before it is written, never against the framed page, so a page's own
+ * `` cannot redirect it. The srcdoc frame is an opaque origin,
+ * so the script needs no CORS and no `crossorigin` attribute is set; a CSP
+ * header on the host page is inherited by the frame and must allow
+ * `script-src` for the asset origin, and the asset must not be served with
+ * `Cross-Origin-Resource-Policy: same-origin`. Ignored in live (`src`)
+ * mode, where the proxy injects the bridge.
+ */
+ bridgeScriptUrl?: string;
+ /**
+ * How long to wait for the bridge's `ready` after each document load on
+ * the `bridgeScriptUrl` path before the surface shows an error state.
+ * Default 5000 ms. No timer runs on the inline path.
+ */
+ bridgeReadyTimeoutMs?: number;
+ /** The bridge could not be established on the `bridgeScriptUrl` path (no
+ * ready within the timeout, or a protocol version mismatch). The surface
+ * shows its own banner as well; this lets the host react (telemetry, a
+ * retry affordance). Never called on the inline path. */
+ onBridgeUnavailable?: (info: BridgeUnavailableInfo) => void;
}
/**
@@ -263,6 +326,9 @@ export const HtmlViewer = forwardRef(
maxAdditionalTargets,
scrollBehavior,
title = "HTML Plan Viewer",
+ bridgeScriptUrl,
+ bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
+ onBridgeUnavailable,
},
ref,
) => {
@@ -320,6 +386,18 @@ export const HtmlViewer = forwardRef(
// themselves); arbitrary HTML renders untouched, like a standalone tab.
const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
+ // The URL path is srcdoc-only: live mode has the proxy inject the bridge.
+ // Resolved against THIS document's base before it is written into the
+ // srcdoc, so a framed page's own can never re-anchor it.
+ const bridgeUrl = useMemo(
+ () => (!liveMode && bridgeScriptUrl
+ ? resolveBridgeScriptUrl(bridgeScriptUrl, document.baseURI)
+ : undefined),
+ [liveMode, bridgeScriptUrl],
+ );
+ const bridgeUrlRef = useRef(bridgeUrl);
+ bridgeUrlRef.current = bridgeUrl;
+
const srcdoc = useMemo(() => {
if (liveMode) return undefined; // src mode: the proxy injects the bridge
const injection = buildSrcdocInjection({
@@ -327,9 +405,46 @@ export const HtmlViewer = forwardRef(
isLight: isLightTheme(),
hostTheme,
diffActive: !!diffActive,
+ bridgeScriptUrl: bridgeUrl,
});
return injectIntoHead(rawHtml, injection);
- }, [liveMode, rawHtml, hostTheme, diffActive]);
+ }, [liveMode, rawHtml, hostTheme, diffActive, bridgeUrl]);
+
+ // Error state for the bridgeScriptUrl path only: the inline path never
+ // sets it (no timer, and a version mismatch there can only be a forged
+ // message, which is warned about and otherwise ignored).
+ const [bridgeError, setBridgeError] = useState(null);
+ // A version-mismatch banner is dismissible (the older bridge keeps
+ // working); reset whenever the error itself changes.
+ const [bridgeErrorDismissed, setBridgeErrorDismissed] = useState(false);
+ const readyTimerRef = useRef | null>(null);
+ const onBridgeUnavailableRef = useRef(onBridgeUnavailable);
+ onBridgeUnavailableRef.current = onBridgeUnavailable;
+ // The timeout is read through a ref at arming time: the timer is armed
+ // once per document load (URL or srcdoc change), never re-armed by a
+ // later prop change, so a host adjusting bridgeReadyTimeoutMs after the
+ // bridge is ready can never produce a false timeout.
+ const bridgeReadyTimeoutMsRef = useRef(bridgeReadyTimeoutMs);
+ bridgeReadyTimeoutMsRef.current = bridgeReadyTimeoutMs;
+ useEffect(() => {
+ if (!bridgeUrl || srcdoc === undefined) return;
+ setBridgeError(null);
+ setBridgeErrorDismissed(false);
+ const url = bridgeUrl;
+ const timeoutMs = bridgeReadyTimeoutMsRef.current;
+ readyTimerRef.current = setTimeout(() => {
+ readyTimerRef.current = null;
+ setBridgeError({ kind: "timeout", url, timeoutMs });
+ }, timeoutMs);
+ return () => {
+ if (readyTimerRef.current !== null) clearTimeout(readyTimerRef.current);
+ readyTimerRef.current = null;
+ };
+ }, [bridgeUrl, srcdoc]);
+ useEffect(() => {
+ setBridgeErrorDismissed(false);
+ if (bridgeError) onBridgeUnavailableRef.current?.(bridgeError);
+ }, [bridgeError]);
const handleResize = useCallback((height: number) => {
if (liveMode) return; // live surfaces are full-viewport; height is ignored
@@ -516,6 +631,33 @@ export const HtmlViewer = forwardRef(
const live = liveSessionRef.current;
if (live && rejectsLiveMessage(live, e.origin, e.data)) return;
if (isBridgeReadyMessage(e.data)) {
+ // Protocol stamp: one console warning on drift, naming both
+ // versions. The ready is still honored (an older bridge answers
+ // every message shape it knows); on the bridgeScriptUrl path the
+ // surface additionally shows its error banner, because there the
+ // drift is a real deployment state (a cached asset from a previous
+ // package version) rather than a forged message.
+ const verdict = checkBridgeProtocolVersion(e.data);
+ const url = bridgeUrlRef.current;
+ if (!verdict.ok) {
+ console.warn(formatBridgeProtocolWarning(verdict, url));
+ }
+ if (url) {
+ if (readyTimerRef.current !== null) {
+ clearTimeout(readyTimerRef.current);
+ readyTimerRef.current = null;
+ }
+ setBridgeError(
+ verdict.ok
+ ? null
+ : {
+ kind: "version-mismatch",
+ url,
+ expectedVersion: verdict.expected,
+ reportedVersion: verdict.reported,
+ },
+ );
+ }
setIframeReadyVersion((version) => version + 1);
setVimBridgePhase("inactive");
setVimHudCommand(null);
@@ -916,6 +1058,37 @@ export const HtmlViewer = forwardRef(
{actionButtons}
)}
+ {/* bridgeScriptUrl path only: the bridge did not come up (no
+ ready within the timeout, or a stale asset's version). Floated
+ over the top of the iframe so it never changes the layout the
+ page renders in; the page itself stays visible. */}
+ {bridgeError && !bridgeErrorDismissed && (
+
+ {formatBridgeUnavailableMessage(bridgeError)}
+ {/* Only the mismatch state is dismissible: the older bridge
+ still works there. A timeout leaves a dead surface, so
+ that banner stays. Inline styles on purpose: hosts that
+ build the guides.show viewer scan this file for utility
+ classes, and this banner must not grow that stylesheet. */}
+ {bridgeError.kind === "version-mismatch" && (
+
+ )}
+
+ )}
{/* Live proxied-app mode navigates a real loopback origin: no
sandbox (the user's own app needs cookies, storage, and
same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
diff --git a/packages/ui/components/html-viewer/HtmlViewer.vimHud.test.tsx b/packages/ui/components/html-viewer/HtmlViewer.vimHud.test.tsx
index cf75b5916..902c79994 100644
--- a/packages/ui/components/html-viewer/HtmlViewer.vimHud.test.tsx
+++ b/packages/ui/components/html-viewer/HtmlViewer.vimHud.test.tsx
@@ -3,6 +3,7 @@ import React from 'react';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { AnnotationType } from '../../types';
+import { BRIDGE_PROTOCOL_VERSION } from './bridge-script';
const hasDom = typeof document !== 'undefined';
const htmlViewerModule = hasDom ? await import('./HtmlViewer') : null;
@@ -66,7 +67,7 @@ describe.if(hasDom)('HtmlViewer Vim HUD bridge', () => {
}));
};
- act(() => dispatchBridgeMessage({ type: 'plannotator-bridge-ready' }));
+ act(() => dispatchBridgeMessage({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION }));
act(() => dispatchBridgeMessage({
type: 'plannotator-bridge-unanchored',
ids: ['removed-text'],
diff --git a/packages/ui/components/html-viewer/bridge-script.ts b/packages/ui/components/html-viewer/bridge-script.ts
index d82d7b0a6..9acc091f4 100644
--- a/packages/ui/components/html-viewer/bridge-script.ts
+++ b/packages/ui/components/html-viewer/bridge-script.ts
@@ -210,6 +210,18 @@ body[data-plannotator-vim-focus-owner]:focus {
}
`;
+/**
+ * Bridge protocol version. Stamped on the bridge's `ready` message
+ * (`protocolVersion`) and compared by the parent (HtmlViewer) against this
+ * same constant. Bump it whenever a message shape changes in a way an older
+ * bridge or an older parent would misread. The inline srcdoc path and the
+ * live proxy always ship the bridge from the same bundle as the parent, so
+ * they match by construction; the check exists for hosts that serve the
+ * generated `bridge-script.asset.js` separately (`bridgeScriptUrl`), where a
+ * cached asset from a previous package version can outlive the parent code.
+ */
+export const BRIDGE_PROTOCOL_VERSION = 1;
+
export const BRIDGE_SCRIPT = `(function() {
var PREFIX = 'plannotator-bridge-';
@@ -4572,7 +4584,7 @@ export const BRIDGE_SCRIPT = `(function() {
// pinpoint: show the cursor affordance immediately instead of waiting for
// the parent's first set-input-method/set-annotate-mode round trip.
updatePinpointCursor();
- var readyMsg = { type: PREFIX + 'ready' };
+ var readyMsg = { type: PREFIX + 'ready', protocolVersion: ${BRIDGE_PROTOCOL_VERSION} };
if (LIVE) readyMsg.pageUrl = currentPageUrl();
postToParent(readyMsg);
}
diff --git a/packages/ui/components/html-viewer/bridgeAsset.test.ts b/packages/ui/components/html-viewer/bridgeAsset.test.ts
new file mode 100644
index 000000000..56a01d72c
--- /dev/null
+++ b/packages/ui/components/html-viewer/bridgeAsset.test.ts
@@ -0,0 +1,223 @@
+/**
+ * The bridge-as-asset seam, package side (no DOM registration needed):
+ *
+ * - the generated `bridge-script.asset.js` is byte-for-byte `BRIDGE_SCRIPT`
+ * and the generated `bridge-script.lite.ts` carries the other exports
+ * unchanged with the literal stubbed (a generator that drifts from the
+ * source module would ship a bridge that disagrees with the parent);
+ * - the package manifest wires both files (exports subpaths, `files`,
+ * `prepack`) so `bun pm pack` ships them and a `?url` import resolves;
+ * - the srcdoc injection has ONE bridge script element: inline by default,
+ * ``)).toBe(true);
+ expect(injection.split("`);
+ // An empty string is "absent": a host misconfiguration must not produce
+ // `);
+ });
+
+ test("URL path: a classic `);
+ expect(injection).not.toContain(BRIDGE_SCRIPT);
+ expect(injection).not.toContain("crossorigin");
+ });
+
+ test("the URL is attribute-escaped so it cannot break out of the tag", () => {
+ const tag = buildBridgeScriptTag('/b.js" onerror="alert(1)');
+ expect(tag).toBe('');
+ });
+
+ test("the URL resolves against the parent base, never the framed page's ", () => {
+ const parent = "https://host.example/workspace/doc/42";
+ expect(resolveBridgeScriptUrl("/assets/bridge.abc.js", parent)).toBe("https://host.example/assets/bridge.abc.js");
+ expect(resolveBridgeScriptUrl("./bridge.js", parent)).toBe("https://host.example/workspace/doc/bridge.js");
+ expect(resolveBridgeScriptUrl("https://cdn.example/b.js", parent)).toBe("https://cdn.example/b.js");
+ // Unparsable input is passed through rather than thrown at render.
+ expect(resolveBridgeScriptUrl("http://[bad", "not a url")).toBe("http://[bad");
+
+ // A page carrying its own still precedes the injected tag
+ // (end of ); the resolved src is absolute and unaffected by it.
+ const page = 't';
+ const doc = injectIntoHead(page, buildSrcdocInjection({
+ ...base,
+ bridgeScriptUrl: resolveBridgeScriptUrl("/assets/bridge.abc.js", parent),
+ }));
+ expect(doc).toContain('');
+ expect(doc.indexOf(" {
+ const page = "t
x
";
+ for (const bridgeScriptUrl of [undefined, "/assets/bridge.js"]) {
+ const doc = injectIntoHead(page, buildSrcdocInjection({ ...base, bridgeScriptUrl }));
+ expect(doc).not.toMatch(/]*http-equiv/i);
+ }
+ // Nor does the bridge itself write one at runtime.
+ expect(BRIDGE_SCRIPT).not.toMatch(/http-equiv/i);
+ expect(BRIDGE_SCRIPT).not.toMatch(/content-security-policy/i);
+ });
+});
+
+describe("bridge protocol version", () => {
+ /** Run the real bridge in an isolated happy-dom window whose `parent` is a
+ * spy, so the test sees exactly what a srcdoc frame would post. happy-dom
+ * is reached through the registrator's own dependency so no new package
+ * dependency is needed; the global document (if any) is untouched. */
+ async function runBridgeIsolated(): Promise {
+ const registrator = Bun.resolveSync("@happy-dom/global-registrator", uiRoot);
+ const happyDom = Bun.resolveSync("happy-dom", dirname(registrator));
+ const { Window } = (await import(happyDom)) as { Window: new (o: { url: string }) => Record };
+ const win = new Window({ url: "about:srcdoc" });
+ (win.document as { write: (s: string) => void }).write("
hi
");
+ const posted: unknown[] = [];
+ const parent = { postMessage: (message: unknown) => posted.push(message) };
+ const names = [
+ "window", "document", "parent", "top", "self", "globalThis", "location",
+ "ResizeObserver", "MutationObserver", "getComputedStyle", "requestAnimationFrame",
+ "cancelAnimationFrame", "setTimeout", "clearTimeout", "Node", "Element",
+ "HTMLElement", "performance", "getSelection", "navigator",
+ ];
+ const values = names.map((name) =>
+ name === "parent" ? parent
+ : name === "top" || name === "self" || name === "globalThis" ? win
+ : win[name],
+ );
+ try {
+ new Function(...names, BRIDGE_SCRIPT)(...values);
+ } finally {
+ const happy = win.happyDOM as { close?: () => Promise } | undefined;
+ await happy?.close?.();
+ }
+ return posted;
+ }
+
+ test("the executed bridge posts a ready stamped with BRIDGE_PROTOCOL_VERSION", async () => {
+ const posted = await runBridgeIsolated();
+ const ready = posted.find(
+ (m) => typeof m === "object" && m !== null && (m as { type?: unknown }).type === "plannotator-bridge-ready",
+ ) as { protocolVersion?: unknown } | undefined;
+ expect(ready).toBeDefined();
+ expect(ready!.protocolVersion).toBe(BRIDGE_PROTOCOL_VERSION);
+ // The parent's check accepts the real bridge's ready as-is.
+ expect(checkBridgeProtocolVersion(ready).ok).toBe(true);
+ });
+
+ test("a stale asset's ready (no stamp, or another version) is a detected mismatch naming both versions", () => {
+ const stale = checkBridgeProtocolVersion({ type: "plannotator-bridge-ready" });
+ expect(stale).toEqual({ ok: false, expected: BRIDGE_PROTOCOL_VERSION, reported: undefined });
+ const other = checkBridgeProtocolVersion({
+ type: "plannotator-bridge-ready",
+ protocolVersion: BRIDGE_PROTOCOL_VERSION + 1,
+ });
+ expect(other.ok).toBe(false);
+ expect(other.reported).toBe(BRIDGE_PROTOCOL_VERSION + 1);
+ // Non-numeric stamps are not a version.
+ expect(checkBridgeProtocolVersion({ type: "plannotator-bridge-ready", protocolVersion: "1" }).ok).toBe(false);
+
+ const warning = formatBridgeProtocolWarning(other, "https://h/bridge.js");
+ expect(warning).toContain(`expects ${BRIDGE_PROTOCOL_VERSION}`);
+ expect(warning).toContain(`reported ${BRIDGE_PROTOCOL_VERSION + 1}`);
+ expect(warning).toContain("https://h/bridge.js");
+ expect(formatBridgeProtocolWarning(stale)).toContain("reported none");
+ });
+});
diff --git a/packages/ui/components/html-viewer/htmlLiveProtocol.test.tsx b/packages/ui/components/html-viewer/htmlLiveProtocol.test.tsx
index f0867cb13..2dd5217a9 100644
--- a/packages/ui/components/html-viewer/htmlLiveProtocol.test.tsx
+++ b/packages/ui/components/html-viewer/htmlLiveProtocol.test.tsx
@@ -18,6 +18,7 @@ import React from 'react';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import type { Annotation } from '../../types';
+import { BRIDGE_PROTOCOL_VERSION } from './bridge-script';
const hasDom = typeof document !== 'undefined';
const hookModule = hasDom ? await import('./useHtmlAnnotation') : null;
@@ -176,7 +177,7 @@ describe.if(hasDom)('live parent side (HtmlViewer with src + liveSession)', () =
const { post, postedToIframe } = await mountLiveViewer({
annotations: [],
});
- await post({ type: 'plannotator-bridge-ready', pageUrl: '/', token: LIVE_TOKEN });
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/', token: LIVE_TOKEN });
expect(postedToIframe.length).toBeGreaterThan(0);
for (const posted of postedToIframe) {
expect(posted.data.token).toBe(LIVE_TOKEN);
@@ -190,10 +191,10 @@ describe.if(hasDom)('live parent side (HtmlViewer with src + liveSession)', () =
test('an unauthenticated ready is ignored; an authenticated one forwards its pageUrl', async () => {
const pages: string[] = [];
const { post, postedToIframe } = await mountLiveViewer({ onPageChange: (p) => pages.push(p) });
- await post({ type: 'plannotator-bridge-ready', pageUrl: '/spoofed' }, LIVE_ORIGIN);
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/spoofed' }, LIVE_ORIGIN);
expect(pages).toEqual([]);
expect(postedToIframe.length).toBe(0);
- await post({ type: 'plannotator-bridge-ready', pageUrl: '/dashboard?x=1', token: LIVE_TOKEN });
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/dashboard?x=1', token: LIVE_TOKEN });
expect(pages).toEqual(['/dashboard?x=1']);
});
@@ -227,7 +228,7 @@ describe.if(hasDom)('live parent side (HtmlViewer with src + liveSession)', () =
annotations: [pageAnn('on-home', '/'), pageAnn('on-about', '/about')],
currentPageUrl: '/',
});
- await post({ type: 'plannotator-bridge-ready', pageUrl: '/', token: LIVE_TOKEN });
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/', token: LIVE_TOKEN });
const restores = postedToIframe.filter((p) => p.data.type === 'plannotator-bridge-find-and-mark');
expect(restores.map((p) => p.data.id)).toEqual(['on-home']);
// Numbering still ships the FULL list (global numbers across pages).
@@ -300,7 +301,7 @@ describe.if(hasDom)('live parent side (HtmlViewer with src + liveSession)', () =
}));
});
};
- await post({ type: 'plannotator-bridge-ready', pageUrl: '/' });
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/' });
await post({ type: 'plannotator-bridge-unanchored', ids: ['home-1'] });
expect(received).toEqual([['home-1']]);
@@ -318,7 +319,7 @@ describe.if(hasDom)('live parent side (HtmlViewer with src + liveSession)', () =
test('the Interact/Annotate mode is pushed on EVERY bridge ready, so it survives page-change reloads and bridge re-injection', async () => {
const { post, postedToIframe } = await mountLiveViewer({ annotateModeActive: false });
- await post({ type: 'plannotator-bridge-ready', pageUrl: '/', token: LIVE_TOKEN });
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/', token: LIVE_TOKEN });
const modePosts = () =>
postedToIframe.filter((p) => p.data.type === 'plannotator-bridge-set-annotate-mode');
expect(modePosts().length).toBe(1);
@@ -327,7 +328,7 @@ describe.if(hasDom)('live parent side (HtmlViewer with src + liveSession)', () =
// again from a FRESH document: the mode must be re-established, not lost
// to the fresh bridge's default.
postedToIframe.length = 0;
- await post({ type: 'plannotator-bridge-ready', pageUrl: '/about', token: LIVE_TOKEN });
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/about', token: LIVE_TOKEN });
expect(modePosts().length).toBe(1);
expect(modePosts()[0]!.data.active).toBe(false);
});
diff --git a/packages/ui/components/html-viewer/htmlPinpointProtocol.test.tsx b/packages/ui/components/html-viewer/htmlPinpointProtocol.test.tsx
index fbf0399cc..ec8d6ed7a 100644
--- a/packages/ui/components/html-viewer/htmlPinpointProtocol.test.tsx
+++ b/packages/ui/components/html-viewer/htmlPinpointProtocol.test.tsx
@@ -14,6 +14,7 @@ import { act } from 'react';
import { createRoot } from 'react-dom/client';
import type { Annotation } from '../../types';
import { AnnotationType } from '../../types';
+import { BRIDGE_PROTOCOL_VERSION } from './bridge-script';
const hasDom = typeof document !== 'undefined';
const hookModule = hasDom ? await import('./useHtmlAnnotation') : null;
@@ -431,7 +432,7 @@ describe.if(hasDom)('ordered saved-annotation sync (placed-marker numbering)', (
await act(async () => {
window.dispatchEvent(new MessageEvent('message', {
source: iframe.contentWindow,
- data: { type: 'plannotator-bridge-ready' },
+ data: { type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION },
}));
});
};
@@ -1123,7 +1124,7 @@ describe.if(hasDom)('readOnly view-only contract', () => {
test('readOnly still restores markers and syncs export-matching numbers on ready', async () => {
const { post, postedToIframe } = await mountReadOnly([committed('ro-1'), committed('ro-2')]);
- await post({ type: 'plannotator-bridge-ready' });
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
const restores = postedToIframe.filter((m) => m.type === 'plannotator-bridge-find-and-mark');
expect(restores.map((m) => m.id)).toEqual(['ro-1', 'ro-2']);
@@ -1141,14 +1142,14 @@ describe.if(hasDom)('readOnly view-only contract', () => {
test('readOnly marker clicks still navigate via onSelectAnnotation', async () => {
const selected: Array = [];
const { post } = await mountReadOnly([committed('ro-1')], (id) => selected.push(id));
- await post({ type: 'plannotator-bridge-ready' });
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
await post({ type: 'plannotator-bridge-mark-click', id: 'ro-1' });
expect(selected).toEqual(['ro-1']);
});
test('readOnly ignores selection messages: no toolbar, no composer', async () => {
const { post } = await mountReadOnly([committed('ro-1')]);
- await post({ type: 'plannotator-bridge-ready' });
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
await post({
type: 'plannotator-bridge-selection',
text: 'Read-only target',
@@ -1274,7 +1275,7 @@ describe.if(hasDom)('unanchored report (trust boundary + delivery)', () => {
window.dispatchEvent(new MessageEvent('message', { source: iframe.contentWindow, data }));
});
};
- const ready = () => post({ type: 'plannotator-bridge-ready' });
+ const ready = () => post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
return { render, post, ready, added, postedToIframe };
}
@@ -1407,7 +1408,7 @@ describe.if(hasDom)('unanchored report (trust boundary + delivery)', () => {
// Host swapped: its list carries the server row only.
await render([pageRow('srv-1')]);
- await post({ type: 'plannotator-bridge-ready' });
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
await post({ type: MSG, ids: [localId, 'srv-1'] });
expect(received.at(-1)).toEqual(['srv-1']);
@@ -1467,7 +1468,7 @@ describe.if(hasDom)('Interact/Annotate mode on static (srcdoc) surfaces', () =>
test('static surfaces default to Annotate armed: set-annotate-mode active:true rides every ready', async () => {
const { post, postedToIframe } = await mountModeViewer();
- await post({ type: 'plannotator-bridge-ready' });
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
const modePosts = postedToIframe.filter((m) => m.type === 'plannotator-bridge-set-annotate-mode');
expect(modePosts.length).toBe(1);
expect(modePosts[0]!.active).toBe(true);
@@ -1475,7 +1476,7 @@ describe.if(hasDom)('Interact/Annotate mode on static (srcdoc) surfaces', () =>
test('a host-driven Interact state is pushed instead of the default', async () => {
const { post, postedToIframe } = await mountModeViewer({ annotateModeActive: false });
- await post({ type: 'plannotator-bridge-ready' });
+ await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
const modePosts = postedToIframe.filter((m) => m.type === 'plannotator-bridge-set-annotate-mode');
expect(modePosts.length).toBe(1);
expect(modePosts[0]!.active).toBe(false);
diff --git a/packages/ui/components/html-viewer/index.ts b/packages/ui/components/html-viewer/index.ts
index 6573a9a66..6c818c9df 100644
--- a/packages/ui/components/html-viewer/index.ts
+++ b/packages/ui/components/html-viewer/index.ts
@@ -1,4 +1,16 @@
-export { HtmlViewer, type HtmlViewerProps } from "./HtmlViewer";
+export {
+ DEFAULT_BRIDGE_READY_TIMEOUT_MS,
+ HtmlViewer,
+ formatBridgeUnavailableMessage,
+ type BridgeUnavailableInfo,
+ type HtmlViewerProps,
+} from "./HtmlViewer";
+export { BRIDGE_PROTOCOL_VERSION } from "./bridge-script";
+export {
+ checkBridgeProtocolVersion,
+ formatBridgeProtocolWarning,
+ type BridgeProtocolVerdict,
+} from "./useHtmlAnnotation";
export {
buildPersistedHtmlAnchor,
projectHostThreads,
diff --git a/packages/ui/components/html-viewer/srcdoc.ts b/packages/ui/components/html-viewer/srcdoc.ts
index 8715d65ab..5333c605d 100644
--- a/packages/ui/components/html-viewer/srcdoc.ts
+++ b/packages/ui/components/html-viewer/srcdoc.ts
@@ -96,6 +96,68 @@ export interface SrcdocInjectionOptions {
hostTheme: boolean;
/** The version-diff view is showing (rawHtml is htmlDiff output). */
diffActive: boolean;
+ /**
+ * Load the bridge through a classic ``;
+ }
+ if (!BRIDGE_SCRIPT) {
+ // Only reachable when a host aliased `./bridge-script` to the generated
+ // `bridge-script.lite` module (which stubs the inline literal) and then
+ // rendered an HtmlViewer without `bridgeScriptUrl`: an empty inline
+ // script would be a silently dead surface, so fail loudly instead.
+ throw new Error(
+ "@plannotator/ui HtmlViewer: the inline bridge script is stubbed out "
+ + "(bridge-script.lite alias) but no bridgeScriptUrl was passed.",
+ );
+ }
+ return ``;
}
/** The ``;
+ return `${buildBridgeScriptTag(bridgeScriptUrl)}`;
}
/**
@@ -126,6 +189,12 @@ export function buildSrcdocInjection({
* script and disables annotation entirely. The iframe `sandbox` attribute is
* the security boundary for the annotate surface; the page's CSP was written
* for its standalone context, so it is removed before injection.
+ *
+ * The package itself never adds a CSP `` to the srcdoc document (the
+ * injection is one `