From edaef4374a1c2214546103aac255afa8c8aaba2b Mon Sep 17 00:00:00 2001 From: bcoll Date: Fri, 14 Jul 2023 22:51:59 +0100 Subject: [PATCH] Add `outboundService` option for customising global `fetch()` target `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. --- packages/miniflare/README.md | 6 + packages/miniflare/src/index.ts | 15 ++- .../miniflare/src/plugins/core/constants.ts | 22 +++- packages/miniflare/src/plugins/core/index.ts | 121 +++++++++++++----- packages/miniflare/test/index.spec.ts | 41 ++++++ 5 files changed, 164 insertions(+), 41 deletions(-) diff --git a/packages/miniflare/README.md b/packages/miniflare/README.md index 2b2b61372..01f9a1dc4 100644 --- a/packages/miniflare/README.md +++ b/packages/miniflare/README.md @@ -315,6 +315,12 @@ parameter in module format Workers. handler. This allows you to access data and functions defined in Node.js from your Worker. +- `outboundService?: string | { network: Network } | { external: ExternalServer } | { disk: DiskDirectory } | (request: Request) => Awaitable` + + Dispatch this Worker's global `fetch()` and `connect()` requests to the + configured service. Service designators follow the same rules above for + `serviceBindings`. + - `routes?: string[]` Array of route patterns for this Worker. These follow the same diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 45e6820ac..2b8027ec6 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -44,8 +44,11 @@ import { normaliseDurableObject, } from "./plugins"; import { + CUSTOM_SERVICE_KNOWN_OUTBOUND, + CustomServiceKind, JsonErrorSchema, NameSourceOptions, + ServiceDesignatorSchema, getUserServiceName, handlePrettyErrorRequest, reviveError, @@ -545,9 +548,15 @@ export class Miniflare { // TODO: technically may want to keep old versions around so can always // recover this in case of setOptions()? const workerIndex = parseInt(customService.substring(0, slashIndex)); - const serviceName = customService.substring(slashIndex + 1); - const service = - this.#workerOpts[workerIndex]?.core.serviceBindings?.[serviceName]; + const serviceKind = customService[slashIndex + 1] as CustomServiceKind; + const serviceName = customService.substring(slashIndex + 2); + let service: z.infer | undefined; + if (serviceKind === CustomServiceKind.UNKNOWN) { + service = + this.#workerOpts[workerIndex]?.core.serviceBindings?.[serviceName]; + } else if (serviceName === CUSTOM_SERVICE_KNOWN_OUTBOUND) { + service = this.#workerOpts[workerIndex]?.core.outboundService; + } // Should only define custom service bindings if `service` is a function assert(typeof service === "function"); try { diff --git a/packages/miniflare/src/plugins/core/constants.ts b/packages/miniflare/src/plugins/core/constants.ts index 5f3099f92..086f6e527 100644 --- a/packages/miniflare/src/plugins/core/constants.ts +++ b/packages/miniflare/src/plugins/core/constants.ts @@ -12,12 +12,28 @@ const SERVICE_CUSTOM_PREFIX = `${CORE_PLUGIN_NAME}:custom`; export function getUserServiceName(workerName = "") { return `${SERVICE_USER_PREFIX}:${workerName}`; } + +// Namespace custom services to avoid conflicts between user-specified names +// and hardcoded Miniflare names +export enum CustomServiceKind { + UNKNOWN = "#", // User specified name (i.e. `serviceBindings`) + KNOWN = "$", // Miniflare specified name (i.e. `outboundService`) +} + +export const CUSTOM_SERVICE_KNOWN_OUTBOUND = "outbound"; + export function getBuiltinServiceName( workerIndex: number, + kind: CustomServiceKind, bindingName: string ) { - return `${SERVICE_BUILTIN_PREFIX}:${workerIndex}:${bindingName}`; + return `${SERVICE_BUILTIN_PREFIX}:${workerIndex}:${kind}${bindingName}`; } -export function getCustomServiceName(workerIndex: number, bindingName: string) { - return `${SERVICE_CUSTOM_PREFIX}:${workerIndex}:${bindingName}`; + +export function getCustomServiceName( + workerIndex: number, + kind: CustomServiceKind, + bindingName: string +) { + return `${SERVICE_CUSTOM_PREFIX}:${workerIndex}:${kind}${bindingName}`; } diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index cdef894fc..b044959cf 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -8,6 +8,7 @@ import SCRIPT_ENTRY from "worker:core/entry"; import { z } from "zod"; import { Service, + ServiceDesignator, Worker_Binding, Worker_Module, kVoid, @@ -31,6 +32,8 @@ import { parseRoutes, } from "../shared"; import { + CUSTOM_SERVICE_KNOWN_OUTBOUND, + CustomServiceKind, SERVICE_ENTRY, getBuiltinServiceName, getCustomServiceName, @@ -82,6 +85,7 @@ export const CoreOptionsSchema = z.intersection( textBlobBindings: z.record(z.string()).optional(), dataBlobBindings: z.record(z.string()).optional(), serviceBindings: z.record(ServiceDesignatorSchema).optional(), + outboundService: ServiceDesignatorSchema.optional(), unsafeEphemeralDurableObjects: z.boolean().optional(), }) @@ -139,6 +143,57 @@ export const SCRIPT_CUSTOM_SERVICE = `addEventListener("fetch", (event) => { event.respondWith(${CoreBindings.SERVICE_LOOPBACK}.fetch(request)); })`; +function getCustomServiceDesignator( + workerIndex: number, + kind: CustomServiceKind, + name: string, + service: z.infer +): ServiceDesignator { + let serviceName: string; + if (typeof service === "function") { + // Custom `fetch` function + serviceName = getCustomServiceName(workerIndex, kind, name); + } else if (typeof service === "object") { + // Builtin workerd service: network, external, disk + serviceName = getBuiltinServiceName(workerIndex, kind, name); + } else { + // Regular user worker + serviceName = getUserServiceName(service); + } + return { name: serviceName }; +} + +function maybeGetCustomServiceService( + workerIndex: number, + kind: CustomServiceKind, + name: string, + service: z.infer +): Service | undefined { + if (typeof service === "function") { + // Custom `fetch` function + return { + name: getCustomServiceName(workerIndex, kind, name), + worker: { + serviceWorkerScript: SCRIPT_CUSTOM_SERVICE, + compatibilityDate: "2022-09-01", + bindings: [ + { + name: CoreBindings.TEXT_CUSTOM_SERVICE, + text: `${workerIndex}/${kind}${name}`, + }, + WORKER_BINDING_SERVICE_LOOPBACK, + ], + }, + }; + } else if (typeof service === "object") { + // Builtin workerd service: network, external, disk + return { + name: getBuiltinServiceName(workerIndex, kind, name), + ...service, + }; + } +} + const FALLBACK_COMPATIBILITY_DATE = "2000-01-01"; function getCurrentCompatibilityDate() { @@ -217,20 +272,14 @@ export const CORE_PLUGIN: Plugin< if (options.serviceBindings !== undefined) { bindings.push( ...Object.entries(options.serviceBindings).map(([name, service]) => { - let serviceName: string; - if (typeof service === "function") { - // Custom `fetch` function - serviceName = getCustomServiceName(workerIndex, name); - } else if (typeof service === "object") { - // Builtin workerd service: network, external, disk - serviceName = getBuiltinServiceName(workerIndex, name); - } else { - // Regular user worker - serviceName = getUserServiceName(service); - } return { name: name, - service: { name: serviceName }, + service: getCustomServiceDesignator( + workerIndex, + CustomServiceKind.UNKNOWN, + name, + service + ), }; }) ); @@ -288,6 +337,15 @@ export const CORE_PLUGIN: Plugin< : options.unsafeEphemeralDurableObjects ? { inMemory: kVoid } : { localDisk: DURABLE_OBJECTS_STORAGE_SERVICE_NAME }, + globalOutbound: + options.outboundService === undefined + ? undefined + : getCustomServiceDesignator( + workerIndex, + CustomServiceKind.KNOWN, + CUSTOM_SERVICE_KNOWN_OUTBOUND, + options.outboundService + ), cacheApiOutbound: { name: getCacheServiceName(workerIndex) }, }, }, @@ -296,31 +354,24 @@ export const CORE_PLUGIN: Plugin< // Define custom `fetch` services if set if (options.serviceBindings !== undefined) { for (const [name, service] of Object.entries(options.serviceBindings)) { - if (typeof service === "function") { - // Custom `fetch` function - services.push({ - name: getCustomServiceName(workerIndex, name), - worker: { - serviceWorkerScript: SCRIPT_CUSTOM_SERVICE, - compatibilityDate: "2022-09-01", - bindings: [ - { - name: CoreBindings.TEXT_CUSTOM_SERVICE, - text: `${workerIndex}/${name}`, - }, - WORKER_BINDING_SERVICE_LOOPBACK, - ], - }, - }); - } else if (typeof service === "object") { - // Builtin workerd service: network, external, disk - services.push({ - name: getBuiltinServiceName(workerIndex, name), - ...service, - }); - } + const maybeService = maybeGetCustomServiceService( + workerIndex, + CustomServiceKind.UNKNOWN, + name, + service + ); + if (maybeService !== undefined) services.push(maybeService); } } + if (options.outboundService !== undefined) { + const maybeService = maybeGetCustomServiceService( + workerIndex, + CustomServiceKind.KNOWN, + CUSTOM_SERVICE_KNOWN_OUTBOUND, + options.outboundService + ); + if (maybeService !== undefined) services.push(maybeService); + } return services; }, diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index f5ed12912..af7b333ae 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -9,6 +9,7 @@ import { Miniflare, MiniflareCoreError, MiniflareOptions, + Response, _transformsForContentEncoding, fetch, } from "miniflare"; @@ -328,6 +329,46 @@ test("Miniflare: custom service binding to another Miniflare instance", async (t }); }); +test("Miniflare: custom outbound service", async (t) => { + const mf = new Miniflare({ + workers: [ + { + name: "a", + modules: true, + script: `export default { + async fetch() { + const res1 = await (await fetch("https://example.com/1")).text(); + const res2 = await (await fetch("https://example.com/2")).text(); + return Response.json({ res1, res2 }); + } + }`, + outboundService: "b", + }, + { + name: "b", + modules: true, + script: `export default { + async fetch(request, env) { + if (request.url === "https://example.com/1") { + return new Response("one"); + } else { + return fetch(request); + } + } + }`, + outboundService(request) { + return new Response(`fallback:${request.url}`); + }, + }, + ], + }); + const res = await mf.dispatchFetch("http://localhost"); + t.deepEqual(await res.json(), { + res1: "one", + res2: "fallback:https://example.com/2", + }); +}); + test("Miniflare: custom upstream as origin", async (t) => { const upstream = await useServer(t, (req, res) => { res.end(`upstream: ${new URL(req.url ?? "", "http://upstream")}`);