build: ship pre-built API bundle; images copy output instead of installing
- API: `pnpm build` bundles dist/ into apps/api/.output (esbuild), with Linux musl native packages for sharp and argon2 kept alongside - docker/api.Dockerfile copies that output; no pnpm install on the server - add dist/migrate.js (prisma migrate deploy equivalent, same _prisma_migrations table and lock) so the Prisma CLI is not needed at runtime - pnpm-workspace: supportedArchitectures for linux/x64/musl - admin: apps/admin/.env.production sets VITE_SITE_URL for the production build - drop NPM_REGISTRY / SITE_URL build args from compose and .env.example - gitignore: anchor node_modules rules so pre-built node_modules are committed - README: describe the pre-built deploy flow Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 5.5
parent
ee8258556d
commit
10e453e4be
@@ -0,0 +1,112 @@
|
||||
// Bundles the compiled API (dist/, from `nest build`) into a self-contained .output/ that
|
||||
// docker/api.Dockerfile copies as-is, so building the server image installs nothing:
|
||||
// .output/dist/main.js, migrate.js, seed/seed.js all JS dependencies inlined
|
||||
// .output/node_modules/ native packages, Linux (musl, x64) builds only
|
||||
// The tsc output is bundled rather than src/ because esbuild does not emit the decorator
|
||||
// metadata Nest's dependency injection relies on.
|
||||
import { build } from "esbuild";
|
||||
import { cpSync, existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const out = path.join(root, ".output");
|
||||
|
||||
// the docker/api.Dockerfile base image (node:*-alpine)
|
||||
const TARGET = { os: "linux", cpu: "x64", libc: "musl" };
|
||||
// loaded at runtime from .output/node_modules (platform-specific binaries)
|
||||
const NATIVE = ["sharp", "@node-rs/argon2"];
|
||||
// optional packages Nest and pg require inside try/catch; not installed, so left unresolved
|
||||
const OPTIONAL = [
|
||||
"@nestjs/microservices",
|
||||
"@nestjs/microservices/*",
|
||||
"@nestjs/websockets",
|
||||
"@nestjs/websockets/*",
|
||||
"class-transformer",
|
||||
"class-transformer/*",
|
||||
"class-validator",
|
||||
"pg-native",
|
||||
];
|
||||
|
||||
rmSync(out, { recursive: true, force: true });
|
||||
|
||||
await build({
|
||||
absWorkingDir: root,
|
||||
entryPoints: {
|
||||
main: "dist/main.js",
|
||||
migrate: "dist/migrate.js",
|
||||
"seed/seed": "dist/seed/seed.js",
|
||||
},
|
||||
outdir: path.join(out, "dist"),
|
||||
bundle: true,
|
||||
splitting: true,
|
||||
chunkNames: "chunks/[name]-[hash]",
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node24",
|
||||
external: [...NATIVE, ...OPTIONAL],
|
||||
// Nest uses class names (logs, throttler keys)
|
||||
keepNames: true,
|
||||
// CommonJS dependencies call require() for Node built-ins (aliased: bundled code imports createRequire too)
|
||||
banner: {
|
||||
js: 'import { createRequire as __bundleCreateRequire } from "node:module"; const require = __bundleCreateRequire(import.meta.url);',
|
||||
},
|
||||
legalComments: "none",
|
||||
logLevel: "warning",
|
||||
});
|
||||
writeFileSync(
|
||||
path.join(out, "package.json"),
|
||||
JSON.stringify({ private: true, type: "module" }, null, 2) + "\n",
|
||||
);
|
||||
|
||||
/** Node's lookup of `name` from `fromDir`; follows pnpm's symlinks to the real package folder */
|
||||
function findPackage(name, fromDir) {
|
||||
for (let dir = fromDir; ; dir = path.dirname(dir)) {
|
||||
const candidate = path.join(dir, "node_modules", name);
|
||||
if (existsSync(path.join(candidate, "package.json"))) return realpathSync(candidate);
|
||||
if (path.dirname(dir) === dir) return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** package.json os/cpu/libc fields, e.g. ["linux"] or ["!win32"] */
|
||||
const allows = (list, value) =>
|
||||
!list ||
|
||||
((list.includes(value) || list.every((v) => v.startsWith("!"))) && !list.includes(`!${value}`));
|
||||
|
||||
const copied = new Map();
|
||||
function copyPackage(name, fromDir) {
|
||||
const dir = findPackage(name, fromDir);
|
||||
if (!dir) return; // optional dependency for another platform, not installed
|
||||
const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
|
||||
if (!allows(pkg.os, TARGET.os) || !allows(pkg.cpu, TARGET.cpu) || !allows(pkg.libc, TARGET.libc))
|
||||
return;
|
||||
if (copied.has(name)) {
|
||||
if (copied.get(name) !== pkg.version)
|
||||
throw new Error(`${name}: needs both ${copied.get(name)} and ${pkg.version}`);
|
||||
return;
|
||||
}
|
||||
copied.set(name, pkg.version);
|
||||
cpSync(dir, path.join(out, "node_modules", name), { recursive: true, dereference: true });
|
||||
for (const dep of Object.keys({ ...pkg.dependencies, ...pkg.optionalDependencies }))
|
||||
copyPackage(dep, dir);
|
||||
}
|
||||
|
||||
for (const name of NATIVE) copyPackage(name, root);
|
||||
for (const name of NATIVE) {
|
||||
if (!copied.has(name)) throw new Error(`${name} is not installed`);
|
||||
}
|
||||
// sharp and argon2 load their binary from a per-platform package; fail here, not in production
|
||||
for (const name of [
|
||||
"@img/sharp-linuxmusl-x64",
|
||||
"@img/sharp-libvips-linuxmusl-x64",
|
||||
"@node-rs/argon2-linux-x64-musl",
|
||||
]) {
|
||||
if (!copied.has(name)) {
|
||||
throw new Error(
|
||||
`${name} is missing; run \`pnpm install\` (pnpm-workspace.yaml supportedArchitectures adds it)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`bundled into .output (native: ${[...copied].map(([n, v]) => `${n}@${v}`).join(", ")})`,
|
||||
);
|
||||
Reference in New Issue
Block a user