diff --git a/src/build/vite/dev.ts b/src/build/vite/dev.ts
index ab4f24671b..bda269edb7 100644
--- a/src/build/vite/dev.ts
+++ b/src/build/vite/dev.ts
@@ -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.
@@ -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);
}
}
@@ -245,10 +250,10 @@ 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);
@@ -256,8 +261,15 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
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. `
` 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 `