#!/usr/bin/env node /** * OmniRoute โ€” Prepublish Build Script * * Builds the Next.js app in standalone mode and copies output * into the `app/` directory that gets published to npm. * * Run with: node scripts/prepublish.mjs */ import { execSync } from "node:child_process"; import { existsSync, mkdirSync, cpSync, rmSync, writeFileSync, readFileSync, readdirSync, statSync, } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { APP_STAGING_ALLOWED_EXACT_PATHS, APP_STAGING_ALLOWED_PATH_PREFIXES, APP_STAGING_REMOVAL_PATHS, findUnexpectedArtifactPaths, } from "./pack-artifact-policy.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const ROOT = join(__dirname, ".."); const APP_DIR = join(ROOT, "app"); function walkFiles(dir: string, rootDir: string = dir, files: string[] = []): string[] { let entries: string[] = []; try { entries = readdirSync(dir); } catch { return files; } for (const entry of entries) { const fullPath = join(dir, entry); let stat; try { stat = statSync(fullPath); } catch { continue; } if (stat.isDirectory()) { walkFiles(fullPath, rootDir, files); continue; } files.push( fullPath .replace(rootDir, "") .replace(/^[/\\]/, "") .replace(/\\/g, "/") ); } return files; } function removeEmptyDirectories(dir: string): boolean { let entries: string[] = []; try { entries = readdirSync(dir); } catch { return false; } let hasFiles = false; for (const entry of entries) { const fullPath = join(dir, entry); let stat; try { stat = statSync(fullPath); } catch { continue; } if (stat.isDirectory()) { const childHasFiles = removeEmptyDirectories(fullPath); if (!childHasFiles) { rmSync(fullPath, { recursive: true, force: true }); } else { hasFiles = true; } continue; } hasFiles = true; } return hasFiles; } console.log("๐Ÿ”จ OmniRoute โ€” Building for npm publish...\n"); // โ”€โ”€ Step 1: Clean previous app/ directory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (existsSync(APP_DIR)) { console.log(" ๐Ÿงน Cleaning previous app/ directory..."); rmSync(APP_DIR, { recursive: true, force: true }); } // โ”€โ”€ Step 2: Install dependencies โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ console.log(" ๐Ÿ“ฆ Installing dependencies..."); execSync("npm install", { cwd: ROOT, stdio: "inherit" }); // โ”€โ”€ Step 2.5: Remove app/ directory before build โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // CRITICAL: The postinstall script may create app/node_modules/@swc/helpers/, // which causes Next.js 16 to interpret app/ as an App Router directory // (competing with src/app/). This makes the build silently skip all real // routes, producing a standalone with only _global-error and _not-found. // We MUST remove app/ before running `next build`. if (existsSync(APP_DIR)) { console.log(" ๐Ÿงน Removing app/ created by postinstall (App Router conflict fix)..."); rmSync(APP_DIR, { recursive: true, force: true }); } // โ”€โ”€ Step 3: Build Next.js โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ console.log(" ๐Ÿ—๏ธ Building Next.js (standalone)..."); execSync("npx next build", { cwd: ROOT, stdio: "inherit", env: { ...process.env, NEXT_PRIVATE_BUILD_WORKER: "0", }, }); // โ”€โ”€ Step 4: Verify standalone output โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const standaloneDir = join(ROOT, ".next", "standalone"); const serverJs = join(standaloneDir, "server.js"); if (!existsSync(serverJs)) { console.error("\n โŒ Standalone build not found at:", standaloneDir); console.error(" Make sure next.config.mjs has: output: 'standalone'"); process.exit(1); } // โ”€โ”€ Step 5: Copy standalone output to app/ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // โ”€โ”€ Step 4.5: Check build for hashed external references โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Warn if Turbopack-style hash suffixes are found โ€” they will be resolved at // runtime by the externals patch in next.config.mjs, but log for visibility. { const HASH_RE = /require\(["']([\w@./-]+-[0-9a-f]{16})["']\)/; const scanDir = ( dir: string, hits: { file: string; mod: string }[] = [] ): { file: string; mod: string }[] => { let entries: string[] = []; try { entries = readdirSync(dir); } catch { return hits; } for (const e of entries) { const f = join(dir, e); try { if (statSync(f).isDirectory()) { scanDir(f, hits); continue; } if (!f.endsWith(".js")) continue; const m = readFileSync(f, "utf8").match(HASH_RE); if (m) hits.push({ file: f.replace(standaloneDir, "app"), mod: m[1] }); } catch { continue; } } return hits; }; const hits = scanDir(join(standaloneDir, ".next", "server")); if (hits.length > 0) { console.warn( " โš ๏ธ Hashed externals in build (will be auto-fixed at runtime by externals patch):" ); hits.slice(0, 5).forEach((h) => console.warn()); if (hits.length > 5) console.warn(); } else { console.log(" โœ… Build clean โ€” no hashed externals found."); } } console.log(" ๐Ÿ“‹ Copying standalone build to app/..."); mkdirSync(APP_DIR, { recursive: true }); cpSync(standaloneDir, APP_DIR, { recursive: true }); // โ”€โ”€ Next.js Turbopack Standalone Tracer Fix โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Workaround for Next.js 15+ standalone mode missing Turbopack chunks const staticChunksSrc = join(ROOT, ".next", "server", "chunks"); const staticChunksDest = join(APP_DIR, ".next", "server", "chunks"); if (existsSync(staticChunksSrc)) { console.log(" ๐Ÿ“‹ Patching standalone build with missing Turbopack chunks..."); cpSync(staticChunksSrc, staticChunksDest, { recursive: true, force: false }); } // โ”€โ”€ Step 5.5: Sanitize hardcoded build-machine paths โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Next.js standalone bakes absolute build-time paths into server.js and // required-server-files.json (outputFileTracingRoot, appDir, turbopack root). // Replace the build machine's absolute path with "." (current directory) // so paths resolve relative to wherever the standalone app/ is installed. console.log(" ๐Ÿงน Sanitizing build-machine paths..."); const buildRoot = ROOT.replace(/\\/g, "/"); // normalise for regex safety const sanitizeTargets = [ join(APP_DIR, "server.js"), join(APP_DIR, ".next", "required-server-files.json"), ]; let sanitisedCount = 0; for (const filePath of sanitizeTargets) { if (!existsSync(filePath)) continue; let content = readFileSync(filePath, "utf8"); // Escape special regex characters in the path const escaped = buildRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp(escaped, "g"); const matches = content.match(re); if (matches) { // Replace with "." so Next.js resolves paths relative to the standalone dir content = content.replace(re, "."); writeFileSync(filePath, content); sanitisedCount += matches.length; } } if (sanitisedCount > 0) { console.log(` โœ… Sanitised ${sanitisedCount} hardcoded path references`); } else { console.log(" โ„น๏ธ No hardcoded paths found to sanitise"); } // โ”€โ”€ Step 5.6: Strip Turbopack hashed externals from compiled chunks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Even when Turbopack is disabled at build time, some instrumentation chunks // may still emit require('package-<16hexchars>') instead of require('package'). // These hashed names don't exist in node_modules and cause MODULE_NOT_FOUND at // runtime. We strip the hex suffix from all .js files in app/.next/server/ // to ensure all require() calls use the real package names. { const serverOutput = join(APP_DIR, ".next", "server"); const HASH_RE = /(['"\\])([a-z@][a-z0-9@./_-]+-[0-9a-f]{16})\1/g; let patchedFiles = 0; let patchedMatches = 0; const walkDir = (dir: string) => { let entries: string[] = []; try { entries = readdirSync(dir); } catch { return; } for (const entry of entries) { const full = join(dir, entry); try { const st = statSync(full); if (st.isDirectory()) { walkDir(full); continue; } if (!entry.endsWith(".js")) continue; const src = readFileSync(full, "utf8"); let count = 0; const patched = src.replace(HASH_RE, (_, q, name) => { const base = name.replace(/-[0-9a-f]{16}$/, ""); count++; return `${q}${base}${q}`; }); if (count > 0) { writeFileSync(full, patched); patchedFiles++; patchedMatches += count; } } catch { /* skip unreadable files */ } } }; if (existsSync(serverOutput)) { walkDir(serverOutput); if (patchedMatches > 0) { console.log( ` ๐Ÿ”ง Hash-strip: patched ${patchedMatches} hashed require() in ${patchedFiles} server chunk file(s)` ); } else { console.log(" โœ… Hash-strip: no hashed externals found in compiled chunks."); } } } // โ”€โ”€ Step 6: Copy static assets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const staticSrc = join(ROOT, ".next", "static"); const staticDest = join(APP_DIR, ".next", "static"); if (existsSync(staticSrc)) { console.log(" ๐Ÿ“‹ Copying static assets..."); mkdirSync(staticDest, { recursive: true }); cpSync(staticSrc, staticDest, { recursive: true }); } // โ”€โ”€ Step 7: Copy public/ assets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const publicSrc = join(ROOT, "public"); const publicDest = join(APP_DIR, "public"); if (existsSync(publicSrc)) { console.log(" ๐Ÿ“‹ Copying public/ assets..."); mkdirSync(publicDest, { recursive: true }); cpSync(publicSrc, publicDest, { recursive: true }); } // โ”€โ”€ Step 8: Compile + copy MITM cert utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const mitmSrc = join(ROOT, "src", "mitm"); const mitmDest = join(APP_DIR, "src", "mitm"); if (existsSync(mitmSrc)) { console.log(" ๐Ÿ”จ Compiling MITM utilities (TypeScript โ†’ JavaScript)..."); mkdirSync(mitmDest, { recursive: true }); // Write a temporary tsconfig.json targeting the mitm directory const mitmTsconfig = { compilerOptions: { target: "ES2020", module: "CommonJS", outDir: mitmDest, rootDir: mitmSrc, resolveJsonModule: true, esModuleInterop: true, skipLibCheck: true, types: ["node"], baseUrl: ".", paths: { "@/*": ["src/*"], }, }, include: [mitmSrc + "/**/*"], }; const tmpTsconfigPath = join(ROOT, "tsconfig.mitm.tmp.json"); writeFileSync(tmpTsconfigPath, JSON.stringify(mitmTsconfig, null, 2)); try { execSync("npx tsc -p tsconfig.mitm.tmp.json", { cwd: ROOT, stdio: "inherit" }); console.log(" โœ… MITM utilities compiled to app/src/mitm/"); } catch (err: any) { console.warn(" โš ๏ธ MITM compile warning (non-fatal):", err.message); // Fallback: copy source files so at least they are present cpSync(mitmSrc, mitmDest, { recursive: true }); } finally { // Cleanup temp tsconfig try { rmSync(tmpTsconfigPath); } catch {} } } // โ”€โ”€ Step 8.5: Bundle MCP server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const mcpSrcFile = join(ROOT, "open-sse", "mcp-server", "server.ts"); const mcpDestDir = join(APP_DIR, "open-sse", "mcp-server"); const mcpDestFile = join(mcpDestDir, "server.js"); if (existsSync(mcpSrcFile)) { console.log(" ๐Ÿ”จ Bundling MCP Server (TypeScript โ†’ JavaScript)..."); mkdirSync(mcpDestDir, { recursive: true }); try { execSync( `npx esbuild open-sse/mcp-server/server.ts --bundle --platform=node --packages=external --format=esm --outfile=app/open-sse/mcp-server/server.js`, { cwd: ROOT, stdio: "inherit" } ); console.log(" โœ… MCP Server bundled to app/open-sse/mcp-server/server.js"); } catch (err: any) { console.warn(" โš ๏ธ MCP Server bundle error:", err.message); } } // โ”€โ”€ Step 8.7: Bundle CLI Entrypoint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const cliSrcFile = join(ROOT, "bin", "omniroute.ts"); const cliDestFile = join(ROOT, "bin", "omniroute.mjs"); if (existsSync(cliSrcFile)) { console.log(" ๐Ÿ”จ Bundling CLI Entrypoint (TypeScript โ†’ JavaScript)..."); try { execSync( `npx esbuild bin/omniroute.ts --bundle --platform=node --packages=external --format=esm --outfile=bin/omniroute.mjs`, { cwd: ROOT, stdio: "inherit" } ); execSync(`chmod +x bin/omniroute.mjs`, { cwd: ROOT }); console.log(" โœ… CLI Entrypoint bundled to bin/omniroute.mjs"); } catch (err: any) { console.warn(" โš ๏ธ CLI bundle error:", err.message); } } // โ”€โ”€ Step 9: Copy shared utilities needed at runtime โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const sharedApiKey = join(ROOT, "src", "shared", "utils", "apiKey.js"); const sharedApiKeyDest = join(APP_DIR, "src", "shared", "utils"); if (existsSync(sharedApiKey)) { console.log(" ๐Ÿ“‹ Copying shared utilities..."); mkdirSync(sharedApiKeyDest, { recursive: true }); cpSync(sharedApiKey, join(sharedApiKeyDest, "apiKey.js")); } // โ”€โ”€ Step 9.5: Copy minimal runtime sidecars required outside .next โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const envExampleSrc = join(ROOT, ".env.example"); if (existsSync(envExampleSrc)) { cpSync(envExampleSrc, join(APP_DIR, ".env.example")); } const openapiSpecSrc = join(ROOT, "docs", "openapi.yaml"); if (existsSync(openapiSpecSrc)) { const docsDest = join(APP_DIR, "docs"); mkdirSync(docsDest, { recursive: true }); cpSync(openapiSpecSrc, join(docsDest, "openapi.yaml")); } const syncEnvSrc = join(ROOT, "scripts", "sync-env.mjs"); if (existsSync(syncEnvSrc)) { const scriptsDest = join(APP_DIR, "scripts"); mkdirSync(scriptsDest, { recursive: true }); cpSync(syncEnvSrc, join(scriptsDest, "sync-env.mjs")); } const migrationsSrc = join(ROOT, "src", "lib", "db", "migrations"); if (existsSync(migrationsSrc)) { const migrationsDest = join(APP_DIR, "src", "lib", "db", "migrations"); mkdirSync(join(APP_DIR, "src", "lib", "db"), { recursive: true }); cpSync(migrationsSrc, migrationsDest, { recursive: true, force: true }); } // โ”€โ”€ Step 10: Ensure data/ directory exists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ mkdirSync(join(APP_DIR, "data"), { recursive: true }); // โ”€โ”€ Step 10.5: Copy @swc/helpers into standalone โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Next.js standalone tracer sometimes omits @swc/helpers from app/node_modules/, // causing MODULE_NOT_FOUND at runtime. Always copy it explicitly. const swcHelpersSrc = join(ROOT, "node_modules", "@swc", "helpers"); const swcHelpersDst = join(APP_DIR, "node_modules", "@swc", "helpers"); if (existsSync(swcHelpersSrc) && !existsSync(swcHelpersDst)) { console.log(" ๐Ÿ“‹ Copying @swc/helpers to standalone app/node_modules..."); mkdirSync(join(APP_DIR, "node_modules", "@swc"), { recursive: true }); cpSync(swcHelpersSrc, swcHelpersDst, { recursive: true }); console.log(" โœ… @swc/helpers included in standalone build."); } // โ”€โ”€ Step 10.6: Remove development-only residue from staged app/ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ for (const relativePath of APP_STAGING_REMOVAL_PATHS) { const targetPath = join(APP_DIR, relativePath); if (existsSync(targetPath)) { console.log(` ๐Ÿงน Removing app/${relativePath} (not needed in npm package)...`); rmSync(targetPath, { recursive: true, force: true }); console.log(` โœ… app/${relativePath} removed.`); } } // โ”€โ”€ Step 10.7: Prune any staged app/ file outside the allowed runtime set โ”€โ”€โ”€ const stagedFiles = walkFiles(APP_DIR); const unexpectedStagedFiles = findUnexpectedArtifactPaths(stagedFiles, { exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS, prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES, }); if (unexpectedStagedFiles.length > 0) { console.log(" ๐Ÿงน Pruning unexpected files from staged app/..."); unexpectedStagedFiles.forEach((unexpectedPath: string) => { rmSync(join(APP_DIR, unexpectedPath), { force: true }); console.log(` โœ… Removed app/${unexpectedPath}`); }); removeEmptyDirectories(APP_DIR); } const remainingUnexpectedFiles = findUnexpectedArtifactPaths(walkFiles(APP_DIR), { exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS, prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES, }); if (remainingUnexpectedFiles.length > 0) { console.error("\n โŒ Staged app/ still contains unexpected publish artifacts:"); remainingUnexpectedFiles.forEach((violation: string) => console.error(` - app/${violation}`)); process.exit(1); } // โ”€โ”€ Done โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const appPkg = join(APP_DIR, "package.json"); if (existsSync(appPkg)) { const pkg = JSON.parse(readFileSync(appPkg, "utf8")); console.log(`\n โœ… Build complete!`); console.log(` App directory: app/`); console.log(` Server entry: app/server.js`); } else { console.log(`\n โœ… Build complete! (app/ ready for publish)`); } console.log("");