Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/expose-public-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/nitro": patch
---

Fix Nitro prod builds: use a physical handler file with inlined manifest content instead of a virtual module with `readFileSync` that referenced an absolute build-machine path
62 changes: 6 additions & 56 deletions packages/core/e2e/bench.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,6 @@ if (!deploymentUrl) {
throw new Error('`DEPLOYMENT_URL` environment variable is not set');
}

console.log('[bench] deploymentUrl:', deploymentUrl);
console.log('[bench] isLocalDeployment:', isLocalDeployment());
console.log('[bench] WORKFLOW_VERCEL_ENV:', process.env.WORKFLOW_VERCEL_ENV);
console.log('[bench] VERCEL_DEPLOYMENT_ID:', process.env.VERCEL_DEPLOYMENT_ID);
console.log(
'[bench] WORKFLOW_TARGET_WORLD:',
process.env.WORKFLOW_TARGET_WORLD
);

// Configure the World for the bench runner process (same as e2e tests)
if (isLocalDeployment()) {
process.env.WORKFLOW_LOCAL_BASE_URL = deploymentUrl;
Expand All @@ -31,19 +22,12 @@ if (isLocalDeployment()) {
const isNextJs = appName.includes('nextjs') || appName.includes('next-');
const dataDirName = isNextJs ? '.next/workflow-data' : '.workflow-data';
process.env.WORKFLOW_LOCAL_DATA_DIR = path.join(appPath, dataDirName);
console.log(
'[bench] configured local world, dataDir:',
process.env.WORKFLOW_LOCAL_DATA_DIR
);
} else if (process.env.WORKFLOW_VERCEL_ENV) {
if (!process.env.VERCEL_DEPLOYMENT_ID) {
throw new Error(
'VERCEL_DEPLOYMENT_ID is required for Vercel benchmarks but is not set'
);
}
console.log('[bench] configured for Vercel world');
} else {
console.log('[bench] no special world configuration');
}

// Manifest type and helpers (same as e2e tests)
Expand All @@ -61,22 +45,15 @@ let cachedManifest: WorkflowManifest | null = null;
async function fetchManifest(): Promise<WorkflowManifest> {
if (cachedManifest) return cachedManifest;
const url = new URL('/.well-known/workflow/v1/manifest.json', deploymentUrl);
console.log('[bench] fetching manifest from:', url.toString());
const res = await fetch(url, {
headers: getProtectionBypassHeaders(),
signal: AbortSignal.timeout(30_000),
redirect: 'follow',
});
console.log('[bench] manifest response status:', res.status, 'url:', res.url);
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to fetch manifest: ${res.status} ${text}`);
}
cachedManifest = (await res.json()) as WorkflowManifest;
console.log(
'[bench] manifest loaded, workflows:',
Object.keys(cachedManifest.workflows).join(', ')
);
return cachedManifest;
}

Expand Down Expand Up @@ -133,27 +110,6 @@ const bufferedTimings: Map<
{ run: any; extra?: { firstByteTimeMs?: number; slurpTimeMs?: number } }[]
> = new Map();

/**
* Await run.returnValue with a timeout to prevent benchmarks from hanging.
*/
async function awaitReturnValue<T>(
run: Run<T>,
timeoutMs = 120_000
): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error(
`run.returnValue timed out after ${timeoutMs}ms for run ${run.runId}`
)
),
timeoutMs
)
);
return Promise.race([run.returnValue, timeout]);
}

/**
* Collect run timing metadata from a completed run.
*/
Expand Down Expand Up @@ -328,14 +284,8 @@ describe('Workflow Performance Benchmarks', () => {
bench(
'workflow with no steps',
async () => {
console.log('[bench] resolving workflow metadata...');
const wf = await benchWf('noStepsWorkflow');
console.log('[bench] calling start() with workflowId:', wf.workflowId);
const run = await start(wf, [42]);
console.log('[bench] start() returned, runId:', run.runId);
console.log('[bench] awaiting returnValue...');
await awaitReturnValue(run);
console.log('[bench] returnValue resolved');
const run = await start(await benchWf('noStepsWorkflow'), [42]);
await run.returnValue;
const timings = await getRunTimings(run);
Comment thread
TooTallNate marked this conversation as resolved.
stageTiming('workflow with no steps', timings);
},
Expand All @@ -346,7 +296,7 @@ describe('Workflow Performance Benchmarks', () => {
'workflow with 1 step',
async () => {
const run = await start(await benchWf('oneStepWorkflow'), [100]);
await awaitReturnValue(run);
await run.returnValue;
const timings = await getRunTimings(run);
stageTiming('workflow with 1 step', timings);
},
Expand Down Expand Up @@ -374,7 +324,7 @@ describe('Workflow Performance Benchmarks', () => {
const run = await start(await benchWf('sequentialStepsWorkflow'), [
count,
]);
await awaitReturnValue(run);
await run.returnValue;
const timings = await getRunTimings(run);
stageTiming(name, timings);
},
Expand All @@ -386,7 +336,7 @@ describe('Workflow Performance Benchmarks', () => {
'workflow with stream',
async () => {
const run = await start(await benchWf('streamWorkflow'), []);
const value = await awaitReturnValue(run);
const value = await run.returnValue;
const timings = await getRunTimings(run);
// Consume the entire stream and track:
// - firstByteTimeMs: time from workflow start to first byte
Expand Down Expand Up @@ -446,7 +396,7 @@ describe('Workflow Performance Benchmarks', () => {
name,
async () => {
const run = await start(await benchWf(workflow), [count]);
await awaitReturnValue(run);
await run.returnValue;
const timings = await getRunTimings(run);
stageTiming(name, timings);
},
Expand Down
141 changes: 50 additions & 91 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import fs from 'fs';
import path from 'path';
import { afterAll, assert, beforeAll, describe, expect, test } from 'vitest';
import type { Run } from '../src/runtime';
import { getRun, start } from '../src/runtime';
import {
getHookByToken,
getRun,
getWorld,
healthCheck,
resumeHook,
start,
} from '../src/runtime';
import {
cliHealthJson,
cliInspectJson,
Expand Down Expand Up @@ -323,51 +330,37 @@ describe('e2e', () => {

const run = await start(await e2e('hookWorkflow'), [token, customData]);

// Wait a few seconds so that the webhook is registered.
// Wait a few seconds so that the hook is registered.
// TODO: make this more efficient when we add subscription support.
await new Promise((resolve) => setTimeout(resolve, 5_000));

const hookUrl = new URL('/api/hook', deploymentUrl);

let res = await fetch(hookUrl, {
method: 'POST',
headers: getProtectionBypassHeaders(),
body: JSON.stringify({ token, data: { message: 'one' } }),
// Look up the hook and resume it with the first payload
let hook = await getHookByToken(token);
expect(hook.runId).toBe(run.runId);
await resumeHook(hook, {
message: 'one',
customData: (hook.metadata as any)?.customData,
});
Comment thread
TooTallNate marked this conversation as resolved.
expect(res.status).toBe(200);
let body = await res.json();
expect(body.runId).toBe(run.runId);

// Invalid token test
res = await fetch(hookUrl, {
method: 'POST',
headers: getProtectionBypassHeaders(),
body: JSON.stringify({ token: 'invalid' }),
});
// NOTE: For Nitro apps (Vite, Hono, etc.) in dev mode, status 404 does some
// unexpected stuff and could return a Vite SPA fallback or can cause a Hono route to hang.
// This is because Nitro passes the 404 requests to the dev server to handle.
expect(res.status).toBeOneOf([404, 422]);
body = await res.json();
expect(body).toBeNull();

res = await fetch(hookUrl, {
method: 'POST',
headers: getProtectionBypassHeaders(),
body: JSON.stringify({ token, data: { message: 'two' } }),
await expect(getHookByToken('invalid')).rejects.toThrow(/not found/i);

// Resume with second payload
hook = await getHookByToken(token);
expect(hook.runId).toBe(run.runId);
await resumeHook(hook, {
message: 'two',
customData: (hook.metadata as any)?.customData,
});
expect(res.status).toBe(200);
body = await res.json();
expect(body.runId).toBe(run.runId);

res = await fetch(hookUrl, {
method: 'POST',
headers: getProtectionBypassHeaders(),
body: JSON.stringify({ token, data: { message: 'three', done: true } }),
// Resume with third (final) payload
hook = await getHookByToken(token);
expect(hook.runId).toBe(run.runId);
await resumeHook(hook, {
message: 'three',
done: true,
customData: (hook.metadata as any)?.customData,
});
expect(res.status).toBe(200);
body = await res.json();
expect(body.runId).toBe(run.runId);

const returnValue = await run.returnValue;
expect(returnValue).toBeInstanceOf(Array);
Expand Down Expand Up @@ -961,20 +954,13 @@ describe('e2e', () => {
await new Promise((resolve) => setTimeout(resolve, 5_000));

// Send payload to first workflow
const hookUrl = new URL('/api/hook', deploymentUrl);
let res = await fetch(hookUrl, {
method: 'POST',
headers: getProtectionBypassHeaders(),
body: JSON.stringify({
token,
data: { message: 'test-message-1', customData },
}),
let hook = await getHookByToken(token);
expect(hook.runId).toBe(run1.runId);
await resumeHook(hook, {
message: 'test-message-1',
customData: (hook.metadata as any)?.customData,
});
Comment thread
TooTallNate marked this conversation as resolved.

expect(res.status).toBe(200);
let body = await res.json();
expect(body.runId).toBe(run1.runId);

// Get first workflow result
const run1Result = await run1.returnValue;
expect(run1Result).toMatchObject({
Expand All @@ -993,19 +979,13 @@ describe('e2e', () => {
await new Promise((resolve) => setTimeout(resolve, 5_000));

// Send payload to second workflow using same token
res = await fetch(hookUrl, {
method: 'POST',
headers: getProtectionBypassHeaders(),
body: JSON.stringify({
token,
data: { message: 'test-message-2', customData },
}),
hook = await getHookByToken(token);
expect(hook.runId).toBe(run2.runId);
await resumeHook(hook, {
message: 'test-message-2',
customData: (hook.metadata as any)?.customData,
});

expect(res.status).toBe(200);
body = await res.json();
expect(body.runId).toBe(run2.runId);

// Get second workflow result
const run2Result = await run2.returnValue;
expect(run2Result).toMatchObject({
Expand Down Expand Up @@ -1059,16 +1039,11 @@ describe('e2e', () => {
expect(run2Data.status).toBe('failed');

// Now send a payload to complete workflow 1
const hookUrl = new URL('/api/hook', deploymentUrl);
const res = await fetch(hookUrl, {
method: 'POST',
headers: getProtectionBypassHeaders(),
body: JSON.stringify({
token,
data: { message: 'test-concurrent', customData },
}),
const hook = await getHookByToken(token);
await resumeHook(hook, {
message: 'test-concurrent',
customData: (hook.metadata as any)?.customData,
});
expect(res.status).toBe(200);

// Verify workflow 1 completed successfully
const run1Result = await run1.returnValue;
Expand Down Expand Up @@ -1250,35 +1225,19 @@ describe('e2e', () => {
'health check (queue-based) - workflow and step endpoints respond to health check messages',
{ timeout: 60_000 },
async () => {
// NOTE: This tests the queue-based health check using healthCheck() function.
// This approach bypasses Vercel Deployment Protection by sending messages
// Tests the queue-based health check using healthCheck() directly.
// This bypasses Vercel Deployment Protection by sending messages
// through the Queue infrastructure rather than direct HTTP.
const url = new URL('/api/test-health-check', deploymentUrl);
const world = getWorld();

// Test workflow endpoint health check
const workflowRes = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getProtectionBypassHeaders(),
},
body: JSON.stringify({ endpoint: 'workflow', timeout: 30000 }),
const workflowResult = await healthCheck(world, 'workflow', {
timeout: 30000,
});
expect(workflowRes.status).toBe(200);
const workflowResult = await workflowRes.json();
expect(workflowResult.healthy).toBe(true);

// Test step endpoint health check
const stepRes = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getProtectionBypassHeaders(),
},
body: JSON.stringify({ endpoint: 'step', timeout: 30000 }),
});
expect(stepRes.status).toBe(200);
const stepResult = await stepRes.json();
const stepResult = await healthCheck(world, 'step', { timeout: 30000 });
expect(stepResult.healthy).toBe(true);
}
);
Expand Down
27 changes: 0 additions & 27 deletions workbench/astro/src/pages/api/hook.ts

This file was deleted.

Loading
Loading