Skip to content

@workflow/vitest: workflow-body local .ts imports are externalized and fail to load on Node 24 (+ a require(esm) cycle) #2289

Closed

Description

@kevinhofmaenner

Summary — With the @workflow/vitest plugin on Node 24, a 'use workflow' integration test fails to load the step bundle when a recipe imports a plain local .ts helper used in its workflow body: the helper is externalized and Node can't resolve its named export. Working around that surfaces a second issue — a require(esm)-in-cycle on a transitively-externalized runtime module.

Versions — @workflow/vitest@4.0.9, workflow@4.3.1, @workflow/world-local@4.1.4, Node 24 (also reproduces on 26), vitest 4.

Symptom 1 — workflow-body local helper externalized

// helper.ts
export function buildPayload(label: string) {
  return { label };
}

// recipe.ts  ('use workflow')
import { buildPayload } from "./helper";

export async function demo(input: { label: string }) {
  "use workflow";
  const p = buildPayload(input.label); // used in the workflow body
  return await echo(p);
}

async function echo(p: { label: string }) {
  "use step";
  return p.label;
}

The generated steps.mjs emits import { buildPayload } from "../helper.js" (note the .ts→.js rewrite); at runtime Node throws:

SyntaxError: The requested module '../helper.js' does not provide an export named 'buildPayload'

bundleTransitiveLocalStepDependencies (added in #1965) only bundles deps reachable from step entries, not workflow-body imports — and the vitest builder's createStepsBundle({ externalizeNonSteps: true, rewriteTsExtensions: true, … }) call doesn't pass it regardless.

Symptom 2 — require(esm) cycle on Node 24

Once symptom 1 is worked around, steps then fail with:

Cannot require() ES Module .../runtime.ts in a cycle. A cycle involving require(esm) is not allowed
to maintain invariants mandated by the ECMAScript specification. Try making at least part of the
dependency in the graph lazily loaded.

…when a step dynamically imports a runtime module that sits in an import cycle. Node 24's loader rejects require() of an ESM module mid-cycle (these cycles are fine under esbuild/webpack in production). Lazy-loading / inlining the cycle members on our side didn't break it, and enabling bundleTransitiveLocalStepDependencies via a patch didn't either.

Ask

Wire bundleTransitiveLocalStepDependencies into the vitest builder, and/or bundle transitive local deps reachable from workflow bodies (not only step entries), so externalized .ts modules aren't loaded through Node's require(esm).

Notes

Reproduced consistently in a Next.js app on Node 24. Happy to put together a standalone repro repo if it would help.

Activity

  1. added a commit that references this issue on Jun 11, 2026
    5b3b22d
  2. VaguelySerious commented on Jun 11, 2026

    @VaguelySerious
    Member

    (AI)

    Reproduction

    Reproduced in a standalone vitest project on Node 24 with the versions from the report (@workflow/vitest@4.0.9, workflow@4.3.1): a 'use workflow' recipe importing a plain local .ts helper produces a step bundle with import { buildPayload } from "../helper.js", which fails at run dispatch (ERR_MODULE_NOT_FOUND in a fresh project; the missing-named-export variant you saw occurs when a compiled helper.js neighbor exists on disk). The beta channel reproduces the same root cause without the .js rewrite: the helper is externalized as a raw .ts specifier, which only loads via Node's erasable-only type stripping — a helper containing an enum fails with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, and a helper importing another local module extensionless fails with ERR_MODULE_NOT_FOUND.

    Root cause: the vitest builder's generated bundles are imported directly by Node's native ESM loader in the test worker (no downstream bundler), but project-local imports of step files were externalized instead of bundled — bundleTransitiveLocalStepDependencies was not wired into the vitest builder, as you suspected.

    For symptom 2: I reproduced a related failure with a step dynamically importing a local module in an import cycle (fails without the fix, passes with it), but not the exact require(esm)-in-cycle message — that shape likely depends on your specific module graph. If it persists after the fix below, a standalone repro would help.

    Related issues

    Fix

    PR: #2351 — enables bundleTransitiveLocalStepDependencies in the vitest builder so local helpers (whether used in workflow bodies or step bodies) are inlined into the step bundle. npm packages stay external, so vi.mock() of third-party packages keeps working. CI is green.

    To see if this fix solves your issue, could you try to install the pre-release in your package.json? Just replace the deps like so:

    {
      "dependencies": {
        "workflow": "https://workflow-tarballs-git-peter-issue-2289.labs.vercel.dev/workflow.tgz",
        "@workflow/world-local": "https://workflow-tarballs-git-peter-issue-2289.labs.vercel.dev/workflow-world-local.tgz"
      },
      "devDependencies": {
        "@workflow/vitest": "https://workflow-tarballs-git-peter-issue-2289.labs.vercel.dev/workflow-vitest.tgz"
      }
    }

    Note this pre-release is built from the v5 beta channel (main), so it includes 5.0 beta changes relative to your 4.x versions.

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

Metadata

Metadata

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