Only install the packages we need

This commit is contained in:
Owen
2026-10-28 15:58:12 -04:00
parent 8ce036b413
commit eea1159f40
3 changed files with 110 additions and 11 deletions
+28 -11
View File
@@ -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
+10
View File
@@ -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) => {
+72
View File
@@ -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 <metafile.json> <out/package.json>
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 <metafile.json> <out/package.json>"
);
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(", ")}`
);