Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* The font cache holds Google Fonts downloads between runs. It is an
* optimisation: the bytes are still fetchable without it, so losing the place
* to keep them is a slower render, not a failed one.
*
* It used to be fatal. `fontCacheDir` called `mkdirSync` unguarded during
* compile, so an unwritable cache root threw straight out and a first-time user
* lost their very first render to
* `EPERM: operation not permitted, mkdir '<home>/.cache/hyperframes/fonts/inter'`.
*/

import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { chmodSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

let lockedRoot: string;
let prevCacheEnv: string | undefined;

const FACE_URL = "https://fonts.gstatic.com/s/inter/v1/inter.woff2";
const FACE_BYTES = "INTER_BYTES";

beforeAll(() => {
prevCacheEnv = process.env.HYPERFRAMES_FONT_CACHE_DIR;
lockedRoot = mkdtempSync(join(tmpdir(), "hf-font-locked-"));
chmodSync(lockedRoot, 0o500);
// A path *under* a read-only parent: creating it is what fails.
process.env.HYPERFRAMES_FONT_CACHE_DIR = join(lockedRoot, "nested");
});

afterAll(() => {
if (prevCacheEnv === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
else process.env.HYPERFRAMES_FONT_CACHE_DIR = prevCacheEnv;
chmodSync(lockedRoot, 0o700);
rmSync(lockedRoot, { recursive: true, force: true });
});

const fetchImpl = (async (input: unknown) => {
const url = String(input);
if (url.startsWith("https://fonts.googleapis.com/")) {
return new Response(
`@font-face { font-family: 'Inter'; font-style: normal; font-weight: 300; src: url(${FACE_URL}) format('woff2'); }`,
{ status: 200 },
);
}
if (url === FACE_URL) return new Response(FACE_BYTES, { status: 200 });
return new Response("", { status: 404 });
}) as unknown as typeof fetch;

const HTML = `<!doctype html><html><head><style>
h1 { font-family: "Inter"; }
</style></head><body><h1>Upright</h1></body></html>`;

describe("unwritable font cache", () => {
it("still resolves fonts instead of aborting the render", async () => {
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");

const result = await injectDeterministicFontFaces(HTML, {
allowSystemFontCapture: false,
fetchImpl,
});

// The point: we got here at all. Before the fallback this threw EACCES.
expect(result).toContain('font-family: "Inter"');
// And the fetched face still made it in, via the temp-directory cache.
expect(result).toContain(Buffer.from(FACE_BYTES).toString("base64"));
});
});
38 changes: 35 additions & 3 deletions packages/producer/src/services/deterministicFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -746,12 +746,44 @@ function fontSlug(familyName: string): string {
.replace(/^-|-$/g, "");
}

/**
* A cache that cannot be created must not end a render. This directory only
* holds Google Fonts downloads between runs — the bytes are still fetchable, so
* losing the place to keep them is a slower render, not a failed one. Before
* this fallback existed an unwritable cache root threw straight out of compile:
* a first-time user lost their very first render to
* `EPERM: operation not permitted, mkdir '<home>/.cache/hyperframes/fonts/inter'`.
*
* Same shape as the Lambda root above: one temp directory per process, so a run
* still de-duplicates its own downloads, it just cannot reuse them next time.
*/
let fallbackFontCacheRoot: string | null = null;
let warnedFontCacheFallback = false;

function fontCacheDir(slug: string): string {
const dir = join(resolveFontCacheRoot(), slug);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
try {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
return dir;
} catch (err) {
fallbackFontCacheRoot ??= mkdtempSync(join(tmpdir(), "hyperframes-fonts-"));
if (!warnedFontCacheFallback) {
warnedFontCacheFallback = true;
const reason = err instanceof Error ? err.message : String(err);
defaultLogger.warn(
`[Compiler] Font cache is not writable (${reason}). Caching to a temporary ` +
`directory for this run instead; fonts will be re-downloaded next time. Set ` +
`HYPERFRAMES_FONT_CACHE_DIR to a writable path to keep them.`,
);
}
const fallback = join(fallbackFontCacheRoot, slug);
if (!existsSync(fallback)) {
mkdirSync(fallback, { recursive: true });
}
return fallback;
}
return dir;
}

// A short, stable discriminator for a single subset's woff2. Google Fonts'
Expand Down
Loading