From 1c14fbb5428979492a937540a3f476aedfebd9c2 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sat, 24 Oct 2026 22:13:28 +0000 Subject: [PATCH] fix(studio): allow a local GSAP dependency mirror (#6247) --- docs/packages/cli.mdx | 5 + docs/packages/studio-server.mdx | 42 ++++ packages/core/src/gsapCdn.test.ts | 35 ++- packages/core/src/gsapCdn.ts | 24 ++- .../src/helpers/subComposition.ts | 3 +- .../studio-server/src/routes/preview.test.ts | 201 ++++++++++++++++++ packages/studio-server/src/routes/preview.ts | 89 +++++--- .../studio/src/utils/gsapSoftReload.test.ts | 42 ++++ packages/studio/src/utils/gsapSoftReload.ts | 13 +- 9 files changed, 421 insertions(+), 33 deletions(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index d263fba0c8..ac8ff3cf8d 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -874,6 +874,11 @@ The server picks one of three modes by itself: **embedded** (the default for **monorepo** (the studio dev server, when you run from the source repo). `--background` works with the embedded server only. +To load the GSAP dependencies Studio adds from a local or private mirror, set +`HYPERFRAMES_GSAP_BASE_URL` before starting the server. See +[Serve GSAP from your own mirror](/packages/studio-server#serve-gsap-from-your-own-mirror) +for the directory layout, restart requirements, and authored-resource scope. + Preview plays in real time, so paint-heavy compositions can stutter on your machine. Render seeks and captures one frame at a time, so the same work usually diff --git a/docs/packages/studio-server.mdx b/docs/packages/studio-server.mdx index 640e199f27..9bd282e383 100644 --- a/docs/packages/studio-server.mdx +++ b/docs/packages/studio-server.mdx @@ -66,6 +66,48 @@ the bundler inlines can be served with ids that a later save does not mint. bundle: (dir, options) => bundleToSingleHtml(dir, { runtime: "inline", ...options }), ``` +## Serve GSAP from your own mirror + +Studio can load the GSAP dependencies it adds from a local or private mirror +when jsDelivr is unavailable. Set `HYPERFRAMES_GSAP_BASE_URL` before starting +the preview server or creating the Studio API: + +```bash +HYPERFRAMES_GSAP_BASE_URL=http://127.0.0.1:8080/vendor/npm \ + npx hyperframes preview ./my-video +``` + +Serve the GSAP distribution files under that URL with this layout, using the +versions your compositions need: + +```text +/gsap@3.15.0/dist/gsap.min.js +/gsap@3.15.0/dist/CustomEase.min.js +/gsap@3.15.0/dist/MotionPathPlugin.min.js +``` + +Studio uses the mirror for the GSAP, CustomEase, and MotionPathPlugin scripts +it adds to previews, including dependencies loaded when you edit motion. +Keep `gsap@` in authored GSAP URLs so the server can infer which +plugin version to add. When it cannot infer a version, it uses the framework's +default, currently `3.15.0`. + +The base must be an absolute HTTP(S) URL without credentials, a query string, +or a fragment. Surrounding whitespace and trailing slashes are removed. +An unset or empty value uses `https://cdn.jsdelivr.net/npm`; an invalid +nonempty value stops API startup with a configuration error. + +The server reads this setting once at startup. Restart it after changing the +value. For a managed CLI preview, run `npx hyperframes preview ./my-video --stop` +before starting it again with the new setting. Cached preview HTML is keyed +by the normalized mirror URL, so a different mirror takes effect after restart +without deleting the preview cache. + +This setting changes only dependencies Studio adds. Your composition's own +script, font, image, and media URLs stay as authored; serve those locally too +for an offline project. If the selected mirror is unavailable or lacks a +requested file, Studio does not retry that dependency through jsDelivr. + ## Other entry points | Import | Purpose | diff --git a/packages/core/src/gsapCdn.test.ts b/packages/core/src/gsapCdn.test.ts index f5d0f246a1..636c0e82a4 100644 --- a/packages/core/src/gsapCdn.test.ts +++ b/packages/core/src/gsapCdn.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { gsapCdnDist, motionPathPluginUrl } from "./gsapCdn"; +import { gsapCdnDist, motionPathPluginUrl, normalizeGsapBaseUrl } from "./gsapCdn"; describe("gsapCdnDist", () => { it("serves the composition's gsap version", () => { @@ -16,3 +16,36 @@ describe("gsapCdnDist", () => { ); }); }); + +describe("GSAP mirrors", () => { + it("uses a normalized npm mirror root while retaining numeric versions", () => { + expect(gsapCdnDist("3.14.2", " https://mirror.example/vendor/npm/// ")).toBe( + "https://mirror.example/vendor/npm/gsap@3.14.2/dist/", + ); + expect(motionPathPluginUrl("3", "http://localhost:8080/npm")).toBe( + "http://localhost:8080/npm/gsap@3/dist/MotionPathPlugin.min.js", + ); + expect(gsapCdnDist("latest", "https://mirror.example/npm")).toBe( + "https://mirror.example/npm/gsap@3.15.0/dist/", + ); + }); + + it.each([undefined, "", " "])("keeps the default root for %j", (base) => { + expect(normalizeGsapBaseUrl(base)).toBe("https://cdn.jsdelivr.net/npm"); + }); + + it.each([ + "/vendor/npm", + "file:///vendor/npm", + "https:mirror.example", + "not a URL", + "https://user:password@mirror.example/npm", + "https://mirror.example/npm?token=x", + "https://mirror.example/npm#section", + "https://mirror.example/npm?", + "https://mirror.example/npm#", + ])("rejects an invalid mirror instead of using the public CDN: %s", (base) => { + expect(() => normalizeGsapBaseUrl(base)).toThrow(); + expect(() => motionPathPluginUrl("3.15.0", base)).toThrow(); + }); +}); diff --git a/packages/core/src/gsapCdn.ts b/packages/core/src/gsapCdn.ts index ad518a2223..0e62ffb1b3 100644 --- a/packages/core/src/gsapCdn.ts +++ b/packages/core/src/gsapCdn.ts @@ -1,11 +1,27 @@ const GSAP_CDN_VERSION = "3.15.0"; +const GSAP_CDN_BASE_URL = "https://cdn.jsdelivr.net/npm"; +export const GSAP_BASE_URL_META = "hyperframes-gsap-base-url"; -export function gsapCdnDist(gsapVersion?: string): string { +/** An npm-style mirror root; callers choose configuration without coupling this helper to a host. */ +export function normalizeGsapBaseUrl(baseUrl?: string): string { + const value = baseUrl?.trim(); + if (!value) return GSAP_CDN_BASE_URL; + if (!/^https?:\/\//i.test(value) || !URL.canParse(value) || /[?#]/.test(value)) { + throw new Error("GSAP base URL must be an absolute HTTP(S) URL without a query or fragment"); + } + const url = new URL(value); + if (url.username || url.password) { + throw new Error("GSAP base URL must not contain credentials"); + } + return url.href.replace(/\/+$/, ""); +} + +export function gsapCdnDist(gsapVersion?: string, baseUrl?: string): string { const version = gsapVersion && /^\d+(\.\d+)*$/.test(gsapVersion) ? gsapVersion : GSAP_CDN_VERSION; - return `https://cdn.jsdelivr.net/npm/gsap@${version}/dist/`; + return `${normalizeGsapBaseUrl(baseUrl)}/gsap@${version}/dist/`; } /** MotionPathPlugin at the composition's own gsap version; a skew registers with a GSAP warning. */ -export function motionPathPluginUrl(gsapVersion?: string): string { - return `${gsapCdnDist(gsapVersion)}MotionPathPlugin.min.js`; +export function motionPathPluginUrl(gsapVersion?: string, baseUrl?: string): string { + return `${gsapCdnDist(gsapVersion, baseUrl)}MotionPathPlugin.min.js`; } diff --git a/packages/studio-server/src/helpers/subComposition.ts b/packages/studio-server/src/helpers/subComposition.ts index fde952e10f..e7e752cf83 100644 --- a/packages/studio-server/src/helpers/subComposition.ts +++ b/packages/studio-server/src/helpers/subComposition.ts @@ -269,6 +269,7 @@ export function buildSubCompositionHtml( runtimeUrl: string, baseHref?: string, rawOverride?: string, + gsapBaseUrl?: string, ): string | null { const compFile = join(projectDir, compPath); if (!existsSync(compFile)) return null; @@ -353,7 +354,7 @@ export function buildSubCompositionHtml( // Fallback: if no index.html head was found, add minimal deps if (!headContent.includes("gsap")) { - headContent += `\n`; + headContent += `\n`; } const htmlOpen = htmlAttrs ? `` : ""; diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index 01b593cd16..8bcf3991d8 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -42,6 +42,7 @@ const tempDirs: string[] = []; afterEach(() => { vi.useRealTimers(); + vi.unstubAllEnvs(); for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -2431,3 +2432,203 @@ describe("the desktop app's private folder", () => { expect(res.status).toBe(200); }); }); + +describe("GSAP mirror configuration", () => { + const mirror = "http://localhost:8080/vendor/npm"; + const metaName = "hyperframes-gsap-base-url"; + const previewUrl = "http://localhost/projects/demo/preview"; + + function appForMirror( + projectDir: string, + base: string, + overrides: Partial = {}, + ) { + vi.stubEnv("HYPERFRAMES_GSAP_BASE_URL", base); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir, overrides)); + return app; + } + + it.each(["/relative/npm", "https://mirror.example/npm?token=1", "file:///npm"])( + "rejects invalid configuration before registering routes: %s", + (base) => { + expect(() => appForMirror(createProjectDir(), base)).toThrow("HYPERFRAMES_GSAP_BASE_URL"); + }, + ); + + it.each(["bundle", "disk fallback", "capture", "subcomposition"])( + "uses server metadata and the configured plugin in the %s document", + async (mode) => { + const projectDir = createProjectDir(); + const authored = "https://authored.example/gsap@3.14.2/dist/gsap.min.js"; + const source = ``; + writeFileSync(join(projectDir, "index.html"), source); + writeFileSync(join(projectDir, "scene.html"), source); + const app = appForMirror(projectDir, mirror, { + bundle: async () => { + if (mode === "disk fallback") throw new Error("test bundle failure"); + return source; + }, + }); + const suffix = + mode === "subcomposition" ? "/comp/scene.html" : mode === "capture" ? "?hf-capture=1" : ""; + const response = await app.request(previewUrl + suffix); + expect(response.status).toBe(200); + const html = await response.text(); + const doc = parseHTML(html).document; + expect(doc.querySelector(`meta[name="${metaName}"]`)?.getAttribute("content")).toBe(mirror); + expect(doc.querySelector(`script[src="${authored}"]`)).not.toBeNull(); + expect(doc.querySelector('script[src*="MotionPathPlugin"]')?.getAttribute("src")).toBe( + `${mirror}/gsap@3.14.2/dist/MotionPathPlugin.min.js`, + ); + expect(html).not.toContain("cdn.jsdelivr.net"); + }, + ); + + it("uses the mirror for generated GSAP, CustomEase, and standalone GSAP", async () => { + const projectDir = createProjectDir(); + mkdirSync(join(projectDir, ".hyperframes")); + writeFileSync( + join(projectDir, ".hyperframes", "studio-motion.json"), + JSON.stringify({ + version: 1, + motions: [ + { + kind: "gsap-motion", + target: { sourceFile: "index.html", id: "card" }, + start: 0, + duration: 1, + customEase: { id: "custom", data: "M0,0 C0.18,0.9 0.32,1 1,1" }, + from: { y: 32 }, + to: { y: 0 }, + }, + ], + }), + ); + writeFileSync(join(projectDir, "scene.html"), ""); + const app = appForMirror(projectDir, mirror); + const root = parseHTML(await (await app.request(previewUrl)).text()).document; + const urls = [...root.querySelectorAll("script[src]")].map((script) => + script.getAttribute("src"), + ); + expect(urls).toContain(`${mirror}/gsap@3.15.0/dist/gsap.min.js`); + expect(urls).toContain(`${mirror}/gsap@3.15.0/dist/CustomEase.min.js`); + const scene = parseHTML( + await (await app.request(previewUrl + "/comp/scene.html")).text(), + ).document; + expect(scene.querySelector('script[src$="/gsap.min.js"]')?.getAttribute("src")).toBe( + `${mirror}/gsap@3.15.0/dist/gsap.min.js`, + ); + }); + + it("preserves authored plugins and the default CDN when the override is unset", async () => { + const projectDir = createProjectDir(); + const plugin = "https://authored.example/MotionPathPlugin.min.js"; + writeFileSync( + join(projectDir, "index.html"), + ``, + ); + const configured = await (await appForMirror(projectDir, mirror).request(previewUrl)).text(); + expect( + parseHTML(configured).document.querySelectorAll('script[src*="MotionPathPlugin"]'), + ).toHaveLength(1); + expect(configured).toContain(`src="${plugin}"`); + const defaults = await (await appForMirror(projectDir, "").request(previewUrl)).text(); + expect( + parseHTML(defaults) + .document.querySelector(`meta[name="${metaName}"]`) + ?.getAttribute("content"), + ).toBe("https://cdn.jsdelivr.net/npm"); + expect(defaults).toContain('var cdnBase="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/"'); + }); + + it("normalizes and escapes metadata without letting authored metadata override it", async () => { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "index.html"), + ``, + ); + const app = appForMirror(projectDir, " https://mirror.example/a©/npm/// "); + const html = await (await app.request(previewUrl)).text(); + expect(html).toContain('content="https://mirror.example/a©/npm"'); + expect( + parseHTML(html).document.querySelector(`meta[name="${metaName}"]`)?.getAttribute("content"), + ).toBe("https://mirror.example/a©/npm"); + }); + + it("invalidates root, subcomposition, and persistent preview caches when the mirror changes", async () => { + const projectDir = createProjectDir(); + writeFileSync(join(projectDir, "scene.html"), ""); + const bundle = vi.fn( + async () => + '
Cached preview
', + ); + const overrides = { + bundle, + previewDocuments: createPreviewDocumentStore(projectDir, "same-build"), + }; + const first = appForMirror(projectDir, mirror, overrides); + const root = await first.request(previewUrl); + const scene = await first.request(previewUrl + "/comp/scene.html"); + const secondBase = "https://other.example/npm"; + const second = appForMirror(projectDir, secondBase, overrides); + const rootNext = await second.request(previewUrl, { + headers: { "If-None-Match": root.headers.get("ETag")! }, + }); + const sceneNext = await second.request(previewUrl + "/comp/scene.html", { + headers: { "If-None-Match": scene.headers.get("ETag")! }, + }); + expect(rootNext.status).toBe(200); + expect(sceneNext.status).toBe(200); + expect(rootNext.headers.get("ETag")).not.toBe(root.headers.get("ETag")); + expect(sceneNext.headers.get("ETag")).not.toBe(scene.headers.get("ETag")); + expect(bundle).toHaveBeenCalledTimes(2); + const firstHtml = parseHTML(await root.text()).document; + const nextHtml = parseHTML(await rootNext.text()).document; + expect(nextHtml.querySelector(`meta[name="${metaName}"]`)?.getAttribute("content")).toBe( + secondBase, + ); + expect(nextHtml.querySelector('meta[name="hf-scene-parts"]')?.getAttribute("content")).not.toBe( + firstHtml.querySelector('meta[name="hf-scene-parts"]')?.getAttribute("content"), + ); + const normalized = appForMirror(projectDir, ` ${secondBase}/// `, overrides); + expect( + ( + await normalized.request(previewUrl, { + headers: { "If-None-Match": rootNext.headers.get("ETag")! }, + }) + ).status, + ).toBe(304); + vi.stubEnv("HYPERFRAMES_GSAP_BASE_URL", mirror); + expect((await second.request(previewUrl)).headers.get("ETag")).toBe( + rootNext.headers.get("ETag"), + ); + }); + + it("retries an authored GSAP failure only on the selected mirror and reports mirror failure", async () => { + const app = appForMirror(createProjectDir(), mirror); + const html = await (await app.request(previewUrl)).text(); + const fallback = /`; -const GSAP_CUSTOM_EASE_CDN_SCRIPT = ``; -const GSAP_MOTION_PATH_CDN_SCRIPT = ``; +function escapeGsapUrl(url: string): string { + return url.replace(/&/g, "&").replace(/"/g, """); +} + +function gsapScriptTag(file: string, baseUrl: string): string { + return ``; +} function injectProjectSignature(html: string, signature: string): string { const tag = ``; @@ -166,7 +175,7 @@ function htmlHasMotionPathPlugin(html: string): boolean { ); } -function injectMotionPathPluginIfNeeded(html: string): string { +function injectMotionPathPluginIfNeeded(html: string, baseUrl: string): string { if (!htmlUsesMotionPath(html) || htmlHasMotionPathPlugin(html)) return html; // The plugin registers onto an already-loaded gsap, so it must come AFTER the // core gsap script — which often lives at body-end, not . Insert it @@ -176,10 +185,10 @@ function injectMotionPathPluginIfNeeded(html: string): string { if (gsap) { const version = gsap.src.match(/gsap@([\d.]+)/)?.[1]; const ordering = `${gsap.type ? ` type="${gsap.type}"` : ""}${gsap.defer ? " defer" : ""}`; - const pluginTag = ``; + const pluginTag = ``; return html.slice(0, gsap.end) + "\n" + pluginTag + html.slice(gsap.end); } - return injectScriptTagIntoHead(html, GSAP_MOTION_PATH_CDN_SCRIPT); + return injectScriptTagIntoHead(html, gsapScriptTag("MotionPathPlugin.min.js", baseUrl)); } function isGsapCoreUrl(src: string): boolean { @@ -221,13 +230,18 @@ function scriptCloseEnd(html: string, start: number): number { return closeAt === undefined ? 0 : html.indexOf(">", closeAt) + 1; } -function injectStudioMotionDependencies(html: string, manifestContent: string): string { +function injectStudioMotionDependencies( + html: string, + manifestContent: string, + baseUrl: string, +): string { const manifest = parseStudioMotionManifestContent(manifestContent); if (!manifest.hasMotion) return html; let next = html; - if (!htmlHasGsap(next)) next = injectScriptTagIntoHead(next, GSAP_CDN_SCRIPT); + if (!htmlHasGsap(next)) + next = injectScriptTagIntoHead(next, gsapScriptTag("gsap.min.js", baseUrl)); if (manifest.hasCustomEase && !htmlHasCustomEase(next)) { - next = injectScriptTagIntoHead(next, GSAP_CUSTOM_EASE_CDN_SCRIPT); + next = injectScriptTagIntoHead(next, gsapScriptTag("CustomEase.min.js", baseUrl)); } return next; } @@ -236,21 +250,23 @@ function injectStudioMotionScript( html: string, projectDir: string, activeCompositionPath: string, + baseUrl: string, ): string { const manifestContent = readStudioMotionManifestContent(projectDir); const script = createStudioMotionRenderBodyScript(manifestContent, { activeCompositionPath, }); if (!script) return html; - const withDependencies = injectStudioMotionDependencies(html, manifestContent); + const withDependencies = injectStudioMotionDependencies(html, manifestContent, baseUrl); const tag = ``; return insertBeforeCloseTag(withDependencies, "body", tag) ?? `${withDependencies}${tag}`; } const GSAP_FALLBACK_ATTR = "data-hf-gsap-fallback"; -const GSAP_CDN_FALLBACK_SCRIPT = ``; +} -function injectGsapCdnFallback(html: string): string { +function injectGsapCdnFallback(html: string, baseUrl: string): string { if (html.includes(GSAP_FALLBACK_ATTR)) return html; - if (html.includes("")) return html.replace("", "" + GSAP_CDN_FALLBACK_SCRIPT); - return GSAP_CDN_FALLBACK_SCRIPT + html; + if (html.includes("")) + return html.replace("", "" + gsapCdnFallbackScript(baseUrl)); + return gsapCdnFallbackScript(baseUrl) + html; } /** @@ -333,9 +351,9 @@ function injectStudioPreviewAugmentations( adapter: StudioApiAdapter, projectDir: string, activeCompositionPath: string, - capture: boolean, + options: { capture: boolean; gsapBaseUrl: string }, ): string { - const marked = capture + const marked = options.capture ? injectTagsAtHeadStart( html, ``, @@ -347,11 +365,20 @@ function injectStudioPreviewAugmentations( return injectStudioMotionScript( injectMotionPathPluginIfNeeded( injectGsapCdnFallback( - injectProjectSignature(marked, resolveProjectSignature(adapter, projectDir)), + injectProjectSignature( + injectTagsAtHeadStart( + marked, + ``, + ), + resolveProjectSignature(adapter, projectDir), + ), + options.gsapBaseUrl, ), + options.gsapBaseUrl, ), projectDir, activeCompositionPath, + options.gsapBaseUrl, ); } @@ -416,6 +443,16 @@ const withPreviewBase = (html: string, projectId: string) => : injectTagsAtHeadStart(html, ``); export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): void { + let gsapBaseUrl: string; + try { + gsapBaseUrl = normalizeGsapBaseUrl(process.env.HYPERFRAMES_GSAP_BASE_URL); + } catch (cause) { + throw new Error( + "Invalid HYPERFRAMES_GSAP_BASE_URL: use an absolute HTTP(S) npm mirror root without credentials, query, or fragment", + { cause }, + ); + } + const gsapEtagSalt = `:gsap:${createHash("sha1").update(gsapBaseUrl).digest("hex").slice(0, 12)}`; const previewCacheHeaders = (etag: string) => ({ "Cache-Control": "private, no-cache", ETag: etag, @@ -472,7 +509,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo adapter, project.dir, mainCompositionPath, - capture, + { capture, gsapBaseUrl }, ); if (previewVariables) bundled = injectPreviewVariables(bundled, previewVariables); bundled = await injectMediaCodecMap( @@ -502,7 +539,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo adapter, project.dir, fallback.compositionPath, - capture, + { capture, gsapBaseUrl }, ); if (previewVariables) { fallbackAugmented = injectPreviewVariables(fallbackAugmented, previewVariables); @@ -533,7 +570,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo const previewVariables = vars.values; const capture = c.req.query(PREVIEW_CAPTURE_PARAM) !== undefined; - const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`; + const etag = `"preview:${signature}${gsapEtagSalt}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`; const ifNoneMatch = c.req.header("If-None-Match"); if (ifNoneMatch === etag) { return new Response(null, { @@ -595,7 +632,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo // not revalidate to a 304 that skips the pin. const compPathHash = createHash("sha1").update(compPath).digest("hex"); const capture = c.req.query(PREVIEW_CAPTURE_PARAM) !== undefined; - const etag = `"comp:v2:${compPathHash}:${signature}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`; + const etag = `"comp:v2:${compPathHash}:${signature}${gsapEtagSalt}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`; const ifNoneMatch = c.req.header("If-None-Match"); if (ifNoneMatch === etag) { return new Response(null, { @@ -614,11 +651,15 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo adapter.runtimeUrl, baseHref, stamped, + gsapBaseUrl, ); if (!html) return c.text("not found", 404); recordPreviewReferences(project.dir, html); html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath)); - html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath, capture); + html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath, { + capture, + gsapBaseUrl, + }); if (previewVariables) html = injectPreviewVariables(html, previewVariables); html = await injectMediaCodecMap(html, adapter, project.dir, compPath, mediaCodecProbeCache); return c.html(html, 200, previewCacheHeaders(etag)); diff --git a/packages/studio/src/utils/gsapSoftReload.test.ts b/packages/studio/src/utils/gsapSoftReload.test.ts index 674d4546d6..20276b9e07 100644 --- a/packages/studio/src/utils/gsapSoftReload.test.ts +++ b/packages/studio/src/utils/gsapSoftReload.test.ts @@ -318,6 +318,28 @@ window.__timelines["root"] = tl;`; expect(contentWindow.__hfMotionPathPluginLoading).toBe(false); }); + it("loads a missing soft-reload plugin from the iframe mirror and reports its failure", () => { + const { iframe, contentWindow } = buildMockIframe({ + gsap: { timeline: vi.fn(), registerPlugin: vi.fn(), version: "3.14.2" }, + }); + const head = iframe.contentDocument!.head; + const meta = document.createElement("meta"); + meta.name = "hyperframes-gsap-base-url"; + meta.content = "http://localhost:8080/vendor/npm"; + head.appendChild(meta); + const onAsyncFailure = vi.fn(); + expect(applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, { onAsyncFailure })).toBe("applied"); + const plugin = head.querySelector("script"); + expect(plugin?.src).toBe( + "http://localhost:8080/vendor/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js", + ); + plugin?.onerror?.(new Event("error")); + expect(onAsyncFailure).toHaveBeenCalledTimes(1); + expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled(); + expect(contentWindow.__hfMotionPathPluginLoading).toBe(false); + expect(head.querySelectorAll("script")).toHaveLength(1); + }); + it('returns "cannot-soft-reload" when multiple GSAP scripts exist (ambiguous)', () => { const script1 = document.createElement("script"); script1.textContent = "const tl = gsap.timeline({ paused: true });"; @@ -490,6 +512,26 @@ describe("ensureMotionPathPluginLoaded", () => { ); }); + it("preloads from the iframe mirror once, retaining the runtime version", () => { + const { iframe, appendedScripts, contentWindow, registerPlugin } = buildBootstrapIframe({ + gsap: { version: "3.14.2", registerPlugin: vi.fn() }, + }); + const meta = document.createElement("meta"); + meta.name = "hyperframes-gsap-base-url"; + meta.content = "http://localhost:8080/vendor/npm"; + iframe.contentDocument!.head.appendChild(meta); + ensureMotionPathPluginLoaded(iframe); + ensureMotionPathPluginLoaded(iframe); + expect(appendedScripts).toHaveLength(1); + expect(appendedScripts[0]!.src).toBe( + "http://localhost:8080/vendor/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js", + ); + appendedScripts[0]!.onerror?.(new Event("error")); + expect(contentWindow.__hfMotionPathPluginLoading).toBe(false); + expect(registerPlugin).not.toHaveBeenCalled(); + expect(appendedScripts).toHaveLength(1); + }); + it("appends the plugin script once and registers it on load", () => { const { iframe, contentWindow, appendedScripts, registerPlugin } = buildBootstrapIframe(); ensureMotionPathPluginLoaded(iframe); diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 2294337f62..aaf0ba511e 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -1,5 +1,5 @@ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading"; -import { motionPathPluginUrl } from "@hyperframes/core/gsap-cdn"; +import { GSAP_BASE_URL_META, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn"; import { findAuthoredElement } from "./authoredSource"; import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity"; import { @@ -50,6 +50,13 @@ type IframeWindow = Window & { // At most one preview waits for its runtime; a newer load replaces it, so a frame that never boots is not kept alive. let pendingReadyRetry: ((event: MessageEvent) => void) | null = null; +function previewMotionPathUrl(doc: Document, version?: string): string { + const baseUrl = doc.head.querySelector( + `meta[name="${GSAP_BASE_URL_META}"]`, + )?.content; + return motionPathPluginUrl(version, baseUrl); +} + export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null): void { if (pendingReadyRetry) window.removeEventListener("message", pendingReadyRetry); pendingReadyRetry = null; @@ -86,7 +93,7 @@ export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null): try { win.__hfMotionPathPluginLoading = true; const pluginScript = doc.createElement("script"); - pluginScript.src = motionPathPluginUrl(win.gsap?.version); + pluginScript.src = previewMotionPathUrl(doc, win.gsap?.version); const finalize = () => { win.__hfMotionPathPluginLoading = false; try { @@ -480,7 +487,7 @@ export function applySoftReload( } win.__hfMotionPathPluginLoading = true; const pluginScript = doc.createElement("script"); - pluginScript.src = motionPathPluginUrl(win.gsap?.version); + pluginScript.src = previewMotionPathUrl(doc, win.gsap?.version); pluginScript.onload = () => { win.__hfMotionPathPluginLoading = false; executeScript();