Skip to content

Commit 6016153

Browse files
authored
fix(vite): route browser asset loads to vite when sec-fetch-dest is absent (#4238)
1 parent a9305f0 commit 6016153

2 files changed

Lines changed: 41 additions & 3 deletions

File tree

src/build/vite/dev.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import { getEnvRunner } from "./env.ts";
1717

1818
// https://vite.dev/guide/api-environment-runtimes.html#modulerunner
1919

20+
// Extensions that strongly indicate a browser asset load (script/style/font/image/media).
21+
// Used as a fallback signal when `Sec-Fetch-Dest` is absent (plain-HTTP non-loopback origins).
22+
// Kept narrow on purpose so that arbitrary dotted Nitro route params (e.g. `.../foo.bar.1`) keep reaching Nitro.
23+
const ASSET_EXT_RE =
24+
/^(?:[jt]sx?|mjs|cjs|css|s[ac]ss|less|styl|vue|svelte|astro|mdx?|map|wasm|png|jpe?g|gif|svg|webp|avif|ico|bmp|woff2?|ttf|otf|eot|mp[34]|webm|wav|ogg|m4a)$/i;
25+
2026
// ---- Types ----
2127

2228
export type FetchHandler = (req: Request) => Promise<Response>;
@@ -237,24 +243,38 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
237243
// https://github.com/vitejs/vite/pull/20866
238244
server.middlewares.use(function nitroDevMiddlewarePre(req, res, next) {
239245
const fetchDest = req.headers["sec-fetch-dest"];
246+
const accept = req.headers["accept"];
240247
const ext = req.url!.match(/\.([a-z0-9]+)(?:[?#]|$)/i)?.[1];
241248
const isNitroRoute = ext
242249
? !!nitro.routing.routes.match(
243250
req.method || "",
244251
new URL(withBase(req.url!, nitro.options.baseURL), "http://localhost").pathname
245252
)
246253
: false;
247-
res.setHeader("vary", "sec-fetch-dest");
254+
// Sec-Fetch-* is only sent on "potentially trustworthy" origins, so on plain-HTTP non-loopback (e.g. http://10.0.0.x) it's absent and a splat Nitro route may swallow browser asset loads (#4234). When the header is missing, treat known asset extensions without `text/html` in Accept as asset loads and let Vite handle them.
255+
const isDocumentLike = fetchDest
256+
? /^(document|iframe|frame|empty)$/.test(fetchDest)
257+
: !(
258+
ext &&
259+
ASSET_EXT_RE.test(ext) &&
260+
!(typeof accept === "string" && /\btext\/html\b/.test(accept))
261+
);
262+
res.setHeader("vary", "sec-fetch-dest, accept");
248263
if (
249-
// Originating from browser tab or no fetch dest (curl, fetch, etc) and (not script, style, image, etc)
250-
(!fetchDest || /^(document|iframe|frame|empty)$/.test(fetchDest)) &&
264+
isDocumentLike &&
251265
// No file extension (not /src/index.ts) unless it is an explicit Nitro route
252266
(!ext || isNitroRoute) &&
253267
// Special prefixes (/__vue-router/auto-routes, /@vite-plugin-layouts/, etc)
254268
!/^\/(?:__|@)/.test(req.url!)
255269
) {
256270
nitroDevMiddleware(req, res, next);
257271
} else {
272+
if (!isDocumentLike) {
273+
// This is an asset load — Vite is the definitive handler. Mark the
274+
// request so the catch-all `nitroDevMiddleware` registered after Vite
275+
// doesn't fall back into a splat Nitro route on a 404.
276+
(req as IncomingMessage & { _nitroHandled?: boolean })._nitroHandled = true;
277+
}
258278
next();
259279
}
260280
});

test/vite/baseurl-dotted-param.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,22 @@ describe("vite:baseURL dotted params", { sequential: true }, () => {
4646
);
4747
}
4848
});
49+
50+
// Browsers omit Sec-Fetch-* on plain-HTTP non-loopback origins (e.g. http://10.0.0.x:3000). Without that signal, a splat Nitro route would swallow `<script src=".../entry-client.ts">` requests. Accept + asset extension is used as a fallback to keep asset loads routed to Vite.
51+
test("does not misroute asset loads to splat Nitro routes when sec-fetch-dest is absent", async () => {
52+
const response = await fetch(`${serverURL}/subdir/api/proxy/entry-client.ts`, {
53+
headers: { accept: "*/*" },
54+
redirect: "manual",
55+
});
56+
expect(await response.text()).not.toBe("entry-client.ts");
57+
});
58+
59+
test("navigation without sec-fetch-dest still routes to Nitro (Accept: text/html)", async () => {
60+
const response = await fetch(`${serverURL}/subdir/api/proxy/page.html`, {
61+
headers: { accept: "text/html,application/xhtml+xml" },
62+
redirect: "manual",
63+
});
64+
expect(response.status).toBe(200);
65+
expect(await response.text()).toBe("page.html");
66+
});
4967
});

0 commit comments

Comments
 (0)