Skip to content

Commit 7d49dca

Browse files
committed
fix(vite): route extensionless URLs matching a nitro route to nitro under asset sec-fetch-dest
`<img src="/api/image">` sends `sec-fetch-dest: image`, which the dev middleware was treating as an asset load and forwarding to vite — even when the URL had no extension and matched an explicit nitro route. now an explicit nitro route always reaches nitro, except when the URL also has an asset-like extension (preserving #4234, where a splat must not swallow `<script src=".../entry-client.ts">`). closes #4241
1 parent c467f13 commit 7d49dca

2 files changed

Lines changed: 30 additions & 21 deletions

File tree

src/build/vite/dev.ts

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -245,31 +245,29 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
245245
const fetchDest = req.headers["sec-fetch-dest"];
246246
const accept = req.headers["accept"];
247247
const ext = req.url!.match(/\.([a-z0-9]+)(?:[?#]|$)/i)?.[1];
248-
const isNitroRoute = ext
249-
? !!nitro.routing.routes.match(
250-
req.method || "",
251-
new URL(withBase(req.url!, nitro.options.baseURL), "http://localhost").pathname
252-
)
253-
: false;
248+
const isNitroRoute = !!nitro.routing.routes.match(
249+
req.method || "",
250+
new URL(withBase(req.url!, nitro.options.baseURL), "http://localhost").pathname
251+
);
254252
// 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-
);
253+
const isAssetByDest =
254+
typeof fetchDest === "string" && !/^(document|iframe|frame|empty)$/.test(fetchDest);
255+
const isAssetByExt = !!ext && ASSET_EXT_RE.test(ext);
256+
const acceptsHTML = typeof accept === "string" && /\btext\/html\b/.test(accept);
257+
const treatAsAsset = isAssetByDest || (!fetchDest && isAssetByExt && !acceptsHTML);
262258
res.setHeader("vary", "sec-fetch-dest, accept");
263-
if (
264-
isDocumentLike &&
265-
// No file extension (not /src/index.ts) unless it is an explicit Nitro route
266-
(!ext || isNitroRoute) &&
259+
// An explicit Nitro route reaches Nitro even when the request is tagged as an asset (e.g. `<img src="/api/image">` with `sec-fetch-dest: image`, #4241), UNLESS the URL also has an asset-like extension — in that case Vite stays the definitive handler so a splat doesn't swallow `<script src=".../entry-client.ts">` (#4234).
260+
const nitroWins = isNitroRoute && !(isAssetByExt && treatAsAsset);
261+
// Fallback for unknown URLs: extensionless, non-asset requests default to Nitro (page navigation, SSR catch-all).
262+
const documentFallback = !ext && !treatAsAsset;
263+
const routeToNitro =
264+
(nitroWins || documentFallback) &&
267265
// Special prefixes (/__vue-router/auto-routes, /@vite-plugin-layouts/, etc)
268-
!/^\/(?:__|@)/.test(req.url!)
269-
) {
266+
!/^\/(?:__|@)/.test(req.url!);
267+
if (routeToNitro) {
270268
nitroDevMiddleware(req, res, next);
271269
} else {
272-
if (!isDocumentLike) {
270+
if (treatAsAsset) {
273271
// This is an asset load — Vite is the definitive handler. Mark the
274272
// request so the catch-all `nitroDevMiddleware` registered after Vite
275273
// doesn't fall back into a splat Nitro route on a 404.

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ describe("vite:baseURL dotted params", { sequential: true }, () => {
2929
});
3030

3131
test("serves Nitro API routes with dotted params under baseURL without redirecting", async () => {
32-
for (const fetchDest of ["empty", "document", undefined]) {
32+
// `image` is included to cover #4241 — `<img src="/api/...">` requests carry `sec-fetch-dest: image` but should still reach an explicit Nitro route.
33+
for (const fetchDest of ["empty", "document", "image", undefined]) {
3334
const headers: Record<string, string> = {};
3435
if (fetchDest) {
3536
headers["sec-fetch-dest"] = fetchDest;
@@ -47,6 +48,16 @@ describe("vite:baseURL dotted params", { sequential: true }, () => {
4748
}
4849
});
4950

51+
// #4241: `<img src="/api/image">` sends `sec-fetch-dest: image`. The URL has no extension but matches an explicit Nitro splat route, so it must reach Nitro instead of being treated as a Vite asset load.
52+
test("routes extensionless URLs matching a Nitro route to Nitro even when sec-fetch-dest tags the request as an asset", async () => {
53+
const response = await fetch(`${serverURL}/subdir/api/proxy/image`, {
54+
headers: { "sec-fetch-dest": "image", accept: "image/*" },
55+
redirect: "manual",
56+
});
57+
expect(response.status).toBe(200);
58+
expect(await response.text()).toBe("image");
59+
});
60+
5061
// 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.
5162
test("does not misroute asset loads to splat Nitro routes when sec-fetch-dest is absent", async () => {
5263
const response = await fetch(`${serverURL}/subdir/api/proxy/entry-client.ts`, {

0 commit comments

Comments
 (0)