Skip to content

skipNodeModulesBundle bundles deps it can't resolve (non-node platform + dependency without an exports field) #993

Description

@adamhl8

Reproduction link or steps

https://stackblitz.com/edit/github-va9a7t9q?file=tsdown.config.ts

deps.skipNodeModulesBundle is documented as "Skip bundling all node_modules dependencies." But for any dependency published without an exports field (legacy main/types only), it gets bundled anyway whenever platform is not "node". For the dts build this causes an error when the inlined package transitively references a CJS .d.ts.

This issue requires a combination of deps.skipNodeModulesBundle: true + a non-node platform + a dependency without an exports field.

What is expected?

deps.skipNodeModulesBundle: true should externalize every bare / non-relative node_modules import, regardless of whether tsdown's resolver can resolve it and regardless of the configured platform.

What is actually happening?

Under a non-node platform, rolldown's resolver does not fall back to the legacy main/types fields, so packages without an exports field don't get resolved. skipNodeModulesBundle only externalizes when that resolve succeeds, so these deps get inlined.

Any additional comments?

The following was generated by Claude:

In DepsPlugin (tsdown/dist/format-*.mjs), externalStrategy only externalizes a node_modules import when the prior this.resolve(...) succeeded:

// resolveId handler
const resolved = await this.resolve(id, importer, { ...extraOptions, skipSelf: true })
let shouldExternal = await externalStrategy(id, importer, resolved)

// externalStrategy
async function externalStrategy(id, importer, resolved) {
  // ...
  if (skipNodeModulesBundle && resolved && (resolved.external || RE_NODE_MODULES.test(resolved.id))) {
    const resolvedDep = await resolveDepSubpath(id, resolved)
    return resolvedDep ? [true, resolvedDep] : true
  }
  // ...
  return false // resolve failed -> NOT externalized -> bundled
}

Under platform: "neutral", resolved is null for a no-exports package, so the function falls through to return false and the dep is bundled.

Workaround

deps.neverBundle feeds rolldown's native external (matched on the specifier, no resolution required), so it externalizes correctly. Note it is tested against both the raw specifier and the resolved absolute path, so internal/absolute paths must be excluded:

export default defineConfig({
  entry: ["./src/index.ts"],
  dts: true,
  platform: "neutral",
  deps: {
    skipNodeModulesBundle: true,
    neverBundle: (id) => !path.isAbsolute(id) && !id.startsWith(".") && !id.startsWith("#"),
  },
})

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions