Skip to content

fix(captions): derive preview GSAP integrity - #3631

Merged
miga-heygen merged 1 commit into
mainfrom
bugfix/1787766784-preview-sri
Sep 4, 2026
Merged

miga-heygen merged 1 commit into
mainfrom
bugfix/1787766784-preview-sri

Conversation

@heygengenesis

@heygengenesis heygengenesis Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #3629

Summary

  • Derive the preview document's GSAP SHA-384 SRI from the exact local buffer returned by the request interceptor.
  • Preserve the production-generated SRI tag and crossorigin; only the in-memory document served to the offline preview is updated.
  • Add a regression test proving the emitted GSAP integrity value matches the intercepted bytes.

Root cause

make-theme.cjs emits a pinned GSAP 3.14.2 CDN tag with SRI, while preview-frames.cjs resolves and serves the locally installed bundle (currently [email protected]). Chromium correctly blocked those mismatched bytes.

Evidence

Headless Chromium preview loaded with no SRI console error

node /tmp/hf3629-e2e/sri-browser-check.cjs completed with browser preview loaded; no SRI console error; it asserted that window.__timelines.main registered and that the page emitted no integrity/SRI console errors.

Tests

  • bun test skills/embedded-captions/scripts/preview-frames.test.mjs
  • bun run lint

Commit-pinned links

  • Offending generated tag on main:
    const GSAP = `<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>`;
  • Main asset resolution and served response:
    if (!gsapSource) {
    const g = findInBun(r, "gsap", path.join("dist", "gsap.min.js"));
    if (g) gsapSource = fs.readFileSync(g, "utf8");
    }
    }
    if (!puppeteer || !sharp) {
    console.error("[preview] need puppeteer+sharp — set HYPERFRAMES_ROOT");
    process.exit(0);
    }
    async function shotAt(browser, file, W, H, t) {
    const page = await browser.newPage();
    try {
    await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 });
    // Serve the page's own CDN <script src=gsap> request from the local bundle
    // (offline-safe) instead of injecting gsap at document-start: a document-start
    // evaluation runs while document.head is still null, and gsap's init then
    // throws "appendChild of null" — which killed previews for theme projects.
    await page.setRequestInterception(true);
    page.on("request", (req) => {
    const u = req.url();
    if (req.resourceType() === "script" && /gsap/i.test(u) && /^https?:/i.test(u)) {
    if (gsapSource)
    req.respond({ status: 200, contentType: "application/javascript", body: gsapSource });
    else req.continue(); // no local bundle — let the CDN load (online machines)
    } else if (req.resourceType() === "media")
    req.abort(); // a-roll pixels come from frames_bg
    else req.continue();
  • Head hash derivation:
    const crypto = require("crypto");
    const { pathToFileURL } = require("url");
    function sriSha384(source) {
    return `sha384-${crypto.createHash("sha384").update(source).digest("base64")}`;
    }
    function withPreviewGsapSri(html, gsapSource) {
    const integrity = sriSha384(gsapSource);
    return html.replace(/<script\b(?=[^>]*\bsrc=["'][^"']*gsap[^"']*["'])[^>]*>/gi, (tag) =>
    tag
    .replace(/\s+integrity\s*=\s*(?:"[^"]*"|'[^']*')/gi, "")
    .replace(/>$/, ` integrity="${integrity}">`),
    );
    }
  • Head in-memory document rewrite and local response:
    if (!gsapSource) {
    const g = findInBun(r, "gsap", path.join("dist", "gsap.min.js"));
    if (g) gsapSource = fs.readFileSync(g);
    }
    }
    if (require.main === module && (!puppeteer || !sharp)) {
    console.error("[preview] need puppeteer+sharp — set HYPERFRAMES_ROOT");
    process.exit(0);
    }
    async function shotAt(browser, file, W, H, t) {
    const page = await browser.newPage();
    try {
    await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 });
    // Serve the page's own CDN <script src=gsap> request from the local bundle
    // (offline-safe) instead of injecting gsap at document-start: a document-start
    // evaluation runs while document.head is still null, and gsap's init then
    // throws "appendChild of null" — which killed previews for theme projects.
    await page.setRequestInterception(true);
    const documentUrl = pathToFileURL(file).href;
    const documentHtml = gsapSource
    ? withPreviewGsapSri(fs.readFileSync(file, "utf8"), gsapSource)
    : fs.readFileSync(file, "utf8");
    page.on("request", (req) => {
    const u = req.url();
    if (req.isNavigationRequest() && u === documentUrl) {
    req.respond({ status: 200, contentType: "text/html", body: documentHtml });
    } else if (req.resourceType() === "script" && /gsap/i.test(u) && /^https?:/i.test(u)) {
    if (gsapSource)
    req.respond({ status: 200, contentType: "application/javascript", body: gsapSource });
    else req.continue(); // no local bundle — let the CDN load (online machines)
    } else if (req.resourceType() === "media")
    req.abort(); // a-roll pixels come from frames_bg
    else req.continue();
    });
    await page.goto(documentUrl, { waitUntil: "load", timeout: 15000 });
  • Head regression test:
    import assert from "node:assert/strict";
    import crypto from "node:crypto";
    import { createRequire } from "node:module";
    import test from "node:test";
    const require = createRequire(import.meta.url);
    const { sriSha384, withPreviewGsapSri } = require("./preview-frames.cjs");
    test("preview GSAP integrity matches the intercepted asset bytes", () => {
    const servedAsset = Buffer.from("the exact local GSAP response body");
    const html = `
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js" integrity="sha384-stale" crossorigin="anonymous"></script>
    <script src="https://cdn.example.test/app.js" integrity="sha384-app" crossorigin="anonymous"></script>
    `;
    const emitted = withPreviewGsapSri(html, servedAsset);
    const expected = `sha384-${crypto.createHash("sha384").update(servedAsset).digest("base64")}`;
    const gsapTag = emitted.match(/<script[^>]*gsap[^>]*>/i)[0];
    assert.equal(sriSha384(servedAsset), expected);
    assert.match(gsapTag, new RegExp(`integrity="${expected}"`));
    assert.match(gsapTag, /crossorigin="anonymous"/);
    assert.match(emitted, /app\.js" integrity="sha384-app" crossorigin="anonymous"/);
    });
  • Required generated skill manifest update:
    "embedded-captions": {
    "hash": "84d986fddfc717a1",
    "files": 139
    },

No #3628 changes and no production/config changes.

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 2e630d3. Clean fix for the preview SRI mismatch.

What it does: The preview interceptor serves the locally installed GSAP bundle but the HTML still carried the CDN SRI hash — browser blocks the script, gsap is undefined, previews break. Fix computes sha384 from the actual served bytes and rewrites the GSAP <script> tag's integrity attribute to match.

Verified:

  • sriSha384() hashes the raw Buffer (correct — SRI is byte-level, not encoding-level)
  • withPreviewGsapSri() regex targets only GSAP script tags (positive lookahead for gsap in src), strips the old integrity, inserts the computed one
  • Non-GSAP scripts are untouched (test verifies app.js keeps its original integrity)
  • gsapSource changed from string to Buffer (readFileSync without encoding) — both crypto.createHash and Puppeteer's req.respond accept Buffers, and this avoids a decode/re-encode roundtrip that could flip the hash
  • Navigation interception serves the rewritten HTML so the browser sees the correct SRI on first parse
  • require.main === module guard makes the script importable for testing without side-effecting on import
  • Manifest updated (139 files, new hash)

LGTM.

— Miga

@miga-heygen
miga-heygen merged commit 0154b40 into main Sep 4, 2026
45 checks passed
@miga-heygen
miga-heygen deleted the bugfix/1787766784-preview-sri branch September 4, 2026 18:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

embedded-captions: preview-frames.cjs is broken by a gsap SRI mismatch (hard-coded 3.14.2 vs installed 3.15.0)

1 participant