Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/player-perf.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ jobs:
if: matrix.shard == 'parity'
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)
working-directory: packages/player
env:
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/runtime/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ describe("installRuntimeControlBridge", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
const payload = { version: 3, segments: [] };
handler(makeControlMessage("set-runtime-data", { channel: "captions", payload }));
handler(makeControlMessage("clear-runtime-data", { channel: "captions" }));
expect(deps.onSetRuntimeData).toHaveBeenCalledWith("captions", payload);
expect(deps.onClearRuntimeData).toHaveBeenCalledWith("captions");
handler(
makeControlMessage("set-runtime-data", { channel: "captions", payload, requestId: 41 }),
);
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", () => {
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/runtime/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ type BridgeDeps = {
) => void;
onEnablePickMode: () => void;
onDisablePickMode: () => void;
onSetRuntimeData?: (channel: string, payload: unknown) => void;
onClearRuntimeData?: (channel: string) => void;
onSetRuntimeData?: (channel: string, payload: unknown, requestId?: number) => void;
onClearRuntimeData?: (channel: string, requestId?: number) => void;
getCanonicalFps: () => number;
};

Expand Down Expand Up @@ -78,10 +78,11 @@ const CONTROL_HANDLERS: Record<string, ControlHandler> = {
"disable-pick-mode": (_d, deps) => deps.onDisablePickMode(),
"flash-elements": (data) => handleFlashElements(data),
"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) => {
if (typeof data.channel === "string") deps.onClearRuntimeData?.(data.channel);
if (typeof data.channel === "string") deps.onClearRuntimeData?.(data.channel, data.requestId);
},
};

Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ import { shouldAttemptPeriodicTimelineBind } from "./timelineRebindPolicy";
import { installStudioCustomEase } from "./customEase";
import { parseNumeric } from "./startExpression";
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_END_ATTR = "data-hf-authored-end";
Expand Down Expand Up @@ -128,14 +133,23 @@ export function initSandboxRuntimeModular(): void {
// Own the analytics bridge before any best-effort runtime installation so
// early failures are observable instead of disappearing before player setup.
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
setRuntimeDataErrorReporter((channel, error) => {
setRuntimeDataErrorReporter((channel, requestId, error) => {
postRuntimeMessage({
source: "hf-preview",
type: "runtime-data-error",
channel,
requestId,
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
// binds (CSS/WAAPI-animated or fully static compositions) — apply at init.
// This runs at DOMContentLoaded, after inline composition scripts have
Expand Down
40 changes: 39 additions & 1 deletion packages/core/src/runtime/runtimeData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
registerRuntimeDataHandler,
resetRuntimeDataForTests,
setRuntimeData,
setRuntimeDataAppliedReporter,
setRuntimeDataErrorReporter,
} from "./runtimeData";

Expand Down Expand Up @@ -48,6 +49,43 @@ describe("runtime data registry", () => {
throw new Error("attach failed");
});
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);
});
});
73 changes: 61 additions & 12 deletions packages/core/src/runtime/runtimeData.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,83 @@
export type RuntimeDataHandler = (payload: unknown) => void;
export type RuntimeDataErrorReporter = (channel: string, error: unknown) => void;
export type RuntimeDataHandler = (payload: unknown) => void | Promise<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 generations = new Map<string, number>();
let reportError: RuntimeDataErrorReporter = () => undefined;
let reportApplied: RuntimeDataAppliedReporter = () => undefined;
let localRequestId = 0;

function validChannel(channel: string): boolean {
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;
localRequestId += 1;
return localRequestId;
}

function deliver(channel: string, retainedData: RetainedRuntimeData): void {
const handler = handlers.get(channel);
if (!handler) return;
const isCurrent = () =>
generations.get(channel) === retainedData.generation && handlers.get(channel) === handler;
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) {
reportError(channel, error);
if (isCurrent()) reportError(channel, retainedData.requestId, error);
}
}

export function setRuntimeDataErrorReporter(reporter: RuntimeDataErrorReporter): void {
reportError = reporter;
}

export function setRuntimeData(channel: string, payload: unknown): void {
export function setRuntimeDataAppliedReporter(reporter: RuntimeDataAppliedReporter): void {
reportApplied = reporter;
}

export function setRuntimeData(channel: string, payload: unknown, requestId?: number): void {
if (!validChannel(channel)) return;
retained.set(channel, payload);
deliver(channel, payload);
const retainedData = {
payload,
requestId: resolveRequestId(requestId),
generation: nextGeneration(channel),
};
retained.set(channel, retainedData);
deliver(channel, retainedData);
}

export function clearRuntimeData(channel: string): void {
export function clearRuntimeData(channel: string, requestId?: number): void {
if (!validChannel(channel)) return;
retained.delete(channel);
deliver(channel, undefined);
deliver(channel, {
payload: undefined,
requestId: resolveRequestId(requestId),
generation: nextGeneration(channel),
});
}

export function registerRuntimeDataHandler(
Expand All @@ -42,7 +87,8 @@ export function registerRuntimeDataHandler(
if (!validChannel(channel))
throw new Error(`Invalid HyperFrames runtime-data channel: ${channel}`);
handlers.set(channel, handler);
if (retained.has(channel)) deliver(channel, retained.get(channel));
const retainedData = retained.get(channel);
if (retainedData) deliver(channel, retainedData);
return () => {
if (handlers.get(channel) === handler) handlers.delete(channel);
};
Expand All @@ -51,5 +97,8 @@ export function registerRuntimeDataHandler(
export function resetRuntimeDataForTests(): void {
retained.clear();
handlers.clear();
generations.clear();
localRequestId = 0;
reportError = () => undefined;
reportApplied = () => undefined;
}
10 changes: 10 additions & 0 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,17 @@ export type RuntimeDataErrorMessage = {
source: "hf-preview";
type: "runtime-data-error";
channel: string;
requestId: number;
message: string;
};

export type RuntimeDataAppliedMessage = {
source: "hf-preview";
type: "runtime-data-applied";
channel: string;
requestId: number;
};

/**
* Analytics events emitted by the runtime.
*
Expand Down Expand Up @@ -229,6 +237,7 @@ export type RuntimeOutboundMessage =
| RuntimeMediaAutoplayBlockedMessage
| RuntimeReadyMessage
| RuntimeDataErrorMessage
| RuntimeDataAppliedMessage
| RuntimeAnalyticsMessage
| RuntimePerformanceMessage
| RuntimeGroupLevelsMessage;
Expand Down Expand Up @@ -332,6 +341,7 @@ export type RuntimeGsapSetVars = Record<string, string | number | boolean | null
type RuntimeDataControlFields = {
channel?: string;
payload?: unknown;
requestId?: number;
};

type RuntimeBridgeControlAction =
Expand Down
33 changes: 32 additions & 1 deletion packages/player/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,40 @@ player.shaderLoading; // "composition" | "player" | "none" (read/write)
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

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
const player = document.querySelector("hyperframes-player");
Expand Down
1 change: 1 addition & 0 deletions packages/player/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"build": "tsup && node scripts/verify-runtime-pin.mjs",
"typecheck": "tsc --noEmit && tsc --noEmit -p tests/perf/tsconfig.json",
"test": "vitest run",
"test:browser-security": "bun run tests/browser/sandbox-origin.ts",
"perf": "bun run tests/perf/index.ts"
},
"dependencies": {
Expand Down
Loading
Loading