fix(config): respect has/missing conditions in next.config headers#48
Conversation
|
/bigbonk review |
There was a problem hiding this comment.
Review: fix(config): respect has/missing conditions in next.config headers
Overall this is a solid, well-structured change. The has/missing condition plumbing is correctly wired through all four request-handling paths (Pages Router dev in index.ts, Pages Router prod in prod-server.ts, App Router dev in app-dev-server.ts, and the shared matchHeaders in config-matchers.ts). The tests are thorough and cover the key scenarios. A few things worth addressing:
Issues
-
Duplicate
__buildRequestContextcalls in app-dev-server — The request context is now built twice per request: once at line 1129 (inside the headers block) and once at line 1171 (for redirects/rewrites). These both callnew URL(request.url)and re-parse cookies, which is wasteful. Since the request is immutable at this point, the context should be built once and reused. This is a minor efficiency nit but worth cleaning up since it's in a hot path. -
Duplicated header-matching logic — The
applyHeadersfunction inindex.ts(lines 3463-3500) now contains the same regex + has/missing logic asmatchHeadersinconfig-matchers.ts. The only difference is thatapplyHeaderscallsres.setHeader()inline whilematchHeadersreturns an array. Consider refactoringapplyHeadersto delegate tomatchHeadersand then iterate the result to callsetHeader, which would eliminate the duplication and ensure the two paths can't drift. -
Legacy behavior test semantics — The "keeps legacy behavior without ctx" test (shims.test.ts:2710) verifies that when no
ctxis passed, rules withhasconditions are applied unconditionally. This is technically correct for backward compat, but it means a caller that forgets to passctxwill silently ignorehas/missingconditions. This design choice is fine for now, but it might be worth a brief code comment inmatchHeadersexplaining whyctxis optional and what happens when it's omitted.
Minor nits
See inline comments.
| let pathname = url.pathname; | ||
| ${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""} | ||
| const extraHeaders = __applyConfigHeaders(pathname); | ||
| const __reqCtx = __buildRequestContext(request); |
There was a problem hiding this comment.
This builds a RequestContext from the request, but __buildRequestContext(request) is called again at line 1171 for the same request. Since the request is immutable at this point, consider building the context once (e.g., at line 1171 before the redirect check) and passing it to both __applyConfigHeaders and __applyConfigRedirects.
| const __reqCtx = __buildRequestContext(request); | |
| const extraHeaders = __applyConfigHeaders(pathname, __reqCtx); |
Then move the const __reqCtx = __buildRequestContext(request) declaration to before the if (__configHeaders.length ...) block at line 1125 so both code paths share the same context.
| // Apply custom headers from next.config.js | ||
| if (nextConfig?.headers.length) { | ||
| applyHeaders(pathname, res, nextConfig.headers); | ||
| const requestForCtx = new Request(new URL(url, `http://${req.headers.host || "localhost"}`), { |
There was a problem hiding this comment.
This constructs a Request object solely to pass to requestContextFromRequest. Since you already have req.headers and url available, it may be cleaner to build the RequestContext directly without the intermediate Request allocation:
| const requestForCtx = new Request(new URL(url, `http://${req.headers.host || "localhost"}`), { | |
| const reqUrl = new URL(url, `http://${req.headers.host || "localhost"}`); |
Then construct the context manually:
const reqCtx: RequestContext = {
headers: new Headers(
Object.fromEntries(
Object.entries(req.headers)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : String(v)])
)
),
cookies: parseCookies(req.headers.cookie as string | null),
query: reqUrl.searchParams,
host: req.headers.host || "localhost",
};This avoids the new Request() allocation (which also copies the body stream descriptor). Not a correctness issue, just a minor efficiency improvement.
| @@ -3446,7 +3463,8 @@ function applyRewrites( | |||
| function applyHeaders( | |||
There was a problem hiding this comment.
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.
| .replace(/___GROUP_(\d+)___/g, (_m, idx) => `(${groups[Number(idx)]})`); | ||
| const sourceRegex = safeRegExp("^" + escaped + "$"); | ||
| if (sourceRegex && sourceRegex.test(pathname)) { | ||
| if (ctx && (rule.has || rule.missing)) { |
There was a problem hiding this comment.
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.
| 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)) { |
| expect(withPreviewQuery.headers()["x-preview-header"]).toBe("true"); | ||
|
|
||
| // unmatched query => header should not be applied | ||
| const withoutPreviewQuery = await request.get(`${BASE}/about?preview=0`); |
There was a problem hiding this comment.
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.
|
Review posted. Here's a summary of the feedback: Key findings
|
Adds has/missing condition support to matchHeaders, wires request context through dev/prod paths, updates NextHeader types, and adds unit + e2e tests for header/cookie/query conditions.
Addresses review feedback: clarify that when no request context is passed, has/missing conditions are silently skipped for backward compatibility with existing callers.
4c4c492 to
f89d8df
Compare
|
/bigbonk one last review before I merge please |
commit: |
bd82d57 to
dfa8b85
Compare
There was a problem hiding this comment.
Review: fix(config): respect has/missing conditions in next.config headers
This PR correctly wires has/missing condition support through all request-handling paths for next.config.headers(). The implementation is clean and addresses a real gap in Next.js API compatibility.
What's good
-
Proper deduplication in
index.ts— All three local wrappers (applyHeaders,applyRedirects,applyRewrites) now delegate to their shared counterparts fromconfig-matchers.ts. This eliminates the ~30 lines of duplicated regex+matching logic that existed before and prevents drift between Pages Router dev and the shared matchers. This was flagged in the previous review and has been addressed. -
Single
__buildRequestContextcall inapp-dev-server.ts— The request context is built once at line 1193 (insidehandler) and passed to both_handleRequest(for redirects/rewrites) and__applyConfigHeaders(for headers). The previous review's concern about duplicate construction has been addressed. -
Correct backward compatibility — The
ctxparameter being optional inmatchHeadersmeans existing callers that don't pass context still work. Rules withhas/missingare applied unconditionally when no context is provided, which is a reasonable default. -
Good test coverage — Unit tests cover has-header, missing-cookie, has-query, and legacy-no-ctx scenarios. E2E tests cover the same conditions against the actual dev server with the
app-basicfixture. -
All four request paths are covered — Pages Router dev (
index.ts), Pages Router prod (prod-server.ts), App Router dev (app-dev-server.ts), and the sharedmatchHeadersinconfig-matchers.tsall wire throughctx. This is exactly the kind of parity check AGENTS.md requires.
Minor observations (non-blocking)
See inline comments for a couple of minor nits. None are blocking.
|
|
||
| // Build request context once for has/missing condition checks | ||
| // across headers, redirects, and rewrites. | ||
| const reqUrl = new URL(url, `http://${req.headers.host || "localhost"}`); |
There was a problem hiding this comment.
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 matched = matchHeaders("/about", rules); | ||
| expect(matched).toEqual([{ key: "x-conditional-header", value: "enabled" }]); | ||
| }); |
There was a problem hiding this comment.
The previous review suggested adding a test for has query with no value (key-existence-only check), which would exercise the condition.value !== undefined branch in checkSingleCondition. Would be a nice addition in a follow-up — not blocking this PR.
| @@ -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; | |||
There was a problem hiding this comment.
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.
|
Review submitted successfully. Here's a summary: Approved — This is a solid PR that correctly wires Key findings:
I left 3 non-blocking inline observations:
|
…ges Router dev Pages Router dev was the only path missing has/missing support for redirects and rewrites. The reqCtx was already being built for headers, so hoist it and pass it to applyRedirects and applyRewrites too. Also refactors applyRedirects and applyRewrites to delegate to the shared matchRedirect/matchRewrite functions, eliminating duplicated pattern-matching logic (same dedup pattern used for applyHeaders).
dfa8b85 to
280237e
Compare
Add integration tests for has/missing conditions on next.config.js headers in the Pages Router, covering both dev and production servers. The implementation was merged in #48 but lacked Pages Router test coverage. Co-authored-by: Bruno Maciel <ibruno@users.noreply.github.com>
Add integration tests for has/missing conditions on next.config.js headers in the Pages Router, covering both dev and production servers. The implementation was merged in #48 but lacked Pages Router test coverage. Co-authored-by: Bruno Maciel <ibruno@users.noreply.github.com>
Fixes
next.config.headers()conditional matching by addinghas/missingsupport inmatchHeaders, wiring request context through dev/prod paths, updatingNextHeadertypes, and enabling coverage with new unit + e2e tests for header/cookie/query conditions (including backward compatibility when no context is provided).