Skip to content

Commit b7d6b7d

Browse files
authoredMar 21, 2025··
vite dev support for SPA mode (#8556)
1 parent 5b41653 commit b7d6b7d

File tree

9 files changed

+98
-7
lines changed

9 files changed

+98
-7
lines changed
 

‎.changeset/eight-cars-fall.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"miniflare": minor
3+
"wrangler": minor
4+
"@cloudflare/vite-plugin": patch
5+
---
6+
7+
Add support for `assets_navigation_prefer_asset_serving` in Vite (`dev` and `preview`)

‎packages/miniflare/src/workers/core/entry.worker.ts

+8
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ function getUserRequest(
9595
// https://developers.cloudflare.com/fundamentals/reference/http-request-headers/#accept-encoding
9696
request.headers.set("Accept-Encoding", "br, gzip");
9797

98+
// `miniflare.dispatchFetch(request)` strips any `sec-fetch-mode` header. This allows clients to
99+
// send it over a `x-mf-sec-fetch-mode` header instead (currently required by `vite preview`)
100+
const secFetchMode = request.headers.get("X-Mf-Sec-Fetch-Mode");
101+
if (secFetchMode) {
102+
request.headers.set("Sec-Fetch-Mode", secFetchMode);
103+
}
104+
request.headers.delete("X-Mf-Sec-Fetch-Mode");
105+
98106
if (rewriteHeadersFromOriginalUrl) {
99107
request.headers.set("Host", url.host);
100108
}

‎packages/vite-plugin-cloudflare/playground/spa-with-api/__tests__/spa-with-api.spec.ts

+41
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { createConnection } from "node:net";
12
import { expect, test } from "vitest";
23
import { page } from "../../__test-utils__";
4+
import { viteTestUrl } from "../../vitest-setup";
35

46
test("returns the correct home page", async () => {
57
const content = await page.textContent("h1");
@@ -18,3 +20,42 @@ test("returns the response from the API", async () => {
1820
const contentAfter = await button.innerText();
1921
expect(contentAfter).toBe("Name from API is: Cloudflare");
2022
});
23+
24+
test("returns the home page even for 404-y pages", async () => {
25+
await page.goto(`${viteTestUrl}/foo`);
26+
const content = await page.textContent("h1");
27+
expect(content).toBe("Vite + React");
28+
});
29+
30+
test("returns the home page even for API-y pages", async () => {
31+
await page.goto(`${viteTestUrl}/api/`);
32+
const content = await page.textContent("h1");
33+
expect(content).toBe("Vite + React");
34+
});
35+
36+
test("requests made with/without explicit 'sec-fetch-mode: navigate' header to delegate correctly", async () => {
37+
const responseWithoutHeader = await fetch(`${viteTestUrl}/foo`);
38+
expect(responseWithoutHeader.status).toBe(404);
39+
expect(await responseWithoutHeader.text()).toEqual("nothing here");
40+
41+
// can't make `fetch`es with `sec-fetch-mode: navigate` header, so we're doing it raw
42+
const { hostname, port } = new URL(viteTestUrl);
43+
const socket = createConnection(parseInt(port), hostname, () => {
44+
socket.write(
45+
`GET /foo HTTP/1.1\r\nHost: ${hostname}\r\nSec-Fetch-Mode: navigate\r\n\r\n`
46+
);
47+
});
48+
49+
let responseWithoutHeaderContentBuffer = "";
50+
socket.on("data", (data) => {
51+
responseWithoutHeaderContentBuffer += data.toString();
52+
});
53+
54+
const responseWithoutHeaderContent = await new Promise((resolve) =>
55+
socket.on("close", () => {
56+
resolve(responseWithoutHeaderContentBuffer);
57+
})
58+
);
59+
60+
expect(responseWithoutHeaderContent).toContain("Vite + React");
61+
});

‎packages/vite-plugin-cloudflare/playground/spa-with-api/api/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ export default {
1212
});
1313
}
1414

15-
return env.ASSETS.fetch(request);
15+
return new Response("nothing here", { status: 404 });
1616
},
1717
} satisfies ExportedHandler<Env>;
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
name = "api"
22
main = "./api/index.ts"
33
compatibility_date = "2024-12-30"
4+
compatibility_flags = ["assets_navigation_prefers_asset_serving"]
45
assets = { not_found_handling = "single-page-application", binding = "ASSETS" }

‎packages/vite-plugin-cloudflare/src/asset-workers/asset-worker.ts

+17-5
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
// @ts-ignore
22
import AssetWorker from "@cloudflare/workers-shared/dist/asset-worker.mjs";
33
import { UNKNOWN_HOST } from "../shared";
4-
import type { WorkerEntrypoint } from "cloudflare:workers";
54

65
interface Env {
76
__VITE_ASSET_EXISTS__: Fetcher;
87
__VITE_FETCH_ASSET__: Fetcher;
98
}
109

11-
export default class CustomAssetWorker extends (AssetWorker as typeof WorkerEntrypoint<Env>) {
12-
override async fetch(request: Request): Promise<Response> {
10+
export default class CustomAssetWorker extends AssetWorker {
11+
async fetch(request: Request): Promise<Response> {
1312
const response = await super.fetch!(request);
1413
const modifiedResponse = new Response(response.body, response);
1514
modifiedResponse.headers.delete("ETag");
@@ -21,7 +20,9 @@ export default class CustomAssetWorker extends (AssetWorker as typeof WorkerEntr
2120
eTag: string
2221
): Promise<{ readableStream: ReadableStream; contentType: string }> {
2322
const url = new URL(eTag, UNKNOWN_HOST);
24-
const response = await this.env.__VITE_FETCH_ASSET__.fetch(url);
23+
const response = await (
24+
this as typeof AssetWorker as { env: Env }
25+
).env.__VITE_FETCH_ASSET__.fetch(url);
2526

2627
if (!response.body) {
2728
throw new Error(`Unexpected error. No HTML found for ${eTag}.`);
@@ -32,9 +33,20 @@ export default class CustomAssetWorker extends (AssetWorker as typeof WorkerEntr
3233
async unstable_exists(pathname: string): Promise<string | null> {
3334
// We need this regex to avoid getting `//` as a pathname, which results in an invalid URL. Should this be fixed upstream?
3435
const url = new URL(pathname.replace(/^\/{2,}/, "/"), UNKNOWN_HOST);
35-
const response = await this.env.__VITE_ASSET_EXISTS__.fetch(url);
36+
const response = await (
37+
this as typeof AssetWorker as { env: Env }
38+
).env.__VITE_ASSET_EXISTS__.fetch(url);
3639
const exists = await response.json();
3740

3841
return exists ? pathname : null;
3942
}
43+
async unstable_canFetch(request: Request) {
44+
// the 'sec-fetch-mode: navigate' header is stripped by something on its way into this worker
45+
// so we restore it from 'x-mf-sec-fetch-mode'
46+
const secFetchMode = request.headers.get("X-Mf-Sec-Fetch-Mode");
47+
if (secFetchMode) {
48+
request.headers.set("Sec-Fetch-Mode", secFetchMode);
49+
}
50+
return await super.unstable_canFetch(request);
51+
}
4052
}

‎packages/vite-plugin-cloudflare/src/miniflare-options.ts

+16
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,21 @@ export function getDevMiniflareOptions(
203203
? resolvedPluginConfig.config.assets
204204
: entryWorkerConfig?.assets;
205205

206+
const compatibilityOptions =
207+
resolvedPluginConfig.type === "assets-only"
208+
? {
209+
compatibility_date: resolvedPluginConfig.config.compatibility_date,
210+
compatibility_flags: resolvedPluginConfig.config.compatibility_flags,
211+
}
212+
: {
213+
...(entryWorkerConfig?.compatibility_date
214+
? { compatibility_date: entryWorkerConfig?.compatibility_date }
215+
: {}),
216+
...(entryWorkerConfig?.compatibility_flags
217+
? { compatibility_flags: entryWorkerConfig?.compatibility_flags }
218+
: {}),
219+
};
220+
206221
const assetWorkers: Array<WorkerOptions> = [
207222
{
208223
name: ROUTER_WORKER_NAME,
@@ -242,6 +257,7 @@ export function getDevMiniflareOptions(
242257
],
243258
bindings: {
244259
CONFIG: {
260+
...compatibilityOptions,
245261
...(assetsConfig?.html_handling
246262
? { html_handling: assetsConfig.html_handling }
247263
: {}),

‎packages/vite-plugin-cloudflare/src/utils.ts

+5
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export function getRouterWorker(miniflare: Miniflare) {
2222
}
2323

2424
export function toMiniflareRequest(request: Request): MiniflareRequest {
25+
// Undici sets the `Sec-Fetch-Mode` header to `cors` so we capture it in a custom header to be converted back later.
26+
const secFetchMode = request.headers.get("Sec-Fetch-Mode");
27+
if (secFetchMode) {
28+
request.headers.set("X-Mf-Sec-Fetch-Mode", secFetchMode);
29+
}
2530
return new MiniflareRequest(request.url, {
2631
method: request.method,
2732
headers: [["accept-encoding", "identity"], ...request.headers],

‎packages/wrangler/src/assets.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,8 @@ export function getAssetsOptions(
436436
html_handling: config.assets?.html_handling,
437437
not_found_handling: config.assets?.not_found_handling,
438438
// The _redirects and _headers files are parsed in Miniflare in dev and parsing is not required for deploy
439-
// Similarly, `compatibility_date` and `compatibility_flags` are populated by Miniflare from the Worker definition and also are not required for deploy
439+
compatibility_date: config.compatibility_date,
440+
compatibility_flags: config.compatibility_flags,
440441
};
441442

442443
return {

0 commit comments

Comments
 (0)
Please sign in to comment.