From 9229b3085966b28ddeaf6aa804354899bdf9dcdc Mon Sep 17 00:00:00 2001 From: bcoll Date: Thu, 23 Feb 2023 11:18:54 +0000 Subject: [PATCH 1/9] Add support for routing to multiple Workers This change ports Miniflare 2's `mounts` router to Miniflare 3. This attempts to replicate the logic of the `route(s)` field in the Wrangler configuration file: https://developers.cloudflare.com/workers/platform/triggers/routes/#matching-behavior Internally, this is implemented by binding all routable workers as service bindings in the entry service. The first worker is always bound as a "fallback", in case no routes match. Validation has been added to ensure we a) have a fallback, b) don't have workers with duplicate names that would cause bindings with the same name, and c) all routable/fallback workers have code so they actually get added as `workerd` services. --- packages/tre/src/index.ts | 50 +++++- packages/tre/src/plugins/cache/index.ts | 9 +- packages/tre/src/plugins/core/index.ts | 160 +++++++++++------- packages/tre/src/plugins/d1/index.ts | 8 +- packages/tre/src/plugins/index.ts | 12 +- packages/tre/src/plugins/kv/index.ts | 8 +- packages/tre/src/plugins/kv/sites.ts | 7 +- packages/tre/src/plugins/r2/index.ts | 9 +- packages/tre/src/plugins/shared/constants.ts | 10 ++ packages/tre/src/plugins/shared/index.ts | 3 +- packages/tre/src/plugins/shared/routing.ts | 142 ++++++++++++++++ packages/tre/src/shared/error.ts | 5 +- packages/tre/test/index.spec.ts | 89 ++++++++++ .../tre/test/plugins/shared/routing.spec.ts | 154 +++++++++++++++++ 14 files changed, 562 insertions(+), 104 deletions(-) create mode 100644 packages/tre/src/plugins/shared/routing.ts create mode 100644 packages/tre/test/plugins/shared/routing.spec.ts diff --git a/packages/tre/src/index.ts b/packages/tre/src/index.ts index d7560c66d..42a2950b5 100644 --- a/packages/tre/src/index.ts +++ b/packages/tre/src/index.ts @@ -35,6 +35,7 @@ import { Plugins, SERVICE_ENTRY, SOCKET_ENTRY, + getGlobalServices, maybeGetSitesManifestModule, normaliseDurableObject, } from "./plugins"; @@ -100,6 +101,9 @@ function validateOptions( const sharedOpts = opts; const multipleWorkers = "workers" in opts; const workerOpts = multipleWorkers ? opts.workers : [opts]; + if (workerOpts.length === 0) { + throw new MiniflareCoreError("ERR_NO_WORKERS", "No workers defined"); + } // Initialise return values const pluginSharedOpts = {} as PluginSharedOptions; @@ -119,6 +123,19 @@ function validateOptions( } } + // Validate names unique + const names = new Set(); + for (const opts of pluginWorkerOpts) { + const name = opts.core.name ?? ""; + if (names.has(name)) { + throw new MiniflareCoreError( + "ERR_DUPLICATE_NAME", + `Multiple workers defined with the same name: "${name}"` + ); + } + names.add(name); + } + return [pluginSharedOpts, pluginWorkerOpts]; } @@ -150,6 +167,19 @@ function getDurableObjectClassNames( return serviceClassNames; } +// Collects all routes from all worker services +function getWorkerRoutes( + allWorkerOpts: PluginWorkerOptions[] +): Map { + const allRoutes = new Map(); + for (const workerOpts of allWorkerOpts) { + if (workerOpts.core.routes !== undefined) { + allRoutes.set(workerOpts.core.name ?? "", workerOpts.core.routes); + } + } + return allRoutes; +} + // ===== `Miniflare` Internal Storage & Routing ===== type OptionalGatewayFactoryType< Gateway extends GatewayConstructor | undefined @@ -622,7 +652,16 @@ export class Miniflare { sharedOpts.core.cf = await setupCf(this.#log, sharedOpts.core.cf); - const services: Service[] = []; + const durableObjectClassNames = getDurableObjectClassNames(allWorkerOpts); + const allWorkerRoutes = getWorkerRoutes(allWorkerOpts); + + const services: Service[] = getGlobalServices({ + optionsVersion, + sharedOptions: sharedOpts.core, + allWorkerRoutes, + fallbackWorkerName: this.#workerOpts[0].core.name, + loopbackPort, + }); const sockets: Socket[] = [ { name: SOCKET_ENTRY, @@ -633,10 +672,13 @@ export class Miniflare { }, ]; - const durableObjectClassNames = getDurableObjectClassNames(allWorkerOpts); - // Dedupe services by name const serviceNames = new Set(); + for (const service of services) { + // Global services should all have unique names + assert(service.name !== undefined && !serviceNames.has(service.name)); + serviceNames.add(service.name); + } for (let i = 0; i < allWorkerOpts.length; i++) { const workerOpts = allWorkerOpts[i]; @@ -662,13 +704,11 @@ export class Miniflare { const pluginServices = await plugin.getServices({ log: this.#log, options: workerOpts[key], - optionsVersion, sharedOptions: sharedOpts[key], workerBindings, workerIndex: i, durableObjectClassNames, additionalModules, - loopbackPort, tmpPath: this.#tmpPath, }); if (pluginServices !== undefined) { diff --git a/packages/tre/src/plugins/cache/index.ts b/packages/tre/src/plugins/cache/index.ts index cd4309ec8..16a742074 100644 --- a/packages/tre/src/plugins/cache/index.ts +++ b/packages/tre/src/plugins/cache/index.ts @@ -1,6 +1,4 @@ import { z } from "zod"; -import { Worker_Binding } from "../../runtime"; -import { SERVICE_LOOPBACK } from "../core"; import { BINDING_SERVICE_LOOPBACK, BINDING_TEXT_PERSIST, @@ -9,6 +7,7 @@ import { HEADER_PERSIST, PersistenceSchema, Plugin, + WORKER_BINDING_SERVICE_LOOPBACK, encodePersist, } from "../shared"; import { HEADER_CACHE_WARN_USAGE } from "./constants"; @@ -69,10 +68,6 @@ export const CACHE_PLUGIN: Plugin< }, getServices({ sharedOptions, options, workerIndex }) { const persistBinding = encodePersist(sharedOptions.cachePersist); - const loopbackBinding: Worker_Binding = { - name: BINDING_SERVICE_LOOPBACK, - service: { name: SERVICE_LOOPBACK }, - }; return [ { name: getCacheServiceName(workerIndex), @@ -87,7 +82,7 @@ export const CACHE_PLUGIN: Plugin< name: BINDING_JSON_CACHE_WARN_USAGE, json: JSON.stringify(options.cacheWarnUsage ?? false), }, - loopbackBinding, + WORKER_BINDING_SERVICE_LOOPBACK, ], compatibilityDate: "2022-09-01", }, diff --git a/packages/tre/src/plugins/core/index.ts b/packages/tre/src/plugins/core/index.ts index 103c608af..aeef00a0a 100644 --- a/packages/tre/src/plugins/core/index.ts +++ b/packages/tre/src/plugins/core/index.ts @@ -14,9 +14,14 @@ import { getCacheServiceName } from "../cache"; import { DURABLE_OBJECTS_STORAGE_SERVICE_NAME } from "../do"; import { BINDING_SERVICE_LOOPBACK, + CORE_PLUGIN_NAME, CloudflareFetchSchema, HEADER_CF_BLOB, Plugin, + SERVICE_LOOPBACK, + WORKER_BINDING_SERVICE_LOOPBACK, + matchRoutes, + parseRoutes, } from "../shared"; import { HEADER_ERROR_STACK } from "./errors"; import { @@ -50,6 +55,8 @@ export const CoreOptionsSchema = z.object({ compatibilityDate: z.string().optional(), compatibilityFlags: z.string().array().optional(), + routes: z.string().array().optional(), + bindings: z.record(JsonSchema).optional(), wasmBindings: z.record(z.string()).optional(), textBlobBindings: z.record(z.string()).optional(), @@ -74,10 +81,6 @@ export const CoreSharedOptionsSchema = z.object({ liveReload: z.boolean().optional(), }); -export const CORE_PLUGIN_NAME = "core"; - -// Service looping back to Miniflare's Node.js process (for storage, etc) -export const SERVICE_LOOPBACK = `${CORE_PLUGIN_NAME}:loopback`; // Service for HTTP socket entrypoint (for checking runtime ready, routing, etc) export const SERVICE_ENTRY = `${CORE_PLUGIN_NAME}:entry`; // Service prefix for all regular user workers @@ -102,9 +105,11 @@ export const HEADER_CUSTOM_SERVICE = "MF-Custom-Service"; export const HEADER_ORIGINAL_URL = "MF-Original-URL"; const BINDING_JSON_VERSION = "MINIFLARE_VERSION"; -const BINDING_SERVICE_USER = "MINIFLARE_USER"; +const BINDING_SERVICE_USER_ROUTE_PREFIX = "MINIFLARE_USER_ROUTE_"; +const BINDING_SERVICE_USER_FALLBACK = "MINIFLARE_USER_FALLBACK"; const BINDING_TEXT_CUSTOM_SERVICE = "MINIFLARE_CUSTOM_SERVICE"; const BINDING_JSON_CF_BLOB = "CF_BLOB"; +const BINDING_JSON_ROUTES = "MINIFLARE_ROUTES"; const BINDING_DATA_LIVE_RELOAD_SCRIPT = "MINIFLARE_LIVE_RELOAD_SCRIPT"; const LIVE_RELOAD_SCRIPT_TEMPLATE = ( @@ -129,7 +134,10 @@ const LIVE_RELOAD_SCRIPT_TEMPLATE = ( // Using `>=` for version check to handle multiple `setOptions` calls before // reload complete. -export const SCRIPT_ENTRY = `async function handleEvent(event) { +export const SCRIPT_ENTRY = ` +const matchRoutes = ${matchRoutes.toString()}; + +async function handleEvent(event) { const probe = event.request.headers.get("${HEADER_PROBE}"); if (probe !== null) { const probeMin = parseInt(probe); @@ -147,11 +155,16 @@ export const SCRIPT_ENTRY = `async function handleEvent(event) { }); request.headers.delete("${HEADER_ORIGINAL_URL}"); - if (globalThis.${BINDING_SERVICE_USER} === undefined) { + let service = globalThis.${BINDING_SERVICE_USER_FALLBACK}; + const url = new URL(request.url); + const route = matchRoutes(${BINDING_JSON_ROUTES}, url); + if (route !== null) service = globalThis["${BINDING_SERVICE_USER_ROUTE_PREFIX}" + route]; + if (service === undefined) { return new Response("No entrypoint worker found", { status: 404 }); } + try { - let response = await ${BINDING_SERVICE_USER}.fetch(request); + let response = await service.fetch(request); if ( response.status === 500 && @@ -319,60 +332,12 @@ export const CORE_PLUGIN: Plugin< async getServices({ log, options, - optionsVersion, workerBindings, workerIndex, - sharedOptions, durableObjectClassNames, additionalModules, - loopbackPort, }) { - // Define core/shared services. - const loopbackBinding: Worker_Binding = { - name: BINDING_SERVICE_LOOPBACK, - service: { name: SERVICE_LOOPBACK }, - }; - - // Services get de-duped by name, so only the first worker's - // SERVICE_LOOPBACK and SERVICE_ENTRY will be used - const serviceEntryBindings: Worker_Binding[] = [ - loopbackBinding, // For converting stack-traces to pretty-error pages - { name: BINDING_JSON_VERSION, json: optionsVersion.toString() }, - { name: BINDING_JSON_CF_BLOB, json: JSON.stringify(sharedOptions.cf) }, - ]; - if (sharedOptions.liveReload) { - const liveReloadScript = LIVE_RELOAD_SCRIPT_TEMPLATE(loopbackPort); - serviceEntryBindings.push({ - name: BINDING_DATA_LIVE_RELOAD_SCRIPT, - data: encoder.encode(liveReloadScript), - }); - } - const services: Service[] = [ - { - name: SERVICE_LOOPBACK, - external: { http: { cfBlobHeader: HEADER_CF_BLOB } }, - }, - { - name: SERVICE_ENTRY, - worker: { - serviceWorkerScript: SCRIPT_ENTRY, - compatibilityDate: "2022-09-01", - bindings: serviceEntryBindings, - }, - }, - // Allow access to private/public addresses: - // https://github.com/cloudflare/miniflare/issues/412 - { - name: "internet", - network: { - // Can't use `["public", "private"]` here because of - // https://github.com/cloudflare/workerd/issues/62 - allow: ["0.0.0.0/0"], - deny: [], - tlsOptions: { trustBrowserCas: true }, - }, - }, - ]; + const services: Service[] = []; // Define regular user worker if script is set const workerScript = getWorkerScript(options, workerIndex); @@ -412,10 +377,13 @@ export const CORE_PLUGIN: Plugin< cacheApiOutbound: { name: getCacheServiceName(workerIndex) }, }, }); - serviceEntryBindings.push({ - name: BINDING_SERVICE_USER, - service: { name }, - }); + } else if (workerIndex === 0 || options.routes?.length) { + throw new MiniflareCoreError( + "ERR_ROUTABLE_NO_SCRIPT", + `Worker [${workerIndex}] ${ + options.name === undefined ? "" : `("${options.name}") ` + }must have code defined as it's routable or the fallback` + ); } // Define custom `fetch` services if set @@ -433,7 +401,7 @@ export const CORE_PLUGIN: Plugin< name: BINDING_TEXT_CUSTOM_SERVICE, text: `${workerIndex}/${name}`, }, - loopbackBinding, + WORKER_BINDING_SERVICE_LOOPBACK, ], }, }); @@ -451,6 +419,74 @@ export const CORE_PLUGIN: Plugin< }, }; +export interface GlobalServicesOptions { + optionsVersion: number; + sharedOptions: z.infer; + allWorkerRoutes: Map; + fallbackWorkerName: string | undefined; + loopbackPort: number; +} +export function getGlobalServices({ + optionsVersion, + sharedOptions, + allWorkerRoutes, + fallbackWorkerName, + loopbackPort, +}: GlobalServicesOptions): Service[] { + // Collect list of workers we could route to, then parse and sort all routes + const routableWorkers = [...allWorkerRoutes.keys()]; + const routes = parseRoutes(allWorkerRoutes); + + // Define core/shared services. + const serviceEntryBindings: Worker_Binding[] = [ + WORKER_BINDING_SERVICE_LOOPBACK, // For converting stack-traces to pretty-error pages + { name: BINDING_JSON_VERSION, json: optionsVersion.toString() }, + { name: BINDING_JSON_ROUTES, json: JSON.stringify(routes) }, + { name: BINDING_JSON_CF_BLOB, json: JSON.stringify(sharedOptions.cf) }, + { + name: BINDING_SERVICE_USER_FALLBACK, + service: { name: getUserServiceName(fallbackWorkerName) }, + }, + ...routableWorkers.map((name) => ({ + name: BINDING_SERVICE_USER_ROUTE_PREFIX + name, + service: { name: getUserServiceName(name) }, + })), + ]; + if (sharedOptions.liveReload) { + const liveReloadScript = LIVE_RELOAD_SCRIPT_TEMPLATE(loopbackPort); + serviceEntryBindings.push({ + name: BINDING_DATA_LIVE_RELOAD_SCRIPT, + data: encoder.encode(liveReloadScript), + }); + } + return [ + { + name: SERVICE_LOOPBACK, + external: { http: { cfBlobHeader: HEADER_CF_BLOB } }, + }, + { + name: SERVICE_ENTRY, + worker: { + serviceWorkerScript: SCRIPT_ENTRY, + compatibilityDate: "2022-09-01", + bindings: serviceEntryBindings, + }, + }, + // Allow access to private/public addresses: + // https://github.com/cloudflare/miniflare/issues/412 + { + name: "internet", + network: { + // Can't use `["public", "private"]` here because of + // https://github.com/cloudflare/workerd/issues/62 + allow: ["0.0.0.0/0"], + deny: [], + tlsOptions: { trustBrowserCas: true }, + }, + }, + ]; +} + function getWorkerScript( options: z.infer, workerIndex: number diff --git a/packages/tre/src/plugins/d1/index.ts b/packages/tre/src/plugins/d1/index.ts index 214c50287..31d30cb01 100644 --- a/packages/tre/src/plugins/d1/index.ts +++ b/packages/tre/src/plugins/d1/index.ts @@ -1,13 +1,12 @@ import { z } from "zod"; import { Service, Worker_Binding } from "../../runtime"; -import { SERVICE_LOOPBACK } from "../core"; import { - BINDING_SERVICE_LOOPBACK, BINDING_TEXT_NAMESPACE, BINDING_TEXT_PLUGIN, PersistenceSchema, Plugin, SCRIPT_PLUGIN_NAMESPACE_PERSIST, + WORKER_BINDING_SERVICE_LOOPBACK, encodePersist, namespaceEntries, } from "../shared"; @@ -52,10 +51,7 @@ export const D1_PLUGIN: Plugin< ...persistBinding, { name: BINDING_TEXT_PLUGIN, text: D1_PLUGIN_NAME }, { name: BINDING_TEXT_NAMESPACE, text: id }, - { - name: BINDING_SERVICE_LOOPBACK, - service: { name: SERVICE_LOOPBACK }, - }, + WORKER_BINDING_SERVICE_LOOPBACK, ], }, })); diff --git a/packages/tre/src/plugins/index.ts b/packages/tre/src/plugins/index.ts index b32a583b4..f71858ad6 100644 --- a/packages/tre/src/plugins/index.ts +++ b/packages/tre/src/plugins/index.ts @@ -1,10 +1,11 @@ import { ValueOf } from "../shared"; import { CACHE_PLUGIN, CACHE_PLUGIN_NAME } from "./cache"; -import { CORE_PLUGIN, CORE_PLUGIN_NAME } from "./core"; +import { CORE_PLUGIN } from "./core"; import { D1_PLUGIN, D1_PLUGIN_NAME } from "./d1"; import { DURABLE_OBJECTS_PLUGIN, DURABLE_OBJECTS_PLUGIN_NAME } from "./do"; import { KV_PLUGIN, KV_PLUGIN_NAME } from "./kv"; import { R2_PLUGIN, R2_PLUGIN_NAME } from "./r2"; +import { CORE_PLUGIN_NAME } from "./shared"; export const PLUGINS = { [CORE_PLUGIN_NAME]: CORE_PLUGIN, @@ -22,7 +23,7 @@ export const PLUGIN_ENTRIES = Object.entries(PLUGINS) as [ ][]; export * from "./shared"; -export { SERVICE_LOOPBACK, SERVICE_ENTRY, HEADER_PROBE } from "./core"; +export { SERVICE_ENTRY, HEADER_PROBE, getGlobalServices } from "./core"; // TODO: be more liberal on exports? export * from "./cache"; @@ -31,7 +32,12 @@ export { ModuleRuleSchema, ModuleDefinitionSchema, } from "./core"; -export type { ModuleRuleType, ModuleRule, ModuleDefinition } from "./core"; +export type { + ModuleRuleType, + ModuleRule, + ModuleDefinition, + GlobalServicesOptions, +} from "./core"; export * from "./d1"; export * from "./do"; export * from "./kv"; diff --git a/packages/tre/src/plugins/kv/index.ts b/packages/tre/src/plugins/kv/index.ts index 5f9744388..2a55e0888 100644 --- a/packages/tre/src/plugins/kv/index.ts +++ b/packages/tre/src/plugins/kv/index.ts @@ -1,13 +1,12 @@ import { z } from "zod"; import { Service, Worker_Binding } from "../../runtime"; -import { SERVICE_LOOPBACK } from "../core"; import { - BINDING_SERVICE_LOOPBACK, BINDING_TEXT_NAMESPACE, BINDING_TEXT_PLUGIN, PersistenceSchema, Plugin, SCRIPT_PLUGIN_NAMESPACE_PERSIST, + WORKER_BINDING_SERVICE_LOOPBACK, encodePersist, namespaceEntries, } from "../shared"; @@ -72,10 +71,7 @@ export const KV_PLUGIN: Plugin< ...persistBinding, { name: BINDING_TEXT_PLUGIN, text: KV_PLUGIN_NAME }, { name: BINDING_TEXT_NAMESPACE, text: id }, - { - name: BINDING_SERVICE_LOOPBACK, - service: { name: SERVICE_LOOPBACK }, - }, + WORKER_BINDING_SERVICE_LOOPBACK, ], }, })); diff --git a/packages/tre/src/plugins/kv/sites.ts b/packages/tre/src/plugins/kv/sites.ts index 8cdc044d8..f851e8680 100644 --- a/packages/tre/src/plugins/kv/sites.ts +++ b/packages/tre/src/plugins/kv/sites.ts @@ -10,12 +10,12 @@ import { testRegExps, } from "../../shared"; import { FileStorage } from "../../storage"; -import { SERVICE_LOOPBACK } from "../core"; import { BINDING_SERVICE_LOOPBACK, BINDING_TEXT_PERSIST, HEADER_PERSIST, PARAM_FILE_UNSANITISE, + WORKER_BINDING_SERVICE_LOOPBACK, } from "../shared"; import { HEADER_SITES, KV_PLUGIN_NAME, PARAM_URL_ENCODED } from "./constants"; @@ -208,14 +208,11 @@ export function getSitesService(options: SitesOptions): Service { serviceWorkerScript: SCRIPT_SITE, compatibilityDate: "2022-09-01", bindings: [ + WORKER_BINDING_SERVICE_LOOPBACK, { name: BINDING_TEXT_PERSIST, text: JSON.stringify(persist), }, - { - name: BINDING_SERVICE_LOOPBACK, - service: { name: SERVICE_LOOPBACK }, - }, { name: BINDING_JSON_SITE_FILTER, json: JSON.stringify(serialisedSiteRegExps), diff --git a/packages/tre/src/plugins/r2/index.ts b/packages/tre/src/plugins/r2/index.ts index f2ded7243..f4d34240c 100644 --- a/packages/tre/src/plugins/r2/index.ts +++ b/packages/tre/src/plugins/r2/index.ts @@ -1,13 +1,12 @@ import { z } from "zod"; import { Service, Worker_Binding } from "../../runtime"; -import { SERVICE_LOOPBACK } from "../core"; import { - BINDING_SERVICE_LOOPBACK, BINDING_TEXT_NAMESPACE, BINDING_TEXT_PLUGIN, PersistenceSchema, Plugin, SCRIPT_PLUGIN_NAMESPACE_PERSIST, + WORKER_BINDING_SERVICE_LOOPBACK, encodePersist, namespaceEntries, } from "../shared"; @@ -40,10 +39,6 @@ export const R2_PLUGIN: Plugin< }, getServices({ options, sharedOptions }) { const persistBinding = encodePersist(sharedOptions.r2Persist); - const loopbackBinding: Worker_Binding = { - name: BINDING_SERVICE_LOOPBACK, - service: { name: SERVICE_LOOPBACK }, - }; const buckets = namespaceEntries(options.r2Buckets); return buckets.map(([_, id]) => ({ name: `${R2_PLUGIN_NAME}:${id}`, @@ -53,7 +48,7 @@ export const R2_PLUGIN: Plugin< ...persistBinding, { name: BINDING_TEXT_PLUGIN, text: R2_PLUGIN_NAME }, { name: BINDING_TEXT_NAMESPACE, text: id }, - loopbackBinding, + WORKER_BINDING_SERVICE_LOOPBACK, ], compatibilityDate: "2022-09-01", }, diff --git a/packages/tre/src/plugins/shared/constants.ts b/packages/tre/src/plugins/shared/constants.ts index 1c3436a6a..2f3ada6d6 100644 --- a/packages/tre/src/plugins/shared/constants.ts +++ b/packages/tre/src/plugins/shared/constants.ts @@ -2,8 +2,13 @@ import { Headers } from "../../http"; import { Worker_Binding } from "../../runtime"; import { Persistence, PersistenceSchema } from "./gateway"; +export const CORE_PLUGIN_NAME = "core"; + export const SOCKET_ENTRY = "entry"; +// Service looping back to Miniflare's Node.js process (for storage, etc) +export const SERVICE_LOOPBACK = `${CORE_PLUGIN_NAME}:loopback`; + export const HEADER_PERSIST = "MF-Persist"; // Even though we inject the `cf` blob in the entry script, we still need to // specify a header, so we receive things like `cf.cacheKey` in loopback @@ -15,6 +20,11 @@ export const BINDING_TEXT_PLUGIN = "MINIFLARE_PLUGIN"; export const BINDING_TEXT_NAMESPACE = "MINIFLARE_NAMESPACE"; export const BINDING_TEXT_PERSIST = "MINIFLARE_PERSIST"; +export const WORKER_BINDING_SERVICE_LOOPBACK: Worker_Binding = { + name: BINDING_SERVICE_LOOPBACK, + service: { name: SERVICE_LOOPBACK }, +}; + // TODO: make this an inherited worker in core plugin export const SCRIPT_PLUGIN_NAMESPACE_PERSIST = `addEventListener("fetch", (event) => { let request = event.request; diff --git a/packages/tre/src/plugins/shared/index.ts b/packages/tre/src/plugins/shared/index.ts index 1de6107f1..796e67a98 100644 --- a/packages/tre/src/plugins/shared/index.ts +++ b/packages/tre/src/plugins/shared/index.ts @@ -12,13 +12,11 @@ export interface PluginServicesOptions< > { log: Log; options: z.infer; - optionsVersion: number; sharedOptions: OptionalZodTypeOf; workerBindings: Worker_Binding[]; workerIndex: number; durableObjectClassNames: DurableObjectClassNames; additionalModules: Worker_Module[]; - loopbackPort: number; tmpPath: string; } @@ -67,3 +65,4 @@ export function namespaceEntries( export * from "./constants"; export * from "./gateway"; export * from "./router"; +export * from "./routing"; diff --git a/packages/tre/src/plugins/shared/routing.ts b/packages/tre/src/plugins/shared/routing.ts new file mode 100644 index 000000000..f8022e946 --- /dev/null +++ b/packages/tre/src/plugins/shared/routing.ts @@ -0,0 +1,142 @@ +import { URL, domainToUnicode } from "url"; +import { MiniflareError } from "../../shared"; + +export type RouterErrorCode = "ERR_QUERY_STRING" | "ERR_INFIX_WILDCARD"; + +export class RouterError extends MiniflareError {} + +export interface WorkerRoute { + target: string; + route: string; + + protocol?: string; + allowHostnamePrefix: boolean; + hostname: string; + path: string; + allowPathSuffix: boolean; +} + +const A_MORE_SPECIFIC = -1; +const B_MORE_SPECIFIC = 1; + +export function parseRoutes(allRoutes: Map): WorkerRoute[] { + const routes: WorkerRoute[] = []; + for (const [target, targetRoutes] of allRoutes) { + for (const route of targetRoutes) { + const hasProtocol = /^[a-z0-9+\-.]+:\/\//i.test(route); + + let urlInput = route; + // If route is missing a protocol, give it one so it parses + if (!hasProtocol) urlInput = `https://${urlInput}`; + const url = new URL(urlInput); + + const protocol = hasProtocol ? url.protocol : undefined; + + const internationalisedAllowHostnamePrefix = + url.hostname.startsWith("xn--*"); + const allowHostnamePrefix = + url.hostname.startsWith("*") || internationalisedAllowHostnamePrefix; + const anyHostname = url.hostname === "*"; + if (allowHostnamePrefix && !anyHostname) { + let hostname = url.hostname; + // If hostname is internationalised (e.g. `xn--gld-tna.se`), decode it + if (internationalisedAllowHostnamePrefix) { + hostname = domainToUnicode(hostname); + } + // Remove leading "*" + url.hostname = hostname.substring(1); + } + + const allowPathSuffix = url.pathname.endsWith("*"); + if (allowPathSuffix) { + url.pathname = url.pathname.substring(0, url.pathname.length - 1); + } + + if (url.search) { + throw new RouterError( + "ERR_QUERY_STRING", + `Route "${route}" for "${target}" contains a query string. This is not allowed.` + ); + } + if (url.toString().includes("*") && !anyHostname) { + throw new RouterError( + "ERR_INFIX_WILDCARD", + `Route "${route}" for "${target}" contains an infix wildcard. This is not allowed.` + ); + } + + routes.push({ + target, + route, + + protocol, + allowHostnamePrefix, + hostname: anyHostname ? "" : url.hostname, + path: url.pathname, + allowPathSuffix, + }); + } + } + + // Sort with the highest specificity first + routes.sort((a, b) => { + // 1. If one route matches on protocol, it is more specific + const aHasProtocol = a.protocol !== undefined; + const bHasProtocol = b.protocol !== undefined; + if (aHasProtocol && !bHasProtocol) return A_MORE_SPECIFIC; + if (!aHasProtocol && bHasProtocol) return B_MORE_SPECIFIC; + + // 2. If one route allows hostname prefixes, it is less specific + if (!a.allowHostnamePrefix && b.allowHostnamePrefix) return A_MORE_SPECIFIC; + if (a.allowHostnamePrefix && !b.allowHostnamePrefix) return B_MORE_SPECIFIC; + + // 3. If one route allows path suffixes, it is less specific + if (!a.allowPathSuffix && b.allowPathSuffix) return A_MORE_SPECIFIC; + if (a.allowPathSuffix && !b.allowPathSuffix) return B_MORE_SPECIFIC; + + // 4. If one route has more path segments, it is more specific + const aPathSegments = a.path.split("/"); + const bPathSegments = b.path.split("/"); + + // Specifically handle known route specificity issue here: + // https://developers.cloudflare.com/workers/platform/known-issues#route-specificity + const aLastSegmentEmpty = aPathSegments[aPathSegments.length - 1] === ""; + const bLastSegmentEmpty = bPathSegments[bPathSegments.length - 1] === ""; + if (aLastSegmentEmpty && !bLastSegmentEmpty) return B_MORE_SPECIFIC; + if (!aLastSegmentEmpty && bLastSegmentEmpty) return A_MORE_SPECIFIC; + + if (aPathSegments.length !== bPathSegments.length) + return bPathSegments.length - aPathSegments.length; + + // 5. If one route has a longer path, it is more specific + if (a.path.length !== b.path.length) return b.path.length - a.path.length; + + // 6. Finally, if one route has a longer hostname, it is more specific + return b.hostname.length - a.hostname.length; + }); + + return routes; +} + +export function matchRoutes(routes: WorkerRoute[], url: URL): string | null { + for (const route of routes) { + if (route.protocol && route.protocol !== url.protocol) continue; + + if (route.allowHostnamePrefix) { + if (!url.hostname.endsWith(route.hostname)) continue; + } else { + if (url.hostname !== route.hostname) continue; + } + + const path = url.pathname + url.search; + if (route.allowPathSuffix) { + if (!path.startsWith(route.path)) continue; + } else { + if (path !== route.path) continue; + } + + return route.target; + } + + return null; +} diff --git a/packages/tre/src/shared/error.ts b/packages/tre/src/shared/error.ts index 34bbadc83..7c479def4 100644 --- a/packages/tre/src/shared/error.ts +++ b/packages/tre/src/shared/error.ts @@ -23,7 +23,10 @@ export type MiniflareCoreErrorCode = | "ERR_PERSIST_UNSUPPORTED" // Unsupported storage persistence protocol | "ERR_PERSIST_REMOTE_UNAUTHENTICATED" // cloudflareFetch implementation not provided | "ERR_PERSIST_REMOTE_UNSUPPORTED" // Remote storage is not supported for this database - | "ERR_FUTURE_COMPATIBILITY_DATE"; // Compatibility date in the future + | "ERR_FUTURE_COMPATIBILITY_DATE" // Compatibility date in the future + | "ERR_NO_WORKERS" // No workers defined + | "ERR_DUPLICATE_NAME" // Multiple workers defined with same name + | "ERR_ROUTABLE_NO_SCRIPT"; // First or routable worker is missing code export class MiniflareCoreError extends MiniflareError {} export class HttpError extends MiniflareError { diff --git a/packages/tre/test/index.spec.ts b/packages/tre/test/index.spec.ts index eb6a8f64d..31082fd1b 100644 --- a/packages/tre/test/index.spec.ts +++ b/packages/tre/test/index.spec.ts @@ -5,6 +5,8 @@ import { DeferredPromise, MessageEvent, Miniflare, + MiniflareCoreError, + MiniflareOptions, fetch, } from "@miniflare/tre"; import test from "ava"; @@ -15,6 +17,93 @@ import { } from "ws"; import { getPort } from "./test-shared"; +test("Miniflare: validates options", async (t) => { + // Check empty workers array rejected + t.throws(() => new Miniflare({ workers: [] }), { + instanceOf: MiniflareCoreError, + code: "ERR_NO_WORKERS", + message: "No workers defined", + }); + + // Check workers with the same name rejected + t.throws(() => new Miniflare({ workers: [{}, {}] }), { + instanceOf: MiniflareCoreError, + code: "ERR_DUPLICATE_NAME", + message: 'Multiple workers defined with the same name: ""', + }); + t.throws( + () => + new Miniflare({ + workers: [{}, { name: "a" }, { name: "b" }, { name: "a" }], + }), + { + instanceOf: MiniflareCoreError, + code: "ERR_DUPLICATE_NAME", + message: 'Multiple workers defined with the same name: "a"', + } + ); + + // // Check entrypoint worker must have script + await t.throwsAsync(() => new Miniflare({ name: "worker" }).ready, { + instanceOf: MiniflareCoreError, + code: "ERR_ROUTABLE_NO_SCRIPT", + message: + 'Worker [0] ("worker") must have code defined as it\'s routable or the fallback', + }); + // Check routable workers must have scripts + await t.throwsAsync( + () => + new Miniflare({ + workers: [ + { name: "entry", script: "" }, + { name: "no-routes", routes: [] }, + { routes: ["*/*"] }, + ], + }).ready, + { + instanceOf: MiniflareCoreError, + code: "ERR_ROUTABLE_NO_SCRIPT", + message: + "Worker [2] must have code defined as it's routable or the fallback", + } + ); +}); + +test("Miniflare: routes to multiple workers with fallback", async (t) => { + const opts: MiniflareOptions = { + port: await getPort(), + workers: [ + { + name: "a", + routes: ["*/api"], + script: `addEventListener("fetch", (event) => { + event.respondWith(new Response("a")); + })`, + }, + { + name: "b", + routes: ["*/api*"], // Less specific than "a"'s + script: `addEventListener("fetch", (event) => { + event.respondWith(new Response("b")); + })`, + }, + ], + }; + const mf = new Miniflare(opts); + + // Check "a"'s more specific route checked first + let res = await mf.dispatchFetch("http://localhost/api"); + t.is(await res.text(), "a"); + + // Check "b" still accessible + res = await mf.dispatchFetch("http://localhost/api2"); + t.is(await res.text(), "b"); + + // Check fallback to first + res = await mf.dispatchFetch("http://localhost/notapi"); + t.is(await res.text(), "a"); +}); + test("Miniflare: web socket kitchen sink", async (t) => { // Create deferred promises for asserting asynchronous event results const clientEventPromise = new DeferredPromise(); diff --git a/packages/tre/test/plugins/shared/routing.spec.ts b/packages/tre/test/plugins/shared/routing.spec.ts new file mode 100644 index 000000000..df4ce607a --- /dev/null +++ b/packages/tre/test/plugins/shared/routing.spec.ts @@ -0,0 +1,154 @@ +// noinspection HttpUrlsUsage + +import { URL } from "url"; +import { RouterError, matchRoutes, parseRoutes } from "@miniflare/tre"; +import test from "ava"; + +// See https://developers.cloudflare.com/workers/platform/routes#matching-behavior and +// https://developers.cloudflare.com/workers/platform/known-issues#route-specificity + +test("throws if route contains query string", (t) => { + t.throws(() => parseRoutes(new Map([["a", ["example.com/?foo=*"]]])), { + instanceOf: RouterError, + code: "ERR_QUERY_STRING", + message: + 'Route "example.com/?foo=*" for "a" contains a query string. This is not allowed.', + }); +}); +test("throws if route contains infix wildcards", (t) => { + t.throws(() => parseRoutes(new Map([["a", ["example.com/*.jpg"]]])), { + instanceOf: RouterError, + code: "ERR_INFIX_WILDCARD", + message: + 'Route "example.com/*.jpg" for "a" contains an infix wildcard. This is not allowed.', + }); +}); +test("routes may begin with http:// or https://", (t) => { + let routes = parseRoutes(new Map([["a", ["example.com/*"]]])); + t.is(matchRoutes(routes, new URL("http://example.com/foo.jpg")), "a"); + t.is(matchRoutes(routes, new URL("https://example.com/foo.jpg")), "a"); + t.is(matchRoutes(routes, new URL("ftp://example.com/foo.jpg")), "a"); + + routes = parseRoutes( + new Map([ + ["a", ["http://example.com/*"]], + ["b", ["https://example.com/*"]], + ]) + ); + t.is(matchRoutes(routes, new URL("http://example.com/foo.jpg")), "a"); + t.is(matchRoutes(routes, new URL("https://example.com/foo.jpg")), "b"); + t.is(matchRoutes(routes, new URL("ftp://example.com/foo.jpg")), null); +}); +test("trailing slash automatically implied", (t) => { + const routes = parseRoutes(new Map([["a", ["example.com"]]])); + t.is(matchRoutes(routes, new URL("http://example.com/")), "a"); + t.is(matchRoutes(routes, new URL("https://example.com/")), "a"); +}); +test("route hostnames may begin with *", (t) => { + let routes = parseRoutes(new Map([["a", ["*example.com/"]]])); + t.is(matchRoutes(routes, new URL("https://example.com/")), "a"); + t.is(matchRoutes(routes, new URL("https://www.example.com/")), "a"); + + routes = parseRoutes(new Map([["a", ["*.example.com/"]]])); + t.is(matchRoutes(routes, new URL("https://example.com/")), null); + t.is(matchRoutes(routes, new URL("https://www.example.com/")), "a"); +}); +test("correctly handles internationalised domain names beginning with *", (t) => { + // https://github.com/cloudflare/miniflare/issues/186 + let routes = parseRoutes(new Map([["a", ["*glöd.se/*"]]])); + t.is(matchRoutes(routes, new URL("https://glöd.se/*")), "a"); + t.is(matchRoutes(routes, new URL("https://www.glöd.se/*")), "a"); + + routes = parseRoutes(new Map([["a", ["*.glöd.se/*"]]])); + t.is(matchRoutes(routes, new URL("https://glöd.se/*")), null); + t.is(matchRoutes(routes, new URL("https://www.glöd.se/*")), "a"); +}); +test("route paths may end with *", (t) => { + const routes = parseRoutes(new Map([["a", ["https://example.com/path*"]]])); + t.is(matchRoutes(routes, new URL("https://example.com/path")), "a"); + t.is(matchRoutes(routes, new URL("https://example.com/path2")), "a"); + t.is( + matchRoutes(routes, new URL("https://example.com/path/readme.txt")), + "a" + ); + t.is(matchRoutes(routes, new URL("https://example.com/notpath")), null); +}); +test("matches most specific route", (t) => { + let routes = parseRoutes( + new Map([ + ["a", ["www.example.com/*"]], + ["b", ["*.example.com/*"]], + ]) + ); + t.is(matchRoutes(routes, new URL("https://www.example.com/")), "a"); + + routes = parseRoutes( + new Map([ + ["a", ["example.com/*"]], + ["b", ["example.com/hello/*"]], + ]) + ); + t.is(matchRoutes(routes, new URL("https://example.com/hello/world")), "b"); + + routes = parseRoutes( + new Map([ + ["a", ["example.com/*"]], + ["b", ["https://example.com/*"]], + ]) + ); + t.is(matchRoutes(routes, new URL("https://example.com/hello")), "b"); + + routes = parseRoutes( + new Map([ + ["a", ["example.com/pa*"]], + ["b", ["example.com/path*"]], + ]) + ); + t.is(matchRoutes(routes, new URL("https://example.com/p")), null); + t.is(matchRoutes(routes, new URL("https://example.com/pa")), "a"); + t.is(matchRoutes(routes, new URL("https://example.com/pat")), "a"); + t.is(matchRoutes(routes, new URL("https://example.com/path")), "b"); +}); +test("matches query params", (t) => { + const routes = parseRoutes(new Map([["a", ["example.com/hello/*"]]])); + t.is( + matchRoutes(routes, new URL("https://example.com/hello/world?foo=bar")), + "a" + ); +}); +test("routes are case-sensitive", (t) => { + const routes = parseRoutes( + new Map([ + ["a", ["example.com/images/*"]], + ["b", ["example.com/Images/*"]], + ]) + ); + t.is(matchRoutes(routes, new URL("https://example.com/images/foo.jpg")), "a"); + t.is(matchRoutes(routes, new URL("https://example.com/Images/foo.jpg")), "b"); +}); +test("escapes regexp control characters", (t) => { + const routes = parseRoutes(new Map([["a", ["example.com/*"]]])); + t.is(matchRoutes(routes, new URL("https://example.com/")), "a"); + t.is(matchRoutes(routes, new URL("https://example2com/")), null); +}); +test('"correctly" handles routes with trailing /*', (t) => { + const routes = parseRoutes( + new Map([ + ["a", ["example.com/images/*"]], + ["b", ["example.com/images*"]], + ]) + ); + t.is(matchRoutes(routes, new URL("https://example.com/images")), "b"); + t.is(matchRoutes(routes, new URL("https://example.com/images123")), "b"); + t.is(matchRoutes(routes, new URL("https://example.com/images/hello")), "b"); // unexpected +}); +test("returns null if no routes match", (t) => { + const routes = parseRoutes(new Map([["a", ["example.com/*"]]])); + t.is(matchRoutes(routes, new URL("https://miniflare.dev/")), null); +}); +test("matches everything route", (t) => { + const routes = parseRoutes(new Map([["a", ["*/*"]]])); + t.is(matchRoutes(routes, new URL("http://example.com/")), "a"); + t.is(matchRoutes(routes, new URL("https://example.com/")), "a"); + t.is(matchRoutes(routes, new URL("https://miniflare.dev/")), "a"); +}); From 839b02c8b06ac88f1b7a436c77af1a7453551dd3 Mon Sep 17 00:00:00 2001 From: bcoll Date: Tue, 7 Mar 2023 10:49:14 +0000 Subject: [PATCH 2/9] Require code for all Workers It doesn't really make sense to have Workers without code. This change updates our `zod` schemas to encode this requirement. --- packages/tre/src/index.ts | 18 ++- packages/tre/src/plugins/core/errors/index.ts | 17 ++- packages/tre/src/plugins/core/index.ts | 108 ++++++++---------- packages/tre/src/plugins/core/modules.ts | 30 +++++ packages/tre/src/plugins/index.ts | 61 +++++++++- packages/tre/src/shared/error.ts | 3 +- packages/tre/src/shared/types.ts | 7 -- packages/tre/test/index.spec.ts | 34 ++---- packages/tre/test/test-shared/miniflare.ts | 28 +++-- 9 files changed, 174 insertions(+), 132 deletions(-) diff --git a/packages/tre/src/index.ts b/packages/tre/src/index.ts index 42a2950b5..467985834 100644 --- a/packages/tre/src/index.ts +++ b/packages/tre/src/index.ts @@ -35,6 +35,8 @@ import { Plugins, SERVICE_ENTRY, SOCKET_ENTRY, + SharedOptions, + WorkerOptions, getGlobalServices, maybeGetSitesManifestModule, normaliseDurableObject, @@ -69,20 +71,12 @@ import { Mutex, NoOpLog, OptionalZodTypeOf, - UnionToIntersection, - ValueOf, defaultClock, } from "./shared"; import { anyAbortSignal } from "./shared/signal"; import { waitForRequest } from "./wait"; // ===== `Miniflare` User Options ===== -export type WorkerOptions = UnionToIntersection< - z.infer["options"]> ->; -export type SharedOptions = UnionToIntersection< - z.infer["sharedOptions"], undefined>> ->; export type MiniflareOptions = SharedOptions & (WorkerOptions | { workers: WorkerOptions[] }); @@ -113,12 +107,12 @@ function validateOptions( // Validate all options for (const [key, plugin] of PLUGIN_ENTRIES) { - // @ts-expect-error pluginSharedOpts[key] could be any plugin's pluginSharedOpts[key] = plugin.sharedOptions?.parse(sharedOpts); for (let i = 0; i < workerOpts.length; i++) { // Make sure paths are correct in validation errors const path = multipleWorkers ? ["workers", i] : undefined; - // @ts-expect-error pluginWorkerOpts[i][key] could be any plugin's + // @ts-expect-error `CoreOptionsSchema` has required options which are + // missing in other plugins' options. pluginWorkerOpts[i][key] = plugin.options.parse(workerOpts[i], { path }); } } @@ -687,6 +681,8 @@ export class Miniflare { const workerBindings: Worker_Binding[] = []; const additionalModules: Worker_Module[] = []; for (const [key, plugin] of PLUGIN_ENTRIES) { + // @ts-expect-error `CoreOptionsSchema` has required options which are + // missing in other plugins' options. const pluginBindings = await plugin.getBindings(workerOpts[key], i); if (pluginBindings !== undefined) { workerBindings.push(...pluginBindings); @@ -703,6 +699,8 @@ export class Miniflare { for (const [key, plugin] of PLUGIN_ENTRIES) { const pluginServices = await plugin.getServices({ log: this.#log, + // @ts-expect-error `CoreOptionsSchema` has required options which are + // missing in other plugins' options. options: workerOpts[key], sharedOptions: sharedOpts[key], workerBindings, diff --git a/packages/tre/src/plugins/core/errors/index.ts b/packages/tre/src/plugins/core/errors/index.ts index e99751890..1cbf11fb4 100644 --- a/packages/tre/src/plugins/core/errors/index.ts +++ b/packages/tre/src/plugins/core/errors/index.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { Request, Response } from "../../../http"; import { Log } from "../../../shared"; import { - ModuleDefinition, + SourceOptions, contentsToString, maybeGetStringScriptPathIndex, } from "../modules"; @@ -42,12 +42,6 @@ import { getSourceMapper } from "./sourcemap"; // [ii] { script: "", modules: true }, -> "" // ] // -export interface SourceOptions { - script?: string; - scriptPath?: string; - modules?: boolean | ModuleDefinition[]; - modulesRoot?: string; -} interface SourceFile { path?: string; // Path may be undefined if file is in-memory @@ -80,7 +74,8 @@ function maybeGetFile( // ((g)[ii], (h)[ii]) custom `contents`, use those. for (const srcOpts of workerSrcOpts) { if (Array.isArray(srcOpts.modules)) { - const modulesRoot = srcOpts.modulesRoot; + const modulesRoot = + "modulesRoot" in srcOpts ? srcOpts.modulesRoot : undefined; // Handle cases (h)[i] and (h)[ii], by re-resolving file relative to // module root if any const modulesRootedFilePath = @@ -106,6 +101,8 @@ function maybeGetFile( // 2. If path matches any `scriptPath`s with custom `script`s, use those for (const srcOpts of workerSrcOpts) { if ( + "scriptPath" in srcOpts && + "script" in srcOpts && srcOpts.scriptPath !== undefined && srcOpts.script !== undefined && path.resolve(srcOpts.scriptPath) === filePath @@ -120,7 +117,7 @@ function maybeGetFile( const workerIndex = maybeGetStringScriptPathIndex(file); if (workerIndex !== undefined) { const srcOpts = workerSrcOpts[workerIndex]; - if (srcOpts.script !== undefined) { + if ("script" in srcOpts && srcOpts.script !== undefined) { return { contents: srcOpts.script }; } } @@ -139,7 +136,7 @@ function maybeGetFile( file === "worker.js" && (srcOpts.modules === undefined || srcOpts.modules === false) ) { - if (srcOpts.script !== undefined) { + if ("script" in srcOpts && srcOpts.script !== undefined) { // Cases: (a), (c) // ...if a custom `script` is defined, use that, with the defined // `scriptPath` if any (Case (c)) diff --git a/packages/tre/src/plugins/core/index.ts b/packages/tre/src/plugins/core/index.ts index aeef00a0a..e5b509611 100644 --- a/packages/tre/src/plugins/core/index.ts +++ b/packages/tre/src/plugins/core/index.ts @@ -1,3 +1,4 @@ +import assert from "assert"; import { readFileSync } from "fs"; import fs from "fs/promises"; import { TextEncoder } from "util"; @@ -25,9 +26,9 @@ import { } from "../shared"; import { HEADER_ERROR_STACK } from "./errors"; import { - ModuleDefinitionSchema, ModuleLocator, - ModuleRuleSchema, + SourceOptions, + SourceOptionsSchema, buildStringScriptPath, convertModuleDefinition, } from "./modules"; @@ -36,33 +37,23 @@ import { ServiceDesignatorSchema } from "./services"; const encoder = new TextEncoder(); const numericCompare = new Intl.Collator(undefined, { numeric: true }).compare; -export const CoreOptionsSchema = z.object({ - name: z.string().optional(), - script: z.string().optional(), - scriptPath: z.string().optional(), - modules: z - .union([ - // Automatically collect modules by parsing `script`/`scriptPath`... - z.boolean(), - // ...or manually define modules - // (used by Wrangler which has its own module collection code) - z.array(ModuleDefinitionSchema), - ]) - .optional(), - modulesRoot: z.string().optional(), - modulesRules: z.array(ModuleRuleSchema).optional(), - - compatibilityDate: z.string().optional(), - compatibilityFlags: z.string().array().optional(), - - routes: z.string().array().optional(), - - bindings: z.record(JsonSchema).optional(), - wasmBindings: z.record(z.string()).optional(), - textBlobBindings: z.record(z.string()).optional(), - dataBlobBindings: z.record(z.string()).optional(), - serviceBindings: z.record(ServiceDesignatorSchema).optional(), -}); +export const CoreOptionsSchema = z.intersection( + SourceOptionsSchema, + z.object({ + name: z.string().optional(), + + compatibilityDate: z.string().optional(), + compatibilityFlags: z.string().array().optional(), + + routes: z.string().array().optional(), + + bindings: z.record(JsonSchema).optional(), + wasmBindings: z.record(z.string()).optional(), + textBlobBindings: z.record(z.string()).optional(), + dataBlobBindings: z.record(z.string()).optional(), + serviceBindings: z.record(ServiceDesignatorSchema).optional(), + }) +); export const CoreSharedOptionsSchema = z.object({ host: z.string().optional(), @@ -337,26 +328,24 @@ export const CORE_PLUGIN: Plugin< durableObjectClassNames, additionalModules, }) { - const services: Service[] = []; - - // Define regular user worker if script is set + // Define regular user worker const workerScript = getWorkerScript(options, workerIndex); - if (workerScript !== undefined) { - // Add additional modules (e.g. "__STATIC_CONTENT_MANIFEST") if any - if ("modules" in workerScript) { - workerScript.modules.push(...additionalModules); - } + // Add additional modules (e.g. "__STATIC_CONTENT_MANIFEST") if any + if ("modules" in workerScript) { + workerScript.modules.push(...additionalModules); + } - const name = getUserServiceName(options.name); - const classNames = Array.from( - durableObjectClassNames.get(name) ?? new Set() - ); - const compatibilityDate = validateCompatibilityDate( - log, - options.compatibilityDate ?? FALLBACK_COMPATIBILITY_DATE - ); + const name = getUserServiceName(options.name); + const classNames = Array.from( + durableObjectClassNames.get(name) ?? new Set() + ); + const compatibilityDate = validateCompatibilityDate( + log, + options.compatibilityDate ?? FALLBACK_COMPATIBILITY_DATE + ); - services.push({ + const services: Service[] = [ + { name, worker: { ...workerScript, @@ -376,15 +365,8 @@ export const CORE_PLUGIN: Plugin< : { localDisk: DURABLE_OBJECTS_STORAGE_SERVICE_NAME }, cacheApiOutbound: { name: getCacheServiceName(workerIndex) }, }, - }); - } else if (workerIndex === 0 || options.routes?.length) { - throw new MiniflareCoreError( - "ERR_ROUTABLE_NO_SCRIPT", - `Worker [${workerIndex}] ${ - options.name === undefined ? "" : `("${options.name}") ` - }must have code defined as it's routable or the fallback` - ); - } + }, + ]; // Define custom `fetch` services if set if (options.serviceBindings !== undefined) { @@ -488,12 +470,13 @@ export function getGlobalServices({ } function getWorkerScript( - options: z.infer, + options: SourceOptions, workerIndex: number -): { serviceWorkerScript: string } | { modules: Worker_Module[] } | undefined { +): { serviceWorkerScript: string } | { modules: Worker_Module[] } { if (Array.isArray(options.modules)) { // If `modules` is a manually defined modules array, use that - const modulesRoot = options.modulesRoot ?? ""; + const modulesRoot = + ("modulesRoot" in options ? options.modulesRoot : undefined) ?? ""; return { modules: options.modules.map((module) => convertModuleDefinition(modulesRoot, module) @@ -503,14 +486,15 @@ function getWorkerScript( // Otherwise get code, preferring string `script` over `scriptPath` let code; - if (options.script !== undefined) { + if ("script" in options && options.script !== undefined) { code = options.script; - } else if (options.scriptPath !== undefined) { + } else if ("scriptPath" in options && options.scriptPath !== undefined) { code = readFileSync(options.scriptPath, "utf8"); } else { // If neither `script`, `scriptPath` nor `modules` is defined, this worker - // doesn't have any code - return; + // doesn't have any code. `SourceOptionsSchema` should've validated against + // this. + assert.fail("Unreachable: Workers must have code"); } if (options.modules) { diff --git a/packages/tre/src/plugins/core/modules.ts b/packages/tre/src/plugins/core/modules.ts index 629f75958..1b7267beb 100644 --- a/packages/tre/src/plugins/core/modules.ts +++ b/packages/tre/src/plugins/core/modules.ts @@ -61,6 +61,36 @@ export const ModuleDefinitionSchema = z.object({ }); export type ModuleDefinition = z.infer; +export const SourceOptionsSchema = z.union([ + z.object({ + // Manually defined modules + // (used by Wrangler which has its own module collection code) + modules: z.array(ModuleDefinitionSchema), + // `modules` "name"s will be their paths relative to this value. + // This ensures file paths in stack traces are correct. + modulesRoot: z.string().optional(), + }), + z.object({ + script: z.string(), + // Optional script path for resolving modules, and stack traces file names + scriptPath: z.string().optional(), + // Automatically collect modules by parsing `script` if `true`, or treat as + // service-worker if `false` + modules: z.boolean().optional(), + // How to interpret automatically collected modules + modulesRules: z.array(ModuleRuleSchema).optional(), + }), + z.object({ + scriptPath: z.string(), + // Automatically collect modules by parsing `scriptPath` if `true`, or treat + // as service-worker if `false` + modules: z.boolean().optional(), + // How to interpret automatically collected modules + modulesRules: z.array(ModuleRuleSchema).optional(), + }), +]); +export type SourceOptions = z.infer; + const DEFAULT_MODULE_RULES: ModuleRule[] = [ { type: "ESModule", include: ["**/*.mjs"] }, { type: "CommonJS", include: ["**/*.js", "**/*.cjs"] }, diff --git a/packages/tre/src/plugins/index.ts b/packages/tre/src/plugins/index.ts index f71858ad6..2f06f0b60 100644 --- a/packages/tre/src/plugins/index.ts +++ b/packages/tre/src/plugins/index.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { ValueOf } from "../shared"; import { CACHE_PLUGIN, CACHE_PLUGIN_NAME } from "./cache"; import { CORE_PLUGIN } from "./core"; @@ -14,29 +15,85 @@ export const PLUGINS = { [DURABLE_OBJECTS_PLUGIN_NAME]: DURABLE_OBJECTS_PLUGIN, [KV_PLUGIN_NAME]: KV_PLUGIN, [R2_PLUGIN_NAME]: R2_PLUGIN, -} as const; +}; export type Plugins = typeof PLUGINS; +// Note, we used to define these as... +// +// ```ts +// // A | B | ... => A & B & ... (https://stackoverflow.com/a/50375286) +// export type UnionToIntersection = ( +// U extends any ? (k: U) => void : never +// ) extends (k: infer I) => void +// ? I +// : never; +// export type WorkerOptions = UnionToIntersection< +// z.infer["options"]> +// >; +// export type SharedOptions = UnionToIntersection< +// z.infer["sharedOptions"], undefined>> +// >; +// ``` +// +// This caused issues when we tried to make `CORE_PLUGIN.options` an +// intersection of a union type (source options) and a regular object type. +// +// ```ts +// type A = { x: 1 } | { x: 2 }; +// type B = A & { y: string }; +// type C = UnionToIntersection; +// ``` +// +// In the above example, `C` is typed `{x: 1} & {x: 2} & {y: string}` which +// simplifies to `never`. Using `[U] extends [any]` instead of `U extends any` +// disables distributivity of union types over conditional types, which types +// `C` `({x: 1} | {x: 2}) & {y: string}` as expected. Unfortunately, this +// appears to prevent us assigning to any `MiniflareOptions` instances after +// creation, which we do quite a lot in tests. +// +// Considering we don't have too many plugins, we now just define these types +// manually, which has the added benefit of faster type checking. +export type WorkerOptions = z.infer & + z.infer & + z.infer & + z.infer & + z.infer & + z.infer; +export type SharedOptions = z.infer & + z.infer & + z.infer & + z.infer & + z.infer & + z.infer; + export const PLUGIN_ENTRIES = Object.entries(PLUGINS) as [ keyof Plugins, ValueOf ][]; export * from "./shared"; -export { SERVICE_ENTRY, HEADER_PROBE, getGlobalServices } from "./core"; // TODO: be more liberal on exports? export * from "./cache"; export { + CORE_PLUGIN, + CORE_PLUGIN_NAME, + HEADER_PROBE, + SERVICE_ENTRY, + CoreOptionsSchema, + CoreSharedOptionsSchema, + getGlobalServices, ModuleRuleTypeSchema, ModuleRuleSchema, ModuleDefinitionSchema, + SourceOptionsSchema, } from "./core"; export type { ModuleRuleType, ModuleRule, ModuleDefinition, GlobalServicesOptions, + SourceOptions, } from "./core"; export * from "./d1"; export * from "./do"; diff --git a/packages/tre/src/shared/error.ts b/packages/tre/src/shared/error.ts index 7c479def4..756006cd9 100644 --- a/packages/tre/src/shared/error.ts +++ b/packages/tre/src/shared/error.ts @@ -25,8 +25,7 @@ export type MiniflareCoreErrorCode = | "ERR_PERSIST_REMOTE_UNSUPPORTED" // Remote storage is not supported for this database | "ERR_FUTURE_COMPATIBILITY_DATE" // Compatibility date in the future | "ERR_NO_WORKERS" // No workers defined - | "ERR_DUPLICATE_NAME" // Multiple workers defined with same name - | "ERR_ROUTABLE_NO_SCRIPT"; // First or routable worker is missing code + | "ERR_DUPLICATE_NAME"; // Multiple workers defined with same name export class MiniflareCoreError extends MiniflareError {} export class HttpError extends MiniflareError { diff --git a/packages/tre/src/shared/types.ts b/packages/tre/src/shared/types.ts index 0f2af8801..3f8eac258 100644 --- a/packages/tre/src/shared/types.ts +++ b/packages/tre/src/shared/types.ts @@ -11,13 +11,6 @@ export function zAwaitable( // { a: A, b: B, ... } => A | B | ... export type ValueOf = T[keyof T]; -// A | B | ... => A & B & ... (https://stackoverflow.com/a/50375286) -export type UnionToIntersection = ( - U extends any ? (k: U) => void : never -) extends (k: infer I) => void - ? I - : never; - export type OptionalZodTypeOf = T extends z.ZodTypeAny ? z.TypeOf : undefined; diff --git a/packages/tre/test/index.spec.ts b/packages/tre/test/index.spec.ts index 31082fd1b..0acf2323b 100644 --- a/packages/tre/test/index.spec.ts +++ b/packages/tre/test/index.spec.ts @@ -26,45 +26,31 @@ test("Miniflare: validates options", async (t) => { }); // Check workers with the same name rejected - t.throws(() => new Miniflare({ workers: [{}, {}] }), { - instanceOf: MiniflareCoreError, - code: "ERR_DUPLICATE_NAME", - message: 'Multiple workers defined with the same name: ""', - }); t.throws( () => new Miniflare({ - workers: [{}, { name: "a" }, { name: "b" }, { name: "a" }], + workers: [{ script: "" }, { script: "" }], }), { instanceOf: MiniflareCoreError, code: "ERR_DUPLICATE_NAME", - message: 'Multiple workers defined with the same name: "a"', + message: 'Multiple workers defined with the same name: ""', } ); - - // // Check entrypoint worker must have script - await t.throwsAsync(() => new Miniflare({ name: "worker" }).ready, { - instanceOf: MiniflareCoreError, - code: "ERR_ROUTABLE_NO_SCRIPT", - message: - 'Worker [0] ("worker") must have code defined as it\'s routable or the fallback', - }); - // Check routable workers must have scripts - await t.throwsAsync( + t.throws( () => new Miniflare({ workers: [ - { name: "entry", script: "" }, - { name: "no-routes", routes: [] }, - { routes: ["*/*"] }, + { script: "" }, + { script: "", name: "a" }, + { script: "", name: "b" }, + { script: "", name: "a" }, ], - }).ready, + }), { instanceOf: MiniflareCoreError, - code: "ERR_ROUTABLE_NO_SCRIPT", - message: - "Worker [2] must have code defined as it's routable or the fallback", + code: "ERR_DUPLICATE_NAME", + message: 'Multiple workers defined with the same name: "a"', } ); }); diff --git a/packages/tre/test/test-shared/miniflare.ts b/packages/tre/test/test-shared/miniflare.ts index e9f6d49c1..9c556f085 100644 --- a/packages/tre/test/test-shared/miniflare.ts +++ b/packages/tre/test/test-shared/miniflare.ts @@ -20,11 +20,6 @@ export interface TestClock { timestamp: number; } -type MiniflareOptionsWithoutScripts = Exclude< - MiniflareOptions, - "script" | "scriptPath" | "modules" | "modulesRoot" ->; - export interface MiniflareTestContext { mf: Miniflare; url: URL; @@ -33,17 +28,17 @@ export interface MiniflareTestContext { // used to prevent races. log: TestLog; clock: TestClock; - setOptions(opts: MiniflareOptionsWithoutScripts): Promise; + setOptions(opts: Partial): Promise; } export function miniflareTest< Env, Context extends MiniflareTestContext = MiniflareTestContext >( - userOpts: MiniflareOptionsWithoutScripts, + userOpts: Partial, handler?: TestMiniflareHandler ): TestFn { - const scriptOpts: MiniflareOptions = {}; + let scriptOpts: MiniflareOptions | undefined; if (handler !== undefined) { const script = ` const handler = (${handler.toString()}); @@ -69,9 +64,9 @@ export function miniflareTest< } } `; - scriptOpts.modules = [ - { type: "ESModule", path: "index.mjs", contents: script }, - ]; + scriptOpts = { + modules: [{ type: "ESModule", path: "index.mjs", contents: script }], + }; } const test = anyTest as TestFn; @@ -80,19 +75,22 @@ export function miniflareTest< const clock: TestClock = { timestamp: 1_000_000 }; // 1000s const clockFunction = () => clock.timestamp; - const opts: MiniflareOptions = { + const opts: Partial = { + ...scriptOpts, port: await getPort(), log, clock: clockFunction, verbose: true, - ...scriptOpts, }; - t.context.mf = new Miniflare({ ...userOpts, ...opts }); + // `as MiniflareOptions` required as we're not enforcing that a script is + // provided between `userOpts` and `opts`. We assume if it's not in + // `userOpts`, a `handler` has been provided. + t.context.mf = new Miniflare({ ...userOpts, ...opts } as MiniflareOptions); t.context.log = log; t.context.clock = clock; t.context.setOptions = (userOpts) => - t.context.mf.setOptions({ ...userOpts, ...opts }); + t.context.mf.setOptions({ ...userOpts, ...opts } as MiniflareOptions); t.context.url = await t.context.mf.ready; }); test.after((t) => t.context.mf.dispose()); From f095aadbcc2f383e89b52f4faf3b040e8552b570 Mon Sep 17 00:00:00 2001 From: bcoll Date: Tue, 7 Mar 2023 11:00:17 +0000 Subject: [PATCH 3/9] fixup! Add support for routing to multiple Workers Assert names unique when collecting routes --- packages/tre/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tre/src/index.ts b/packages/tre/src/index.ts index 467985834..43bd42751 100644 --- a/packages/tre/src/index.ts +++ b/packages/tre/src/index.ts @@ -168,7 +168,9 @@ function getWorkerRoutes( const allRoutes = new Map(); for (const workerOpts of allWorkerOpts) { if (workerOpts.core.routes !== undefined) { - allRoutes.set(workerOpts.core.name ?? "", workerOpts.core.routes); + const name = workerOpts.core.name ?? ""; + assert(!allRoutes.has(name)); + allRoutes.set(name, workerOpts.core.routes); } } return allRoutes; From 4e99a58fa4a901a3dd33ea167df50e2b4f310b63 Mon Sep 17 00:00:00 2001 From: bcoll Date: Tue, 7 Mar 2023 11:16:04 +0000 Subject: [PATCH 4/9] fixup! Add support for routing to multiple Workers Move `CORE_PLUGIN_NAME` back to `core` --- packages/tre/src/plugins/core/index.ts | 3 ++- packages/tre/src/plugins/index.ts | 3 +-- packages/tre/src/plugins/shared/constants.ts | 4 +--- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/tre/src/plugins/core/index.ts b/packages/tre/src/plugins/core/index.ts index e5b509611..d752513a0 100644 --- a/packages/tre/src/plugins/core/index.ts +++ b/packages/tre/src/plugins/core/index.ts @@ -15,7 +15,6 @@ import { getCacheServiceName } from "../cache"; import { DURABLE_OBJECTS_STORAGE_SERVICE_NAME } from "../do"; import { BINDING_SERVICE_LOOPBACK, - CORE_PLUGIN_NAME, CloudflareFetchSchema, HEADER_CF_BLOB, Plugin, @@ -72,6 +71,8 @@ export const CoreSharedOptionsSchema = z.object({ liveReload: z.boolean().optional(), }); +export const CORE_PLUGIN_NAME = "core"; + // Service for HTTP socket entrypoint (for checking runtime ready, routing, etc) export const SERVICE_ENTRY = `${CORE_PLUGIN_NAME}:entry`; // Service prefix for all regular user workers diff --git a/packages/tre/src/plugins/index.ts b/packages/tre/src/plugins/index.ts index 2f06f0b60..d8035af21 100644 --- a/packages/tre/src/plugins/index.ts +++ b/packages/tre/src/plugins/index.ts @@ -1,12 +1,11 @@ import { z } from "zod"; import { ValueOf } from "../shared"; import { CACHE_PLUGIN, CACHE_PLUGIN_NAME } from "./cache"; -import { CORE_PLUGIN } from "./core"; +import { CORE_PLUGIN, CORE_PLUGIN_NAME } from "./core"; import { D1_PLUGIN, D1_PLUGIN_NAME } from "./d1"; import { DURABLE_OBJECTS_PLUGIN, DURABLE_OBJECTS_PLUGIN_NAME } from "./do"; import { KV_PLUGIN, KV_PLUGIN_NAME } from "./kv"; import { R2_PLUGIN, R2_PLUGIN_NAME } from "./r2"; -import { CORE_PLUGIN_NAME } from "./shared"; export const PLUGINS = { [CORE_PLUGIN_NAME]: CORE_PLUGIN, diff --git a/packages/tre/src/plugins/shared/constants.ts b/packages/tre/src/plugins/shared/constants.ts index 2f3ada6d6..51b5fe0d7 100644 --- a/packages/tre/src/plugins/shared/constants.ts +++ b/packages/tre/src/plugins/shared/constants.ts @@ -2,12 +2,10 @@ import { Headers } from "../../http"; import { Worker_Binding } from "../../runtime"; import { Persistence, PersistenceSchema } from "./gateway"; -export const CORE_PLUGIN_NAME = "core"; - export const SOCKET_ENTRY = "entry"; // Service looping back to Miniflare's Node.js process (for storage, etc) -export const SERVICE_LOOPBACK = `${CORE_PLUGIN_NAME}:loopback`; +export const SERVICE_LOOPBACK = "loopback"; export const HEADER_PERSIST = "MF-Persist"; // Even though we inject the `cf` blob in the entry script, we still need to From 1abbdbc716432139efcd05a0468a758f94e2c99f Mon Sep 17 00:00:00 2001 From: bcoll Date: Tue, 7 Mar 2023 11:18:49 +0000 Subject: [PATCH 5/9] fixup! Add support for routing to multiple Workers Add specific error message when defining multiple unnamed workers --- packages/tre/src/index.ts | 4 +++- packages/tre/test/index.spec.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/tre/src/index.ts b/packages/tre/src/index.ts index 43bd42751..c49dbdd48 100644 --- a/packages/tre/src/index.ts +++ b/packages/tre/src/index.ts @@ -124,7 +124,9 @@ function validateOptions( if (names.has(name)) { throw new MiniflareCoreError( "ERR_DUPLICATE_NAME", - `Multiple workers defined with the same name: "${name}"` + name === "" + ? "Multiple workers defined without a `name`" + : `Multiple workers defined with the same \`name\`: "${name}"` ); } names.add(name); diff --git a/packages/tre/test/index.spec.ts b/packages/tre/test/index.spec.ts index 0acf2323b..716f38513 100644 --- a/packages/tre/test/index.spec.ts +++ b/packages/tre/test/index.spec.ts @@ -34,7 +34,7 @@ test("Miniflare: validates options", async (t) => { { instanceOf: MiniflareCoreError, code: "ERR_DUPLICATE_NAME", - message: 'Multiple workers defined with the same name: ""', + message: "Multiple workers defined without a `name`", } ); t.throws( @@ -50,7 +50,7 @@ test("Miniflare: validates options", async (t) => { { instanceOf: MiniflareCoreError, code: "ERR_DUPLICATE_NAME", - message: 'Multiple workers defined with the same name: "a"', + message: 'Multiple workers defined with the same `name`: "a"', } ); }); From b7e619e8a8b97b75fbb2da717a44be2913491ccf Mon Sep 17 00:00:00 2001 From: bcoll Date: Tue, 7 Mar 2023 11:38:30 +0000 Subject: [PATCH 6/9] fixup! Add support for routing to multiple Workers Use `Map` when de-duping services --- packages/tre/src/index.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/tre/src/index.ts b/packages/tre/src/index.ts index c49dbdd48..de004f378 100644 --- a/packages/tre/src/index.ts +++ b/packages/tre/src/index.ts @@ -653,13 +653,21 @@ export class Miniflare { const durableObjectClassNames = getDurableObjectClassNames(allWorkerOpts); const allWorkerRoutes = getWorkerRoutes(allWorkerOpts); - const services: Service[] = getGlobalServices({ + // Use Map to dedupe services by name + const services = new Map(); + const globalServices = getGlobalServices({ optionsVersion, sharedOptions: sharedOpts.core, allWorkerRoutes, fallbackWorkerName: this.#workerOpts[0].core.name, loopbackPort, }); + for (const service of globalServices) { + // Global services should all have unique names + assert(service.name !== undefined && !services.has(service.name)); + services.set(service.name, service); + } + const sockets: Socket[] = [ { name: SOCKET_ENTRY, @@ -670,14 +678,6 @@ export class Miniflare { }, ]; - // Dedupe services by name - const serviceNames = new Set(); - for (const service of services) { - // Global services should all have unique names - assert(service.name !== undefined && !serviceNames.has(service.name)); - serviceNames.add(service.name); - } - for (let i = 0; i < allWorkerOpts.length; i++) { const workerOpts = allWorkerOpts[i]; @@ -715,16 +715,15 @@ export class Miniflare { }); if (pluginServices !== undefined) { for (const service of pluginServices) { - if (service.name !== undefined && !serviceNames.has(service.name)) { - serviceNames.add(service.name); - services.push(service); + if (service.name !== undefined && !services.has(service.name)) { + services.set(service.name, service); } } } } } - return { services, sockets }; + return { services: Array.from(services.values()), sockets }; } get ready(): Promise { From 0e2c68d822be0cf3cd4a8a5a903d22534929c149 Mon Sep 17 00:00:00 2001 From: bcoll Date: Tue, 7 Mar 2023 12:36:30 +0000 Subject: [PATCH 7/9] fixup! Add support for routing to multiple Workers Extract out common plugin/namespace/persist Worker into function --- packages/tre/src/plugins/cache/index.ts | 3 ++- packages/tre/src/plugins/d1/index.ts | 19 +++-------------- packages/tre/src/plugins/kv/index.ts | 19 +++-------------- packages/tre/src/plugins/r2/index.ts | 19 +++-------------- packages/tre/src/plugins/shared/constants.ts | 22 ++++++++++++++++++-- 5 files changed, 31 insertions(+), 51 deletions(-) diff --git a/packages/tre/src/plugins/cache/index.ts b/packages/tre/src/plugins/cache/index.ts index 16a742074..d786a6bff 100644 --- a/packages/tre/src/plugins/cache/index.ts +++ b/packages/tre/src/plugins/cache/index.ts @@ -24,6 +24,7 @@ export const CacheSharedOptionsSchema = z.object({ const BINDING_JSON_CACHE_WARN_USAGE = "MINIFLARE_CACHE_WARN_USAGE"; +const CACHE_SCRIPT_COMPAT_DATE = "2022-09-01"; export const CACHE_LOOPBACK_SCRIPT = `addEventListener("fetch", (event) => { const request = new Request(event.request); const url = new URL(request.url); @@ -84,7 +85,7 @@ export const CACHE_PLUGIN: Plugin< }, WORKER_BINDING_SERVICE_LOOPBACK, ], - compatibilityDate: "2022-09-01", + compatibilityDate: CACHE_SCRIPT_COMPAT_DATE, }, }, ]; diff --git a/packages/tre/src/plugins/d1/index.ts b/packages/tre/src/plugins/d1/index.ts index 31d30cb01..bb2134ccc 100644 --- a/packages/tre/src/plugins/d1/index.ts +++ b/packages/tre/src/plugins/d1/index.ts @@ -1,14 +1,10 @@ import { z } from "zod"; import { Service, Worker_Binding } from "../../runtime"; import { - BINDING_TEXT_NAMESPACE, - BINDING_TEXT_PLUGIN, PersistenceSchema, Plugin, - SCRIPT_PLUGIN_NAMESPACE_PERSIST, - WORKER_BINDING_SERVICE_LOOPBACK, - encodePersist, namespaceEntries, + pluginNamespacePersistWorker, } from "../shared"; import { D1Gateway } from "./gateway"; import { D1Router } from "./router"; @@ -40,20 +36,11 @@ export const D1_PLUGIN: Plugin< })); }, getServices({ options, sharedOptions }) { - const persistBinding = encodePersist(sharedOptions.d1Persist); + const persist = sharedOptions.d1Persist; const databases = namespaceEntries(options.d1Databases); return databases.map(([_, id]) => ({ name: `${SERVICE_DATABASE_PREFIX}:${id}`, - worker: { - serviceWorkerScript: SCRIPT_PLUGIN_NAMESPACE_PERSIST, - compatibilityDate: "2022-09-01", - bindings: [ - ...persistBinding, - { name: BINDING_TEXT_PLUGIN, text: D1_PLUGIN_NAME }, - { name: BINDING_TEXT_NAMESPACE, text: id }, - WORKER_BINDING_SERVICE_LOOPBACK, - ], - }, + worker: pluginNamespacePersistWorker(D1_PLUGIN_NAME, id, persist), })); }, }; diff --git a/packages/tre/src/plugins/kv/index.ts b/packages/tre/src/plugins/kv/index.ts index 2a55e0888..799167eb6 100644 --- a/packages/tre/src/plugins/kv/index.ts +++ b/packages/tre/src/plugins/kv/index.ts @@ -1,14 +1,10 @@ import { z } from "zod"; import { Service, Worker_Binding } from "../../runtime"; import { - BINDING_TEXT_NAMESPACE, - BINDING_TEXT_PLUGIN, PersistenceSchema, Plugin, - SCRIPT_PLUGIN_NAMESPACE_PERSIST, - WORKER_BINDING_SERVICE_LOOPBACK, - encodePersist, namespaceEntries, + pluginNamespacePersistWorker, } from "../shared"; import { KV_PLUGIN_NAME } from "./constants"; import { KVGateway } from "./gateway"; @@ -60,20 +56,11 @@ export const KV_PLUGIN: Plugin< return bindings; }, getServices({ options, sharedOptions }) { - const persistBinding = encodePersist(sharedOptions.kvPersist); + const persist = sharedOptions.kvPersist; const namespaces = namespaceEntries(options.kvNamespaces); const services = namespaces.map(([_, id]) => ({ name: `${SERVICE_NAMESPACE_PREFIX}:${id}`, - worker: { - serviceWorkerScript: SCRIPT_PLUGIN_NAMESPACE_PERSIST, - compatibilityDate: "2022-09-01", - bindings: [ - ...persistBinding, - { name: BINDING_TEXT_PLUGIN, text: KV_PLUGIN_NAME }, - { name: BINDING_TEXT_NAMESPACE, text: id }, - WORKER_BINDING_SERVICE_LOOPBACK, - ], - }, + worker: pluginNamespacePersistWorker(KV_PLUGIN_NAME, id, persist), })); if (isWorkersSitesEnabled(options)) { diff --git a/packages/tre/src/plugins/r2/index.ts b/packages/tre/src/plugins/r2/index.ts index f4d34240c..cdb0a4746 100644 --- a/packages/tre/src/plugins/r2/index.ts +++ b/packages/tre/src/plugins/r2/index.ts @@ -1,14 +1,10 @@ import { z } from "zod"; import { Service, Worker_Binding } from "../../runtime"; import { - BINDING_TEXT_NAMESPACE, - BINDING_TEXT_PLUGIN, PersistenceSchema, Plugin, - SCRIPT_PLUGIN_NAMESPACE_PERSIST, - WORKER_BINDING_SERVICE_LOOPBACK, - encodePersist, namespaceEntries, + pluginNamespacePersistWorker, } from "../shared"; import { R2Gateway } from "./gateway"; import { R2Router } from "./router"; @@ -38,20 +34,11 @@ export const R2_PLUGIN: Plugin< })); }, getServices({ options, sharedOptions }) { - const persistBinding = encodePersist(sharedOptions.r2Persist); + const persist = sharedOptions.r2Persist; const buckets = namespaceEntries(options.r2Buckets); return buckets.map(([_, id]) => ({ name: `${R2_PLUGIN_NAME}:${id}`, - worker: { - serviceWorkerScript: SCRIPT_PLUGIN_NAMESPACE_PERSIST, - bindings: [ - ...persistBinding, - { name: BINDING_TEXT_PLUGIN, text: R2_PLUGIN_NAME }, - { name: BINDING_TEXT_NAMESPACE, text: id }, - WORKER_BINDING_SERVICE_LOOPBACK, - ], - compatibilityDate: "2022-09-01", - }, + worker: pluginNamespacePersistWorker(R2_PLUGIN_NAME, id, persist), })); }, }; diff --git a/packages/tre/src/plugins/shared/constants.ts b/packages/tre/src/plugins/shared/constants.ts index 51b5fe0d7..e720ec2c7 100644 --- a/packages/tre/src/plugins/shared/constants.ts +++ b/packages/tre/src/plugins/shared/constants.ts @@ -1,5 +1,5 @@ import { Headers } from "../../http"; -import { Worker_Binding } from "../../runtime"; +import { Worker, Worker_Binding } from "../../runtime"; import { Persistence, PersistenceSchema } from "./gateway"; export const SOCKET_ENTRY = "entry"; @@ -24,7 +24,8 @@ export const WORKER_BINDING_SERVICE_LOOPBACK: Worker_Binding = { }; // TODO: make this an inherited worker in core plugin -export const SCRIPT_PLUGIN_NAMESPACE_PERSIST = `addEventListener("fetch", (event) => { +const SCRIPT_PLUGIN_NAMESPACE_PERSIST_COMPAT_DATE = "2022-09-01"; +const SCRIPT_PLUGIN_NAMESPACE_PERSIST = `addEventListener("fetch", (event) => { let request = event.request; const url = new URL(request.url); url.pathname = \`/\${${BINDING_TEXT_PLUGIN}}/\${${BINDING_TEXT_NAMESPACE}}\${url.pathname}\`; @@ -47,6 +48,23 @@ export function decodePersist(headers: Headers): Persistence { : PersistenceSchema.parse(JSON.parse(header)); } +export function pluginNamespacePersistWorker( + plugin: string, + namespace: string, + persist: Persistence +): Worker { + return { + serviceWorkerScript: SCRIPT_PLUGIN_NAMESPACE_PERSIST, + compatibilityDate: SCRIPT_PLUGIN_NAMESPACE_PERSIST_COMPAT_DATE, + bindings: [ + ...encodePersist(persist), + { name: BINDING_TEXT_PLUGIN, text: plugin }, + { name: BINDING_TEXT_NAMESPACE, text: namespace }, + WORKER_BINDING_SERVICE_LOOPBACK, + ], + }; +} + export enum CfHeader { Error = "cf-r2-error", Request = "cf-r2-request", From 0ed30499689e52085cc76a2439abd29ba3a90632 Mon Sep 17 00:00:00 2001 From: bcoll Date: Tue, 7 Mar 2023 13:49:40 +0000 Subject: [PATCH 8/9] fixup! Add support for routing to multiple Workers Use same specificity calculation for routes as internal service --- packages/tre/src/plugins/shared/routing.ts | 56 ++++++++-------------- 1 file changed, 21 insertions(+), 35 deletions(-) diff --git a/packages/tre/src/plugins/shared/routing.ts b/packages/tre/src/plugins/shared/routing.ts index f8022e946..c85b02354 100644 --- a/packages/tre/src/plugins/shared/routing.ts +++ b/packages/tre/src/plugins/shared/routing.ts @@ -8,6 +8,7 @@ export class RouterError extends MiniflareError {} export interface WorkerRoute { target: string; route: string; + specificity: number; protocol?: string; allowHostnamePrefix: boolean; @@ -16,8 +17,18 @@ export interface WorkerRoute { allowPathSuffix: boolean; } -const A_MORE_SPECIFIC = -1; -const B_MORE_SPECIFIC = 1; +function routeSpecificity(url: URL) { + // Adapted from internal config service routing table implementation + const hostParts = url.host.split("."); + let hostScore = hostParts.length; + if (hostParts[0] === "*") hostScore -= 2; + + const pathParts = url.pathname.split("/"); + let pathScore = pathParts.length; + if (pathParts[pathParts.length - 1] === "*") pathScore -= 2; + + return hostScore * 26 + pathScore; +} export function parseRoutes(allRoutes: Map): WorkerRoute[] { const routes: WorkerRoute[] = []; @@ -29,6 +40,7 @@ export function parseRoutes(allRoutes: Map): WorkerRoute[] { // If route is missing a protocol, give it one so it parses if (!hasProtocol) urlInput = `https://${urlInput}`; const url = new URL(urlInput); + const specificity = routeSpecificity(url); const protocol = hasProtocol ? url.protocol : undefined; @@ -68,6 +80,7 @@ export function parseRoutes(allRoutes: Map): WorkerRoute[] { routes.push({ target, route, + specificity, protocol, allowHostnamePrefix, @@ -80,39 +93,12 @@ export function parseRoutes(allRoutes: Map): WorkerRoute[] { // Sort with the highest specificity first routes.sort((a, b) => { - // 1. If one route matches on protocol, it is more specific - const aHasProtocol = a.protocol !== undefined; - const bHasProtocol = b.protocol !== undefined; - if (aHasProtocol && !bHasProtocol) return A_MORE_SPECIFIC; - if (!aHasProtocol && bHasProtocol) return B_MORE_SPECIFIC; - - // 2. If one route allows hostname prefixes, it is less specific - if (!a.allowHostnamePrefix && b.allowHostnamePrefix) return A_MORE_SPECIFIC; - if (a.allowHostnamePrefix && !b.allowHostnamePrefix) return B_MORE_SPECIFIC; - - // 3. If one route allows path suffixes, it is less specific - if (!a.allowPathSuffix && b.allowPathSuffix) return A_MORE_SPECIFIC; - if (a.allowPathSuffix && !b.allowPathSuffix) return B_MORE_SPECIFIC; - - // 4. If one route has more path segments, it is more specific - const aPathSegments = a.path.split("/"); - const bPathSegments = b.path.split("/"); - - // Specifically handle known route specificity issue here: - // https://developers.cloudflare.com/workers/platform/known-issues#route-specificity - const aLastSegmentEmpty = aPathSegments[aPathSegments.length - 1] === ""; - const bLastSegmentEmpty = bPathSegments[bPathSegments.length - 1] === ""; - if (aLastSegmentEmpty && !bLastSegmentEmpty) return B_MORE_SPECIFIC; - if (!aLastSegmentEmpty && bLastSegmentEmpty) return A_MORE_SPECIFIC; - - if (aPathSegments.length !== bPathSegments.length) - return bPathSegments.length - aPathSegments.length; - - // 5. If one route has a longer path, it is more specific - if (a.path.length !== b.path.length) return b.path.length - a.path.length; - - // 6. Finally, if one route has a longer hostname, it is more specific - return b.hostname.length - a.hostname.length; + if (a.specificity === b.specificity) { + // If routes are equally specific, sort by longest route first + return b.route.length - a.route.length; + } else { + return b.specificity - a.specificity; + } }); return routes; From a629360c3dccde24cb81dd1610adb9d0bf82fc5d Mon Sep 17 00:00:00 2001 From: bcoll Date: Tue, 7 Mar 2023 13:55:07 +0000 Subject: [PATCH 9/9] fixup! fixup! Add support for routing to multiple Workers --- packages/tre/test/index.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tre/test/index.spec.ts b/packages/tre/test/index.spec.ts index 716f38513..730fee734 100644 --- a/packages/tre/test/index.spec.ts +++ b/packages/tre/test/index.spec.ts @@ -68,7 +68,7 @@ test("Miniflare: routes to multiple workers with fallback", async (t) => { }, { name: "b", - routes: ["*/api*"], // Less specific than "a"'s + routes: ["*/api/*"], // Less specific than "a"'s script: `addEventListener("fetch", (event) => { event.respondWith(new Response("b")); })`, @@ -82,7 +82,7 @@ test("Miniflare: routes to multiple workers with fallback", async (t) => { t.is(await res.text(), "a"); // Check "b" still accessible - res = await mf.dispatchFetch("http://localhost/api2"); + res = await mf.dispatchFetch("http://localhost/api/2"); t.is(await res.text(), "b"); // Check fallback to first