Skip to content
Merged
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
25 changes: 25 additions & 0 deletions packages/cli/src/commands/play.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,31 @@ describe("registerCompositionRoute", () => {
expect(mocks.resolveProxy).not.toHaveBeenCalled();
});

it("serves the runtime script ahead of every author script", async () => {
// Compositions read `window.__hyperframes.getVariables()` from an inline
// script at init. A runtime injected before </body> loads after that script,
// so the documented API is undefined exactly where authors are told to call
// it. Pin the ordering at the served-document boundary.
const project = tmpProject();
writeFileSync(
join(project.dir, "index.html"),
[
'<html><head><script src="https://cdn.example/gsap.js"></script></head>',
'<body><div id="root"></div>',
"<script>window.__probe = typeof window.__hyperframes;</script>",
"</body></html>",
].join(""),
);
const app = await buildApp(project, false);

const html = await (await app.request("/composition/index.html")).text();

const runtimeIndex = html.indexOf('<script src="/runtime.js">');
expect(runtimeIndex).toBeGreaterThanOrEqual(0);
const firstAuthorScriptIndex = html.search(/<script(?! src="\/runtime\.js")/);
expect(firstAuthorScriptIndex).toBeGreaterThan(runtimeIndex);
});

it("injects __HF_MEDIA_CODEC_MAP__ into served composition HTML", async () => {
const project = tmpProject();
writeFileSync(join(project.dir, "index.html"), "<html><head></head><body></body></html>");
Expand Down
16 changes: 11 additions & 5 deletions packages/cli/src/utils/compositionServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolve, dirname } from "node:path";
import { Readable } from "node:stream";
import { fileURLToPath } from "node:url";
import { getMimeType } from "@hyperframes/studio-server";
import { injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";

/**
* `window.__HF_MEDIA_CODEC_MAP__` injection + proxy pre-warm for HTML served
Expand Down Expand Up @@ -63,12 +64,17 @@ export function resolveSlideshowPath(): string | null {
return candidates.find((p) => existsSync(p)) ?? null;
}

/** Inject the runtime <script> into composition HTML before </body> (or at the end). */
/**
* Inject the runtime <script> at the top of the composition's <head>, ahead of
* every author script. Compositions read `window.__hyperframes.getVariables()`
* from an inline script at init, so a runtime appended before </body> is not
* loaded yet at that point and the documented API is undefined. The compiled
* render path hoists the runtime into <head> the same way; this keeps the
* served `play` document in parity with it. Placement falls back to before
* <body>, then to the top of the document, for fragments without a <head>.
*/
export function injectRuntime(html: string): string {
const runtimeTag = `<script src="/runtime.js"></script>`;
return html.includes("</body>")
? html.replace("</body>", `${runtimeTag}\n</body>`)
: html + `\n${runtimeTag}`;
return injectTagsAtHeadStart(html, `<script src="/runtime.js"></script>`);
}

export function assetContentType(filePath: string): string {
Expand Down
20 changes: 14 additions & 6 deletions packages/core/src/compiler/htmlDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,16 +174,24 @@ function inlineScriptTags(scripts: readonly string[]): string {
return scripts.map((source) => `<script>${escapeInlineScriptSource(source)}</script>`).join("\n");
}

export function injectScriptsAtHeadStart(html: string, scripts: readonly string[]): string {
if (scripts.length === 0) return html;
const headTags = inlineScriptTags(scripts);
/**
* Insert raw tag markup at the very start of `<head>`, ahead of every author
* script (inline or external). Falls back to just before `<body>`, then to the
* top of the document, for fragments that carry neither.
*/
export function injectTagsAtHeadStart(html: string, tags: string): string {
if (html.includes("<head")) {
return html.replace(/<head\b[^>]*>/i, (match) => `${match}\n${headTags}`);
return html.replace(/<head\b[^>]*>/i, (match) => `${match}\n${tags}`);
}
if (html.includes("<body")) {
return html.replace("<body", () => `${headTags}\n<body`);
return html.replace("<body", () => `${tags}\n<body`);
}
return `${headTags}\n${html}`;
return `${tags}\n${html}`;
}

export function injectScriptsAtHeadStart(html: string, scripts: readonly string[]): string {
if (scripts.length === 0) return html;
return injectTagsAtHeadStart(html, inlineScriptTags(scripts));
}

export function injectScriptsIntoHtml(
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export {
export {
RUNTIME_BOOTSTRAP_ATTR,
injectScriptsAtHeadStart,
injectTagsAtHeadStart,
injectScriptsIntoHtml,
parseHTMLContent,
stripEmbeddedRuntimeScripts,
Expand Down
31 changes: 24 additions & 7 deletions packages/player/src/shader-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,25 @@ function normalizeShaderLoadingMode(value: string | null): ShaderLoadingMode {
return "composition";
}

function setQueryParam(params: URLSearchParams, key: string, value: string | null): void {
if (value === null) params.delete(key);
else params.set(key, value);
/** Drop one of our own keys from raw `a=1&b=2` pairs, matched by name so that
* nothing around it has to be decoded. */
function withoutParam(pairs: string[], key: string): string[] {
return pairs.filter((pair) => pair !== "" && pair.split("=")[0] !== key);
}

/**
* The player's own params, appended to the query the composition author wrote
* rather than merged into a re-serialized copy of it.
*
* `new URLSearchParams(query).toString()` is a form-encoding round trip: it
* re-encodes the *whole* query as application/x-www-form-urlencoded, which
* writes every space as `+`. A composition reading its own query with
* `decodeURIComponent` — percent-decoding, which leaves `+` alone — cannot undo
* that, so a value of "Ship it today" arrived on the page as "Ship+it+today".
* The two codecs are not inverses, and the player has no business picking one
* for a query it is only passing along. The author's bytes now travel through
* byte-identical; only our two keys are rewritten.
*/
function withShaderQueryParams(
src: string,
scale: string | null,
Expand All @@ -76,10 +90,13 @@ function withShaderQueryParams(
const queryIndex = beforeHash.indexOf("?");
const path = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash;
const query = queryIndex >= 0 ? beforeHash.slice(queryIndex + 1) : "";
const params = new URLSearchParams(query);
setQueryParam(params, SHADER_CAPTURE_SCALE_PARAM, scale);
setQueryParam(params, SHADER_LOADING_PARAM, loadingMode === "composition" ? null : loadingMode);
const nextQuery = params.toString();
let pairs = withoutParam(query.split("&"), SHADER_CAPTURE_SCALE_PARAM);
pairs = withoutParam(pairs, SHADER_LOADING_PARAM);
if (scale !== null) pairs.push(`${SHADER_CAPTURE_SCALE_PARAM}=${encodeURIComponent(scale)}`);
if (loadingMode !== "composition") {
pairs.push(`${SHADER_LOADING_PARAM}=${encodeURIComponent(loadingMode)}`);
}
const nextQuery = pairs.join("&");
return `${path}${nextQuery ? `?${nextQuery}` : ""}${hash}`;
}

Expand Down
Loading