Skip to content
Draft
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/tidy-bundles-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/builders': minor
---

Add an optional observer for completed workflow bundles and watch rebuilds.
30 changes: 30 additions & 0 deletions packages/builders/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,36 @@ 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.

### Observing completed bundles

Builder configurations can also provide an `onAfterBundle` observer. It runs
once after a combined workflow bundle and its manifest have been written
successfully, and again after each successful watch rebuild:

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

// Pass as `onAfterBundle` in the builder configuration.
const onAfterBundle: WorkflowAfterBundleHook = async ({
buildTarget,
workingDir,
workflowManifest,
artifacts,
}) => {
// Publish or otherwise derive data from the completed bundle.
};
```

Every invocation has exactly three artifact descriptors, ordered as `steps`,
`workflows`, and `manifest`. Paths are absolute. These are the files that make
up the completed combined workflow bundle boundary. Webhook, source-map,
diagnostics, public-manifest copies, and optional client outputs do not produce
separate invocations or artifact descriptors.

The observer is awaited, and throwing rejects the build or rebuild. It is not
called when bundle or manifest generation fails. Build systems can rebuild
unchanged inputs, so consumers are expected to make side effects idempotent.

## Architecture

The builder system uses:
Expand Down
221 changes: 221 additions & 0 deletions packages/builders/src/after-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { WorkflowManifest } from './apply-swc-transform.js';
import { BaseBuilder, type DiscoveredEntries } from './base-builder.js';
import type { StandaloneConfig } from './types.js';

const discoveredEntries: DiscoveredEntries = {
discoveredSteps: new Set(),
discoveredWorkflows: new Set(),
discoveredSerdeFiles: new Set(),
};

class TestBuilder extends BaseBuilder {
readonly #stepsPath: string;
readonly #workflowsPath: string;
#workflowBundleError: Error | undefined;

constructor(config: StandaloneConfig) {
super(config);
this.#stepsPath = config.stepsBundlePath;
this.#workflowsPath = config.workflowsBundlePath;
}

async build(): Promise<void> {
// no-op
}

protected override async createStepsBundle() {
writeFileSync(
this.#stepsPath,
'export const __steps_registered = true;\n',
'utf-8'
);
return {
context: undefined,
manifest: {
steps: {
'src/workflow.ts': {
runStep: { stepId: 'step//src/workflow.ts//runStep' },
},
},
},
};
}

protected override async createWorkflowsBundle() {
if (this.#workflowBundleError) {
const error = this.#workflowBundleError;
this.#workflowBundleError = undefined;
throw error;
}
return {
manifest: {
workflows: {
'src/workflow.ts': {
run: { workflowId: 'workflow//src/workflow.ts//run' },
},
},
},
interimBundleText: 'export async function run() { return "ok"; }',
};
}

public createTestBundle() {
return this.createCombinedBundle({
inputFiles: [],
stepsOutfile: this.#stepsPath,
flowOutfile: this.#workflowsPath,
bundleFinalOutput: false,
discoveredEntries,
});
}

public failNextWorkflowBundle(error: Error): void {
this.#workflowBundleError = error;
}

public createTestManifest(manifest: WorkflowManifest, manifestDir: string) {
return this.createManifest({
workflowBundlePath: this.#workflowsPath,
manifestDir,
manifest,
});
}
}

describe('onAfterBundle', () => {
let testRoot: string;
let workflowsPath: string;
let stepsPath: string;
let manifestDir: string;

beforeEach(() => {
testRoot = mkdtempSync(join(tmpdir(), 'workflow-after-bundle-'));
workflowsPath = join(testRoot, 'workflows.js');
stepsPath = join(testRoot, 'steps.js');
manifestDir = join(testRoot, 'manifest');
mkdirSync(manifestDir, { recursive: true });
});

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

function createBuilder(
onAfterBundle: NonNullable<StandaloneConfig['onAfterBundle']>,
watch = false
): TestBuilder {
return new TestBuilder({
buildTarget: 'standalone',
workingDir: testRoot,
dirs: ['.'],
watch,
stepsBundlePath: stepsPath,
workflowsBundlePath: workflowsPath,
webhookBundlePath: join(testRoot, 'webhook.js'),
onAfterBundle,
suppressCreateManifestLogs: true,
});
}

it('runs once with the completed bundle manifest and three artifacts', async () => {
const onAfterBundle = vi.fn();
const builder = createBuilder(onAfterBundle);
const { manifest } = await builder.createTestBundle();

await builder.createTestManifest(manifest, manifestDir);

expect(onAfterBundle).toHaveBeenCalledOnce();
expect(onAfterBundle).toHaveBeenCalledWith({
buildTarget: 'standalone',
workingDir: testRoot,
workflowManifest: manifest,
artifacts: [
{ kind: 'steps', path: stepsPath },
{ kind: 'workflows', path: workflowsPath },
{ kind: 'manifest', path: join(manifestDir, 'manifest.json') },
],
});
});

it('runs after every successful watch rebuild', async () => {
const onAfterBundle = vi.fn();
const builder = createBuilder(onAfterBundle, true);
const { bundleFinal, manifest } = await builder.createTestBundle();

await builder.createTestManifest(manifest, manifestDir);
await bundleFinal?.('export async function run() { return "updated"; }');
await builder.createTestManifest(manifest, manifestDir);

expect(bundleFinal).toBeDefined();
expect(onAfterBundle).toHaveBeenCalledTimes(2);
});

it('reports the actual manifest path when manifestDir is relative', async () => {
const onAfterBundle = vi.fn();
const builder = createBuilder(onAfterBundle);
const { manifest } = await builder.createTestBundle();
const relativeManifestDir = relative(process.cwd(), manifestDir);

await builder.createTestManifest(manifest, relativeManifestDir);

expect(onAfterBundle).toHaveBeenCalledWith(
expect.objectContaining({
artifacts: expect.arrayContaining([
{ kind: 'manifest', path: join(manifestDir, 'manifest.json') },
]),
})
);
});

it('is awaited and rejects the build when it throws', async () => {
const error = new Error('registration failed');
const builder = createBuilder(async () => {
await Promise.resolve();
throw error;
});
const { manifest } = await builder.createTestBundle();

await expect(
builder.createTestManifest(manifest, manifestDir)
).rejects.toBe(error);
});

it('does not run for an incomplete or failed bundle', async () => {
const onAfterBundle = vi.fn();
const incompleteBuilder = createBuilder(onAfterBundle);
const manifest: WorkflowManifest = {};

writeFileSync(workflowsPath, '', 'utf-8');
await incompleteBuilder.createTestManifest(manifest, manifestDir);

const failedBuilder = createBuilder(onAfterBundle);
const completedBundle = await failedBuilder.createTestBundle();
rmSync(workflowsPath, { force: true });
await failedBuilder.createTestManifest(
completedBundle.manifest,
manifestDir
);

expect(onAfterBundle).not.toHaveBeenCalled();
});

it('does not register artifacts when createCombinedBundle fails', async () => {
const onAfterBundle = vi.fn();
const builder = createBuilder(onAfterBundle);
const error = new Error('workflow bundle failed');
builder.failNextWorkflowBundle(error);

await expect(builder.createTestBundle()).rejects.toBe(error);

// A later manifest write for the same path must not turn the failed bundle
// into an observable completion.
writeFileSync(workflowsPath, '', 'utf-8');
await builder.createTestManifest({}, manifestDir);

expect(onAfterBundle).not.toHaveBeenCalled();
});
});
75 changes: 66 additions & 9 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
import { createSwcPlugin } from './swc-esbuild-plugin.js';
import { detectWorkflowPatterns } from './transform-utils.js';
import type { SourcemapMode, WorkflowConfig } from './types.js';
import type {
SourcemapMode,
WorkflowBundleArtifact,
WorkflowConfig,
} from './types.js';
import { extractWorkflowGraphs } from './workflows-extractor.js';
import { hasSameContent, writeFileIfChanged } from './write-if-changed.js';

Expand Down Expand Up @@ -218,6 +222,13 @@ export abstract class BaseBuilder {
private warnedExternalPackages = new Set<string>();
private workflowBuildStartTime: number | undefined;
private manifestTransformCache = new Map<string, CachedManifestTransform>();
private completedBundleArtifacts = new Map<
string,
readonly [
WorkflowBundleArtifact<'steps'>,
WorkflowBundleArtifact<'workflows'>,
]
>();

constructor(config: WorkflowConfig) {
this.config = config;
Expand Down Expand Up @@ -401,6 +412,26 @@ export abstract class BaseBuilder {
this.manifestTransformCache.clear();
}

private recordCompletedBundleArtifacts({
stepsPath,
workflowsPath,
}: {
stepsPath: string;
workflowsPath: string;
}): void {
const resolvedWorkflowsPath = resolve(
this.config.workingDir,
workflowsPath
);
this.completedBundleArtifacts.set(resolvedWorkflowsPath, [
{
kind: 'steps',
path: resolve(this.config.workingDir, stepsPath),
},
{ kind: 'workflows', path: resolvedWorkflowsPath },
]);
}

/**
* Pseudo-packages that should not be checked for workflow patterns.
*/
Expand Down Expand Up @@ -1810,6 +1841,11 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr
},
};

this.recordCompletedBundleArtifacts({
stepsPath: stepsOutfile,
workflowsPath: flowOutfile,
});

// Create a custom bundleFinal for watch mode that uses workflowEntrypoint
const combinedBundleFinal = async (interimBundleText: string) => {
const escaped = interimBundleText.replace(/[\\`$]/g, '\\$&');
Expand Down Expand Up @@ -2325,8 +2361,10 @@ export const OPTIONS = handler;`;
manifest: WorkflowManifest;
}): Promise<string | undefined> {
const buildStart = Date.now();
const manifestPath = resolve(manifestDir, 'manifest.json');
this.logCreateManifestInfo('Creating manifest...');

let manifestJson: string;
try {
const workflowGraphs = await extractWorkflowGraphs(workflowBundlePath);

Expand All @@ -2343,13 +2381,10 @@ export const OPTIONS = handler;`;
workflows: sortManifestEntries(workflows),
classes: sortManifestEntries(classes),
};
const manifestJson = JSON.stringify(output, null, 2);
manifestJson = JSON.stringify(output, null, 2);

await mkdir(manifestDir, { recursive: true });
await writeFileIfChanged(
join(manifestDir, 'manifest.json'),
manifestJson
);
await mkdir(dirname(manifestPath), { recursive: true });
await writeFileIfChanged(manifestPath, manifestJson);

const diagnosticsManifestPath = this.getDiagnosticsManifestPath();
if (diagnosticsManifestPath) {
Expand Down Expand Up @@ -2384,8 +2419,6 @@ export const OPTIONS = handler;`;
);
}
this.resetWorkflowBuildTimer();

return manifestJson;
} catch (error) {
console.warn(
'Failed to create manifest:',
Expand All @@ -2394,6 +2427,30 @@ export const OPTIONS = handler;`;
this.resetWorkflowBuildTimer();
return undefined;
}

const resolvedWorkflowBundlePath = resolve(
this.config.workingDir,
workflowBundlePath
);
const bundleArtifacts = this.completedBundleArtifacts.get(
resolvedWorkflowBundlePath
);
if (bundleArtifacts) {
await this.config.onAfterBundle?.({
buildTarget: this.config.buildTarget,
workingDir: this.config.workingDir,
workflowManifest: manifest,
artifacts: [
...bundleArtifacts,
{
kind: 'manifest',
path: manifestPath,
},
],
});
}

return manifestJson;
}

private convertStepsManifest(
Expand Down
Loading
Loading