worker-bundler: package.json "imports" (#subpath) and an extensionless legacy "main" make a dependency silently external, so chalk@5 and xml2js fail at Worker start · Issue #2577 · cloudflare/agents · GitHub
Skip to content

worker-bundler: package.json "imports" (#subpath) and an extensionless legacy "main" make a dependency silently external, so chalk@5 and xml2js fail at Worker start #2577

Open

Description

@ondraulehla

Summary

@cloudflare/worker-bundler 0.2.6 leaves two kinds of dependency unresolved. The build reports no warning. The Worker then fails to start with No such module.

  1. A package that uses the imports field of package.json (#subpath). The resolver treats #dep as a package name and marks it external. chalk@5 uses this for #ansi-styles and #supports-color.
  2. A package whose legacy main has no file extension. The resolver reads the exact path and does not probe .js or index.js. xml2js ("main": "./lib/xml2js"), form-data (./lib/form_data), combined-stream and pug ("main": "lib") have this shape.

Both are in packages/worker-bundler/src/resolver.ts. Case 2 is a sibling of #2466, which fixed subpaths in the same function.

Expected and actual behavior

Expected: the resolver follows imports and probes extensions the way Node and esbuild do, or the build reports a warning.

Actual, inside workerd with the real Worker Loader (bundle: true):

chalk@5.6.2  : Failed to start Worker: No such module "#ansi-styles". imported from "bundle.js"   warnings=[]
xml2js@0.6.2 : Failed to start Worker: No such module "xml2js". imported from "bundle.js"         warnings=[]

The fixtures in the reproduction below give the same result with bundle: true and bundle: false. The control fixture with "main": "lib/index.js" serves 200.

Runnable reproduction

The reproduction uses bundle: false, so it also runs under plain Node, like the one in #2466. No account, credentials or network calls are needed. I ran it with Node 24.18.0 and the published 0.2.6 package.

In an empty directory:

npm init -y
npm install --ignore-scripts @cloudflare/worker-bundler@0.2.6

Save this as repro.mjs:

import { createWorker } from "@cloudflare/worker-bundler";

async function run(label, files) {
  const result = await createWorker({ bundle: false, entryPoint: "index.js", files });
  console.log(label);
  for (const [name, code] of Object.entries(result.modules)) console.log(`  ${name}: ${String(code).trim()}`);
  console.log();
}

// Case 1: package.json "imports" (#subpath), the shape chalk@5 uses.
await run('case 1: "imports" (#dep)', {
  "index.js": 'import { v } from "hashimp"; export default { fetch() { return new Response(v); } };',
  "node_modules/hashimp/package.json": JSON.stringify({ name: "hashimp", exports: "./index.js", imports: { "#dep": "./dep.js" } }),
  "node_modules/hashimp/index.js": 'export { v } from "#dep";',
  "node_modules/hashimp/dep.js": 'export const v = "ok-imports";',
});

// Case 2: legacy "main" without a file extension, the shape xml2js uses.
await run('case 2: "main" without extension', {
  "index.js": 'import { v } from "noext"; export default { fetch() { return new Response(v); } };',
  "node_modules/noext/package.json": JSON.stringify({ name: "noext", main: "lib/index" }),
  "node_modules/noext/lib/index.js": 'export const v = "ok-main";',
});

// Control: the same package with "main": "lib/index.js".
await run('control: "main" with extension', {
  "index.js": 'import { v } from "withext"; export default { fetch() { return new Response(v); } };',
  "node_modules/withext/package.json": JSON.stringify({ name: "withext", main: "lib/index.js" }),
  "node_modules/withext/lib/index.js": 'export const v = "ok";',
});

Run node repro.mjs. Observed output:

case 1: "imports" (#dep)
  index.js: import { v } from "/node_modules/hashimp/index.js"; export default { fetch() { return new Response(v); } };
  node_modules/hashimp/index.js: export { v } from "#dep";

case 2: "main" without extension
  index.js: import { v } from "noext"; export default { fetch() { return new Response(v); } };

control: "main" with extension
  index.js: import { v } from "/node_modules/withext/lib/index.js"; export default { fetch() { return new Response(v); } };
  node_modules/withext/lib/index.js: export const v = "ok";

In case 1, #dep stays in the output and dep.js is not in modules. In case 2, noext stays a bare specifier and lib/index.js is not in modules. Loaded with the Worker Loader, these modules fail at start. The errors are No such module "#dep" and No such module "noext".

Cause and suggested fix

Case 1. resolveModule() sends every specifier that is not relative to resolvePackage() (resolver.ts:88). parsePackageSpecifier() (:230) reads #dep as a package name. There is no node_modules/#dep/package.json, so the result is external: true. Nothing in the resolver reads the imports field of the importing package. resolve.exports, which the resolver already uses, has an imports() function for this. A fix can detect a specifier that starts with # and find the nearest package.json of the importer. It then calls resolveExports.imports(pkg, specifier, { conditions }).

Case 2. The legacy branch (:162-170) builds fullPath from the raw main value and accepts it only when files.read(fullPath) finds a file. There is no extension or index probing. The fallback at :178 probes only the package root, so main: "lib/index" and main: "lib" fall through to external: true. The existing resolveWithExtensions() helper (:195) can replace the exact read: resolveWithExtensions(\node_modules/${packageName}/${entry}`, files, extensions)`.

I searched issues and PRs for "worker-bundler imports", "subpath imports", "No such module", "worker-bundler main" and "package.json imports". The only related item is #2466 / #2471 (subpaths without an exports map). That fix did not touch # specifiers or extension probing. I can open a PR with both fixes and regression cases if that helps.

Version: @cloudflare/worker-bundler 0.2.6 (npm latest). main at b7c6ab9 has the same resolver. Node 24.18.0, macOS 26.6.2. The workerd results come from the repo's own vitest-pool-workers setup in packages/worker-bundler.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions