Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle Next.js rewrites/redirects/headers #5212

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
b089f20
add RE2
leoortizz Nov 4, 2022
c5bf204
filter out rewrites not supported by RE2
leoortizz Nov 4, 2022
c58f6c7
Merge branch 'master' into leoortizz_nextjs_rewrites
leoortizz Nov 4, 2022
9998dd8
filter out rewrites to third parties
leoortizz Nov 4, 2022
28495f1
check rewrites, redirects and headers w/ same fn
leoortizz Nov 7, 2022
279012e
isThirdPartyUrl > isUrl, .includes > .startsWith
leoortizz Nov 7, 2022
81e636f
Merge branch 'master' into leoortizz_nextjs_rewrites
leoortizz Nov 9, 2022
0352221
remove RE2, detect regex as stated in Next.js docs
leoortizz Nov 15, 2022
00f3fb1
add unit tests for Next.js utils
leoortizz Nov 15, 2022
54611e9
add unit tests for frameworks/utils
leoortizz Nov 15, 2022
0760593
Merge branch 'master' into leoortizz_nextjs_rewrites
leoortizz Nov 15, 2022
cc1d080
`replaceAll` > `replace`
leoortizz Nov 15, 2022
7f9a58e
header/redirects/rewrites checks as reusable utils
leoortizz Nov 15, 2022
f76c76a
Merge branch 'master' into leoortizz_nextjs_rewrites
leoortizz Nov 16, 2022
dc5fdcc
replace for loop with regex
leoortizz Nov 16, 2022
33b602e
`.exec` > `.test`
leoortizz Nov 16, 2022
170d04d
`has` should also require backend
leoortizz Nov 16, 2022
72771d0
reduce `pathHasRegex` verbosity
leoortizz Nov 16, 2022
cd62b47
fix `isUrl`, more test cases
leoortizz Nov 16, 2022
b1bc7da
Merge branch 'master' into leoortizz_nextjs_rewrites
leoortizz Nov 17, 2022
6c079ab
require backend if rewrites afterFiles or fallback
leoortizz Nov 17, 2022
750f2fa
filter prerendered routes that matches custom ...
leoortizz Nov 17, 2022
9bbe63b
Merge branch 'master' into leoortizz_nextjs_rewrites
leoortizz Nov 18, 2022
13462a5
remove TODO
leoortizz Nov 18, 2022
7bc1339
`rewrite.has` > `"has" in rewrite`
leoortizz Nov 18, 2022
b07c6c3
move Next.js types to interfaces.ts
leoortizz Nov 21, 2022
d1abbbb
filter out redirects with `has`
leoortizz Nov 21, 2022
9c6aac3
filter out headers with `has`
leoortizz Nov 21, 2022
7437cb0
add paths for tests, adjust pathHasRegex tests
leoortizz Nov 21, 2022
e31f094
tests for supported rewrites
leoortizz Nov 21, 2022
d649f02
tests for supported redirects
leoortizz Nov 21, 2022
2d9fee1
tests for supported headers
leoortizz Nov 21, 2022
1ab51c0
tests for `getNextjsRewritesToUse`
leoortizz Nov 21, 2022
71226bb
temporarily disable `cleanEscapedChars` tests
leoortizz Nov 21, 2022
c3915d9
Merge branch 'master' into leoortizz_nextjs_rewrites
leoortizz Nov 21, 2022
7895769
comment out unused import
leoortizz Nov 21, 2022
9923860
replace types with interfaces
leoortizz Nov 28, 2022
9257980
Merge branch 'master' into leoortizz_nextjs_rewrites
leoortizz Dec 2, 2022
f0533e6
changelog
leoortizz Dec 2, 2022
75ba873
remove side effects from custom routes filters
leoortizz Dec 5, 2022
9311974
refactor `getNextjsRewritesToUse`
leoortizz Dec 5, 2022
6984b5f
`=== false` > `!`
leoortizz Dec 5, 2022
49a958b
destructure with defaults, reduce ?, reduce ifs
leoortizz Dec 5, 2022
c2560bc
simplify isSupported... utils
leoortizz Dec 5, 2022
ba9fec9
simplify custom routes filters
leoortizz Dec 5, 2022
3a91834
instantiate regexes before loop
leoortizz Dec 5, 2022
db8c398
replace some with every, check negation
leoortizz Dec 5, 2022
8f482ca
Merge branch 'master' into leoortizz_nextjs_rewrites
leoortizz Dec 5, 2022
9a230e3
improve Next.js utils documentation
leoortizz Dec 5, 2022
a0f4c05
readJSON util with type
leoortizz Dec 5, 2022
425fa5d
replace readFile/JSON.parse with typed readJSON
leoortizz Dec 5, 2022
0369402
Merge branch 'master' into leoortizz_nextjs_rewrites
leoortizz Dec 5, 2022
d49b302
Merge branch 'master' into leoortizz_nextjs_rewrites
leoortizz Dec 6, 2022
f3b4a44
fix cleanEscapedChars, improve function doc
leoortizz Dec 8, 2022
c2a9c4a
fix `cleanEscapedChars` test
leoortizz Dec 8, 2022
fa809ef
replace as Manifest with typed readJSON
leoortizz Dec 8, 2022
44e0148
typed readJSON defaulting to `any`
leoortizz Dec 8, 2022
4b505c5
Merge branch 'master' into leoortizz_nextjs_rewrites
leoortizz Dec 8, 2022
ee7d975
Merge master
jamesdaniels Dec 9, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
@@ -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
@@ -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
@@ -0,0 +1,31 @@
import type { Header, Rewrite, Redirect } from "next/dist/lib/load-custom-routes";

Comment on lines +1 to +2
Copy link
Member Author

Choose a reason for hiding this comment

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

Unfortunately Redirect is not extendable because its members are not statically known, so this is not possible:

export interface RoutesManifestRedirect extends Redirect {
  regex: string;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

:(

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
@@ -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
@@ -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