diff --git a/.changeset/fair-emus-jam.md b/.changeset/fair-emus-jam.md new file mode 100644 index 000000000000..764b1a14d466 --- /dev/null +++ b/.changeset/fair-emus-jam.md @@ -0,0 +1,11 @@ +--- +"miniflare": minor +--- + +feat: add support for wrapped bindings + +This change adds a new `wrappedBindings` worker option for configuring +`workerd`'s [wrapped bindings](https://github.com/cloudflare/workerd/blob/bfcef2d850514c569c039cb84c43bc046af4ffb9/src/workerd/server/workerd.capnp#L469-L487). +These allow custom bindings to be written as JavaScript functions accepting an +`env` parameter of "inner bindings" and returning the value to bind. For more +details, refer to the [API docs](https://github.com/cloudflare/workers-sdk/blob/main/packages/miniflare/README.md#core). diff --git a/packages/miniflare/README.md b/packages/miniflare/README.md index 1fe60d0e8fb5..b23ee9ea6b7f 100644 --- a/packages/miniflare/README.md +++ b/packages/miniflare/README.md @@ -315,6 +315,114 @@ parameter in module format Workers. handler. This allows you to access data and functions defined in Node.js from your Worker. + + +- `wrappedBindings?: Record }>` + + Record mapping binding name to designators to inject as + [wrapped bindings](https://github.com/cloudflare/workerd/blob/bfcef2d850514c569c039cb84c43bc046af4ffb9/src/workerd/server/workerd.capnp#L469-L487) into this Worker. + Wrapped bindings allow custom bindings to be written as JavaScript functions + accepting an `env` parameter of "inner bindings" and returning the value to + bind. A `string` designator is equivalent to `{ scriptName: }`. + `scriptName`'s bindings will be used as "inner bindings". JSON `bindings` in + the `designator` also become "inner bindings" and will override any of + `scriptName` bindings with the same name. The Worker named `scriptName`... + + - Must define a single `ESModule` as its source, using + `{ modules: true, script: "..." }`, `{ modules: true, scriptPath: "..." }`, + or `{ modules: [...] }` + - Must provide the function to use for the wrapped binding as an `entrypoint` + named export or a default export if `entrypoint` is omitted + - Must not be the first/entrypoint worker + - Must not be bound to with service or Durable Object bindings + - Must not define `compatibilityDate` or `compatibilityFlags` + - Must not define `outboundService` + - Must not directly or indirectly have a wrapped binding to itself + - Must not be used as an argument to `Miniflare#getWorker()` + +
+ Wrapped Bindings Example + + ```ts + import { Miniflare } from "miniflare"; + const store = new Map(); + const mf = new Miniflare({ + workers: [ + { + wrappedBindings: { + MINI_KV: { + scriptName: "mini-kv", // Use Worker named `mini-kv` for implementation + bindings: { NAMESPACE: "ns" }, // Override `NAMESPACE` inner binding + }, + }, + modules: true, + script: `export default { + async fetch(request, env, ctx) { + // Example usage of wrapped binding + await env.MINI_KV.set("key", "value"); + return new Response(await env.MINI_KV.get("key")); + } + }`, + }, + { + name: "mini-kv", + serviceBindings: { + // Function-valued service binding for accessing Node.js state + async STORE(request) { + const { pathname } = new URL(request.url); + const key = pathname.substring(1); + if (request.method === "GET") { + const value = store.get(key); + const status = value === undefined ? 404 : 200; + return new Response(value ?? null, { status }); + } else if (request.method === "PUT") { + const value = await request.text(); + store.set(key, value); + return new Response(null, { status: 204 }); + } else if (request.method === "DELETE") { + store.delete(key); + return new Response(null, { status: 204 }); + } else { + return new Response(null, { status: 405 }); + } + }, + }, + modules: true, + script: ` + // Implementation of binding + class MiniKV { + constructor(env) { + this.STORE = env.STORE; + this.baseURL = "http://x/" + (env.NAMESPACE ?? "") + ":"; + } + async get(key) { + const res = await this.STORE.fetch(this.baseURL + key); + return res.status === 404 ? null : await res.text(); + } + async set(key, body) { + await this.STORE.fetch(this.baseURL + key, { method: "PUT", body }); + } + async delete(key) { + await this.STORE.fetch(this.baseURL + key, { method: "DELETE" }); + } + } + + // env has the type { STORE: Fetcher, NAMESPACE?: string } + export default function (env) { + return new MiniKV(env); + } + `, + }, + ], + }); + ``` + +
+ + > :warning: `wrappedBindings` are only supported in modules format Workers. + + + - `outboundService?: string | { network: Network } | { external: ExternalServer } | { disk: DiskDirectory } | (request: Request) => Awaitable` Dispatch this Worker's global `fetch()` and `connect()` requests to the diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index fc02e5429e84..bd778d8c8857 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -58,6 +58,7 @@ import { SOCKET_ENTRY, SharedOptions, WorkerOptions, + WrappedBindingNames, getDirectSocketName, getGlobalServices, kProxyNodeBinding, @@ -72,6 +73,7 @@ import { ServiceDesignatorSchema, getUserServiceName, handlePrettyErrorRequest, + maybeWrappedModuleToWorkerName, reviveError, } from "./plugins/core"; import { @@ -93,6 +95,7 @@ import { MiniflareCoreError, NoOpLog, OptionalZodTypeOf, + _isCyclic, stripAnsi, } from "./shared"; import { @@ -306,6 +309,47 @@ function getDurableObjectClassNames( return serviceClassNames; } +function invalidWrappedAsBound(name: string, bindingType: string): never { + const stringName = JSON.stringify(name); + throw new MiniflareCoreError( + "ERR_INVALID_WRAPPED", + `Cannot use ${stringName} for wrapped binding because it is bound to with ${bindingType} bindings.\nEnsure other workers don't define ${bindingType} bindings to ${stringName}.` + ); +} +function getWrappedBindingNames( + allWorkerOpts: PluginWorkerOptions[], + durableObjectClassNames: DurableObjectClassNames +): WrappedBindingNames { + // Build set of all worker names bound to as wrapped bindings. + // Also check these "workers" aren't bound to as services/Durable Objects. + // We won't add them as regular workers so these bindings would fail. + const wrappedBindingWorkerNames = new Set(); + for (const workerOpts of allWorkerOpts) { + for (const designator of Object.values( + workerOpts.core.wrappedBindings ?? {} + )) { + const scriptName = + typeof designator === "object" ? designator.scriptName : designator; + if (durableObjectClassNames.has(getUserServiceName(scriptName))) { + invalidWrappedAsBound(scriptName, "Durable Object"); + } + wrappedBindingWorkerNames.add(scriptName); + } + } + // Need to collect all wrapped bindings before checking service bindings + for (const workerOpts of allWorkerOpts) { + for (const designator of Object.values( + workerOpts.core.serviceBindings ?? {} + )) { + if (typeof designator !== "string") continue; + if (wrappedBindingWorkerNames.has(designator)) { + invalidWrappedAsBound(designator, "service"); + } + } + } + return wrappedBindingWorkerNames; +} + function getQueueConsumers( allWorkerOpts: PluginWorkerOptions[] ): QueueConsumers { @@ -353,11 +397,13 @@ function getQueueConsumers( // Collects all routes from all worker services function getWorkerRoutes( - allWorkerOpts: PluginWorkerOptions[] + allWorkerOpts: PluginWorkerOptions[], + wrappedBindingNames: Set ): Map { const allRoutes = new Map(); for (const workerOpts of allWorkerOpts) { const name = workerOpts.core.name ?? ""; + if (wrappedBindingNames.has(name)) continue; // Wrapped bindings un-routable assert(!allRoutes.has(name)); // Validated unique names earlier allRoutes.set(name, workerOpts.core.routes ?? []); } @@ -918,23 +964,42 @@ export class Miniflare { sharedOpts.core.cf = await setupCf(this.#log, sharedOpts.core.cf); const durableObjectClassNames = getDurableObjectClassNames(allWorkerOpts); + const wrappedBindingNames = getWrappedBindingNames( + allWorkerOpts, + durableObjectClassNames + ); const queueConsumers = getQueueConsumers(allWorkerOpts); - const allWorkerRoutes = getWorkerRoutes(allWorkerOpts); + const allWorkerRoutes = getWorkerRoutes(allWorkerOpts, wrappedBindingNames); const workerNames = [...allWorkerRoutes.keys()]; // Use Map to dedupe services by name const services = new Map(); + const extensions: Extension[] = [ + { + modules: [ + { name: "miniflare:shared", esModule: SCRIPT_MINIFLARE_SHARED() }, + { name: "miniflare:zod", esModule: SCRIPT_MINIFLARE_ZOD() }, + ], + }, + ]; const sockets: Socket[] = [await configureEntrySocket(sharedOpts.core)]; // Bindings for `ProxyServer` Durable Object const proxyBindings: Worker_Binding[] = []; + const allWorkerBindings = new Map(); + const wrappedBindingsToPopulate: { + workerName: string; + innerBindings: Worker_Binding[]; + }[] = []; + for (let i = 0; i < allWorkerOpts.length; i++) { const workerOpts = allWorkerOpts[i]; const workerName = workerOpts.core.name ?? ""; // Collect all bindings from this worker const workerBindings: Worker_Binding[] = []; + allWorkerBindings.set(workerName, workerBindings); const additionalModules: Worker_Module[] = []; for (const [key, plugin] of PLUGIN_ENTRIES) { // @ts-expect-error `CoreOptionsSchema` has required options which are @@ -948,6 +1013,24 @@ export class Miniflare { if (isNativeTargetBinding(binding)) { proxyBindings.push(buildProxyBinding(key, workerName, binding)); } + // If this is a wrapped binding to a wrapped binding worker, record + // it, so we can populate its inner bindings with all the wrapped + // binding worker's bindings. + if ( + "wrapped" in binding && + binding.wrapped?.moduleName !== undefined && + binding.wrapped.innerBindings !== undefined + ) { + const workerName = maybeWrappedModuleToWorkerName( + binding.wrapped.moduleName + ); + if (workerName !== undefined) { + wrappedBindingsToPopulate.push({ + workerName, + innerBindings: binding.wrapped.innerBindings, + }); + } + } } if (key === "kv") { @@ -971,12 +1054,13 @@ export class Miniflare { additionalModules, tmpPath: this.#tmpPath, workerNames, + wrappedBindingNames, durableObjectClassNames, unsafeEphemeralDurableObjects, queueConsumers, }; for (const [key, plugin] of PLUGIN_ENTRIES) { - const pluginServices = await plugin.getServices({ + const pluginServicesExtensions = await plugin.getServices({ ...pluginServicesOptionsBase, // @ts-expect-error `CoreOptionsSchema` has required options which are // missing in other plugins' options. @@ -984,7 +1068,15 @@ export class Miniflare { // @ts-expect-error `QueuesPlugin` doesn't define shared options sharedOptions: sharedOpts[key], }); - if (pluginServices !== undefined) { + if (pluginServicesExtensions !== undefined) { + let pluginServices: Service[]; + if (Array.isArray(pluginServicesExtensions)) { + pluginServices = pluginServicesExtensions; + } else { + pluginServices = pluginServicesExtensions.services; + extensions.push(...pluginServicesExtensions.extensions); + } + for (const service of pluginServices) { if (service.name !== undefined && !services.has(service.name)) { services.set(service.name, service); @@ -1028,44 +1120,6 @@ export class Miniflare { } } - // For testing proxy client serialisation, add an API that just returns its - // arguments. Note without the `.pipeThrough(new TransformStream())` below, - // we'll see `TypeError: Inter-TransformStream ReadableStream.pipeTo() is - // not implemented.`. `IdentityTransformStream` doesn't work here. - // TODO(soon): add support for wrapped bindings and remove this. The API - // will probably look something like `{ wrappedBindings: { A: "a" } }` - // where `"a"` is the name of a "worker" in `workers`. - const extensions: Extension[] = [ - { - modules: [ - { name: "miniflare:shared", esModule: SCRIPT_MINIFLARE_SHARED() }, - { name: "miniflare:zod", esModule: SCRIPT_MINIFLARE_ZOD() }, - ], - }, - { - modules: [ - { - name: "miniflare-internal:identity", - internal: true, // Not accessible to user code - esModule: ` - class Identity { - async asyncIdentity(...args) { - const i = args.findIndex((arg) => arg instanceof ReadableStream); - if (i !== -1) args[i] = args[i].pipeThrough(new TransformStream()); - return args; - } - } - export default function() { return new Identity(); } - `, - }, - ], - }, - ]; - proxyBindings.push({ - name: "IDENTITY", - wrapped: { moduleName: "miniflare-internal:identity" }, - }); - const globalServices = getGlobalServices({ sharedOptions: sharedOpts.core, allWorkerRoutes, @@ -1080,7 +1134,31 @@ export class Miniflare { services.set(service.name, service); } - return { services: Array.from(services.values()), sockets, extensions }; + // Populate wrapped binding inner bindings with bound worker's bindings + for (const toPopulate of wrappedBindingsToPopulate) { + const bindings = allWorkerBindings.get(toPopulate.workerName); + if (bindings === undefined) continue; + const existingBindingNames = new Set( + toPopulate.innerBindings.map(({ name }) => name) + ); + toPopulate.innerBindings.push( + // If there's already an inner binding with this name, don't add again + ...bindings.filter(({ name }) => !existingBindingNames.has(name)) + ); + } + // If we populated wrapped bindings, we may have created cycles in the + // `services` array. Attempting to serialise these will lead to unbounded + // recursion, so make sure we don't have any + const servicesArray = Array.from(services.values()); + if (wrappedBindingsToPopulate.length > 0 && _isCyclic(servicesArray)) { + throw new MiniflareCoreError( + "ERR_CYCLIC", + "Generated workerd config contains cycles. " + + "Ensure wrapped bindings don't have bindings to themselves." + ); + } + + return { services: servicesArray, sockets, extensions }; } async #assembleAndUpdateConfig() { @@ -1437,7 +1515,16 @@ export class Miniflare { // shares its `env` with Miniflare's entry worker, so has access to routes) const bindingName = CoreBindings.SERVICE_USER_ROUTE_PREFIX + workerName; const fetcher = proxyClient.env[bindingName]; - assert(fetcher !== undefined); + if (fetcher === undefined) { + // `#findAndAssertWorkerIndex()` will throw if a "worker" doesn't exist + // with the specified name. If this "worker" was used as a wrapped binding + // though, it won't be added as a service binding, and so will be + // undefined here. In this case, throw a more specific error. + const stringName = JSON.stringify(workerName); + throw new TypeError( + `${stringName} is being used as a wrapped binding, and cannot be accessed as a worker` + ); + } return fetcher as ReplaceWorkersTypes; } diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 108a3e0bd621..967459e32881 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -11,6 +11,7 @@ import SCRIPT_ENTRY from "worker:core/entry"; import { z } from "zod"; import { fetch } from "../../http"; import { + Extension, Service, ServiceDesignator, Worker_Binding, @@ -18,7 +19,7 @@ import { kVoid, supportedCompatibilityDate, } from "../../runtime"; -import { JsonSchema, Log, MiniflareCoreError } from "../../shared"; +import { Json, JsonSchema, Log, MiniflareCoreError } from "../../shared"; import { Awaitable, CoreBindings, @@ -84,6 +85,12 @@ export function createFetchMock() { return new MockAgent(); } +const WrappedBindingSchema = z.object({ + scriptName: z.string(), + entrypoint: z.string().optional(), + bindings: z.record(JsonSchema).optional(), +}); + const CoreOptionsSchemaInput = z.intersection( SourceOptionsSchema, z.object({ @@ -99,6 +106,9 @@ const CoreOptionsSchemaInput = z.intersection( textBlobBindings: z.record(z.string()).optional(), dataBlobBindings: z.record(z.string()).optional(), serviceBindings: z.record(ServiceDesignatorSchema).optional(), + wrappedBindings: z + .record(z.union([z.string(), WrappedBindingSchema])) + .optional(), outboundService: ServiceDesignatorSchema.optional(), fetchMock: z.instanceof(MockAgent).optional(), @@ -266,6 +276,25 @@ function validateCompatibilityDate(log: Log, compatibilityDate: string) { return compatibilityDate; } +function buildJsonBindings(bindings: Record): Worker_Binding[] { + return Object.entries(bindings).map(([name, value]) => ({ + name, + json: JSON.stringify(value), + })); +} + +const WRAPPED_MODULE_PREFIX = "miniflare-internal:wrapped:"; +function workerNameToWrappedModule(workerName: string): string { + return WRAPPED_MODULE_PREFIX + workerName; +} +export function maybeWrappedModuleToWorkerName( + name: string +): string | undefined { + if (name.startsWith(WRAPPED_MODULE_PREFIX)) { + return name.substring(WRAPPED_MODULE_PREFIX.length); + } +} + export const CORE_PLUGIN: Plugin< typeof CoreOptionsSchema, typeof CoreSharedOptionsSchema @@ -276,12 +305,7 @@ export const CORE_PLUGIN: Plugin< const bindings: Awaitable[] = []; if (options.bindings !== undefined) { - bindings.push( - ...Object.entries(options.bindings).map(([name, value]) => ({ - name, - json: JSON.stringify(value), - })) - ); + bindings.push(...buildJsonBindings(options.bindings)); } if (options.wasmBindings !== undefined) { bindings.push( @@ -308,7 +332,7 @@ export const CORE_PLUGIN: Plugin< bindings.push( ...Object.entries(options.serviceBindings).map(([name, service]) => { return { - name: name, + name, service: getCustomServiceDesignator( workerIndex, CustomServiceKind.UNKNOWN, @@ -319,6 +343,28 @@ export const CORE_PLUGIN: Plugin< }) ); } + if (options.wrappedBindings !== undefined) { + bindings.push( + ...Object.entries(options.wrappedBindings).map(([name, designator]) => { + // Normalise designator + const isObject = typeof designator === "object"; + const scriptName = isObject ? designator.scriptName : designator; + const entrypoint = isObject ? designator.entrypoint : undefined; + const bindings = isObject ? designator.bindings : undefined; + + // Build binding + const moduleName = workerNameToWrappedModule(scriptName); + const innerBindings = + bindings === undefined ? [] : buildJsonBindings(bindings); + // `scriptName`'s bindings will be added to `innerBindings` when + // assembling the config + return { + name, + wrapped: { moduleName, entrypoint, innerBindings }, + }; + }) + ); + } if (options.unsafeEvalBinding !== undefined) { bindings.push({ @@ -379,6 +425,7 @@ export const CORE_PLUGIN: Plugin< options, workerBindings, workerIndex, + wrappedBindingNames, durableObjectClassNames, additionalModules, }) { @@ -419,8 +466,9 @@ export const CORE_PLUGIN: Plugin< } } - const name = getUserServiceName(options.name); - const classNames = durableObjectClassNames.get(name); + const name = options.name ?? ""; + const serviceName = getUserServiceName(options.name); + const classNames = durableObjectClassNames.get(serviceName); const classNamesEntries = Array.from(classNames ?? []); const compatibilityDate = validateCompatibilityDate( @@ -428,9 +476,65 @@ export const CORE_PLUGIN: Plugin< options.compatibilityDate ?? FALLBACK_COMPATIBILITY_DATE ); - const services: Service[] = [ - { - name, + const isWrappedBinding = wrappedBindingNames.has(name); + + const services: Service[] = []; + const extensions: Extension[] = []; + if (isWrappedBinding) { + const stringName = JSON.stringify(name); + function invalidWrapped(reason: string): never { + const message = `Cannot use ${stringName} for wrapped binding because ${reason}`; + throw new MiniflareCoreError("ERR_INVALID_WRAPPED", message); + } + if (workerIndex === 0) { + invalidWrapped( + `it's the entrypoint.\nEnsure ${stringName} isn't the first entry in the \`workers\` array.` + ); + } + if (!("modules" in workerScript)) { + invalidWrapped( + `it's a service worker.\nEnsure ${stringName} sets \`modules\` to \`true\` or an array of modules` + ); + } + if (workerScript.modules.length !== 1) { + invalidWrapped( + `it isn't a single module.\nEnsure ${stringName} doesn't include unbundled \`import\`s.` + ); + } + const firstModule = workerScript.modules[0]; + if (!("esModule" in firstModule)) { + invalidWrapped("it isn't a single ES module"); + } + if (options.compatibilityDate !== undefined) { + invalidWrapped( + "it defines a compatibility date.\nWrapped bindings use the compatibility date of the worker with the binding." + ); + } + if (options.compatibilityFlags?.length) { + invalidWrapped( + "it defines compatibility flags.\nWrapped bindings use the compatibility flags of the worker with the binding." + ); + } + if (options.outboundService !== undefined) { + invalidWrapped( + "it defines an outbound service.\nWrapped bindings use the outbound service of the worker with the binding." + ); + } + // We validate this "worker" isn't bound to for services/Durable Objects + // in `getWrappedBindingNames()`. + + extensions.push({ + modules: [ + { + name: workerNameToWrappedModule(name), + esModule: firstModule.esModule, + internal: true, + }, + ], + }); + } else { + services.push({ + name: serviceName, worker: { ...workerScript, compatibilityDate, @@ -466,8 +570,8 @@ export const CORE_PLUGIN: Plugin< ), cacheApiOutbound: { name: getCacheServiceName(workerIndex) }, }, - }, - ]; + }); + } // Define custom `fetch` services if set if (options.serviceBindings !== undefined) { @@ -491,7 +595,7 @@ export const CORE_PLUGIN: Plugin< if (maybeService !== undefined) services.push(maybeService); } - return services; + return { services, extensions }; }, }; diff --git a/packages/miniflare/src/plugins/shared/index.ts b/packages/miniflare/src/plugins/shared/index.ts index 7de087f25366..d410e08e42e9 100644 --- a/packages/miniflare/src/plugins/shared/index.ts +++ b/packages/miniflare/src/plugins/shared/index.ts @@ -4,7 +4,12 @@ import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; import { z } from "zod"; -import { Service, Worker_Binding, Worker_Module } from "../../runtime"; +import { + Extension, + Service, + Worker_Binding, + Worker_Module, +} from "../../runtime"; import { Log, MiniflareCoreError, OptionalZodTypeOf } from "../../shared"; import { Awaitable, QueueConsumerSchema, sanitisePath } from "../../workers"; @@ -13,6 +18,10 @@ export const DEFAULT_PERSIST_ROOT = ".mf"; export const PersistenceSchema = z.boolean().or(z.string()).optional(); export type Persistence = z.infer; +// Set of "worker" names that are being used as wrapped bindings and shouldn't +// be added a regular worker services. These workers shouldn't be routable. +export type WrappedBindingNames = Set; + // Maps **service** names to the Durable Object class names exported by them export type DurableObjectClassNames = Map< string, @@ -45,11 +54,17 @@ export interface PluginServicesOptions< workerNames: string[]; // ~~Leaky abstractions~~ "Plugin specific options" :) + wrappedBindingNames: WrappedBindingNames; durableObjectClassNames: DurableObjectClassNames; unsafeEphemeralDurableObjects: boolean; queueConsumers: QueueConsumers; } +export interface ServicesExtensions { + services: Service[]; + extensions: Extension[]; +} + export interface PluginBase< Options extends z.ZodType, SharedOptions extends z.ZodType | undefined, @@ -64,7 +79,7 @@ export interface PluginBase< ): Awaitable>; getServices( options: PluginServicesOptions - ): Awaitable; + ): Awaitable; } export type Plugin< diff --git a/packages/miniflare/src/shared/error.ts b/packages/miniflare/src/shared/error.ts index d86a9aed6231..53d57891cc2e 100644 --- a/packages/miniflare/src/shared/error.ts +++ b/packages/miniflare/src/shared/error.ts @@ -30,5 +30,7 @@ export type MiniflareCoreErrorCode = | "ERR_DUPLICATE_NAME" // Multiple workers defined with same name | "ERR_DIFFERENT_UNIQUE_KEYS" // Multiple Durable Object bindings declared for same class with different unsafe unique keys | "ERR_DIFFERENT_PREVENT_EVICTION" // Multiple Durable Object bindings declared for same class with different unsafe prevent eviction values - | "ERR_MULTIPLE_OUTBOUNDS"; // Both `outboundService` and `fetchMock` specified + | "ERR_MULTIPLE_OUTBOUNDS" // Both `outboundService` and `fetchMock` specified + | "ERR_INVALID_WRAPPED" // Worker not allowed to be used as wrapped binding + | "ERR_CYCLIC"; // Generate cyclic workerd config export class MiniflareCoreError extends MiniflareError {} diff --git a/packages/miniflare/src/shared/types.ts b/packages/miniflare/src/shared/types.ts index 0f0c734c520a..d09fd22346e9 100644 --- a/packages/miniflare/src/shared/types.ts +++ b/packages/miniflare/src/shared/types.ts @@ -21,3 +21,15 @@ export type Json = Literal | { [key: string]: Json } | Json[]; export const JsonSchema: z.ZodType = z.lazy(() => z.union([LiteralSchema, z.array(JsonSchema), z.record(JsonSchema)]) ); + +/** @internal */ +export function _isCyclic(value: unknown, seen = new Set()) { + if (typeof value !== "object" || value === null) return false; + for (const child of Object.values(value)) { + if (seen.has(child)) return true; + seen.add(child); + if (_isCyclic(child, seen)) return true; + seen.delete(child); + } + return false; +} diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index 729a8e0da833..e3a430380fa3 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -27,6 +27,7 @@ import { MiniflareOptions, ReplaceWorkersTypes, Response, + WorkerOptions, _forceColour, _transformsForContentEncoding, createFetchMock, @@ -1143,25 +1144,18 @@ test("Miniflare: exits cleanly", async (t) => { t.is(stderr, ""); }); -test("Miniflare: allows the use of unsafe eval bindings", async (t) => { - const log = new TestLog(t); - +test("Miniflare: supports unsafe eval bindings", async (t) => { const mf = new Miniflare({ - log, modules: true, - script: ` - export default { - fetch(req, env, ctx) { - const three = env.UNSAFE_EVAL.eval('2 + 1'); - - const fn = env.UNSAFE_EVAL.newFunction( - 'return \`the computed value is \${n}\`', '', 'n' - ); - - return new Response(fn(three)); - } - } - `, + script: `export default { + fetch(req, env, ctx) { + const three = env.UNSAFE_EVAL.eval("2 + 1"); + const fn = env.UNSAFE_EVAL.newFunction( + "return \`the computed value is \${n}\`", "", "n" + ); + return new Response(fn(three)); + } + }`, unsafeEvalBinding: "UNSAFE_EVAL", }); t.teardown(() => mf.dispose()); @@ -1170,3 +1164,461 @@ test("Miniflare: allows the use of unsafe eval bindings", async (t) => { t.true(response.ok); t.is(await response.text(), "the computed value is 3"); }); + +test("Miniflare: supports wrapped bindings", async (t) => { + const store = new Map(); + const mf = new Miniflare({ + workers: [ + { + wrappedBindings: { + MINI_KV: { + scriptName: "mini-kv", + bindings: { NAMESPACE: "ns" }, + }, + }, + modules: true, + script: `export default { + async fetch(request, env, ctx) { + await env.MINI_KV.set("key", "value"); + const value = await env.MINI_KV.get("key"); + await env.MINI_KV.delete("key"); + const emptyValue = await env.MINI_KV.get("key"); + await env.MINI_KV.set("key", "another value"); + return Response.json({ value, emptyValue }); + } + }`, + }, + { + name: "mini-kv", + serviceBindings: { + async STORE(request) { + const { pathname } = new URL(request.url); + const key = pathname.substring(1); + if (request.method === "GET") { + const value = store.get(key); + const status = value === undefined ? 404 : 200; + return new Response(value ?? null, { status }); + } else if (request.method === "PUT") { + const value = await request.text(); + store.set(key, value); + return new Response(null, { status: 204 }); + } else if (request.method === "DELETE") { + store.delete(key); + return new Response(null, { status: 204 }); + } else { + return new Response(null, { status: 405 }); + } + }, + }, + modules: true, + script: ` + class MiniKV { + constructor(env) { + this.STORE = env.STORE; + this.baseURL = "http://x/" + (env.NAMESPACE ?? "") + ":"; + } + async get(key) { + const res = await this.STORE.fetch(this.baseURL + key); + return res.status === 404 ? null : await res.text(); + } + async set(key, body) { + await this.STORE.fetch(this.baseURL + key, { method: "PUT", body }); + } + async delete(key) { + await this.STORE.fetch(this.baseURL + key, { method: "DELETE" }); + } + } + + export default function (env) { + return new MiniKV(env); + } + `, + }, + ], + }); + t.teardown(() => mf.dispose()); + + const res = await mf.dispatchFetch("http://localhost/"); + t.deepEqual(await res.json(), { value: "value", emptyValue: null }); + t.deepEqual(store, new Map([["ns:key", "another value"]])); +}); +test("Miniflare: check overrides default bindings with bindings from wrapped binding designator", async (t) => { + const mf = new Miniflare({ + workers: [ + { + wrappedBindings: { + WRAPPED: { + scriptName: "binding", + entrypoint: "wrapped", + bindings: { B: "overridden b" }, + }, + }, + modules: true, + script: `export default { + fetch(request, env, ctx) { + return env.WRAPPED(); + } + }`, + }, + { + name: "binding", + modules: true, + bindings: { A: "default a", B: "default b" }, + script: `export function wrapped(env) { + return () => Response.json(env); + }`, + }, + ], + }); + t.teardown(() => mf.dispose()); + + const res = await mf.dispatchFetch("http://localhost/"); + t.deepEqual(await res.json(), { A: "default a", B: "overridden b" }); +}); +test("Miniflare: checks uses compatibility and outbound configuration of binder", async (t) => { + const workers: WorkerOptions[] = [ + { + compatibilityDate: "2022-03-21", // Default-on date for `global_navigator` + compatibilityFlags: ["nodejs_compat"], + wrappedBindings: { WRAPPED: "binding" }, + modules: true, + script: `export default { + fetch(request, env, ctx) { + return env.WRAPPED(); + } + }`, + outboundService(request) { + return new Response(`outbound:${request.url}`); + }, + }, + { + name: "binding", + modules: [ + { + type: "ESModule", + path: "index.mjs", + contents: `export default function () { + return async () => { + const typeofNavigator = typeof navigator; + let importedNode = false; + try { + await import("node:util"); + importedNode = true; + } catch {} + const outboundRes = await fetch("http://placeholder/"); + const outboundText = await outboundRes.text(); + return Response.json({ typeofNavigator, importedNode, outboundText }); + } + }`, + }, + ], + }, + ]; + const mf = new Miniflare({ workers }); + t.teardown(() => mf.dispose()); + + let res = await mf.dispatchFetch("http://localhost/"); + t.deepEqual(await res.json(), { + typeofNavigator: "object", + importedNode: true, + outboundText: "outbound:http://placeholder/", + }); + + const fetchMock = createFetchMock(); + fetchMock.disableNetConnect(); + fetchMock + .get("http://placeholder") + .intercept({ path: "/" }) + .reply(200, "mocked"); + workers[0].compatibilityDate = "2022-03-20"; + workers[0].compatibilityFlags = []; + workers[0].outboundService = undefined; + workers[0].fetchMock = fetchMock; + await mf.setOptions({ workers }); + res = await mf.dispatchFetch("http://localhost/"); + t.deepEqual(await res.json(), { + typeofNavigator: "undefined", + importedNode: false, + outboundText: "mocked", + }); +}); +test("Miniflare: cannot call getWorker() on wrapped binding worker", async (t) => { + const mf = new Miniflare({ + workers: [ + { + wrappedBindings: { WRAPPED: "binding" }, + modules: true, + script: `export default { + fetch(request, env, ctx) { + return env.WRAPPED; + } + }`, + }, + { + name: "binding", + modules: true, + script: `export default function () { + return "🎁"; + }`, + }, + ], + }); + t.teardown(() => mf.dispose()); + + await t.throwsAsync(mf.getWorker("binding"), { + instanceOf: TypeError, + message: + '"binding" is being used as a wrapped binding, and cannot be accessed as a worker', + }); +}); +test("Miniflare: prohibits invalid wrapped bindings", async (t) => { + const mf = new Miniflare({ modules: true, script: "" }); + t.teardown(() => mf.dispose()); + + // Check prohibits using entrypoint worker + await t.throwsAsync( + mf.setOptions({ + name: "a", + modules: true, + script: "", + wrappedBindings: { + WRAPPED: { scriptName: "a", entrypoint: "wrapped" }, + }, + }), + { + instanceOf: MiniflareCoreError, + code: "ERR_INVALID_WRAPPED", + message: + 'Cannot use "a" for wrapped binding because it\'s the entrypoint.\n' + + 'Ensure "a" isn\'t the first entry in the `workers` array.', + } + ); + + // Check prohibits using service worker + await t.throwsAsync( + mf.setOptions({ + workers: [ + { modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } }, + { name: "binding", script: "" }, + ], + }), + { + instanceOf: MiniflareCoreError, + code: "ERR_INVALID_WRAPPED", + message: + 'Cannot use "binding" for wrapped binding because it\'s a service worker.\n' + + 'Ensure "binding" sets `modules` to `true` or an array of modules', + } + ); + + // Check prohibits multiple modules + await t.throwsAsync( + mf.setOptions({ + workers: [ + { modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } }, + { + name: "binding", + modules: [ + { type: "ESModule", path: "index.mjs", contents: "" }, + { type: "ESModule", path: "dep.mjs", contents: "" }, + ], + }, + ], + }), + { + instanceOf: MiniflareCoreError, + code: "ERR_INVALID_WRAPPED", + message: + 'Cannot use "binding" for wrapped binding because it isn\'t a single module.\n' + + 'Ensure "binding" doesn\'t include unbundled `import`s.', + } + ); + + // Check prohibits non-ES-modules + await t.throwsAsync( + mf.setOptions({ + workers: [ + { modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } }, + { + name: "binding", + modules: [{ type: "CommonJS", path: "index.cjs", contents: "" }], + }, + ], + }), + { + instanceOf: MiniflareCoreError, + code: "ERR_INVALID_WRAPPED", + message: + 'Cannot use "binding" for wrapped binding because it isn\'t a single ES module', + } + ); + + // Check prohibits Durable Object bindings + await t.throwsAsync( + mf.setOptions({ + workers: [ + { + modules: true, + script: "", + wrappedBindings: { WRAPPED: "binding" }, + durableObjects: { + OBJECT: { scriptName: "binding", className: "TestObject" }, + }, + }, + { + name: "binding", + modules: [{ type: "ESModule", path: "index.mjs", contents: "" }], + }, + ], + }), + { + instanceOf: MiniflareCoreError, + code: "ERR_INVALID_WRAPPED", + message: + 'Cannot use "binding" for wrapped binding because it is bound to with Durable Object bindings.\n' + + 'Ensure other workers don\'t define Durable Object bindings to "binding".', + } + ); + + // Check prohibits service bindings + await t.throwsAsync( + mf.setOptions({ + workers: [ + { + modules: true, + script: "", + wrappedBindings: { + WRAPPED: { scriptName: "binding", entrypoint: "wrapped" }, + }, + serviceBindings: { SERVICE: "binding" }, + }, + { + name: "binding", + modules: [{ type: "ESModule", path: "index.mjs", contents: "" }], + }, + ], + }), + { + instanceOf: MiniflareCoreError, + code: "ERR_INVALID_WRAPPED", + message: + 'Cannot use "binding" for wrapped binding because it is bound to with service bindings.\n' + + 'Ensure other workers don\'t define service bindings to "binding".', + } + ); + + // Check prohibits compatibility date and flags + await t.throwsAsync( + mf.setOptions({ + workers: [ + { modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } }, + { + name: "binding", + compatibilityDate: "2023-11-01", + modules: [{ type: "ESModule", path: "index.mjs", contents: "" }], + }, + ], + }), + { + instanceOf: MiniflareCoreError, + code: "ERR_INVALID_WRAPPED", + message: + 'Cannot use "binding" for wrapped binding because it defines a compatibility date.\n' + + "Wrapped bindings use the compatibility date of the worker with the binding.", + } + ); + await t.throwsAsync( + mf.setOptions({ + workers: [ + { modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } }, + { + name: "binding", + compatibilityFlags: ["nodejs_compat"], + modules: [{ type: "ESModule", path: "index.mjs", contents: "" }], + }, + ], + }), + { + instanceOf: MiniflareCoreError, + code: "ERR_INVALID_WRAPPED", + message: + 'Cannot use "binding" for wrapped binding because it defines compatibility flags.\n' + + "Wrapped bindings use the compatibility flags of the worker with the binding.", + } + ); + + // Check prohibits outbound service + await t.throwsAsync( + mf.setOptions({ + workers: [ + { modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } }, + { + name: "binding", + outboundService() { + assert.fail(); + }, + modules: [{ type: "ESModule", path: "index.mjs", contents: "" }], + }, + ], + }), + { + instanceOf: MiniflareCoreError, + code: "ERR_INVALID_WRAPPED", + message: + 'Cannot use "binding" for wrapped binding because it defines an outbound service.\n' + + "Wrapped bindings use the outbound service of the worker with the binding.", + } + ); + + // Check prohibits cyclic wrapped bindings + await t.throwsAsync( + mf.setOptions({ + workers: [ + { modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } }, + { + name: "binding", + wrappedBindings: { WRAPPED: "binding" }, // Simple cycle + modules: [{ type: "ESModule", path: "index.mjs", contents: "" }], + }, + ], + }), + { + instanceOf: MiniflareCoreError, + code: "ERR_CYCLIC", + message: + "Generated workerd config contains cycles. Ensure wrapped bindings don't have bindings to themselves.", + } + ); + await t.throwsAsync( + mf.setOptions({ + workers: [ + { + modules: true, + script: "", + wrappedBindings: { WRAPPED1: "binding-1" }, + }, + { + name: "binding-1", + wrappedBindings: { WRAPPED2: "binding-2" }, + modules: [{ type: "ESModule", path: "index.mjs", contents: "" }], + }, + { + name: "binding-2", + wrappedBindings: { WRAPPED3: "binding-3" }, + modules: [{ type: "ESModule", path: "index.mjs", contents: "" }], + }, + { + name: "binding-3", + wrappedBindings: { WRAPPED1: "binding-1" }, // Multi-step cycle + modules: [{ type: "ESModule", path: "index.mjs", contents: "" }], + }, + ], + }), + { + instanceOf: MiniflareCoreError, + code: "ERR_CYCLIC", + message: + "Generated workerd config contains cycles. Ensure wrapped bindings don't have bindings to themselves.", + } + ); +}); diff --git a/packages/miniflare/test/plugins/core/proxy/client.spec.ts b/packages/miniflare/test/plugins/core/proxy/client.spec.ts index b824326ab834..aadb03da1f01 100644 --- a/packages/miniflare/test/plugins/core/proxy/client.spec.ts +++ b/packages/miniflare/test/plugins/core/proxy/client.spec.ts @@ -54,11 +54,38 @@ test("ProxyClient: supports service bindings with WebSockets", async (t) => { }); test("ProxyClient: supports serialising multiple ReadableStreams, Blobs and Files", async (t) => { - const mf = new Miniflare({ script: nullScript }); + // For testing proxy client serialisation, add an API that just returns its + // arguments. Note without the `.pipeThrough(new TransformStream())` below, + // we'll see `TypeError: Inter-TransformStream ReadableStream.pipeTo() is + // not implemented.`. `IdentityTransformStream` doesn't work here. + const mf = new Miniflare({ + workers: [ + { + name: "entry", + modules: true, + script: "", + wrappedBindings: { IDENTITY: "identity" }, + }, + { + name: "identity", + modules: true, + script: ` + class Identity { + async asyncIdentity(...args) { + const i = args.findIndex((arg) => arg instanceof ReadableStream); + if (i !== -1) args[i] = args[i].pipeThrough(new TransformStream()); + return args; + } + } + export default function() { return new Identity(); } + `, + }, + ], + }); t.teardown(() => mf.dispose()); const client = await mf._getProxyClient(); - const IDENTITY = client.env.IDENTITY as { + const IDENTITY = client.env["MINIFLARE_PROXY:core:entry:IDENTITY"] as { asyncIdentity(...args: Args): Promise; }; diff --git a/packages/miniflare/test/shared/types.spec.ts b/packages/miniflare/test/shared/types.spec.ts new file mode 100644 index 000000000000..0cd2d9396259 --- /dev/null +++ b/packages/miniflare/test/shared/types.spec.ts @@ -0,0 +1,25 @@ +import test from "ava"; +import { _isCyclic } from "miniflare"; + +test("_isCyclic: detects cycles", (t) => { + // Check simple cycle + const a: { a?: unknown } = {}; + a.a = a; + t.true(_isCyclic(a)); + + // Check simple array cycle + const b: unknown[] = []; + b.push(b); + t.true(_isCyclic(b)); + + // Check duplicated, but not cyclic values + const c = {}; + const d = [c, c]; + t.false(_isCyclic(c)); + t.false(_isCyclic(d)); + + // Check long cycle + const e = { f: { g: {}, h: { i: {} } } }; + e.f.h.i = e.f; + t.true(_isCyclic(e)); +});