mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
Merge pull request #775 from heygen-com/feat/producer-fail-closed-font-fetch
feat(producer): fail-closed font fetch flag in deterministicFonts
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Tests for `injectDeterministicFontFaces`'s `failClosedFontFetch` gate.
|
||||
*
|
||||
* Production callers (the in-process `htmlCompiler`) call the function
|
||||
* without options and get the legacy behavior: external font fetch failures
|
||||
* are swallowed and a warning is logged. Distributed callers pass
|
||||
* `failClosedFontFetch: true` so missing fonts surface as typed
|
||||
* non-retryable failures before any chunk is rendered.
|
||||
*
|
||||
* The tests inject `fetchImpl` so no real network call happens.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
FONT_FETCH_FAILED,
|
||||
FontFetchError,
|
||||
injectDeterministicFontFaces,
|
||||
} from "./deterministicFonts.js";
|
||||
|
||||
// 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.)
|
||||
const HTML_REQUESTING_UNRESOLVED_FONT = `<!doctype html>
|
||||
<html><head><style>
|
||||
body { font-family: "NotARealFontFamilyForTest", sans-serif; }
|
||||
</style></head>
|
||||
<body><h1>hello</h1></body>
|
||||
</html>`;
|
||||
|
||||
function makeFailingFetch(): typeof fetch {
|
||||
return (async () => {
|
||||
throw new TypeError("simulated network failure");
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function makeHttp404Fetch(): typeof fetch {
|
||||
return (async () =>
|
||||
new Response("", { status: 404, statusText: "Not Found" })) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("injectDeterministicFontFaces — failClosedFontFetch: false (default)", () => {
|
||||
it("swallows a network failure and returns the original HTML (no throw)", async () => {
|
||||
const result = await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: false,
|
||||
fetchImpl: makeFailingFetch(),
|
||||
});
|
||||
// No @font-face was injected because the fetch failed — but the call
|
||||
// resolves successfully with the original HTML.
|
||||
expect(result.includes("data-hyperframes-deterministic-fonts")).toBe(false);
|
||||
});
|
||||
|
||||
it("swallows a 404 response and returns the original HTML (no throw)", async () => {
|
||||
const result = await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: false,
|
||||
fetchImpl: makeHttp404Fetch(),
|
||||
});
|
||||
expect(result.includes("data-hyperframes-deterministic-fonts")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves legacy behavior when no options object is supplied at all", async () => {
|
||||
// injectDeterministicFontFaces(html) — no second arg.
|
||||
// We can't easily mock fetch globally here, so just assert the call
|
||||
// signature still accepts a single argument.
|
||||
const fn = injectDeterministicFontFaces;
|
||||
expect(fn.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
|
||||
it("throws FontFetchError on a network failure", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl: makeFailingFetch(),
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FontFetchError);
|
||||
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
|
||||
expect((caught as FontFetchError).code).toBe("FONT_FETCH_FAILED");
|
||||
expect((caught as FontFetchError).familyName).toBe("NotARealFontFamilyForTest");
|
||||
expect((caught as Error).message).toContain("simulated network failure");
|
||||
});
|
||||
|
||||
it("throws FontFetchError on a 404 response", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl: makeHttp404Fetch(),
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FontFetchError);
|
||||
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
|
||||
expect((caught as Error).message).toContain("HTTP 404");
|
||||
expect((caught as Error).message).toContain("NotARealFontFamilyForTest");
|
||||
});
|
||||
|
||||
it("includes the requested URL in the error", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl: makeHttp404Fetch(),
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as FontFetchError).url).toContain("fonts.googleapis.com");
|
||||
expect((caught as FontFetchError).url).toContain("NotARealFontFamilyForTest");
|
||||
});
|
||||
|
||||
it("does NOT throw when the HTML uses a pre-bundled font (no fetch happens)", async () => {
|
||||
// "Inter" is in FONT_ALIASES → uses bundled font data, never reaches
|
||||
// the Google Fonts path → failClosedFontFetch=true is irrelevant here
|
||||
// and shouldn't trip. (The full <html><head> wrap is required because
|
||||
// injectDeterministicFontFaces injects into <head>.)
|
||||
const html = `<!doctype html><html><head><style>body { font-family: "Inter", sans-serif; }</style></head><body></body></html>`;
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl: makeFailingFetch(),
|
||||
});
|
||||
expect(result).toContain("data-hyperframes-deterministic-fonts");
|
||||
});
|
||||
|
||||
it("does NOT throw when the HTML requests no fonts at all", async () => {
|
||||
const html = `<!doctype html><html><body><p>no fonts</p></body></html>`;
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl: makeFailingFetch(),
|
||||
});
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FontFetchError", () => {
|
||||
it("exposes the FONT_FETCH_FAILED typed-failure code", () => {
|
||||
const err = new FontFetchError("Foo", "https://example.com", "boom");
|
||||
expect(err.code).toBe(FONT_FETCH_FAILED);
|
||||
expect(err.code).toBe("FONT_FETCH_FAILED");
|
||||
expect(err.familyName).toBe("Foo");
|
||||
expect(err.url).toBe("https://example.com");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
@@ -246,7 +246,10 @@ function buildFontFaceRule(familyName: string, src: string, weight: string, styl
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function buildFontFaceCss(requestedFamilies: Map<string, string>): Promise<{
|
||||
async function buildFontFaceCss(
|
||||
requestedFamilies: Map<string, string>,
|
||||
options: InternalFontFetchOptions,
|
||||
): Promise<{
|
||||
css: string;
|
||||
unresolved: string[];
|
||||
}> {
|
||||
@@ -268,7 +271,7 @@ async function buildFontFaceCss(requestedFamilies: Map<string, string>): Promise
|
||||
}
|
||||
|
||||
// Path 2: fetch from Google Fonts (with local cache)
|
||||
const googleFaces = await fetchGoogleFont(originalCaseFamily);
|
||||
const googleFaces = await fetchGoogleFont(originalCaseFamily, options);
|
||||
if (googleFaces.length > 0) {
|
||||
for (const face of googleFaces) {
|
||||
rules.push(buildFontFaceRule(originalCaseFamily, face.dataUri, face.weight, face.style));
|
||||
@@ -341,21 +344,88 @@ type GoogleFontFace = {
|
||||
dataUri: string;
|
||||
};
|
||||
|
||||
async function fetchGoogleFont(familyName: string): Promise<GoogleFontFace[]> {
|
||||
/**
|
||||
* Typed code classifying a font-fetch failure as non-retryable for
|
||||
* distributed workflow adapters — a missing Google Fonts entry will not heal
|
||||
* on retry.
|
||||
*/
|
||||
export const FONT_FETCH_FAILED = "FONT_FETCH_FAILED";
|
||||
|
||||
/**
|
||||
* Typed error thrown by {@link injectDeterministicFontFaces} when
|
||||
* `failClosedFontFetch === true` and an external font fetch fails. The
|
||||
* default (swallow + warn) preserves the in-process behavior.
|
||||
*/
|
||||
export class FontFetchError extends Error {
|
||||
readonly code: typeof FONT_FETCH_FAILED = FONT_FETCH_FAILED;
|
||||
readonly familyName: string;
|
||||
readonly url: string;
|
||||
readonly cause?: unknown;
|
||||
|
||||
constructor(familyName: string, url: string, message: string, cause?: unknown) {
|
||||
super(message);
|
||||
this.name = "FontFetchError";
|
||||
this.familyName = familyName;
|
||||
this.url = url;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal threading of the failClosed flag + fetch override through callers. */
|
||||
interface InternalFontFetchOptions {
|
||||
failClosedFontFetch: boolean;
|
||||
fetchImpl: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a typed FontFetchError describing why a Google Fonts request failed.
|
||||
* Centralizes the message wording so all four call sites (CSS/woff2 ×
|
||||
* HTTP-error/exception) stay phrased identically.
|
||||
*/
|
||||
function fontFetchError(
|
||||
familyName: string,
|
||||
url: string,
|
||||
what: "Google Fonts CSS" | `Google Fonts woff2 (${string}/${string})`,
|
||||
cause: { status: number } | { error: unknown },
|
||||
): FontFetchError {
|
||||
const reason =
|
||||
"status" in cause
|
||||
? `returned HTTP ${cause.status}`
|
||||
: `failed: ${(cause.error as Error).message}`;
|
||||
const message =
|
||||
`[deterministicFonts] ${what} fetch for ${JSON.stringify(familyName)} ${reason}. ` +
|
||||
`Distributed renders require deterministic fonts; system-font fallback would produce ` +
|
||||
`non-byte-identical output.`;
|
||||
return new FontFetchError(familyName, url, message, "error" in cause ? cause.error : undefined);
|
||||
}
|
||||
|
||||
async function fetchGoogleFont(
|
||||
familyName: string,
|
||||
options: InternalFontFetchOptions,
|
||||
): Promise<GoogleFontFace[]> {
|
||||
const slug = fontSlug(familyName);
|
||||
const encodedFamily = encodeURIComponent(familyName);
|
||||
const url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700`;
|
||||
|
||||
let cssText: string;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
const res = await options.fetchImpl(url, {
|
||||
headers: { "User-Agent": WOFF2_USER_AGENT },
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (options.failClosedFontFetch) {
|
||||
throw fontFetchError(familyName, url, "Google Fonts CSS", { status: res.status });
|
||||
}
|
||||
return [];
|
||||
}
|
||||
cssText = await res.text();
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Rethrow typed error untouched. Other errors (network, DNS) get wrapped
|
||||
// when failClosed is on, swallowed otherwise.
|
||||
if (err instanceof FontFetchError) throw err;
|
||||
if (options.failClosedFontFetch) {
|
||||
throw fontFetchError(familyName, url, "Google Fonts CSS", { error: err });
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -376,12 +446,22 @@ async function fetchGoogleFont(familyName: string): Promise<GoogleFontFace[]> {
|
||||
|
||||
// Check cache first
|
||||
if (!existsSync(cachePath)) {
|
||||
const woff2What = `Google Fonts woff2 (${weight}/${style})` as const;
|
||||
try {
|
||||
const fontRes = await fetch(woff2Url);
|
||||
if (!fontRes.ok) continue;
|
||||
const fontRes = await options.fetchImpl(woff2Url);
|
||||
if (!fontRes.ok) {
|
||||
if (options.failClosedFontFetch) {
|
||||
throw fontFetchError(familyName, woff2Url, woff2What, { status: fontRes.status });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const buffer = Buffer.from(await fontRes.arrayBuffer());
|
||||
writeFileSync(cachePath, buffer);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (err instanceof FontFetchError) throw err;
|
||||
if (options.failClosedFontFetch) {
|
||||
throw fontFetchError(familyName, woff2Url, woff2What, { error: err });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -402,7 +482,39 @@ async function fetchGoogleFont(familyName: string): Promise<GoogleFontFace[]> {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function injectDeterministicFontFaces(html: string): Promise<string> {
|
||||
/**
|
||||
* Options for {@link injectDeterministicFontFaces}.
|
||||
*/
|
||||
export interface InjectDeterministicFontFacesOptions {
|
||||
/**
|
||||
* When `true`, any external font fetch failure (Google Fonts CSS or
|
||||
* woff2) throws {@link FontFetchError} with code `FONT_FETCH_FAILED`.
|
||||
*
|
||||
* Default `false`: failed fetches are silently swallowed; the composition
|
||||
* falls back to system fonts via `warnUnresolvedFonts`. This preserves the
|
||||
* in-process behavior.
|
||||
*
|
||||
* Distributed callers pass `true` so font availability is part of the
|
||||
* planDir's content-addressed hash and fetch failures surface as typed
|
||||
* non-retryable errors.
|
||||
*/
|
||||
failClosedFontFetch?: boolean;
|
||||
/**
|
||||
* Injectable `fetch` implementation. Defaults to the global `fetch`.
|
||||
* Tests pass a stub to simulate fetch failures without going over the
|
||||
* network.
|
||||
*/
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function injectDeterministicFontFaces(
|
||||
html: string,
|
||||
options: InjectDeterministicFontFacesOptions = {},
|
||||
): Promise<string> {
|
||||
const failClosedFontFetch = options.failClosedFontFetch === true;
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const fetchOptions: InternalFontFetchOptions = { failClosedFontFetch, fetchImpl };
|
||||
|
||||
const existingFaces = extractExistingFontFaces(html);
|
||||
const requestedFamilies = extractRequestedFontFamilies(html);
|
||||
const pendingFamilies = new Map<string, string>();
|
||||
@@ -417,7 +529,7 @@ export async function injectDeterministicFontFaces(html: string): Promise<string
|
||||
return html;
|
||||
}
|
||||
|
||||
const { css, unresolved } = await buildFontFaceCss(pendingFamilies);
|
||||
const { css, unresolved } = await buildFontFaceCss(pendingFamilies, fetchOptions);
|
||||
if (!css) {
|
||||
if (unresolved.length > 0) {
|
||||
warnUnresolvedFonts(unresolved);
|
||||
|
||||
Reference in New Issue
Block a user