Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ dev-server.log
Dockerfile
Dockerfile.worker
.dockerignore
dist
1 change: 1 addition & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
40 changes: 28 additions & 12 deletions Dockerfile.worker
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -14,12 +15,27 @@
# Run: docker run --env-file <secrets> 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 \
Expand All @@ -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"]
3 changes: 2 additions & 1 deletion docs/deployment-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 16 additions & 8 deletions docs/worker-deploy-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions scripts/build-worker.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 1 addition & 1 deletion scripts/ci-change-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
21 changes: 15 additions & 6 deletions tests/tsx-server-only-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
78 changes: 78 additions & 0 deletions tests/worker-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string, { dev?: boolean }>;
};
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([]);
});
});
10 changes: 9 additions & 1 deletion worker/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down