diff --git a/next.config.mjs b/next.config.mjs index 1bf1e304..395c5305 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -39,25 +39,60 @@ const nextConfig = { }, webpack: (config, { isServer }) => { if (isServer) { - // Force better-sqlite3 to always be required by its exact package name. + // ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ──────── // - // Next.js 16 webpack compiles the instrumentation hook into a separate - // chunk ([root-of-the-server]__.js) and can emit a hashed require - // such as `require('better-sqlite3-90e2652d1716b047')` even when the - // package is listed in `serverExternalPackages`. That hashed name doesn't - // exist in node_modules, causing the 500 error reported in issue #394. + // Next.js 16 (with or without Turbopack) compiles the instrumentation hook + // into a separate chunk and emits hashed require() calls such as: + // require('better-sqlite3-90e2652d1716b047') + // require('zod-dcb22c6336e0bc69') + // require('pino-28069d5257187539') // - // Adding an explicit `externals` function overrides the bundler's default - // handling and always emits `require('better-sqlite3')`, which resolves - // correctly in the published standalone build. + // These hashed names don't exist in node_modules and cause a 500 at + // startup on all npm global installs (issues #394, #396, #398). + // + // We use two strategies: + // 1. Exact-name externals for all known server-side packages. + // 2. Hash-strip catch-all: any require('-<16hexchars>' strips the + // suffix and falls through to the real package name. + // + const HASH_PATTERN = /^(.+)-[0-9a-f]{16}$/; + + const KNOWN_EXTERNALS = new Set([ + "better-sqlite3", + "zod", + "pino", + "pino-pretty", + "child_process", + "fs", + "path", + "os", + "crypto", + "net", + "tls", + "http", + "https", + "stream", + "buffer", + "util", + ]); + const prev = config.externals ?? []; const prevArr = Array.isArray(prev) ? prev : [prev]; config.externals = [ ...prevArr, ({ request }, callback) => { - if (request === "better-sqlite3") { + // Case 1: Exact known package — treat as external + if (KNOWN_EXTERNALS.has(request)) { return callback(null, `commonjs ${request}`); } + // Case 2: Hash-suffixed name — strip hash, use base name + // e.g. "better-sqlite3-90e2652d1716b047" → "better-sqlite3" + // "zod-dcb22c6336e0bc69" → "zod" + const hashMatch = request?.match?.(HASH_PATTERN); + if (hashMatch) { + const baseName = hashMatch[1]; + return callback(null, `commonjs ${baseName}`); + } callback(); }, ]; @@ -75,6 +110,7 @@ const nextConfig = { } return config; }, + async rewrites() { return [ { diff --git a/scripts/prepublish.mjs b/scripts/prepublish.mjs index fc215fe0..94a588c6 100644 --- a/scripts/prepublish.mjs +++ b/scripts/prepublish.mjs @@ -10,7 +10,16 @@ */ import { execSync } from "node:child_process"; -import { existsSync, mkdirSync, cpSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + cpSync, + rmSync, + writeFileSync, + readFileSync, + readdirSync, + statSync, +} from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -37,7 +46,13 @@ console.log(" 🏗️ Building Next.js (standalone)..."); execSync("npx next build", { cwd: ROOT, stdio: "inherit", - env: { ...process.env, EXPERIMENTAL_TURBOPACK: "0" }, + env: { + ...process.env, + // Force webpack codegen — Turbopack emits hashed require() calls for + // server external packages that break npm global installs (#394, #396, #398). + EXPERIMENTAL_TURBOPACK: "0", + NEXT_PRIVATE_BUILD_WORKER: "0", + }, }); // ── Step 4: Verify standalone output ─────────────────────── @@ -51,6 +66,46 @@ if (!existsSync(serverJs)) { } // ── 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, hits = []) => { + let entries = []; + 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 });