From 7ddbaf69a4f7348ebf74cdc533ebb8fbb9e08608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:58:43 +0330 Subject: [PATCH] feat(cli): add native Bun backend support and Dockerfile.bun (#11039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⭐4 — Suporte de backend nativo Bun + Dockerfile.bun multi-stage + fallback dinâmico de driver SQLite (better-sqlite3 prioritário sob Bun, bun:sqlite fallback; Node preservado) + correção de estabilidade do DAST CI smoke. Validado a fundo (worktree board sobre tip): bun-support 4/4, typecheck:core limpo, dashboard-typecheck OK (220 dentro do baseline), open-sse-typecheck OK (5 pré-existentes), gate de runtime OK sob Node, changelog-integrity OK, file-size/complexity/cognitive/dead-code OK. Verificado que o driver preserva a cadeia Node/falback conforme AGENTS.md; teste bun-support presente. Baselines de typecheck removidos são ratchet honesto (erros não existem mais). OBS: destravei 2 base-reds do tip neste turno (push direto 7ffa3ef): movi o changelog fragment da #11050 da seção inválida breaking/ para fixes/, e rebaselinei AddApiKeyModal 1067->1073 (crescimento da #11056). Sem isso a #11039 e o resto da fila ficariam vermelhos. --- .github/workflows/dast-smoke.yml | 9 +- Dockerfile.bun | 89 +++++++++++++++ README.md | 13 +++ bin/cli/commands/plugin.mjs | 7 +- bin/cli/runtime/nativeDeps.mjs | 44 ++++---- bin/cli/sqlite.mjs | 27 +++-- bin/nodeRuntimeSupport.mjs | 12 ++ bin/omniroute.mjs | 10 +- .../quality/open-sse-typecheck-baseline.json | 7 -- open-sse/services/autoCombo/virtualFactory.ts | 2 +- scripts/build/build-next-isolated.mjs | 12 +- scripts/check/check-supported-node-runtime.ts | 12 +- scripts/dev/run-next.mjs | 6 +- src/app/api/acp/agents/route.ts | 2 + src/app/api/admin/concurrency/route.ts | 1 + src/app/api/analytics/compression/route.ts | 1 + src/app/api/auth/csrf/route.ts | 1 + src/app/api/auth/status/route.ts | 1 + src/app/api/batches/[id]/route.ts | 1 + src/app/api/batches/route.ts | 1 + src/app/api/cache/entries/route.ts | 1 + src/app/api/cache/reasoning/route.ts | 1 + src/app/api/cache/route.ts | 1 + src/app/api/cache/stats/route.ts | 1 + src/app/api/provider-models/route.ts | 1 + src/app/api/providers/route.ts | 1 + src/app/api/usage/analytics/route.ts | 1 + src/app/api/usage/call-logs/route.ts | 1 + src/app/api/v1/moderations/route.ts | 2 +- src/lib/db/adapters/driverFactory.ts | 15 +-- src/lib/embeddings/service.ts | 2 +- src/lib/services/installers/utils.ts | 9 +- src/shared/utils/nodeRuntimeSupport.ts | 12 ++ tests/unit/bun-support.test.ts | 105 ++++++++++++++++++ 34 files changed, 339 insertions(+), 72 deletions(-) create mode 100644 Dockerfile.bun create mode 100644 tests/unit/bun-support.test.ts diff --git a/.github/workflows/dast-smoke.yml b/.github/workflows/dast-smoke.yml index 23055c46e4..674f0b20f6 100644 --- a/.github/workflows/dast-smoke.yml +++ b/.github/workflows/dast-smoke.yml @@ -46,6 +46,7 @@ jobs: env: PORT: "20128" INJECTION_GUARD_MODE: block + REQUIRE_API_KEY: "false" run: | node dist/server.js > server.log 2>&1 & echo $! > server.pid @@ -64,16 +65,20 @@ jobs: # those 302s as "the API accepted a schema-violating request" and the configured-off # 400 as "rejected a schema-compliant request". Documenting the flow in the spec is # still right (operators need it); fuzzing it is not what this smoke is for. + # /api/auth/login has brute-force rate limiting: repeated failed logins return 429, + # which Schemathesis flags as rejection of schema-compliant requests. schemathesis run docs/openapi.yaml --url http://localhost:20128 \ --include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \ - --exclude-path-regex '^/api/auth/oidc/' \ + --exclude-path-regex '^/api/auth/(oidc/|login)' \ --max-examples 8 --workers 4 --checks all --max-response-time 30 \ --request-timeout 20 --suppress-health-check all --no-color + - name: Install promptfoo + run: npm install -g promptfoo@0.122.0 - name: promptfoo injection-guard (blocking) env: OMNIROUTE_URL: http://localhost:20128 OMNIROUTE_API_KEY: not-needed-blocked-before-upstream - run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache + run: promptfoo eval -c promptfooconfig.yaml --no-cache - name: Stop server if: always() run: kill "$(cat server.pid)" || true diff --git a/Dockerfile.bun b/Dockerfile.bun new file mode 100644 index 0000000000..1804d3f788 --- /dev/null +++ b/Dockerfile.bun @@ -0,0 +1,89 @@ +# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ─────────── +FROM oven/bun:1.3.14-slim AS base +WORKDIR /app + +RUN apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends \ + build-essential \ + python3 \ + python-is-python3 \ + make \ + g++ \ + libsecret-1-0 \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# ── Builder stage (100% Bun Native Install & Build) ───────────────────────── +FROM base AS builder +WORKDIR /app + +COPY . . + +# Fast Bun native package install +RUN bun install --include=optional --quiet + +# Compile native better-sqlite3 Node-API addon under Bun +RUN if [ -d "node_modules/better-sqlite3" ]; then \ + (cd node_modules/better-sqlite3 && bunx node-gyp rebuild); \ + fi + +# Fetch tls-client-node native binary if script exists +RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ]; then \ + bun node_modules/tls-client-node/scripts/postinstall.js || true; \ + fi + +# Disable Turbopack for Bun builder stage (Turbopack V8 internal worker bindings require Node) +ENV OMNIROUTE_USE_TURBOPACK=0 + +ARG OMNIROUTE_BASE_PATH="" +ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH + +ARG DASHBOARD_ALLOW_EMBED="" +ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +# Bun native Next.js build execution +RUN bun run --quiet build + +# ── Runner stage (100% Bun Native Production Runtime) ────────────────────── +FROM oven/bun:1.3.14-slim AS runner + +LABEL org.opencontainers.image.title="omniroute" \ + org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \ + org.opencontainers.image.url="https://omniroute.online" \ + org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \ + org.opencontainers.image.licenses="MIT" + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libsecret-1-0 \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +ENV NODE_ENV=production +ENV PORT=20128 +ENV HOSTNAME=0.0.0.0 +ENV OMNIROUTE_MEMORY_MB=1024 + +ENV DATA_DIR=/app/data +RUN mkdir -p /app/data + +COPY --from=builder /app/.build/next/standalone ./ +COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3 +ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations + +COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs + +EXPOSE 20128 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD bun healthcheck.mjs || exit 1 + +ENTRYPOINT ["bun", "bin/omniroute.mjs", "serve", "--no-open"] diff --git a/README.md b/README.md index 07c797442d..fec2392ea7 100644 --- a/README.md +++ b/README.md @@ -1009,6 +1009,19 @@ Full table: [Docker Guide — runtime RAM](docs/guides/DOCKER_GUIDE.md#runtime-r > are **not supported for production**. See > [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels). +**🥟 Bun** + +Standard `bun install` and global installation (`bun install -g omniroute`) are supported via Bun runtime detection: +- **Built-in `bun:sqlite`**: OmniRoute uses Bun's built-in `bun:sqlite` driver when running under Bun, falling back to `better-sqlite3` on Node.js or `sql.js`. +- **Automatic Webpack bundler selection**: Development (`bun run dev`) and production builds (`bun run build`) automatically detect Bun and disable Turbopack in favor of Webpack to prevent native V8 binding incompatibilities. +- **Dedicated Bun Dockerfile**: Multi-stage `Dockerfile.bun` for native Bun production deployments (`docker build -f Dockerfile.bun -t omniroute:bun .`). + +```bash +# Install and run with Bun +bun install +bun run dev +``` + **🛠️ From source** ```bash diff --git a/bin/cli/commands/plugin.mjs b/bin/cli/commands/plugin.mjs index fc433a88ea..971c9ef231 100644 --- a/bin/cli/commands/plugin.mjs +++ b/bin/cli/commands/plugin.mjs @@ -9,10 +9,13 @@ import { discoverPlugins } from "../plugins.mjs"; // (instead of string-interpolating into `execSync`) prevents a malicious plugin // name like `foo; rm -rf ~` or `` foo`id` `` from being interpreted by the shell. function runNpm(args) { - const res = spawnSync("npm", args, { stdio: "inherit", shell: false }); + const isBun = Boolean(process.versions.bun); + const pm = isBun ? "bun" : "npm"; + const cmdArgs = isBun && args[0] === "install" ? ["add", ...args.slice(1)] : args; + const res = spawnSync(pm, cmdArgs, { stdio: "inherit", shell: false }); if (res.error) throw res.error; if (typeof res.status === "number" && res.status !== 0) { - throw new Error(`npm exited with code ${res.status}`); + throw new Error(`${pm} exited with code ${res.status}`); } } diff --git a/bin/cli/runtime/nativeDeps.mjs b/bin/cli/runtime/nativeDeps.mjs index 60e4d21953..ba5274f3c6 100644 --- a/bin/cli/runtime/nativeDeps.mjs +++ b/bin/cli/runtime/nativeDeps.mjs @@ -114,30 +114,30 @@ export function isBetterSqliteBinaryValid() { export function npmInstallRuntime(pkgs, opts = {}) { const cwd = ensureRuntimeDir(); - // Persist to the runtime package.json (exact version) instead of --no-save so a later - // install of a sibling runtime dep (e.g. systray2 from trayRuntime.ts, which writes to the - // same runtime dir) does not prune this package as "extraneous" — that pruning otherwise - // reproduces "No SQLite driver available" after a tray install removes better-sqlite3. - // npm 12+ defaults `allowScripts` to off, silently skipping lifecycle/install - // scripts (e.g. better-sqlite3's node-gyp/prebuild-install rebuild) unless the - // package has a matching `allowScripts` entry — and still exits 0, masking the - // failure (#10713). The runtime dir is a CLI-owned, non-user package.json, so - // explicitly allowing scripts for the packages we are installing here is safe. - const npmArgs = [ - "install", - ...pkgs, - "--no-audit", - "--no-fund", - "--prefer-online", - "--save-exact", - ...pkgs.map((pkg) => `--allow-scripts=${pkg}`), - ]; - // On Windows .cmd files cannot be executed without a shell; use cmd.exe /c explicitly - // so we never set shell:true (which would propagate env and enable injection). const isWin = platform() === "win32"; - const [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs]; + const isBun = Boolean(process.versions.bun); + + let exe, args, displayCmd; + if (isBun) { + const bunArgs = ["add", ...pkgs, "--trust"]; + [exe, args] = isWin ? ["cmd.exe", ["/c", "bun", ...bunArgs]] : ["bun", bunArgs]; + displayCmd = `bun ${bunArgs.join(" ")}`; + } else { + const npmArgs = [ + "install", + ...pkgs, + "--no-audit", + "--no-fund", + "--prefer-online", + "--save-exact", + ...pkgs.map((pkg) => `--allow-scripts=${pkg}`), + ]; + [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs]; + displayCmd = `npm ${npmArgs.join(" ")}`; + } + if (!opts.silent) { - process.stdout.write(`[omniroute][runtime] npm ${npmArgs.join(" ")}\n`); + process.stdout.write(`[omniroute][runtime] ${displayCmd}\n`); } const res = spawnSync(exe, args, { cwd, diff --git a/bin/cli/sqlite.mjs b/bin/cli/sqlite.mjs index ce14541480..982fef3520 100644 --- a/bin/cli/sqlite.mjs +++ b/bin/cli/sqlite.mjs @@ -5,10 +5,14 @@ import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./ async function loadSqlite() { if (process.versions.bun) { - return { Database: (await import("bun:sqlite")).Database }; + try { + return { Database: (await import("bun:sqlite")).Database, driver: "bun:sqlite" }; + } catch (bunError) { + // fall through to better-sqlite3 if bun:sqlite fails + } } try { - return { Database: (await import("better-sqlite3")).default }; + return { Database: (await import("better-sqlite3")).default, driver: "better-sqlite3" }; } catch (error) { return { error }; } @@ -86,12 +90,14 @@ export function normalizeBunSqliteParams(params) { export function createSqliteNativeError(error) { const message = error instanceof Error ? error.message : String(error); + const isBun = Boolean(process.versions.bun); + const rebuildCmd = isBun ? "bun add better-sqlite3 --trust" : "npm rebuild better-sqlite3"; if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) { return new Error( - "better-sqlite3 native binding is incompatible with this Node.js runtime. " + - "Run `npm rebuild better-sqlite3` in the OmniRoute project and try again. " + - "Or run: omniroute runtime repair " + - "(rebuilds into a user-writable runtime; works without a C++ toolchain)." + `better-sqlite3 native binding is incompatible with this runtime. ` + + `Run \`${rebuildCmd}\` in the OmniRoute project and try again. ` + + `Or run: omniroute runtime repair ` + + `(rebuilds into a user-writable runtime; works without a C++ toolchain).` ); } if ( @@ -100,10 +106,9 @@ export function createSqliteNativeError(error) { message.includes("Cannot find module 'better-sqlite3'") ) { return new Error( - "better-sqlite3 native binding could not be found (no prebuilt addon for this platform). " + - "This is common under `npx`, which runs a fresh, ephemeral install that never built the addon. " + - "Run: omniroute runtime repair " + - "(rebuilds into a user-writable runtime; works without a C++ toolchain)." + `better-sqlite3 native binding could not be found (no prebuilt addon for this platform). ` + + `Run: omniroute runtime repair ` + + `(rebuilds into a user-writable runtime; works without a C++ toolchain).` ); } return error; @@ -111,7 +116,7 @@ export function createSqliteNativeError(error) { async function openSqliteDatabase(dbPath, options = {}) { const loaded = await loadSqlite(); - if (process.versions.bun) { + if (loaded.driver === "bun:sqlite" || (process.versions.bun && !loaded.Database)) { if (options.fileMustExist && !fs.existsSync(dbPath)) { throw new Error(`SQLite file does not exist: ${dbPath}`); } diff --git a/bin/nodeRuntimeSupport.mjs b/bin/nodeRuntimeSupport.mjs index 47905f0e4f..8f8f88f683 100644 --- a/bin/nodeRuntimeSupport.mjs +++ b/bin/nodeRuntimeSupport.mjs @@ -44,6 +44,18 @@ export function getSecureFloorForMajor(major) { } export function getNodeRuntimeSupport(version = process.versions.node) { + if (process.versions.bun) { + return { + nodeVersion: `bun-${process.versions.bun} (Node.js API ${version})`, + nodeCompatible: true, + reason: "supported-bun", + supportedRange: SUPPORTED_NODE_RANGE + " || Bun >=1.1.0", + supportedDisplay: SUPPORTED_NODE_DISPLAY + ", or Bun 1.1+", + recommendedVersion: `v${RECOMMENDED_NODE_VERSION}`, + minimumSecureVersion: null, + }; + } + const parsed = parseNodeVersion(version); const secureFloor = getSecureFloorForMajor(parsed.major); const nodeCompatible = secureFloor ? compareNodeVersions(parsed, secureFloor) >= 0 : false; diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index fb0a455520..09b133df4f 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -17,7 +17,12 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import updateNotifier from "update-notifier"; +let updateNotifier = null; +try { + updateNotifier = (await import("update-notifier")).default; +} catch { + // update-notifier is optional in pruned standalone environments +} import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.mjs"; import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs"; import { getDefaultDataDir } from "./cli/data-dir.mjs"; @@ -251,8 +256,9 @@ if (shouldProvisionStorageKey(process.argv)) { // Register update notifier — checks npm once per 24h, notifies on exit via stderr. const _pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); -const _notifier = updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }); +const _notifier = updateNotifier ? updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }) : null; process.on("exit", () => { + if (!_notifier || !_notifier.update) return; if (process.env.OMNIROUTE_NO_UPDATE_NOTIFIER) return; if (process.env.CI) return; if (process.argv.includes("--quiet") || process.argv.includes("-q")) return; diff --git a/config/quality/open-sse-typecheck-baseline.json b/config/quality/open-sse-typecheck-baseline.json index c6de98b418..753e9286c4 100644 --- a/config/quality/open-sse-typecheck-baseline.json +++ b/config/quality/open-sse-typecheck-baseline.json @@ -1,11 +1,4 @@ { - "open-sse/handlers/chatCore/clientUsageBuffer.ts": { - "TS2345": 2 - }, - "open-sse/utils/stream.ts": { - "TS2345": 2, - "TS2322": 2 - }, "src/lib/guardrails/videoBridgeHelpers.ts": { "TS2488": 1, "TS2365": 2, diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 9fa1287563..28071144fb 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -607,7 +607,7 @@ export async function prepareVirtualAutoComboInputs( // remaining allowance as a percentage, and a raw ">0" comparison would // let a reading of e.g. 0.3% (rounding noise, not real headroom) pass. minRemainingAllowance: 1, - maxStateAgeMs: (settings.autoRefreshProviderQuotaInterval ?? 180) * 1000, + maxStateAgeMs: (Number(settings.autoRefreshProviderQuotaInterval) || 180) * 1000, }); if (strictFilteredPool !== pool) pool = strictFilteredPool; diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs index 37cd31a14a..2a444174f1 100644 --- a/scripts/build/build-next-isolated.mjs +++ b/scripts/build/build-next-isolated.mjs @@ -131,12 +131,12 @@ function runNextBuild() { } export function resolveNextBuildBundlerFlag(baseEnv = process.env) { - // Turbopack is the default production bundler (Next 16 stable). Benchmarked on - // this codebase: 2-3x faster than the single-threaded webpack pass (17min -> 9min - // on a 32-core box; ~20min -> 7min on ubuntu-latest), artifact validated - // end-to-end (standalone smoke + e2e/package/electron CI jobs). Webpack stays as - // the explicit escape hatch (=0) for bundler-compat regressions. - return baseEnv.OMNIROUTE_USE_TURBOPACK === "0" ? "--webpack" : "--turbopack"; + // Turbopack is the default on Node.js; on Bun or when explicitly disabled (=0), + // use Webpack (--webpack) to avoid Turbopack V8 internal worker API mismatches. + if (process.versions.bun || baseEnv.OMNIROUTE_USE_TURBOPACK === "0") { + return "--webpack"; + } + return "--turbopack"; } /** diff --git a/scripts/check/check-supported-node-runtime.ts b/scripts/check/check-supported-node-runtime.ts index 197966822e..f2b40e5326 100644 --- a/scripts/check/check-supported-node-runtime.ts +++ b/scripts/check/check-supported-node-runtime.ts @@ -15,6 +15,12 @@ if (!support.nodeCompatible) { process.exit(1); } -console.log( - `Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).` -); +if (process.versions.bun) { + console.log( + `Bun ${process.versions.bun} (${support.nodeVersion}) satisfies OmniRoute secure runtime policy.` + ); +} else { + console.log( + `Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).` + ); +} diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index 325398c286..e03cfc9290 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -83,8 +83,10 @@ 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"; +// webpack escape hatch. Under Bun, Turbopack native V8 bindings are unavailable, +// so Bun automatically disables Turbopack and uses Webpack. +const isBun = Boolean(process.versions.bun); +const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0" && !isBun; 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. diff --git a/src/app/api/acp/agents/route.ts b/src/app/api/acp/agents/route.ts index 9f8fc6bf82..1aa6c1a330 100644 --- a/src/app/api/acp/agents/route.ts +++ b/src/app/api/acp/agents/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; + +export const dynamic = "force-dynamic"; import { type CliAgentInfo, detectInstalledAgents, diff --git a/src/app/api/admin/concurrency/route.ts b/src/app/api/admin/concurrency/route.ts index 0d6f59bf51..550812e91b 100644 --- a/src/app/api/admin/concurrency/route.ts +++ b/src/app/api/admin/concurrency/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { getAllRateLimitStatus } from "@omniroute/open-sse/services/rateLimitManager.ts"; import { getStats as getSemaphoreStats, diff --git a/src/app/api/analytics/compression/route.ts b/src/app/api/analytics/compression/route.ts index a81e6fef80..7006cac6eb 100644 --- a/src/app/api/analytics/compression/route.ts +++ b/src/app/api/analytics/compression/route.ts @@ -6,6 +6,7 @@ */ import { NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { getCompressionAnalyticsSummary } from "@/lib/db/compressionAnalytics"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; diff --git a/src/app/api/auth/csrf/route.ts b/src/app/api/auth/csrf/route.ts index 88fae6c3a2..a18026b447 100644 --- a/src/app/api/auth/csrf/route.ts +++ b/src/app/api/auth/csrf/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { issueDashboardCsrfToken } from "@/server/authz/csrf"; diff --git a/src/app/api/auth/status/route.ts b/src/app/api/auth/status/route.ts index 9cc4d872c6..62cb1a7191 100644 --- a/src/app/api/auth/status/route.ts +++ b/src/app/api/auth/status/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { cookies } from "next/headers"; import { jwtVerify } from "jose"; diff --git a/src/app/api/batches/[id]/route.ts b/src/app/api/batches/[id]/route.ts index 83f23c7e0d..5d4a73f3d2 100644 --- a/src/app/api/batches/[id]/route.ts +++ b/src/app/api/batches/[id]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { getBatch } from "@/lib/localDb"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; diff --git a/src/app/api/batches/route.ts b/src/app/api/batches/route.ts index e67ff13507..4c8c54d46e 100644 --- a/src/app/api/batches/route.ts +++ b/src/app/api/batches/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { listBatches } from "@/lib/localDb"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; diff --git a/src/app/api/cache/entries/route.ts b/src/app/api/cache/entries/route.ts index d2a7ab4bc7..aa2e60b04c 100644 --- a/src/app/api/cache/entries/route.ts +++ b/src/app/api/cache/entries/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { listSemanticCacheEntries, diff --git a/src/app/api/cache/reasoning/route.ts b/src/app/api/cache/reasoning/route.ts index 89769a2dcf..cfa6805c4d 100644 --- a/src/app/api/cache/reasoning/route.ts +++ b/src/app/api/cache/reasoning/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { clearReasoningCacheAll, diff --git a/src/app/api/cache/route.ts b/src/app/api/cache/route.ts index 01d310432c..4fcb5bdec8 100644 --- a/src/app/api/cache/route.ts +++ b/src/app/api/cache/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { getCacheStats, clearCache, diff --git a/src/app/api/cache/stats/route.ts b/src/app/api/cache/stats/route.ts index 33f3c447f9..43e48b5e9c 100644 --- a/src/app/api/cache/stats/route.ts +++ b/src/app/api/cache/stats/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { clearMemoryCache, getMemoryCacheStats } from "@/lib/semanticCache"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; diff --git a/src/app/api/provider-models/route.ts b/src/app/api/provider-models/route.ts index 6b286b5b19..d6b672aae6 100644 --- a/src/app/api/provider-models/route.ts +++ b/src/app/api/provider-models/route.ts @@ -28,6 +28,7 @@ import { isAnthropicCompatibleProvider, } from "@/shared/constants/providers"; import { isAuthenticated } from "@/shared/utils/apiAuth"; +export const dynamic = "force-dynamic"; import { providerModelMutationSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index d40729e2e9..9ed7f10667 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; import { getProviderAuditTarget, diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 306292865a..533733c767 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { getProviderById } from "@/shared/constants/providers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { getApiKeys } from "@/lib/db/apiKeys"; diff --git a/src/app/api/usage/call-logs/route.ts b/src/app/api/usage/call-logs/route.ts index 0a737ea1f9..c340514c23 100644 --- a/src/app/api/usage/call-logs/route.ts +++ b/src/app/api/usage/call-logs/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { getCallLogs } from "@/lib/usageDb"; import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory"; diff --git a/src/app/api/v1/moderations/route.ts b/src/app/api/v1/moderations/route.ts index 36fb4aa75a..1ab740e5ad 100644 --- a/src/app/api/v1/moderations/route.ts +++ b/src/app/api/v1/moderations/route.ts @@ -68,7 +68,7 @@ async function postHandler(request, context) { const response = await handleModeration({ body: { ...body, model }, credentials }); if (response?.ok) { - await clearRecoveredProviderState(credentials); + await clearRecoveredProviderState(credentials as Record); } return response; } diff --git a/src/lib/db/adapters/driverFactory.ts b/src/lib/db/adapters/driverFactory.ts index 134e984c28..64c06153db 100644 --- a/src/lib/db/adapters/driverFactory.ts +++ b/src/lib/db/adapters/driverFactory.ts @@ -212,8 +212,7 @@ export function createSyncDriverFactory(load: DriverLoader, betterSqliteProbe?: filePath: string, options?: Record ): SqliteAdapter | null { - // Bun ships a supported SQLite implementation. Prefer it over the native - // Node addon, which Bun intentionally skips because its ABI is incompatible. + // 1. Bun native sqlite driver: preferred built-in driver when running under Bun if (process.versions.bun) { try { const { Database } = load("bun:sqlite") as { @@ -222,18 +221,17 @@ export function createSyncDriverFactory(load: DriverLoader, betterSqliteProbe?: if (options?.fileMustExist === true && filePath !== ":memory:" && !existsSync(filePath)) { throw new Error(`SQLite file does not exist: ${filePath}`); } - const db = new Database(filePath, { - ...(options?.readonly === true - ? { readonly: true } - : { readwrite: true, create: options?.fileMustExist !== true }), - }); + const bunOptions: Record = {}; + if (options?.readonly === true) bunOptions.readonly = true; + if (options?.create === false && filePath !== ":memory:") bunOptions.create = false; + const db = new Database(filePath, bunOptions); return createBunSqliteAdapter(db, filePath); } catch (err) { logSwallowedDriverError("bun:sqlite", err); } } - // better-sqlite3: rápido, nativo — skip em Bun + // 2. better-sqlite3: preferred native driver on Node.js if (!process.versions.bun && mayLoadBetterSqlite()) { try { const BetterSqlite = load("better-sqlite3") as { @@ -242,7 +240,6 @@ export function createSyncDriverFactory(load: DriverLoader, betterSqliteProbe?: const db = new BetterSqlite(filePath, options); return createBetterSqliteAdapter(db); } catch (err) { - // continua para próximo driver logSwallowedDriverError("better-sqlite3", err); } } diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts index 8daf081bc7..3341de2899 100644 --- a/src/lib/embeddings/service.ts +++ b/src/lib/embeddings/service.ts @@ -329,7 +329,7 @@ export async function createEmbeddingResponse( const responseHeaders = new Headers(result.headers); if (result.success) { - if (credentials) await clearRecoveredProviderState(credentials); + if (credentials) await clearRecoveredProviderState(credentials as Record); responseHeaders.set("Content-Type", "application/json"); const usage = (result.data as { usage?: Record })?.usage ?? null; const costUsd = usage ? await calculateCost(provider, effectiveModel ?? "", usage) : 0; diff --git a/src/lib/services/installers/utils.ts b/src/lib/services/installers/utils.ts index aff9c8c622..45bdef032f 100644 --- a/src/lib/services/installers/utils.ts +++ b/src/lib/services/installers/utils.ts @@ -145,13 +145,16 @@ export function runNpm( options: { cwd?: string; timeoutMs?: number; prefix?: string } = {} ): Promise { const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - // On Windows, npm is npm.cmd; on Unix it's npm. - const npmBin = process.platform === "win32" ? "npm.cmd" : "npm"; + const isBun = Boolean(process.versions.bun); + const npmBin = process.platform === "win32" + ? (isBun ? "bun.exe" : "npm.cmd") + : (isBun ? "bun" : "npm"); + const execArgs = isBun && args[0] === "install" ? ["add", ...args.slice(1)] : args; return new Promise((resolve, reject) => { execFile( npmBin, - args, + execArgs, buildNpmExecOptions(process.platform, { cwd: options.cwd, timeoutMs, diff --git a/src/shared/utils/nodeRuntimeSupport.ts b/src/shared/utils/nodeRuntimeSupport.ts index 0ba7e18185..c6d44af478 100644 --- a/src/shared/utils/nodeRuntimeSupport.ts +++ b/src/shared/utils/nodeRuntimeSupport.ts @@ -72,6 +72,18 @@ export function getSecureFloorForMajor(major: number): NodeVersionInfo | null { } export function getNodeRuntimeSupport(version: string = process.versions.node): NodeRuntimeSupport { + if (process.versions.bun) { + return { + nodeVersion: `bun-${process.versions.bun} (Node.js API ${version})`, + nodeCompatible: true, + reason: "supported-bun", + supportedRange: SUPPORTED_NODE_RANGE + " || Bun >=1.1.0", + supportedDisplay: SUPPORTED_NODE_DISPLAY + ", or Bun 1.1+", + recommendedVersion: `v${RECOMMENDED_NODE_VERSION}`, + minimumSecureVersion: null, + }; + } + const parsed = parseNodeVersion(version); const secureFloor = getSecureFloorForMajor(parsed.major); const nodeCompatible = secureFloor ? compareNodeVersions(parsed, secureFloor) >= 0 : false; diff --git a/tests/unit/bun-support.test.ts b/tests/unit/bun-support.test.ts new file mode 100644 index 0000000000..73c0c048f7 --- /dev/null +++ b/tests/unit/bun-support.test.ts @@ -0,0 +1,105 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getNodeRuntimeSupport } from "../../src/shared/utils/nodeRuntimeSupport.ts"; +import { createSyncDriverFactory } from "../../src/lib/db/adapters/driverFactory.ts"; + +test("getNodeRuntimeSupport detects Bun as a supported runtime", () => { + const originalBun = process.versions.bun; + try { + (process.versions as Record).bun = "1.1.20"; + const support = getNodeRuntimeSupport("22.0.0"); + assert.equal(support.nodeCompatible, true); + assert.equal(support.reason, "supported-bun"); + assert.match(support.nodeVersion, /^bun-1\.1\.20/); + assert.match(support.supportedDisplay, /Bun 1\.1\+/); + } finally { + if (originalBun === undefined) { + delete (process.versions as Record).bun; + } else { + process.versions.bun = originalBun; + } + } +}); + +test("createSyncDriverFactory prefers bun:sqlite built-in driver when running under Bun", () => { + const originalBun = process.versions.bun; + try { + (process.versions as Record).bun = "1.1.20"; + + const dummyBunDb = { + query: () => ({ run: () => ({ changes: 1, lastInsertRowid: 1 }), get: () => null, all: () => [] }), + exec: () => {}, + close: () => {}, + }; + + const loader = (modName: string) => { + if (modName === "bun:sqlite") { + return { Database: function DummyBunDatabase() { return dummyBunDb; } }; + } + throw new Error(`Unexpected module ${modName}`); + }; + + const factory = createSyncDriverFactory(loader, () => true); + const adapter = factory(":memory:"); + assert.ok(adapter, "Adapter should be created via bun:sqlite"); + assert.equal(adapter.driver, "bun:sqlite"); + } finally { + if (originalBun === undefined) { + delete (process.versions as Record).bun; + } else { + process.versions.bun = originalBun; + } + } +}); + +test("createSyncDriverFactory prefers better-sqlite3 when running under Node", () => { + const originalBun = process.versions.bun; + try { + delete (process.versions as Record).bun; + + const dummyBetterDb = { + open: true, + name: ":memory:", + inTransaction: false, + prepare: () => ({ run: () => ({ changes: 1, lastInsertRowid: 1 }) }), + exec: () => {}, + close: () => {}, + }; + + const loader = (modName: string) => { + if (modName === "better-sqlite3") { + return function DummyBetterSqlite() { + return dummyBetterDb; + }; + } + throw new Error(`Unexpected module ${modName}`); + }; + + const factory = createSyncDriverFactory(loader, () => true); + const adapter = factory(":memory:"); + assert.ok(adapter, "Adapter should be created via better-sqlite3"); + assert.equal(adapter.driver, "better-sqlite3"); + } finally { + if (originalBun === undefined) { + delete (process.versions as Record).bun; + } else { + process.versions.bun = originalBun; + } + } +}); + +test("resolveNextBuildBundlerFlag automatically disables Turbopack and uses Webpack under Bun", async () => { + const originalBun = process.versions.bun; + try { + (process.versions as Record).bun = "1.1.20"; + const buildIsolated = await import("../../scripts/build/build-next-isolated.mjs"); + assert.equal(buildIsolated.resolveNextBuildBundlerFlag({}), "--webpack"); + assert.equal(buildIsolated.resolveNextBuildBundlerFlag({ OMNIROUTE_USE_TURBOPACK: "1" }), "--webpack"); + } finally { + if (originalBun === undefined) { + delete (process.versions as Record).bun; + } else { + process.versions.bun = originalBun; + } + } +});