Skip to content
Merged
19 changes: 12 additions & 7 deletions packages/builders/src/apply-swc-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ const decoratorOptionsCache = new Map<
ReturnType<typeof getDecoratorOptionsForDirectory>
>();

function getDecoratorOptions() {
const cwd = process.cwd();
function getDecoratorOptions(projectRoot?: string) {
const cwd = projectRoot ?? process.cwd();
let cached = decoratorOptionsCache.get(cwd);
if (!cached) {
cached = getDecoratorOptionsForDirectory(cwd);
Expand Down Expand Up @@ -56,12 +56,18 @@ export async function applySwcTransform(
* Used for module specifier resolution when filename is relative.
* If not provided, filename is joined with process.cwd().
*/
absolutePath?: string
absolutePath?: string,
/**
* Optional project root used for module specifier resolution.
* Defaults to process.cwd() for backwards compatibility.
*/
projectRoot?: string
): Promise<{
code: string;
workflowManifest: WorkflowManifest;
}> {
const decoratorOptions = await getDecoratorOptions();
const resolvedProjectRoot = projectRoot ?? process.cwd();
const decoratorOptions = await getDecoratorOptions(resolvedProjectRoot);

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

// Resolve module specifier for packages (node_modules or workspace packages)
const projectRoot = process.cwd();
const absoluteFilename = absolutePath
? absolutePath
: isAbsolute(filename)
? filename
: join(projectRoot, filename);
: join(resolvedProjectRoot, filename);
const { moduleSpecifier } = resolveModuleSpecifier(
absoluteFilename,
projectRoot
resolvedProjectRoot
);

// Transform with SWC to support syntax esbuild doesn't
Expand Down
21 changes: 18 additions & 3 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export abstract class BaseBuilder {
this.config = config;
}

protected get transformProjectRoot(): string {
return this.config.projectRoot || this.config.workingDir;
}

/**
* Whether informational BaseBuilder logs should be printed.
* Subclasses can override this to silence progress logs while keeping warnings/errors.
Expand Down Expand Up @@ -171,7 +175,9 @@ export abstract class BaseBuilder {
await esbuild.build({
treeShaking: true,
entryPoints: inputs,
plugins: [createDiscoverEntriesPlugin(state)],
plugins: [
createDiscoverEntriesPlugin(state, this.transformProjectRoot),
],
platform: 'node',
write: false,
outdir,
Expand Down Expand Up @@ -499,6 +505,7 @@ export abstract class BaseBuilder {
mode: 'step',
entriesToBundle: normalizedEntriesToBundle,
outdir: outfile ? dirname(outfile) : undefined,
projectRoot: this.transformProjectRoot,
workflowManifest,
}),
],
Expand Down Expand Up @@ -529,7 +536,9 @@ export abstract class BaseBuilder {
const { workflowManifest: fileManifest } = await applySwcTransform(
relativeFilepath,
source,
'workflow'
'workflow',
workflowFile,
this.transformProjectRoot
);
if (fileManifest.workflows) {
workflowManifest.workflows = Object.assign(
Expand Down Expand Up @@ -700,6 +709,7 @@ export abstract class BaseBuilder {
createPseudoPackagePlugin(),
createSwcPlugin({
mode: 'workflow',
projectRoot: this.transformProjectRoot,
workflowManifest,
}),
// This plugin must run after the swc plugin to ensure dead code elimination
Expand Down Expand Up @@ -951,7 +961,12 @@ export const POST = workflowEntrypoint(workflowCode);`;
'.mjs',
'.cjs',
],
plugins: [createSwcPlugin({ mode: 'client' })],
plugins: [
createSwcPlugin({
mode: 'client',
projectRoot: this.transformProjectRoot,
}),
],
});

this.logEsbuildMessages(clientResult, 'client library bundle');
Expand Down
2 changes: 2 additions & 0 deletions packages/builders/src/config-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,15 @@ export async function getDecoratorOptionsForDirectoryWithConfigPath(
*/
export function createBaseBuilderConfig(options: {
workingDir: string;
projectRoot?: string;
dirs?: string[];
watch?: boolean;
externalPackages?: string[];
runtime?: string;
}): Omit<WorkflowConfig, 'buildTarget'> {
return {
dirs: options.dirs ?? ['workflows'],
projectRoot: options.projectRoot,
workingDir: options.workingDir,
watch: options.watch,
stepsBundlePath: '', // Not used by base builder methods
Expand Down
145 changes: 145 additions & 0 deletions packages/builders/src/discover-entries-esbuild-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import * as esbuild from 'esbuild';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { applySwcTransformMock } = vi.hoisted(() => ({
applySwcTransformMock: vi.fn(),
}));

vi.mock('./apply-swc-transform.js', () => ({
applySwcTransform: applySwcTransformMock,
}));

import {
createDiscoverEntriesPlugin,
importParents,
} from './discover-entries-esbuild-plugin.js';

const realTmpdir = realpathSync(tmpdir());

function normalizeSlashes(path: string): string {
return path.replace(/\\/g, '/');
}

function writeFile(path: string, contents = ''): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, contents, 'utf-8');
}

describe('createDiscoverEntriesPlugin projectRoot', () => {
let testRoot: string;

beforeEach(() => {
testRoot = mkdtempSync(join(realTmpdir, 'workflow-discover-plugin-'));
importParents.clear();
applySwcTransformMock.mockReset();
applySwcTransformMock.mockImplementation(
async (_filename: string, source: string) => ({
code: source,
workflowManifest: {},
})
);
});

afterEach(() => {
importParents.clear();
rmSync(testRoot, { recursive: true, force: true });
});

function setupFixture() {
const appRoot = join(testRoot, 'apps', 'chat');
const packageRoot = join(testRoot, 'packages', 'vade');
const workflowFile = join(
packageRoot,
'src',
'internal',
'message',
'workflow',
'handle-message.ts'
);

writeFile(
workflowFile,
`export async function handleMessageWorkflow(message) {
"use workflow";

return message;
}
`
);

return {
appRoot,
packageRoot,
workflowFile,
};
}

it('uses the explicit projectRoot during discovery transforms', async () => {
const fixture = setupFixture();
const normalizedWorkflowFile = normalizeSlashes(fixture.workflowFile);
const state = {
discoveredSteps: [],
discoveredWorkflows: [],
discoveredSerdeFiles: [],
};

const result = await esbuild.build({
entryPoints: [fixture.workflowFile],
absWorkingDir: fixture.packageRoot,
bundle: true,
format: 'esm',
platform: 'node',
write: false,
plugins: [createDiscoverEntriesPlugin(state, fixture.appRoot)],
});

expect(result.errors).toHaveLength(0);
expect(state.discoveredWorkflows).toEqual([normalizedWorkflowFile]);
expect(applySwcTransformMock).toHaveBeenCalledWith(
normalizedWorkflowFile,
expect.stringContaining('"use workflow"'),
false,
normalizedWorkflowFile,
fixture.appRoot
);
});

it('defaults discovery transforms to absWorkingDir when projectRoot is omitted', async () => {
const fixture = setupFixture();
const normalizedWorkflowFile = normalizeSlashes(fixture.workflowFile);
const state = {
discoveredSteps: [],
discoveredWorkflows: [],
discoveredSerdeFiles: [],
};

const result = await esbuild.build({
entryPoints: [fixture.workflowFile],
absWorkingDir: fixture.packageRoot,
bundle: true,
format: 'esm',
platform: 'node',
write: false,
plugins: [createDiscoverEntriesPlugin(state)],
});

expect(result.errors).toHaveLength(0);
expect(state.discoveredWorkflows).toEqual([normalizedWorkflowFile]);
expect(applySwcTransformMock).toHaveBeenCalledWith(
normalizedWorkflowFile,
expect.stringContaining('"use workflow"'),
false,
normalizedWorkflowFile,
fixture.packageRoot
);
});
});
19 changes: 12 additions & 7 deletions packages/builders/src/discover-entries-esbuild-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,14 @@ export function parentHasChild(parent: string, childToFind: string): boolean {
return false;
}

export function createDiscoverEntriesPlugin(state: {
discoveredSteps: string[];
discoveredWorkflows: string[];
discoveredSerdeFiles: string[];
}): Plugin {
export function createDiscoverEntriesPlugin(
state: {
discoveredSteps: string[];
discoveredWorkflows: string[];
discoveredSerdeFiles: string[];
},
projectRoot?: string
): Plugin {
return {
name: 'discover-entries-esbuild-plugin',
setup(build) {
Expand Down Expand Up @@ -145,9 +148,11 @@ export function createDiscoverEntriesPlugin(state: {
}

const { code: transformedCode } = await applySwcTransform(
args.path,
normalizedPath,
source,
false
false,
normalizedPath,
projectRoot || build.initialOptions.absWorkingDir || process.cwd()
);

return {
Expand Down
47 changes: 46 additions & 1 deletion packages/builders/src/module-specifier.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
clearModuleSpecifierCache,
getImportPath,
resolveModuleSpecifier,
} from './module-specifier.js';

function writeJson(path: string, value: unknown): void {
Expand Down Expand Up @@ -260,4 +261,48 @@ describe('getImportPath', () => {
isPackage: false,
});
});

it('treats a workspace package file as local when projectRoot is the package itself', () => {
const projectRoot = join(testRoot, 'packages/vade');
const filePath = join(
projectRoot,
'src/internal/message/workflow/handle-message.ts'
);

writeJson(join(projectRoot, 'package.json'), {
name: 'vade',
version: '0.0.0',
});

writeFile(filePath, `'use workflow';\n`);

expect(resolveModuleSpecifier(filePath, projectRoot)).toEqual({
moduleSpecifier: undefined,
});
});

it('uses the consuming app root to resolve workspace package workflow ids', () => {
const projectRoot = join(testRoot, 'apps/chat');
const packageDir = join(testRoot, 'packages/vade');
const filePath = join(
packageDir,
'src/internal/message/workflow/handle-message.ts'
);

writeJson(join(projectRoot, 'package.json'), {
name: 'chat',
dependencies: { vade: 'workspace:*' },
});

writeJson(join(packageDir, 'package.json'), {
name: 'vade',
version: '0.0.0',
});

writeFile(filePath, `'use workflow';\n`);

expect(resolveModuleSpecifier(filePath, projectRoot)).toEqual({
moduleSpecifier: 'vade@0.0.0',
});
});
});
Loading
Loading