diff --git a/src/presets/netlify/utils.ts b/src/presets/netlify/utils.ts index d04c0348be..d8c0d3825c 100644 --- a/src/presets/netlify/utils.ts +++ b/src/presets/netlify/utils.ts @@ -28,10 +28,9 @@ export async function writeRedirects(nitro: Nitro) { code = 301; } contents = - `${key.replace("/**", "/*")}\t${routeRules.redirect!.to.replace( - "/**", - "/:splat" - )}\t${code}\n` + contents; + `${key.replace("/**", "/*")}\t${routeRules + .redirect!.to.replace("/**", "/:splat") + .replace("**", ":splat")}\t${code}\n` + contents; } if (existsSync(redirectsPath)) { diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 02447c8a0e..3b66490312 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -167,7 +167,7 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { route = defu(route, { status: routeRules.redirect.status, headers: { - Location: routeRules.redirect.to.replace("/**", "/$1"), + Location: routeRules.redirect.to.replace("/**", "/$1").replace("**", "$1"), }, }); } @@ -184,7 +184,7 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { const proxy = routeRules.proxy!; const route: Record = { src: path.replace("/**", "/(.*)"), - dest: proxy.to.replace("/**", "/$1"), + dest: proxy.to.replace("/**", "/$1").replace("**", "$1"), }; if (routeRules.headers) { route.headers = routeRules.headers; diff --git a/src/runtime/internal/route-rules.ts b/src/runtime/internal/route-rules.ts index 88214c5c17..ced7249e5e 100644 --- a/src/runtime/internal/route-rules.ts +++ b/src/runtime/internal/route-rules.ts @@ -30,6 +30,24 @@ export const redirect: RouteRuleCtor<"redirect"> = ((m) => targetPath = withoutBase(targetPath, strpBase); } target = joinURL(target.slice(0, -3), targetPath); + } else if (target.includes("**")) { + // Wildcard ** in a non-trailing position (e.g., "/target?param=**") + // Use only pathname (not search) to avoid redirect loops + let targetPath = event.url.pathname; + const strpBase = (m.options as any)._redirectStripBase; + if (strpBase) { + targetPath = withoutBase(targetPath, strpBase); + } + targetPath = targetPath.replace(/^\//, ""); + if (!targetPath) { + // No captured path -> skip redirect to avoid infinite loop + return; + } + target = target.replace("**", targetPath); + // Merge any original query params into the target + if (event.url.search) { + target = withQuery(target, Object.fromEntries(event.url.searchParams)); + } } else if (event.url.search) { target = withQuery(target, Object.fromEntries(event.url.searchParams)); } @@ -50,6 +68,24 @@ export const proxy: RouteRuleCtor<"proxy"> = ((m) => targetPath = withoutBase(targetPath, strpBase); } target = joinURL(target.slice(0, -3), targetPath); + } else if (target.includes("**")) { + // Wildcard ** in a non-trailing position (e.g., "/target?param=**") + // Use only pathname (not search) to avoid redirect loops + let targetPath = event.url.pathname; + const strpBase = (m.options as any)._proxyStripBase; + if (strpBase) { + targetPath = withoutBase(targetPath, strpBase); + } + targetPath = targetPath.replace(/^\//, ""); + if (!targetPath) { + // No captured path -> skip proxy to avoid infinite loop + return; + } + target = target.replace("**", targetPath); + // Merge any original query params into the target + if (event.url.search) { + target = withQuery(target, Object.fromEntries(event.url.searchParams)); + } } else if (event.url.search) { target = withQuery(target, Object.fromEntries(event.url.searchParams)); } diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index ba9b84b47c..b798e66c1a 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -98,6 +98,9 @@ export default defineConfig({ redirect: { to: "https://nitro.build/", status: 308 }, }, "/rules/redirect/wildcard/**": { redirect: "https://nitro.build/**" }, + "/rules/redirect/wildcard-query/**": { + redirect: { to: "/target?param=**", status: 301 }, + }, "/rules/nested/**": { redirect: "/base", headers: { "x-test": "test" } }, "/rules/nested/override": { redirect: { to: "/other" } }, "/rules/_/noncached/cached": { swr: true }, diff --git a/test/presets/netlify.test.ts b/test/presets/netlify.test.ts index cf89a93cc9..b93421a955 100644 --- a/test/presets/netlify.test.ts +++ b/test/presets/netlify.test.ts @@ -41,12 +41,13 @@ describe("nitro:preset:netlify", async () => { const redirects = await fsp.readFile(resolve(ctx.outDir, "../dist/_redirects"), "utf8"); expect(redirects).toMatchInlineSnapshot(` - "/rules/nested/override /other 302 - /rules/redirect/wildcard/* https://nitro.build/:splat 302 - /rules/redirect/obj https://nitro.build/ 301 - /rules/nested/* /base 302 - /rules/redirect /base 302 - " + "/rules/nested/override /other 302 + /rules/redirect/wildcard-query/* /target?param=:splat 301 + /rules/redirect/wildcard/* https://nitro.build/:splat 302 + /rules/redirect/obj https://nitro.build/ 301 + /rules/nested/* /base 302 + /rules/redirect /base 302 + " `); }); diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index 732d2ebd33..fbf05a8a26 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -68,6 +68,13 @@ describe("nitro:preset:vercel:web", async () => { "src": "/rules/redirect/wildcard/(.*)", "status": 307, }, + { + "headers": { + "Location": "/target?param=$1", + }, + "src": "/rules/redirect/wildcard-query/(.*)", + "status": 301, + }, { "headers": { "Location": "/other", diff --git a/test/tests.ts b/test/tests.ts index 684406edc3..cf418c7583 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -312,6 +312,17 @@ export function testNitro( }); expect(wildcard.status).toBe(307); expect(wildcard.headers.location).toBe("https://nitro.build/nuxt"); + + const wildcardQuery = await callHandler({ + url: "/rules/redirect/wildcard-query/FOO", + }); + expect(wildcardQuery.status).toBe(301); + expect(wildcardQuery.headers.location).toBe("/target?param=FOO"); + + const wildcardQueryEmpty = await callHandler({ + url: "/rules/redirect/wildcard-query/", + }); + expect(wildcardQueryEmpty.status).not.toBe(301); }); it("binary response", async () => {