From fa584661ad8ffc7fd271cee7d0701b3c723d2b85 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Fri, 6 Feb 2026 01:14:20 -0800 Subject: [PATCH 1/2] feat: expose workflow manifest via HTTP when WORKFLOW_PUBLIC_MANIFEST=1 When WORKFLOW_PUBLIC_MANIFEST=1 is set, each framework builder exposes the manifest at /.well-known/workflow/v1/manifest.json via the most appropriate mechanism for the framework: - Next.js: Copies to public/.well-known/workflow/v1/ (static) - SvelteKit: Copies to static/.well-known/workflow/v1/ (static) - VercelBuildOutputAPI: Copies to .vercel/output/static/.well-known/workflow/v1/ (static) - Nitro (vite/hono/express/fastify/nuxt/nitro-v2/v3): Virtual handler - Astro: Generated manifest.json.js page route - NestJS: @Get endpoint on WorkflowController Also fixes Astro LocalBuilder missing createManifest() call, removes the unused example workbench public/ directory, and changes BaseBuilder.createManifest() to return the manifest JSON string. --- .gitignore | 4 ++ packages/astro/src/builder.ts | 45 ++++++++++++++++-- packages/builders/src/base-builder.ts | 21 +++++++-- .../builders/src/vercel-build-output-api.ts | 18 ++++++- packages/nest/src/workflow.controller.ts | 31 +++++++++++- packages/next/src/builder.ts | 18 ++++++- packages/nitro/src/index.ts | 47 +++++++++++++++++++ packages/sveltekit/src/builder.ts | 25 +++++++++- workbench/example/package.json | 2 +- workbench/example/public/index.html | 16 ------- 10 files changed, 194 insertions(+), 33 deletions(-) delete mode 100644 workbench/example/public/index.html diff --git a/.gitignore b/.gitignore index ec15a82f9f..4da5cf9dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ packages/swc-plugin-workflow/build-hash.json .DS_Store +# Generated manifest files copied to static asset directories by builders +workbench/nextjs-*/public/.well-known/workflow +workbench/sveltekit/static/.well-known/workflow + diff --git a/packages/astro/src/builder.ts b/packages/astro/src/builder.ts index 85dc97a87e..d13cae3f03 100644 --- a/packages/astro/src/builder.ts +++ b/packages/astro/src/builder.ts @@ -59,9 +59,42 @@ export class LocalBuilder extends BaseBuilder { }; // Generate the three Astro route handlers - await this.buildStepsRoute(options); - await this.buildWorkflowsRoute(options); + const stepsManifest = await this.buildStepsRoute(options); + const workflowsManifest = await this.buildWorkflowsRoute(options); await this.buildWebhookRoute({ workflowGeneratedDir }); + + // Merge manifests from both bundles + const manifest = { + steps: { ...stepsManifest.steps, ...workflowsManifest.steps }, + workflows: { + ...stepsManifest.workflows, + ...workflowsManifest.workflows, + }, + classes: { ...stepsManifest.classes, ...workflowsManifest.classes }, + }; + + // Generate unified manifest + const workflowBundlePath = join(workflowGeneratedDir, 'flow.js'); + const manifestJson = await this.createManifest({ + workflowBundlePath, + manifestDir: workflowGeneratedDir, + manifest, + }); + + // Expose manifest as a public HTTP route when WORKFLOW_PUBLIC_MANIFEST=1 + // Astro maps `foo.json.js` to the URL `/foo.json` + if (this.shouldExposePublicManifest && manifestJson) { + await writeFile( + join(workflowGeneratedDir, 'manifest.json.js'), + `export function GET() { + return new Response(${JSON.stringify(manifestJson)}, { + headers: { "content-type": "application/json" }, + }); +} + +export const prerender = false;\n` + ); + } } private async buildStepsRoute({ @@ -75,7 +108,7 @@ export class LocalBuilder extends BaseBuilder { }) { // Create steps route: .well-known/workflow/v1/step.js const stepsRouteFile = join(workflowGeneratedDir, 'step.js'); - await this.createStepsBundle({ + const { manifest } = await this.createStepsBundle({ format: 'esm', inputFiles, outfile: stepsRouteFile, @@ -97,6 +130,8 @@ export const POST = async ({request}) => { export const prerender = false;` ); await writeFile(stepsRouteFile, stepsRouteContent); + + return manifest; } private async buildWorkflowsRoute({ @@ -110,7 +145,7 @@ export const prerender = false;` }) { // Create workflows route: .well-known/workflow/v1/flow.js const workflowsRouteFile = join(workflowGeneratedDir, 'flow.js'); - await this.createWorkflowsBundle({ + const { manifest } = await this.createWorkflowsBundle({ format: 'esm', outfile: workflowsRouteFile, bundleFinalOutput: false, @@ -132,6 +167,8 @@ export const POST = async ({request}) => { export const prerender = false;` ); await writeFile(workflowsRouteFile, workflowsRouteContent); + + return manifest; } private async buildWebhookRoute({ diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 6e4a5048bf..6a1c43530c 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -1027,9 +1027,19 @@ export const OPTIONS = handler;`; } } + /** + * Whether the manifest should be exposed as a public HTTP route. + * Controlled by the `WORKFLOW_PUBLIC_MANIFEST` environment variable. + */ + protected get shouldExposePublicManifest(): boolean { + return process.env.WORKFLOW_PUBLIC_MANIFEST === '1'; + } + /** * Creates a manifest JSON file containing step/workflow/class metadata * and graph data for visualization. + * + * @returns The manifest JSON string, or undefined if manifest creation failed. */ protected async createManifest({ workflowBundlePath, @@ -1039,7 +1049,7 @@ export const OPTIONS = handler;`; workflowBundlePath: string; manifestDir: string; manifest: WorkflowManifest; - }): Promise { + }): Promise { const buildStart = Date.now(); console.log('Creating manifest...'); @@ -1054,12 +1064,10 @@ export const OPTIONS = handler;`; const classes = this.convertClassesManifest(manifest.classes); const output = { version: '1.0.0', steps, workflows, classes }; + const manifestJson = JSON.stringify(output, null, 2); await mkdir(manifestDir, { recursive: true }); - await writeFile( - join(manifestDir, 'manifest.json'), - JSON.stringify(output, null, 2) - ); + await writeFile(join(manifestDir, 'manifest.json'), manifestJson); const stepCount = Object.values(steps).reduce( (acc, s) => acc + Object.keys(s).length, @@ -1078,11 +1086,14 @@ export const OPTIONS = handler;`; `Created manifest with ${stepCount} ${pluralize('step', 'steps', stepCount)}, ${workflowCount} ${pluralize('workflow', 'workflows', workflowCount)}, and ${classCount} ${pluralize('class', 'classes', classCount)}`, `${Date.now() - buildStart}ms` ); + + return manifestJson; } catch (error) { console.warn( 'Failed to create manifest:', error instanceof Error ? error.message : String(error) ); + return undefined; } } diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index ead468d509..0d2e6582ee 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { BaseBuilder } from './base-builder.js'; import { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js'; @@ -33,12 +33,26 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { // Generate unified manifest const workflowBundlePath = join(workflowGeneratedDir, 'flow.func/index.js'); - await this.createManifest({ + const manifestJson = await this.createManifest({ workflowBundlePath, manifestDir: workflowGeneratedDir, manifest, }); + // Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1. + // Vercel Build Output API serves static files from .vercel/output/static/ + if (this.shouldExposePublicManifest && manifestJson) { + const staticManifestDir = join( + outputDir, + 'static/.well-known/workflow/v1' + ); + await mkdir(staticManifestDir, { recursive: true }); + await copyFile( + join(workflowGeneratedDir, 'manifest.json'), + join(staticManifestDir, 'manifest.json') + ); + } + await this.createClientLibrary(); } diff --git a/packages/nest/src/workflow.controller.ts b/packages/nest/src/workflow.controller.ts index 04f8767364..d27c220be4 100644 --- a/packages/nest/src/workflow.controller.ts +++ b/packages/nest/src/workflow.controller.ts @@ -1,4 +1,5 @@ -import { All, Controller, Post, Req, Res } from '@nestjs/common'; +import { readFileSync } from 'node:fs'; +import { All, Controller, Get, Post, Req, Res } from '@nestjs/common'; import { join } from 'pathe'; // Module-level state for configuration @@ -110,4 +111,32 @@ export class WorkflowController { const webResponse = await POST(webRequest); await sendWebResponse(res, webResponse); } + + @Get('manifest.json') + async handleManifest(@Res() res: any) { + if (process.env.WORKFLOW_PUBLIC_MANIFEST !== '1') { + if (typeof res.code === 'function') { + res.code(404).send(''); + } else { + res.status(404).end(''); + } + return; + } + const outDir = getOutDir(); + let manifest: string; + try { + manifest = readFileSync(join(outDir, 'manifest.json'), 'utf-8'); + } catch { + if (typeof res.code === 'function') { + res.code(404).send(''); + } else { + res.status(404).end(''); + } + return; + } + const webResponse = new Response(manifest, { + headers: { 'content-type': 'application/json' }, + }); + await sendWebResponse(res, webResponse); + } } diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts index 72714e14cc..02484c9995 100644 --- a/packages/next/src/builder.ts +++ b/packages/next/src/builder.ts @@ -1,5 +1,5 @@ import { constants } from 'node:fs'; -import { access, mkdir, stat, writeFile } from 'node:fs/promises'; +import { access, copyFile, mkdir, stat, writeFile } from 'node:fs/promises'; import { extname, join, resolve } from 'node:path'; import Watchpack from 'watchpack'; @@ -62,12 +62,26 @@ export async function getNextBuilder() { // Write unified manifest to workflow generated directory const workflowBundlePath = join(workflowGeneratedDir, 'flow/route.js'); - await this.createManifest({ + const manifestJson = await this.createManifest({ workflowBundlePath, manifestDir: workflowGeneratedDir, manifest, }); + // Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1. + // Next.js serves files from public/ at the root URL. + if (this.shouldExposePublicManifest && manifestJson) { + const publicManifestDir = join( + this.config.workingDir, + 'public/.well-known/workflow/v1' + ); + await mkdir(publicManifestDir, { recursive: true }); + await copyFile( + join(workflowGeneratedDir, 'manifest.json'), + join(publicManifestDir, 'manifest.json') + ); + } + await this.writeFunctionsConfig(outputDir); if (this.config.watch) { diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index 972afd9e28..baf8441937 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -95,6 +95,11 @@ export default { '/.well-known/workflow/v1/flow', 'workflow/workflows.mjs' ); + + // Expose manifest as a public HTTP route when WORKFLOW_PUBLIC_MANIFEST=1 + if (process.env.WORKFLOW_PUBLIC_MANIFEST === '1') { + addManifestHandler(nitro); + } } }, } satisfies NitroModule; @@ -127,3 +132,45 @@ function addVirtualHandler(nitro: Nitro, route: string, buildPath: string) { `; } } + +function addManifestHandler(nitro: Nitro) { + const route = '/.well-known/workflow/v1/manifest.json'; + const virtualId = '#workflow/manifest-handler'; + const manifestPath = join(nitro.options.buildDir, 'workflow/manifest.json'); + + nitro.options.handlers.push({ route, handler: virtualId }); + + if (!nitro.routing) { + // Nitro v2 (legacy) + nitro.options.virtual[virtualId] = /* js */ ` + import { fromWebHandler } from "h3"; + import { readFileSync } from "node:fs"; + function GET() { + try { + const manifest = readFileSync(${JSON.stringify(manifestPath)}, "utf-8"); + return new Response(manifest, { + headers: { "content-type": "application/json" }, + }); + } catch { + return new Response("Manifest not found", { status: 404 }); + } + } + export default fromWebHandler(GET); + `; + } else { + // Nitro v3+ + nitro.options.virtual[virtualId] = /* js */ ` + import { readFileSync } from "node:fs"; + export default async () => { + try { + const manifest = readFileSync(${JSON.stringify(manifestPath)}, "utf-8"); + return new Response(manifest, { + headers: { "content-type": "application/json" }, + }); + } catch { + return new Response("Manifest not found", { status: 404 }); + } + }; + `; + } +} diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts index 0a5650ed15..3a0a988af6 100644 --- a/packages/sveltekit/src/builder.ts +++ b/packages/sveltekit/src/builder.ts @@ -1,5 +1,12 @@ import { constants } from 'node:fs'; -import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { + access, + copyFile, + mkdir, + readFile, + stat, + writeFile, +} from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { BaseBuilder, @@ -67,11 +74,25 @@ export class SvelteKitBuilder extends BaseBuilder { // Generate unified manifest const workflowBundlePath = join(workflowGeneratedDir, 'flow/+server.js'); - await this.createManifest({ + const manifestJson = await this.createManifest({ workflowBundlePath, manifestDir: workflowGeneratedDir, manifest, }); + + // Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1. + // SvelteKit serves files from static/ at the root URL. + if (this.shouldExposePublicManifest && manifestJson) { + const staticManifestDir = join( + this.config.workingDir, + 'static/.well-known/workflow/v1' + ); + await mkdir(staticManifestDir, { recursive: true }); + await copyFile( + join(workflowGeneratedDir, 'manifest.json'), + join(staticManifestDir, 'manifest.json') + ); + } } private async buildStepsRoute({ diff --git a/workbench/example/package.json b/workbench/example/package.json index 6d33265239..80b5515487 100644 --- a/workbench/example/package.json +++ b/workbench/example/package.json @@ -7,7 +7,7 @@ "scripts": { "workflow": "workflow", "wf": "wf", - "build": "workflow build --target vercel-build-output-api --workflow-manifest manifest.js && rm -rf .vercel/output/static && cp -rv public .vercel/output/static" + "build": "workflow build --target vercel-build-output-api --workflow-manifest manifest.js" }, "devDependencies": { "@workflow/world-postgres": "workspace:*", diff --git a/workbench/example/public/index.html b/workbench/example/public/index.html deleted file mode 100644 index 0bf033fdd8..0000000000 --- a/workbench/example/public/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - -

Workflow DevKit Example

- - - - From 97e1d183400d85d8bbd860d4dff4c07f827f68e3 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Fri, 6 Feb 2026 10:46:34 -0800 Subject: [PATCH 2/2] changeset --- .changeset/social-suits-pick.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/social-suits-pick.md diff --git a/.changeset/social-suits-pick.md b/.changeset/social-suits-pick.md new file mode 100644 index 0000000000..ac3a5acdcd --- /dev/null +++ b/.changeset/social-suits-pick.md @@ -0,0 +1,10 @@ +--- +"@workflow/sveltekit": patch +"@workflow/builders": patch +"@workflow/astro": patch +"@workflow/nitro": patch +"@workflow/nest": patch +"@workflow/next": patch +--- + +Expose workflow manifest via HTTP when `WORKFLOW_PUBLIC_MANIFEST=1`