Skip to content

Deferred builder drops steps imported via a '.step' specifier (x.step.ts) — runtime 'Step is not registered in the current deployment' #2199

@pablodecm

Description

@pablodecm

Summary

Since lazyDiscovery became the default (@workflow/next@5.0.0-beta.8, #1805), the deferred builder fails to register any externalized step file whose import specifier ends in .step, e.g. a "use step" function moved into its own my-step.step.ts file and imported as import { x } from "./my-step.step". (The docs colocate steps in the workflow file, which is unaffected; this only bites when steps are split into separate *.step.ts files.)

The step is silently omitted from the generated workflow manifest (__step_registrations.js / .well-known/workflow/v1/manifest.json), and at runtime the workflow fails with:

FatalError: Step "step//./steps/my-step.step//myStep" is not registered in the current deployment. This usually indicates a build or bundling issue that caused the step to not be included in the deployment.

The root cause is an extension-detection bug in the deferred builder's transitive import resolver: it treats the .step in the import specifier as a file extension and therefore never tries appending .ts, so x.step.ts is never resolved or collected.

The eager builder (the pre-beta.8 default) is unaffected because it discovers step files by globbing the filesystem rather than by resolving import specifiers.

Note on the file naming: the *.step.ts naming that triggers this is an internal convention, not something the SDK documents — so it can trivially be renamed (e.g. x-step.ts) as a workaround. This is being filed anyway because the underlying behavior is a footgun: a perfectly valid relative import is silently dropped at build time and only fails at runtime as Step "..." is not registered in the current deployment, with no warning. Module resolution should be robust to a base name that contains a dot (x.step.ts is a valid filename) rather than mistaking the trailing .step for a module extension.

Environment

  • workflow / @workflow/next / @workflow/core: 5.0.0-beta.10 (also affects beta.8, beta.9)
  • @workflow/builders: 5.0.0-beta.10
  • Next.js: 16.2.6 (Turbopack; deferredEntries experiment active)
  • Node.js: 24

lazyDiscovery is not set explicitly — a bare withWorkflow(config) is used and inherited the new default. On Next.js >= 16.2.0-canary.48 this selects the deferred builder (shouldUseDeferredBuilder returns true).

Severity / impact

High. With the deferred builder as the default, every workflow that delegates to a step in a separate *.step.ts file (imported as "./x.step") is broken in production (and in local dev). The build succeeds; the failure only surfaces at runtime when the step is invoked. The docs' colocated style is unaffected, but splitting "use step" functions into their own files is a natural organization (e.g. for large workflows) and silently breaks.

Reproduction

Minimal app on workflow@5.0.0-beta.10 + next@16.2.6.

sample.workflow.ts:

import { sampleStep } from "./steps/sample.step";

export async function sampleWorkflow(input: { n: number }) {
  "use workflow";
  return await sampleStep(input);
}

steps/sample.step.ts:

export async function sampleStep(input: { n: number }) {
  "use step";
  return input.n + 1;
}

app/api/run/route.ts (so the workflow is reachable from the build entry graph):

import { sampleWorkflow } from "../../../sample.workflow";
export function GET() {
  return new Response(typeof sampleWorkflow);
}

next.config.mjs:

import { withWorkflow } from "workflow/next";
export default withWorkflow({});

Build with WORKFLOW_PUBLIC_MANIFEST=1 next build and inspect public/.well-known/workflow/v1/manifest.json.

Expected

steps/sample.step.ts appears in the manifest steps map and the step is registered.

Actual

The steps map contains the workflow's inline registrations and SDK-internal files, but not steps/sample.step.ts. At runtime, invoking sampleStep throws Step "...sample.step//sampleStep" is not registered in the current deployment.

Additional evidence

In a larger multi-workflow build on beta.10, all workflows were discovered and registered, yet none of the externalized *.step.ts files were — even though each was statically imported by a registered workflow. The discriminating variable is solely the import specifier suffix, independent of directory:

  • ✅ registered: import { x } from "./my-step" (file my-step.ts)
  • ✅ registered: import { x } from "./steps/my-step" (file steps/my-step.ts)
  • ❌ dropped: import { x } from "./my-step.step" (file my-step.step.ts)
  • ❌ dropped: import { x } from "./steps/my-step.step" (file steps/my-step.step.ts)

The steps/ subdirectory is irrelevant; extname() only inspects the basename, so any specifier ending in .step fails regardless of location. This is purely about the specifier suffix, not inline-vs-externalized and not the folder.

Root cause

@workflow/next dist/builder-deferred.js, method resolveTransitiveStepImportTargetPath:

const absoluteTargetPath = resolve(dirname(sourceFilePath), importPath);
// importPath = "./steps/sample.step"  ->  absoluteTargetPath = ".../steps/sample.step"

const candidatePaths = new Set([
  this.resolveImportTargetWithExtensionFallbacks(absoluteTargetPath),
]);

if (!extname(absoluteTargetPath)) {          // <-- BUG
  const extensionCandidates = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
  for (const extensionCandidate of extensionCandidates) {
    candidatePaths.add(`${absoluteTargetPath}${extensionCandidate}`);
    candidatePaths.add(join(absoluteTargetPath, `index${extensionCandidate}`));
  }
}

extname(".../steps/sample.step") returns ".step" (truthy), so the !extname(...) guard is false and the extension-append branch is skipped entirely. The only candidate tried is .../steps/sample.step (no extension), which does not exist on disk (the real file is sample.step.ts). resolveImportTargetWithExtensionFallbacks only has fallbacks for .js/.mjs/.cjs/.jsx (not .step), so it returns the path unchanged. Resolution fails → null → the step file is never added to discoveredStepFiles → never bundled into __step_registrations.js.

For "./my-step" (no dotted suffix), extname is "", so the append branch runs, my-step.ts resolves, and the step registers — which is why those work.

Why eager is unaffected

The eager builder (@workflow/builders base-builder.js getInputFiles()) discovers step files by globbing **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} directly, so it never resolves the .step specifier and picks up *.step.ts regardless of how it's imported.

Suggested fix

In resolveTransitiveStepImportTargetPath, do not gate the extension-append candidates on !extname(...). A specifier suffix like .step (or .workflow, .task) is not a real module extension — it is part of a file's base name — so the resolver should still attempt the source-extension fallbacks. Options:

  • Always also try ${absoluteTargetPath}${ext} candidates when the path does not resolve as-is, regardless of extname; or
  • Only treat the suffix as an extension when it is in the set of real JS/TS extensions (.ts/.tsx/.mts/.cts/.js/.jsx/.mjs/.cjs), so .step/.workflow/.task fall through to the append logic; or
  • Add .step.ts, .step.tsx, etc. to the fallback set in resolveImportTargetWithExtensionFallbacks.

A regression test should cover a workflow importing a step via "./x.step" where the file is x.step.ts and assert the step appears in the emitted manifest.

Workarounds (for consumers)

  • Stay on / downgrade to 5.0.0-beta.7 (eager default) — verified to register all *.step.ts files.
  • Or set withWorkflow(config, { workflows: { lazyDiscovery: false } }) to force the eager builder on supported Next.js versions.
  • Or change imports to include the explicit extension ("./steps/x.step.ts") or rename the files to avoid the dotted suffix (x-step.ts imported as "./x-step").

Secondary observation (likely related, lower priority)

During the same builds, Turbopack emits non-fatal errors for missing external sourcemaps from @workflow/serde (e.g. @workflow/serde@4.1.0-beta.2, @workflow/serde@5.0.0-beta.1):

failed to read input source map: failed to find input source map file "index.js.map" in ".../@workflow/serde/dist/index.js"

These versions ship external sourcemaps (//# sourceMappingURL=index.js.map) whose relative reference does not resolve once the module is bundled by Turbopack. @workflow/serde@5.0.0-beta.2 ships inline sourcemaps and does not have this problem (cf. #1799), but the beta.8beta.10 dependency graph still resolved the older external-sourcemap versions. This is debug-only noise (it does not affect emitted code or the manifest) but it is fatal under Turbopack in some build contexts.

References

Metadata

Metadata

Assignees

No one assigned

    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