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
10 changes: 10 additions & 0 deletions .changeset/social-suits-pick.md
Original file line number Diff line number Diff line change
@@ -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`
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

45 changes: 41 additions & 4 deletions packages/astro/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -97,6 +130,8 @@ export const POST = async ({request}) => {
export const prerender = false;`
);
await writeFile(stepsRouteFile, stepsRouteContent);

return manifest;
}

private async buildWorkflowsRoute({
Expand All @@ -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,
Expand All @@ -132,6 +167,8 @@ export const POST = async ({request}) => {
export const prerender = false;`
);
await writeFile(workflowsRouteFile, workflowsRouteContent);

return manifest;
}

private async buildWebhookRoute({
Expand Down
21 changes: 16 additions & 5 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1039,7 +1049,7 @@ export const OPTIONS = handler;`;
workflowBundlePath: string;
manifestDir: string;
manifest: WorkflowManifest;
}): Promise<void> {
}): Promise<string | undefined> {
const buildStart = Date.now();
console.log('Creating manifest...');

Expand All @@ -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,
Expand All @@ -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;
}
}

Expand Down
18 changes: 16 additions & 2 deletions packages/builders/src/vercel-build-output-api.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
}

Expand Down
31 changes: 30 additions & 1 deletion packages/nest/src/workflow.controller.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}
}
18 changes: 16 additions & 2 deletions packages/next/src/builder.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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'
);
Comment thread
TooTallNate marked this conversation as resolved.
await mkdir(publicManifestDir, { recursive: true });
await copyFile(
join(workflowGeneratedDir, 'manifest.json'),
join(publicManifestDir, 'manifest.json')
);
Copy link
Copy Markdown
Member

@ijjk ijjk Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of placing this in a static folder which won't get cleaned up if this is added for one run but not on a successive run and then could get deployed it feels more correct for the flow.js endpoint to handle serving this or not.

That would reduce additional endpoint being exposed as well, similar to what we did for the health check

}

await this.writeFunctionsConfig(outputDir);

if (this.config.watch) {
Expand Down
47 changes: 47 additions & 0 deletions packages/nitro/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Comment thread
TooTallNate marked this conversation as resolved.
`;
} 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 });
}
};
Comment thread
TooTallNate marked this conversation as resolved.
`;
}
}
25 changes: 23 additions & 2 deletions packages/sveltekit/src/builder.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Comment thread
TooTallNate marked this conversation as resolved.
});

// 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({
Expand Down
2 changes: 1 addition & 1 deletion workbench/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
16 changes: 0 additions & 16 deletions workbench/example/public/index.html

This file was deleted.

Loading