diff --git a/Dockerfile.dns b/Dockerfile.dns index 8ac34b78b..9e78fdf79 100644 --- a/Dockerfile.dns +++ b/Dockerfile.dns @@ -16,11 +16,19 @@ FROM ${NODE_IMAGE} AS builder WORKDIR /app -RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* +COPY package.json ./ -COPY package*.json .npmrc ./ - -RUN npm ci +# esbuild.mjs marks every npm package external, so bundling needs only the +# bundler's own tooling (at the versions package.json pins), not the full +# dependency tree. Installed outside /app so the root package.json's +# dependencies aren't pulled in with it. +RUN mkdir /tools && cd /tools && echo '{"private": true}' > package.json && \ + npm install --no-audit --no-fund --no-package-lock $(node -p ' \ + const p = require("/app/package.json"); \ + const all = { ...p.dependencies, ...p.devDependencies }; \ + ["esbuild", "esbuild-node-externals", "yargs"] \ + .map((n) => `${n}@${all[n]}`).join(" ")') && \ + ln -s /tools/node_modules /app/node_modules COPY . . @@ -28,15 +36,24 @@ COPY . . ARG BUILD=enterprise ARG DATABASE=pg +# Bundle, then write a package.json listing only the packages the bundle +# imports at runtime RUN npm run set:$DATABASE && \ npm run set:$BUILD && \ mkdir -p dist && \ - node esbuild.mjs -e server/private/dnsServer.ts -o dist/dns.mjs && \ - test -f dist/dns.mjs + node esbuild.mjs -e server/private/dnsServer.ts -o dist/dns.mjs -m dist/dns.meta.json && \ + node scripts/bundle-package-json.mjs dist/dns.meta.json /out/package.json -# Copy only the node_modules files the bundle can load -RUN npm prune --omit=dev && \ - node trace-deps.mjs -o /out dist/dns.mjs +FROM ${NODE_IMAGE} AS deps + +WORKDIR /app + +# Installing the trimmed package.json against the root lockfile keeps every +# transitive version pinned to what the main Pangolin image ships +COPY --from=builder /out/package.json ./package.json +COPY package-lock.json .npmrc ./ + +RUN npm install --omit=dev --no-audit --no-fund FROM ${NODE_IMAGE} AS runner @@ -44,9 +61,9 @@ WORKDIR /app RUN apt-get update && apt-get install -y tzdata && rm -rf /var/lib/apt/lists/* -COPY --from=builder /out ./ +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/package.json ./package.json COPY --from=builder /app/dist/dns.mjs ./dist/dns.mjs -COPY --from=builder /app/package.json ./package.json ENV ENVIRONMENT=prod ENV NODE_ENV=production diff --git a/esbuild.mjs b/esbuild.mjs index 03697d4fd..098bca9e5 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -45,6 +45,11 @@ const argv = yargs(hideBin(process.argv)) choices: ["oss", "saas", "enterprise"], default: build }) + .option("metafile", { + alias: "m", + describe: "Write the esbuild metafile (inputs/imports) to this path", + type: "string" + }) .help() .alias("help", "h").argv; @@ -281,6 +286,7 @@ esbuild }) ], sourcemap: "inline", + metafile: Boolean(argv.metafile), target: "node24" }) .then((result) => { @@ -306,6 +312,10 @@ esbuild process.exit(1); } + if (argv.metafile) { + fs.writeFileSync(argv.metafile, JSON.stringify(result.metafile)); + } + console.log("Build completed successfully"); }) .catch((error) => { diff --git a/scripts/bundle-package-json.mjs b/scripts/bundle-package-json.mjs new file mode 100644 index 000000000..5117fd18a --- /dev/null +++ b/scripts/bundle-package-json.mjs @@ -0,0 +1,72 @@ +// Writes a package.json that depends only on the npm packages an esbuild +// bundle imports at runtime, so a standalone image (e.g. Dockerfile.dns) can +// `npm install` just those instead of the full dependency tree. Versions and +// overrides come from the root package.json, and running `npm install` next +// to the root package-lock.json keeps transitive versions pinned to the lock. +// +// Usage: node scripts/bundle-package-json.mjs + +import { isBuiltin } from "node:module"; +import fs from "node:fs"; +import path from "node:path"; + +const [metafilePath, outPath] = process.argv.slice(2); +if (!metafilePath || !outPath) { + console.error( + "Usage: node scripts/bundle-package-json.mjs " + ); + process.exit(1); +} + +const metafile = JSON.parse(fs.readFileSync(metafilePath, "utf8")); +const rootPkg = JSON.parse(fs.readFileSync("package.json", "utf8")); + +// "@scope/name/sub/path" -> "@scope/name", "name/sub/path" -> "name" +function packageName(specifier) { + const parts = specifier.split("/"); + return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; +} + +const used = new Set(); +for (const output of Object.values(metafile.outputs)) { + for (const imp of output.imports) { + if (!imp.external || isBuiltin(imp.path)) continue; + used.add(packageName(imp.path)); + } +} + +const dependencies = {}; +const missing = []; +for (const name of [...used].sort()) { + const version = rootPkg.dependencies?.[name]; + if (version) { + dependencies[name] = version; + } else { + missing.push(name); + } +} + +// A runtime import of something that is only a devDependency (or not +// declared at all) would be pruned from the image and fail at startup +if (missing.length > 0) { + console.error( + `Bundle imports packages not in package.json dependencies: ${missing.join(", ")}` + ); + process.exit(1); +} + +const pkg = { + name: rootPkg.name, + version: rootPkg.version, + private: true, + type: rootPkg.type, + dependencies, + ...(rootPkg.overrides && { overrides: rootPkg.overrides }) +}; + +fs.mkdirSync(path.dirname(outPath), { recursive: true }); +fs.writeFileSync(outPath, JSON.stringify(pkg, null, 4) + "\n"); + +console.log( + `Wrote ${outPath} with ${Object.keys(dependencies).length} dependencies: ${Object.keys(dependencies).join(", ")}` +);