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"); +});