Merge pull request #1278

Read a PDF's text with the app's own pdfjs (#1277).
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 20:10:04 +02:00
commit d3621617c6
6 files changed
+274 -179

No files matched your search

+55 -151
View File
@@ -1,172 +1,76 @@
/**
* Structural guard: the server runs one pdf.js, and only one module opens it.
* Structural guard: the server runs one pdfjs.
*
* ## The failure this freezes (GitHub #1277)
* ## The failure this freezes
*
* Under Node, pdf.js keeps its in-process worker on `globalThis.pdfjsWorker`,
* and whichever copy opens a document first owns it until the process exits.
* The local text read ran a library that pinned its own, older pdfjs-dist,
* while the rasterizer ran the app's. Whichever ran first broke the other
* with "The API version … does not match the Worker version …": after a text
* read every thumbnail and every vision read of a scan failed, after a render
* every local text read failed without a word, until a restart. Nothing in
* the gate noticed, because each path passed on its own and the image tracing
* shipped every `pdfjs-dist@*` the store held.
* `pdf-parse` brings its own `pdfjs-dist` beside the app's, and under Node
* every pdfjs copy keeps its worker on the one `globalThis.pdfjsWorker`. While
* local text extraction went through pdf-parse, whichever copy ran first in a
* process broke the other: "The API version "6.4.299" does not match the
* Worker version "5.4.296"". Thumbnails and vision reads failed after a text
* extraction, and the text extraction failed after a raster.
*
* ## What it proves and what it does not
*
* - The lockfile resolves exactly one pdfjs-dist, the version package.json
* pins, and no package in it depends on pdfjs-dist except the app itself.
* A new dependency that brings its own pdf.js, even the same version,
* fails here and has to be looked at; every line of the lockfile that
* names pdfjs-dist is either understood or reported.
* - The checkout resolves that same version from the working directory,
* which is how the server finds it.
* - No production module under `src/` imports pdfjs-dist except
* `src/lib/documents/pdfjs.ts`, so both paths open documents with the same
* options, and nothing imports pdf-parse.
*
* It does not prove the image carries one copy; the Dockerfile hoists the
* pinned version by name and the tracing glob can only find what the lockfile
* installed. The behaviour itself is pinned by the two
* `pdfjs-one-runtime-*` tests under `src/lib/documents/__tests__/`.
* No runtime file under `src/` imports `pdf-parse`, so the only pdfjs the
* server loads is the app's own. Tests may still read generated PDFs back with
* it; they run in their own processes. The pdfjs-one-version tests prove the
* two orders in one process; this file stops the import from coming back.
*/
import { createRequire } from "node:module";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readSourceFolder, readSourceText } from "./helpers/source-files";
import {
readSourceText,
requireFloor,
stripComments,
walkSourceFiles,
} from "./helpers/source-files";
const ROOT = process.cwd();
/** Relative to `src/`. */
const LOADER = "lib/documents/pdfjs.ts";
const SRC = join(process.cwd(), "src");
const pkg = JSON.parse(readSourceText(join(ROOT, "package.json"))) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const pinned =
pkg.dependencies?.["pdfjs-dist"] ?? pkg.devDependencies?.["pdfjs-dist"];
/** Every lockfile line naming pdfjs-dist, sorted into what it means. */
function readLockfile(lockfile: string): {
versions: Set<string>;
rootImporters: number;
unexplained: string[];
} {
const lines = lockfile.split("\n");
const versions = new Set<string>();
let rootImporters = 0;
const unexplained: string[] = [];
lines.forEach((line, i) => {
if (!line.includes("pdfjs-dist")) return;
// A resolved copy: ` pdfjs-dist@6.4.299:` in `packages:` and again in
// `snapshots:` (where a peer suffix in parentheses may follow).
const resolved =
/^ {2}'?pdfjs-dist@(\d+\.\d+\.\d+[\w.+-]*)(?:\(.*\))?'?:/.exec(line);
if (resolved) {
versions.add(resolved[1]!);
return;
}
// The app's own entry under `importers:`, followed by its specifier.
if (
/^ {6}pdfjs-dist:\s*$/.test(line) &&
/^\s+specifier:/.test(lines[i + 1] ?? "")
) {
rootImporters++;
return;
}
// Anything else is another package depending on pdfjs-dist, an
// override, or a spelling this reader does not know: all reported.
unexplained.push(`${i + 1}: ${line.trim()}`);
});
return { versions, rootImporters, unexplained };
}
/** Production modules under `src/` that import a pdf.js package. */
function pdfjsImporters(files: Map<string, string>): string[] {
const specifier =
/(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s*)["'](?:pdfjs-dist|pdf-parse)(?:\/[^"']*)?["']/m;
return [...files]
.filter(([, source]) => specifier.test(source))
.map(([path]) => path)
.sort();
}
/** Production modules under `src/`, keyed by their path relative to it. */
function productionSources(): Map<string, string> {
return new Map(
readSourceFolder("src", {
floor: 1000,
extensions: [".ts", ".tsx", ".mts", ".js", ".mjs"],
include: (rel) =>
!rel.startsWith("generated/") &&
!rel.includes("__tests__/") &&
!/\.(?:test|spec)\.[mc]?[tj]sx?$/.test(rel),
}),
/**
* Every runtime module under `src/`: the suites, mocks and generated client
* are not loaded by the server. About 3,400 today; the floor sits below that
* with headroom, so a walk that comes back short fails instead of passing.
*/
function runtimeFiles(): string[] {
const files = walkSourceFiles(SRC, {
floor: 5000,
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
}).filter(
(rel) =>
!rel.startsWith("generated/") &&
!rel.split("/").includes("__tests__") &&
!rel.split("/").includes("__mocks__") &&
!/\.test\.(ts|tsx)$/.test(rel),
);
return [...requireFloor("runtime files under src/", files, 3000)];
}
describe("one pdf.js (#1277)", () => {
it("package.json pins pdfjs-dist exactly and lists no pdf-parse", () => {
expect(pinned, "pdfjs-dist is a direct dependency").toMatch(
/^\d+\.\d+\.\d+$/,
/** A static import, a re-export, a dynamic import or a require of pdf-parse. */
const PDF_PARSE_IMPORT =
/(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|\bimport\s+)["']pdf-parse(?:\/[^"']*)?["']/;
describe("one pdfjs in the server", () => {
it("no runtime file imports pdf-parse", () => {
const importers = runtimeFiles().filter((rel) =>
PDF_PARSE_IMPORT.test(stripComments(readSourceText(join(SRC, rel)))),
);
expect(pkg.dependencies?.["pdf-parse"]).toBeUndefined();
expect(pkg.devDependencies?.["pdf-parse"]).toBeUndefined();
expect(importers).toEqual([]);
});
it("the lockfile resolves one pdfjs-dist, and only the app depends on it", () => {
const lock = readLockfile(readSourceText(join(ROOT, "pnpm-lock.yaml")));
expect(lock.unexplained, "lockfile lines naming pdfjs-dist").toEqual([]);
expect([...lock.versions]).toEqual([pinned]);
expect(lock.rootImporters).toBe(1);
});
it("the checkout resolves the pinned copy", () => {
const anchor = createRequire(join(ROOT, "package.json"));
const resolved = JSON.parse(
readSourceText(anchor.resolve("pdfjs-dist/package.json")),
) as { version: string };
expect(resolved.version).toBe(pinned);
});
it("only the shared loader imports pdf.js", () => {
const sources = productionSources();
expect(sources.has(LOADER), `${LOADER} exists`).toBe(true);
expect(pdfjsImporters(sources)).toEqual([LOADER]);
});
it("the readers catch what they exist to catch", () => {
const twoCopies = [
"importers:",
" .:",
" devDependencies:",
" pdfjs-dist:",
" specifier: 6.4.299",
" version: 6.4.299",
"packages:",
" pdfjs-dist@5.4.296:",
" pdfjs-dist@6.4.299:",
"snapshots:",
" some-parser@2.4.5:",
" dependencies:",
" pdfjs-dist: 5.4.296",
].join("\n");
const lock = readLockfile(twoCopies);
expect([...lock.versions].sort()).toEqual(["5.4.296", "6.4.299"]);
expect(lock.unexplained).toEqual(["13: pdfjs-dist: 5.4.296"]);
const importers = pdfjsImporters(
new Map([
[LOADER, 'await import("pdfjs-dist/legacy/build/pdf.mjs")'],
["a", 'import { PDFParse } from "pdf-parse";'],
["b", "const m = await import(\n 'pdfjs-dist'\n);"],
["c", 'import "pdfjs-dist/legacy/build/pdf.worker.mjs";'],
["d", '// mentions "pdfjs-dist" in prose only'],
]),
);
expect(importers).toEqual(["a", "b", "c", LOADER].sort());
it("recognises each way of importing it", () => {
for (const line of [
'import { PDFParse } from "pdf-parse";',
'import type { PDFParse } from "pdf-parse";',
'const { PDFParse } = await import("pdf-parse");',
"const m = require('pdf-parse/worker');",
'export { PDFParse } from "pdf-parse";',
]) {
expect(PDF_PARSE_IMPORT.test(line), line).toBe(true);
}
expect(PDF_PARSE_IMPORT.test('import x from "pdf-parser";')).toBe(false);
});
});