Skip to content

Commit 30aba24

Browse files
fix(builders) - support explicit project roots in swc transforms (#1399)
* fix(builders) - support explicit project roots in swc transforms * fix(builders) - normalize windows expectations in discovery test * fix(builders) - use normalized paths in discovery plugin applySwcTransform calls * fix(next) - reuse outputFileTracingRoot for builder projectRoot --------- Co-authored-by: Nathan Rajlich <n@n8.io>
1 parent afa3931 commit 30aba24

12 files changed

Lines changed: 494 additions & 21 deletions

‎packages/builders/src/apply-swc-transform.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ const decoratorOptionsCache = new Map<
1313
ReturnType<typeof getDecoratorOptionsForDirectory>
1414
>();
1515

16-
function getDecoratorOptions() {
17-
const cwd = process.cwd();
16+
function getDecoratorOptions(projectRoot?: string) {
17+
const cwd = projectRoot ?? process.cwd();
1818
let cached = decoratorOptionsCache.get(cwd);
1919
if (!cached) {
2020
cached = getDecoratorOptionsForDirectory(cwd);
@@ -56,12 +56,18 @@ export async function applySwcTransform(
5656
* Used for module specifier resolution when filename is relative.
5757
* If not provided, filename is joined with process.cwd().
5858
*/
59-
absolutePath?: string
59+
absolutePath?: string,
60+
/**
61+
* Optional project root used for module specifier resolution.
62+
* Defaults to process.cwd() for backwards compatibility.
63+
*/
64+
projectRoot?: string
6065
): Promise<{
6166
code: string;
6267
workflowManifest: WorkflowManifest;
6368
}> {
64-
const decoratorOptions = await getDecoratorOptions();
69+
const resolvedProjectRoot = projectRoot ?? process.cwd();
70+
const decoratorOptions = await getDecoratorOptions(resolvedProjectRoot);
6571

6672
const swcPluginPath = require.resolve('@workflow/swc-plugin', {
6773
paths: [dirname(fileURLToPath(import.meta.url))],
@@ -75,15 +81,14 @@ export async function applySwcTransform(
7581
filename.endsWith('.cts');
7682

7783
// Resolve module specifier for packages (node_modules or workspace packages)
78-
const projectRoot = process.cwd();
7984
const absoluteFilename = absolutePath
8085
? absolutePath
8186
: isAbsolute(filename)
8287
? filename
83-
: join(projectRoot, filename);
88+
: join(resolvedProjectRoot, filename);
8489
const { moduleSpecifier } = resolveModuleSpecifier(
8590
absoluteFilename,
86-
projectRoot
91+
resolvedProjectRoot
8792
);
8893

8994
// Transform with SWC to support syntax esbuild doesn't

‎packages/builders/src/base-builder.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export abstract class BaseBuilder {
4444
this.config = config;
4545
}
4646

47+
protected get transformProjectRoot(): string {
48+
return this.config.projectRoot || this.config.workingDir;
49+
}
50+
4751
/**
4852
* Whether informational BaseBuilder logs should be printed.
4953
* Subclasses can override this to silence progress logs while keeping warnings/errors.
@@ -171,7 +175,9 @@ export abstract class BaseBuilder {
171175
await esbuild.build({
172176
treeShaking: true,
173177
entryPoints: inputs,
174-
plugins: [createDiscoverEntriesPlugin(state)],
178+
plugins: [
179+
createDiscoverEntriesPlugin(state, this.transformProjectRoot),
180+
],
175181
platform: 'node',
176182
write: false,
177183
outdir,
@@ -499,6 +505,7 @@ export abstract class BaseBuilder {
499505
mode: 'step',
500506
entriesToBundle: normalizedEntriesToBundle,
501507
outdir: outfile ? dirname(outfile) : undefined,
508+
projectRoot: this.transformProjectRoot,
502509
workflowManifest,
503510
}),
504511
],
@@ -529,7 +536,9 @@ export abstract class BaseBuilder {
529536
const { workflowManifest: fileManifest } = await applySwcTransform(
530537
relativeFilepath,
531538
source,
532-
'workflow'
539+
'workflow',
540+
workflowFile,
541+
this.transformProjectRoot
533542
);
534543
if (fileManifest.workflows) {
535544
workflowManifest.workflows = Object.assign(
@@ -700,6 +709,7 @@ export abstract class BaseBuilder {
700709
createPseudoPackagePlugin(),
701710
createSwcPlugin({
702711
mode: 'workflow',
712+
projectRoot: this.transformProjectRoot,
703713
workflowManifest,
704714
}),
705715
// This plugin must run after the swc plugin to ensure dead code elimination
@@ -951,7 +961,12 @@ export const POST = workflowEntrypoint(workflowCode);`;
951961
'.mjs',
952962
'.cjs',
953963
],
954-
plugins: [createSwcPlugin({ mode: 'client' })],
964+
plugins: [
965+
createSwcPlugin({
966+
mode: 'client',
967+
projectRoot: this.transformProjectRoot,
968+
}),
969+
],
955970
});
956971

957972
this.logEsbuildMessages(clientResult, 'client library bundle');

‎packages/builders/src/config-helpers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,15 @@ export async function getDecoratorOptionsForDirectoryWithConfigPath(
9191
*/
9292
export function createBaseBuilderConfig(options: {
9393
workingDir: string;
94+
projectRoot?: string;
9495
dirs?: string[];
9596
watch?: boolean;
9697
externalPackages?: string[];
9798
runtime?: string;
9899
}): Omit<WorkflowConfig, 'buildTarget'> {
99100
return {
100101
dirs: options.dirs ?? ['workflows'],
102+
projectRoot: options.projectRoot,
101103
workingDir: options.workingDir,
102104
watch: options.watch,
103105
stepsBundlePath: '', // Not used by base builder methods
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import {
2+
mkdirSync,
3+
mkdtempSync,
4+
realpathSync,
5+
rmSync,
6+
writeFileSync,
7+
} from 'node:fs';
8+
import { tmpdir } from 'node:os';
9+
import { dirname, join } from 'node:path';
10+
import * as esbuild from 'esbuild';
11+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12+
13+
const { applySwcTransformMock } = vi.hoisted(() => ({
14+
applySwcTransformMock: vi.fn(),
15+
}));
16+
17+
vi.mock('./apply-swc-transform.js', () => ({
18+
applySwcTransform: applySwcTransformMock,
19+
}));
20+
21+
import {
22+
createDiscoverEntriesPlugin,
23+
importParents,
24+
} from './discover-entries-esbuild-plugin.js';
25+
26+
const realTmpdir = realpathSync(tmpdir());
27+
28+
function normalizeSlashes(path: string): string {
29+
return path.replace(/\\/g, '/');
30+
}
31+
32+
function writeFile(path: string, contents = ''): void {
33+
mkdirSync(dirname(path), { recursive: true });
34+
writeFileSync(path, contents, 'utf-8');
35+
}
36+
37+
describe('createDiscoverEntriesPlugin projectRoot', () => {
38+
let testRoot: string;
39+
40+
beforeEach(() => {
41+
testRoot = mkdtempSync(join(realTmpdir, 'workflow-discover-plugin-'));
42+
importParents.clear();
43+
applySwcTransformMock.mockReset();
44+
applySwcTransformMock.mockImplementation(
45+
async (_filename: string, source: string) => ({
46+
code: source,
47+
workflowManifest: {},
48+
})
49+
);
50+
});
51+
52+
afterEach(() => {
53+
importParents.clear();
54+
rmSync(testRoot, { recursive: true, force: true });
55+
});
56+
57+
function setupFixture() {
58+
const appRoot = join(testRoot, 'apps', 'chat');
59+
const packageRoot = join(testRoot, 'packages', 'vade');
60+
const workflowFile = join(
61+
packageRoot,
62+
'src',
63+
'internal',
64+
'message',
65+
'workflow',
66+
'handle-message.ts'
67+
);
68+
69+
writeFile(
70+
workflowFile,
71+
`export async function handleMessageWorkflow(message) {
72+
"use workflow";
73+
74+
return message;
75+
}
76+
`
77+
);
78+
79+
return {
80+
appRoot,
81+
packageRoot,
82+
workflowFile,
83+
};
84+
}
85+
86+
it('uses the explicit projectRoot during discovery transforms', async () => {
87+
const fixture = setupFixture();
88+
const normalizedWorkflowFile = normalizeSlashes(fixture.workflowFile);
89+
const state = {
90+
discoveredSteps: [],
91+
discoveredWorkflows: [],
92+
discoveredSerdeFiles: [],
93+
};
94+
95+
const result = await esbuild.build({
96+
entryPoints: [fixture.workflowFile],
97+
absWorkingDir: fixture.packageRoot,
98+
bundle: true,
99+
format: 'esm',
100+
platform: 'node',
101+
write: false,
102+
plugins: [createDiscoverEntriesPlugin(state, fixture.appRoot)],
103+
});
104+
105+
expect(result.errors).toHaveLength(0);
106+
expect(state.discoveredWorkflows).toEqual([normalizedWorkflowFile]);
107+
expect(applySwcTransformMock).toHaveBeenCalledWith(
108+
normalizedWorkflowFile,
109+
expect.stringContaining('"use workflow"'),
110+
false,
111+
normalizedWorkflowFile,
112+
fixture.appRoot
113+
);
114+
});
115+
116+
it('defaults discovery transforms to absWorkingDir when projectRoot is omitted', async () => {
117+
const fixture = setupFixture();
118+
const normalizedWorkflowFile = normalizeSlashes(fixture.workflowFile);
119+
const state = {
120+
discoveredSteps: [],
121+
discoveredWorkflows: [],
122+
discoveredSerdeFiles: [],
123+
};
124+
125+
const result = await esbuild.build({
126+
entryPoints: [fixture.workflowFile],
127+
absWorkingDir: fixture.packageRoot,
128+
bundle: true,
129+
format: 'esm',
130+
platform: 'node',
131+
write: false,
132+
plugins: [createDiscoverEntriesPlugin(state)],
133+
});
134+
135+
expect(result.errors).toHaveLength(0);
136+
expect(state.discoveredWorkflows).toEqual([normalizedWorkflowFile]);
137+
expect(applySwcTransformMock).toHaveBeenCalledWith(
138+
normalizedWorkflowFile,
139+
expect.stringContaining('"use workflow"'),
140+
false,
141+
normalizedWorkflowFile,
142+
fixture.packageRoot
143+
);
144+
});
145+
});

‎packages/builders/src/discover-entries-esbuild-plugin.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,14 @@ export function parentHasChild(parent: string, childToFind: string): boolean {
5858
return false;
5959
}
6060

61-
export function createDiscoverEntriesPlugin(state: {
62-
discoveredSteps: string[];
63-
discoveredWorkflows: string[];
64-
discoveredSerdeFiles: string[];
65-
}): Plugin {
61+
export function createDiscoverEntriesPlugin(
62+
state: {
63+
discoveredSteps: string[];
64+
discoveredWorkflows: string[];
65+
discoveredSerdeFiles: string[];
66+
},
67+
projectRoot?: string
68+
): Plugin {
6669
return {
6770
name: 'discover-entries-esbuild-plugin',
6871
setup(build) {
@@ -145,9 +148,11 @@ export function createDiscoverEntriesPlugin(state: {
145148
}
146149

147150
const { code: transformedCode } = await applySwcTransform(
148-
args.path,
151+
normalizedPath,
149152
source,
150-
false
153+
false,
154+
normalizedPath,
155+
projectRoot || build.initialOptions.absWorkingDir || process.cwd()
151156
);
152157

153158
return {

‎packages/builders/src/module-specifier.test.ts‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
22
import { tmpdir } from 'node:os';
33
import { dirname, join } from 'node:path';
44
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
55
import {
66
clearModuleSpecifierCache,
77
getImportPath,
8+
resolveModuleSpecifier,
89
} from './module-specifier.js';
910

1011
function writeJson(path: string, value: unknown): void {
@@ -260,4 +261,48 @@ describe('getImportPath', () => {
260261
isPackage: false,
261262
});
262263
});
264+
265+
it('treats a workspace package file as local when projectRoot is the package itself', () => {
266+
const projectRoot = join(testRoot, 'packages/vade');
267+
const filePath = join(
268+
projectRoot,
269+
'src/internal/message/workflow/handle-message.ts'
270+
);
271+
272+
writeJson(join(projectRoot, 'package.json'), {
273+
name: 'vade',
274+
version: '0.0.0',
275+
});
276+
277+
writeFile(filePath, `'use workflow';\n`);
278+
279+
expect(resolveModuleSpecifier(filePath, projectRoot)).toEqual({
280+
moduleSpecifier: undefined,
281+
});
282+
});
283+
284+
it('uses the consuming app root to resolve workspace package workflow ids', () => {
285+
const projectRoot = join(testRoot, 'apps/chat');
286+
const packageDir = join(testRoot, 'packages/vade');
287+
const filePath = join(
288+
packageDir,
289+
'src/internal/message/workflow/handle-message.ts'
290+
);
291+
292+
writeJson(join(projectRoot, 'package.json'), {
293+
name: 'chat',
294+
dependencies: { vade: 'workspace:*' },
295+
});
296+
297+
writeJson(join(packageDir, 'package.json'), {
298+
name: 'vade',
299+
version: '0.0.0',
300+
});
301+
302+
writeFile(filePath, `'use workflow';\n`);
303+
304+
expect(resolveModuleSpecifier(filePath, projectRoot)).toEqual({
305+
moduleSpecifier: 'vade@0.0.0',
306+
});
307+
});
263308
});

0 commit comments

Comments
 (0)