Skip to content
Merged
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
11 changes: 11 additions & 0 deletions packages/vinext/src/config/config-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,16 +669,27 @@ export async function proxyExternalRequest(
/**
* Apply custom header rules from next.config.js.
* Returns an array of { key, value } pairs to set on the response.
*
* `ctx` is optional for backward compatibility with existing callers.
* When omitted, `has`/`missing` conditions are not evaluated.
*/
export function matchHeaders(
pathname: string,
headers: NextHeader[],
ctx?: RequestContext,
): Array<{ key: string; value: string }> {
const result: Array<{ key: string; value: string }> = [];
for (const rule of headers) {
const escaped = escapeHeaderSource(rule.source);
const sourceRegex = safeRegExp("^" + escaped + "$");
if (sourceRegex && sourceRegex.test(pathname)) {
// When no request context is available, skip has/missing checks
// and apply all path-matched rules unconditionally (backward compat).
if (ctx && (rule.has || rule.missing)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth a brief comment explaining the design: when ctx is not provided, has/missing conditions are silently ignored and all path-matched rules apply unconditionally. This is intentional for backward compatibility but could be surprising to future readers.

Suggested change
if (ctx && (rule.has || rule.missing)) {
// When no request context is available, skip has/missing checks
// and apply all path-matched rules unconditionally (backward compat).
if (ctx && (rule.has || rule.missing)) {

if (!checkHasConditions(rule.has, rule.missing, ctx)) {
continue;
}
}
result.push(...rule.headers);
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface NextRewrite {

export interface NextHeader {
source: string;
has?: HasCondition[];
missing?: HasCondition[];
headers: Array<{ key: string; value: string }>;
}

Expand Down
91 changes: 51 additions & 40 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,24 @@ import {
type ResolvedNextConfig,
type NextRedirect,
type NextRewrite,
type NextHeader,
} from "./config/next-config.js";

import { findMiddlewareFile, runMiddleware } from "./server/middleware.js";
import { generateSafeRegExpCode, generateMiddlewareMatcherCode, generateNormalizePathCode } from "./server/middleware-codegen.js";
import { normalizePath } from "./server/normalize-path.js";
import { findInstrumentationFile, runInstrumentation } from "./server/instrumentation.js";
import { validateDevRequest } from "./server/dev-origin-check.js";
import { safeRegExp, escapeHeaderSource, isExternalUrl, proxyExternalRequest } from "./config/config-matchers.js";
import {
safeRegExp,
isExternalUrl,
proxyExternalRequest,
parseCookies,
matchHeaders,
matchRedirect,
matchRewrite,
type RequestContext,
} from "./config/config-matchers.js";
import { scanMetadataFiles } from "./server/metadata-routes.js";
import { staticExportPages } from "./build/static-export.js";
import tsconfigPaths from "vite-tsconfig-paths";
Expand Down Expand Up @@ -2472,9 +2482,26 @@ hydrate();
}
}

// Build request context once for has/missing condition checks
// across headers, redirects, and rewrites.
const reqUrl = new URL(url, `http://${req.headers.host || "localhost"}`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: You're building the RequestContext manually here (parsing headers, cookies, query) rather than using the existing requestContextFromRequest helper from config-matchers.ts. This is intentional since you're working with a Node.js IncomingMessage (not a Web Request), so constructing a new Request() just to pass it to requestContextFromRequest would be wasteful.

That said, this pattern of converting Node.js headers to Headers + parsing cookies manually is somewhat boilerplate-heavy. If this pattern recurs elsewhere, it might be worth adding a requestContextFromNodeRequest(req, url) helper to config-matchers.ts. Not needed for this PR though.

const reqCtxHeaders = new Headers(
Object.fromEntries(
Object.entries(req.headers)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : String(v)])
),
);
const reqCtx: RequestContext = {
headers: reqCtxHeaders,
cookies: parseCookies(reqCtxHeaders.get("cookie")),
query: reqUrl.searchParams,
host: reqCtxHeaders.get("host") ?? reqUrl.host,
};

// Apply custom headers from next.config.js
if (nextConfig?.headers.length) {
applyHeaders(pathname, res, nextConfig.headers);
applyHeaders(pathname, res, nextConfig.headers, reqCtx);
}

// Apply redirects from next.config.js
Expand All @@ -2483,6 +2510,7 @@ hydrate();
pathname,
res,
nextConfig.redirects,
reqCtx,
);
if (redirected) return;
}
Expand All @@ -2491,7 +2519,7 @@ hydrate();
let resolvedUrl = url;
if (nextConfig?.rewrites.beforeFiles.length) {
resolvedUrl =
applyRewrites(pathname, nextConfig.rewrites.beforeFiles) ??
applyRewrites(pathname, nextConfig.rewrites.beforeFiles, reqCtx) ??
url;
}

Expand Down Expand Up @@ -2532,6 +2560,7 @@ hydrate();
const afterRewrite = applyRewrites(
resolvedUrl.split("?")[0],
nextConfig.rewrites.afterFiles,
reqCtx,
);
if (afterRewrite) resolvedUrl = afterRewrite;
}
Expand All @@ -2557,6 +2586,7 @@ hydrate();
const fallbackRewrite = applyRewrites(
resolvedUrl.split("?")[0],
nextConfig.rewrites.fallback,
reqCtx,
);
if (fallbackRewrite) {
// External fallback rewrite — proxy to external URL
Expand Down Expand Up @@ -3402,22 +3432,15 @@ function applyRedirects(
pathname: string,
res: any,
redirects: NextRedirect[],
ctx?: RequestContext,
): boolean {
for (const redirect of redirects) {
const params = matchConfigPattern(pathname, redirect.source);
if (params) {
let dest = redirect.destination;
for (const [key, value] of Object.entries(params)) {
dest = dest.replace(`:${key}*`, value);
dest = dest.replace(`:${key}+`, value);
dest = dest.replace(`:${key}`, value);
}
// Sanitize to prevent open redirect via protocol-relative URLs
dest = sanitizeDestinationLocal(dest);
res.writeHead(redirect.permanent ? 308 : 307, { Location: dest });
res.end();
return true;
}
const result = matchRedirect(pathname, redirects, ctx);
if (result) {
// Sanitize to prevent open redirect via protocol-relative URLs
const dest = sanitizeDestinationLocal(result.destination);
res.writeHead(result.permanent ? 308 : 307, { Location: dest });
res.end();
return true;
}
return false;
}
Expand Down Expand Up @@ -3494,20 +3517,12 @@ async function proxyExternalRewriteNode(
function applyRewrites(
pathname: string,
rewrites: NextRewrite[],
ctx?: RequestContext,
): string | null {
for (const rewrite of rewrites) {
const params = matchConfigPattern(pathname, rewrite.source);
if (params) {
let dest = rewrite.destination;
for (const [key, value] of Object.entries(params)) {
dest = dest.replace(`:${key}*`, value);
dest = dest.replace(`:${key}+`, value);
dest = dest.replace(`:${key}`, value);
}
// Sanitize to prevent open redirect via protocol-relative URLs
dest = sanitizeDestinationLocal(dest);
return dest;
}
const dest = matchRewrite(pathname, rewrites, ctx);
if (dest) {
// Sanitize to prevent open redirect via protocol-relative URLs
return sanitizeDestinationLocal(dest);
}
return null;
}
Expand All @@ -3518,16 +3533,12 @@ function applyRewrites(
function applyHeaders(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This applyHeaders function now contains the same regex matching + has/missing checking logic as matchHeaders in config-matchers.ts. Consider refactoring to reuse matchHeaders:

function applyHeaders(
  pathname: string,
  res: any,
  headers: NextHeader[],
  ctx?: RequestContext,
): void {
  const matched = matchHeaders(pathname, headers, ctx);
  for (const header of matched) {
    res.setHeader(header.key, header.value);
  }
}

This eliminates ~30 lines of duplicated pattern matching logic and ensures the two code paths can't drift out of sync — which is exactly the kind of parity issue flagged in AGENTS.md.

pathname: string,
res: any,
headers: Array<{ source: string; headers: Array<{ key: string; value: string }> }>,
headers: NextHeader[],
ctx?: RequestContext,
): void {
for (const rule of headers) {
const escaped = escapeHeaderSource(rule.source);
const sourceRegex = safeRegExp("^" + escaped + "$");
if (sourceRegex && sourceRegex.test(pathname)) {
for (const header of rule.headers) {
res.setHeader(header.key, header.value);
}
}
const matched = matchHeaders(pathname, headers, ctx);
for (const header of matched) {
res.setHeader(header.key, header.value);
}
}

Expand Down
17 changes: 11 additions & 6 deletions packages/vinext/src/server/app-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1152,7 +1152,7 @@ async function __proxyExternalRequest(request, externalUrl) {
return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: respHeaders });
}

function __applyConfigHeaders(pathname) {
function __applyConfigHeaders(pathname, ctx) {
const result = [];
for (const rule of __configHeaders) {
const groups = [];
Expand All @@ -1168,7 +1168,12 @@ function __applyConfigHeaders(pathname) {
.replace(/:[\\w-]+/g, "[^/]+")
.replace(/___GROUP_(\\d+)___/g, (_, idx) => "(" + groups[Number(idx)] + ")");
const sourceRegex = __safeRegExp("^" + escaped + "$");
if (sourceRegex && sourceRegex.test(pathname)) result.push(...rule.headers);
if (sourceRegex && sourceRegex.test(pathname)) {
if (ctx && (rule.has || rule.missing)) {
if (!__checkHasConditions(rule.has, rule.missing, ctx)) continue;
}
result.push(...rule.headers);
}
}
return result;
Comment on lines 1155 to 1178
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This __applyConfigHeaders function duplicates the escaping logic from escapeHeaderSource in config-matchers.ts. This is expected since the app-dev-server generates self-contained virtual module code that can't import from config-matchers.ts at runtime. Just flagging that if escapeHeaderSource ever changes (e.g., to handle new metacharacters), this inline version needs to be updated in lockstep. A code comment noting this dependency would help future maintainers.

Also worth noting: the inline escaping here uses the simpler ___GROUP_N___ placeholder approach, while escapeHeaderSource uses Unicode PUA sentinels (\uE000). Both achieve the same result but the approaches are slightly different, which could theoretically diverge on edge cases with very unusual source patterns. Probably fine in practice.

}
Expand All @@ -1185,7 +1190,8 @@ export default async function handler(request) {
_runWithCacheState(() =>
_runWithPrivateCache(() =>
runWithFetchCache(async () => {
const response = await _handleRequest(request);
const __reqCtx = __buildRequestContext(request);
const response = await _handleRequest(request, __reqCtx);
// Apply custom headers from next.config.js to non-redirect responses.
// Skip redirects (3xx) because Response.redirect() creates immutable headers,
// and Next.js doesn't apply custom headers to redirects anyway.
Expand All @@ -1194,7 +1200,7 @@ export default async function handler(request) {
let pathname;
try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""}
const extraHeaders = __applyConfigHeaders(pathname);
const extraHeaders = __applyConfigHeaders(pathname, __reqCtx);
for (const h of extraHeaders) {
response.headers.set(h.key, h.value);
}
Expand All @@ -1207,7 +1213,7 @@ export default async function handler(request) {
);
}

async function _handleRequest(request) {
async function _handleRequest(request, __reqCtx) {
const url = new URL(request.url);

// ── Cross-origin request protection ─────────────────────────────────
Expand Down Expand Up @@ -1253,7 +1259,6 @@ async function _handleRequest(request) {
}

// ── Apply redirects from next.config.js ───────────────────────────────
const __reqCtx = __buildRequestContext(request);
if (__configRedirects.length) {
const __redir = __applyConfigRedirects(pathname, __reqCtx);
if (__redir) {
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -825,7 +825,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {

// ── 5. Apply custom headers from next.config.js ───────────────
if (configHeaders.length) {
const matched = matchHeaders(resolvedPathname, configHeaders);
const matched = matchHeaders(resolvedPathname, configHeaders, reqCtx);
for (const h of matched) {
middlewareHeaders[h.key.toLowerCase()] = h.value;
}
Expand Down
2 changes: 1 addition & 1 deletion tests/app-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2163,7 +2163,7 @@ describe("App Router next.config.js features (generateRscEntry)", () => {
expect(code).toContain("__safeDevHosts");
// Should call dev origin validation inside _handleRequest
const callSite = code.indexOf("const __originBlock = __validateDevRequestOrigin(request)");
const handleRequestIdx = code.indexOf("async function _handleRequest(request)");
const handleRequestIdx = code.indexOf("async function _handleRequest(request, __reqCtx)");
expect(callSite).toBeGreaterThan(-1);
expect(handleRequestIdx).toBeGreaterThan(-1);
// The call should be inside the function body (after the function declaration)
Expand Down
43 changes: 33 additions & 10 deletions tests/e2e/app-router/config-redirect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,37 @@ test.describe("Config Custom Headers (OpenNext compat)", () => {
);

// Ref: opennextjs-cloudflare headers.test.ts — has/missing conditions
// vinext matchHeaders() in config-matchers.ts only checks source pattern.
// Tests: ON-15 #6 in TRACKING.md
test.fixme(
"config headers with has/missing conditions",
async () => {
// Would test: header rule with has: [{ type: "cookie", key: "logged-in" }]
// only applies when the cookie is present in the request.
// Needs: has/missing support in matchHeaders(), matchRedirect(), matchRewrite()
},
);
test("config headers with has/missing conditions", async ({ request }) => {
const baseline = await request.get(`${BASE}/about`);
expect(baseline.status()).toBe(200);
expect(baseline.headers()["x-conditional-header"]).toBeUndefined();
expect(baseline.headers()["x-preview-header"]).toBeUndefined();

// has(header) + missing(cookie) => header should be applied
const withRequiredHeader = await request.get(`${BASE}/about`, {
headers: { "x-user-tier": "pro" },
});
expect(withRequiredHeader.status()).toBe(200);
expect(withRequiredHeader.headers()["x-conditional-header"]).toBe("enabled");

// same request + cookie that must be missing => header should not be applied
const blockedByMissingCondition = await request.get(`${BASE}/about`, {
headers: {
"x-user-tier": "pro",
"cookie": "no-config-header=1",
},
});
expect(blockedByMissingCondition.status()).toBe(200);
expect(blockedByMissingCondition.headers()["x-conditional-header"]).toBeUndefined();

// has(query) => header should be applied
const withPreviewQuery = await request.get(`${BASE}/about?preview=1`);
expect(withPreviewQuery.status()).toBe(200);
expect(withPreviewQuery.headers()["x-preview-header"]).toBe("true");

// unmatched query => header should not be applied
const withoutPreviewQuery = await request.get(`${BASE}/about?preview=0`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice test case — verifying that preview=0 doesn't match the value: "1" condition is a good edge case. One thing that might be worth testing in a follow-up: a has query condition with no value (i.e., just checking for the key's presence regardless of value). That would exercise the condition.value !== undefined branch in checkSingleCondition.

expect(withoutPreviewQuery.status()).toBe(200);
expect(withoutPreviewQuery.headers()["x-preview-header"]).toBeUndefined();
});
});
13 changes: 13 additions & 0 deletions tests/fixtures/app-basic/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ const nextConfig: NextConfig = {
source: "/about",
headers: [{ key: "X-Page-Header", value: "about-page" }],
},
// Used by E2E: config-redirect.spec.ts — has/missing on headers rules
{
source: "/about",
has: [{ type: "header", key: "x-user-tier", value: "pro" }],
missing: [{ type: "cookie", key: "no-config-header" }],
headers: [{ key: "X-Conditional-Header", value: "enabled" }],
},
// Used by E2E: config-redirect.spec.ts — has query condition
{
source: "/about",
has: [{ type: "query", key: "preview", value: "1" }],
headers: [{ key: "X-Preview-Header", value: "true" }],
},
// Used by E2E: config-redirect.spec.ts (catch-all for e2e header test)
{
source: "/(.*)",
Expand Down
Loading
Loading