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
49 changes: 49 additions & 0 deletions packages/core/src/compiler/externalScripts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export interface ExternalScriptAttributes {
integrity?: string;
crossorigin?: string;
}

export function readExternalScriptAttributes(el: Element): ExternalScriptAttributes {
const attributes: ExternalScriptAttributes = {};
if (el.hasAttribute("integrity")) attributes.integrity = el.getAttribute("integrity") || "";
if (el.hasAttribute("crossorigin")) attributes.crossorigin = el.getAttribute("crossorigin") || "";
return attributes;
}

/** Deduplicate scripts without discarding a nested composition's integrity requirement. */
export function ensureExternalScriptTag(
doc: Document,
src: string,
attributes: ExternalScriptAttributes = {},
): void {
const existing = [...doc.querySelectorAll("script[src]")].filter(
(el) => el.getAttribute("src")?.trim() === src,
);
const requirements = new Set(
[attributes.integrity, ...existing.map((el) => el.getAttribute("integrity"))]
.map((value) =>
value
?.trim()
.replace(
/(^|[\t\n\f\r ])(sha256|sha384|sha512)-/gi,
(_match, space: string, algorithm: string) => `${space}${algorithm.toLowerCase()}-`,
),
)
.filter((value): value is string => Boolean(value)),
);
if (requirements.size > 1) {
throw new Error(`Conflicting script integrity requirements for ${src}`);
}
const integrity = [...requirements][0];
const elements = existing.length ? existing : [doc.createElement("script")];
for (const el of elements) {
if (integrity) el.setAttribute("integrity", integrity);
if (attributes.crossorigin !== undefined)
el.setAttribute("crossorigin", attributes.crossorigin);
}
if (!existing.length) {
const el = elements[0]!;
el.setAttribute("src", src);
doc.body.appendChild(el);
}
}
96 changes: 96 additions & 0 deletions packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from "node:path";
import { parseHTML } from "linkedom";
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { bundleToSingleHtml, emitRootCompositionVariableStyles } from "./htmlBundler";
import { ensureExternalScriptTag } from "./externalScripts";
import { resetUnknownEnumWarnings } from "../runtime/getVariables";
import { sanitizeCssValue } from "../runtime/applyVariableBindings";
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
Expand Down Expand Up @@ -1629,3 +1630,98 @@ describe("emitRootCompositionVariableStyles — <style> breakout", () => {
expect(css).toContain("#ff0066");
});
});

describe("nested script integrity", () => {
it("preserves and deduplicates a nested pin even when the root already loads that URL", async () => {
const src = "https://cdn.example.com/pinned.js";
const dir = makeTempProject({
"index.html": `<html><head><script src="${src}"></script></head><body><div data-composition-id="root" data-width="320" data-height="180" data-duration="1"><div data-composition-id="child" data-composition-src="child.html"></div></div></body></html>`,
"child.html": `<html><head><script src="${src}" integrity="sha384-YQ==" crossorigin="anonymous"></script></head><body><div data-composition-id="child" data-width="320" data-height="180" data-duration="1">Child</div></body></html>`,
});
try {
const bundled = await bundleToSingleHtml(dir);
const { document } = parseHTML(bundled);
const scripts = [...document.querySelectorAll("script[src]")].filter(
(el) => el.getAttribute("src") === src,
);
expect(scripts).toHaveLength(1);
expect(scripts[0]?.getAttribute("integrity")).toBe("sha384-YQ==");
expect(scripts[0]?.getAttribute("crossorigin")).toBe("anonymous");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

it("preserves every duplicate script pin and rejects conflicting requirements", () => {
const { document } = parseHTML(
'<html><body><script src="https://cdn.example.com/a.js"></script><script src="https://cdn.example.com/a.js"></script></body></html>',
);
const src = "https://cdn.example.com/a.js";
ensureExternalScriptTag(document, src, { integrity: "sha384-YQ==", crossorigin: "anonymous" });
ensureExternalScriptTag(document, src);
for (const el of document.querySelectorAll("script")) {
expect(el.getAttribute("integrity")).toBe("sha384-YQ==");
expect(el.getAttribute("crossorigin")).toBe("anonymous");
}
expect(() => ensureExternalScriptTag(document, src, { integrity: "sha384-Yg==" })).toThrow(
"Conflicting script integrity",
);
});

it("keeps protected local scripts external when hoisting an inline template", async () => {
const dir = makeTempProject({
"index.html": `<html><body><template id="child-template"><div data-composition-id="child" data-width="320" data-height="180"><script src="local.js" integrity="sha384-YQ==" crossorigin="anonymous"></script></div></template><div data-composition-id="root" data-width="320" data-height="180" data-duration="1"><div data-composition-id="child" data-start="0" data-duration="1"></div></div></body></html>`,
"local.js": "window.localPinWitness = true;",
});
try {
const bundled = await bundleToSingleHtml(dir);
const { document } = parseHTML(bundled);
const script = document.querySelector('script[src="local.js"]');
expect(script?.getAttribute("integrity")).toBe("sha384-YQ==");
expect(script?.getAttribute("crossorigin")).toBe("anonymous");
expect(bundled).not.toContain("window.localPinWitness = true;");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it.each(["root", "sibling", "template"])(
"does not inline local bytes before a later %s integrity requirement",
async (placement) => {
const unpinned = '<script src="local.js"></script>';
const pinned =
'<script src="./local.js" integrity="sHa384-YQ==" crossorigin="anonymous"></script>';
const rootScript = placement === "root" ? unpinned : "";
const first =
placement === "sibling"
? '<div data-composition-id="first" data-composition-src="first.html"></div>'
: "";
const templates =
placement === "template"
? `<template id="first-template"><div data-composition-id="first">${unpinned}</div></template><template id="child-template"><div data-composition-id="child">${pinned}</div></template>`
: "";
const children =
placement === "template"
? '<div data-composition-id="first" data-start="0" data-duration="1"></div><div data-composition-id="child" data-start="0" data-duration="1"></div>'
: `${first}<div data-composition-id="child" data-composition-src="child.html"></div>`;
const dir = makeTempProject({
"index.html": `<html><head>${rootScript}</head><body>${templates}<div data-composition-id="root" data-width="320" data-height="180" data-duration="1">${children}</div></body></html>`,
"first.html": `<html><head>${unpinned}</head><body><div data-composition-id="first" data-width="320" data-height="180" data-duration="1">First</div></body></html>`,
"child.html": `<html><head>${pinned}</head><body><div data-composition-id="child" data-width="320" data-height="180" data-duration="1">Child</div></body></html>`,
"local.js": "window.alteredLocalBytes = true;",
});
try {
const bundled = await bundleToSingleHtml(dir);
expect(bundled).not.toContain("window.alteredLocalBytes = true;");
const { document } = parseHTML(bundled);
const local = [...document.querySelectorAll("script[src]")].filter((el) =>
/local\.js$/.test(el.getAttribute("src") || ""),
);
expect(local.length).toBeGreaterThan(0);
for (const el of local) expect(el.getAttribute("integrity")).toBe("sha384-YQ==");
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
142 changes: 89 additions & 53 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
ensureExternalScriptTag,
readExternalScriptAttributes,
type ExternalScriptAttributes,
} from "./externalScripts";
import { markFlattenedInnerRoot } from "../runtime/flattenedRoot";
export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
import { parseHostVariableValues, warnUnknownEnumValues } from "../runtime/getVariables";
Expand Down Expand Up @@ -722,31 +727,55 @@ export interface BundleOptions {
* - Inlines small textual assets as data URLs
*/

function ensureExternalScriptTag(doc: Document, src: string): void {
if (queryByAttr(doc, "src", src, "script")) return;
const el = doc.createElement("script");
el.setAttribute("src", src);
doc.body.appendChild(el);
type DeferredScriptChunk = string | (() => string);

function preserveLocalScriptIntegrity(
doc: Document,
src: string,
resolvePath: (src: string) => string | null,
): boolean {
const path = resolvePath(src);
if (!path) return false;
const pinned = [...doc.querySelectorAll("script[src][integrity]")].filter((el) => {
const candidate = el.getAttribute("src") || "";
return (
isRelativeUrl(candidate) &&
resolvePath(candidate) === path &&
el.getAttribute("integrity")?.trim()
);
});
for (const el of pinned) ensureExternalScriptTag(doc, src, readExternalScriptAttributes(el));
return pinned.length > 0;
}

function hoistExternalScript(
src: string,
projectDir: string,
doc: Document,
seenSrcs: Set<string>,
chunks: string[],
chunks: DeferredScriptChunk[],
attributes: ExternalScriptAttributes,
): void {
if (attributes.integrity?.trim()) {
ensureExternalScriptTag(doc, src, attributes);
seenSrcs.add(src);
return;
}
if (seenSrcs.has(src)) return;
seenSrcs.add(src);
if (!isNonRelativeUrl(src) && !isAbsolute(src)) {
const jsPath = resolveWithinProject(projectDir, src);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js != null) {
chunks.push(js);
chunks.push(() =>
preserveLocalScriptIntegrity(doc, src, (value) => resolveWithinProject(projectDir, value))
? ""
: js,
);
return;
}
}
ensureExternalScriptTag(doc, src);
ensureExternalScriptTag(doc, src, attributes);
}

function hoistCompositionScripts(
Expand All @@ -759,7 +788,7 @@ function hoistCompositionScripts(
runtimeCompId: string | undefined;
authoredRootId: string | undefined;
seenCompScriptSrcs: Set<string>;
compScriptChunks: string[];
compScriptChunks: DeferredScriptChunk[];
},
): void {
for (const scriptEl of [...container.querySelectorAll("script")]) {
Expand All @@ -771,6 +800,7 @@ function hoistCompositionScripts(
opts.document,
opts.seenCompScriptSrcs,
opts.compScriptChunks,
readExternalScriptAttributes(scriptEl),
);
} else {
opts.compScriptChunks.push(
Expand Down Expand Up @@ -857,42 +887,6 @@ export async function bundleToSingleHtml(
}
}

// Inline local JS
const localJsChunks: string[] = [];
let jsAnchorPlaced = false;
for (const el of [...document.querySelectorAll("script[src]")]) {
const src = el.getAttribute("src");
if (!src || !isRelativeUrl(src)) continue;
// Module scripts can contain static imports whose resolution is relative
// to the script URL. Folding their source into a classic inline script
// both drops module semantics and changes the import base URL.
if ((el.getAttribute("type") || "").trim().toLowerCase() === "module") continue;
const jsPath = resolveEntryPath(src);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js == null) continue;
localJsChunks.push(js);
if (!jsAnchorPlaced) {
const anchor = document.createElement("script");
anchor.setAttribute("data-hf-bundled-local-js", "1");
el.replaceWith(anchor);
jsAnchorPlaced = true;
} else {
el.remove();
}
}
if (localJsChunks.length > 0) {
const anchor = document.querySelector('script[data-hf-bundled-local-js="1"]');
const joinedJs = joinJsChunks(localJsChunks);
if (anchor) {
anchor.removeAttribute("data-hf-bundled-local-js");
anchor.textContent = joinedJs;
} else {
const script = document.createElement("script");
script.textContent = joinedJs;
document.body.appendChild(script);
}
}

// Inline sub-compositions (via shared function)
const trackedCompositionHosts = getBundledTrackedCompositionHosts(document);
const hostIdentityByElement = assignBundledRuntimeCompositionIds(trackedCompositionHosts);
Expand Down Expand Up @@ -927,7 +921,7 @@ export async function bundleToSingleHtml(
},
});
const compStyleChunks: string[] = [...subCompResult.styles];
const compScriptChunks: string[] = [];
const compScriptChunks: DeferredScriptChunk[] = [];
const compExternalLinks = [...subCompResult.externalLinks];
const compVariablesByComp: Record<string, Record<string, unknown>> = {
...subCompResult.variablesByComp,
Expand All @@ -939,21 +933,24 @@ export async function bundleToSingleHtml(
continue;
}
const extSrc = scriptItem.src;
if (scriptItem.integrity?.trim()) {
ensureExternalScriptTag(document, extSrc, scriptItem);
seenCompScriptSrcs.add(extSrc);
continue;
}
if (seenCompScriptSrcs.has(extSrc)) continue;
seenCompScriptSrcs.add(extSrc);
if (isRelativeUrl(extSrc)) {
const jsPath = resolveEntryPath(extSrc);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js != null) {
compScriptChunks.push(js);
compScriptChunks.push(() =>
preserveLocalScriptIntegrity(document, extSrc, resolveEntryPath) ? "" : js,
);
continue;
}
}
if (!queryByAttr(document, "src", extSrc, "script")) {
const extScript = document.createElement("script");
extScript.setAttribute("src", extSrc);
document.body.appendChild(extScript);
}
ensureExternalScriptTag(document, extSrc, scriptItem);
}

// Inline template compositions: inject <template id="X-template"> content into
Expand Down Expand Up @@ -1068,6 +1065,43 @@ export async function bundleToSingleHtml(
templateEl.remove();
}

// Inline local JS
const localJsChunks: string[] = [];
let jsAnchorPlaced = false;
for (const el of [...document.querySelectorAll("script[src]")]) {
const src = el.getAttribute("src");
if (!src || !isRelativeUrl(src)) continue;
if (preserveLocalScriptIntegrity(document, src, resolveEntryPath)) continue;
// Module scripts can contain static imports whose resolution is relative
// to the script URL. Folding their source into a classic inline script
// both drops module semantics and changes the import base URL.
if ((el.getAttribute("type") || "").trim().toLowerCase() === "module") continue;
const jsPath = resolveEntryPath(src);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js == null) continue;
localJsChunks.push(js);
if (!jsAnchorPlaced) {
const anchor = document.createElement("script");
anchor.setAttribute("data-hf-bundled-local-js", "1");
el.replaceWith(anchor);
jsAnchorPlaced = true;
} else {
el.remove();
}
}
if (localJsChunks.length > 0) {
const anchor = document.querySelector('script[data-hf-bundled-local-js="1"]');
const joinedJs = joinJsChunks(localJsChunks);
if (anchor) {
anchor.removeAttribute("data-hf-bundled-local-js");
anchor.textContent = joinedJs;
} else {
const script = document.createElement("script");
script.textContent = joinedJs;
document.body.appendChild(script);
}
}

// Inject external scripts from sub-compositions (e.g., Lottie CDN)
// that aren't already present in the main document.
for (const link of compExternalLinks) {
Expand All @@ -1092,7 +1126,9 @@ export async function bundleToSingleHtml(
}
if (compScriptChunks.length) {
const compScript = document.createElement("script");
compScript.textContent = joinJsChunks(compScriptChunks);
compScript.textContent = joinJsChunks(
compScriptChunks.map((chunk) => (typeof chunk === "string" ? chunk : chunk())),
);
document.body.appendChild(compScript);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,5 @@ export {
MEDIA_RENDER_ID_ATTR,
assignMediaRenderIds,
} from "./mediaRenderIds";

export { ensureExternalScriptTag } from "./externalScripts";
Loading
Loading