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
5 changes: 5 additions & 0 deletions .changeset/date-subclass-vm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/core': patch
---

Fix `Date` subclassing inside workflow functions. The deterministic `Date` override in the workflow VM now forwards `new.target` via `Reflect.construct`, so subclasses like `TZDate` from `@date-fns/tz` keep their identity, methods, and fields. Calling `Date()` without `new` now returns the (fixed) time string per spec, instead of a `Date` object.
4 changes: 4 additions & 0 deletions .changeset/olive-pugs-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Fix the `onAfterTransform` sample in the builders README so it type-checks on its own.
5 changes: 5 additions & 0 deletions .changeset/quiet-donkeys-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/core': patch
---

Fix replay divergence when a step result overtook an earlier sleep or hook delivery that was parked behind an unread hook's payload
5 changes: 5 additions & 0 deletions .changeset/tidy-dodos-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/builders': minor
---

Add an optional observer for accepted workflow SWC transform results.
1 change: 1 addition & 0 deletions .vercel.approvers
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@vercel/workflow
26 changes: 26 additions & 0 deletions packages/builders/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,32 @@ class MyBuilder extends BaseBuilder {
}
```

### Observing transforms

Builder configurations can provide an optional `onAfterTransform` observer for
tooling that derives metadata from the exact SWC output used by a build:

```typescript
import type { WorkflowAfterTransformHook } from '@workflow/builders';

// Pass as `onAfterTransform` in the builder configuration.
const onAfterTransform: WorkflowAfterTransformHook = async ({
mode,
filename,
absolutePath,
source,
code,
workflowManifest,
}) => {
// Observe the accepted transform result.
};
```

The observer is awaited after the transform's manifest entries have been
accepted. It cannot replace the generated code, and throwing aborts the build.
A source file may be observed multiple times across transform modes, bundles,
and watch rebuilds, so consumers should deduplicate results when necessary.

## Architecture

The builder system uses:
Expand Down
3 changes: 3 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1159,6 +1159,7 @@ export const __steps_registered = true;
projectRoot: this.transformProjectRoot,
moduleSpecifierRoot: this.moduleSpecifierRoot,
workflowManifest,
onAfterTransform: this.config.onAfterTransform,
bundleTransitiveLocalStepDependencies,
rewriteTsExtensions,
sideEffectEntries: normalizedSideEffectEntries,
Expand Down Expand Up @@ -1392,6 +1393,7 @@ export const __steps_registered = true;
projectRoot: this.transformProjectRoot,
moduleSpecifierRoot: this.moduleSpecifierRoot,
workflowManifest,
onAfterTransform: this.config.onAfterTransform,
sideEffectEntries: normalizedWorkflowSideEffectEntries,
}),
// This plugin must run after the swc plugin to ensure dead code elimination
Expand Down Expand Up @@ -1940,6 +1942,7 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr
mode: 'step',
projectRoot: this.transformProjectRoot,
moduleSpecifierRoot: this.moduleSpecifierRoot,
onAfterTransform: this.config.onAfterTransform,
sideEffectEntries: normalizedClientSideEffectEntries,
}),
],
Expand Down
6 changes: 5 additions & 1 deletion packages/builders/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ export {
type SerdeClassCheckResult,
} from './serde-checker.js';
export { StandaloneBuilder } from './standalone.js';
export { createSwcPlugin } from './swc-esbuild-plugin.js';
export {
createSwcPlugin,
type WorkflowAfterTransformHook,
type WorkflowTransformResult,
} from './swc-esbuild-plugin.js';
export {
detectWorkflowPatterns,
generatedWorkflowPathPattern,
Expand Down
119 changes: 119 additions & 0 deletions packages/builders/src/swc-esbuild-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,127 @@ describe('createSwcPlugin externalizeNonSteps', () => {
rmSync(testRoot, { recursive: true, force: true });
});

it('reports authoritative transform results to an optional observer', async () => {
const srcDir = join(testRoot, 'src');
const stepFile = join(srcDir, 'step.ts');
const source = 'export const value = 42;';
const workflowManifest = {
steps: {
'src/step.ts': {
value: {
stepId: 'step//src/step//value',
},
},
},
};
const onAfterTransform = vi.fn();

writeFile(stepFile, source);
applySwcTransformMock.mockResolvedValue({
code: `${source}\n/* transformed */`,
workflowManifest,
});

await esbuild.build({
entryPoints: [stepFile],
absWorkingDir: testRoot,
outdir: join(testRoot, 'out'),
bundle: true,
write: false,
plugins: [
createSwcPlugin({
mode: 'step',
entriesToBundle: [stepFile],
onAfterTransform,
}),
],
});

expect(onAfterTransform).toHaveBeenCalledOnce();
expect(onAfterTransform).toHaveBeenCalledWith({
mode: 'step',
filename: 'src/step.ts',
absolutePath: stepFile,
source,
code: `${source}\n/* transformed */`,
workflowManifest,
});
});

it('awaits asynchronous transform observers', async () => {
const stepFile = join(testRoot, 'src', 'step.ts');
let markObserverStarted: () => void = () => {};
let releaseObserver: () => void = () => {};
const observerStarted = new Promise<void>((resolve) => {
markObserverStarted = resolve;
});
const observerBlocked = new Promise<void>((resolve) => {
releaseObserver = resolve;
});
let buildCompleted = false;

writeFile(stepFile, 'export const value = 42;');

const build = esbuild.build({
entryPoints: [stepFile],
absWorkingDir: testRoot,
outdir: join(testRoot, 'out'),
bundle: true,
write: false,
plugins: [
createSwcPlugin({
mode: 'step',
entriesToBundle: [stepFile],
onAfterTransform: async () => {
markObserverStarted();
await observerBlocked;
},
}),
],
});
void build.then(() => {
buildCompleted = true;
});

await observerStarted;
await Promise.resolve();
expect(buildCompleted).toBe(false);

releaseObserver();
await build;
expect(buildCompleted).toBe(true);
});

it('fails the build when a transform observer throws', async () => {
const stepFile = join(testRoot, 'src', 'step.ts');

writeFile(stepFile, 'export const value = 42;');

await expect(
esbuild.build({
entryPoints: [stepFile],
absWorkingDir: testRoot,
outdir: join(testRoot, 'out'),
bundle: true,
write: false,
plugins: [
createSwcPlugin({
mode: 'step',
entriesToBundle: [stepFile],
onAfterTransform: () => {
throw new Error('transform observer failed');
},
}),
],
})
).rejects.toThrow(/transform observer failed/);
});

it('fails the build when two files emit the same step id', async () => {
const srcDir = join(testRoot, 'src');
const firstStepFile = join(srcDir, 'confirmation.ts');
const secondStepFile = join(srcDir, 'reschedule.ts');
const onAfterTransform = vi.fn();

writeFile(firstStepFile, `export const first = true;`);
writeFile(secondStepFile, `export const second = true;`);
Expand Down Expand Up @@ -86,10 +203,12 @@ describe('createSwcPlugin externalizeNonSteps', () => {
plugins: [
createSwcPlugin({
mode: 'step',
onAfterTransform,
}),
],
})
).rejects.toThrow(/Duplicate workflow step ID/);
expect(onAfterTransform).toHaveBeenCalledOnce();
});

it('fails the build when two files emit the same workflow id', async () => {
Expand Down
29 changes: 29 additions & 0 deletions packages/builders/src/swc-esbuild-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,33 @@ import {
import { resolveModuleSpecifier } from './module-specifier.js';
import { resolveWorkflowAliasRelativePath } from './workflow-alias.js';

export interface WorkflowTransformResult {
readonly mode: 'step' | 'workflow';
readonly filename: string;
readonly absolutePath: string;
readonly source: string;
readonly code: string;
readonly workflowManifest: WorkflowManifest;
}

export type WorkflowAfterTransformHook = (
result: WorkflowTransformResult
) => void | Promise<void>;

export interface SwcPluginOptions {
mode: 'step' | 'workflow';
entriesToBundle?: string[];
outdir?: string;
projectRoot?: string;
moduleSpecifierRoot?: string;
workflowManifest?: WorkflowManifest;
/**
* Optional observer invoked after a transform's manifest entries have been
* accepted. A file may be observed multiple times across modes, bundles, and
* watch rebuilds. The observer is awaited, cannot replace the generated code,
* and aborts the build if it throws.
*/
onAfterTransform?: WorkflowAfterTransformHook;
/**
* Rewrite TypeScript extensions (.ts, .tsx, .mts, .cts) to their JS
* equivalents (.js, .mjs, .cjs) in externalized import paths.
Expand Down Expand Up @@ -516,6 +536,15 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin {
workflowIdsForCurrentBuild
);

await options.onAfterTransform?.({
mode: options.mode,
filename: relativeFilepath,
absolutePath: args.path,
source: normalizedSource,
code: transformedCode,
workflowManifest,
});

return {
contents: transformedCode,
loader,
Expand Down
14 changes: 14 additions & 0 deletions packages/builders/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { WorkflowAfterTransformHook } from './swc-esbuild-plugin.js';

export const validBuildTargets = [
'standalone',
'vercel-build-output-api',
Expand Down Expand Up @@ -49,6 +51,18 @@ interface BaseWorkflowConfig {

workflowManifestPath?: string;

/**
* Optional observer invoked after each authoritative SWC transform has been
* accepted into a workflow bundle's manifest.
*
* A source file may be observed multiple times across transform modes,
* bundles, and watch rebuilds. The observer is awaited and cannot replace the
* transformed code. Throwing rejects the build, allowing integrations to
* require their derived artifacts to remain consistent with the emitted
* workflow bundles.
*/
onAfterTransform?: WorkflowAfterTransformHook;

// Optional prefix for debug files (e.g., "_" for Astro to ignore them)
debugFilePrefix?: string;

Expand Down
Loading
Loading