mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(producer): cache the google fonts css lookup per process (#4097)
* fix(producer): cache the google fonts css lookup per process Studio compiles every composition in a project separately for its sidebar thumbnails and for gsap-animation extraction. Each compile called injectDeterministicFontFaces, which re-fetched the same font family's CSS metadata from fonts.googleapis.com over the network every single time -- only the woff2 binary files were cached to disk, not the CSS lookup itself. A project with a dozen small compositions and three declared font families fired dozens of redundant network round trips serially, holding the editor's asset-readiness overlay well past its own 8-second timeout. Adds an in-process cache for the CSS lookup keyed by the exact request URL, self-evicting on failure so a later call still retries. Measured on a real project: gsap-animation extraction across 12 compositions dropped from ~16.4s to ~2.9s wall time, and per-composition thumbnail generation dropped from 9-11s each to 10-14ms each once the cache is warm. * fix(producer): isolate the google fonts css cache from any one caller's abort An adversarial review of the process-wide CSS-lookup cache found two real bugs before it shipped. First, the cached promise carried whichever caller reached it first's AbortSignal, so one render's cancellation could fail a completely unrelated concurrent render sharing the same font family. Second, the cache has no reset between test files running in one process, so two sibling test suites asserting per-call fetch counts against the same Google Fonts URL silently saw each other's cached result. Strips the caller-specific AbortSignal from the shared fetch and races each caller's own signal against it independently, so a caller can still bail out on its own cancellation without affecting anyone else awaiting the same lookup. Clears the cache in the two sibling test files' own beforeEach. * fix(producer): key the google fonts css cache by fail-closed mode and drop transient statuses
This commit is contained in:
@@ -16,11 +16,17 @@
|
||||
* so they are hermetic.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { EMBEDDED_FONT_DATA } from "./fontData.generated.js";
|
||||
import { _clearGoogleFontCssCacheForTests } from "./deterministicFonts.js";
|
||||
|
||||
// The Google Fonts CSS cache is process-lifetime and keyed by URL; several
|
||||
// tests here request the same family, so a leftover entry from an earlier
|
||||
// test would silently skip this test's own fetchImpl.
|
||||
beforeEach(() => _clearGoogleFontCssCacheForTests());
|
||||
|
||||
let cacheDir: string;
|
||||
let prevCacheEnv: string | undefined;
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
* The tests inject `fetchImpl` so no real network call happens.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
_clearGoogleFontCssCacheForTests,
|
||||
FONT_FETCH_FAILED,
|
||||
FONT_FETCH_UNAVAILABLE,
|
||||
FontFetchError,
|
||||
@@ -23,6 +24,11 @@ import {
|
||||
injectDeterministicFontFaces,
|
||||
} from "./deterministicFonts.js";
|
||||
|
||||
// The Google Fonts CSS cache is process-lifetime and keyed by URL; several
|
||||
// tests here request the same unresolved-font URL, so a leftover entry from
|
||||
// an earlier test would silently skip this test's own fetchImpl.
|
||||
beforeEach(() => _clearGoogleFontCssCacheForTests());
|
||||
|
||||
// HTML that requests a font NOT in FONT_ALIASES, so the resolver falls
|
||||
// through to the Google Fonts fetch path. (Bundled fonts like Inter
|
||||
// bypass fetch entirely.)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
FONT_ALIASES,
|
||||
FONT_ALIAS_KEYS,
|
||||
_clearGoogleFontCssCacheForTests,
|
||||
injectDeterministicFontFaces,
|
||||
} from "./deterministicFonts.js";
|
||||
|
||||
@@ -45,6 +49,87 @@ describe("existing font-face recognition", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google Fonts CSS request caching", () => {
|
||||
beforeEach(() => _clearGoogleFontCssCacheForTests());
|
||||
|
||||
it("reuses one CSS lookup across separate compiles requesting the same family", async () => {
|
||||
// Same character repertoire (just reordered) so both compute the same
|
||||
// Google Fonts `text=` subset and thus the same request URL — isolates
|
||||
// the cache from font-text extraction, covered elsewhere.
|
||||
const htmlA = `<html><body><p style="font-family: 'Cache Probe Font';">Hello</p></body></html>`;
|
||||
const htmlB = `<html><body><p style="font-family: 'Cache Probe Font';">olleH</p></body></html>`;
|
||||
let requests = 0;
|
||||
const fetchImpl = Object.assign(
|
||||
async () => {
|
||||
requests += 1;
|
||||
return new Response("", { status: 404 });
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
);
|
||||
await injectDeterministicFontFaces(htmlA, { fetchImpl, allowSystemFontCapture: false });
|
||||
await injectDeterministicFontFaces(htmlB, { fetchImpl, allowSystemFontCapture: false });
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
|
||||
it("does not fail a concurrent caller when another caller sharing the lookup aborts", async () => {
|
||||
const prevCacheEnv = process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), "hf-font-abort-isolation-"));
|
||||
process.env.HYPERFRAMES_FONT_CACHE_DIR = cacheDir;
|
||||
try {
|
||||
const htmlA = `<html><head></head><body><p style="font-family: 'Abort Isolation Font';">Hello</p></body></html>`;
|
||||
const htmlB = `<html><head></head><body><p style="font-family: 'Abort Isolation Font';">olleH</p></body></html>`;
|
||||
const woff2Url = "https://fonts.gstatic.com/s/abortisolationfont/v1/font.woff2";
|
||||
const cssBody =
|
||||
`@font-face { font-style: normal; font-weight: 400; ` +
|
||||
`src: url(${woff2Url}) format('woff2'); }`;
|
||||
let resolveCss: (() => void) | undefined;
|
||||
// Mirrors real fetch(): the CSS request rejects if its own `init.signal`
|
||||
// aborts, so this proves the shared lookup isn't wired to any one
|
||||
// caller's signal. The woff2 request always succeeds immediately —
|
||||
// only the shared CSS lookup is under test here.
|
||||
const fetchImpl = Object.assign(
|
||||
async (url: string, init?: RequestInit) => {
|
||||
if (!url.includes("css2")) return new Response(new Uint8Array([0, 1, 2, 3]));
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
resolveCss = () => resolve(new Response(cssBody, { status: 200 }));
|
||||
init?.signal?.addEventListener("abort", () =>
|
||||
reject(new DOMException("aborted", "AbortError")),
|
||||
);
|
||||
});
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
);
|
||||
const controllerA = new AbortController();
|
||||
// A must populate the cache entry first, and both resolve to a real
|
||||
// face (not a 404) so failClosedFontFetch's own "unresolved font"
|
||||
// check can't confound the result with a leaked abort.
|
||||
const resultA = injectDeterministicFontFaces(htmlA, {
|
||||
fetchImpl,
|
||||
allowSystemFontCapture: false,
|
||||
failClosedFontFetch: true,
|
||||
abortSignal: controllerA.signal,
|
||||
});
|
||||
while (!resolveCss) await new Promise((r) => setTimeout(r, 0));
|
||||
const resultB = injectDeterministicFontFaces(htmlB, {
|
||||
fetchImpl,
|
||||
allowSystemFontCapture: false,
|
||||
failClosedFontFetch: true,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
controllerA.abort();
|
||||
resolveCss();
|
||||
|
||||
await expect(resultA).rejects.toThrow();
|
||||
expect(await resultB).toContain("@font-face");
|
||||
} finally {
|
||||
if (prevCacheEnv === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
else process.env.HYPERFRAMES_FONT_CACHE_DIR = prevCacheEnv;
|
||||
rmSync(cacheDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FONT_ALIASES cross-platform coverage", () => {
|
||||
it("maps macOS sans-serif system fonts to inter", () => {
|
||||
expect(FONT_ALIASES["sf pro"]).toBe("inter");
|
||||
|
||||
@@ -1118,6 +1118,57 @@ async function ensureWoff2DataUri(
|
||||
return `data:font/woff2;base64,${readFileSync(cachePath).toString("base64")}`;
|
||||
}
|
||||
|
||||
// Per-process cache for Google Fonts CSS lookups, keyed by request URL —
|
||||
// repeat compiles of the same family reuse one network round trip. A
|
||||
// rejected lookup evicts itself so a later call still retries.
|
||||
const googleFontCssCache = new Map<string, Promise<{ ok: true; body: string } | { ok: false }>>();
|
||||
|
||||
/** Test-only reset — the cache is otherwise process-lifetime, shared across calls. */
|
||||
export function _clearGoogleFontCssCacheForTests(): void {
|
||||
googleFontCssCache.clear();
|
||||
}
|
||||
|
||||
/** Rejects with `signal`'s own abort reason without cancelling `promise` itself. */
|
||||
function raceAgainstAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) return Promise.reject(callerAbortReason(signal));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => reject(callerAbortReason(signal));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
||||
});
|
||||
}
|
||||
|
||||
function fetchGoogleFontCss(
|
||||
url: string,
|
||||
familyName: string,
|
||||
options: InternalFontFetchOptions,
|
||||
): Promise<{ ok: true; body: string } | { ok: false }> {
|
||||
// Fail-closed callers retry and throw where lenient ones don't, so they never share an entry.
|
||||
const key = `${options.failClosedFontFetch ? "closed" : "open"}:${url}`;
|
||||
let shared = googleFontCssCache.get(key);
|
||||
if (!shared) {
|
||||
// The shared fetch must not carry any one caller's abortSignal, or that
|
||||
// caller cancelling would fail the lookup for every other waiter.
|
||||
shared = fetchFontResource(
|
||||
url,
|
||||
{ headers: { "User-Agent": WOFF2_USER_AGENT } },
|
||||
(response) => response.text(),
|
||||
familyName,
|
||||
"Google Fonts CSS",
|
||||
{ ...options, abortSignal: undefined },
|
||||
).then((result) => {
|
||||
if (result.ok) return { ok: true as const, body: result.body };
|
||||
// A transient status must not stick for the process lifetime.
|
||||
if (isRetryableFontFetchStatus(result.response.status)) googleFontCssCache.delete(key);
|
||||
return { ok: false as const };
|
||||
});
|
||||
googleFontCssCache.set(key, shared);
|
||||
shared.catch(() => googleFontCssCache.delete(key));
|
||||
}
|
||||
return raceAgainstAbort(shared, options.abortSignal);
|
||||
}
|
||||
|
||||
async function fetchGoogleFont(
|
||||
familyName: string,
|
||||
options: InternalFontFetchOptions,
|
||||
@@ -1136,14 +1187,7 @@ async function fetchGoogleFont(
|
||||
|
||||
let cssText: string;
|
||||
try {
|
||||
const cssResult = await fetchFontResource(
|
||||
url,
|
||||
{ headers: { "User-Agent": WOFF2_USER_AGENT } },
|
||||
(response) => response.text(),
|
||||
familyName,
|
||||
"Google Fonts CSS",
|
||||
options,
|
||||
);
|
||||
const cssResult = await fetchGoogleFontCss(url, familyName, options);
|
||||
if (!cssResult.ok) {
|
||||
// 4xx is a *deterministic* answer from Google Fonts that this
|
||||
// family is not served (e.g. HTTP 400 for "Segoe UI", "Arial",
|
||||
|
||||
Reference in New Issue
Block a user