Skip to content
Open
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
7 changes: 3 additions & 4 deletions src/presets/netlify/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
4 changes: 2 additions & 2 deletions src/presets/vercel/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
});
}
Expand All @@ -184,7 +184,7 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) {
const proxy = routeRules.proxy!;
const route: Record<string, any> = {
src: path.replace("/**", "/(.*)"),
dest: proxy.to.replace("/**", "/$1"),
dest: proxy.to.replace("/**", "/$1").replace("**", "$1"),
};
if (routeRules.headers) {
route.headers = routeRules.headers;
Expand Down
36 changes: 36 additions & 0 deletions src/runtime/internal/route-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand All @@ -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));
}
Expand Down
3 changes: 3 additions & 0 deletions test/fixture/nitro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
13 changes: 7 additions & 6 deletions test/presets/netlify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
"
`);
});

Expand Down
7 changes: 7 additions & 0 deletions test/presets/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions test/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading