diff --git a/Dockerfile b/Dockerfile index 0bf3a59fe..327481e05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,13 +24,19 @@ RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \ npm run build:cli && \ test -f dist/server.mjs -# Create placeholder files for MaxMind databases to avoid COPY errors -# Real files should be present for saas builds, placeholders for oss builds -RUN touch /app/GeoLite2-Country.mmdb /app/GeoLite2-ASN.mmdb +# Instead of shipping the full production node_modules (most of which is UI-only +# code already compiled into .next), copy only the files the server bundles can +# load, merged with the node_modules Next.js traced for its standalone server. +# Pruning first keeps dev dependencies out of the trace. +RUN npm prune --omit=dev && \ + node trace-deps.mjs -o .next/standalone dist/server.mjs dist/migrations.mjs dist/cli.mjs -FROM base AS builder - -RUN npm ci --omit=dev +# MaxMind databases are only shipped in saas builds +RUN mkdir -p maxmind && \ + if [ "$BUILD" = "saas" ]; then \ + touch GeoLite2-Country.mmdb GeoLite2-ASN.mmdb && \ + mv GeoLite2-Country.mmdb GeoLite2-ASN.mmdb maxmind/; \ + fi # FROM node:24.18.1-slim AS runner FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner @@ -39,9 +45,6 @@ WORKDIR /app RUN apt-get update && apt-get install -y curl tzdata && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/package.json ./package.json - COPY --from=builder-dev /app/.next/standalone ./ COPY --from=builder-dev /app/.next/static ./.next/static COPY --from=builder-dev /app/dist ./dist @@ -55,17 +58,12 @@ COPY server/db/ios_models.json ./dist/ios_models.json COPY server/db/mac_models.json ./dist/mac_models.json COPY public ./public -# Copy MaxMind databases for SaaS builds +# Copy MaxMind databases (empty directory for non-saas builds) ARG BUILD=oss -RUN mkdir -p ./maxmind +COPY --from=builder-dev /app/maxmind ./maxmind -# Copy MaxMind databases (placeholders exist for oss builds, real files for saas) -COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb -COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.mmdb - -# Remove MaxMind databases for non-saas builds (keep only for saas) -RUN if [ "$BUILD" != "saas" ]; then rm -rf ./maxmind; fi +RUN if [ "$BUILD" != "saas" ]; then rmdir ./maxmind; fi # OCI Image Labels - Build Args for dynamic values ARG VERSION="dev" diff --git a/server/nextServer.ts b/server/nextServer.ts index 18b14a397..0a54c1e2a 100644 --- a/server/nextServer.ts +++ b/server/nextServer.ts @@ -1,5 +1,7 @@ import next from "next"; import express from "express"; +import fs from "fs"; +import path from "path"; import { parse } from "url"; import logger from "@server/logger"; import config from "@server/lib/config"; @@ -8,9 +10,28 @@ import { stripDuplicateSesions } from "./middlewares/stripDuplicateSessions"; const nextPort = config.getRawConfig().server.next_port; export async function createNextServer() { + const dev = process.env.ENVIRONMENT !== "prod"; + + // The image only ships the files Next traced for its standalone output, + // which leaves out what is needed to load next.config at runtime. Hand + // Next the config resolved at build time instead, the same way the + // generated .next/standalone/server.js does. + const requiredServerFilesPath = path.join( + process.cwd(), + ".next", + "required-server-files.json" + ); + if (!dev && fs.existsSync(requiredServerFilesPath)) { + const { config: nextConfig } = JSON.parse( + fs.readFileSync(requiredServerFilesPath, "utf8") + ); + process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = + JSON.stringify(nextConfig); + } + // const app = next({ dev }); const app = next({ - dev: process.env.ENVIRONMENT !== "prod", + dev, turbopack: false }); const handle = app.getRequestHandler(); diff --git a/trace-deps.mjs b/trace-deps.mjs new file mode 100644 index 000000000..db3e7110e --- /dev/null +++ b/trace-deps.mjs @@ -0,0 +1,75 @@ +// Copies only the node_modules files that the bundled server entry points can +// actually load into an output directory, so the Docker image does not need +// the full production node_modules tree (most of which is UI-only code that +// Next.js has already compiled into .next). +// +// Usage: node trace-deps.mjs -o [entry.mjs...] +// +// Uses the copy of @vercel/nft that ships with Next.js (the same tracer Next +// uses for its standalone output), so it works after `npm prune --omit=dev`. + +import nft from "next/dist/compiled/@vercel/nft/index.js"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import path from "path"; +import fs from "fs"; + +const argv = yargs(hideBin(process.argv)) + .usage("Usage: $0 -o [string] ") + .option("out", { + alias: "o", + describe: "Directory to copy the traced node_modules files into", + type: "string", + demandOption: true + }) + .demandCommand(1) + .help() + .alias("help", "h").argv; + +const { nodeFileTrace } = nft; +const base = process.cwd(); +const outDir = path.resolve(argv.out); + +const { fileList, warnings } = await nodeFileTrace( + argv._.map((entry) => path.resolve(String(entry))), + { + base, + // Next.js is traced by `next build` for the standalone output (which + // includes next/dist/server/next, the entry createNextServer uses). + // Tracing it from here would pull in its dev server and bundlers. + ignore: (file) => file.startsWith("node_modules/next/") + } +); + +// Unresolvable optional requires are expected (native binaries for other +// platforms, pg-native, bufferutil, ...), so only list them on request +if (process.env.TRACE_VERBOSE) { + for (const warning of warnings) { + console.warn(`trace warning: ${warning.message}`); + } +} else if (warnings.size > 0) { + console.log( + `${warnings.size} trace warnings (set TRACE_VERBOSE=1 to list them)` + ); +} + +let count = 0; +for (const file of fileList) { + if (!file.startsWith("node_modules/")) continue; + + const src = path.join(base, file); + const dest = path.join(outDir, file); + const stat = fs.lstatSync(src); + if (stat.isDirectory() || fs.existsSync(dest)) continue; + + fs.mkdirSync(path.dirname(dest), { recursive: true }); + if (stat.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(src), dest); + } else { + fs.copyFileSync(src, dest); + fs.chmodSync(dest, stat.mode); + } + count++; +} + +console.log(`Traced ${count} node_modules files into ${outDir}`);