diff --git a/.env.example b/.env.example index 3a34638331..18f1559346 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,8 @@ PORT=20128 # endpoint display (useDisplayBaseUrl) shows https://host/omniroute/v1 instead of # https://host/v1. Rebuild after changing this value (Next basePath is build-time). # Default: "" (served at the domain root). Example: /omniroute to serve under https://host/omniroute +# Docker: baked at image build time via build-arg; root-path images can also apply this at +# container start (see docs/guides/DOCKER_GUIDE.md). # OMNIROUTE_BASE_PATH= # # Browser-visible mirror of OMNIROUTE_BASE_PATH, inlined at build time so the diff --git a/Dockerfile b/Dockerfile index 48c00b2ccd..1924fcef5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,6 +95,11 @@ RUN --mount=type=cache,id=npm-cache,target=/root/.npm \ # See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6. ENV OMNIROUTE_USE_TURBOPACK=1 +# Next.js basePath is fixed at build time; pass OMNIROUTE_BASE_PATH here when the +# image should serve under a reverse-proxy subpath without a runtime patch. +ARG OMNIROUTE_BASE_PATH="" +ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH + # Docker containers cannot run the MITM/Agent-Bridge stack (no host DNS/cert # access), so keep @/mitm/manager on the graceful stub (#3390). This flag is # Docker-only: npm/Electron/VPS builds must bundle the REAL manager (#6344). diff --git a/changelog.d/fixes/8615-docker-basepath-bundle-patch.md b/changelog.d/fixes/8615-docker-basepath-bundle-patch.md new file mode 100644 index 0000000000..c3b57cba74 --- /dev/null +++ b/changelog.d/fixes/8615-docker-basepath-bundle-patch.md @@ -0,0 +1 @@ +- **fix(docker):** Honor `OMNIROUTE_BASE_PATH` behind reverse-proxy subpaths by baking the subpath into the standalone bundle at image build time (`ARG OMNIROUTE_BASE_PATH`) and patching the published root-path image at container start (`scripts/docker/ensure-docker-base-path.mjs`); healthcheck probes the prefixed `/api/monitoring/health` route ([#8615](https://github.com/diegosouzapw/OmniRoute/pull/8615)) — thanks @DinonowDev diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3c995fa15e..f65c2b1335 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -49,6 +49,8 @@ services: build: context: . target: runner-cli + args: + OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-} image: omniroute:prod restart: unless-stopped stop_grace_period: 40s @@ -64,6 +66,7 @@ services: - API_HOST=${API_HOST:-0.0.0.0} - HOSTNAME=0.0.0.0 - DATA_DIR=/app/data + - OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-} ports: - "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}" - "${PROD_API_PORT:-20131}:${API_PORT:-20129}" diff --git a/docker-compose.yml b/docker-compose.yml index 9b3add8ee3..b20b067788 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ x-common: &common env_file: .env environment: - DATA_DIR=/app/data # Must match the volume mount below + - OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-} - PORT=${PORT:-20128} - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} @@ -74,6 +75,8 @@ services: build: context: . target: runner-base + args: + OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-} image: omniroute:base ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" @@ -92,6 +95,8 @@ services: build: context: . target: runner-web + args: + OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-} image: omniroute:web ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" @@ -107,6 +112,8 @@ services: build: context: . target: runner-cli + args: + OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-} image: omniroute:cli ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" @@ -127,6 +134,8 @@ services: build: context: . target: runner-base + args: + OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-} image: omniroute:base ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 4e7fb6875a..242c215ae6 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -173,9 +173,60 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md), | `AUTO_UPDATE_HOST_REPO_DIR` | Host path mounted into `cli` profile at `/workspace/omniroute` for self-update workflows | `.` (current directory) | | `OMNIROUTE_MEMORY_MB` | Runtime Node heap ceiling for the Docker standalone server; overrides the image fallback above | `512` | | `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` | +| `OMNIROUTE_BASE_PATH` | URL subpath when the app is published behind a reverse proxy (e.g. `/omniroute`) | _(empty = root)_ | +| `NEXT_PUBLIC_BASE_URL` | Public browser origin including the subpath (e.g. `https://host/omniroute`) | unset | | `PROD_DASHBOARD_PORT` | Host-side dashboard port for `docker-compose.prod.yml` | `20130` | | `CLIPROXYAPI_PORT` | Host-side port for the `cliproxyapi` sidecar | `8317` | +## Reverse Proxy on a Subpath (Traefik / nginx) + +Next.js `basePath` is compiled into the standalone bundle. OmniRoute records the baked +value in a sentinel file at the app root (written during `npm run build`; read by +`scripts/docker/ensure-docker-base-path.mjs`) and compares it with +`OMNIROUTE_BASE_PATH` when the container starts. When they differ and the image was +built for the domain root, the entrypoint rewrites the standalone manifests and embedded +`basePath` literals before `node dev/run-standalone.mjs` runs. + +### Compose build (recommended) + +Set both variables in `.env`, then rebuild so the image and runtime agree: + +```bash +# .env +OMNIROUTE_BASE_PATH=/omniroute +NEXT_PUBLIC_BASE_URL=https://myhostname.example.com/omniroute +``` + +```bash +docker compose --profile base up -d --build +``` + +`docker-compose.yml` forwards `OMNIROUTE_BASE_PATH` as a Docker build-arg and as a +runtime environment variable. + +### Pre-built root image + runtime subpath + +Published `diegosouzapw/omniroute:*` images are built for the domain root. You can still +set `OMNIROUTE_BASE_PATH` at runtime; the container patches the bundle once on startup. +Pair it with the matching public origin: + +```yaml +services: + omniroute: + image: diegosouzapw/omniroute:latest + environment: + OMNIROUTE_BASE_PATH: /omniroute + NEXT_PUBLIC_BASE_URL: https://myhostname.example.com/omniroute +``` + +Configure the reverse proxy to forward the **full** external path (do not strip the +prefix). Traefik should route `PathPrefix(`/omniroute`)` to the container without +`StripPrefix`, so Next.js receives `/omniroute/...` and serves assets from +`/omniroute/_next/...`. + +The Docker healthcheck probes `/api/monitoring/health` prefixed with the active +`OMNIROUTE_BASE_PATH`. + ## Docker Compose with Caddy (HTTPS Auto-TLS) OmniRoute can be securely exposed using Caddy's automatic SSL provisioning. Ensure your domain's DNS A record points to your server's IP. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b56757460b..f044cfca46 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -120,7 +120,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | Variable | Default | Source File | Description | | ------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | -| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs` | URL subpath for serving OmniRoute behind a reverse proxy under a subpath (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. | +| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs`, `scripts/docker/ensure-docker-base-path.mjs` | URL subpath for serving OmniRoute behind a reverse proxy (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. In Docker the value is baked during `docker build` (`ARG OMNIROUTE_BASE_PATH`); pre-built root images can apply a different runtime value once at container start before Next.js boots. Set `NEXT_PUBLIC_BASE_URL` to the public origin including the same subpath. | | `NEXT_PUBLIC_OMNIROUTE_BASE_PATH` | _(empty = root)_ | `src/shared/hooks/useDisplayBaseUrl.ts` | Browser-visible mirror of `OMNIROUTE_BASE_PATH`, inlined at build time so the dashboard endpoint display shows `https://host/omniroute/v1` instead of `https://host/v1`. Falls back to `OMNIROUTE_BASE_PATH` when unset. Rebuild after changing (Next `basePath` is build-time). | | `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | | `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | diff --git a/next.config.mjs b/next.config.mjs index 8374b514c9..3ae4091413 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -3,6 +3,7 @@ import { createMDX } from "fumadocs-mdx/next"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { mitmManagerAliasFor } from "./scripts/build/mitm-stub-flag.mjs"; +import { normalizeBasePath } from "./scripts/build/normalizeBasePath.mjs"; const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); const distDir = process.env.NEXT_DIST_DIR || ".build/next"; @@ -101,12 +102,12 @@ const nextConfig = { // before route matching, so authz classification (classifyRoute/isLocalOnlyPath) // keeps operating on un-prefixed paths — see src/server/authz/pipeline.ts for // the two redirect call sites that re-add it via `request.nextUrl.basePath`. - basePath: process.env.OMNIROUTE_BASE_PATH || "", + basePath: normalizeBasePath(process.env.OMNIROUTE_BASE_PATH), // Mirror OMNIROUTE_BASE_PATH into a NEXT_PUBLIC_* so client display helpers // (useDisplayBaseUrl) can append the subpath to window.location.origin when // building curl/endpoint examples. Empty by default (root deploys unchanged). env: { - NEXT_PUBLIC_OMNIROUTE_BASE_PATH: process.env.OMNIROUTE_BASE_PATH || "", + NEXT_PUBLIC_OMNIROUTE_BASE_PATH: normalizeBasePath(process.env.OMNIROUTE_BASE_PATH), }, distDir, // Turbopack config: redirect native modules to stubs at build time diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index 0a083d81d4..f61c7041ca 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -177,6 +177,21 @@ const EXTRA_MODULE_ENTRIES = [ src: ["scripts", "build", "bootstrap-env.mjs"], dest: ["build", "bootstrap-env.mjs"], }, + { + label: "normalizeBasePath helper", + src: ["scripts", "build", "normalizeBasePath.mjs"], + dest: ["build", "normalizeBasePath.mjs"], + }, + { + label: "docker basePath entrypoint", + src: ["scripts", "docker", "ensure-docker-base-path.mjs"], + dest: ["docker", "ensure-docker-base-path.mjs"], + }, + { + label: "docker basePath patcher", + src: ["scripts", "docker", "patch-standalone-base-path.mjs"], + dest: ["docker", "patch-standalone-base-path.mjs"], + }, { label: "healthcheck script", src: ["scripts", "dev", "healthcheck.mjs"], diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs index f3e6c406ff..8d2e0da139 100644 --- a/scripts/build/build-next-isolated.mjs +++ b/scripts/build/build-next-isolated.mjs @@ -329,6 +329,17 @@ export async function main() { projectRoot, copyNatives: true, }); + const { spawnSync } = await import("node:child_process"); + const basePathWrite = spawnSync( + process.execPath, + [path.join(projectRoot, "scripts", "build", "write-build-base-path.mjs")], + { cwd: projectRoot, env: process.env, stdio: "inherit" } + ); + if (basePathWrite.status !== 0) { + console.warn( + "[build-next-isolated] Non-fatal error writing BUILD_OMNIROUTE_BASE_PATH sentinel" + ); + } } catch (assembleErr) { console.warn("[build-next-isolated] Non-fatal error assembling standalone:", assembleErr); } diff --git a/scripts/build/normalizeBasePath.mjs b/scripts/build/normalizeBasePath.mjs new file mode 100644 index 0000000000..321c4eef0a --- /dev/null +++ b/scripts/build/normalizeBasePath.mjs @@ -0,0 +1,31 @@ +/** + * Normalize OMNIROUTE_BASE_PATH for Next.js `basePath` and Docker sentinels. + * + * Rules mirror src/shared/services/modelSyncScheduler.ts::normalizeInternalBasePath + * so runtime self-fetches and the compiled bundle agree on the subpath shape. + * + * @param {string | undefined | null} value + * @returns {string} "" for root, otherwise "/segment" without trailing slash + */ +export function normalizeBasePath(value) { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed || trimmed === "/") return ""; + if (!trimmed.startsWith("/") || /[?#\\]/.test(trimmed)) return ""; + + const segments = trimmed.split("/").filter(Boolean); + if (segments.some((segment) => segment === "." || segment === "..")) return ""; + return `/${segments.join("/")}`; +} + +/** + * Prefix an application path with a normalized base path. + * + * @param {string} basePath normalized via normalizeBasePath + * @param {string} pathname must start with "/" + */ +export function joinBasePath(basePath, pathname) { + const pathPart = pathname.startsWith("/") ? pathname : `/${pathname}`; + if (!basePath) return pathPart; + if (pathPart === "/") return `${basePath}/`; + return `${basePath}${pathPart}`; +} diff --git a/scripts/build/write-build-base-path.mjs b/scripts/build/write-build-base-path.mjs new file mode 100644 index 0000000000..03e50a8bda --- /dev/null +++ b/scripts/build/write-build-base-path.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +/** + * Writes BUILD_OMNIROUTE_BASE_PATH into the standalone bundle so container start + * can compare the baked Next.js basePath against OMNIROUTE_BASE_PATH. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { normalizeBasePath } from "./normalizeBasePath.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, "..", ".."); +const NEXT_DIST = process.env.NEXT_DIST_DIR || ".build/next"; + +function writeSentinel(targetDir) { + const basePath = normalizeBasePath(process.env.OMNIROUTE_BASE_PATH); + const sentinel = path.join(targetDir, "BUILD_OMNIROUTE_BASE_PATH"); + fs.writeFileSync(sentinel, `${basePath}\n`); + return { basePath, sentinel }; +} + +const standaloneDir = path.join(ROOT, NEXT_DIST, "standalone"); +if (!fs.existsSync(standaloneDir)) { + console.error( + `[write-build-base-path] FATAL: standalone dir not found: ${standaloneDir}\n` + + " Run `npm run build` first." + ); + process.exit(1); +} + +const { basePath, sentinel } = writeSentinel(standaloneDir); +console.log( + `[write-build-base-path] Recorded basePath ${basePath || "(root)"} -> ${path.relative(ROOT, sentinel)}` +); + +const distDir = path.join(ROOT, "dist"); +if (fs.existsSync(distDir)) { + const distSentinel = writeSentinel(distDir).sentinel; + console.log(`[write-build-base-path] Recorded basePath -> ${path.relative(ROOT, distSentinel)}`); +} diff --git a/scripts/check-permissions.sh b/scripts/check-permissions.sh index 87679c04ec..807e09aeea 100755 --- a/scripts/check-permissions.sh +++ b/scripts/check-permissions.sh @@ -8,6 +8,13 @@ if [ -n "$OMNIROUTE_MEMORY_MB" ]; then export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=${OMNIROUTE_MEMORY_MB}" fi +# Hard Rule #13: never interpolate OMNIROUTE_BASE_PATH (or any runtime path) +# into sed/awk/shell. The Node guard reads process.env itself — invoke with a +# fixed argv only; do not pass the subpath as a CLI argument or script body. +if [ -f docker/ensure-docker-base-path.mjs ]; then + node docker/ensure-docker-base-path.mjs || exit 1 +fi + DATA_PATH="${DATA_DIR:-/app/data}" if [ -d "$DATA_PATH" ] && [ ! -w "$DATA_PATH" ]; then echo "WARNING: $DATA_PATH is not writable by the current user (UID $(id -u))." diff --git a/scripts/dev/healthcheck.mjs b/scripts/dev/healthcheck.mjs index 56acaae205..c65b0957b1 100644 --- a/scripts/dev/healthcheck.mjs +++ b/scripts/dev/healthcheck.mjs @@ -21,6 +21,22 @@ import { networkInterfaces } from "node:os"; const DEFAULT_HOSTS = ["127.0.0.1", "localhost", "::1"]; const DEFAULT_TIMEOUT_MS = 4000; +const DEFAULT_HEALTH_PATH = "/api/monitoring/health"; + +function normalizeBasePath(value) { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed || trimmed === "/") return ""; + if (!trimmed.startsWith("/") || /[?#\\]/.test(trimmed)) return ""; + const segments = trimmed.split("/").filter(Boolean); + if (segments.some((segment) => segment === "." || segment === "..")) return ""; + return `/${segments.join("/")}`; +} + +/** Prefixes the health route with the configured Next.js basePath. */ +export function resolveHealthPath(basePathValue) { + const basePath = normalizeBasePath(basePathValue); + return basePath ? `${basePath}${DEFAULT_HEALTH_PATH}` : DEFAULT_HEALTH_PATH; +} /** * Get the primary non-loopback IPv4 address (container internal IP). @@ -45,10 +61,11 @@ function getContainerInternalIP() { * Build the health URL for a host, bracketing IPv6 literals (e.g. `::1`). * @param {string} host * @param {string|number} port + * @param {string} healthPath path to probe, including any basePath prefix */ -function healthUrl(host, port) { +function healthUrl(host, port, healthPath = DEFAULT_HEALTH_PATH) { const hostPart = host.includes(":") ? `[${host}]` : host; - return `http://${hostPart}:${port}/api/monitoring/health`; + return `http://${hostPart}:${port}${healthPath}`; } /** @@ -62,6 +79,7 @@ function healthUrl(host, port) { * @param {string[]} [opts.hosts] * @param {typeof fetch} [opts.fetchImpl] * @param {number} [opts.timeoutMs] + * @param {string} [opts.healthPath] * @returns {Promise} the host that succeeded */ export async function probeHealth({ @@ -69,11 +87,12 @@ export async function probeHealth({ hosts = DEFAULT_HOSTS, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, + healthPath = DEFAULT_HEALTH_PATH, } = {}) { let lastError = new Error("no hosts to probe"); for (const host of hosts) { try { - const res = await fetchImpl(healthUrl(host, port), { + const res = await fetchImpl(healthUrl(host, port, healthPath), { signal: AbortSignal.timeout(timeoutMs), }); if (res.ok) return host; @@ -96,7 +115,8 @@ async function main() { } try { - await probeHealth({ port, hosts }); + const healthPath = resolveHealthPath(process.env.OMNIROUTE_BASE_PATH); + await probeHealth({ port, hosts, healthPath }); process.exit(0); } catch (err) { // Surface the failure so `docker inspect ... .State.Health[].Output` is diff --git a/scripts/docker/ensure-docker-base-path.mjs b/scripts/docker/ensure-docker-base-path.mjs new file mode 100644 index 0000000000..5d7eca25d4 --- /dev/null +++ b/scripts/docker/ensure-docker-base-path.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +/** + * Compares OMNIROUTE_BASE_PATH at container start against BUILD_OMNIROUTE_BASE_PATH + * recorded during the image build. When they differ and the image was built for the + * domain root, rewrites the standalone bundle before Next.js starts. + * + * Hard Rule #13: the subpath is read only from process.env (or an injected env + * object in tests). Never accept it as a CLI argument or interpolate it into a + * shell sed/awk invocation — see scripts/docker/patch-basepath.sh. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { normalizeBasePath } from "../build/normalizeBasePath.mjs"; +import { patchStandaloneBasePath } from "./patch-standalone-base-path.mjs"; + +const SENTINEL = "BUILD_OMNIROUTE_BASE_PATH"; + +function readBakedBasePath(appRoot) { + const sentinelPath = path.join(appRoot, SENTINEL); + if (!fs.existsSync(sentinelPath)) return ""; + return normalizeBasePath(fs.readFileSync(sentinelPath, "utf8")); +} + +function writeBakedBasePath(appRoot, basePath) { + fs.writeFileSync(path.join(appRoot, SENTINEL), `${basePath}\n`); +} + +/** + * @param {object} [opts] + * @param {string} [opts.appRoot] + * @param {NodeJS.ProcessEnv} [opts.env] + */ +export function ensureDockerBasePath(opts = {}) { + const appRoot = opts.appRoot || process.cwd(); + const runtime = normalizeBasePath(opts.env?.OMNIROUTE_BASE_PATH ?? process.env.OMNIROUTE_BASE_PATH); + const baked = readBakedBasePath(appRoot); + + if (runtime === baked) { + return { action: "noop", runtime, baked }; + } + + if (!runtime && baked) { + throw new Error( + `This OmniRoute image was built for subpath ${baked}, but OMNIROUTE_BASE_PATH is unset. ` + + `Set OMNIROUTE_BASE_PATH=${baked} or rebuild without the build-arg.` + ); + } + + const patchResult = patchStandaloneBasePath({ + appRoot, + fromBasePath: baked, + toBasePath: runtime, + }); + writeBakedBasePath(appRoot, runtime); + return { action: "patched", runtime, baked, patchResult }; +} + +function main() { + try { + const result = ensureDockerBasePath(); + if (result.action === "patched") { + const { patchResult, runtime } = result; + console.log( + `[ensure-docker-base-path] Applied runtime subpath ${runtime} ` + + `(manifests=${patchResult.patchedManifests}, textFiles=${patchResult.patchedTextFiles})` + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[ensure-docker-base-path] ${message}`); + console.error( + "[ensure-docker-base-path] Rebuild the image with the same subpath, e.g.\n" + + " docker compose build --build-arg OMNIROUTE_BASE_PATH=/omniroute" + ); + process.exit(1); + } +} + +const isEntrypoint = + Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isEntrypoint) { + main(); +} diff --git a/scripts/docker/patch-basepath.sh b/scripts/docker/patch-basepath.sh new file mode 100755 index 0000000000..fa5ca096cb --- /dev/null +++ b/scripts/docker/patch-basepath.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Hard Rule #13 — OMNIROUTE_BASE_PATH must travel via the process environment. +# This wrapper never expands the subpath into sed/awk/shell script text; Node +# reads OMNIROUTE_BASE_PATH from env inside ensure-docker-base-path.mjs. +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec node "${SCRIPT_DIR}/ensure-docker-base-path.mjs" diff --git a/scripts/docker/patch-standalone-base-path.mjs b/scripts/docker/patch-standalone-base-path.mjs new file mode 100644 index 0000000000..e0d2185245 --- /dev/null +++ b/scripts/docker/patch-standalone-base-path.mjs @@ -0,0 +1,168 @@ +/** + * Rewrites Next.js standalone manifests and embedded basePath literals so a bundle + * built for the domain root can serve under OMNIROUTE_BASE_PATH at container start. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { normalizeBasePath } from "../build/normalizeBasePath.mjs"; + +const JSON_MANIFEST_NAMES = new Set([ + "routes-manifest.json", + "prerender-manifest.json", + "required-server-files.json", + "images-manifest.json", + "app-path-routes-manifest.json", +]); + +/** + * @param {string} appRoot + * @returns {string[]} + */ +export function discoverNextDistRoots(appRoot) { + const candidates = [".build/next", ".next", path.join(".build", "next")]; + const found = []; + for (const rel of candidates) { + const abs = path.join(appRoot, rel); + if (fs.existsSync(path.join(abs, "routes-manifest.json"))) { + found.push(abs); + } + } + return found; +} + +/** + * @param {unknown} node + * @param {string} basePath + */ +function patchJsonNode(node, basePath) { + if (!node || typeof node !== "object") return; + if (Array.isArray(node)) { + for (const entry of node) patchJsonNode(entry, basePath); + return; + } + for (const [key, value] of Object.entries(node)) { + if (key === "basePath" && typeof value === "string") { + node[key] = basePath; + continue; + } + patchJsonNode(value, basePath); + } +} + +/** + * @param {string} filePath + * @param {string} basePath + */ +export function patchJsonManifestFile(filePath, basePath) { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw); + patchJsonNode(parsed, basePath); + const next = `${JSON.stringify(parsed, null, 2)}\n`; + if (next !== raw) { + fs.writeFileSync(filePath, next); + return true; + } + return false; +} + +const BASE_PATH_LITERAL_RE = + /basePath\s*:\s*(?:""|''|`{2})|basePath\s*:\s*void 0|"basePath"\s*:\s*""/g; + +/** + * @param {string} content + * @param {string} basePath + */ +export function patchBasePathLiterals(content, basePath) { + const escaped = basePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return content.replace(BASE_PATH_LITERAL_RE, (match) => { + if (match.startsWith('"basePath"')) return `"basePath":"${escaped}"`; + if (match.includes("void 0")) return `basePath:"${escaped}"`; + return `basePath:"${escaped}"`; + }); +} + +/** + * @param {string} rootDir + * @param {string} basePath + */ +function walkAndPatchTextFiles(rootDir, basePath) { + let patchedFiles = 0; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + continue; + } + if (!/\.(?:js|json|cjs|mjs)$/.test(entry.name)) continue; + const before = fs.readFileSync(full, "utf8"); + const after = patchBasePathLiterals(before, basePath); + if (after !== before) { + fs.writeFileSync(full, after); + patchedFiles += 1; + } + } + } + return patchedFiles; +} + +/** + * @param {object} opts + * @param {string} opts.appRoot standalone bundle root (cwd in Docker) + * @param {string} opts.fromBasePath normalized baked base path + * @param {string} opts.toBasePath normalized runtime base path + */ +export function patchStandaloneBasePath({ appRoot, fromBasePath, toBasePath }) { + const from = normalizeBasePath(fromBasePath); + const to = normalizeBasePath(toBasePath); + if (from === to) { + return { changed: false, patchedManifests: 0, patchedTextFiles: 0, distRoots: [] }; + } + if (from) { + throw new Error( + `runtime OMNIROUTE_BASE_PATH (${to || "(root)"}) does not match the image build ` + + `(${from}). Rebuild with --build-arg OMNIROUTE_BASE_PATH=${to || '""'}.` + ); + } + if (!to) { + throw new Error("patchStandaloneBasePath requires a non-empty target base path"); + } + + const distRoots = discoverNextDistRoots(appRoot); + if (distRoots.length === 0) { + throw new Error( + "could not locate routes-manifest.json under .build/next or .next in the standalone bundle" + ); + } + + let patchedManifests = 0; + let patchedTextFiles = 0; + for (const distRoot of distRoots) { + for (const name of JSON_MANIFEST_NAMES) { + const manifestPath = path.join(distRoot, name); + if (!fs.existsSync(manifestPath)) continue; + if (patchJsonManifestFile(manifestPath, to)) patchedManifests += 1; + } + const serverDir = path.join(distRoot, "server"); + if (fs.existsSync(serverDir)) patchedTextFiles += walkAndPatchTextFiles(serverDir, to); + const staticDir = path.join(distRoot, "static"); + if (fs.existsSync(staticDir)) patchedTextFiles += walkAndPatchTextFiles(staticDir, to); + } + + for (const entry of ["server.js", "server-ws.mjs"]) { + const serverEntry = path.join(appRoot, entry); + if (!fs.existsSync(serverEntry)) continue; + const before = fs.readFileSync(serverEntry, "utf8"); + const after = patchBasePathLiterals(before, to); + if (after !== before) { + fs.writeFileSync(serverEntry, after); + patchedTextFiles += 1; + } + } + + return { changed: true, patchedManifests, patchedTextFiles, distRoots, toBasePath: to }; +} diff --git a/tests/unit/docker-base-path-patch.test.ts b/tests/unit/docker-base-path-patch.test.ts new file mode 100644 index 0000000000..f62ce84ea4 --- /dev/null +++ b/tests/unit/docker-base-path-patch.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + patchBasePathLiterals, + patchJsonManifestFile, + patchStandaloneBasePath, +} from "../../scripts/docker/patch-standalone-base-path.mjs"; + +test("patchBasePathLiterals rewrites empty basePath literals", () => { + const input = 'const cfg={basePath:"",assetPrefix:void 0};"basePath":""'; + const output = patchBasePathLiterals(input, "/omniroute"); + assert.match(output, /basePath:"\/omniroute"/); + assert.match(output, /"basePath":"\/omniroute"/); +}); + +test("patchJsonManifestFile updates nested basePath fields", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-basepath-")); + const filePath = path.join(dir, "routes-manifest.json"); + fs.writeFileSync( + filePath, + JSON.stringify({ basePath: "", nested: { basePath: "" } }, null, 2) + ); + assert.equal(patchJsonManifestFile(filePath, "/omniroute"), true); + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + assert.equal(parsed.basePath, "/omniroute"); + assert.equal(parsed.nested.basePath, "/omniroute"); +}); + +test("patchStandaloneBasePath rewrites a root-path standalone tree", () => { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-standalone-")); + const distRoot = path.join(appRoot, ".build", "next"); + fs.mkdirSync(path.join(distRoot, "server"), { recursive: true }); + fs.writeFileSync( + path.join(distRoot, "routes-manifest.json"), + JSON.stringify({ basePath: "" }) + ); + fs.writeFileSync( + path.join(distRoot, "server", "chunk.js"), + 'export const config={basePath:""};' + ); + fs.writeFileSync(path.join(appRoot, "BUILD_OMNIROUTE_BASE_PATH"), "\n"); + + const result = patchStandaloneBasePath({ + appRoot, + fromBasePath: "", + toBasePath: "/omniroute", + }); + + assert.equal(result.changed, true); + assert.equal( + JSON.parse(fs.readFileSync(path.join(distRoot, "routes-manifest.json"), "utf8")).basePath, + "/omniroute" + ); + assert.match(fs.readFileSync(path.join(distRoot, "server", "chunk.js"), "utf8"), /\/omniroute/); +}); + +test("patchStandaloneBasePath rejects mismatched non-root builds", () => { + assert.throws( + () => + patchStandaloneBasePath({ + appRoot: process.cwd(), + fromBasePath: "/custom", + toBasePath: "/omniroute", + }), + /does not match the image build/ + ); +}); diff --git a/tests/unit/docker-ensure-base-path.test.ts b/tests/unit/docker-ensure-base-path.test.ts new file mode 100644 index 0000000000..8269a99cab --- /dev/null +++ b/tests/unit/docker-ensure-base-path.test.ts @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { ensureDockerBasePath } from "../../scripts/docker/ensure-docker-base-path.mjs"; + +function makeStandaloneRoot(basePath = "") { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-docker-base-")); + const distRoot = path.join(appRoot, ".build", "next"); + fs.mkdirSync(path.join(distRoot, "server"), { recursive: true }); + fs.writeFileSync( + path.join(distRoot, "routes-manifest.json"), + JSON.stringify({ basePath }) + ); + fs.writeFileSync( + path.join(distRoot, "server", "chunk.js"), + `export const config={basePath:"${basePath}"};` + ); + fs.writeFileSync(path.join(appRoot, "BUILD_OMNIROUTE_BASE_PATH"), `${basePath}\n`); + return appRoot; +} + +test("ensureDockerBasePath is a no-op when runtime matches the baked sentinel", () => { + const appRoot = makeStandaloneRoot(""); + const result = ensureDockerBasePath({ appRoot, env: { OMNIROUTE_BASE_PATH: "" } }); + assert.equal(result.action, "noop"); +}); + +test("ensureDockerBasePath patches a root image when a subpath is configured", () => { + const appRoot = makeStandaloneRoot(""); + const result = ensureDockerBasePath({ appRoot, env: { OMNIROUTE_BASE_PATH: "/omniroute" } }); + assert.equal(result.action, "patched"); + assert.equal(fs.readFileSync(path.join(appRoot, "BUILD_OMNIROUTE_BASE_PATH"), "utf8"), "/omniroute\n"); + const manifest = JSON.parse( + fs.readFileSync(path.join(appRoot, ".build", "next", "routes-manifest.json"), "utf8") + ); + assert.equal(manifest.basePath, "/omniroute"); +}); + +test("ensureDockerBasePath rejects unset runtime on subpath images", () => { + const appRoot = makeStandaloneRoot("/omniroute"); + assert.throws( + () => ensureDockerBasePath({ appRoot, env: {} }), + /built for subpath/ + ); +}); diff --git a/tests/unit/docker-healthcheck-base-path.test.ts b/tests/unit/docker-healthcheck-base-path.test.ts new file mode 100644 index 0000000000..a49b548a55 --- /dev/null +++ b/tests/unit/docker-healthcheck-base-path.test.ts @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveHealthPath } from "../../scripts/dev/healthcheck.mjs"; + +test("resolveHealthPath keeps the default route at the domain root", () => { + assert.equal(resolveHealthPath(""), "/api/monitoring/health"); + assert.equal(resolveHealthPath(undefined), "/api/monitoring/health"); +}); + +test("resolveHealthPath prefixes the health route with OMNIROUTE_BASE_PATH", () => { + assert.equal(resolveHealthPath("/omniroute/"), "/omniroute/api/monitoring/health"); + assert.equal(resolveHealthPath("/omniroute"), "/omniroute/api/monitoring/health"); +}); diff --git a/tests/unit/dockerfile-base-path-arg.test.ts b/tests/unit/dockerfile-base-path-arg.test.ts new file mode 100644 index 0000000000..9749801b7e --- /dev/null +++ b/tests/unit/dockerfile-base-path-arg.test.ts @@ -0,0 +1,56 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); + +test("Dockerfile exposes OMNIROUTE_BASE_PATH as a builder-stage build arg", () => { + const dockerfile = fs.readFileSync(path.join(REPO_ROOT, "Dockerfile"), "utf8"); + assert.match(dockerfile, /ARG OMNIROUTE_BASE_PATH/); + assert.match(dockerfile, /ENV OMNIROUTE_BASE_PATH=\$OMNIROUTE_BASE_PATH/); +}); + +test("docker-compose forwards OMNIROUTE_BASE_PATH into image builds", () => { + const compose = fs.readFileSync(path.join(REPO_ROOT, "docker-compose.yml"), "utf8"); + assert.match(compose, /OMNIROUTE_BASE_PATH: \$\{OMNIROUTE_BASE_PATH:-\}/); + assert.match(compose, /- OMNIROUTE_BASE_PATH=\$\{OMNIROUTE_BASE_PATH:-\}/); +}); + +test("entrypoint runs the docker basePath guard before the server starts", () => { + const entrypoint = fs.readFileSync(path.join(REPO_ROOT, "scripts/check-permissions.sh"), "utf8"); + assert.match(entrypoint, /docker\/ensure-docker-base-path\.mjs/); +}); + +test("Hard Rule #13: shell basePath helpers never interpolate into sed/awk", () => { + const shellFiles = [ + "scripts/check-permissions.sh", + "scripts/docker/patch-basepath.sh", + ]; + for (const rel of shellFiles) { + const source = fs.readFileSync(path.join(REPO_ROOT, rel), "utf8"); + assert.doesNotMatch( + source, + /\b(?:sed|awk)\b.*\$\{?OMNIROUTE_BASE_PATH/, + `${rel} must not expand OMNIROUTE_BASE_PATH into sed/awk` + ); + assert.doesNotMatch( + source, + /(?:sed|awk).*OMNIROUTE_BASE_PATH/, + `${rel} must not pass OMNIROUTE_BASE_PATH on a sed/awk command line` + ); + } + + const patcher = fs.readFileSync( + path.join(REPO_ROOT, "scripts/docker/patch-basepath.sh"), + "utf8" + ); + assert.match(patcher, /exec node .*ensure-docker-base-path\.mjs/); + assert.match(patcher, /Hard Rule #13/); + + const ensure = fs.readFileSync( + path.join(REPO_ROOT, "scripts/docker/ensure-docker-base-path.mjs"), + "utf8" + ); + assert.match(ensure, /process\.env\.OMNIROUTE_BASE_PATH/); +}); diff --git a/tests/unit/normalize-base-path.test.ts b/tests/unit/normalize-base-path.test.ts new file mode 100644 index 0000000000..4f0b4d7554 --- /dev/null +++ b/tests/unit/normalize-base-path.test.ts @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + joinBasePath, + normalizeBasePath, +} from "../../scripts/build/normalizeBasePath.mjs"; + +test("normalizeBasePath returns empty for root and blank values", () => { + assert.equal(normalizeBasePath(undefined), ""); + assert.equal(normalizeBasePath(""), ""); + assert.equal(normalizeBasePath("/"), ""); + assert.equal(normalizeBasePath(" "), ""); +}); + +test("normalizeBasePath strips trailing slashes and rejects unsafe paths", () => { + assert.equal(normalizeBasePath("/omniroute/"), "/omniroute"); + assert.equal(normalizeBasePath("omniroute"), ""); + assert.equal(normalizeBasePath("/../etc"), ""); + assert.equal(normalizeBasePath("/omni?x=1"), ""); +}); + +test("joinBasePath prefixes application routes", () => { + assert.equal(joinBasePath("", "/api/health"), "/api/health"); + assert.equal(joinBasePath("/omniroute", "/api/health"), "/omniroute/api/health"); + assert.equal(joinBasePath("/omniroute", "/"), "/omniroute/"); +});