diff --git a/e2e-tests/adapters/cypress/e2e/redirects.cy.ts b/e2e-tests/adapters/cypress/e2e/redirects.cy.ts index 2ef68bd05c522..08d9f4a9f19ac 100644 --- a/e2e-tests/adapters/cypress/e2e/redirects.cy.ts +++ b/e2e-tests/adapters/cypress/e2e/redirects.cy.ts @@ -1,6 +1,6 @@ import { applyTrailingSlashOption } from "../../utils" -Cypress.on("uncaught:exception", (err) => { +Cypress.on("uncaught:exception", err => { if (err.message.includes("Minified React error")) { return false } @@ -14,44 +14,149 @@ describe("Redirects", () => { it("should redirect from non-existing page to existing", () => { cy.visit(applyTrailingSlashOption(`/redirect`, TRAILING_SLASH), { failOnStatusCode: false, - }).waitForRouteChange() - .assertRoute(applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH)) + }) + .waitForRouteChange() + .assertRoute( + applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH) + ) cy.get(`h1`).should(`have.text`, `Hit`) }) it("should respect that pages take precedence over redirects", () => { - cy.visit(applyTrailingSlashOption(`/routes/redirect/existing`, TRAILING_SLASH), { - failOnStatusCode: false, - }).waitForRouteChange() - .assertRoute(applyTrailingSlashOption(`/routes/redirect/existing`, TRAILING_SLASH)) + cy.visit( + applyTrailingSlashOption(`/routes/redirect/existing`, TRAILING_SLASH), + { + failOnStatusCode: false, + } + ) + .waitForRouteChange() + .assertRoute( + applyTrailingSlashOption(`/routes/redirect/existing`, TRAILING_SLASH) + ) cy.get(`h1`).should(`have.text`, `Existing`) }) + it("should respect force redirect", () => { + cy.visit( + applyTrailingSlashOption( + `/routes/redirect/existing-force`, + TRAILING_SLASH + ), + { + failOnStatusCode: false, + } + ) + .waitForRouteChange() + .assertRoute( + applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH) + ) + + cy.get(`h1`).should(`have.text`, `Hit`) + }) + it("should respect country condition on redirect", () => { + cy.visit( + applyTrailingSlashOption( + `/routes/redirect/country-condition`, + TRAILING_SLASH + ), + { + failOnStatusCode: false, + headers: { + "x-nf-country": "us", + }, + } + ) + .waitForRouteChange() + .assertRoute( + applyTrailingSlashOption(`/routes/redirect/hit-us`, TRAILING_SLASH) + ) + + cy.get(`h1`).should(`have.text`, `Hit US`) + + cy.visit( + applyTrailingSlashOption( + `/routes/redirect/country-condition`, + TRAILING_SLASH + ), + { + failOnStatusCode: false, + headers: { + "x-nf-country": "de", + }, + } + ) + .waitForRouteChange() + .assertRoute( + applyTrailingSlashOption(`/routes/redirect/hit-de`, TRAILING_SLASH) + ) + + cy.get(`h1`).should(`have.text`, `Hit DE`) + + // testing fallback + cy.visit( + applyTrailingSlashOption( + `/routes/redirect/country-condition`, + TRAILING_SLASH + ), + { + failOnStatusCode: false, + headers: { + "x-nf-country": "fr", + }, + } + ) + .waitForRouteChange() + .assertRoute( + applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH) + ) + + cy.get(`h1`).should(`have.text`, `Hit`) + }) it("should support hash parameter on direct visit", () => { - cy.visit(applyTrailingSlashOption(`/redirect`, TRAILING_SLASH) + `#anchor`, { - failOnStatusCode: false, - }).waitForRouteChange() + cy.visit( + applyTrailingSlashOption(`/redirect`, TRAILING_SLASH) + `#anchor`, + { + failOnStatusCode: false, + } + ).waitForRouteChange() - cy.location(`pathname`).should(`equal`, applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH)) + cy.location(`pathname`).should( + `equal`, + applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH) + ) cy.location(`hash`).should(`equal`, `#anchor`) cy.location(`search`).should(`equal`, ``) }) it("should support search parameter on direct visit", () => { - cy.visit(applyTrailingSlashOption(`/redirect`, TRAILING_SLASH) + `?query_param=hello`, { - failOnStatusCode: false, - }).waitForRouteChange() + cy.visit( + applyTrailingSlashOption(`/redirect`, TRAILING_SLASH) + + `?query_param=hello`, + { + failOnStatusCode: false, + } + ).waitForRouteChange() - cy.location(`pathname`).should(`equal`, applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH)) + cy.location(`pathname`).should( + `equal`, + applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH) + ) cy.location(`hash`).should(`equal`, ``) cy.location(`search`).should(`equal`, `?query_param=hello`) }) it("should support search & hash parameter on direct visit", () => { - cy.visit(applyTrailingSlashOption(`/redirect`, TRAILING_SLASH) + `?query_param=hello#anchor`, { - failOnStatusCode: false, - }).waitForRouteChange() + cy.visit( + applyTrailingSlashOption(`/redirect`, TRAILING_SLASH) + + `?query_param=hello#anchor`, + { + failOnStatusCode: false, + } + ).waitForRouteChange() - cy.location(`pathname`).should(`equal`, applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH)) + cy.location(`pathname`).should( + `equal`, + applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH) + ) cy.location(`hash`).should(`equal`, `#anchor`) cy.location(`search`).should(`equal`, `?query_param=hello`) }) -}) \ No newline at end of file +}) diff --git a/e2e-tests/adapters/gatsby-node.ts b/e2e-tests/adapters/gatsby-node.ts index 0df93fcc92857..85a9e3b2ca007 100644 --- a/e2e-tests/adapters/gatsby-node.ts +++ b/e2e-tests/adapters/gatsby-node.ts @@ -2,15 +2,57 @@ import * as path from "path" import type { GatsbyNode, GatsbyConfig } from "gatsby" import { applyTrailingSlashOption } from "./utils" -const TRAILING_SLASH = (process.env.TRAILING_SLASH || `never`) as GatsbyConfig["trailingSlash"] +const TRAILING_SLASH = (process.env.TRAILING_SLASH || + `never`) as GatsbyConfig["trailingSlash"] -export const createPages: GatsbyNode["createPages"] = ({ actions: { createRedirect, createSlice } }) => { +export const createPages: GatsbyNode["createPages"] = ({ + actions: { createRedirect, createSlice }, +}) => { createRedirect({ fromPath: applyTrailingSlashOption("/redirect", TRAILING_SLASH), toPath: applyTrailingSlashOption("/routes/redirect/hit", TRAILING_SLASH), }) createRedirect({ - fromPath: applyTrailingSlashOption("/routes/redirect/existing", TRAILING_SLASH), + fromPath: applyTrailingSlashOption( + "/routes/redirect/existing", + TRAILING_SLASH + ), + toPath: applyTrailingSlashOption("/routes/redirect/hit", TRAILING_SLASH), + }) + createRedirect({ + fromPath: applyTrailingSlashOption( + "/routes/redirect/existing-force", + TRAILING_SLASH + ), + toPath: applyTrailingSlashOption("/routes/redirect/hit", TRAILING_SLASH), + force: true, + }) + createRedirect({ + fromPath: applyTrailingSlashOption( + "/routes/redirect/country-condition", + TRAILING_SLASH + ), + toPath: applyTrailingSlashOption("/routes/redirect/hit-us", TRAILING_SLASH), + conditions: { + country: ["us"], + }, + }) + createRedirect({ + fromPath: applyTrailingSlashOption( + "/routes/redirect/country-condition", + TRAILING_SLASH + ), + toPath: applyTrailingSlashOption("/routes/redirect/hit-de", TRAILING_SLASH), + conditions: { + country: ["de"], + }, + }) + // fallback if not matching a country condition + createRedirect({ + fromPath: applyTrailingSlashOption( + "/routes/redirect/country-condition", + TRAILING_SLASH + ), toPath: applyTrailingSlashOption("/routes/redirect/hit", TRAILING_SLASH), }) @@ -19,4 +61,4 @@ export const createPages: GatsbyNode["createPages"] = ({ actions: { createRedire component: path.resolve(`./src/components/footer.jsx`), context: {}, }) -} \ No newline at end of file +} diff --git a/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs b/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs index 9c9e5f1619643..ed09a06299eac 100644 --- a/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs +++ b/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs @@ -32,6 +32,8 @@ const deployInfo = JSON.parse(deployResults.stdout) process.env.DEPLOY_URL = deployInfo.deploy_url +console.log(`Deployed to ${deployInfo.deploy_url}`) + try { await execa(`npm`, [`run`, npmScriptToRun], { stdio: `inherit` }) } finally { diff --git a/e2e-tests/adapters/src/pages/routes/redirect/existing-force.jsx b/e2e-tests/adapters/src/pages/routes/redirect/existing-force.jsx new file mode 100644 index 0000000000000..83a6399ab2535 --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/redirect/existing-force.jsx @@ -0,0 +1,14 @@ +import * as React from "react" +import Layout from "../../../components/layout" + +const ExistingForcePage = () => { + return ( + +

Existing Force

+
+ ) +} + +export default ExistingForcePage + +export const Head = () => Existing Force diff --git a/e2e-tests/adapters/src/pages/routes/redirect/hit-de.jsx b/e2e-tests/adapters/src/pages/routes/redirect/hit-de.jsx new file mode 100644 index 0000000000000..45d6d7a506a6f --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/redirect/hit-de.jsx @@ -0,0 +1,14 @@ +import * as React from "react" +import Layout from "../../../components/layout" + +const HitDEPage = () => { + return ( + +

Hit DE

+
+ ) +} + +export default HitDEPage + +export const Head = () => Hit DE diff --git a/e2e-tests/adapters/src/pages/routes/redirect/hit-us.jsx b/e2e-tests/adapters/src/pages/routes/redirect/hit-us.jsx new file mode 100644 index 0000000000000..446eb1ea2ea29 --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/redirect/hit-us.jsx @@ -0,0 +1,14 @@ +import * as React from "react" +import Layout from "../../../components/layout" + +const HitUSPage = () => { + return ( + +

Hit US

+
+ ) +} + +export default HitUSPage + +export const Head = () => Hit US diff --git a/packages/gatsby-adapter-netlify/src/__tests__/route-handler.ts b/packages/gatsby-adapter-netlify/src/__tests__/route-handler.ts index 9d8af395b6c97..7368236cec068 100644 --- a/packages/gatsby-adapter-netlify/src/__tests__/route-handler.ts +++ b/packages/gatsby-adapter-netlify/src/__tests__/route-handler.ts @@ -1,6 +1,7 @@ import fs from "fs-extra" import { tmpdir } from "os" import { join } from "path" +import type { IRedirectRoute, RoutesManifest } from "gatsby" import { injectEntries, ADAPTER_MARKER_START, @@ -8,6 +9,7 @@ import { NETLIFY_PLUGIN_MARKER_START, NETLIFY_PLUGIN_MARKER_END, GATSBY_PLUGIN_MARKER_START, + processRoutesManifest, } from "../route-handler" function generateLotOfContent(placeholderCharacter: string): string { @@ -142,4 +144,50 @@ describe(`route-handler`, () => { }) }) }) + + describe(`createRedirects`, () => { + it(`honors the force parameter`, async () => { + const manifest: RoutesManifest = [ + { + path: `/old-url`, + type: `redirect`, + toPath: `/new-url`, + status: 301, + headers: [{ key: `string`, value: `string` }], + force: true, + }, + { + path: `/old-url2`, + type: `redirect`, + toPath: `/new-url2`, + status: 308, + headers: [{ key: `string`, value: `string` }], + force: false, + }, + ] + + const { redirects } = processRoutesManifest(manifest) + + // `!` is appended to status to mark forced redirect + expect(redirects).toMatch(/^\/old-url\s+\/new-url\s+301!$/m) + // `!` is not appended to status to mark not forced redirect + expect(redirects).toMatch(/^\/old-url2\s+\/new-url2\s+308$/m) + }) + + it(`honors the conditions parameter`, async () => { + const redirect: IRedirectRoute = { + path: `/old-url`, + type: `redirect`, + toPath: `/new-url`, + status: 200, + headers: [{ key: `string`, value: `string` }], + conditions: { language: [`ca`, `us`] }, + } + + const { redirects } = processRoutesManifest([redirect]) + expect(redirects).toMatch( + /^\/old-url\s+\/new-url\s+200\s+Language=ca,us$/m + ) + }) + }) }) diff --git a/packages/gatsby-adapter-netlify/src/route-handler.ts b/packages/gatsby-adapter-netlify/src/route-handler.ts index d9e5a6b6d84a2..a80930c2ca4e6 100644 --- a/packages/gatsby-adapter-netlify/src/route-handler.ts +++ b/packages/gatsby-adapter-netlify/src/route-handler.ts @@ -130,11 +130,11 @@ export async function injectEntries( await fs.move(tmpFile, fileName) } -export async function handleRoutesManifest( - routesManifest: RoutesManifest -): Promise<{ +export function processRoutesManifest(routesManifest: RoutesManifest): { + redirects: string + headers: string lambdasThatUseCaching: Map -}> { +} { const lambdasThatUseCaching = new Map() let _redirects = `` @@ -159,14 +159,13 @@ export async function handleRoutesManifest( const { status: routeStatus, toPath, - force, // TODO: add headers handling headers, ...rest } = route let status = String(routeStatus) - if (force) { + if (rest.force) { status = `${status}!` } @@ -194,7 +193,7 @@ export async function handleRoutesManifest( const conditionName = conditionKey.charAt(0).toUpperCase() + conditionKey.slice(1) - pieces.push(`${conditionName}:${conditionValue}`) + pieces.push(`${conditionName}=${conditionValue}`) } } } @@ -222,9 +221,18 @@ export async function handleRoutesManifest( )}` } } + return { redirects: _redirects, headers: _headers, lambdasThatUseCaching } +} - await injectEntries(`public/_redirects`, _redirects) - await injectEntries(`public/_headers`, _headers) +export async function handleRoutesManifest( + routesManifest: RoutesManifest +): Promise<{ + lambdasThatUseCaching: Map +}> { + const { redirects, headers, lambdasThatUseCaching } = + processRoutesManifest(routesManifest) + await injectEntries(`public/_redirects`, redirects) + await injectEntries(`public/_headers`, headers) return { lambdasThatUseCaching, diff --git a/packages/gatsby-core-utils/.gitignore b/packages/gatsby-core-utils/.gitignore index 849ddff3b7ec9..654e6ceded07b 100644 --- a/packages/gatsby-core-utils/.gitignore +++ b/packages/gatsby-core-utils/.gitignore @@ -1 +1,2 @@ dist/ +src/__tests__/.cache-fetch \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/__snapshots__/manager.ts.snap b/packages/gatsby/src/utils/adapter/__tests__/__snapshots__/manager.ts.snap index b622b18d0df61..293ef0f4e01c5 100644 --- a/packages/gatsby/src/utils/adapter/__tests__/__snapshots__/manager.ts.snap +++ b/packages/gatsby/src/utils/adapter/__tests__/__snapshots__/manager.ts.snap @@ -261,6 +261,38 @@ Array [ "toPath": "/new-url", "type": "redirect", }, + Object { + "conditions": Object { + "language": Array [ + "ca", + "us", + ], + }, + "force": true, + "headers": Array [ + Object { + "key": "x-xss-protection", + "value": "1; mode=block", + }, + Object { + "key": "x-content-type-options", + "value": "nosniff", + }, + Object { + "key": "referrer-policy", + "value": "same-origin", + }, + Object { + "key": "x-frame-options", + "value": "DENY", + }, + ], + "ignoreCase": true, + "path": "/old-url2", + "status": 301, + "toPath": "/new-url2", + "type": "redirect", + }, Object { "functionId": "ssr-engine", "path": "/ssr/", diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/state.ts b/packages/gatsby/src/utils/adapter/__tests__/fixtures/state.ts index b272b43e5d4ad..f4aef674d18d8 100644 --- a/packages/gatsby/src/utils/adapter/__tests__/fixtures/state.ts +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/state.ts @@ -70,6 +70,14 @@ const redirects: IGatsbyState["redirects"] = [{ ignoreCase: true, redirectInBrowser: false, toPath: '/new-url' +}, { + fromPath: '/old-url2', + isPermanent: true, + ignoreCase: true, + redirectInBrowser: false, + toPath: '/new-url2', + force: true, + conditions: { language: [`ca`, `us`] } }, { fromPath: 'https://old-url', isPermanent: true, diff --git a/packages/gatsby/src/utils/adapter/__tests__/manager.ts b/packages/gatsby/src/utils/adapter/__tests__/manager.ts index 601b18f88bd35..2ea7545ab39f6 100644 --- a/packages/gatsby/src/utils/adapter/__tests__/manager.ts +++ b/packages/gatsby/src/utils/adapter/__tests__/manager.ts @@ -181,6 +181,42 @@ describe(`getRoutesManifest`, () => { expect.objectContaining({ path: `/static/app-456.js` }) ) }) + + it(`should respect "force" redirects parameter`, () => { + mockStoreState(stateDefault, { + config: { ...stateDefault.config }, + }) + + const { routes } = getRoutesManifest() + + expect(routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: `/old-url2`, + force: true, + }), + ]) + ) + }) + + it(`should respect "conditions" redirects parameter`, () => { + mockStoreState(stateDefault, { + config: { ...stateDefault.config }, + }) + process.chdir(fixturesDir) + setWebpackAssets(new Set([`app-123.js`])) + + const { routes } = getRoutesManifest() + + expect(routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: `/old-url2`, + conditions: { language: [`ca`, `us`] }, + }), + ]) + ) + }) }) describe(`getFunctionsManifest`, () => { diff --git a/packages/gatsby/src/utils/adapter/manager.ts b/packages/gatsby/src/utils/adapter/manager.ts index 459f0d9a4da7e..40c6b90778fea 100644 --- a/packages/gatsby/src/utils/adapter/manager.ts +++ b/packages/gatsby/src/utils/adapter/manager.ts @@ -519,17 +519,28 @@ function getRoutesManifest(): { // redirect routes for (const redirect of state.redirects.values()) { + const { + fromPath, + toPath, + statusCode, + isPermanent, + ignoreCase, + redirectInBrowser, + ...platformSpecificFields + } = redirect + addRoute({ - path: redirect.fromPath, + path: fromPath, type: `redirect`, - toPath: redirect.toPath, + toPath: toPath, status: - redirect.statusCode ?? - (redirect.isPermanent + statusCode ?? + (isPermanent ? HTTP_STATUS_CODE.MOVED_PERMANENTLY_301 : HTTP_STATUS_CODE.FOUND_302), - ignoreCase: redirect.ignoreCase, + ignoreCase: ignoreCase, headers: BASE_HEADERS, + ...platformSpecificFields, }) }