Skip to content

Commit

Permalink
Add support for built-in workerd services (#435)
Browse files Browse the repository at this point in the history
Specifically, this allows custom `network`, `external` and `disk`
services to be specified as service bindings. Requests to `network`
services are dispatched according to URL. Requests to `external`
services are dispatched to the specified remote server. Requests to
`disk` services are dispatched to an HTTP service backed by an
on-disk directory.

This PR also fixes a bug where custom function service bindings with
the same name in different Workers would dispatch all requests to the
first Workers' function.
  • Loading branch information
mrbbot committed Oct 31, 2023
1 parent 0ca2623 commit 37e7b04
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 36 deletions.
16 changes: 14 additions & 2 deletions packages/miniflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ parameter in module format Workers.
Record mapping binding name to paths containing arbitrary binary data to
inject as `ArrayBuffer` bindings into this Worker.

- `serviceBindings?: Record<string, string | (request: Request) => Awaitable<Response>>`
- `serviceBindings?: Record<string, string | { network: Network } | { external: ExternalServer } | { disk: DiskDirectory } | (request: Request) => Awaitable<Response>>`

Record mapping binding name to service designators to inject as
`{ fetch: typeof fetch }`
Expand All @@ -284,10 +284,22 @@ parameter in module format Workers.

- If the designator is a `string`, requests will be dispatched to the Worker
with that `name`.
- If the designator is an object of the form `{ network: { ... } }`, where
`network` is a
[`workerd` `Network` struct](https://github.com/cloudflare/workerd/blob/bdbd6075c7c53948050c52d22f2dfa37bf376253/src/workerd/server/workerd.capnp#L555-L598),
requests will be dispatched according to the `fetch`ed URL.
- If the designator is an object of the form `{ external: { ... } }` where
`external` is a
[`workerd` `ExternalServer` struct](https://github.com/cloudflare/workerd/blob/bdbd6075c7c53948050c52d22f2dfa37bf376253/src/workerd/server/workerd.capnp#L504-L553),
requests will be dispatched to the specified remote server.
- If the designator is an object of the form `{ disk: { ... } }` where `disk`
is a
[`workerd` `DiskDirectory` struct](https://github.com/cloudflare/workerd/blob/bdbd6075c7c53948050c52d22f2dfa37bf376253/src/workerd/server/workerd.capnp#L600-L643),
requests will be dispatched to an HTTP service backed by an on-disk
directory.
- If the designator is a function, requests will be dispatched to your custom
handler. This allows you to access data and functions defined in Node.js
from your Worker.
<!--TODO: other service types, disk, network, external, etc-->

#### Cache

Expand Down
2 changes: 1 addition & 1 deletion packages/miniflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ export class Miniflare {
const workerBindings: Worker_Binding[] = [];
const additionalModules: Worker_Module[] = [];
for (const [key, plugin] of PLUGIN_ENTRIES) {
const pluginBindings = await plugin.getBindings(workerOpts[key]);
const pluginBindings = await plugin.getBindings(workerOpts[key], i);
if (pluginBindings !== undefined) {
workerBindings.push(...pluginBindings);

Expand Down
71 changes: 41 additions & 30 deletions packages/miniflare/src/plugins/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { readFileSync } from "fs";
import fs from "fs/promises";
import { TextEncoder } from "util";
import { bold } from "kleur/colors";
import { Request, Response } from "undici";
import { z } from "zod";
import {
Service,
Expand All @@ -11,13 +10,7 @@ import {
kVoid,
supportedCompatibilityDate,
} from "../../runtime";
import {
Awaitable,
JsonSchema,
Log,
MiniflareCoreError,
zAwaitable,
} from "../../shared";
import { Awaitable, JsonSchema, Log, MiniflareCoreError } from "../../shared";
import { getCacheServiceName } from "../cache";
import {
BINDING_SERVICE_LOOPBACK,
Expand All @@ -31,15 +24,11 @@ import {
STRING_SCRIPT_PATH,
convertModuleDefinition,
} from "./modules";
import { ServiceDesignatorSchema } from "./services";

const encoder = new TextEncoder();
const numericCompare = new Intl.Collator(undefined, { numeric: true }).compare;

// (request: Request) => Awaitable<Response>
export const ServiceFetchSchema = z
.function()
.args(z.instanceof(Request))
.returns(zAwaitable(z.instanceof(Response)));
export const CoreOptionsSchema = z.object({
name: z.string().optional(),
script: z.string().optional(),
Expand All @@ -62,10 +51,7 @@ export const CoreOptionsSchema = z.object({
wasmBindings: z.record(z.string()).optional(),
textBlobBindings: z.record(z.string()).optional(),
dataBlobBindings: z.record(z.string()).optional(),
// TODO: add support for workerd network/external/disk services here
serviceBindings: z
.record(z.union([z.string(), ServiceFetchSchema]))
.optional(),
serviceBindings: z.record(ServiceDesignatorSchema).optional(),
});

export const CoreSharedOptionsSchema = z.object({
Expand All @@ -92,11 +78,19 @@ export const SERVICE_LOOPBACK = `${CORE_PLUGIN_NAME}:loopback`;
export const SERVICE_ENTRY = `${CORE_PLUGIN_NAME}:entry`;
// Service prefix for all regular user workers
const SERVICE_USER_PREFIX = `${CORE_PLUGIN_NAME}:user`;
// Service prefix for `workerd`'s builtin services (network, external, disk)
const SERVICE_BUILTIN_PREFIX = `${CORE_PLUGIN_NAME}:builtin`;
// Service prefix for custom fetch functions defined in `serviceBindings` option
const SERVICE_CUSTOM_PREFIX = `${CORE_PLUGIN_NAME}:custom`;

export function getUserServiceName(name = "") {
return `${SERVICE_USER_PREFIX}:${name}`;
export function getUserServiceName(workerName = "") {
return `${SERVICE_USER_PREFIX}:${workerName}`;
}
function getBuiltinServiceName(workerIndex: number, bindingName: string) {
return `${SERVICE_BUILTIN_PREFIX}:${workerIndex}:${bindingName}`;
}
function getCustomServiceName(workerIndex: number, bindingName: string) {
return `${SERVICE_CUSTOM_PREFIX}:${workerIndex}:${bindingName}`;
}

export const HEADER_PROBE = "MF-Probe";
Expand Down Expand Up @@ -235,7 +229,7 @@ export const CORE_PLUGIN: Plugin<
> = {
options: CoreOptionsSchema,
sharedOptions: CoreSharedOptionsSchema,
getBindings(options) {
getBindings(options, workerIndex) {
const bindings: Awaitable<Worker_Binding>[] = [];

if (options.bindings !== undefined) {
Expand Down Expand Up @@ -269,15 +263,23 @@ export const CORE_PLUGIN: Plugin<
}
if (options.serviceBindings !== undefined) {
bindings.push(
...Object.entries(options.serviceBindings).map(([name, service]) => ({
name,
service: {
name:
typeof service === "function"
? `${SERVICE_CUSTOM_PREFIX}:${name}` // Custom `fetch` function
: `${SERVICE_USER_PREFIX}:${service}`, // Regular user worker
},
}))
...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 },
};
})
);
}

Expand Down Expand Up @@ -372,8 +374,9 @@ export const CORE_PLUGIN: Plugin<
if (options.serviceBindings !== undefined) {
for (const [name, service] of Object.entries(options.serviceBindings)) {
if (typeof service === "function") {
// Custom `fetch` function
services.push({
name: `${SERVICE_CUSTOM_PREFIX}:${name}`,
name: getCustomServiceName(workerIndex, name),
worker: {
serviceWorkerScript: SCRIPT_CUSTOM_SERVICE,
compatibilityDate: "2022-09-01",
Expand All @@ -389,6 +392,12 @@ export const CORE_PLUGIN: Plugin<
],
},
});
} else if (typeof service === "object") {
// Builtin workerd service: network, external, disk
services.push({
name: getBuiltinServiceName(workerIndex, name),
...service,
});
}
}
}
Expand Down Expand Up @@ -430,3 +439,5 @@ function getWorkerScript(
return { serviceWorkerScript: code };
}
}

export * from "./services";
83 changes: 83 additions & 0 deletions packages/miniflare/src/plugins/core/services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Request, Response } from "undici";
import { z } from "zod";
import {
ExternalServer,
HttpOptions_Style,
TlsOptions_Version,
} from "../../runtime";
import { zAwaitable } from "../../shared";

// Zod validators for types in runtime/config/workerd.ts.
// All options should be optional except where specifically stated.
// TODO: autogenerate these with runtime/config/workerd.ts from capnp

export const HttpOptionsHeaderSchema = z.object({
name: z.string(), // name should be required
value: z.ostring(), // If omitted, the header will be removed
});
const HttpOptionsSchema = z.object({
style: z.nativeEnum(HttpOptions_Style).optional(),
forwardedProtoHeader: z.ostring(),
cfBlobHeader: z.ostring(),
injectRequestHeaders: HttpOptionsHeaderSchema.array().optional(),
injectResponseHeaders: HttpOptionsHeaderSchema.array().optional(),
});

const TlsOptionsKeypairSchema = z.object({
privateKey: z.ostring(),
certificateChain: z.ostring(),
});

const TlsOptionsSchema = z.object({
keypair: TlsOptionsKeypairSchema.optional(),
requireClientCerts: z.oboolean(),
trustBrowserCas: z.oboolean(),
trustedCertificates: z.string().array().optional(),
minVersion: z.nativeEnum(TlsOptions_Version).optional(),
cipherList: z.ostring(),
});

const NetworkSchema = z.object({
allow: z.string().array().optional(),
deny: z.string().array().optional(),
tlsOptions: TlsOptionsSchema.optional(),
});

export const ExternalServerSchema = z.intersection(
z.object({ address: z.string() }), // address should be required
z.union([
z.object({ http: z.optional(HttpOptionsSchema) }),
z.object({
https: z.optional(
z.object({
options: HttpOptionsSchema.optional(),
tlsOptions: TlsOptionsSchema.optional(),
certificateHost: z.ostring(),
})
),
}),
])
) as z.ZodType<ExternalServer>;
// This type cast is required for `api-extractor` to produce a `.d.ts` rollup.
// Rather than outputting a `z.ZodIntersection<...>` for this type, it will
// just use `z.ZodType<ExternalServer>`. Without this, the extractor process
// just ends up pinned at 100% CPU. Probably unbounded recursion? I guess this
// type is too complex? Something to investigate... :thinking_face:

const DiskDirectorySchema = z.object({
path: z.string(), // path should be required
writable: z.oboolean(),
});

export const ServiceFetchSchema = z
.function()
.args(z.instanceof(Request))
.returns(zAwaitable(z.instanceof(Response)));

export const ServiceDesignatorSchema = z.union([
z.string(),
z.object({ network: NetworkSchema }),
z.object({ external: ExternalServerSchema }),
z.object({ disk: DiskDirectorySchema }),
ServiceFetchSchema,
]);
5 changes: 4 additions & 1 deletion packages/miniflare/src/plugins/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ export interface PluginBase<
SharedOptions extends z.ZodType | undefined,
> {
options: Options;
getBindings(options: z.infer<Options>): Awaitable<Worker_Binding[] | void>;
getBindings(
options: z.infer<Options>,
workerIndex: number
): Awaitable<Worker_Binding[] | void>;
getServices(
options: PluginServicesOptions<Options, SharedOptions>
): Awaitable<Service[] | void>;
Expand Down
5 changes: 3 additions & 2 deletions packages/miniflare/src/runtime/config/workerd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,10 @@ export type Worker_DurableObjectNamespace = { className?: string } & (
| { ephemeralLocal?: Void }
);

export type ExternalServer =
export type ExternalServer = { address?: string } & (
| { http: HttpOptions }
| { https: ExternalServer_Https };
| { https: ExternalServer_Https }
);

export interface ExternalServer_Https {
options?: HttpOptions;
Expand Down

0 comments on commit 37e7b04

Please sign in to comment.