From 0486b221ddb4e5490b5c0463e3e65e237d9036e6 Mon Sep 17 00:00:00 2001 From: Richard Pillaca Date: Thu, 11 Jun 2026 14:07:49 -0700 Subject: [PATCH] fix(resume): polyfill canvas DOM globals for pdf parsing on Vercel (#93) Production PDF parsing failed with "ReferenceError: DOMMatrix is not defined" (pdfjs expects browser canvas globals that Node lacks). Add @napi-rs/canvas as a real dependency and install DOMMatrix/ImageData/ Path2D on globalThis before pdf-parse loads. Local parsing was unaffected, which masked the bug until the prod smoke test. Also pace inline admin email fan-out with a 300ms gap between sends: sequential-but-instant rejections still tripped Resend's requests-per-second cap (observed 429 in prod EmailLog). Co-authored-by: Claude --- package.json | 1 + pnpm-lock.yaml | 125 +++++++++++++++++++++++++++++++ src/lib/resume/extractText.ts | 27 ++++++- src/server/jobs/dispatch.test.ts | 10 ++- src/server/jobs/dispatch.ts | 8 +- 5 files changed, 167 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 31d2d30..9ad972e 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@dnd-kit/utilities": "3.2.2", "@google-cloud/storage": "^7.19.0", "@hookform/resolvers": "5.2.1", + "@napi-rs/canvas": "^1.0.0", "@prisma/adapter-pg": "7.7.0", "@prisma/client": "7.7.0", "@radix-ui/react-dialog": "^1.1.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d80337..a9452c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: '@hookform/resolvers': specifier: 5.2.1 version: 5.2.1(react-hook-form@7.74.0(react@19.2.5)) + '@napi-rs/canvas': + specifier: ^1.0.0 + version: 1.0.0 '@prisma/adapter-pg': specifier: 7.7.0 version: 7.7.0 @@ -831,24 +834,48 @@ packages: cpu: [arm64] os: [android] + '@napi-rs/canvas-android-arm64@1.0.0': + resolution: {integrity: sha512-3hNKJObUK7JsCF9aJlVCs1J0/KE/gGfZNeK8MO1ge6bB3aicr5walGme9t9No1f/oyk9GgvdAT/rjSdsx3gbIw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + '@napi-rs/canvas-darwin-arm64@0.1.80': resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@napi-rs/canvas-darwin-arm64@1.0.0': + resolution: {integrity: sha512-ZIja19/BiGz2puhki+WUYSRriwFeFJ8Mi9eK3hZdSS85w4Y60cuEAJVhMCfKwswQkKkUtrnzdKMBuO7TupvexA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@napi-rs/canvas-darwin-x64@0.1.80': resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@napi-rs/canvas-darwin-x64@1.0.0': + resolution: {integrity: sha512-hImggWc82jqZVpEsFR9S7PE9OQYjq/H/D7vwCGB6X1jRH+UVBP1+1niJTPBOat1B154T6GKK7/kcFtoWgjgFzQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.0': + resolution: {integrity: sha512-hlJRy6d+kWLKVOG/+1rEvNQVURZ0DxxRPJsLmEWwhwiXZUJc0BF5o9esALHSEP4CoJK4wChRtj3hnyBgVx2oWA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==} engines: {node: '>= 10'} @@ -856,6 +883,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-arm64-gnu@1.0.0': + resolution: {integrity: sha512-5Hru4T3RXkosRQafcjelv7AUzw9mXqmGYsxnzeDDOWveFCJyEPMSJltvGCM+jfH98seOCbfwm9KyFg6Jm5FhAA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-arm64-musl@0.1.80': resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} engines: {node: '>= 10'} @@ -863,6 +897,13 @@ packages: os: [linux] libc: [musl] + '@napi-rs/canvas-linux-arm64-musl@1.0.0': + resolution: {integrity: sha512-LTUl9jS8WsLSUGaxQZKQkxfluOJRpgvBuxxdM4pYcjib+di8AU4OzQc6+L6SzGMLcKc9H0RAjojRatBhTMqYdg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} engines: {node: '>= 10'} @@ -870,6 +911,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-riscv64-gnu@1.0.0': + resolution: {integrity: sha512-Iz931SAZf+WVDzpjk52Q3ffW3zw0YflFwEZMgs036Wfu1kX/LrwT9wGjsuSqyduqefUkl91/vTdAjn8hQu5ezA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-x64-gnu@0.1.80': resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} engines: {node: '>= 10'} @@ -877,6 +925,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-x64-gnu@1.0.0': + resolution: {integrity: sha512-pFEQ5eFK4JusgN1K6KkO9DKP/Hi1WMJOkF8Ch03/khTc4bFbCKkCCsJG4YcOMOW9bI4XbT2/eMAWxhO0xaWgPA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-x64-musl@0.1.80': resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} engines: {node: '>= 10'} @@ -884,16 +939,39 @@ packages: os: [linux] libc: [musl] + '@napi-rs/canvas-linux-x64-musl@1.0.0': + resolution: {integrity: sha512-jnvr8NrLHiZ3NCiOKWqDbkI4Ah+QDrqtZ+sddPZBltEb1mQ2coSvCSJYfict+oAwcm0c970oTmVySpjKP/lnaA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.0': + resolution: {integrity: sha512-y2j9/Gfd5joqiqxdP/L1smqjQ+uAx3C4N0EC7bDHrnZEEH8ToM/OC5p3uHvtj4Lq591aHj+ArL01UDLNwT5HgQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@napi-rs/canvas-win32-x64-msvc@0.1.80': resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@napi-rs/canvas-win32-x64-msvc@1.0.0': + resolution: {integrity: sha512-qwdhh9N6Gge/hC4pL9S1tQp0iKwhSl/dYjg7+RGp9k26iRGRi5MqqUyKGOXIWli0zOcuy5Y2wIH/jk2ry6i/jA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@napi-rs/canvas@0.1.80': resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==} engines: {node: '>= 10'} + '@napi-rs/canvas@1.0.0': + resolution: {integrity: sha512-Jqxcy1XOIqj+lH9sl1GT+il6GR3uQv13vI2mrwubP3uT8Olak2ClDrK2RnxlQKjwv8BRr4b3ug0YR7c6hBX8wg==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -4616,33 +4694,66 @@ snapshots: '@napi-rs/canvas-android-arm64@0.1.80': optional: true + '@napi-rs/canvas-android-arm64@1.0.0': + optional: true + '@napi-rs/canvas-darwin-arm64@0.1.80': optional: true + '@napi-rs/canvas-darwin-arm64@1.0.0': + optional: true + '@napi-rs/canvas-darwin-x64@0.1.80': optional: true + '@napi-rs/canvas-darwin-x64@1.0.0': + optional: true + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': optional: true + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.0': + optional: true + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': optional: true + '@napi-rs/canvas-linux-arm64-gnu@1.0.0': + optional: true + '@napi-rs/canvas-linux-arm64-musl@0.1.80': optional: true + '@napi-rs/canvas-linux-arm64-musl@1.0.0': + optional: true + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': optional: true + '@napi-rs/canvas-linux-riscv64-gnu@1.0.0': + optional: true + '@napi-rs/canvas-linux-x64-gnu@0.1.80': optional: true + '@napi-rs/canvas-linux-x64-gnu@1.0.0': + optional: true + '@napi-rs/canvas-linux-x64-musl@0.1.80': optional: true + '@napi-rs/canvas-linux-x64-musl@1.0.0': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.0': + optional: true + '@napi-rs/canvas-win32-x64-msvc@0.1.80': optional: true + '@napi-rs/canvas-win32-x64-msvc@1.0.0': + optional: true + '@napi-rs/canvas@0.1.80': optionalDependencies: '@napi-rs/canvas-android-arm64': 0.1.80 @@ -4656,6 +4767,20 @@ snapshots: '@napi-rs/canvas-linux-x64-musl': 0.1.80 '@napi-rs/canvas-win32-x64-msvc': 0.1.80 + '@napi-rs/canvas@1.0.0': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.0 + '@napi-rs/canvas-darwin-arm64': 1.0.0 + '@napi-rs/canvas-darwin-x64': 1.0.0 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.0 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.0 + '@napi-rs/canvas-linux-arm64-musl': 1.0.0 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.0 + '@napi-rs/canvas-linux-x64-gnu': 1.0.0 + '@napi-rs/canvas-linux-x64-musl': 1.0.0 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.0 + '@napi-rs/canvas-win32-x64-msvc': 1.0.0 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 diff --git a/src/lib/resume/extractText.ts b/src/lib/resume/extractText.ts index 935a111..2d2110b 100644 --- a/src/lib/resume/extractText.ts +++ b/src/lib/resume/extractText.ts @@ -10,6 +10,29 @@ type PdfParseModule = { PDFParse?: PdfParseCtor; }; +// pdfjs (inside pdf-parse) references browser canvas globals that Node does +// not provide. On Vercel's runtime this surfaced as "ReferenceError: DOMMatrix +// is not defined" and failed every PDF parse, so the globals are polyfilled +// from @napi-rs/canvas before pdf-parse loads. +let domGlobalsPromise: Promise | null = null; + +function ensurePdfDomGlobals(): Promise { + domGlobalsPromise ??= (async () => { + const g = globalThis as Record; + if (typeof g.DOMMatrix !== "undefined") return; + try { + const canvas = await import("@napi-rs/canvas"); + g.DOMMatrix ??= canvas.DOMMatrix; + g.ImageData ??= canvas.ImageData; + g.Path2D ??= canvas.Path2D; + } catch (err) { + // Parsing of text-only PDFs may still succeed without the polyfill. + console.warn("[extractText] canvas polyfill unavailable:", err); + } + })(); + return domGlobalsPromise; +} + // pdf-parse is listed in next.config.ts `serverExternalPackages`, so both // webpack and Turbopack leave this dynamic import external and Node resolves // the real package at runtime. (A createRequire(import.meta.url) + require() @@ -17,7 +40,9 @@ type PdfParseModule = { let pdfParsePromise: Promise | null = null; function loadPdfParse(): Promise { - pdfParsePromise ??= import("pdf-parse") as Promise; + pdfParsePromise ??= ensurePdfDomGlobals().then( + () => import("pdf-parse") as Promise, + ); return pdfParsePromise; } diff --git a/src/server/jobs/dispatch.test.ts b/src/server/jobs/dispatch.test.ts index fba6ff4..9141c9c 100644 --- a/src/server/jobs/dispatch.test.ts +++ b/src/server/jobs/dispatch.test.ts @@ -72,6 +72,12 @@ async function flushInlineTasks(): Promise { await new Promise((resolve) => setImmediate(resolve)); } +// The inline admin fan-out paces consecutive sends with a real 300ms timer +// (Resend rate limit), so flushing setImmediate alone is not enough. +async function flushPacedFanOut(): Promise { + await new Promise((resolve) => setTimeout(resolve, 400)); +} + describe("dispatchResumeParse", () => { it("enqueues to pg-boss in worker mode", async () => { process.env.JOB_RUNNER = "worker"; @@ -155,7 +161,7 @@ describe("dispatchAdminNotificationEmails", () => { process.env.JOB_RUNNER = "inline"; const result = await dispatchAdminNotificationEmails(input); - await flushInlineTasks(); + await flushPacedFanOut(); expect(dispatchEmailJobMock).toHaveBeenCalledTimes(2); expect(dispatchEmailJobMock).toHaveBeenCalledWith( @@ -174,7 +180,7 @@ describe("dispatchAdminNotificationEmails", () => { try { const result = await dispatchAdminNotificationEmails(input); - await flushInlineTasks(); + await flushPacedFanOut(); // Inline delivery is fire-and-forget post-response: failures are logged // to EmailLog by the send functions, never surfaced to the caller. diff --git a/src/server/jobs/dispatch.ts b/src/server/jobs/dispatch.ts index 13ac1a2..1b96842 100644 --- a/src/server/jobs/dispatch.ts +++ b/src/server/jobs/dispatch.ts @@ -89,7 +89,13 @@ export async function dispatchAdminNotificationEmails( })); runAfterResponse("email-admin-fan-out", async () => { - for (const job of jobs) { + for (const [index, job] of jobs.entries()) { + // Sequential alone is not enough: rejected sends return in ~50ms, so a + // burst of admins (+ the applicant confirmation) still trips Resend's + // requests-per-second cap. Pace consecutive sends. + if (index > 0) { + await new Promise((resolve) => setTimeout(resolve, 300)); + } const result = await dispatchEmailJob(job); if (result.skipped) { console.warn(`[jobs:email] skipped ${job.kind} for ${job.payload.to}`);