Fix fonts.ts crashing every client bundle that reaches it (v0.2.9)
Validate e-invoices / mustang (push) Successful in 25s
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:
@@ -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
@@ -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
@@ -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" },
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user