Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/slow-zebras-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/builders': patch
'@workflow/next': patch
---

Fix deferred Next.js discovery bootstrap and improve workflow alias path resolution for app/pages/workflows sources.
6 changes: 0 additions & 6 deletions .changeset/strong-dryers-begin.md

This file was deleted.

262 changes: 149 additions & 113 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
import { getImportPath } from './module-specifier.js';
import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
Expand Down Expand Up @@ -153,7 +154,8 @@ export abstract class BaseBuilder {

protected async discoverEntries(
inputs: string[],
outdir: string
outdir: string,
tsconfigPath?: string
): Promise<DiscoveredEntries> {
const previousResult = this.discoveredEntries.get(inputs);

Expand All @@ -171,6 +173,11 @@ export abstract class BaseBuilder {
};

const discoverStart = Date.now();
const effectiveTsconfigPath =
tsconfigPath ?? (await this.findTsConfigPath());
const esbuildTsconfigOptions = await getEsbuildTsconfigOptions(
effectiveTsconfigPath
);
try {
await esbuild.build({
treeShaking: true,
Expand All @@ -185,6 +192,7 @@ export abstract class BaseBuilder {
sourcemap: false,
absWorkingDir: this.config.workingDir,
logLevel: 'silent',
...esbuildTsconfigOptions,
// External packages that should not be bundled during discovery
external: this.config.externalPackages || [],
});
Expand Down Expand Up @@ -345,7 +353,7 @@ export abstract class BaseBuilder {
// new entries and changes to existing ones
const discovered =
discoveredEntries ??
(await this.discoverEntries(inputFiles, dirname(outfile)));
(await this.discoverEntries(inputFiles, dirname(outfile), tsconfigPath));
const stepFiles = [...discovered.discoveredSteps].sort();
const workflowFiles = [...discovered.discoveredWorkflows].sort();
const serdeFiles = [...discovered.discoveredSerdeFiles].sort();
Expand Down Expand Up @@ -457,6 +465,8 @@ export abstract class BaseBuilder {
)
)
: undefined;
const esbuildTsconfigOptions =
await getEsbuildTsconfigOptions(tsconfigPath);
const esbuildCtx = await esbuild.context({
banner: {
js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n',
Expand All @@ -480,8 +490,9 @@ export abstract class BaseBuilder {
minify: false,
jsx: 'preserve',
logLevel: 'error',
// Use tsconfig for path alias resolution
tsconfig: tsconfigPath,
// Use tsconfig for path alias resolution.
// For symlinked configs this uses tsconfigRaw to preserve cwd-relative aliases.
...esbuildTsconfigOptions,
resolveExtensions: [
'.ts',
'.tsx',
Expand Down Expand Up @@ -583,6 +594,7 @@ export abstract class BaseBuilder {
format = 'cjs',
outfile,
bundleFinalOutput = true,
keepInterimBundleContext = this.config.watch,
tsconfigPath,
discoveredEntries,
}: {
Expand All @@ -591,6 +603,7 @@ export abstract class BaseBuilder {
outfile: string;
format?: 'cjs' | 'esm';
bundleFinalOutput?: boolean;
keepInterimBundleContext?: boolean;
discoveredEntries?: DiscoveredEntries;
}): Promise<{
manifest: WorkflowManifest;
Expand All @@ -599,7 +612,7 @@ export abstract class BaseBuilder {
}> {
const discovered =
discoveredEntries ??
(await this.discoverEntries(inputFiles, dirname(outfile)));
(await this.discoverEntries(inputFiles, dirname(outfile), tsconfigPath));
const workflowFiles = [...discovered.discoveredWorkflows].sort();
const serdeFiles = [...discovered.discoveredSerdeFiles].sort();

Expand Down Expand Up @@ -660,6 +673,8 @@ export abstract class BaseBuilder {

const bundleStartTime = Date.now();
const workflowManifest: WorkflowManifest = {};
const esbuildTsconfigOptions =
await getEsbuildTsconfigOptions(tsconfigPath);

// Bundle with esbuild and our custom SWC plugin in workflow mode.
// this bundle will be run inside a vm isolate
Expand Down Expand Up @@ -691,8 +706,9 @@ export abstract class BaseBuilder {
// This intermediate bundle is executed via runInContext() in a VM, so we need
// inline source maps to get meaningful stack traces instead of "evalmachine.<anonymous>".
sourcemap: 'inline',
// Use tsconfig for path alias resolution
tsconfig: tsconfigPath,
// Use tsconfig for path alias resolution.
// For symlinked configs this uses tsconfigRaw to preserve cwd-relative aliases.
...esbuildTsconfigOptions,
resolveExtensions: [
'.ts',
'.tsx',
Expand Down Expand Up @@ -725,132 +741,152 @@ export abstract class BaseBuilder {
// - createPseudoPackagePlugin() to handle server-only/client-only with empty modules
// - createNodeModuleErrorPlugin() to catch Node.js builtin imports at build time
});
const interimBundle = await interimBundleCtx.rebuild();

this.logEsbuildMessages(
interimBundle,
'intermediate workflow bundle',
true,
{
suppressWarnings: this.config.suppressCreateWorkflowsBundleWarnings,
}
);
this.logCreateWorkflowsBundleInfo(
'Created intermediate workflow bundle',
`${Date.now() - bundleStartTime}ms`
);
let shouldDisposeInterimBundleCtx = !keepInterimBundleContext;
try {
const interimBundle = await interimBundleCtx.rebuild();

if (this.config.workflowManifestPath) {
const resolvedPath = resolve(
process.cwd(),
this.config.workflowManifestPath
this.logEsbuildMessages(
interimBundle,
'intermediate workflow bundle',
true,
{
suppressWarnings: this.config.suppressCreateWorkflowsBundleWarnings,
}
);
this.logCreateWorkflowsBundleInfo(
'Created intermediate workflow bundle',
`${Date.now() - bundleStartTime}ms`
);
let prefix = '';

if (resolvedPath.endsWith('.cjs')) {
prefix = 'module.exports = ';
} else if (
resolvedPath.endsWith('.js') ||
resolvedPath.endsWith('.mjs')
) {
prefix = 'export default ';
}
if (this.config.workflowManifestPath) {
const resolvedPath = resolve(
process.cwd(),
this.config.workflowManifestPath
);
let prefix = '';

if (resolvedPath.endsWith('.cjs')) {
prefix = 'module.exports = ';
} else if (
resolvedPath.endsWith('.js') ||
resolvedPath.endsWith('.mjs')
) {
prefix = 'export default ';
}

await mkdir(dirname(resolvedPath), { recursive: true });
await writeFile(
resolvedPath,
prefix + JSON.stringify(workflowManifest, null, 2)
);
}
await mkdir(dirname(resolvedPath), { recursive: true });
await writeFile(
resolvedPath,
prefix + JSON.stringify(workflowManifest, null, 2)
);
}

// Create .gitignore in .swc directory
await this.createSwcGitignore();
// Create .gitignore in .swc directory
await this.createSwcGitignore();

if (!interimBundle.outputFiles || interimBundle.outputFiles.length === 0) {
throw new Error('No output files generated from esbuild');
}
if (
!interimBundle.outputFiles ||
interimBundle.outputFiles.length === 0
) {
throw new Error('No output files generated from esbuild');
}

const bundleFinal = async (interimBundle: string) => {
const workflowBundleCode = interimBundle;
const bundleFinal = async (interimBundle: string) => {
const workflowBundleCode = interimBundle;

const workflowFunctionCode = `// biome-ignore-all lint: generated file
const workflowFunctionCode = `// biome-ignore-all lint: generated file
/* eslint-disable */
import { workflowEntrypoint } from 'workflow/runtime';

const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`;

export const POST = workflowEntrypoint(workflowCode);`;

// we skip the final bundling step for Next.js so it can bundle itself
if (!bundleFinalOutput) {
if (!outfile) {
throw new Error(`Invariant: missing outfile for workflow bundle`);
// we skip the final bundling step for Next.js so it can bundle itself
if (!bundleFinalOutput) {
if (!outfile) {
throw new Error(`Invariant: missing outfile for workflow bundle`);
}
// Ensure the output directory exists
const outputDir = dirname(outfile);
await mkdir(outputDir, { recursive: true });

// Atomic write: write to temp file then rename to prevent
// file watchers from reading partial file during write
const tempPath = `${outfile}.${randomUUID()}.tmp`;
await writeFile(tempPath, workflowFunctionCode);
await rename(tempPath, outfile);
return;
}
// Ensure the output directory exists
const outputDir = dirname(outfile);
await mkdir(outputDir, { recursive: true });

// Atomic write: write to temp file then rename to prevent
// file watchers from reading partial file during write
const tempPath = `${outfile}.${randomUUID()}.tmp`;
await writeFile(tempPath, workflowFunctionCode);
await rename(tempPath, outfile);
return;
}

const bundleStartTime = Date.now();

// Now bundle this so we can resolve the @workflow/core dependency
// we could remove this if we do nft tracing or similar instead
const finalWorkflowResult = await esbuild.build({
banner: {
js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n',
},
stdin: {
contents: workflowFunctionCode,
resolveDir: this.config.workingDir,
sourcefile: 'virtual-entry.js',
loader: 'js',
},
outfile,
// Source maps for the final workflow bundle wrapper (not important since this code
// doesn't run in the VM - only the intermediate bundle sourcemap is relevant)
sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING,
absWorkingDir: this.config.workingDir,
bundle: true,
format,
platform: 'node',
target: 'es2022',
write: true,
keepNames: true,
minify: false,
external: ['@aws-sdk/credential-provider-web-identity'],
});

this.logEsbuildMessages(
finalWorkflowResult,
'final workflow bundle',
true,
{
suppressWarnings: this.config.suppressCreateWorkflowsBundleWarnings,
}
);
this.logCreateWorkflowsBundleInfo(
'Created final workflow bundle',
`${Date.now() - bundleStartTime}ms`
);
};
await bundleFinal(interimBundle.outputFiles[0].text);
const bundleStartTime = Date.now();

// Now bundle this so we can resolve the @workflow/core dependency
// we could remove this if we do nft tracing or similar instead
const finalWorkflowResult = await esbuild.build({
banner: {
js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n',
},
stdin: {
contents: workflowFunctionCode,
resolveDir: this.config.workingDir,
sourcefile: 'virtual-entry.js',
loader: 'js',
},
outfile,
// Source maps for the final workflow bundle wrapper (not important since this code
// doesn't run in the VM - only the intermediate bundle sourcemap is relevant)
sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING,
absWorkingDir: this.config.workingDir,
bundle: true,
format,
platform: 'node',
target: 'es2022',
write: true,
keepNames: true,
minify: false,
external: ['@aws-sdk/credential-provider-web-identity'],
});

if (this.config.watch) {
return {
manifest: workflowManifest,
interimBundleCtx,
bundleFinal,
this.logEsbuildMessages(
finalWorkflowResult,
'final workflow bundle',
true,
{
suppressWarnings: this.config.suppressCreateWorkflowsBundleWarnings,
}
);
this.logCreateWorkflowsBundleInfo(
'Created final workflow bundle',
`${Date.now() - bundleStartTime}ms`
);
};
await bundleFinal(interimBundle.outputFiles[0].text);

if (keepInterimBundleContext) {
shouldDisposeInterimBundleCtx = false;
return {
manifest: workflowManifest,
interimBundleCtx,
bundleFinal,
};
}
return { manifest: workflowManifest };
} catch (error) {
shouldDisposeInterimBundleCtx = true;
throw error;
} finally {
if (shouldDisposeInterimBundleCtx) {
try {
await interimBundleCtx.dispose();
} catch (disposeError) {
console.warn(
'Warning: Failed to dispose workflow bundle context',
disposeError
);
}
}
}
await interimBundleCtx.dispose();
return { manifest: workflowManifest };
}

/**
Expand Down
Loading
Loading