Files
S. EsmaeiliandClaude Haiku 5.5 10e453e4be 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>
2026-10-24 14:41:31 +03:30

113 lines
4.2 KiB
JavaScript

// 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(", ")})`,
);