diff --git a/CHANGELOG.md b/CHANGELOG.md index 52890686b4b..6149438f5a5 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/src/frameworks/next/index.ts b/src/frameworks/next/index.ts index 884bdbb5cc9..6a71d5dbfe0 100644 --- a/src/frameworks/next/index.ts +++ b/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"; @@ -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", @@ -140,27 +132,56 @@ export async function build(dir: string): Promise { } } - const manifestBuffer = await readFile(join(dir, distDir, "routes-manifest.json")); - const manifest: Manifest = JSON.parse(manifestBuffer.toString()); + const manifest = await readJSON(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 }; } @@ -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( + 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 @@ -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 diff --git a/src/frameworks/next/interfaces.ts b/src/frameworks/next/interfaces.ts new file mode 100644 index 00000000000..e012402b9fb --- /dev/null +++ b/src/frameworks/next/interfaces.ts @@ -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; +} diff --git a/src/frameworks/next/utils.ts b/src/frameworks/next/utils.ts new file mode 100644 index 00000000000..040f06adedb --- /dev/null +++ b/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 /(? 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 []; +} diff --git a/src/frameworks/utils.ts b/src/frameworks/utils.ts index d45174a03b1..2024df5e1b6 100644 --- a/src/frameworks/utils.ts +++ b/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( + file: string, + options?: ReadOptions | BufferEncoding | string +): Promise { + return originalReadJSON(file, options) as Promise; +} /** * Prints a warning if the build script in package.json diff --git a/src/test/frameworks/next/helpers/headers.ts b/src/test/frameworks/next/helpers/headers.ts new file mode 100644 index 00000000000..8f8bb447006 --- /dev/null +++ b/src/test/frameworks/next/helpers/headers.ts @@ -0,0 +1,292 @@ +import type { RoutesManifestHeader } from "../../../../frameworks/next/interfaces"; +import { supportedPaths, unsupportedPaths } from "./paths"; + +export const supportedHeaders: RoutesManifestHeader[] = [ + ...supportedPaths.map((path) => ({ + source: path, + regex: "", + headers: [ + { + key: "x-path", + value: ":path", + }, + { + key: "some:path", + value: "hi", + }, + { + key: "x-test", + value: "some:value*", + }, + { + key: "x-test-2", + value: "value*", + }, + { + key: "x-test-3", + value: ":value?", + }, + { + key: "x-test-4", + value: ":value+", + }, + { + key: "x-test-5", + value: "something https:", + }, + { + key: "x-test-6", + value: ":hello(world)", + }, + { + key: "x-test-7", + value: "hello(world)", + }, + { + key: "x-test-8", + value: "hello{1,}", + }, + { + key: "x-test-9", + value: ":hello{1,2}", + }, + { + key: "content-security-policy", + value: + "default-src 'self'; img-src *; media-src media1.com media2.com; script-src userscripts.example.com/:path", + }, + ], + })), + { + regex: "", + source: "/add-header", + headers: [ + { + key: "x-custom-header", + value: "hello world", + }, + { + key: "x-another-header", + value: "hello again", + }, + ], + }, + { + regex: "", + source: "/my-other-header/:path", + headers: [ + { + key: "x-path", + value: ":path", + }, + { + key: "some:path", + value: "hi", + }, + { + key: "x-test", + value: "some:value*", + }, + { + key: "x-test-2", + value: "value*", + }, + { + key: "x-test-3", + value: ":value?", + }, + { + key: "x-test-4", + value: ":value+", + }, + { + key: "x-test-5", + value: "something https:", + }, + { + key: "x-test-6", + value: ":hello(world)", + }, + { + key: "x-test-7", + value: "hello(world)", + }, + { + key: "x-test-8", + value: "hello{1,}", + }, + { + key: "x-test-9", + value: ":hello{1,2}", + }, + { + key: "content-security-policy", + value: + "default-src 'self'; img-src *; media-src media1.com media2.com; script-src userscripts.example.com/:path", + }, + ], + }, + { + regex: "", + source: "/without-params/url", + headers: [ + { + key: "x-origin", + value: "https://example.com", + }, + ], + }, + { + regex: "", + source: "/with-params/url/:path*", + headers: [ + { + key: "x-url", + value: "https://example.com/:path*", + }, + ], + }, + { + regex: "", + source: "/with-params/url2/:path*", + headers: [ + { + key: "x-url", + value: "https://example.com:8080?hello=:path*", + }, + ], + }, + { + regex: "", + source: "/:path*", + headers: [ + { + key: "x-something", + value: "applied-everywhere", + }, + ], + }, + { + regex: "", + source: "/catchall-header/:path*", + headers: [ + { + key: "x-value", + value: ":path*", + }, + ], + }, +]; + +export const unsupportedHeaders: RoutesManifestHeader[] = [ + ...unsupportedPaths.map((path) => ({ + source: path, + regex: "", + headers: [ + { + key: "x-custom-header", + value: "hello world", + }, + { + key: "x-another-header", + value: "hello again", + }, + ], + })), + { + regex: "", + source: "/named-pattern/:path(.*)", + headers: [ + { + key: "x-something", + value: "value=:path", + }, + { + key: "path-:path", + value: "end", + }, + ], + }, + + { + regex: "", + source: "/my-headers/(.*)", + headers: [ + { + key: "x-first-header", + value: "first", + }, + { + key: "x-second-header", + value: "second", + }, + ], + }, + + { + regex: "", + source: "/has-header-1", + has: [ + { + type: "header", + key: "x-my-header", + value: "(?.*)", + }, + ], + headers: [ + { + key: "x-another", + value: "header", + }, + ], + }, + { + regex: "", + source: "/has-header-2", + has: [ + { + type: "query", + key: "my-query", + }, + ], + headers: [ + { + key: "x-added", + value: "value", + }, + ], + }, + { + regex: "", + source: "/has-header-3", + has: [ + { + type: "cookie", + key: "loggedIn", + value: "true", + }, + ], + headers: [ + { + key: "x-is-user", + value: "yuuuup", + }, + ], + }, + { + regex: "", + source: "/has-header-4", + has: [ + { + type: "host", + value: "example.com", + }, + ], + headers: [ + { + key: "x-is-host", + value: "yuuuup", + }, + ], + }, +]; diff --git a/src/test/frameworks/next/helpers/index.ts b/src/test/frameworks/next/helpers/index.ts new file mode 100644 index 00000000000..83772e4ea98 --- /dev/null +++ b/src/test/frameworks/next/helpers/index.ts @@ -0,0 +1,4 @@ +export * from "./paths"; +export * from "./headers"; +export * from "./redirects"; +export * from "./rewrites"; diff --git a/src/test/frameworks/next/helpers/paths.ts b/src/test/frameworks/next/helpers/paths.ts new file mode 100644 index 00000000000..2b5c3e3f881 --- /dev/null +++ b/src/test/frameworks/next/helpers/paths.ts @@ -0,0 +1,90 @@ +export const pathsWithRegex = [ + "/(.*)", + "/post/:slug(\\d{1,})", + "/:path((?!another-page$).*)", + "/api-hello-regex/:first(.*)", + "/unnamed-params/nested/(.*)/:test/(.*)", +] as const; + +export const pathsWithEscapedChars = [ + `/post\\(someStringBetweenParentheses\\)/:slug`, + `/english\\(default\\)/:slug`, + `/post/\\(es\\?cap\\Wed\\*p\\{ar\\}en\\:th\\eses\\)`, +] as const; + +export const pathsWithRegexAndEscapedChars = [ + `/post/\\(escapedparentheses\\)/:slug(\\d{1,})`, + `/post/\\(es\\?cap\\Wed\\*p\\{ar\\}en\\:th\\eses\\)/:slug(\\d{1,})`, +] as const; + +export const pathsAsGlobs = [ + "/specific/:path*", + "/another/:path*", + "/about", + "/", + "/old-blog/:path*", + "/blog/:path*", + "/to-websocket", + "/to-nowhere", + "/rewriting-to-auto-export", + "/rewriting-to-another-auto-export/:path*", + "/to-another", + "/another/one", + "/nav", + "/404", + "/hello-world", + "/static/hello.txt", + "/another", + "/multi-rewrites", + "/first", + "/hello", + "/second", + "/hello-again", + "/to-hello", + "/hello", + "/blog/post-1", + "/blog/post-2", + "/test/:path", + "/:path", + "/test-overwrite/:something/:another", + "/params/this-should-be-the-value", + "/params/:something", + "/with-params", + "/query-rewrite/:section/:name", + "/hidden/_next/:path*", + "/_next/:path*", + "/proxy-me/:path*", + "/api-hello", + "/api/hello", + "/api/hello?name=:first*", + "/api-hello-param/:name", + "/api/hello?hello=:name", + "/api-dynamic-param/:name", + "/api/dynamic/:name?hello=:name", + "/:path/post-321", + "/with-params", + "/with-params", + "/catchall-rewrite/:path*", + "/with-params", + "/catchall-query/:path*", + "/has-rewrite-1", + "/has-rewrite-2", + "/has-rewrite-3", + "/has-rewrite-4", + "/has-rewrite-5", + "/:hasParam", + "/has-rewrite-6", + "/with-params", + "/has-rewrite-7", + "/has-rewrite-8", + "/blog-catchall/:post", + "/missing-rewrite-1", + "/with-params", + "/missing-rewrite-2", + "/with-params", + "/missing-rewrite-3", + "/overridden/:path*", +] as const; + +export const supportedPaths = [...pathsWithEscapedChars, ...pathsAsGlobs] as const; +export const unsupportedPaths = [...pathsWithRegex, ...pathsWithRegexAndEscapedChars] as const; diff --git a/src/test/frameworks/next/helpers/redirects.ts b/src/test/frameworks/next/helpers/redirects.ts new file mode 100644 index 00000000000..d67394951f2 --- /dev/null +++ b/src/test/frameworks/next/helpers/redirects.ts @@ -0,0 +1,107 @@ +import type { Manifest } from "../../../../frameworks/next/interfaces"; +import { supportedPaths, unsupportedPaths } from "./paths"; + +export const supportedRedirects: NonNullable = supportedPaths.map( + (path) => ({ + source: path, + destination: `${path}/redirect`, + regex: "", + statusCode: 301, + }) +); + +export const unsupportedRedirects: NonNullable = [ + ...unsupportedPaths.map((path) => ({ + source: path, + destination: `/${path}/redirect`, + regex: "", + statusCode: 301, + })), + { + source: "/has-redirect-1", + has: [ + { + type: "header", + key: "x-my-header", + value: "(?.*)", + }, + ], + destination: "/another?myHeader=:myHeader", + permanent: false, + regex: "", + }, + { + source: "/has-redirect-2", + has: [ + { + type: "query", + key: "my-query", + }, + ], + destination: "/another?value=:myquery", + permanent: false, + regex: "", + }, + { + source: "/has-redirect-3", + has: [ + { + type: "cookie", + key: "loggedIn", + value: "true", + }, + ], + destination: "/another?authorized=1", + permanent: false, + regex: "", + }, + { + source: "/has-redirect-4", + has: [ + { + type: "host", + value: "example.com", + }, + ], + destination: "/another?host=1", + permanent: false, + regex: "", + }, + { + source: "/:path/has-redirect-5", + has: [ + { + type: "header", + key: "x-test-next", + }, + ], + destination: "/somewhere", + permanent: false, + regex: "", + }, + { + source: "/has-redirect-6", + has: [ + { + type: "host", + value: "(?.*)-test.example.com", + }, + ], + destination: "https://:subdomain.example.com/some-path/end?a=b", + permanent: false, + regex: "", + }, + { + source: "/has-redirect-7", + has: [ + { + type: "query", + key: "hello", + value: "(?.*)", + }, + ], + destination: "/somewhere?value=:hello", + permanent: false, + regex: "", + }, +]; diff --git a/src/test/frameworks/next/helpers/rewrites.ts b/src/test/frameworks/next/helpers/rewrites.ts new file mode 100644 index 00000000000..cc55dde9669 --- /dev/null +++ b/src/test/frameworks/next/helpers/rewrites.ts @@ -0,0 +1,85 @@ +import type { + RoutesManifestRewrite, + RoutesManifestRewriteObject, +} from "../../../../frameworks/next/interfaces"; +import { supportedPaths, unsupportedPaths } from "./paths"; + +export const supportedRewritesArray: RoutesManifestRewrite[] = supportedPaths.map((path) => ({ + source: path, + destination: `${path}/rewrite`, + regex: "", +})); + +export const unsupportedRewritesArray: RoutesManifestRewrite[] = [ + ...unsupportedPaths.map((path) => ({ + source: path, + destination: `/${path}/rewrite`, + regex: "", + })), + // external http URL + { + source: "/:path*", + destination: "http://firebase.google.com", + regex: "", + }, + // external https URL + { + source: "/:path*", + destination: "https://firebase.google.com", + regex: "", + }, + // with has + { + source: "/specific/:path*", + destination: "/some/specific/:path", + regex: "", + has: [ + { type: "query", key: "overrideMe" }, + { + type: "header", + key: "x-rewrite-me", + }, + ], + }, + // with has + { + source: "/specific/:path*", + destination: "/some/specific/:path", + regex: "", + has: [ + { + type: "query", + key: "page", + // the page value will not be available in the + // destination since value is provided and doesn't + // use a named capture group e.g. (?home) + value: "home", + }, + ], + }, + // with has + { + source: "/specific/:path*", + destination: "/some/specific/:path", + regex: "", + has: [ + { + type: "cookie", + key: "authorized", + value: "true", + }, + ], + }, +]; + +export const supportedRewritesObject: RoutesManifestRewriteObject = { + afterFiles: unsupportedRewritesArray, // should be ignored, only beforeFiles is used + beforeFiles: supportedRewritesArray, + fallback: unsupportedRewritesArray, // should be ignored, only beforeFiles is used +}; + +export const unsupportedRewritesObject: RoutesManifestRewriteObject = { + afterFiles: unsupportedRewritesArray, // should be ignored, only beforeFiles is used + beforeFiles: unsupportedRewritesArray, + fallback: unsupportedRewritesArray, // should be ignored, only beforeFiles is used +}; diff --git a/src/test/frameworks/next/utils.spec.ts b/src/test/frameworks/next/utils.spec.ts new file mode 100644 index 00000000000..06c0e995b25 --- /dev/null +++ b/src/test/frameworks/next/utils.spec.ts @@ -0,0 +1,145 @@ +import { expect } from "chai"; + +import { + pathHasRegex, + cleanEscapedChars, + isRewriteSupportedByFirebase, + isRedirectSupportedByFirebase, + isHeaderSupportedByFirebase, + getNextjsRewritesToUse, +} from "../../../frameworks/next/utils"; +import { + pathsAsGlobs, + pathsWithEscapedChars, + pathsWithRegex, + pathsWithRegexAndEscapedChars, + supportedHeaders, + supportedRedirects, + supportedRewritesArray, + supportedRewritesObject, + unsupportedHeaders, + unsupportedRedirects, + unsupportedRewritesArray, +} from "./helpers"; + +describe("Next.js utils", () => { + describe("pathHasRegex", () => { + it("should identify regex", () => { + for (const path of pathsWithRegex) { + expect(pathHasRegex(path)).to.be.true; + } + }); + + it("should not identify escaped parentheses as regex", () => { + for (const path of pathsWithEscapedChars) { + expect(pathHasRegex(path)).to.be.false; + } + }); + + it("should identify regex along with escaped chars", () => { + for (const path of pathsWithRegexAndEscapedChars) { + expect(pathHasRegex(path)).to.be.true; + } + }); + + it("should not identify globs as regex", () => { + for (const path of pathsAsGlobs) { + expect(pathHasRegex(path)).to.be.false; + } + }); + }); + + describe("cleanEscapedChars", () => { + it("should clean escaped chars", () => { + // path containing all escaped chars + const testPath = "/\\(\\)\\{\\}\\:\\+\\?\\*/:slug"; + + expect(testPath.includes("\\(")).to.be.true; + expect(cleanEscapedChars(testPath).includes("\\(")).to.be.false; + + expect(testPath.includes("\\)")).to.be.true; + expect(cleanEscapedChars(testPath).includes("\\)")).to.be.false; + + expect(testPath.includes("\\{")).to.be.true; + expect(cleanEscapedChars(testPath).includes("\\{")).to.be.false; + + expect(testPath.includes("\\}")).to.be.true; + expect(cleanEscapedChars(testPath).includes("\\}")).to.be.false; + + expect(testPath.includes("\\:")).to.be.true; + expect(cleanEscapedChars(testPath).includes("\\:")).to.be.false; + + expect(testPath.includes("\\+")).to.be.true; + expect(cleanEscapedChars(testPath).includes("\\+")).to.be.false; + + expect(testPath.includes("\\?")).to.be.true; + expect(cleanEscapedChars(testPath).includes("\\?")).to.be.false; + + expect(testPath.includes("\\*")).to.be.true; + expect(cleanEscapedChars(testPath).includes("\\*")).to.be.false; + }); + }); + + describe("isRewriteSupportedByFirebase", () => { + it("should allow supported rewrites", () => { + for (const rewrite of supportedRewritesArray) { + expect(isRewriteSupportedByFirebase(rewrite)).to.be.true; + } + }); + + it("should disallow unsupported rewrites", () => { + for (const rewrite of unsupportedRewritesArray) { + expect(isRewriteSupportedByFirebase(rewrite)).to.be.false; + } + }); + }); + + describe("isRedirectSupportedByFirebase", () => { + it("should allow supported redirects", () => { + for (const redirect of supportedRedirects) { + expect(isRedirectSupportedByFirebase(redirect)).to.be.true; + } + }); + + it("should disallow unsupported redirects", () => { + for (const redirect of unsupportedRedirects) { + expect(isRedirectSupportedByFirebase(redirect)).to.be.false; + } + }); + }); + + describe("isHeaderSupportedByFirebase", () => { + it("should allow supported headers", () => { + for (const header of supportedHeaders) { + expect(isHeaderSupportedByFirebase(header)).to.be.true; + } + }); + + it("should disallow unsupported headers", () => { + for (const header of unsupportedHeaders) { + expect(isHeaderSupportedByFirebase(header)).to.be.false; + } + }); + }); + + describe("getNextjsRewritesToUse", () => { + it("should use only beforeFiles", () => { + if (!supportedRewritesObject?.beforeFiles?.length) { + throw new Error("beforeFiles must have rewrites"); + } + + const rewritesToUse = getNextjsRewritesToUse(supportedRewritesObject); + + for (const [i, rewrite] of supportedRewritesObject.beforeFiles.entries()) { + expect(rewrite.source).to.equal(rewritesToUse[i].source); + expect(rewrite.destination).to.equal(rewritesToUse[i].destination); + } + }); + + it("should return all rewrites if in array format", () => { + const rewritesToUse = getNextjsRewritesToUse(supportedRewritesArray); + + expect(rewritesToUse).to.have.length(supportedRewritesArray.length); + }); + }); +}); diff --git a/src/test/frameworks/utils.spec.ts b/src/test/frameworks/utils.spec.ts index 9d0c15c00cd..ebb675aadbc 100644 --- a/src/test/frameworks/utils.spec.ts +++ b/src/test/frameworks/utils.spec.ts @@ -3,8 +3,31 @@ import * as sinon from "sinon"; import * as fs from "fs"; import { warnIfCustomBuildScript } from "../../frameworks/utils"; +import { isUrl } from "../../frameworks/utils"; describe("Frameworks utils", () => { + describe("isUrl", () => { + it("should identify http URL", () => { + expect(isUrl("http://firebase.google.com")).to.be.true; + }); + + it("should identify https URL", () => { + expect(isUrl("https://firebase.google.com")).to.be.true; + }); + + it("should ignore URL within path", () => { + expect(isUrl("path/?url=https://firebase.google.com")).to.be.false; + }); + + it("should ignore path starting with http but without protocol", () => { + expect(isUrl("httpendpoint/foo/bar")).to.be.false; + }); + + it("should ignore path starting with https but without protocol", () => { + expect(isUrl("httpsendpoint/foo/bar")).to.be.false; + }); + }); + describe("warnIfCustomBuildScript", () => { const framework = "Next.js"; let sandbox: sinon.SinonSandbox;