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.8–beta.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
Summary
Since
lazyDiscoverybecame 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 ownmy-step.step.tsfile and imported asimport { 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.tsfiles.)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:The root cause is an extension-detection bug in the deferred builder's transitive import resolver: it treats the
.stepin the import specifier as a file extension and therefore never tries appending.ts, sox.step.tsis 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.
Environment
workflow/@workflow/next/@workflow/core:5.0.0-beta.10(also affectsbeta.8,beta.9)@workflow/builders:5.0.0-beta.1016.2.6(Turbopack;deferredEntriesexperiment active)lazyDiscoveryis not set explicitly — a barewithWorkflow(config)is used and inherited the new default. On Next.js>= 16.2.0-canary.48this selects the deferred builder (shouldUseDeferredBuilderreturnstrue).Severity / impact
High. With the deferred builder as the default, every workflow that delegates to a step in a separate
*.step.tsfile (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:steps/sample.step.ts:app/api/run/route.ts(so the workflow is reachable from the build entry graph):next.config.mjs:Build with
WORKFLOW_PUBLIC_MANIFEST=1 next buildand inspectpublic/.well-known/workflow/v1/manifest.json.Expected
steps/sample.step.tsappears in the manifeststepsmap and the step is registered.Actual
The
stepsmap contains the workflow's inline registrations and SDK-internal files, but notsteps/sample.step.ts. At runtime, invokingsampleStepthrowsStep "...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.tsfiles were — even though each was statically imported by a registered workflow. The discriminating variable is solely the import specifier suffix, independent of directory:import { x } from "./my-step"(filemy-step.ts)import { x } from "./steps/my-step"(filesteps/my-step.ts)import { x } from "./my-step.step"(filemy-step.step.ts)import { x } from "./steps/my-step.step"(filesteps/my-step.step.ts)The
steps/subdirectory is irrelevant;extname()only inspects the basename, so any specifier ending in.stepfails regardless of location. This is purely about the specifier suffix, not inline-vs-externalized and not the folder.Root cause
@workflow/nextdist/builder-deferred.js, methodresolveTransitiveStepImportTargetPath: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 issample.step.ts).resolveImportTargetWithExtensionFallbacksonly has fallbacks for.js/.mjs/.cjs/.jsx(not.step), so it returns the path unchanged. Resolution fails →null→ the step file is never added todiscoveredStepFiles→ never bundled into__step_registrations.js.For
"./my-step"(no dotted suffix),extnameis"", so the append branch runs,my-step.tsresolves, and the step registers — which is why those work.Why eager is unaffected
The eager builder (
@workflow/buildersbase-builder.jsgetInputFiles()) discovers step files by globbing**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}directly, so it never resolves the.stepspecifier and picks up*.step.tsregardless 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:${absoluteTargetPath}${ext}candidates when the path does not resolve as-is, regardless ofextname; or.ts/.tsx/.mts/.cts/.js/.jsx/.mjs/.cjs), so.step/.workflow/.taskfall through to the append logic; or.step.ts,.step.tsx, etc. to the fallback set inresolveImportTargetWithExtensionFallbacks.A regression test should cover a workflow importing a step via
"./x.step"where the file isx.step.tsand assert the step appears in the emitted manifest.Workarounds (for consumers)
5.0.0-beta.7(eager default) — verified to register all*.step.tsfiles.withWorkflow(config, { workflows: { lazyDiscovery: false } })to force the eager builder on supported Next.js versions."./steps/x.step.ts") or rename the files to avoid the dotted suffix (x-step.tsimported 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):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.2ships inline sourcemaps and does not have this problem (cf. #1799), but thebeta.8–beta.10dependency 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
lazyDiscoverythe default (introduced the regression's trigger)