fix: harden CDN script inlining with linkedom (#352)

* fix: harden CDN script inlining

* test: add spanish empire regression fixture
This commit is contained in:
Miguel Ángel
2026-04-20 21:13:59 +02:00
committed by GitHub
parent 4a55bc8673
commit a539266683
7 changed files with 2889 additions and 13 deletions
@@ -152,6 +152,26 @@ describe("inlineExternalScripts", () => {
}
});
it("preserves non-src script attributes when inlining", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock(
async () => new Response('console.log("module");', { status: 200 }),
) as any;
try {
const html =
'<html><body><script type="module" data-role="boot" src="https://cdn.example.com/module.js"></script></body></html>';
const result = await inlineExternalScripts(html);
expect(result).toMatch(/<script\b[^>]*\btype="module"/);
expect(result).toMatch(/<script\b[^>]*\bdata-role="boot"/);
expect(result).toContain('console.log("module");');
expect(result).not.toContain('src="https://cdn.example.com/module.js"');
} finally {
globalThis.fetch = originalFetch;
}
});
it("escapes </script in downloaded content", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock(
@@ -169,6 +189,45 @@ describe("inlineExternalScripts", () => {
}
});
it("preserves literal replacement tokens in downloaded script content", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock(
async () =>
new Response('const before = "$`"; const after = "$\'"; const both = "$&";', {
status: 200,
}),
) as any;
try {
const html = `<html><body><script src="https://cdn.example.com/d3.min.js"></script><div>tail</div></body></html>`;
const result = await inlineExternalScripts(html);
expect(result).toContain('const before = "$`";');
expect(result).toContain('const after = "$\'";');
expect(result).toContain('const both = "$&";');
expect(result.match(/<script>/g)?.length).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
it("returns a fragment when the input has no html/body wrapper", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock(async () => new Response("var d3 = {};", { status: 200 })) as any;
try {
const html = '<script src="https://cdn.example.com/d3.min.js"></script><div>tail</div>';
const result = await inlineExternalScripts(html);
expect(result).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
expect(result).toContain("var d3 = {};");
expect(result).toContain("<div>tail</div>");
expect(result).not.toContain('src="https://cdn.example.com/d3.min.js"');
} finally {
globalThis.fetch = originalFetch;
}
});
it("warns but keeps original tag when fetch fails", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock(async () => {
@@ -223,10 +282,11 @@ describe("inlineExternalScripts", () => {
<script src="https://cdn.example.com/gsap.min.js"></script>
</body></html>`;
const result = await inlineExternalScripts(html);
// Both should be found, both fetched
// Both identical script tags should be fetched and replaced independently.
expect(fetchCount).toBe(2);
// At least one should be inlined (regex replaces first occurrence)
expect(result).toContain("var gsap = {};");
expect(
result.match(/\/\* inlined: https:\/\/cdn\.example\.com\/gsap\.min\.js \*\//g)?.length,
).toBe(2);
} finally {
globalThis.fetch = originalFetch;
}
+12 -10
View File
@@ -777,7 +777,9 @@ function ensureFullDocument(html: string): string {
* works without network access (Docker, CI, restricted environments).
*/
export async function inlineExternalScripts(html: string): Promise<string> {
const { document } = parseHTML(html);
const fullHtml = ensureFullDocument(html);
const wrappedFragment = fullHtml !== html;
const { document } = parseHTML(fullHtml);
const scripts = document.querySelectorAll("script[src]");
const externalScripts: { el: Element; src: string }[] = [];
@@ -800,21 +802,21 @@ export async function inlineExternalScripts(html: string): Promise<string> {
}),
);
let result = html;
for (let i = 0; i < downloads.length; i++) {
const download = downloads[i]!;
const { src } = externalScripts[i]!;
const { el, src } = externalScripts[i]!;
if (download.status === "fulfilled") {
const escapedSrc = src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const scriptTagRe = new RegExp(
`<script\\b[^>]*\\bsrc=["']${escapedSrc}["'][^>]*>\\s*</script>`,
"is",
);
// Escape </script in downloaded content to prevent premature tag closure.
// <\/script is safe: the HTML parser doesn't recognize it as a close tag,
// but JS treats \/ as / so the code executes identically.
const safeText = download.value.text.replace(/<\/script/gi, "<\\/script");
result = result.replace(scriptTagRe, `<script>/* inlined: ${src} */\n${safeText}\n</script>`);
const inlineScript = document.createElement("script");
for (const attr of Array.from(el.attributes)) {
if (attr.name.toLowerCase() === "src") continue;
inlineScript.setAttribute(attr.name, attr.value);
}
inlineScript.textContent = `/* inlined: ${src} */\n${safeText}\n`;
el.replaceWith(inlineScript);
console.log(`[Compiler] Inlined CDN script: ${src}`);
} else {
console.warn(
@@ -825,7 +827,7 @@ export async function inlineExternalScripts(html: string): Promise<string> {
}
}
return result;
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
}
/**
@@ -0,0 +1,13 @@
{
"name": "spanish-empire-cdn-inline",
"description": "Regression guard for the Spanish Empire composition, trimmed to the first 8 seconds so the suite stays practical while still exercising D3, TopoJSON, and GSAP loaded from CDNs. The fixture vendors world-atlas locally so the render remains deterministic while covering the CDN script inlining path that previously corrupted third-party bundles.",
"tags": ["regression", "cdn-inline", "prod-style", "slow"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 1,
"renderConfig": {
"fps": 30,
"workers": 1
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:72ceaede15c4270c8351455eb20aacd79458a95ed76b4bf7907f0e336b7567b3
size 6910717
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long