Skip to content

Commit

Permalink
Handle Next.js rewrites/redirects/headers (#5212)
Browse files Browse the repository at this point in the history
* Handle Next.js rewrites/redirects/headers incompatible with `firebase.json` in Cloud Functions
* Filter out Next.js prerendered routes that matches rewrites/redirects/headers rules from SSG content directory
  • Loading branch information
jamesdaniels authored Dec 9, 2022
1 parent d7f0186 commit cbe5790
Show file tree
Hide file tree
Showing 12 changed files with 1,020 additions and 35 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
- Add support for Firestore TTL (#5267)
- Fix bug where secrets were not loaded when emulating functions with `--inpsect-functions`. (#4605)
- Handle Next.js rewrites/redirects/headers incompatible with `firebase.json` in Cloud Functions (#5212)
- Filter out Next.js prerendered routes that matches rewrites/redirects/headers rules from SSG content directory (#5212)
- Warn if a web framework's package.json contains anything other than the framework default build command.
141 changes: 107 additions & 34 deletions src/frameworks/next/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { execSync } from "child_process";
import { readFile, mkdir, copyFile } from "fs/promises";
import { dirname, join } from "path";
import type { Header, Rewrite, Redirect } from "next/dist/lib/load-custom-routes";
import type { NextConfig } from "next";
import { copy, mkdirp, pathExists } from "fs-extra";
import { pathToFileURL, parse } from "url";
Expand All @@ -22,24 +21,17 @@ import { IncomingMessage, ServerResponse } from "http";
import { logger } from "../../logger";
import { FirebaseError } from "../../error";
import { fileExistsSync } from "../../fsutils";
import {
cleanEscapedChars,
getNextjsRewritesToUse,
isHeaderSupportedByFirebase,
isRedirectSupportedByFirebase,
isRewriteSupportedByFirebase,
} from "./utils";
import type { Manifest } from "./interfaces";
import { readJSON } from "../utils";
import { warnIfCustomBuildScript } from "../utils";

// Next.js's exposed interface is incomplete here
// TODO see if there's a better way to grab this
interface Manifest {
distDir?: string;
basePath?: string;
headers?: (Header & { regex: string })[];
redirects?: (Redirect & { regex: string })[];
rewrites?:
| (Rewrite & { regex: string })[]
| {
beforeFiles?: (Rewrite & { regex: string })[];
afterFiles?: (Rewrite & { regex: string })[];
fallback?: (Rewrite & { regex: string })[];
};
}

const CLI_COMMAND = join(
"node_modules",
".bin",
Expand Down Expand Up @@ -140,27 +132,56 @@ export async function build(dir: string): Promise<BuildResult> {
}
}

const manifestBuffer = await readFile(join(dir, distDir, "routes-manifest.json"));
const manifest: Manifest = JSON.parse(manifestBuffer.toString());
const manifest = await readJSON<Manifest>(join(dir, distDir, "routes-manifest.json"));

const {
headers: nextJsHeaders = [],
redirects: nextJsRedirects = [],
rewrites: nextJsRewrites = [],
} = manifest;
const headers = nextJsHeaders.map(({ source, headers }) => ({ source, headers }));

const isEveryHeaderSupported = nextJsHeaders.every(isHeaderSupportedByFirebase);
if (!isEveryHeaderSupported) wantsBackend = true;

const headers = nextJsHeaders.filter(isHeaderSupportedByFirebase).map(({ source, headers }) => ({
// clean up unnecessary escaping
source: cleanEscapedChars(source),
headers,
}));

const isEveryRedirectSupported = nextJsRedirects.every(isRedirectSupportedByFirebase);
if (!isEveryRedirectSupported) wantsBackend = true;

const redirects = nextJsRedirects
.filter(({ internal }: any) => !internal)
.map(({ source, destination, statusCode: type }) => ({ source, destination, type }));
const nextJsRewritesToUse = Array.isArray(nextJsRewrites)
? nextJsRewrites
: nextJsRewrites.beforeFiles || [];
.filter(isRedirectSupportedByFirebase)
.map(({ source, destination, statusCode: type }) => ({
// clean up unnecessary escaping
source: cleanEscapedChars(source),
destination,
type,
}));

const nextJsRewritesToUse = getNextjsRewritesToUse(nextJsRewrites);

// rewrites.afterFiles / rewrites.fallback are not supported by firebase.json
if (
!Array.isArray(nextJsRewrites) &&
(nextJsRewrites.afterFiles?.length || nextJsRewrites.fallback?.length)
) {
wantsBackend = true;
} else {
const isEveryRewriteSupported = nextJsRewritesToUse.every(isRewriteSupportedByFirebase);
if (!isEveryRewriteSupported) wantsBackend = true;
}

// Can we change i18n into Firebase settings?
const rewrites = nextJsRewritesToUse
.map(({ source, destination, has }) => {
// Can we change i18n into Firebase settings?
if (has) return undefined;
return { source, destination };
})
.filter((it) => it);
.filter(isRewriteSupportedByFirebase)
.map(({ source, destination }) => ({
// clean up unnecessary escaping
source: cleanEscapedChars(source),
destination,
}));

return { wantsBackend, headers, redirects, rewrites };
}
Expand Down Expand Up @@ -215,10 +236,47 @@ export async function ɵcodegenPublicDirectory(sourceDir: string, destDir: strin
}
}

const prerenderManifestBuffer = await readFile(
join(sourceDir, distDir, "prerender-manifest.json")
const [prerenderManifest, routesManifest] = await Promise.all([
readJSON(
join(
sourceDir,
distDir,
"prerender-manifest.json" // TODO: get this from next/constants
)
),
readJSON<Manifest>(
join(
sourceDir,
distDir,
"routes-manifest.json" // TODO: get this from next/constants
)
),
]);

const { redirects = [], rewrites = [], headers = [] } = routesManifest;

const rewritesToUse = getNextjsRewritesToUse(rewrites);
const rewritesNotSupportedByFirebase = rewritesToUse.filter(
(rewrite) => !isRewriteSupportedByFirebase(rewrite)
);
const rewritesRegexesNotSupportedByFirebase = rewritesNotSupportedByFirebase.map(
(rewrite) => new RegExp(rewrite.regex)
);

const redirectsNotSupportedByFirebase = redirects.filter(
(redirect) => !isRedirectSupportedByFirebase(redirect)
);
const redirectsRegexesNotSupportedByFirebase = redirectsNotSupportedByFirebase.map(
(redirect) => new RegExp(redirect.regex)
);

const headersNotSupportedByFirebase = headers.filter(
(header) => !isHeaderSupportedByFirebase(header)
);
const prerenderManifest = JSON.parse(prerenderManifestBuffer.toString());
const headersRegexesNotSupportedByFirebase = headersNotSupportedByFirebase.map(
(header) => new RegExp(header.regex)
);

for (const path in prerenderManifest.routes) {
if (prerenderManifest.routes[path]) {
// Skip ISR in the deploy to hosting
Expand All @@ -227,6 +285,21 @@ export async function ɵcodegenPublicDirectory(sourceDir: string, destDir: strin
continue;
}

const routeMatchUnsupportedRewrite = rewritesRegexesNotSupportedByFirebase.some(
(rewriteRegex) => rewriteRegex.test(path)
);
if (routeMatchUnsupportedRewrite) continue;

const routeMatchUnsupportedRedirect = redirectsRegexesNotSupportedByFirebase.some(
(redirectRegex) => redirectRegex.test(path)
);
if (routeMatchUnsupportedRedirect) continue;

const routeMatchUnsupportedHeader = headersRegexesNotSupportedByFirebase.some(
(headerRegex) => headerRegex.test(path)
);
if (routeMatchUnsupportedHeader) continue;

// TODO(jamesdaniels) explore oppertunity to simplify this now that we
// are defaulting cleanURLs to true for frameworks

Expand Down
31 changes: 31 additions & 0 deletions src/frameworks/next/interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Header, Rewrite, Redirect } from "next/dist/lib/load-custom-routes";

export interface RoutesManifestRewrite extends Rewrite {
regex: string;
}

export interface RoutesManifestRewriteObject {
beforeFiles?: RoutesManifestRewrite[];
afterFiles?: RoutesManifestRewrite[];
fallback?: RoutesManifestRewrite[];
}

export interface RoutesManifestHeader extends Header {
regex: string;
}

// Next.js's exposed interface is incomplete here
// TODO see if there's a better way to grab this
// TODO: rename to RoutesManifest as Next.js has other types of manifests
export interface Manifest {
distDir?: string;
basePath?: string;
headers?: RoutesManifestHeader[];
redirects?: Array<
Redirect & {
regex: string;
internal?: boolean;
}
>;
rewrites?: RoutesManifestRewrite[] | RoutesManifestRewriteObject;
}
114 changes: 114 additions & 0 deletions src/frameworks/next/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { Header, Redirect, Rewrite } from "next/dist/lib/load-custom-routes";
import type { Manifest, RoutesManifestRewrite } from "./interfaces";
import { isUrl } from "../utils";

/**
* Whether the given path has a regex or not.
* According to the Next.js documentation:
* ```md
* To match a regex path you can wrap the regex in parentheses
* after a parameter, for example /post/:slug(\\d{1,}) will match /post/123
* but not /post/abc.
* ```
* See: https://nextjs.org/docs/api-reference/next.config.js/redirects#regex-path-matching
*/
export function pathHasRegex(path: string): boolean {
// finds parentheses that are not preceded by double backslashes
return /(?<!\\)\(/.test(path);
}

/**
* Remove escaping from characters used for Regex patch matching that Next.js
* requires. As Firebase Hosting does not require escaping for those charachters,
* we remove them.
*
* According to the Next.js documentation:
* ```md
* The following characters (, ), {, }, :, *, +, ? are used for regex path
* matching, so when used in the source as non-special values they must be
* escaped by adding \\ before them.
* ```
*
* See: https://nextjs.org/docs/api-reference/next.config.js/rewrites#regex-path-matching
*/
export function cleanEscapedChars(path: string): string {
return path.replace(/\\([(){}:+?*])/g, (a, b: string) => b);
}

/**
* Whether a Next.js rewrite is supported by `firebase.json`.
*
* See: https://firebase.google.com/docs/hosting/full-config#rewrites
*
* Next.js unsupported rewrites includes:
* - Rewrites with the `has` property that is used by Next.js for Header,
* Cookie, and Query Matching.
* - https://nextjs.org/docs/api-reference/next.config.js/rewrites#header-cookie-and-query-matching
*
* - Rewrites using regex for path matching.
* - https://nextjs.org/docs/api-reference/next.config.js/rewrites#regex-path-matching
*
* - Rewrites to external URLs
*/
export function isRewriteSupportedByFirebase(rewrite: Rewrite): boolean {
return !("has" in rewrite || pathHasRegex(rewrite.source) || isUrl(rewrite.destination));
}

/**
* Whether a Next.js redirect is supported by `firebase.json`.
*
* See: https://firebase.google.com/docs/hosting/full-config#redirects
*
* Next.js unsupported redirects includes:
* - Redirects with the `has` property that is used by Next.js for Header,
* Cookie, and Query Matching.
* - https://nextjs.org/docs/api-reference/next.config.js/redirects#header-cookie-and-query-matching
*
* - Redirects using regex for path matching.
* - https://nextjs.org/docs/api-reference/next.config.js/redirects#regex-path-matching
*
* - Next.js internal redirects
*/
export function isRedirectSupportedByFirebase(redirect: Redirect): boolean {
return !("has" in redirect || pathHasRegex(redirect.source) || "internal" in redirect);
}

/**
* Whether a Next.js custom header is supported by `firebase.json`.
*
* See: https://firebase.google.com/docs/hosting/full-config#headers
*
* Next.js unsupported headers includes:
* - Custom header with the `has` property that is used by Next.js for Header,
* Cookie, and Query Matching.
* - https://nextjs.org/docs/api-reference/next.config.js/headers#header-cookie-and-query-matching
*
* - Custom header using regex for path matching.
* - https://nextjs.org/docs/api-reference/next.config.js/headers#regex-path-matching
*/
export function isHeaderSupportedByFirebase(header: Header): boolean {
return !("has" in header || pathHasRegex(header.source));
}

/**
* Get which Next.js rewrites will be used before checking supported items individually.
*
* Next.js rewrites can be arrays or objects:
* - For arrays, all supported items can be used.
* - For objects only `beforeFiles` can be used.
*
* See: https://nextjs.org/docs/api-reference/next.config.js/rewrites
*/
export function getNextjsRewritesToUse(
nextJsRewrites: Manifest["rewrites"]
): RoutesManifestRewrite[] {
if (Array.isArray(nextJsRewrites)) {
return nextJsRewrites;
}

if (nextJsRewrites?.beforeFiles) {
return nextJsRewrites.beforeFiles;
}

return [];
}
21 changes: 20 additions & 1 deletion src/frameworks/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
import { readFile } from "fs/promises";
import { readJSON as originalReadJSON } from "fs-extra";
import type { ReadOptions } from "fs-extra";
import { join } from "path";
import { readFile } from "fs/promises";

/**
* Whether the given string starts with http:// or https://
*/
export function isUrl(url: string): boolean {
return /^https?:\/\//.test(url);
}

/**
* add type to readJSON
*/
export function readJSON<JsonType = any>(
file: string,
options?: ReadOptions | BufferEncoding | string
): Promise<JsonType> {
return originalReadJSON(file, options) as Promise<JsonType>;
}

/**
* Prints a warning if the build script in package.json
Expand Down
Loading

0 comments on commit cbe5790

Please sign in to comment.