-
Notifications
You must be signed in to change notification settings - Fork 190
[nest] Support CommonJS compilation for NestJS projects #982
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@workflow/nest": patch | ||
| --- | ||
|
|
||
| [nest] Support CommonJS compilation for NestJS projects |
| 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 { | ||
| /** | ||
|
|
@@ -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', | ||
|
|
@@ -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 { | ||
|
|
@@ -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. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The This should be fine since the shim uses |
||
| if (this.#moduleType === 'commonjs') { | ||
| await this.#rewriteStepsBundleForCjs(); | ||
| } | ||
|
|
||
| await this.createWebhookBundle({ | ||
| outfile: join(this.#outDir, 'webhook.mjs'), | ||
| bundle: false, | ||
|
|
@@ -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); | ||
| } | ||
| } | ||
| 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' | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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:import foo from "../something.ts"— unlikely in practice for externalized imports but worth noting.import * as foo from "../something.ts"— this pattern could appear in generated bundles.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.