Skip to content

Commit 8dc3628

Browse files
committed
feat(routing): implement routing normalization and add related tests
1 parent 2e3d6e7 commit 8dc3628

4 files changed

Lines changed: 534 additions & 9 deletions

File tree

packages/core/src/build/createRoutingConfig.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { RuntimeRoutingConfig } from "../types/adapter.js";
55

66
import type { BuildCompleteContext } from "./adapter.js";
77
import type * as buildHelper from "./helper.js";
8+
import { normalizeRouting } from "./normalizeRouting.js";
89

910
const EXECUTABLE_OUTPUT_TYPES = ["pages", "pagesApi", "appPages", "appRoutes"] as const;
1011
const PATHNAME_OUTPUT_TYPES = [...EXECUTABLE_OUTPUT_TYPES, "staticFiles"] as const;
@@ -150,7 +151,10 @@ export function createRoutingConfig(
150151
];
151152
const routingConfig: RuntimeRoutingConfig = {
152153
buildId: context.buildId,
153-
routes: context.routing,
154+
routes: normalizeRouting(context.routing, {
155+
locales: context.config.i18n?.locales ?? [],
156+
apiPathnames: new Set(context.outputs.pagesApi.map((output) => output.pathname)),
157+
}),
154158
pathnames,
155159
routeIndex,
156160
};
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import type { NextAdapterRouting } from "../types/adapter.js";
2+
3+
/**
4+
* A route of `NextAdapterRouting`, with the fields Next emits on top of what the resolver reads.
5+
*/
6+
type AdapterRoute = NextAdapterRouting["beforeMiddleware"][number] & {
7+
source?: string;
8+
priority?: boolean;
9+
};
10+
11+
type CustomRouteGroup = "beforeMiddleware" | "beforeFiles" | "afterFiles" | "fallback";
12+
13+
const CUSTOM_ROUTE_GROUPS: CustomRouteGroup[] = ["beforeMiddleware", "beforeFiles", "afterFiles", "fallback"];
14+
15+
/** The locale capture Next prefixes localized dynamic routes with. */
16+
const LOCALE_CAPTURE = "(?<nextLocale>[^/]{1,})";
17+
18+
export type NormalizeRoutingOptions = {
19+
/** The configured locales, empty when `i18n` is not configured. */
20+
locales: string[];
21+
/** The pathnames of the pages router API routes. */
22+
apiPathnames: Set<string>;
23+
};
24+
25+
function isExternalDestination(destination: string): boolean {
26+
return destination.startsWith("http://") || destination.startsWith("https://");
27+
}
28+
29+
function splitDestination(destination: string): [pathname: string, search: string] {
30+
const separator = destination.indexOf("?");
31+
return separator === -1
32+
? [destination, ""]
33+
: [destination.slice(0, separator), destination.slice(separator)];
34+
}
35+
36+
function isRedirect(route: AdapterRoute): boolean {
37+
return (
38+
route.status !== undefined &&
39+
route.status >= 300 &&
40+
route.status < 400 &&
41+
Object.keys(route.headers ?? {}).some((key) => key.toLowerCase() === "location")
42+
);
43+
}
44+
45+
/**
46+
* Whether the route is one Next already emitted for a locale.
47+
*
48+
* Next prefixes the source of every route it localizes, either with the locale group
49+
* (`/:nextInternalLocale(en|nl)/...`) or with a concrete locale for the default locale variant.
50+
* A route declared with `locale: false` keeps its source as authored.
51+
*/
52+
function isLocalized(route: AdapterRoute, locales: string[]): boolean {
53+
const source = route.source ?? "";
54+
return (
55+
source.startsWith("/:nextInternalLocale") ||
56+
locales.some((locale) => source === `/${locale}` || source.startsWith(`/${locale}/`))
57+
);
58+
}
59+
60+
/**
61+
* Drops the locale a destination targeting a pages router API route carries.
62+
*
63+
* Next localizes every destination when `i18n` is configured, but never emits a localized output
64+
* for an API route - and the resolver never localizes an `/api/` request either. The locale would
65+
* only make the destination unresolvable, so it is stripped.
66+
*/
67+
function delocalizeApiDestination(destination: string, apiPathnames: Set<string>): string {
68+
const [pathname, search] = splitDestination(destination);
69+
// The locale of a destination is a capture reference - `/$1/api/query` or `/$nextLocale/api/foo`.
70+
const localePrefix = pathname.match(/^\/\$\w+(?<rest>\/.*)$/);
71+
const rest = localePrefix?.groups?.["rest"];
72+
return rest !== undefined && apiPathnames.has(rest) ? `${rest}${search}` : destination;
73+
}
74+
75+
/**
76+
* Prefixes an internal destination with a locale.
77+
*
78+
* API routes are the exception - see `delocalizeApiDestination`.
79+
*/
80+
function localizeDestination(destination: string, locale: string, apiPathnames: Set<string>): string {
81+
if (isExternalDestination(destination)) {
82+
return destination;
83+
}
84+
const [pathname, search] = splitDestination(destination);
85+
if (apiPathnames.has(pathname)) {
86+
return destination;
87+
}
88+
// `/` is the pathname of the locale root itself, i.e. `/en` and not `/en/`.
89+
return `/${locale}${pathname === "/" ? "" : pathname}${search}`;
90+
}
91+
92+
/**
93+
* Creates the variant of a route matching the pathname of a locale.
94+
*
95+
* The resolver prefixes the pathname of the request with the detected locale before matching any
96+
* route, so a route Next did not localize - i.e. one declared with `locale: false` - would never
97+
* match. The variants restore the routes for every locale the request may have been resolved to.
98+
*/
99+
function localizeRoute(route: AdapterRoute, locale: string, apiPathnames: Set<string>): AdapterRoute {
100+
return {
101+
...route,
102+
source: route.source === undefined ? undefined : `/${locale}${route.source}`,
103+
sourceRegex: route.sourceRegex.replace(/^\^/, `^\\/${locale}`),
104+
destination:
105+
route.destination === undefined
106+
? undefined
107+
: localizeDestination(route.destination, locale, apiPathnames),
108+
};
109+
}
110+
111+
/**
112+
* Turns a redirect into a route the resolver stops at.
113+
*
114+
* The resolver only stops processing a group when the matched route has a destination, so a
115+
* redirect - which carries its target in a `location` header - lets every route after it match and
116+
* override that header. Next stops at the first matching redirect, and so must we: the destination
117+
* makes the resolver return the redirect right away.
118+
*/
119+
function withRedirectDestination(route: AdapterRoute): AdapterRoute {
120+
const location = Object.entries(route.headers ?? {}).find(([key]) => key.toLowerCase() === "location")?.[1];
121+
return location === undefined ? route : { ...route, destination: location };
122+
}
123+
124+
/**
125+
* The name of the capture group spanning the whole condition value, if there is one.
126+
*
127+
* `(?<destination>\w+)` captures the value as a whole, `foo-(?<id>\d+)` only a part of it.
128+
*/
129+
function wholeValueCaptureName(value: string): string | undefined {
130+
const match = value.match(/^\(\?<(?<name>\w+)>(?<body>.*)\)$/s);
131+
const body = match?.groups?.["body"];
132+
if (body === undefined) {
133+
return undefined;
134+
}
135+
// The group only spans the whole value when its opening parenthesis is the one closing at the end.
136+
let depth = 0;
137+
for (let index = 0; index < body.length; index++) {
138+
const character = body[index];
139+
if (character === "\\") {
140+
index++;
141+
} else if (character === "(") {
142+
depth++;
143+
} else if (character === ")" && depth-- === 0) {
144+
return undefined;
145+
}
146+
}
147+
return depth === 0 ? match?.groups?.["name"] : undefined;
148+
}
149+
150+
/**
151+
* Renames the capture references of the `has` conditions of a route to the names the resolver binds.
152+
*
153+
* Next names them after the capture group of the condition value - `$destination` for a
154+
* `(?<destination>\w+)` query condition - while the resolver binds the value it matched to the key
155+
* of the condition instead. Only a group spanning the whole value can be renamed: it is the one
156+
* case where both are the same string.
157+
*/
158+
function alignHasCaptures(route: AdapterRoute): AdapterRoute {
159+
const renames = (route.has ?? []).flatMap((condition) => {
160+
// A `host` condition matches on the hostname and binds no capture.
161+
if (condition.type === "host" || condition.value === undefined) {
162+
return [];
163+
}
164+
const name = wholeValueCaptureName(condition.value);
165+
// The resolver strips everything but the letters of the key it binds the value to.
166+
const boundName = condition.key.replace(/[^a-zA-Z]/g, "");
167+
return name === undefined || name === boundName ? [] : [[name, boundName] as const];
168+
});
169+
if (renames.length === 0) {
170+
return route;
171+
}
172+
const rename = (value: string) =>
173+
renames.reduce((current, [name, boundName]) => current.replaceAll(`$${name}`, `$${boundName}`), value);
174+
return {
175+
...route,
176+
destination: route.destination === undefined ? undefined : rename(route.destination),
177+
headers:
178+
route.headers === undefined
179+
? undefined
180+
: Object.fromEntries(Object.entries(route.headers).map(([key, value]) => [key, rename(value)])),
181+
};
182+
}
183+
184+
/**
185+
* Drops the locale Next prefixed a pages router API dynamic route with.
186+
*
187+
* The resolver leaves `/api/` requests unlocalized, so the locale capture would keep the route from
188+
* ever matching one.
189+
*/
190+
function delocalizeApiDynamicRoute(route: AdapterRoute, apiPathnames: Set<string>): AdapterRoute {
191+
if (!route.source || !apiPathnames.has(route.source) || !route.sourceRegex.includes(LOCALE_CAPTURE)) {
192+
return route;
193+
}
194+
return {
195+
...route,
196+
sourceRegex: route.sourceRegex.replace(LOCALE_CAPTURE, ""),
197+
destination: route.destination?.replace("/$nextLocale", ""),
198+
};
199+
}
200+
201+
/**
202+
* Reconciles the routing Next emits with the pathnames the resolver actually matches.
203+
*
204+
* Next assumes a router that localizes every request and stops at the first matching redirect,
205+
* neither of which the resolver does. This rewrites the routes so that both agree.
206+
*/
207+
export function normalizeRouting(
208+
routing: NextAdapterRouting,
209+
{ locales, apiPathnames }: NormalizeRoutingOptions
210+
): NextAdapterRouting {
211+
const normalized: NextAdapterRouting = {
212+
...routing,
213+
dynamicRoutes: routing.dynamicRoutes.map((route) =>
214+
delocalizeApiDynamicRoute(route as AdapterRoute, apiPathnames)
215+
),
216+
};
217+
218+
for (const group of CUSTOM_ROUTE_GROUPS) {
219+
normalized[group] = (routing[group] as AdapterRoute[]).flatMap((originalRoute) => {
220+
const route = alignHasCaptures(originalRoute);
221+
const withApiDestination: AdapterRoute =
222+
route.destination === undefined
223+
? route
224+
: { ...route, destination: delocalizeApiDestination(route.destination, apiPathnames) };
225+
// A priority route is matched against the request as it came in - i.e. before the resolver
226+
// localizes it - so it needs no variant.
227+
const variants =
228+
locales.length === 0 || route.priority || isLocalized(route, locales)
229+
? []
230+
: locales.map((locale) => localizeRoute(withApiDestination, locale, apiPathnames));
231+
return [...variants, withApiDestination].map((variant) =>
232+
!variant.priority && isRedirect(variant) && variant.destination === undefined
233+
? withRedirectDestination(variant)
234+
: variant
235+
);
236+
});
237+
}
238+
239+
return normalized;
240+
}

packages/core/src/core/routingHandler.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,21 @@ function headersToRecord(headers: Headers): Record<string, string | string[]> {
6868
return result;
6969
}
7070

71+
/**
72+
* Converts the headers of the routed request to the record the middleware expects.
73+
*
74+
* Next builds the middleware `Request` from `Object.entries(request.headers)`, which yields nothing
75+
* for a `Headers` instance - the middleware would see a request without a single header, and the
76+
* headers it forwards with `NextResponse.next({ request })` would drop every one of them.
77+
*/
78+
function headersToRequestRecord(headers: Headers): Record<string, string> {
79+
const result: Record<string, string> = {};
80+
headers.forEach((value, key) => {
81+
result[key] = value;
82+
});
83+
return result;
84+
}
85+
7186
function applyResponseHeaders(eventOrResult: InternalEvent | InternalResult, headers: Headers): void {
7287
const isResult = isInternalResult(eventOrResult);
7388
const keyPrefix = isResult ? "" : MIDDLEWARE_HEADER_PREFIX;
@@ -212,6 +227,7 @@ export default async function routingHandler(
212227
}
213228

214229
let directMiddlewareResult: InternalResult | undefined;
230+
let middlewareRewriteStatusCode: number | undefined;
215231
let middlewareHeaders = new Headers(event.headers);
216232
const buildId = RoutingConfig.buildId || BuildId;
217233
const basePath = NextConfig.basePath ?? "";
@@ -243,6 +259,11 @@ export default async function routingHandler(
243259
return { requestHeaders: context.headers };
244260
}
245261

262+
// The middleware runs on the user visible pathname, so a `_next/data` request has to be
263+
// invoked on the page it carries - the middleware has no route for the data pathname.
264+
const middlewareUrl = new URL(context.url);
265+
middlewareUrl.pathname = matchPath;
266+
246267
const middleware = await middlewareLoader();
247268
const response = await middleware.default({
248269
geo: {
@@ -252,18 +273,23 @@ export default async function routingHandler(
252273
latitude: event.headers["x-open-next-latitude"],
253274
longitude: event.headers["x-open-next-longitude"],
254275
},
255-
headers: context.headers,
276+
headers: headersToRequestRecord(context.headers),
256277
method: event.method || "GET",
257278
nextConfig: {
258279
basePath: NextConfig.basePath,
259280
i18n: NextConfig.i18n,
260281
trailingSlash: NextConfig.trailingSlash,
261282
},
262-
url: context.url.toString(),
283+
url: middlewareUrl.toString(),
263284
body: context.requestBody,
264285
} as unknown as Request);
265286
const result = responseToMiddlewareResult(response, context.headers, context.url);
266287
restoreNullOrigin(result, context.url);
288+
// The resolver has no notion of the status of a rewrite, but `NextResponse.rewrite(url,
289+
// { status })` serves the destination with that status - it has to be carried over here.
290+
if (result.rewrite && response.status !== 200) {
291+
middlewareRewriteStatusCode = response.status;
292+
}
267293
if (result.bodySent) {
268294
directMiddlewareResult = {
269295
type: event.type,
@@ -282,6 +308,7 @@ export default async function routingHandler(
282308
}
283309

284310
const responseHeaders = routingResult.resolvedHeaders ?? new Headers();
311+
const rewriteStatusCode = routingResult.status ?? middlewareRewriteStatusCode;
285312
if (routingResult.redirect) {
286313
// The resolver already set a `location` from the matched route headers. It must be replaced
287314
// - and not merely added to the record - so that the response has a single redirect target.
@@ -319,7 +346,7 @@ export default async function routingHandler(
319346
applyResponseHeaders(externalEvent, responseHeaders);
320347
return createRoutingResult(externalEvent, [], {
321348
isExternalRewrite: true,
322-
rewriteStatusCode: routingResult.status,
349+
rewriteStatusCode,
323350
initialURL: event.url,
324351
});
325352
}
@@ -336,7 +363,7 @@ export default async function routingHandler(
336363
};
337364
applyResponseHeaders(notFoundEvent, responseHeaders);
338365
return createRoutingResult(notFoundEvent, [], {
339-
rewriteStatusCode: routingResult.status,
366+
rewriteStatusCode,
340367
initialURL: event.url,
341368
});
342369
}
@@ -353,7 +380,7 @@ export default async function routingHandler(
353380
middlewareHeaders,
354381
routingResult.resolvedQuery ?? routingResult.invocationTarget.query
355382
),
356-
rewriteStatusCode: routingResult.status,
383+
rewriteStatusCode,
357384
};
358385
const resolvedRoutes = getResolvedRoute(routingResult.resolvedPathname);
359386

@@ -375,7 +402,7 @@ export default async function routingHandler(
375402
};
376403
applyResponseHeaders(notFoundEvent, responseHeaders);
377404
return createRoutingResult(notFoundEvent, [], {
378-
rewriteStatusCode: routingResult.status,
405+
rewriteStatusCode,
379406
initialURL: event.url,
380407
});
381408
}
@@ -390,15 +417,15 @@ export default async function routingHandler(
390417
applyResponseHeaders(cacheInterceptionResult.result, responseHeaders);
391418
applyResponseHeaders(cacheInterceptionResult.resumeRequest, responseHeaders);
392419
return createRoutingResult(cacheInterceptionResult.resumeRequest, resolvedRoutes, {
393-
rewriteStatusCode: routingResult.status,
420+
rewriteStatusCode,
394421
initialResponse: cacheInterceptionResult.result,
395422
initialURL: event.url,
396423
});
397424
}
398425

399426
applyResponseHeaders(cacheInterceptionResult, responseHeaders);
400427
return createRoutingResult(cacheInterceptionResult, resolvedRoutes, {
401-
rewriteStatusCode: routingResult.status,
428+
rewriteStatusCode,
402429
initialURL: event.url,
403430
});
404431
} catch (e) {

0 commit comments

Comments
 (0)