Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
30 changes: 20 additions & 10 deletions src/build/vite/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,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.
// We also intercept imports with Vite-specific query suffixes (like ?url)
// to ensure they are handled by Vite's asset plugins instead of being externalized.
const hasQuery = id.includes("?");
if (
this.#preventExternalize &&
(this.#preventExternalize || hasQuery) &&
!id.startsWith("file://") &&
importer &&
id[0] !== "." &&
id[0] !== "/"
(hasQuery || (id[0] !== "." && id[0] !== "/"))
) {
const resolved = await this.pluginContainer.resolveId(id, importer);
if (resolved && !resolved.external) {
if (resolved && (!resolved.external || hasQuery)) {
return super.fetchModule(resolved.id, importer, options);
}
}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 34ead56: extracted VITE_QUERY_RE = /\?(url|raw|inline|worker)(?:&|$)/ as a module-level constant and replaced hasQuery with hasViteQuery, keeping the behavior consistent with the noExternal pattern in env.ts.

Expand Down Expand Up @@ -245,19 +247,27 @@ 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