diff --git a/.dockerignore b/.dockerignore index ffd4e6bc1..708ee02cf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,3 +19,4 @@ dev-server.log Dockerfile Dockerfile.worker .dockerignore +dist diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 2547a647d..0a7e1e865 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -36,6 +36,7 @@ on: - "worker/python/requirements.txt" - "scripts/check-node-engine.cjs" - "scripts/guard-next-build.mjs" + - "scripts/build-worker.mjs" - ".github/workflows/docker-image.yml" workflow_dispatch: schedule: diff --git a/.gitignore b/.gitignore index 377d5ada8..51d2059d4 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,6 @@ ds-bundle/ .design-sync/.cache/ .design-sync/learnings/ .design-sync/node_modules/ + +# esbuild output for the worker image (scripts/build-worker.mjs) +dist/ diff --git a/Dockerfile.worker b/Dockerfile.worker index c0669e87c..0ffb34d4d 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -3,9 +3,10 @@ # See docs/deployment-architecture.md for why the worker ships as a # container instead of completing the edge-agent migration. # -# Runtime contents: -# - Node 24 + full (dev-inclusive) node_modules: the worker runs through -# tsx, which is a devDependency. +# Runtime contents (2026-07-13 audit, finding 12 — slimmed): +# - Node 24 + PRODUCTION-ONLY node_modules: the worker runs as a +# prebuilt esbuild bundle (dist/worker/index.mjs), so tsx and the rest +# of the dev toolchain never reach the image. # - Tesseract OCR (Debian package, bundles English language data). # - Python venv with worker/python/requirements.txt (PyMuPDF, Pillow, # pytesseract). The venv's `python` matches the PYTHON_BIN default. @@ -14,12 +15,27 @@ # Run: docker run --env-file clinical-kb-worker # Secrets are injected at run time; nothing is baked into the image. -FROM node:24-bookworm-slim AS deps +# Dev-inclusive install + bundle. scripts/build-worker.mjs resolves the +# `server-only` marker to the standalone stub at build time (the job +# run-tsx.mjs previously did at runtime) and keeps npm packages external, +# so the bundle resolves them from the runner's production node_modules. +FROM node:24-bookworm-slim AS build WORKDIR /app COPY package.json package-lock.json .npmrc ./ COPY scripts/check-node-engine.cjs scripts/check-node-engine.cjs COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs RUN npm ci +COPY . . +RUN node scripts/build-worker.mjs + +# Production-only node_modules for the runtime stage. The preinstall/ +# postinstall lifecycle scripts must be present in every npm-ci stage. +FROM node:24-bookworm-slim AS prod-deps +WORKDIR /app +COPY package.json package-lock.json .npmrc ./ +COPY scripts/check-node-engine.cjs scripts/check-node-engine.cjs +COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs +RUN npm ci --omit=dev FROM node:24-bookworm-slim AS runner RUN apt-get update \ @@ -31,15 +47,15 @@ RUN python3 -m venv /opt/ocr-venv \ && /opt/ocr-venv/bin/pip install --no-cache-dir -r worker/python/requirements.txt ENV PATH="/opt/ocr-venv/bin:${PATH}" ENV NODE_ENV=production -COPY --from=deps /app/node_modules ./node_modules -COPY . . +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY worker/python worker/python +COPY package.json ./package.json USER node # Long-poll worker; WORKER_* env vars control claim batch size, concurrency, # and the stale-claim window (see src/lib/env.ts). # -# Route through scripts/run-tsx.mjs, NOT bare tsx: worker/index.ts imports -# src/lib/env, which begins with `import "server-only"`. Under bare tsx that -# import throws and the worker crash-loops on boot. run-tsx.mjs registers the -# server-only stub hook so the import resolves outside the Next bundler. -# Guarded by tests/tsx-server-only-runner.test.ts. -CMD ["node", "scripts/run-tsx.mjs", "worker/index.ts"] +# The bundle already has `server-only` stubbed (scripts/build-worker.mjs), so +# no runtime loader hook is needed. Guarded by +# tests/tsx-server-only-runner.test.ts. +CMD ["node", "dist/worker/index.mjs"] diff --git a/docs/deployment-architecture.md b/docs/deployment-architecture.md index 24391288c..914ad0f73 100644 --- a/docs/deployment-architecture.md +++ b/docs/deployment-architecture.md @@ -229,7 +229,8 @@ check and watch patterns rather than relying on dashboard defaults. ### Decision: containerized worker (recommended) over completing the edge-agent migration -Ship the existing worker as a container (`Dockerfile.worker`: Node 24 + tsx + +Ship the existing worker as a container (`Dockerfile.worker`: Node 24 + a +prebuilt esbuild bundle over production-only `node_modules` + Tesseract + a Python venv with `worker/python/requirements.txt`) and run **one always-on worker instance** co-located in Railway Singapore (`worker` service). The `indexing-v3-agent` Edge Function **stays** in its current role as the diff --git a/docs/worker-deploy-runbook.md b/docs/worker-deploy-runbook.md index 8fec7783d..0b2a2d097 100644 --- a/docs/worker-deploy-runbook.md +++ b/docs/worker-deploy-runbook.md @@ -63,9 +63,11 @@ the standard gates. Status: ✅ **CI covers the worker image build.** All build inputs referenced by `Dockerfile.worker` are present in the tree (`package-lock.json`, `.npmrc`, -`scripts/check-node-engine.cjs`, `scripts/run-tsx.mjs`, +`scripts/check-node-engine.cjs`, `scripts/build-worker.mjs`, `worker/python/requirements.txt`, `worker/index.ts`) and the `server-only` -runner path is guarded by `tests/tsx-server-only-runner.test.ts`. +bundle path is guarded by `tests/tsx-server-only-runner.test.ts` plus +`tests/worker-bundle.test.ts` (resolve-checks every bundle external against +plain-`node` ESM resolution and the `--omit=dev` prune). Local build for parity (optional; needs Docker with a few GB free — unlike the app image it does **not** need the 8 GiB build heap): @@ -76,17 +78,23 @@ docker build -f Dockerfile.worker -t clinical-kb-worker . ### What ships in the image -- **Node 24** (`node:24-bookworm-slim`) + the **full** `node_modules` - (dev-inclusive): the worker runs through `tsx`, which is a devDependency. +- **Node 24** (`node:24-bookworm-slim`) + **production-only** `node_modules` + (`npm ci --omit=dev`): the worker runs as a prebuilt esbuild bundle + (`dist/worker/index.mjs`, built in a separate image stage by + `scripts/build-worker.mjs`), so tsx and the rest of the dev toolchain never + reach the image. - **Tesseract OCR** (Debian package; bundles English language data). - A **Python venv** at `/opt/ocr-venv` with `worker/python/requirements.txt` (PyMuPDF, Pillow, pytesseract). The venv is first on `PATH`, so the default `PYTHON_BIN=python` resolves to it — no override needed in-container. - Runtime is the non-root `node` user. No secret is baked into any layer. -- `CMD` routes through `scripts/run-tsx.mjs` (registers the `server-only` - stub so `worker/index.ts`'s `import "server-only"` resolves outside the Next - bundler). **Do not** change this to bare `tsx` — the worker would crash-loop - on boot. +- `CMD` runs the bundle under plain `node`. The build aliases `server-only` + to the standalone stub (what `scripts/run-tsx.mjs` did at runtime), so + `worker/index.ts`'s `import "server-only"` resolves outside the Next + bundler. **Do not** change this to bare `tsx`/`node` on the TypeScript + sources — the worker would crash-loop on boot. Bundle externals must stay + resolvable under plain-`node` ESM semantics with production-only deps; + `tests/worker-bundle.test.ts` enforces both. - The default command is the **always-on long-poll loop** (no `--once`): probe Supabase health → claim jobs → process → poll every `WORKER_POLL_MS` when idle. `--once` is a drain-and-exit mode for local/one-shot use, not for the diff --git a/package-lock.json b/package-lock.json index af53f6d7c..ca7fb9ab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.10", + "esbuild": "0.28.1", "eslint": "^9.39.5", "eslint-config-next": "16.2.10", "fast-check": "^4.8.0", diff --git a/package.json b/package.json index 98934ff11..84b78db14 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "format:check": "prettier --check .", "worker": "node scripts/run-tsx.mjs worker/index.ts", "worker:once": "node scripts/run-tsx.mjs worker/index.ts --once", + "build:worker": "node scripts/build-worker.mjs", "import:docs": "node scripts/run-tsx.mjs scripts/import-documents.ts", "import:docs:20": "node scripts/run-tsx.mjs scripts/import-documents.ts --queue-batch-size 20", "measure:wrapped-dose-units": "node scripts/run-tsx.mjs scripts/measure-wrapped-dose-prevalence.ts", @@ -171,6 +172,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.10", + "esbuild": "0.28.1", "eslint": "^9.39.5", "eslint-config-next": "16.2.10", "fast-check": "^4.8.0", diff --git a/scripts/build-worker.mjs b/scripts/build-worker.mjs new file mode 100644 index 000000000..8805f7bc9 --- /dev/null +++ b/scripts/build-worker.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * build-worker — bundle the ingestion worker for the production image. + * + * The worker previously ran through tsx (a devDependency) inside the + * container, which forced Dockerfile.worker to ship the full dev-inclusive + * node_modules (2026-07-13 audit, finding 12). This bundles worker/index.ts + * and every repo-local import into a single ESM file so the runtime stage + * needs only production node_modules. + * + * - `packages: "external"`: npm packages are NOT bundled; they resolve from + * the image's production node_modules exactly as they do under tsx today, + * so native/binary packages and package-internal asset loading keep working. + * - `server-only` alias: src/lib/env.ts starts with `import "server-only"`, + * which throws outside the Next bundler. run-tsx.mjs stubs it with a module + * hook at runtime; this bundle resolves it to the same stub at build time + * (guarded by tests/tsx-server-only-runner.test.ts). + * - `.mjs` output: the repo's package.json has no `"type": "module"`, so the + * extension is what marks the bundle as ESM. + */ +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +/** + * Shared with tests/worker-bundle.test.ts, which resolve-checks externals. + * @type {import("esbuild").BuildOptions} + */ +export const workerBuildOptions = { + entryPoints: ["worker/index.ts"], + outfile: "dist/worker/index.mjs", + bundle: true, + platform: "node", + format: "esm", + target: "node24", + packages: "external", + sourcemap: true, + logLevel: "info", + alias: { + "server-only": "./tests/stubs/server-only.ts", + // next's package.json has no `exports` map, so Node's ESM resolver needs + // the explicit file: `import "next/server"` boots under tsx/Turbopack but + // throws ERR_MODULE_NOT_FOUND under plain `node`. src/lib/http.ts pulls + // this in. tests/worker-bundle.test.ts resolve-checks every external. + "next/server": "next/server.js", + }, +}; + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + await build(workerBuildOptions); +} diff --git a/scripts/ci-change-scope.mjs b/scripts/ci-change-scope.mjs index 090731171..4128368b0 100644 --- a/scripts/ci-change-scope.mjs +++ b/scripts/ci-change-scope.mjs @@ -134,7 +134,7 @@ const containerPatterns = [ "railway.app.json", "railway.worker.json", "worker/python/requirements.txt", - /^scripts\/(check-node-engine|guard-next-build)\.(?:cjs|mjs)$/, + /^scripts\/(check-node-engine|guard-next-build|build-worker)\.(?:cjs|mjs)$/, ]; const sourcePatterns = ["data", "src", "tests", "scripts", "worker", "playwright", "public", "supabase"]; diff --git a/tests/tsx-server-only-runner.test.ts b/tests/tsx-server-only-runner.test.ts index 3f6d058e1..8f8543c5d 100644 --- a/tests/tsx-server-only-runner.test.ts +++ b/tests/tsx-server-only-runner.test.ts @@ -16,19 +16,28 @@ describe("standalone TSX server-only compatibility", () => { expect(packageJson.scripts["check:supabase-project"]).toContain("scripts/run-tsx.mjs"); }); - it("boots the worker image through the server-only-aware runner, not bare tsx", () => { + it("boots the worker image from the server-only-safe esbuild bundle, not bare tsx", () => { const dockerfile = readFileSync(new URL("../Dockerfile.worker", import.meta.url), "utf8"); const cmdLine = dockerfile.split(/\r?\n/).find((line) => line.trimStart().startsWith("CMD")); expect(cmdLine, "Dockerfile.worker must define a CMD").toBeDefined(); // worker/index.ts imports src/lib/env, which is `import "server-only"`. - // Bare tsx throws on that import at boot; the entrypoint must route through - // scripts/run-tsx.mjs, which registers the server-only stub hook. Assert the - // exact exec-form vector so no bare-tsx variant (["tsx", …], ["npx","tsx", …], - // tsx/dist/cli.mjs, or a reordered command) can slip through. + // Bare tsx throws on that import at boot. The image runs the prebuilt + // esbuild bundle, whose build (scripts/build-worker.mjs) aliases + // `server-only` to the standalone stub — the same guarantee run-tsx.mjs + // gave at runtime. Assert the exact exec-form vector so no bare-tsx + // variant (["tsx", …], ["npx","tsx", …], tsx/dist/cli.mjs, or a reordered + // command) can slip through. const bracket = cmdLine!.indexOf("["); expect(bracket, "worker CMD must use JSON exec form").toBeGreaterThan(-1); const execVector = JSON.parse(cmdLine!.slice(bracket)) as string[]; - expect(execVector).toEqual(["node", "scripts/run-tsx.mjs", "worker/index.ts"]); + expect(execVector).toEqual(["node", "dist/worker/index.mjs"]); + // The bundle is only server-only-safe because the build stubs the marker; + // lock that alias so a build-script edit can't silently drop it. + const buildScript = readFileSync(new URL("../scripts/build-worker.mjs", import.meta.url), "utf8"); + expect(buildScript).toContain('"server-only": "./tests/stubs/server-only.ts"'); + // And the Dockerfile build stage must actually produce the bundle the CMD runs. + expect(dockerfile).toContain("RUN node scripts/build-worker.mjs"); + expect(dockerfile).toContain("npm ci --omit=dev"); }); it("keeps the Next server-only marker while stubbing it only for standalone runners", () => { diff --git a/tests/worker-bundle.test.ts b/tests/worker-bundle.test.ts new file mode 100644 index 000000000..4b7d636e1 --- /dev/null +++ b/tests/worker-bundle.test.ts @@ -0,0 +1,78 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { describe, expect, it } from "vitest"; +import { workerBuildOptions } from "../scripts/build-worker.mjs"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); + +/** + * The worker image runs the esbuild bundle under plain `node` with + * production-only node_modules (Dockerfile.worker). Two failure modes are + * invisible to tsx/vitest and only surface at container boot: + * 1. an external import Node's strict ESM resolver can't resolve (e.g. + * `next/server` — next has no `exports` map, so the explicit + * `next/server.js` file is required); + * 2. an external that only exists because of a devDependency, which + * `npm ci --omit=dev` prunes from the runtime stage. + * This test rebuilds the bundle in-memory and checks every external against + * both, so the class of bug is caught in CI instead of at deploy. + */ +describe("worker production bundle", () => { + it("keeps every external import resolvable under plain node with prod-only deps", async () => { + const result = await build({ + ...workerBuildOptions, + write: false, + metafile: true, + sourcemap: false, + logLevel: "silent", + }); + if (!result.metafile) throw new Error("esbuild did not return a metafile"); + const externals = new Set(); + for (const output of Object.values(result.metafile.outputs)) { + for (const imported of output.imports ?? []) { + if (imported.external) externals.add(imported.path); + } + } + expect(externals.size).toBeGreaterThan(0); + // server-only must be compiled into the bundle as the stub, never left + // external — the production image has no runtime loader hook. + expect([...externals]).not.toContain("server-only"); + + const bareSpecs = [...externals].filter((spec) => !spec.startsWith("node:")).sort(); + + // (2) every external package must survive `npm ci --omit=dev`. + const lock = JSON.parse(readFileSync(new URL("../package-lock.json", import.meta.url), "utf8")) as { + packages: Record; + }; + const devOnly = bareSpecs.filter((spec) => { + const segments = spec.split("/"); + const pkgName = spec.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]; + const entry = lock.packages[`node_modules/${pkgName}`]; + return !entry || entry.dev === true; + }); + expect(devOnly, "externals pruned by --omit=dev (import them from prod deps or bundle them)").toEqual([]); + + // (1) every external must resolve under Node's native ESM resolver (the + // vitest/vite resolver is more lenient, so ask a plain node subprocess). + const probe = [ + "import { existsSync } from 'node:fs';", + "import { fileURLToPath } from 'node:url';", + "const bad = [];", + "for (const spec of JSON.parse(process.env.WORKER_BUNDLE_SPECS)) {", + " try {", + " const url = import.meta.resolve(spec);", + " if (url.startsWith('file:') && !existsSync(fileURLToPath(url))) bad.push(spec);", + " } catch { bad.push(spec); }", + "}", + "process.stdout.write(JSON.stringify(bad));", + ].join("\n"); + const out = execFileSync(process.execPath, ["--input-type=module", "-e", probe], { + cwd: repoRoot, + env: { ...process.env, WORKER_BUNDLE_SPECS: JSON.stringify(bareSpecs) }, + encoding: "utf8", + }); + expect(JSON.parse(out), "externals plain `node` cannot resolve at boot").toEqual([]); + }); +}); diff --git a/worker/index.ts b/worker/index.ts index a68f0bba7..5379b6ae3 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,5 +1,13 @@ -import { loadEnvConfig } from "@next/env"; +// Namespace import + runtime pick (same pattern as scripts/classify-documents.ts): +// @next/env is CJS whose named exports Node's ESM lexer can't detect, so a +// named import crashes the esbuild-bundled worker at container boot. +import * as nextEnv from "@next/env"; +const loadEnvConfig = + nextEnv.loadEnvConfig ?? + (nextEnv as unknown as { default?: { loadEnvConfig?: typeof nextEnv.loadEnvConfig } }).default?.loadEnvConfig; + +if (!loadEnvConfig) throw new Error("Unable to load @next/env loadEnvConfig."); loadEnvConfig(process.cwd()); import { safeErrorLogDetails } from "../src/lib/privacy";