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("${BRIDGE_SCRIPT}`); + // 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 `