Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 7d4cab7

Browse files
authored
Add outboundService option for customising global fetch() target (#629)
`workerd` exposes a `globalOutbound` option for customising which service global `fetch()` calls should be dispatched to. For tests, it's useful to be able to mock responses to certain routes or assert certain requests are made. This new option allows `fetch()` call to be sent to another configured Worker. See the added test for an example. Note support for `undici`'s `MockAgent` API in Miniflare 3 is still planned. This is a lower-level primitive for customising `fetch()` responses.
1 parent c0f32f3 commit 7d4cab7

File tree

5 files changed

+164
-41
lines changed

5 files changed

+164
-41
lines changed

packages/miniflare/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,12 @@ parameter in module format Workers.
315315
handler. This allows you to access data and functions defined in Node.js
316316
from your Worker.
317317

318+
- `outboundService?: string | { network: Network } | { external: ExternalServer } | { disk: DiskDirectory } | (request: Request) => Awaitable<Response>`
319+
320+
Dispatch this Worker's global `fetch()` and `connect()` requests to the
321+
configured service. Service designators follow the same rules above for
322+
`serviceBindings`.
323+
318324
- `routes?: string[]`
319325

320326
Array of route patterns for this Worker. These follow the same

packages/miniflare/src/index.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,11 @@ import {
4444
normaliseDurableObject,
4545
} from "./plugins";
4646
import {
47+
CUSTOM_SERVICE_KNOWN_OUTBOUND,
48+
CustomServiceKind,
4749
JsonErrorSchema,
4850
NameSourceOptions,
51+
ServiceDesignatorSchema,
4952
getUserServiceName,
5053
handlePrettyErrorRequest,
5154
reviveError,
@@ -545,9 +548,15 @@ export class Miniflare {
545548
// TODO: technically may want to keep old versions around so can always
546549
// recover this in case of setOptions()?
547550
const workerIndex = parseInt(customService.substring(0, slashIndex));
548-
const serviceName = customService.substring(slashIndex + 1);
549-
const service =
550-
this.#workerOpts[workerIndex]?.core.serviceBindings?.[serviceName];
551+
const serviceKind = customService[slashIndex + 1] as CustomServiceKind;
552+
const serviceName = customService.substring(slashIndex + 2);
553+
let service: z.infer<typeof ServiceDesignatorSchema> | undefined;
554+
if (serviceKind === CustomServiceKind.UNKNOWN) {
555+
service =
556+
this.#workerOpts[workerIndex]?.core.serviceBindings?.[serviceName];
557+
} else if (serviceName === CUSTOM_SERVICE_KNOWN_OUTBOUND) {
558+
service = this.#workerOpts[workerIndex]?.core.outboundService;
559+
}
551560
// Should only define custom service bindings if `service` is a function
552561
assert(typeof service === "function");
553562
try {

packages/miniflare/src/plugins/core/constants.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,28 @@ const SERVICE_CUSTOM_PREFIX = `${CORE_PLUGIN_NAME}:custom`;
1212
export function getUserServiceName(workerName = "") {
1313
return `${SERVICE_USER_PREFIX}:${workerName}`;
1414
}
15+
16+
// Namespace custom services to avoid conflicts between user-specified names
17+
// and hardcoded Miniflare names
18+
export enum CustomServiceKind {
19+
UNKNOWN = "#", // User specified name (i.e. `serviceBindings`)
20+
KNOWN = "$", // Miniflare specified name (i.e. `outboundService`)
21+
}
22+
23+
export const CUSTOM_SERVICE_KNOWN_OUTBOUND = "outbound";
24+
1525
export function getBuiltinServiceName(
1626
workerIndex: number,
27+
kind: CustomServiceKind,
1728
bindingName: string
1829
) {
19-
return `${SERVICE_BUILTIN_PREFIX}:${workerIndex}:${bindingName}`;
30+
return `${SERVICE_BUILTIN_PREFIX}:${workerIndex}:${kind}${bindingName}`;
2031
}
21-
export function getCustomServiceName(workerIndex: number, bindingName: string) {
22-
return `${SERVICE_CUSTOM_PREFIX}:${workerIndex}:${bindingName}`;
32+
33+
export function getCustomServiceName(
34+
workerIndex: number,
35+
kind: CustomServiceKind,
36+
bindingName: string
37+
) {
38+
return `${SERVICE_CUSTOM_PREFIX}:${workerIndex}:${kind}${bindingName}`;
2339
}

packages/miniflare/src/plugins/core/index.ts

Lines changed: 86 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import SCRIPT_ENTRY from "worker:core/entry";
88
import { z } from "zod";
99
import {
1010
Service,
11+
ServiceDesignator,
1112
Worker_Binding,
1213
Worker_Module,
1314
kVoid,
@@ -31,6 +32,8 @@ import {
3132
parseRoutes,
3233
} from "../shared";
3334
import {
35+
CUSTOM_SERVICE_KNOWN_OUTBOUND,
36+
CustomServiceKind,
3437
SERVICE_ENTRY,
3538
getBuiltinServiceName,
3639
getCustomServiceName,
@@ -82,6 +85,7 @@ export const CoreOptionsSchema = z.intersection(
8285
textBlobBindings: z.record(z.string()).optional(),
8386
dataBlobBindings: z.record(z.string()).optional(),
8487
serviceBindings: z.record(ServiceDesignatorSchema).optional(),
88+
outboundService: ServiceDesignatorSchema.optional(),
8589

8690
unsafeEphemeralDurableObjects: z.boolean().optional(),
8791
})
@@ -139,6 +143,57 @@ export const SCRIPT_CUSTOM_SERVICE = `addEventListener("fetch", (event) => {
139143
event.respondWith(${CoreBindings.SERVICE_LOOPBACK}.fetch(request));
140144
})`;
141145

146+
function getCustomServiceDesignator(
147+
workerIndex: number,
148+
kind: CustomServiceKind,
149+
name: string,
150+
service: z.infer<typeof ServiceDesignatorSchema>
151+
): ServiceDesignator {
152+
let serviceName: string;
153+
if (typeof service === "function") {
154+
// Custom `fetch` function
155+
serviceName = getCustomServiceName(workerIndex, kind, name);
156+
} else if (typeof service === "object") {
157+
// Builtin workerd service: network, external, disk
158+
serviceName = getBuiltinServiceName(workerIndex, kind, name);
159+
} else {
160+
// Regular user worker
161+
serviceName = getUserServiceName(service);
162+
}
163+
return { name: serviceName };
164+
}
165+
166+
function maybeGetCustomServiceService(
167+
workerIndex: number,
168+
kind: CustomServiceKind,
169+
name: string,
170+
service: z.infer<typeof ServiceDesignatorSchema>
171+
): Service | undefined {
172+
if (typeof service === "function") {
173+
// Custom `fetch` function
174+
return {
175+
name: getCustomServiceName(workerIndex, kind, name),
176+
worker: {
177+
serviceWorkerScript: SCRIPT_CUSTOM_SERVICE,
178+
compatibilityDate: "2022-09-01",
179+
bindings: [
180+
{
181+
name: CoreBindings.TEXT_CUSTOM_SERVICE,
182+
text: `${workerIndex}/${kind}${name}`,
183+
},
184+
WORKER_BINDING_SERVICE_LOOPBACK,
185+
],
186+
},
187+
};
188+
} else if (typeof service === "object") {
189+
// Builtin workerd service: network, external, disk
190+
return {
191+
name: getBuiltinServiceName(workerIndex, kind, name),
192+
...service,
193+
};
194+
}
195+
}
196+
142197
const FALLBACK_COMPATIBILITY_DATE = "2000-01-01";
143198

144199
function getCurrentCompatibilityDate() {
@@ -217,20 +272,14 @@ export const CORE_PLUGIN: Plugin<
217272
if (options.serviceBindings !== undefined) {
218273
bindings.push(
219274
...Object.entries(options.serviceBindings).map(([name, service]) => {
220-
let serviceName: string;
221-
if (typeof service === "function") {
222-
// Custom `fetch` function
223-
serviceName = getCustomServiceName(workerIndex, name);
224-
} else if (typeof service === "object") {
225-
// Builtin workerd service: network, external, disk
226-
serviceName = getBuiltinServiceName(workerIndex, name);
227-
} else {
228-
// Regular user worker
229-
serviceName = getUserServiceName(service);
230-
}
231275
return {
232276
name: name,
233-
service: { name: serviceName },
277+
service: getCustomServiceDesignator(
278+
workerIndex,
279+
CustomServiceKind.UNKNOWN,
280+
name,
281+
service
282+
),
234283
};
235284
})
236285
);
@@ -288,6 +337,15 @@ export const CORE_PLUGIN: Plugin<
288337
: options.unsafeEphemeralDurableObjects
289338
? { inMemory: kVoid }
290339
: { localDisk: DURABLE_OBJECTS_STORAGE_SERVICE_NAME },
340+
globalOutbound:
341+
options.outboundService === undefined
342+
? undefined
343+
: getCustomServiceDesignator(
344+
workerIndex,
345+
CustomServiceKind.KNOWN,
346+
CUSTOM_SERVICE_KNOWN_OUTBOUND,
347+
options.outboundService
348+
),
291349
cacheApiOutbound: { name: getCacheServiceName(workerIndex) },
292350
},
293351
},
@@ -296,31 +354,24 @@ export const CORE_PLUGIN: Plugin<
296354
// Define custom `fetch` services if set
297355
if (options.serviceBindings !== undefined) {
298356
for (const [name, service] of Object.entries(options.serviceBindings)) {
299-
if (typeof service === "function") {
300-
// Custom `fetch` function
301-
services.push({
302-
name: getCustomServiceName(workerIndex, name),
303-
worker: {
304-
serviceWorkerScript: SCRIPT_CUSTOM_SERVICE,
305-
compatibilityDate: "2022-09-01",
306-
bindings: [
307-
{
308-
name: CoreBindings.TEXT_CUSTOM_SERVICE,
309-
text: `${workerIndex}/${name}`,
310-
},
311-
WORKER_BINDING_SERVICE_LOOPBACK,
312-
],
313-
},
314-
});
315-
} else if (typeof service === "object") {
316-
// Builtin workerd service: network, external, disk
317-
services.push({
318-
name: getBuiltinServiceName(workerIndex, name),
319-
...service,
320-
});
321-
}
357+
const maybeService = maybeGetCustomServiceService(
358+
workerIndex,
359+
CustomServiceKind.UNKNOWN,
360+
name,
361+
service
362+
);
363+
if (maybeService !== undefined) services.push(maybeService);
322364
}
323365
}
366+
if (options.outboundService !== undefined) {
367+
const maybeService = maybeGetCustomServiceService(
368+
workerIndex,
369+
CustomServiceKind.KNOWN,
370+
CUSTOM_SERVICE_KNOWN_OUTBOUND,
371+
options.outboundService
372+
);
373+
if (maybeService !== undefined) services.push(maybeService);
374+
}
324375

325376
return services;
326377
},

packages/miniflare/test/index.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Miniflare,
1010
MiniflareCoreError,
1111
MiniflareOptions,
12+
Response,
1213
_transformsForContentEncoding,
1314
fetch,
1415
} from "miniflare";
@@ -328,6 +329,46 @@ test("Miniflare: custom service binding to another Miniflare instance", async (t
328329
});
329330
});
330331

332+
test("Miniflare: custom outbound service", async (t) => {
333+
const mf = new Miniflare({
334+
workers: [
335+
{
336+
name: "a",
337+
modules: true,
338+
script: `export default {
339+
async fetch() {
340+
const res1 = await (await fetch("https://example.com/1")).text();
341+
const res2 = await (await fetch("https://example.com/2")).text();
342+
return Response.json({ res1, res2 });
343+
}
344+
}`,
345+
outboundService: "b",
346+
},
347+
{
348+
name: "b",
349+
modules: true,
350+
script: `export default {
351+
async fetch(request, env) {
352+
if (request.url === "https://example.com/1") {
353+
return new Response("one");
354+
} else {
355+
return fetch(request);
356+
}
357+
}
358+
}`,
359+
outboundService(request) {
360+
return new Response(`fallback:${request.url}`);
361+
},
362+
},
363+
],
364+
});
365+
const res = await mf.dispatchFetch("http://localhost");
366+
t.deepEqual(await res.json(), {
367+
res1: "one",
368+
res2: "fallback:https://example.com/2",
369+
});
370+
});
371+
331372
test("Miniflare: custom upstream as origin", async (t) => {
332373
const upstream = await useServer(t, (req, res) => {
333374
res.end(`upstream: ${new URL(req.url ?? "", "http://upstream")}`);

0 commit comments

Comments
 (0)