Skip to content

Commit 3a16272

Browse files
authored
feat(world-vercel): allow a custom dispatcher (vercel#2235)
* feat(world-vercel): allow a custom dispatcher Add a `dispatcher` option to `APIConfig`/`createVercelWorld` so callers can supply a custom undici dispatcher. It is threaded through every request path (HTTP and the queue) and defaults to the shared undici RetryAgent. * docs(world-vercel): explain why dispatcher is typed unknown
1 parent fe41b3b commit 3a16272

10 files changed

Lines changed: 68 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@workflow/world-vercel': minor
3+
---
4+
5+
Add a `dispatcher` option to `createVercelWorld` for supplying a custom undici dispatcher, used for both HTTP and queue requests. Defaults to the shared undici `RetryAgent`.

packages/world-vercel/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,15 @@ Integrates with Vercel's infrastructure for storage, queuing, and authentication
66

77
Used by default for deployments on Vercel. Authentication and API endpoints are configured automatically in Vercel deployments.
88

9+
## Custom dispatcher
10+
11+
HTTP requests (including the queue) default to a shared undici `RetryAgent` that handles connection pooling and retries. Pass a custom `dispatcher` to override it — e.g. to tune undici on newer Node runtimes:
12+
13+
```ts
14+
import { Agent } from 'undici';
15+
import { createVercelWorld } from '@workflow/world-vercel';
16+
import { setWorld } from '@workflow/core/runtime';
17+
18+
setWorld(createVercelWorld({ dispatcher: new Agent({ connections: 16 }) }));
19+
```
20+

packages/world-vercel/src/encryption.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ export async function fetchRunKey(
9797
token?: string;
9898
/** Team ID for team-scoped API requests. */
9999
teamId?: string;
100+
/** Custom HTTP dispatcher. Defaults to the shared undici agent. */
101+
dispatcher?: unknown;
100102
}
101103
): Promise<Uint8Array | undefined> {
102104
// Authenticate via provided token (CLI/config), VERCEL_TOKEN env var
@@ -126,7 +128,7 @@ export async function fetchRunKey(
126128
Authorization: `Bearer ${token}`,
127129
},
128130
// @ts-expect-error -- undici dispatcher is accepted by Node.js fetch but not in @types/node's RequestInit
129-
dispatcher: getDispatcher(),
131+
dispatcher: getDispatcher({ dispatcher: options?.dispatcher }),
130132
}
131133
);
132134

@@ -170,7 +172,8 @@ export async function fetchRunKey(
170172
export function createGetEncryptionKeyForRun(
171173
projectId: string | undefined,
172174
teamId?: string,
173-
token?: string
175+
token?: string,
176+
dispatcher?: unknown
174177
): World['getEncryptionKeyForRun'] {
175178
if (!projectId) return undefined;
176179

@@ -215,6 +218,10 @@ export function createGetEncryptionKeyForRun(
215218
// HKDF derivation server-side so the raw deployment key never leaves
216219
// the API boundary.
217220
if (!deploymentId) return undefined;
218-
return fetchRunKey(deploymentId, projectId, runId, { token, teamId });
221+
return fetchRunKey(deploymentId, projectId, runId, {
222+
token,
223+
teamId,
224+
dispatcher,
225+
});
219226
};
220227
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getDispatcher } from './http-client.js';
3+
4+
describe('getDispatcher', () => {
5+
it('returns the shared default dispatcher when none is provided', () => {
6+
expect(getDispatcher()).toBe(getDispatcher());
7+
expect(getDispatcher({})).toBe(getDispatcher());
8+
});
9+
10+
it('returns the caller-supplied dispatcher when provided', () => {
11+
const custom = {};
12+
expect(getDispatcher({ dispatcher: custom })).toBe(custom);
13+
});
14+
});

packages/world-vercel/src/http-client.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
import { Agent, RetryAgent } from 'undici';
2+
import type { APIConfig } from './utils.js';
23

34
let _dispatcher: RetryAgent | undefined;
45

6+
/**
7+
* Resolves the undici dispatcher for a request: the caller's override, or the
8+
* shared default agent.
9+
*/
10+
export function getDispatcher(config?: APIConfig): unknown {
11+
return config?.dispatcher ?? getDefaultDispatcher();
12+
}
13+
514
/**
615
* Returns a shared undici RetryAgent wrapping an Agent.
716
*
817
* - Connection pooling (up to 8 connections per origin)
918
* - Retry: Automatic retry on 429/5xx or network errors with exponential backoff
1019
* - Observes Retry-After header if received and lower than 30s
1120
*/
12-
export function getDispatcher(): RetryAgent {
21+
function getDefaultDispatcher(): RetryAgent {
1322
if (!_dispatcher) {
1423
_dispatcher = new RetryAgent(
1524
new Agent({

packages/world-vercel/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ export function createVercelWorld(config?: APIConfig): World {
3838
getEncryptionKeyForRun: createGetEncryptionKeyForRun(
3939
projectId,
4040
config?.projectConfig?.teamId,
41-
config?.token
41+
config?.token,
42+
config?.dispatcher
4243
),
4344
resolveLatestDeploymentId: createResolveLatestDeploymentId(config),
4445
};

packages/world-vercel/src/queue.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ export function createQueue(config?: APIConfig): Queue {
193193

194194
const clientOptions = {
195195
region,
196-
dispatcher: getDispatcher(),
196+
dispatcher: getDispatcher(config),
197197
transport: dualTransport,
198198
...(usingProxy && {
199199
// final path will be /queues-proxy/api/v3/topic/...

packages/world-vercel/src/refs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export async function resolveRefDescriptor(
110110
const response = await fetch(url, {
111111
method: 'GET',
112112
headers,
113-
dispatcher: getDispatcher(),
113+
dispatcher: getDispatcher(config),
114114
} as any);
115115

116116
span?.setAttributes({

packages/world-vercel/src/resolve-latest-deployment.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function createResolveLatestDeploymentId(
5858
Authorization: `Bearer ${token}`,
5959
},
6060
// @ts-expect-error -- undici dispatcher is accepted by Node.js fetch but not in @types/node's RequestInit
61-
dispatcher: getDispatcher(),
61+
dispatcher: getDispatcher(config),
6262
});
6363

6464
if (!response.ok) {

packages/world-vercel/src/utils.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,17 @@ const getWorkflowServerUrlOverride = (): string =>
108108
export interface APIConfig {
109109
token?: string;
110110
headers?: RequestInit['headers'];
111+
/**
112+
* Custom HTTP dispatcher passed to every `fetch()` call (e.g. an undici
113+
* `Agent`/`RetryAgent`). Defaults to a shared undici `RetryAgent`.
114+
*
115+
* Typed as `unknown` on purpose: undici's `Dispatcher` type is nominally
116+
* version-specific (it differs across v6/v7/v8 and the `undici-types`
117+
* bundled with each `@types/node` major), so a concrete type would reject a
118+
* dispatcher from a different undici version. Callers may pass any undici
119+
* version's dispatcher, or any object implementing the dispatcher contract.
120+
*/
121+
dispatcher?: unknown;
111122
projectConfig?: {
112123
/** The real Vercel project ID (e.g., prj_xxx) */
113124
projectId?: string;
@@ -344,7 +355,7 @@ export async function makeRequest<T>({
344355
try {
345356
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- undici v7 dispatcher types don't match @types/node's RequestInit
346357
response = await fetch(request, {
347-
dispatcher: getDispatcher(),
358+
dispatcher: getDispatcher(config),
348359
} as any);
349360
} catch (error) {
350361
const elapsed = Date.now() - fetchStart;

0 commit comments

Comments
 (0)