Skip to content

[nest] Steps bundle declares require twice with --module commonjs #3778

Description

@muffe

workflow-nest build --module commonjs writes a steps bundle that declares require twice, so importing it throws.

$ npx @workflow/nest build --module commonjs
✓ Compiled workflows in 177ms (20 steps, 2 workflows)
[@workflow/nest] Built local workflow bundles

$ sed -n '1,6p' .nestjs/workflow/steps.mjs
import { createRequire as __bundled_createRequire } from 'node:module';
const require = __bundled_createRequire(import.meta.url);
// biome-ignore-all lint: generated file
/* eslint-disable */
import { createRequire as __createRequire } from "node:module";
var require = __createRequire(import.meta.url);

$ node -e "import('./.nestjs/workflow/steps.mjs')"
SyntaxError: Identifier 'require' has already been declared

The build itself reports success, so the failure shows up later and indirectly: the flow route answers 500, the queue retries the message, and the run stays pending. It took us a while to connect the two.

Where the two declarations come from

Line 2 is the shim that #rewriteStepsBundleForCjs() prepends in packages/nest/src/builder.ts. Line 6 is the esbuild banner @workflow/builders adds so bundled CJS code works in an ESM output.

createStepsBundle already has an option for this, skipEsmRequireBanner, and its comment describes the same hazard. The comment is about the __createRequire identifier being declared twice. In this case the two helper identifiers differ (__bundled_createRequire and __createRequire), but both statements declare a variable named require, and const plus var in one module scope is a syntax error.

NestLocalBuilder never passes that option, so it keeps its default of false. createCombinedBundle does pass it (skipEsmRequireBanner: bundleFinalOutput), which is why the --vercel output is unaffected. All four functions it emits pass node --check. Only the local bundle breaks.

Versions

workflow, @workflow/nest and @workflow/builders are all 5.0.0-beta.44. @nestjs/core 11.1.19, Node 24.16.0. Nest project compiling to CommonJS, WorkflowModule.forRoot({ moduleType: 'commonjs', distDir: 'dist' }).

Workaround

We remove the prepended shim after the build and keep the esbuild banner, which provides the same binding. It works, but it is a post-processing step over generated output, so we would rather drop it.

--module es6 is not an alternative here

We checked, since that would sidestep the whole rewrite. It does avoid the duplicate declaration, and the bundle then holds one require, from the esbuild banner alone. It does not load either, for an unrelated reason. With es6 the bundle externalises to the .ts sources:

import { getWorkflowServices } from "../../src/workflows/workflow-context.ts";
import { CommercetoolsEnvironment } from "../../src/commerceToolsApi.service.ts";
import { POSTBACK_STATUSES } from "../../src/better-payment/webhook/postback-config.ts";

Node has to strip the types from those files at load time, and one of them declares an enum:

SyntaxError: TypeScript enum is not supported in strip-only mode

Nothing about that file is unusual, so any CommonJS Nest project should hit this as soon as a step imports from a source file containing an enum. Handling it would need --experimental-transform-types at runtime, which we would rather not require.

Where we might still be wrong

Is NestLocalBuilder meant to be driven through the CLI for a CommonJS project, or should the local bundles come from somewhere else? If there is a third option we have missed, we would be glad to hear it.

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