Fix fonts.ts crashing every client bundle that reaches it (v0.2.9)
Validate e-invoices / mustang (push) Successful in 25s

The v0.2.8 fileURLToPath fix (for a Turbopack asset-hash bug in the
Payload backend) wrongly assumed fonts.ts never reaches a browser
bundle. It does: einfach-produktiv's LiveCompanySettingsPreviewClient
renders <InvoiceDocument> client-side, and several Client Components
pull this module in transitively via the index.ts barrel just by
importing computeTaxBreakdown. fileURLToPath isn't a real function in
a browser's node:url shim, so calling it unconditionally at module
scope crashed module evaluation for any client bundle that included
it — including einfach-produktiv's entire /cart page.

Fix: branch on typeof window === "undefined" — browser keeps the
original new URL(..., import.meta.url) idiom (correctly asset-hashed
by Turbopack for a consumer's own build), Node.js keeps the
fileURLToPath/path.join resolution from v0.2.8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-30 18:22:53 +00:00
parent ed96d36b54
commit 87cf1db330
3 changed files with 49 additions and 18 deletions
+2
View File
@@ -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/<hash>.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.<hash>.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 `<InvoiceDocument>` 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:
+1 -1
View File
@@ -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",
+46 -17
View File
@@ -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/<hash>.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.<hash>.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
// <InvoiceDocument> 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.<hash>.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" },
],
});