Skip to content

Commit

Permalink
Add outboundService option for customising global fetch() target
Browse files Browse the repository at this point in the history
`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.
  • Loading branch information
mrbbot committed Jul 15, 2023
1 parent c0f32f3 commit edaef43
Show file tree
Hide file tree
Showing 5 changed files with 164 additions and 41 deletions.
6 changes: 6 additions & 0 deletions packages/miniflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>`
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
Expand Down
15 changes: 12 additions & 3 deletions packages/miniflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ import {
normaliseDurableObject,
} from "./plugins";
import {
CUSTOM_SERVICE_KNOWN_OUTBOUND,
CustomServiceKind,
JsonErrorSchema,
NameSourceOptions,
ServiceDesignatorSchema,
getUserServiceName,
handlePrettyErrorRequest,
reviveError,
Expand Down Expand Up @@ -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<typeof ServiceDesignatorSchema> | 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 {
Expand Down
22 changes: 19 additions & 3 deletions packages/miniflare/src/plugins/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
121 changes: 86 additions & 35 deletions packages/miniflare/src/plugins/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SCRIPT_ENTRY from "worker:core/entry";
import { z } from "zod";
import {
Service,
ServiceDesignator,
Worker_Binding,
Worker_Module,
kVoid,
Expand All @@ -31,6 +32,8 @@ import {
parseRoutes,
} from "../shared";
import {
CUSTOM_SERVICE_KNOWN_OUTBOUND,
CustomServiceKind,
SERVICE_ENTRY,
getBuiltinServiceName,
getCustomServiceName,
Expand Down Expand Up @@ -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(),
})
Expand Down Expand Up @@ -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<typeof ServiceDesignatorSchema>
): 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<typeof ServiceDesignatorSchema>
): 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() {
Expand Down Expand Up @@ -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
),
};
})
);
Expand Down Expand Up @@ -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) },
},
},
Expand All @@ -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;
},
Expand Down
41 changes: 41 additions & 0 deletions packages/miniflare/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Miniflare,
MiniflareCoreError,
MiniflareOptions,
Response,
_transformsForContentEncoding,
fetch,
} from "miniflare";
Expand Down Expand Up @@ -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")}`);
Expand Down

0 comments on commit edaef43

Please sign in to comment.