From ef1417a101e9ee3463fd201ba89a1f0b8ec0324f Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Sat, 25 Oct 2025 20:12:48 -0700 Subject: [PATCH 1/2] Rename vercel static builder to basic builder --- .changeset/famous-jeans-itch.md | 5 +++++ packages/cli/src/commands/build.ts | 20 +++++++++---------- packages/cli/src/commands/dev.ts | 4 ++-- packages/cli/src/commands/init.ts | 2 +- .../{vercel-static.ts => standalone.ts} | 2 +- packages/cli/src/lib/config/types.ts | 4 ++-- .../cli/src/lib/config/workflow-config.ts | 2 +- 7 files changed, 22 insertions(+), 17 deletions(-) create mode 100644 .changeset/famous-jeans-itch.md rename packages/cli/src/lib/builders/{vercel-static.ts => standalone.ts} (97%) diff --git a/.changeset/famous-jeans-itch.md b/.changeset/famous-jeans-itch.md new file mode 100644 index 00000000..505ad32b --- /dev/null +++ b/.changeset/famous-jeans-itch.md @@ -0,0 +1,5 @@ +--- +"@workflow/cli": patch +--- + +Rename vercel-static builder to standalone diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 25e24164..c9ddaf59 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -1,7 +1,7 @@ import { Args, Flags } from '@oclif/core'; import { BaseCommand } from '../base.js'; import { VercelBuildOutputAPIBuilder } from '../lib/builders/vercel-build-output-api.js'; -import { VercelStaticBuilder } from '../lib/builders/vercel-static.js'; +import { StandaloneBuilder } from '../lib/builders/standalone.js'; import { type BuildTarget, isValidBuildTarget } from '../lib/config/types.js'; import { getWorkflowConfig } from '../lib/config/workflow-config.js'; @@ -11,15 +11,15 @@ export default class Build extends BaseCommand { static examples = [ '$ workflow build', '$ workflow build --target vercel-build-output-api', - '$ workflow build vercel-static', + '$ workflow build standalone', ]; static flags = { target: Flags.string({ char: 't', description: 'build target', - options: ['vercel-static', 'vercel-build-output-api'], - default: 'vercel-static', + options: ['standalone', 'vercel-build-output-api'], + default: 'standalone', }), 'workflow-manifest': Flags.string({ char: 'm', @@ -59,10 +59,10 @@ export default class Build extends BaseCommand { // Validate build target if (!isValidBuildTarget(buildTarget)) { this.logWarn( - `Invalid target "${buildTarget}". Using default "vercel-static".` + `Invalid target "${buildTarget}". Using default "standalone".` ); - this.logWarn('Valid targets: vercel-static, vercel-build-output-api'); - buildTarget = 'vercel-static'; + this.logWarn('Valid targets: standalone, vercel-build-output-api'); + buildTarget = 'standalone'; } this.logInfo(`Using target: ${buildTarget}`); @@ -74,9 +74,9 @@ export default class Build extends BaseCommand { try { // Build using appropriate builder - if (config.buildTarget === 'vercel-static') { - this.logInfo('Building with VercelStaticBuilder'); - const builder = new VercelStaticBuilder(config); + if (config.buildTarget === 'standalone') { + this.logInfo('Building with StandaloneBuilder'); + const builder = new StandaloneBuilder(config); await builder.build(); } else if (config.buildTarget === 'vercel-build-output-api') { this.logInfo('Building with VercelBuildOutputAPIBuilder'); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index df94816f..0ad98552 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -16,8 +16,8 @@ export default class Dev extends BaseCommand { target: Flags.string({ char: 't', description: 'build target for development', - options: ['vercel-static', 'vercel-build-output-api'], - default: 'vercel-static', + options: ['standalone', 'vercel-build-output-api'], + default: 'standalone', }), }; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index cc6c8b24..99aa25c6 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -13,7 +13,7 @@ export default class Init extends BaseCommand { static flags = { template: Flags.string({ description: 'template to use', - options: ['basic', 'nextjs', 'express'], + options: ['standalone', 'nextjs', 'express'], }), yes: Flags.boolean({ char: 'y', diff --git a/packages/cli/src/lib/builders/vercel-static.ts b/packages/cli/src/lib/builders/standalone.ts similarity index 97% rename from packages/cli/src/lib/builders/vercel-static.ts rename to packages/cli/src/lib/builders/standalone.ts index 9348081b..b08d71da 100644 --- a/packages/cli/src/lib/builders/vercel-static.ts +++ b/packages/cli/src/lib/builders/standalone.ts @@ -2,7 +2,7 @@ import { mkdir } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { BaseBuilder } from './base-builder.js'; -export class VercelStaticBuilder extends BaseBuilder { +export class StandaloneBuilder extends BaseBuilder { async build(): Promise { const inputFiles = await this.getInputFiles(); const tsConfig = await this.getTsConfigOptions(); diff --git a/packages/cli/src/lib/config/types.ts b/packages/cli/src/lib/config/types.ts index 5957fc57..9bdeb712 100644 --- a/packages/cli/src/lib/config/types.ts +++ b/packages/cli/src/lib/config/types.ts @@ -1,5 +1,5 @@ export const validBuildTargets = [ - 'vercel-static', + 'standalone', 'vercel-build-output-api', 'next', ] as const; @@ -41,5 +41,5 @@ export interface WorkflowConfig { export function isValidBuildTarget( target: string | undefined ): target is BuildTarget { - return target === 'vercel-static' || target === 'vercel-build-output-api'; + return target === 'standalone' || target === 'vercel-build-output-api'; } diff --git a/packages/cli/src/lib/config/workflow-config.ts b/packages/cli/src/lib/config/workflow-config.ts index ea44d513..fe18c25b 100644 --- a/packages/cli/src/lib/config/workflow-config.ts +++ b/packages/cli/src/lib/config/workflow-config.ts @@ -8,7 +8,7 @@ export const getWorkflowConfig = ( buildTarget?: BuildTarget; workflowManifest?: string; } = { - buildTarget: 'vercel-static', + buildTarget: 'standalone', } ) => { const config: WorkflowConfig = { From a4adba78b23b9d10cd7b69e77fadcf2ed124c690 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Sat, 25 Oct 2025 22:59:06 -0700 Subject: [PATCH 2/2] feat: create @workflow/builders package with shared builder infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit extracts builder infrastructure from @workflow/cli into a new shared @workflow/builders package. This improves code organization by: - Creating a dedicated package for builder functionality - Allowing @workflow/next and @workflow/nitro to depend on builders directly - Reducing coupling between framework integrations and the CLI - Preparing for moving NextBuilder to @workflow/next in the next PR Changes: - Created new @workflow/builders package - Moved BaseBuilder, BasicBuilder, and VercelBuildOutputAPIBuilder - Moved esbuild plugins (swc, discover-entries, node-module) - Moved WorkflowConfig and BuildTarget types - Updated @workflow/cli to import from @workflow/builders - Updated @workflow/nitro to import from @workflow/builders - Re-exported types from CLI for backwards compatibility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/create-builders-package.md | 7 + packages/builders/README.md | 43 ++ packages/builders/package.json | 54 ++ packages/builders/src/apply-swc-transform.ts | 76 ++ packages/builders/src/base-builder.ts | 673 ++++++++++++++++++ .../src/discover-entries-esbuild-plugin.ts | 105 +++ packages/builders/src/index.ts | 10 + .../src/node-module-esbuild-plugin.test.ts | 146 ++++ .../src/node-module-esbuild-plugin.ts | 26 + packages/builders/src/standalone.ts | 100 +++ packages/builders/src/swc-esbuild-plugin.ts | 183 +++++ packages/builders/src/types.ts | 31 + .../builders/src/vercel-build-output-api.ts | 213 ++++++ packages/builders/tsconfig.json | 8 + packages/cli/package.json | 1 + packages/cli/src/commands/build.ts | 6 +- packages/cli/src/lib/builders/next-build.ts | 2 +- packages/cli/src/lib/builders/standalone.ts | 2 +- .../lib/builders/vercel-build-output-api.ts | 2 +- packages/cli/src/lib/config/types.ts | 40 +- packages/nitro/package.json | 2 +- packages/nitro/src/builders.ts | 3 +- pnpm-lock.yaml | 54 +- 23 files changed, 1744 insertions(+), 43 deletions(-) create mode 100644 .changeset/create-builders-package.md create mode 100644 packages/builders/README.md create mode 100644 packages/builders/package.json create mode 100644 packages/builders/src/apply-swc-transform.ts create mode 100644 packages/builders/src/base-builder.ts create mode 100644 packages/builders/src/discover-entries-esbuild-plugin.ts create mode 100644 packages/builders/src/index.ts create mode 100644 packages/builders/src/node-module-esbuild-plugin.test.ts create mode 100644 packages/builders/src/node-module-esbuild-plugin.ts create mode 100644 packages/builders/src/standalone.ts create mode 100644 packages/builders/src/swc-esbuild-plugin.ts create mode 100644 packages/builders/src/types.ts create mode 100644 packages/builders/src/vercel-build-output-api.ts create mode 100644 packages/builders/tsconfig.json diff --git a/.changeset/create-builders-package.md b/.changeset/create-builders-package.md new file mode 100644 index 00000000..0de4aa85 --- /dev/null +++ b/.changeset/create-builders-package.md @@ -0,0 +1,7 @@ +--- +"@workflow/builders": patch +"@workflow/cli": patch +"@workflow/nitro": patch +--- + +Create @workflow/builders package with shared builder infrastructure diff --git a/packages/builders/README.md b/packages/builders/README.md new file mode 100644 index 00000000..fa843fdf --- /dev/null +++ b/packages/builders/README.md @@ -0,0 +1,43 @@ +# @workflow/builders + +Shared builder infrastructure for Workflow DevKit. This package provides the base builder class and utilities used by framework-specific integrations. + +## Overview + +This package contains the core build logic for transforming workflow source files into deployable bundles. It is used by: + +- `@workflow/cli` - For standalone/basic builds +- `@workflow/next` - For Next.js integration +- `@workflow/nitro` - For Nitro/Nuxt integration + +## Key Components + +- **BaseBuilder**: Abstract base class providing common build logic +- **Build plugins**: esbuild plugins for workflow transformations +- **SWC integration**: Compiler plugin integration for workflow directives + +## Usage + +This package is typically not used directly. Instead, use one of the framework-specific packages that extend `BaseBuilder`: + +```typescript +import { BaseBuilder } from '@workflow/builders'; + +class MyBuilder extends BaseBuilder { + async build(): Promise { + // Implement builder-specific logic + } +} +``` + +## Architecture + +The builder system uses: + +1. **esbuild** for bundling and tree-shaking +2. **SWC** for transforming workflow directives (`"use workflow"`, `"use step"`) +3. **Enhanced resolve** for TypeScript path mapping + +## License + +MIT diff --git a/packages/builders/package.json b/packages/builders/package.json new file mode 100644 index 00000000..23e1caa3 --- /dev/null +++ b/packages/builders/package.json @@ -0,0 +1,54 @@ +{ + "name": "@workflow/builders", + "version": "4.0.1-beta.3", + "description": "Shared builder infrastructure for Workflow DevKit", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./base-builder": { + "types": "./dist/base-builder.d.ts", + "default": "./dist/base-builder.js" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/vercel/workflow.git", + "directory": "packages/builders" + }, + "scripts": { + "build": "tsc", + "clean": "tsc --build --clean && rm -r dist ||:", + "dev": "tsc --watch", + "test": "vitest run src", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "catalog:", + "@workflow/tsconfig": "workspace:*" + }, + "dependencies": { + "@swc/core": "1.11.24", + "@workflow/swc-plugin": "workspace:*", + "@workflow/errors": "workspace:*", + "@workflow/core": "workspace:*", + "builtin-modules": "^5.0.0", + "chalk": "^5.6.2", + "comment-json": "4.2.5", + "enhanced-resolve": "5.18.2", + "esbuild": "catalog:", + "find-up": "7.0.0", + "tinyglobby": "^0.2.14" + } +} diff --git a/packages/builders/src/apply-swc-transform.ts b/packages/builders/src/apply-swc-transform.ts new file mode 100644 index 00000000..c7937eda --- /dev/null +++ b/packages/builders/src/apply-swc-transform.ts @@ -0,0 +1,76 @@ +import { transform } from '@swc/core'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.filename); + +export type WorkflowManifest = { + steps?: { + [relativeFileName: string]: { + [functionName: string]: { + stepId: string; + }; + }; + }; + workflows?: { + [relativeFileName: string]: { + [functionName: string]: { + workflowId: string; + }; + }; + }; +}; + +export async function applySwcTransform( + filename: string, + source: string, + mode: 'workflow' | 'step' | 'client' | false, + jscConfig?: { + paths?: Record; + // this must be absolute path + baseUrl?: string; + } +): Promise<{ + code: string; + workflowManifest: WorkflowManifest; +}> { + // Determine if this is a TypeScript file + const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx'); + const isTsx = filename.endsWith('.tsx'); + + // Transform with SWC to support syntax esbuild doesn't + const result = await transform(source, { + filename, + swcrc: false, + jsc: { + parser: { + syntax: isTypeScript ? 'typescript' : 'ecmascript', + tsx: isTsx, + }, + target: 'es2022', + experimental: mode + ? { + plugins: [[require.resolve('@workflow/swc-plugin'), { mode }]], + } + : undefined, + ...jscConfig, + }, + // TODO: investigate proper source map support as they + // won't even be used in Node.js by default unless we + // intercept errors and apply them ourselves + sourceMaps: false, + minify: false, + }); + + const workflowCommentMatch = result.code.match( + /\/\*\*__internal_workflows({.*?})\*\//s + ); + + const parsedWorkflows = JSON.parse( + workflowCommentMatch?.[1] || '{}' + ) as WorkflowManifest; + + return { + code: result.code, + workflowManifest: parsedWorkflows || {}, + }; +} diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts new file mode 100644 index 00000000..48caa357 --- /dev/null +++ b/packages/builders/src/base-builder.ts @@ -0,0 +1,673 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import chalk from 'chalk'; +import { parse } from 'comment-json'; +import enhancedResolveOriginal from 'enhanced-resolve'; +import * as esbuild from 'esbuild'; +import { findUp } from 'find-up'; +import { glob } from 'tinyglobby'; +import type { WorkflowConfig } from './types.js'; +import type { WorkflowManifest } from './apply-swc-transform.js'; +import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js'; +import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; +import { createSwcPlugin } from './swc-esbuild-plugin.js'; + +const enhancedResolve = promisify(enhancedResolveOriginal); + +const EMIT_SOURCEMAPS_FOR_DEBUGGING = + process.env.WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING === '1'; + +export abstract class BaseBuilder { + protected config: WorkflowConfig; + + constructor(config: WorkflowConfig) { + this.config = config; + } + + abstract build(): Promise; + + protected async getTsConfigOptions(): Promise<{ + baseUrl?: string; + paths?: Record; + }> { + const options: { + paths?: Record; + baseUrl?: string; + } = {}; + + const cwd = this.config.workingDir || process.cwd(); + + const tsJsConfig = await findUp(['tsconfig.json', 'jsconfig.json'], { + cwd, + }); + + if (tsJsConfig) { + try { + const rawJson = await readFile(tsJsConfig, 'utf8'); + const parsed: null | { + compilerOptions?: { + paths?: Record | undefined; + baseUrl?: string; + }; + } = parse(rawJson) as any; + + if (parsed) { + options.paths = parsed.compilerOptions?.paths; + + if (parsed.compilerOptions?.baseUrl) { + options.baseUrl = resolve(cwd, parsed.compilerOptions.baseUrl); + } else { + options.baseUrl = cwd; + } + } + } catch (err) { + console.error( + `Failed to parse ${tsJsConfig} aliases might not apply properly`, + err + ); + } + } + + return options; + } + + protected async getInputFiles(): Promise { + const result = await glob( + this.config.dirs.map( + (dir) => + `${resolve( + this.config.workingDir, + dir + )}/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` + ), + { + ignore: [ + '**/node_modules/**', + '**/.git/**', + '**/.next/**', + '**/.vercel/**', + '**/.workflow-data/**', + '**/.well-known/workflow/**', + ], + absolute: true, + } + ); + return result; + } + + private discoveredEntries: WeakMap< + string[], + { + discoveredSteps: string[]; + discoveredWorkflows: string[]; + } + > = new WeakMap(); + + protected async discoverEntries( + inputs: string[], + outdir: string + ): Promise<{ + discoveredSteps: string[]; + discoveredWorkflows: string[]; + }> { + const previousResult = this.discoveredEntries.get(inputs); + + if (previousResult) { + return previousResult; + } + const state: { + discoveredSteps: string[]; + discoveredWorkflows: string[]; + } = { + discoveredSteps: [], + discoveredWorkflows: [], + }; + + const discoverStart = Date.now(); + try { + await esbuild.build({ + treeShaking: true, + entryPoints: inputs, + plugins: [createDiscoverEntriesPlugin(state)], + platform: 'node', + write: false, + outdir, + bundle: true, + sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING, + absWorkingDir: this.config.workingDir, + logLevel: 'silent', + }); + } catch (_) {} + + console.log( + `Discovering workflow directives`, + `${Date.now() - discoverStart}ms` + ); + + this.discoveredEntries.set(inputs, state); + return state; + } + + // write debug information to JSON file (maybe move to diagnostics folder) + // if on Vercel + private async writeDebugFile( + outfile: string, + debugData: object, + merge?: boolean + ): Promise { + try { + let existing = {}; + if (merge) { + existing = JSON.parse( + await readFile(`${outfile}.debug.json`, 'utf8').catch(() => '{}') + ); + } + await writeFile( + `${outfile}.debug.json`, + JSON.stringify( + { + ...existing, + ...debugData, + }, + null, + 2 + ) + ); + } catch (error: unknown) { + console.warn('Failed to write debug file:', error); + } + } + + private logEsbuildMessages( + result: { errors?: any[]; warnings?: any[] }, + phase: string + ): void { + if (result.errors && result.errors.length > 0) { + console.error(`❌ esbuild errors in ${phase}:`); + for (const error of result.errors) { + console.error(` ${error.text}`); + if (error.location) { + console.error( + ` at ${error.location.file}:${error.location.line}:${error.location.column}` + ); + } + } + } + + if (result.warnings && result.warnings.length > 0) { + console.warn(`! esbuild warnings in ${phase}:`); + for (const warning of result.warnings) { + console.warn(` ${warning.text}`); + if (warning.location) { + console.warn( + ` at ${warning.location.file}:${warning.location.line}:${warning.location.column}` + ); + } + } + } + } + + protected async createStepsBundle({ + inputFiles, + format = 'cjs', + outfile, + externalizeNonSteps, + tsBaseUrl, + tsPaths, + }: { + tsPaths?: Record; + tsBaseUrl?: string; + inputFiles: string[]; + outfile: string; + format?: 'cjs' | 'esm'; + externalizeNonSteps?: boolean; + }): Promise { + // These need to handle watching for dev to scan for + // new entries and changes to existing ones + const { discoveredSteps: stepFiles } = await this.discoverEntries( + inputFiles, + dirname(outfile) + ); + + // log the step files for debugging + await this.writeDebugFile(outfile, { stepFiles }); + + const stepsBundleStart = Date.now(); + const workflowManifest: WorkflowManifest = {}; + const builtInSteps = 'workflow/internal/builtins'; + + const resolvedBuiltInSteps = await enhancedResolve( + dirname(outfile), + 'workflow/internal/builtins' + ).catch((err) => { + throw new Error( + [ + chalk.red('Failed to resolve built-in steps sources.'), + `${chalk.yellow.bold('hint:')} run \`${chalk.cyan.italic('npm install workflow')}\` to resolve this issue.`, + '', + `Caused by: ${chalk.red(String(err))}`, + ].join('\n') + ); + }); + + // Create a virtual entry that imports all files. All step definitions + // will get registered thanks to the swc transform. + const imports = stepFiles.map((file) => `import '${file}';`).join('\n'); + const entryContent = ` + // Built in steps + import '${builtInSteps}'; + // User steps + ${imports} + // API entrypoint + export { stepEntrypoint as POST } from 'workflow/runtime';`; + + // Bundle with esbuild and our custom SWC plugin + const esbuildCtx = await esbuild.context({ + banner: { + js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n', + }, + stdin: { + contents: entryContent, + resolveDir: this.config.workingDir, + sourcefile: 'virtual-entry.js', + loader: 'js', + }, + outfile, + absWorkingDir: this.config.workingDir, + bundle: true, + format, + platform: 'node', + conditions: ['node'], + target: 'es2022', + write: true, + treeShaking: true, + keepNames: true, + minify: false, + resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], + // TODO: investigate proper source map support + sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING, + plugins: [ + createSwcPlugin({ + mode: 'step', + entriesToBundle: externalizeNonSteps + ? [ + ...stepFiles, + ...(resolvedBuiltInSteps ? [resolvedBuiltInSteps] : []), + ] + : undefined, + outdir: outfile ? dirname(outfile) : undefined, + tsBaseUrl, + tsPaths, + workflowManifest, + }), + ], + // Plugin should catch most things, but this lets users hard override + // if the plugin misses anything that should be externalized + external: this.config.externalPackages || [], + }); + + const stepsResult = await esbuildCtx.rebuild(); + + this.logEsbuildMessages(stepsResult, 'steps bundle creation'); + console.log('Created steps bundle', `${Date.now() - stepsBundleStart}ms`); + + const partialWorkflowManifest = { + steps: workflowManifest.steps, + }; + // always write to debug file + await this.writeDebugFile( + join(dirname(outfile), 'manifest'), + partialWorkflowManifest, + true + ); + + // Create .gitignore in .swc directory + await this.createSwcGitignore(); + + if (this.config.watch) { + return esbuildCtx; + } + await esbuildCtx.dispose(); + } + + protected async createWorkflowsBundle({ + inputFiles, + format = 'cjs', + outfile, + bundleFinalOutput = true, + tsBaseUrl, + tsPaths, + }: { + tsPaths?: Record; + tsBaseUrl?: string; + inputFiles: string[]; + outfile: string; + format?: 'cjs' | 'esm'; + bundleFinalOutput?: boolean; + }): Promise Promise; + }> { + const { discoveredWorkflows: workflowFiles } = await this.discoverEntries( + inputFiles, + dirname(outfile) + ); + + // log the workflow files for debugging + await this.writeDebugFile(outfile, { workflowFiles }); + + // Create a virtual entry that imports all files + const imports = + `globalThis.__private_workflows = new Map();\n` + + workflowFiles + .map( + (file, workflowFileIdx) => + `import * as workflowFile${workflowFileIdx} from '${file}'; + Object.values(workflowFile${workflowFileIdx}).map(item => item?.workflowId && globalThis.__private_workflows.set(item.workflowId, item))` + ) + .join('\n'); + + const bundleStartTime = Date.now(); + const workflowManifest: WorkflowManifest = {}; + + // Bundle with esbuild and our custom SWC plugin in workflow mode. + // this bundle will be run inside a vm isolate + const interimBundleCtx = await esbuild.context({ + stdin: { + contents: imports, + resolveDir: this.config.workingDir, + sourcefile: 'virtual-entry.js', + loader: 'js', + }, + bundle: true, + absWorkingDir: this.config.workingDir, + format: 'cjs', // Runs inside the VM which expects cjs + platform: 'neutral', // The platform is neither node nor browser + mainFields: ['module', 'main'], // To support npm style imports + conditions: ['workflow'], // Allow packages to export 'workflow' compliant versions + target: 'es2022', + write: false, + treeShaking: true, + keepNames: true, + minify: false, + // TODO: investigate proper source map support + sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING, + resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], + plugins: [ + createSwcPlugin({ + mode: 'workflow', + tsBaseUrl, + tsPaths, + workflowManifest, + }), + // This plugin must run after the swc plugin to ensure dead code elimination + // happens first, preventing false positives on Node.js imports in unused code paths + createNodeModuleErrorPlugin(), + ], + }); + const interimBundle = await interimBundleCtx.rebuild(); + + this.logEsbuildMessages(interimBundle, 'intermediate workflow bundle'); + console.log( + 'Created intermediate workflow bundle', + `${Date.now() - bundleStartTime}ms` + ); + const partialWorkflowManifest = { + workflows: workflowManifest.workflows, + }; + await this.writeDebugFile( + join(dirname(outfile), 'manifest'), + partialWorkflowManifest, + true + ); + + 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.workflows, null, 2) + ); + } + + // Create .gitignore in .swc directory + await this.createSwcGitignore(); + + if (!interimBundle.outputFiles || interimBundle.outputFiles.length === 0) { + throw new Error('No output files generated from esbuild'); + } + + const bundleFinal = async (interimBundle: string) => { + const workflowBundleCode = interimBundle; + + // Create the workflow function handler with proper linter suppressions + 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`); + } + // Ensure the output directory exists + const outputDir = dirname(outfile); + await mkdir(outputDir, { recursive: true }); + + await writeFile(outfile, workflowFunctionCode); + 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, + // TODO: investigate proper source map support + 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'); + console.log( + 'Created final workflow bundle', + `${Date.now() - bundleStartTime}ms` + ); + }; + await bundleFinal(interimBundle.outputFiles[0].text); + + if (this.config.watch) { + return { + interimBundleCtx, + bundleFinal, + }; + } + await interimBundleCtx.dispose(); + } + + protected async buildClientLibrary(): Promise { + if (!this.config.clientBundlePath) { + // Silently exit since no client bundle was requested + return; + } + + console.log('Generating a client library at', this.config.clientBundlePath); + console.log( + 'NOTE: The recommended way to use workflow with a framework like NextJS is using the loader/plugin with webpack/turbobpack/rollup' + ); + + // Ensure we have the directory for the client bundle + const outputDir = dirname(this.config.clientBundlePath); + await mkdir(outputDir, { recursive: true }); + + const inputFiles = await this.getInputFiles(); + + // Create a virtual entry that imports all files + const imports = inputFiles + .map((file) => `export * from '${file}';`) + .join('\n'); + + // Bundle with esbuild and our custom SWC plugin + const clientResult = await esbuild.build({ + banner: { + js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n', + }, + stdin: { + contents: imports, + resolveDir: this.config.workingDir, + sourcefile: 'virtual-entry.js', + loader: 'js', + }, + outfile: this.config.clientBundlePath, + bundle: true, + format: 'esm', + platform: 'node', + target: 'es2022', + write: true, + treeShaking: true, + external: ['@workflow/core'], + resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], + plugins: [createSwcPlugin({ mode: 'client' })], + }); + + this.logEsbuildMessages(clientResult, 'client library bundle'); + + // Create .gitignore in .swc directory + await this.createSwcGitignore(); + } + + protected async createWebhookBundle({ + outfile, + bundle = false, + }: { + outfile: string; + bundle?: boolean; + }): Promise { + console.log('Creating webhook route'); + await mkdir(dirname(outfile), { recursive: true }); + + // Create a static route that calls resumeWebhook + // This route works for both Next.js and Vercel Build Output API + const routeContent = `import { resumeWebhook } from 'workflow/api'; + +async function handler(request) { + const url = new URL(request.url); + // Extract token from pathname: /.well-known/workflow/v1/webhook/{token} + const pathParts = url.pathname.split('/'); + const token = decodeURIComponent(pathParts[pathParts.length - 1]); + + if (!token) { + return new Response('Missing token', { status: 400 }); + } + + try { + const response = await resumeWebhook(token, request); + return response; + } catch (error) { + // TODO: differentiate between invalid token and other errors + console.error('Error during resumeWebhook', error); + return new Response(null, { status: 404 }); + } +} + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const PATCH = handler; +export const DELETE = handler; +export const HEAD = handler; +export const OPTIONS = handler; +`; + + if (!bundle) { + // For Next.js, just write the unbundled file + await writeFile(outfile, routeContent); + return; + } + + // For Build Output API, bundle with esbuild to resolve imports + + const webhookBundleStart = Date.now(); + const result = await esbuild.build({ + banner: { + js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n', + }, + stdin: { + contents: routeContent, + resolveDir: this.config.workingDir, + sourcefile: 'webhook-route.js', + loader: 'js', + }, + outfile, + absWorkingDir: this.config.workingDir, + bundle: true, + format: 'cjs', + platform: 'node', + conditions: ['import', 'module', 'node', 'default'], + target: 'es2022', + write: true, + treeShaking: true, + keepNames: true, + minify: false, + resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], + sourcemap: false, + mainFields: ['module', 'main'], + // Don't externalize anything - bundle everything including workflow packages + external: [], + }); + + this.logEsbuildMessages(result, 'webhook bundle creation'); + console.log( + 'Created webhook bundle', + `${Date.now() - webhookBundleStart}ms` + ); + } + + private async createSwcGitignore(): Promise { + try { + await writeFile( + join(this.config.workingDir, '.swc', '.gitignore'), + '*\n' + ); + } catch { + // We're intentionally silently ignoring this error - creating .gitignore isn't critical + } + } +} diff --git a/packages/builders/src/discover-entries-esbuild-plugin.ts b/packages/builders/src/discover-entries-esbuild-plugin.ts new file mode 100644 index 00000000..056e7c0b --- /dev/null +++ b/packages/builders/src/discover-entries-esbuild-plugin.ts @@ -0,0 +1,105 @@ +import { readFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import enhancedResolveOriginal from 'enhanced-resolve'; +import type { Plugin } from 'esbuild'; +import { applySwcTransform } from './apply-swc-transform.js'; + +const enhancedResolve = promisify(enhancedResolveOriginal); + +export const jsTsRegex = /\.(ts|tsx|js|jsx|mjs|cjs)$/; + +// Matches: 'use workflow'; "use workflow"; 'use step'; "use step"; at line start with optional whitespace +export const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m; +export const useStepPattern = /^\s*(['"])use step\1;?\s*$/m; + +// parent -> child relationship +export const importParents = new Map(); + +// check if a parent has a child in it's import chain +// e.g. if a dependency needs to be bundled because it has +// a 'use workflow/'use step' directive in it +export function parentHasChild(parent: string, childToFind: string) { + let child: string | undefined; + let currentParent: string | undefined = parent; + const visited = new Set(); + + do { + if (currentParent) { + // Detect circular imports to prevent infinite loop + if (visited.has(currentParent)) { + break; + } + visited.add(currentParent); + child = importParents.get(currentParent); + } + + if (child === childToFind) { + return true; + } + currentParent = child; + } while (child && currentParent); + + return false; +} + +export function createDiscoverEntriesPlugin(state: { + discoveredSteps: string[]; + discoveredWorkflows: string[]; +}): Plugin { + return { + name: 'discover-entries-esbuild-plugin', + setup(build) { + build.onResolve({ filter: jsTsRegex }, async (args) => { + try { + const resolved = await enhancedResolve(args.resolveDir, args.path); + + if (resolved) { + importParents.set(args.importer, resolved); + } + } catch (_) {} + return null; + }); + + // Handle TypeScript and JavaScript files + build.onLoad({ filter: jsTsRegex }, async (args) => { + try { + // Determine the loader based on the output + let loader: 'js' | 'jsx' = 'js'; + const isTypeScript = + args.path.endsWith('.ts') || args.path.endsWith('.tsx'); + if (!isTypeScript && args.path.endsWith('.jsx')) { + loader = 'jsx'; + } + const source = await readFile(args.path, 'utf8'); + const hasUseWorkflow = useWorkflowPattern.test(source); + const hasUseStep = useStepPattern.test(source); + + if (hasUseWorkflow) { + state.discoveredWorkflows.push(args.path); + } + + if (hasUseStep) { + state.discoveredSteps.push(args.path); + } + + const { code: transformedCode } = await applySwcTransform( + args.path, + source, + false + ); + + return { + contents: transformedCode, + loader, + }; + } catch (_) { + // ignore trace errors during discover phase + return { + contents: '', + loader: 'js', + }; + } + }); + }, + }; +} diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts new file mode 100644 index 00000000..32a74a61 --- /dev/null +++ b/packages/builders/src/index.ts @@ -0,0 +1,10 @@ +export { BaseBuilder } from './base-builder.js'; +export { StandaloneBuilder } from './standalone.js'; +export { VercelBuildOutputAPIBuilder } from './vercel-build-output-api.js'; +export type { WorkflowConfig, BuildTarget } from './types.js'; +export { validBuildTargets, isValidBuildTarget } from './types.js'; +export type { WorkflowManifest } from './apply-swc-transform.js'; +export { applySwcTransform } from './apply-swc-transform.js'; +export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js'; +export { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; +export { createSwcPlugin } from './swc-esbuild-plugin.js'; diff --git a/packages/builders/src/node-module-esbuild-plugin.test.ts b/packages/builders/src/node-module-esbuild-plugin.test.ts new file mode 100644 index 00000000..041a4285 --- /dev/null +++ b/packages/builders/src/node-module-esbuild-plugin.test.ts @@ -0,0 +1,146 @@ +import * as esbuild from 'esbuild'; +import { describe, expect, it } from 'vitest'; +import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; + +describe('workflow-node-module-error plugin', () => { + it('should error on fs import', async () => { + const testCode = ` + import { readFile } from "fs"; + export function workflow() { + return readFile("test.txt"); + } + `; + + await expect( + esbuild.build({ + stdin: { + contents: testCode, + resolveDir: process.cwd(), + sourcefile: 'test-workflow.ts', + loader: 'ts', + }, + bundle: true, + write: false, + platform: 'neutral', + plugins: [createNodeModuleErrorPlugin()], + logLevel: 'silent', + }) + ).rejects.toThrow(/Cannot use Node\.js module "fs"/); + }); + + it('should error on path import', async () => { + const testCode = ` + import { join } from "path"; + export function workflow() { + return join("a", "b"); + } + `; + + await expect( + esbuild.build({ + stdin: { + contents: testCode, + resolveDir: process.cwd(), + sourcefile: 'test-workflow.ts', + loader: 'ts', + }, + format: 'cjs', + bundle: true, + write: false, + platform: 'neutral', + plugins: [createNodeModuleErrorPlugin()], + logLevel: 'silent', + }) + ).rejects.toThrow(/Cannot use Node\.js module "path"/); + }); + + it('should error on node: prefixed imports', async () => { + const testCode = ` + import { readFile } from "node:fs"; + export function workflow() { + return readFile; + } + `; + + await expect( + esbuild.build({ + stdin: { + contents: testCode, + resolveDir: process.cwd(), + sourcefile: 'test-workflow.ts', + loader: 'ts', + }, + bundle: true, + write: false, + platform: 'neutral', + format: 'cjs', + plugins: [createNodeModuleErrorPlugin()], + logLevel: 'silent', + }) + ).rejects.toThrow(/Cannot use Node\.js module/); + }); + + it('should error on multiple Node.js imports', async () => { + const testCode = ` + import { readFile } from "fs"; + import { join } from "path"; + export function workflow() { + return readFile(join("a", "b")); + } + `; + + const result = esbuild.build({ + stdin: { + contents: testCode, + resolveDir: process.cwd(), + sourcefile: 'test-workflow.ts', + loader: 'ts', + }, + format: 'cjs', + bundle: true, + write: false, + platform: 'neutral', + plugins: [createNodeModuleErrorPlugin()], + logLevel: 'silent', + }); + + await expect(result).rejects.toThrow(); + + // Verify we get errors for both imports + try { + await result; + } catch (error: any) { + expect(error.message).toMatch(/fs/); + expect(error.message).toMatch(/path/); + } + }); + + it('should allow non-Node.js npm package imports', async () => { + const testCode = ` + // This should NOT error - it's not a built-in Node.js module + import { someFunction } from "some-random-package"; + export function workflow() { + return "ok"; + } + `; + + // This will fail because the package doesn't exist, but it shouldn't + // fail with our plugin's error message + await expect( + esbuild.build({ + stdin: { + contents: testCode, + resolveDir: process.cwd(), + sourcefile: 'test-workflow.ts', + loader: 'ts', + }, + bundle: true, + write: false, + platform: 'neutral', + plugins: [createNodeModuleErrorPlugin()], + logLevel: 'silent', + external: ['some-random-package'], // Mark as external so it doesn't fail resolution + }) + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/builders/src/node-module-esbuild-plugin.ts b/packages/builders/src/node-module-esbuild-plugin.ts new file mode 100644 index 00000000..a1bf3d51 --- /dev/null +++ b/packages/builders/src/node-module-esbuild-plugin.ts @@ -0,0 +1,26 @@ +import { ERROR_SLUGS } from '@workflow/errors'; +import builtinModules from 'builtin-modules'; +import type * as esbuild from 'esbuild'; + +const nodeModulesRegex = new RegExp(`^(${builtinModules.join('|')})`); + +export function createNodeModuleErrorPlugin(): esbuild.Plugin { + return { + name: 'workflow-node-module-error', + setup(build) { + build.onResolve({ filter: nodeModulesRegex }, (args) => { + // Ignore if the import is coming from a node_modules folder + if (args.importer.includes('node_modules')) return null; + + return { + path: args.path, + errors: [ + { + text: `Cannot use Node.js module "${args.path}" in workflow functions. Move this module to a step function.\n\nLearn more: https://useworkflow.dev/err/${ERROR_SLUGS.NODE_JS_MODULE_IN_WORKFLOW}`, + }, + ], + }; + }); + }, + }; +} diff --git a/packages/builders/src/standalone.ts b/packages/builders/src/standalone.ts new file mode 100644 index 00000000..b08d71da --- /dev/null +++ b/packages/builders/src/standalone.ts @@ -0,0 +1,100 @@ +import { mkdir } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { BaseBuilder } from './base-builder.js'; + +export class StandaloneBuilder extends BaseBuilder { + async build(): Promise { + const inputFiles = await this.getInputFiles(); + const tsConfig = await this.getTsConfigOptions(); + + const options = { + inputFiles, + tsBaseUrl: tsConfig.baseUrl, + tsPaths: tsConfig.paths, + }; + await this.buildStepsBundle(options); + await this.buildWorkflowsBundle(options); + await this.buildWebhookFunction(); + + await this.buildClientLibrary(); + } + + private async buildStepsBundle({ + inputFiles, + tsPaths, + tsBaseUrl, + }: { + inputFiles: string[]; + tsBaseUrl?: string; + tsPaths?: Record; + }): Promise { + console.log( + 'Creating Vercel API steps bundle at', + this.config.stepsBundlePath + ); + + const stepsBundlePath = resolve( + this.config.workingDir, + this.config.stepsBundlePath + ); + + // Ensure directory exists + await mkdir(dirname(stepsBundlePath), { recursive: true }); + + await this.createStepsBundle({ + outfile: stepsBundlePath, + inputFiles, + tsBaseUrl, + tsPaths, + }); + } + + private async buildWorkflowsBundle({ + inputFiles, + tsPaths, + tsBaseUrl, + }: { + inputFiles: string[]; + tsBaseUrl?: string; + tsPaths?: Record; + }): Promise { + console.log( + 'Creating vercel API workflows bundle at', + this.config.workflowsBundlePath + ); + + const workflowBundlePath = resolve( + this.config.workingDir, + this.config.workflowsBundlePath + ); + + // Ensure directory exists + await mkdir(dirname(workflowBundlePath), { recursive: true }); + + await this.createWorkflowsBundle({ + outfile: workflowBundlePath, + inputFiles, + tsBaseUrl, + tsPaths, + }); + } + + private async buildWebhookFunction(): Promise { + console.log( + 'Creating vercel API webhook bundle at', + this.config.webhookBundlePath + ); + + const webhookBundlePath = resolve( + this.config.workingDir, + this.config.webhookBundlePath + ); + + // Ensure directory exists + await mkdir(dirname(webhookBundlePath), { recursive: true }); + + await this.createWebhookBundle({ + outfile: webhookBundlePath, + }); + } +} diff --git a/packages/builders/src/swc-esbuild-plugin.ts b/packages/builders/src/swc-esbuild-plugin.ts new file mode 100644 index 00000000..862096bb --- /dev/null +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -0,0 +1,183 @@ +import { readFile } from 'node:fs/promises'; +import enhancedResolveOrig from 'enhanced-resolve'; +import type { Plugin } from 'esbuild'; +import { relative } from 'path'; +import { promisify } from 'util'; +import { + applySwcTransform, + type WorkflowManifest, +} from './apply-swc-transform.js'; +import { + jsTsRegex, + parentHasChild, +} from './discover-entries-esbuild-plugin.js'; + +export interface SwcPluginOptions { + mode: 'step' | 'workflow' | 'client'; + entriesToBundle?: string[]; + outdir?: string; + tsPaths?: Record; + tsBaseUrl?: string; + workflowManifest?: WorkflowManifest; +} + +const NODE_RESOLVE_OPTIONS = { + dependencyType: 'commonjs', + modules: ['node_modules'], + exportsFields: ['exports'], + importsFields: ['imports'], + conditionNames: ['node', 'require'], + descriptionFiles: ['package.json'], + extensions: ['.ts', '.mts', '.cjs', '.js', '.json', '.node'], + enforceExtensions: false, + symlinks: true, + mainFields: ['main'], + mainFiles: ['index'], + roots: [], + fullySpecified: false, + preferRelative: false, + preferAbsolute: false, + restrictions: [], +}; + +const NODE_ESM_RESOLVE_OPTIONS = { + ...NODE_RESOLVE_OPTIONS, + dependencyType: 'esm', + conditionNames: ['node', 'import'], +}; + +export function createSwcPlugin(options: SwcPluginOptions): Plugin { + return { + name: 'swc-workflow-plugin', + setup(build) { + // everything is external unless explicitly configured + // to be bundled + const cjsResolver = promisify( + enhancedResolveOrig.create(NODE_RESOLVE_OPTIONS) + ); + const esmResolver = promisify( + enhancedResolveOrig.create(NODE_ESM_RESOLVE_OPTIONS) + ); + + const enhancedResolve = async (context: string, path: string) => { + try { + return await esmResolver(context, path); + } catch (_) { + return cjsResolver(context, path); + } + }; + + build.onResolve({ filter: /.*/ }, async (args) => { + if (!options.entriesToBundle) { + return null; + } + + try { + let resolvedPath: string | false | undefined = args.path; + + // handle local imports e.g. ./hello or ../another + if (args.path.startsWith('.')) { + resolvedPath = await enhancedResolve(args.resolveDir, args.path); + } else { + resolvedPath = await enhancedResolve( + // `args.resolveDir` is not used here to ensure we only + // externalize packages that can be resolved in the + // project's working directory e.g. a nested dep can't + // be externalized as we won't be able to resolve it once + // it's parent has been bundled + build.initialOptions.absWorkingDir || process.cwd(), + args.path + ); + } + + if (!resolvedPath) return null; + + for (const entryToBundle of options.entriesToBundle) { + if (resolvedPath === entryToBundle) { + return null; + } + + // if the current entry imports a child that needs + // to be bundled then it needs to also be bundled so + // that the child can have our transform applied + if (parentHasChild(resolvedPath, entryToBundle)) { + return null; + } + } + + const isFilePath = + args.path.startsWith('.') || args.path.startsWith('/'); + + return { + external: true, + path: isFilePath + ? relative(options.outdir || '', resolvedPath) + : args.path, + }; + } catch (_) {} + return null; + }); + + // Handle TypeScript and JavaScript files + build.onLoad({ filter: jsTsRegex }, async (args) => { + // Determine if this is a TypeScript file + const isTypeScript = + args.path.endsWith('.ts') || args.path.endsWith('.tsx'); + + try { + // Determine the loader based on the output + let loader: 'js' | 'jsx' = 'js'; + if (!isTypeScript && args.path.endsWith('.jsx')) { + loader = 'jsx'; + } + const source = await readFile(args.path, 'utf8'); + const { code: transformedCode, workflowManifest } = + await applySwcTransform( + args.path, + source, + options.mode, + // we need to provide the tsconfig/jsconfig + // alias via swc so that we can resolve them + // with our custom resolve logic + { + paths: options.tsPaths, + baseUrl: options.tsBaseUrl, + } + ); + + if (!options.workflowManifest) { + options.workflowManifest = {}; + } + options.workflowManifest.workflows = Object.assign( + options.workflowManifest.workflows || {}, + workflowManifest.workflows + ); + options.workflowManifest.steps = Object.assign( + options.workflowManifest.steps || {}, + workflowManifest.steps + ); + + return { + contents: transformedCode, + loader, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + `❌ SWC transform error in ${args.path}:`, + errorMessage + ); + return { + errors: [ + { + text: `SWC transform failed: ${errorMessage}`, + location: { file: args.path, line: 0, column: 0 }, + }, + ], + }; + } + }); + }, + }; +} diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts new file mode 100644 index 00000000..7cadc172 --- /dev/null +++ b/packages/builders/src/types.ts @@ -0,0 +1,31 @@ +export const validBuildTargets = [ + 'standalone', + 'vercel-build-output-api', + 'next', +] as const; +export type BuildTarget = (typeof validBuildTargets)[number]; + +export interface WorkflowConfig { + watch?: boolean; + dirs: string[]; + workingDir: string; + buildTarget: BuildTarget; + stepsBundlePath: string; + workflowsBundlePath: string; + webhookBundlePath: string; + + // Optionally generate a client library for workflow execution. The preferred + // method of using workflow is to use a loader within a framework (like + // NextJS) that resolves client bindings on the fly. + clientBundlePath?: string; + + externalPackages?: string[]; + + workflowManifestPath?: string; +} + +export function isValidBuildTarget( + target: string | undefined +): target is BuildTarget { + return target === 'standalone' || target === 'vercel-build-output-api'; +} diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts new file mode 100644 index 00000000..d4d46c11 --- /dev/null +++ b/packages/builders/src/vercel-build-output-api.ts @@ -0,0 +1,213 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { BaseBuilder } from './base-builder.js'; + +export class VercelBuildOutputAPIBuilder extends BaseBuilder { + async build(): Promise { + const outputDir = resolve(this.config.workingDir, '.vercel/output'); + const functionsDir = join(outputDir, 'functions'); + const workflowGeneratedDir = join(functionsDir, '.well-known/workflow/v1'); + + // Ensure output directories exist + await mkdir(workflowGeneratedDir, { recursive: true }); + + const inputFiles = await this.getInputFiles(); + const tsConfig = await this.getTsConfigOptions(); + const options = { + inputFiles, + workflowGeneratedDir, + tsBaseUrl: tsConfig.baseUrl, + tsPaths: tsConfig.paths, + }; + await this.buildStepsFunction(options); + await this.buildWorkflowsFunction(options); + await this.buildWebhookFunction(options); + await this.createBuildOutputConfig(outputDir); + + await this.buildClientLibrary(); + } + + private async buildStepsFunction({ + inputFiles, + workflowGeneratedDir, + tsPaths, + tsBaseUrl, + }: { + inputFiles: string[]; + workflowGeneratedDir: string; + tsBaseUrl?: string; + tsPaths?: Record; + }): Promise { + console.log('Creating Vercel Build Output API steps function'); + const stepsFuncDir = join(workflowGeneratedDir, 'step.func'); + await mkdir(stepsFuncDir, { recursive: true }); + + // Create steps bundle + await this.createStepsBundle({ + inputFiles, + outfile: join(stepsFuncDir, 'index.js'), + tsBaseUrl, + tsPaths, + }); + + // Create package.json for CommonJS + const packageJson = { + type: 'commonjs', + }; + await writeFile( + join(stepsFuncDir, 'package.json'), + JSON.stringify(packageJson, null, 2) + ); + + // Create .vc-config.json for steps function + const stepsConfig = { + runtime: 'nodejs22.x', + handler: 'index.js', + launcherType: 'Nodejs', + architecture: 'arm64', + shouldAddHelpers: true, + shouldAddSourcemapSupport: true, + experimentalTriggers: [ + { + type: 'queue/v1beta', + topic: '__wkf_step_*', + consumer: 'default', + maxDeliveries: 64, // Optional: Maximum number of delivery attempts (default: 3) + retryAfterSeconds: 5, // Optional: Delay between retries (default: 60) + initialDelaySeconds: 0, // Optional: Initial delay before first delivery (default: 0) + }, + ], + }; + + await writeFile( + join(stepsFuncDir, '.vc-config.json'), + JSON.stringify(stepsConfig, null, 2) + ); + } + + private async buildWorkflowsFunction({ + inputFiles, + workflowGeneratedDir, + tsPaths, + tsBaseUrl, + }: { + inputFiles: string[]; + workflowGeneratedDir: string; + tsBaseUrl?: string; + tsPaths?: Record; + }): Promise { + console.log('Creating Vercel Build Output API workflows function'); + const workflowsFuncDir = join(workflowGeneratedDir, 'flow.func'); + await mkdir(workflowsFuncDir, { recursive: true }); + + await this.createWorkflowsBundle({ + outfile: join(workflowsFuncDir, 'index.js'), + inputFiles, + tsBaseUrl, + tsPaths, + }); + + // Create package.json for ESM support + const packageJson = { + type: 'commonjs', + }; + await writeFile( + join(workflowsFuncDir, 'package.json'), + JSON.stringify(packageJson, null, 2) + ); + + // Create .vc-config.json for workflows function + const workflowsConfig = { + runtime: 'nodejs22.x', + handler: 'index.js', + launcherType: 'Nodejs', + architecture: 'arm64', + shouldAddHelpers: true, + experimentalTriggers: [ + { + type: 'queue/v1beta', + topic: '__wkf_workflow_*', + consumer: 'default', + maxDeliveries: 64, // Optional: Maximum number of delivery attempts (default: 3) + retryAfterSeconds: 5, // Optional: Delay between retries (default: 60) + initialDelaySeconds: 0, // Optional: Initial delay before first delivery (default: 0) + }, + ], + }; + + await writeFile( + join(workflowsFuncDir, '.vc-config.json'), + JSON.stringify(workflowsConfig, null, 2) + ); + } + + private async buildWebhookFunction({ + workflowGeneratedDir, + bundle = true, + }: { + inputFiles: string[]; + workflowGeneratedDir: string; + tsBaseUrl?: string; + tsPaths?: Record; + bundle?: boolean; + }): Promise { + console.log('Creating Vercel Build Output API webhook function'); + const webhookFuncDir = join(workflowGeneratedDir, 'webhook/[token].func'); + + // Bundle the webhook route with dependencies resolved + await this.createWebhookBundle({ + outfile: join(webhookFuncDir, 'index.js'), + bundle, // Build Output API needs bundling (except in tests) + }); + + // Create package.json for CommonJS + const packageJson = { + type: 'commonjs', + }; + await writeFile( + join(webhookFuncDir, 'package.json'), + JSON.stringify(packageJson, null, 2) + ); + + // Create .vc-config.json for webhook function + const webhookConfig = { + runtime: 'nodejs22.x', + handler: 'index.js', + launcherType: 'Nodejs', + architecture: 'arm64', + shouldAddHelpers: false, + }; + + await writeFile( + join(webhookFuncDir, '.vc-config.json'), + JSON.stringify(webhookConfig, null, 2) + ); + } + + private async createBuildOutputConfig(outputDir: string): Promise { + // Create config.json for Build Output API + const buildOutputConfig = { + version: 3, + routes: [ + { + src: '^\\/\\.well-known\\/workflow\\/v1\\/webhook\\/([^\\/]+)$', + dest: '/.well-known/workflow/v1/webhook/[token]', + }, + ], + }; + + await writeFile( + join(outputDir, 'config.json'), + JSON.stringify(buildOutputConfig, null, 2) + ); + + console.log(`Build Output API created at ${outputDir}`); + console.log('Steps function available at /.well-known/workflow/v1/step'); + console.log( + 'Workflows function available at /.well-known/workflow/v1/flow' + ); + console.log( + 'Webhook function available at /.well-known/workflow/v1/webhook/[token]' + ); + } +} diff --git a/packages/builders/tsconfig.json b/packages/builders/tsconfig.json new file mode 100644 index 00000000..c24407c5 --- /dev/null +++ b/packages/builders/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@workflow/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index b7dd4e19..7e388bfc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -45,6 +45,7 @@ "@oclif/core": "^4.0.0", "@oclif/plugin-help": "^6.0.0", "@swc/core": "1.11.24", + "@workflow/builders": "workspace:*", "@workflow/swc-plugin": "workspace:*", "@workflow/errors": "workspace:*", "@workflow/core": "workspace:*", diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index c9ddaf59..61c13663 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -1,7 +1,9 @@ import { Args, Flags } from '@oclif/core'; +import { + StandaloneBuilder, + VercelBuildOutputAPIBuilder, +} from '@workflow/builders'; import { BaseCommand } from '../base.js'; -import { VercelBuildOutputAPIBuilder } from '../lib/builders/vercel-build-output-api.js'; -import { StandaloneBuilder } from '../lib/builders/standalone.js'; import { type BuildTarget, isValidBuildTarget } from '../lib/config/types.js'; import { getWorkflowConfig } from '../lib/config/workflow-config.js'; diff --git a/packages/cli/src/lib/builders/next-build.ts b/packages/cli/src/lib/builders/next-build.ts index 88eafe0d..7249bbdc 100644 --- a/packages/cli/src/lib/builders/next-build.ts +++ b/packages/cli/src/lib/builders/next-build.ts @@ -2,7 +2,7 @@ import { constants } from 'node:fs'; import { access, mkdir, stat, writeFile } from 'node:fs/promises'; import { extname, join, resolve } from 'node:path'; import Watchpack from 'watchpack'; -import { BaseBuilder } from './base-builder.js'; +import { BaseBuilder } from '@workflow/builders'; export class NextBuilder extends BaseBuilder { async build() { diff --git a/packages/cli/src/lib/builders/standalone.ts b/packages/cli/src/lib/builders/standalone.ts index b08d71da..e98afe46 100644 --- a/packages/cli/src/lib/builders/standalone.ts +++ b/packages/cli/src/lib/builders/standalone.ts @@ -1,6 +1,6 @@ import { mkdir } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; -import { BaseBuilder } from './base-builder.js'; +import { BaseBuilder } from '@workflow/builders'; export class StandaloneBuilder extends BaseBuilder { async build(): Promise { diff --git a/packages/cli/src/lib/builders/vercel-build-output-api.ts b/packages/cli/src/lib/builders/vercel-build-output-api.ts index d4d46c11..f85b7235 100644 --- a/packages/cli/src/lib/builders/vercel-build-output-api.ts +++ b/packages/cli/src/lib/builders/vercel-build-output-api.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; -import { BaseBuilder } from './base-builder.js'; +import { BaseBuilder } from '@workflow/builders'; export class VercelBuildOutputAPIBuilder extends BaseBuilder { async build(): Promise { diff --git a/packages/cli/src/lib/config/types.ts b/packages/cli/src/lib/config/types.ts index 9bdeb712..469b081a 100644 --- a/packages/cli/src/lib/config/types.ts +++ b/packages/cli/src/lib/config/types.ts @@ -1,9 +1,12 @@ -export const validBuildTargets = [ - 'standalone', - 'vercel-build-output-api', - 'next', -] as const; -export type BuildTarget = (typeof validBuildTargets)[number]; +// Re-export builder types for backwards compatibility +export type { + BuildTarget, + WorkflowConfig, +} from '@workflow/builders'; +export { + validBuildTargets, + isValidBuildTarget, +} from '@workflow/builders'; export type InspectCLIOptions = { json?: boolean; @@ -18,28 +21,3 @@ export type InspectCLIOptions = { withData?: boolean; backend?: string; }; - -export interface WorkflowConfig { - watch?: boolean; - dirs: string[]; - workingDir: string; - buildTarget: BuildTarget; - stepsBundlePath: string; - workflowsBundlePath: string; - webhookBundlePath: string; - - // Optionally generate a client library for workflow execution. The preferred - // method of using workflow is to use a loader within a framework (like - // NextJS) that resolves client bindings on the fly. - clientBundlePath?: string; - - externalPackages?: string[]; - - workflowManifestPath?: string; -} - -export function isValidBuildTarget( - target: string | undefined -): target is BuildTarget { - return target === 'standalone' || target === 'vercel-build-output-api'; -} diff --git a/packages/nitro/package.json b/packages/nitro/package.json index 98b3c1e8..2e8d1ba4 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -28,7 +28,7 @@ "dependencies": { "@swc/core": "1.11.24", "@workflow/swc-plugin": "workspace:*", - "@workflow/cli": "workspace:*", + "@workflow/builders": "workspace:*", "@workflow/core": "workspace:*", "exsolve": "^1.0.7" }, diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index e38ffa60..3a8751dc 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -1,7 +1,6 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { BaseBuilder } from '@workflow/cli/dist/lib/builders/base-builder'; -import { VercelBuildOutputAPIBuilder } from '@workflow/cli/dist/lib/builders/vercel-build-output-api'; +import { BaseBuilder, VercelBuildOutputAPIBuilder } from '@workflow/builders'; import type { Nitro } from 'nitro/types'; const CommonBuildOptions = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90428dd6..9b5313f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,49 @@ importers: specifier: workspace:* version: link:../workflow + packages/builders: + dependencies: + '@swc/core': + specifier: 1.11.24 + version: 1.11.24 + '@workflow/core': + specifier: workspace:* + version: link:../core + '@workflow/errors': + specifier: workspace:* + version: link:../errors + '@workflow/swc-plugin': + specifier: workspace:* + version: link:../swc-plugin-workflow + builtin-modules: + specifier: ^5.0.0 + version: 5.0.0 + chalk: + specifier: ^5.6.2 + version: 5.6.2 + comment-json: + specifier: 4.2.5 + version: 4.2.5 + enhanced-resolve: + specifier: 5.18.2 + version: 5.18.2 + esbuild: + specifier: 'catalog:' + version: 0.25.11 + find-up: + specifier: 7.0.0 + version: 7.0.0 + tinyglobby: + specifier: ^0.2.14 + version: 0.2.15 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.6.2 + '@workflow/tsconfig': + specifier: workspace:* + version: link:../tsconfig + packages/cli: dependencies: '@oclif/core': @@ -280,6 +323,9 @@ importers: '@swc/core': specifier: 1.11.24 version: 1.11.24 + '@workflow/builders': + specifier: workspace:* + version: link:../builders '@workflow/core': specifier: workspace:* version: link:../core @@ -476,9 +522,9 @@ importers: '@swc/core': specifier: 1.11.24 version: 1.11.24 - '@workflow/cli': + '@workflow/builders': specifier: workspace:* - version: link:../cli + version: link:../builders '@workflow/core': specifier: workspace:* version: link:../core @@ -11122,7 +11168,7 @@ snapshots: semver: 7.7.2 string-width: 4.2.3 supports-color: 8.1.1 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 widest-line: 3.1.0 wordwrap: 1.0.0 wrap-ansi: 7.0.0 @@ -13093,7 +13139,7 @@ snapshots: dependencies: minimatch: 10.0.3 path-browserify: 1.0.1 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 '@tybys/wasm-util@0.10.0': dependencies: