Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nest-commonjs-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/nest": patch
---

[nest] Support CommonJS compilation for NestJS projects
11 changes: 11 additions & 0 deletions packages/nest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ WorkflowModule.forRoot({

// Skip building in production when bundles are pre-built
skipBuild: false,

// SWC module type: 'es6' (default) or 'commonjs'
// Set to 'commonjs' if your NestJS project compiles to CJS via SWC
moduleType: 'es6',

// Directory where NestJS compiles .ts to .js (default: 'dist')
// Only used when moduleType is 'commonjs'
// Should match the outDir in your tsconfig.json
distDir: 'dist',
});
```

Expand Down Expand Up @@ -164,6 +173,8 @@ WorkflowModule.forRoot({
dirs: ['src/workflows'],
outDir: '.nestjs/workflow',
skipBuild: process.env.NODE_ENV === 'production',
moduleType: 'commonjs', // if using SWC CommonJS compilation
distDir: 'dist', // where compiled .js files live
})
```

Expand Down
12 changes: 8 additions & 4 deletions packages/nest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./builder": {
"types": "./dist/builder.d.ts",
"import": "./dist/builder.js"
"import": "./dist/builder.js",
"default": "./dist/builder.js"
}
},
"files": [
Expand All @@ -26,7 +28,8 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "tsc --build --clean && rm -rf dist"
"clean": "tsc --build --clean && rm -rf dist",
"test": "vitest run src"
},
"dependencies": {
"@swc/core": "catalog:",
Expand Down Expand Up @@ -59,7 +62,8 @@
"@nestjs/core": "^11.0.1",
"@types/node": "catalog:",
"@workflow/tsconfig": "workspace:*",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:"
},
"license": "Apache-2.0",
"repository": {
Expand Down
78 changes: 76 additions & 2 deletions packages/nest/src/builder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import { BaseBuilder, createBaseBuilderConfig } from '@workflow/builders';
import { join } from 'pathe';
import { rewriteTsImportsInContent } from './cjs-rewrite.js';

export interface NestBuilderOptions {
/**
Expand All @@ -23,19 +24,40 @@ export interface NestBuilderOptions {
* @default false
*/
watch?: boolean;
/**
* SWC module compilation type.
* Set to 'commonjs' if your NestJS project compiles to CJS via SWC.
* When 'commonjs', the builder rewrites externalized imports in the
* steps bundle to use require() via createRequire, avoiding ESM/CJS
* named-export interop issues with SWC's _export() wrapper pattern.
* @default 'es6'
*/
moduleType?: 'es6' | 'commonjs';
/**
* Directory where NestJS compiles .ts source files to .js (relative to workingDir).
* Used when moduleType is 'commonjs' to resolve compiled file paths.
* This should match the `outDir` in your tsconfig.json.
* @default 'dist'
*/
distDir?: string;
}

export class NestLocalBuilder extends BaseBuilder {
#outDir: string;
#moduleType: 'es6' | 'commonjs';
#distDir: string;
#dirs: string[];
#workingDir: string;

constructor(options: NestBuilderOptions = {}) {
const workingDir = options.workingDir ?? process.cwd();
const outDir = options.outDir ?? join(workingDir, '.nestjs/workflow');
const dirs = options.dirs ?? ['src'];
super({
...createBaseBuilderConfig({
workingDir,
watch: options.watch ?? false,
dirs: options.dirs ?? ['src'],
dirs,
}),
// Use 'standalone' as base target - we handle the specific bundling ourselves
buildTarget: 'standalone',
Expand All @@ -44,6 +66,10 @@ export class NestLocalBuilder extends BaseBuilder {
webhookBundlePath: join(outDir, 'webhook.mjs'),
});
this.#outDir = outDir;
this.#moduleType = options.moduleType ?? 'es6';
this.#distDir = options.distDir ?? 'dist';
this.#dirs = dirs;
this.#workingDir = workingDir;
}

get outDir(): string {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex only matches named imports (import { ... } from "..."), but what about:

  1. Default imports: import foo from "../something.ts" — unlikely in practice for externalized imports but worth noting.
  2. Namespace imports: import * as foo from "../something.ts" — this pattern could appear in generated bundles.
  3. Side-effect imports: import "../something.ts" — probably not an issue since these don't have named exports.

Could you verify which import patterns esbuild actually emits in the externalized steps bundle? If it only ever generates named imports here, this is fine — just want to make sure we're not silently skipping rewrites for some patterns.

Expand All @@ -68,6 +94,14 @@ export class NestLocalBuilder extends BaseBuilder {
inputFiles,
});

// When the NestJS project compiles to CJS via SWC, the ESM steps bundle
// can't import named exports from CJS files because cjs-module-lexer
// doesn't recognize SWC's _export() wrapper pattern.
// Rewrite externalized .ts imports to use require() via createRequire.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requireShim is prepended to the entire file content. If the original stepsContent starts with other imports (e.g., from @workflow/core or npm packages), the createRequire import and const require = ... will be at the very top, followed by the original content with .ts imports rewritten.

This should be fine since the shim uses import (hoisted) followed by const (not hoisted), and the rewritten require() calls are also const declarations. Just noting that this means the file will have a mix of import statements and const ... = require(...) calls. Node.js handles this correctly in .mjs files — import and require can coexist when require comes from createRequire.

if (this.#moduleType === 'commonjs') {
await this.#rewriteStepsBundleForCjs();
}

await this.createWebhookBundle({
outfile: join(this.#outDir, 'webhook.mjs'),
bundle: false,
Expand All @@ -92,4 +126,44 @@ export class NestLocalBuilder extends BaseBuilder {
await writeFile(join(this.#outDir, '.gitignore'), '*\n');
}
}

/**
* Rewrite externalized .ts/.tsx imports in the steps bundle to use require()
* for CommonJS compatibility.
*
* When NestJS compiles to CJS via SWC, the ESM steps bundle can't import
* named exports from CJS files because cjs-module-lexer doesn't recognize
* SWC's _export() wrapper pattern. This rewrites the imports to use
* createRequire() and points them to the compiled .js files in distDir.
*/
async #rewriteStepsBundleForCjs(): Promise<void> {
const stepsPath = join(this.#outDir, 'steps.mjs');
const stepsContent = await readFile(stepsPath, 'utf-8');

const { content: rewritten, matchCount } = rewriteTsImportsInContent(
stepsContent,
{
outDir: this.#outDir,
workingDir: this.#workingDir,
distDir: this.#distDir,
dirs: this.#dirs,
}
);

if (matchCount === 0) {
console.warn(
'[@workflow/nest] No .ts/.tsx imports found to rewrite for CommonJS. ' +
"If you expected externalized imports, esbuild's output format may have changed."
);
return;
}

const requireShim = [
`import { createRequire as __bundled_createRequire } from 'node:module';`,
`const require = __bundled_createRequire(import.meta.url);`,
``,
].join('\n');

await writeFile(stepsPath, requireShim + rewritten);
}
}
154 changes: 154 additions & 0 deletions packages/nest/src/cjs-rewrite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest';
import {
mapSourceToDistPath,
rewriteTsImportsInContent,
TS_IMPORT_REGEX,
} from './cjs-rewrite.js';

describe('TS_IMPORT_REGEX', () => {
const testRegex = () =>
new RegExp(TS_IMPORT_REGEX.source, TS_IMPORT_REGEX.flags);

it('matches named imports from .ts files', () => {
const s = 'import { foo, bar } from "../src/services/helper.ts";';
expect(testRegex().test(s)).toBe(true);
});

it('matches named imports from .tsx files', () => {
const s = 'import { foo } from "./components/Widget.tsx";';
expect(testRegex().test(s)).toBe(true);
});

it('matches imports with "as" alias', () => {
const s = 'import { hasValue as hv } from "../utils.ts";';
expect(testRegex().test(s)).toBe(true);
});

it('does not match imports from node_modules', () => {
expect(testRegex().test('import { x } from "@workflow/core";')).toBe(false);
});
});

describe('rewriteTsImportsInContent', () => {
const opts = {
outDir: '/proj/.nestjs/workflow',
workingDir: '/proj',
distDir: 'dist',
dirs: ['src'],
};

it('rewrites named imports from .ts to require()', () => {
const content = [
'import { foo, bar } from "../../src/services/helper.ts";',
'const x = 1;',
].join('\n');

const { content: result, matchCount } = rewriteTsImportsInContent(
content,
opts
);

expect(matchCount).toBe(1);
expect(result).toContain('require("../../dist/services/helper.js")');
expect(result).toMatch(/\bfoo\b.*\bbar\b/);
});

it('rewrites imports with "as" alias', () => {
const content = 'import { hasValue as hv } from "../../src/utils.ts";';

const { content: result, matchCount } = rewriteTsImportsInContent(
content,
opts
);

expect(matchCount).toBe(1);
expect(result).toContain('hasValue: hv');
expect(result).toContain('require("../../dist/utils.js")');
});

it('handles .tsx files', () => {
const content = 'import { Widget } from "../../src/components/Widget.tsx";';

const { content: result, matchCount } = rewriteTsImportsInContent(
content,
opts
);

expect(matchCount).toBe(1);
expect(result).toContain('dist/components/Widget.js');
});

it('returns matchCount 0 when no .ts/.tsx imports', () => {
const content = 'import { x } from "@workflow/core";\nconst y = 1;';
const { content: result, matchCount } = rewriteTsImportsInContent(
content,
opts
);

expect(matchCount).toBe(0);
expect(result).toBe(content);
});

it('rewrites multiple imports', () => {
const content = [
'import { a } from "../../src/a.ts";',
'import { b } from "../../src/b.ts";',
].join('\n');

const { content: result, matchCount } = rewriteTsImportsInContent(
content,
opts
);

expect(matchCount).toBe(2);
expect(result).toContain('require("../../dist/a.js")');
expect(result).toContain('require("../../dist/b.js")');
});
});

describe('mapSourceToDistPath', () => {
it('maps src/ path with dirs=["src"]', () => {
expect(mapSourceToDistPath('src/services/foo.ts', ['src'], 'dist')).toBe(
'dist/services/foo.js'
);
});

it('maps src/ path with dirs=["src"] for .tsx', () => {
expect(mapSourceToDistPath('src/components/foo.tsx', ['src'], 'dist')).toBe(
'dist/components/foo.js'
);
});

it('handles dirs with multiple entries', () => {
expect(mapSourceToDistPath('src/foo.ts', ['src', 'lib'], 'dist')).toBe(
'dist/foo.js'
);
expect(mapSourceToDistPath('lib/bar.ts', ['src', 'lib'], 'dist')).toBe(
'dist/bar.js'
);
});

it('handles dirs: ["."] - fallthrough to dist prepend', () => {
expect(mapSourceToDistPath('services/foo.ts', ['.'], 'dist')).toBe(
'dist/services/foo.js'
);
});

it('handles dirs: [".", "src"] - src matches first for src/ files', () => {
expect(mapSourceToDistPath('src/foo.ts', ['.', 'src'], 'dist')).toBe(
'dist/foo.js'
);
});

it('handles dirs: [".", "src"] - fallthrough for files outside src/', () => {
expect(mapSourceToDistPath('services/foo.ts', ['.', 'src'], 'dist')).toBe(
'dist/services/foo.js'
);
});

it('handles path outside all dirs', () => {
expect(mapSourceToDistPath('other/foo.ts', ['src'], 'dist')).toBe(
'dist/other/foo.js'
);
});
});
Loading