fix(docker): raise build-stage Node heap to stop production-build OOM (#4076) (#4104)

The Docker builder stage ran `npm run build` with V8's default heap ceiling
(~2 GB). After #4052 forced the heavier webpack engine (Turbopack panics on this
Next.js version), the production optimization pass exceeded that ceiling and the
build died with "FATAL ERROR: ... JavaScript heap out of memory" at
[builder] npm run build.

The builder stage now sets NODE_OPTIONS=--max-old-space-size (default 4096 MB,
overridable via --build-arg OMNIROUTE_BUILD_MEMORY_MB) before the build; the
value propagates to the spawned next build (resolveNextBuildEnv spreads
process.env). Build-only — the runtime heap on the runner stage is unchanged,
and CI/local builds (which invoke npm run build directly) are unaffected.

Regression guard: tests/unit/dockerfile-build-heap-4076.test.ts asserts the
builder stage sets the heap ceiling, before npm run build, at >= 4096 MB.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-17 15:42:55 -03:00
committed by GitHub
parent 4941a0d462
commit 42cf06e4af
3 changed files with 86 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ _In development — bullets added per PR; finalized at release._
- **fix(ws): start the LiveWS sidecar with `cwd` at the package root (global/systemd installs)** — the standalone LiveWS launcher (`scripts/start-ws-server.mjs`) re-spawns itself with `node --import tsx <self>` but did not set `cwd`. When the WebSocket sidecar was launched from outside the package directory — a global npm/homebrew install, or a `systemd`/`launchd` unit started from `$HOME` — Node could not resolve the `tsx` package (`ERR_MODULE_NOT_FOUND: Cannot find package 'tsx'`), and even from the package directory `tsx` could not resolve the tsconfig `@/*` path aliases (e.g. `@/types/databaseSettings`), so the sidecar never booted. The spawn now pins `cwd` to the package root (the directory above `scripts/`, where `package.json` + `tsconfig.json` live), which resolves both `tsx` discovery and the `@/*` aliases regardless of launch directory. ([#4055](https://github.com/diegosouzapw/OmniRoute/issues/4055) — thanks @Rahulsharma0810)
- **fix(dashboard): Logs page auto-refresh now works in embedded/proxied dashboards** — the Request Logger gated each auto-refresh tick on a static `document.visibilityState === "visible"` read. Hosts that report a permanent non-`"visible"` state without ever firing a `visibilitychange` event (Docker dashboard wrappers, embedded webviews) froze auto-refresh entirely — only the manual Refresh button worked, a regression from 3.8.24's unconditional polling. The pause is now event-driven and fail-open: polling starts enabled and only pauses after a real `visibilitychange` → hidden transition (still preserving the backgrounded-tab optimization for normal browser tabs). ([#4054](https://github.com/diegosouzapw/OmniRoute/issues/4054) — thanks @tjengbudi)
- **fix(docker): raise the build-stage Node heap to stop the production-build OOM** — the Docker `builder` stage ran `npm run build` with V8's default heap ceiling (~2 GB). After #4052 forced the heavier webpack engine (Turbopack panics on this Next.js version), the production optimization pass exceeded that ceiling and the build died with `FATAL ERROR: … JavaScript heap out of memory` at `[builder] npm run build`. The builder stage now sets `NODE_OPTIONS=--max-old-space-size` (default 4096 MB, overridable via `--build-arg OMNIROUTE_BUILD_MEMORY_MB=…`) before the build; the value propagates to the spawned `next build`. Build-only — the runtime heap (`OMNIROUTE_MEMORY_MB` on the runner stage) is unchanged. ([#4076](https://github.com/diegosouzapw/OmniRoute/issues/4076) — thanks @kamenkadmitry)
---

View File

@@ -46,6 +46,17 @@ RUN --mount=type=cache,target=/root/.npm \
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
ENV OMNIROUTE_USE_TURBOPACK=0
# Raise the V8 heap ceiling for the build. The webpack production optimization
# pass (forced above since Turbopack panics) needs more than V8's default ceiling
# (~2 GB) for a codebase this size; a memory-constrained Docker build otherwise
# dies with "FATAL ERROR: ... JavaScript heap out of memory" at `[builder] npm run
# build` (#4076). NODE_OPTIONS propagates to the spawned `next build` child
# (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env). Build-only;
# the runtime heap is set separately on the runner stage (OMNIROUTE_MEMORY_MB).
# Override for hosts with more/less RAM: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
ARG OMNIROUTE_BUILD_MEMORY_MB=4096
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
COPY . ./
RUN --mount=type=cache,target=/app/.build/next/cache \
mkdir -p /app/data && npm run build

View File

@@ -0,0 +1,74 @@
/**
* #4076 — Docker build fails with "JavaScript heap out of memory" during the
* `[builder] npm run build` step. The webpack production optimization pass (forced
* since #4052 replaced the panicking Turbopack engine) needs more heap than V8's
* default ceiling, which a memory-constrained Docker build does not provide.
*
* Fix: the `builder` stage must raise the heap ceiling via NODE_OPTIONS
* (`--max-old-space-size`) BEFORE running `npm run build`, so the value propagates
* to the spawned `next build` child (build-next-isolated.mjs → resolveNextBuildEnv
* spreads process.env). This is a Docker-only change — CI/local builds invoke
* `npm run build` directly and are unaffected.
*
* This guards the mechanism (the heap setting is present and ordered correctly);
* the end-to-end "the OOM is gone" proof is a successful `docker build`.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf-8");
const lines = dockerfile.split("\n");
/** Line indices that bound the `builder` stage (from its FROM to the next FROM). */
function builderStageRange(): { start: number; end: number } {
const start = lines.findIndex((l) => /^FROM\s+\S+\s+AS\s+builder\b/i.test(l.trim()));
assert.ok(start >= 0, "Dockerfile must declare a `builder` stage");
const after = lines.slice(start + 1).findIndex((l) => /^FROM\s+/i.test(l.trim()));
const end = after === -1 ? lines.length : start + 1 + after;
return { start, end };
}
test("#4076 builder stage raises the Node heap ceiling via NODE_OPTIONS", () => {
const { start, end } = builderStageRange();
const stage = lines.slice(start, end);
const heapLineIdx = stage.findIndex(
(l) => /NODE_OPTIONS/.test(l) && /--max-old-space-size/.test(l)
);
assert.ok(
heapLineIdx >= 0,
"builder stage must set NODE_OPTIONS with --max-old-space-size to avoid the #4076 build OOM"
);
});
test("#4076 the heap ceiling is set BEFORE `npm run build` so it reaches `next build`", () => {
const { start, end } = builderStageRange();
const stage = lines.slice(start, end);
const heapLineIdx = stage.findIndex(
(l) => /NODE_OPTIONS/.test(l) && /--max-old-space-size/.test(l)
);
const buildLineIdx = stage.findIndex((l) => /npm run build\b/.test(l));
assert.ok(buildLineIdx >= 0, "builder stage must run `npm run build`");
assert.ok(heapLineIdx >= 0, "builder stage must set the heap ceiling");
assert.ok(
heapLineIdx < buildLineIdx,
"NODE_OPTIONS heap ceiling must be set before the `npm run build` step"
);
});
test("#4076 the build heap default is at least 4096 MB (the V8 default ~2 GB OOMed)", () => {
const { start, end } = builderStageRange();
const stage = lines.slice(start, end).join("\n");
// Match the literal default in either `--max-old-space-size=N` or an ARG default
// referenced by the ENV (e.g. ARG OMNIROUTE_BUILD_MEMORY_MB=4096).
const direct = stage.match(/--max-old-space-size=(\d+)/);
const argDefault = stage.match(/ARG\s+\w*MEMORY\w*\s*=\s*(\d+)/i);
const value = Number(direct?.[1] ?? argDefault?.[1] ?? 0);
assert.ok(
value >= 4096,
`build heap default must be >= 4096 MB to clear the #4076 OOM (found ${value})`
);
});