diff --git a/README.md b/README.md index d01d093..17a2cfd 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ One canonical implementation, consumed by both repos, makes this class of drift **2026-07-30, font path broke every PDF render inside the Payload backend (v0.2.8).** `fonts.ts` (added in v0.2.6 above) resolved the vendored `.ttf` files via `new URL('./assets/fonts/...', import.meta.url).pathname` — that exact expression shape is what Next.js's Turbopack/webpack bundler statically detects and rewrites into its own hashed static-asset pipeline (`.next/server/assets/.ttf`). Harmless in the frontend's own Next.js build, but the Payload backend is *also* a Next.js app (Payload 3's standard architecture) consuming this package as a `node_modules` git dependency — there, the hash Turbopack's compiled server code referenced didn't match what it actually emitted to disk, so every single invoice/correction-invoice download in the admin failed with `ENOENT: .../LiberationSans-Regular..ttf`. Confirmed not a stale-build-cache issue: reproduced identically even after a from-scratch `docker compose build --no-cache`. Fixed by resolving the fonts directory via `fileURLToPath(import.meta.url)` + `path.dirname`/`path.join` instead — Turbopack's special-casing only triggers on the literal `new URL(x, import.meta.url)` pattern, not an equivalent built from `node:url`/`node:path` primitives, so this resolves to the same correct absolute path at runtime without ever entering the bundler's asset-hashing path. Since `Font.register()`'s `src` field only accepts a `string` (path/URL), not a `Buffer`, there was no way to sidestep this by embedding the font bytes directly instead. +**2026-07-30, same day: that fix broke `fonts.ts` in the browser instead (v0.2.9).** The v0.2.8 fix above assumed "this file only runs server-side (a PDF renderer has no reason to ever reach a client bundle)" — wrong on both counts: einfach-produktiv's `LiveCompanySettingsPreviewClient.tsx` renders `` directly in the browser for its live preview, and several of its Client Components (`CartContent.tsx`, `CheckoutContent.tsx`, etc.) pull in `fonts.ts` transitively just by importing `computeTaxBreakdown` from this package's barrel `index.ts` (which also re-exports `invoicePdf.tsx`). Calling `fileURLToPath(import.meta.url)` unconditionally at module scope — fine in Node.js, but `node:url`'s `fileURLToPath` isn't a real function in a browser bundle's polyfilled shim (Next.js still resolves the *import* to some stub object rather than erroring; only *calling* the function throws) — crashed module evaluation for every client bundle that reached this module, breaking e.g. einfach-produktiv's entire `/cart` page (`Uncaught TypeError: fileURLToPath is not a function`). Fixed by branching on `typeof window === "undefined"`: the browser path keeps the original `new URL('./file', import.meta.url)` idiom (correctly asset-hashed by Turbopack for that consumer's own build — the v0.2.8 bug only ever applied to being consumed as a `node_modules` dependency by a *different* Next.js app), the Node.js path keeps the `fileURLToPath`/`path.join` resolution from v0.2.8. Both `fonts.ts` imports stay static at the top of the file either way — only the function *calls* are gated, since the import itself never threw. + ## How this is consumed Not published to npm — installed as a git dependency: diff --git a/package.json b/package.json index b2b0c4d..b0c8998 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@einfach-produktiv/invoicing", - "version": "0.2.8", + "version": "0.2.9", "private": true, "description": "Shared invoice / correction-invoice (Stornorechnung, Gutschrift) PDF generation and VAT-breakdown math, consumed as a git dependency by both the einfach-produktiv frontend and the payload backend — not published to npm.", "type": "module", diff --git a/src/fonts.ts b/src/fonts.ts index db2c97f..93c20f5 100644 --- a/src/fonts.ts +++ b/src/fonts.ts @@ -15,26 +15,55 @@ import { dirname, join } from "node:path"; // Arial (SIL Open Font License, see src/assets/fonts/LICENSE-OFL.txt), so // embedding it doesn't shift any existing layout. // -// Path built via fileURLToPath+join, NOT `new URL('./file', import.meta.url)` -// — that literal pattern is exactly what Next.js's Turbopack/webpack bundler -// statically detects and rewrites into its own hashed static-asset system -// (`.next/server/assets/.ttf`). When this package is consumed as a -// node_modules git dependency inside a Next.js/Payload server build, that -// rewritten hash ended up not matching the file Turbopack actually emitted, -// so every PDF render at runtime failed with `ENOENT: .../LiberationSans- -// Regular..ttf` — reproduced even with a from-scratch `--no-cache` -// rebuild, so it wasn't a stale-cache issue, the asset-hashing itself was -// wrong for this consumption path. This file only runs server-side (a PDF -// renderer has no reason to ever reach a client bundle) and Turbopack does -// not apply the same special-cased URL rewriting to a plain -// fileURLToPath/path.join construction, so this sidesteps the bug entirely -// while still resolving to the correct absolute path at runtime. -const fontsDir = join(dirname(fileURLToPath(import.meta.url)), "assets/fonts"); +// Contrary to this file's own previous assumption, this DOES reach client +// bundles: einfach-produktiv's LiveCompanySettingsPreviewClient.tsx renders +// straight in the browser, and its CartContent.tsx/ +// CheckoutContent.tsx/etc. pull in this module transitively just by +// importing computeTaxBreakdown from this package's barrel index.ts (which +// also re-exports invoicePdf.tsx). So the font path must resolve correctly +// in BOTH a browser bundle and a Node.js server process — the two need +// genuinely different resolution strategies, not just different bugs: +// +// - Browser: needs a fetchable URL. `new URL('./file', import.meta.url)` is +// the standard bundler idiom for this — Turbopack/webpack statically +// detect it and rewrite it into their own hashed static-asset system, +// which is exactly what makes it fetchable at runtime in the browser. +// - Node.js (server-side rendering in either consumer, or the Payload +// backend's invoice endpoints): needs a real filesystem path for +// react-pdf to `fs.readFile`. Using the same `new URL(..., import.meta.url)` +// pattern here is what broke every PDF render inside the Payload backend +// specifically — when this package is consumed as a node_modules git +// dependency inside a *different* Next.js app's server build, Turbopack's +// rewritten asset hash didn't match the file it actually emitted, so +// every render failed with `ENOENT: .../LiberationSans-Regular..ttf` +// (reproduced even from a `--no-cache` rebuild, so not a stale-cache +// issue — the asset-hashing itself was wrong for that consumption path). +// `fileURLToPath`+`path.join` sidesteps that bug entirely by resolving a +// plain filesystem path instead of going through the asset-hash rewrite — +// but `node:url`'s `fileURLToPath` isn't a real function in a browser +// bundle's `node:url` shim (Next.js still resolves the import to *some* +// stub object rather than erroring, it just has no working +// `fileURLToPath`), so calling it unconditionally at module scope (as +// this file used to) crashed every client bundle that reaches this +// module, e.g. einfach-produktiv's /cart page. The static imports above +// are harmless either way — only actually *calling* these functions in a +// browser bundle throws, so it's enough to gate the call, not the import. +// +// `typeof window === "undefined"` is true for every Node.js process +// (build-time SSR in either consumer's own Next.js app, and the Payload +// backend's server-only invoice endpoints) and false only in an actual +// browser, which is exactly the split needed here. +const isBrowser = typeof window !== "undefined"; + +function resolveFontUrl(fileName: string): string { + if (isBrowser) return new URL(`./assets/fonts/${fileName}`, import.meta.url).toString(); + return join(dirname(fileURLToPath(import.meta.url)), "assets/fonts", fileName); +} Font.register({ family: "Liberation Sans", fonts: [ - { src: join(fontsDir, "LiberationSans-Regular.ttf"), fontWeight: "normal" }, - { src: join(fontsDir, "LiberationSans-Bold.ttf"), fontWeight: "bold" }, + { src: resolveFontUrl("LiberationSans-Regular.ttf"), fontWeight: "normal" }, + { src: resolveFontUrl("LiberationSans-Bold.ttf"), fontWeight: "bold" }, ], });