Skip to content

[Nuxt/Nitro dev] Workflow step bundle still externalizes transitive local TS modules (native ESM crash) #1179

@okueng

Description

@okueng

Description

When a workflow or step file imports a local .ts module that itself imports another local .ts module, the workflow dev bundle still externalizes part of that local dependency graph. At runtime in Nuxt dev, Node ends up loading those externalized files directly under native ESM, and their own extensionless relative imports fail.

This is related to #278 (which fixed direct imports) but appears to remain unresolved for transitive local TypeScript dependencies.

Current status

Still reproducible as of March 26, 2026 with:

  • workflow: 4.2.0-beta.72
  • @workflow/nitro: 4.0.1-beta.67
  • @workflow/nuxt: 4.0.1-beta.56
  • nuxt: 4.4.2
  • Node: 24
  • macOS

Why this crashes in Nuxt dev

workflow/nuxt already performs a local workflow build in dev via @workflow/nitro. The problem is not the absence of a dev bundling pass.

The problem is that the generated dev step bundle is still not fully self-contained for some transitive local TS dependencies. It externalizes local .ts modules, and once Node loads those files directly under native ESM, their own relative imports must be fully specified.

So the failure mode is:

workflow dev build -> .nuxt/workflow/steps.mjs with externalized local TS modules
Node ESM loads those externalized files directly
an extensionless relative import inside one of those files fails

Depending on Node version, the exact crash point can differ:

  • On some versions, loading the externalized .ts file itself fails.
  • On newer Node versions, the .ts file can load, but its own extensionless relative imports still fail under native ESM.

Reproduction

https://github.com/okueng/workflow-transitive-dep-bug

git clone https://github.com/okueng/workflow-transitive-dep-bug
cd workflow-transitive-dep-bug
pnpm install
pnpm dev
# visit any page -> error

File structure

server/workflows/my-workflow.ts   -> imports ../../shared/constants
shared/constants.ts               -> imports ./helpers
shared/helpers.ts                 -> leaf module

Expected

Both constants.ts and helpers.ts should be bundled into the generated workflow step bundle so that .nuxt/workflow/steps.mjs is self-contained for local runtime dependencies.

Actual

The generated .nuxt/workflow/steps.mjs still contains externalized local TS imports. Example output from a current reproduction:

import { CATEGORIES } from "../../shared/constants.ts";

And in a larger real app on current versions, emitted lines look like:

import { PathwayTypeValues } from "../../shared/domain/pathways.ts";
import { getStaticFxRates } from "../../shared/utils/staticFxRates.ts";
import { resolvePathwayEquations, resolveDerivedVariables } from "../../shared/calc/equations.ts";

Those externalized files then execute under native Node ESM. If one of them contains an extensionless relative import such as ./helpers, dev crashes with an error in this family:

Error: Cannot find module '/path/to/shared/helpers' imported from /path/to/shared/constants.ts

Likely fix area

The bug still appears to be in how @workflow/builders discovers and retains the local transitive dependency graph for step bundles.

The earlier analysis still seems directionally correct:

  1. discover-entries-esbuild-plugin.js likely does not resolve extensionless TS imports correctly during discovery.
  2. Its onResolve filter appears too restrictive for extensionless relative specifiers.
  3. The later externalization logic appears to miss at least some reverse transitive relationships from step entrypoints to local helper modules.

Suggested fix

The previously proposed patch still seems like the right place to investigate:

--- a/dist/discover-entries-esbuild-plugin.js
+++ b/dist/discover-entries-esbuild-plugin.js
-const enhancedResolve = promisify(enhancedResolveOriginal);
+const enhancedResolve = promisify(enhancedResolveOriginal.create({
+    extensions: ['.ts', '.tsx', '.mts', '.cts', '.cjs', '.mjs', '.js', '.jsx', '.json', '.node'],
+    mainFields: ['main'],
+    mainFiles: ['index'],
+    conditionNames: ['node', 'import'],
+}));
-            build.onResolve({ filter: jsTsRegex }, async (args) => {
+            build.onResolve({ filter: /^[./]/ }, async (args) => {
--- a/dist/swc-esbuild-plugin.js
+++ b/dist/swc-esbuild-plugin.js
                         if (parentHasChild(normalizedResolvedPath, normalizedEntry)) {
                             return null;
                         }
+                        if (parentHasChild(normalizedEntry, normalizedResolvedPath)) {
+                            return null;
+                        }

The onResolve filter uses /^[./]/ rather than /.*/ to avoid pulling node_modules into the import graph and over-bundling package dependencies.

If helpful, I can convert this into a PR against the current @workflow/builders source rather than the built dist/ output.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions