diff --git a/.changeset/remote-bundle-hooks-frameworks.md b/.changeset/remote-bundle-hooks-frameworks.md new file mode 100644 index 0000000000..a25ae713ce --- /dev/null +++ b/.changeset/remote-bundle-hooks-frameworks.md @@ -0,0 +1,11 @@ +--- +'@workflow/next': patch +'@workflow/nitro': patch +'@workflow/nuxt': patch +'@workflow/astro': patch +'@workflow/sveltekit': patch +'@workflow/nest': patch +--- + +Forward the completed-bundle hook through the framework integrations so +applications can generate derived workflow metadata consistently. diff --git a/.changeset/tidy-bundles-observe.md b/.changeset/tidy-bundles-observe.md new file mode 100644 index 0000000000..95b4a37335 --- /dev/null +++ b/.changeset/tidy-bundles-observe.md @@ -0,0 +1,5 @@ +--- +'@workflow/builders': minor +--- + +Add an optional hook for completed workflow bundles and watch rebuilds. diff --git a/packages/astro/README.md b/packages/astro/README.md index d838346d6a..de411f84c4 100644 --- a/packages/astro/README.md +++ b/packages/astro/README.md @@ -1,3 +1,21 @@ # workflow/astro The docs have moved! Refer to them [here](https://workflow-sdk.dev/) + +Applications can derive deployment metadata after each successful workflow +bundle by passing `onAfterBundle` to `workflow()`: + +```ts +import { workflow } from '@workflow/astro' + +export default { + integrations: [ + workflow({ + onAfterBundle: ({ artifacts }) => { + // Derive deployment metadata from artifacts here. + void artifacts + } + }) + ] +} +``` diff --git a/packages/astro/src/builder.ts b/packages/astro/src/builder.ts index f5449cc12f..0ccaa428f2 100644 --- a/packages/astro/src/builder.ts +++ b/packages/astro/src/builder.ts @@ -32,6 +32,7 @@ export class LocalBuilder extends BaseBuilder { projectRoot: config.projectRoot, dirs: config.dirs, sourcemap: options.sourcemap, + onAfterBundle: options.onAfterBundle, }), ...options, dirs: config.dirs, @@ -187,6 +188,7 @@ export class VercelBuilder extends VercelBuildOutputAPIBuilder { dirs: config.dirs, runtime: options.runtime, sourcemap: options.sourcemap, + onAfterBundle: options.onAfterBundle, }), ...options, dirs: config.dirs, diff --git a/packages/astro/src/plugin.ts b/packages/astro/src/plugin.ts index 7fc616b261..7e24048a3d 100644 --- a/packages/astro/src/plugin.ts +++ b/packages/astro/src/plugin.ts @@ -1,6 +1,10 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { type AstroConfig, createBuildQueue } from '@workflow/builders'; +import { + type AstroConfig, + createBuildQueue, + type WorkflowAfterBundleHook, +} from '@workflow/builders'; import { workflowTransformPlugin } from '@workflow/rollup'; import { workflowHotUpdatePlugin } from '@workflow/vite'; import type { AstroIntegration, HookParameters } from 'astro'; @@ -14,6 +18,11 @@ export interface WorkflowPluginOptions { * also be set via the `WORKFLOW_SOURCEMAP` environment variable. */ sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both'; + + /** + * Runs after the workflow bundles and manifest have been written. + */ + onAfterBundle?: WorkflowAfterBundleHook; } export function workflowPlugin( @@ -21,6 +30,7 @@ export function workflowPlugin( ): AstroIntegration { let builderOptions: Partial = { sourcemap: options.sourcemap, + onAfterBundle: options.onAfterBundle, }; const enqueue = createBuildQueue(); @@ -36,6 +46,7 @@ export function workflowPlugin( workingDir: fileURLToPath(config.root), dirs: [join(srcDir, 'pages'), join(srcDir, 'workflows')], sourcemap: options.sourcemap, + onAfterBundle: options.onAfterBundle, }; const vitePlugins = [workflowTransformPlugin()]; diff --git a/packages/builders/README.md b/packages/builders/README.md index c77a822ab9..fa4bda5d02 100644 --- a/packages/builders/README.md +++ b/packages/builders/README.md @@ -56,6 +56,63 @@ 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. +### Running a hook after bundle artifacts are complete + +Low-level builder configurations can provide an `onAfterBundle` hook. 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, + artifacts, +}) => { + const manifestPath = artifacts.find( + (artifact) => artifact.kind === 'manifest' + )!.path; + // Read manifestPath or derive other data from the completed bundle. +}; +``` + +Every invocation has exactly three artifact descriptors, ordered as `steps`, +`workflows`, and `manifest`. `workingDir` and every artifact path are absolute; +relative output paths are resolved against the builder's `workingDir` before +the files are written. The `manifest` artifact is authoritative: it points to +the serialized manifest with its `version`, converted entries, and workflow +graphs. The hook does not expose the internal SWC manifest shape. + +This is a **bundle boundary**, not the end of the builder's complete `build()` +method. Framework-specific webhook, source-map, diagnostics, public-manifest, +function-configuration, and optional client outputs may not exist yet and do +not produce separate invocations or artifact descriptors. Code that needs one +of those later outputs must run at a framework-specific build-completion hook +instead. + +`onAfterBundle` is currently a builder API. Direct `StandaloneBuilder` and +`VercelBuildOutputAPIBuilder` configurations can provide it. The Next, Nitro, +Nuxt, Astro, SvelteKit, and Nest integrations forward the option through their +framework configuration surfaces. +Builders that do not call `createManifest()`, including `SimBuilder`, do not +invoke it. + +The hook is awaited serially, including during watch rebuilds, so it should stay +fast or hand expensive work to another system. A hook failure is thrown as +`onAfterBundle hook failed`, with the original thrown value available as its +`cause`. The three bundle files have already been written at that point and are +not rolled back. A direct build rejects; a framework watcher may catch and log +that rejection according to its normal error policy. + +The hook is not called when bundle or manifest generation fails. Each +successful bundle write authorizes at most one hook call, and a later failed +rebuild invalidates the prior completion. Build systems can rebuild unchanged +inputs, so consumers should still make external side effects idempotent. The +hook runs in the build process with the builder's filesystem access and receives +absolute local paths; only install trusted hooks. + ## Architecture The builder system uses: diff --git a/packages/builders/src/after-bundle.test.ts b/packages/builders/src/after-bundle.test.ts new file mode 100644 index 0000000000..fe049f7cc6 --- /dev/null +++ b/packages/builders/src/after-bundle.test.ts @@ -0,0 +1,307 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, 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 { + // no-op + } + + protected override async createStepsBundle({ outfile }: { outfile: string }) { + mkdirSync(dirname(outfile), { recursive: true }); + writeFileSync( + outfile, + '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, + options: { + watch?: boolean; + workingDir?: string; + stepsPath?: string; + workflowsPath?: string; + } = {} + ): TestBuilder { + const workingDir = options.workingDir ?? testRoot; + return new TestBuilder({ + buildTarget: 'standalone', + workingDir, + dirs: ['.'], + watch: options.watch, + stepsBundlePath: options.stepsPath ?? stepsPath, + workflowsBundlePath: options.workflowsPath ?? workflowsPath, + webhookBundlePath: join(workingDir, 'webhook.js'), + onAfterBundle, + suppressCreateManifestLogs: true, + }); + } + + it('runs once with the three frozen, completed bundle 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, + artifacts: [ + { kind: 'steps', path: stepsPath }, + { kind: 'workflows', path: workflowsPath }, + { kind: 'manifest', path: join(manifestDir, 'manifest.json') }, + ], + }); + + const result = onAfterBundle.mock.calls[0][0]; + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.artifacts)).toBe(true); + expect(result.artifacts.every(Object.isFrozen)).toBe(true); + + const writtenManifest = JSON.parse( + readFileSync(join(manifestDir, 'manifest.json'), 'utf-8') + ); + expect(writtenManifest).toMatchObject({ + version: '1.0.0', + workflows: { + 'src/workflow.ts': { + run: { + workflowId: 'workflow//src/workflow.ts//run', + graph: { nodes: [], edges: [] }, + }, + }, + }, + }); + }); + + it('runs after every successful watch rebuild', async () => { + const onAfterBundle = vi.fn(); + const builder = createBuilder(onAfterBundle, { watch: 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); + await builder.createTestManifest(manifest, manifestDir); + + expect(bundleFinal).toBeDefined(); + expect(onAfterBundle).toHaveBeenCalledTimes(2); + }); + + it('writes and reports relative artifact paths against workingDir', async () => { + const onAfterBundle = vi.fn(); + const builder = createBuilder(onAfterBundle, { + workingDir: relative(process.cwd(), testRoot), + stepsPath: 'output/steps.js', + workflowsPath: 'output/workflows.js', + }); + const { manifest } = await builder.createTestBundle(); + + await builder.createTestManifest(manifest, 'output/manifest'); + + const result = onAfterBundle.mock.calls[0][0]; + expect(result).toEqual({ + buildTarget: 'standalone', + workingDir: testRoot, + artifacts: [ + { kind: 'steps', path: join(testRoot, 'output/steps.js') }, + { kind: 'workflows', path: join(testRoot, 'output/workflows.js') }, + { + kind: 'manifest', + path: join(testRoot, 'output/manifest/manifest.json'), + }, + ], + }); + expect( + result.artifacts.every(({ path }: { path: string }) => existsSync(path)) + ).toBe(true); + }); + + it('is awaited and rejects the build when it throws', async () => { + const error = new Error('registration failed'); + const onAfterBundle = vi.fn(async () => { + await Promise.resolve(); + throw error; + }); + const builder = createBuilder(onAfterBundle); + const { manifest } = await builder.createTestBundle(); + + await expect( + builder.createTestManifest(manifest, manifestDir) + ).rejects.toMatchObject({ + message: 'onAfterBundle hook failed', + cause: error, + }); + expect(existsSync(join(manifestDir, 'manifest.json'))).toBe(true); + + // The failed hook does not roll back the files or leave a completion token + // that can invoke the same hook again without another bundle write. + await expect( + builder.createTestManifest(manifest, manifestDir) + ).resolves.toEqual(expect.any(String)); + expect(onAfterBundle).toHaveBeenCalledOnce(); + }); + + it('preserves a non-Error hook failure as the error cause', async () => { + const builder = createBuilder(() => Promise.reject('registration failed')); + const { manifest } = await builder.createTestBundle(); + + await expect( + builder.createTestManifest(manifest, manifestDir) + ).rejects.toMatchObject({ + message: 'onAfterBundle hook failed', + cause: 'registration failed', + }); + }); + + 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(); + }); + + it('invalidates an earlier completion when a rebuild fails', async () => { + const onAfterBundle = vi.fn(); + const builder = createBuilder(onAfterBundle); + const completedBundle = await builder.createTestBundle(); + + await builder.createTestManifest(completedBundle.manifest, manifestDir); + expect(onAfterBundle).toHaveBeenCalledOnce(); + + const error = new Error('workflow rebuild failed'); + builder.failNextWorkflowBundle(error); + await expect(builder.createTestBundle()).rejects.toBe(error); + await builder.createTestManifest(completedBundle.manifest, manifestDir); + + expect(onAfterBundle).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 5f94ca2dfc..5667eac990 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -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, + WorkflowBundleArtifacts, + WorkflowConfig, +} from './types.js'; import { extractWorkflowGraphs } from './workflows-extractor.js'; import { hasSameContent, writeFileIfChanged } from './write-if-changed.js'; @@ -202,6 +206,11 @@ type CachedManifestTransform = { contentHash: string; }; +type CompletedBundleArtifacts = readonly [ + WorkflowBundleArtifacts[0], + WorkflowBundleArtifacts[1], +]; + /** * Base class for workflow builders. Provides common build logic for transforming * workflow source files into deployable bundles using esbuild and SWC. @@ -218,6 +227,10 @@ export abstract class BaseBuilder { private warnedExternalPackages = new Set(); private workflowBuildStartTime: number | undefined; private manifestTransformCache = new Map(); + private completedBundleArtifacts = new Map< + string, + CompletedBundleArtifacts + >(); constructor(config: WorkflowConfig) { this.config = config; @@ -401,6 +414,29 @@ 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, + Object.freeze([ + Object.freeze({ + kind: 'steps', + path: resolve(this.config.workingDir, stepsPath), + }), + Object.freeze({ kind: 'workflows', path: resolvedWorkflowsPath }), + ]) + ); + } + /** * Pseudo-packages that should not be checked for workflow patterns. */ @@ -1675,12 +1711,18 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr stepsManifest: WorkflowManifest; workflowsManifest: WorkflowManifest; }> { + const resolvedStepsOutfile = resolve(this.config.workingDir, stepsOutfile); + const resolvedFlowOutfile = resolve(this.config.workingDir, flowOutfile); + + // A new build attempt invalidates any prior completion for this output. + // The entry is restored only after both bundle artifacts have been written. + this.completedBundleArtifacts.delete(resolvedFlowOutfile); this.startWorkflowBuildTimer(); const effectiveDiscoveredEntries = discoveredEntries ?? (await this.discoverEntries( inputFiles, - dirname(flowOutfile), + dirname(resolvedFlowOutfile), tsconfigPath )); @@ -1689,7 +1731,7 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr const { context: stepsContext, manifest: stepsManifest } = await this.createStepsBundle({ inputFiles, - outfile: stepsOutfile, + outfile: resolvedStepsOutfile, // When bundleFinalOutput is true, use ESM for the steps bundle // regardless of the final output format. The final esbuild pass // converts everything to the target format. Using CJS here causes @@ -1709,7 +1751,7 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr }); // 2. Build workflow VM code - const tempWorkflowOutfile = `${flowOutfile}.__wf_tmp.js`; + const tempWorkflowOutfile = `${resolvedFlowOutfile}.__wf_tmp.js`; const workflowsResult = await this.createWorkflowsBundle({ inputFiles, outfile: tempWorkflowOutfile, @@ -1733,7 +1775,7 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr } // 3. Generate combined route file - const stepsRelativePath = `./${basename(stepsOutfile).replace(/\\/g, '/')}`; + const stepsRelativePath = `./${basename(resolvedStepsOutfile).replace(/\\/g, '/')}`; const escapedVMCode = workflowVMCode.replace(/[\\`$]/g, '\\$&'); const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode({ basePath: this.config.basePath, @@ -1755,7 +1797,7 @@ const workflowCode = \`${escapedVMCode}\`; ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode})`)}`; if (!bundleFinalOutput) { - await this.writeGeneratedFile(flowOutfile, combinedFunctionCode); + await this.writeGeneratedFile(resolvedFlowOutfile, combinedFunctionCode); } else { // Bundle the combined code for standalone use const bundleStartTime = Date.now(); @@ -1771,11 +1813,11 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr }, stdin: { contents: combinedFunctionCode, - resolveDir: dirname(flowOutfile), + resolveDir: dirname(resolvedFlowOutfile), sourcefile: 'virtual-entry.js', loader: 'js', }, - outfile: flowOutfile, + outfile: resolvedFlowOutfile, absWorkingDir: this.config.workingDir, bundle: true, format, @@ -1810,8 +1852,16 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr }, }; + this.recordCompletedBundleArtifacts({ + stepsPath: resolvedStepsOutfile, + workflowsPath: resolvedFlowOutfile, + }); + // Create a custom bundleFinal for watch mode that uses workflowEntrypoint const combinedBundleFinal = async (interimBundleText: string) => { + // A failed rebuild must not leave the previous successful bundle marked + // as complete. + this.completedBundleArtifacts.delete(resolvedFlowOutfile); const escaped = interimBundleText.replace(/[\\`$]/g, '\\$&'); const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( { @@ -1832,9 +1882,13 @@ const workflowCode = \`${escaped}\`; ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode})`)}`; - const outputDir = dirname(flowOutfile); + const outputDir = dirname(resolvedFlowOutfile); await mkdir(outputDir, { recursive: true }); - await this.writeGeneratedFile(flowOutfile, code); + await this.writeGeneratedFile(resolvedFlowOutfile, code); + this.recordCompletedBundleArtifacts({ + stepsPath: resolvedStepsOutfile, + workflowsPath: resolvedFlowOutfile, + }); }; if (this.config.watch) { @@ -2325,10 +2379,28 @@ export const OPTIONS = handler;`; manifest: WorkflowManifest; }): Promise { const buildStart = Date.now(); + const resolvedWorkflowBundlePath = resolve( + this.config.workingDir, + workflowBundlePath + ); + const manifestPath = resolve( + this.config.workingDir, + manifestDir, + 'manifest.json' + ); + const bundleArtifacts = this.completedBundleArtifacts.get( + resolvedWorkflowBundlePath + ); + // One successful bundle write authorizes at most one manifest hook call, + // even when manifest creation or the hook itself fails. + this.completedBundleArtifacts.delete(resolvedWorkflowBundlePath); this.logCreateManifestInfo('Creating manifest...'); + let manifestJson: string; try { - const workflowGraphs = await extractWorkflowGraphs(workflowBundlePath); + const workflowGraphs = await extractWorkflowGraphs( + resolvedWorkflowBundlePath + ); const steps = this.convertStepsManifest(manifest.steps); const workflows = this.convertWorkflowsManifest( @@ -2343,13 +2415,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) { @@ -2384,8 +2453,6 @@ export const OPTIONS = handler;`; ); } this.resetWorkflowBuildTimer(); - - return manifestJson; } catch (error) { console.warn( 'Failed to create manifest:', @@ -2394,6 +2461,38 @@ export const OPTIONS = handler;`; this.resetWorkflowBuildTimer(); return undefined; } + + if (bundleArtifacts) { + const onAfterBundle = this.config.onAfterBundle; + if (onAfterBundle) { + const artifacts = Object.freeze([ + ...bundleArtifacts, + Object.freeze({ + kind: 'manifest', + path: manifestPath, + }), + ]) as WorkflowBundleArtifacts; + const result = Object.freeze({ + buildTarget: this.config.buildTarget, + workingDir: resolve(this.config.workingDir), + artifacts, + }); + const hookStart = Date.now(); + this.logCreateManifestInfo('Running onAfterBundle hook...'); + try { + await onAfterBundle(result); + } catch (cause) { + throw new Error('onAfterBundle hook failed', { cause }); + } finally { + this.logCreateManifestInfo( + 'Finished onAfterBundle hook', + `${Date.now() - hookStart}ms` + ); + } + } + } + + return manifestJson; } private convertStepsManifest( diff --git a/packages/builders/src/config-helpers.ts b/packages/builders/src/config-helpers.ts index 6e92637da0..edb913a13e 100644 --- a/packages/builders/src/config-helpers.ts +++ b/packages/builders/src/config-helpers.ts @@ -3,7 +3,11 @@ import { readFile } from 'node:fs/promises'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { findUp } from 'find-up'; import JSON5 from 'json5'; -import type { SourcemapMode, WorkflowConfig } from './types.js'; +import type { + SourcemapMode, + WorkflowAfterBundleHook, + WorkflowConfig, +} from './types.js'; export interface DecoratorOptions { decorators: boolean; @@ -99,6 +103,7 @@ export function createBaseBuilderConfig(options: { externalPackages?: string[]; runtime?: string; sourcemap?: SourcemapMode; + onAfterBundle?: WorkflowAfterBundleHook; }): Omit { return { dirs: options.dirs ?? ['workflows'], @@ -111,6 +116,7 @@ export function createBaseBuilderConfig(options: { externalPackages: options.externalPackages, runtime: options.runtime, sourcemap: options.sourcemap, + onAfterBundle: options.onAfterBundle, }; } diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 425603e6a6..c7e063fcc2 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -65,10 +65,16 @@ export { export type { AstroConfig, BuildTarget, + NitroConfig, NextConfig, StandaloneConfig, SvelteKitConfig, VercelBuildOutputConfig, + WorkflowAfterBundleHook, + WorkflowBundleArtifact, + WorkflowBundleArtifactKind, + WorkflowBundleArtifacts, + WorkflowBundleResult, WorkflowConfig, } from './types.js'; export { isValidBuildTarget, validBuildTargets } from './types.js'; diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts index de8c423cc3..c3a6696c1d 100644 --- a/packages/builders/src/types.ts +++ b/packages/builders/src/types.ts @@ -8,7 +8,34 @@ export const validBuildTargets = [ 'sveltekit', 'astro', ] as const; -export type BuildTarget = (typeof validBuildTargets)[number]; + +/** The output strategy used to emit workflow build artifacts. */ +export type BuildTarget = (typeof validBuildTargets)[number] | 'nitro'; + +export type WorkflowBundleArtifactKind = 'steps' | 'workflows' | 'manifest'; + +export interface WorkflowBundleArtifact< + Kind extends WorkflowBundleArtifactKind = WorkflowBundleArtifactKind, +> { + readonly kind: Kind; + readonly path: string; +} + +export type WorkflowBundleArtifacts = readonly [ + WorkflowBundleArtifact<'steps'>, + WorkflowBundleArtifact<'workflows'>, + WorkflowBundleArtifact<'manifest'>, +]; + +export interface WorkflowBundleResult { + readonly buildTarget: BuildTarget; + readonly workingDir: string; + readonly artifacts: WorkflowBundleArtifacts; +} + +export type WorkflowAfterBundleHook = ( + result: WorkflowBundleResult +) => void | Promise; /** * Source map emission mode for generated workflow bundles. Matches esbuild's @@ -63,6 +90,20 @@ interface BaseWorkflowConfig { */ onAfterTransform?: WorkflowAfterTransformHook; + /** + * Optional hook invoked after a complete workflow bundle and its + * manifest have been written successfully. + * + * Each invocation contains exactly one `steps`, one `workflows`, and one + * `manifest` artifact. The hook is awaited and runs again after every + * successful watch rebuild. The hook runs at the bundle boundary, before + * later builder-specific outputs may have been written. Throwing rejects + * the build or rebuild after the bundle and manifest files have been written. + * Consumers should make side effects idempotent because build systems may + * rebuild unchanged inputs. + */ + onAfterBundle?: WorkflowAfterBundleHook; + // Optional prefix for debug files (e.g., "_" for Astro to ignore them) debugFilePrefix?: string; @@ -168,6 +209,20 @@ export interface NextConfig extends BaseWorkflowConfig { webhookBundlePath: string; } +/** + * Configuration for Nitro and Nuxt builds. + * + * Nitro is a framework adapter rather than a CLI target, so it is not part of + * the CLI's `validBuildTargets` list. + */ +export interface NitroConfig extends BaseWorkflowConfig { + buildTarget: 'nitro'; + // Nitro computes paths dynamically, so these are not used by the base. + stepsBundlePath: string; + workflowsBundlePath: string; + webhookBundlePath: string; +} + /** * Configuration for SvelteKit builds. */ @@ -208,12 +263,13 @@ export type WorkflowConfig = | StandaloneConfig | VercelBuildOutputConfig | NextConfig + | NitroConfig | NestConfig | SvelteKitConfig | AstroConfig; export function isValidBuildTarget( target: string | undefined -): target is BuildTarget { - return !!target && validBuildTargets.includes(target as BuildTarget); +): target is (typeof validBuildTargets)[number] { + return !!target && (validBuildTargets as readonly string[]).includes(target); } diff --git a/packages/builders/src/vercel-build-output-api.test.ts b/packages/builders/src/vercel-build-output-api.test.ts index 081058d49d..df35680fe3 100644 --- a/packages/builders/src/vercel-build-output-api.test.ts +++ b/packages/builders/src/vercel-build-output-api.test.ts @@ -1,10 +1,10 @@ import { execFileSync } from 'node:child_process'; -import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { VercelBuildOutputConfig } from './types.js'; import { VercelBuildOutputAPIBuilder } from './vercel-build-output-api.js'; @@ -22,7 +22,10 @@ function getFlowFuncDir(workingDir: string): string { ); } -function createBuilder(workingDir: string): VercelBuildOutputAPIBuilder { +function createBuilder( + workingDir: string, + onAfterBundle?: VercelBuildOutputConfig['onAfterBundle'] +): VercelBuildOutputAPIBuilder { const config: VercelBuildOutputConfig = { buildTarget: 'vercel-build-output-api', workingDir, @@ -31,6 +34,7 @@ function createBuilder(workingDir: string): VercelBuildOutputAPIBuilder { workflowsBundlePath: join(workingDir, 'unused-workflows.mjs'), webhookBundlePath: join(workingDir, 'unused-webhook.mjs'), suppressCreateManifestLogs: true, + onAfterBundle, }; return new VercelBuildOutputAPIBuilder(config); } @@ -96,6 +100,7 @@ describe('VercelBuildOutputAPIBuilder ESM output', () => { }); afterEach(() => { + vi.unstubAllEnvs(); rmSync(workingDir, { recursive: true, force: true }); }); @@ -189,4 +194,61 @@ export async function gaxWorkflow(): Promise { ).toHaveLength(1); } ); + + it( + 'runs the bundle hook after the webhook output and before the public manifest copy', + { timeout: BUILD_TIMEOUT }, + async () => { + vi.stubEnv('WORKFLOW_PUBLIC_MANIFEST', '1'); + await writeWorkflowRuntimeStub(workingDir); + await write( + join(workingDir, 'src/workflows/example.ts'), + `export async function exampleStep(): Promise { + 'use step'; + return 'ok'; +} + +export async function exampleWorkflow(): Promise { + 'use workflow'; + return exampleStep(); +} +` + ); + + const workflowGeneratedDir = join( + workingDir, + '.vercel/output/functions/.well-known/workflow/v1' + ); + const webhookPath = join( + workflowGeneratedDir, + 'webhook/[token].func/index.mjs' + ); + const publicManifestPath = join( + workingDir, + '.vercel/output/static/.well-known/workflow/v1/manifest.json' + ); + let hookObservation: + | { + artifactPathsExist: boolean; + webhookExists: boolean; + publicManifestExists: boolean; + } + | undefined; + + await createBuilder(workingDir, ({ artifacts }) => { + hookObservation = { + artifactPathsExist: artifacts.every(({ path }) => existsSync(path)), + webhookExists: existsSync(webhookPath), + publicManifestExists: existsSync(publicManifestPath), + }; + }).build(); + + expect(hookObservation).toEqual({ + artifactPathsExist: true, + webhookExists: true, + publicManifestExists: false, + }); + expect(existsSync(publicManifestPath)).toBe(true); + } + ); }); diff --git a/packages/nest/README.md b/packages/nest/README.md index 447a5bb6b6..30e36108ec 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -139,6 +139,12 @@ WorkflowModule.forRoot({ // Only used when moduleType is 'commonjs' // Should match the outDir in your tsconfig.json distDir: 'dist', + + // Derive deployment metadata after each successful build. + onAfterBundle: ({ artifacts }) => { + // Derive deployment metadata from artifacts here. + void artifacts; + }, }); ``` diff --git a/packages/nest/src/builder.test.ts b/packages/nest/src/builder.test.ts index e2c42c7e00..03e975a841 100644 --- a/packages/nest/src/builder.test.ts +++ b/packages/nest/src/builder.test.ts @@ -161,6 +161,13 @@ describe('NestLocalBuilder CommonJS steps bundle', () => { rmSync(workingDir, { recursive: true, force: true }); }); + it('forwards the after-bundle hook to the builder config', () => { + const onAfterBundle = () => {}; + const builder = new NestLocalBuilder({ onAfterBundle }); + + expect((builder as any).config.onAfterBundle).toBe(onAfterBundle); + }); + it( 'declares `require` exactly once', { timeout: BUILD_TIMEOUT }, diff --git a/packages/nest/src/builder.ts b/packages/nest/src/builder.ts index 751040484c..158786aaf2 100644 --- a/packages/nest/src/builder.ts +++ b/packages/nest/src/builder.ts @@ -1,5 +1,9 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { BaseBuilder, createBaseBuilderConfig } from '@workflow/builders'; +import { + BaseBuilder, + createBaseBuilderConfig, + type WorkflowAfterBundleHook, +} from '@workflow/builders'; import { join } from 'pathe'; import { rewriteTsImportsInContent } from './cjs-rewrite.js'; @@ -51,6 +55,8 @@ export interface NestBuilderOptions { * Can also be set via the `WORKFLOW_SOURCEMAP` environment variable. */ sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both'; + /** Runs after the workflow bundles and manifest have been written. */ + onAfterBundle?: WorkflowAfterBundleHook; /** * Route prefix the workflow endpoints are served under, stamped into the * generated flow route so the runtime generates matching callback URLs. @@ -76,6 +82,7 @@ export class NestLocalBuilder extends BaseBuilder { watch: false, dirs, sourcemap: options.sourcemap, + onAfterBundle: options.onAfterBundle, }), basePath: options.basePath, // Use 'standalone' as base target - we handle the specific bundling ourselves diff --git a/packages/nest/src/vercel-builder.ts b/packages/nest/src/vercel-builder.ts index 7770ca9387..ccfb9608cf 100644 --- a/packages/nest/src/vercel-builder.ts +++ b/packages/nest/src/vercel-builder.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url'; import { createBaseBuilderConfig, VercelBuildOutputAPIBuilder, + type WorkflowAfterBundleHook, } from '@workflow/builders'; import * as esbuild from 'esbuild'; import { resolveAbsentNestPeers } from './nest-optional-peers.js'; @@ -208,6 +209,8 @@ export interface NestVercelBuilderOptions { runtime?: string; /** esbuild sourcemap mode for workflow bundles. */ sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both'; + /** Runs after the workflow bundles and manifest have been written. */ + onAfterBundle?: WorkflowAfterBundleHook; /** * Route prefix the app is served under, stamped into the generated flow route * so the runtime generates matching callback URLs. @@ -244,6 +247,7 @@ export class NestVercelBuilder extends VercelBuildOutputAPIBuilder { dirs, runtime: options.runtime, sourcemap: options.sourcemap, + onAfterBundle: options.onAfterBundle, // A step that imports an application service pulls `@nestjs/common` // into the workflow function, and `@nestjs/common` `require()`s its // optional peers behind try/catch. Without this the build fails to diff --git a/packages/next/README.md b/packages/next/README.md index 1bc4db3736..729b37804f 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -1,3 +1,19 @@ # @workflow/next Next.js plugin for [Workflow SDK](https://workflow-sdk.dev). + +Applications can derive deployment metadata after each successful workflow +bundle by passing `onAfterBundle` to `withWorkflow`: + +```ts +import { withWorkflow } from '@workflow/next' + +export default withWorkflow(nextConfig, { + workflows: { + onAfterBundle: ({ artifacts }) => { + // Derive deployment metadata from artifacts here. + void artifacts + } + } +}) +``` diff --git a/packages/next/src/builder-eager.test.ts b/packages/next/src/builder-eager.test.ts index 36b8f52f21..28cbc11c23 100644 --- a/packages/next/src/builder-eager.test.ts +++ b/packages/next/src/builder-eager.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it } from 'vitest'; -import { createNextEntrypointMatcher } from './builder-eager.js'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import * as buildersModule from '@workflow/builders'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createNextEntrypointMatcher, + getNextBuilderEager, +} from './builder-eager.js'; const pageExtensions = ['js', 'jsx', 'ts', 'tsx', 'mts', 'cts']; const isNextEntrypoint = createNextEntrypointMatcher(pageExtensions); @@ -50,3 +58,106 @@ describe('isNextEntrypoint', () => { ).toBe(true); }); }); + +async function write(path: string, contents: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents, 'utf8'); +} + +async function writeWorkflowRuntimeStub(workingDir: string): Promise { + const packageDir = join(workingDir, 'node_modules/workflow'); + await write( + join(packageDir, 'package.json'), + JSON.stringify({ + name: 'workflow', + version: '1.0.0', + type: 'module', + exports: { + './api': './api.js', + './internal/builtins': './builtins.js', + './runtime': './runtime.js', + }, + }) + ); + await write( + join(packageDir, 'api.js'), + 'export async function resumeWebhook() { return new Response(null, { status: 204 }); }\n' + ); + await write( + join(packageDir, 'builtins.js'), + 'export const __workflow_builtins = true;\n' + ); + await write( + join(packageDir, 'runtime.js'), + 'export function workflowEntrypoint() { return async function POST() { return new Response(null, { status: 204 }); }; }\n' + ); +} + +describe('NextBuilder onAfterBundle integration', () => { + let workingDir: string | undefined; + + afterEach(() => { + vi.unstubAllEnvs(); + if (workingDir) { + rmSync(workingDir, { recursive: true, force: true }); + workingDir = undefined; + } + }); + + it('invokes the hook through the real Next manifest path', async () => { + vi.stubEnv('NODE_ENV', 'production'); + workingDir = mkdtempSync(join(tmpdir(), 'workflow-next-after-bundle-')); + await writeWorkflowRuntimeStub(workingDir); + await write( + join(workingDir, 'app/page.tsx'), + `export async function exampleStep(): Promise { + 'use step'; + return 'ok'; +} + +export async function exampleWorkflow(): Promise { + 'use workflow'; + return exampleStep(); +} + +export default function Page() { + return null; +} +` + ); + + const onAfterBundle = vi.fn(); + const NextBuilder = await getNextBuilderEager(buildersModule); + const builder = new NextBuilder({ + watch: false, + dirs: ['.'], + pageExtensions: ['tsx', 'ts', 'jsx', 'js'], + projectRoot: workingDir, + moduleSpecifierRoot: workingDir, + workingDir, + distDir: '.next', + buildTarget: 'next', + workflowsBundlePath: '', + stepsBundlePath: '', + webhookBundlePath: '', + suppressCreateManifestLogs: true, + suppressCreateWebhookBundleLogs: true, + suppressCreateWorkflowsBundleLogs: true, + onAfterBundle, + }); + + await builder.build(); + + expect(onAfterBundle).toHaveBeenCalledOnce(); + const result = onAfterBundle.mock.calls[0][0]; + expect(result.buildTarget).toBe('next'); + expect( + result.artifacts.every(({ path }: { path: string }) => existsSync(path)) + ).toBe(true); + expect(result.artifacts.map(({ kind }: { kind: string }) => kind)).toEqual([ + 'steps', + 'workflows', + 'manifest', + ]); + }); +}); diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index b947c725b3..3b0b62448d 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -125,6 +125,15 @@ describe('withWorkflow builder config', () => { }); }); + it('forwards the after-bundle hook to the workflow builder', async () => { + const onAfterBundle = vi.fn(); + const config = withWorkflow({}, { workflows: { onAfterBundle } }); + + await config('phase-production-build', { defaultConfig: {} }); + + expect(builderConfigs[0].onAfterBundle).toBe(onAfterBundle); + }); + it.each([ 'phase-production-build', 'phase-development-server', diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index c83ddc6e4f..1572394836 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -6,6 +6,7 @@ import { resolveProjectRoot, WORKFLOW_OPTIONAL_WS_NATIVE_MODULES, } from '@workflow/builders'; +import type { WorkflowAfterBundleHook } from '@workflow/builders'; import type { NextConfig } from 'next'; import semver from 'semver'; import { getNextBuilder } from './builder.js'; @@ -467,6 +468,13 @@ export function withWorkflow( * environment variable. */ sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both'; + /** + * Runs after the workflow bundles and manifest have been written. + * + * This is useful for integrations that derive deployment metadata from + * the authoritative Workflow SDK manifest. + */ + onAfterBundle?: WorkflowAfterBundleHook; }; } = {} ) { @@ -626,6 +634,7 @@ export function withWorkflow( stepsBundlePath: '', // not used in base webhookBundlePath: '', // node used in base sourcemap: workflows?.sourcemap, + onAfterBundle: workflows?.onAfterBundle, externalPackages: [ // server-only and client-only are pseudo-packages handled by Next.js // during its build process. We mark them as external to prevent esbuild diff --git a/packages/nitro/README.md b/packages/nitro/README.md index 3606a9bf34..6aae5e37ca 100644 --- a/packages/nitro/README.md +++ b/packages/nitro/README.md @@ -3,3 +3,17 @@ The docs have moved! Refer to them [here](https://workflow-sdk.dev/) The Nitro module uses Nitro's `workspaceDir` as the workflow project root, so monorepo apps can import sibling workspace packages without extra workflow config. + +Applications can derive deployment metadata at the bundle boundary by passing +an `onAfterBundle` hook through `nitro.options.workflow`: + +```ts +export default defineNitroConfig({ + workflow: { + onAfterBundle: ({ artifacts }) => { + // Derive deployment metadata from artifacts here. + void artifacts + } + } +}) +``` diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index ec63a321a6..84323ad63d 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -44,6 +44,7 @@ export class VercelBuilder extends VercelBuildOutputAPIBuilder { dirs: getNitroWorkflowDirs(nitro), runtime: nitro.options.workflow?.runtime, sourcemap: nitro.options.workflow?.sourcemap, + onAfterBundle: nitro.options.workflow?.onAfterBundle, externalPackages: getNitroStringExternals(nitro), }), buildTarget: 'vercel-build-output-api', @@ -73,9 +74,10 @@ export class LocalBuilder extends BaseBuilder { watch: nitro.options.dev, dirs: getNitroWorkflowDirs(nitro), sourcemap: nitro.options.workflow?.sourcemap, + onAfterBundle: nitro.options.workflow?.onAfterBundle, externalPackages: getNitroStringExternals(nitro), }), - buildTarget: 'next', // Placeholder, not actually used + buildTarget: 'nitro', }); this.#outDir = outDir; } diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index 3f6a0502ae..cdc4166496 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -11,7 +11,11 @@ type StubOptions = { preset?: string; rootDir?: string; workspaceDir?: string; - workflow?: { dirs?: string[]; runtime?: string }; + workflow?: { + dirs?: string[]; + runtime?: string; + onAfterBundle?: (...args: any[]) => unknown; + }; externals?: { external?: Array boolean)>; }; @@ -117,6 +121,18 @@ describe('@workflow/nitro virtual handlers', () => { }); describe('@workflow/nitro builder lifecycle', () => { + it('forwards the after-bundle hook with the Nitro build target', () => { + const onAfterBundle = vi.fn(); + const nitro = createNitroStub({ + routing: true, + workflow: { onAfterBundle }, + }); + const builder = new LocalBuilder(nitro); + + expect((builder as any).config.buildTarget).toBe('nitro'); + expect((builder as any).config.onAfterBundle).toBe(onAfterBundle); + }); + it('closes a development Nitro instance with its Vite plugin container', async () => { const nitro = createNitroStub({ routing: true, dev: true }); nitro.close = vi.fn(async () => {}); diff --git a/packages/nitro/src/types.ts b/packages/nitro/src/types.ts index 395b445ad7..e5bd58c3d9 100644 --- a/packages/nitro/src/types.ts +++ b/packages/nitro/src/types.ts @@ -1,3 +1,5 @@ +import type { WorkflowAfterBundleHook } from '@workflow/builders'; + export interface ModuleOptions { /** @internal */ _vite?: boolean; @@ -36,6 +38,14 @@ export interface ModuleOptions { */ sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both'; + /** + * Runs after the workflow bundles and manifest have been written. + * + * This is useful for integrations that derive deployment metadata from the + * authoritative Workflow SDK manifest. + */ + onAfterBundle?: WorkflowAfterBundleHook; + /** * Embed the workflow observability dashboard in-process on this server, * served at `/_workflow` (configurable). The UI runs inside the same Nitro diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index 80a735eade..14d443505c 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -3,3 +3,18 @@ Nuxt module for [Workflow SDK](https://workflow-sdk.dev). Monorepo workspace package imports work without extra workflow config because `workflow/nuxt` runs through Nitro's detected `workspaceDir`. + +Applications can derive deployment metadata after each successful workflow +bundle by passing `onAfterBundle` in the Nuxt module options: + +```ts +export default defineNuxtConfig({ + modules: ['@workflow/nuxt'], + workflow: { + onAfterBundle: ({ artifacts }) => { + // Derive deployment metadata from artifacts here. + void artifacts + } + } +}) +``` diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index aadbd03b1c..5891819204 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -9,9 +9,14 @@ export interface ModuleOptions { * @default true */ typescriptPlugin: boolean; + + /** + * Runs after the workflow bundles and manifest have been written. + */ + onAfterBundle?: NitroModuleOptions['onAfterBundle']; } -const module: NuxtModule = defineNuxtModule({ +const module: NuxtModule = defineNuxtModule({ meta: { name: 'workflow', configKey: 'workflow', @@ -28,10 +33,16 @@ const module: NuxtModule = defineNuxtModule({ if (!nuxt.options.nitro.modules.includes('@workflow/nitro')) { nuxt.options.nitro.workflow ||= {} as NitroModuleOptions; nuxt.options.nitro.workflow.typescriptPlugin = options.typescriptPlugin; + nuxt.options.nitro.workflow.onAfterBundle = options.onAfterBundle; // Signal to @workflow/nitro that Vite handles SSR externalization, // so the Nitro module should not override Nitro externals config. nuxt.options.nitro.workflow._vite = true; nuxt.options.nitro.modules.push('@workflow/nitro'); + } else if (options.onAfterBundle) { + // Preserve an existing Nitro module registration while still forwarding + // the hook configured through the Nuxt module. + nuxt.options.nitro.workflow ||= {} as NitroModuleOptions; + nuxt.options.nitro.workflow.onAfterBundle = options.onAfterBundle; } // Force Vite to bundle workflow SDK packages in SSR mode rather than diff --git a/packages/sveltekit/README.md b/packages/sveltekit/README.md index 90696e285e..539d048489 100644 --- a/packages/sveltekit/README.md +++ b/packages/sveltekit/README.md @@ -1,3 +1,25 @@ # workflow/sveltekit The docs have moved! Refer to them [here](https://workflow-sdk.dev/) + +Applications can derive deployment metadata after each successful workflow +bundle by passing `onAfterBundle` to `workflow()`: + +```ts +import { workflow } from '@workflow/sveltekit' + +export default { + kit: { + vite: { + plugins: [ + workflow({ + onAfterBundle: ({ artifacts }) => { + // Derive deployment metadata from artifacts here. + void artifacts + } + }) + ] + } + } +} +``` diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts index 3980a3dfc1..a22a472caf 100644 --- a/packages/sveltekit/src/builder.ts +++ b/packages/sveltekit/src/builder.ts @@ -38,6 +38,7 @@ export class SvelteKitBuilder extends BaseBuilder { watch: config.watch, externalPackages: [...SVELTEKIT_VIRTUAL_MODULES], sourcemap: config.sourcemap, + onAfterBundle: config.onAfterBundle, }), ...config, dirs, diff --git a/packages/sveltekit/src/plugin.test.ts b/packages/sveltekit/src/plugin.test.ts index 0dd1dc421a..1d8d93f56a 100644 --- a/packages/sveltekit/src/plugin.test.ts +++ b/packages/sveltekit/src/plugin.test.ts @@ -144,4 +144,26 @@ describe('workflowPlugin', () => { }, ]); }); + + it('passes the after-bundle hook to the builder', async () => { + const configPath = join(workingDir, 'svelte.config.js'); + await writeFile(configPath, 'export default {};'); + mocks.loadConfig.mockResolvedValue({ + config: {}, + configFilePath: configPath, + configSource: 'svelte', + }); + const onAfterBundle = vi.fn(); + + const [plugins] = workflowPlugin({ onAfterBundle }); + await plugins; + + expect(mocks.builderConfigs).toEqual([ + { + routesDir: join(workingDir, 'src/routes'), + workingDir, + onAfterBundle, + }, + ]); + }); }); diff --git a/packages/sveltekit/src/plugin.ts b/packages/sveltekit/src/plugin.ts index ed5c490252..f96672691e 100644 --- a/packages/sveltekit/src/plugin.ts +++ b/packages/sveltekit/src/plugin.ts @@ -2,7 +2,10 @@ import { existsSync } from 'node:fs'; import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { loadConfig } from '@sveltejs/load-config'; -import { createBuildQueue } from '@workflow/builders'; +import { + createBuildQueue, + type WorkflowAfterBundleHook, +} from '@workflow/builders'; import { workflowTransformPlugin } from '@workflow/rollup'; import { workflowHotUpdatePlugin } from '@workflow/vite'; import type { Plugin, PluginOption } from 'vite'; @@ -16,6 +19,11 @@ export interface WorkflowPluginOptions { * also be set via the `WORKFLOW_SOURCEMAP` environment variable. */ sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both'; + + /** + * Runs after the workflow bundles and manifest have been written. + */ + onAfterBundle?: WorkflowAfterBundleHook; } const resolvingConfig = new Set();