Invoices: ship pdfjs-dist's worker into the standalone image

Every PDF import failed in production with "Setting up fake worker failed:
Cannot find module '/app/node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'".

output: "standalone" ships only the files Next's output tracer can see. It
traced pdf.mjs, because lib/invoice-parser.ts imports it by literal path,
but the worker's sole reference is a createRequire(...).resolve() call,
which the tracer does not follow. So the image contained pdf.mjs alone,
pdfjs fell back to its default "./pdf.worker.mjs" relative to pdf.mjs, and
the import hit a file that was never copied.

Copy the whole package in the runner stage: the worker has to sit next to
pdf.mjs, and so do the standard fonts, which the parser needs because it
passes useSystemFonts. Verified against the reconstructed runner layout —
the old layout reproduces the error verbatim, the new one parses and
extracts text.

Also in lib/invoice-parser.ts: workerPinned was set before the resolve, so
a failed resolve was never retried; and the empty catch swallowed the
MODULE_NOT_FOUND that named the real cause. Both fixed.
This commit is contained in:
David 2026-10-06 10:49:10 +02:00
parent 2594dead84
commit 6ae4e30284
2 changed files with 23 additions and 5 deletions

View file

@ -78,6 +78,15 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Next's output tracer cannot see pdfjs-dist's worker. The only reference to it
# is a createRequire(...).resolve() call, which the tracer does not follow, so
# the standalone bundle ships pdf.mjs WITHOUT pdf.worker.mjs — and every parse
# then dies with "Setting up fake worker failed: Cannot find module
# '/app/node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'". Copy the package
# in whole: the worker has to sit next to pdf.mjs at runtime, and so do the
# standard fonts, which the parser needs because it passes useSystemFonts.
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/pdfjs-dist ./node_modules/pdfjs-dist
USER nextjs
EXPOSE 3000

View file

@ -172,21 +172,30 @@ export function isoDate(s: string): string {
// falls back to a dynamic import of the worker module, which the bundler rewrites to a
// .next path that does not exist in the standalone image — every parse then dies with
// "Setting up fake worker failed". Pinning the worker to the real installed file avoids
// that. pdfjs-dist is listed in serverExternalPackages so it is required (and traced)
// from node_modules rather than bundled.
// that, but only if the file is actually there: pdfjs-dist is in
// serverExternalPackages so it is required from node_modules rather than bundled, and
// Next's tracer does NOT copy pdf.worker.mjs — the standalone bundle contains pdf.mjs
// alone. The Dockerfile copies the whole package in for that reason. Do not assume this
// pin alone is sufficient.
let workerPinned = false;
async function loadPdfjs() {
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
if (!workerPinned) {
workerPinned = true;
try {
const { createRequire } = await import("node:module");
const req = createRequire(import.meta.url);
pdfjs.GlobalWorkerOptions.workerSrc = req.resolve(
"pdfjs-dist/legacy/build/pdf.worker.mjs",
);
} catch {
// Leave pdfjs's own default in place; it is correct for unbundled Node.
workerPinned = true;
} catch (err) {
// Not fatal, but it must not be silent: pdfjs then falls back to a worker
// path relative to pdf.mjs, so the parse fails much later with an opaque
// "Setting up fake worker failed". Staying UNpinned lets the next parse
// retry, so the worker is picked up as soon as it is on disk.
console.error(
`[invoice-parser] could not resolve pdf.worker.mjs: ${(err as Error).message}`,
);
}
}
return pdfjs;