From c1c99b776800995c024572e393ae714025c9aca0 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Wed, 17 Dec 2025 17:03:07 -0800 Subject: [PATCH 01/14] add nextjs pages router workbench --- .changeset/every-ants-bet.md | 5 + packages/builders/src/base-builder.ts | 42 +- packages/next/package.json | 3 +- packages/next/src/builder.ts | 403 ++++++++++++++++-- packages/next/src/pages-adapter.ts | 120 ++++++ pnpm-lock.yaml | 332 ++++++++++++++- workbench/nextjs-pages-router/.gitignore | 46 ++ workbench/nextjs-pages-router/README.md | 36 ++ workbench/nextjs-pages-router/next.config.ts | 8 + workbench/nextjs-pages-router/package.json | 48 +++ workbench/nextjs-pages-router/pages/_app.tsx | 5 + .../nextjs-pages-router/pages/_document.tsx | 13 + workbench/nextjs-pages-router/pages/index.tsx | 3 + .../nextjs-pages-router/postcss.config.mjs | 7 + workbench/nextjs-pages-router/public/file.svg | 1 + .../nextjs-pages-router/public/globe.svg | 1 + workbench/nextjs-pages-router/public/next.svg | 1 + .../nextjs-pages-router/public/vercel.svg | 1 + .../nextjs-pages-router/public/window.svg | 1 + workbench/nextjs-pages-router/tsconfig.json | 34 ++ workbench/nextjs-pages-router/workflows | 1 + 21 files changed, 1048 insertions(+), 63 deletions(-) create mode 100644 .changeset/every-ants-bet.md create mode 100644 packages/next/src/pages-adapter.ts create mode 100644 workbench/nextjs-pages-router/.gitignore create mode 100644 workbench/nextjs-pages-router/README.md create mode 100644 workbench/nextjs-pages-router/next.config.ts create mode 100644 workbench/nextjs-pages-router/package.json create mode 100644 workbench/nextjs-pages-router/pages/_app.tsx create mode 100644 workbench/nextjs-pages-router/pages/_document.tsx create mode 100644 workbench/nextjs-pages-router/pages/index.tsx create mode 100644 workbench/nextjs-pages-router/postcss.config.mjs create mode 100644 workbench/nextjs-pages-router/public/file.svg create mode 100644 workbench/nextjs-pages-router/public/globe.svg create mode 100644 workbench/nextjs-pages-router/public/next.svg create mode 100644 workbench/nextjs-pages-router/public/vercel.svg create mode 100644 workbench/nextjs-pages-router/public/window.svg create mode 100644 workbench/nextjs-pages-router/tsconfig.json create mode 120000 workbench/nextjs-pages-router/workflows diff --git a/.changeset/every-ants-bet.md b/.changeset/every-ants-bet.md new file mode 100644 index 000000000..0e61faab2 --- /dev/null +++ b/.changeset/every-ants-bet.md @@ -0,0 +1,5 @@ +--- +"@workflow/next": patch +--- + +Add Pages Router support for Next.js integration. The integration now auto-detects which router(s) the project uses and generates appropriate workflow routes for both App Router and Pages Router. diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 984fa3f1c..59b63103d 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -789,7 +789,7 @@ export const OPTIONS = handler;`; absWorkingDir: this.config.workingDir, bundle: true, jsx: 'preserve', - format: 'cjs', + format: 'esm', platform: 'node', conditions: ['import', 'module', 'node', 'default'], target: 'es2022', @@ -809,8 +809,44 @@ export const OPTIONS = handler;`; ], sourcemap: false, mainFields: ['module', 'main'], - // Don't externalize anything - bundle everything including workflow packages - external: [], + // Externalize Node.js built-ins with node: prefix so bundlers + // (webpack/turbopack) know they're server-only and won't try + // to bundle them for the browser + external: [ + 'node:fs', + 'node:path', + 'node:os', + 'node:crypto', + 'node:stream', + 'node:buffer', + 'node:util', + 'node:events', + 'node:http', + 'node:https', + 'node:url', + 'node:querystring', + 'node:zlib', + 'node:async_hooks', + 'node:module', + ], + // Rewrite bare Node.js imports to use node: prefix + alias: { + fs: 'node:fs', + path: 'node:path', + os: 'node:os', + crypto: 'node:crypto', + stream: 'node:stream', + buffer: 'node:buffer', + util: 'node:util', + events: 'node:events', + http: 'node:http', + https: 'node:https', + url: 'node:url', + querystring: 'node:querystring', + zlib: 'node:zlib', + async_hooks: 'node:async_hooks', + module: 'node:module', + }, }); this.logEsbuildMessages(result, 'webhook bundle creation'); diff --git a/packages/next/package.json b/packages/next/package.json index 519fa794c..93625357d 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -19,7 +19,8 @@ "exports": { ".": "./dist/index.js", "./loader": "./dist/loader.js", - "./runtime": "./dist/runtime.js" + "./runtime": "./dist/runtime.js", + "./pages-adapter": "./dist/pages-adapter.js" }, "scripts": { "build": "tsc", diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts index 660c0bfc8..5ffccadd1 100644 --- a/packages/next/src/builder.ts +++ b/packages/next/src/builder.ts @@ -3,6 +3,17 @@ import { access, mkdir, stat, writeFile } from 'node:fs/promises'; import { extname, join, resolve } from 'node:path'; import Watchpack from 'watchpack'; +/** + * Router detection result indicating which Next.js routers are available + * in the project and their directory locations. + */ +interface RouterConfig { + hasAppRouter: boolean; + appRouterDir: string | null; + hasPagesRouter: boolean; + pagesRouterDir: string | null; +} + let CachedNextBuilder: any; // Create the NextBuilder class dynamically by extending the ESM BaseBuilder @@ -24,31 +35,54 @@ export async function getNextBuilder() { class NextBuilder extends BaseBuilderClass { async build() { - const outputDir = await this.findAppDirectory(); - const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v1'); - - // Ensure output directories exist - await mkdir(workflowGeneratedDir, { recursive: true }); - // ignore the generated assets + const routers = await this.detectRouters(); - await writeFile(join(workflowGeneratedDir, '.gitignore'), '*'); + if (!routers.hasAppRouter && !routers.hasPagesRouter) { + throw new Error( + 'Could not find Next.js router. Expected either "app", "src/app", "pages", or "src/pages" to exist.' + ); + } const inputFiles = await this.getInputFiles(); const tsConfig = await this.getTsConfigOptions(); - const options = { - inputFiles, - workflowGeneratedDir, - tsBaseUrl: tsConfig.baseUrl, - tsPaths: tsConfig.paths, - }; + // Build for App Router if present + let appRouterBuildResult: + | { + stepsBuildContext: import('esbuild').BuildContext | undefined; + workflowsBundle: + | void + | { + interimBundleCtx: import('esbuild').BuildContext; + bundleFinal: (interimBundleResult: string) => Promise; + } + | undefined; + workflowGeneratedDir: string; + } + | undefined; + + if (routers.hasAppRouter && routers.appRouterDir) { + appRouterBuildResult = await this.buildForAppRouter( + routers.appRouterDir, + inputFiles, + tsConfig + ); + } - const stepsBuildContext = await this.buildStepsFunction(options); - const workflowsBundle = await this.buildWorkflowsFunction(options); - await this.buildWebhookRoute({ workflowGeneratedDir }); - await this.writeFunctionsConfig(outputDir); + // Build for Pages Router if present + if (routers.hasPagesRouter && routers.pagesRouterDir) { + await this.buildForPagesRouter( + routers.pagesRouterDir, + inputFiles, + tsConfig + ); + } - if (this.config.watch) { + // Watch mode only supported for App Router currently + // (Pages Router generates static files that don't need rebuild context) + if (this.config.watch && appRouterBuildResult) { + const { stepsBuildContext, workflowsBundle, workflowGeneratedDir } = + appRouterBuildResult; if (!stepsBuildContext) { throw new Error( 'Invariant: expected steps build context in watch mode' @@ -61,6 +95,14 @@ export async function getNextBuilder() { let stepsCtx = stepsBuildContext; let workflowsCtx = workflowsBundle; + // Options object for rebuild functions + const options = { + inputFiles, + workflowGeneratedDir, + tsBaseUrl: tsConfig.baseUrl, + tsPaths: tsConfig.paths, + }; + const normalizePath = (pathname: string) => pathname.replace(/\\/g, '/'); const knownFiles = new Set(); @@ -340,11 +382,12 @@ export async function getNextBuilder() { protected async getInputFiles(): Promise { const inputFiles = await super.getInputFiles(); - return inputFiles.filter((item) => - // non-exact pattern match to try to narrow - // down to just app route entrypoints, this will - // not be valid when pages router support is added - item.match(/[/\\](route|page|layout)\./) + return inputFiles.filter( + (item) => + // App Router: route.ts, page.ts, layout.ts + item.match(/[/\\](route|page|layout)\./) || + // Pages Router: any file in pages/ + item.match(/[/\\]pages[/\\]/) ); } @@ -441,31 +484,305 @@ export async function getNextBuilder() { }); } - private async findAppDirectory(): Promise { - const appDir = resolve(this.config.workingDir, 'app'); - const srcAppDir = resolve(this.config.workingDir, 'src/app'); + /** + * Builds workflow routes for App Router. + * Generates routes in app/.well-known/workflow/v1/ + */ + private async buildForAppRouter( + appDir: string, + inputFiles: string[], + tsConfig: { baseUrl?: string; paths?: Record } + ): Promise<{ + stepsBuildContext: import('esbuild').BuildContext | undefined; + workflowsBundle: + | void + | { + interimBundleCtx: import('esbuild').BuildContext; + bundleFinal: (interimBundleResult: string) => Promise; + } + | undefined; + workflowGeneratedDir: string; + }> { + const workflowGeneratedDir = join(appDir, '.well-known/workflow/v1'); + + // Ensure output directories exist + await mkdir(workflowGeneratedDir, { recursive: true }); + await writeFile(join(workflowGeneratedDir, '.gitignore'), '*'); + + const options = { + inputFiles, + workflowGeneratedDir, + tsBaseUrl: tsConfig.baseUrl, + tsPaths: tsConfig.paths, + }; + const stepsBuildContext = await this.buildStepsFunction(options); + const workflowsBundle = await this.buildWorkflowsFunction(options); + await this.buildWebhookRoute({ workflowGeneratedDir }); + await this.writeFunctionsConfig(appDir); + + return { + stepsBuildContext, + workflowsBundle, + workflowGeneratedDir, + }; + } + + /** + * Builds workflow routes for Pages Router. + * Generates routes in pages/.well-known/workflow/v1/ + */ + private async buildForPagesRouter( + pagesDir: string, + inputFiles: string[], + tsConfig: { baseUrl?: string; paths?: Record } + ): Promise { + const workflowGeneratedDir = join(pagesDir, '.well-known/workflow/v1'); + + // Ensure output directories exist + await mkdir(workflowGeneratedDir, { recursive: true }); + await writeFile(join(workflowGeneratedDir, '.gitignore'), '*'); + + // Build steps route for Pages Router + await this.buildStepsFunctionPages({ + inputFiles, + workflowGeneratedDir, + tsBaseUrl: tsConfig.baseUrl, + tsPaths: tsConfig.paths, + }); + + // Build workflows route for Pages Router + await this.buildWorkflowsFunctionPages({ + inputFiles, + workflowGeneratedDir, + tsBaseUrl: tsConfig.baseUrl, + tsPaths: tsConfig.paths, + }); + + // Build webhook route for Pages Router + await this.buildWebhookRoutePages({ workflowGeneratedDir }); + + // Write config.json + await this.writeFunctionsConfig(pagesDir); + } + + /** + * Builds the steps function route for Pages Router. + * Generates pages/api/.well-known/workflow/v1/step.js + */ + private async buildStepsFunctionPages({ + inputFiles, + workflowGeneratedDir, + tsPaths, + tsBaseUrl, + }: { + inputFiles: string[]; + workflowGeneratedDir: string; + tsBaseUrl?: string; + tsPaths?: Record; + }): Promise { + // Create steps bundle with Pages Router wrapper + const stepsRouteFile = join(workflowGeneratedDir, 'step.js'); + + // First, build the steps bundle to a temporary location + const tempStepsFile = join(workflowGeneratedDir, '_temp_steps.js'); + await this.createStepsBundle({ + format: 'esm', + inputFiles, + outfile: tempStepsFile, + externalizeNonSteps: true, + tsBaseUrl, + tsPaths, + }); + + // Read the generated bundle + const { readFile: readFileFs } = await import('node:fs/promises'); + const stepsBundle = await readFileFs(tempStepsFile, 'utf-8'); + + // Extract the POST handler and wrap it for Pages Router + // The generated bundle exports `POST` which is the stepEntrypoint + const pagesRouterWrapper = `// biome-ignore-all lint: generated file +/* eslint-disable */ +import { convertPagesRequest, sendPagesResponse } from '@workflow/next/pages-adapter'; + +${stepsBundle.replace('export { stepEntrypoint as POST }', 'const POST = stepEntrypoint;')} + +export default async function handler(req, res) { + const webRequest = await convertPagesRequest(req); + const webResponse = await POST(webRequest); + await sendPagesResponse(res, webResponse); +} + +export const config = { + api: { + bodyParser: false, + }, +}; +`; + + await writeFile(stepsRouteFile, pagesRouterWrapper); + + // Clean up temp file + const { unlink } = await import('node:fs/promises'); + await unlink(tempStepsFile).catch(() => {}); + } + + /** + * Builds the workflows function route for Pages Router. + * Generates pages/api/.well-known/workflow/v1/flow.js + */ + private async buildWorkflowsFunctionPages({ + inputFiles, + workflowGeneratedDir, + tsPaths, + tsBaseUrl, + }: { + inputFiles: string[]; + workflowGeneratedDir: string; + tsBaseUrl?: string; + tsPaths?: Record; + }): Promise { + // Create workflows bundle with Pages Router wrapper + const workflowsRouteFile = join(workflowGeneratedDir, 'flow.js'); + + // First, build the workflows bundle to a temporary location + const tempWorkflowsFile = join(workflowGeneratedDir, '_temp_flow.js'); + await this.createWorkflowsBundle({ + format: 'esm', + outfile: tempWorkflowsFile, + bundleFinalOutput: false, + inputFiles, + tsBaseUrl, + tsPaths, + }); + + // Read the generated bundle + const { readFile: readFileFs } = await import('node:fs/promises'); + const workflowsBundle = await readFileFs(tempWorkflowsFile, 'utf-8'); + + // Wrap for Pages Router + const pagesRouterWrapper = `// biome-ignore-all lint: generated file +/* eslint-disable */ +import { convertPagesRequest, sendPagesResponse } from '@workflow/next/pages-adapter'; + +${workflowsBundle.replace('export const POST =', 'const POST =')} + +export default async function handler(req, res) { + const webRequest = await convertPagesRequest(req); + const webResponse = await POST(webRequest); + await sendPagesResponse(res, webResponse); +} + +export const config = { + api: { + bodyParser: false, + }, +}; +`; + + await writeFile(workflowsRouteFile, pagesRouterWrapper); + + // Clean up temp file + const { unlink } = await import('node:fs/promises'); + await unlink(tempWorkflowsFile).catch(() => {}); + } + + /** + * Builds the webhook route for Pages Router. + * Generates pages/api/.well-known/workflow/v1/webhook/[token].js + */ + private async buildWebhookRoutePages({ + workflowGeneratedDir, + }: { + workflowGeneratedDir: string; + }): Promise { + const webhookDir = join(workflowGeneratedDir, 'webhook'); + await mkdir(webhookDir, { recursive: true }); + + const webhookRouteFile = join(webhookDir, '[token].js'); + + // Create Pages Router webhook handler + const routeContent = `// biome-ignore-all lint: generated file +/* eslint-disable */ +import { resumeWebhook } from 'workflow/api'; +import { convertPagesRequest, sendPagesResponse } from '@workflow/next/pages-adapter'; + +export default async function handler(req, res) { + const webRequest = await convertPagesRequest(req); + const { token } = req.query; + + if (!token || typeof token !== 'string') { + res.status(400).send('Missing token'); + return; + } + + try { + const response = await resumeWebhook(decodeURIComponent(token), webRequest); + await sendPagesResponse(res, response); + } catch (error) { + console.error('Error during resumeWebhook', error); + res.status(404).end(); + } +} + +export const config = { + api: { + bodyParser: false, + }, +}; +`; + + await writeFile(webhookRouteFile, routeContent); + } + + /** + * Helper to check if a directory exists. + */ + private async directoryExists(dirPath: string): Promise { try { - await access(appDir, constants.F_OK); - const appStats = await stat(appDir); - if (!appStats.isDirectory()) { - throw new Error(`Path exists but is not a directory: ${appDir}`); - } - return appDir; + await access(dirPath, constants.F_OK); + const stats = await stat(dirPath); + return stats.isDirectory(); } catch { - try { - await access(srcAppDir, constants.F_OK); - const srcAppStats = await stat(srcAppDir); - if (!srcAppStats.isDirectory()) { - throw new Error(`Path exists but is not a directory: ${srcAppDir}`); - } - return srcAppDir; - } catch { - throw new Error( - 'Could not find Next.js app directory. Expected either "app" or "src/app" to exist.' - ); + return false; + } + } + + /** + * Detects which Next.js routers are available in the project. + * Checks for both App Router (app/, src/app/) and Pages Router (pages/, src/pages/). + */ + private async detectRouters(): Promise { + const possibleAppDirs = ['app', 'src/app']; + const possiblePagesDirs = ['pages', 'src/pages']; + + let appRouterDir: string | null = null; + let pagesRouterDir: string | null = null; + + // Check for App Router + for (const dir of possibleAppDirs) { + const fullPath = resolve(this.config.workingDir, dir); + if (await this.directoryExists(fullPath)) { + appRouterDir = fullPath; + break; } } + + // Check for Pages Router + for (const dir of possiblePagesDirs) { + const fullPath = resolve(this.config.workingDir, dir); + if (await this.directoryExists(fullPath)) { + pagesRouterDir = fullPath; + break; + } + } + + return { + hasAppRouter: appRouterDir !== null, + appRouterDir, + hasPagesRouter: pagesRouterDir !== null, + pagesRouterDir, + }; } } diff --git a/packages/next/src/pages-adapter.ts b/packages/next/src/pages-adapter.ts new file mode 100644 index 000000000..37cec806b --- /dev/null +++ b/packages/next/src/pages-adapter.ts @@ -0,0 +1,120 @@ +import type { IncomingMessage } from 'node:http'; + +/** + * Next.js Pages Router request type. + * Uses Node.js IncomingMessage with additional Next.js properties. + */ +interface NextApiRequest extends IncomingMessage { + query: Record; + body?: unknown; +} + +/** + * Next.js Pages Router response type. + * We define only the methods we use rather than extending ServerResponse + * to avoid type conflicts with the base class's method signatures. + */ +interface NextApiResponse { + statusCode: number; + status(statusCode: number): NextApiResponse; + send(body: unknown): void; + json(body: unknown): void; + setHeader(name: string, value: string | number | readonly string[]): void; + write(chunk: unknown): boolean; + end(body?: unknown): void; +} + +/** + * Converts a Pages Router NextApiRequest to a Web API Request. + * This allows the workflow runtime (which uses Web APIs) to handle + * requests from Pages Router endpoints. + */ +export async function convertPagesRequest( + req: NextApiRequest +): Promise { + const protocol = req.headers['x-forwarded-proto'] || 'http'; + const host = req.headers.host || 'localhost:3000'; + const url = new URL(req.url || '/', `${protocol}://${host}`); + + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value) { + if (Array.isArray(value)) { + for (const v of value) { + headers.append(key, v); + } + } else { + headers.set(key, value); + } + } + } + + const init: RequestInit = { + method: req.method || 'GET', + headers, + }; + + // Only include body for methods that support it + if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method || 'GET')) { + // Handle body - it may already be parsed by Next.js body parser + if (req.body !== undefined && req.body !== null) { + if (typeof req.body === 'string') { + init.body = req.body; + } else if (Buffer.isBuffer(req.body)) { + init.body = req.body; + } else { + // Object body - serialize to JSON + init.body = JSON.stringify(req.body); + // Ensure content-type is set for JSON + if (!headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + } + } + } + + return new Request(url.toString(), init); +} + +/** + * Sends a Web API Response through a Pages Router NextApiResponse. + * Handles streaming responses by reading the body incrementally. + */ +export async function sendPagesResponse( + res: NextApiResponse, + webResponse: Response +): Promise { + // Set status code + res.statusCode = webResponse.status; + + // Copy headers from Web Response to Node.js response + webResponse.headers.forEach((value, key) => { + // Skip headers that Node.js/Next.js handles automatically + // or that could cause issues with the response + const lowerKey = key.toLowerCase(); + if ( + lowerKey !== 'content-encoding' && + lowerKey !== 'transfer-encoding' && + lowerKey !== 'connection' + ) { + res.setHeader(key, value); + } + }); + + // Send body + if (webResponse.body) { + const reader = webResponse.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + } finally { + reader.releaseLock(); + } + res.end(); + } else { + res.end(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0623f7ac1..8edf04a1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -637,7 +637,7 @@ importers: version: link:../tsconfig next: specifier: 16.0.10 - version: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) packages/nitro: dependencies: @@ -1236,7 +1236,7 @@ importers: version: 9.5.0(astro@5.16.3(@netlify/blobs@9.1.2)(@types/node@24.6.2)(@vercel/blob@2.0.0)(@vercel/functions@3.1.4(@aws-sdk/credential-provider-web-identity@3.844.0))(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)))(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)) '@astrojs/vercel': specifier: ^9.0.0 - version: 9.0.2(@aws-sdk/credential-provider-web-identity@3.844.0)(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(astro@5.16.3(@netlify/blobs@9.1.2)(@types/node@24.6.2)(@vercel/blob@2.0.0)(@vercel/functions@3.1.4(@aws-sdk/credential-provider-web-identity@3.844.0))(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)))(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + version: 9.0.2(@aws-sdk/credential-provider-web-identity@3.844.0)(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(astro@5.16.3(@netlify/blobs@9.1.2)(@types/node@24.6.2)(@vercel/blob@2.0.0)(@vercel/functions@3.1.4(@aws-sdk/credential-provider-web-identity@3.844.0))(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)))(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(rollup@4.53.2)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) '@workflow/world-postgres': specifier: workspace:* version: link:../../packages/world-postgres @@ -1394,6 +1394,94 @@ importers: specifier: 'catalog:' version: 4.1.11 + workbench/nextjs-pages-router: + dependencies: + '@ai-sdk/react': + specifier: 2.0.76 + version: 2.0.76(react@19.2.1)(zod@4.1.11) + '@node-rs/xxhash': + specifier: 1.7.6 + version: 1.7.6 + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@radix-ui/react-tooltip': + specifier: 1.2.8 + version: 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@vercel/otel': + specifier: ^1.13.0 + version: 1.13.0(@opentelemetry/api-logs@0.57.2)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)) + '@workflow/ai': + specifier: workspace:* + version: link:../../packages/ai + ai: + specifier: 'catalog:' + version: 5.0.104(zod@4.1.11) + class-variance-authority: + specifier: 0.7.1 + version: 0.7.1 + clsx: + specifier: 2.1.1 + version: 2.1.1 + lodash.chunk: + specifier: ^4.2.0 + version: 4.2.0 + lucide-react: + specifier: 0.555.0 + version: 0.555.0(react@19.2.1) + mixpart: + specifier: 0.0.4 + version: 0.0.4 + next: + specifier: 16.0.10 + version: 16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + openai: + specifier: 6.9.1 + version: 6.9.1(ws@8.18.3)(zod@4.1.11) + react: + specifier: ^19.0.0 + version: 19.2.1 + react-dom: + specifier: ^19.0.0 + version: 19.2.1(react@19.2.1) + tailwind-merge: + specifier: 3.4.0 + version: 3.4.0 + workflow: + specifier: workspace:* + version: link:../../packages/workflow + zod: + specifier: 'catalog:' + version: 4.1.11 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4 + version: 4.1.13 + '@types/lodash.chunk': + specifier: ^4.2.9 + version: 4.2.9 + '@types/node': + specifier: 'catalog:' + version: 22.19.0 + '@types/react': + specifier: ^19 + version: 19.1.13 + '@types/react-dom': + specifier: ^19 + version: 19.1.9(@types/react@19.1.13) + '@vercel/blob': + specifier: 2.0.0 + version: 2.0.0 + '@workflow/world-postgres': + specifier: workspace:* + version: link:../../packages/world-postgres + tailwindcss: + specifier: ^4 + version: 4.1.13 + typescript: + specifier: 'catalog:' + version: 5.9.3 + workbench/nextjs-turbopack: dependencies: '@ai-sdk/react': @@ -1697,7 +1785,7 @@ importers: dependencies: '@ai-sdk/react': specifier: 2.0.76 - version: 2.0.76(react@19.2.0)(zod@4.1.11) + version: 2.0.76(react@19.2.1)(zod@4.1.11) '@node-rs/xxhash': specifier: 1.7.6 version: 1.7.6 @@ -11362,6 +11450,11 @@ packages: peerDependencies: react: ^19.2.0 + react-dom@19.2.1: + resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} + peerDependencies: + react: ^19.2.1 + react-hook-form@7.65.0: resolution: {integrity: sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==} engines: {node: '>=18.0.0'} @@ -11463,6 +11556,10 @@ packages: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} + react@19.2.1: + resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} + engines: {node: '>=0.10.0'} + read-package-up@11.0.0: resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} engines: {node: '>=18'} @@ -13506,12 +13603,12 @@ snapshots: optionalDependencies: zod: 4.1.11 - '@ai-sdk/react@2.0.76(react@19.2.0)(zod@4.1.11)': + '@ai-sdk/react@2.0.76(react@19.2.1)(zod@4.1.11)': dependencies: '@ai-sdk/provider-utils': 3.0.12(zod@4.1.11) ai: 5.0.76(zod@4.1.11) - react: 19.2.0 - swr: 2.3.6(react@19.2.0) + react: 19.2.1 + swr: 2.3.6(react@19.2.1) throttleit: 2.1.0 optionalDependencies: zod: 4.1.11 @@ -13594,10 +13691,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/vercel@9.0.2(@aws-sdk/credential-provider-web-identity@3.844.0)(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(astro@5.16.3(@netlify/blobs@9.1.2)(@types/node@24.6.2)(@vercel/blob@2.0.0)(@vercel/functions@3.1.4(@aws-sdk/credential-provider-web-identity@3.844.0))(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)))(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': + '@astrojs/vercel@9.0.2(@aws-sdk/credential-provider-web-identity@3.844.0)(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(astro@5.16.3(@netlify/blobs@9.1.2)(@types/node@24.6.2)(@vercel/blob@2.0.0)(@vercel/functions@3.1.4(@aws-sdk/credential-provider-web-identity@3.844.0))(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)))(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(rollup@4.53.2)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': dependencies: '@astrojs/internal-helpers': 0.7.5 - '@vercel/analytics': 1.5.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + '@vercel/analytics': 1.5.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) '@vercel/functions': 2.2.13(@aws-sdk/credential-provider-web-identity@3.844.0) '@vercel/nft': 0.30.4(rollup@4.53.2) '@vercel/routing-utils': 5.3.0 @@ -14769,6 +14866,12 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + '@floating-ui/react-dom@2.1.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + '@floating-ui/utils@0.2.10': {} '@formatjs/intl-localematcher@0.6.2': @@ -16542,6 +16645,15 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -16656,6 +16768,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.13)(react@19.2.1)': + dependencies: + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -16694,6 +16812,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-context@1.1.2(@types/react@19.1.13)(react@19.2.1)': + dependencies: + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -16817,6 +16941,19 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.13)(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-dismissable-layer@1.1.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -16994,6 +17131,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-id@1.1.1(@types/react@19.1.13)(react@19.2.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.13)(react@19.2.1) + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -17235,6 +17379,24 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/rect': 1.1.1 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-portal@1.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.0.1(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -17285,6 +17447,16 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.13)(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-presence@1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.13)(react@19.1.0) @@ -17325,6 +17497,16 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.13)(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-primitive@2.0.1(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-slot': 1.1.1(@types/react@19.1.13)(react@19.1.0) @@ -17370,6 +17552,15 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.13)(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.2.0) @@ -17587,6 +17778,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-slot@1.2.3(@types/react@19.1.13)(react@19.2.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.2.1) + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-slot@1.2.4(@types/react@19.1.13)(react@19.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.2.0) @@ -17777,6 +17975,26 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.1.13)(react@19.1.0)': dependencies: react: 19.1.0 @@ -17801,6 +18019,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.13)(react@19.2.1)': + dependencies: + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-use-controllable-state@1.1.0(@types/react@19.1.13)(react@19.1.0)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.13)(react@19.1.0) @@ -17832,6 +18056,14 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.13)(react@19.2.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.13)(react@19.2.1) + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.13)(react@19.1.0)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.13)(react@19.1.0) @@ -17853,6 +18085,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.13)(react@19.2.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.13)(react@19.2.1) + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@19.1.13)(react@19.1.0)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.13)(react@19.1.0) @@ -17881,6 +18120,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.1.13)(react@19.2.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.13)(react@19.2.1) + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.1.13)(react@19.2.0)': dependencies: react: 19.2.0 @@ -17912,6 +18158,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.13)(react@19.2.1)': + dependencies: + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-use-previous@1.1.0(@types/react@19.1.13)(react@19.1.0)': dependencies: react: 19.1.0 @@ -17958,6 +18210,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.1.13)(react@19.2.1)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-use-size@1.1.0(@types/react@19.1.13)(react@19.1.0)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.1.13)(react@19.1.0) @@ -17986,6 +18245,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-use-size@1.1.1(@types/react@19.1.13)(react@19.2.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.13)(react@19.2.1) + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -18013,6 +18279,15 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/rect@1.1.0': {} '@radix-ui/rect@1.1.1': {} @@ -19208,11 +19483,11 @@ snapshots: unhead: 2.0.19 vue: 3.5.22(typescript@5.9.3) - '@vercel/analytics@1.5.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': + '@vercel/analytics@1.5.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': optionalDependencies: '@sveltejs/kit': 2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) - next: 16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - react: 19.2.0 + next: 16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + react: 19.2.1 svelte: 5.43.3 vue: 3.5.22(typescript@5.9.3) vue-router: 4.6.3(vue@3.5.22(typescript@5.9.3)) @@ -23015,6 +23290,10 @@ snapshots: dependencies: react: 19.1.1 + lucide-react@0.555.0(react@19.2.1): + dependencies: + react: 19.2.1 + luxon@3.7.2: {} magic-regexp@0.10.0: @@ -23821,15 +24100,15 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: '@next/env': 16.0.10 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001751 postcss: 8.4.31 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.2.0) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + styled-jsx: 5.1.6(react@19.2.1) optionalDependencies: '@next/swc-darwin-arm64': 16.0.10 '@next/swc-darwin-x64': 16.0.10 @@ -23844,7 +24123,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - optional: true nf3@0.1.10: {} @@ -25480,6 +25758,11 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 + react-dom@19.2.1(react@19.2.1): + dependencies: + react: 19.2.1 + scheduler: 0.27.0 + react-hook-form@7.65.0(react@19.2.0): dependencies: react: 19.2.0 @@ -25647,6 +25930,8 @@ snapshots: react@19.2.0: {} + react@19.2.1: {} + read-package-up@11.0.0: dependencies: find-up-simple: 1.0.1 @@ -26481,6 +26766,11 @@ snapshots: client-only: 0.0.1 react: 19.1.1 + styled-jsx@5.1.6(react@19.2.1): + dependencies: + client-only: 0.0.1 + react: 19.2.1 + stylehacks@7.0.6(postcss@8.5.6): dependencies: browserslist: 4.27.0 @@ -26569,6 +26859,12 @@ snapshots: react: 19.2.0 use-sync-external-store: 1.5.0(react@19.2.0) + swr@2.3.6(react@19.2.1): + dependencies: + dequal: 2.0.3 + react: 19.2.1 + use-sync-external-store: 1.5.0(react@19.2.1) + system-architecture@0.1.0: {} tagged-tag@1.0.0: {} @@ -27224,6 +27520,10 @@ snapshots: dependencies: react: 19.2.0 + use-sync-external-store@1.5.0(react@19.2.1): + dependencies: + react: 19.2.1 + util-deprecate@1.0.2: {} uuid@10.0.0: {} diff --git a/workbench/nextjs-pages-router/.gitignore b/workbench/nextjs-pages-router/.gitignore new file mode 100644 index 000000000..53e329412 --- /dev/null +++ b/workbench/nextjs-pages-router/.gitignore @@ -0,0 +1,46 @@ + +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.env*.local + +# workflow +_workflows.ts diff --git a/workbench/nextjs-pages-router/README.md b/workbench/nextjs-pages-router/README.md new file mode 100644 index 000000000..e215bc4cc --- /dev/null +++ b/workbench/nextjs-pages-router/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/workbench/nextjs-pages-router/next.config.ts b/workbench/nextjs-pages-router/next.config.ts new file mode 100644 index 000000000..65134ac35 --- /dev/null +++ b/workbench/nextjs-pages-router/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from 'next'; +import { withWorkflow } from 'workflow/next'; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default withWorkflow(nextConfig); diff --git a/workbench/nextjs-pages-router/package.json b/workbench/nextjs-pages-router/package.json new file mode 100644 index 000000000..9f361bd5b --- /dev/null +++ b/workbench/nextjs-pages-router/package.json @@ -0,0 +1,48 @@ +{ + "name": "@workflow/example-nextjs-pages-router", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "scripts": { + "generate:workflows": "node ../scripts/generate-workflows-registry.js", + "predev": "pnpm generate:workflows", + "prebuild": "pnpm generate:workflows", + "dev": "next dev --turbopack", + "build": "next build --turbopack", + "clean": "rm -rf .next .swc pages/.well-known/workflow _workflows.ts", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@ai-sdk/react": "2.0.76", + "@node-rs/xxhash": "1.7.6", + "@opentelemetry/api": "^1.9.0", + "@radix-ui/react-tooltip": "1.2.8", + "@vercel/otel": "^1.13.0", + "@workflow/ai": "workspace:*", + "ai": "catalog:", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "lodash.chunk": "^4.2.0", + "lucide-react": "0.555.0", + "mixpart": "0.0.4", + "next": "16.0.10", + "openai": "6.9.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "3.4.0", + "workflow": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/lodash.chunk": "^4.2.9", + "@types/node": "catalog:", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vercel/blob": "2.0.0", + "@workflow/world-postgres": "workspace:*", + "tailwindcss": "^4", + "typescript": "catalog:" + } +} diff --git a/workbench/nextjs-pages-router/pages/_app.tsx b/workbench/nextjs-pages-router/pages/_app.tsx new file mode 100644 index 000000000..da826ed16 --- /dev/null +++ b/workbench/nextjs-pages-router/pages/_app.tsx @@ -0,0 +1,5 @@ +import type { AppProps } from 'next/app'; + +export default function App({ Component, pageProps }: AppProps) { + return ; +} diff --git a/workbench/nextjs-pages-router/pages/_document.tsx b/workbench/nextjs-pages-router/pages/_document.tsx new file mode 100644 index 000000000..23b445ffc --- /dev/null +++ b/workbench/nextjs-pages-router/pages/_document.tsx @@ -0,0 +1,13 @@ +import { Html, Head, Main, NextScript } from 'next/document'; + +export default function Document() { + return ( + + + +
+ + + + ); +} diff --git a/workbench/nextjs-pages-router/pages/index.tsx b/workbench/nextjs-pages-router/pages/index.tsx new file mode 100644 index 000000000..e85d5092c --- /dev/null +++ b/workbench/nextjs-pages-router/pages/index.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Hello, Next.js!

; +} diff --git a/workbench/nextjs-pages-router/postcss.config.mjs b/workbench/nextjs-pages-router/postcss.config.mjs new file mode 100644 index 000000000..297374d80 --- /dev/null +++ b/workbench/nextjs-pages-router/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/workbench/nextjs-pages-router/public/file.svg b/workbench/nextjs-pages-router/public/file.svg new file mode 100644 index 000000000..004145cdd --- /dev/null +++ b/workbench/nextjs-pages-router/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/workbench/nextjs-pages-router/public/globe.svg b/workbench/nextjs-pages-router/public/globe.svg new file mode 100644 index 000000000..567f17b0d --- /dev/null +++ b/workbench/nextjs-pages-router/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/workbench/nextjs-pages-router/public/next.svg b/workbench/nextjs-pages-router/public/next.svg new file mode 100644 index 000000000..5174b28c5 --- /dev/null +++ b/workbench/nextjs-pages-router/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/workbench/nextjs-pages-router/public/vercel.svg b/workbench/nextjs-pages-router/public/vercel.svg new file mode 100644 index 000000000..770539603 --- /dev/null +++ b/workbench/nextjs-pages-router/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/workbench/nextjs-pages-router/public/window.svg b/workbench/nextjs-pages-router/public/window.svg new file mode 100644 index 000000000..b2b2a44f6 --- /dev/null +++ b/workbench/nextjs-pages-router/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/workbench/nextjs-pages-router/tsconfig.json b/workbench/nextjs-pages-router/tsconfig.json new file mode 100644 index 000000000..3a13f90a7 --- /dev/null +++ b/workbench/nextjs-pages-router/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/workbench/nextjs-pages-router/workflows b/workbench/nextjs-pages-router/workflows new file mode 120000 index 000000000..452b21e36 --- /dev/null +++ b/workbench/nextjs-pages-router/workflows @@ -0,0 +1 @@ +../example/workflows \ No newline at end of file From 25507abef2e89b9375ce63ba603b924bab2296d1 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 14:28:09 -0800 Subject: [PATCH 02/14] refactor: inline pages adapter code --- packages/next/src/builder.ts | 112 ++++++++++++++++++++++++--- packages/next/src/pages-adapter.ts | 120 ----------------------------- 2 files changed, 103 insertions(+), 129 deletions(-) delete mode 100644 packages/next/src/pages-adapter.ts diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts index 5ffccadd1..5cf076528 100644 --- a/packages/next/src/builder.ts +++ b/packages/next/src/builder.ts @@ -33,6 +33,99 @@ export async function getNextBuilder() { 'import("@workflow/builders")' )) as typeof import('@workflow/builders'); + // Inlined pages-adapter code to avoid requiring @workflow/next as a dependency + // in Pages Router projects. This code converts between Next.js Pages Router + // request/response objects and Web API Request/Response objects. + const PAGES_ADAPTER_CODE = ` +async function readRawBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +async function convertPagesRequest(req) { + const protocol = req.headers["x-forwarded-proto"] || "http"; + const host = req.headers.host || "localhost:3000"; + const url = new URL(req.url || "/", \`\${protocol}://\${host}\`); + + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value) { + if (Array.isArray(value)) { + for (const v of value) { + headers.append(key, v); + } + } else { + headers.set(key, value); + } + } + } + + const init = { + method: req.method || "GET", + headers, + }; + + if (!["GET", "HEAD", "OPTIONS"].includes(req.method || "GET")) { + // Handle body - check if already parsed by Next.js body parser + if (req.body !== undefined && req.body !== null) { + if (typeof req.body === "string") { + init.body = req.body; + } else if (Buffer.isBuffer(req.body)) { + init.body = req.body; + } else { + init.body = JSON.stringify(req.body); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + } + } else { + // Body parser disabled - read raw body from stream + const rawBody = await readRawBody(req); + if (rawBody.length > 0) { + init.body = rawBody; + } + } + } + + return new Request(url.toString(), init); +} + +async function sendPagesResponse(res, webResponse) { + res.statusCode = webResponse.status; + + webResponse.headers.forEach((value, key) => { + const lowerKey = key.toLowerCase(); + if ( + lowerKey !== "content-encoding" && + lowerKey !== "transfer-encoding" && + lowerKey !== "connection" + ) { + res.setHeader(key, value); + } + }); + + if (webResponse.body) { + const reader = webResponse.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + } finally { + reader.releaseLock(); + } + res.end(); + } else { + res.end(); + } +} +`; + class NextBuilder extends BaseBuilderClass { async build() { const routers = await this.detectRouters(); @@ -530,14 +623,18 @@ export async function getNextBuilder() { /** * Builds workflow routes for Pages Router. - * Generates routes in pages/.well-known/workflow/v1/ + * Generates routes in pages/api/.well-known/workflow/v1/ + * For pages router, can use Next.js rewrites to point to .well-known/workflow/v1/ */ private async buildForPagesRouter( pagesDir: string, inputFiles: string[], tsConfig: { baseUrl?: string; paths?: Record } ): Promise { - const workflowGeneratedDir = join(pagesDir, '.well-known/workflow/v1'); + const workflowGeneratedDir = join( + pagesDir, + 'api/.well-known/workflow/v1' + ); // Ensure output directories exist await mkdir(workflowGeneratedDir, { recursive: true }); @@ -603,9 +700,8 @@ export async function getNextBuilder() { // The generated bundle exports `POST` which is the stepEntrypoint const pagesRouterWrapper = `// biome-ignore-all lint: generated file /* eslint-disable */ -import { convertPagesRequest, sendPagesResponse } from '@workflow/next/pages-adapter'; - -${stepsBundle.replace('export { stepEntrypoint as POST }', 'const POST = stepEntrypoint;')} +${PAGES_ADAPTER_CODE} +${stepsBundle.replace(/export\s*\{\s*stepEntrypoint\s+as\s+POST\s*\};?/g, 'const POST = stepEntrypoint;')} export default async function handler(req, res) { const webRequest = await convertPagesRequest(req); @@ -663,8 +759,7 @@ export const config = { // Wrap for Pages Router const pagesRouterWrapper = `// biome-ignore-all lint: generated file /* eslint-disable */ -import { convertPagesRequest, sendPagesResponse } from '@workflow/next/pages-adapter'; - +${PAGES_ADAPTER_CODE} ${workflowsBundle.replace('export const POST =', 'const POST =')} export default async function handler(req, res) { @@ -705,8 +800,7 @@ export const config = { const routeContent = `// biome-ignore-all lint: generated file /* eslint-disable */ import { resumeWebhook } from 'workflow/api'; -import { convertPagesRequest, sendPagesResponse } from '@workflow/next/pages-adapter'; - +${PAGES_ADAPTER_CODE} export default async function handler(req, res) { const webRequest = await convertPagesRequest(req); const { token } = req.query; diff --git a/packages/next/src/pages-adapter.ts b/packages/next/src/pages-adapter.ts deleted file mode 100644 index 37cec806b..000000000 --- a/packages/next/src/pages-adapter.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { IncomingMessage } from 'node:http'; - -/** - * Next.js Pages Router request type. - * Uses Node.js IncomingMessage with additional Next.js properties. - */ -interface NextApiRequest extends IncomingMessage { - query: Record; - body?: unknown; -} - -/** - * Next.js Pages Router response type. - * We define only the methods we use rather than extending ServerResponse - * to avoid type conflicts with the base class's method signatures. - */ -interface NextApiResponse { - statusCode: number; - status(statusCode: number): NextApiResponse; - send(body: unknown): void; - json(body: unknown): void; - setHeader(name: string, value: string | number | readonly string[]): void; - write(chunk: unknown): boolean; - end(body?: unknown): void; -} - -/** - * Converts a Pages Router NextApiRequest to a Web API Request. - * This allows the workflow runtime (which uses Web APIs) to handle - * requests from Pages Router endpoints. - */ -export async function convertPagesRequest( - req: NextApiRequest -): Promise { - const protocol = req.headers['x-forwarded-proto'] || 'http'; - const host = req.headers.host || 'localhost:3000'; - const url = new URL(req.url || '/', `${protocol}://${host}`); - - const headers = new Headers(); - for (const [key, value] of Object.entries(req.headers)) { - if (value) { - if (Array.isArray(value)) { - for (const v of value) { - headers.append(key, v); - } - } else { - headers.set(key, value); - } - } - } - - const init: RequestInit = { - method: req.method || 'GET', - headers, - }; - - // Only include body for methods that support it - if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method || 'GET')) { - // Handle body - it may already be parsed by Next.js body parser - if (req.body !== undefined && req.body !== null) { - if (typeof req.body === 'string') { - init.body = req.body; - } else if (Buffer.isBuffer(req.body)) { - init.body = req.body; - } else { - // Object body - serialize to JSON - init.body = JSON.stringify(req.body); - // Ensure content-type is set for JSON - if (!headers.has('content-type')) { - headers.set('content-type', 'application/json'); - } - } - } - } - - return new Request(url.toString(), init); -} - -/** - * Sends a Web API Response through a Pages Router NextApiResponse. - * Handles streaming responses by reading the body incrementally. - */ -export async function sendPagesResponse( - res: NextApiResponse, - webResponse: Response -): Promise { - // Set status code - res.statusCode = webResponse.status; - - // Copy headers from Web Response to Node.js response - webResponse.headers.forEach((value, key) => { - // Skip headers that Node.js/Next.js handles automatically - // or that could cause issues with the response - const lowerKey = key.toLowerCase(); - if ( - lowerKey !== 'content-encoding' && - lowerKey !== 'transfer-encoding' && - lowerKey !== 'connection' - ) { - res.setHeader(key, value); - } - }); - - // Send body - if (webResponse.body) { - const reader = webResponse.body.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - res.write(value); - } - } finally { - reader.releaseLock(); - } - res.end(); - } else { - res.end(); - } -} From e620129bfdbc8e8c53bb5eea156cdc63af432327 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 14:30:07 -0800 Subject: [PATCH 03/14] add symlinks to nextjs pages router --- .../components/invocations-panel.tsx | 1 + .../components/terminal-log.tsx | 1 + .../components/ui/badge.tsx | 1 + .../components/ui/button.tsx | 1 + .../components/ui/card.tsx | 1 + .../components/ui/tooltip.tsx | 1 + .../components/workflow-button.tsx | 75 ++++ workbench/nextjs-pages-router/definitions.ts | 67 +++ workbench/nextjs-pages-router/hooks | 1 + .../nextjs-pages-router/instrumentation.ts | 1 + workbench/nextjs-pages-router/lib | 1 + workbench/nextjs-pages-router/next.config.ts | 8 + workbench/nextjs-pages-router/pages/_app.tsx | 18 +- .../nextjs-pages-router/pages/api/chat.ts | 12 + .../nextjs-pages-router/pages/api/hook.ts | 34 ++ .../pages/api/test-direct-step-call.ts | 26 ++ .../nextjs-pages-router/pages/api/trigger.ts | 190 +++++++++ .../pages/api/workflows/await.ts | 44 ++ .../pages/api/workflows/start.ts | 105 +++++ .../pages/api/workflows/stream.ts | 59 +++ workbench/nextjs-pages-router/pages/index.tsx | 385 +++++++++++++++++- .../nextjs-pages-router/styles/globals.css | 1 + 22 files changed, 1030 insertions(+), 3 deletions(-) create mode 120000 workbench/nextjs-pages-router/components/invocations-panel.tsx create mode 120000 workbench/nextjs-pages-router/components/terminal-log.tsx create mode 120000 workbench/nextjs-pages-router/components/ui/badge.tsx create mode 120000 workbench/nextjs-pages-router/components/ui/button.tsx create mode 120000 workbench/nextjs-pages-router/components/ui/card.tsx create mode 120000 workbench/nextjs-pages-router/components/ui/tooltip.tsx create mode 100644 workbench/nextjs-pages-router/components/workflow-button.tsx create mode 100644 workbench/nextjs-pages-router/definitions.ts create mode 120000 workbench/nextjs-pages-router/hooks create mode 120000 workbench/nextjs-pages-router/instrumentation.ts create mode 120000 workbench/nextjs-pages-router/lib create mode 100644 workbench/nextjs-pages-router/pages/api/chat.ts create mode 100644 workbench/nextjs-pages-router/pages/api/hook.ts create mode 100644 workbench/nextjs-pages-router/pages/api/test-direct-step-call.ts create mode 100644 workbench/nextjs-pages-router/pages/api/trigger.ts create mode 100644 workbench/nextjs-pages-router/pages/api/workflows/await.ts create mode 100644 workbench/nextjs-pages-router/pages/api/workflows/start.ts create mode 100644 workbench/nextjs-pages-router/pages/api/workflows/stream.ts create mode 120000 workbench/nextjs-pages-router/styles/globals.css diff --git a/workbench/nextjs-pages-router/components/invocations-panel.tsx b/workbench/nextjs-pages-router/components/invocations-panel.tsx new file mode 120000 index 000000000..31e529a58 --- /dev/null +++ b/workbench/nextjs-pages-router/components/invocations-panel.tsx @@ -0,0 +1 @@ +../../nextjs-turbopack/components/invocations-panel.tsx \ No newline at end of file diff --git a/workbench/nextjs-pages-router/components/terminal-log.tsx b/workbench/nextjs-pages-router/components/terminal-log.tsx new file mode 120000 index 000000000..eca6dc28c --- /dev/null +++ b/workbench/nextjs-pages-router/components/terminal-log.tsx @@ -0,0 +1 @@ +../../nextjs-turbopack/components/terminal-log.tsx \ No newline at end of file diff --git a/workbench/nextjs-pages-router/components/ui/badge.tsx b/workbench/nextjs-pages-router/components/ui/badge.tsx new file mode 120000 index 000000000..a92aae094 --- /dev/null +++ b/workbench/nextjs-pages-router/components/ui/badge.tsx @@ -0,0 +1 @@ +../../../nextjs-turbopack/components/ui/badge.tsx \ No newline at end of file diff --git a/workbench/nextjs-pages-router/components/ui/button.tsx b/workbench/nextjs-pages-router/components/ui/button.tsx new file mode 120000 index 000000000..50144f350 --- /dev/null +++ b/workbench/nextjs-pages-router/components/ui/button.tsx @@ -0,0 +1 @@ +../../../nextjs-turbopack/components/ui/button.tsx \ No newline at end of file diff --git a/workbench/nextjs-pages-router/components/ui/card.tsx b/workbench/nextjs-pages-router/components/ui/card.tsx new file mode 120000 index 000000000..515f5b99c --- /dev/null +++ b/workbench/nextjs-pages-router/components/ui/card.tsx @@ -0,0 +1 @@ +../../../nextjs-turbopack/components/ui/card.tsx \ No newline at end of file diff --git a/workbench/nextjs-pages-router/components/ui/tooltip.tsx b/workbench/nextjs-pages-router/components/ui/tooltip.tsx new file mode 120000 index 000000000..5bdf68210 --- /dev/null +++ b/workbench/nextjs-pages-router/components/ui/tooltip.tsx @@ -0,0 +1 @@ +../../../nextjs-turbopack/components/ui/tooltip.tsx \ No newline at end of file diff --git a/workbench/nextjs-pages-router/components/workflow-button.tsx b/workbench/nextjs-pages-router/components/workflow-button.tsx new file mode 100644 index 000000000..f677eae0f --- /dev/null +++ b/workbench/nextjs-pages-router/components/workflow-button.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { WorkflowDefinition } from '@/definitions'; + +interface WorkflowButtonProps { + workflow: WorkflowDefinition; + onStart: (workflowName: string, args: unknown[]) => void; +} + +export function WorkflowButton({ workflow, onStart }: WorkflowButtonProps) { + const hasArgs = workflow.defaultArgs.length > 0; + + return ( + +
+ + +
+
+

+ {workflow.displayName} +

+ {hasArgs && ( + + ({workflow.defaultArgs.length} arg + {workflow.defaultArgs.length !== 1 ? 's' : ''}) + + )} +
+ {hasArgs && ( +
+                  {JSON.stringify(workflow.defaultArgs, null, 2)}
+                
+ )} +
+
+ +
+
+ {workflow.displayName} +
+
+ {workflow.workflowFile} +
+ {hasArgs ? ( + <> +
Default Arguments:
+
+                    {JSON.stringify(workflow.defaultArgs, null, 2)}
+                  
+ + ) : ( +
No arguments required
+ )} +
+
+
+ +
+
+ ); +} diff --git a/workbench/nextjs-pages-router/definitions.ts b/workbench/nextjs-pages-router/definitions.ts new file mode 100644 index 000000000..45367436d --- /dev/null +++ b/workbench/nextjs-pages-router/definitions.ts @@ -0,0 +1,67 @@ +import { allWorkflows } from '@/_workflows'; + +export type WorkflowDefinition = { + workflowFile: string; + name: string; + displayName: string; + description?: string; + defaultArgs: unknown[]; +}; + +// Default arguments for workflows that require them +// Based on e2e test arguments from packages/core/e2e/e2e.test.ts +const DEFAULT_ARGS_MAP: Record = { + // 1_simple.ts + simple: [42], + // 4_ai.ts + ai: ['What is the weather in San Francisco?'], + agent: ['What is the weather in Muscat?'], + // 7_full.ts + handleUserSignup: ['user@example.com'], + // 98_duplicate_case.ts + addTenWorkflow: [123], + // 99_e2e.ts + hookWorkflow: [ + Math.random().toString(36).slice(2), + Math.random().toString(36).slice(2), + ], + webhookWorkflow: [ + Math.random().toString(36).slice(2), + Math.random().toString(36).slice(2), + Math.random().toString(36).slice(2), + ], + hookCleanupTestWorkflow: [ + Math.random().toString(36).slice(2), + Math.random().toString(36).slice(2), + ], + closureVariableWorkflow: [7], +}; + +// Dynamically generate workflow definitions from allWorkflows +export const WORKFLOW_DEFINITIONS: WorkflowDefinition[] = Object.entries( + allWorkflows +) + .flatMap(([workflowFile, workflows]) => + Object.entries(workflows) + .filter( + ([_, value]) => + typeof value === 'function' && + 'workflowId' in value && + typeof value.workflowId === 'string' + ) + .map(([name]) => ({ + workflowFile, + name, + displayName: name, + defaultArgs: DEFAULT_ARGS_MAP[name] || [], + })) + ) + .sort((a, b) => { + // Sort by file name first, then by workflow name + if (a.workflowFile !== b.workflowFile) { + return a.workflowFile.localeCompare(b.workflowFile); + } + return a.name.localeCompare(b.name); + }); + +export type WorkflowName = string; diff --git a/workbench/nextjs-pages-router/hooks b/workbench/nextjs-pages-router/hooks new file mode 120000 index 000000000..824a5207e --- /dev/null +++ b/workbench/nextjs-pages-router/hooks @@ -0,0 +1 @@ +../nextjs-turbopack/hooks \ No newline at end of file diff --git a/workbench/nextjs-pages-router/instrumentation.ts b/workbench/nextjs-pages-router/instrumentation.ts new file mode 120000 index 000000000..ebe5506ef --- /dev/null +++ b/workbench/nextjs-pages-router/instrumentation.ts @@ -0,0 +1 @@ +../nextjs-turbopack/instrumentation.ts \ No newline at end of file diff --git a/workbench/nextjs-pages-router/lib b/workbench/nextjs-pages-router/lib new file mode 120000 index 000000000..e5dbf7d43 --- /dev/null +++ b/workbench/nextjs-pages-router/lib @@ -0,0 +1 @@ +../nextjs-turbopack/lib \ No newline at end of file diff --git a/workbench/nextjs-pages-router/next.config.ts b/workbench/nextjs-pages-router/next.config.ts index 65134ac35..562067430 100644 --- a/workbench/nextjs-pages-router/next.config.ts +++ b/workbench/nextjs-pages-router/next.config.ts @@ -3,6 +3,14 @@ import { withWorkflow } from 'workflow/next'; const nextConfig: NextConfig = { /* config options here */ + rewrites: async () => { + return [ + { + source: '/.well-known/workflow/v1/:path*', + destination: '/api/.well-known/workflow/v1/:path*', + }, + ]; + }, }; export default withWorkflow(nextConfig); diff --git a/workbench/nextjs-pages-router/pages/_app.tsx b/workbench/nextjs-pages-router/pages/_app.tsx index da826ed16..a1b6df7ba 100644 --- a/workbench/nextjs-pages-router/pages/_app.tsx +++ b/workbench/nextjs-pages-router/pages/_app.tsx @@ -1,5 +1,21 @@ import type { AppProps } from 'next/app'; +import { Geist, Geist_Mono } from 'next/font/google'; +import '@/styles/globals.css'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'], +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); export default function App({ Component, pageProps }: AppProps) { - return ; + return ( +
+ +
+ ); } diff --git a/workbench/nextjs-pages-router/pages/api/chat.ts b/workbench/nextjs-pages-router/pages/api/chat.ts new file mode 100644 index 000000000..a9c06986c --- /dev/null +++ b/workbench/nextjs-pages-router/pages/api/chat.ts @@ -0,0 +1,12 @@ +// THIS FILE IS JUST FOR TESTING HMR AS AN ENTRY NEEDS +// TO IMPORT THE WORKFLOWS TO DISCOVER THEM AND WATCH +import type { NextApiRequest, NextApiResponse } from 'next'; +import * as workflows from '@/workflows/3_streams'; + +export default async function handler( + _req: NextApiRequest, + res: NextApiResponse +) { + console.log(workflows); + res.json('hello world'); +} diff --git a/workbench/nextjs-pages-router/pages/api/hook.ts b/workbench/nextjs-pages-router/pages/api/hook.ts new file mode 100644 index 000000000..bad9f5834 --- /dev/null +++ b/workbench/nextjs-pages-router/pages/api/hook.ts @@ -0,0 +1,34 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getHookByToken, resumeHook } from 'workflow/api'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + const { token, data } = req.body; + + let hook: Awaited>; + try { + hook = await getHookByToken(token); + console.log('hook', hook); + } catch (error) { + console.log('error during getHookByToken', error); + // TODO: `WorkflowAPIError` is not exported, so for now + // we'll return 404 assuming it's the "invalid" token test case + res.status(404).json(null); + return; + } + + await resumeHook(hook.token, { + ...data, + // @ts-expect-error metadata is not typed + customData: hook.metadata?.customData, + }); + + res.json(hook); +} diff --git a/workbench/nextjs-pages-router/pages/api/test-direct-step-call.ts b/workbench/nextjs-pages-router/pages/api/test-direct-step-call.ts new file mode 100644 index 000000000..a75da68c6 --- /dev/null +++ b/workbench/nextjs-pages-router/pages/api/test-direct-step-call.ts @@ -0,0 +1,26 @@ +// This route tests calling step functions directly outside of any workflow context +// After the SWC compiler changes, step functions in client mode have their directive removed +// and keep their original implementation, allowing them to be called as regular async functions + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { add } from '@/workflows/99_e2e'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + const { x, y } = req.body; + + console.log(`Calling step function directly with x=${x}, y=${y}`); + + // Call step function directly as a regular async function (no workflow context) + const result = await add(x, y); + console.log(`add(${x}, ${y}) = ${result}`); + + res.json({ result }); +} diff --git a/workbench/nextjs-pages-router/pages/api/trigger.ts b/workbench/nextjs-pages-router/pages/api/trigger.ts new file mode 100644 index 000000000..ef3ab91cb --- /dev/null +++ b/workbench/nextjs-pages-router/pages/api/trigger.ts @@ -0,0 +1,190 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getRun, start } from 'workflow/api'; +import { + WorkflowRunFailedError, + WorkflowRunNotCompletedError, +} from 'workflow/internal/errors'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import { allWorkflows } from '@/_workflows'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method === 'POST') { + return handlePost(req, res); + } else if (req.method === 'GET') { + return handleGet(req, res); + } else { + res.status(405).json({ error: 'Method not allowed' }); + } +} + +async function handlePost(req: NextApiRequest, res: NextApiResponse) { + const workflowFile = + (req.query.workflowFile as string) || 'workflows/99_e2e.ts'; + if (!workflowFile) { + res.status(400).send('No workflowFile query parameter provided'); + return; + } + + const workflows = allWorkflows[workflowFile as keyof typeof allWorkflows]; + if (!workflows) { + res.status(400).send(`Workflow file "${workflowFile}" not found`); + return; + } + + const workflowFn = (req.query.workflowFn as string) || 'simple'; + if (!workflowFn) { + res.status(400).send('No workflow query parameter provided'); + return; + } + + const workflow = workflows[workflowFn as keyof typeof workflows]; + if (!workflow) { + res.status(400).send(`Workflow "${workflowFn}" not found`); + return; + } + + let args: unknown[] = []; + + // Args from query string + const argsParam = req.query.args as string; + if (argsParam) { + args = argsParam.split(',').map((arg) => { + const num = parseFloat(arg); + return Number.isNaN(num) ? arg.trim() : num; + }); + } else { + // Args from body + const body = req.body; + if (body && Object.keys(body).length > 0) { + args = hydrateWorkflowArguments( + typeof body === 'string' ? JSON.parse(body) : body, + globalThis + ); + } else { + args = [42]; + } + } + + console.log(`Starting "${workflowFn}" workflow with args: ${args}`); + + try { + const run = await start(workflow as any, args as any); + console.log('Run', run.runId); + res.json(run); + } catch (err) { + console.error(`Failed to start!!`, err); + throw err; + } +} + +async function handleGet(req: NextApiRequest, res: NextApiResponse) { + const runId = req.query.runId as string; + if (!runId) { + res.status(400).send('No runId provided'); + return; + } + + const outputStreamParam = req.query['output-stream'] as string; + if (outputStreamParam) { + const namespace = outputStreamParam === '1' ? undefined : outputStreamParam; + const run = getRun(runId); + const stream = run.getReadable({ + namespace, + }); + + res.setHeader('Content-Type', 'application/octet-stream'); + + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + // Add JSON framing to the stream, wrapping binary data in base64 + const data = + value instanceof Uint8Array + ? { data: Buffer.from(value).toString('base64') } + : value; + res.write(`${JSON.stringify(data)}\n`); + } + } finally { + reader.releaseLock(); + } + res.end(); + return; + } + + try { + const run = getRun(runId); + const returnValue = await run.returnValue; + console.log('Return value:', returnValue); + + // Include run metadata in headers + const [createdAt, startedAt, completedAt] = await Promise.all([ + run.createdAt, + run.startedAt, + run.completedAt, + ]); + + res.setHeader('X-Workflow-Run-Created-At', createdAt?.toISOString() || ''); + res.setHeader('X-Workflow-Run-Started-At', startedAt?.toISOString() || ''); + res.setHeader( + 'X-Workflow-Run-Completed-At', + completedAt?.toISOString() || '' + ); + + if (returnValue instanceof ReadableStream) { + res.setHeader('Content-Type', 'application/octet-stream'); + const reader = returnValue.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + } finally { + reader.releaseLock(); + } + res.end(); + } else { + res.json(returnValue); + } + } catch (error) { + if (error instanceof Error) { + if (WorkflowRunNotCompletedError.is(error)) { + res.status(202).json({ + ...error, + name: error.name, + message: error.message, + }); + return; + } + + if (WorkflowRunFailedError.is(error)) { + const cause = error.cause as Error & { code?: string }; + res.status(400).json({ + ...error, + name: error.name, + message: error.message, + cause: { + message: cause.message, + stack: cause.stack, + code: cause.code, + }, + }); + return; + } + } + + console.error( + 'Unexpected error while getting workflow return value:', + error + ); + res.status(500).json({ + error: 'Internal server error', + }); + } +} diff --git a/workbench/nextjs-pages-router/pages/api/workflows/await.ts b/workbench/nextjs-pages-router/pages/api/workflows/await.ts new file mode 100644 index 000000000..a2c0f5497 --- /dev/null +++ b/workbench/nextjs-pages-router/pages/api/workflows/await.ts @@ -0,0 +1,44 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getRun } from 'workflow/api'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + try { + const { runId } = req.body as { runId: string }; + + if (!runId) { + res.status(400).json({ error: 'runId is required' }); + return; + } + + // Get the workflow run + const run = await getRun(runId); + + if (!run) { + res.status(404).json({ error: `Workflow run "${runId}" not found` }); + return; + } + + // Await the result + const result = await run.returnValue; + + res.json({ + runId, + status: 'completed', + result, + }); + } catch (error) { + console.error('Error awaiting workflow:', error); + res.status(500).json({ + error: 'Failed to await workflow', + details: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/workbench/nextjs-pages-router/pages/api/workflows/start.ts b/workbench/nextjs-pages-router/pages/api/workflows/start.ts new file mode 100644 index 000000000..90f8badcc --- /dev/null +++ b/workbench/nextjs-pages-router/pages/api/workflows/start.ts @@ -0,0 +1,105 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { start } from 'workflow/api'; +import { allWorkflows } from '@/_workflows'; +import { + WORKFLOW_DEFINITIONS, + type WorkflowName, +} from '@/app/workflows/definitions'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + try { + const { workflowName, args } = req.body as { + workflowName: WorkflowName; + args?: unknown[]; + }; + + // Find workflow definition + const definition = WORKFLOW_DEFINITIONS.find( + (w) => w.name === workflowName + ); + if (!definition) { + res.status(404).json({ error: `Workflow "${workflowName}" not found` }); + return; + } + + // Get the workflow file + const workflows = + allWorkflows[definition.workflowFile as keyof typeof allWorkflows]; + if (!workflows) { + res.status(404).json({ + error: `Workflow file "${definition.workflowFile}" not found`, + }); + return; + } + + // Get the workflow function + const workflowFn = workflows[ + workflowName as keyof typeof workflows + ] as () => Promise; + if (typeof workflowFn !== 'function') { + res + .status(400) + .json({ error: `Workflow "${workflowName}" is not a function` }); + return; + } + + // Use provided args or default args + const workflowArgs = args !== undefined ? args : definition.defaultArgs; + + // Start the workflow + // @ts-expect-error - we're doing arbitrary calls to unknown functions + const run = await start(workflowFn, workflowArgs); + + if (!run) { + res.status(500).json({ error: 'Failed to get workflow run' }); + return; + } + + // Set headers for streaming response + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Workflow-Run-Id', run.runId); + + // Stream the workflow output + const reader = run.readable.getReader(); + + const streamData = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + res.write(value); + } + } + } catch (error) { + console.error('Error streaming workflow:', error); + } + }; + + // Race between stream completion and workflow completion + await Promise.race([streamData(), run.returnValue]); + + // Give a moment for any final stream data + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Close the stream + reader.releaseLock(); + res.end(); + } catch (error) { + console.error('Error starting workflow:', error); + res.status(500).json({ + error: 'Failed to start workflow', + details: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/workbench/nextjs-pages-router/pages/api/workflows/stream.ts b/workbench/nextjs-pages-router/pages/api/workflows/stream.ts new file mode 100644 index 000000000..7e08eb18d --- /dev/null +++ b/workbench/nextjs-pages-router/pages/api/workflows/stream.ts @@ -0,0 +1,59 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getRun } from 'workflow/api'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + try { + const { runId } = req.body as { runId: string }; + + if (!runId) { + res.status(400).json({ error: 'runId is required' }); + return; + } + + // Get the workflow run + const run = await getRun(runId); + + if (!run) { + res.status(404).json({ error: `Workflow run "${runId}" not found` }); + return; + } + + // Set headers for streaming response + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Workflow-Run-Id', runId); + + // Get the readable stream and pipe it to the response + const readable = run.getReadable(); + const reader = readable.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + res.write(value); + } + } + } finally { + reader.releaseLock(); + } + + res.end(); + } catch (error) { + console.error('Error resuming stream:', error); + res.status(500).json({ + error: 'Failed to resume stream', + details: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/workbench/nextjs-pages-router/pages/index.tsx b/workbench/nextjs-pages-router/pages/index.tsx index e85d5092c..7dfea1f57 100644 --- a/workbench/nextjs-pages-router/pages/index.tsx +++ b/workbench/nextjs-pages-router/pages/index.tsx @@ -1,3 +1,384 @@ -export default function Page() { - return

Hello, Next.js!

; +import Head from 'next/head'; +import { useRef, useEffect, useCallback } from 'react'; +import { WORKFLOW_DEFINITIONS } from '@/definitions'; +import { WorkflowButton } from '@/components/workflow-button'; +import { TerminalLog } from '@/components/terminal-log'; +import { InvocationsPanel } from '@/components/invocations-panel'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { useWorkflowStorage } from '@/hooks'; + +export default function Home() { + // Track active stream abort controllers + const streamAbortControllers = useRef>( + new Map() + ); + + // Track which runs we've attempted to reconnect to (to avoid duplicates) + const reconnectionAttempts = useRef>(new Set()); + + // Use custom hooks for localStorage management + const { + logs, + addLog, + invocations, + addInvocation, + updateInvocationStatus, + updateInvocationRunId, + clearAll, + isHydrated, + } = useWorkflowStorage(); + + // Stream reading helper + const readStream = useCallback( + async (runId: string, reader: ReadableStreamDefaultReader) => { + const decoder = new TextDecoder(); + const abortController = new AbortController(); + streamAbortControllers.current.set(runId, abortController); + + try { + while (true) { + if (abortController.signal.aborted) { + reader.cancel(); + break; + } + + const { done, value } = await reader.read(); + + if (done) { + break; + } + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n').filter((line) => line.trim()); + + for (const line of lines) { + addLog('stream', line, runId); + } + } + + if (!abortController.signal.aborted) { + // Stream completed successfully + updateInvocationStatus(runId, 'stream_complete'); + addLog('info', 'Stream completed', runId); + } + } catch (streamError) { + if (!abortController.signal.aborted) { + const errorMsg = + streamError instanceof Error + ? streamError.message + : String(streamError); + addLog('error', `Stream error: ${errorMsg}`, runId); + updateInvocationStatus(runId, 'disconnected', undefined, errorMsg); + } + } finally { + streamAbortControllers.current.delete(runId); + } + }, + [addLog, updateInvocationStatus] + ); + + // Await workflow result + const awaitWorkflowResult = useCallback( + async (runId: string) => { + try { + const response = await fetch('/api/workflows/await', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ runId }), + }); + + if (!response.ok) { + const error = await response.json(); + const errorMsg = `${error.error || 'Unknown error'}${ + error.details ? ` - ${error.details}` : '' + }`; + addLog('error', `API error awaiting result: ${errorMsg}`, runId); + // Use "failed" for API errors (our side) + updateInvocationStatus(runId, 'failed', undefined, errorMsg); + } else { + const data = await response.json(); + + // Check if the workflow result itself is an error + const isWorkflowError = + data.result && + typeof data.result === 'object' && + (data.result.error || data.result instanceof Error); + + if (isWorkflowError) { + const errorMsg = + data.result.error || data.result.message || 'Workflow error'; + addLog('error', `Workflow returned error: ${errorMsg}`, runId); + // Use "error" for workflow errors (the workflow returned an error) + updateInvocationStatus(runId, 'error', data.result, errorMsg); + } else { + addLog( + 'result', + `Workflow completed: ${JSON.stringify(data.result)}`, + runId + ); + updateInvocationStatus(runId, 'done', data.result); + } + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + addLog('error', `Failed to await result: ${errorMsg}`, runId); + // Use "failed" for client/network errors + updateInvocationStatus(runId, 'failed', undefined, errorMsg); + } + }, + [addLog, updateInvocationStatus] + ); + + // Reconnect to a stream (or just await result if stream is done) + const reconnectToRun = useCallback( + async (runId: string, silent = false) => { + // First try to reconnect to the stream + try { + if (!silent) { + addLog('info', `Reconnecting to run ${runId}...`, runId); + } + updateInvocationStatus(runId, 'streaming'); + + const streamResponse = await fetch('/api/workflows/stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ runId }), + }); + + if (streamResponse.ok) { + // Stream is still available - read it + const reader = streamResponse.body?.getReader(); + if (reader) { + await readStream(runId, reader); + } + } else { + // Stream not available - that's okay, workflow may have completed + if (!silent) { + addLog('info', `Stream ended, checking result...`, runId); + } + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (!silent) { + addLog('error', `Error reconnecting to stream: ${errorMsg}`, runId); + } + } + + // Always try to await the result + await awaitWorkflowResult(runId); + }, + [addLog, updateInvocationStatus, readStream, awaitWorkflowResult] + ); + + // Manual reconnect handler (from UI button) + const reconnectStream = useCallback( + async (runId: string) => { + // Reset the reconnection tracking for manual reconnects + reconnectionAttempts.current.delete(runId); + await reconnectToRun(runId, false); + }, + [reconnectToRun] + ); + + // Auto-reconnect on page load for any "reconnecting" invocations + useEffect(() => { + // Wait for hydration to complete + if (!isHydrated) return; + + const reconnectingInvocations = invocations.filter( + (inv) => inv.status === 'reconnecting' + ); + + for (const inv of reconnectingInvocations) { + // Skip if we've already attempted this run + if (reconnectionAttempts.current.has(inv.runId)) continue; + + // Mark as attempted + reconnectionAttempts.current.add(inv.runId); + + if (inv.runId.startsWith('temp-')) { + // temp IDs can't be reconnected - mark as failed + updateInvocationStatus( + inv.runId, + 'failed', + undefined, + 'Lost connection before receiving run ID' + ); + } else { + // Reconnect in the background + addLog('info', `Auto-reconnecting to ${inv.runId}...`, inv.runId); + reconnectToRun(inv.runId, true); + } + } + }, [isHydrated, invocations, reconnectToRun, addLog, updateInvocationStatus]); + + // Start a workflow + const startWorkflow = async (workflowName: string, args: unknown[]) => { + let runId: string | null = null; + let tempId = ''; + + try { + // Create invocation with "invoked" status + tempId = `temp-${crypto.randomUUID()}`; + addLog('info', `Starting workflow: ${workflowName}`); + addInvocation(tempId, workflowName); + + const response = await fetch('/api/workflows/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workflowName, + args, + }), + }); + + if (!response.ok) { + const error = await response.json(); + const errorMsg = `${error.error || 'Unknown error'}${ + error.details ? ` - ${error.details}` : '' + }`; + addLog('error', `API error starting workflow: ${errorMsg}`); + // Use "failed" for API errors + updateInvocationStatus(tempId, 'failed', undefined, errorMsg); + return; + } + + // Check if this is a streaming response + const contentType = response.headers.get('Content-Type'); + const isStream = contentType?.includes('text/event-stream'); + + if (!isStream) { + const errorMsg = 'No stream available - expected text/event-stream'; + addLog('error', errorMsg); + updateInvocationStatus(tempId, 'failed', undefined, errorMsg); + return; + } + + // Get run ID from header + runId = response.headers.get('X-Workflow-Run-Id'); + + if (!runId) { + const errorMsg = 'No run ID returned from server'; + addLog('error', errorMsg); + updateInvocationStatus(tempId, 'failed', undefined, errorMsg); + return; + } + + // Update with real run ID and "streaming" status + updateInvocationRunId(tempId, runId, 'streaming'); + addLog('info', `Started run ${runId}`, runId); + + // Read the stream + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No reader available'); + } + + await readStream(runId, reader); + + // Wait for the workflow result + await awaitWorkflowResult(runId); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + addLog('error', `Failed to start workflow: ${errorMsg}`); + // Use "failed" for client errors + const targetId = runId || tempId; + if (targetId) { + updateInvocationStatus(targetId, 'failed', undefined, errorMsg); + } + } + }; + + // Disconnect from a stream + const disconnectStream = (runId: string) => { + const controller = streamAbortControllers.current.get(runId); + if (controller) { + controller.abort(); + streamAbortControllers.current.delete(runId); + updateInvocationStatus(runId, 'disconnected'); + addLog('info', 'Disconnected from stream', runId); + } + }; + + return ( + <> + + Workflow DevKit Examples + + + +
+
+
+

+ Workflow DevKit Examples +

+

+ Select a workflow to start a run and view its output +

+
+ +
+ {/* Left Column - Workflow List */} +
+

Available Workflows

+
+ {Object.entries( + WORKFLOW_DEFINITIONS.reduce( + (acc, workflow) => { + if (!acc[workflow.workflowFile]) { + acc[workflow.workflowFile] = []; + } + acc[workflow.workflowFile].push(workflow); + return acc; + }, + {} as Record + ) + ).map(([workflowFile, workflows]) => ( +
+

+ {workflowFile} +

+
+ {workflows.map((workflow) => ( + + ))} +
+
+ ))} +
+
+ + {/* Middle Column - Invocations */} +
+ +
+ + {/* Right Column - Terminal Log */} +
+ +
+
+
+
+
+ + ); } diff --git a/workbench/nextjs-pages-router/styles/globals.css b/workbench/nextjs-pages-router/styles/globals.css new file mode 120000 index 000000000..08bc2b577 --- /dev/null +++ b/workbench/nextjs-pages-router/styles/globals.css @@ -0,0 +1 @@ +../../nextjs-turbopack/app/globals.css \ No newline at end of file From 83a9cb11db0fc7e8dc56f87ef81388b3c9c1c1ab Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 14:34:51 -0800 Subject: [PATCH 04/14] test: add nextjs pages router --- .changeset/config.json | 6 ++---- .changeset/pre.json | 3 ++- packages/core/e2e/local-build.test.ts | 1 + scripts/create-test-matrix.mjs | 11 +++++++++++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 2e452cba2..97800f261 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,9 +1,6 @@ { "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", - "changelog": [ - "@changesets/changelog-github", - { "repo": "vercel/workflow" } - ], + "changelog": ["@changesets/changelog-github", { "repo": "vercel/workflow" }], "commit": false, "fixed": [], "linked": [], @@ -14,6 +11,7 @@ "docs", "nextjs-turbopack", "nextjs-webpack", + "@workflow/example-nextjs-pages-router", "@workflow/example-app", "@workflow/example-hono", "@workflow/example-express", diff --git a/.changeset/pre.json b/.changeset/pre.json index d2305a761..e569169a2 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -38,7 +38,8 @@ "@workflow/rollup": "4.0.0-beta.1", "@workflow/astro": "4.0.0-beta.1", "@workflow/example-fastify": "0.0.0", - "@workflow/vite": "4.0.0-beta.1" + "@workflow/vite": "4.0.0-beta.1", + "@workflow/example-nextjs-pages-router": "0.0.0" }, "changesets": [ "add-documentation", diff --git a/packages/core/e2e/local-build.test.ts b/packages/core/e2e/local-build.test.ts index b8f01e858..2743399f5 100644 --- a/packages/core/e2e/local-build.test.ts +++ b/packages/core/e2e/local-build.test.ts @@ -16,6 +16,7 @@ describe.each([ 'express', 'fastify', 'astro', + 'nextjs-pages-router', ])('e2e', (project) => { test('builds without errors', { timeout: 180_000 }, async () => { // skip if we're targeting specific app to test diff --git a/scripts/create-test-matrix.mjs b/scripts/create-test-matrix.mjs index e0c236b0a..a8c254607 100644 --- a/scripts/create-test-matrix.mjs +++ b/scripts/create-test-matrix.mjs @@ -12,6 +12,12 @@ const DEV_TEST_CONFIGS = { apiFilePath: 'app/api/chat/route.ts', apiFileImportPath: '../../..', }, + 'nextjs-pages-router': { + generatedStepPath: 'pages/api/.well-known/workflow/v1/step/route.js', + generatedWorkflowPath: 'pages/api/.well-known/workflow/v1/flow/route.js', + apiFilePath: 'pages/api/chat.ts', + apiFileImportPath: '../..', + }, nitro: { generatedStepPath: 'node_modules/.nitro/workflow/steps.mjs', generatedWorkflowPath: 'node_modules/.nitro/workflow/workflows.mjs', @@ -76,6 +82,11 @@ const matrix = { project: 'example-nextjs-workflow-webpack', ...DEV_TEST_CONFIGS['nextjs-webpack'], }, + { + name: 'nextjs-pages-router', + project: 'workbench-nextjs-pages-router-workflow', + ...DEV_TEST_CONFIGS['nextjs-pages-router'], + }, ], }; From 488062dc9cb83a564b31a071c0cf62463709332e Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 14:38:24 -0800 Subject: [PATCH 05/14] revert --- packages/builders/src/base-builder.ts | 42 ++------------------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 59b63103d..984fa3f1c 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -789,7 +789,7 @@ export const OPTIONS = handler;`; absWorkingDir: this.config.workingDir, bundle: true, jsx: 'preserve', - format: 'esm', + format: 'cjs', platform: 'node', conditions: ['import', 'module', 'node', 'default'], target: 'es2022', @@ -809,44 +809,8 @@ export const OPTIONS = handler;`; ], sourcemap: false, mainFields: ['module', 'main'], - // Externalize Node.js built-ins with node: prefix so bundlers - // (webpack/turbopack) know they're server-only and won't try - // to bundle them for the browser - external: [ - 'node:fs', - 'node:path', - 'node:os', - 'node:crypto', - 'node:stream', - 'node:buffer', - 'node:util', - 'node:events', - 'node:http', - 'node:https', - 'node:url', - 'node:querystring', - 'node:zlib', - 'node:async_hooks', - 'node:module', - ], - // Rewrite bare Node.js imports to use node: prefix - alias: { - fs: 'node:fs', - path: 'node:path', - os: 'node:os', - crypto: 'node:crypto', - stream: 'node:stream', - buffer: 'node:buffer', - util: 'node:util', - events: 'node:events', - http: 'node:http', - https: 'node:https', - url: 'node:url', - querystring: 'node:querystring', - zlib: 'node:zlib', - async_hooks: 'node:async_hooks', - module: 'node:module', - }, + // Don't externalize anything - bundle everything including workflow packages + external: [], }); this.logEsbuildMessages(result, 'webhook bundle creation'); From 81acf2a113c5a8ccf85b17bc39f6c1abb434dfac Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 14:40:01 -0800 Subject: [PATCH 06/14] revert --- packages/next/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/next/package.json b/packages/next/package.json index 93625357d..519fa794c 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -19,8 +19,7 @@ "exports": { ".": "./dist/index.js", "./loader": "./dist/loader.js", - "./runtime": "./dist/runtime.js", - "./pages-adapter": "./dist/pages-adapter.js" + "./runtime": "./dist/runtime.js" }, "scripts": { "build": "tsc", From 629c89ad26f8244be8507b47c0038bd61540ae73 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 14:48:11 -0800 Subject: [PATCH 07/14] fix: next pages router config json path --- packages/next/src/builder.ts | 4 ++-- workbench/nextjs-pages-router/pages/api/workflows/start.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts index 5cf076528..74741c864 100644 --- a/packages/next/src/builder.ts +++ b/packages/next/src/builder.ts @@ -659,8 +659,8 @@ async function sendPagesResponse(res, webResponse) { // Build webhook route for Pages Router await this.buildWebhookRoutePages({ workflowGeneratedDir }); - // Write config.json - await this.writeFunctionsConfig(pagesDir); + // Write config.json (must be in pages/api/ for Pages Router) + await this.writeFunctionsConfig(join(pagesDir, 'api')); } /** diff --git a/workbench/nextjs-pages-router/pages/api/workflows/start.ts b/workbench/nextjs-pages-router/pages/api/workflows/start.ts index 90f8badcc..ae41fcc1e 100644 --- a/workbench/nextjs-pages-router/pages/api/workflows/start.ts +++ b/workbench/nextjs-pages-router/pages/api/workflows/start.ts @@ -1,10 +1,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { start } from 'workflow/api'; import { allWorkflows } from '@/_workflows'; -import { - WORKFLOW_DEFINITIONS, - type WorkflowName, -} from '@/app/workflows/definitions'; +import { WORKFLOW_DEFINITIONS, type WorkflowName } from '@/definitions'; export default async function handler( req: NextApiRequest, From 444c07b3b4365bc0a8abf104cbbfe7ca1a833350 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 14:53:43 -0800 Subject: [PATCH 08/14] feat: add pages router next config rewrite in next builder --- packages/next/src/index.ts | 62 ++++++++++++++++++++ workbench/nextjs-pages-router/next.config.ts | 8 --- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index cd24924d2..87475bc26 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -1,7 +1,40 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; import type { NextConfig } from 'next'; import semver from 'semver'; import { getNextBuilder } from './builder.js'; +/** + * Next.js rewrite rule type + */ +interface Rewrite { + source: string; + destination: string; + basePath?: false; + locale?: false; + has?: Array<{ type: string; key?: string; value?: string }>; + missing?: Array<{ type: string; key?: string; value?: string }>; +} + +/** + * Check if Pages Router exists in the project. + */ +function hasPagesRouter(): boolean { + const cwd = process.cwd(); + return ( + existsSync(resolve(cwd, 'pages')) || existsSync(resolve(cwd, 'src/pages')) + ); +} + +/** + * The rewrite rule for Pages Router to map workflow protocol routes + * from /.well-known/workflow/v1/* to /api/.well-known/workflow/v1/* + */ +const PAGES_ROUTER_WORKFLOW_REWRITE: Rewrite = { + source: '/.well-known/workflow/v1/:path*', + destination: '/api/.well-known/workflow/v1/:path*', +}; + export function withWorkflow( nextConfigOrFn: | NextConfig @@ -135,6 +168,35 @@ export function withWorkflow( process.env.WORKFLOW_NEXT_PRIVATE_BUILT = '1'; } + // For Pages Router, add rewrites to map /.well-known/workflow/v1/* to /api/.well-known/workflow/v1/* + // This is needed because Pages Router API routes must be in /pages/api/ + if (hasPagesRouter()) { + const existingRewrites = nextConfig.rewrites; + nextConfig.rewrites = (async () => { + // Get existing rewrites if any + let existing: any = []; + if (typeof existingRewrites === 'function') { + existing = await existingRewrites(); + } else if (existingRewrites) { + existing = existingRewrites; + } + + // Handle both array format and object format (beforeFiles/afterFiles/fallback) + if (Array.isArray(existing)) { + return [...existing, PAGES_ROUTER_WORKFLOW_REWRITE]; + } else { + return { + beforeFiles: existing.beforeFiles || [], + afterFiles: [ + ...(existing.afterFiles || []), + PAGES_ROUTER_WORKFLOW_REWRITE, + ], + fallback: existing.fallback || [], + }; + } + }) as any; + } + return nextConfig; }; } diff --git a/workbench/nextjs-pages-router/next.config.ts b/workbench/nextjs-pages-router/next.config.ts index 562067430..65134ac35 100644 --- a/workbench/nextjs-pages-router/next.config.ts +++ b/workbench/nextjs-pages-router/next.config.ts @@ -3,14 +3,6 @@ import { withWorkflow } from 'workflow/next'; const nextConfig: NextConfig = { /* config options here */ - rewrites: async () => { - return [ - { - source: '/.well-known/workflow/v1/:path*', - destination: '/api/.well-known/workflow/v1/:path*', - }, - ]; - }, }; export default withWorkflow(nextConfig); From d7fc8f2381c62f3635895b58eedb04bd83d39149 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 15:09:20 -0800 Subject: [PATCH 09/14] fix: update pages router symlink --- workbench/nextjs-pages-router/workflows | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workbench/nextjs-pages-router/workflows b/workbench/nextjs-pages-router/workflows index 452b21e36..ca7d3e96d 120000 --- a/workbench/nextjs-pages-router/workflows +++ b/workbench/nextjs-pages-router/workflows @@ -1 +1 @@ -../example/workflows \ No newline at end of file +../nextjs-turbopack/workflows \ No newline at end of file From c738cd07cf7bc6e3b04911b7181d141cd7218469 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 15:09:59 -0800 Subject: [PATCH 10/14] fix: test matrix script --- scripts/create-test-matrix.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/create-test-matrix.mjs b/scripts/create-test-matrix.mjs index a8c254607..165a723dd 100644 --- a/scripts/create-test-matrix.mjs +++ b/scripts/create-test-matrix.mjs @@ -13,10 +13,11 @@ const DEV_TEST_CONFIGS = { apiFileImportPath: '../../..', }, 'nextjs-pages-router': { - generatedStepPath: 'pages/api/.well-known/workflow/v1/step/route.js', - generatedWorkflowPath: 'pages/api/.well-known/workflow/v1/flow/route.js', + generatedStepPath: 'pages/api/.well-known/workflow/v1/step.js', + generatedWorkflowPath: 'pages/api/.well-known/workflow/v1/flow.js', apiFilePath: 'pages/api/chat.ts', apiFileImportPath: '../..', + // Note: Watch mode is not supported for Pages Router, dev tests may need longer timeouts }, nitro: { generatedStepPath: 'node_modules/.nitro/workflow/steps.mjs', From 54ed765936ad46b8abd077c38aa3d85a4575ba9e Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 15:30:42 -0800 Subject: [PATCH 11/14] fix: body parsing hook --- workbench/nextjs-pages-router/pages/api/hook.ts | 2 +- .../nextjs-pages-router/pages/api/test-direct-step-call.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/workbench/nextjs-pages-router/pages/api/hook.ts b/workbench/nextjs-pages-router/pages/api/hook.ts index bad9f5834..4092d6f94 100644 --- a/workbench/nextjs-pages-router/pages/api/hook.ts +++ b/workbench/nextjs-pages-router/pages/api/hook.ts @@ -10,7 +10,7 @@ export default async function handler( return; } - const { token, data } = req.body; + const { token, data } = JSON.parse(req.body); let hook: Awaited>; try { diff --git a/workbench/nextjs-pages-router/pages/api/test-direct-step-call.ts b/workbench/nextjs-pages-router/pages/api/test-direct-step-call.ts index a75da68c6..ce36778ba 100644 --- a/workbench/nextjs-pages-router/pages/api/test-direct-step-call.ts +++ b/workbench/nextjs-pages-router/pages/api/test-direct-step-call.ts @@ -14,7 +14,7 @@ export default async function handler( return; } - const { x, y } = req.body; + const { x, y } = JSON.parse(req.body); console.log(`Calling step function directly with x=${x}, y=${y}`); From c65bab0cceb6fb003373dd94cd61a2f248b0de12 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 15:39:30 -0800 Subject: [PATCH 12/14] fix: json parsing already parsed object in pages router --- .../nextjs-pages-router/pages/api/test-direct-step-call.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workbench/nextjs-pages-router/pages/api/test-direct-step-call.ts b/workbench/nextjs-pages-router/pages/api/test-direct-step-call.ts index ce36778ba..a75da68c6 100644 --- a/workbench/nextjs-pages-router/pages/api/test-direct-step-call.ts +++ b/workbench/nextjs-pages-router/pages/api/test-direct-step-call.ts @@ -14,7 +14,7 @@ export default async function handler( return; } - const { x, y } = JSON.parse(req.body); + const { x, y } = req.body; console.log(`Calling step function directly with x=${x}, y=${y}`); From 0442d59cdac570887aeab141cb477ba8f49b06bf Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 16:02:35 -0800 Subject: [PATCH 13/14] feat: add pages router rebuilding --- packages/next/src/builder.ts | 185 +++++++++++++++++++++++------------ 1 file changed, 122 insertions(+), 63 deletions(-) diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts index 74741c864..85b7a90dd 100644 --- a/packages/next/src/builder.ts +++ b/packages/next/src/builder.ts @@ -163,38 +163,55 @@ async function sendPagesResponse(res, webResponse) { } // Build for Pages Router if present + let pagesRouterConfig: + | { + pagesDir: string; + workflowGeneratedDir: string; + } + | undefined; + if (routers.hasPagesRouter && routers.pagesRouterDir) { + const workflowGeneratedDir = join( + routers.pagesRouterDir, + 'api/.well-known/workflow/v1' + ); await this.buildForPagesRouter( routers.pagesRouterDir, inputFiles, tsConfig ); + pagesRouterConfig = { + pagesDir: routers.pagesRouterDir, + workflowGeneratedDir, + }; } - // Watch mode only supported for App Router currently - // (Pages Router generates static files that don't need rebuild context) - if (this.config.watch && appRouterBuildResult) { - const { stepsBuildContext, workflowsBundle, workflowGeneratedDir } = - appRouterBuildResult; - if (!stepsBuildContext) { - throw new Error( - 'Invariant: expected steps build context in watch mode' - ); - } - if (!workflowsBundle) { - throw new Error('Invariant: expected workflows bundle in watch mode'); - } - - let stepsCtx = stepsBuildContext; - let workflowsCtx = workflowsBundle; + // Watch mode for App Router and/or Pages Router + if (this.config.watch && (appRouterBuildResult || pagesRouterConfig)) { + // App Router watch state (may be undefined if only Pages Router) + let stepsCtx = appRouterBuildResult?.stepsBuildContext; + let workflowsCtx = appRouterBuildResult?.workflowsBundle; // Options object for rebuild functions - const options = { - inputFiles, - workflowGeneratedDir, - tsBaseUrl: tsConfig.baseUrl, - tsPaths: tsConfig.paths, - }; + const appRouterOptions = appRouterBuildResult + ? { + inputFiles, + workflowGeneratedDir: appRouterBuildResult.workflowGeneratedDir, + tsBaseUrl: tsConfig.baseUrl, + tsPaths: tsConfig.paths, + } + : null; + + // Pages Router rebuild options + const pagesRouterOptions = pagesRouterConfig + ? { + inputFiles, + workflowGeneratedDir: pagesRouterConfig.workflowGeneratedDir, + tsBaseUrl: tsConfig.baseUrl, + tsPaths: tsConfig.paths, + pagesDir: pagesRouterConfig.pagesDir, + } + : null; const normalizePath = (pathname: string) => pathname.replace(/\\/g, '/'); @@ -230,8 +247,19 @@ async function sendPagesResponse(res, webResponse) { '/.parcel-cache/', '/.well-known/workflow/', ]; - const normalizedGeneratedDir = workflowGeneratedDir.replace(/\\/g, '/'); - ignoredPathFragments.push(normalizedGeneratedDir); + // Collect all generated directories to ignore + const normalizedGeneratedDirs: string[] = []; + if (appRouterOptions) { + normalizedGeneratedDirs.push( + appRouterOptions.workflowGeneratedDir.replace(/\\/g, '/') + ); + } + if (pagesRouterOptions) { + normalizedGeneratedDirs.push( + pagesRouterOptions.workflowGeneratedDir.replace(/\\/g, '/') + ); + } + ignoredPathFragments.push(...normalizedGeneratedDirs); // There is a node.js bug on MacOS which causes closing file watchers to be really slow. // This limits the number of watchers to mitigate the issue. @@ -248,8 +276,11 @@ async function sendPagesResponse(res, webResponse) { if (extension && !watchableExtensions.has(extension)) { return true; } - if (normalizedPath.startsWith(normalizedGeneratedDir)) { - return true; + // Check if path is in any of the generated directories + for (const genDir of normalizedGeneratedDirs) { + if (normalizedPath.startsWith(genDir)) { + return true; + } } for (const fragment of ignoredPathFragments) { if (normalizedPath.includes(fragment)) { @@ -283,25 +314,38 @@ async function sendPagesResponse(res, webResponse) { const fullRebuild = async () => { const newInputFiles = await this.getInputFiles(); - options.inputFiles = newInputFiles; - await stepsCtx.dispose(); - const newStepsCtx = await this.buildStepsFunction(options); - if (!newStepsCtx) { - throw new Error( - 'Invariant: expected steps build context after rebuild' - ); + // Rebuild App Router if present + if (appRouterOptions && stepsCtx && workflowsCtx) { + appRouterOptions.inputFiles = newInputFiles; + + await stepsCtx.dispose(); + const newStepsCtx = await this.buildStepsFunction(appRouterOptions); + if (!newStepsCtx) { + throw new Error( + 'Invariant: expected steps build context after rebuild' + ); + } + stepsCtx = newStepsCtx; + + await workflowsCtx.interimBundleCtx.dispose(); + const newWorkflowsCtx = + await this.buildWorkflowsFunction(appRouterOptions); + if (!newWorkflowsCtx) { + throw new Error( + 'Invariant: expected workflows bundle context after rebuild' + ); + } + workflowsCtx = newWorkflowsCtx; } - stepsCtx = newStepsCtx; - await workflowsCtx.interimBundleCtx.dispose(); - const newWorkflowsCtx = await this.buildWorkflowsFunction(options); - if (!newWorkflowsCtx) { - throw new Error( - 'Invariant: expected workflows bundle context after rebuild' - ); + // Rebuild Pages Router if present + if (pagesRouterOptions) { + pagesRouterOptions.inputFiles = newInputFiles; + await this.buildStepsFunctionPages(pagesRouterOptions); + await this.buildWorkflowsFunctionPages(pagesRouterOptions); + console.log('Rebuilt Pages Router bundles'); } - workflowsCtx = newWorkflowsCtx; }; const logBuildMessages = ( @@ -330,32 +374,47 @@ async function sendPagesResponse(res, webResponse) { }; const rebuildExistingFiles = async () => { - const rebuiltStepStart = Date.now(); - const stepsResult = await stepsCtx.rebuild(); - logBuildMessages(stepsResult, 'steps bundle'); - console.log( - 'Rebuilt steps bundle', - `${Date.now() - rebuiltStepStart}ms` - ); + // Rebuild App Router if present (uses incremental rebuild) + if (stepsCtx && workflowsCtx) { + const rebuiltStepStart = Date.now(); + const stepsResult = await stepsCtx.rebuild(); + logBuildMessages(stepsResult, 'steps bundle'); + console.log( + 'Rebuilt steps bundle', + `${Date.now() - rebuiltStepStart}ms` + ); - const rebuiltWorkflowStart = Date.now(); - const workflowResult = await workflowsCtx.interimBundleCtx.rebuild(); - logBuildMessages(workflowResult, 'workflows bundle'); + const rebuiltWorkflowStart = Date.now(); + const workflowResult = + await workflowsCtx.interimBundleCtx.rebuild(); + logBuildMessages(workflowResult, 'workflows bundle'); - if ( - !workflowResult.outputFiles || - workflowResult.outputFiles.length === 0 - ) { - console.error( - 'No output generated while rebuilding workflows bundle' + if ( + !workflowResult.outputFiles || + workflowResult.outputFiles.length === 0 + ) { + console.error( + 'No output generated while rebuilding workflows bundle' + ); + return; + } + await workflowsCtx.bundleFinal(workflowResult.outputFiles[0].text); + console.log( + 'Rebuilt workflow bundle', + `${Date.now() - rebuiltWorkflowStart}ms` + ); + } + + // Rebuild Pages Router if present (full rebuild since no incremental support) + if (pagesRouterOptions) { + const rebuiltPagesStart = Date.now(); + await this.buildStepsFunctionPages(pagesRouterOptions); + await this.buildWorkflowsFunctionPages(pagesRouterOptions); + console.log( + 'Rebuilt Pages Router bundles', + `${Date.now() - rebuiltPagesStart}ms` ); - return; } - await workflowsCtx.bundleFinal(workflowResult.outputFiles[0].text); - console.log( - 'Rebuilt workflow bundle', - `${Date.now() - rebuiltWorkflowStart}ms` - ); }; const isWatchableFile = (path: string) => From d0bdf71339cfc642cf6f4595c6a32f76de20027f Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 18 Dec 2025 16:22:31 -0800 Subject: [PATCH 14/14] test: add nextjs pages router prod test --- .github/workflows/tests.yml | 47 +++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 17b53b819..97c3f19d5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,8 +27,8 @@ jobs: id: find-comment with: issue-number: ${{ github.event.pull_request.number }} - comment-author: 'github-actions[bot]' - body-includes: '' + comment-author: "github-actions[bot]" + body-includes: "" - name: Get existing comment body if: steps.find-comment.outputs.comment-id != '' @@ -130,9 +130,9 @@ jobs: - name: Setup environment uses: ./.github/actions/setup-workflow-dev with: - setup-rust: 'true' - install-args: '--ignore-scripts' - build-packages: 'false' + setup-rust: "true" + install-args: "--ignore-scripts" + build-packages: "false" - name: Run Unit Tests run: pnpm test --filter='!./docs' @@ -212,6 +212,9 @@ jobs: - name: "astro" project-id: "prj_YDAXj3K8LM0hgejuIMhioz2yLgTI" project-slug: "workbench-astro-workflow" + - name: "nextjs-pages" + project-id: "prj_9ttXonF6FkgEMx09rWge5AX5dbkH" + project-slug: "workbench-nextjs-pages-router" env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} @@ -222,7 +225,7 @@ jobs: - name: Setup environment uses: ./.github/actions/setup-workflow-dev with: - build-packages: 'false' + build-packages: "false" - name: Build CLI run: pnpm turbo run build --filter='@workflow/cli' @@ -243,7 +246,7 @@ jobs: env: DEPLOYMENT_URL: ${{ steps.waitForDeployment.outputs.deployment-url }} APP_NAME: ${{ matrix.app.name }} - WORKFLOW_VERCEL_SKIP_PROXY: 'true' + WORKFLOW_VERCEL_SKIP_PROXY: "true" WORKFLOW_VERCEL_ENV: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} WORKFLOW_VERCEL_AUTH_TOKEN: ${{ secrets.VERCEL_LABS_TOKEN }} WORKFLOW_VERCEL_TEAM: "team_nO2mCG4W8IxPIeKoSsqwAxxB" @@ -279,8 +282,8 @@ jobs: - name: Setup environment uses: ./.github/actions/setup-workflow-dev with: - install-dependencies: 'false' - build-packages: 'false' + install-dependencies: "false" + build-packages: "false" - id: set-matrix run: echo "matrix=$(node ./scripts/create-test-matrix.mjs)" >> $GITHUB_OUTPUT @@ -304,8 +307,8 @@ jobs: - name: Setup environment uses: ./.github/actions/setup-workflow-dev with: - install-dependencies: 'false' - build-packages: 'false' + install-dependencies: "false" + build-packages: "false" - name: Setup canary if: ${{ matrix.app.canary }} @@ -366,8 +369,8 @@ jobs: - name: Setup environment uses: ./.github/actions/setup-workflow-dev with: - install-dependencies: 'false' - build-packages: 'false' + install-dependencies: "false" + build-packages: "false" - name: Setup canary if: ${{ matrix.app.canary }} @@ -448,8 +451,8 @@ jobs: - name: Setup environment uses: ./.github/actions/setup-workflow-dev with: - install-dependencies: 'false' - build-packages: 'false' + install-dependencies: "false" + build-packages: "false" - name: Setup canary if: ${{ matrix.app.canary }} @@ -572,8 +575,8 @@ jobs: - name: Setup environment uses: ./.github/actions/setup-workflow-dev with: - install-dependencies: 'false' - build-packages: 'false' + install-dependencies: "false" + build-packages: "false" - id: set-matrix run: echo "matrix=$(node ./scripts/create-community-worlds-matrix.mjs)" >> $GITHUB_OUTPUT @@ -598,7 +601,15 @@ jobs: summary: name: E2E Summary runs-on: ubuntu-latest - needs: [e2e-vercel-prod, e2e-local-dev, e2e-local-prod, e2e-local-postgres, e2e-windows, e2e-community] + needs: + [ + e2e-vercel-prod, + e2e-local-dev, + e2e-local-prod, + e2e-local-postgres, + e2e-windows, + e2e-community, + ] if: always() && !cancelled() timeout-minutes: 10