From 2898f6b74748f270b5099d56fd720623cdd7bb39 Mon Sep 17 00:00:00 2001 From: Benson Cho <100653148+choden-dev@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:52:12 +1000 Subject: [PATCH 1/2] fix(orders): make server-side PDF parsing work in the standalone build Follow-up to #103. After switching page counting to pdfjs, valid PDFs were being rejected as invalid in production. Two root causes: 1. The pdfjs-dist *default* entry references browser-only globals (DOMMatrix) and throws 'ReferenceError: DOMMatrix is not defined' under Node. We already import the legacy build, but Next.js's standalone output-file tracing does not follow the dynamic subpath import, so the legacy build was missing from the deployed container. The import then threw and was swallowed as 'invalid PDF'. Fix: force the legacy build into the bundle via outputFileTracingIncludes. 2. countPdfPages swallowed *all* errors and returned 0, so a failure to load the parser (a server misconfiguration) was indistinguishable from a genuinely corrupt file. Fix: load the parser outside the try/catch and throw a distinct PdfParserUnavailableError so it surfaces as a 500 instead of telling the user their file is invalid; log genuine parse failures so real bad files can be told apart from parser regressions. --- lib/pdf.ts | 58 +++++++++++++++++++++++++++++++++++++++----------- next.config.ts | 11 ++++++++++ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/lib/pdf.ts b/lib/pdf.ts index 6f1e90e..4a2acc2 100644 --- a/lib/pdf.ts +++ b/lib/pdf.ts @@ -15,21 +15,47 @@ import type * as pdfjsTypes from "pdfjs-dist"; +/** + * Raised when the pdfjs parser itself cannot be loaded/initialised — as opposed + * to the input simply being an invalid PDF. Callers can treat this as a server + * error (retryable) rather than telling the user their file is broken. + */ +export class PdfParserUnavailableError extends Error { + constructor(cause: unknown) { + super("PDF parser could not be loaded on the server", { cause }); + this.name = "PdfParserUnavailableError"; + } +} + let pdfjsPromise: Promise | null = null; /** - * Lazily load pdfjs-dist. Importing at module scope can break the Cloudflare - * Workers runtime during bundling/SSR, so we defer to first use. The `canvas` - * native dependency is stubbed via pnpm.overrides (see shims/canvas), which is - * fine because we only parse the document structure here — we never render. + * Lazily load pdfjs-dist's **legacy** build. + * + * The default entry (`pdfjs-dist`) references browser-only globals such as + * `DOMMatrix` at module-evaluation time and throws + * `ReferenceError: DOMMatrix is not defined` under Node — which is what the + * server runtime (Next.js standalone server in the container) actually is. The + * `legacy` build targets non-DOM/Node environments and avoids those globals. + * + * Next.js's standalone output-file tracing does not reliably follow this + * dynamic subpath import, so the legacy build is *also* force-included via + * `outputFileTracingIncludes` in next.config.ts. Without that, the legacy + * build is missing from the deployed container, the import below throws, and + * every upload is wrongly rejected as an invalid PDF. + * + * The load is memoised, but a failed load is NOT cached — so a transient + * initialisation problem doesn't permanently poison every subsequent request. */ function getPdfjs(): Promise { if (!pdfjsPromise) { - // Use the legacy build: it targets Node/non-DOM environments and does - // not rely on browser-only globals, which suits server-side parsing. - pdfjsPromise = import( - "pdfjs-dist/legacy/build/pdf.mjs" - ) as unknown as Promise; + pdfjsPromise = import("pdfjs-dist/legacy/build/pdf.mjs") + .then((mod) => mod as unknown as typeof pdfjsTypes) + .catch((err) => { + // Don't cache the failure — allow a retry on the next call. + pdfjsPromise = null; + throw new PdfParserUnavailableError(err); + }); } return pdfjsPromise; } @@ -42,8 +68,13 @@ function getPdfjs(): Promise { * valid PDF (callers treat < 1 as an invalid file). */ export async function countPdfPages(buffer: Buffer): Promise { + // Load the parser first. A failure here means the *server* is misconfigured + // (e.g. the pdfjs legacy build wasn't traced into the standalone bundle), + // not that the user's file is bad — so let it propagate as a real error + // instead of masquerading as "invalid PDF" (which returning 0 would do). + const pdfjs = await getPdfjs(); + try { - const pdfjs = await getPdfjs(); // pdfjs mutates the underlying buffer, so hand it a fresh copy. It also // expects a Uint8Array, not a Node Buffer view with a shared pool. const data = new Uint8Array( @@ -63,8 +94,11 @@ export async function countPdfPages(buffer: Buffer): Promise { const { numPages } = doc; await doc.destroy(); return typeof numPages === "number" && numPages > 0 ? numPages : 0; - } catch { - // Corrupt/encrypted/non-PDF input — signal invalid to the caller. + } catch (err) { + // Genuinely corrupt/encrypted/non-PDF input — signal invalid to the + // caller. Log it so we can tell real bad files apart from unexpected + // parser regressions. + console.error("countPdfPages: failed to parse PDF", err); return 0; } } diff --git a/next.config.ts b/next.config.ts index 0a92db0..3fe5648 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,6 +21,17 @@ const nextConfig: NextConfig = { ignoreBuildErrors: false, }, serverExternalPackages: ["jose", "pdfjs-dist"], + // pdfjs-dist is loaded server-side (lib/pdf.ts) via a dynamic import of its + // *legacy* build to count PDF pages. Next.js's standalone output-file + // tracing does not reliably follow that dynamic subpath import, so the + // legacy build can be missing from the deployed container — at runtime the + // import then fails and every upload is wrongly reported as an invalid PDF. + // Explicitly include the legacy build (and the shared package internals it + // pulls in) so it is always copied into the standalone bundle. + outputFileTracingIncludes: { + "/api/shop/orders": ["./node_modules/pdfjs-dist/legacy/**"], + "/api/admin/orders": ["./node_modules/pdfjs-dist/legacy/**"], + }, // Inline assetPrefix so the value is baked into the client bundle. The // docker-entrypoint.sh placeholder swap also rewrites this at container // startup, so the same image can be redeployed against different CDNs. From eaa36acd1b1d766c7586fdfa2ec2aacbcaf98a95 Mon Sep 17 00:00:00 2001 From: Benson Cho <100653148+choden-dev@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:56:44 +1000 Subject: [PATCH 2/2] fix(orders): count PDF pages with pdf-lib instead of pdfjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the server-side pdfjs-based page counter with pdf-lib. pdfjs-dist is designed for the browser: its default build references DOM-only globals (DOMMatrix) and crashes under Node, and its legacy build was unreliable to trace into the Next.js standalone/container bundle — so valid PDFs were being rejected as invalid in production. pdf-lib is a dependency-free, pure-TypeScript PDF library that runs anywhere Node runs (no native addons, no DOM globals, no web worker, no bundler/file-tracing special-casing). It parses the document object graph and returns an authoritative page count via getPageCount(). - Accurate on real-world PDFs (annotations/incremental updates that made the original byte-scanner overcount 200 -> 467). - ignoreEncryption: true lets us still count pages of password/permission -protected PDFs instead of falsely rejecting them. - Invalid/corrupt input is caught and logged, returning 0 so callers reject it with a clear message. Also drops the now-unnecessary outputFileTracingIncludes workaround for the pdfjs legacy build. pdfjs-dist remains a dependency for the client-side PdfOrder preview only. --- lib/pdf.ts | 111 +++++++++++++------------------------------------ next.config.ts | 11 ----- package.json | 1 + pnpm-lock.yaml | 37 +++++++++++++++++ 4 files changed, 67 insertions(+), 93 deletions(-) diff --git a/lib/pdf.ts b/lib/pdf.ts index 4a2acc2..ab59c1e 100644 --- a/lib/pdf.ts +++ b/lib/pdf.ts @@ -2,102 +2,49 @@ * Server-side PDF utilities. * * Page counting is used to price orders, so it must be accurate and must never - * trust client-supplied counts. Previously this scanned the raw PDF bytes for - * `/Type /Page` markers, but that badly overcounts real-world PDFs: the marker - * also appears in object streams, annotations/form fields, orphaned objects - * left by incremental updates, and even inside content streams. A 200-page PDF - * could be counted as 467. + * trust client-supplied counts. * - * We now parse the document with pdfjs-dist (already a dependency, used - * client-side by the PdfOrder component) and read the authoritative page count - * from the parsed document. - */ - -import type * as pdfjsTypes from "pdfjs-dist"; - -/** - * Raised when the pdfjs parser itself cannot be loaded/initialised — as opposed - * to the input simply being an invalid PDF. Callers can treat this as a server - * error (retryable) rather than telling the user their file is broken. - */ -export class PdfParserUnavailableError extends Error { - constructor(cause: unknown) { - super("PDF parser could not be loaded on the server", { cause }); - this.name = "PdfParserUnavailableError"; - } -} - -let pdfjsPromise: Promise | null = null; - -/** - * Lazily load pdfjs-dist's **legacy** build. - * - * The default entry (`pdfjs-dist`) references browser-only globals such as - * `DOMMatrix` at module-evaluation time and throws - * `ReferenceError: DOMMatrix is not defined` under Node — which is what the - * server runtime (Next.js standalone server in the container) actually is. The - * `legacy` build targets non-DOM/Node environments and avoids those globals. - * - * Next.js's standalone output-file tracing does not reliably follow this - * dynamic subpath import, so the legacy build is *also* force-included via - * `outputFileTracingIncludes` in next.config.ts. Without that, the legacy - * build is missing from the deployed container, the import below throws, and - * every upload is wrongly rejected as an invalid PDF. + * History: + * - Originally this scanned the raw PDF bytes for `/Type /Page` markers, which + * badly overcounts real-world PDFs — the marker also appears in object + * streams, annotations/form fields, orphaned objects left by incremental + * updates, and inside content streams. A 200-page PDF was counted as 467. + * - We then tried pdfjs-dist, but its default build references browser-only + * globals (`DOMMatrix`) and crashes under Node, and its legacy build was + * unreliable to trace into the Next.js standalone/container bundle — causing + * valid PDFs to be rejected as invalid in production. * - * The load is memoised, but a failed load is NOT cached — so a transient - * initialisation problem doesn't permanently poison every subsequent request. + * We now use `pdf-lib`: a dependency-free, pure-TypeScript PDF library that runs + * anywhere Node runs (no native addons, no DOM globals, no web worker, no + * bundler/file-tracing special-casing). It parses the document object graph and + * exposes an authoritative page count. */ -function getPdfjs(): Promise { - if (!pdfjsPromise) { - pdfjsPromise = import("pdfjs-dist/legacy/build/pdf.mjs") - .then((mod) => mod as unknown as typeof pdfjsTypes) - .catch((err) => { - // Don't cache the failure — allow a retry on the next call. - pdfjsPromise = null; - throw new PdfParserUnavailableError(err); - }); - } - return pdfjsPromise; -} + +import { PDFDocument } from "pdf-lib"; /** - * Count the number of pages in a PDF by parsing it with pdfjs. + * Count the number of pages in a PDF by parsing its structure. * * @param buffer Raw PDF bytes. * @returns The authoritative page count, or 0 if the file cannot be parsed as a * valid PDF (callers treat < 1 as an invalid file). */ export async function countPdfPages(buffer: Buffer): Promise { - // Load the parser first. A failure here means the *server* is misconfigured - // (e.g. the pdfjs legacy build wasn't traced into the standalone bundle), - // not that the user's file is bad — so let it propagate as a real error - // instead of masquerading as "invalid PDF" (which returning 0 would do). - const pdfjs = await getPdfjs(); - try { - // pdfjs mutates the underlying buffer, so hand it a fresh copy. It also - // expects a Uint8Array, not a Node Buffer view with a shared pool. - const data = new Uint8Array( - buffer.buffer.slice( - buffer.byteOffset, - buffer.byteOffset + buffer.byteLength, - ), - ); - const doc = await pdfjs.getDocument({ - data, - // Server-side hardening: don't fetch external resources, don't rely - // on a worker thread, and avoid eval-based font handling. - isEvalSupported: false, - useWorkerFetch: false, - disableFontFace: true, - }).promise; - const { numPages } = doc; - await doc.destroy(); + const doc = await PDFDocument.load(buffer, { + // We only need the page count, so skip the extra work of parsing all + // form fields, and don't refuse encrypted PDFs — a customer may well + // upload a password/permission-protected PDF and we can still read + // its page count without decrypting the content streams. + ignoreEncryption: true, + updateMetadata: false, + }); + const numPages = doc.getPageCount(); return typeof numPages === "number" && numPages > 0 ? numPages : 0; } catch (err) { - // Genuinely corrupt/encrypted/non-PDF input — signal invalid to the - // caller. Log it so we can tell real bad files apart from unexpected - // parser regressions. + // Corrupt / non-PDF input — signal invalid to the caller. Log it so a + // genuine bad file can be told apart from an unexpected parser + // regression. console.error("countPdfPages: failed to parse PDF", err); return 0; } diff --git a/next.config.ts b/next.config.ts index 3fe5648..0a92db0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,17 +21,6 @@ const nextConfig: NextConfig = { ignoreBuildErrors: false, }, serverExternalPackages: ["jose", "pdfjs-dist"], - // pdfjs-dist is loaded server-side (lib/pdf.ts) via a dynamic import of its - // *legacy* build to count PDF pages. Next.js's standalone output-file - // tracing does not reliably follow that dynamic subpath import, so the - // legacy build can be missing from the deployed container — at runtime the - // import then fails and every upload is wrongly reported as an invalid PDF. - // Explicitly include the legacy build (and the shared package internals it - // pulls in) so it is always copied into the standalone bundle. - outputFileTracingIncludes: { - "/api/shop/orders": ["./node_modules/pdfjs-dist/legacy/**"], - "/api/admin/orders": ["./node_modules/pdfjs-dist/legacy/**"], - }, // Inline assetPrefix so the value is baked into the client bundle. The // docker-entrypoint.sh placeholder swap also rewrites this at container // startup, so the same image can be redeployed against different CDNs. diff --git a/package.json b/package.json index 8f6f331..ab09a09 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "nextjs-google-analytics": "^2.3.7", "nodemailer": "^9.0.1", "payload": "^3.83.0", + "pdf-lib": "^1.17.1", "pdfjs-dist": "^5.6.205", "pug": "^3.0.4", "react": "19.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9df285..9d16daf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: payload: specifier: ^3.83.0 version: 3.83.0(graphql@16.13.2)(typescript@5.8.3) + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 pdfjs-dist: specifier: ^5.6.205 version: 5.6.205 @@ -1528,6 +1531,12 @@ packages: react: ^19.0.1 || ^19.1.2 || ^19.2.1 react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1 + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==, tarball: https://packages.atlassian.com/api/npm/npm-remote/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==, tarball: https://packages.atlassian.com/api/npm/npm-remote/@pdf-lib/upng/-/upng-1.0.1.tgz} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==, tarball: https://packages.atlassian.com/api/npm/npm-remote/@pinojs/redact/-/redact-0.4.0.tgz} @@ -3033,6 +3042,9 @@ packages: openid-client@5.7.1: resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==, tarball: https://packages.atlassian.com/api/npm/npm-remote/openid-client/-/openid-client-5.7.1.tgz} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==, tarball: https://packages.atlassian.com/api/npm/npm-remote/pako/-/pako-1.0.11.tgz} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, tarball: https://packages.atlassian.com/api/npm/npm-remote/parent-module/-/parent-module-1.0.1.tgz} engines: {node: '>=6'} @@ -3072,6 +3084,9 @@ packages: peerDependencies: graphql: ^16.8.1 + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==, tarball: https://packages.atlassian.com/api/npm/npm-remote/pdf-lib/-/pdf-lib-1.17.1.tgz} + pdfjs-dist@5.6.205: resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==, tarball: https://packages.atlassian.com/api/npm/npm-remote/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz} engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} @@ -3533,6 +3548,9 @@ packages: typescript: optional: true + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==, tarball: https://packages.atlassian.com/api/npm/npm-remote/tslib/-/tslib-1.14.1.tgz} + tslib@2.4.0: resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==, tarball: https://packages.atlassian.com/api/npm/npm-remote/tslib/-/tslib-2.4.0.tgz} @@ -5624,6 +5642,14 @@ snapshots: - supports-color - typescript + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + '@pinojs/redact@0.4.0': {} '@popperjs/core@2.11.8': {} @@ -7357,6 +7383,8 @@ snapshots: object-hash: 2.2.0 oidc-token-hash: 5.2.0 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7430,6 +7458,13 @@ snapshots: - typescript - utf-8-validate + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + pdfjs-dist@5.6.205: optionalDependencies: '@napi-rs/canvas': 0.1.99 @@ -7955,6 +7990,8 @@ snapshots: optionalDependencies: typescript: 5.8.3 + tslib@1.14.1: {} + tslib@2.4.0: {} tslib@2.8.1: {}