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
190 changes: 188 additions & 2 deletions packages/player/src/composition-probe.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { readCompositionSizeFromDocument, runtimeCdnUrlForVersion } from "./composition-probe.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
CompositionProbe,
readCompositionSizeFromDocument,
runtimeCdnUrlForVersion,
} from "./composition-probe.js";
import { RUNTIME_CDN_URL } from "./runtime-url.js";

describe("readCompositionSizeFromDocument", () => {
it("reads dimensions from the composition root", () => {
Expand Down Expand Up @@ -36,3 +41,184 @@ describe("runtimeCdnUrlForVersion", () => {
expect(() => runtimeCdnUrlForVersion("latest")).toThrow("Invalid HyperFrames runtime version");
});
});

// ── Runtime detection and injection ──
//
// `window.__hf` is a namespace, not a bridge. The core runtime creates it, but
// so does `@hyperframes/shader-transitions` (to publish `shaderTransitionsReady`),
// so an authored composition that uses shader transitions and registers its
// own `__timelines` carries `__hf` with no runtime behind it. The probe used
// to read that as "runtime present": it never injected the runtime, refused
// the direct-timeline adapter, and the embed timed out after 8 s. The only
// global the player ever drives is `__player`, so that is the bridge check.
describe("CompositionProbe runtime detection", () => {
type FakeTimeline = {
duration: () => number;
time: () => number;
seek: () => void;
play: () => void;
pause: () => void;
};
type FakeWindow = {
__hf?: unknown;
__player?: unknown;
__timelines?: Record<string, FakeTimeline>;
};
type FakeScript = { src: string; onerror: (() => void) | null };

function fakeTimeline(duration = 10): FakeTimeline {
return { duration: () => duration, time: () => 0, seek() {}, play() {}, pause() {} };
}

// A minimal contentDocument so injection does not go through happy-dom's
// real `<script src>` loading. `appendChild` records the injected script.
function fakeDocument(bodyHtml: string, appended: FakeScript[]) {
const doc = document.implementation.createHTMLDocument();
doc.body.innerHTML = bodyHtml;
return {
querySelector: (selector: string) => doc.querySelector(selector),
createElement: (): FakeScript => ({ src: "", onerror: null }),
head: { appendChild: (node: FakeScript) => appended.push(node) },
};
}

function fakeIframe(win: FakeWindow, doc: unknown): HTMLIFrameElement {
const iframe = document.createElement("iframe");
Object.defineProperty(iframe, "contentWindow", { configurable: true, get: () => win });
Object.defineProperty(iframe, "contentDocument", { configurable: true, get: () => doc });
return iframe;
}

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("drives __timelines directly when only the shader-transitions __hf namespace is present", () => {
const appended: FakeScript[] = [];
const win: FakeWindow = {
__hf: { shaderTransitionsReady: Promise.resolve() },
__timelines: { main: fakeTimeline(10) },
};
const iframe = fakeIframe(
win,
fakeDocument(
'<div data-composition-id="main" data-width="1080" data-height="1920"></div>',
appended,
),
);
const onReady = vi.fn();
const onError = vi.fn();
const probe = new CompositionProbe(iframe, { onReady, onError });

probe.start();
vi.advanceTimersByTime(200);

expect(onError).not.toHaveBeenCalled();
expect(appended).toHaveLength(0);
expect(onReady).toHaveBeenCalledTimes(1);
expect(onReady.mock.calls[0][0]).toMatchObject({
duration: 10,
adapter: { kind: "direct-timeline" },
});
expect(probe.hasRuntimeBridge(win as unknown as Window)).toBe(false);
probe.stop();
});

it("still prefers an installed __player bridge over direct __timelines", () => {
const win: FakeWindow = {
__hf: {},
__player: { getDuration: () => 7 },
__timelines: { main: fakeTimeline(10) },
};
const iframe = fakeIframe(win, fakeDocument("", []));
const onReady = vi.fn();
const probe = new CompositionProbe(iframe, { onReady, onError: vi.fn() });

probe.start();
vi.advanceTimersByTime(200);

expect(onReady).toHaveBeenCalledTimes(1);
expect(onReady.mock.calls[0][0]).toMatchObject({ duration: 7, adapter: { kind: "runtime" } });
expect(probe.hasRuntimeBridge(win as unknown as Window)).toBe(true);
expect(probe.resolveDirectTimelineAdapter()).toBeNull();
probe.stop();
});

it("injects the runtime into a nested composition despite a pre-existing __hf namespace", () => {
const appended: FakeScript[] = [];
const win: FakeWindow = { __hf: { shaderTransitionsReady: Promise.resolve() } };
const iframe = fakeIframe(
win,
fakeDocument('<div data-composition-src="child.html"></div>', appended),
);
const onRuntimeInjected = vi.fn();
const probe = new CompositionProbe(iframe, {
onReady: vi.fn(),
onError: vi.fn(),
onRuntimeInjected,
});

probe.start();
vi.advanceTimersByTime(200);

expect(onRuntimeInjected).toHaveBeenCalledTimes(1);
expect(appended).toHaveLength(1);
expect(appended[0].src).toBe(RUNTIME_CDN_URL);
probe.stop();
});

it("loads the runtime from resolveRuntimeUrl when the host configures one", () => {
// `runtime-src` was honoured for srcdoc only; an src embed always fetched
// the runtime from jsDelivr, which fails offline or under a strict CSP.
const appended: FakeScript[] = [];
const iframe = fakeIframe(
{},
fakeDocument('<div data-composition-src="child.html"></div>', appended),
);
const probe = new CompositionProbe(iframe, {
onReady: vi.fn(),
onError: vi.fn(),
resolveRuntimeUrl: () => "http://127.0.0.1:8900/hyperframe.runtime.iife.js",
});

probe.start();
vi.advanceTimersByTime(200);

expect(appended).toHaveLength(1);
expect(appended[0].src).toBe("http://127.0.0.1:8900/hyperframe.runtime.iife.js");
probe.stop();
});

it("reports a runtime that fails to load instead of waiting out the 8 s timeout", () => {
const appended: FakeScript[] = [];
const iframe = fakeIframe(
{},
fakeDocument('<div data-composition-src="child.html"></div>', appended),
);
const onError = vi.fn();
const probe = new CompositionProbe(iframe, {
onReady: vi.fn(),
onError,
resolveRuntimeUrl: () => "http://127.0.0.1:8900/missing.js",
});

probe.start();
vi.advanceTimersByTime(200);
expect(appended).toHaveLength(1);

appended[0].onerror?.();

expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
"HyperFrames runtime failed to load from http://127.0.0.1:8900/missing.js",
);

// The probe has stopped: no second, generic timeout error follows.
vi.advanceTimersByTime(10_000);
expect(onError).toHaveBeenCalledTimes(1);
});
});
42 changes: 36 additions & 6 deletions packages/player/src/composition-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,29 @@ export interface ProbeCallbacks {
onError: (message: string) => void;
/** Called when runtime is successfully injected (informational). */
onRuntimeInjected?: () => void;
/**
* Where to load the runtime from when the probe has to inject it. Read at
* injection time, not at construction, so a `runtime-src` set after the
* element was created is still honoured. Defaults to the pinned CDN build.
*/
resolveRuntimeUrl?: () => string;
}

/**
* Whether the core runtime has installed its player bridge in this window.
*
* The bridge is `window.__player`: the runtime creates it synchronously during
* init, in the same task as `window.__hf`, and it is the only global the
* player ever drives. `window.__hf` on its own is not evidence of a runtime:
* it is a shared namespace that `@hyperframes/shader-transitions` also creates
* (`window.__hf = window.__hf || {}`) to publish `shaderTransitionsReady`, so
* an authored composition using shader transitions carries `__hf` with no
* runtime behind it. Treating that as "runtime present" meant the probe never
* injected the runtime and refused the direct-timeline adapter, and the embed
* timed out after 8 s with a black frame.
*/
function hasRuntimeBridge(win: Window): boolean {
return isObjectRecord(Reflect.get(win, "__player"));
}

/**
Expand Down Expand Up @@ -88,13 +111,11 @@ export class CompositionProbe {
attempts++;
try {
const win = this._iframe.contentWindow as Window & {
__player?: { getDuration: () => number };
__timelines?: Record<string, { duration: () => number }>;
__hf?: unknown;
};
if (!win) return;

const hasRuntime = !!(win.__hf || win.__player);
const hasRuntime = hasRuntimeBridge(win);
const hasTimelines = !!(win.__timelines && Object.keys(win.__timelines).length > 0);
const hasNestedCompositions =
!!this._iframe.contentDocument?.querySelector("[data-composition-src]");
Expand Down Expand Up @@ -163,7 +184,7 @@ export class CompositionProbe {
}

hasRuntimeBridge(win: Window): boolean {
return Reflect.get(win, "__hf") !== undefined || isObjectRecord(Reflect.get(win, "__player"));
return hasRuntimeBridge(win);
}

// ── Private ──────────────────────────────────────────────────────────────
Expand All @@ -173,8 +194,17 @@ export class CompositionProbe {
try {
const doc = this._iframe.contentDocument;
if (!doc) return;
const runtimeUrl = this._callbacks.resolveRuntimeUrl?.() ?? RUNTIME_CDN_URL;
const script = doc.createElement("script");
script.src = RUNTIME_CDN_URL;
script.src = runtimeUrl;
// A runtime that is blocked (CSP, offline, 404) used to look exactly like
// a slow one: the probe kept polling and reported a missing timeline 8 s
// later. Fail on the script's own error instead, naming the URL.
script.onerror = () => {
if (this._interval === null) return;
this.stop();
this._callbacks.onError(`HyperFrames runtime failed to load from ${runtimeUrl}`);
};
(doc.head || doc.documentElement).appendChild(script);
this._callbacks.onRuntimeInjected?.();
} catch {
Expand All @@ -183,7 +213,7 @@ export class CompositionProbe {
}

private _resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
if (this.hasRuntimeBridge(win)) return null;
if (hasRuntimeBridge(win)) return null;

const timelines = Reflect.get(win, "__timelines");
if (!isObjectRecord(timelines)) return null;
Expand Down
33 changes: 33 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1537,6 +1537,39 @@ describe("HyperframesPlayer srcdoc attribute", () => {
player.remove();
});

it("uses a configured runtime source when the probe injects into an src embed", () => {
// `runtime-src` used to be read on the srcdoc path only; an `src` embed always
// fetched the runtime from jsDelivr, so a local copy could never be used
// offline or behind a `script-src 'self'` CSP. Both paths resolve it the
// same way now.
vi.useFakeTimers();
const player = document.createElement("hyperframes-player") as PlayerInternal;
player.setAttribute("src", "/comp-nested.html");
player.setAttribute("runtime-src", "http://127.0.0.1:8900/hyperframe.runtime.iife.js");
document.body.appendChild(player);

const appended: Array<{ src: string }> = [];
const doc = document.implementation.createHTMLDocument();
doc.body.innerHTML = '<div data-composition-src="child.html"></div>';
Object.defineProperty(player.iframe, "contentWindow", { configurable: true, get: () => ({}) });
stubIframeContentDocument(player.iframe, {
querySelector: (selector: string) => doc.querySelector(selector),
querySelectorAll: () => [],
createElement: () => ({ src: "" }),
head: { appendChild: (node: { src: string }) => appended.push(node) },
} as unknown as Document);

player.iframe.dispatchEvent(new Event("load"));
vi.advanceTimersByTime(200);

expect(appended.map((node) => node.src)).toEqual([
"http://127.0.0.1:8900/hyperframe.runtime.iife.js",
]);

player.remove();
vi.useRealTimers();
});

it("falls back to the pinned runtime for an unsafe runtime source", () => {
const player = document.createElement("hyperframes-player") as PlayerInternal;
player.setAttribute("runtime-src", 'javascript:alert("no")');
Expand Down
4 changes: 4 additions & 0 deletions packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getShaderModeFromElement,
prepareSrcForElement,
prepareSrcdocForElement,
resolveRuntimeUrlFromElement,
} from "./shader-options.js";
import { createShaderLoader } from "./shader-loader-element.js";
import { ShaderLoaderState } from "./shader-loader-state.js";
Expand Down Expand Up @@ -180,6 +181,9 @@ class HyperframesPlayer extends HTMLElement {
this.probe = new CompositionProbe(this.iframe, {
onReady: (result) => this._onProbeReady(result),
onError: (message) => this.dispatchEvent(new CustomEvent("error", { detail: { message } })),
// Same resolution as the srcdoc path, so `runtime-src` applies to an
// `src` embed too instead of being silently replaced by the CDN URL.
resolveRuntimeUrl: () => resolveRuntimeUrlFromElement(this),
});

this.addEventListener("click", (event) => {
Expand Down
14 changes: 12 additions & 2 deletions packages/player/src/shader-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,21 @@ export function prepareSrcdocForElement(el: Element, srcdoc: string): string {
normalizeShaderCaptureScale(el.getAttribute(SHADER_CAPTURE_SCALE_ATTR)),
getShaderModeFromElement(el),
),
runtimeSrcFromElement(el),
resolveRuntimeUrlFromElement(el),
);
}

function runtimeSrcFromElement(el: Element): string {
/**
* The runtime URL an element has asked for: `runtime-src` when it is set and
* safe, the pinned jsDelivr build otherwise.
*
* Shared by both embed paths. srcdoc puts this URL in the document head at
* parse time; an `src` embed hands it to the probe, which appends it as a
* `<script>` once it knows the composition needs the runtime. Resolving it in
* one place keeps the two paths on the same URL, so a `runtime-src` that works
* for srcdoc also works for src.
*/
export function resolveRuntimeUrlFromElement(el: Element): string {
const configured = el.getAttribute(RUNTIME_SRC_ATTR)?.trim();
if (!configured) return RUNTIME_CDN_URL;
try {
Expand Down
4 changes: 3 additions & 1 deletion packages/player/src/shouldInjectRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
* current probe tick.
*
* The player polls the loaded iframe every 200ms to discover either:
* - a runtime bridge already installed (`window.__hf` / `window.__player`), or
* - a runtime bridge already installed (`window.__player`; `window.__hf` is
* a shared namespace that shader-transitions also creates, so it is not
* evidence of a runtime), or
* - GSAP timelines registered at `window.__timelines`.
*
* Two classes of composition require different injection timing:
Expand Down