Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions src/build/vite/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import { getEnvRunner } from "./env.ts";

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

// Vite-specific query suffixes that must be resolved through the plugin container.
const VITE_QUERY_RE = /\?(url|raw|inline|worker)(?:&|$)/;

// Extensions that strongly indicate a browser asset load (script/style/font/image/media).
// Used as a fallback signal when `Sec-Fetch-Dest` is absent (plain-HTTP non-loopback origins).
// Kept narrow on purpose so that arbitrary dotted Nitro route params (e.g. `.../foo.bar.1`) keep reaching Nitro.
Expand Down Expand Up @@ -77,15 +80,17 @@ export class FetchableDevEnvironment extends DevEnvironment {
// We intercept bare imports, resolve them through the environment's plugin
// pipeline (which respects resolve.conditions and picks ESM), then route
// the resolved path through transformRequest for proper SSR processing.
// Intercept Vite-specific query suffixes (?url, ?raw, ?inline, ?worker) so they
// are resolved through the plugin container rather than externalized.
const hasViteQuery = VITE_QUERY_RE.test(id);
if (
this.#preventExternalize &&
(this.#preventExternalize || hasViteQuery) &&
!id.startsWith("file://") &&
importer &&
id[0] !== "." &&
id[0] !== "/"
(hasViteQuery || (id[0] !== "." && id[0] !== "/"))
) {
const resolved = await this.pluginContainer.resolveId(id, importer);
if (resolved && !resolved.external) {
if (resolved && (!resolved.external || hasViteQuery)) {
return super.fetchModule(resolved.id, importer, options);
}
}
Expand Down Expand Up @@ -245,19 +250,26 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
const fetchDest = req.headers["sec-fetch-dest"];
const accept = req.headers["accept"];
const ext = req.url!.split(/[?#]/, 1)[0].match(/\.([a-z0-9]+)$/i)?.[1];
const isNitroRoute = !!nitro.routing.routes.match(
req.method || "",
new URL(withBase(req.url!, nitro.options.baseURL), "http://localhost").pathname
);
const reqPathname = new URL(withBase(req.url!, nitro.options.baseURL), "http://localhost")
.pathname;
const nitroRouteMatch = nitro.routing.routes.match(req.method || "", reqPathname);
const isNitroRoute = !!nitroRouteMatch;
// 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.
const isAssetByDest =
typeof fetchDest === "string" && !/^(document|iframe|frame|empty)$/.test(fetchDest);
const isAssetByExt = !!ext && ASSET_EXT_RE.test(ext);
const acceptsHTML = typeof accept === "string" && /\btext\/html\b/.test(accept);
const treatAsAsset = isAssetByDest || (!fetchDest && isAssetByExt && !acceptsHTML);
res.setHeader("vary", "sec-fetch-dest, accept");
// 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).
const nitroWins = isNitroRoute && !(isAssetByExt && treatAsAsset);
// An explicit Nitro route reaches Nitro even when the request is tagged as an asset (#4241).
// The asset-extension guard only applies to root-level wildcards (/**) to prevent a renderer
// or user catch-all from swallowing Vite's own <script>/<link> serves (#4234). Specific
// prefixed routes (e.g. /api/photos/**) are never wildcards on Vite-managed paths (#4252).
const matchedRoutePattern = Array.isArray(nitroRouteMatch)
? nitroRouteMatch[0]?.route
: nitroRouteMatch?.route;
const isRootWildcard = matchedRoutePattern === "/**" || matchedRoutePattern?.startsWith("/**:");
const nitroWins = isNitroRoute && (!isRootWildcard || !(isAssetByExt && treatAsAsset));
// Fallback for unknown URLs: extensionless, non-asset requests default to Nitro (page navigation, SSR catch-all).
const documentFallback = !ext && !treatAsAsset;
const routeToNitro =
Expand Down
1 change: 1 addition & 0 deletions src/build/vite/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function createNitroEnvironment(ctx: NitroPluginContext): EnvironmentOpti
: [
/^nitro$/, // i have absolutely no idea why and how it fixes issues!
new RegExp(`^(${runtimeDependencies.join("|")})$`), // virtual resolutions in vite skip plugin hooks
/\?(url|raw|inline|worker)(?:&|$)/,
...ctx.bundlerConfig!.base.noExternal,
]
: true, // production build is standalone
Expand Down
6 changes: 6 additions & 0 deletions test/vite/baseurl-dotted-param-fixture/routes/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { EventHandler } from "h3";

export default ((event) => {
const path = event.context.params?.path ?? "";
return `root-wildcard:${path}`;
}) satisfies EventHandler;
40 changes: 33 additions & 7 deletions test/vite/baseurl-dotted-param.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ describe("vite:baseURL dotted params", { sequential: true }, () => {
});

test("serves Nitro API routes with dotted params under baseURL without redirecting", async () => {
// `image` is included to cover #4241 — `<img src="/api/...">` requests carry `sec-fetch-dest: image` but should still reach an explicit Nitro route.
for (const fetchDest of ["empty", "document", "image", undefined]) {
const headers: Record<string, string> = {};
if (fetchDest) {
Expand Down Expand Up @@ -58,16 +57,43 @@ describe("vite:baseURL dotted params", { sequential: true }, () => {
expect(await response.text()).toBe("image");
});

// 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.
test("does not misroute asset loads to splat Nitro routes when sec-fetch-dest is absent", async () => {
const response = await fetch(`${serverURL}/subdir/api/proxy/entry-client.ts`, {
headers: { accept: "*/*" },
test("prefixed splat routes win over Vite assets even with asset extensions and sec-fetch-dest", async () => {
for (const fetchDest of ["script", "style", "image", undefined]) {
const headers: Record<string, string> = { accept: "*/*" };
if (fetchDest) {
headers["sec-fetch-dest"] = fetchDest;
}
const response = await fetch(`${serverURL}/subdir/api/proxy/entry-client.ts`, {
headers,
redirect: "manual",
});
expect(response.status, `fetchDest: ${fetchDest}`).toBe(200);
expect(await response.text(), `fetchDest: ${fetchDest}`).toBe("entry-client.ts");
}
});

test("root-level wildcards do not swallow Vite assets (protects #4234)", async () => {
for (const fetchDest of ["script", "style", "image", undefined]) {
const headers: Record<string, string> = { accept: "*/*" };
if (fetchDest) {
headers["sec-fetch-dest"] = fetchDest;
}
const response = await fetch(`${serverURL}/subdir/entry-client.ts`, {
headers,
redirect: "manual",
});
expect(response.status, `fetchDest: ${fetchDest}`).toBe(404);
}
});

test("root-level wildcards *do* swallow Vite assets when NOT an asset extension", async () => {
const response = await fetch(`${serverURL}/subdir/some-page`, {
redirect: "manual",
});
expect(await response.text()).not.toBe("entry-client.ts");
expect(response.status).toBe(200);
expect(await response.text()).toBe("root-wildcard:some-page");
});

// The extension extraction must look at the path only — a `.png` in the query string (e.g. `?file=bar.png`) must not flag the request as an asset and divert it to Vite.
test("ignores asset-like extensions inside the query string when routing to Nitro", async () => {
const response = await fetch(`${serverURL}/subdir/api/proxy/data?file=bar.png`, {
headers: { "sec-fetch-dest": "image", accept: "image/*" },
Expand Down