#!/usr/bin/env node /** * OmniRoute โ€” Prepublish Build Script * * Consumes the .build/next/standalone artifact produced by `npm run build` * (build-next-isolated.mjs) and assembles the npm staging `dist/` directory. * Does NOT run a second `next build` โ€” the caller must run `npm run build` first, * or this script will invoke it exactly once if the artifact is absent. * * Run with: node scripts/build/prepublish.ts */ import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, cpSync, rmSync, writeFileSync, readFileSync, readdirSync, statSync, chmodSync, } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { assembleStandalone } from "./assembleStandalone.mjs"; 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 NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx"; const DIST_DIR = join(ROOT, "dist"); const METHOD_GUARD_REQUIRE = 'require("./http-method-guard.cjs").installHttpMethodGuard();\n'; 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 dist/ directory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (existsSync(DIST_DIR)) { console.log(" ๐Ÿงน Cleaning previous dist/ directory..."); rmSync(DIST_DIR, { recursive: true, force: true }); } // โ”€โ”€ Step 2: Assert / trigger the Next.js standalone build โ”€โ”€ // prepublish no longer runs its own `next build`. It consumes the // .build/next/standalone artifact produced by `npm run build` (build-next-isolated.mjs). // If the artifact is absent we invoke it exactly once. const NEXT_DIST = process.env.NEXT_DIST_DIR || ".build/next"; const standaloneServerJs = join(ROOT, NEXT_DIST, "standalone", "server.js"); if (!existsSync(standaloneServerJs)) { console.log(" ๐Ÿ—๏ธ .build/next/standalone not found โ€” running `npm run build` once..."); execFileSync(process.execPath, ["scripts/build/build-next-isolated.mjs"], { cwd: ROOT, stdio: "inherit", }); if (!existsSync(standaloneServerJs)) { console.error( "\n โŒ Standalone build not found after `npm run build` at:", standaloneServerJs ); console.error(" Make sure next.config.mjs has: output: 'standalone'"); process.exit(1); } } console.log(" โœ… Standalone artifact present:", standaloneServerJs); // โ”€โ”€ Step 3โ€“7: Assemble standalone into dist/ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // All shared copy/sync/sanitize/chunk-patch operations are delegated to // assembleStandalone. npm-UNIQUE steps (MITM, MCP, CLI, sidecars) follow. console.log(" ๐Ÿ“‹ Assembling standalone bundle into dist/..."); assembleStandalone({ distDir: join(ROOT, NEXT_DIST), outDir: DIST_DIR, projectRoot: ROOT, sanitizePaths: true, patchTurbopackChunks: true, copyNatives: true, }); console.log(" โœ… Standalone bundle assembled to dist/"); const distServer = join(DIST_DIR, "server.js"); const methodGuardSrc = join(ROOT, "scripts", "dev", "http-method-guard.cjs"); const methodGuardDest = join(DIST_DIR, "http-method-guard.cjs"); if (existsSync(methodGuardSrc)) { cpSync(methodGuardSrc, methodGuardDest); } if (existsSync(distServer)) { const serverSource = readFileSync(distServer, "utf8"); if (!serverSource.includes("installHttpMethodGuard")) { writeFileSync(distServer, METHOD_GUARD_REQUIRE + serverSource); console.log(" โœ… Patched dist/server.js with HTTP method guard."); } } // โ”€โ”€ Step 8: Compile + copy MITM cert utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const mitmSrc = join(ROOT, "src", "mitm"); const mitmDest = join(DIST_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: "ES2022", module: "NodeNext", moduleResolution: "NodeNext", outDir: mitmDest, rootDir: mitmSrc, strict: false, noImplicitAny: false, strictNullChecks: false, noEmitOnError: true, allowImportingTsExtensions: true, rewriteRelativeImportExtensions: true, ignoreDeprecations: "6.0", 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 { execFileSync(NPX_BIN, ["tsc", "-p", "tsconfig.mitm.tmp.json"], { cwd: ROOT, stdio: "inherit", }); const mitmServerSrc = join(mitmSrc, "server.cjs"); if (existsSync(mitmServerSrc)) { cpSync(mitmServerSrc, join(mitmDest, "server.cjs")); } console.log(" โœ… MITM utilities compiled to dist/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(DIST_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 { execFileSync( NPX_BIN, [ "esbuild", "open-sse/mcp-server/server.ts", "--bundle", "--platform=node", "--packages=external", "--format=esm", "--outfile=dist/open-sse/mcp-server/server.js", ], { cwd: ROOT, stdio: "inherit" } ); console.log(" โœ… MCP Server bundled to dist/open-sse/mcp-server/server.js"); } catch (err: any) { console.warn(" โš ๏ธ MCP Server bundle error:", err.message); } } // โ”€โ”€ Step 8.6: Bundle LLMLingua ONNX worker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // The worker is spawned via worker_threads at a path the Next.js bundler cannot // statically trace, so it must ship as a standalone .js (mirrors the MCP-server // bundling above). Heavy deps (@atjsh/llmlingua-2 / @huggingface/transformers / // @tensorflow/tfjs / js-tiktoken) stay EXTERNAL โ€” they are optionalDependencies, // dynamically imported at runtime, and the worker fail-opens if any is absent. const llmWorkerSrc = join( ROOT, "open-sse", "services", "compression", "engines", "llmlingua", "onnxWorker.ts" ); const llmWorkerDestDir = join( DIST_DIR, "open-sse", "services", "compression", "engines", "llmlingua" ); if (existsSync(llmWorkerSrc)) { console.log(" ๐Ÿ”จ Bundling LLMLingua ONNX worker (TypeScript โ†’ JavaScript)..."); mkdirSync(llmWorkerDestDir, { recursive: true }); try { execFileSync( NPX_BIN, [ "esbuild", "open-sse/services/compression/engines/llmlingua/onnxWorker.ts", "--bundle", "--platform=node", "--packages=external", "--format=esm", "--outfile=dist/open-sse/services/compression/engines/llmlingua/onnxWorker.js", ], { cwd: ROOT, stdio: "inherit" } ); console.log( " โœ… LLMLingua worker bundled to dist/open-sse/services/compression/engines/llmlingua/onnxWorker.js" ); } catch (err: any) { console.warn(" โš ๏ธ LLMLingua worker 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 { execFileSync( NPX_BIN, [ "esbuild", "bin/omniroute.ts", "--bundle", "--platform=node", "--packages=external", "--format=esm", "--outfile=bin/omniroute.mjs", ], { cwd: ROOT, stdio: "inherit" } ); chmodSync(cliDestFile, 0o755); console.log(" โœ… CLI Entrypoint bundled to bin/omniroute.mjs"); } catch (err: any) { console.warn(" โš ๏ธ CLI bundle error:", err.message); } } // โ”€โ”€ Step 8.8: Build @omniroute/opencode-plugin โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // The plugin ships bundled inside the omniroute npm package (see root // package.json "files": ["@omniroute/", ...]). Its built `dist/` MUST be // present in the publish tarball so `omniroute setup opencode` can copy it // into the user's OpenCode plugin dir. If the build fails we surface the // error โ€” shipping without the plugin's dist breaks the documented install // flow for every downstream user. const opencodePluginSrc = join(ROOT, "@omniroute", "opencode-plugin"); const opencodePluginDist = join(opencodePluginSrc, "dist", "index.js"); const opencodePluginCjs = join(opencodePluginSrc, "dist", "index.cjs"); if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package.json"))) { const pluginAlreadyBuilt = existsSync(opencodePluginDist) && existsSync(opencodePluginCjs); if (!pluginAlreadyBuilt) { console.log("\n ๐Ÿ”จ Building @omniroute/opencode-plugin (tsup)..."); try { // The plugin is a standalone package (not an npm workspace), so the root // install never populates its node_modules โ€” and tsup with `dts: true` // needs the plugin's own devDependencies (typescript, @opencode-ai/plugin // types). Without this install a fresh CI publish fails at this step. if (!existsSync(join(opencodePluginSrc, "node_modules"))) { const NPM_BIN = process.platform === "win32" ? "npm.cmd" : "npm"; execFileSync(NPM_BIN, ["install", "--no-audit", "--no-fund"], { cwd: opencodePluginSrc, stdio: "inherit", }); } execFileSync(NPX_BIN, ["tsup"], { cwd: opencodePluginSrc, stdio: "inherit", env: { ...process.env, NODE_ENV: "production" }, }); console.log(" โœ… @omniroute/opencode-plugin bundled to @omniroute/opencode-plugin/dist/"); } catch (err: any) { console.error(" โŒ Failed to build @omniroute/opencode-plugin:", err.message); console.error(" The published package would be missing the plugin dist."); console.error( " Run `cd @omniroute/opencode-plugin && npm install && npm run build` to debug." ); process.exit(1); } } else { console.log(" โœ… @omniroute/opencode-plugin dist/ already present (skipping rebuild)"); } // Remove plugin node_modules after build โ€” hard links created by npm install on Linux // (CI runner) end up in the tarball as LINK entries, which npm registry rejects with // E415 "Hard link is not allowed". The node_modules are only needed for the tsup build; // they must not ship in the published package. const pluginNodeModules = join(opencodePluginSrc, "node_modules"); if (existsSync(pluginNodeModules)) { rmSync(pluginNodeModules, { recursive: true, force: true }); console.log(" ๐Ÿงน Removed @omniroute/opencode-plugin/node_modules (hard link guard)"); } } else { console.log(" โญ๏ธ @omniroute/opencode-plugin not found in workspace (skipping build)"); } // โ”€โ”€ Step 9: Copy shared utilities needed at runtime โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const sharedApiKey = join(ROOT, "src", "shared", "utils", "apiKey.js"); const sharedApiKeyDest = join(DIST_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(DIST_DIR, ".env.example")); } const openapiSpecSrc = join(ROOT, "docs", "openapi.yaml"); if (existsSync(openapiSpecSrc)) { const docsDest = join(DIST_DIR, "docs"); mkdirSync(docsDest, { recursive: true }); cpSync(openapiSpecSrc, join(docsDest, "openapi.yaml")); } const docsMarkdownSrc = join(ROOT, "docs"); if (existsSync(docsMarkdownSrc)) { const docsDest = join(DIST_DIR, "docs"); mkdirSync(docsDest, { recursive: true }); const mdFiles = readdirSync(docsMarkdownSrc).filter( (f) => f.endsWith(".md") || f.endsWith(".mdx") ); for (const mdFile of mdFiles) { cpSync(join(docsMarkdownSrc, mdFile), join(docsDest, mdFile)); } if (mdFiles.length > 0) { console.log(`[prepublish] Copied ${mdFiles.length} docs markdown files to dist/docs/`); } } const syncEnvSrc = join(ROOT, "scripts", "sync-env.mjs"); if (existsSync(syncEnvSrc)) { const scriptsDest = join(DIST_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(DIST_DIR, "src", "lib", "db", "migrations"); mkdirSync(join(DIST_DIR, "src", "lib", "db"), { recursive: true }); cpSync(migrationsSrc, migrationsDest, { recursive: true, force: true }); } const runtimeAssetDirs = [ { source: join(ROOT, "open-sse", "services", "compression", "engines", "rtk", "filters"), destination: join(DIST_DIR, "open-sse", "services", "compression", "engines", "rtk", "filters"), }, { source: join(ROOT, "open-sse", "services", "compression", "rules"), destination: join(DIST_DIR, "open-sse", "services", "compression", "rules"), }, ]; for (const assetDir of runtimeAssetDirs) { if (existsSync(assetDir.source)) { mkdirSync(dirname(assetDir.destination), { recursive: true }); cpSync(assetDir.source, assetDir.destination, { recursive: true, force: true }); } } // โ”€โ”€ Step 10: Ensure data/ directory exists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ mkdirSync(join(DIST_DIR, "data"), { recursive: true }); // โ”€โ”€ Step 10.5: Copy @swc/helpers into standalone โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Next.js standalone tracer sometimes omits @swc/helpers from dist/node_modules/, // causing MODULE_NOT_FOUND at runtime. Always copy it explicitly. const swcHelpersSrc = join(ROOT, "node_modules", "@swc", "helpers"); const swcHelpersDst = join(DIST_DIR, "node_modules", "@swc", "helpers"); if (existsSync(swcHelpersSrc) && !existsSync(swcHelpersDst)) { console.log(" ๐Ÿ“‹ Copying @swc/helpers to standalone dist/node_modules..."); mkdirSync(join(DIST_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 dist/ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ for (const relativePath of APP_STAGING_REMOVAL_PATHS) { const targetPath = join(DIST_DIR, relativePath); if (existsSync(targetPath)) { console.log(` ๐Ÿงน Removing dist/${relativePath} (not needed in npm package)...`); rmSync(targetPath, { recursive: true, force: true }); console.log(` โœ… dist/${relativePath} removed.`); } } // โ”€โ”€ Step 10.7: Prune any staged dist/ file outside the allowed runtime set โ”€โ”€ const stagedFiles = walkFiles(DIST_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 dist/..."); unexpectedStagedFiles.forEach((unexpectedPath: string) => { rmSync(join(DIST_DIR, unexpectedPath), { force: true }); console.log(` โœ… Removed dist/${unexpectedPath}`); }); removeEmptyDirectories(DIST_DIR); } const remainingUnexpectedFiles = findUnexpectedArtifactPaths(walkFiles(DIST_DIR), { exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS, prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES, }); if (remainingUnexpectedFiles.length > 0) { console.error("\n โŒ Staged dist/ still contains unexpected publish artifacts:"); remainingUnexpectedFiles.forEach((violation: string) => console.error(` - dist/${violation}`) ); process.exit(1); } // โ”€โ”€ Done โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const distPkg = join(DIST_DIR, "package.json"); if (existsSync(distPkg)) { JSON.parse(readFileSync(distPkg, "utf8")); console.log(`\n โœ… Build complete!`); console.log(` Dist directory: dist/`); console.log(` Server entry: dist/server.js`); } else { console.log(`\n โœ… Build complete! (dist/ ready for publish)`); } console.log("");