@cloudflare/next-on-pages
Version:
`@cloudflare/next-on-pages` is a CLI tool that you can use to build and develop [Next.js](https://nextjs.org/) applications so that they can run on the [Cloudflare Pages](https://pages.cloudflare.com/) platform (and integrate with Cloudflare's various oth
682 lines (583 loc) • 22.5 kB
text/typescript
import { parse } from 'cookie';
import type { MatchPCREResult, MatchedSetHeaders } from './utils';
import { isLocaleTrailingSlashRegex, parseAcceptLanguage } from './utils';
import {
applyHeaders,
applyPCREMatches,
applySearchParams,
checkhasField,
getNextPhase,
isUrl,
matchPCRE,
runOrFetchBuildOutputItem,
} from './utils';
import type { RequestContext } from '../../src/utils/requestContext';
export type CheckRouteStatus = 'skip' | 'next' | 'done' | 'error';
export type CheckPhaseStatus = Extract<CheckRouteStatus, 'error' | 'done'>;
/**
* The routes matcher is used to match a request to a route and run the route's middleware.
*/
export class RoutesMatcher {
/** URL from the request to match */
private url: URL;
/** Cookies from the request to match */
private cookies: Record<string, string>;
/** Wildcard match from the Vercel build output config */
private wildcardMatch: VercelWildCard | undefined;
/** Path for the matched route */
public path: string;
/** Status for the response object */
public status: number | undefined;
/** Headers for the response object */
public headers: MatchedSetHeaders;
/** Search params for the response object */
public searchParams: URLSearchParams;
/** Custom response body from middleware */
public body: BodyInit | undefined | null;
/** Counter for how many times the function to check a phase has been called */
public checkPhaseCounter;
/** Tracker for the middleware that have been invoked in a phase */
private middlewareInvoked: string[];
/** Locales found during routing */
public locales: Set<string>;
/**
* Creates a new instance of a request matcher.
*
* The matcher is used to match a request to a route and run the route's middleware.
*
* @param routes The processed Vercel build output config routes.
* @param output Vercel build output.
* @param reqCtx Request context object; request object, assets fetcher, and execution context.
* @param buildMetadata Metadata generated by the next-on-pages build process.
* @param wildcardConfig Wildcard options from the Vercel build output config.
* @returns The matched set of path, status, headers, and search params.
*/
constructor(
/** Processed routes from the Vercel build output config. */
private routes: ProcessedVercelRoutes,
/** Vercel build output. */
private output: VercelBuildOutput,
/** Request Context object for the request to match */
private reqCtx: RequestContext,
buildMetadata: NextOnPagesBuildMetadata,
wildcardConfig?: VercelWildcardConfig,
) {
this.url = new URL(reqCtx.request.url);
this.cookies = parse(reqCtx.request.headers.get('cookie') || '');
this.path = this.url.pathname || '/';
this.headers = { normal: new Headers(), important: new Headers() };
this.searchParams = new URLSearchParams();
applySearchParams(this.searchParams, this.url.searchParams);
this.checkPhaseCounter = 0;
this.middlewareInvoked = [];
this.wildcardMatch = wildcardConfig?.find(
w => w.domain === this.url.hostname,
);
this.locales = new Set(buildMetadata.collectedLocales);
}
/**
* Checks if a Vercel source route from the build output config matches the request.
*
* @param route Build output config source route.
* @param checkStatus Whether to check the status code of the route.
* @returns The source path match result if the route matches, otherwise `undefined`.
*/
private checkRouteMatch(
route: VercelSource,
{
checkStatus,
checkIntercept,
}: { checkStatus: boolean; checkIntercept: boolean },
): { routeMatch: MatchPCREResult; routeDest?: string } | undefined {
const srcMatch = matchPCRE(route.src, this.path, route.caseSensitive);
if (!srcMatch.match) return;
// One of the HTTP `methods` conditions must be met - skip if not met.
if (
route.methods &&
!route.methods
.map(m => m.toUpperCase())
.includes(this.reqCtx.request.method.toUpperCase())
) {
return;
}
const hasFieldProps = {
url: this.url,
cookies: this.cookies,
headers: this.reqCtx.request.headers,
routeDest: route.dest,
};
// All `has` conditions must be met - skip if one is not met.
if (
route.has?.find(has => {
const result = checkhasField(has, hasFieldProps);
if (result.newRouteDest) {
// If the `has` condition had a named capture to update the destination, update it.
hasFieldProps.routeDest = result.newRouteDest;
}
return !result.valid;
})
) {
return;
}
// All `missing` conditions must not be met - skip if one is met.
if (route.missing?.find(has => checkhasField(has, hasFieldProps).valid)) {
return;
}
// Required status code must match (i.e. for error routes) - skip if not met.
if (checkStatus && route.status !== this.status) {
return;
}
if (checkIntercept && route.dest) {
const interceptRouteRegex = /\/(\(\.+\))+/;
const destIsIntercept = interceptRouteRegex.test(route.dest);
const pathIsIntercept = interceptRouteRegex.test(this.path);
// If the new destination is an intercept route, only allow it if the current path is also
// an intercept route.
if (destIsIntercept && !pathIsIntercept) {
return;
}
}
return { routeMatch: srcMatch, routeDest: hasFieldProps.routeDest };
}
/**
* Processes the response from running a middleware function.
*
* Handles rewriting the URL and applying redirects, response headers, and overriden request headers.
*
* @param resp Middleware response object.
*/
private processMiddlewareResp(resp: Response): void {
const overrideKey = 'x-middleware-override-headers';
const overrideHeader = resp.headers.get(overrideKey);
if (overrideHeader) {
const overridenHeaderKeys = new Set(
overrideHeader.split(',').map(h => h.trim()),
);
for (const key of overridenHeaderKeys.keys()) {
const valueKey = `x-middleware-request-${key}`;
const value = resp.headers.get(valueKey);
if (this.reqCtx.request.headers.get(key) !== value) {
if (value) {
this.reqCtx.request.headers.set(key, value);
} else {
this.reqCtx.request.headers.delete(key);
}
}
resp.headers.delete(valueKey);
}
resp.headers.delete(overrideKey);
}
const rewriteKey = 'x-middleware-rewrite';
const rewriteHeader = resp.headers.get(rewriteKey);
if (rewriteHeader) {
const newUrl = new URL(rewriteHeader, this.url);
const rewriteIsExternal = this.url.hostname !== newUrl.hostname;
this.path = rewriteIsExternal ? `${newUrl}` : newUrl.pathname;
applySearchParams(this.searchParams, newUrl.searchParams);
resp.headers.delete(rewriteKey);
}
const middlewareNextKey = 'x-middleware-next';
const middlewareNextHeader = resp.headers.get(middlewareNextKey);
if (middlewareNextHeader) {
resp.headers.delete(middlewareNextKey);
} else if (!rewriteHeader && !resp.headers.has('location')) {
// We should set the final response body and status to the middleware's if it does not want
// to continue and did not rewrite/redirect the URL.
this.body = resp.body;
this.status = resp.status;
} else if (
resp.headers.has('location') &&
resp.status >= 300 &&
resp.status < 400
) {
this.status = resp.status;
}
// copy to the request object the headers that have been set by the middleware
applyHeaders(this.reqCtx.request.headers, resp.headers);
applyHeaders(this.headers.normal, resp.headers);
this.headers.middlewareLocation = resp.headers.get('location');
}
/**
* Runs the middleware function for a route if it exists.
*
* @param path Path to the route's middleware function.
* @returns Whether the middleware function was run successfully.
*/
private async runRouteMiddleware(path?: string): Promise<boolean> {
// If there is no path, return true as it did not result in an error.
if (!path) return true;
const item = path && this.output[path];
if (!item || item.type !== 'middleware') {
// The middleware function could not be found. Set the status to 500 and bail out.
this.status = 500;
return false;
}
const resp = await runOrFetchBuildOutputItem(item, this.reqCtx, {
path: this.path,
searchParams: this.searchParams,
headers: this.headers,
status: this.status,
});
this.middlewareInvoked.push(path);
if (resp.status === 500) {
// The middleware function threw an error. Set the status and bail out.
this.status = resp.status;
return false;
}
this.processMiddlewareResp(resp);
return true;
}
/**
* Resets the response status and headers if the route should override them.
*
* @param route Build output config source route.
*/
private applyRouteOverrides(route: VercelSource): void {
if (!route.override) return;
this.status = undefined;
this.headers.normal = new Headers();
this.headers.important = new Headers();
}
/**
* Applies the route's headers for the response object.
*
* @param route Build output config source route.
* @param srcMatch Matches from the PCRE matcher.
* @param captureGroupKeys Named capture group keys from the PCRE matcher.
*/
private applyRouteHeaders(
route: VercelSource,
srcMatch: RegExpMatchArray,
captureGroupKeys: string[],
): void {
if (!route.headers) return;
applyHeaders(this.headers.normal, route.headers, {
match: srcMatch,
captureGroupKeys,
});
if (route.important) {
applyHeaders(this.headers.important, route.headers, {
match: srcMatch,
captureGroupKeys,
});
}
}
/**
* Applies the route's status code for the response object.
*
* @param route Build output config source route.
*/
private applyRouteStatus(route: VercelSource): void {
if (!route.status) return;
this.status = route.status;
}
/**
* Applies the route's destination for the matching the path to the Vercel build output.
*
* Applies any wildcard matches to the destination.
*
* @param route Build output config source route.
* @param srcMatch Matches from the PCRE matcher.
* @param captureGroupKeys Named capture group keys from the PCRE matcher.
* @returns The previous path for the route before applying the destination.
*/
private applyRouteDest(
route: VercelSource,
srcMatch: RegExpMatchArray,
captureGroupKeys: string[],
): string {
if (!route.dest) return this.path;
const prevPath = this.path;
let processedDest = route.dest;
// Apply wildcard matches before PCRE matches
if (this.wildcardMatch && /\$wildcard/.test(processedDest)) {
processedDest = processedDest.replace(
/\$wildcard/g,
this.wildcardMatch.value,
);
}
this.path = applyPCREMatches(processedDest, srcMatch, captureGroupKeys);
// NOTE: Special handling for `/index` RSC routes. Sometimes the Vercel build output config
// has a record to rewrite `^/` to `/index.rsc`, however, this will hit requests to pages
// that aren't `/`. In this case, we should check that the previous path is `/`. This should
// not match requests to `/__index.prefetch.rsc` as Vercel handles those requests missing in
// later phases.
// https://github.com/vercel/vercel/blob/31daff/packages/next/src/utils.ts#L3321
const isRscIndex = /\/index\.rsc$/i.test(this.path);
const isPrevAbsoluteIndex = /^\/(?:index)?$/i.test(prevPath);
const isPrevPrefetchRscIndex = /^\/__index\.prefetch\.rsc$/i.test(prevPath);
if (isRscIndex && !isPrevAbsoluteIndex && !isPrevPrefetchRscIndex) {
this.path = prevPath;
}
// NOTE: Special handling for `.rsc` requests. If the Vercel CLI failed to generate an RSC version
// of the page and the build output config has a record mapping the request to the RSC variant, we
// should strip the `.rsc` extension from the path. We do not strip the extension if the request is
// to a `.prefetch.rsc` file as Vercel handles those requests missing in later phases.
const isRsc = /\.rsc$/i.test(this.path);
const isPrefetchRsc = /\.prefetch\.rsc$/i.test(this.path);
const pathExistsInOutput = this.path in this.output;
if (isRsc && !isPrefetchRsc && !pathExistsInOutput) {
this.path = this.path.replace(/\.rsc/i, '');
}
// Merge search params for later use when serving a response.
const destUrl = new URL(this.path, this.url);
applySearchParams(this.searchParams, destUrl.searchParams);
// If the new dest is not an URL, update the path with the path from the URL.
if (!isUrl(this.path)) this.path = destUrl.pathname;
return prevPath;
}
/**
* Applies the route's redirects for locales and internationalization.
*
* @param route Build output config source route.
*/
private applyLocaleRedirects(route: VercelSource): void {
if (!route.locale?.redirect) return;
// Automatic locale detection is only supposed to occur at the root. However, the build output
// sometimes uses `/` as the regex instead of `^/$`. So, we should check if the `route.src` is
// equal to the path if it is not a regular expression, to determine if we are at the root.
// https://nextjs.org/docs/pages/building-your-application/routing/internationalization#automatic-locale-detection
const srcIsRegex = /^\^(.)*$/.test(route.src);
if (!srcIsRegex && route.src !== this.path) return;
// If we already have a location header set, we might have found a locale redirect earlier.
if (this.headers.normal.has('location')) return;
const {
locale: { redirect: redirects, cookie: cookieName },
} = route;
const cookieValue = cookieName && this.cookies[cookieName];
const cookieLocales = parseAcceptLanguage(cookieValue ?? '');
const headerLocales = parseAcceptLanguage(
this.reqCtx.request.headers.get('accept-language') ?? '',
);
// Locales from the cookie take precedence over the header.
const locales = [...cookieLocales, ...headerLocales];
const redirectLocales = locales
.map(locale => redirects[locale])
.filter(Boolean) as string[];
const redirectValue = redirectLocales[0];
if (redirectValue) {
const needsRedirecting = !this.path.startsWith(redirectValue);
if (needsRedirecting) {
this.headers.normal.set('location', redirectValue);
this.status = 307;
}
return;
}
}
/**
* Modifies the source route's `src` regex to be friendly with previously found locale's in the
* `miss` phase.
*
* There is a source route generated for rewriting `/{locale}/*` to `/*` when no file was found
* for the path. This causes issues when using an SSR function for the index page as the request
* to `/{locale}` will not be caught by the regex. Therefore, the regex needs to be updated to
* also match requests to solely `/{locale}` when the path has no trailing slash.
*
* @param route Build output config source route.
* @param phase Current phase of the routing process.
* @returns The route with the locale friendly regex.
*/
private getLocaleFriendlyRoute(
route: VercelSource,
phase: VercelPhase,
): VercelSource {
if (!this.locales || phase !== 'miss') {
return route;
}
if (isLocaleTrailingSlashRegex(route.src, this.locales)) {
return {
...route,
src: route.src.replace(/\/\(\.\*\)\$$/, '(?:/(.*))?$'),
};
}
return route;
}
/**
* Checks a route to see if it matches the current request.
*
* @param phase Current phase of the routing process.
* @param route Build output config source route.
* @returns The status from checking the route.
*/
private async checkRoute(
phase: VercelPhase,
rawRoute: VercelSource,
): Promise<CheckRouteStatus> {
const localeFriendlyRoute = this.getLocaleFriendlyRoute(rawRoute, phase);
const { routeMatch, routeDest } =
this.checkRouteMatch(localeFriendlyRoute, {
checkStatus: phase === 'error',
// The build output config correctly maps relevant request paths to be intercepts in the
// `none` phase, while the `rewrite` phase can contain entries that rewrite to an intercept
// that matches requests that are not actually intercepts, causing a 404.
checkIntercept: phase === 'rewrite',
}) ?? {};
const route: VercelSource = { ...localeFriendlyRoute, dest: routeDest };
// If this route doesn't match, continue to the next one.
if (!routeMatch?.match) return 'skip';
// If this route is a middleware route, check if it has already been invoked.
if (
route.middlewarePath &&
this.middlewareInvoked.includes(route.middlewarePath)
) {
return 'skip';
}
const { match: srcMatch, captureGroupKeys } = routeMatch;
// If this route overrides, replace the response headers and status.
this.applyRouteOverrides(route);
// If this route has a locale, apply the redirects for it.
this.applyLocaleRedirects(route);
// Call and process the middleware if this is a middleware route.
const success = await this.runRouteMiddleware(route.middlewarePath);
if (!success) return 'error';
// If the middleware set a response body or resulted in a redirect, we are done.
if (this.body !== undefined || this.headers.middlewareLocation) {
return 'done';
}
// Update final headers with the ones from this route.
this.applyRouteHeaders(route, srcMatch, captureGroupKeys);
// Update the status code if this route has one.
this.applyRouteStatus(route);
// Update the path with the new destination.
const prevPath = this.applyRouteDest(route, srcMatch, captureGroupKeys);
// If `check` is required and the path isn't a URL, check it again.
if (route.check && !isUrl(this.path)) {
if (prevPath === this.path) {
// NOTE: If the current/rewritten path is the same as the one that entered the phase, it
// can cause an infinite loop. Therefore, we should just set the status to `404` instead
// when we are in the `miss` phase. Otherwise, we should continue to the next phase.
// This happens with invalid `/_next/static/...` and `/_next/data/...` requests.
if (phase !== 'miss') {
return this.checkPhase(getNextPhase(phase));
}
this.status = 404;
} else if (phase === 'miss') {
// When in the `miss` phase, enter `filesystem` if the file is not in the build output. This
// avoids rewrites in `none` that do the opposite of those in `miss`, and would cause infinite
// loops (e.g. i18n). If it is in the build output, remove a potentially applied `404` status.
if (
!(this.path in this.output) &&
!(this.path.replace(/\/$/, '') in this.output)
) {
return this.checkPhase('filesystem');
}
if (this.status === 404) {
this.status = undefined;
}
} else {
// In all other instances, we need to enter the `none` phase so we can ensure that requests
// for the `RSC` variant of pages are served correctly.
return this.checkPhase('none');
}
}
// If we found a match and shouldn't continue finding matches, break out of the loop.
if (!route.continue) {
return 'done';
}
// If the route is a redirect then we're actually done
const isRedirect =
route.status && route.status >= 300 && route.status <= 399;
if (isRedirect) {
return 'done';
}
return 'next';
}
/**
* Checks a phase from the routing process to see if any route matches the current request.
*
* @param phase Current phase for routing.
* @returns The status from checking the phase.
*/
private async checkPhase(phase: VercelPhase): Promise<CheckPhaseStatus> {
if (this.checkPhaseCounter++ >= 50) {
// eslint-disable-next-line no-console
console.error(
`Routing encountered an infinite loop while checking ${this.url.pathname}`,
);
this.status = 500;
return 'error';
}
// Reset the middleware invoked list as this is a new phase.
this.middlewareInvoked = [];
let shouldContinue = true;
for (const route of this.routes[phase]) {
const result = await this.checkRoute(phase, route);
if (result === 'error') {
return 'error';
}
if (result === 'done') {
shouldContinue = false;
break;
}
}
// In the `hit` phase or for external urls/redirects/middleware responses, return the match.
if (
phase === 'hit' ||
isUrl(this.path) ||
this.headers.normal.has('location') ||
!!this.body
) {
return 'done';
}
if (phase === 'none') {
// applications using the Pages router with i18n plus a catch-all root route
// redirect all requests (including /api/ ones) to the catch-all route, the only
// way to prevent this erroneous behavior is to remove the locale here if the
// path without the locale exists in the vercel build output
for (const locale of this.locales) {
const localeRegExp = new RegExp(`/${locale}(/.*)`);
const match = this.path.match(localeRegExp);
const pathWithoutLocale = match?.[1];
if (pathWithoutLocale && pathWithoutLocale in this.output) {
this.path = pathWithoutLocale;
break;
}
}
}
let pathExistsInOutput = this.path in this.output;
// paths could incorrectly not be detected as existing in the output due to the `trailingSlash` setting
// in `next.config.mjs`, so let's check for that here and update the path in such case
if (!pathExistsInOutput && this.path.endsWith('/')) {
const newPath = this.path.replace(/\/$/, '');
pathExistsInOutput = newPath in this.output;
if (pathExistsInOutput) {
this.path = newPath;
}
}
// In the `miss` phase, set status to 404 if no path was found and it isn't an error code.
if (phase === 'miss' && !pathExistsInOutput) {
const should404 = !this.status || this.status < 400;
this.status = should404 ? 404 : this.status;
}
let nextPhase: VercelHandleValue = 'miss';
if (pathExistsInOutput || phase === 'miss' || phase === 'error') {
// If the route exists, enter the `hit` phase. For `miss` and `error` phases, enter the `hit`
// phase to update headers (e.g. `x-matched-path`).
nextPhase = 'hit';
} else if (shouldContinue) {
nextPhase = getNextPhase(phase);
}
return this.checkPhase(nextPhase);
}
/**
* Runs the matcher for a phase.
*
* @param phase The phase to start matching routes from.
* @returns The status from checking for matches.
*/
public async run(
phase: Extract<VercelPhase, 'none' | 'error'> = 'none',
): Promise<CheckPhaseStatus> {
// Reset the counter for each run.
this.checkPhaseCounter = 0;
const result = await this.checkPhase(phase);
// Update status to redirect user to external URL.
if (
this.headers.normal.has('location') &&
(!this.status || this.status < 300 || this.status >= 400)
) {
this.status = 307;
}
return result;
}
}