From 36117ba8b79499a23c074325b017e47715b17df4 Mon Sep 17 00:00:00 2001 From: Magnar Ovedal Myrtveit Date: Wed, 5 Aug 2026 07:43:31 +0000 Subject: [PATCH] fix(adapter-node): fail the build on imports that resolve to no installed package Only `dependencies` are external, so everything else is bundled and any bare import left in the output has to resolve against a deployment that will not contain it. When it cannot, the failure is both fatal and silent: the build succeeds, the image builds, and the server exits during module evaluation with ERR_MODULE_NOT_FOUND. For `instrumentation.server.js` that happens before the server can log anything at all. A dependency that statically imports an unmet optional peer is enough to trigger it. Rolldown reports "Module not found, treating it as an external dependency" and carries on, which is easy to lose among the other build output and does not stop a broken bundle from shipping. Check what was actually emitted rather than trusting the graph: walk the written chunks' imports, skip our own chunks, relative specifiers and builtins, and resolve the rest from the project root. Anything that fails to resolve there cannot resolve at runtime either, so fail the build and name the specifier and the chunk it came from. --- .../adapter-node-unresolvable-imports.md | 5 +++ packages/adapter-node/index.js | 39 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 .changeset/adapter-node-unresolvable-imports.md diff --git a/.changeset/adapter-node-unresolvable-imports.md b/.changeset/adapter-node-unresolvable-imports.md new file mode 100644 index 000000000000..e8a40ce9c174 --- /dev/null +++ b/.changeset/adapter-node-unresolvable-imports.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-node': patch +--- + +fix: fail the build when the output contains imports that resolve to no installed package diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 07df66d08aa5..078e77e5249c 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire, isBuiltin } from 'node:module'; import { extname } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { rolldown } from 'rolldown'; const files = fileURLToPath(new URL('./files', import.meta.url).href); @@ -137,7 +138,7 @@ export default function (opts = {}) { ] }); - await bundle.write({ + const bundled = await bundle.write({ dir: out, format: 'esm', sourcemap: true, @@ -155,6 +156,40 @@ export default function (opts = {}) { } }); + // Anything not in `dependencies` is bundled, so a bare import left in the output + // resolves against a deployment that will not contain it. That is fatal but silent: + // the build succeeds and the server dies as the module is evaluated, which for + // `instrumentation.server.js` is before it can log anything at all. + const require_from_project = createRequire(pathToFileURL('package.json')); + const emitted = new Set(bundled.output.map((chunk) => chunk.fileName)); + + /** @type {string[]} */ + const unresolvable = []; + + for (const chunk of bundled.output) { + if (chunk.type !== 'chunk') continue; + + for (const source of [...chunk.imports, ...chunk.dynamicImports]) { + // imports of our own chunks are relative but reported without a leading `./` + if (emitted.has(source) || /^[./]/.test(source) || isBuiltin(source)) continue; + + try { + require_from_project.resolve(source); + } catch { + unresolvable.push(` ${source} (imported by ${chunk.fileName})`); + } + } + } + + if (unresolvable.length > 0) { + throw new Error( + 'The build contains imports that resolve to no installed package, so the server ' + + `would fail to start with ERR_MODULE_NOT_FOUND:\n${unresolvable.join('\n')}\n\n` + + 'If these are optional dependencies, add them to "dependencies" so they are ' + + 'installed alongside the build, or stop importing them.' + ); + } + if (builder.hasServerInstrumentationFile()) { builder.instrument({ entrypoint: `${out}/index.js`,