diff --git a/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts b/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts index b9cccf486a..186abb3e19 100644 --- a/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts +++ b/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts @@ -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; diff --git a/packages/producer/src/services/deterministicFonts-failClosed.test.ts b/packages/producer/src/services/deterministicFonts-failClosed.test.ts index b903b50f71..ed98684ccc 100644 --- a/packages/producer/src/services/deterministicFonts-failClosed.test.ts +++ b/packages/producer/src/services/deterministicFonts-failClosed.test.ts @@ -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.) diff --git a/packages/producer/src/services/deterministicFonts.test.ts b/packages/producer/src/services/deterministicFonts.test.ts index f76fee312b..ebf6290f3c 100644 --- a/packages/producer/src/services/deterministicFonts.test.ts +++ b/packages/producer/src/services/deterministicFonts.test.ts @@ -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 = `

Hello

`; + const htmlB = `

olleH

`; + 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 = `

Hello

`; + const htmlB = `

olleH

`; + 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((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"); diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 923d5fa7d4..534f5723d1 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -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>(); + +/** 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(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(callerAbortReason(signal)); + return new Promise((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",