Skip to content
Draft
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
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"undici": "^8.12.4",
"use-sync-external-store": "^1.7.0",
"v0": "3.0.13",
"workflow": "^4.8.9",
"workflow": "^5.2.3",
"yaml": "^2.9.1",
"zod": "^4.6.7",
"zustand": "^5.0.16"
Expand Down
21 changes: 16 additions & 5 deletions apps/web/scripts/patch-world-vercel-undici-fetch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
* Agent as `dispatcher`. Those are different undici class copies, so
* dispatch() throws "Cannot read private member #P".
*
* Remove the custom dispatcher from world-vercel call sites that ultimately
* reach Node/Next global fetch. For world-local, use npm `undici.fetch` with
* the npm `Agent` so its long-running-step timeout configuration is preserved.
* Remove the custom dispatcher where a Request must reach Node/Next global
* fetch. For string URLs in Workflow 5's shared HTTP core and world-local,
* use npm `undici.fetch` with the npm `Agent` so transport settings survive.
* Idempotent. Safe to skip if the package is absent.
*
* Lives under apps/web. Root .vercelignore must use `/scripts/` (anchored).
Expand All @@ -23,6 +23,8 @@ export function patchSource(src) {
const hasAffectedCallSite =
src.includes('await fetch(') ||
src.includes('await undiciFetch(') ||
src.includes(': fetch(url, {') ||
src.includes(': undiciFetch(url, {') ||
src.includes('dispatcher: getDispatcher(');
if (!hasAffectedCallSite) {
return { src, result: 'no-fetch-call' };
Expand All @@ -31,17 +33,26 @@ export function patchSource(src) {
// Keep global/Next fetch. undici.fetch rejects Request objects
// ("Failed to parse URL from [object Request]") which getRun uses.
// dpl_BBme started (runId) but status poll 500'd after the rename.
if (next.includes('fetch as undiciFetch')) {
if (next.includes('await undiciFetch(')) {
next = next.replace(
/^import \{ fetch as undiciFetch \} from 'undici';\n/m,
'',
);
next = next.replaceAll('await undiciFetch(', 'await fetch(');
}
// Workflow 5's shared HTTP core uses a string URL. Keep its custom Agent
// paired with npm undici instead of passing it to Node/Next global fetch.
if (next.includes(': fetch(url, {') && next.includes('dispatcher,')) {
next = "import { fetch as undiciFetch } from 'undici';\n" + next;
next = next.replace(': fetch(url, {', ': undiciFetch(url, {');
}
// Do not pass Agent across fetch (#P / #dispatch).
next = next
.replace(/\s*dispatcher:\s*getDispatcher\([^)]*\),?/g, '')
.replace(/^\s*dispatcher,\s*$/gm, '');
.replace(/^\s*dispatcher:\s*undiciDispatcher,\s*$/gm, '');
if (!next.includes(': undiciFetch(url, {')) {
next = next.replace(/^\s*dispatcher,\s*$/gm, '');
}
if (next === src) {
return { src, result: 'already-patched' };
}
Expand Down
122 changes: 120 additions & 2 deletions apps/web/src/lib/__tests__/patch-world-vercel-undici-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
Expand Down Expand Up @@ -34,6 +35,19 @@ export function createQueue(config) {
}
`;

const WORKFLOW_5_UTILS_FIXTURE = `const response = await fetch(undiciRequest, {
dispatcher: undiciDispatcher,
});
`;

const WORKFLOW_5_HTTP_CORE_FIXTURE = `const pending = nodeAgents
? nodeHttpFetch(url, { method })
: fetch(url, {
method,
dispatcher,
});
`;

const LOCAL_QUEUE_FIXTURE = `import { Agent } from 'undici';
export async function deliver(url, httpAgent) {
return (response = await fetch(createWorkflowUrl(url), {
Expand Down Expand Up @@ -85,6 +99,23 @@ describe('patch-world-vercel-undici-fetch (issue #1538)', () => {
expect(src).not.toMatch(/dispatcher:\s*getDispatcher/);
});

it('keeps Workflow 5 Request inputs on global fetch without a foreign Agent', () => {
const { src, result } = patchSource(WORKFLOW_5_UTILS_FIXTURE);
expect(result).toBe('patched');
expect(src).toContain('await fetch(undiciRequest, {');
expect(src).not.toContain('dispatcher: undiciDispatcher');
expect(patchSource(src)).toEqual({ src, result: 'already-patched' });
});

it('pairs Workflow 5 string URL fetches with their npm undici Agent', () => {
const { src, result } = patchSource(WORKFLOW_5_HTTP_CORE_FIXTURE);
expect(result).toBe('patched');
expect(src).toContain("import { fetch as undiciFetch } from 'undici';");
expect(src).toContain(': undiciFetch(url, {');
expect(src).toMatch(/^\s*dispatcher,\s*$/m);
expect(patchSource(src)).toEqual({ src, result: 'already-patched' });
});

it('pairs world-local fetch with its npm Agent and preserves dispatcher settings', () => {
const { src, result } = patchWorldLocalSource(LOCAL_QUEUE_FIXTURE);
expect(result).toBe('patched');
Expand Down Expand Up @@ -163,10 +194,12 @@ describe('patch-world-vercel-undici-fetch (issue #1538)', () => {
'utf8',
);
expect(utils).toContain('await fetch(');
expect(httpCore).toContain('await fetch(');
expect(httpCore).toContain(': undiciFetch(url, {');
expect(httpCore).toContain("import { fetch as undiciFetch } from 'undici';");
expect(utils).not.toContain('undiciFetch');
expect(utils).not.toMatch(/dispatcher:\s*getDispatcher/);
expect(httpCore).not.toMatch(/^\s*dispatcher,\s*$/m);
expect(httpCore).toMatch(/^\s*dispatcher,\s*$/m);
expect(utils).not.toContain('dispatcher: undiciDispatcher');
expect(queue).not.toMatch(/dispatcher:\s*getDispatcher/);
expect(localQueue).toContain("fetch as undiciFetch } from 'undici'");
// world-local 4.2.x patches fetch(createWorkflowUrl(...)). Newer builds
Expand All @@ -177,4 +210,89 @@ describe('patch-world-vercel-undici-fetch (issue #1538)', () => {
expect(patchedLegacy || patchedUrl || nodeHttp).toBe(true);
expect(localQueue).toMatch(/dispatcher:\s*httpAgent/);
});
it('executes the installed Workflow 5 HTTP core with its own Agent', () => {
patchInstalledWorldVercel();
const worldRoot = resolveWorldVercelRoot();
expect(worldRoot).not.toBeNull();
// A native child preserves the package's own module resolution and Agent
// class. The server is loopback-only; global fetch must never be selected.
const script = String.raw`
import assert from 'node:assert/strict';
import http from 'node:http';
import path from 'node:path';
import { once } from 'node:events';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
const worldRoot = process.argv[1];
const require = createRequire(path.join(worldRoot, 'package.json'));
const { Agent } = require('undici');
const { instrumentedFetch } = await import(
pathToFileURL(path.join(worldRoot, 'dist/http-core.js')).href
);
let received = [];
let dispatched = 0;
let globalFetchCalls = 0;
globalThis.fetch = async () => {
globalFetchCalls++;
throw new Error('Workflow selected a foreign global fetch');
};
const server = http.createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
received.push({
url: request.url, method: request.method,
body: Buffer.concat(chunks).toString(), headers: request.headers,
});
if (request.url === '/slow') return;
response.writeHead(request.url === '/failure' ? 503 : 200, {
'content-type': 'application/json',
});
response.end(JSON.stringify({ ok: request.url === '/ok' }));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const origin = 'http://127.0.0.1:' + server.address().port;
class CountingAgent extends Agent {
dispatch(options, handler) {
assert.equal(String(options.origin), origin);
dispatched++;
return super.dispatch(options, handler);
}
}
const dispatcher = new CountingAgent({ connections: 1 });
const request = (route, extra = {}) => instrumentedFetch({
method: 'POST', url: origin + route,
headers: new Headers({ 'x-workflow-regression': 'local' }),
body: 'workflow transport payload', dispatcher,
peerService: 'workflow-loopback', injectTraceContext: false,
timeoutMs: 2000, ...extra,
});
try {
const response = await request('/ok');
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { ok: true });
assert.equal(received[0].method, 'POST');
assert.equal(received[0].body, 'workflow transport payload');
assert.equal(received[0].headers['x-workflow-regression'], 'local');
assert.match(received[0].headers['x-request-time'], /^\d+$/);
await assert.rejects(request('/failure'), error => error.status === 503);
await assert.rejects(request('/slow', { timeoutMs: 500 }),
error => error.code === 'TIMEOUT');
assert.equal(dispatched, 3);
assert.equal(globalFetchCalls, 0);
assert.deepEqual(received.map(value => value.url), ['/ok', '/failure', '/slow']);
console.log(JSON.stringify({ requests: received.length, dispatched, globalFetchCalls }));
} finally {
await dispatcher.destroy();
server.closeAllConnections();
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
}
`;
const output = execFileSync(process.execPath, ['--input-type=module', '--eval', script, worldRoot!], {
encoding: 'utf8', timeout: 10_000,
env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, NODE_ENV: 'test' },
});
expect(JSON.parse(output.trim())).toEqual({ requests: 3, dispatched: 3, globalFetchCalls: 0 });
}, 15_000);

});
2 changes: 1 addition & 1 deletion apps/web/src/lib/video-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { canonicalYouTubeSource, resolveYouTubeSourceId } from '@/lib/video-sour
import { createHash } from 'node:crypto';
import { reasonEnvelope, reasonEnvelopeJson } from '@/lib/api-reason-envelope';
import { canonicalReviewContent, invalidGroundedSpec, parseGroundedSpec, sourceForGroundedSpec, type GroundedSpecRecord } from '@/lib/grounded-build-spec';
import { start } from 'workflow/api';
import { NextResponse } from 'next/server';
import { resolveVideoUrl } from '@/lib/video-url-request';
import { withWorldVercelFetch } from '@/lib/world-vercel-fetch';
Expand Down Expand Up @@ -268,6 +267,7 @@ const SOURCE_HASH = /^[a-f0-9]{64}$/;
type StartVideoPackExtraction = (identity: VideoPackV0Json) => Promise<void>;

async function startDurableVideoPackExtraction(identity: VideoPackV0Json): Promise<void> {
const { start } = await import('workflow/api');
await withWorldVercelFetch(async () => {
await start(videoPackExtractionWorkflow, [identity]);
});
Expand Down
7 changes: 7 additions & 0 deletions apps/web/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"git": {
"deploymentEnabled": {
"dependabot/npm_and_yarn/apps/web/workflow-5.1.0": false
}
}
}
Loading
Loading