Skip to content

Commit c07d0cb

Browse files
authored
Support V2 protocol for module fallback service in Miniflare and Vite plugin (#13618)
1 parent b04eedf commit c07d0cb

12 files changed

Lines changed: 339 additions & 82 deletions

File tree

.changeset/shaky-parks-buy.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"miniflare": minor
3+
---
4+
5+
Support V2 protocol for module fallback service
6+
7+
When the `new_module_registry` compatibility flag is set, requests sent to `unsafeModuleFallbackService()` use a different protocol. Miniflare now supports both protocols and exports a `parseModuleFallbackRequest()` utility to ease handling.

.changeset/twelve-eggs-fetch.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@cloudflare/vite-plugin": minor
3+
---
4+
5+
Support V2 protocol for module fallback service
6+
7+
When the `new_module_registry` compatibility flag is set, requests sent to `unsafeModuleFallbackService()` use a different protocol. The Vite plugin now supports both protocols in its handling of additional module types.

packages/miniflare/src/index.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
reviveError,
7474
} from "./plugins/core";
7575
import { InspectorProxyController } from "./plugins/core/inspector-proxy";
76+
import { isModuleFallbackRequest } from "./plugins/core/module-fallback";
7677
import { HyperdriveProxyController } from "./plugins/hyperdrive/hyperdrive-proxy";
7778
import { imagesLocalFetcher } from "./plugins/images/fetcher";
7879
import {
@@ -1523,15 +1524,6 @@ export class Miniflare {
15231524
request,
15241525
customFetchService
15251526
);
1526-
} else if (
1527-
this.#sharedOpts.core.unsafeModuleFallbackService !== undefined &&
1528-
request.headers.has("X-Resolve-Method") &&
1529-
originalUrl === null
1530-
) {
1531-
response = await this.#sharedOpts.core.unsafeModuleFallbackService(
1532-
request,
1533-
this
1534-
);
15351527
} else if (url.pathname === "/core/error") {
15361528
response = await handlePrettyErrorRequest(
15371529
this.#log,
@@ -1627,6 +1619,21 @@ export class Miniflare {
16271619
response = Response.json(
16281620
this.publicUrl ?? this.#runtimeEntryURL?.toString() ?? null
16291621
);
1622+
} else if (
1623+
// Module fallback check MUST come after all known pathname handlers.
1624+
// The V2 protocol (new_module_registry compat flag) uses POST requests,
1625+
// which would otherwise match internal loopback requests from embedded
1626+
// workers (e.g., POST to /core/log, /core/error, /core/store-temp-file).
1627+
// By checking module fallback last, we ensure internal endpoints are
1628+
// handled first, and only truly unmatched requests go to the fallback.
1629+
this.#sharedOpts.core.unsafeModuleFallbackService !== undefined &&
1630+
isModuleFallbackRequest(request) &&
1631+
originalUrl === null
1632+
) {
1633+
response = await this.#sharedOpts.core.unsafeModuleFallbackService(
1634+
request,
1635+
this
1636+
);
16301637
}
16311638
} catch (e: any) {
16321639
this.#log.error(e);
@@ -3171,3 +3178,9 @@ export {
31713178
getDefaultDevRegistryPath,
31723179
getWorkerRegistry,
31733180
} from "./shared/dev-registry";
3181+
export { parseModuleFallbackRequest } from "./plugins/core/module-fallback";
3182+
export type {
3183+
V1ModuleFallbackRequest,
3184+
V2ModuleFallbackRequest,
3185+
ParsedModuleFallbackRequest,
3186+
} from "./plugins/core/module-fallback";
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Shared utilities for parsing module fallback service requests.
3+
*
4+
* The module fallback service supports two protocols:
5+
* - V1 (legacy): GET request with query params and X-Resolve-Method header
6+
* - V2 (new_module_registry): POST request with JSON body
7+
*
8+
* The protocol version is determined by the `new_module_registry` compatibility flag.
9+
*/
10+
import assert from "node:assert";
11+
import type { Request } from "../../http";
12+
13+
/** V1 protocol request (legacy module registry) */
14+
export interface V1ModuleFallbackRequest {
15+
protocol: "v1";
16+
/** Import type: "import" for ES modules, "require" for CommonJS */
17+
type: "import" | "require";
18+
/** Module specifier as a path (e.g., "/my-module.js") */
19+
specifier: string;
20+
/** Original specifier as written in source code */
21+
rawSpecifier?: string;
22+
/** Referrer module path */
23+
referrer?: string;
24+
}
25+
26+
/** V2 protocol request (new module registry) */
27+
export interface V2ModuleFallbackRequest {
28+
protocol: "v2";
29+
/** Import type: includes "internal" for runtime-originated imports */
30+
type: "import" | "require" | "internal";
31+
/** Module specifier as a URL (e.g., "file:///bundle/my-module.js") */
32+
specifier: string;
33+
/** Original specifier as written in source code */
34+
rawSpecifier?: string;
35+
/** Referrer module URL */
36+
referrer?: string;
37+
/** Import attributes from the import statement */
38+
attributes?: Array<{ name: string; value: string }>;
39+
}
40+
41+
/** Discriminated union of both protocol versions */
42+
export type ParsedModuleFallbackRequest =
43+
| V1ModuleFallbackRequest
44+
| V2ModuleFallbackRequest;
45+
46+
/**
47+
* Checks if a request is a module fallback service request.
48+
* This detects both V1 (GET with X-Resolve-Method header) and V2 (POST) protocols.
49+
*/
50+
export function isModuleFallbackRequest(request: Request): boolean {
51+
// V1: GET request with X-Resolve-Method header
52+
if (request.method === "GET" && request.headers.has("X-Resolve-Method")) {
53+
return true;
54+
}
55+
56+
// V2: POST request (the new module registry always uses POST)
57+
if (request.method === "POST") {
58+
return true;
59+
}
60+
61+
return false;
62+
}
63+
64+
export function assertIsV2ModuleFallbackProtocol(
65+
body: unknown
66+
): asserts body is Omit<V2ModuleFallbackRequest, "protocol"> {
67+
assert(typeof body === "object" && body !== null && "specifier" in body);
68+
}
69+
70+
/**
71+
* Parses a module fallback service request into a protocol-specific format.
72+
* Automatically detects V1 vs V2 protocol based on HTTP method.
73+
*
74+
* @param request - The incoming Request object
75+
* @returns Parsed request data, or null if the request is malformed
76+
*/
77+
export async function parseModuleFallbackRequest(
78+
request: Request
79+
): Promise<ParsedModuleFallbackRequest | null> {
80+
// V1 Protocol: GET with X-Resolve-Method header
81+
if (request.method === "GET" && request.headers.has("X-Resolve-Method")) {
82+
const url = new URL(request.url);
83+
const specifier = url.searchParams.get("specifier");
84+
85+
if (!specifier) {
86+
return null;
87+
}
88+
89+
const resolveMethod = request.headers.get("X-Resolve-Method");
90+
91+
return {
92+
protocol: "v1",
93+
type: resolveMethod === "require" ? "require" : "import",
94+
specifier,
95+
rawSpecifier: url.searchParams.get("rawSpecifier") ?? undefined,
96+
referrer: url.searchParams.get("referrer") ?? undefined,
97+
};
98+
}
99+
100+
// V2 Protocol: POST with JSON body
101+
if (request.method === "POST") {
102+
try {
103+
const body = await request.json();
104+
assertIsV2ModuleFallbackProtocol(body);
105+
106+
return {
107+
...body,
108+
protocol: "v2",
109+
};
110+
} catch {
111+
return null;
112+
}
113+
}
114+
115+
return null;
116+
}

packages/miniflare/test/index.spec.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from "miniflare";
2828
import { onTestFinished, test } from "vitest";
2929
import { WebSocketServer } from "ws";
30+
import { assertIsV2ModuleFallbackProtocol } from "../src/plugins/core/module-fallback";
3031
import {
3132
FIXTURES_PATH,
3233
TestLog,
@@ -3503,6 +3504,88 @@ test("Miniflare: can use module fallback service", async ({ expect }) => {
35033504
expect(await res.text()).toBe('Error: No such module "virtual/a.mjs".');
35043505
});
35053506

3507+
test("Miniflare: can use module fallback service with V2 protocol", async ({
3508+
expect,
3509+
}) => {
3510+
// V2 protocol uses file:// URLs instead of paths
3511+
// Module keys are paths (without the /bundle prefix that workerd adds)
3512+
const modules: Record<string, Omit<Worker_Module, "name">> = {
3513+
"/virtual/a.mjs": {
3514+
esModule: `
3515+
import { b } from "./dir/b.mjs";
3516+
export default "a" + b;
3517+
`,
3518+
},
3519+
"/virtual/dir/b.mjs": {
3520+
esModule: 'export { default as b } from "./c.cjs";',
3521+
},
3522+
"/virtual/dir/c.cjs": {
3523+
commonJsModule: 'module.exports = "c" + require("./sub/d.cjs");',
3524+
},
3525+
"/virtual/dir/sub/d.cjs": {
3526+
commonJsModule: 'module.exports = "d";',
3527+
},
3528+
};
3529+
3530+
const mf = new Miniflare({
3531+
async unsafeModuleFallbackService(request) {
3532+
// V2 protocol uses POST with JSON body instead of GET with query params
3533+
assert(request.method === "POST", "V2 protocol should use POST method");
3534+
3535+
const body = await request.json();
3536+
assertIsV2ModuleFallbackProtocol(body);
3537+
3538+
assert(
3539+
body.type === "import" || body.type === "require",
3540+
`Expected type to be "import" or "require", got "${body.type}"`
3541+
);
3542+
assert(
3543+
body.specifier.startsWith("file://"),
3544+
`V2 specifier should be a file:// URL, got "${body.specifier}"`
3545+
);
3546+
3547+
// V2 specifier is a file:// URL like "file:///bundle/virtual/a.mjs"
3548+
// Extract the path and strip the "/bundle" prefix to match our modules map
3549+
const specifierUrl = new URL(body.specifier);
3550+
const modulePath = specifierUrl.pathname.replace(/^\/bundle/, "");
3551+
const maybeModule = modules[modulePath];
3552+
if (maybeModule === undefined) {
3553+
return new Response(null, { status: 404 });
3554+
}
3555+
3556+
// V2 protocol expects name to match the full URL specifier
3557+
return Response.json({ name: body.specifier, ...maybeModule });
3558+
},
3559+
workers: [
3560+
{
3561+
name: "a",
3562+
routes: ["*/a"],
3563+
compatibilityFlags: ["export_commonjs_default", "new_module_registry"],
3564+
modulesRoot: "/",
3565+
modules: [
3566+
{
3567+
type: "ESModule",
3568+
path: "/virtual/index.mjs",
3569+
contents: `
3570+
import a from "./a.mjs";
3571+
export default {
3572+
async fetch() {
3573+
return new Response(a);
3574+
}
3575+
}
3576+
`,
3577+
},
3578+
],
3579+
unsafeUseModuleFallbackService: true,
3580+
},
3581+
],
3582+
});
3583+
useDispose(mf);
3584+
3585+
const res = await mf.dispatchFetch("http://localhost/a");
3586+
expect(await res.text()).toBe("acd");
3587+
});
3588+
35063589
test("Miniflare: respects rootPath for path-valued options", async ({
35073590
expect,
35083591
}) => {
Lines changed: 1 addition & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1 @@
1-
import { test } from "vitest";
2-
import {
3-
getJsonResponse,
4-
getTextResponse,
5-
page,
6-
viteTestUrl,
7-
} from "../../__test-utils__";
8-
9-
test("supports Data modules with a '.bin' extension", async ({ expect }) => {
10-
const result = await getJsonResponse("/bin");
11-
expect(result).toEqual({ byteLength: 342936 });
12-
});
13-
14-
test("supports Text modules with a '.html' extension", async ({ expect }) => {
15-
await page.goto(`${viteTestUrl}/html`);
16-
const content = await page.textContent("h1");
17-
expect(content).toBe("Hello world");
18-
});
19-
20-
test("supports Text modules imported via subpath imports", async ({
21-
expect,
22-
}) => {
23-
await page.goto(`${viteTestUrl}/subpath-html`);
24-
const content = await page.textContent("h1");
25-
expect(content).toBe("Hello world");
26-
});
27-
28-
test("supports Text modules imported via subpath imports with extension", async ({
29-
expect,
30-
}) => {
31-
await page.goto(`${viteTestUrl}/subpath-html-with-ext`);
32-
const content = await page.textContent("h1");
33-
expect(content).toBe("Hello world");
34-
});
35-
36-
test("supports Text modules with a '.txt' extension", async ({ expect }) => {
37-
const result = await getTextResponse("/text");
38-
expect(result).toBe("Example text content.\n");
39-
});
40-
41-
test("supports Text modules with a '.sql' extension", async ({ expect }) => {
42-
const result = await getTextResponse("/sql");
43-
expect(result).toBe("SELECT * FROM users;\n");
44-
});
45-
46-
test("supports modules with `__`s in the filename", async ({ expect }) => {
47-
const result = await getTextResponse("/text2");
48-
expect(result).toBe("Example text content 2");
49-
});
50-
51-
test("supports CompiledWasm modules with a '.wasm' extension", async ({
52-
expect,
53-
}) => {
54-
const result = await getJsonResponse("/wasm");
55-
expect(result).toEqual({ result: 7 });
56-
});
57-
58-
test("supports CompiledWasm modules with a '.wasm?module' extension", async ({
59-
expect,
60-
}) => {
61-
const result = await getJsonResponse("/wasm-with-module-param");
62-
expect(result).toEqual({ result: 11 });
63-
});
64-
65-
test("supports CompiledWasm modules with a '.wasm?init' extension", async ({
66-
expect,
67-
}) => {
68-
const result = await getJsonResponse("/wasm-with-init-param");
69-
expect(result).toEqual({ result: 15 });
70-
});
1+
import "./base-tests";

0 commit comments

Comments
 (0)