Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 9f539d8

Browse files
committed
Customisable mount routing, inspired by multiflare, thanks @dan-lee
- Mounts now get their routes from the standard or `[miniflare]` `route`/`routes` `wrangler.toml` keys - `convertNodeRequest` no longer takes an upstream, it just uses the `Host` header - `convertNodeRequest` will now give `https` requests `https` URLs - `Miniflare#dispatchFetch` will now rewrite the request URL/`Host` header to match the upstream - Upstream URLs are now validated on init
1 parent 6959eb2 commit 9f539d8

File tree

12 files changed

+514
-87
lines changed

12 files changed

+514
-87
lines changed

packages/core/src/error.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { MiniflareError } from "@miniflare/shared";
2+
3+
export type MiniflareCoreErrorCode =
4+
| "ERR_NO_SCRIPT" // No script specified but one was required
5+
| "ERR_MOUNT_NO_NAME" // Attempted to mount a worker with an empty string name
6+
| "ERR_MOUNT_NESTED" // Attempted to recursively mount workers
7+
| "ERR_MOUNT" // Error whilst mounting worker
8+
| "ERR_INVALID_UPSTREAM"; // Invalid upstream URL
9+
10+
export class MiniflareCoreError extends MiniflareError<MiniflareCoreErrorCode> {}

packages/core/src/index.ts

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
Compatibility,
88
Context,
99
Log,
10-
MiniflareError,
1110
Mutex,
1211
Options,
1312
PluginContext,
@@ -29,8 +28,10 @@ import {
2928
import type { Watcher } from "@miniflare/watcher";
3029
import { dequal } from "dequal/lite";
3130
import { dim } from "kleur/colors";
31+
import { MiniflareCoreError } from "./error";
3232
import { formatSize, pathsToString } from "./helpers";
3333
import { BindingsPlugin, CorePlugin, _populateBuildConfig } from "./plugins";
34+
import { Router } from "./router";
3435
import {
3536
Request,
3637
RequestInfo,
@@ -49,6 +50,9 @@ import { PluginStorageFactory } from "./storage";
4950

5051
export * from "./plugins";
5152
export * from "./standards";
53+
54+
export * from "./error";
55+
export * from "./router";
5256
export * from "./storage";
5357

5458
/** @internal */
@@ -90,14 +94,6 @@ export type MiniflareCoreOptions<Plugins extends CorePluginSignatures> = Omit<
9094
mounts?: Record<string, string | Omit<Options<Plugins>, "mounts">>;
9195
};
9296

93-
export type MiniflareCoreErrorCode =
94-
| "ERR_NO_SCRIPT" // No script specified but one was required
95-
| "ERR_MOUNT_NO_NAME" // Attempted to mount a worker with an empty string name
96-
| "ERR_MOUNT_NESTED" // Attempted to recursively mount workers
97-
| "ERR_MOUNT"; // Error whilst mounting worker
98-
99-
export class MiniflareCoreError extends MiniflareError<MiniflareCoreErrorCode> {}
100-
10197
function getPluginEntries<Plugins extends PluginSignatures>(
10298
plugins: Plugins
10399
): PluginEntries<Plugins> {
@@ -244,6 +240,7 @@ export class MiniflareCore<
244240
#previousRootPath?: string;
245241
#instances?: PluginInstances<Plugins>;
246242
#mounts?: Map<string, MiniflareCore<Plugins>>;
243+
#router?: Router;
247244

248245
#wranglerConfigPath?: string;
249246
#watching?: boolean;
@@ -512,12 +509,15 @@ export class MiniflareCore<
512509
scriptRunForModuleExports: false,
513510
};
514511
mount = new MiniflareCore(this.#originalPlugins, ctx, mountOptions);
515-
mount.addEventListener("reload", (event) => {
512+
mount.addEventListener("reload", async (event) => {
516513
// Reload parent (us) whenever mounted child reloads, ignoring the
517514
// initial reload. This ensures the page is reloaded when live
518515
// reloading, and also that we're using up-to-date Durable Object
519516
// classes from mounts.
520-
if (!event.initial) this.#reload();
517+
if (!event.initial) {
518+
await this.#updateRouter();
519+
await this.#reload();
520+
}
521521
});
522522
try {
523523
await mount.getPlugins();
@@ -542,6 +542,26 @@ export class MiniflareCore<
542542
}
543543
}
544544
}
545+
await this.#updateRouter();
546+
}
547+
548+
async #updateRouter(): Promise<void> {
549+
const allRoutes = new Map<string, string[]>();
550+
for (const [name, mount] of this.#mounts!) {
551+
const routes = (await mount.getPlugins()).CorePlugin.routes;
552+
if (routes) allRoutes.set(name, routes);
553+
}
554+
this.#router ??= new Router();
555+
this.#router.update(allRoutes);
556+
if (this.#mounts!.size) {
557+
this.#ctx.log.debug(
558+
`Mount Routes:${this.#router.routes.length === 0 ? " <none>" : ""}`
559+
);
560+
for (let i = 0; i < this.#router.routes.length; i++) {
561+
const route = this.#router.routes[i];
562+
this.#ctx.log.debug(`${i + 1}. ${route.route} => ${route.target}`);
563+
}
564+
}
545565
}
546566

547567
async #reload(): Promise<void> {
@@ -849,29 +869,38 @@ export class MiniflareCore<
849869
// noinspection SuspiciousTypeOfGuard
850870
let request =
851871
input instanceof Request && !init ? input : new Request(input, init);
872+
const url = new URL(request.url);
852873

853874
// Forward to matching mount if any
854875
if (this.#mounts?.size) {
855-
const url = new URL(request.url);
856-
for (const [name, mount] of this.#mounts) {
857-
const prefix = `/${name}`;
858-
if (url.pathname === prefix || url.pathname.startsWith(`${prefix}/`)) {
859-
// Trim mount prefix from request URL
860-
url.pathname = url.pathname.slice(prefix.length);
861-
return mount.dispatchFetch(new Request(url, request));
862-
}
876+
const mountMatch = this.#router!.match(url);
877+
if (mountMatch !== null) {
878+
const mount = this.#mounts.get(mountMatch);
879+
if (mount) return mount.dispatchFetch(request);
863880
}
864881
}
865882

866-
const corePlugin = this.#instances!.CorePlugin;
867-
const globalScope = this.#globalScope;
883+
// If upstream set, and the request URL doesn't begin with it, rewrite it
884+
const { upstreamURL } = this.#instances!.CorePlugin;
885+
if (upstreamURL && !url.toString().startsWith(upstreamURL.toString())) {
886+
let path = url.pathname + url.search;
887+
// Remove leading slash so we resolve relative to upstream's path
888+
if (path.startsWith("/")) path = path.substring(1);
889+
const newURL = new URL(path, upstreamURL);
890+
request = new Request(newURL, request);
891+
// Make sure Host header is correct
892+
request.headers.set("host", upstreamURL.host);
893+
}
868894

895+
// Parse form data files as strings if the compatibility flag isn't set
869896
if (!this.#compat!.isEnabled("formdata_parser_supports_files")) {
870897
request = withStringFormDataFiles(request);
871898
}
899+
900+
const globalScope = this.#globalScope;
872901
return globalScope![kDispatchFetch]<WaitUntil>(
873902
withImmutableHeaders(request),
874-
!!corePlugin.upstream
903+
!!upstreamURL // only proxy if upstream URL set
875904
);
876905
}
877906

packages/core/src/plugins/core.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
globsToMatcher,
3535
} from "@miniflare/shared";
3636
import { File, FormData, Headers } from "undici";
37+
import { MiniflareCoreError } from "../error";
3738
import {
3839
DOMException,
3940
FetchEvent,
@@ -98,6 +99,7 @@ export interface CoreOptions {
9899
updateCheck?: boolean;
99100
// Replaced in MiniflareCoreOptions with something plugins-specific
100101
mounts?: Record<string, string | CoreOptions | BindingsOptions>;
102+
routes?: string[];
101103
}
102104

103105
function mapMountEntries([name, pathEnv]: [string, string]): [
@@ -280,8 +282,22 @@ export class CorePlugin extends Plugin<CoreOptions> implements CoreOptions {
280282
})
281283
mounts?: Record<string, string | CoreOptions | BindingsOptions>;
282284

285+
@Option({
286+
type: OptionType.NONE,
287+
fromWrangler: ({ route, routes, miniflare }) => {
288+
const result: string[] = [];
289+
if (route) result.push(route);
290+
if (routes) result.push(...routes);
291+
if (miniflare?.route) result.push(miniflare.route);
292+
if (miniflare?.routes) result.push(...miniflare.routes);
293+
return result.length ? result : undefined;
294+
},
295+
})
296+
routes?: string[];
297+
283298
readonly processedModuleRules: ProcessedModuleRule[] = [];
284299

300+
readonly upstreamURL?: URL;
285301
readonly #globals: Context;
286302

287303
constructor(ctx: PluginContext, options?: CoreOptions) {
@@ -299,6 +315,18 @@ export class CorePlugin extends Plugin<CoreOptions> implements CoreOptions {
299315
CompatResponse = proxyStringFormDataFiles(CompatResponse);
300316
}
301317

318+
// Try to parse upstream URL if set
319+
try {
320+
this.upstreamURL =
321+
this.upstream === undefined ? undefined : new URL(this.upstream);
322+
} catch (e: any) {
323+
// Throw with a more helpful error message
324+
throw new MiniflareCoreError(
325+
"ERR_INVALID_UPSTREAM",
326+
`Invalid upstream URL: \"${this.upstream}\". Make sure you've included the protocol.`
327+
);
328+
}
329+
302330
// Build globals object
303331
// noinspection JSDeprecatedSymbols
304332
this.#globals = {

packages/core/src/router.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { URL } from "url";
2+
import { MiniflareError } from "@miniflare/shared";
3+
4+
export type RouterErrorCode = "ERR_QUERY_STRING" | "ERR_INFIX_WILDCARD";
5+
6+
export class RouterError extends MiniflareError<RouterErrorCode> {}
7+
8+
export interface Route {
9+
target: string;
10+
route: string;
11+
12+
protocol?: string;
13+
allowHostnamePrefix: boolean;
14+
hostname: string;
15+
path: string;
16+
allowPathSuffix: boolean;
17+
}
18+
19+
const A_MORE_SPECIFIC = -1;
20+
const B_MORE_SPECIFIC = 1;
21+
22+
export class Router {
23+
routes: Route[] = [];
24+
25+
update(allRoutes: Map<string, string[]>): void {
26+
const newRoutes: Route[] = [];
27+
for (const [target, routes] of allRoutes) {
28+
for (const route of routes) {
29+
const hasProtocol = /^[a-z0-9+\-.]+:\/\//i.test(route);
30+
31+
let urlInput = route;
32+
// If route is missing a protocol, give it one so it parses
33+
if (!hasProtocol) urlInput = `https://${urlInput}`;
34+
const url = new URL(urlInput);
35+
36+
const protocol = hasProtocol ? url.protocol : undefined;
37+
38+
const allowHostnamePrefix = url.hostname.startsWith("*");
39+
if (allowHostnamePrefix) {
40+
url.hostname = url.hostname.substring(1);
41+
}
42+
43+
const allowPathSuffix = url.pathname.endsWith("*");
44+
if (allowPathSuffix) {
45+
url.pathname = url.pathname.substring(0, url.pathname.length - 1);
46+
}
47+
48+
if (url.search) {
49+
throw new RouterError(
50+
"ERR_QUERY_STRING",
51+
`Route "${route}" for "${target}" contains a query string. This is not allowed.`
52+
);
53+
}
54+
if (url.toString().includes("*")) {
55+
throw new RouterError(
56+
"ERR_INFIX_WILDCARD",
57+
`Route "${route}" for "${target}" contains an infix wildcard. This is not allowed.`
58+
);
59+
}
60+
61+
newRoutes.push({
62+
target,
63+
route,
64+
65+
protocol,
66+
allowHostnamePrefix,
67+
hostname: url.hostname,
68+
path: url.pathname,
69+
allowPathSuffix,
70+
});
71+
}
72+
}
73+
74+
// Sort with highest specificity first
75+
newRoutes.sort((a, b) => {
76+
// 1. If one route matches on protocol, it is more specific
77+
const aHasProtocol = a.protocol !== undefined;
78+
const bHasProtocol = b.protocol !== undefined;
79+
if (aHasProtocol && !bHasProtocol) return A_MORE_SPECIFIC;
80+
if (!aHasProtocol && bHasProtocol) return B_MORE_SPECIFIC;
81+
82+
// 2. If one route allows hostname prefixes, it is less specific
83+
if (!a.allowHostnamePrefix && b.allowHostnamePrefix)
84+
return A_MORE_SPECIFIC;
85+
if (a.allowHostnamePrefix && !b.allowHostnamePrefix)
86+
return B_MORE_SPECIFIC;
87+
88+
// 3. If one route allows path suffixes, it is less specific
89+
if (!a.allowPathSuffix && b.allowPathSuffix) return A_MORE_SPECIFIC;
90+
if (a.allowPathSuffix && !b.allowPathSuffix) return B_MORE_SPECIFIC;
91+
92+
// 4. If one route has more path segments, it is more specific
93+
const aPathSegments = a.path.split("/");
94+
const bPathSegments = b.path.split("/");
95+
96+
// Specifically handle known route specificity issue here:
97+
// https://developers.cloudflare.com/workers/platform/known-issues#route-specificity
98+
const aLastSegmentEmpty = aPathSegments[aPathSegments.length - 1] === "";
99+
const bLastSegmentEmpty = bPathSegments[bPathSegments.length - 1] === "";
100+
if (aLastSegmentEmpty && !bLastSegmentEmpty) return B_MORE_SPECIFIC;
101+
if (!aLastSegmentEmpty && bLastSegmentEmpty) return A_MORE_SPECIFIC;
102+
103+
if (aPathSegments.length !== bPathSegments.length)
104+
return bPathSegments.length - aPathSegments.length;
105+
106+
// 5. If one route has a longer path, it is more specific
107+
if (a.path.length !== b.path.length) return b.path.length - a.path.length;
108+
109+
// 6. Finally, if one route has a longer hostname, it is more specific
110+
return b.hostname.length - a.hostname.length;
111+
});
112+
113+
this.routes = newRoutes;
114+
}
115+
116+
match(url: URL): string | null {
117+
for (const route of this.routes) {
118+
if (route.protocol && route.protocol !== url.protocol) continue;
119+
120+
if (route.allowHostnamePrefix) {
121+
if (!url.hostname.endsWith(route.hostname)) continue;
122+
} else {
123+
if (url.hostname !== route.hostname) continue;
124+
}
125+
126+
const path = url.pathname + url.search;
127+
if (route.allowPathSuffix) {
128+
if (!path.startsWith(route.path)) continue;
129+
} else {
130+
if (path !== route.path) continue;
131+
}
132+
133+
return route.target;
134+
}
135+
136+
return null;
137+
}
138+
}

0 commit comments

Comments
 (0)