fix(studio): allow a local GSAP dependency mirror (#6247)

This commit is contained in:
Abdullah M. Nazif
2026-10-24 22:13:28 +00:00
committed by GitHub
parent aefca2144c
commit 1c14fbb542
9 changed files with 421 additions and 33 deletions
+5
View File
@@ -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.
<Note>
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
+42
View File
@@ -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
<mirror-root>/gsap@3.15.0/dist/gsap.min.js
<mirror-root>/gsap@3.15.0/dist/CustomEase.min.js
<mirror-root>/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@<version>` 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 |
+34 -1
View File
@@ -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();
});
});
+20 -4
View File
@@ -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`;
}
@@ -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<script src="${gsapCdnDist()}gsap.min.js"></script>`;
headContent += `\n<script src="${gsapCdnDist(undefined, gsapBaseUrl).replace(/&/g, "&amp;")}gsap.min.js"></script>`;
}
const htmlOpen = htmlAttrs ? `<html ${htmlAttrs}>` : "<html>";
@@ -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<StudioApiAdapter> = {},
) {
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 = `<html><head><meta name="${metaName}" content="https://authored.example/npm"><script src="${authored}"></script></head><body><script>gsap.to("#card", { motionPath: {} });</script></body></html>`;
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"), "<template><section>Scene</section></template>");
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"),
`<html><head><script src="${plugin}"></script></head><body><script>gsap.to("#card", { motionPath: {} });</script></body></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"),
`<html><head><meta name="${metaName}" content="https://authored.example/npm"></head><body></body></html>`,
);
const app = appForMirror(projectDir, " https://mirror.example/a&copy;/npm/// ");
const html = await (await app.request(previewUrl)).text();
expect(html).toContain('content="https://mirror.example/a&amp;copy;/npm"');
expect(
parseHTML(html).document.querySelector(`meta[name="${metaName}"]`)?.getAttribute("content"),
).toBe("https://mirror.example/a&copy;/npm");
});
it("invalidates root, subcomposition, and persistent preview caches when the mirror changes", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "scene.html"), "<template><section>Scene</section></template>");
const bundle = vi.fn(
async () =>
'<html><head></head><body><div data-hf-scene="a">Cached preview</div></body></html>',
);
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 = /<script data-hf-gsap-fallback>([\s\S]*?)<\/script>/.exec(html)?.[1] ?? "";
let onError: (event: { target: unknown }) => void = () => undefined;
const scripts: { tagName: string; src: string; onerror?: (event: Event) => void }[] = [];
const doc = {
addEventListener: (_type: string, listener: typeof onError) => {
onError = listener;
},
createElement: () => ({ tagName: "SCRIPT", src: "" }),
head: { appendChild: (script: (typeof scripts)[number]) => scripts.push(script) },
};
const reported = vi.fn();
new Function("document", "reportError", fallback)(doc, reported);
onError({
target: { tagName: "SCRIPT", src: "https://authored.example/gsap@3.14.2/dist/gsap.min.js" },
});
expect(scripts).toHaveLength(1);
expect(scripts[0]?.src).toBe(`${mirror}/gsap@3.15.0/dist/gsap.min.js`);
onError({ target: scripts[0] });
scripts[0]?.onerror?.(new Event("error"));
await vi.waitFor(() => expect(reported).toHaveBeenCalledTimes(1));
expect(scripts).toHaveLength(1);
expect(html).not.toContain("cdn.jsdelivr.net");
});
});
+65 -24
View File
@@ -19,7 +19,12 @@ import {
STUDIO_PREVIEW_ERRORS,
} from "@hyperframes/core/studio-preview-mark";
import { PREVIEW_PROXY_BOX_PARAM, parsePreviewProxyBox } from "@hyperframes/core";
import { gsapCdnDist, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
import {
GSAP_BASE_URL_META,
gsapCdnDist,
motionPathPluginUrl,
normalizeGsapBaseUrl,
} from "@hyperframes/core/gsap-cdn";
import { findStartTags, injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
import type { ResolvedProject, StudioApiAdapter } from "../types.js";
@@ -76,9 +81,13 @@ import { requestSubPath } from "../helpers/requestSubPath.js";
import { lazyPreviewImages } from "../helpers/lazyPreviewImages.js";
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_SCRIPT = `<script src="${gsapCdnDist()}gsap.min.js"></script>`;
const GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="${gsapCdnDist()}CustomEase.min.js"></script>`;
const GSAP_MOTION_PATH_CDN_SCRIPT = `<script src="${motionPathPluginUrl()}"></script>`;
function escapeGsapUrl(url: string): string {
return url.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function gsapScriptTag(file: string, baseUrl: string): string {
return `<script src="${escapeGsapUrl(gsapCdnDist(undefined, baseUrl) + file)}"></script>`;
}
function injectProjectSignature(html: string, signature: string): string {
const tag = `<meta name="${PROJECT_SIGNATURE_META}" content="${signature}">`;
@@ -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 <head>. 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 = `<script${ordering} src="${motionPathPluginUrl(version)}"></script>`;
const pluginTag = `<script${ordering} src="${escapeGsapUrl(motionPathPluginUrl(version, baseUrl))}"></script>`;
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 = `<script type="${AFTER_FONTS_SCRIPT_TYPE}">${script.replace(/<\/script/gi, "<\\/script")}</script>`;
return insertBeforeCloseTag(withDependencies, "body", tag) ?? `${withDependencies}${tag}`;
}
const GSAP_FALLBACK_ATTR = "data-hf-gsap-fallback";
const GSAP_CDN_FALLBACK_SCRIPT = `<script ${GSAP_FALLBACK_ATTR}>
function gsapCdnFallbackScript(baseUrl: string): string {
return `<script ${GSAP_FALLBACK_ATTR}>
(function(){
var cdnBase="${gsapCdnDist()}";
var cdnBase=${JSON.stringify(gsapCdnDist(undefined, baseUrl)).replace(/</g, "\\u003c")};
var loaded={};
function loadFallback(file){
if(loaded[file])return loaded[file];
@@ -270,11 +286,13 @@ const GSAP_CDN_FALLBACK_SCRIPT = `<script ${GSAP_FALLBACK_ATTR}>
},true);
})();
</script>`;
}
function injectGsapCdnFallback(html: string): string {
function injectGsapCdnFallback(html: string, baseUrl: string): string {
if (html.includes(GSAP_FALLBACK_ATTR)) return html;
if (html.includes("<head>")) return html.replace("<head>", "<head>" + GSAP_CDN_FALLBACK_SCRIPT);
return GSAP_CDN_FALLBACK_SCRIPT + html;
if (html.includes("<head>"))
return html.replace("<head>", "<head>" + 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,
`<meta name="${STUDIO_PREVIEW_MARK_META}" content="${STUDIO_PREVIEW_CAPTURE_MODE}">`,
@@ -347,11 +365,20 @@ function injectStudioPreviewAugmentations(
return injectStudioMotionScript(
injectMotionPathPluginIfNeeded(
injectGsapCdnFallback(
injectProjectSignature(marked, resolveProjectSignature(adapter, projectDir)),
injectProjectSignature(
injectTagsAtHeadStart(
marked,
`<meta name="${GSAP_BASE_URL_META}" content="${escapeGsapUrl(options.gsapBaseUrl)}">`,
),
resolveProjectSignature(adapter, projectDir),
),
options.gsapBaseUrl,
),
options.gsapBaseUrl,
),
projectDir,
activeCompositionPath,
options.gsapBaseUrl,
);
}
@@ -416,6 +443,16 @@ const withPreviewBase = (html: string, projectId: string) =>
: injectTagsAtHeadStart(html, `<base href="${previewBaseHref(projectId)}">`);
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));
@@ -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);
+10 -3
View File
@@ -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<HTMLMetaElement>(
`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();