#!/usr/bin/env node import fs from "node:fs"; import http from "node:http"; import path from "node:path"; import next from "next"; import { bootstrapEnv } from "../build/bootstrap-env.mjs"; import { resolveRuntimePorts, withRuntimePortEnv } from "../build/runtime-env.mjs"; import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs"; import methodGuard from "./http-method-guard.cjs"; import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs"; import { isTurbopackCacheCorruption, purgeAllTurbopackCaches, } from "./turbopackCacheHeal.mjs"; import { randomUUID } from "node:crypto"; const { maybeHandleDisallowedMethod } = methodGuard; // Pre-read DATA_DIR from local .env before bootstrap resolves paths if (!process.env.DATA_DIR) { try { const raw = fs.readFileSync(path.join(process.cwd(), ".env"), "utf8"); const match = raw.match(/^DATA_DIR=(.+)$/m); if (match?.[1]?.trim()) process.env.DATA_DIR = match[1].trim(); } catch { /* .env ausente ou ilegível — ok, bootstrap usa o padrão */ } } // Add check for conflicting app/ directory (Issue #1206) const rootAppDir = path.join(process.cwd(), "app"); if (fs.existsSync(rootAppDir) && fs.statSync(rootAppDir).isDirectory()) { console.error("\x1b[31m[FATAL ERROR]\x1b[0m Next.js App Router conflict detected!"); console.error(`A root-level 'app/' directory was found at: ${rootAppDir}`); console.error("This conflicts with the 'src/app/' directory on Windows environments."); console.error("Next.js will serve 404s for all pages because it prefers the root 'app/' folder."); console.error("Please rename or delete the root 'app/' directory before starting OmniRoute.\n"); process.exit(1); } const mode = process.argv[2] === "start" ? "start" : "dev"; const dev = mode === "dev"; // Self-heal a stale better-sqlite3 native binary after a Node version switch // (nvm 22 <-> 24) before bootstrap touches the DB. No-op when the ABI matches. if (dev) { ensureNativeSqlite(); } const bootstrappedEnv = bootstrapEnv(); const runtimePorts = resolveRuntimePorts(bootstrappedEnv); const mergedEnv = withRuntimePortEnv(bootstrappedEnv, runtimePorts); for (const [key, value] of Object.entries(mergedEnv)) { if (value !== undefined) { process.env[key] = value; } } // The mergedEnv copy above pulls NODE_ENV straight from `.env` — and the shipped // `.env.example` default is `NODE_ENV=production`. Next's programmatic `next()` // entry (unlike the `next` CLI) trusts that value verbatim, so `npm run dev` // would boot the dev bundler with NODE_ENV=production. That desyncs Next's // webpack CSS pipeline and `src/app/globals.css` reaches webpack's JS parser // instead of PostCSS — failing as `Module parse failed: Unexpected character // '@'` on the `@import "tailwindcss"` line. Force NODE_ENV to track the run // mode, exactly like the `next` CLI does. process.env.NODE_ENV = dev ? "development" : "production"; const { dashboardPort } = runtimePorts; const hostname = process.env.HOST || "0.0.0.0"; // Turbopack by default in dev (matches the Next 16 CLI default and the production // build default in build-next-isolated.mjs); OMNIROUTE_USE_TURBOPACK=0 is the // webpack escape hatch. const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0"; process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID(); // Per-process secret used to prove the trusted peer-IP stamp came from this // server (read by the authz middleware in the same process). See peer-stamp.mjs. ensurePeerStampToken(); // Next 16 picks Turbopack by default in dev. Passing `turbopack: false` to the // programmatic next() entry is *not* enough on its own: // - parseBundlerArgs (node_modules/next/dist/lib/bundler.js) sees no positive // bundler flag and falls back to `process.env.TURBOPACK = 'auto'`. // - next-dev-server.js then reads `process.env.TURBOPACK` directly and // starts Turbopack regardless of the option we passed. // Force webpack by both passing `webpack: true` and clearing the env var. // Mirrors the workaround PR #4052 applied for the production Docker build. if (!useTurbopack) { delete process.env.TURBOPACK; } function createNextApp() { return next({ dev, dir: process.cwd(), hostname, port: dashboardPort, turbopack: useTurbopack, webpack: !useTurbopack, }); } let nextApp = createNextApp(); // Best-effort self-heal for a corrupted Turbopack persistent dev cache (#6289): // on Windows an mmap of an SST cache file can fail ("os error 1455" / paging // file too small), which Turbopack surfaces as a misleading module-resolve // error. This is an UPSTREAM Turbopack cache-corruption bug — not our code. // When `prepare()` rejects with that signature we purge the cache and retry // ONCE. Caveat: the failure often surfaces as a runtime overlay rather than a // `prepare()` rejection, so this cannot always intercept it — the reliable // remedy remains manually deleting `.build/next/**/cache/turbopack`. async function prepareWithHeal() { try { await nextApp.prepare(); } catch (error) { const detail = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error); if (!useTurbopack || !isTurbopackCacheCorruption(detail)) throw error; console.warn( "[Next] Turbopack dev cache looks corrupted (Windows mmap / os error 1455 — known upstream bug). Purging and retrying once…" ); const removed = purgeAllTurbopackCaches(); for (const dir of removed) console.warn(`[Next] purged Turbopack cache: ${dir}`); nextApp = createNextApp(); await nextApp.prepare(); console.warn("[Next] Turbopack dev cache purged; startup retry succeeded."); } } async function start() { await prepareWithHeal(); const requestHandler = nextApp.getRequestHandler(); const upgradeHandler = nextApp.getUpgradeHandler(); const responsesWsProxy = createResponsesWsProxy({ baseUrl: `http://127.0.0.1:${dashboardPort}`, bridgeSecret: process.env.OMNIROUTE_WS_BRIDGE_SECRET, }); const wsBridge = createOmnirouteWsBridge({ baseUrl: `http://127.0.0.1:${dashboardPort}`, }); const server = http.createServer((req, res) => { if (maybeHandleDisallowedMethod(req, res)) return; // Stamp the real TCP peer IP before Next sees the request, so the authz // middleware can decide LOCAL_ONLY locality without trusting the Host header. stampPeerIp(req); return requestHandler(req, res); }); server.on("upgrade", async (req, socket, head) => { try { const responsesWsHandled = await responsesWsProxy.handleUpgrade(req, socket, head); if (responsesWsHandled) return; const handled = await wsBridge.handleUpgrade(req, socket, head); if (handled) return; await upgradeHandler(req, socket, head); } catch (error) { if (!socket.destroyed) { socket.destroy(error instanceof Error ? error : undefined); } console.error("[WS] Upgrade handling failed:", error); } }); server.on("error", (error) => { console.error("[FATAL] Next custom server failed:", error); process.exit(1); }); const shutdown = async (signal) => { try { await new Promise((resolve) => server.close(resolve)); await nextApp.close(); } catch (error) { console.error("[SHUTDOWN] Failed during signal:", signal, error); } finally { process.exit(0); } }; process.on("SIGINT", () => void shutdown("SIGINT")); process.on("SIGTERM", () => void shutdown("SIGTERM")); server.listen(dashboardPort, hostname, () => { const bundler = dev ? (useTurbopack ? "turbopack" : "webpack") : "production"; console.log( `[Next] ${mode} server listening on http://${hostname}:${dashboardPort} (${bundler})` ); }); } start().catch((error) => { console.error("[FATAL] Failed to start Next custom server:", error); process.exit(1); });