Skip to content

Commit b87b472

Browse files
authored
Support Mixed Mode Dispatch Namespaces (#9245)
* Support Dispatch Namespace bindings * Create tough-seals-whisper.md * Update tough-seals-whisper.md * address comments
1 parent 02d40ed commit b87b472

File tree

7 files changed

+213
-13
lines changed

7 files changed

+213
-13
lines changed

.changeset/tough-seals-whisper.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"miniflare": patch
3+
"wrangler": patch
4+
---
5+
6+
Support Mixed Mode Dispatch Namespaces

fixtures/mixed-mode-node-test/tests/startMixedModeSession.test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,43 @@ baseDescribe("startMixedModeSession", () => {
170170
await mixedModeSession.ready;
171171
await mixedModeSession.dispose();
172172
});
173+
174+
test("Dispatch Namespace mixed mode binding", async () => {
175+
const mixedModeSession = await experimental_startMixedModeSession({
176+
DISPATCH: {
177+
type: "dispatch_namespace",
178+
namespace: "mixed-mode-test-namespace",
179+
},
180+
});
181+
182+
const mf = new Miniflare({
183+
compatibilityDate: "2025-01-01",
184+
modules: true,
185+
script: /* javascript */ `
186+
export default {
187+
async fetch(request, env) {
188+
try{
189+
const worker = env.DISPATCH.get("mixed-mode-test-customer-worker")
190+
return Response.json({
191+
"worker": await (await worker.fetch("http://example.com")).text(),
192+
})}catch(e){console.log(e);return new Response(e)}
193+
}
194+
}
195+
`,
196+
dispatchNamespaces: {
197+
DISPATCH: {
198+
namespace: "mixed-mode-test-namespace",
199+
mixedModeConnectionString: mixedModeSession.mixedModeConnectionString,
200+
},
201+
},
202+
});
203+
const response = await (
204+
await mf.dispatchFetch("http://example.com")
205+
).text();
206+
assert.match(response, /Hello from customer worker/);
207+
await mf.dispose();
208+
209+
await mixedModeSession.ready;
210+
await mixedModeSession.dispose();
211+
});
173212
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import assert from "node:assert";
2+
import LOCAL_DISPATCH_NAMESPACE from "worker:dispatch-namespace/dispatch-namespace";
3+
import { z } from "zod";
4+
import { Worker_Binding } from "../../runtime";
5+
import {
6+
mixedModeClientWorker,
7+
MixedModeConnectionString,
8+
Plugin,
9+
ProxyNodeBinding,
10+
} from "../shared";
11+
12+
export const DispatchNamespaceOptionsSchema = z.object({
13+
dispatchNamespaces: z
14+
.record(
15+
z.object({
16+
namespace: z.string(),
17+
mixedModeConnectionString: z
18+
.custom<MixedModeConnectionString>()
19+
.optional(),
20+
})
21+
)
22+
.optional(),
23+
});
24+
25+
export const DISPATCH_NAMESPACE_PLUGIN_NAME = "dispatch-namespace";
26+
27+
export const DISPATCH_NAMESPACE_PLUGIN: Plugin<
28+
typeof DispatchNamespaceOptionsSchema
29+
> = {
30+
options: DispatchNamespaceOptionsSchema,
31+
async getBindings(options) {
32+
if (!options.dispatchNamespaces) {
33+
return [];
34+
}
35+
36+
const bindings = Object.entries(
37+
options.dispatchNamespaces
38+
).map<Worker_Binding>(([name, config]) => {
39+
return {
40+
name,
41+
wrapped: {
42+
moduleName: `${DISPATCH_NAMESPACE_PLUGIN_NAME}:local-dispatch-namespace`,
43+
innerBindings: [
44+
{
45+
name: "fetcher",
46+
service: {
47+
name: `${DISPATCH_NAMESPACE_PLUGIN_NAME}:ns:${config.namespace}`,
48+
},
49+
},
50+
],
51+
},
52+
};
53+
});
54+
return bindings;
55+
},
56+
getNodeBindings(options: z.infer<typeof DispatchNamespaceOptionsSchema>) {
57+
if (!options.dispatchNamespaces) {
58+
return {};
59+
}
60+
return Object.fromEntries(
61+
Object.keys(options.dispatchNamespaces).map((name) => [
62+
name,
63+
new ProxyNodeBinding(),
64+
])
65+
);
66+
},
67+
async getServices({ options }) {
68+
if (!options.dispatchNamespaces) {
69+
return [];
70+
}
71+
72+
return {
73+
services: Object.entries(options.dispatchNamespaces).map(
74+
([name, config]) => {
75+
assert(
76+
config.mixedModeConnectionString,
77+
"Dispatch Namespace bindings only support Mixed Mode"
78+
);
79+
return {
80+
name: `${DISPATCH_NAMESPACE_PLUGIN_NAME}:ns:${config.namespace}`,
81+
worker: mixedModeClientWorker(
82+
config.mixedModeConnectionString,
83+
name
84+
),
85+
};
86+
}
87+
),
88+
extensions: [
89+
{
90+
modules: [
91+
{
92+
name: `${DISPATCH_NAMESPACE_PLUGIN_NAME}:local-dispatch-namespace`,
93+
esModule: LOCAL_DISPATCH_NAMESPACE(),
94+
internal: true,
95+
},
96+
],
97+
},
98+
],
99+
};
100+
},
101+
};

packages/miniflare/src/plugins/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414
import { CACHE_PLUGIN, CACHE_PLUGIN_NAME } from "./cache";
1515
import { CORE_PLUGIN, CORE_PLUGIN_NAME } from "./core";
1616
import { D1_PLUGIN, D1_PLUGIN_NAME } from "./d1";
17+
import {
18+
DISPATCH_NAMESPACE_PLUGIN,
19+
DISPATCH_NAMESPACE_PLUGIN_NAME,
20+
} from "./dispatch-namespace";
1721
import { DURABLE_OBJECTS_PLUGIN, DURABLE_OBJECTS_PLUGIN_NAME } from "./do";
1822
import { EMAIL_PLUGIN, EMAIL_PLUGIN_NAME } from "./email";
1923
import { HYPERDRIVE_PLUGIN, HYPERDRIVE_PLUGIN_NAME } from "./hyperdrive";
@@ -43,6 +47,7 @@ export const PLUGINS = {
4347
[ANALYTICS_ENGINE_PLUGIN_NAME]: ANALYTICS_ENGINE_PLUGIN,
4448
[AI_PLUGIN_NAME]: AI_PLUGIN,
4549
[BROWSER_RENDERING_PLUGIN_NAME]: BROWSER_RENDERING_PLUGIN,
50+
[DISPATCH_NAMESPACE_PLUGIN_NAME]: DISPATCH_NAMESPACE_PLUGIN,
4651
};
4752
export type Plugins = typeof PLUGINS;
4853

@@ -97,7 +102,8 @@ export type WorkerOptions = z.input<typeof CORE_PLUGIN.options> &
97102
z.input<typeof SECRET_STORE_PLUGIN.options> &
98103
z.input<typeof ANALYTICS_ENGINE_PLUGIN.options> &
99104
z.input<typeof AI_PLUGIN.options> &
100-
z.input<typeof BROWSER_RENDERING_PLUGIN.options>;
105+
z.input<typeof BROWSER_RENDERING_PLUGIN.options> &
106+
z.input<typeof DISPATCH_NAMESPACE_PLUGIN.options>;
101107

102108
export type SharedOptions = z.input<typeof CORE_PLUGIN.sharedOptions> &
103109
z.input<typeof CACHE_PLUGIN.sharedOptions> &
@@ -162,3 +168,4 @@ export * from "./email";
162168
export * from "./analytics-engine";
163169
export * from "./ai";
164170
export * from "./browser-rendering";
171+
export * from "./dispatch-namespace";
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
interface Env {
2+
fetcher: Fetcher;
3+
}
4+
5+
class LocalDispatchNamespace implements DispatchNamespace {
6+
constructor(private env: Env) {}
7+
get(
8+
name: string,
9+
args?: { [key: string]: any },
10+
options?: DynamicDispatchOptions
11+
): Fetcher {
12+
return {
13+
...this.env.fetcher,
14+
fetch: (
15+
input: RequestInfo | URL,
16+
init?: RequestInit
17+
): Promise<Response> => {
18+
const request = new Request(input, init);
19+
request.headers.set(
20+
"MF-Dispatch-Namespace-Options",
21+
JSON.stringify({ name, args, options })
22+
);
23+
return this.env.fetcher.fetch(request);
24+
},
25+
};
26+
}
27+
}
28+
29+
export default function (env: Env) {
30+
return new LocalDispatchNamespace(env);
31+
}
Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
1-
export default {
2-
async fetch(request, env) {
1+
import { WorkerEntrypoint } from "cloudflare:workers";
2+
3+
export default class Client extends WorkerEntrypoint<{
4+
mixedModeConnectionString: string;
5+
binding: string;
6+
}> {
7+
async fetch(request: Request) {
38
const proxiedHeaders = new Headers();
49
for (const [name, value] of request.headers) {
510
// The `Upgrade` header needs to be special-cased to prevent:
611
// TypeError: Worker tried to return a WebSocket in a response to a request which did not contain the header "Upgrade: websocket"
7-
if (name === "upgrade") {
12+
if (name === "upgrade" || name.startsWith("MF-")) {
813
proxiedHeaders.set(name, value);
914
} else {
1015
proxiedHeaders.set(`MF-Header-${name}`, value);
1116
}
1217
}
1318
proxiedHeaders.set("MF-URL", request.url);
14-
proxiedHeaders.set("MF-Binding", env.binding);
19+
proxiedHeaders.set("MF-Binding", this.env.binding);
1520
const req = new Request(request, {
1621
headers: proxiedHeaders,
1722
});
1823

19-
return fetch(env.mixedModeConnectionString, req);
20-
},
21-
} satisfies ExportedHandler<{
22-
mixedModeConnectionString: string;
23-
binding: string;
24-
}>;
24+
return fetch(this.env.mixedModeConnectionString, req);
25+
}
26+
}

packages/wrangler/templates/mixedMode/proxyServerWorker/index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,21 @@ export default {
1313
originalHeaders.set(name, value);
1414
}
1515
}
16-
return env[targetBinding].fetch(
16+
let fetcher = env[targetBinding];
17+
18+
// Special case the Dispatch Namespace binding because it has a top-level synchronous .get() call
19+
const dispatchNamespaceOptions = originalHeaders.get(
20+
"MF-Dispatch-Namespace-Options"
21+
);
22+
if (dispatchNamespaceOptions) {
23+
const { name, args, options } = JSON.parse(dispatchNamespaceOptions);
24+
fetcher = (env[targetBinding] as DispatchNamespace).get(
25+
name,
26+
args,
27+
options
28+
);
29+
}
30+
return (fetcher as Fetcher).fetch(
1731
request.headers.get("MF-URL")!,
1832
new Request(request, {
1933
redirect: "manual",
@@ -23,4 +37,4 @@ export default {
2337
}
2438
return new Response("Provide a binding", { status: 400 });
2539
},
26-
} satisfies ExportedHandler<Record<string, Fetcher>>;
40+
} satisfies ExportedHandler<Record<string, Fetcher | DispatchNamespace>>;

0 commit comments

Comments
 (0)