fix(player): report and fail closed on runtime delivery errors (#3472)

* feat(player): report runtime data application

* fix(player): fail closed on runtime data delivery

* fix(player): close runtime delivery and sandbox gaps

* fix(player): satisfy runtime contract and CodeQL

* refactor(player): simplify runtime tag scanner

* style(player): apply repository formatter

* fix(player): type runtime tag boundaries

* fix(core): mint guest-local runtime-data ids in a separate space from host ids
This commit is contained in:
Vance Ingalls 2026-08-30 13:00:14 -07:00 committed by GitHub
parent 3337cc8990
commit 859ac622c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 632 additions and 58 deletions

View File

@ -107,6 +107,13 @@ jobs:
if: matrix.shard == 'parity' if: matrix.shard == 'parity'
uses: ./.github/actions/install-ffmpeg-linux uses: ./.github/actions/install-ffmpeg-linux
- name: Verify sandbox origin boundary (load shard only)
if: matrix.shard == 'load'
working-directory: packages/player
env:
PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
run: bun run test:browser-security
- name: Run player perf — ${{ matrix.shard }} (measure mode) - name: Run player perf — ${{ matrix.shard }} (measure mode)
working-directory: packages/player working-directory: packages/player
env: env:

View File

@ -50,10 +50,12 @@ describe("installRuntimeControlBridge", () => {
const deps = createMockDeps(); const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps); const handler = installRuntimeControlBridge(deps);
const payload = { version: 3, segments: [] }; const payload = { version: 3, segments: [] };
handler(makeControlMessage("set-runtime-data", { channel: "captions", payload })); handler(
handler(makeControlMessage("clear-runtime-data", { channel: "captions" })); makeControlMessage("set-runtime-data", { channel: "captions", payload, requestId: 41 }),
expect(deps.onSetRuntimeData).toHaveBeenCalledWith("captions", payload); );
expect(deps.onClearRuntimeData).toHaveBeenCalledWith("captions"); handler(makeControlMessage("clear-runtime-data", { channel: "captions", requestId: 42 }));
expect(deps.onSetRuntimeData).toHaveBeenCalledWith("captions", payload, 41);
expect(deps.onClearRuntimeData).toHaveBeenCalledWith("captions", 42);
}); });
it("dispatches stop-media command", () => { it("dispatches stop-media command", () => {

View File

@ -28,8 +28,8 @@ type BridgeDeps = {
) => void; ) => void;
onEnablePickMode: () => void; onEnablePickMode: () => void;
onDisablePickMode: () => void; onDisablePickMode: () => void;
onSetRuntimeData?: (channel: string, payload: unknown) => void; onSetRuntimeData?: (channel: string, payload: unknown, requestId?: number) => void;
onClearRuntimeData?: (channel: string) => void; onClearRuntimeData?: (channel: string, requestId?: number) => void;
getCanonicalFps: () => number; getCanonicalFps: () => number;
}; };
@ -78,10 +78,11 @@ const CONTROL_HANDLERS: Record<string, ControlHandler> = {
"disable-pick-mode": (_d, deps) => deps.onDisablePickMode(), "disable-pick-mode": (_d, deps) => deps.onDisablePickMode(),
"flash-elements": (data) => handleFlashElements(data), "flash-elements": (data) => handleFlashElements(data),
"set-runtime-data": (data, deps) => { "set-runtime-data": (data, deps) => {
if (typeof data.channel === "string") deps.onSetRuntimeData?.(data.channel, data.payload); if (typeof data.channel === "string")
deps.onSetRuntimeData?.(data.channel, data.payload, data.requestId);
}, },
"clear-runtime-data": (data, deps) => { "clear-runtime-data": (data, deps) => {
if (typeof data.channel === "string") deps.onClearRuntimeData?.(data.channel); if (typeof data.channel === "string") deps.onClearRuntimeData?.(data.channel, data.requestId);
}, },
}; };

View File

@ -67,7 +67,12 @@ import { shouldAttemptPeriodicTimelineBind } from "./timelineRebindPolicy";
import { installStudioCustomEase } from "./customEase"; import { installStudioCustomEase } from "./customEase";
import { parseNumeric } from "./startExpression"; import { parseNumeric } from "./startExpression";
import { parseStrictFiniteTimingNumber } from "./playbackRate"; import { parseStrictFiniteTimingNumber } from "./playbackRate";
import { clearRuntimeData, setRuntimeData, setRuntimeDataErrorReporter } from "./runtimeData"; import {
clearRuntimeData,
setRuntimeData,
setRuntimeDataAppliedReporter,
setRuntimeDataErrorReporter,
} from "./runtimeData";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end"; const AUTHORED_END_ATTR = "data-hf-authored-end";
@ -133,14 +138,23 @@ export function initSandboxRuntimeModular(): void {
// Own the analytics bridge before any best-effort runtime installation so // Own the analytics bridge before any best-effort runtime installation so
// early failures are observable instead of disappearing before player setup. // early failures are observable instead of disappearing before player setup.
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void); initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
setRuntimeDataErrorReporter((channel, error) => { setRuntimeDataErrorReporter((channel, requestId, error) => {
postRuntimeMessage({ postRuntimeMessage({
source: "hf-preview", source: "hf-preview",
type: "runtime-data-error", type: "runtime-data-error",
channel, channel,
requestId,
message: error instanceof Error ? error.message : String(error), message: error instanceof Error ? error.message : String(error),
}); });
}); });
setRuntimeDataAppliedReporter((channel, requestId) => {
postRuntimeMessage({
source: "hf-preview",
type: "runtime-data-applied",
channel,
requestId,
});
});
// SDK moveElement edits must render even when no usable GSAP timeline ever // SDK moveElement edits must render even when no usable GSAP timeline ever
// binds (CSS/WAAPI-animated or fully static compositions) — apply at init. // binds (CSS/WAAPI-animated or fully static compositions) — apply at init.
// This runs at DOMContentLoaded, after inline composition scripts have // This runs at DOMContentLoaded, after inline composition scripts have

View File

@ -4,6 +4,7 @@ import {
registerRuntimeDataHandler, registerRuntimeDataHandler,
resetRuntimeDataForTests, resetRuntimeDataForTests,
setRuntimeData, setRuntimeData,
setRuntimeDataAppliedReporter,
setRuntimeDataErrorReporter, setRuntimeDataErrorReporter,
} from "./runtimeData"; } from "./runtimeData";
@ -48,6 +49,63 @@ describe("runtime data registry", () => {
throw new Error("attach failed"); throw new Error("attach failed");
}); });
expect(() => setRuntimeData("captions", {})).not.toThrow(); expect(() => setRuntimeData("captions", {})).not.toThrow();
expect(reporter).toHaveBeenCalledWith("captions", expect.any(Error)); expect(reporter).toHaveBeenCalledWith("captions", expect.any(Number), expect.any(Error));
});
it("reports asynchronous completion and rejection", async () => {
const applied = vi.fn();
const failed = vi.fn();
setRuntimeDataAppliedReporter(applied);
setRuntimeDataErrorReporter(failed);
registerRuntimeDataHandler("captions", async (payload) => {
await Promise.resolve();
if (payload === "bad") throw new Error("async attach failed");
});
setRuntimeData("captions", "good");
await vi.waitFor(() => expect(applied).toHaveBeenCalledWith("captions", expect.any(Number)));
setRuntimeData("captions", "bad");
await vi.waitFor(() =>
expect(failed).toHaveBeenCalledWith("captions", expect.any(Number), expect.any(Error)),
);
});
it("reports only the latest concurrent delivery on a channel", async () => {
const applied = vi.fn();
setRuntimeDataAppliedReporter(applied);
const resolvers: Array<() => void> = [];
registerRuntimeDataHandler(
"captions",
() => new Promise<void>((resolve) => resolvers.push(resolve)),
);
setRuntimeData("captions", "first", 101);
setRuntimeData("captions", "latest", 102);
resolvers[1]?.();
await vi.waitFor(() => expect(applied).toHaveBeenCalledWith("captions", 102));
resolvers[0]?.();
await Promise.resolve();
expect(applied).toHaveBeenCalledTimes(1);
});
it("never reports a composition-side delivery under a pending host request id", async () => {
const applied = vi.fn();
setRuntimeDataAppliedReporter(applied);
const resolvers: Array<() => void> = [];
registerRuntimeDataHandler(
"captions",
() => new Promise<void>((resolve) => resolvers.push(resolve)),
);
// The host mints id 1 and waits on it; the composition then calls the two-argument
// public form, which mints an id of its own.
setRuntimeData("captions", "first", 1);
setRuntimeData("captions", "latest");
resolvers[1]?.();
await vi.waitFor(() => expect(applied).toHaveBeenCalledTimes(1));
const [, reportedId] = applied.mock.calls[0] ?? [];
expect(reportedId).not.toBe(1);
}); });
}); });

View File

@ -1,21 +1,56 @@
export type RuntimeDataHandler = (payload: unknown) => void; export type RuntimeDataHandler = (payload: unknown) => void | Promise<void>;
export type RuntimeDataErrorReporter = (channel: string, error: unknown) => void; export type RuntimeDataErrorReporter = (channel: string, requestId: number, error: unknown) => void;
export type RuntimeDataAppliedReporter = (channel: string, requestId: number) => void;
const retained = new Map<string, unknown>(); type RetainedRuntimeData = {
payload: unknown;
requestId: number;
generation: number;
};
const retained = new Map<string, RetainedRuntimeData>();
const handlers = new Map<string, RuntimeDataHandler>(); const handlers = new Map<string, RuntimeDataHandler>();
const generations = new Map<string, number>();
let reportError: RuntimeDataErrorReporter = () => undefined; let reportError: RuntimeDataErrorReporter = () => undefined;
let reportApplied: RuntimeDataAppliedReporter = () => undefined;
let localRequestId = 0;
function validChannel(channel: string): boolean { function validChannel(channel: string): boolean {
return /^[a-z][a-z0-9-]{0,63}$/.test(channel); return /^[a-z][a-z0-9-]{0,63}$/.test(channel);
} }
function deliver(channel: string, payload: unknown): void { function nextGeneration(channel: string): number {
const generation = (generations.get(channel) ?? 0) + 1;
generations.set(channel, generation);
return generation;
}
function resolveRequestId(requestId: number | undefined): number {
if (typeof requestId === "number" && Number.isSafeInteger(requestId) && requestId > 0)
return requestId;
// Guest-local ids count down so they can never collide with a host id, which is
// required above to be positive. A shared id space lets a composition-side call
// report `applied` under a host request's id while that host payload is still in flight.
localRequestId -= 1;
return localRequestId;
}
function deliver(channel: string, retainedData: RetainedRuntimeData): void {
const handler = handlers.get(channel); const handler = handlers.get(channel);
if (!handler) return; if (!handler) return;
const isCurrent = () =>
generations.get(channel) === retainedData.generation && handlers.get(channel) === handler;
try { try {
handler(payload); void Promise.resolve(handler(retainedData.payload)).then(
() => {
if (isCurrent()) reportApplied(channel, retainedData.requestId);
},
(error) => {
if (isCurrent()) reportError(channel, retainedData.requestId, error);
},
);
} catch (error) { } catch (error) {
reportError(channel, error); if (isCurrent()) reportError(channel, retainedData.requestId, error);
} }
} }
@ -23,16 +58,29 @@ export function setRuntimeDataErrorReporter(reporter: RuntimeDataErrorReporter):
reportError = reporter; reportError = reporter;
} }
export function setRuntimeData(channel: string, payload: unknown): void { export function setRuntimeDataAppliedReporter(reporter: RuntimeDataAppliedReporter): void {
if (!validChannel(channel)) return; reportApplied = reporter;
retained.set(channel, payload);
deliver(channel, payload);
} }
export function clearRuntimeData(channel: string): void { export function setRuntimeData(channel: string, payload: unknown, requestId?: number): void {
if (!validChannel(channel)) return;
const retainedData = {
payload,
requestId: resolveRequestId(requestId),
generation: nextGeneration(channel),
};
retained.set(channel, retainedData);
deliver(channel, retainedData);
}
export function clearRuntimeData(channel: string, requestId?: number): void {
if (!validChannel(channel)) return; if (!validChannel(channel)) return;
retained.delete(channel); retained.delete(channel);
deliver(channel, undefined); deliver(channel, {
payload: undefined,
requestId: resolveRequestId(requestId),
generation: nextGeneration(channel),
});
} }
export function registerRuntimeDataHandler( export function registerRuntimeDataHandler(
@ -42,7 +90,8 @@ export function registerRuntimeDataHandler(
if (!validChannel(channel)) if (!validChannel(channel))
throw new Error(`Invalid HyperFrames runtime-data channel: ${channel}`); throw new Error(`Invalid HyperFrames runtime-data channel: ${channel}`);
handlers.set(channel, handler); handlers.set(channel, handler);
if (retained.has(channel)) deliver(channel, retained.get(channel)); const retainedData = retained.get(channel);
if (retainedData) deliver(channel, retainedData);
return () => { return () => {
if (handlers.get(channel) === handler) handlers.delete(channel); if (handlers.get(channel) === handler) handlers.delete(channel);
}; };
@ -51,5 +100,8 @@ export function registerRuntimeDataHandler(
export function resetRuntimeDataForTests(): void { export function resetRuntimeDataForTests(): void {
retained.clear(); retained.clear();
handlers.clear(); handlers.clear();
generations.clear();
localRequestId = 0;
reportError = () => undefined; reportError = () => undefined;
reportApplied = () => undefined;
} }

View File

@ -175,9 +175,17 @@ export type RuntimeDataErrorMessage = {
source: "hf-preview"; source: "hf-preview";
type: "runtime-data-error"; type: "runtime-data-error";
channel: string; channel: string;
requestId: number;
message: string; message: string;
}; };
export type RuntimeDataAppliedMessage = {
source: "hf-preview";
type: "runtime-data-applied";
channel: string;
requestId: number;
};
/** /**
* Analytics events emitted by the runtime. * Analytics events emitted by the runtime.
* *
@ -229,6 +237,7 @@ export type RuntimeOutboundMessage =
| RuntimeMediaAutoplayBlockedMessage | RuntimeMediaAutoplayBlockedMessage
| RuntimeReadyMessage | RuntimeReadyMessage
| RuntimeDataErrorMessage | RuntimeDataErrorMessage
| RuntimeDataAppliedMessage
| RuntimeAnalyticsMessage | RuntimeAnalyticsMessage
| RuntimePerformanceMessage | RuntimePerformanceMessage
| RuntimeGroupLevelsMessage; | RuntimeGroupLevelsMessage;
@ -332,6 +341,7 @@ export type RuntimeGsapSetVars = Record<string, string | number | boolean | null
type RuntimeDataControlFields = { type RuntimeDataControlFields = {
channel?: string; channel?: string;
payload?: unknown; payload?: unknown;
requestId?: number;
}; };
type RuntimeBridgeControlAction = type RuntimeBridgeControlAction =

View File

@ -35,7 +35,7 @@ declare global {
channel: string, channel: string,
handler: (payload: unknown) => void, handler: (payload: unknown) => void,
) => () => void; ) => () => void;
setRuntimeData?: (channel: string, payload: unknown) => void; setRuntimeData?: (channel: string, payload: unknown, requestId?: number) => void;
clearRuntimeData?: (channel: string) => void; clearRuntimeData?: (channel: string) => void;
[key: string]: unknown; [key: string]: unknown;
}; };

View File

@ -132,9 +132,40 @@ player.shaderLoading; // "composition" | "player" | "none" (read/write)
player.iframeElement; // HTMLIFrameElement (read-only) player.iframeElement; // HTMLIFrameElement (read-only)
``` ```
## Runtime data delivery
`setRuntimeData(channel, payload)` clones and retains the payload, then delivers it after the
composition runtime is ready. Invalid channels and non-cloneable payloads throw synchronously.
Failures after the call returns are reported with `runtimedataerror`; successful application is
reported with `runtimedataapplied`. Both events include `{ channel, requestId }`, and errors also
include `message`. Listen for both outcomes when delivery matters:
```js
player.addEventListener("runtimedataapplied", ({ detail }) => {
console.log("applied", detail.channel, detail.requestId);
});
player.addEventListener("runtimedataerror", ({ detail }) => {
console.error("not applied", detail.channel, detail.requestId, detail.message);
});
player.setRuntimeData("captions", captionData);
```
Only the latest in-flight update for a channel can emit a completion. A missing runtime response,
iframe teardown, or bridge delivery failure emits `runtimedataerror` instead of remaining pending
indefinitely.
## Advanced: iframe access ## Advanced: iframe access
The composition runs inside a sandboxed `<iframe>` in the player's Shadow DOM. For most use cases you don't need direct access — the JavaScript API above is enough. But if you're building an editor, recorder, or custom timeline that needs to inspect the composition's DOM or read its `__player` / `__timelines` runtime objects, use the `iframeElement` getter: The composition runs inside a sandboxed `<iframe>` in the player's Shadow DOM. The default sandbox includes `allow-same-origin` for editor, recorder, and custom-timeline integrations that inspect the composition DOM. That is a trusted-content mode, not an isolation boundary: same-origin composition code can reach the embedding page.
For read-only or message-bridge integrations, set `sandbox-origin="opaque"`. Any non-null value is
treated as opaque so a typo cannot weaken isolation. Changing the attribute reloads the active
composition because browser sandbox changes take effect only on navigation. Opaque mode removes
`allow-same-origin` while retaining scripts, and prevents the composition from reading unrelated
parent DOM. Direct `contentDocument`, `__player`, and `__timelines` access is intentionally
unavailable in that mode.
If you are building a trusted editor integration that needs direct access, use the `iframeElement` getter:
```js ```js
const player = document.querySelector("hyperframes-player"); const player = document.querySelector("hyperframes-player");

View File

@ -31,6 +31,7 @@
"build": "tsup && node scripts/verify-runtime-pin.mjs", "build": "tsup && node scripts/verify-runtime-pin.mjs",
"typecheck": "tsc --noEmit && tsc --noEmit -p tests/perf/tsconfig.json", "typecheck": "tsc --noEmit && tsc --noEmit -p tests/perf/tsconfig.json",
"test": "vitest run", "test": "vitest run",
"test:browser-security": "bun run tests/browser/sandbox-origin.ts",
"perf": "bun run tests/perf/index.ts" "perf": "bun run tests/perf/index.ts"
}, },
"dependencies": { "dependencies": {

View File

@ -2481,7 +2481,7 @@ describe("HyperframesPlayer retained runtime data", () => {
player.setRuntimeData("captions", { words: ["direct"] }); player.setRuntimeData("captions", { words: ["direct"] });
expect(direct).toHaveBeenCalledWith("captions", { words: ["direct"] }); expect(direct).toHaveBeenCalledWith("captions", { words: ["direct"] }, expect.any(Number));
expect(runtimeCalls()).toHaveLength(0); expect(runtimeCalls()).toHaveLength(0);
}); });
@ -2497,7 +2497,138 @@ describe("HyperframesPlayer retained runtime data", () => {
expect(player.iframeElement.referrerPolicy).toBe("no-referrer"); expect(player.iframeElement.referrerPolicy).toBe("no-referrer");
}); });
it("supports an opaque-origin sandbox for hosts that do not need direct iframe DOM access", () => {
player.setAttribute("sandbox-origin", "opaque");
expect(player.iframeElement.sandbox.contains("allow-scripts")).toBe(true);
expect(player.iframeElement.sandbox.contains("allow-same-origin")).toBe(false);
expect(player.iframeElement.sandbox.contains("allow-top-navigation")).toBe(false);
player.removeAttribute("sandbox-origin");
expect(player.iframeElement.sandbox.contains("allow-same-origin")).toBe(true);
});
it("treats every non-null sandbox-origin value as restrictive", () => {
player.setAttribute("sandbox-origin", "opaqu");
expect(player.iframeElement.sandbox.contains("allow-same-origin")).toBe(false);
});
it("rejects payloads that structuredClone cannot transfer", () => { it("rejects payloads that structuredClone cannot transfer", () => {
expect(() => player.setRuntimeData("captions", () => undefined)).toThrow(); expect(() => player.setRuntimeData("captions", () => undefined)).toThrow();
}); });
it("fails closed when structuredClone is unavailable", () => {
const original = globalThis.structuredClone;
Object.defineProperty(globalThis, "structuredClone", {
configurable: true,
value: undefined,
});
try {
expect(() => player.setRuntimeData("captions", { words: ["unsafe"] })).toThrow(
/requires structuredClone support/,
);
player._onMessage(readyMessage());
expect(runtimeCalls()).toHaveLength(0);
} finally {
Object.defineProperty(globalThis, "structuredClone", {
configurable: true,
value: original,
});
}
});
it("reports postMessage delivery failures instead of silently dropping runtime data", () => {
player._onMessage(readyMessage());
postSpy.mockImplementation(() => {
throw new DOMException("payload cannot be cloned", "DataCloneError");
});
const errors: CustomEvent[] = [];
player.addEventListener("runtimedataerror", (event) => errors.push(event as CustomEvent));
player.setRuntimeData("captions", { words: ["value"] });
expect(errors).toHaveLength(1);
expect(errors[0]?.detail).toMatchObject({
channel: "captions",
requestId: expect.any(Number),
message: "payload cannot be cloned",
});
});
it("reports a null iframe window as a delivery failure", () => {
player._onMessage(readyMessage());
Object.defineProperty(player.iframeElement, "contentWindow", {
configurable: true,
get: () => null,
});
const errors: CustomEvent[] = [];
player.addEventListener("runtimedataerror", (event) => errors.push(event as CustomEvent));
player.setRuntimeData("captions", { words: ["value"] });
expect(errors).toHaveLength(1);
expect(errors[0]?.detail).toMatchObject({
channel: "captions",
requestId: expect.any(Number),
message: "Composition iframe is unavailable",
});
});
it("reports a bounded error when the runtime never responds", () => {
vi.useFakeTimers();
try {
player._onMessage(readyMessage());
const errors: CustomEvent[] = [];
player.addEventListener("runtimedataerror", (event) => errors.push(event as CustomEvent));
player.setRuntimeData("captions", { words: ["value"] });
vi.advanceTimersByTime(10_000);
expect(errors).toHaveLength(1);
expect(errors[0]?.detail).toMatchObject({
channel: "captions",
requestId: expect.any(Number),
message: "Runtime data delivery timed out after 10000ms",
});
} finally {
vi.useRealTimers();
}
});
it("ignores a superseded completion and correlates the latest application", () => {
player._onMessage(readyMessage());
postSpy.mockClear();
const applied: CustomEvent[] = [];
player.addEventListener("runtimedataapplied", (event) => applied.push(event as CustomEvent));
player.setRuntimeData("captions", { words: ["first"] });
player.setRuntimeData("captions", { words: ["latest"] });
const requests = runtimeCalls().map((call) => (call[0] as { requestId: number }).requestId);
player._onMessage(
new MessageEvent("message", {
source: window,
data: {
source: "hf-preview",
type: "runtime-data-applied",
channel: "captions",
requestId: requests[0],
},
}),
);
expect(applied).toHaveLength(0);
player._onMessage(
new MessageEvent("message", {
source: window,
data: {
source: "hf-preview",
type: "runtime-data-applied",
channel: "captions",
requestId: requests[1],
},
}),
);
expect(applied).toHaveLength(1);
expect(applied[0]?.detail).toEqual({ channel: "captions", requestId: requests[1] });
});
}); });

View File

@ -29,6 +29,8 @@ import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol";
// production browsers. // production browsers.
const MIN_PLAYBACK_RATE = 0.1; const MIN_PLAYBACK_RATE = 0.1;
const MAX_PLAYBACK_RATE = 5; const MAX_PLAYBACK_RATE = 5;
const SANDBOX_ORIGIN_ATTR = "sandbox-origin";
const RUNTIME_DATA_DELIVERY_TIMEOUT_MS = 10_000;
export type ColorGradingTarget = export type ColorGradingTarget =
| string | string
@ -47,8 +49,13 @@ export type ColorGradingCompareState = {
}; };
type RuntimeDataBridge = { type RuntimeDataBridge = {
setRuntimeData?: (channel: string, payload: unknown) => void; setRuntimeData?: (channel: string, payload: unknown, requestId?: number) => void;
clearRuntimeData?: (channel: string) => void; clearRuntimeData?: (channel: string, requestId?: number) => void;
};
type PendingRuntimeDataDelivery = {
requestId: number;
timeoutId: number;
}; };
function clampPlaybackRate(rate: number): number { function clampPlaybackRate(rate: number): number {
@ -70,6 +77,7 @@ class HyperframesPlayer extends HTMLElement {
"poster", "poster",
"playback-rate", "playback-rate",
"audio-src", "audio-src",
SANDBOX_ORIGIN_ATTR,
SHADER_CAPTURE_SCALE_ATTR, SHADER_CAPTURE_SCALE_ATTR,
SHADER_LOADING_ATTR, SHADER_LOADING_ATTR,
]; ];
@ -104,6 +112,8 @@ class HyperframesPlayer extends HTMLElement {
private _runtimeFps = 30; private _runtimeFps = 30;
private _runtimeBridgeReady = false; private _runtimeBridgeReady = false;
private _runtimeData = new Map<string, unknown>(); private _runtimeData = new Map<string, unknown>();
private _runtimeDataRequestId = 0;
private _pendingRuntimeData = new Map<string, PendingRuntimeDataDelivery>();
constructor() { constructor() {
super(); super();
@ -163,6 +173,7 @@ class HyperframesPlayer extends HTMLElement {
} }
connectedCallback() { connectedCallback() {
this._applySandboxOriginPolicy();
this.resizeObserver.observe(this); this.resizeObserver.observe(this);
window.addEventListener("message", this._onMessage); window.addEventListener("message", this._onMessage);
this.iframe.addEventListener("load", this._onIframeLoad); this.iframe.addEventListener("load", this._onIframeLoad);
@ -200,24 +211,34 @@ class HyperframesPlayer extends HTMLElement {
this._paused = true; this._paused = true;
this._ready = false; this._ready = false;
this._runtimeBridgeReady = false; this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries("Player disconnected before runtime data was applied");
} }
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
attributeChangedCallback(name: string, _old: string | null, val: string | null) { attributeChangedCallback(name: string, oldVal: string | null, val: string | null) {
switch (name) { switch (name) {
case "src": case "src":
if (val) { if (val) {
this._ready = false; this._ready = false;
this._runtimeBridgeReady = false; this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries(
"Composition navigated before runtime data was applied",
);
this.iframe.src = prepareSrcForElement(this, val); this.iframe.src = prepareSrcForElement(this, val);
} }
break; break;
case "srcdoc": case "srcdoc":
this._ready = false; this._ready = false;
this._runtimeBridgeReady = false; this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries(
"Composition navigated before runtime data was applied",
);
if (val !== null) this.iframe.srcdoc = prepareSrcdocForElement(this, val); if (val !== null) this.iframe.srcdoc = prepareSrcdocForElement(this, val);
else this.iframe.removeAttribute("srcdoc"); else this.iframe.removeAttribute("srcdoc");
break; break;
case SANDBOX_ORIGIN_ATTR:
this._applySandboxOriginPolicy(this.isConnected && oldVal !== val);
break;
// Reject NaN/zero/negative dimensions the same way the composition // Reject NaN/zero/negative dimensions the same way the composition
// probe does (a typo like width="abc" or width="0" would otherwise // probe does (a typo like width="abc" or width="0" would otherwise
// reach scaleIframeToFit as scale(NaN) or a division by zero and // reach scaleIframeToFit as scale(NaN) or a division by zero and
@ -275,6 +296,28 @@ class HyperframesPlayer extends HTMLElement {
} }
} }
private _applySandboxOriginPolicy(reloadActiveDocument = false): void {
if (this.hasAttribute(SANDBOX_ORIGIN_ATTR)) {
this.iframe.sandbox.remove("allow-same-origin");
} else {
this.iframe.sandbox.add("allow-same-origin");
}
if (reloadActiveDocument) this._reloadForSandboxOriginPolicy();
}
private _reloadForSandboxOriginPolicy(): void {
this._ready = false;
this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries("Sandbox policy changed before runtime data was applied");
const srcdoc = this.getAttribute("srcdoc");
if (srcdoc !== null) {
this.iframe.srcdoc = prepareSrcdocForElement(this, srcdoc);
return;
}
const src = this.getAttribute("src");
this.iframe.src = src === null ? "about:blank" : prepareSrcForElement(this, src);
}
/** /**
* The inner `<iframe>` rendering the composition. Use this when integrating * The inner `<iframe>` rendering the composition. Use this when integrating
* with tools that need `contentWindow` `.contentWindow` on the * with tools that need `contentWindow` `.contentWindow` on the
@ -395,7 +438,12 @@ class HyperframesPlayer extends HTMLElement {
if (!/^[a-z][a-z0-9-]{0,63}$/.test(channel)) { if (!/^[a-z][a-z0-9-]{0,63}$/.test(channel)) {
throw new Error(`Invalid HyperFrames runtime-data channel: ${channel}`); throw new Error(`Invalid HyperFrames runtime-data channel: ${channel}`);
} }
const retained = typeof structuredClone === "function" ? structuredClone(payload) : payload; if (typeof structuredClone !== "function") {
throw new Error(
"HyperFrames runtime data requires structuredClone support; refusing an unverified payload",
);
}
const retained = structuredClone(payload);
this._runtimeData.set(channel, retained); this._runtimeData.set(channel, retained);
this._deliverRuntimeData(channel, retained); this._deliverRuntimeData(channel, retained);
} }
@ -529,9 +577,20 @@ class HyperframesPlayer extends HTMLElement {
else this.removeAttribute("loop"); else this.removeAttribute("loop");
} }
private _sendControl(action: string, extra: Record<string, unknown> = {}) { private _sendControl(action: string, extra: Record<string, unknown> = {}): boolean {
try { try {
this.iframe.contentWindow?.postMessage( const frameWindow = this.iframe.contentWindow;
if (!frameWindow) {
if (action === "set-runtime-data" || action === "clear-runtime-data") {
this._rejectRuntimeDataDelivery(
extra["channel"],
extra["requestId"],
"Composition iframe is unavailable",
);
}
return false;
}
frameWindow.postMessage(
{ {
...extra, ...extra,
source: "hf-parent", source: "hf-parent",
@ -541,43 +600,53 @@ class HyperframesPlayer extends HTMLElement {
}, },
"*", "*",
); );
} catch { return true;
/* cross-origin */ } catch (error) {
if (action === "set-runtime-data" || action === "clear-runtime-data") {
this._rejectRuntimeDataDelivery(
extra["channel"],
extra["requestId"],
error instanceof Error ? error.message : String(error),
);
}
return false;
} }
} }
private _deliverRuntimeData(channel: string, payload: unknown): void { private _deliverRuntimeData(channel: string, payload: unknown): void {
if (!this.isConnected || !this._runtimeBridgeReady) return; if (!this.isConnected || !this._runtimeBridgeReady) return;
if (this._trySetRuntimeDataDirect(channel, payload)) return; const requestId = this._beginRuntimeDataDelivery(channel);
this._sendControl("set-runtime-data", { channel, payload }); if (this._trySetRuntimeDataDirect(channel, payload, requestId)) return;
this._sendControl("set-runtime-data", { channel, payload, requestId });
} }
private _deliverRuntimeDataClear(channel: string): void { private _deliverRuntimeDataClear(channel: string): void {
if (!this.isConnected || !this._runtimeBridgeReady) return; if (!this.isConnected || !this._runtimeBridgeReady) return;
if (this._tryClearRuntimeDataDirect(channel)) return; const requestId = this._beginRuntimeDataDelivery(channel);
this._sendControl("clear-runtime-data", { channel }); if (this._tryClearRuntimeDataDirect(channel, requestId)) return;
this._sendControl("clear-runtime-data", { channel, requestId });
} }
private _trySetRuntimeDataDirect(channel: string, payload: unknown): boolean { private _trySetRuntimeDataDirect(channel: string, payload: unknown, requestId: number): boolean {
try { try {
const bridge = ( const bridge = (
this.iframe.contentWindow as (Window & { __hyperframes?: RuntimeDataBridge }) | null this.iframe.contentWindow as (Window & { __hyperframes?: RuntimeDataBridge }) | null
)?.__hyperframes; )?.__hyperframes;
if (typeof bridge?.setRuntimeData !== "function") return false; if (typeof bridge?.setRuntimeData !== "function") return false;
bridge.setRuntimeData(channel, payload); bridge.setRuntimeData(channel, payload, requestId);
return true; return true;
} catch { } catch {
return false; return false;
} }
} }
private _tryClearRuntimeDataDirect(channel: string): boolean { private _tryClearRuntimeDataDirect(channel: string, requestId: number): boolean {
try { try {
const bridge = ( const bridge = (
this.iframe.contentWindow as (Window & { __hyperframes?: RuntimeDataBridge }) | null this.iframe.contentWindow as (Window & { __hyperframes?: RuntimeDataBridge }) | null
)?.__hyperframes; )?.__hyperframes;
if (typeof bridge?.clearRuntimeData !== "function") return false; if (typeof bridge?.clearRuntimeData !== "function") return false;
bridge.clearRuntimeData(channel); bridge.clearRuntimeData(channel, requestId);
return true; return true;
} catch { } catch {
return false; return false;
@ -590,6 +659,69 @@ class HyperframesPlayer extends HTMLElement {
} }
} }
private _beginRuntimeDataDelivery(channel: string): number {
const previous = this._pendingRuntimeData.get(channel);
if (previous) window.clearTimeout(previous.timeoutId);
this._runtimeDataRequestId += 1;
const requestId = this._runtimeDataRequestId;
const timeoutId = window.setTimeout(() => {
this._rejectRuntimeDataDelivery(
channel,
requestId,
`Runtime data delivery timed out after ${RUNTIME_DATA_DELIVERY_TIMEOUT_MS}ms`,
);
}, RUNTIME_DATA_DELIVERY_TIMEOUT_MS);
this._pendingRuntimeData.set(channel, { requestId, timeoutId });
return requestId;
}
private _resolveRuntimeDataDelivery(channel: unknown, requestId: unknown): void {
const pending = this._takeRuntimeDataDelivery(channel, requestId);
if (!pending) return;
this.dispatchEvent(
new CustomEvent("runtimedataapplied", {
detail: { channel, requestId: pending.requestId },
}),
);
}
private _rejectRuntimeDataDelivery(channel: unknown, requestId: unknown, message: unknown): void {
const pending = this._takeRuntimeDataDelivery(channel, requestId);
if (!pending) return;
this.dispatchEvent(
new CustomEvent("runtimedataerror", {
detail: {
channel,
requestId: pending.requestId,
message: typeof message === "string" ? message : String(message),
},
}),
);
}
private _takeRuntimeDataDelivery(
channel: unknown,
requestId: unknown,
): PendingRuntimeDataDelivery | null {
if (
typeof channel !== "string" ||
typeof requestId !== "number" ||
!Number.isSafeInteger(requestId)
)
return null;
const pending = this._pendingRuntimeData.get(channel);
if (!pending || pending.requestId !== requestId) return null;
window.clearTimeout(pending.timeoutId);
this._pendingRuntimeData.delete(channel);
return pending;
}
private _rejectAllRuntimeDataDeliveries(message: string): void {
for (const [channel, pending] of [...this._pendingRuntimeData]) {
this._rejectRuntimeDataDelivery(channel, pending.requestId, message);
}
}
/** /**
* Returns the iframe's contentDocument if same-origin and reachable, * Returns the iframe's contentDocument if same-origin and reachable,
* otherwise null. Accessing contentDocument can throw on cross-origin * otherwise null. Accessing contentDocument can throw on cross-origin
@ -749,6 +881,10 @@ class HyperframesPlayer extends HTMLElement {
this._replayBridgeState(); this._replayBridgeState();
this._replayRuntimeData(); this._replayRuntimeData();
}, },
onRuntimeDataApplied: (channel, requestId) =>
this._resolveRuntimeDataDelivery(channel, requestId),
onRuntimeDataError: (channel, requestId, message) =>
this._rejectRuntimeDataDelivery(channel, requestId, message),
onRuntimeTimelineReady: (duration) => this._onRuntimeTimelineReady(duration), onRuntimeTimelineReady: (duration) => this._onRuntimeTimelineReady(duration),
setRuntimeFps: (fps) => { setRuntimeFps: (fps) => {
this._runtimeFps = fps; this._runtimeFps = fps;

View File

@ -68,4 +68,14 @@ describe("ensureRuntimeBeforeBodyScripts", () => {
expect(out.indexOf(URL)).toBeLessThan(out.indexOf("read()")); expect(out.indexOf(URL)).toBeLessThan(out.indexOf("read()"));
}); });
it("does not mistake a longer tag name for head", () => {
const out = ensureRuntimeBeforeBodyScripts(
`<html><header><script>early()</script></header><body><script>read()</script></body></html>`,
URL,
);
expect(out.indexOf(URL)).toBeLessThan(out.indexOf("<body>"));
expect(out.indexOf(URL)).toBeGreaterThan(out.indexOf("</header>"));
});
}); });

View File

@ -32,23 +32,43 @@ function alreadyHasRuntime(html: string, runtimeUrl: string): boolean {
return /hyperframe\.runtime\.iife\.js|__hyperframes\s*=/.test(html); return /hyperframe\.runtime\.iife\.js|__hyperframes\s*=/.test(html);
} }
type OpeningTag = { index: number; end: number };
const OPENING_TAG_BOUNDARIES = new Set<string | undefined>([">", " ", "\t", "\n", "\r", "\f"]);
/** Find an opening tag in one linear pass, without a backtracking regex over caller-owned HTML. */
function findOpeningTag(html: string, tagName: string): OpeningTag | null {
const lower = html.toLowerCase();
const prefix = `<${tagName}`;
let from = 0;
while (from < lower.length) {
const index = lower.indexOf(prefix, from);
if (index < 0) return null;
const boundary = lower[index + prefix.length];
if (OPENING_TAG_BOUNDARIES.has(boundary)) {
const close = lower.indexOf(">", index + prefix.length);
if (close < 0) return null;
return { index, end: close + 1 };
}
from = index + prefix.length;
}
return null;
}
export function ensureRuntimeBeforeBodyScripts(html: string, runtimeUrl: string): string { export function ensureRuntimeBeforeBodyScripts(html: string, runtimeUrl: string): string {
if (!html || alreadyHasRuntime(html, runtimeUrl)) return html; if (!html || alreadyHasRuntime(html, runtimeUrl)) return html;
const tag = `<script src="${runtimeUrl}"></script>`; const tag = `<script src="${runtimeUrl}"></script>`;
const head = /<head[^>]*>/i.exec(html); const head = findOpeningTag(html, "head");
if (head) { if (head) {
const at = head.index + head[0].length; return html.slice(0, head.end) + tag + html.slice(head.end);
return html.slice(0, at) + tag + html.slice(at);
} }
// No head: get in before <body> so body scripts still see the runtime. A // No head: get in before <body> so body scripts still see the runtime. A
// fragment with neither lands at the front, which is the same guarantee. // fragment with neither lands at the front, which is the same guarantee.
const body = /<body[^>]*>/i.exec(html); const body = findOpeningTag(html, "body");
if (body) return html.slice(0, body.index) + tag + html.slice(body.index); if (body) return html.slice(0, body.index) + tag + html.slice(body.index);
const htmlTag = /<html[^>]*>/i.exec(html); const htmlTag = findOpeningTag(html, "html");
if (htmlTag) { if (htmlTag) {
const at = htmlTag.index + htmlTag[0].length; return html.slice(0, htmlTag.end) + tag + html.slice(htmlTag.end);
return html.slice(0, at) + tag + html.slice(at);
} }
return tag + html; return tag + html;
} }

View File

@ -13,6 +13,8 @@ const makeCallbacks = (): MessageHandlerCallbacks => ({
updateControlsPlaying: vi.fn(), updateControlsPlaying: vi.fn(),
dispatchEvent: vi.fn(), dispatchEvent: vi.fn(),
onRuntimeReady: vi.fn(), onRuntimeReady: vi.fn(),
onRuntimeDataApplied: vi.fn(),
onRuntimeDataError: vi.fn(),
onRuntimeTimelineReady: vi.fn(), onRuntimeTimelineReady: vi.fn(),
setRuntimeFps: vi.fn(), setRuntimeFps: vi.fn(),
seek: vi.fn(), seek: vi.fn(),
@ -72,7 +74,7 @@ describe("handleRuntimeMessage stage-size", () => {
}); });
describe("handleRuntimeMessage runtime data errors", () => { describe("handleRuntimeMessage runtime data errors", () => {
it("surfaces a channel-scoped player event", () => { it("routes a correlated channel-scoped error", () => {
const frameWindow = {} as Window; const frameWindow = {} as Window;
const callbacks = makeCallbacks(); const callbacks = makeCallbacks();
handleRuntimeMessage( handleRuntimeMessage(
@ -82,15 +84,33 @@ describe("handleRuntimeMessage runtime data errors", () => {
source: "hf-preview", source: "hf-preview",
type: "runtime-data-error", type: "runtime-data-error",
channel: "captions", channel: "captions",
requestId: 41,
message: "attach failed", message: "attach failed",
}, },
} as MessageEvent, } as MessageEvent,
frameWindow, frameWindow,
callbacks, callbacks,
); );
expect(callbacks.dispatchEvent).toHaveBeenCalledWith( expect(callbacks.onRuntimeDataError).toHaveBeenCalledWith("captions", 41, "attach failed");
expect.objectContaining({ type: "runtimedataerror" }), });
it("routes a correlated successful application", () => {
const frameWindow = {} as Window;
const callbacks = makeCallbacks();
handleRuntimeMessage(
{
source: frameWindow,
data: {
source: "hf-preview",
type: "runtime-data-applied",
channel: "captions",
requestId: 42,
},
} as MessageEvent,
frameWindow,
callbacks,
); );
expect(callbacks.onRuntimeDataApplied).toHaveBeenCalledWith("captions", 42);
}); });
}); });

View File

@ -40,6 +40,8 @@ export interface MessageHandlerCallbacks extends PlaybackStateCallbacks {
* uses it to replay current bridge state (mute, volume, playback rate) so * uses it to replay current bridge state (mute, volume, playback rate) so
* control messages sent before the iframe's listener registered aren't lost. */ * control messages sent before the iframe's listener registered aren't lost. */
onRuntimeReady: () => void; onRuntimeReady: () => void;
onRuntimeDataApplied?: (channel: unknown, requestId: unknown) => void;
onRuntimeDataError?: (channel: unknown, requestId: unknown, message: unknown) => void;
/** Invoked when the runtime posts a finite positive timeline duration. The /** Invoked when the runtime posts a finite positive timeline duration. The
* player uses this as the cross-origin readiness signal because the * player uses this as the cross-origin readiness signal because the
* same-origin composition probe cannot inspect CDN iframes. */ * same-origin composition probe cannot inspect CDN iframes. */
@ -93,11 +95,12 @@ export function handleRuntimeMessage(
} }
if (data["type"] === "runtime-data-error") { if (data["type"] === "runtime-data-error") {
callbacks.dispatchEvent( callbacks.onRuntimeDataError?.(data["channel"], data["requestId"], data["message"]);
new CustomEvent("runtimedataerror", { return;
detail: { channel: data["channel"], message: data["message"] }, }
}),
); if (data["type"] === "runtime-data-applied") {
callbacks.onRuntimeDataApplied?.(data["channel"], data["requestId"]);
return; return;
} }

View File

@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { launchBrowser } from "../perf/runner.js";
import { startServer } from "../perf/server.js";
const server = startServer();
const browser = await launchBrowser();
try {
const page = await browser.newPage();
await page.goto(`${server.origin}/host.html?fixture=sandbox-probe`, {
waitUntil: "domcontentloaded",
});
await page.waitForFunction(() => (window.__sandboxProbeResults?.length ?? 0) >= 1);
assert.equal(
await page.evaluate(() => window.__sandboxProbeResults?.at(-1)),
true,
"the default trusted sandbox should allow same-origin parent access",
);
await page.evaluate(() => {
document.querySelector("hyperframes-player")?.setAttribute("sandbox-origin", "opaque");
});
await page.waitForFunction(() => (window.__sandboxProbeResults?.length ?? 0) >= 2);
assert.equal(
await page.evaluate(() => window.__sandboxProbeResults?.at(-1)),
false,
"switching a live player to opaque must reload into an isolated origin",
);
await page.evaluate(() => {
document.querySelector("hyperframes-player")?.setAttribute("sandbox-origin", "opaqu");
});
await page.waitForFunction(() => (window.__sandboxProbeResults?.length ?? 0) >= 3);
assert.equal(
await page.evaluate(() => window.__sandboxProbeResults?.at(-1)),
false,
"an unrecognized non-null policy must remain isolated",
);
await page.evaluate(() => {
document.querySelector("hyperframes-player")?.removeAttribute("sandbox-origin");
});
await page.waitForFunction(() => (window.__sandboxProbeResults?.length ?? 0) >= 4);
assert.equal(
await page.evaluate(() => window.__sandboxProbeResults?.at(-1)),
true,
"removing the policy must reload into the documented trusted mode",
);
} finally {
await browser.close();
await server.stop();
}

View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sandbox origin probe</title>
</head>
<body>
<script>
let canAccessParent = false;
try {
canAccessParent = window.parent.document.body !== null;
} catch {
canAccessParent = false;
}
window.parent.postMessage({ source: "hf-sandbox-probe", canAccessParent }, "*");
</script>
</body>
</html>

View File

@ -44,6 +44,7 @@ declare global {
__playerNavStart?: number; __playerNavStart?: number;
__playerDuration?: number; __playerDuration?: number;
__playerError?: string; __playerError?: string;
__sandboxProbeResults?: boolean[];
} }
} }

View File

@ -102,6 +102,12 @@ function buildHostHtml(fixtureName: string, width: number, height: number): stri
window.__playerReady = false; window.__playerReady = false;
window.__playerReadyAt = null; window.__playerReadyAt = null;
window.__playerNavStart = performance.timeOrigin + performance.now(); window.__playerNavStart = performance.timeOrigin + performance.now();
window.__sandboxProbeResults = [];
window.addEventListener("message", function (event) {
if (event.data && event.data.source === "hf-sandbox-probe") {
window.__sandboxProbeResults.push(event.data.canAccessParent === true);
}
});
const player = document.getElementById("player"); const player = document.getElementById("player");
player.addEventListener("ready", function (event) { player.addEventListener("ready", function (event) {
window.__playerReady = true; window.__playerReady = true;