mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
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:
parent
3337cc8990
commit
859ac622c2
7
.github/workflows/player-perf.yml
vendored
7
.github/workflows/player-perf.yml
vendored
@ -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:
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
2
packages/core/src/runtime/window.d.ts
vendored
2
packages/core/src/runtime/window.d.ts
vendored
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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] });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
packages/player/tests/browser/sandbox-origin.ts
Normal file
53
packages/player/tests/browser/sandbox-origin.ts
Normal 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();
|
||||||
|
}
|
||||||
18
packages/player/tests/perf/fixtures/sandbox-probe/index.html
Normal file
18
packages/player/tests/perf/fixtures/sandbox-probe/index.html
Normal 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>
|
||||||
@ -44,6 +44,7 @@ declare global {
|
|||||||
__playerNavStart?: number;
|
__playerNavStart?: number;
|
||||||
__playerDuration?: number;
|
__playerDuration?: number;
|
||||||
__playerError?: string;
|
__playerError?: string;
|
||||||
|
__sandboxProbeResults?: boolean[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user