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

Commit 7d032ee

Browse files
authored
[Miniflare 3] Add support for routing to multiple Workers (#520)
* 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. * 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. * fixup! Add support for routing to multiple Workers Assert names unique when collecting routes * fixup! Add support for routing to multiple Workers Move `CORE_PLUGIN_NAME` back to `core` * fixup! Add support for routing to multiple Workers Add specific error message when defining multiple unnamed workers * fixup! Add support for routing to multiple Workers Use `Map` when de-duping services * fixup! Add support for routing to multiple Workers Extract out common plugin/namespace/persist Worker into function * fixup! Add support for routing to multiple Workers Use same specificity calculation for routes as internal service * fixup! fixup! Add support for routing to multiple Workers
1 parent 4cb0dd9 commit 7d032ee

File tree

18 files changed

+715
-248
lines changed

18 files changed

+715
-248
lines changed

packages/tre/src/index.ts

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ import {
3535
Plugins,
3636
SERVICE_ENTRY,
3737
SOCKET_ENTRY,
38+
SharedOptions,
39+
WorkerOptions,
40+
getGlobalServices,
3841
maybeGetSitesManifestModule,
3942
normaliseDurableObject,
4043
} from "./plugins";
@@ -68,20 +71,12 @@ import {
6871
Mutex,
6972
NoOpLog,
7073
OptionalZodTypeOf,
71-
UnionToIntersection,
72-
ValueOf,
7374
defaultClock,
7475
} from "./shared";
7576
import { anyAbortSignal } from "./shared/signal";
7677
import { waitForRequest } from "./wait";
7778

7879
// ===== `Miniflare` User Options =====
79-
export type WorkerOptions = UnionToIntersection<
80-
z.infer<ValueOf<Plugins>["options"]>
81-
>;
82-
export type SharedOptions = UnionToIntersection<
83-
z.infer<Exclude<ValueOf<Plugins>["sharedOptions"], undefined>>
84-
>;
8580
export type MiniflareOptions = SharedOptions &
8681
(WorkerOptions | { workers: WorkerOptions[] });
8782

@@ -100,6 +95,9 @@ function validateOptions(
10095
const sharedOpts = opts;
10196
const multipleWorkers = "workers" in opts;
10297
const workerOpts = multipleWorkers ? opts.workers : [opts];
98+
if (workerOpts.length === 0) {
99+
throw new MiniflareCoreError("ERR_NO_WORKERS", "No workers defined");
100+
}
103101

104102
// Initialise return values
105103
const pluginSharedOpts = {} as PluginSharedOptions;
@@ -109,16 +107,31 @@ function validateOptions(
109107

110108
// Validate all options
111109
for (const [key, plugin] of PLUGIN_ENTRIES) {
112-
// @ts-expect-error pluginSharedOpts[key] could be any plugin's
113110
pluginSharedOpts[key] = plugin.sharedOptions?.parse(sharedOpts);
114111
for (let i = 0; i < workerOpts.length; i++) {
115112
// Make sure paths are correct in validation errors
116113
const path = multipleWorkers ? ["workers", i] : undefined;
117-
// @ts-expect-error pluginWorkerOpts[i][key] could be any plugin's
114+
// @ts-expect-error `CoreOptionsSchema` has required options which are
115+
// missing in other plugins' options.
118116
pluginWorkerOpts[i][key] = plugin.options.parse(workerOpts[i], { path });
119117
}
120118
}
121119

120+
// Validate names unique
121+
const names = new Set<string>();
122+
for (const opts of pluginWorkerOpts) {
123+
const name = opts.core.name ?? "";
124+
if (names.has(name)) {
125+
throw new MiniflareCoreError(
126+
"ERR_DUPLICATE_NAME",
127+
name === ""
128+
? "Multiple workers defined without a `name`"
129+
: `Multiple workers defined with the same \`name\`: "${name}"`
130+
);
131+
}
132+
names.add(name);
133+
}
134+
122135
return [pluginSharedOpts, pluginWorkerOpts];
123136
}
124137

@@ -150,6 +163,21 @@ function getDurableObjectClassNames(
150163
return serviceClassNames;
151164
}
152165

166+
// Collects all routes from all worker services
167+
function getWorkerRoutes(
168+
allWorkerOpts: PluginWorkerOptions[]
169+
): Map<string, string[]> {
170+
const allRoutes = new Map<string, string[]>();
171+
for (const workerOpts of allWorkerOpts) {
172+
if (workerOpts.core.routes !== undefined) {
173+
const name = workerOpts.core.name ?? "";
174+
assert(!allRoutes.has(name));
175+
allRoutes.set(name, workerOpts.core.routes);
176+
}
177+
}
178+
return allRoutes;
179+
}
180+
153181
// ===== `Miniflare` Internal Storage & Routing =====
154182
type OptionalGatewayFactoryType<
155183
Gateway extends GatewayConstructor<any> | undefined
@@ -622,7 +650,24 @@ export class Miniflare {
622650

623651
sharedOpts.core.cf = await setupCf(this.#log, sharedOpts.core.cf);
624652

625-
const services: Service[] = [];
653+
const durableObjectClassNames = getDurableObjectClassNames(allWorkerOpts);
654+
const allWorkerRoutes = getWorkerRoutes(allWorkerOpts);
655+
656+
// Use Map to dedupe services by name
657+
const services = new Map<string, Service>();
658+
const globalServices = getGlobalServices({
659+
optionsVersion,
660+
sharedOptions: sharedOpts.core,
661+
allWorkerRoutes,
662+
fallbackWorkerName: this.#workerOpts[0].core.name,
663+
loopbackPort,
664+
});
665+
for (const service of globalServices) {
666+
// Global services should all have unique names
667+
assert(service.name !== undefined && !services.has(service.name));
668+
services.set(service.name, service);
669+
}
670+
626671
const sockets: Socket[] = [
627672
{
628673
name: SOCKET_ENTRY,
@@ -633,18 +678,15 @@ export class Miniflare {
633678
},
634679
];
635680

636-
const durableObjectClassNames = getDurableObjectClassNames(allWorkerOpts);
637-
638-
// Dedupe services by name
639-
const serviceNames = new Set<string>();
640-
641681
for (let i = 0; i < allWorkerOpts.length; i++) {
642682
const workerOpts = allWorkerOpts[i];
643683

644684
// Collect all bindings from this worker
645685
const workerBindings: Worker_Binding[] = [];
646686
const additionalModules: Worker_Module[] = [];
647687
for (const [key, plugin] of PLUGIN_ENTRIES) {
688+
// @ts-expect-error `CoreOptionsSchema` has required options which are
689+
// missing in other plugins' options.
648690
const pluginBindings = await plugin.getBindings(workerOpts[key], i);
649691
if (pluginBindings !== undefined) {
650692
workerBindings.push(...pluginBindings);
@@ -661,28 +703,27 @@ export class Miniflare {
661703
for (const [key, plugin] of PLUGIN_ENTRIES) {
662704
const pluginServices = await plugin.getServices({
663705
log: this.#log,
706+
// @ts-expect-error `CoreOptionsSchema` has required options which are
707+
// missing in other plugins' options.
664708
options: workerOpts[key],
665-
optionsVersion,
666709
sharedOptions: sharedOpts[key],
667710
workerBindings,
668711
workerIndex: i,
669712
durableObjectClassNames,
670713
additionalModules,
671-
loopbackPort,
672714
tmpPath: this.#tmpPath,
673715
});
674716
if (pluginServices !== undefined) {
675717
for (const service of pluginServices) {
676-
if (service.name !== undefined && !serviceNames.has(service.name)) {
677-
serviceNames.add(service.name);
678-
services.push(service);
718+
if (service.name !== undefined && !services.has(service.name)) {
719+
services.set(service.name, service);
679720
}
680721
}
681722
}
682723
}
683724
}
684725

685-
return { services, sockets };
726+
return { services: Array.from(services.values()), sockets };
686727
}
687728

688729
get ready(): Promise<URL> {

packages/tre/src/plugins/cache/index.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import { z } from "zod";
2-
import { Worker_Binding } from "../../runtime";
3-
import { SERVICE_LOOPBACK } from "../core";
42
import {
53
BINDING_SERVICE_LOOPBACK,
64
BINDING_TEXT_PERSIST,
@@ -9,6 +7,7 @@ import {
97
HEADER_PERSIST,
108
PersistenceSchema,
119
Plugin,
10+
WORKER_BINDING_SERVICE_LOOPBACK,
1211
encodePersist,
1312
} from "../shared";
1413
import { HEADER_CACHE_WARN_USAGE } from "./constants";
@@ -25,6 +24,7 @@ export const CacheSharedOptionsSchema = z.object({
2524

2625
const BINDING_JSON_CACHE_WARN_USAGE = "MINIFLARE_CACHE_WARN_USAGE";
2726

27+
const CACHE_SCRIPT_COMPAT_DATE = "2022-09-01";
2828
export const CACHE_LOOPBACK_SCRIPT = `addEventListener("fetch", (event) => {
2929
const request = new Request(event.request);
3030
const url = new URL(request.url);
@@ -69,10 +69,6 @@ export const CACHE_PLUGIN: Plugin<
6969
},
7070
getServices({ sharedOptions, options, workerIndex }) {
7171
const persistBinding = encodePersist(sharedOptions.cachePersist);
72-
const loopbackBinding: Worker_Binding = {
73-
name: BINDING_SERVICE_LOOPBACK,
74-
service: { name: SERVICE_LOOPBACK },
75-
};
7672
return [
7773
{
7874
name: getCacheServiceName(workerIndex),
@@ -87,9 +83,9 @@ export const CACHE_PLUGIN: Plugin<
8783
name: BINDING_JSON_CACHE_WARN_USAGE,
8884
json: JSON.stringify(options.cacheWarnUsage ?? false),
8985
},
90-
loopbackBinding,
86+
WORKER_BINDING_SERVICE_LOOPBACK,
9187
],
92-
compatibilityDate: "2022-09-01",
88+
compatibilityDate: CACHE_SCRIPT_COMPAT_DATE,
9389
},
9490
},
9591
];

packages/tre/src/plugins/core/errors/index.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { z } from "zod";
55
import { Request, Response } from "../../../http";
66
import { Log } from "../../../shared";
77
import {
8-
ModuleDefinition,
8+
SourceOptions,
99
contentsToString,
1010
maybeGetStringScriptPathIndex,
1111
} from "../modules";
@@ -42,12 +42,6 @@ import { getSourceMapper } from "./sourcemap";
4242
// [ii] { script: "<contents:3>", modules: true }, -> "<script:3>"
4343
// ]
4444
//
45-
export interface SourceOptions {
46-
script?: string;
47-
scriptPath?: string;
48-
modules?: boolean | ModuleDefinition[];
49-
modulesRoot?: string;
50-
}
5145

5246
interface SourceFile {
5347
path?: string; // Path may be undefined if file is in-memory
@@ -80,7 +74,8 @@ function maybeGetFile(
8074
// ((g)[ii], (h)[ii]) custom `contents`, use those.
8175
for (const srcOpts of workerSrcOpts) {
8276
if (Array.isArray(srcOpts.modules)) {
83-
const modulesRoot = srcOpts.modulesRoot;
77+
const modulesRoot =
78+
"modulesRoot" in srcOpts ? srcOpts.modulesRoot : undefined;
8479
// Handle cases (h)[i] and (h)[ii], by re-resolving file relative to
8580
// module root if any
8681
const modulesRootedFilePath =
@@ -106,6 +101,8 @@ function maybeGetFile(
106101
// 2. If path matches any `scriptPath`s with custom `script`s, use those
107102
for (const srcOpts of workerSrcOpts) {
108103
if (
104+
"scriptPath" in srcOpts &&
105+
"script" in srcOpts &&
109106
srcOpts.scriptPath !== undefined &&
110107
srcOpts.script !== undefined &&
111108
path.resolve(srcOpts.scriptPath) === filePath
@@ -120,7 +117,7 @@ function maybeGetFile(
120117
const workerIndex = maybeGetStringScriptPathIndex(file);
121118
if (workerIndex !== undefined) {
122119
const srcOpts = workerSrcOpts[workerIndex];
123-
if (srcOpts.script !== undefined) {
120+
if ("script" in srcOpts && srcOpts.script !== undefined) {
124121
return { contents: srcOpts.script };
125122
}
126123
}
@@ -139,7 +136,7 @@ function maybeGetFile(
139136
file === "worker.js" &&
140137
(srcOpts.modules === undefined || srcOpts.modules === false)
141138
) {
142-
if (srcOpts.script !== undefined) {
139+
if ("script" in srcOpts && srcOpts.script !== undefined) {
143140
// Cases: (a), (c)
144141
// ...if a custom `script` is defined, use that, with the defined
145142
// `scriptPath` if any (Case (c))

0 commit comments

Comments
 (0)