From 2505005a4197ad2561d78ad74c2eb3fcbfd9f1fa Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Mon, 13 Nov 2023 15:06:36 +0100 Subject: [PATCH] feat(gatsby,gatsby-adapter-netlify): support pathPrefix and trailingSlash options (#38666) * initial wip * prefix all the things * lambda handler stripping path prefix * test: adjust e2e setup to also run variant with path prefix and no trailing slashes * chore: use queue for file moving to limit concurrency * tmp: don't clean up deploys while debugging things * test: added variant variables to deploy title * fix TS * fix unit tests * try different cypress group * try different cypress group2 * maybe fix passing cypress env? * fix ssr path_prefix * keep 404/500 status pages in original place * fix typo * fix assertion? * streamline file moving logic * cache (and restore) publishdir locally too * add pretty-url unit tests * handle dynamic paths when generating pretty url file names * update intercepting glob to handle path prefix * update intercepting glob to handle path prefix 2 * restore automatic deploys deletion * test: jest ensure we mount files that we test filepaths for * handle path prefix in header rules * drop debug helpers * first check if local * drop debug log * handle external redirects when pathPrefix is used. Thanks @techfg * make placeholder syntax consistent * update comment * move dynamic route path normalization to its own function (cherry picked from commit 63e57cf3dd96083d219c09741a751206bb715a53) --- e2e-tests/adapters/cypress/configs/netlify.ts | 3 +- e2e-tests/adapters/cypress/e2e/basics.cy.ts | 13 +- .../adapters/cypress/e2e/client-only.cy.ts | 46 ++-- e2e-tests/adapters/cypress/e2e/headers.cy.ts | 51 ++-- .../adapters/cypress/e2e/redirects.cy.ts | 10 +- e2e-tests/adapters/cypress/e2e/ssr.cy.ts | 8 +- e2e-tests/adapters/cypress/support/e2e.ts | 4 +- e2e-tests/adapters/gatsby-config.ts | 3 + e2e-tests/adapters/package.json | 11 +- .../scripts/deploy-and-run/netlify.mjs | 57 +++-- e2e-tests/adapters/src/pages/404.jsx | 3 +- e2e-tests/adapters/src/pages/500.jsx | 2 +- e2e-tests/adapters/src/pages/index.jsx | 14 +- .../src/pages/routes/sub-router/[...].jsx | 4 +- packages/gatsby-adapter-netlify/package.json | 2 + .../src/__tests__/pretty-urls.ts | 238 ++++++++++++++++++ packages/gatsby-adapter-netlify/src/index.ts | 15 +- .../gatsby-adapter-netlify/src/pretty-urls.ts | 96 +++++++ .../src/route-handler.ts | 30 ++- packages/gatsby/src/commands/build.ts | 2 +- .../utils/adapter/__tests__/fixtures/state.ts | 1 + .../src/utils/adapter/__tests__/manager.ts | 101 ++++++++ packages/gatsby/src/utils/adapter/manager.ts | 28 ++- .../utils/page-ssr-module/bundle-webpack.ts | 17 +- .../src/utils/page-ssr-module/lambda.ts | 58 ++++- yarn.lock | 31 +++ 26 files changed, 721 insertions(+), 127 deletions(-) create mode 100644 packages/gatsby-adapter-netlify/src/__tests__/pretty-urls.ts create mode 100644 packages/gatsby-adapter-netlify/src/pretty-urls.ts diff --git a/e2e-tests/adapters/cypress/configs/netlify.ts b/e2e-tests/adapters/cypress/configs/netlify.ts index f4018879e1d4c..6d0f2a8f216a6 100644 --- a/e2e-tests/adapters/cypress/configs/netlify.ts +++ b/e2e-tests/adapters/cypress/configs/netlify.ts @@ -3,8 +3,7 @@ import { defineConfig } from "cypress" export default defineConfig({ e2e: { baseUrl: process.env.DEPLOY_URL || `http://localhost:8888`, - // Netlify doesn't handle trailing slash behaviors really, so no use in testing it - excludeSpecPattern: [`cypress/e2e/trailing-slash.cy.ts`], + excludeSpecPattern: [], projectId: `4enh4m`, videoUploadOnPasses: false, experimentalRunAllSpecs: true, diff --git a/e2e-tests/adapters/cypress/e2e/basics.cy.ts b/e2e-tests/adapters/cypress/e2e/basics.cy.ts index a3f0ef00b455f..06de4a4d6aae6 100644 --- a/e2e-tests/adapters/cypress/e2e/basics.cy.ts +++ b/e2e-tests/adapters/cypress/e2e/basics.cy.ts @@ -1,12 +1,15 @@ import { title } from "../../constants" import { WorkaroundCachedResponse } from "../utils/dont-cache-responses-in-browser" +const PATH_PREFIX = Cypress.env(`PATH_PREFIX`) || `` + describe("Basics", () => { beforeEach(() => { - cy.intercept("/gatsby-icon.png").as("static-folder-image") - cy.intercept("/static/astro-**.png", WorkaroundCachedResponse).as( - "img-import" - ) + cy.intercept(PATH_PREFIX + "/gatsby-icon.png").as("static-folder-image") + cy.intercept( + PATH_PREFIX + "/static/astro-**.png", + WorkaroundCachedResponse + ).as("img-import") cy.visit("/").waitForRouteChange() }) @@ -35,7 +38,7 @@ describe("Basics", () => { failOnStatusCode: false, }) - cy.get("h1").should("have.text", "Page not found") + cy.get("h1").should("have.text", "Page not found (custom)") }) it("should apply CSS", () => { cy.get(`h1`).should(`have.css`, `color`, `rgb(21, 21, 22)`) diff --git a/e2e-tests/adapters/cypress/e2e/client-only.cy.ts b/e2e-tests/adapters/cypress/e2e/client-only.cy.ts index 58b24efa98cb1..fa323d77c59b3 100644 --- a/e2e-tests/adapters/cypress/e2e/client-only.cy.ts +++ b/e2e-tests/adapters/cypress/e2e/client-only.cy.ts @@ -1,15 +1,15 @@ -Cypress.on('uncaught:exception', (err) => { - if (err.message.includes('Minified React error')) { +Cypress.on("uncaught:exception", err => { + if (err.message.includes("Minified React error")) { return false } }) -describe('Sub-Router', () => { +describe("Sub-Router", () => { const routes = [ { path: "/routes/sub-router", marker: "index", - label: "Index route" + label: "Index route", }, { path: `/routes/sub-router/page/profile`, @@ -51,39 +51,47 @@ describe('Sub-Router', () => { }) }) -describe('Paths', () => { +describe("Paths", () => { const routes = [ { - name: 'client-only', - param: 'dune', + name: "client-only", + param: "dune", }, { - name: 'client-only/wildcard', - param: 'atreides/harkonnen', + name: "client-only/wildcard", + param: "atreides/harkonnen", }, { - name: 'client-only/named-wildcard', - param: 'corinno/fenring', + name: "client-only/named-wildcard", + param: "corinno/fenring", }, ] as const for (const route of routes) { it(`should return "${route.name}" result`, () => { - cy.visit(`/routes/${route.name}${route.param ? `/${route.param}` : ''}`).waitForRouteChange() + cy.visit( + `/routes/${route.name}${route.param ? `/${route.param}` : ""}` + ).waitForRouteChange() cy.get("[data-testid=title]").should("have.text", route.name) cy.get("[data-testid=params]").should("have.text", route.param) }) } }) -describe('Prioritize', () => { - it('should prioritize static page over matchPath page with wildcard', () => { - cy.visit('/routes/client-only/prioritize').waitForRouteChange() - cy.get("[data-testid=title]").should("have.text", "client-only/prioritize static") +describe("Prioritize", () => { + it("should prioritize static page over matchPath page with wildcard", () => { + cy.visit("/routes/client-only/prioritize").waitForRouteChange() + cy.get("[data-testid=title]").should( + "have.text", + "client-only/prioritize static" + ) }) - it('should return result for wildcard on nested prioritized path', () => { - cy.visit('/routes/client-only/prioritize/nested').waitForRouteChange() - cy.get("[data-testid=title]").should("have.text", "client-only/prioritize matchpath") + it("should return result for wildcard on nested prioritized path", () => { + cy.visit("/routes/client-only/prioritize/nested").waitForRouteChange() + cy.get("[data-testid=title]").should( + "have.text", + "client-only/prioritize matchpath" + ) cy.get("[data-testid=params]").should("have.text", "nested") }) }) diff --git a/e2e-tests/adapters/cypress/e2e/headers.cy.ts b/e2e-tests/adapters/cypress/e2e/headers.cy.ts index 6ff0002d19ce6..a2cd58cdecbb8 100644 --- a/e2e-tests/adapters/cypress/e2e/headers.cy.ts +++ b/e2e-tests/adapters/cypress/e2e/headers.cy.ts @@ -1,5 +1,7 @@ import { WorkaroundCachedResponse } from "../utils/dont-cache-responses-in-browser" +const PATH_PREFIX = Cypress.env(`PATH_PREFIX`) || `` + describe("Headers", () => { const defaultHeaders = { "x-xss-protection": "1; mode=block", @@ -73,23 +75,38 @@ describe("Headers", () => { } beforeEach(() => { - cy.intercept("/", WorkaroundCachedResponse).as("index") - cy.intercept("routes/ssr/static", WorkaroundCachedResponse).as("ssr") - cy.intercept("routes/dsg/static", WorkaroundCachedResponse).as("dsg") - - cy.intercept("**/page-data.json", WorkaroundCachedResponse).as("page-data") - cy.intercept("**/app-data.json", WorkaroundCachedResponse).as("app-data") - cy.intercept("**/slice-data/*.json", WorkaroundCachedResponse).as( - "slice-data" - ) - cy.intercept("**/page-data/sq/d/*.json", WorkaroundCachedResponse).as( - "static-query-result" - ) - - cy.intercept("/static/astro-**.png", WorkaroundCachedResponse).as( - "img-webpack-import" - ) - cy.intercept("*.js", WorkaroundCachedResponse).as("js") + cy.intercept(PATH_PREFIX + "/", WorkaroundCachedResponse).as("index") + cy.intercept( + PATH_PREFIX + "/routes/ssr/static", + WorkaroundCachedResponse + ).as("ssr") + cy.intercept( + PATH_PREFIX + "/routes/dsg/static", + WorkaroundCachedResponse + ).as("dsg") + + cy.intercept( + PATH_PREFIX + "/**/page-data.json", + WorkaroundCachedResponse + ).as("page-data") + cy.intercept( + PATH_PREFIX + "/**/app-data.json", + WorkaroundCachedResponse + ).as("app-data") + cy.intercept( + PATH_PREFIX + "/**/slice-data/*.json", + WorkaroundCachedResponse + ).as("slice-data") + cy.intercept( + PATH_PREFIX + "/**/page-data/sq/d/*.json", + WorkaroundCachedResponse + ).as("static-query-result") + + cy.intercept( + PATH_PREFIX + "/static/astro-**.png", + WorkaroundCachedResponse + ).as("img-webpack-import") + cy.intercept(PATH_PREFIX + "/**/*.js", WorkaroundCachedResponse).as("js") }) it("should contain correct headers for index page", () => { diff --git a/e2e-tests/adapters/cypress/e2e/redirects.cy.ts b/e2e-tests/adapters/cypress/e2e/redirects.cy.ts index 08d9f4a9f19ac..bb0fea6f5c3d0 100644 --- a/e2e-tests/adapters/cypress/e2e/redirects.cy.ts +++ b/e2e-tests/adapters/cypress/e2e/redirects.cy.ts @@ -7,6 +7,7 @@ Cypress.on("uncaught:exception", err => { }) const TRAILING_SLASH = Cypress.env(`TRAILING_SLASH`) || `never` +const PATH_PREFIX = Cypress.env(`PATH_PREFIX`) || `` // Those tests won't work using `gatsby serve` because it doesn't support redirects @@ -122,7 +123,8 @@ describe("Redirects", () => { cy.location(`pathname`).should( `equal`, - applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH) + PATH_PREFIX + + applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH) ) cy.location(`hash`).should(`equal`, `#anchor`) cy.location(`search`).should(`equal`, ``) @@ -138,7 +140,8 @@ describe("Redirects", () => { cy.location(`pathname`).should( `equal`, - applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH) + PATH_PREFIX + + applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH) ) cy.location(`hash`).should(`equal`, ``) cy.location(`search`).should(`equal`, `?query_param=hello`) @@ -154,7 +157,8 @@ describe("Redirects", () => { cy.location(`pathname`).should( `equal`, - applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH) + PATH_PREFIX + + applyTrailingSlashOption(`/routes/redirect/hit`, TRAILING_SLASH) ) cy.location(`hash`).should(`equal`, `#anchor`) cy.location(`search`).should(`equal`, `?query_param=hello`) diff --git a/e2e-tests/adapters/cypress/e2e/ssr.cy.ts b/e2e-tests/adapters/cypress/e2e/ssr.cy.ts index 7826ae8ec7c19..892906e07f1ae 100644 --- a/e2e-tests/adapters/cypress/e2e/ssr.cy.ts +++ b/e2e-tests/adapters/cypress/e2e/ssr.cy.ts @@ -1,6 +1,8 @@ const staticPath = "/routes/ssr/static" const paramPath = "/routes/ssr/param" +const PATH_PREFIX = Cypress.env(`PATH_PREFIX`) || `` + describe("Server Side Rendering (SSR)", () => { it(`direct visit no query params (${staticPath})`, () => { cy.visit(staticPath).waitForRouteChange() @@ -32,8 +34,8 @@ describe("Server Side Rendering (SSR)", () => { cy.visit(errorPath, { failOnStatusCode: false }).waitForRouteChange() cy.location(`pathname`) - .should(`equal`, errorPath) + .should(`equal`, PATH_PREFIX + errorPath) .get(`h1`) - .should(`have.text`, `INTERNAL SERVER ERROR`) + .should(`have.text`, `INTERNAL SERVER ERROR (custom)`) }) -}) \ No newline at end of file +}) diff --git a/e2e-tests/adapters/cypress/support/e2e.ts b/e2e-tests/adapters/cypress/support/e2e.ts index 198a0c3b8202b..faadac91bf7f8 100644 --- a/e2e-tests/adapters/cypress/support/e2e.ts +++ b/e2e-tests/adapters/cypress/support/e2e.ts @@ -15,6 +15,8 @@ declare global { } } +const PATH_PREFIX = Cypress.env(`PATH_PREFIX`) || `` + Cypress.Commands.add(`assertRoute`, route => { - cy.url().should(`equal`, `${window.location.origin}${route}`) + cy.url().should(`equal`, `${window.location.origin}${PATH_PREFIX}${route}`) }) diff --git a/e2e-tests/adapters/gatsby-config.ts b/e2e-tests/adapters/gatsby-config.ts index b9a70fcf6c0bb..1ae605f8e074f 100644 --- a/e2e-tests/adapters/gatsby-config.ts +++ b/e2e-tests/adapters/gatsby-config.ts @@ -5,6 +5,8 @@ import { siteDescription, title } from "./constants" const shouldUseDebugAdapter = process.env.USE_DEBUG_ADAPTER ?? false const trailingSlash = (process.env.TRAILING_SLASH || `never`) as GatsbyConfig["trailingSlash"] +const pathPrefix = (process.env.PATH_PREFIX || + undefined) as GatsbyConfig["pathPrefix"] let configOverrides: GatsbyConfig = {} @@ -21,6 +23,7 @@ const config: GatsbyConfig = { siteDescription, }, trailingSlash, + pathPrefix, plugins: [], headers: [ { diff --git a/e2e-tests/adapters/package.json b/e2e-tests/adapters/package.json index ce195953c5614..c3f4911265acd 100644 --- a/e2e-tests/adapters/package.json +++ b/e2e-tests/adapters/package.json @@ -6,19 +6,21 @@ "author": "LekoArts", "scripts": { "develop": "cross-env CYPRESS_SUPPORT=y gatsby develop", - "build": "cross-env CYPRESS_SUPPORT=y gatsby build", + "build": "cross-env CYPRESS_SUPPORT=y gatsby build --prefix-paths", "build:debug": "cross-env USE_DEBUG_ADAPTER=y CYPRESS_SUPPORT=y npm run build", "serve": "gatsby serve", "clean": "gatsby clean", "cy:open": "cypress open --browser chrome --e2e", "develop:debug": "start-server-and-test develop http://localhost:8000 'npm run cy:open -- --config baseUrl=http://localhost:8000'", "ssat:debug": "start-server-and-test serve http://localhost:9000 cy:open", - "test:template": "cross-env-shell CYPRESS_GROUP_NAME=$ADAPTER TRAILING_SLASH=$TRAILING_SLASH node ../../scripts/cypress-run-with-conditional-record-flag.js --browser chrome --e2e --config-file \"cypress/configs/$ADAPTER.ts\" --env TRAILING_SLASH=$TRAILING_SLASH", - "test:template:debug": "cross-env-shell CYPRESS_GROUP_NAME=$ADAPTER TRAILING_SLASH=$TRAILING_SLASH npm run cy:open -- --config-file \"cypress/configs/$ADAPTER.ts\" --env TRAILING_SLASH=$TRAILING_SLASH", + "test:template": "cross-env-shell CYPRESS_GROUP_NAME=\"adapter:$ADAPTER / trailingSlash:${TRAILING_SLASH:-always} / pathPrefix:${PATH_PREFIX:--}\" TRAILING_SLASH=$TRAILING_SLASH PATH_PREFIX=$PATH_PREFIX node ../../scripts/cypress-run-with-conditional-record-flag.js --browser chrome --e2e --config-file \"cypress/configs/$ADAPTER.ts\" --env TRAILING_SLASH=$TRAILING_SLASH,PATH_PREFIX=$PATH_PREFIX", + "test:template:debug": "cross-env-shell CYPRESS_GROUP_NAME=\"adapter:$ADAPTER / trailingSlash:${TRAILING_SLASH:-always} / pathPrefix:${PATH_PREFIX:--}\" TRAILING_SLASH=$TRAILING_SLASH PATH_PREFIX=$PATH_PREFIX npm run cy:open -- --config-file \"cypress/configs/$ADAPTER.ts\" --env TRAILING_SLASH=$TRAILING_SLASH,PATH_PREFIX=$PATH_PREFIX", "test:debug": "npm-run-all -s build:debug ssat:debug", "test:netlify": "cross-env TRAILING_SLASH=always node scripts/deploy-and-run/netlify.mjs test:template", "test:netlify:debug": "cross-env TRAILING_SLASH=always node scripts/deploy-and-run/netlify.mjs test:template:debug", - "test": "npm-run-all -c -s test:netlify" + "test:netlify:prefix-never": "cross-env TRAILING_SLASH=never PATH_PREFIX=/prefix node scripts/deploy-and-run/netlify.mjs test:template", + "test:netlify:prefix-never:debug": "cross-env TRAILING_SLASH=never PATH_PREFIX=/prefix node scripts/deploy-and-run/netlify.mjs test:template:debug", + "test": "npm-run-all -c -s test:netlify test:netlify:prefix-never" }, "dependencies": { "gatsby": "next", @@ -29,6 +31,7 @@ "devDependencies": { "cross-env": "^7.0.3", "cypress": "^12.14.0", + "dotenv": "^8.6.0", "gatsby-cypress": "^3.11.0", "netlify-cli": "^15.8.0", "npm-run-all": "^4.1.5", diff --git a/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs b/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs index 644dba2ee83f4..f3a5525f48081 100644 --- a/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs +++ b/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs @@ -1,14 +1,23 @@ // @ts-check - import { execa } from "execa" -process.env.NETLIFY_SITE_ID = process.env.E2E_ADAPTERS_NETLIFY_SITE_ID +// only set NETLIFY_SITE_ID from E2E_ADAPTERS_NETLIFY_SITE_ID if it's set +if (process.env.E2E_ADAPTERS_NETLIFY_SITE_ID) { + process.env.NETLIFY_SITE_ID = process.env.E2E_ADAPTERS_NETLIFY_SITE_ID +} process.env.ADAPTER = "netlify" -const deployTitle = process.env.CIRCLE_SHA1 || "N/A" +const deployTitle = `${ + process.env.CIRCLE_SHA1 || "N/A commit" +} - trailingSlash:${process.env.TRAILING_SLASH || `always`} / pathPrefix:${ + process.env.PATH_PREFIX || `-` +}` const npmScriptToRun = process.argv[2] || "test:netlify" +// ensure clean build +await execa(`npm`, [`run`, `clean`], { stdio: `inherit` }) + const deployResults = await execa( "ntl", ["deploy", "--build", "--json", "--message", deployTitle], @@ -30,28 +39,32 @@ if (deployResults.exitCode !== 0) { const deployInfo = JSON.parse(deployResults.stdout) -process.env.DEPLOY_URL = deployInfo.deploy_url +const deployUrl = deployInfo.deploy_url + (process.env.PATH_PREFIX ?? ``) +process.env.DEPLOY_URL = deployUrl -console.log(`Deployed to ${deployInfo.deploy_url}`) +console.log(`Deployed to ${deployUrl}`) try { await execa(`npm`, [`run`, npmScriptToRun], { stdio: `inherit` }) } finally { - // if (!process.env.GATSBY_TEST_SKIP_CLEANUP) { - // console.log(`Deleting project with deploy_id ${deployInfo.deploy_id}`) - // const deleteResponse = await execa("ntl", [ - // "api", - // "deleteDeploy", - // "--data", - // `{ "deploy_id": "${deployInfo.deploy_id}" }`, - // ]) - // if (deleteResponse.exitCode !== 0) { - // throw new Error( - // `Failed to delete project ${deleteResponse.stdout} ${deleteResponse.stderr} (${deleteResponse.exitCode})` - // ) - // } - // console.log( - // `Successfully deleted project with deploy_id ${deployInfo.deploy_id}` - // ) - // } + if (!process.env.GATSBY_TEST_SKIP_CLEANUP) { + console.log(`Deleting project with deploy_id ${deployInfo.deploy_id}`) + + const deleteResponse = await execa("ntl", [ + "api", + "deleteDeploy", + "--data", + `{ "deploy_id": "${deployInfo.deploy_id}" }`, + ]) + + if (deleteResponse.exitCode !== 0) { + throw new Error( + `Failed to delete project ${deleteResponse.stdout} ${deleteResponse.stderr} (${deleteResponse.exitCode})` + ) + } + + console.log( + `Successfully deleted project with deploy_id ${deployInfo.deploy_id}` + ) + } } diff --git a/e2e-tests/adapters/src/pages/404.jsx b/e2e-tests/adapters/src/pages/404.jsx index a9c4c826920b1..cb52d83131f93 100644 --- a/e2e-tests/adapters/src/pages/404.jsx +++ b/e2e-tests/adapters/src/pages/404.jsx @@ -17,11 +17,10 @@ const paragraphStyles = { marginBottom: 48, } - const NotFoundPage = () => { return (
-

Page not found

+

Page not found (custom)

Sorry 😔, we couldn’t find what you were looking for.
diff --git a/e2e-tests/adapters/src/pages/500.jsx b/e2e-tests/adapters/src/pages/500.jsx index 01ebb0f9c7992..9646578ac9970 100644 --- a/e2e-tests/adapters/src/pages/500.jsx +++ b/e2e-tests/adapters/src/pages/500.jsx @@ -19,7 +19,7 @@ const paragraphStyles = { const InternalServerErrorPage = () => (

-

INTERNAL SERVER ERROR

+

INTERNAL SERVER ERROR (custom)

Go home

diff --git a/e2e-tests/adapters/src/pages/index.jsx b/e2e-tests/adapters/src/pages/index.jsx index a6c5365026e47..9cbcccbe6ac45 100644 --- a/e2e-tests/adapters/src/pages/index.jsx +++ b/e2e-tests/adapters/src/pages/index.jsx @@ -1,5 +1,5 @@ import * as React from "react" -import { Link, graphql } from "gatsby" +import { Link, graphql, withPrefix } from "gatsby" import Layout from "../components/layout" import gatsbyAstronaut from "../images/astro.png" import "./index.css" @@ -8,12 +8,12 @@ const routes = [ { text: "Static", url: "/routes/static", - id: "static-without-slash" + id: "static-without-slash", }, { text: "Static (With Slash)", url: "/routes/static/", - id: "static-with-slash" + id: "static-with-slash", }, { text: "SSR", @@ -38,7 +38,7 @@ const routes = [ { text: "Client-Only Named Wildcard", url: "/routes/client-only/named-wildcard/corinno/fenring", - } + }, ] const functions = [ @@ -67,7 +67,11 @@ const IndexPage = ({ data }) => { Gatsby Astronaut
- Gatsby Monogram Logo + Gatsby Monogram Logo

{data.site.siteMetadata.title}

    diff --git a/e2e-tests/adapters/src/pages/routes/sub-router/[...].jsx b/e2e-tests/adapters/src/pages/routes/sub-router/[...].jsx index 564bc2e801504..04f6056fa5f57 100644 --- a/e2e-tests/adapters/src/pages/routes/sub-router/[...].jsx +++ b/e2e-tests/adapters/src/pages/routes/sub-router/[...].jsx @@ -1,10 +1,10 @@ import * as React from "react" import { Router } from "@reach/router" -import { Link } from "gatsby" +import { Link, withPrefix } from "gatsby" import Layout from "../../../components/layout" const routes = [`/`, `/not-found`, `/page/profile`, `/nested`, `/nested/foo`] -const basePath = `/routes/sub-router` +const basePath = withPrefix(`/routes/sub-router`) const Page = ({ page }) => (
    [client-only-path] {page}
    diff --git a/packages/gatsby-adapter-netlify/package.json b/packages/gatsby-adapter-netlify/package.json index 70f3ce4a3e153..a7a9304ab01c3 100644 --- a/packages/gatsby-adapter-netlify/package.json +++ b/packages/gatsby-adapter-netlify/package.json @@ -35,6 +35,7 @@ "@netlify/cache-utils": "^5.1.5", "@netlify/functions": "^1.6.0", "cookie": "^0.5.0", + "fastq": "^1.15.0", "fs-extra": "^11.1.1" }, "devDependencies": { @@ -42,6 +43,7 @@ "@babel/core": "^7.20.12", "babel-preset-gatsby-package": "^3.12.0", "cross-env": "^7.0.3", + "memfs": "^4.6.0", "rimraf": "^5.0.1", "typescript": "^5.1.6" }, diff --git a/packages/gatsby-adapter-netlify/src/__tests__/pretty-urls.ts b/packages/gatsby-adapter-netlify/src/__tests__/pretty-urls.ts new file mode 100644 index 0000000000000..ad32941243cf7 --- /dev/null +++ b/packages/gatsby-adapter-netlify/src/__tests__/pretty-urls.ts @@ -0,0 +1,238 @@ +import fse from "fs-extra" +import { vol } from "memfs" +import { + generatePrettyUrlFilePath, + createStaticAssetsPathHandler, + normalizeDynamicRoutePath, +} from "../pretty-urls" + +jest.mock(`fs`, () => jest.requireActual(`memfs`).fs) + +describe(`generatePrettyUrlFilePath`, () => { + it(`/`, () => { + expect(generatePrettyUrlFilePath(`/`)).toEqual(`/index.html`) + }) + + it(`/foo`, () => { + expect(generatePrettyUrlFilePath(`/foo`)).toEqual(`/foo.html`) + }) + + it(`/foo/`, () => { + expect(generatePrettyUrlFilePath(`/foo/`)).toEqual(`/foo/index.html`) + }) +}) + +describe(`normalizeRoutePath`, () => { + it(`static path return path as-is`, () => { + expect(normalizeDynamicRoutePath(`/foo/`)).toEqual(`/foo/`) + }) + + it(`replaces ':param: with [param]`, () => { + expect(normalizeDynamicRoutePath(`/foo/:param/bar/`)).toEqual( + `/foo/[param]/bar/` + ) + }) + + it(`replaces '*' with [...]`, () => { + expect(normalizeDynamicRoutePath(`/foo/*`)).toEqual(`/foo/[...]`) + }) + + it(`replaces '*named' with [...named]`, () => { + expect(normalizeDynamicRoutePath(`/foo/*named`)).toEqual(`/foo/[...named]`) + }) +}) + +describe(`createStaticAssetsPathHandler`, () => { + beforeEach(() => { + vol.reset() + }) + + it(`no-op if filepath is already coorect for given route`, async () => { + const copySpy = jest.spyOn(fse, `copy`) + const moveSpy = jest.spyOn(fse, `move`) + + vol.fromJSON( + { + "public/index.html": `index`, + "public/_gatsby/slices/slice-1.html": `slice`, + "public/index.css": `body {}`, + }, + process.cwd() + ) + + const { ensureStaticAssetPath, fileMovingDone } = + createStaticAssetsPathHandler() + + ensureStaticAssetPath(`public/index.html`, `/`) + ensureStaticAssetPath(`public/index.css`, `/index.css`) + ensureStaticAssetPath( + `public/_gatsby/slices/slice-1.html`, + `/_gatsby/slices/slice-1.html` + ) + + await fileMovingDone() + + expect(moveSpy).not.toBeCalled() + expect(copySpy).not.toBeCalled() + }) + + describe(`moves or copies file if filepath is incorrect for given route`, () => { + it(`removes trailing slash`, async () => { + vol.fromJSON({ "public/foo/index.html": `foo` }, process.cwd()) + + const { ensureStaticAssetPath, fileMovingDone } = + createStaticAssetsPathHandler() + + expect(vol.existsSync(`public/foo/index.html`)).toEqual(true) + expect(vol.existsSync(`public/foo.html`)).toEqual(false) + + ensureStaticAssetPath(`public/foo/index.html`, `/foo`) + + await fileMovingDone() + + expect(vol.existsSync(`public/foo/index.html`)).toEqual(false) + expect(vol.existsSync(`public/foo.html`)).toEqual(true) + }) + + it(`adds path prefix`, async () => { + vol.fromJSON({ "public/foo/index.html": `foo` }, process.cwd()) + + const { ensureStaticAssetPath, fileMovingDone } = + createStaticAssetsPathHandler() + + expect(vol.existsSync(`public/foo/index.html`)).toEqual(true) + expect(vol.existsSync(`public/prefix/foo/index.html`)).toEqual(false) + + ensureStaticAssetPath(`public/foo/index.html`, `/prefix/foo/`) + + await fileMovingDone() + + expect(vol.existsSync(`public/foo/index.html`)).toEqual(false) + expect(vol.existsSync(`public/prefix/foo/index.html`)).toEqual(true) + }) + + it(`adds path prefix and removes trailing slash`, async () => { + vol.fromJSON({ "public/foo/index.html": `foo` }, process.cwd()) + + const { ensureStaticAssetPath, fileMovingDone } = + createStaticAssetsPathHandler() + + expect(vol.existsSync(`public/foo/index.html`)).toEqual(true) + expect(vol.existsSync(`public/prefix/foo.html`)).toEqual(false) + + ensureStaticAssetPath(`public/foo/index.html`, `/prefix/foo`) + + await fileMovingDone() + + expect(vol.existsSync(`public/foo/index.html`)).toEqual(false) + expect(vol.existsSync(`public/prefix/foo.html`)).toEqual(true) + }) + + it(`handles non html assets for path prefix`, async () => { + vol.fromJSON({ "public/index.css": `body {}` }, process.cwd()) + + const { ensureStaticAssetPath, fileMovingDone } = + createStaticAssetsPathHandler() + + expect(vol.existsSync(`public/index.css`)).toEqual(true) + expect(vol.existsSync(`public/prefix/index.css`)).toEqual(false) + + ensureStaticAssetPath(`public/index.css`, `/prefix/index.css`) + + await fileMovingDone() + + expect(vol.existsSync(`public/index.css`)).toEqual(false) + expect(vol.existsSync(`public/prefix/index.css`)).toEqual(true) + }) + + it(`handles dynamic param paths syntax (is not using reserved characters for file paths)`, async () => { + vol.fromJSON({ "public/[param]/index.html": `:param` }, process.cwd()) + + const { ensureStaticAssetPath, fileMovingDone } = + createStaticAssetsPathHandler() + + expect(vol.existsSync(`public/[param]/index.html`)).toEqual(true) + expect(vol.existsSync(`public/foo/[param]/index.html`)).toEqual(false) + + ensureStaticAssetPath(`public/[param]/index.html`, `/foo/:param/`) + + await fileMovingDone() + + expect(vol.existsSync(`public/foo/[param]/index.html`)).toEqual(true) + expect(vol.existsSync(`public/[param]/index.html`)).toEqual(false) + }) + + it(`handles dynamic named wildcard paths syntax (is not using reserved characters for file paths)`, async () => { + vol.fromJSON( + { "public/[...wildcard]/index.html": `*wildcard` }, + process.cwd() + ) + + const { ensureStaticAssetPath, fileMovingDone } = + createStaticAssetsPathHandler() + + expect(vol.existsSync(`public/[...wildcard]/index.html`)).toEqual(true) + expect(vol.existsSync(`public/foo/[...wildcard].html`)).toEqual(false) + + ensureStaticAssetPath(`public/[...wildcard]/index.html`, `/foo/*wildcard`) + + await fileMovingDone() + + expect(vol.existsSync(`public/foo/[...wildcard].html`)).toEqual(true) + expect(vol.existsSync(`public/[...wildcard]/index.html`)).toEqual(false) + }) + + it(`handles dynamic unnamed wildcard paths syntax (is not using reserved characters for file paths)`, async () => { + vol.fromJSON({ "public/[...]/index.html": `*` }, process.cwd()) + + const { ensureStaticAssetPath, fileMovingDone } = + createStaticAssetsPathHandler() + + expect(vol.existsSync(`public/[...]/index.html`)).toEqual(true) + expect(vol.existsSync(`public/foo/[...].html`)).toEqual(false) + + ensureStaticAssetPath(`public/[...]/index.html`, `/foo/*`) + + await fileMovingDone() + + expect(vol.existsSync(`public/foo/[...].html`)).toEqual(true) + expect(vol.existsSync(`public/[...]/index.html`)).toEqual(false) + }) + + it(`keeps 404.html in root`, async () => { + vol.fromJSON({ "public/404.html": `404` }, process.cwd()) + + const { ensureStaticAssetPath, fileMovingDone } = + createStaticAssetsPathHandler() + + expect(vol.existsSync(`public/404.html`)).toEqual(true) + expect(vol.existsSync(`public/foo/404.html`)).toEqual(false) + + ensureStaticAssetPath(`public/404.html`, `/foo/404.html`) + + await fileMovingDone() + + expect(vol.existsSync(`public/404.html`)).toEqual(true) + // 404 page is coped so it exists in both locations + expect(vol.existsSync(`public/foo/404.html`)).toEqual(true) + }) + + it(`keeps 500.html in root`, async () => { + vol.fromJSON({ "public/500.html": `500` }, process.cwd()) + + const { ensureStaticAssetPath, fileMovingDone } = + createStaticAssetsPathHandler() + + expect(vol.existsSync(`public/500.html`)).toEqual(true) + expect(vol.existsSync(`public/foo/500.html`)).toEqual(false) + + ensureStaticAssetPath(`public/500.html`, `/foo/500.html`) + + await fileMovingDone() + + expect(vol.existsSync(`public/500.html`)).toEqual(true) + // 500 page is coped so it exists in both locations + expect(vol.existsSync(`public/foo/500.html`)).toEqual(true) + }) + }) +}) diff --git a/packages/gatsby-adapter-netlify/src/index.ts b/packages/gatsby-adapter-netlify/src/index.ts index 8c13e36f4e823..2e20a63a35508 100644 --- a/packages/gatsby-adapter-netlify/src/index.ts +++ b/packages/gatsby-adapter-netlify/src/index.ts @@ -1,3 +1,4 @@ +import { join } from "path" import type { AdapterInit, IAdapterConfig } from "gatsby" import { prepareFunctionVariants } from "./lambda-handler" import { handleRoutesManifest } from "./route-handler" @@ -17,12 +18,16 @@ async function getCacheUtils(): Promise { if (_cacheUtils) { return _cacheUtils } - if (process.env.NETLIFY) { - const CACHE_DIR = `/opt/build/cache` + let CACHE_DIR: string | undefined + if (process.env.NETLIFY_LOCAL) { + CACHE_DIR = join(process.cwd(), `.netlify`, `build-cache`) + } else if (process.env.NETLIFY) { + CACHE_DIR = `/opt/build/cache` + } + if (CACHE_DIR) { _cacheUtils = (await import(`@netlify/cache-utils`)).bindOpts({ cacheDir: CACHE_DIR, }) - return _cacheUtils } return undefined @@ -116,8 +121,8 @@ const createNetlifyAdapter: AdapterInit = options => { excludeDatastoreFromEngineFunction, deployURL, supports: { - pathPrefix: false, - trailingSlash: [`always`], + pathPrefix: true, + trailingSlash: [`always`, `never`, `ignore`], }, pluginsToDisable: [ `gatsby-plugin-netlify-cache`, diff --git a/packages/gatsby-adapter-netlify/src/pretty-urls.ts b/packages/gatsby-adapter-netlify/src/pretty-urls.ts new file mode 100644 index 0000000000000..a4658d447df3f --- /dev/null +++ b/packages/gatsby-adapter-netlify/src/pretty-urls.ts @@ -0,0 +1,96 @@ +import fastq from "fastq" +import fs from "fs-extra" + +export function generatePrettyUrlFilePath(routePath: string): string { + if (routePath.endsWith(`/`)) { + return `${routePath}index.html` + } else { + return `${routePath}.html` + } +} + +interface IMoveTask { + from: string + to: string + keepOriginalFile: boolean +} + +export function normalizeDynamicRoutePath(routePath: string): string { + return ( + routePath + // replace `:param` with `[param]` + .replace(/:([^:/\\]+)/gm, `[$1]`) + // replace `*param` with `[...param]` and `*` with `[...]` + .replace(/\*([^:/\\]*)/gm, `[...$1]`) + ) +} + +export function createStaticAssetsPathHandler(): { + ensureStaticAssetPath: ( + filePath: string, + routePath: string + ) => { finalFilePath: string; isDynamic: boolean } + fileMovingDone: () => Promise +} { + const moveQueue = fastq(async (task, cb) => { + try { + if (task.keepOriginalFile) { + await fs.copy(task.from, task.to, { overwrite: true }) + } else { + await fs.move(task.from, task.to, { overwrite: true }) + } + cb(null, undefined) + } catch (error) { + cb(error) + } + }, 2) + + function ensureStaticAssetPath( + filePath: string, + routePath: string + ): { finalFilePath: string; isDynamic: boolean } { + const shouldUsePrettyUrl = + filePath.endsWith(`.html`) && !routePath.endsWith(`.html`) + + let isDynamic = false + // dynamic routes syntax use characters that are reserved in a lot of filesystems + // so if route is dynamic we should normalize filepath + if (routePath.includes(`:`) || routePath.includes(`*`)) { + routePath = normalizeDynamicRoutePath(routePath) + isDynamic = true + } + + const finalFilePath = `public${ + shouldUsePrettyUrl ? generatePrettyUrlFilePath(routePath) : routePath + }` + + if (finalFilePath !== filePath) { + moveQueue.push({ + from: filePath, + to: finalFilePath, + // 404.html should stay in root of PUBLISH_DIR to be used as custom 404 page + // both 404.html and 500.html should stay in root of PUBLISH_DIR to be bundled correctly for SSR/DSG + keepOriginalFile: + filePath === `public/404.html` || filePath === `public/500.html`, + }) + } + return { + finalFilePath, + isDynamic, + } + } + + const fileMovingDone = (): Promise => { + if (moveQueue.idle()) { + return Promise.resolve() + } + return new Promise(resolve => { + moveQueue.drain = resolve + }) + } + + return { + ensureStaticAssetPath, + fileMovingDone, + } +} diff --git a/packages/gatsby-adapter-netlify/src/route-handler.ts b/packages/gatsby-adapter-netlify/src/route-handler.ts index 2faf3216f59f6..f6320dc146c77 100644 --- a/packages/gatsby-adapter-netlify/src/route-handler.ts +++ b/packages/gatsby-adapter-netlify/src/route-handler.ts @@ -3,6 +3,7 @@ import { tmpdir } from "os" import { Transform } from "stream" import { join, basename } from "path" import fs from "fs-extra" +import { createStaticAssetsPathHandler } from "./pretty-urls" const NETLIFY_REDIRECT_KEYWORDS_ALLOWLIST = new Set([ `query`, @@ -144,9 +145,13 @@ export function processRoutesManifest( redirects: string headers: string lambdasThatUseCaching: Map + fileMovingPromise: Promise } { const lambdasThatUseCaching = new Map() + const { ensureStaticAssetPath, fileMovingDone } = + createStaticAssetsPathHandler() + let _redirects = `` let _headers = `` for (const route of routesManifest) { @@ -214,9 +219,13 @@ export function processRoutesManifest( } _redirects += pieces.join(` `) + `\n` } else if (route.type === `static`) { - // regular static asset without dynamic paths will just work, so skipping those - if (route.path.includes(`:`) || route.path.includes(`*`)) { - _redirects += `${encodeURI(fromPath)} ${route.filePath.replace( + const { finalFilePath, isDynamic } = ensureStaticAssetPath( + route.filePath, + fromPath + ) + + if (isDynamic) { + _redirects += `${encodeURI(fromPath)} ${finalFilePath.replace( /^public/, `` )} 200\n` @@ -235,7 +244,13 @@ export function processRoutesManifest( }, ``) } } - return { redirects: _redirects, headers: _headers, lambdasThatUseCaching } + + return { + redirects: _redirects, + headers: _headers, + lambdasThatUseCaching, + fileMovingPromise: fileMovingDone(), + } } export async function handleRoutesManifest( @@ -244,13 +259,12 @@ export async function handleRoutesManifest( ): Promise<{ lambdasThatUseCaching: Map }> { - const { redirects, headers, lambdasThatUseCaching } = processRoutesManifest( - routesManifest, - headerRoutes - ) + const { redirects, headers, lambdasThatUseCaching, fileMovingPromise } = + processRoutesManifest(routesManifest, headerRoutes) await injectEntries(`public/_redirects`, redirects) await injectEntries(`public/_headers`, headers) + await fileMovingPromise return { lambdasThatUseCaching, diff --git a/packages/gatsby/src/commands/build.ts b/packages/gatsby/src/commands/build.ts index 05dbc1f7bb2a6..364329e7b26cc 100644 --- a/packages/gatsby/src/commands/build.ts +++ b/packages/gatsby/src/commands/build.ts @@ -698,8 +698,8 @@ module.exports = async function build( } if (adapterManager) { - await adapterManager.adapt() await adapterManager.storeCache() + await adapterManager.adapt() } showExperimentNotices() diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/state.ts b/packages/gatsby/src/utils/adapter/__tests__/fixtures/state.ts index f4aef674d18d8..d24966a853ad1 100644 --- a/packages/gatsby/src/utils/adapter/__tests__/fixtures/state.ts +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/state.ts @@ -169,4 +169,5 @@ export const state = { slices, html, components, + program: {} } as unknown as IGatsbyState diff --git a/packages/gatsby/src/utils/adapter/__tests__/manager.ts b/packages/gatsby/src/utils/adapter/__tests__/manager.ts index 2ea7545ab39f6..348a6cb442667 100644 --- a/packages/gatsby/src/utils/adapter/__tests__/manager.ts +++ b/packages/gatsby/src/utils/adapter/__tests__/manager.ts @@ -109,6 +109,29 @@ describe(`getRoutesManifest`, () => { ) }) + it(`should not prepend '\\' to external redirects (path prefix variant)`, () => { + mockStoreState(stateDefault, { + program: { + ...stateDefault.program, + prefixPaths: true, + }, + config: { + ...stateDefault.config, + pathPrefix: `/prefix`, + }, + }) + process.chdir(fixturesDir) + setWebpackAssets(new Set([`app-123.js`])) + + const { routes } = getRoutesManifest() + expect(routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: `https://old-url` }), + expect.objectContaining({ path: `http://old-url` }), + ]) + ) + }) + it(`should return header rules`, () => { mockStoreState(stateDefault, { config: { @@ -182,6 +205,84 @@ describe(`getRoutesManifest`, () => { ) }) + it(`should return header rules (path prefix variant)`, () => { + mockStoreState(stateDefault, { + program: { + ...stateDefault.program, + prefixPaths: true, + }, + config: { + ...stateDefault.config, + pathPrefix: `/prefix`, + headers: [ + { + source: `/ssr/*`, + headers: [ + { + key: `x-ssr-header`, + value: `my custom header value from config`, + }, + ], + }, + ], + }, + }) + process.chdir(fixturesDir) + setWebpackAssets(new Set([`app-123.js`, `static/app-456.js`])) + + const { headers } = getRoutesManifest() + + expect(headers).toContainEqual({ + headers: [ + { key: `x-xss-protection`, value: `1; mode=block` }, + { key: `x-content-type-options`, value: `nosniff` }, + { key: `referrer-policy`, value: `same-origin` }, + { key: `x-frame-options`, value: `DENY` }, + ], + path: `/prefix/*`, + }) + expect(headers).toContainEqual({ + headers: [ + { + key: `cache-control`, + value: `public, max-age=31536000, immutable`, + }, + ], + path: `/prefix/static/*`, + }) + expect(headers).toContainEqual({ + headers: [ + { + key: `cache-control`, + value: `public, max-age=0, must-revalidate`, + }, + ], + path: `/prefix/page-data/index/page-data.json`, + }) + expect(headers).toContainEqual({ + headers: [ + { + key: `cache-control`, + value: `public, max-age=31536000, immutable`, + }, + ], + path: `/prefix/app-123.js`, + }) + expect(headers).not.toContainEqual({ + headers: [ + { key: `x-xss-protection`, value: `1; mode=block` }, + { key: `x-content-type-options`, value: `nosniff` }, + { key: `referrer-policy`, value: `same-origin` }, + { key: `x-frame-options`, value: `DENY` }, + ], + path: `/prefix/ssr/*`, + }) + + expect(headers).not.toContain( + expect.objectContaining({ path: `/prefix/static/app-456.js` }) + ) + }) + it(`should respect "force" redirects parameter`, () => { mockStoreState(stateDefault, { config: { ...stateDefault.config }, diff --git a/packages/gatsby/src/utils/adapter/manager.ts b/packages/gatsby/src/utils/adapter/manager.ts index 40c6b90778fea..5c56f16d64c2d 100644 --- a/packages/gatsby/src/utils/adapter/manager.ts +++ b/packages/gatsby/src/utils/adapter/manager.ts @@ -278,26 +278,26 @@ type RouteWithScore = { score: number } & Route const headersAreEqual = (a, b): boolean => a.key === b.key && a.value === b.value -const defaultHeaderRoutes: HeaderRoutes = [ +const getDefaultHeaderRoutes = (pathPrefix: string): HeaderRoutes => [ { - path: `/*`, + path: `${pathPrefix}/*`, headers: BASE_HEADERS, }, { - path: `/static/*`, + path: `${pathPrefix}/static/*`, headers: PERMANENT_CACHE_CONTROL_HEADER, }, ] const customHeaderFilter = - (route: Route) => + (route: Route, pathPrefix: string) => (h: IHeader["headers"][0]): boolean => { for (const baseHeader of BASE_HEADERS) { if (headersAreEqual(baseHeader, h)) { return false } } - if (route.path.startsWith(`/static/`)) { + if (route.path.startsWith(`${pathPrefix}/static/`)) { for (const cachingHeader of PERMAMENT_CACHING_HEADERS) { if (headersAreEqual(cachingHeader, h)) { return false @@ -314,8 +314,11 @@ function getRoutesManifest(): { const routes: Array = [] const state = store.getState() const createHeaders = createHeadersMatcher(state.config.headers) + const pathPrefix = state.program.prefixPaths + ? state.config.pathPrefix ?? `` + : `` - const headerRoutes: HeaderRoutes = [...defaultHeaderRoutes] + const headerRoutes: HeaderRoutes = [...getDefaultHeaderRoutes(pathPrefix)] const fileAssets = new Set( globSync(`**/**`, { @@ -328,10 +331,15 @@ function getRoutesManifest(): { // TODO: This could be a "addSortedRoute" function that would add route to the list in sorted order. TBD if necessary performance-wise function addRoute(route: Route): void { if ( - !route.path.startsWith(`/`) && !(route.path.startsWith(`https://`) || route.path.startsWith(`http://`)) ) { - route.path = `/${route.path}` + if (!route.path.startsWith(`/`)) { + route.path = `/${route.path}` + } + + if (pathPrefix && !route.path.startsWith(pathPrefix)) { + route.path = posix.join(pathPrefix, route.path) + } } // Apply trailing slash behavior unless it's a redirect. Redirects should always be exact matches @@ -344,7 +352,9 @@ function getRoutesManifest(): { if (route.type !== `function`) { route.headers = createHeaders(route.path, route.headers) - const customHeaders = route.headers.filter(customHeaderFilter(route)) + const customHeaders = route.headers.filter( + customHeaderFilter(route, pathPrefix) + ) if (customHeaders.length > 0) { headerRoutes.push({ path: route.path, headers: customHeaders }) } diff --git a/packages/gatsby/src/utils/page-ssr-module/bundle-webpack.ts b/packages/gatsby/src/utils/page-ssr-module/bundle-webpack.ts index b9f2f612857ec..6b253093f6c2a 100644 --- a/packages/gatsby/src/utils/page-ssr-module/bundle-webpack.ts +++ b/packages/gatsby/src/utils/page-ssr-module/bundle-webpack.ts @@ -77,6 +77,9 @@ export async function createPageSSRBundle({ isVerbose?: boolean }): Promise { const state = store.getState() + const pathPrefix = state.program.prefixPaths + ? state.config.pathPrefix ?? `` + : `` const slicesStateObject = {} for (const [key, value] of state.slices) { slicesStateObject[key] = value @@ -224,12 +227,14 @@ export async function createPageSSRBundle({ `utf-8` ) - functionCode = functionCode.replace( - `%CDN_DATASTORE_PATH%`, - shouldBundleDatastore() - ? `` - : `${state.adapter.config.deployURL ?? ``}/${LmdbOnCdnPath}` - ) + functionCode = functionCode + .replace( + `%CDN_DATASTORE_PATH%`, + shouldBundleDatastore() + ? `` + : `${state.adapter.config.deployURL ?? ``}/${LmdbOnCdnPath}` + ) + .replace(`%PATH_PREFIX%`, pathPrefix) await fs.outputFile(path.join(outputDir, `lambda.js`), functionCode) diff --git a/packages/gatsby/src/utils/page-ssr-module/lambda.ts b/packages/gatsby/src/utils/page-ssr-module/lambda.ts index fb4c4f0b61944..c051aff44010b 100644 --- a/packages/gatsby/src/utils/page-ssr-module/lambda.ts +++ b/packages/gatsby/src/utils/page-ssr-module/lambda.ts @@ -13,6 +13,7 @@ import type { ISSRData } from "./entry" import { link } from "linkfs" const cdnDatastore = `%CDN_DATASTORE_PATH%` +const PATH_PREFIX = `%PATH_PREFIX%` function setupFsWrapper(): string { // setup global._fsWrapper @@ -172,14 +173,13 @@ function reverseFixedPagePath(pageDataRequestPath: string): string { return pageDataRequestPath === `index` ? `/` : pageDataRequestPath } -function getPathInfo(req: GatsbyFunctionRequest): +function getPathInfo(requestPath: string): | { isPageData: boolean pagePath: string } | undefined { - // @ts-ignore GatsbyFunctionRequest.path is not in types ... there is no property in types that can be used to get a path currently - const matches = req.url.matchAll(/^\/?page-data\/(.+)\/page-data.json$/gm) + const matches = requestPath.matchAll(/^\/?page-data\/(.+)\/page-data.json$/gm) for (const [, requestedPagePath] of matches) { return { isPageData: true, @@ -190,8 +190,7 @@ function getPathInfo(req: GatsbyFunctionRequest): // if not matched return { isPageData: false, - // @ts-ignore GatsbyFunctionRequest.path is not in types ... there is no property in types that can be used to get a path currently - pagePath: req.url, + pagePath: requestPath, } } @@ -232,26 +231,61 @@ function getErrorBody(statusCode: number): string { return body } +interface IPageInfo { + page: IGatsbyPage + isPageData: boolean + pagePath: string +} + +function getPage( + pathname: string, + graphqlEngine: GraphQLEngineType +): IPageInfo | undefined { + const pathInfo = getPathInfo(pathname) + if (!pathInfo) { + return undefined + } + + const { isPageData, pagePath } = pathInfo + + const page = graphqlEngine.findPageByPath(pagePath) + if (!page) { + return undefined + } + + return { + page, + isPageData, + pagePath, + } +} + async function engineHandler( req: GatsbyFunctionRequest, res: GatsbyFunctionResponse ): Promise { try { const graphqlEngine = await engineReadyPromise - const pathInfo = getPathInfo(req) - if (!pathInfo) { - res.status(404).send(getErrorBody(404)) - return + let pageInfo: IPageInfo | undefined + + const originalPathName = req.url ?? `` + + if (PATH_PREFIX && originalPathName.startsWith(PATH_PREFIX)) { + const maybePath = originalPathName.slice(PATH_PREFIX.length) + pageInfo = getPage(maybePath, graphqlEngine) } - const { isPageData, pagePath } = pathInfo + if (!pageInfo) { + pageInfo = getPage(originalPathName, graphqlEngine) + } - const page = graphqlEngine.findPageByPath(pagePath) - if (!page) { + if (!pageInfo) { res.status(404).send(getErrorBody(404)) return } + const { pagePath, isPageData, page } = pageInfo + const data = await getData({ pathName: pagePath, graphqlEngine, diff --git a/yarn.lock b/yarn.lock index d13fe809e3c81..55d476539b4ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6070,6 +6070,11 @@ arg@^5.0.0: resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.0.tgz#a20e2bb5710e82950a516b3f933fee5ed478be90" integrity sha512-4P8Zm2H+BRS+c/xX1LrHw0qKpEhdlZjLCgWy+d78T9vqa2Z2SiD2wMrYuWIAFy5IZUD7nnNXroRttz+0RzlrzQ== +arg@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" + integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== + argparse@^1.0.10, argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -13296,6 +13301,11 @@ husky@3.1.0: run-node "^1.0.0" slash "^3.0.0" +hyperdyperid@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/hyperdyperid/-/hyperdyperid-1.2.0.tgz#59668d323ada92228d2a869d3e474d5a33b69e6b" + integrity sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A== + hyperlinker@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/hyperlinker/-/hyperlinker-1.0.0.tgz#23dc9e38a206b208ee49bc2d6c8ef47027df0c0e" @@ -15037,6 +15047,14 @@ json-diff@^1.0.6: colors "^1.4.0" dreamopt "~0.8.0" +json-joy@^9.2.0: + version "9.6.0" + resolved "https://registry.yarnpkg.com/json-joy/-/json-joy-9.6.0.tgz#b691310024205b2d082737ca3c7e72cac0e364ac" + integrity sha512-vJtJD89T0OOZFMaENe95xKCOdibMev/lELkclTdhZxLplwbBPxneWNuctUPizk2nLqtGfBxwCXVO42G9LBoFBA== + dependencies: + arg "^5.0.2" + hyperdyperid "^1.2.0" + json-loader@^0.5.7: version "0.5.7" resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.7.tgz#dca14a70235ff82f0ac9a3abeb60d337a365185d" @@ -16523,6 +16541,14 @@ memfs@^3.1.2, memfs@^3.2.2: dependencies: fs-monkey "1.0.3" +memfs@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.6.0.tgz#e812d438f73482a7110420d13d381c730b9a4de5" + integrity sha512-I6mhA1//KEZfKRQT9LujyW6lRbX7RkC24xKododIDO3AGShcaFAMKElv1yFGWX8fD4UaSiwasr3NeQ5TdtHY1A== + dependencies: + json-joy "^9.2.0" + thingies "^1.11.1" + memoizee@^0.4.15: version "0.4.15" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.15.tgz#e6f3d2da863f318d02225391829a6c5956555b72" @@ -23307,6 +23333,11 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +thingies@^1.11.1: + version "1.12.0" + resolved "https://registry.yarnpkg.com/thingies/-/thingies-1.12.0.tgz#a815c224482d607aa70f563d3cbb351a338e4710" + integrity sha512-AiGqfYC1jLmJagbzQGuoZRM48JPsr9yB734a7K6wzr34NMhjUPrWSQrkF7ZBybf3yCerCL2Gcr02kMv4NmaZfA== + thread-stream@^0.15.1: version "0.15.2" resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-0.15.2.tgz#fb95ad87d2f1e28f07116eb23d85aba3bc0425f4"