import type { NextConfig } from 'next'; import { existsSync, realpathSync } from 'node:fs'; import { networkInterfaces } from 'node:os'; import { dirname, isAbsolute, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; // Daemon port the local Express server binds to (see apps/daemon/src/cli.ts). The // dev-all launcher overrides OD_PORT after probing for a free port; we read // the same env so /api, /artifacts, and /frames always reach the right // daemon instance during `next dev`. const DAEMON_PORT = Number(process.env.OD_PORT) || 7456; const DAEMON_ORIGIN = `http://127.0.0.1:${DAEMON_PORT}`; // The regular CLI build still ships as a static export so the `od` daemon can // serve a single-process production build. Packaged desktop builds opt into a // server runtime with OD_WEB_OUTPUT_MODE=server; in that mode the web sidecar // owns the Next.js SSR server and proxies daemon routes at runtime. The // packaged-size standalone spike uses OD_WEB_OUTPUT_MODE=standalone to ask // Next.js for a traced standalone server while keeping the sidecar-owned daemon // proxy in front of it at runtime. const isProd = process.env.NODE_ENV !== 'development'; const webOutputMode = process.env.OD_WEB_OUTPUT_MODE; const isServerOutput = webOutputMode === 'server' || webOutputMode === 'standalone'; const shouldStaticExport = isProd && !isServerOutput; const WEB_ROOT = dirname(fileURLToPath(import.meta.url)); function resolveWorkspaceRoot(): string { const computed = dirname(dirname(WEB_ROOT)); const override = process.env.OD_WORKSPACE_ROOT; if (override && override.trim()) { const resolved = isAbsolute(override.trim()) ? override.trim() : resolve(WEB_ROOT, override.trim()); if (!existsSync(resolved)) { throw new Error( `OD_WORKSPACE_ROOT="${override}" resolved to "${resolved}" which does not exist. ` + `Fix the path or unset the variable to use the computed default.`, ); } // Canonicalize via realpathSync so that symlinked paths (e.g. macOS // /tmp → /private/tmp) compare correctly against WEB_ROOT. const canonicalResolved = realpathSync(resolved); const canonicalWebRoot = realpathSync(WEB_ROOT); const rel = relative(canonicalResolved, canonicalWebRoot); // rel.startsWith('..') catches the non-ancestor case on POSIX. // isAbsolute(rel) catches the Windows cross-drive case where relative() // returns an absolute path (e.g. C:\repo\apps\web) instead of a ..-path. if (rel.startsWith('..') || isAbsolute(rel)) { throw new Error( `OD_WORKSPACE_ROOT="${override}" resolved to "${canonicalResolved}" but WEB_ROOT "${canonicalWebRoot}" ` + `is not inside it (relative path "${rel}"). ` + `The override must be an ancestor of apps/web.`, ); } // Require the resolved path to be a real pnpm workspace root. Without this, // an ancestor like `/apps` would pass the relative-path check but // miss the sibling `packages/*` directory that `apps/web` imports from // (for example `@open-design/contracts`), and Next would later fail deep // inside file tracing / Turbopack with a much harder-to-diagnose error. if (!existsSync(resolve(canonicalResolved, 'pnpm-workspace.yaml'))) { throw new Error( `OD_WORKSPACE_ROOT="${override}" resolved to "${canonicalResolved}" but no ` + `pnpm-workspace.yaml was found there. The override must point at the ` + `pnpm workspace root so outputFileTracingRoot and turbopack.root can ` + `resolve sibling packages.`, ); } return canonicalResolved; } return computed; } const WORKSPACE_ROOT = resolveWorkspaceRoot(); const toPosixPath = (value: string) => value.replaceAll('\\', '/'); function resolveDistDir(defaultValue: string) { if (process.env.OD_WEB_PROD === '1') return defaultValue; const configured = process.env.OD_WEB_DIST_DIR; if (!configured) return defaultValue; return toPosixPath(isAbsolute(configured) ? relative(WEB_ROOT, configured) || '.' : configured); } const DIST_DIR = shouldStaticExport && !process.env.OD_WEB_DIST_DIR ? null : resolveDistDir('.next'); function resolveDevTsconfigPath() { const configured = process.env.OD_WEB_TSCONFIG_PATH; if (!configured) return undefined; return toPosixPath(isAbsolute(configured) ? relative(WEB_ROOT, configured) || 'tsconfig.json' : configured); } const DEV_TSCONFIG_PATH = resolveDevTsconfigPath(); function parseAllowedDevHost(value: string): string | null { const trimmed = value.trim(); if (!trimmed) return null; try { return new URL(trimmed).hostname.toLowerCase(); } catch { try { return new URL(`http://${trimmed}`).hostname.toLowerCase(); } catch { return null; } } } function parseIpv4(value: string): [number, number, number, number] | null { const parts = value.split('.'); if (parts.length !== 4) return null; if (!parts.every((part) => /^\d+$/.test(part))) return null; const octets = parts.map((part) => Number(part)); if (!octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) return null; return octets as [number, number, number, number]; } function isPrivateLanIpv4(value: string): boolean { const octets = parseIpv4(value); if (octets == null) return false; const [a, b] = octets; return ( a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127) ); } function localPrivateLanHosts(): string[] { return Object.values(networkInterfaces()) .flatMap((entries) => entries ?? []) .filter((entry) => entry.family === 'IPv4' && !entry.internal && isPrivateLanIpv4(entry.address)) .map((entry) => entry.address); } function configuredAllowedDevHosts(): string[] { const configured = (process.env.OD_ALLOWED_DEV_ORIGINS ?? '') .split(',') .map(parseAllowedDevHost) .filter((host): host is string => host != null); const allowedOrigins = (process.env.OD_ALLOWED_ORIGINS ?? '') .split(',') .map(parseAllowedDevHost) .filter((host): host is string => host != null); const bindHost = parseAllowedDevHost(process.env.OD_HOST ?? ''); return Array.from(new Set([ '127.0.0.1', ...localPrivateLanHosts(), ...(bindHost != null && bindHost !== '0.0.0.0' && bindHost !== '::' ? [bindHost] : []), ...configured, ...allowedOrigins, ])); } const nextConfig: NextConfig = { allowedDevOrigins: configuredAllowedDevHosts(), outputFileTracingRoot: WORKSPACE_ROOT, reactStrictMode: true, // Emit browser sourcemaps so packaged-runtime exceptions can be symbolicated // by PostHog. `tools/pack/src/web-sourcemaps.ts` runs after `next build` // to inject chunk IDs, upload to PostHog, and ALWAYS delete the .map files // before packaging so source never ships inside an installer. productionBrowserSourceMaps: true, transpilePackages: ['@open-design/components'], turbopack: { root: WORKSPACE_ROOT, }, ...(DEV_TSCONFIG_PATH ? { typescript: { tsconfigPath: DEV_TSCONFIG_PATH } } : {}), // Static exports keep Next.js's default `out/` output directory so static // hosts like Vercel can publish the generated site directly. Server runtimes // still keep a predictable traced build directory for sidecar launchers. ...(DIST_DIR ? { distDir: DIST_DIR } : {}), ...(shouldStaticExport ? { output: 'export' as const, // `next export` skips trailing slashes by default; opting in keeps // the daemon's static fallback simple (every directory has its own // index.html on disk). trailingSlash: true, images: { unoptimized: true }, } : webOutputMode === 'standalone' ? { output: 'standalone' as const, } : !isProd ? { async rewrites() { // In dev we run the daemon on a sibling port; proxy the app API // proxy so the SPA can hit /api, /artifacts, and /frames without // CORS gymnastics. SSE on /api/chat works through this rewrite // because Next.js's dev server streams responses unbuffered. return [ { source: '/api/:path*', destination: `${DAEMON_ORIGIN}/api/:path*` }, { source: '/artifacts/:path*', destination: `${DAEMON_ORIGIN}/artifacts/:path*` }, { source: '/frames/:path*', destination: `${DAEMON_ORIGIN}/frames/:path*` }, ]; }, devIndicators: { position: 'bottom-right', }, } : {}), }; export default nextConfig;