From 138ccf2d046d8754ac01a46f42d6e83e3ed2cdef Mon Sep 17 00:00:00 2001 From: Felipe Britto Date: Fri, 18 Sep 2026 11:31:10 -0300 Subject: [PATCH] fix(build): copy ioredis and bcryptjs into the standalone bundle (#13352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both packages are only reachable through code paths the standalone tracer never follows, so they get silently dropped from the built node_modules/: - ioredis is a deliberately lazy dependency (#6559 in rateLimiter.ts) — reached only via a runtime `await import("ioredis")` in rateLimiter.ts, warmupScheduler/circuitBreakerFactory.ts and quota/redisQuotaStore.ts, never through a static top-level import. Any self-hosted deployment that sets REDIS_URL crashes on first use with "Cannot find module 'ioredis'". - bcryptjs is statically imported by src/lib/auth/managementPassword.ts, so the main server bundle is fine (Next inlines the small pure-JS package into the compiled chunk). bin/cli/settings-store.mjs (the `omniroute reset-password` CLI) is a separate, unbundled entrypoint that needs the real package physically present in node_modules/ — nothing else requires it as a loose runtime dependency, so it was never copied. `node bin/reset-password.mjs --password-stdin` failed with "Cannot find package 'bcryptjs'" (ERR_MODULE_NOT_FOUND) on an otherwise healthy production deployment. Both reproduced on a real self-hosted Docker deployment (v3.8.49/51). Adds the two entries to EXTRA_MODULE_ENTRIES (the single source of truth cited in the Dockerfile) and extends the sync/async parity test with fixtures + assertions for both. --- scripts/build/assembleStandalone.mjs | 37 ++++++++++++++++++++ tests/unit/build/assemble-standalone.test.ts | 17 +++++++++ 2 files changed, 54 insertions(+) diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index 16ee2426c2..05e3d2a82d 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -163,6 +163,43 @@ const EXTRA_MODULE_ENTRIES = [ dest: ["node_modules", "pino-pretty"], }, { label: "split2", src: ["node_modules", "split2"], dest: ["node_modules", "split2"] }, + { + // ioredis is a deliberately LAZY dependency (Redis is optional — see the + // #6559 comment in src/shared/utils/rateLimiter.ts) — reached only via a + // runtime `await import("ioredis")` in rateLimiter.ts, + // warmupScheduler/circuitBreakerFactory.ts and quota/redisQuotaStore.ts, + // never through a static top-level import. The standalone tracer only + // follows statically-analyzable imports, so it never sees these call + // sites and drops ioredis from node_modules/ entirely. Any self-hosted + // deployment that actually sets REDIS_URL crashes the first time it + // reaches one of those call sites with "Cannot find module 'ioredis'" — + // reproduced on a production Docker deployment (REDIS_URL configured, + // v3.8.49) where the standalone image shipped ioredis/package.json but + // none of its own dependencies or built/ output. + label: "ioredis (dynamic import — #6559)", + src: ["node_modules", "ioredis"], + dest: ["node_modules", "ioredis"], + }, + { + // bcryptjs IS statically imported by src/lib/auth/managementPassword.ts, + // so the main server bundle is fine — Next's server compiler inlines the + // small pure-JS package directly into the compiled route chunk instead of + // leaving it as an external node_modules dependency. bin/cli/settings- + // store.mjs (the `omniroute reset-password` / bin/reset-password.mjs + // CLI, used to recover a lost dashboard password) is a separate, + // unbundled entrypoint that does a plain runtime `import bcrypt from + // "bcryptjs"` and needs the real package physically present in + // node_modules/ — which nothing else requires as a loose runtime + // dependency, so it is never copied. Reproduced on a production + // deployment: `node bin/reset-password.mjs --password-stdin` failed with + // "Cannot find package 'bcryptjs' imported from + // /app/bin/cli/settings-store.mjs" (ERR_MODULE_NOT_FOUND) even though the + // same container's dashboard login (which also depends on bcryptjs) was + // working normally. + label: "bcryptjs (bin/cli/settings-store.mjs — reset-password CLI)", + src: ["node_modules", "bcryptjs"], + dest: ["node_modules", "bcryptjs"], + }, { label: "migrations", src: ["src", "lib", "db", "migrations"], dest: ["migrations"] }, { label: "MITM server", src: ["src", "mitm", "server.cjs"], dest: ["src", "mitm", "server.cjs"] }, { diff --git a/tests/unit/build/assemble-standalone.test.ts b/tests/unit/build/assemble-standalone.test.ts index 5894c3d82b..2adc67ff9d 100644 --- a/tests/unit/build/assemble-standalone.test.ts +++ b/tests/unit/build/assemble-standalone.test.ts @@ -40,6 +40,10 @@ function seedSidecarSources(root: string) { "node_modules/pino-abstract-transport/index.js", "node_modules/pino-pretty/index.js", "node_modules/split2/index.js", + "node_modules/ioredis/package.json", + "node_modules/ioredis/built/index.js", + "node_modules/bcryptjs/package.json", + "node_modules/bcryptjs/index.js", "node_modules/playwright-core/index.js", "node_modules/sql.js/package.json", "node_modules/sql.js/dist/sql-wasm.js", @@ -173,6 +177,19 @@ test("async and sync sidecar copy paths produce identical bundle trees", async ( ]) { assert.ok(asyncTree.includes(sqlJsFile), `sql.js runtime file copied: ${sqlJsFile}`); } + // #6559 / reset-password CLI: both are only reachable at runtime (a dynamic + // `import("ioredis")`, or a separate unbundled bin/cli/ entrypoint for + // bcryptjs), so the standalone tracer never picks them up on its own — + // regression guard for the two "Cannot find module/package" crashes + // reproduced on a real self-hosted deployment. + for (const runtimeOnlyFile of [ + "node_modules/ioredis/package.json", + "node_modules/ioredis/built/index.js", + "node_modules/bcryptjs/package.json", + "node_modules/bcryptjs/index.js", + ]) { + assert.ok(asyncTree.includes(runtimeOnlyFile), `runtime-only dep copied: ${runtimeOnlyFile}`); + } fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });