diff --git a/.circleci/config.yml b/.circleci/config.yml index 9fe269388f054..31d482ba479d7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -444,6 +444,15 @@ jobs: - store_test_results: path: e2e-tests/trailing-slash/cypress/results + e2e_tests_adapters: + <<: *e2e-executor + steps: + - run: echo 'export CYPRESS_RECORD_KEY="${CY_CLOUD_ADAPTERS}"' >> "$BASH_ENV" + - e2e-test: + test_path: e2e-tests/adapters + - store_test_results: + path: e2e-tests/adapters/cypress/results + starters_validate: executor: node steps: @@ -594,6 +603,8 @@ workflows: <<: *e2e-test-workflow - e2e_tests_trailing-slash: <<: *e2e-test-workflow + - e2e_tests_adapters: + <<: *e2e-test-workflow - e2e_tests_development_runtime_with_react_18: <<: *e2e-test-workflow - e2e_tests_production_runtime_with_react_18: diff --git a/e2e-tests/adapters/.gitignore b/e2e-tests/adapters/.gitignore new file mode 100644 index 0000000000000..abb27db5d05c7 --- /dev/null +++ b/e2e-tests/adapters/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +.cache/ +public + +# Local Netlify folder +.netlify + +# Cypress output +cypress/videos/ +cypress/screenshots/ + +# Custom .yarnrc file for gatsby-dev on Yarn 3 +.yarnrc.yml diff --git a/e2e-tests/adapters/README.md b/e2e-tests/adapters/README.md new file mode 100644 index 0000000000000..eab3c8f5265a3 --- /dev/null +++ b/e2e-tests/adapters/README.md @@ -0,0 +1,27 @@ +# adapters + +E2E testing suite for Gatsby's [adapters](http://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/adapters/) feature. +If possible, run the tests locally with a CLI. Otherwise deploy the site to the target platform and run Cypress on the deployed URL. + +Adapters being tested: + +- [gatsby-adapter-netlify](https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-adapter-netlify) + +## Usage + +- To run all tests, use `npm run test` +- To run individual tests, use `npm run test:%NAME` where `test:%NAME` is the script, e.g. `npm run test:netlify` + +If you want to open Cypress locally as a UI, you can run the `:debug` scripts. For example, `npm run test:netlify:debug` to test the Netlify Adapter with Cypress open. + +### Adding a new adapter + +- Add a new Cypress config inside `cypress/configs` +- Add a new `test:` script that should run `start-server-and-test`. You can check what e.g. `test:netlify` is doing. +- Run the Cypress test suites that should work. If you want to exclude a spec, you can use Cypress' [excludeSpecPattern](https://docs.cypress.io/guides/references/configuration#excludeSpecPattern) + +## External adapters + +As mentioned in [Creating an Adapter](https://gatsbyjs.com/docs/how-to/previews-deploys-hosting/creating-an-adapter/#testing) you can use this test suite for your own adapter. + +Copy the whole `adapters` folder, and follow [adding a new adapter](#adding-a-new-adapter). diff --git a/e2e-tests/adapters/constants.ts b/e2e-tests/adapters/constants.ts new file mode 100644 index 0000000000000..ebec159409f08 --- /dev/null +++ b/e2e-tests/adapters/constants.ts @@ -0,0 +1,2 @@ +export const title = "Adapters" +export const siteDescription = "End-to-End tests for Gatsby Adapters" \ No newline at end of file diff --git a/e2e-tests/adapters/cypress.config.ts b/e2e-tests/adapters/cypress.config.ts new file mode 100644 index 0000000000000..ce7740301e527 --- /dev/null +++ b/e2e-tests/adapters/cypress.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "cypress" + +export default defineConfig({ + e2e: { + baseUrl: `http://localhost:9000`, + projectId: `4enh4m`, + videoUploadOnPasses: false, + experimentalRunAllSpecs: true, + retries: 2, + }, +}) \ No newline at end of file diff --git a/e2e-tests/adapters/cypress/configs/netlify.ts b/e2e-tests/adapters/cypress/configs/netlify.ts new file mode 100644 index 0000000000000..361e9a861ed20 --- /dev/null +++ b/e2e-tests/adapters/cypress/configs/netlify.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "cypress" + +export default defineConfig({ + e2e: { + baseUrl: `http://localhost:8888`, + // Netlify doesn't handle trailing slash behaviors really, so no use in testing it + excludeSpecPattern: [`cypress/e2e/trailing-slash.cy.ts`,], + projectId: `4enh4m`, + videoUploadOnPasses: false, + experimentalRunAllSpecs: true, + retries: 2, + }, +}) \ No newline at end of file diff --git a/e2e-tests/adapters/cypress/e2e/basics.cy.ts b/e2e-tests/adapters/cypress/e2e/basics.cy.ts new file mode 100644 index 0000000000000..51707bd32e1c4 --- /dev/null +++ b/e2e-tests/adapters/cypress/e2e/basics.cy.ts @@ -0,0 +1,44 @@ +import { title } from "../../constants" + +describe('Basics', () => { + beforeEach(() => { + cy.intercept("/gatsby-icon.png").as("static-folder-image") + cy.intercept("/static/astro-**.png").as("img-import") + + cy.visit('/').waitForRouteChange() + }) + + it('should display index page', () => { + cy.get('h1').should('have.text', title) + cy.title().should('eq', 'Adapters E2E') + }) + // If this test fails, run "gatsby build" and retry + it('should serve assets from "static" folder', () => { + cy.wait("@static-folder-image").should(req => { + expect(req.response.statusCode).to.be.gte(200).and.lt(400) + }) + + cy.get('[alt="Gatsby Monogram Logo"]').should('be.visible') + }) + it('should serve assets imported through webpack', () => { + cy.wait("@img-import").should(req => { + expect(req.response.statusCode).to.be.gte(200).and.lt(400) + }) + + cy.get('[alt="Gatsby Astronaut"]').should('be.visible') + }) + it(`should show custom 404 page on invalid URL`, () => { + cy.visit(`/non-existent-page`, { + failOnStatusCode: false, + }) + + cy.get('h1').should('have.text', 'Page not found') + }) + it('should apply CSS', () => { + cy.get(`h1`).should( + `have.css`, + `color`, + `rgb(21, 21, 22)` + ) + }) +}) \ No newline at end of file diff --git a/e2e-tests/adapters/cypress/e2e/client-only.cy.ts b/e2e-tests/adapters/cypress/e2e/client-only.cy.ts new file mode 100644 index 0000000000000..58b24efa98cb1 --- /dev/null +++ b/e2e-tests/adapters/cypress/e2e/client-only.cy.ts @@ -0,0 +1,89 @@ +Cypress.on('uncaught:exception', (err) => { + if (err.message.includes('Minified React error')) { + return false + } +}) + +describe('Sub-Router', () => { + const routes = [ + { + path: "/routes/sub-router", + marker: "index", + label: "Index route" + }, + { + path: `/routes/sub-router/page/profile`, + marker: `profile`, + label: `Dynamic route`, + }, + { + path: `/routes/sub-router/not-found`, + marker: `NotFound`, + label: `Default route (not found)`, + }, + { + path: `/routes/sub-router/nested`, + marker: `nested-page/index`, + label: `Index route inside nested router`, + }, + { + path: `/routes/sub-router/nested/foo`, + marker: `nested-page/foo`, + label: `Dynamic route inside nested router`, + }, + { + path: `/routes/sub-router/static`, + marker: `static-sibling`, + label: `Static route that is a sibling to client only path`, + }, + ] as const + + routes.forEach(({ path, marker, label }) => { + it(label, () => { + cy.visit(path).waitForRouteChange() + cy.get(`[data-testid="dom-marker"]`).contains(marker) + + cy.url().should( + `match`, + new RegExp(`^${Cypress.config().baseUrl + path}/?$`) + ) + }) + }) +}) + +describe('Paths', () => { + const routes = [ + { + name: 'client-only', + param: 'dune', + }, + { + name: 'client-only/wildcard', + param: 'atreides/harkonnen', + }, + { + 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.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") + }) + 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/dsg.cy.ts b/e2e-tests/adapters/cypress/e2e/dsg.cy.ts new file mode 100644 index 0000000000000..f1ea9e3e063d4 --- /dev/null +++ b/e2e-tests/adapters/cypress/e2e/dsg.cy.ts @@ -0,0 +1,14 @@ +import { title } from "../../constants" + +describe("Deferred Static Generation (DSG)", () => { + it("should work correctly", () => { + cy.visit("/routes/dsg/static").waitForRouteChange() + + cy.get("h1").contains("DSG") + }) + it("should work with page queries", () => { + cy.visit("/routes/dsg/graphql-query").waitForRouteChange() + + cy.get(`[data-testid="title"]`).should("have.text", title) + }) +}) \ No newline at end of file diff --git a/e2e-tests/adapters/cypress/e2e/functions.cy.ts b/e2e-tests/adapters/cypress/e2e/functions.cy.ts new file mode 100644 index 0000000000000..b69c4048c86d5 --- /dev/null +++ b/e2e-tests/adapters/cypress/e2e/functions.cy.ts @@ -0,0 +1,27 @@ +const routes = [ + { + name: 'static', + param: '', + }, + { + name: 'param', + param: 'dune', + }, + { + name: 'wildcard', + param: 'atreides/harkonnen' + }, + { + name: 'named-wildcard', + param: 'corinno/fenring' + } +] as const + +describe('Functions', () => { + for (const route of routes) { + it(`should return "${route.name}" result`, () => { + cy.request(`/api/${route.name}${route.param ? `/${route.param}` : ''}`).as(`req-${route.name}`) + cy.get(`@req-${route.name}`).its('body').should('contain', `Hello World${route.param ? ` from ${route.param}` : ``}`) + }) + } +}) \ No newline at end of file diff --git a/e2e-tests/adapters/cypress/e2e/redirects.cy.ts b/e2e-tests/adapters/cypress/e2e/redirects.cy.ts new file mode 100644 index 0000000000000..2ef68bd05c522 --- /dev/null +++ b/e2e-tests/adapters/cypress/e2e/redirects.cy.ts @@ -0,0 +1,57 @@ +import { applyTrailingSlashOption } from "../../utils" + +Cypress.on("uncaught:exception", (err) => { + if (err.message.includes("Minified React error")) { + return false + } +}) + +const TRAILING_SLASH = Cypress.env(`TRAILING_SLASH`) || `never` + +// Those tests won't work using `gatsby serve` because it doesn't support redirects + +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)) + + 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.get(`h1`).should(`have.text`, `Existing`) + }) + it("should support hash parameter on direct visit", () => { + cy.visit(applyTrailingSlashOption(`/redirect`, TRAILING_SLASH) + `#anchor`, { + failOnStatusCode: false, + }).waitForRouteChange() + + 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.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.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/cypress/e2e/slices.cy.ts b/e2e-tests/adapters/cypress/e2e/slices.cy.ts new file mode 100644 index 0000000000000..a0778170f0ac6 --- /dev/null +++ b/e2e-tests/adapters/cypress/e2e/slices.cy.ts @@ -0,0 +1,9 @@ +import { siteDescription } from "../../constants" + +describe("Slices", () => { + it("should work correctly", () => { + cy.visit('/').waitForRouteChange() + + cy.get(`footer`).should("have.text", siteDescription) + }) +}) \ No newline at end of file diff --git a/e2e-tests/adapters/cypress/e2e/ssr.cy.ts b/e2e-tests/adapters/cypress/e2e/ssr.cy.ts new file mode 100644 index 0000000000000..7826ae8ec7c19 --- /dev/null +++ b/e2e-tests/adapters/cypress/e2e/ssr.cy.ts @@ -0,0 +1,39 @@ +const staticPath = "/routes/ssr/static" +const paramPath = "/routes/ssr/param" + +describe("Server Side Rendering (SSR)", () => { + it(`direct visit no query params (${staticPath})`, () => { + cy.visit(staticPath).waitForRouteChange() + cy.get(`[data-testid="query"]`).contains(`{}`) + cy.get(`[data-testid="params"]`).contains(`{}`) + }) + + it(`direct visit with query params (${staticPath})`, () => { + cy.visit(staticPath + `?foo=bar`).waitForRouteChange() + cy.get(`[data-testid="query"]`).contains(`{"foo":"bar"}`) + cy.get(`[data-testid="params"]`).contains(`{}`) + }) + + it(`direct visit no query params (${paramPath})`, () => { + cy.visit(paramPath + `/foo`).waitForRouteChange() + cy.get(`[data-testid="query"]`).contains(`{}`) + cy.get(`[data-testid="params"]`).contains(`{"param":"foo"}`) + }) + + it(`direct visit with query params (${paramPath})`, () => { + cy.visit(paramPath + `/foo` + `?foo=bar`).waitForRouteChange() + cy.get(`[data-testid="query"]`).contains(`{"foo":"bar"}`) + cy.get(`[data-testid="params"]`).contains(`{"param":"foo"}`) + }) + + it(`should display custom 500 page`, () => { + const errorPath = `/routes/ssr/error-path` + + cy.visit(errorPath, { failOnStatusCode: false }).waitForRouteChange() + + cy.location(`pathname`) + .should(`equal`, errorPath) + .get(`h1`) + .should(`have.text`, `INTERNAL SERVER ERROR`) + }) +}) \ No newline at end of file diff --git a/e2e-tests/adapters/cypress/e2e/trailing-slash.cy.ts b/e2e-tests/adapters/cypress/e2e/trailing-slash.cy.ts new file mode 100644 index 0000000000000..c595f655907ce --- /dev/null +++ b/e2e-tests/adapters/cypress/e2e/trailing-slash.cy.ts @@ -0,0 +1,49 @@ +import { assertPageVisits } from "../utils/assert-page-visits" +import { applyTrailingSlashOption } from "../../utils" + +Cypress.on("uncaught:exception", (err) => { + if (err.message.includes("Minified React error")) { + return false + } +}) + +const TRAILING_SLASH = Cypress.env(`TRAILING_SLASH`) || `never` + +describe("trailingSlash", () => { + describe(TRAILING_SLASH, () => { + it("should work when using Gatsby Link (without slash)", () => { + cy.visit('/').waitForRouteChange() + + cy.get(`[data-testid="static-without-slash"]`).click().waitForRouteChange().assertRoute(applyTrailingSlashOption(`/routes/static`, TRAILING_SLASH)) + }) + it("should work when using Gatsby Link (with slash)", () => { + cy.visit('/').waitForRouteChange() + + cy.get(`[data-testid="static-with-slash"]`).click().waitForRouteChange().assertRoute(applyTrailingSlashOption(`/routes/static`, TRAILING_SLASH)) + }) + it("should work on direct visit (with other setting)", () => { + const destination = applyTrailingSlashOption("/routes/static", TRAILING_SLASH) + const inverse = TRAILING_SLASH === `always` ? "/routes/static" : "/routes/static/" + + assertPageVisits([ + { + path: destination, + status: 200, + }, + { path: inverse, status: 301, destinationPath: destination } + ]) + + cy.visit(inverse).waitForRouteChange().assertRoute(applyTrailingSlashOption(`/routes/static`, TRAILING_SLASH)) + }) + it("should work on direct visit (with current setting)", () => { + assertPageVisits([ + { + path: applyTrailingSlashOption("/routes/static", TRAILING_SLASH), + status: 200, + }, + ]) + + cy.visit(applyTrailingSlashOption("/routes/static", TRAILING_SLASH)).waitForRouteChange().assertRoute(applyTrailingSlashOption(`/routes/static`, TRAILING_SLASH)) + }) + }) +}) diff --git a/e2e-tests/adapters/cypress/support/e2e.ts b/e2e-tests/adapters/cypress/support/e2e.ts new file mode 100644 index 0000000000000..198a0c3b8202b --- /dev/null +++ b/e2e-tests/adapters/cypress/support/e2e.ts @@ -0,0 +1,20 @@ +// https://docs.cypress.io/guides/core-concepts/writing-and-organizing-tests#Support-file + +import "gatsby-cypress" + +declare global { + namespace Cypress { + interface Chainable { + /** + * Assert the current URL + * @param route + * @example cy.assertRoute('/page-2') + */ + assertRoute(value: string): Chainable> + } + } +} + +Cypress.Commands.add(`assertRoute`, route => { + cy.url().should(`equal`, `${window.location.origin}${route}`) +}) diff --git a/e2e-tests/adapters/cypress/tsconfig.json b/e2e-tests/adapters/cypress/tsconfig.json new file mode 100644 index 0000000000000..83fb87e55fd8f --- /dev/null +++ b/e2e-tests/adapters/cypress/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"] + }, + "include": ["**/*.ts"] +} \ No newline at end of file diff --git a/e2e-tests/adapters/cypress/utils/assert-page-visits.ts b/e2e-tests/adapters/cypress/utils/assert-page-visits.ts new file mode 100644 index 0000000000000..0fe806f5666f7 --- /dev/null +++ b/e2e-tests/adapters/cypress/utils/assert-page-visits.ts @@ -0,0 +1,16 @@ +export function assertPageVisits(pages: Array<{ path: string; status: number; destinationPath?: string }>) { + for (let i = 0; i < pages.length; i++) { + const page = pages[i] + + cy.intercept(new RegExp(`^${page.path}$`), req => { + req.continue(res => { + expect(res.statusCode).to.equal(page.status) + if (page.destinationPath) { + expect(res.headers.location).to.equal(page.destinationPath) + } else { + expect(res.headers.location).to.be.undefined + } + }) + }) + } +} diff --git a/e2e-tests/adapters/debug-adapter.ts b/e2e-tests/adapters/debug-adapter.ts new file mode 100644 index 0000000000000..9333d1bff0733 --- /dev/null +++ b/e2e-tests/adapters/debug-adapter.ts @@ -0,0 +1,37 @@ +import { inspect } from "util" +import type { AdapterInit } from "gatsby" + +const createTestingAdapter: AdapterInit = (adapterOptions) => { + return { + name: `gatsby-adapter-debug`, + cache: { + restore({ directories, reporter }) { + reporter.info(`[gatsby-adapter-debug] cache.restore() ${directories}`) + }, + store({ directories, reporter }) { + reporter.info(`[gatsby-adapter-debug] cache.store() ${directories}`) + } + }, + adapt({ + routesManifest, + functionsManifest, + pathPrefix, + trailingSlash, + reporter, + }) { + reporter.info(`[gatsby-adapter-debug] adapt()`) + + console.log(`[gatsby-adapter-debug] adapt()`, inspect({ + routesManifest, + functionsManifest, + pathPrefix, + trailingSlash, + }, { + depth: Infinity, + colors: true + })) + } + } +} + +export default createTestingAdapter \ No newline at end of file diff --git a/e2e-tests/adapters/gatsby-config.ts b/e2e-tests/adapters/gatsby-config.ts new file mode 100644 index 0000000000000..8ccc03130cc54 --- /dev/null +++ b/e2e-tests/adapters/gatsby-config.ts @@ -0,0 +1,27 @@ +import type { GatsbyConfig } from "gatsby" +import debugAdapter from "./debug-adapter" +import { siteDescription, title } from "./constants" + +const shouldUseDebugAdapter = process.env.USE_DEBUG_ADAPTER ?? false +const trailingSlash = (process.env.TRAILING_SLASH || `never`) as GatsbyConfig["trailingSlash"] + +let configOverrides: GatsbyConfig = {} + +// Should conditionally add debug adapter to config +if (shouldUseDebugAdapter) { + configOverrides = { + adapter: debugAdapter(), + } +} + +const config: GatsbyConfig = { + siteMetadata: { + title, + siteDescription, + }, + trailingSlash, + plugins: [], + ...configOverrides, +} + +export default config diff --git a/e2e-tests/adapters/gatsby-node.ts b/e2e-tests/adapters/gatsby-node.ts new file mode 100644 index 0000000000000..0df93fcc92857 --- /dev/null +++ b/e2e-tests/adapters/gatsby-node.ts @@ -0,0 +1,22 @@ +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"] + +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), + toPath: applyTrailingSlashOption("/routes/redirect/hit", TRAILING_SLASH), + }) + + createSlice({ + id: `footer`, + component: path.resolve(`./src/components/footer.jsx`), + context: {}, + }) +} \ No newline at end of file diff --git a/e2e-tests/adapters/netlify.toml b/e2e-tests/adapters/netlify.toml new file mode 100644 index 0000000000000..d1e965d4a8f4c --- /dev/null +++ b/e2e-tests/adapters/netlify.toml @@ -0,0 +1,3 @@ +[build] + command = "npm run build" + publish = "public/" \ No newline at end of file diff --git a/e2e-tests/adapters/package.json b/e2e-tests/adapters/package.json new file mode 100644 index 0000000000000..cb46f8a6a0c4e --- /dev/null +++ b/e2e-tests/adapters/package.json @@ -0,0 +1,38 @@ +{ + "name": "adapters", + "version": "1.0.0", + "private": true, + "description": "E2E test site for testing official adapters", + "author": "LekoArts", + "scripts": { + "develop": "cross-env CYPRESS_SUPPORT=y gatsby develop", + "build": "cross-env CYPRESS_SUPPORT=y gatsby build", + "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:debug": "npm-run-all -s build:debug ssat:debug", + "test:netlify": "start-server-and-test 'cross-env TRAILING_SLASH=always BROWSER=none ntl serve --port 8888' http://localhost:8888 'cross-env ADAPTER=netlify TRAILING_SLASH=always npm run test:template'", + "test:netlify:debug": "start-server-and-test 'cross-env TRAILING_SLASH=always BROWSER=none ntl serve --port 8888' http://localhost:8888 'cross-env ADAPTER=netlify TRAILING_SLASH=always npm run test:template:debug'", + "test": "npm-run-all -c -s test:netlify" + }, + "dependencies": { + "gatsby": "next", + "gatsby-adapter-netlify": "latest", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "cross-env": "^7.0.3", + "cypress": "^12.14.0", + "gatsby-cypress": "^3.11.0", + "netlify-cli": "^15.8.0", + "npm-run-all": "^4.1.5", + "start-server-and-test": "^2.0.0", + "typescript": "^5.1.6" + } +} diff --git a/e2e-tests/adapters/src/api/named-wildcard/[...slug].js b/e2e-tests/adapters/src/api/named-wildcard/[...slug].js new file mode 100644 index 0000000000000..39c741258ffa8 --- /dev/null +++ b/e2e-tests/adapters/src/api/named-wildcard/[...slug].js @@ -0,0 +1,3 @@ +export default function (req, res) { + res.send(`Hello World from ${req.params.slug}`) +} diff --git a/e2e-tests/adapters/src/api/param/[slug].js b/e2e-tests/adapters/src/api/param/[slug].js new file mode 100644 index 0000000000000..39c741258ffa8 --- /dev/null +++ b/e2e-tests/adapters/src/api/param/[slug].js @@ -0,0 +1,3 @@ +export default function (req, res) { + res.send(`Hello World from ${req.params.slug}`) +} diff --git a/e2e-tests/adapters/src/api/static/index.js b/e2e-tests/adapters/src/api/static/index.js new file mode 100644 index 0000000000000..a98959dc324be --- /dev/null +++ b/e2e-tests/adapters/src/api/static/index.js @@ -0,0 +1,3 @@ +export default function (req, res) { + res.send(`Hello World`) +} diff --git a/e2e-tests/adapters/src/api/wildcard/[...].js b/e2e-tests/adapters/src/api/wildcard/[...].js new file mode 100644 index 0000000000000..c519fa4ce7e9d --- /dev/null +++ b/e2e-tests/adapters/src/api/wildcard/[...].js @@ -0,0 +1,3 @@ +export default function (req, res) { + res.send(`Hello World from ${req.params['*']}`) +} diff --git a/e2e-tests/adapters/src/components/footer.jsx b/e2e-tests/adapters/src/components/footer.jsx new file mode 100644 index 0000000000000..a931738313bd9 --- /dev/null +++ b/e2e-tests/adapters/src/components/footer.jsx @@ -0,0 +1,29 @@ +import * as React from "react" +import { useStaticQuery, graphql } from "gatsby" + +const Footer = () => { + const data = useStaticQuery(graphql` + { + site { + siteMetadata { + siteDescription + } + } + } + `) + + return ( + + ) +} + +export default Footer + +const footerStyles = { + color: "#787483", + paddingLeft: 72, + paddingRight: 72, + fontFamily: "-apple-system, Roboto, sans-serif, serif", +} \ No newline at end of file diff --git a/e2e-tests/adapters/src/components/layout.jsx b/e2e-tests/adapters/src/components/layout.jsx new file mode 100644 index 0000000000000..ca1b4584473d2 --- /dev/null +++ b/e2e-tests/adapters/src/components/layout.jsx @@ -0,0 +1,34 @@ +import * as React from "react" +import { Link, Slice } from "gatsby" + +const Layout = ({ children, hideBackToHome = false }) => ( + <> + {!hideBackToHome && ( + + Back to Home + + )} +
+ {children} +
+ + +) + +export default Layout + +const pageStyles = { + color: "#232129", + padding: 72, + fontFamily: "-apple-system, Roboto, sans-serif, serif", +} + +const backToHomeStyle = { + position: "absolute", + textDecoration: "none", + fontFamily: "-apple-system, Roboto, sans-serif, serif", + color: "#232129", + border: '2px solid #8954A8', + padding: '5px 10px', + borderRadius: '8px', +} diff --git a/e2e-tests/adapters/src/images/astro.png b/e2e-tests/adapters/src/images/astro.png new file mode 100644 index 0000000000000..3844bf5975b11 Binary files /dev/null and b/e2e-tests/adapters/src/images/astro.png differ diff --git a/e2e-tests/adapters/src/pages/404.jsx b/e2e-tests/adapters/src/pages/404.jsx new file mode 100644 index 0000000000000..a9c4c826920b1 --- /dev/null +++ b/e2e-tests/adapters/src/pages/404.jsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { Link } from "gatsby" + +const pageStyles = { + color: "#232129", + padding: "96px", + fontFamily: "-apple-system, Roboto, sans-serif, serif", +} + +const headingStyles = { + marginTop: 0, + marginBottom: 64, + maxWidth: 320, +} + +const paragraphStyles = { + marginBottom: 48, +} + + +const NotFoundPage = () => { + return ( +
+

Page not found

+

+ Sorry 😔, we couldn’t find what you were looking for. +
+ Go home. +

+
+ ) +} + +export default NotFoundPage + +export const Head = () => Not found diff --git a/e2e-tests/adapters/src/pages/500.jsx b/e2e-tests/adapters/src/pages/500.jsx new file mode 100644 index 0000000000000..01ebb0f9c7992 --- /dev/null +++ b/e2e-tests/adapters/src/pages/500.jsx @@ -0,0 +1,31 @@ +import * as React from "react" +import { Link } from "gatsby" + +const pageStyles = { + color: "#232129", + padding: "96px", + fontFamily: "-apple-system, Roboto, sans-serif, serif", +} + +const headingStyles = { + marginTop: 0, + marginBottom: 64, + maxWidth: 320, +} + +const paragraphStyles = { + marginBottom: 48, +} + +const InternalServerErrorPage = () => ( +
+

INTERNAL SERVER ERROR

+

+ Go home +

+
+) + +export const Head = () => 500: Internal Server Error + +export default InternalServerErrorPage diff --git a/e2e-tests/adapters/src/pages/index.css b/e2e-tests/adapters/src/pages/index.css new file mode 100644 index 0000000000000..2af5c216e1316 --- /dev/null +++ b/e2e-tests/adapters/src/pages/index.css @@ -0,0 +1,49 @@ +@keyframes float { + 50% { + transform: translateY(24px); + } +} + +.titleStyles { + display: flex; + align-items: center; + flex-direction: row; + color: rgb(21, 21, 22); +} + +.astroWrapper { + transform: rotate(-18deg); + position: absolute; + top: 1.5rem; + right: 1.75rem; +} + +.astro { + max-height: 128px; + animation-name: float; + animation-duration: 5s; + animation-iteration-count: infinite; +} + +.listStyles { + margin-top: 30px; + margin-bottom: 72px; + padding-left: 0; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-gap: 8px; +} + +.listItemStyles { + font-weight: 300; + font-size: 24px; + max-width: 560px; +} + +.linkStyle { + color: #8954a8; + font-weight: bold; + font-size: 16px; + vertical-align: 5%; + text-decoration: none; +} diff --git a/e2e-tests/adapters/src/pages/index.jsx b/e2e-tests/adapters/src/pages/index.jsx new file mode 100644 index 0000000000000..a6c5365026e47 --- /dev/null +++ b/e2e-tests/adapters/src/pages/index.jsx @@ -0,0 +1,105 @@ +import * as React from "react" +import { Link, graphql } from "gatsby" +import Layout from "../components/layout" +import gatsbyAstronaut from "../images/astro.png" +import "./index.css" + +const routes = [ + { + text: "Static", + url: "/routes/static", + id: "static-without-slash" + }, + { + text: "Static (With Slash)", + url: "/routes/static/", + id: "static-with-slash" + }, + { + text: "SSR", + url: "/routes/ssr/static", + }, + { + text: "DSG", + url: "/routes/dsg/static", + }, + { + text: "Sub-Router", + url: "/routes/sub-router", + }, + { + text: "Client-Only Params", + url: "/routes/client-only/dune", + }, + { + text: "Client-Only Wildcard", + url: "/routes/client-only/wildcard/atreides/harkonnen", + }, + { + text: "Client-Only Named Wildcard", + url: "/routes/client-only/named-wildcard/corinno/fenring", + } +] + +const functions = [ + { + text: "Functions (Static)", + url: "/api/static", + }, + { + text: "Functions (Param)", + url: "/api/param/dune", + }, + { + text: "Functions (Wildcard)", + url: "/api/wildcard/atreides/harkonnen", + }, + { + text: "Functions (Named Wildcard)", + url: "/api/named-wildcard/corinno/fenring", + }, +] + +const IndexPage = ({ data }) => { + return ( + +
+ Gatsby Astronaut +
+
+ Gatsby Monogram Logo +

{data.site.siteMetadata.title}

+
+
    + {routes.map(link => ( +
  • + + {link.text} + +
  • + ))} + {functions.map(link => ( +
  • + + {link.text} + +
  • + ))} +
+
+ ) +} + +export default IndexPage + +export const Head = () => Adapters E2E + +export const query = graphql` + { + site { + siteMetadata { + title + } + } + } +` diff --git a/e2e-tests/adapters/src/pages/routes/client-only/[id].jsx b/e2e-tests/adapters/src/pages/routes/client-only/[id].jsx new file mode 100644 index 0000000000000..d369944c269e4 --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/client-only/[id].jsx @@ -0,0 +1,13 @@ +import React from "react" +import Layout from "../../../components/layout" + +const ClientOnlyParams = props => ( + +

client-only

+

{props.params.id}

+
+) + +export const Head = () => Client-Only Params + +export default ClientOnlyParams diff --git a/e2e-tests/adapters/src/pages/routes/client-only/named-wildcard/[...slug].jsx b/e2e-tests/adapters/src/pages/routes/client-only/named-wildcard/[...slug].jsx new file mode 100644 index 0000000000000..d36f7910629fb --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/client-only/named-wildcard/[...slug].jsx @@ -0,0 +1,13 @@ +import React from "react" +import Layout from "../../../../components/layout" + +const ClientOnlyNamedWildcard = props => ( + +

client-only/named-wildcard

+

{props.params.slug}

+
+) + +export const Head = () => Client-Only Named Wildcard + +export default ClientOnlyNamedWildcard diff --git a/e2e-tests/adapters/src/pages/routes/client-only/prioritize/[...].jsx b/e2e-tests/adapters/src/pages/routes/client-only/prioritize/[...].jsx new file mode 100644 index 0000000000000..b19c063843ddf --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/client-only/prioritize/[...].jsx @@ -0,0 +1,13 @@ +import React from "react" +import Layout from "../../../../components/layout" + +const ClientOnlyPrioritizeMatchPath = props => ( + +

client-only/prioritize matchpath

+

{props.params["*"]}

+
+) + +export const Head = () => Client-Only Prioritize Matchpath + +export default ClientOnlyPrioritizeMatchPath diff --git a/e2e-tests/adapters/src/pages/routes/client-only/prioritize/index.jsx b/e2e-tests/adapters/src/pages/routes/client-only/prioritize/index.jsx new file mode 100644 index 0000000000000..a03a354944955 --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/client-only/prioritize/index.jsx @@ -0,0 +1,12 @@ +import React from "react" +import Layout from "../../../../components/layout" + +const ClientOnlyPrioritizeStatic = props => ( + +

client-only/prioritize static

+
+) + +export const Head = () => Client-Only Prioritize static + +export default ClientOnlyPrioritizeStatic diff --git a/e2e-tests/adapters/src/pages/routes/client-only/wildcard/[...].jsx b/e2e-tests/adapters/src/pages/routes/client-only/wildcard/[...].jsx new file mode 100644 index 0000000000000..bb30eeff36a12 --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/client-only/wildcard/[...].jsx @@ -0,0 +1,13 @@ +import React from "react" +import Layout from "../../../../components/layout" + +const ClientOnlyWildcard = props => ( + +

client-only/wildcard

+

{props.params["*"]}

+
+) + +export const Head = () => Client-Only Wildcard + +export default ClientOnlyWildcard diff --git a/e2e-tests/adapters/src/pages/routes/dsg/graphql-query.jsx b/e2e-tests/adapters/src/pages/routes/dsg/graphql-query.jsx new file mode 100644 index 0000000000000..358c8517f12ad --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/dsg/graphql-query.jsx @@ -0,0 +1,34 @@ +import * as React from "react" +import { graphql } from "gatsby" +import Layout from "../../../components/layout" + +const DSGWithGraphQLQuery = ({ data: { site: { siteMetadata } } }) => { + return ( + +

DSG

+

{siteMetadata.title}

+
+ ) +} + +export default DSGWithGraphQLQuery + +export const Head = () => DSG + +export async function config() { + return () => { + return { + defer: true, + } + } +} + +export const query = graphql` + { + site { + siteMetadata { + title + } + } + } +` \ No newline at end of file diff --git a/e2e-tests/adapters/src/pages/routes/dsg/static.jsx b/e2e-tests/adapters/src/pages/routes/dsg/static.jsx new file mode 100644 index 0000000000000..0e964be012d8e --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/dsg/static.jsx @@ -0,0 +1,22 @@ +import * as React from "react" +import Layout from "../../../components/layout" + +const DSG = () => { + return ( + +

DSG

+
+ ) +} + +export default DSG + +export const Head = () => DSG + +export async function config() { + return () => { + return { + defer: true, + } + } +} \ No newline at end of file diff --git a/e2e-tests/adapters/src/pages/routes/redirect/existing.jsx b/e2e-tests/adapters/src/pages/routes/redirect/existing.jsx new file mode 100644 index 0000000000000..24d70c8c9fa7b --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/redirect/existing.jsx @@ -0,0 +1,14 @@ +import * as React from "react" +import Layout from "../../../components/layout" + +const ExistingPage = () => { + return ( + +

Existing

+
+ ) +} + +export default ExistingPage + +export const Head = () => Existing \ No newline at end of file diff --git a/e2e-tests/adapters/src/pages/routes/redirect/hit.jsx b/e2e-tests/adapters/src/pages/routes/redirect/hit.jsx new file mode 100644 index 0000000000000..3d7bdda1fc222 --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/redirect/hit.jsx @@ -0,0 +1,14 @@ +import * as React from "react" +import Layout from "../../../components/layout" + +const HitPage = () => { + return ( + +

Hit

+
+ ) +} + +export default HitPage + +export const Head = () => Hit \ No newline at end of file diff --git a/e2e-tests/adapters/src/pages/routes/ssr/error-path.jsx b/e2e-tests/adapters/src/pages/routes/ssr/error-path.jsx new file mode 100644 index 0000000000000..445d56a97e89e --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/ssr/error-path.jsx @@ -0,0 +1,11 @@ +import React from "react" + +export default function ErrorPath({ serverData }) { + return ( +
This will never render
+ ) +} + +export async function getServerData() { + throw new Error(`Some runtime error`) +} diff --git a/e2e-tests/adapters/src/pages/routes/ssr/param/[param].jsx b/e2e-tests/adapters/src/pages/routes/ssr/param/[param].jsx new file mode 100644 index 0000000000000..0dde25d8589d9 --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/ssr/param/[param].jsx @@ -0,0 +1,30 @@ +import React from "react" + +export default function Params({ serverData }) { + return ( +
+

SSR

+
+ +
+            {JSON.stringify({ serverData }, null, 2)}
+          
+
+
+
+ +
{JSON.stringify(serverData?.arg?.query)}
+
{JSON.stringify(serverData?.arg?.params)}
+
+
+
+ ) +} + +export async function getServerData(arg) { + return { + props: { + arg, + }, + } +} diff --git a/e2e-tests/adapters/src/pages/routes/ssr/static.jsx b/e2e-tests/adapters/src/pages/routes/ssr/static.jsx new file mode 100644 index 0000000000000..971c95cbd4827 --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/ssr/static.jsx @@ -0,0 +1,36 @@ +import * as React from "react" +import Layout from "../../../components/layout" + +const SSR = ({ serverData }) => { + return ( + +

SSR

+
+ +
+            {JSON.stringify({ serverData }, null, 2)}
+          
+
+
+
+ +
{JSON.stringify(serverData?.arg?.query)}
+
{JSON.stringify(serverData?.arg?.params)}
+
+
+
+ ) +} + +export default SSR + +export const Head = () => SSR + +export function getServerData(arg) { + return { + props: { + ssr: true, + arg, + }, + } +} \ No newline at end of file diff --git a/e2e-tests/adapters/src/pages/routes/static.jsx b/e2e-tests/adapters/src/pages/routes/static.jsx new file mode 100644 index 0000000000000..709b5b24559f1 --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/static.jsx @@ -0,0 +1,14 @@ +import * as React from "react" +import Layout from "../../components/layout" + +const StaticPage = () => { + return ( + +

Static

+
+ ) +} + +export default StaticPage + +export const Head = () => Static \ No newline at end of file diff --git a/e2e-tests/adapters/src/pages/routes/sub-router/[...].jsx b/e2e-tests/adapters/src/pages/routes/sub-router/[...].jsx new file mode 100644 index 0000000000000..564bc2e801504 --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/sub-router/[...].jsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { Router } from "@reach/router" +import { Link } from "gatsby" +import Layout from "../../../components/layout" + +const routes = [`/`, `/not-found`, `/page/profile`, `/nested`, `/nested/foo`] +const basePath = `/routes/sub-router` + +const Page = ({ page }) => ( +
[client-only-path] {page}
+) + +const NestedRouterRoute = props => ( + +) + +const PageWithNestedRouter = () => ( + + + + +) + +const NotFound = () => + +const ClientOnlyPathPage = () => ( + + + + + + + +
    + {routes.map(route => ( +
  • + + {route} + +
  • + ))} +
+
+) + +export const Head = () => Sub-Router + +export default ClientOnlyPathPage diff --git a/e2e-tests/adapters/src/pages/routes/sub-router/static.jsx b/e2e-tests/adapters/src/pages/routes/sub-router/static.jsx new file mode 100644 index 0000000000000..2bc278990d864 --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/sub-router/static.jsx @@ -0,0 +1,15 @@ +import * as React from "react" +import Layout from "../../../components/layout" + +const StaticPage = () => { + return ( + +

Static

+
[client-only-path] static-sibling
+
+ ) +} + +export default StaticPage + +export const Head = () => Static \ No newline at end of file diff --git a/e2e-tests/adapters/static/gatsby-icon.png b/e2e-tests/adapters/static/gatsby-icon.png new file mode 100644 index 0000000000000..908bc78a7f559 Binary files /dev/null and b/e2e-tests/adapters/static/gatsby-icon.png differ diff --git a/e2e-tests/adapters/utils.ts b/e2e-tests/adapters/utils.ts new file mode 100644 index 0000000000000..59ac1df851aa8 --- /dev/null +++ b/e2e-tests/adapters/utils.ts @@ -0,0 +1,20 @@ +import type { GatsbyConfig } from "gatsby" + +export const applyTrailingSlashOption = ( + input: string, + // Normally this is "always" but as part of this test suite we default to "never" + option: GatsbyConfig["trailingSlash"] = `never` +): string => { + if (input === `/`) return input + + const hasTrailingSlash = input.endsWith(`/`) + + if (option === `always`) { + return hasTrailingSlash ? input : `${input}/` + } + if (option === `never`) { + return hasTrailingSlash ? input.slice(0, -1) : input + } + + return input +} \ No newline at end of file diff --git a/integration-tests/functions/package.json b/integration-tests/functions/package.json index 3c75ffb5479c3..739ee4c5f3688 100644 --- a/integration-tests/functions/package.json +++ b/integration-tests/functions/package.json @@ -10,7 +10,7 @@ "scripts": { "clean": "gatsby clean", "build": "gatsby build", - "develop": "gatsby develop", + "develop": "GATSBY_PRECOMPILE_DEVELOP_FUNCTIONS=true gatsby develop", "serve": "gatsby serve", "test-prod": "start-server-and-test serve 9000 jest:prod", "test-dev": "start-server-and-test develop 8000 jest:dev", diff --git a/integration-tests/functions/test-helpers.js b/integration-tests/functions/test-helpers.js index b428fc0208151..0c457df8bc215 100644 --- a/integration-tests/functions/test-helpers.js +++ b/integration-tests/functions/test-helpers.js @@ -7,32 +7,47 @@ const FormData = require("form-data") jest.setTimeout(15000) +const FETCH_RETRY_COUNT = 2 +async function fetchWithRetry(...args) { + for (let i = 0; i <= FETCH_RETRY_COUNT; i++) { + try { + const response = await fetch(...args) + return response + } catch (e) { + // ignore unless last retry + if (i === FETCH_RETRY_COUNT) { + throw e + } + } + } +} + export function runTests(env, host) { describe(env, () => { test(`top-level API`, async () => { - const result = await fetch(`${host}/api/top-level`).then(res => + const result = await fetchWithRetry(`${host}/api/top-level`).then(res => res.text() ) expect(result).toMatchSnapshot() }) test(`secondary-level API`, async () => { - const result = await fetch(`${host}/api/a-directory/function`).then(res => - res.text() - ) + const result = await fetchWithRetry( + `${host}/api/a-directory/function` + ).then(res => res.text()) expect(result).toMatchSnapshot() }) test(`secondary-level API with index.js`, async () => { - const result = await fetch(`${host}/api/a-directory`).then(res => + const result = await fetchWithRetry(`${host}/api/a-directory`).then(res => res.text() ) expect(result).toMatchSnapshot() }) test(`secondary-level API`, async () => { - const result = await fetch(`${host}/api/dir/function`).then(res => - res.text() + const result = await fetchWithRetry(`${host}/api/dir/function`).then( + res => res.text() ) expect(result).toMatchSnapshot() @@ -48,7 +63,7 @@ export function runTests(env, host) { ] for (const route of routes) { - const result = await fetch(route).then(res => res.text()) + const result = await fetchWithRetry(route).then(res => res.text()) expect(result).toMatchSnapshot() } @@ -59,7 +74,7 @@ export function runTests(env, host) { const routes = [`${host}/api/users/23/additional`] for (const route of routes) { - const result = await fetch(route).then(res => res.json()) + const result = await fetchWithRetry(route).then(res => res.json()) expect(result).toMatchSnapshot() } @@ -67,14 +82,14 @@ export function runTests(env, host) { test(`unnamed wildcard routes`, async () => { const routes = [`${host}/api/dir/super`] for (const route of routes) { - const result = await fetch(route).then(res => res.json()) + const result = await fetchWithRetry(route).then(res => res.json()) expect(result).toMatchSnapshot() } }) test(`named wildcard routes`, async () => { const route = `${host}/api/named-wildcard/super` - const result = await fetch(route).then(res => res.json()) + const result = await fetchWithRetry(route).then(res => res.json()) expect(result).toMatchInlineSnapshot(` Object { @@ -86,8 +101,8 @@ export function runTests(env, host) { describe(`environment variables`, () => { test(`can use inside functions`, async () => { - const result = await fetch(`${host}/api/env-variables`).then(res => - res.text() + const result = await fetchWithRetry(`${host}/api/env-variables`).then( + res => res.text() ) expect(result).toEqual(`word`) @@ -96,8 +111,8 @@ export function runTests(env, host) { describe(`typescript`, () => { test(`typescript functions work`, async () => { - const result = await fetch(`${host}/api/i-am-typescript`).then(res => - res.text() + const result = await fetchWithRetry(`${host}/api/i-am-typescript`).then( + res => res.text() ) expect(result).toMatchSnapshot() @@ -107,13 +122,15 @@ export function runTests(env, host) { describe(`function errors don't crash the server`, () => { // This test mainly just shows that the server doesn't crash. test(`normal`, async () => { - const result = await fetch(`${host}/api/error-send-function-twice`) + const result = await fetchWithRetry( + `${host}/api/error-send-function-twice` + ) expect(result.status).toEqual(200) }) test(`no handler function export`, async () => { - const result = await fetch(`${host}/api/no-function-export`) + const result = await fetchWithRetry(`${host}/api/no-function-export`) expect(result.status).toEqual(500) const body = await result.text() @@ -128,7 +145,7 @@ export function runTests(env, host) { }) test(`function throws`, async () => { - const result = await fetch(`${host}/api/function-throw`) + const result = await fetchWithRetry(`${host}/api/function-throw`) expect(result.status).toEqual(500) const body = await result.text() @@ -144,7 +161,7 @@ export function runTests(env, host) { describe(`response formats`, () => { test(`returns json correctly`, async () => { - const res = await fetch(`${host}/api/i-am-json`) + const res = await fetchWithRetry(`${host}/api/i-am-json`) const result = await res.json() const { date, ...headers } = Object.fromEntries(res.headers) @@ -152,7 +169,7 @@ export function runTests(env, host) { expect(headers).toMatchSnapshot() }) test(`returns text correctly`, async () => { - const res = await fetch(`${host}/api/i-am-typescript`) + const res = await fetchWithRetry(`${host}/api/i-am-typescript`) const result = await res.text() const { date, ...headers } = Object.fromEntries(res.headers) @@ -163,19 +180,19 @@ export function runTests(env, host) { describe(`functions can send custom statuses`, () => { test(`can return 200 status`, async () => { - const res = await fetch(`${host}/api/status`) + const res = await fetchWithRetry(`${host}/api/status`) expect(res.status).toEqual(200) }) test(`can return 404 status`, async () => { - const res = await fetch(`${host}/api/status?code=404`) + const res = await fetchWithRetry(`${host}/api/status?code=404`) expect(res.status).toEqual(404) }) test(`can return 500 status`, async () => { - const res = await fetch(`${host}/api/status?code=500`) + const res = await fetchWithRetry(`${host}/api/status?code=500`) expect(res.status).toEqual(500) }) @@ -183,9 +200,9 @@ export function runTests(env, host) { describe(`functions can parse different ways of sending data`, () => { test(`query string`, async () => { - const result = await fetch(`${host}/api/parser?amIReal=true`).then( - res => res.json() - ) + const result = await fetchWithRetry( + `${host}/api/parser?amIReal=true` + ).then(res => res.json()) expect(result).toMatchSnapshot() }) @@ -194,7 +211,7 @@ export function runTests(env, host) { const { URLSearchParams } = require("url") const params = new URLSearchParams() params.append("a", `form parameters`) - const result = await fetch(`${host}/api/parser`, { + const result = await fetchWithRetry(`${host}/api/parser`, { method: `POST`, body: params, }).then(res => res.json()) @@ -207,7 +224,7 @@ export function runTests(env, host) { const form = new FormData() form.append("a", `form-data`) - const result = await fetch(`${host}/api/parser`, { + const result = await fetchWithRetry(`${host}/api/parser`, { method: `POST`, body: form, }).then(res => res.json()) @@ -217,7 +234,7 @@ export function runTests(env, host) { test(`json body`, async () => { const body = { a: `json` } - const result = await fetch(`${host}/api/parser`, { + const result = await fetchWithRetry(`${host}/api/parser`, { method: `POST`, body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, @@ -233,7 +250,7 @@ export function runTests(env, host) { const form = new FormData() form.append("file", file) - const result = await fetch(`${host}/api/parser`, { + const result = await fetchWithRetry(`${host}/api/parser`, { method: `POST`, body: form, }).then(res => res.json()) @@ -246,7 +263,7 @@ export function runTests(env, host) { // const { createReadStream } = require("fs") // const stream = createReadStream(path.join(__dirname, "./fixtures/test.txt")) - // const res = await fetch(`${host}/api/parser`, { + // const res = await fetchWithRetry(`${host}/api/parser`, { // method: `POST`, // body: stream, // }) @@ -259,7 +276,7 @@ export function runTests(env, host) { describe(`functions get parsed cookies`, () => { test(`cookie`, async () => { - const result = await fetch(`${host}/api/cookie-me`, { + const result = await fetchWithRetry(`${host}/api/cookie-me`, { headers: { cookie: `foo=blue;` }, }).then(res => res.json()) @@ -269,7 +286,7 @@ export function runTests(env, host) { describe(`functions can redirect`, () => { test(`normal`, async () => { - const result = await fetch(`${host}/api/redirect-me`) + const result = await fetchWithRetry(`${host}/api/redirect-me`) expect(result.url).toEqual(host + `/`) }) @@ -277,7 +294,7 @@ export function runTests(env, host) { describe(`functions can have custom middleware`, () => { test(`normal`, async () => { - const result = await fetch(`${host}/api/cors`) + const result = await fetchWithRetry(`${host}/api/cors`) const headers = Object.fromEntries(result.headers) expect(headers[`access-control-allow-origin`]).toEqual(`*`) @@ -289,7 +306,7 @@ export function runTests(env, host) { describe(`50kb string`, () => { const body = `x`.repeat(50 * 1024) it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -308,7 +325,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { text: { limit: "100mb" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-text-limit`, { method: `POST`, @@ -333,7 +350,7 @@ export function runTests(env, host) { describe(`50mb string`, () => { const body = `x`.repeat(50 * 1024 * 1024) it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -345,7 +362,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { text: { limit: "100mb" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-text-limit`, { method: `POST`, @@ -370,7 +387,7 @@ export function runTests(env, host) { describe(`custom type`, () => { const body = `test-string` it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -389,7 +406,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { text: { type: "*/*" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-text-type`, { method: `POST`, @@ -418,7 +435,7 @@ export function runTests(env, host) { content: `x`.repeat(50 * 1024), }) it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -439,7 +456,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { json: { limit: "100mb" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-json-limit`, { method: `POST`, @@ -468,7 +485,7 @@ export function runTests(env, host) { content: `x`.repeat(50 * 1024 * 1024), }) it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -480,7 +497,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { json: { limit: "100mb" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-json-limit`, { method: `POST`, @@ -509,7 +526,7 @@ export function runTests(env, host) { content: `test-string`, }) it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -528,7 +545,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { json: { type: "*/*" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-json-type`, { method: `POST`, @@ -557,7 +574,7 @@ export function runTests(env, host) { content: `x`.repeat(50 * 1024), }) it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -576,7 +593,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { raw: { limit: "100mb" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-raw-limit`, { method: `POST`, @@ -603,7 +620,7 @@ export function runTests(env, host) { content: `x`.repeat(50 * 1024 * 1024), }) it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -615,7 +632,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { raw: { limit: "100mb" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-raw-limit`, { method: `POST`, @@ -642,7 +659,7 @@ export function runTests(env, host) { content: `test-string`, }) it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -661,7 +678,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { raw: { type: "*/*" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-raw-type`, { method: `POST`, @@ -688,7 +705,7 @@ export function runTests(env, host) { content: `test-string`, }) it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -708,7 +725,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { raw: { type: "*/*" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-raw-type`, { method: `POST`, @@ -743,7 +760,7 @@ export function runTests(env, host) { body.append(`content`, `x`.repeat(50 * 1024)) it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -764,7 +781,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { urlencoded: { limit: "100mb" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-urlencoded-limit`, { method: `POST`, @@ -796,7 +813,7 @@ export function runTests(env, host) { body.append(`content`, `x`.repeat(50 * 1024 * 1024)) it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -808,7 +825,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { urlencoded: { limit: "100mb" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-urlencoded-limit`, { method: `POST`, @@ -840,7 +857,7 @@ export function runTests(env, host) { body.append(`content`, `test-string`) it(`on default config`, async () => { - const result = await fetch(`${host}/api/config/defaults`, { + const result = await fetchWithRetry(`${host}/api/config/defaults`, { method: `POST`, body, headers: { @@ -859,7 +876,7 @@ export function runTests(env, host) { }) it(`on { bodyParser: { urlencoded: { type: "*/*" }}}`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/config/body-parser-urlencoded-type`, { method: `POST`, @@ -885,18 +902,18 @@ export function runTests(env, host) { describe(`plugins can declare functions and they can be shadowed`, () => { test(`shadowing`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/gatsby-plugin-cool/shadowed` ).then(res => res.text()) expect(result).toEqual(`I am shadowed`) - const result2 = await fetch( + const result2 = await fetchWithRetry( `${host}/api/gatsby-plugin-cool/not-shadowed` ).then(res => res.text()) expect(result2).toEqual(`I am not shadowed`) }) test(`plugins can't declare functions outside of their namespace`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/i-will-not-work-cause-namespacing` ) expect(result.status).toEqual(404) @@ -905,8 +922,8 @@ export function runTests(env, host) { describe(`typescript files are resolved without needing to specify their extension`, () => { test(`typescript`, async () => { - const result = await fetch(`${host}/api/extensions`).then(res => - res.text() + const result = await fetchWithRetry(`${host}/api/extensions`).then( + res => res.text() ) expect(result).toEqual(`hi`) }) @@ -914,23 +931,25 @@ export function runTests(env, host) { describe(`ignores files that match the pattern`, () => { test(`dotfile`, async () => { - const result = await fetch(`${host}/api/ignore/.config`) + const result = await fetchWithRetry(`${host}/api/ignore/.config`) expect(result.status).toEqual(404) }) test(`.d.ts file`, async () => { - const result = await fetch(`${host}/api/ignore/foo.d`) + const result = await fetchWithRetry(`${host}/api/ignore/foo.d`) expect(result.status).toEqual(404) }) test(`test file`, async () => { - const result = await fetch(`${host}/api/ignore/hello.test`) + const result = await fetchWithRetry(`${host}/api/ignore/hello.test`) expect(result.status).toEqual(404) }) test(`test directory`, async () => { - const result = await fetch(`${host}/api/ignore/__tests__/hello`) + const result = await fetchWithRetry( + `${host}/api/ignore/__tests__/hello` + ) expect(result.status).toEqual(404) }) test(`test file in plugin`, async () => { - const result = await fetch( + const result = await fetchWithRetry( `${host}/api/gatsby-plugin-cool/shadowed.test` ) expect(result.status).toEqual(404) @@ -939,9 +958,9 @@ export function runTests(env, host) { describe(`bundling`, () => { test(`should succeed when gatsby-core-utils is imported`, async () => { - const result = await fetch(`${host}/api/ignore-lmdb-require`).then( - res => res.text() - ) + const result = await fetchWithRetry( + `${host}/api/ignore-lmdb-require` + ).then(res => res.text()) expect(result).toEqual(`hello world`) }) }) @@ -968,7 +987,7 @@ export function runTests(env, host) { // path.join(apiDir, `function-a.js`) // ) // setTimeout(async () => { - // const result = await fetch( + // const result = await fetchWithRetry( // `${host}/api/function-a` // ).then(res => res.text()) diff --git a/packages/gatsby-adapter-netlify/.babelrc b/packages/gatsby-adapter-netlify/.babelrc new file mode 100644 index 0000000000000..84af48678d3f0 --- /dev/null +++ b/packages/gatsby-adapter-netlify/.babelrc @@ -0,0 +1,10 @@ +{ + "presets": [ + [ + "babel-preset-gatsby-package", + { + "keepDynamicImports": ["./src/index.ts"] + } + ] + ] +} diff --git a/packages/gatsby-adapter-netlify/README.md b/packages/gatsby-adapter-netlify/README.md new file mode 100644 index 0000000000000..945d9fe60f9e8 --- /dev/null +++ b/packages/gatsby-adapter-netlify/README.md @@ -0,0 +1,41 @@ +# gatsby-adapter-netlify + +Gatsby [adapter](https://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/adapters/) for [Netlify](https://www.netlify.com/). + +This adapter enables following features on Netlify: + +- [Redirects](https://www.gatsbyjs.com/docs/reference/config-files/actions/#createRedirect) +- [HTTP Headers](https://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/headers/) +- Application of [default caching headers](https://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/caching/) +- [Deferred Static Generation (DSG)](https://www.gatsbyjs.com/docs/how-to/rendering-options/using-deferred-static-generation/) +- [Server-Side Rendering (SSR)](https://www.gatsbyjs.com/docs/how-to/rendering-options/using-server-side-rendering/) +- [Gatsby Functions](https://www.gatsbyjs.com/docs/reference/functions/) +- Caching of builds between deploys + +This adapter is part of Gatsby's [zero-configuration deployments](https://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/zero-configuration-deployments/) feature and will be installed automatically on Netlify. You can add `gatsby-adapter-netlify` to your `dependencies` and `gatsby-config` to have more robust installs and to be able to change its options. + +## Installation + +```shell +npm install gatsby-adapter-netlify +``` + +## Usage + +Add `gatsby-adapter-netlify` to your [`gatsby-config`](https://www.gatsbyjs.com/docs/reference/config-files/gatsby-config/) and configure the [`adapter`](https://www.gatsbyjs.com/docs/reference/config-files/gatsby-config/#adapter) option. + +```js +const adapter = require("gatsby-adapter-netlify") + +module.exports = { + adapter: adapter({ + excludeDatastoreFromEngineFunction: false, + }), +} +``` + +### Options + +**excludeDatastoreFromEngineFunction** (optional, default: `false`) + +If `true`, Gatsby will not include the LMDB datastore in the serverless functions used for SSR/DSG. Instead, it will upload the datastore to Netlify's CDN and download it on first load of the functions. diff --git a/packages/gatsby-adapter-netlify/package.json b/packages/gatsby-adapter-netlify/package.json new file mode 100644 index 0000000000000..4c371e95b36d8 --- /dev/null +++ b/packages/gatsby-adapter-netlify/package.json @@ -0,0 +1,57 @@ +{ + "name": "gatsby-adapter-netlify", + "version": "1.0.0-next.0", + "description": "Gatsby adapter for Netlify", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "babel src --out-dir dist/ --ignore \"**/__tests__\" --extensions \".ts\"", + "typegen": "rimraf --glob \"dist/**/*.d.ts\" && tsc --emitDeclarationOnly --declaration --declarationDir dist/", + "watch": "babel -w src --out-dir dist/ --ignore \"**/__tests__\" --extensions \".ts\"", + "prepare": "cross-env NODE_ENV=production npm run build && npm run typegen" + }, + "keywords": [ + "gatsby", + "gatsby-plugin", + "gatsby-adapter" + ], + "author": "pieh", + "contributors": [ + "LekoArts" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/gatsbyjs/gatsby.git", + "directory": "packages/gatsby-adapter-netlify" + }, + "homepage": "https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-adapter-netlify#readme", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@netlify/functions": "^1.6.0", + "@netlify/cache-utils": "^5.1.5", + "cookie": "^0.5.0", + "fs-extra": "^11.1.1" + }, + "devDependencies": { + "@babel/cli": "^7.20.7", + "@babel/core": "^7.20.12", + "babel-preset-gatsby-package": "^3.12.0-next.0", + "cross-env": "^7.0.3", + "rimraf": "^5.0.1", + "typescript": "^5.1.6" + }, + "peerDependencies": { + "gatsby": "^5.10.0-alpha" + }, + "files": [ + "dist/" + ], + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/gatsby-adapter-netlify/src/index.ts b/packages/gatsby-adapter-netlify/src/index.ts new file mode 100644 index 0000000000000..65494e7df3dbf --- /dev/null +++ b/packages/gatsby-adapter-netlify/src/index.ts @@ -0,0 +1,117 @@ +import type { AdapterInit, IAdapterConfig } from "gatsby" +import { prepareFunctionVariants } from "./lambda-handler" +import { handleRoutesManifest } from "./route-handler" +import packageJson from "gatsby-adapter-netlify/package.json" + +interface INetlifyCacheUtils { + restore: (paths: Array) => Promise + save: (paths: Array) => Promise +} + +interface INetlifyAdapterOptions { + excludeDatastoreFromEngineFunction?: boolean +} + +let _cacheUtils: INetlifyCacheUtils | undefined +async function getCacheUtils(): Promise { + if (_cacheUtils) { + return _cacheUtils + } + if (process.env.NETLIFY) { + const CACHE_DIR = `/opt/build/cache` + _cacheUtils = (await import(`@netlify/cache-utils`)).bindOpts({ + cacheDir: CACHE_DIR, + }) + + return _cacheUtils + } + return undefined +} + +const createNetlifyAdapter: AdapterInit = options => { + return { + name: `gatsby-adapter-netlify`, + cache: { + async restore({ directories, reporter }): Promise { + const utils = await getCacheUtils() + if (utils) { + reporter.verbose( + `[gatsby-adapter-netlify] using @netlify/cache-utils restore` + ) + return await utils.restore(directories) + } + + return false + }, + async store({ directories, reporter }): Promise { + const utils = await getCacheUtils() + if (utils) { + reporter.verbose( + `[gatsby-adapter-netlify] using @netlify/cache-utils save` + ) + await utils.save(directories) + } + }, + }, + async adapt({ routesManifest, functionsManifest }): Promise { + const { lambdasThatUseCaching } = await handleRoutesManifest( + routesManifest + ) + + // functions handling + for (const fun of functionsManifest) { + await prepareFunctionVariants( + fun, + lambdasThatUseCaching.get(fun.functionId) + ) + } + }, + config: ({ reporter }): IAdapterConfig => { + reporter.verbose( + `[gatsby-adapter-netlify] version: ${packageJson?.version ?? `unknown`}` + ) + // excludeDatastoreFromEngineFunction can be enabled either via options or via env var (to preserve handling of env var that existed in Netlify build plugin). + let excludeDatastoreFromEngineFunction = + options?.excludeDatastoreFromEngineFunction + + if ( + typeof excludeDatastoreFromEngineFunction === `undefined` && + typeof process.env.GATSBY_EXCLUDE_DATASTORE_FROM_BUNDLE !== `undefined` + ) { + excludeDatastoreFromEngineFunction = + process.env.GATSBY_EXCLUDE_DATASTORE_FROM_BUNDLE === `true` || + process.env.GATSBY_EXCLUDE_DATASTORE_FROM_BUNDLE === `1` + } + + if (typeof excludeDatastoreFromEngineFunction === `undefined`) { + excludeDatastoreFromEngineFunction = false + } + + const deployURL = process.env.NETLIFY_LOCAL + ? `http://localhost:8888` + : process.env.DEPLOY_URL + + if (excludeDatastoreFromEngineFunction && !deployURL) { + reporter.warn( + `[gatsby-adapter-netlify] excludeDatastoreFromEngineFunction is set to true but no DEPLOY_URL is set. Disabling excludeDatastoreFromEngineFunction.` + ) + excludeDatastoreFromEngineFunction = false + } + + return { + excludeDatastoreFromEngineFunction, + deployURL, + supports: { + pathPrefix: false, + trailingSlash: [`always`], + }, + pluginsToDisable: [ + `gatsby-plugin-netlify-cache`, + `gatsby-plugin-netlify`, + ], + } + }, + } +} + +export default createNetlifyAdapter diff --git a/packages/gatsby-adapter-netlify/src/lambda-handler.ts b/packages/gatsby-adapter-netlify/src/lambda-handler.ts new file mode 100644 index 0000000000000..ab617a0b7edc7 --- /dev/null +++ b/packages/gatsby-adapter-netlify/src/lambda-handler.ts @@ -0,0 +1,362 @@ +import type { IFunctionDefinition } from "gatsby" +import packageJson from "gatsby-adapter-netlify/package.json" +import fs from "fs-extra" +import * as path from "path" + +interface INetlifyFunctionConfig { + externalNodeModules?: Array + includedFiles?: Array + includedFilesBasePath?: string + ignoredNodeModules?: Array + nodeBundler?: "esbuild" | "esbuild_zisi" | "nft" | "zisi" | "none" + nodeSourcemap?: boolean + nodeVersion?: string + processDynamicNodeImports?: boolean + rustTargetDirectory?: string + schedule?: string + zipGo?: boolean + name?: string + generator?: string + nodeModuleFormat?: "cjs" | "esm" +} + +interface INetlifyFunctionManifest { + config: INetlifyFunctionConfig + version: number +} + +async function prepareFunction( + fun: IFunctionDefinition, + odbfunctionName?: string +): Promise { + let functionId = fun.functionId + let isODB = false + + if (odbfunctionName) { + functionId = odbfunctionName + isODB = true + } + + const internalFunctionsDir = path.join( + process.cwd(), + `.netlify`, + `functions-internal`, + functionId + ) + + await fs.ensureDir(internalFunctionsDir) + + // This is a temporary hacky approach, eventually it should be just `fun.name` + const displayName = isODB + ? `DSG` + : fun.name === `SSR & DSG` + ? `SSR` + : fun.name + + const functionManifest: INetlifyFunctionManifest = { + config: { + name: displayName, + generator: `gatsby-adapter-netlify@${packageJson?.version ?? `unknown`}`, + includedFiles: fun.requiredFiles.map(file => + file.replace(/\[/g, `*`).replace(/]/g, `*`) + ), + externalNodeModules: [`msgpackr-extract`], + }, + version: 1, + } + + await fs.writeJSON( + path.join(internalFunctionsDir, `${functionId}.json`), + functionManifest + ) + + function getRelativePathToModule(modulePath: string): string { + const absolutePath = require.resolve(modulePath) + + return `./` + path.relative(internalFunctionsDir, absolutePath) + } + + const handlerSource = /* javascript */ ` +const Stream = require("stream") +const http = require("http") +const { Buffer } = require("buffer") +const cookie = require("${getRelativePathToModule(`cookie`)}") +${ + isODB + ? `const { builder } = require("${getRelativePathToModule( + `@netlify/functions` + )}")` + : `` +} + +const preferDefault = m => (m && m.default) || m + +const functionModule = require("${getRelativePathToModule( + path.join(process.cwd(), fun.pathToEntryPoint) + )}") + +const functionHandler = preferDefault(functionModule) + +const statuses = { + "100": "Continue", + "101": "Switching Protocols", + "102": "Processing", + "103": "Early Hints", + "200": "OK", + "201": "Created", + "202": "Accepted", + "203": "Non-Authoritative Information", + "204": "No Content", + "205": "Reset Content", + "206": "Partial Content", + "207": "Multi-Status", + "208": "Already Reported", + "226": "IM Used", + "300": "Multiple Choices", + "301": "Moved Permanently", + "302": "Found", + "303": "See Other", + "304": "Not Modified", + "305": "Use Proxy", + "307": "Temporary Redirect", + "308": "Permanent Redirect", + "400": "Bad Request", + "401": "Unauthorized", + "402": "Payment Required", + "403": "Forbidden", + "404": "Not Found", + "405": "Method Not Allowed", + "406": "Not Acceptable", + "407": "Proxy Authentication Required", + "408": "Request Timeout", + "409": "Conflict", + "410": "Gone", + "411": "Length Required", + "412": "Precondition Failed", + "413": "Payload Too Large", + "414": "URI Too Long", + "415": "Unsupported Media Type", + "416": "Range Not Satisfiable", + "417": "Expectation Failed", + "418": "I'm a Teapot", + "421": "Misdirected Request", + "422": "Unprocessable Entity", + "423": "Locked", + "424": "Failed Dependency", + "425": "Too Early", + "426": "Upgrade Required", + "428": "Precondition Required", + "429": "Too Many Requests", + "431": "Request Header Fields Too Large", + "451": "Unavailable For Legal Reasons", + "500": "Internal Server Error", + "501": "Not Implemented", + "502": "Bad Gateway", + "503": "Service Unavailable", + "504": "Gateway Timeout", + "505": "HTTP Version Not Supported", + "506": "Variant Also Negotiates", + "507": "Insufficient Storage", + "508": "Loop Detected", + "509": "Bandwidth Limit Exceeded", + "510": "Not Extended", + "511": "Network Authentication Required" +} + +const createRequestObject = ({ event, context }) => { + const { + path = "", + multiValueQueryStringParameters, + queryStringParameters, + httpMethod, + multiValueHeaders = {}, + body, + isBase64Encoded, + } = event + const newStream = new Stream.Readable() + const req = Object.assign(newStream, http.IncomingMessage.prototype) + req.url = path + req.originalUrl = req.url + req.query = queryStringParameters + req.multiValueQuery = multiValueQueryStringParameters + req.method = httpMethod + req.rawHeaders = [] + req.headers = {} + // Expose Netlify Function event and context on request object. + req.netlifyFunctionParams = { event, context } + for (const key of Object.keys(multiValueHeaders)) { + for (const value of multiValueHeaders[key]) { + req.rawHeaders.push(key, value) + } + req.headers[key.toLowerCase()] = multiValueHeaders[key].toString() + } + req.getHeader = name => req.headers[name.toLowerCase()] + req.getHeaders = () => req.headers + // Gatsby includes cookie middleware + const cookies = req.headers.cookie + if (cookies) { + req.cookies = cookie.parse(cookies) + } + // req.connection = {} + if (body) { + req.push(body, isBase64Encoded ? "base64" : undefined) + } + req.push(null) + return req +} + +const createResponseObject = ({ onResEnd }) => { + const response = { + isBase64Encoded: true, + multiValueHeaders: {}, + }; + const res = new Stream(); + Object.defineProperty(res, 'statusCode', { + get() { + return response.statusCode; + }, + set(statusCode) { + response.statusCode = statusCode; + }, + }); + res.headers = { 'content-type': 'text/html; charset=utf-8' }; + res.writeHead = (status, headers) => { + response.statusCode = status; + if (headers) { + res.headers = Object.assign(res.headers, headers); + } + // Return res object to allow for chaining + // Fixes: https://github.com/netlify/next-on-netlify/pull/74 + return res; + }; + res.write = (chunk) => { + if (!response.body) { + response.body = Buffer.from(''); + } + response.body = Buffer.concat([ + Buffer.isBuffer(response.body) + ? response.body + : Buffer.from(response.body), + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), + ]); + return true; + }; + res.setHeader = (name, value) => { + res.headers[name.toLowerCase()] = value; + return res; + }; + res.removeHeader = (name) => { + delete res.headers[name.toLowerCase()]; + }; + res.getHeader = (name) => res.headers[name.toLowerCase()]; + res.getHeaders = () => res.headers; + res.hasHeader = (name) => Boolean(res.getHeader(name)); + res.end = (text) => { + if (text) + res.write(text); + if (!res.statusCode) { + res.statusCode = 200; + } + if (response.body) { + response.body = Buffer.from(response.body).toString('base64'); + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore These types are a mess, and need sorting out + response.multiValueHeaders = res.headers; + res.writeHead(response.statusCode); + // Convert all multiValueHeaders into arrays + for (const key of Object.keys(response.multiValueHeaders)) { + const header = response.multiValueHeaders[key]; + if (!Array.isArray(header)) { + response.multiValueHeaders[key] = [header]; + } + } + res.finished = true; + res.writableEnded = true; + // Call onResEnd handler with the response object + onResEnd(response); + return res; + }; + // Gatsby Functions additions + res.send = (data) => { + if (res.finished) { + return res; + } + if (typeof data === 'number') { + return res + .status(data) + .setHeader('content-type', 'text/plain; charset=utf-8') + .end(statuses[data] || String(data)); + } + if (typeof data === 'boolean' || typeof data === 'object') { + if (Buffer.isBuffer(data)) { + res.setHeader('content-type', 'application/octet-Stream'); + } + else if (data !== null) { + return res.json(data); + } + } + res.end(data); + return res; + }; + res.json = (data) => { + if (res.finished) { + return res; + } + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(data)); + return res; + }; + res.status = (code) => { + const numericCode = Number.parseInt(code); + if (!Number.isNaN(numericCode)) { + response.statusCode = numericCode; + } + return res; + }; + res.redirect = (statusCodeOrUrl, url) => { + let statusCode = statusCodeOrUrl; + let Location = url; + if (!url && typeof statusCodeOrUrl === 'string') { + Location = statusCodeOrUrl; + statusCode = 302; + } + res.writeHead(statusCode, { Location }); + res.end(); + return res; + }; + return res; +}; + +const handler = async (event, context) => { + const req = createRequestObject({ event, context }) + + return new Promise(async resolve => { + try { + const res = createResponseObject({ onResEnd: resolve }) + await functionHandler(req, res) + } catch(error) { + console.error("Error executing " + event.path, error) + resolve({ statusCode: 500 }) + } + }) +} + +exports.handler = ${isODB ? `builder(handler)` : `handler`} +` + + await fs.writeFile( + path.join(internalFunctionsDir, `${functionId}.js`), + handlerSource + ) +} + +export async function prepareFunctionVariants( + fun: IFunctionDefinition, + odbfunctionName?: string +): Promise { + await prepareFunction(fun) + if (odbfunctionName) { + await prepareFunction(fun, odbfunctionName) + } +} diff --git a/packages/gatsby-adapter-netlify/src/route-handler.ts b/packages/gatsby-adapter-netlify/src/route-handler.ts new file mode 100644 index 0000000000000..17c5b8f346aa4 --- /dev/null +++ b/packages/gatsby-adapter-netlify/src/route-handler.ts @@ -0,0 +1,153 @@ +import type { RoutesManifest } from "gatsby" +import { EOL } from "os" +import fs from "fs-extra" + +const NETLIFY_REDIRECT_KEYWORDS_ALLOWLIST = new Set([ + `query`, + `conditions`, + `headers`, + `signed`, + `edge_handler`, +]) + +const NETLIFY_CONDITIONS_ALLOWLIST = new Set([`language`, `country`]) + +const toNetlifyPath = (fromPath: string, toPath: string): Array => { + // Modifies query parameter redirects, having no effect on other fromPath strings + const netlifyFromPath = fromPath.replace(/[&?]/, ` `) + // Modifies wildcard & splat redirects, having no effect on other toPath strings + const netlifyToPath = toPath.replace(/\*/, `:splat`) + + return [netlifyFromPath, netlifyToPath] +} +const MARKER_START = `# gatsby-adapter-netlify start` +const MARKER_END = `# gatsby-adapter-netlify end` + +async function injectEntries(fileName: string, content: string): Promise { + await fs.ensureFile(fileName) + + const data = await fs.readFile(fileName, `utf8`) + const [initial = ``, rest = ``] = data.split(MARKER_START) + const [, final = ``] = rest.split(MARKER_END) + const out = [ + initial === EOL ? `` : initial, + initial.endsWith(EOL) ? `` : EOL, + MARKER_START, + EOL, + content, + EOL, + MARKER_END, + final.startsWith(EOL) ? `` : EOL, + final === EOL ? `` : final, + ] + .filter(Boolean) + .join(``) + .replace( + /# @netlify\/plugin-gatsby redirects start(.|\n|\r)*# @netlify\/plugin-gatsby redirects end/gm, + `` + ) + .replace(/## Created with gatsby-plugin-netlify(.|\n|\r)*$/gm, ``) + + await fs.outputFile(fileName, out) +} + +export async function handleRoutesManifest( + routesManifest: RoutesManifest +): Promise<{ + lambdasThatUseCaching: Map +}> { + const lambdasThatUseCaching = new Map() + + let _redirects = `` + let _headers = `` + for (const route of routesManifest) { + const fromPath = route.path.replace(/\*.*/, `*`) + + if (route.type === `function`) { + let functionName = route.functionId + if (route.cache) { + functionName = `${route.functionId}-odb` + if (!lambdasThatUseCaching.has(route.functionId)) { + lambdasThatUseCaching.set(route.functionId, functionName) + } + } + + const invocationURL = `/.netlify/${ + route.cache ? `builders` : `functions` + }/${functionName}` + _redirects += `${encodeURI(fromPath)} ${invocationURL} 200\n` + } else if (route.type === `redirect`) { + const { + status: routeStatus, + toPath, + force, + // TODO: add headers handling + headers, + ...rest + } = route + let status = String(routeStatus) + + if (force) { + status = `${status}!` + } + + const [netlifyFromPath, netlifyToPath] = toNetlifyPath(fromPath, toPath) + + // The order of the first 3 parameters is significant. + // The order for rest params (key-value pairs) is arbitrary. + const pieces = [netlifyFromPath, netlifyToPath, status] + + for (const [key, value] of Object.entries(rest)) { + if (NETLIFY_REDIRECT_KEYWORDS_ALLOWLIST.has(key)) { + if (key === `conditions`) { + // "conditions" key from Gatsby contains only "language" and "country" + // which need special transformation to match Netlify _redirects + // https://www.gatsbyjs.com/docs/reference/config-files/actions/#createRedirect + if (value && typeof value === `object`) { + for (const [conditionKey, conditionValueRaw] of Object.entries( + value + )) { + if (NETLIFY_CONDITIONS_ALLOWLIST.has(conditionKey)) { + const conditionValue = Array.isArray(conditionValueRaw) + ? conditionValueRaw.join(`,`) + : conditionValueRaw + // Gatsby gives us "country", we want "Country" + const conditionName = + conditionKey.charAt(0).toUpperCase() + conditionKey.slice(1) + + pieces.push(`${conditionName}:${conditionValue}`) + } + } + } + } else { + pieces.push(`${key}=${value}`) + } + } + } + _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( + /^public/, + `` + )} 200\n` + } + + _headers += `${encodeURI(fromPath)}\n${route.headers.reduce( + (acc, curr) => { + acc += ` ${curr.key}: ${curr.value}\n` + return acc + }, + `` + )}` + } + } + + await injectEntries(`public/_redirects`, _redirects) + await injectEntries(`public/_headers`, _headers) + + return { + lambdasThatUseCaching, + } +} diff --git a/packages/gatsby-adapter-netlify/tsconfig.json b/packages/gatsby-adapter-netlify/tsconfig.json new file mode 100644 index 0000000000000..9f57aee168802 --- /dev/null +++ b/packages/gatsby-adapter-netlify/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"], + "exclude": ["node_modules", "src/__tests__", "dist"], +} diff --git a/packages/gatsby-cli/src/structured-errors/error-map.ts b/packages/gatsby-cli/src/structured-errors/error-map.ts index 47b18bb7af70e..53494e9fe9786 100644 --- a/packages/gatsby-cli/src/structured-errors/error-map.ts +++ b/packages/gatsby-cli/src/structured-errors/error-map.ts @@ -918,6 +918,25 @@ const errors: Record = { category: ErrorCategory.USER, docsUrl: `https://gatsby.dev/graphql-typegen`, }, + // Gatsby Adapters + "12200": { + text: (): string => + `Tried to create adapter routes for webpack assets but failed. If the issue persists, please open an issue with a reproduction at https://gatsby.dev/bug-report for more help.`, + level: Level.ERROR, + type: Type.ADAPTER, + category: ErrorCategory.SYSTEM, + }, + "12201": { + text: (context): string => + `Adapter "${ + context.adapterName + }" is not compatible with following settings:\n${context.incompatibleFeatures + .map(line => ` - ${line}`) + .join(`\n`)}`, + level: Level.ERROR, + type: Type.ADAPTER, + category: ErrorCategory.THIRD_PARTY, + }, // Partial hydration "80000": { text: (context): string => diff --git a/packages/gatsby-cli/src/structured-errors/types.ts b/packages/gatsby-cli/src/structured-errors/types.ts index 1383b50f5666b..9557279a0e7b7 100644 --- a/packages/gatsby-cli/src/structured-errors/types.ts +++ b/packages/gatsby-cli/src/structured-errors/types.ts @@ -82,6 +82,7 @@ export enum Type { FUNCTIONS_COMPILATION = `FUNCTIONS.COMPILATION`, FUNCTIONS_EXECUTION = `FUNCTIONS.EXECUTION`, CLI_VALIDATION = `CLI.VALIDATION`, + ADAPTER = `ADAPTER`, // webpack errors for each stage enum: packages/gatsby/src/commands/types.ts WEBPACK_DEVELOP = `WEBPACK.DEVELOP`, WEBPACK_DEVELOP_HTML = `WEBPACK.DEVELOP-HTML`, diff --git a/packages/gatsby-page-utils/src/__tests__/apply-trailing-slash-option.ts b/packages/gatsby-page-utils/src/__tests__/apply-trailing-slash-option.ts index 92b8bd877c951..c22ce92f43b2f 100644 --- a/packages/gatsby-page-utils/src/__tests__/apply-trailing-slash-option.ts +++ b/packages/gatsby-page-utils/src/__tests__/apply-trailing-slash-option.ts @@ -8,6 +8,14 @@ describe(`applyTrailingSlashOption`, () => { it(`returns / for root index page`, () => { expect(applyTrailingSlashOption(indexPage)).toEqual(indexPage) }) + it(`should leave non-trailing paths for certain suffixes`, () => { + expect(applyTrailingSlashOption(`/nested/path.html`)).toEqual( + `/nested/path.html` + ) + expect(applyTrailingSlashOption(`/nested/path.xml`)).toEqual( + `/nested/path.xml` + ) + }) describe(`always`, () => { it(`should add trailing slash`, () => { expect(applyTrailingSlashOption(withoutSlash, `always`)).toEqual( diff --git a/packages/gatsby-page-utils/src/apply-trailing-slash-option.ts b/packages/gatsby-page-utils/src/apply-trailing-slash-option.ts index 4a7266a52d568..ae91cd26059a0 100644 --- a/packages/gatsby-page-utils/src/apply-trailing-slash-option.ts +++ b/packages/gatsby-page-utils/src/apply-trailing-slash-option.ts @@ -1,22 +1,30 @@ export type TrailingSlash = "always" | "never" | "ignore" +const endsWithSuffixes = (suffixes: Array, input): boolean => { + for (const suffix of suffixes) { + if (input.endsWith(suffix)) return true + } + return false +} + +const suffixes = [`.html`, `.json`, `.js`, `.map`, `.txt`, `.xml`, `.pdf`] + export const applyTrailingSlashOption = ( input: string, option: TrailingSlash = `always` ): string => { - const hasHtmlSuffix = input.endsWith(`.html`) - const hasXmlSuffix = input.endsWith(`.xml`) - const hasPdfSuffix = input.endsWith(`.pdf`) - if (input === `/`) return input - if (hasHtmlSuffix || hasXmlSuffix || hasPdfSuffix) { - option = `never` + + const hasTrailingSlash = input.endsWith(`/`) + + if (endsWithSuffixes(suffixes, input)) { + return input } if (option === `always`) { - return input.endsWith(`/`) ? input : `${input}/` + return hasTrailingSlash ? input : `${input}/` } if (option === `never`) { - return input.endsWith(`/`) ? input.slice(0, -1) : input + return hasTrailingSlash ? input.slice(0, -1) : input } return input diff --git a/packages/gatsby-plugin-image/tsconfig.json b/packages/gatsby-plugin-image/tsconfig.json index adcd64efa9520..cd53d61feda91 100644 --- a/packages/gatsby-plugin-image/tsconfig.json +++ b/packages/gatsby-plugin-image/tsconfig.json @@ -11,7 +11,8 @@ "esModuleInterop": true, "skipLibCheck": true, "module": "ESNext", - "moduleResolution": "node" + "moduleResolution": "node", + "downlevelIteration": true // "jsxFactory": "createElement" }, "files": ["./src/global.ts", "./src/index.ts", "./src/index.browser.ts"] diff --git a/packages/gatsby/.gitignore b/packages/gatsby/.gitignore index dba058b704eb1..e4ab68e97a3a5 100644 --- a/packages/gatsby/.gitignore +++ b/packages/gatsby/.gitignore @@ -35,3 +35,4 @@ cache-dir/commonjs/ # cached files /latest-apis.json +/latest-adapters.js diff --git a/packages/gatsby/adapters.js b/packages/gatsby/adapters.js new file mode 100644 index 0000000000000..ac0dc9bc1c641 --- /dev/null +++ b/packages/gatsby/adapters.js @@ -0,0 +1,27 @@ +// @ts-check + +/** + * List of adapters that should be automatically installed if not present already. + * The first item which test function returns `true` will be used. + * + * If you're the author of an adapter and want to add it to this list, please open a PR! + * If you want to create an adapter, please see: http://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/creating-an-adapter/ + * + * @type {Array} + * @see http://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/zero-configuration-deployments/ + */ +const adaptersManifest = [ + { + name: `Netlify`, + module: `gatsby-adapter-netlify`, + test: () => !!process.env.NETLIFY || !!process.env.NETLIFY_LOCAL, + versions: [ + { + gatsbyVersion: `^5.0.0`, + moduleVersion: `^1.0.0-alpha`, + } + ], + } +] + +module.exports = adaptersManifest diff --git a/packages/gatsby/babel.config.js b/packages/gatsby/babel.config.js index ba3256026d691..9a2ea474eb6ce 100644 --- a/packages/gatsby/babel.config.js +++ b/packages/gatsby/babel.config.js @@ -11,7 +11,8 @@ module.exports = { `./src/bootstrap/get-config-file.ts`, `./src/bootstrap/resolve-module-exports.ts`, `./src/bootstrap/load-plugins/validate.ts`, - `./src/utils/import-gatsby-plugin.ts` + `./src/utils/adapter/init.ts`, + `./src/utils/import-gatsby-plugin.ts`, ] }]], } diff --git a/packages/gatsby/index.d.ts b/packages/gatsby/index.d.ts index 14d700d3ed84f..00bb8ed93ef5f 100644 --- a/packages/gatsby/index.d.ts +++ b/packages/gatsby/index.d.ts @@ -22,6 +22,7 @@ export type AvailableFeatures = | "graphql-typegen" | "content-file-path" | "stateful-source-nodes" + | "adapters" export { Link, @@ -33,6 +34,18 @@ export { export * from "gatsby-script" +export { + AdapterInit, + IAdapter, + IStaticRoute, + IFunctionRoute, + IRedirectRoute, + IFunctionDefinition, + RoutesManifest, + FunctionsManifest, + IAdapterConfig, +} from "./dist/utils/adapter/types" + export const useScrollRestoration: (key: string) => { ref: React.MutableRefObject onScroll(): void @@ -318,6 +331,20 @@ type Proxy = { url: string } +type Header = { + /** + * The path to match requests against. + */ + source: string + /** + * Your custom response headers. + */ + headers: Array<{ + key: string + value: string + }> +} + /** * Gatsby configuration API. * @@ -355,6 +382,16 @@ export interface GatsbyConfig { partytownProxiedURLs?: Array /** Sometimes you need more granular/flexible access to the development server. Gatsby exposes the Express.js development server to your site’s gatsby-config.js where you can add Express middleware as needed. */ developMiddleware?(app: any): void + /** + * You can set custom HTTP headers on the response of a given path. This allows you to, e.g. modify the caching behavior or configure access control. You can apply HTTP headers to static routes and redirects. + * @see http://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/headers/ + */ + headers?: Array
+ /** + * Adapters are responsible for taking the production output from Gatsby and turning it into something your deployment platform understands. They make it easier to build and deploy Gatsby on any deployment platform. + * @see http://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/adapters/ + */ + adapter?: IAdapter } /** diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index ddec7989b8de7..1738208dfb1c1 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -52,6 +52,7 @@ "babel-preset-gatsby": "^3.12.0-next.1", "better-opn": "^2.1.1", "bluebird": "^3.7.2", + "body-parser": "1.20.1", "browserslist": "^4.21.9", "cache-manager": "^2.11.1", "chalk": "^4.1.2", @@ -118,6 +119,7 @@ "joi": "^17.9.2", "json-loader": "^0.5.7", "latest-version": "^7.0.0", + "linkfs": "^2.1.0", "lmdb": "2.5.3", "lodash": "^4.17.21", "meant": "^1.0.3", @@ -213,6 +215,7 @@ "node": ">=18.0.0" }, "files": [ + "adapters.js", "apis.json", "ipc.json", "cache-dir/", diff --git a/packages/gatsby/scripts/__tests__/api.js b/packages/gatsby/scripts/__tests__/api.js index c3bd68d38e038..dc1c6ce7ca41e 100644 --- a/packages/gatsby/scripts/__tests__/api.js +++ b/packages/gatsby/scripts/__tests__/api.js @@ -36,6 +36,7 @@ it("generates the expected api output", done => { "content-file-path", "slices", "stateful-source-nodes", + "adapters", ], "node": Object { "createPages": Object {}, diff --git a/packages/gatsby/scripts/output-api-file.js b/packages/gatsby/scripts/output-api-file.js index 8863f26421d7a..8f3cf1b5f7c32 100644 --- a/packages/gatsby/scripts/output-api-file.js +++ b/packages/gatsby/scripts/output-api-file.js @@ -41,7 +41,7 @@ async function outputFile() { }, {}) /** @type {Array} */ - output.features = ["image-cdn", "graphql-typegen", "content-file-path", "slices", "stateful-source-nodes"]; + output.features = ["image-cdn", "graphql-typegen", "content-file-path", "slices", "stateful-source-nodes", "adapters"]; return fs.writeFile( path.resolve(OUTPUT_FILE_NAME), diff --git a/packages/gatsby/scripts/postinstall.js b/packages/gatsby/scripts/postinstall.js index a6667923f6041..ea53766964b27 100644 --- a/packages/gatsby/scripts/postinstall.js +++ b/packages/gatsby/scripts/postinstall.js @@ -1,6 +1,7 @@ try { - const { getLatestAPIs } = require('../dist/utils/get-latest-apis') + const { getLatestAPIs, getLatestAdapters } = require('../dist/utils/get-latest-gatsby-files') getLatestAPIs() + getLatestAdapters() } catch (e) { // we're probably just bootstrapping and not published yet! } diff --git a/packages/gatsby/src/bootstrap/index.ts b/packages/gatsby/src/bootstrap/index.ts index 31efa1cea8132..51e26407a7d76 100644 --- a/packages/gatsby/src/bootstrap/index.ts +++ b/packages/gatsby/src/bootstrap/index.ts @@ -18,6 +18,7 @@ import type { GatsbyWorkerPool } from "../utils/worker/pool" import { handleStalePageData } from "../utils/page-data" import { savePartialStateToDisk } from "../redux" import { IProgram } from "../commands/types" +import type { IAdapterManager } from "../utils/adapter/types" const tracer = globalTracer() @@ -26,6 +27,7 @@ export async function bootstrap( ): Promise<{ gatsbyNodeGraphQLFunction: Runner workerPool: GatsbyWorkerPool + adapterManager?: IAdapterManager }> { const spanArgs = initialContext.parentSpan ? { childOf: initialContext.parentSpan } @@ -86,5 +88,6 @@ export async function bootstrap( return { gatsbyNodeGraphQLFunction: context.gatsbyNodeGraphQLFunction, workerPool, + adapterManager: context.adapterManager, } } diff --git a/packages/gatsby/src/bootstrap/load-plugins/__tests__/validate.ts b/packages/gatsby/src/bootstrap/load-plugins/__tests__/validate.ts index d27e9601bcf02..21d3b48e9f239 100644 --- a/packages/gatsby/src/bootstrap/load-plugins/__tests__/validate.ts +++ b/packages/gatsby/src/bootstrap/load-plugins/__tests__/validate.ts @@ -6,7 +6,7 @@ jest.mock(`gatsby-cli/lib/reporter`, () => { } }) jest.mock(`../../resolve-module-exports`) -jest.mock(`../../../utils/get-latest-apis`) +jest.mock(`../../../utils/get-latest-gatsby-files`) import reporter from "gatsby-cli/lib/reporter" import { @@ -18,7 +18,7 @@ import { ExportType, IEntry, } from "../validate" -import { getLatestAPIs } from "../../../utils/get-latest-apis" +import { getLatestAPIs } from "../../../utils/get-latest-gatsby-files" import { resolveModuleExports } from "../../resolve-module-exports" beforeEach(() => { diff --git a/packages/gatsby/src/bootstrap/load-plugins/index.ts b/packages/gatsby/src/bootstrap/load-plugins/index.ts index 36696adf4f747..bdeb46afdd72c 100644 --- a/packages/gatsby/src/bootstrap/load-plugins/index.ts +++ b/packages/gatsby/src/bootstrap/load-plugins/index.ts @@ -1,3 +1,4 @@ +import reporter from "gatsby-cli/lib/reporter" import { store } from "../../redux" import { IGatsbyState } from "../../redux/types" import * as nodeAPIs from "../../utils/api-node-docs" @@ -38,11 +39,31 @@ export async function loadPlugins( // Create a flattened array of the plugins const pluginArray = flattenPlugins(pluginInfos) + const { disablePlugins } = store.getState().program + const pluginArrayWithoutDisabledPlugins = pluginArray.filter(plugin => { + const disabledInfo = disablePlugins?.find( + entry => entry.name === plugin.name + ) + + if (disabledInfo) { + if (!process.env.GATSBY_WORKER_ID) { + // show this warning only once in main process + reporter.warn( + `Disabling plugin "${plugin.name}":\n${disabledInfo.reasons + .map(line => ` - ${line}`) + .join(`\n`)}` + ) + } + return false + } + return true + }) + // Work out which plugins use which APIs, including those which are not // valid Gatsby APIs, aka 'badExports' let { flattenedPlugins, badExports } = await collatePluginAPIs({ currentAPIs, - flattenedPlugins: pluginArray, + flattenedPlugins: pluginArrayWithoutDisabledPlugins, rootDir, }) diff --git a/packages/gatsby/src/bootstrap/load-plugins/validate.ts b/packages/gatsby/src/bootstrap/load-plugins/validate.ts index 6bca15195da53..059a38d72775a 100644 --- a/packages/gatsby/src/bootstrap/load-plugins/validate.ts +++ b/packages/gatsby/src/bootstrap/load-plugins/validate.ts @@ -10,7 +10,7 @@ import { stripIndent } from "common-tags" import { trackCli } from "gatsby-telemetry" import { isWorker } from "gatsby-worker" import { resolveModuleExports } from "../resolve-module-exports" -import { getLatestAPIs } from "../../utils/get-latest-apis" +import { getLatestAPIs } from "../../utils/get-latest-gatsby-files" import { GatsbyNode, PackageJson } from "../../../" import { IPluginInfo, diff --git a/packages/gatsby/src/bootstrap/requires-writer.ts b/packages/gatsby/src/bootstrap/requires-writer.ts index 53f3dff141508..7f596eed42282 100644 --- a/packages/gatsby/src/bootstrap/requires-writer.ts +++ b/packages/gatsby/src/bootstrap/requires-writer.ts @@ -12,6 +12,10 @@ import { } from "../utils/gatsby-webpack-virtual-modules" import { getPageMode } from "../utils/page-mode" import { devSSRWillInvalidate } from "../commands/build-html" +import { rankRoute } from "../utils/rank-route" + +const hasContentFilePath = (componentPath: string): boolean => + componentPath.includes(`?__contentFilePath=`) interface IGatsbyPageComponent { componentPath: string @@ -24,39 +28,6 @@ interface IGatsbyPageMatchPath { matchPath: string | undefined } -// path ranking algorithm copied (with small adjustments) from `@reach/router` (internal util, not exported from the package) -// https://github.com/reach/router/blob/28a79e7fc3a3487cb3304210dc3501efb8a50eba/src/lib/utils.js#L216-L254 -const paramRe = /^:(.+)/ - -const SEGMENT_POINTS = 4 -const STATIC_POINTS = 3 -const DYNAMIC_POINTS = 2 -const SPLAT_PENALTY = 1 -const ROOT_POINTS = 1 - -const isRootSegment = (segment: string): boolean => segment === `` -const isDynamic = (segment: string): boolean => paramRe.test(segment) -const isSplat = (segment: string): boolean => segment === `*` -const hasContentFilePath = (componentPath: string): boolean => - componentPath.includes(`?__contentFilePath=`) - -const segmentize = (uri: string): Array => - uri - // strip starting/ending slashes - .replace(/(^\/+|\/+$)/g, ``) - .split(`/`) - -const rankRoute = (path: string): number => - segmentize(path).reduce((score, segment) => { - score += SEGMENT_POINTS - if (isRootSegment(segment)) score += ROOT_POINTS - else if (isDynamic(segment)) score += DYNAMIC_POINTS - else if (isSplat(segment)) score -= SEGMENT_POINTS + SPLAT_PENALTY - else score += STATIC_POINTS - return score - }, 0) -// end of copied `@reach/router` internals - let lastHash: string | null = null export const resetLastHash = (): void => { diff --git a/packages/gatsby/src/commands/build.ts b/packages/gatsby/src/commands/build.ts index f8f58c7bc89ff..05dbc1f7bb2a6 100644 --- a/packages/gatsby/src/commands/build.ts +++ b/packages/gatsby/src/commands/build.ts @@ -134,10 +134,11 @@ module.exports = async function build( }) } - const { gatsbyNodeGraphQLFunction, workerPool } = await bootstrap({ - program, - parentSpan: buildSpan, - }) + const { gatsbyNodeGraphQLFunction, workerPool, adapterManager } = + await bootstrap({ + program, + parentSpan: buildSpan, + }) await apiRunnerNode(`onPreBuild`, { graphql: gatsbyNodeGraphQLFunction, @@ -696,6 +697,11 @@ module.exports = async function build( report.info(`.cache/deletedPages.txt created`) } + if (adapterManager) { + await adapterManager.adapt() + await adapterManager.storeCache() + } + showExperimentNotices() if (await userGetsSevenDayFeedback()) { diff --git a/packages/gatsby/src/commands/serve.ts b/packages/gatsby/src/commands/serve.ts index c4a79fdab3617..3ce95bb8fa96c 100644 --- a/packages/gatsby/src/commands/serve.ts +++ b/packages/gatsby/src/commands/serve.ts @@ -172,14 +172,15 @@ module.exports = async (program: IServeProgram): Promise => { } if (functions) { - app.use( - `/api/*`, - ...functionMiddlewares({ - getFunctions(): Array { - return functions - }, - }) - ) + const functionMiddlewaresInstances = functionMiddlewares({ + getFunctions(): Array { + return functions + }, + }) + + router.use(`/api/*`, ...functionMiddlewaresInstances) + // TODO(v6) remove handler from app and only keep it on router (router is setup on pathPrefix, while app is always root) + app.use(`/api/*`, ...functionMiddlewaresInstances) } // Handle SSR & DSG Pages @@ -223,7 +224,7 @@ module.exports = async (program: IServeProgram): Promise => { spanContext, }) const results = await renderPageData({ data, spanContext }) - if (page.mode === `SSR` && data.serverDataHeaders) { + if (data.serverDataHeaders) { for (const [name, value] of Object.entries( data.serverDataHeaders )) { @@ -273,7 +274,7 @@ module.exports = async (program: IServeProgram): Promise => { spanContext, }) const results = await renderHTML({ data, spanContext }) - if (page.mode === `SSR` && data.serverDataHeaders) { + if (data.serverDataHeaders) { for (const [name, value] of Object.entries( data.serverDataHeaders )) { diff --git a/packages/gatsby/src/commands/types.ts b/packages/gatsby/src/commands/types.ts index 5fd0afe9737f8..9f481af90fd90 100644 --- a/packages/gatsby/src/commands/types.ts +++ b/packages/gatsby/src/commands/types.ts @@ -35,6 +35,10 @@ export interface IProgram { verbose?: boolean prefixPaths?: boolean setStore?: (store: Store) => void + disablePlugins?: Array<{ + name: string + reasons: Array + }> } /** diff --git a/packages/gatsby/src/datastore/lmdb/lmdb-datastore.ts b/packages/gatsby/src/datastore/lmdb/lmdb-datastore.ts index e9908afcea6a3..3a567cb1dc9cf 100644 --- a/packages/gatsby/src/datastore/lmdb/lmdb-datastore.ts +++ b/packages/gatsby/src/datastore/lmdb/lmdb-datastore.ts @@ -29,7 +29,7 @@ const lmdbDatastore = { const preSyncDeletedNodeIdsCache = new Set() -function getDefaultDbPath(): string { +export function getDefaultDbPath(): string { const dbFileName = process.env.NODE_ENV === `test` ? `test-datastore-${ diff --git a/packages/gatsby/src/internal-plugins/functions/__tests__/config.ts b/packages/gatsby/src/internal-plugins/functions/__tests__/config.ts index 2cd8fa6bf7471..36823933d2784 100644 --- a/packages/gatsby/src/internal-plugins/functions/__tests__/config.ts +++ b/packages/gatsby/src/internal-plugins/functions/__tests__/config.ts @@ -1,4 +1,5 @@ import { createConfig } from "../config" +import { printConfigWarnings } from "../middleware" import reporter from "gatsby-cli/lib/reporter" import type { IGatsbyFunction } from "../../../redux/types" const reporterWarnSpy = jest.spyOn(reporter, `warn`) @@ -7,6 +8,15 @@ beforeEach(() => { reporterWarnSpy.mockReset() }) +function createConfigAndPrintWarnings( + userConfig: any, + functionObj: IGatsbyFunction +): any { + const { config, warnings } = createConfig(userConfig) + printConfigWarnings(warnings, functionObj) + return config +} + const testFunction: IGatsbyFunction = { functionRoute: `a-directory/function`, matchPath: undefined, @@ -15,11 +25,13 @@ const testFunction: IGatsbyFunction = { originalRelativeFilePath: `a-directory/function.js`, relativeCompiledFilePath: `a-directory/function.js`, absoluteCompiledFilePath: `/Users/misiek/dev/functions-test/.cache/functions/a-directory/function.js`, + functionId: `a-directory/function`, } -describe(`createConfig`, () => { +describe(`createConfigAndPrintWarnings`, () => { it(`defaults`, () => { - expect(createConfig(undefined, testFunction)).toMatchInlineSnapshot(` + expect(createConfigAndPrintWarnings(undefined, testFunction)) + .toMatchInlineSnapshot(` Object { "bodyParser": Object { "json": Object { @@ -43,7 +55,7 @@ describe(`createConfig`, () => { describe(`input not matching schema (fallback to default and warnings)`, () => { it(`{ bodyParser: false }`, () => { - expect(createConfig({ bodyParser: false }, testFunction)) + expect(createConfigAndPrintWarnings({ bodyParser: false }, testFunction)) .toMatchInlineSnapshot(` Object { "bodyParser": Object { @@ -101,7 +113,7 @@ describe(`createConfig`, () => { }) it(`{ bodyParser: "foo" }`, () => { - expect(createConfig({ bodyParser: `foo` }, testFunction)) + expect(createConfigAndPrintWarnings({ bodyParser: `foo` }, testFunction)) .toMatchInlineSnapshot(` Object { "bodyParser": Object { @@ -159,7 +171,7 @@ describe(`createConfig`, () => { }) it(`{ unrelated: true }`, () => { - expect(createConfig({ unrelated: true }, testFunction)) + expect(createConfigAndPrintWarnings({ unrelated: true }, testFunction)) .toMatchInlineSnapshot(` Object { "bodyParser": Object { @@ -216,8 +228,12 @@ describe(`createConfig`, () => { }) it(`{ bodyParser: { unrelated: true } }`, () => { - expect(createConfig({ bodyParser: { unrelated: true } }, testFunction)) - .toMatchInlineSnapshot(` + expect( + createConfigAndPrintWarnings( + { bodyParser: { unrelated: true } }, + testFunction + ) + ).toMatchInlineSnapshot(` Object { "bodyParser": Object { "json": Object { @@ -280,7 +296,7 @@ describe(`createConfig`, () => { const customTextConfig = { limit: `1mb`, } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { text: customTextConfig, @@ -300,7 +316,7 @@ describe(`createConfig`, () => { const customTextConfig = { type: `lorem/*`, } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { text: customTextConfig, @@ -318,7 +334,7 @@ describe(`createConfig`, () => { it(`input not matching schema (fallback to default) - not an config object`, () => { const customTextConfig = `foo` - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { text: customTextConfig, @@ -355,7 +371,7 @@ describe(`createConfig`, () => { it(`input not matching schema (fallback to default) - config object not matching schema`, () => { const customTextConfig = { wat: true } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { text: customTextConfig, @@ -396,7 +412,7 @@ describe(`createConfig`, () => { const customTextConfig = { limit: `1mb`, } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { json: customTextConfig, @@ -417,7 +433,7 @@ describe(`createConfig`, () => { const customTextConfig = { type: `lorem/*`, } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { json: customTextConfig, @@ -435,7 +451,7 @@ describe(`createConfig`, () => { it(`input not matching schema (fallback to default) - not an config object`, () => { const customTextConfig = `foo` - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { json: customTextConfig, @@ -473,7 +489,7 @@ describe(`createConfig`, () => { it(`input not matching schema (fallback to default) - config object not matching schema`, () => { const customTextConfig = { wat: true } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { json: customTextConfig, @@ -515,7 +531,7 @@ describe(`createConfig`, () => { const customTextConfig = { limit: `1mb`, } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { raw: customTextConfig, @@ -535,7 +551,7 @@ describe(`createConfig`, () => { const customTextConfig = { type: `lorem/*`, } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { raw: customTextConfig, @@ -553,7 +569,7 @@ describe(`createConfig`, () => { it(`input not matching schema (fallback to default) - not an config object`, () => { const customTextConfig = `foo` - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { raw: customTextConfig, @@ -591,7 +607,7 @@ describe(`createConfig`, () => { it(`input not matching schema (fallback to default) - config object not matching schema`, () => { const customTextConfig = { wat: true } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { raw: customTextConfig, @@ -634,7 +650,7 @@ describe(`createConfig`, () => { limit: `1mb`, extended: true, } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { urlencoded: customTextConfig, @@ -656,7 +672,7 @@ describe(`createConfig`, () => { type: `lorem/*`, extended: true, } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { urlencoded: customTextConfig, @@ -675,7 +691,7 @@ describe(`createConfig`, () => { it(`input not matching schema (fallback to default) - not an config object`, () => { const customTextConfig = `foo` - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { urlencoded: customTextConfig, @@ -716,7 +732,7 @@ describe(`createConfig`, () => { it(`input not matching schema (fallback to default) - config object not matching schema`, () => { const customTextConfig = { wat: true } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { urlencoded: customTextConfig, @@ -757,7 +773,7 @@ describe(`createConfig`, () => { it(`input not matching schema (fallback to default) - "extended" is required"`, () => { const customTextConfig = { limit: `200kb` } - const generatedConfig = createConfig( + const generatedConfig = createConfigAndPrintWarnings( { bodyParser: { urlencoded: customTextConfig, diff --git a/packages/gatsby/src/internal-plugins/functions/api-function-webpack-loader.ts b/packages/gatsby/src/internal-plugins/functions/api-function-webpack-loader.ts new file mode 100644 index 0000000000000..1e2578d320dd4 --- /dev/null +++ b/packages/gatsby/src/internal-plugins/functions/api-function-webpack-loader.ts @@ -0,0 +1,71 @@ +import type { LoaderDefinition } from "webpack" + +const APIFunctionLoader: LoaderDefinition = async function () { + const params = new URLSearchParams(this.resourceQuery) + const matchPath = params.get(`matchPath`) + + const modulePath = this.resourcePath + + return /* javascript */ ` + const preferDefault = m => (m && m.default) || m + + const functionModule = require('${modulePath}'); + const functionToExecute = preferDefault(functionModule); + const matchPath = '${matchPath}'; + const { match: reachMatch } = require('@gatsbyjs/reach-router'); + const { urlencoded, text, json, raw } = require('body-parser') + const multer = require('multer') + const { createConfig } = require('gatsby/dist/internal-plugins/functions/config') + + function functionWrapper(req, res) { + if (matchPath) { + let functionPath = req.originalUrl + + functionPath = functionPath.replace(new RegExp('^/*' + PREFIX_TO_STRIP), '') + functionPath = functionPath.replace(new RegExp('^/*api/?'), '') + + const matchResult = reachMatch(matchPath, functionPath) + if (matchResult) { + req.params = matchResult.params + if (req.params['*']) { + // TODO(v6): Remove this backwards compatability for v3 + req.params['0'] = req.params['*'] + } + } + } + + // handle body parsing if request stream was not yet consumed + const { config } = createConfig(functionModule?.config) + const middlewares = + req.readableEnded + ? [] + : [ + multer().any(), + raw(config?.bodyParser?.raw ?? { limit: '100kb' }), + text(config?.bodyParser?.text ?? { limit: '100kb' }), + urlencoded(config?.bodyParser?.urlencoded ?? { limit: '100kb', extended: true }), + json(config?.bodyParser?.json ?? { limit: '100kb' }) + ] + + let i = 0 + function runMiddlewareOrFunction() { + if (i >= middlewares.length) { + functionToExecute(req, res); + } else { + middlewares[i++](req, res, runMiddlewareOrFunction) + } + } + + + runMiddlewareOrFunction() + } + + module.exports = typeof functionToExecute === 'function' + ? { + default: functionWrapper, + config: functionModule?.config + } : functionModule + ` +} + +export default APIFunctionLoader diff --git a/packages/gatsby/src/internal-plugins/functions/config.ts b/packages/gatsby/src/internal-plugins/functions/config.ts index 0f4a1d17608e4..a9e3610651bc7 100644 --- a/packages/gatsby/src/internal-plugins/functions/config.ts +++ b/packages/gatsby/src/internal-plugins/functions/config.ts @@ -1,6 +1,4 @@ import Joi from "joi" -import reporter from "gatsby-cli/lib/reporter" -import type { IGatsbyFunction } from "../../internal" import type { GatsbyFunctionBodyParserCommonMiddlewareConfig, GatsbyFunctionBodyParserUrlencodedConfig, @@ -35,12 +33,14 @@ const defaultConfig = { }, } -let warnings: Array<{ +export interface IAPIFunctionWarning { property: string | null original: any expectedType: string replacedWith: any -}> = [] +} + +let warnings: Array = [] function bodyParserConfigFailover( property: keyof IGatsbyBodyParserConfigProcessed, @@ -141,34 +141,12 @@ const functionConfigSchema: Joi.ObjectSchema = return defaultConfig }) -export function createConfig( - userConfig: unknown, - functionObj: IGatsbyFunction -): IGatsbyFunctionConfigProcessed { +export function createConfig(userConfig: unknown): { + config: IGatsbyFunctionConfigProcessed + warnings: Array +} { warnings = [] const { value } = functionConfigSchema.validate(userConfig) - - if (warnings.length) { - for (const warning of warnings) { - reporter.warn( - `${ - warning.property - ? `\`${warning.property}\` property of exported config` - : `Exported config` - } in \`${ - functionObj.originalRelativeFilePath - }\` is misconfigured.\nExpected object:\n\n${ - warning.expectedType - }\n\nGot:\n\n${JSON.stringify( - warning.original - )}\n\nUsing default:\n\n${JSON.stringify( - warning.replacedWith, - null, - 2 - )}` - ) - } - } - - return value as IGatsbyFunctionConfigProcessed + const config = value as IGatsbyFunctionConfigProcessed + return { config, warnings } } diff --git a/packages/gatsby/src/internal-plugins/functions/gatsby-node.ts b/packages/gatsby/src/internal-plugins/functions/gatsby-node.ts index 49bf730cd3e99..5f4b5b8141d0e 100644 --- a/packages/gatsby/src/internal-plugins/functions/gatsby-node.ts +++ b/packages/gatsby/src/internal-plugins/functions/gatsby-node.ts @@ -154,6 +154,7 @@ const createWebpackConfig = async ({ store.getState().flattenedPlugins ) + const seenFunctionIds = new Set() // Glob and return object with relative/absolute paths + which plugin // they belong to. const allFunctions = await Promise.all( @@ -175,6 +176,22 @@ const createWebpackConfig = async ({ ) const finalName = urlResolve(dir, name === `index` ? `` : name) + // functionId should have only alphanumeric characters and dashes + const functionIdBase = _.kebabCase(compiledFunctionName).replace( + /[^a-zA-Z0-9-]/g, + `-` + ) + + let functionId = functionIdBase + + if (seenFunctionIds.has(functionId)) { + let counter = 2 + do { + functionId = `${functionIdBase}-${counter}` + counter++ + } while (seenFunctionIds.has(functionId)) + } + knownFunctions.push({ functionRoute: finalName, pluginName: glob.pluginName, @@ -183,6 +200,7 @@ const createWebpackConfig = async ({ relativeCompiledFilePath: compiledFunctionName, absoluteCompiledFilePath: compiledPath, matchPath: getMatchPath(finalName), + functionId, }) }) @@ -203,6 +221,11 @@ const createWebpackConfig = async ({ JSON.stringify(knownFunctions, null, 4) ) + const { + config: { pathPrefix }, + program, + } = store.getState() + // Load environment variables from process.env.* and .env.* files. // Logic is shared with webpack.config.js @@ -269,7 +292,11 @@ const createWebpackConfig = async ({ parsedFile.name ) - entries[compiledNameWithoutExtension] = functionObj.originalAbsoluteFilePath + let entryToTheFunction = functionObj.originalAbsoluteFilePath + // we wrap user defined function with our preamble that handles matchPath as well as body parsing + // see api-function-webpack-loader.ts for more info + entryToTheFunction += `?matchPath=` + (functionObj.matchPath ?? ``) + entries[compiledNameWithoutExtension] = entryToTheFunction }) activeEntries = entries @@ -316,6 +343,13 @@ const createWebpackConfig = async ({ // Webpack expects extensions when importing ESM modules as that's what the spec describes. // Not all libraries have adapted so we don't enforce its behaviour // @see https://github.com/webpack/webpack/issues/11467 + { + test: /\.[tj]sx?$/, + resourceQuery: /matchPath/, + use: { + loader: require.resolve(`./api-function-webpack-loader`), + }, + }, { test: /\.mjs$/i, resolve: { @@ -358,7 +392,12 @@ const createWebpackConfig = async ({ ], }, plugins: [ - new webpack.DefinePlugin(processEnvVars), + new webpack.DefinePlugin({ + PREFIX_TO_STRIP: JSON.stringify( + program.prefixPaths ? pathPrefix?.replace(/(^\/+|\/+$)/g, ``) : `` + ), + ...processEnvVars, + }), new webpack.IgnorePlugin({ checkResource(resource): boolean { if (resource === `lmdb`) { diff --git a/packages/gatsby/src/internal-plugins/functions/middleware.ts b/packages/gatsby/src/internal-plugins/functions/middleware.ts index a17ce677426fe..461d65eff34e4 100644 --- a/packages/gatsby/src/internal-plugins/functions/middleware.ts +++ b/packages/gatsby/src/internal-plugins/functions/middleware.ts @@ -9,6 +9,7 @@ import { createConfig, IGatsbyFunctionConfigProcessed, IGatsbyBodyParserConfigProcessed, + IAPIFunctionWarning, } from "./config" import type { IGatsbyFunction } from "../../redux/types" @@ -45,6 +46,33 @@ interface ICreateMiddlewareConfig { showDebugMessageInResponse?: boolean } +export function printConfigWarnings( + warnings: Array, + functionObj: IGatsbyFunction +): void { + if (warnings.length) { + for (const warning of warnings) { + reporter.warn( + `${ + warning.property + ? `\`${warning.property}\` property of exported config` + : `Exported config` + } in \`${ + functionObj.originalRelativeFilePath + }\` is misconfigured.\nExpected object:\n\n${ + warning.expectedType + }\n\nGot:\n\n${JSON.stringify( + warning.original + )}\n\nUsing default:\n\n${JSON.stringify( + warning.replacedWith, + null, + 2 + )}` + ) + } + } +} + function createSetContextFunctionMiddleware({ getFunctions, prepareFn, @@ -122,11 +150,15 @@ function createSetContextFunctionMiddleware({ } if (fnToExecute) { + const { config, warnings } = createConfig(userConfig) + + printConfigWarnings(warnings, functionObj) + req.context = { functionObj, fnToExecute, params: req.params, - config: createConfig(userConfig, functionObj), + config, showDebugMessageInResponse: showDebugMessageInResponse ?? false, } } diff --git a/packages/gatsby/src/joi-schemas/__tests__/joi.ts b/packages/gatsby/src/joi-schemas/__tests__/joi.ts index 7a2e7591c8510..91c65b026ae68 100644 --- a/packages/gatsby/src/joi-schemas/__tests__/joi.ts +++ b/packages/gatsby/src/joi-schemas/__tests__/joi.ts @@ -265,6 +265,106 @@ describe(`gatsby config`, () => { }) ) }) + + it(`returns empty array when headers are not set`, () => { + const config = {} + + const result = gatsbyConfigSchema.validate(config) + expect(result.value?.headers).toEqual([]) + }) + + it(`lets you create custom HTTP headers for a path`, () => { + const config = { + headers: [ + { + source: `*`, + headers: [ + { + key: `x-custom-header`, + value: `some value`, + }, + ], + }, + ], + } + + const result = gatsbyConfigSchema.validate(config) + expect(result.value?.headers).toEqual(config.headers) + }) + + it(`throws on incorrect headers definitions`, () => { + const configOne = { + headers: { + source: `*`, + headers: [ + { + key: `x-custom-header`, + value: `some value`, + }, + ], + }, + } + + const resultOne = gatsbyConfigSchema.validate(configOne) + expect(resultOne.error).toMatchInlineSnapshot( + `[ValidationError: "headers" must be an array]` + ) + + const configTwo = { + headers: [ + { + source: `*`, + headers: { + key: `x-custom-header`, + value: `some value`, + }, + }, + ], + } + + const resultTwo = gatsbyConfigSchema.validate(configTwo) + expect(resultTwo.error).toMatchInlineSnapshot( + `[ValidationError: "headers[0].headers" must be an array]` + ) + }) + + it(`lets you add an adapter`, () => { + const config = { + adapter: { + name: `gatsby-adapter-name`, + cache: { + restore: (): Promise => Promise.resolve(), + store: (): Promise => Promise.resolve(), + }, + adapt: (): Promise => Promise.resolve(), + }, + } + + const result = gatsbyConfigSchema.validate(config) + expect(result.value?.adapter).toEqual(config.adapter) + }) + + it(`throws on incorrect adapter setting`, () => { + const configOne = { + adapter: `gatsby-adapter-name`, + } + + const resultOne = gatsbyConfigSchema.validate(configOne) + expect(resultOne.error).toMatchInlineSnapshot( + `[ValidationError: "adapter" must be of type object]` + ) + + const configTwo = { + adapter: { + name: `gatsby-adapter-name`, + }, + } + + const resultTwo = gatsbyConfigSchema.validate(configTwo) + expect(resultTwo.error).toMatchInlineSnapshot( + `[ValidationError: "adapter.adapt" is required]` + ) + }) }) describe(`node schema`, () => { diff --git a/packages/gatsby/src/joi-schemas/joi.ts b/packages/gatsby/src/joi-schemas/joi.ts index 6f36a53bcc371..cb5402fd55295 100644 --- a/packages/gatsby/src/joi-schemas/joi.ts +++ b/packages/gatsby/src/joi-schemas/joi.ts @@ -83,6 +83,39 @@ export const gatsbyConfigSchema: Joi.ObjectSchema = Joi.object() return value }), + headers: Joi.array() + .items( + Joi.object() + .keys({ + source: Joi.string().required(), + headers: Joi.array() + .items( + Joi.object() + .keys({ + key: Joi.string().required(), + value: Joi.string().required(), + }) + .required() + .unknown(false) + ) + .required(), + }) + .unknown(false) + ) + .default([]), + adapter: Joi.object() + .keys({ + name: Joi.string().required(), + cache: Joi.object() + .keys({ + restore: Joi.func(), + store: Joi.func(), + }) + .unknown(false), + adapt: Joi.func().required(), + config: Joi.func(), + }) + .unknown(false), }) // throws when both assetPrefix and pathPrefix are defined .when( diff --git a/packages/gatsby/src/redux/__tests__/__snapshots__/index.js.snap b/packages/gatsby/src/redux/__tests__/__snapshots__/index.js.snap index eae952bbb1063..d4df325fae6ed 100644 --- a/packages/gatsby/src/redux/__tests__/__snapshots__/index.js.snap +++ b/packages/gatsby/src/redux/__tests__/__snapshots__/index.js.snap @@ -129,6 +129,7 @@ Object { "status": Object { "LAST_NODE_COUNTER": 0, "PLUGINS_HASH": "", + "cdnObfuscatedPrefix": "1234567890", "plugins": Object {}, }, "typeOwners": Object { diff --git a/packages/gatsby/src/redux/__tests__/index.js b/packages/gatsby/src/redux/__tests__/index.js index bf0b4e670d69a..009cc669ce44e 100644 --- a/packages/gatsby/src/redux/__tests__/index.js +++ b/packages/gatsby/src/redux/__tests__/index.js @@ -3,7 +3,6 @@ const path = require(`path`) const v8 = require(`v8`) const telemetry = require(`gatsby-telemetry`) const reporter = require(`gatsby-cli/lib/reporter`) -const { murmurhash } = require(`gatsby-core-utils/murmurhash`) const writeToCache = jest.spyOn(require(`../persist`), `writeToCache`) const v8Serialize = jest.spyOn(v8, `serialize`) const v8Deserialize = jest.spyOn(v8, `deserialize`) @@ -87,7 +86,18 @@ jest.mock(`glob`, () => { }), } }) -jest.mock(`gatsby-core-utils/murmurhash`) + +jest.mock(`gatsby-core-utils`, () => { + return { + ...jest.requireActual(`gatsby-core-utils`), + murmurhash: { + murmurhash: jest.fn(() => `1234567890`), + }, + uuid: { + v4: jest.fn(() => `1234567890`), + }, + } +}) function getFakeNodes() { // Set nodes to something or the cache will fail because it asserts this @@ -151,7 +161,6 @@ describe(`redux db`, () => { mockWrittenContent.set(pageTemplatePath, `foo`) reporterWarn.mockClear() reporterInfo.mockClear() - murmurhash.mockReturnValue(`1234567890`) }) it(`should have cache status telemetry event`, async () => { diff --git a/packages/gatsby/src/redux/actions/__tests__/internal.ts b/packages/gatsby/src/redux/actions/__tests__/internal.ts index ea01df2b77a0f..d42eea6fd4b7b 100644 --- a/packages/gatsby/src/redux/actions/__tests__/internal.ts +++ b/packages/gatsby/src/redux/actions/__tests__/internal.ts @@ -22,6 +22,7 @@ describe(`setSiteConfig`, () => { Object { "payload": Object { "graphqlTypegen": false, + "headers": Array [], "jsxRuntime": "classic", "pathPrefix": "", "polyfill": true, @@ -41,6 +42,7 @@ describe(`setSiteConfig`, () => { Object { "payload": Object { "graphqlTypegen": false, + "headers": Array [], "jsxRuntime": "classic", "pathPrefix": "", "polyfill": true, diff --git a/packages/gatsby/src/redux/index.ts b/packages/gatsby/src/redux/index.ts index 51a9db83fdaa3..5b4b5e81a9131 100644 --- a/packages/gatsby/src/redux/index.ts +++ b/packages/gatsby/src/redux/index.ts @@ -12,10 +12,42 @@ import telemetry from "gatsby-telemetry" import { mett } from "../utils/mett" import thunk, { ThunkMiddleware, ThunkAction, ThunkDispatch } from "redux-thunk" -import * as reducers from "./reducers" +import * as rawReducers from "./reducers" import { writeToCache, readFromCache } from "./persist" import { IGatsbyState, ActionsUnion, GatsbyStateKeys } from "./types" +const persistedReduxKeys = [ + `nodes`, + `typeOwners`, + `statefulSourcePlugins`, + `status`, + `components`, + `jobsV2`, + `staticQueryComponents`, + `webpackCompilationHash`, + `pageDataStats`, + `pages`, + `staticQueriesByTemplate`, + `pendingPageDataWrites`, + `queries`, + `html`, + `slices`, + `slicesByTemplate`, +] + +const reducers = persistedReduxKeys.reduce((acc, key) => { + const rawReducer = rawReducers[key] + acc[key] = function (state, action): any { + if (action.type === `RESTORE_CACHE` && action.payload[key]) { + return action.payload[key] + } else { + return rawReducer(state, action) + } + } + + return acc +}, rawReducers) + // Create event emitter for actions export const emitter = mett() @@ -75,13 +107,22 @@ export type GatsbyReduxStore = Store & { dispatch: ThunkDispatch & IMultiDispatch } -export const configureStore = (initialState: IGatsbyState): GatsbyReduxStore => - createStore( +export const configureStore = ( + initialState: IGatsbyState +): GatsbyReduxStore => { + const store = createStore( combineReducers({ ...reducers }), initialState, applyMiddleware(thunk as ThunkMiddleware, multi) ) + store.dispatch({ + type: `INIT`, + }) + + return store +} + export const store: GatsbyReduxStore = configureStore( process.env.GATSBY_WORKER_POOL_WORKER ? ({} as IGatsbyState) : readState() ) @@ -108,24 +149,9 @@ export const saveState = (): void => { const state = store.getState() - return writeToCache({ - nodes: state.nodes, - typeOwners: state.typeOwners, - statefulSourcePlugins: state.statefulSourcePlugins, - status: state.status, - components: state.components, - jobsV2: state.jobsV2, - staticQueryComponents: state.staticQueryComponents, - webpackCompilationHash: state.webpackCompilationHash, - pageDataStats: state.pageDataStats, - pages: state.pages, - pendingPageDataWrites: state.pendingPageDataWrites, - staticQueriesByTemplate: state.staticQueriesByTemplate, - queries: state.queries, - html: state.html, - slices: state.slices, - slicesByTemplate: state.slicesByTemplate, - }) + const sliceOfStateToPersist = _.pick(state, persistedReduxKeys) + + return writeToCache(sliceOfStateToPersist) } export const savePartialStateToDisk = ( diff --git a/packages/gatsby/src/redux/reducers/adapter.ts b/packages/gatsby/src/redux/reducers/adapter.ts new file mode 100644 index 0000000000000..29215e318adb6 --- /dev/null +++ b/packages/gatsby/src/redux/reducers/adapter.ts @@ -0,0 +1,21 @@ +import { noOpAdapterManager } from "../../utils/adapter/no-op-manager" +import type { ActionsUnion, IGatsbyState } from "../types" + +export const adapterReducer = ( + state: IGatsbyState["adapter"] = { + instance: undefined, + manager: noOpAdapterManager(), + config: { + excludeDatastoreFromEngineFunction: false, + pluginsToDisable: [], + }, + }, + action: ActionsUnion +): IGatsbyState["adapter"] => { + switch (action.type) { + case `SET_ADAPTER`: + return action.payload + default: + return state + } +} diff --git a/packages/gatsby/src/redux/reducers/index.ts b/packages/gatsby/src/redux/reducers/index.ts index b23897cf61a3b..54e4ef76e47ed 100644 --- a/packages/gatsby/src/redux/reducers/index.ts +++ b/packages/gatsby/src/redux/reducers/index.ts @@ -37,6 +37,7 @@ import { statefulSourcePluginsReducer } from "./stateful-source-plugins" import { slicesReducer } from "./slices" import { componentsUsingSlicesReducer } from "./components-using-slices" import { slicesByTemplateReducer } from "./slices-by-template" +import { adapterReducer } from "./adapter" /** * @property exports.nodesTouched Set @@ -81,4 +82,5 @@ export { componentsUsingSlicesReducer as componentsUsingSlices, slicesByTemplateReducer as slicesByTemplate, telemetryReducer as telemetry, + adapterReducer as adapter, } diff --git a/packages/gatsby/src/redux/reducers/program.ts b/packages/gatsby/src/redux/reducers/program.ts index 07bcbbd70ffa6..e5487a054fa83 100644 --- a/packages/gatsby/src/redux/reducers/program.ts +++ b/packages/gatsby/src/redux/reducers/program.ts @@ -15,6 +15,7 @@ const initialState: IStateProgram = { extensions: [], browserslist: [], report: reporter, + disablePlugins: [], } export const programReducer = ( @@ -24,6 +25,7 @@ export const programReducer = ( switch (action.type) { case `SET_PROGRAM`: return { + ...state, ...action.payload, } @@ -39,6 +41,27 @@ export const programReducer = ( status: `BOOTSTRAP_FINISHED`, } + case `DISABLE_PLUGINS_BY_NAME`: { + if (!state.disablePlugins) { + state.disablePlugins = [] + } + for (const pluginToDisable of action.payload.pluginsToDisable) { + let disabledPlugin = state.disablePlugins.find( + entry => entry.name === pluginToDisable + ) + if (!disabledPlugin) { + disabledPlugin = { + name: pluginToDisable, + reasons: [], + } + state.disablePlugins.push(disabledPlugin) + } + disabledPlugin.reasons.push(action.payload.reason) + } + + return state + } + default: return state } diff --git a/packages/gatsby/src/redux/reducers/status.ts b/packages/gatsby/src/redux/reducers/status.ts index 46b7222543898..43c86a9639a71 100644 --- a/packages/gatsby/src/redux/reducers/status.ts +++ b/packages/gatsby/src/redux/reducers/status.ts @@ -1,10 +1,12 @@ import _ from "lodash" +import { uuid } from "gatsby-core-utils" import { ActionsUnion, IGatsbyState } from "../types" const defaultState: IGatsbyState["status"] = { PLUGINS_HASH: ``, LAST_NODE_COUNTER: 0, plugins: {}, + cdnObfuscatedPrefix: ``, } export const statusReducer = ( @@ -13,7 +15,16 @@ export const statusReducer = ( ): IGatsbyState["status"] => { switch (action.type) { case `DELETE_CACHE`: - return defaultState + return { + ...defaultState, + cdnObfuscatedPrefix: state.cdnObfuscatedPrefix ?? ``, + } + case `INIT`: { + if (!state.cdnObfuscatedPrefix) { + state.cdnObfuscatedPrefix = uuid.v4() + } + return state + } case `UPDATE_PLUGINS_HASH`: return { ...state, diff --git a/packages/gatsby/src/redux/types.ts b/packages/gatsby/src/redux/types.ts index 127d4a12b884b..9f03fbbba753e 100644 --- a/packages/gatsby/src/redux/types.ts +++ b/packages/gatsby/src/redux/types.ts @@ -1,4 +1,5 @@ import type { TrailingSlash } from "gatsby-page-utils" +import type { Express } from "express" import { IProgram, Stage } from "../commands/types" import { GraphQLFieldExtensionDefinition } from "../schema/extensions" import { @@ -14,6 +15,11 @@ import { InternalJob, JobResultInterface } from "../utils/jobs/manager" import { ITypeMetadata } from "../schema/infer/inference-metadata" import { Span } from "opentracing" import { ICollectedSlices } from "../utils/babel/find-slices" +import type { + IAdapter, + IAdapterFinalConfig, + IAdapterManager, +} from "../utils/adapter/types" type SystemPath = string type Identifier = string @@ -24,6 +30,7 @@ export interface IRedirect { isPermanent?: boolean redirectInBrowser?: boolean ignoreCase: boolean + statusCode?: HttpStatusCode // Users can add anything to this createRedirect API [key: string]: any } @@ -86,6 +93,8 @@ export interface IGatsbyFunction { matchPath: string | undefined /** The plugin that owns this function route **/ pluginName: string + /** Function identifier used to match functions usage in routes manifest */ + functionId: string } export interface IGraphQLTypegenOptions { @@ -94,6 +103,15 @@ export interface IGraphQLTypegenOptions { generateOnBuild: boolean } +export interface IHeader { + source: string + headers: Array<{ + key: string + value: string + }> +} + +// TODO: The keys of IGatsbyConfig are all optional so that in reducers like reducers/config.ts the default state for the config can be an empty object. This isn't ideal because some of those options are actually always defined because Joi validation sets defaults. Somehow fix this :D export interface IGatsbyConfig { plugins?: Array<{ // This is the name of the plugin like `gatsby-plugin-manifest` @@ -112,7 +130,7 @@ export interface IGatsbyConfig { } // @deprecated polyfill?: boolean - developMiddleware?: any + developMiddleware?: (app: Express) => void proxy?: any partytownProxiedURLs?: Array pathPrefix?: string @@ -122,6 +140,8 @@ export interface IGatsbyConfig { jsxImportSource?: string trailingSlash?: TrailingSlash graphqlTypegen?: IGraphQLTypegenOptions + headers?: Array + adapter?: IAdapter } export interface IGatsbyNode { @@ -304,6 +324,7 @@ export interface IGatsbyState { plugins: Record PLUGINS_HASH: Identifier LAST_NODE_COUNTER: number + cdnObfuscatedPrefix: string } queries: { byNode: Map> @@ -399,6 +420,11 @@ export interface IGatsbyState { slices: Map componentsUsingSlices: Map slicesByTemplate: Map + adapter: { + instance?: IAdapter + manager: IAdapterManager + config: IAdapterFinalConfig + } } export type GatsbyStateKeys = keyof IGatsbyState @@ -423,6 +449,7 @@ export interface ICachedReduxState { } export type ActionsUnion = + | IInitAction | IAddChildNodeToParentNodeAction | IAddFieldToNodeAction | IAddThirdPartySchema @@ -510,6 +537,12 @@ export type ActionsUnion = | ISlicesScriptsRegenerated | IProcessGatsbyImageSourceUrlAction | IClearGatsbyImageSourceUrlAction + | ISetAdapterAction + | IDisablePluginsByNameAction + +export interface IInitAction { + type: `INIT` +} export interface ISetComponentFeatures { type: `SET_COMPONENT_FEATURES` @@ -1168,6 +1201,23 @@ export interface IClearGatsbyImageSourceUrlAction { type: `CLEAR_GATSBY_IMAGE_SOURCE_URL` } +export interface ISetAdapterAction { + type: `SET_ADAPTER` + payload: { + instance?: IAdapter + manager: IAdapterManager + config: IAdapterFinalConfig + } +} + +export interface IDisablePluginsByNameAction { + type: `DISABLE_PLUGINS_BY_NAME` + payload: { + pluginsToDisable: Array + reason: string + } +} + export interface ITelemetry { gatsbyImageSourceUrls: Set } @@ -1203,3 +1253,381 @@ export interface IClearJobV2Context { requestId: string } } + +export const HTTP_STATUS_CODE = { + /** + * The server has received the request headers and the client should proceed to send the request body + * (in the case of a request for which a body needs to be sent; for example, a POST request). + * Sending a large request body to a server after a request has been rejected for inappropriate headers would be inefficient. + * To have a server check the request's headers, a client must send Expect: 100-continue as a header in its initial request + * and receive a 100 Continue status code in response before sending the body. The response 417 Expectation Failed indicates the request should not be continued. + */ + CONTINUE_100: 100, + + /** + * The requester has asked the server to switch protocols and the server has agreed to do so. + */ + SWITCHING_PROTOCOLS_101: 101, + + /** + * A WebDAV request may contain many sub-requests involving file operations, requiring a long time to complete the request. + * This code indicates that the server has received and is processing the request, but no response is available yet. + * This prevents the client from timing out and assuming the request was lost. + */ + PROCESSING_102: 102, + + /** + * Standard response for successful HTTP requests. + * The actual response will depend on the request method used. + * In a GET request, the response will contain an entity corresponding to the requested resource. + * In a POST request, the response will contain an entity describing or containing the result of the action. + */ + OK_200: 200, + + /** + * The request has been fulfilled, resulting in the creation of a new resource. + */ + CREATED_201: 201, + + /** + * The request has been accepted for processing, but the processing has not been completed. + * The request might or might not be eventually acted upon, and may be disallowed when processing occurs. + */ + ACCEPTED_202: 202, + + /** + * SINCE HTTP/1.1 + * The server is a transforming proxy that received a 200 OK from its origin, + * but is returning a modified version of the origin's response. + */ + NON_AUTHORITATIVE_INFORMATION_203: 203, + + /** + * The server successfully processed the request and is not returning any content. + */ + NO_CONTENT_204: 204, + + /** + * The server successfully processed the request, but is not returning any content. + * Unlike a 204 response, this response requires that the requester reset the document view. + */ + RESET_CONTENT_205: 205, + + /** + * The server is delivering only part of the resource (byte serving) due to a range header sent by the client. + * The range header is used by HTTP clients to enable resuming of interrupted downloads, + * or split a download into multiple simultaneous streams. + */ + PARTIAL_CONTENT_206: 206, + + /** + * The message body that follows is an XML message and can contain a number of separate response codes, + * depending on how many sub-requests were made. + */ + MULTI_STATUS_207: 207, + + /** + * The members of a DAV binding have already been enumerated in a preceding part of the (multistatus) response, + * and are not being included again. + */ + ALREADY_REPORTED_208: 208, + + /** + * The server has fulfilled a request for the resource, + * and the response is a representation of the result of one or more instance-manipulations applied to the current instance. + */ + IM_USED_226: 226, + + /** + * Indicates multiple options for the resource from which the client may choose (via agent-driven content negotiation). + * For example, this code could be used to present multiple video format options, + * to list files with different filename extensions, or to suggest word-sense disambiguation. + */ + MULTIPLE_CHOICES_300: 300, + + /** + * This and all future requests should be directed to the given URI. + */ + MOVED_PERMANENTLY_301: 301, + + /** + * This is an example of industry practice contradicting the standard. + * The HTTP/1.0 specification (RFC 1945) required the client to perform a temporary redirect + * (the original describing phrase was "Moved Temporarily"), but popular browsers implemented 302 + * with the functionality of a 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307 + * to distinguish between the two behaviours. However, some Web applications and frameworks + * use the 302 status code as if it were the 303. + */ + FOUND_302: 302, + + /** + * SINCE HTTP/1.1 + * The response to the request can be found under another URI using a GET method. + * When received in response to a POST (or PUT/DELETE), the client should presume that + * the server has received the data and should issue a redirect with a separate GET message. + */ + SEE_OTHER_303: 303, + + /** + * Indicates that the resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match. + * In such case, there is no need to retransmit the resource since the client still has a previously-downloaded copy. + */ + NOT_MODIFIED_304: 304, + + /** + * SINCE HTTP/1.1 + * The requested resource is available only through a proxy, the address for which is provided in the response. + * Many HTTP clients (such as Mozilla and Internet Explorer) do not correctly handle responses with this status code, primarily for security reasons. + */ + USE_PROXY_305: 305, + + /** + * No longer used. Originally meant "Subsequent requests should use the specified proxy." + */ + SWITCH_PROXY_306: 306, + + /** + * SINCE HTTP/1.1 + * In this case, the request should be repeated with another URI; however, future requests should still use the original URI. + * In contrast to how 302 was historically implemented, the request method is not allowed to be changed when reissuing the original request. + * For example, a POST request should be repeated using another POST request. + */ + TEMPORARY_REDIRECT_307: 307, + + /** + * The request and all future requests should be repeated using another URI. + * 307 and 308 parallel the behaviors of 302 and 301, but do not allow the HTTP method to change. + * So, for example, submitting a form to a permanently redirected resource may continue smoothly. + */ + PERMANENT_REDIRECT_308: 308, + + /** + * The server cannot or will not process the request due to an apparent client error + * (e.g., malformed request syntax, too large size, invalid request message framing, or deceptive request routing). + */ + BAD_REQUEST_400: 400, + + /** + * Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet + * been provided. The response must include a WWW-Authenticate header field containing a challenge applicable to the + * requested resource. See Basic access authentication and Digest access authentication. 401 semantically means + * "unauthenticated",i.e. the user does not have the necessary credentials. + */ + UNAUTHORIZED_401: 401, + + /** + * Reserved for future use. The original intention was that this code might be used as part of some form of digital + * cash or micro payment scheme, but that has not happened, and this code is not usually used. + * Google Developers API uses this status if a particular developer has exceeded the daily limit on requests. + */ + PAYMENT_REQUIRED_402: 402, + + /** + * The request was valid, but the server is refusing action. + * The user might not have the necessary permissions for a resource. + */ + FORBIDDEN_403: 403, + + /** + * The requested resource could not be found but may be available in the future. + * Subsequent requests by the client are permissible. + */ + NOT_FOUND_404: 404, + + /** + * A request method is not supported for the requested resource; + * for example, a GET request on a form that requires data to be presented via POST, or a PUT request on a read-only resource. + */ + METHOD_NOT_ALLOWED_405: 405, + + /** + * The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request. + */ + NOT_ACCEPTABLE_406: 406, + + /** + * The client must first authenticate itself with the proxy. + */ + PROXY_AUTHENTICATION_REQUIRED_407: 407, + + /** + * The server timed out waiting for the request. + * According to HTTP specifications: + * "The client did not produce a request within the time that the server was prepared to wait. The client MAY repeat the request without modifications at any later time." + */ + REQUEST_TIMEOUT_408: 408, + + /** + * Indicates that the request could not be processed because of conflict in the request, + * such as an edit conflict between multiple simultaneous updates. + */ + CONFLICT_409: 409, + + /** + * Indicates that the resource requested is no longer available and will not be available again. + * This should be used when a resource has been intentionally removed and the resource should be purged. + * Upon receiving a 410 status code, the client should not request the resource in the future. + * Clients such as search engines should remove the resource from their indices. + * Most use cases do not require clients and search engines to purge the resource, and a "404 Not Found" may be used instead. + */ + GONE_410: 410, + + /** + * The request did not specify the length of its content, which is required by the requested resource. + */ + LENGTH_REQUIRED_411: 411, + + /** + * The server does not meet one of the preconditions that the requester put on the request. + */ + PRECONDITION_FAILED_412: 412, + + /** + * The request is larger than the server is willing or able to process. Previously called "Request Entity Too Large". + */ + PAYLOAD_TOO_LARGE_413: 413, + + /** + * The URI provided was too long for the server to process. Often the result of too much data being encoded as a query-string of a GET request, + * in which case it should be converted to a POST request. + * Called "Request-URI Too Long" previously. + */ + URI_TOO_LONG_414: 414, + + /** + * The request entity has a media type which the server or resource does not support. + * For example, the client uploads an image as image/svg+xml, but the server requires that images use a different format. + */ + UNSUPPORTED_MEDIA_TYPE_415: 415, + + /** + * The client has asked for a portion of the file (byte serving), but the server cannot supply that portion. + * For example, if the client asked for a part of the file that lies beyond the end of the file. + * Called "Requested Range Not Satisfiable" previously. + */ + RANGE_NOT_SATISFIABLE_416: 416, + + /** + * The server cannot meet the requirements of the Expect request-header field. + */ + EXPECTATION_FAILED_417: 417, + + /** + * This code was defined in 1998 as one of the traditional IETF April Fools' jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol, + * and is not expected to be implemented by actual HTTP servers. The RFC specifies this code should be returned by + * teapots requested to brew coffee. This HTTP status is used as an Easter egg in some websites, including Google.com. + */ + I_AM_A_TEAPOT_418: 418, + + /** + * The request was directed at a server that is not able to produce a response (for example because a connection reuse). + */ + MISDIRECTED_REQUEST_421: 421, + + /** + * The request was well-formed but was unable to be followed due to semantic errors. + */ + UNPROCESSABLE_ENTITY_422: 422, + + /** + * The resource that is being accessed is locked. + */ + LOCKED_423: 423, + + /** + * The request failed due to failure of a previous request (e.g., a PROPPATCH). + */ + FAILED_DEPENDENCY_424: 424, + + /** + * The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field. + */ + UPGRADE_REQUIRED_426: 426, + + /** + * The origin server requires the request to be conditional. + * Intended to prevent "the 'lost update' problem, where a client + * GETs a resource's state, modifies it, and PUTs it back to the server, + * when meanwhile a third party has modified the state on the server, leading to a conflict." + */ + PRECONDITION_REQUIRED_428: 428, + + /** + * The user has sent too many requests in a given amount of time. Intended for use with rate-limiting schemes. + */ + TOO_MANY_REQUESTS_429: 429, + + /** + * The server is unwilling to process the request because either an individual header field, + * or all the header fields collectively, are too large. + */ + REQUEST_HEADER_FIELDS_TOO_LARGE_431: 431, + + /** + * A server operator has received a legal demand to deny access to a resource or to a set of resources + * that includes the requested resource. The code 451 was chosen as a reference to the novel Fahrenheit 451. + */ + UNAVAILABLE_FOR_LEGAL_REASONS_451: 451, + + /** + * A generic error message, given when an unexpected condition was encountered and no more specific message is suitable. + */ + INTERNAL_SERVER_ERROR_500: 500, + + /** + * The server either does not recognize the request method, or it lacks the ability to fulfill the request. + * Usually this implies future availability (e.g., a new feature of a web-service API). + */ + NOT_IMPLEMENTED_501: 501, + + /** + * The server was acting as a gateway or proxy and received an invalid response from the upstream server. + */ + BAD_GATEWAY_502: 502, + + /** + * The server is currently unavailable (because it is overloaded or down for maintenance). + * Generally, this is a temporary state. + */ + SERVICE_UNAVAILABLE_503: 503, + + /** + * The server was acting as a gateway or proxy and did not receive a timely response from the upstream server. + */ + GATEWAY_TIMEOUT_504: 504, + + /** + * The server does not support the HTTP protocol version used in the request + */ + HTTP_VERSION_NOT_SUPPORTED_505: 505, + + /** + * Transparent content negotiation for the request results in a circular reference. + */ + VARIANT_ALSO_NEGOTIATES_506: 506, + + /** + * The server is unable to store the representation needed to complete the request. + */ + INSUFFICIENT_STORAGE_507: 507, + + /** + * The server detected an infinite loop while processing the request. + */ + LOOP_DETECTED_508: 508, + + /** + * Further extensions to the request are required for the server to fulfill it. + */ + NOT_EXTENDED_510: 510, + + /** + * The client needs to authenticate to gain network access. + * Intended for use by intercepting proxies used to control access to the network (e.g., "captive portals" used + * to require agreement to Terms of Service before granting full Internet access via a Wi-Fi hotspot). + */ + NETWORK_AUTHENTICATION_REQUIRED_511: 511, +} as const + +export type HttpStatusCode = + (typeof HTTP_STATUS_CODE)[keyof typeof HTTP_STATUS_CODE] diff --git a/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts b/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts index 1ffb9dbe56ac5..8b5d99f4e41c2 100644 --- a/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts +++ b/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts @@ -11,6 +11,7 @@ import reporter from "gatsby-cli/lib/reporter" import { schemaCustomizationAPIs } from "./print-plugins" import type { GatsbyNodeAPI } from "../../redux/types" import * as nodeApis from "../../utils/api-node-docs" +import { store } from "../../redux" type Reporter = typeof reporter @@ -81,7 +82,6 @@ export async function createGraphqlEngineBundle( // those are required in some runtime paths, but we don't need them externals: [ `cbor-x`, // optional dep of lmdb-store, but we are using `msgpack` (default) encoding, so we don't need it - `babel-runtime/helpers/asyncToGenerator`, // undeclared dep of yurnalist (but used in code path we don't use) `electron`, // :shrug: `got` seems to have electron specific code path mod.builtinModules.reduce((acc, builtinModule) => { if (builtinModule === `fs`) { @@ -99,6 +99,18 @@ export async function createGraphqlEngineBundle( rules: [ { oneOf: [ + { + // specific set of loaders for sharp + test: /node_modules[/\\]sharp[/\\].*\.[cm]?js$/, + // it is recommended for Node builds to turn off AMD support + parser: { amd: false }, + use: [ + assetRelocatorUseEntry, + { + loader: require.resolve(`./sharp-bundling-patch`), + }, + ], + }, { // specific set of loaders for LMBD - our custom patch to massage lmdb to work with relocator -> relocator test: /node_modules[/\\]lmdb[/\\].*\.[cm]?js/, @@ -108,6 +120,11 @@ export async function createGraphqlEngineBundle( assetRelocatorUseEntry, { loader: require.resolve(`./lmdb-bundling-patch`), + options: { + forcedBinaryModule: store.getState().adapter.instance + ? `@lmdb/lmdb-${process.platform}-${process.arch}/node.abi83.glibc.node` + : undefined, + }, }, ], }, @@ -189,6 +206,8 @@ export async function createGraphqlEngineBundle( "graphql-import-node$": require.resolve(`./shims/no-op-module`), "graphql-import-node/register$": require.resolve(`./shims/no-op-module`), + "babel-runtime/helpers/asyncToGenerator": + require.resolve(`./shims/no-op-module`), // undeclared dep of yurnalist (but used in code path we don't use) }, }, plugins: [ diff --git a/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts b/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts index adb892311b9c7..d40cc391a0c3d 100644 --- a/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts +++ b/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts @@ -25,35 +25,51 @@ const { createRequire } = require(`module`) // eslint-disable-next-line @typescript-eslint/no-explicit-any export default function (this: any, source: string): string { let lmdbBinaryLocation: string | undefined + try { const lmdbRoot = this?._module.resourceResolveData?.descriptionFileRoot || path.dirname(this.resourcePath).replace(`/dist`, ``) const lmdbRequire = createRequire(this.resourcePath) - let nodeGypBuild - try { - nodeGypBuild = lmdbRequire(`node-gyp-build-optional-packages`) - } catch (e) { - // lmdb@2.3.8 way of loading binaries failed, we will try to fallback to - // old way before failing completely + const forcedBinaryModule = this.getOptions()?.forcedBinaryModule + let absoluteModulePath + if (forcedBinaryModule) { + try { + absoluteModulePath = lmdbRequire.resolve(forcedBinaryModule) + } catch (e) { + // no-op + } } - if (!nodeGypBuild) { - // if lmdb@2.3.8 didn't import expected node-gyp-build fork (node-gyp-build-optional-packages) - // let's try falling back to upstream package - if that doesn't work, we will fail compilation - nodeGypBuild = lmdbRequire(`node-gyp-build`) + if (!absoluteModulePath) { + let nodeGypBuild + try { + nodeGypBuild = lmdbRequire(`node-gyp-build-optional-packages`) + } catch (e) { + // lmdb@2.3.8 way of loading binaries failed, we will try to fallback to + // old way before failing completely + } + + if (!nodeGypBuild) { + // if lmdb@2.3.8 didn't import expected node-gyp-build fork (node-gyp-build-optional-packages) + // let's try falling back to upstream package - if that doesn't work, we will fail compilation + nodeGypBuild = lmdbRequire(`node-gyp-build`) + } + absoluteModulePath = nodeGypBuild.path(lmdbRoot) } lmdbBinaryLocation = slash( - path.relative( - path.dirname(this.resourcePath), - nodeGypBuild.path(lmdbRoot) - ) + path.relative(path.dirname(this.resourcePath), absoluteModulePath) ) } catch (e) { return source } + + if (!lmdbBinaryLocation) { + return source + } + return source .replace( `require$1('node-gyp-build-optional-packages')(dirName)`, diff --git a/packages/gatsby/src/schema/graphql-engine/sharp-bundling-patch.ts b/packages/gatsby/src/schema/graphql-engine/sharp-bundling-patch.ts new file mode 100644 index 0000000000000..11f6d51527e1c --- /dev/null +++ b/packages/gatsby/src/schema/graphql-engine/sharp-bundling-patch.ts @@ -0,0 +1,6 @@ +export default function (this: any, source: string): string { + return source?.replace( + `versions = require(\`../vendor/\${versions.vips}/\${platformAndArch}/versions.json\`);`, + `` + ) +} diff --git a/packages/gatsby/src/services/initialize.ts b/packages/gatsby/src/services/initialize.ts index edbb546936837..fda5e3f1248dd 100644 --- a/packages/gatsby/src/services/initialize.ts +++ b/packages/gatsby/src/services/initialize.ts @@ -26,6 +26,8 @@ import { enableNodeMutationsDetection } from "../utils/detect-node-mutations" import { compileGatsbyFiles } from "../utils/parcel/compile-gatsby-files" import { resolveModule } from "../utils/module-resolver" import { writeGraphQLConfig } from "../utils/graphql-typegen/file-writes" +import { initAdapterManager } from "../utils/adapter/manager" +import type { IAdapterManager } from "../utils/adapter/types" interface IPluginResolution { resolve: string @@ -81,6 +83,7 @@ export async function initialize({ store: Store workerPool: WorkerPool.GatsbyWorkerPool webhookBody?: WebhookBody + adapterManager?: IAdapterManager }> { if (process.env.GATSBY_DISABLE_CACHE_PERSISTENCE) { reporter.info( @@ -184,6 +187,14 @@ export async function initialize({ }) activity.end() + let adapterManager: IAdapterManager | undefined = undefined + + // Only initialize adapters during "gatsby build" + if (process.env.gatsby_executing_command === `build`) { + adapterManager = await initAdapterManager() + await adapterManager.restoreCache() + } + // Load plugins activity = reporter.activityTimer(`load plugins`, { parentSpan, @@ -281,7 +292,7 @@ export async function initialize({ activity.start() const files = await glob( [ - `public/**/*.{html,css}`, + `public/**/*.{html,css,mdb}`, `!public/page-data/**/*`, `!public/static`, `!public/static/**/*.{html,css}`, @@ -432,6 +443,7 @@ export async function initialize({ `!.cache/compiled`, // Add webpack `!.cache/webpack`, + `!.cache/adapters`, ] if (process.env.GATSBY_EXPERIMENTAL_PRESERVE_FILE_DOWNLOAD_CACHE) { @@ -671,5 +683,6 @@ export async function initialize({ store, workerPool, webhookBody: initialWebhookBody, + adapterManager, } } diff --git a/packages/gatsby/src/utils/__tests__/get-latest-apis.ts b/packages/gatsby/src/utils/__tests__/get-latest-gatsby-files.ts similarity index 91% rename from packages/gatsby/src/utils/__tests__/get-latest-apis.ts rename to packages/gatsby/src/utils/__tests__/get-latest-gatsby-files.ts index d1d31e70761fd..274d4a0685589 100644 --- a/packages/gatsby/src/utils/__tests__/get-latest-apis.ts +++ b/packages/gatsby/src/utils/__tests__/get-latest-gatsby-files.ts @@ -15,7 +15,7 @@ jest.mock(`axios`, () => { const path = require(`path`) const fs = require(`fs-extra`) const axios = require(`axios`) -import { getLatestAPIs, IAPIResponse } from "../get-latest-apis" +import { getLatestAPIs, IAPIResponse } from "../get-latest-gatsby-files" beforeEach(() => { ;[fs, axios].forEach(mock => @@ -47,7 +47,7 @@ describe(`default behavior: has network connectivity`, () => { expect(data).toEqual(getMockAPIFile()) }) - it(`writes api file`, async () => { + it(`writes apis.json file`, async () => { const data = await getLatestAPIs() expect(fs.writeFile).toHaveBeenCalledWith( @@ -77,7 +77,7 @@ describe(`downloading APIs failure`, () => { expect(data).toEqual(apis) }) - it(`falls back to local api.json if latest-apis.json not cached`, async () => { + it(`falls back to local apis.json if latest-apis.json not cached`, async () => { const apis = getMockAPIFile() fs.pathExists.mockResolvedValueOnce(false) fs.readJSON.mockResolvedValueOnce(apis) diff --git a/packages/gatsby/src/utils/adapter/__tests__/__snapshots__/manager.ts.snap b/packages/gatsby/src/utils/adapter/__tests__/__snapshots__/manager.ts.snap new file mode 100644 index 0000000000000..d1a544ca1a1a8 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/__snapshots__/manager.ts.snap @@ -0,0 +1,274 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getRoutesManifest should return routes manifest 1`] = ` +Array [ + Object { + "filePath": "public/page-data/sq/d/1.json", + "headers": Array [ + Object { + "key": "cache-control", + "value": "public, max-age=0, must-revalidate", + }, + 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", + }, + ], + "path": "/page-data/sq/d/1.json", + "type": "static", + }, + Object { + "filePath": "public/_gatsby/slices/_gatsby-scripts-1.html", + "headers": Array [ + Object { + "key": "cache-control", + "value": "public, max-age=0, must-revalidate", + }, + 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", + }, + ], + "path": "/_gatsby/slices/_gatsby-scripts-1.html", + "type": "static", + }, + Object { + "cache": true, + "functionId": "ssr-engine", + "path": "/page-data/dsg/page-data.json", + "type": "function", + }, + Object { + "filePath": "public/page-data/index/page-data.json", + "headers": Array [ + Object { + "key": "cache-control", + "value": "public, max-age=0, must-revalidate", + }, + 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", + }, + ], + "path": "/page-data/index/page-data.json", + "type": "static", + }, + Object { + "functionId": "ssr-engine", + "path": "/page-data/ssr/page-data.json", + "type": "function", + }, + Object { + "functionId": "static-index-js", + "path": "/api/static/", + "type": "function", + }, + Object { + "filePath": "public/page-data/app-data.json", + "headers": Array [ + Object { + "key": "cache-control", + "value": "public, max-age=0, must-revalidate", + }, + 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", + }, + ], + "path": "/page-data/app-data.json", + "type": "static", + }, + Object { + "filePath": "public/app-123.js", + "headers": Array [ + Object { + "key": "cache-control", + "value": "public, max-age=31536000, immutable", + }, + 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", + }, + ], + "path": "/app-123.js", + "type": "static", + }, + Object { + "filePath": "public/chunk-map.json", + "headers": Array [ + Object { + "key": "cache-control", + "value": "public, max-age=0, must-revalidate", + }, + 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", + }, + ], + "path": "/chunk-map.json", + "type": "static", + }, + Object { + "cache": true, + "functionId": "ssr-engine", + "path": "/dsg/", + "type": "function", + }, + Object { + "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-url", + "status": 301, + "toPath": "/new-url", + "type": "redirect", + }, + Object { + "functionId": "ssr-engine", + "path": "/ssr/", + "type": "function", + }, + Object { + "filePath": "public/webpack.stats.json", + "headers": Array [ + Object { + "key": "cache-control", + "value": "public, max-age=0, must-revalidate", + }, + 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", + }, + ], + "path": "/webpack.stats.json", + "type": "static", + }, + Object { + "filePath": "public/index.html", + "headers": Array [ + Object { + "key": "cache-control", + "value": "public, max-age=0, must-revalidate", + }, + 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", + }, + ], + "path": "/", + "type": "static", + }, +] +`; diff --git a/packages/gatsby/src/utils/adapter/__tests__/create-headers.ts b/packages/gatsby/src/utils/adapter/__tests__/create-headers.ts new file mode 100644 index 0000000000000..30b7204a441b0 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/create-headers.ts @@ -0,0 +1,298 @@ +import { createHeadersMatcher } from "../create-headers" + +describe(`createHeadersMatcher`, () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it(`returns default headers if no custom headers are defined`, () => { + const matcher = createHeadersMatcher(undefined) + + const defaults = [ + { + key: `x-default-header`, + value: `win`, + }, + ] + + const result = matcher(`/some-path/`, defaults) + + expect(result).toEqual(defaults) + }) + + it(`returns default headers if an empty array as headers is defined`, () => { + const matcher = createHeadersMatcher([]) + + const defaults = [ + { + key: `x-default-header`, + value: `win`, + }, + ] + + const result = matcher(`/some-path/`, defaults) + + expect(result).toEqual(defaults) + }) + + it(`gracefully handles trailing slash inconsistencies`, () => { + const matcher = createHeadersMatcher([ + { + source: `/some-path`, + headers: [ + { + key: `x-custom-header`, + value: `win`, + }, + ], + }, + { + source: `/another-path/`, + headers: [ + { + key: `x-custom-header`, + value: `win`, + }, + ], + }, + ]) + + const defaults = [] + + const resultOne = matcher(`/some-path/`, defaults) + const resultTwo = matcher(`/another-path`, defaults) + + expect(resultOne).toEqual([{ key: `x-custom-header`, value: `win` }]) + expect(resultTwo).toEqual([{ key: `x-custom-header`, value: `win` }]) + }) + + it(`combines with non-overlapping keys`, () => { + const matcher = createHeadersMatcher([ + { + source: `*`, + headers: [ + { + key: `x-another-custom-header`, + value: `win`, + }, + ], + }, + { + source: `/some-path/`, + headers: [ + { + key: `x-custom-header`, + value: `win`, + }, + ], + }, + ]) + + const defaults = [ + { + key: `x-default-header`, + value: `win`, + }, + ] + + const result = matcher(`/some-path/`, defaults) + + expect(result).toEqual([ + ...defaults, + { key: `x-another-custom-header`, value: `win` }, + { key: `x-custom-header`, value: `win` }, + ]) + }) + + it(`combines with overlapping keys`, () => { + const matcher = createHeadersMatcher([ + { + source: `*`, + headers: [ + { + key: `x-custom-header`, + value: `lose-2`, + }, + ], + }, + { + source: `/some-path/`, + headers: [ + { + key: `x-custom-header`, + value: `win`, + }, + ], + }, + ]) + + const defaults = [ + { + key: `x-custom-header`, + value: `lose-1`, + }, + ] + + const result = matcher(`/some-path/`, defaults) + + expect(result).toEqual([{ key: `x-custom-header`, value: `win` }]) + }) + + it(`combines with overlapping & non-overlapping keys`, () => { + const matcher = createHeadersMatcher([ + { + source: `*`, + headers: [ + { + key: `x-custom-header`, + value: `lose-2`, + }, + { + key: `x-dynamic-header`, + value: `win`, + }, + ], + }, + { + source: `/some-path/`, + headers: [ + { + key: `x-custom-header`, + value: `win`, + }, + { + key: `x-static-header`, + value: `win`, + }, + ], + }, + ]) + + const defaults = [ + { + key: `x-custom-header`, + value: `lose-1`, + }, + { + key: `x-default-header`, + value: `win`, + }, + ] + + const result = matcher(`/some-path/`, defaults) + + expect(result).toEqual([ + { + key: `x-custom-header`, + value: `win`, + }, + { + key: `x-default-header`, + value: `win`, + }, + { + key: `x-dynamic-header`, + value: `win`, + }, + { + key: `x-static-header`, + value: `win`, + }, + ]) + }) + + it(`static wins over dynamic`, () => { + const matcher = createHeadersMatcher([ + { + source: `*`, + headers: [ + { + key: `x-custom-header`, + value: `lose-1`, + }, + ], + }, + { + source: `/some-path/foo`, + headers: [ + { + key: `x-custom-header`, + value: `win`, + }, + ], + }, + { + source: `/some-path/*`, + headers: [ + { + key: `x-custom-header`, + value: `lose-2`, + }, + ], + }, + { + source: `/some-path/:slug`, + headers: [ + { + key: `x-custom-header`, + value: `lose-3`, + }, + ], + }, + ]) + + const defaults = [] + + const result = matcher(`/some-path/foo`, defaults) + + expect(result).toEqual([ + { + key: `x-custom-header`, + value: `win`, + }, + ]) + }) + + it(`dynamic entries have correct specificity`, () => { + const matcher = createHeadersMatcher([ + { + source: `*`, + headers: [ + { + key: `x-custom-header`, + value: `lose-1`, + }, + ], + }, + { + source: `/some-path/*`, + headers: [ + { + key: `x-custom-header`, + value: `lose-2`, + }, + ], + }, + { + source: `/some-path/:slug`, + headers: [ + { + key: `x-custom-header`, + value: `win`, + }, + ], + }, + ]) + + const defaults = [] + + const result = matcher(`/some-path/foo`, defaults) + + expect(result).toEqual([ + { + key: `x-custom-header`, + value: `win`, + }, + ]) + }) +}) diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/.cache/data/datastore/data.mdb b/packages/gatsby/src/utils/adapter/__tests__/fixtures/.cache/data/datastore/data.mdb new file mode 100644 index 0000000000000..625c0891b2c30 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/.cache/data/datastore/data.mdb @@ -0,0 +1 @@ +// noop \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/.cache/functions/static/index.js b/packages/gatsby/src/utils/adapter/__tests__/fixtures/.cache/functions/static/index.js new file mode 100644 index 0000000000000..625c0891b2c30 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/.cache/functions/static/index.js @@ -0,0 +1 @@ +// noop \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/.cache/page-ssr/lambda.js b/packages/gatsby/src/utils/adapter/__tests__/fixtures/.cache/page-ssr/lambda.js new file mode 100644 index 0000000000000..625c0891b2c30 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/.cache/page-ssr/lambda.js @@ -0,0 +1 @@ +// noop \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/.cache/query-engine/index.js b/packages/gatsby/src/utils/adapter/__tests__/fixtures/.cache/query-engine/index.js new file mode 100644 index 0000000000000..625c0891b2c30 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/.cache/query-engine/index.js @@ -0,0 +1 @@ +// noop \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/.gitignore b/packages/gatsby/src/utils/adapter/__tests__/fixtures/.gitignore new file mode 100644 index 0000000000000..449d3d123040e --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/.gitignore @@ -0,0 +1,2 @@ +!.cache +!public \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/_gatsby/slices/_gatsby-scripts-1.html b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/_gatsby/slices/_gatsby-scripts-1.html new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/app-123.js b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/app-123.js new file mode 100644 index 0000000000000..625c0891b2c30 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/app-123.js @@ -0,0 +1 @@ +// noop \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/chunk-map.json b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/chunk-map.json new file mode 100644 index 0000000000000..9e26dfeeb6e64 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/chunk-map.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/index.html b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/index.html new file mode 100644 index 0000000000000..6668b30218583 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/page-data/app-data.json b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/page-data/app-data.json new file mode 100644 index 0000000000000..9e26dfeeb6e64 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/page-data/app-data.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/page-data/index/page-data.json b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/page-data/index/page-data.json new file mode 100644 index 0000000000000..9e26dfeeb6e64 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/page-data/index/page-data.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/page-data/sq/d/1.json b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/page-data/sq/d/1.json new file mode 100644 index 0000000000000..9e26dfeeb6e64 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/page-data/sq/d/1.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/webpack.stats.json b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/webpack.stats.json new file mode 100644 index 0000000000000..9e26dfeeb6e64 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/public/webpack.stats.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/gatsby/src/utils/adapter/__tests__/fixtures/state.ts b/packages/gatsby/src/utils/adapter/__tests__/fixtures/state.ts new file mode 100644 index 0000000000000..471317128314f --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/fixtures/state.ts @@ -0,0 +1,152 @@ +import { IGatsbyState } from "../../../../internal" + +const pages: IGatsbyState["pages"] = new Map() + +pages.set(`/`, { + internalComponentName: 'ComponentIndex', + path: '/', + matchPath: undefined, + component: '/x/src/pages/index.tsx', + componentPath: '/x/src/pages/index.tsx', + componentChunkName: 'component---src-pages-index-tsx', + isCreatedByStatefulCreatePages: true, + context: {}, + updatedAt: 1, + slices: {}, + pluginCreator___NODE: '', + pluginCreatorId: '', + mode: 'SSG', + ownerNodeId: `` +}) + +pages.set(`/dsg`, { + internalComponentName: 'Component/dsg', + path: '/dsg', + matchPath: undefined, + component: '/x/src/pages/dsg.tsx', + componentPath: '/x/src/pages/dsg.tsx', + componentChunkName: 'component---src-pages-dsg-tsx', + isCreatedByStatefulCreatePages: true, + context: {}, + updatedAt: 1, + slices: {}, + pluginCreator___NODE: '', + pluginCreatorId: '', + mode: 'DSG', + ownerNodeId: ``, + defer: true, +}) + +pages.set(`/ssr`, { + internalComponentName: 'Component/ssr', + path: '/ssr', + matchPath: undefined, + component: '/x/src/pages/ssr.tsx', + componentPath: '/x/src/pages/ssr.tsx', + componentChunkName: 'component---src-pages-ssr-tsx', + isCreatedByStatefulCreatePages: true, + context: {}, + updatedAt: 1, + slices: {}, + pluginCreator___NODE: '', + pluginCreatorId: '', + mode: 'SSR', + ownerNodeId: `` +}) + +const staticQueryComponents: IGatsbyState["staticQueryComponents"] = new Map() + +staticQueryComponents.set(`sq--src-pages-index-tsx`, { + name: 'TitleQuery', + componentPath: '/x/src/pages/index.tsx', + id: 'sq--src-pages-index-tsx', + query: 'query BioQuery {\n site {\n siteMetadata {\n title\n }\n }\n}', + hash: '1' +}) + +const redirects: IGatsbyState["redirects"] = [{ + fromPath: '/old-url', + isPermanent: true, + ignoreCase: true, + redirectInBrowser: false, + toPath: '/new-url' +}] + +const functions: IGatsbyState["functions"] = [{ + functionRoute: 'static', + pluginName: 'default-site-plugin', + originalAbsoluteFilePath: '/x/src/api/static/index.js', + originalRelativeFilePath: 'static/index.js', + relativeCompiledFilePath: 'static/index.js', + absoluteCompiledFilePath: '/x/.cache/functions/static/index.js', + matchPath: undefined, + functionId: 'static-index-js' +}] + +const components: IGatsbyState["components"] = new Map() + +components.set('/x/src/pages/dsg.tsx', { + componentPath: '/x/src/pages/dsg.tsx', + componentChunkName: 'component---src-pages-dsg-tsx', + query: '', + pages: new Set([``]), + isInBootstrap: true, + serverData: false, + config: false, + isSlice: false, + Head: true +}) + +components.set('/x/src/pages/index.tsx', { + componentPath: '/x/src/pages/index.tsx', + componentChunkName: 'component---src-pages-index-tsx', + query: '', + pages: new Set([``]), + isInBootstrap: true, + serverData: false, + config: false, + isSlice: false, + Head: true +}) + +components.set('/x/src/pages/ssr.tsx', { + componentPath: '/x/src/pages/ssr.tsx', + componentChunkName: 'component---src-pages-ssr-tsx', + query: '', + pages: new Set([``]), + isInBootstrap: true, + serverData: true, + config: false, + isSlice: false, + Head: false +}) + +const slices: IGatsbyState["slices"] = new Map() + +const html: IGatsbyState["html"] = { + trackedHtmlFiles: new Map(), + browserCompilationHash: ``, + ssrCompilationHash: ``, + trackedStaticQueryResults: new Map(), + unsafeBuiltinWasUsedInSSR: false, + templateCompilationHashes: {}, + slicesProps: { + bySliceId: new Map(), + byPagePath: new Map(), + bySliceName: new Map(), + }, + pagesThatNeedToStitchSlices: new Set() +} + +export const state = { + pages, + staticQueryComponents, + redirects, + functions, + config: { + headers: [], + }, + slices, + html, + components, +} as unknown as IGatsbyState diff --git a/packages/gatsby/src/utils/adapter/__tests__/get-route-path.ts b/packages/gatsby/src/utils/adapter/__tests__/get-route-path.ts new file mode 100644 index 0000000000000..d9d1572568d89 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/get-route-path.ts @@ -0,0 +1,39 @@ +import type { IGatsbyFunction, IGatsbyPage } from "../../../redux/types" +import { + getRoutePathFromPage, + getRoutePathFromFunction, +} from "../get-route-path" + +describe(`getRoutePathFromPage`, () => { + it(`returns the page path if no matchPath is defined`, () => { + const page = { + path: `/`, + } as IGatsbyPage + + expect(getRoutePathFromPage(page)).toEqual(`/`) + }) + it(`replaces the named part of a wildcard matchPath with a wildcard`, () => { + const page = { + matchPath: `/foo/*bar`, + } as IGatsbyPage + + expect(getRoutePathFromPage(page)).toEqual(`/foo/*`) + }) +}) + +describe(`getRoutePathFromFunction`, () => { + it(`returns the functionRoute if no matchPath is defined`, () => { + const functionInfo = { + functionRoute: `/`, + } as IGatsbyFunction + + expect(getRoutePathFromFunction(functionInfo)).toEqual(`/`) + }) + it(`replaces the named part of a wildcard matchPath with a wildcard`, () => { + const functionInfo = { + matchPath: `/foo/*bar`, + } as IGatsbyFunction + + expect(getRoutePathFromFunction(functionInfo)).toEqual(`/foo/*`) + }) +}) diff --git a/packages/gatsby/src/utils/adapter/__tests__/manager.ts b/packages/gatsby/src/utils/adapter/__tests__/manager.ts new file mode 100644 index 0000000000000..8fc17c0e90b60 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/__tests__/manager.ts @@ -0,0 +1,129 @@ +import { store } from "../../../redux" +import { + getRoutesManifest, + getFunctionsManifest, + setWebpackAssets, +} from "../manager" +import { state as stateDefault } from "./fixtures/state" +import { IGatsbyState } from "../../../internal" + +jest.mock(`../../../redux`, () => { + return { + emitter: { + on: jest.fn(), + }, + store: { + getState: jest.fn(), + }, + } +}) + +jest.mock(`../../engines-helpers`, () => { + return { + shouldGenerateEngines: jest.fn().mockReturnValue(true), + shouldBundleDatastore: jest.fn().mockReturnValue(true), + } +}) + +function mockStoreState( + state: IGatsbyState, + additionalState: Partial = {} +): void { + const mergedState = { ...state, ...additionalState } + ;(store.getState as jest.Mock).mockReturnValue(mergedState) +} + +const fixturesDir = `${__dirname}/fixtures` + +let cwdToRestore +beforeAll(() => { + cwdToRestore = process.cwd() +}) + +afterAll(() => { + process.chdir(cwdToRestore) +}) + +describe(`getRoutesManifest`, () => { + it(`should return routes manifest`, () => { + mockStoreState(stateDefault) + process.chdir(fixturesDir) + setWebpackAssets(new Set([`app-123.js`])) + + const routesManifest = getRoutesManifest() + + expect(routesManifest).toMatchSnapshot() + }) + + it(`should respect "never" trailingSlash config option`, () => { + mockStoreState(stateDefault, { + config: { ...stateDefault.config, trailingSlash: `never` }, + }) + process.chdir(fixturesDir) + setWebpackAssets(new Set([`app-123.js`])) + + const routesManifest = getRoutesManifest() + + expect(routesManifest).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: `/` }), + expect.objectContaining({ path: `/ssr` }), + expect.objectContaining({ path: `/dsg` }), + expect.objectContaining({ path: `/api/static` }), + ]) + ) + }) + + it(`should respect "always" trailingSlash config option`, () => { + mockStoreState(stateDefault, { + config: { ...stateDefault.config, trailingSlash: `always` }, + }) + process.chdir(fixturesDir) + setWebpackAssets(new Set([`app-123.js`])) + + const routesManifest = getRoutesManifest() + + expect(routesManifest).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: `/` }), + expect.objectContaining({ path: `/ssr/` }), + expect.objectContaining({ path: `/dsg/` }), + expect.objectContaining({ path: `/api/static/` }), + ]) + ) + }) +}) + +describe(`getFunctionsManifest`, () => { + it(`should return functions manifest`, () => { + mockStoreState(stateDefault) + process.chdir(fixturesDir) + + const functionsManifest = getFunctionsManifest() + + expect(functionsManifest).toMatchInlineSnapshot(` + Array [ + Object { + "functionId": "static-index-js", + "name": "/api/static/index", + "pathToEntryPoint": ".cache/functions/static/index.js", + "requiredFiles": Array [ + ".cache/functions/static/index.js", + ], + }, + Object { + "functionId": "ssr-engine", + "name": "SSR & DSG", + "pathToEntryPoint": ".cache/page-ssr/lambda.js", + "requiredFiles": Array [ + "public/404.html", + "public/500.html", + ".cache/data/datastore/data.mdb", + ".cache/page-ssr/lambda.js", + ".cache/query-engine/index.js", + ], + }, + ] + `) + }) +}) diff --git a/packages/gatsby/src/utils/adapter/constants.ts b/packages/gatsby/src/utils/adapter/constants.ts new file mode 100644 index 0000000000000..ff396fcebb415 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/constants.ts @@ -0,0 +1,36 @@ +import type { IHeader } from "../../redux/types" + +export const BASE_HEADERS: IHeader["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`, + }, +] + +export const MUST_REVALIDATE_HEADERS: IHeader["headers"] = [ + { + key: `cache-control`, + value: `public, max-age=0, must-revalidate`, + }, + ...BASE_HEADERS, +] + +export const PERMAMENT_CACHING_HEADERS: IHeader["headers"] = [ + { + key: `cache-control`, + value: `public, max-age=31536000, immutable`, + }, + ...BASE_HEADERS, +] diff --git a/packages/gatsby/src/utils/adapter/create-headers.ts b/packages/gatsby/src/utils/adapter/create-headers.ts new file mode 100644 index 0000000000000..65605dc3b0d97 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/create-headers.ts @@ -0,0 +1,86 @@ +import { match } from "@gatsbyjs/reach-router" +import type { IHeader } from "../../redux/types" +import { rankRoute } from "../rank-route" + +type Headers = IHeader["headers"] +interface IHeaderWithScore extends IHeader { + score: number +} + +// We don't care if the path has a trailing slash or not, but to be able to compare stuff we need to normalize it +const normalizePath = (input: string): string => + input.endsWith(`/`) ? input : `${input}/` + +export const createHeadersMatcher = ( + headers: Array | undefined +): ((path: string, defaultHeaders: Headers) => Headers) => { + // Split the incoming user headers into two buckets: + // - dynamicHeaders: Headers with dynamic paths (e.g. /* or /:tests) + // - staticHeaders: Headers with fully static paths (e.g. /static/) + // Also add a score using the rankRoute function to each header + let dynamicHeaders: Array = [] + const staticHeaders: Map = new Map() + + // If no custom headers are defined by the user in the gatsby-config, we can return only the default headers + if (!headers || headers.length === 0) { + return (_path: string, defaultHeaders: Headers) => defaultHeaders + } + + for (const header of headers) { + if (header.source.includes(`:`) || header.source.includes(`*`)) { + // rankRoute is the internal function that also "match" uses + const score = rankRoute(header.source) + + dynamicHeaders.push({ ...header, score }) + } else { + staticHeaders.set(normalizePath(header.source), header) + } + } + + // Sort the dynamic headers by score, moving the ones with the highest specificity to the end of the array + // If the score is the same, do a lexigraphic comparison of the source + dynamicHeaders = dynamicHeaders.sort((a, b) => { + const order = a.score - b.score + if (order !== 0) { + return order + } + return a.source.localeCompare(b.source) + }) + + return (path: string, defaultHeaders: Headers): Headers => { + // Create a map of headers for the given path + // The key will be the header key. Since a key may only appear once in a map, the last header with the same key will win + const uniqueHeaders: Map = new Map() + + // 1. Add default headers + for (const h of defaultHeaders) { + uniqueHeaders.set(h.key, h.value) + } + + // 2. Add dynamic headers that match the current path + for (const d of dynamicHeaders) { + if (match(d.source, path)) { + for (const h of d.headers) { + uniqueHeaders.set(h.key, h.value) + } + } + } + + const staticEntry = staticHeaders.get(normalizePath(path)) + + // 3. Add static headers that match the current path + if (staticEntry) { + for (const h of staticEntry.headers) { + uniqueHeaders.set(h.key, h.value) + } + } + + // Convert the map back to an array of objects + return Array.from(uniqueHeaders.entries()).map(([key, value]) => { + return { + key, + value, + } + }) + } +} diff --git a/packages/gatsby/src/utils/adapter/get-route-path.ts b/packages/gatsby/src/utils/adapter/get-route-path.ts new file mode 100644 index 0000000000000..a4018bc19684b --- /dev/null +++ b/packages/gatsby/src/utils/adapter/get-route-path.ts @@ -0,0 +1,25 @@ +import type { IGatsbyFunction, IGatsbyPage } from "../../redux/types" + +function maybeDropNamedPartOfWildcard( + path: string | undefined +): string | undefined { + if (!path) { + return path + } + + // Replaces `/foo/*bar` with `/foo/*` + return path.replace(/\*.+$/, `*`) +} + +export function getRoutePathFromPage(page: IGatsbyPage): string { + return maybeDropNamedPartOfWildcard(page.matchPath) ?? page.path +} + +export function getRoutePathFromFunction( + functionInfo: IGatsbyFunction +): string { + return ( + maybeDropNamedPartOfWildcard(functionInfo.matchPath) ?? + functionInfo.functionRoute + ) +} diff --git a/packages/gatsby/src/utils/adapter/init.ts b/packages/gatsby/src/utils/adapter/init.ts new file mode 100644 index 0000000000000..236aa278af0a5 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/init.ts @@ -0,0 +1,192 @@ +import reporter from "gatsby-cli/lib/reporter" +import _ from "lodash" +import { createRequireFromPath } from "gatsby-core-utils/create-require-from-path" +import { join } from "path" +import { emptyDir, ensureDir, outputJson } from "fs-extra" +import execa, { Options as ExecaOptions } from "execa" +import { version as gatsbyVersion } from "gatsby/package.json" +import { satisfies } from "semver" +import type { AdapterInit } from "./types" +import { preferDefault } from "../../bootstrap/prefer-default" +import { getLatestAdapters } from "../get-latest-gatsby-files" + +const getAdaptersCacheDir = (): string => join(process.cwd(), `.cache/adapters`) + +const createAdaptersCacheDir = async (): Promise => { + await ensureDir(getAdaptersCacheDir()) + await emptyDir(getAdaptersCacheDir()) + + const packageJsonPath = join(getAdaptersCacheDir(), `package.json`) + + await outputJson(packageJsonPath, { + name: `gatsby-adapters`, + description: `This directory contains adapters that have been automatically installed by Gatsby.`, + version: `1.0.0`, + private: true, + author: `Gatsby`, + license: `MIT`, + }) +} + +export async function getAdapterInit(): Promise { + // 1. Find the correct adapter and its details (e.g. version) + const latestAdapters = await getLatestAdapters() + const adapterToUse = latestAdapters.find(candidate => candidate.test()) + + if (!adapterToUse) { + reporter.verbose( + `No adapter was found for the current environment. Skipping adapter initialization.` + ) + return undefined + } + + const versionForCurrentGatsbyVersion = adapterToUse.versions.find(entry => + satisfies(gatsbyVersion, entry.gatsbyVersion, { includePrerelease: true }) + ) + + if (!versionForCurrentGatsbyVersion) { + reporter.verbose( + `The ${adapterToUse.name} adapter is not compatible with your current Gatsby version ${gatsbyVersion}.` + ) + return undefined + } + + // 2. Check if the user has manually installed the adapter and try to resolve it from there + try { + const siteRequire = createRequireFromPath(`${process.cwd()}/:internal:`) + const adapterPackageJson = siteRequire( + `${adapterToUse.module}/package.json` + ) + const adapterGatsbyPeerDependency = _.get( + adapterPackageJson, + `peerDependencies.gatsby` + ) + const moduleVersion = adapterPackageJson?.version + + // Check if the peerDependency of the adapter is compatible with the current Gatsby version + if ( + adapterGatsbyPeerDependency && + !satisfies(gatsbyVersion, adapterGatsbyPeerDependency, { + includePrerelease: true, + }) + ) { + reporter.warn( + `The ${adapterToUse.name} adapter is not compatible with your current Gatsby version ${gatsbyVersion} - It requires gatsby@${adapterGatsbyPeerDependency}` + ) + return undefined + } + + // Cross-check the adapter version with the version manifest and see if the adapter version is correct for the current Gatsby version + const isAdapterCompatible = satisfies( + moduleVersion, + versionForCurrentGatsbyVersion.moduleVersion, + { + includePrerelease: true, + } + ) + + if (!isAdapterCompatible) { + reporter.warn( + `${adapterToUse.module}@${moduleVersion} is not compatible with your current Gatsby version ${gatsbyVersion} - Install ${adapterToUse.module}@${versionForCurrentGatsbyVersion.moduleVersion} or later.` + ) + + return undefined + } + + const required = siteRequire.resolve(adapterToUse.module) + + if (required) { + reporter.verbose( + `Reusing existing adapter ${adapterToUse.module} inside node_modules` + ) + + // TODO: double preferDefault is most ceirtainly wrong - figure it out + return preferDefault(preferDefault(await import(required))) as AdapterInit + } + } catch (e) { + // no-op + } + + // 3. Check if a previous run has installed the correct adapter into .cache/adapters already and try to resolve it from there + try { + const adaptersRequire = createRequireFromPath( + `${getAdaptersCacheDir()}/:internal:` + ) + const required = adaptersRequire.resolve(adapterToUse.module) + + if (required) { + reporter.verbose( + `Reusing existing adapter ${adapterToUse.module} inside .cache/adapters` + ) + + // TODO: double preferDefault is most ceirtainly wrong - figure it out + return preferDefault(preferDefault(await import(required))) as AdapterInit + } + } catch (e) { + // no-op + } + + const installTimer = reporter.activityTimer( + `Installing ${adapterToUse.name} adapter (${adapterToUse.module}@${versionForCurrentGatsbyVersion.moduleVersion})` + ) + // 4. If both a manually installed version and a cached version are not found, install the adapter into .cache/adapters + try { + installTimer.start() + await createAdaptersCacheDir() + + const options: ExecaOptions = { + stderr: `inherit`, + cwd: getAdaptersCacheDir(), + } + + const npmAdditionalCliArgs = [ + `--no-progress`, + `--no-audit`, + `--no-fund`, + `--loglevel`, + `error`, + `--color`, + `always`, + `--legacy-peer-deps`, + `--save-exact`, + ] + + await execa( + `npm`, + [ + `install`, + ...npmAdditionalCliArgs, + `${adapterToUse.module}@${versionForCurrentGatsbyVersion.moduleVersion}`, + ], + options + ) + + installTimer.end() + + reporter.info( + `If you plan on staying on this deployment platform, consider installing ${adapterToUse.module} as a dependency in your project. This will give you faster and more robust installs.` + ) + + const adaptersRequire = createRequireFromPath( + `${getAdaptersCacheDir()}/:internal:` + ) + const required = adaptersRequire.resolve(adapterToUse.module) + + if (required) { + reporter.verbose( + `Using installed adapter ${adapterToUse.module} inside .cache/adapters` + ) + + // TODO: double preferDefault is most ceirtainly wrong - figure it out + return preferDefault(preferDefault(await import(required))) as AdapterInit + } + } catch (e) { + installTimer.end() + + reporter.warn( + `Could not install adapter ${adapterToUse.module}. Please install it yourself by adding it to your package.json's dependencies and try building your project again.` + ) + } + + return undefined +} diff --git a/packages/gatsby/src/utils/adapter/manager.ts b/packages/gatsby/src/utils/adapter/manager.ts new file mode 100644 index 0000000000000..1cc8ab48265f0 --- /dev/null +++ b/packages/gatsby/src/utils/adapter/manager.ts @@ -0,0 +1,582 @@ +import reporter from "gatsby-cli/lib/reporter" +import { applyTrailingSlashOption, TrailingSlash } from "gatsby-page-utils" +import { generateHtmlPath } from "gatsby-core-utils/page-html" +import { slash } from "gatsby-core-utils/path" +import { generatePageDataPath } from "gatsby-core-utils/page-data" +import { posix } from "path" +import { sync as globSync } from "glob" +import telemetry from "gatsby-telemetry" +import { copy, pathExists, unlink } from "fs-extra" +import type { + FunctionsManifest, + IAdaptContext, + RoutesManifest, + Route, + IAdapterManager, + IFunctionRoute, + IAdapter, + IAdapterFinalConfig, + IAdapterConfig, +} from "./types" +import { store, readState } from "../../redux" +import { getPageMode } from "../page-mode" +import { getStaticQueryPath } from "../static-query-utils" +import { getAdapterInit } from "./init" +import { + LmdbOnCdnPath, + shouldBundleDatastore, + shouldGenerateEngines, +} from "../engines-helpers" +import { + BASE_HEADERS, + MUST_REVALIDATE_HEADERS, + PERMAMENT_CACHING_HEADERS, +} from "./constants" +import { createHeadersMatcher } from "./create-headers" +import { HTTP_STATUS_CODE } from "../../redux/types" +import type { IHeader } from "../../redux/types" +import { rankRoute } from "../rank-route" +import { + getRoutePathFromFunction, + getRoutePathFromPage, +} from "./get-route-path" +import { noOpAdapterManager } from "./no-op-manager" +import { getDefaultDbPath } from "../../datastore/lmdb/lmdb-datastore" + +async function setAdapter({ + instance, + manager, +}: { + instance?: IAdapter + manager: IAdapterManager +}): Promise { + const configFromAdapter = await manager.config() + + store.dispatch({ + type: `SET_ADAPTER`, + payload: { + manager, + instance, + config: configFromAdapter, + }, + }) + + if (instance) { + // if adapter reports that it doesn't support certain features, we need to fail the build + // to avoid broken deploys + + const incompatibleFeatures: Array = [] + + // pathPrefix support + if ( + configFromAdapter?.supports?.pathPrefix === false && + store.getState().program.prefixPaths && + store.getState().config.pathPrefix + ) { + incompatibleFeatures.push( + `pathPrefix is not supported. Please remove the pathPrefix option from your gatsby-config, don't use "--prefix-paths" CLI toggle or PREFIX_PATHS environment variable.` + ) + } + + // trailingSlash support + if (configFromAdapter?.supports?.trailingSlash) { + const { trailingSlash } = store.getState().config + + if ( + !configFromAdapter.supports.trailingSlash.includes( + trailingSlash ?? `always` + ) + ) { + incompatibleFeatures.push( + `trailingSlash option "${trailingSlash}". Supported option${ + configFromAdapter.supports.trailingSlash.length > 1 ? `s` : `` + }: ${configFromAdapter.supports.trailingSlash + .map(option => `"${option}"`) + .join(`, `)}` + ) + } + } + + if (incompatibleFeatures.length > 0) { + reporter.panic({ + id: `12201`, + context: { + adapterName: instance.name, + incompatibleFeatures, + }, + }) + } + + if (configFromAdapter.pluginsToDisable.length > 0) { + store.dispatch({ + type: `DISABLE_PLUGINS_BY_NAME`, + payload: { + pluginsToDisable: configFromAdapter.pluginsToDisable, + reason: `Not compatible with the "${instance.name}" adapter. Please remove it from your gatsby-config.`, + }, + }) + } + } +} + +export async function initAdapterManager(): Promise { + let adapter: IAdapter + + const config = store.getState().config + const { adapter: adapterFromGatsbyConfig, trailingSlash, pathPrefix } = config + + // If the user specified an adapter inside their gatsby-config, use that instead of trying to figure out an adapter for the current environment + if (adapterFromGatsbyConfig) { + adapter = adapterFromGatsbyConfig + + reporter.verbose(`Using adapter ${adapter.name} from gatsby-config`) + } else { + const adapterInit = await getAdapterInit() + + // If we don't have adapter, use no-op adapter manager + if (!adapterInit) { + telemetry.trackFeatureIsUsed(`adapter:no-op`) + + const manager = noOpAdapterManager() + + await setAdapter({ manager }) + + return manager + } + + adapter = adapterInit() + } + + reporter.info(`Using ${adapter.name} adapter`) + telemetry.trackFeatureIsUsed(`adapter:${adapter.name}`) + + const directoriesToCache = [`.cache`, `public`] + const manager: IAdapterManager = { + restoreCache: async (): Promise => { + if (!adapter.cache) { + return + } + + const result = await adapter.cache.restore({ + directories: directoriesToCache, + reporter, + }) + if (result === false) { + // if adapter reports `false`, we can skip trying to re-hydrate state + return + } + + const cachedState = readState() + + // readState() returns empty object if there is no cached state or it's corrupted etc + // so we want to avoid dispatching RESTORE_CACHE action in that case + if (Object.keys(cachedState).length > 0) { + store.dispatch({ + type: `RESTORE_CACHE`, + payload: cachedState, + }) + } + }, + storeCache: async (): Promise => { + if (!adapter.cache) { + return + } + + await adapter.cache.store({ directories: directoriesToCache, reporter }) + }, + adapt: async (): Promise => { + if (!adapter.adapt) { + return + } + + // handle lmdb file + const mdbInPublicPath = `public/${LmdbOnCdnPath}` + if (!shouldBundleDatastore()) { + const mdbPath = getDefaultDbPath() + `/data.mdb` + copy(mdbPath, mdbInPublicPath) + } else { + // ensure public dir doesn't have lmdb file + if (await pathExists(mdbInPublicPath)) { + await unlink(mdbInPublicPath) + } + } + + let _routesManifest: RoutesManifest | undefined = undefined + let _functionsManifest: FunctionsManifest | undefined = undefined + const adaptContext: IAdaptContext = { + get routesManifest(): RoutesManifest { + if (!_routesManifest) { + _routesManifest = getRoutesManifest() + } + + return _routesManifest + }, + get functionsManifest(): FunctionsManifest { + if (!_functionsManifest) { + _functionsManifest = getFunctionsManifest() + } + + return _functionsManifest + }, + reporter, + // Our internal Gatsby config allows this to be undefined but for the adapter we should always pass through the default values and correctly show this in the TypeScript types + trailingSlash: trailingSlash as TrailingSlash, + pathPrefix: pathPrefix as string, + } + + await adapter.adapt(adaptContext) + }, + config: async (): Promise => { + let configFromAdapter: undefined | IAdapterConfig = undefined + if (adapter.config) { + configFromAdapter = await adapter.config({ reporter }) + + if ( + configFromAdapter?.excludeDatastoreFromEngineFunction && + !configFromAdapter?.deployURL + ) { + throw new Error( + `Can't exclude datastore from engine function without adapter providing deployURL` + ) + } + } + + return { + excludeDatastoreFromEngineFunction: + configFromAdapter?.excludeDatastoreFromEngineFunction ?? false, + deployURL: configFromAdapter?.deployURL, + supports: configFromAdapter?.supports, + pluginsToDisable: configFromAdapter?.pluginsToDisable ?? [], + } + }, + } + + await setAdapter({ manager, instance: adapter }) + + return manager +} + +let webpackAssets: Set | undefined +export function setWebpackAssets(assets: Set): void { + webpackAssets = assets +} + +type RouteWithScore = { score: number } & Route + +function getRoutesManifest(): RoutesManifest { + const routes: Array = [] + const state = store.getState() + const createHeaders = createHeadersMatcher(state.config.headers) + + const fileAssets = new Set( + globSync(`**/**`, { + cwd: posix.join(process.cwd(), `public`), + nodir: true, + dot: true, + }).map(filePath => slash(filePath)) + ) + + // 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 = `/${route.path}` + } + + // Apply trailing slash behavior unless it's a redirect. Redirects should always be exact matches + if (route.type !== `redirect`) { + route.path = applyTrailingSlashOption( + route.path, + state.config.trailingSlash + ) + } + + if (route.type !== `function`) { + route.headers = createHeaders(route.path, route.headers) + } + + ;(route as RouteWithScore).score = rankRoute(route.path) + + routes.push(route as RouteWithScore) + } + + function addStaticRoute({ + path, + pathToFillInPublicDir, + headers, + }: { + path: string + pathToFillInPublicDir: string + headers: IHeader["headers"] + }): void { + addRoute({ + path, + type: `static`, + filePath: posix.join(`public`, pathToFillInPublicDir), + headers, + }) + + if (fileAssets.has(pathToFillInPublicDir)) { + fileAssets.delete(pathToFillInPublicDir) + } else { + reporter.verbose( + `[Adapters] Tried to remove "${pathToFillInPublicDir}" from fileAssets but it wasn't there` + ) + } + } + + // routes - pages - static (SSG) or function (DSG/SSR) + for (const page of state.pages.values()) { + const htmlRoutePath = slash(getRoutePathFromPage(page)) + const pageDataRoutePath = slash(generatePageDataPath(``, htmlRoutePath)) + + const pageMode = getPageMode(page) + + if (pageMode === `SSG`) { + const htmlFilePath = slash(generateHtmlPath(``, page.path)) + const pageDataFilePath = slash(generatePageDataPath(``, page.path)) + + addStaticRoute({ + path: htmlRoutePath, + pathToFillInPublicDir: htmlFilePath, + headers: MUST_REVALIDATE_HEADERS, + }) + addStaticRoute({ + path: pageDataRoutePath, + pathToFillInPublicDir: pageDataFilePath, + headers: MUST_REVALIDATE_HEADERS, + }) + } else { + const commonFields: Omit = { + type: `function`, + functionId: `ssr-engine`, + } + + if (pageMode === `DSG`) { + commonFields.cache = true + } + + addRoute({ + path: htmlRoutePath, + ...commonFields, + }) + + addRoute({ + path: pageDataRoutePath, + ...commonFields, + }) + } + } + + // static query json assets + for (const staticQueryComponent of state.staticQueryComponents.values()) { + const staticQueryResultPath = getStaticQueryPath(staticQueryComponent.hash) + addStaticRoute({ + path: staticQueryResultPath, + pathToFillInPublicDir: staticQueryResultPath, + headers: MUST_REVALIDATE_HEADERS, + }) + } + + // app-data.json + { + const appDataFilePath = posix.join(`page-data`, `app-data.json`) + addStaticRoute({ + path: appDataFilePath, + pathToFillInPublicDir: appDataFilePath, + headers: MUST_REVALIDATE_HEADERS, + }) + } + + // webpack assets + if (!webpackAssets) { + reporter.panic({ + id: `12200`, + context: {}, + }) + } + + for (const asset of webpackAssets) { + addStaticRoute({ + path: asset, + pathToFillInPublicDir: asset, + headers: PERMAMENT_CACHING_HEADERS, + }) + } + + // chunk-map.json + { + const chunkMapFilePath = posix.join(`chunk-map.json`) + addStaticRoute({ + path: chunkMapFilePath, + pathToFillInPublicDir: chunkMapFilePath, + headers: MUST_REVALIDATE_HEADERS, + }) + } + + // webpack.stats.json + { + const webpackStatsFilePath = posix.join(`webpack.stats.json`) + addStaticRoute({ + path: webpackStatsFilePath, + pathToFillInPublicDir: webpackStatsFilePath, + headers: MUST_REVALIDATE_HEADERS, + }) + } + + for (const slice of state.slices.values()) { + const sliceDataPath = posix.join(`slice-data`, `${slice.name}.json`) + + addStaticRoute({ + path: sliceDataPath, + pathToFillInPublicDir: sliceDataPath, + headers: MUST_REVALIDATE_HEADERS, + }) + } + + function addSliceHtmlRoute(name: string, hasChildren: boolean): void { + const sliceHtml1Path = posix.join(`_gatsby`, `slices`, `${name}-1.html`) + addStaticRoute({ + path: sliceHtml1Path, + pathToFillInPublicDir: sliceHtml1Path, + headers: MUST_REVALIDATE_HEADERS, + }) + if (hasChildren) { + const sliceHtml2Path = posix.join(`_gatsby`, `slices`, `${name}-2.html`) + addStaticRoute({ + path: sliceHtml2Path, + pathToFillInPublicDir: sliceHtml2Path, + headers: MUST_REVALIDATE_HEADERS, + }) + } + } + + addSliceHtmlRoute(`_gatsby-scripts`, false) + for (const [ + name, + { hasChildren }, + ] of state.html.slicesProps.bySliceId.entries()) { + addSliceHtmlRoute(name, hasChildren) + } + + // redirect routes + for (const redirect of state.redirects.values()) { + addRoute({ + path: redirect.fromPath, + type: `redirect`, + toPath: redirect.toPath, + status: + redirect.statusCode ?? + (redirect.isPermanent + ? HTTP_STATUS_CODE.MOVED_PERMANENTLY_301 + : HTTP_STATUS_CODE.FOUND_302), + ignoreCase: redirect.ignoreCase, + headers: BASE_HEADERS, + }) + } + + // function routes + for (const functionInfo of state.functions.values()) { + addRoute({ + path: `/api/${getRoutePathFromFunction(functionInfo)}`, + type: `function`, + functionId: functionInfo.functionId, + }) + } + + for (const fileAsset of fileAssets) { + // try to classify remaining assets + let headers: IHeader["headers"] | undefined = undefined + + if (fileAsset.startsWith(`~partytown`)) { + // no hashes, must revalidate + headers = MUST_REVALIDATE_HEADERS + } else if ( + fileAsset.startsWith(`_gatsby/image`) || + fileAsset.startsWith(`_gatsby/file`) + ) { + headers = PERMAMENT_CACHING_HEADERS + } + + if (!headers) { + headers = BASE_HEADERS + } + + addStaticRoute({ + path: fileAsset, + pathToFillInPublicDir: fileAsset, + headers, + }) + } + + return ( + routes + .sort((a, b) => { + // The higher the score, the higher the specificity of our path + const order = b.score - a.score + if (order !== 0) { + return order + } + + // if specificity is the same we do lexigraphic comparison of path to ensure + // deterministic order regardless of order pages where created + return a.path.localeCompare(b.path) + }) + // The score should be internal only, so we remove it from the final manifest + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .map(({ score, ...rest }): Route => { + return { ...rest } + }) + ) +} + +function getFunctionsManifest(): FunctionsManifest { + const functions = [] as FunctionsManifest + + for (const functionInfo of store.getState().functions.values()) { + const pathToEntryPoint = posix.join( + `.cache`, + `functions`, + functionInfo.relativeCompiledFilePath + ) + const relativePathWithoutFileExtension = posix.join( + posix.parse(functionInfo.originalRelativeFilePath).dir, + posix.parse(functionInfo.originalRelativeFilePath).name + ) + + functions.push({ + functionId: functionInfo.functionId, + name: `/api/${relativePathWithoutFileExtension}`, + pathToEntryPoint, + requiredFiles: [pathToEntryPoint], + }) + } + + if (shouldGenerateEngines()) { + function getFilesFrom(dir: string): Array { + return globSync(`**/**`, { + cwd: posix.join(process.cwd(), dir), + nodir: true, + dot: true, + }).map(file => posix.join(dir, file)) + } + + functions.push({ + functionId: `ssr-engine`, + pathToEntryPoint: posix.join(`.cache`, `page-ssr`, `lambda.js`), + name: `SSR & DSG`, + requiredFiles: [ + `public/404.html`, + `public/500.html`, + ...(shouldBundleDatastore() + ? getFilesFrom(posix.join(`.cache`, `data`, `datastore`)) + : []), + ...getFilesFrom(posix.join(`.cache`, `page-ssr`)), + ...getFilesFrom(posix.join(`.cache`, `query-engine`)), + ], + }) + } + + return functions +} + +export { getRoutesManifest, getFunctionsManifest } diff --git a/packages/gatsby/src/utils/adapter/no-op-manager.ts b/packages/gatsby/src/utils/adapter/no-op-manager.ts new file mode 100644 index 0000000000000..edc5d74c207bf --- /dev/null +++ b/packages/gatsby/src/utils/adapter/no-op-manager.ts @@ -0,0 +1,15 @@ +import { IAdapterFinalConfig, IAdapterManager } from "./types" + +export function noOpAdapterManager(): IAdapterManager { + return { + restoreCache: (): void => {}, + storeCache: (): void => {}, + adapt: (): void => {}, + config: async (): Promise => { + return { + excludeDatastoreFromEngineFunction: false, + pluginsToDisable: [], + } + }, + } +} diff --git a/packages/gatsby/src/utils/adapter/types.ts b/packages/gatsby/src/utils/adapter/types.ts new file mode 100644 index 0000000000000..b9915427228eb --- /dev/null +++ b/packages/gatsby/src/utils/adapter/types.ts @@ -0,0 +1,248 @@ +import type reporter from "gatsby-cli/lib/reporter" +import type { TrailingSlash } from "gatsby-page-utils" +import type { IHeader, HttpStatusCode } from "../../redux/types" + +interface IBaseRoute { + /** + * Request path that should be matched for this route. + * It can be: + * - static: `/about/` + * - dynamic: + * - parameterized: `/blog/:slug/` + * - catch-all / wildcard: `/app/*` + */ + path: string +} + +export interface IStaticRoute extends IBaseRoute { + type: `static` + /** + * Location of the file that should be served for this route. + */ + filePath: string + /** + * HTTP headers that should be set for this route. + * @see http://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/headers/ + */ + headers: IHeader["headers"] +} + +export interface IFunctionRoute extends IBaseRoute { + type: `function` + /** + * Unique identifier of this function. Corresponds to the `functionId` inside the `functionsManifest`. + * Some functions will be shared for multiple routes, e.g. SSR or DSG functions. + */ + functionId: string + /** + * If `cache` is true, response of function should be cached for current deployment and served on subsequent requests for this route. + */ + cache?: true +} + +/** + * Redirects are being created through the `createRedirect` action. + * @see https://www.gatsbyjs.com/docs/reference/config-files/actions/#createRedirect + */ +export interface IRedirectRoute extends IBaseRoute { + type: `redirect` + /** + * The redirect should happen from `path` to `toPath`. + */ + toPath: string + /** + * HTTP status code that should be used for this redirect. + */ + status: HttpStatusCode + ignoreCase?: boolean + /** + * HTTP headers that should be used for this redirect. + * @see http://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/headers/ + */ + headers: IHeader["headers"] + [key: string]: unknown +} + +export type Route = IStaticRoute | IFunctionRoute | IRedirectRoute + +export type RoutesManifest = Array + +export interface IFunctionDefinition { + /** + * Unique identifier of this function. Corresponds to the `functionId` inside the `routesManifest`. + */ + functionId: string + /** + * Unique name of this function. Use this as a display name for the function. + */ + name: string + /** + * Path to function entrypoint that will be used to create function + */ + pathToEntryPoint: string + /** + * List of all required files that this function needs to run + */ + requiredFiles: Array +} + +export type FunctionsManifest = Array + +interface IDefaultContext { + /** + * Reporter instance that can be used to log messages to terminal + * @see https://www.gatsbyjs.com/docs/reference/config-files/node-api-helpers/#reporter + */ + reporter: typeof reporter +} + +export interface IAdaptContext extends IDefaultContext { + routesManifest: RoutesManifest + functionsManifest: FunctionsManifest + /** + * @see https://www.gatsbyjs.com/docs/reference/config-files/gatsby-config/#pathprefix + */ + pathPrefix: string + /** + * @see https://www.gatsbyjs.com/docs/reference/config-files/gatsby-config/#trailingslash + */ + trailingSlash: TrailingSlash +} + +export interface ICacheContext extends IDefaultContext { + directories: Array +} + +export interface IAdapterConfig { + /** + * URL representing the unique URL for an individual deploy + */ + deployURL?: string + /** + * If `true`, Gatsby will not include the LMDB datastore in the serverless functions used for SSR/DSG. + * Instead, it will try to download the datastore from the given `deployURL`. + */ + excludeDatastoreFromEngineFunction?: boolean + /** + * Adapters can optionally describe which features they support to prevent potentially faulty deployments + */ + supports?: { + /** + * If `false`, Gatsby will fail the build if user tries to use pathPrefix. + */ + pathPrefix?: boolean + /** + * Provide array of supported traling slash options + * @example [`always`] + */ + trailingSlash?: Array + } + /** + * List of plugins that should be disabled when using this adapter. Purpose of this is to disable + * any potential plugins that serve similar role as adapter that would cause conflicts when both + * plugin and adapter is used at the same time. + */ + pluginsToDisable?: Array +} + +type WithRequired = T & { [P in K]-?: T[P] } + +/** + * This is the internal version of "IAdapterConfig" to enforce that certain keys must be present. + * Authors of adapters will only see "IAdapterConfig". + */ +export type IAdapterFinalConfig = WithRequired< + IAdapterConfig, + "excludeDatastoreFromEngineFunction" | "pluginsToDisable" +> + +export interface IAdapter { + /** + * Unique name of the adapter. Used to identify adapter in manifest. + */ + name: string + cache?: { + /** + * Hook to restore `directories` from previous builds. This is executed very early on in the build process. If `false` is returned Gatsby will skip its cache restoration. + */ + restore: ( + context: ICacheContext + ) => Promise | boolean | void + /** + * Hook to store `directories` for the current build. Executed as one of the last steps in the build process. + */ + store: (context: ICacheContext) => Promise | void + } + /** + * Hook to take Gatsby’s output and preparing it for deployment on the adapter’s platform. Executed as one of the last steps in the build process. + * + * The `adapt` hook should do the following things: + * - Apply HTTP headers to assets + * - Apply redirects and rewrites. The adapter should can also create its own redirects/rewrites if necessary (e.g. mapping serverless functions to internal URLs). + * - Wrap serverless functions coming from Gatsby with platform-specific code (if necessary). Gatsby will produce [Express-like](https://expressjs.com/) handlers. + * - Apply trailing slash behavior and path prefix to URLs + * - Possibly upload assets to CDN + * + * @see http://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/creating-an-adapter/ + */ + adapt: (context: IAdaptContext) => Promise | void + /** + * Hook to pass information from the adapter to Gatsby. You must return an object with a predefined shape. + * Gatsby uses this information to adjust its build process. The information can be e.g. things that are only known once the project is deployed. + * + * This hook can enable advanced feature of adapters and it is not required to implement it. + * + * @see http://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/creating-an-adapter/ + */ + config?: ( + context: IDefaultContext + ) => Promise | IAdapterConfig +} + +/** + * Adapter initialization function that returns an instance of the adapter. + * @see http://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/creating-an-adapter/ + */ +export type AdapterInit> = ( + adapterOptions?: T +) => IAdapter + +export interface IAdapterManager { + restoreCache: () => Promise | void + storeCache: () => Promise | void + adapt: () => Promise | void + config: () => Promise +} +/** + * Types for gatsby/adapters.js + * @see http://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/zero-configuration-deployments/ + */ +export interface IAdapterManifestEntry { + /** + * Name of the adapter + */ + name: string + /** + * Test function to determine if adapter should be used + */ + test: () => boolean + /** + * npm module name of the adapter + */ + module: string + /** + * List of version pairs that are supported by the adapter. + * This allows to have multiple versions of the adapter for different versions of Gatsby. + * This is useful for when APIs change or bugs are fixed that require different implementations. + */ + versions: Array<{ + /** + * Version of the `gatsby` package. This is a semver range. + */ + gatsbyVersion: string + /** + * Version of the adapter. This is a semver range. + */ + moduleVersion: string + }> +} diff --git a/packages/gatsby/src/utils/did-you-mean.ts b/packages/gatsby/src/utils/did-you-mean.ts index 014636ea88f1a..5273eae04fffa 100644 --- a/packages/gatsby/src/utils/did-you-mean.ts +++ b/packages/gatsby/src/utils/did-you-mean.ts @@ -14,6 +14,8 @@ export const KNOWN_CONFIG_KEYS = [ `jsxImportSource`, `trailingSlash`, `graphqlTypegen`, + `headers`, + `adapter`, ] export function didYouMean( diff --git a/packages/gatsby/src/utils/engines-helpers.ts b/packages/gatsby/src/utils/engines-helpers.ts index 3f73842bcf1a3..20c7814986339 100644 --- a/packages/gatsby/src/utils/engines-helpers.ts +++ b/packages/gatsby/src/utils/engines-helpers.ts @@ -1,4 +1,4 @@ -import { emitter } from "../redux" +import { emitter, store } from "../redux" import { ICreatePageAction, ISetComponentFeatures } from "../redux/types" import { trackFeatureIsUsed } from "gatsby-telemetry" @@ -23,3 +23,13 @@ emitter.on(`SET_COMPONENT_FEATURES`, (action: ISetComponentFeatures) => { shouldSendTelemetryForHeadAPI = false } }) + +export function shouldBundleDatastore(): boolean { + return !store.getState().adapter.config.excludeDatastoreFromEngineFunction +} + +function getCDNObfuscatedPath(path: string): string { + return `${store.getState().status.cdnObfuscatedPrefix}-${path}` +} + +export const LmdbOnCdnPath = getCDNObfuscatedPath(`data.mdb`) diff --git a/packages/gatsby/src/utils/gatsby-webpack-stats-extractor.ts b/packages/gatsby/src/utils/gatsby-webpack-stats-extractor.ts index 78dcd65d1ee45..b09dba3d65154 100644 --- a/packages/gatsby/src/utils/gatsby-webpack-stats-extractor.ts +++ b/packages/gatsby/src/utils/gatsby-webpack-stats-extractor.ts @@ -4,6 +4,7 @@ import { Compiler } from "webpack" import { PARTIAL_HYDRATION_CHUNK_REASON } from "./webpack/plugins/partial-hydration" import { store } from "../redux" import { ensureFileContent } from "./ensure-file-content" +import { setWebpackAssets } from "./adapter/manager" let previousChunkMapJson: string | undefined let previousWebpackStatsJson: string | undefined @@ -60,12 +61,44 @@ export class GatsbyWebpackStatsExtractor { } } + const { + namedChunkGroups = {}, + name = ``, + ...assetsRelatedStats + } = stats.toJson({ + all: false, + chunkGroups: true, + cachedAssets: true, + assets: true, + }) + const webpackStats = { - ...stats.toJson({ all: false, chunkGroups: true }), + name, + namedChunkGroups, assetsByChunkName: assets, childAssetsByChunkName: childAssets, } + if (assetsRelatedStats.assets) { + const assets = new Set() + for (const asset of assetsRelatedStats.assets) { + assets.add(asset.name) + + if (asset.info.related) { + for (const relatedAssets of Object.values(asset.info.related)) { + if (Array.isArray(relatedAssets)) { + for (const relatedAsset of relatedAssets) { + assets.add(relatedAsset) + } + } else { + assets.add(relatedAssets) + } + } + } + } + setWebpackAssets(assets) + } + const newChunkMapJson = JSON.stringify(assetsMap) if (newChunkMapJson !== previousChunkMapJson) { await fs.writeFile( diff --git a/packages/gatsby/src/utils/get-latest-apis.ts b/packages/gatsby/src/utils/get-latest-apis.ts deleted file mode 100644 index 9ecb77db66b25..0000000000000 --- a/packages/gatsby/src/utils/get-latest-apis.ts +++ /dev/null @@ -1,35 +0,0 @@ -import path from "path" -import fs from "fs-extra" -import axios from "axios" - -const API_FILE = `https://unpkg.com/gatsby/apis.json` -const ROOT = path.join(__dirname, `..`, `..`) -const OUTPUT_FILE = path.join(ROOT, `latest-apis.json`) - -export interface IAPIResponse { - browser: Record - node: Record - ssr: Record -} - -export const getLatestAPIs = async (): Promise => { - try { - const { data } = await axios.get(API_FILE, { timeout: 5000 }) - - await fs.writeFile(OUTPUT_FILE, JSON.stringify(data, null, 2), `utf8`) - - return data - } catch (e) { - if (await fs.pathExists(OUTPUT_FILE)) { - return fs.readJSON(OUTPUT_FILE) - } - // possible offline/network issue - return fs.readJSON(path.join(ROOT, `apis.json`)).catch(() => { - return { - browser: {}, - node: {}, - ssr: {}, - } - }) - } -} diff --git a/packages/gatsby/src/utils/get-latest-gatsby-files.ts b/packages/gatsby/src/utils/get-latest-gatsby-files.ts new file mode 100644 index 0000000000000..13d4b006b1765 --- /dev/null +++ b/packages/gatsby/src/utils/get-latest-gatsby-files.ts @@ -0,0 +1,81 @@ +import path from "path" +import * as fs from "fs-extra" +import axios from "axios" +import { IAdapterManifestEntry } from "./adapter/types" +import { preferDefault } from "../bootstrap/prefer-default" + +const ROOT = path.join(__dirname, `..`, `..`) +const UNPKG_ROOT = `https://unpkg.com/gatsby/` + +const FILE_NAMES = { + APIS: `apis.json`, + ADAPTERS: `adapters.js`, +} + +const OUTPUT_FILES = { + APIS: path.join(ROOT, `latest-apis.json`), + ADAPTERS: path.join(ROOT, `latest-adapters.js`), +} + +export interface IAPIResponse { + browser: Record + node: Record + ssr: Record +} + +const _getFile = async ({ + fileName, + outputFileName, + defaultReturn, +}: { + fileName: string + outputFileName: string + defaultReturn: T +}): Promise => { + try { + const { data } = await axios.get(`${UNPKG_ROOT}${fileName}`, { + timeout: 5000, + }) + + await fs.writeFile(outputFileName, JSON.stringify(data, null, 2), `utf8`) + + return data + } catch (e) { + if (await fs.pathExists(outputFileName)) { + return fs.readJSON(outputFileName) + } + + if (fileName.endsWith(`.json`)) { + return fs.readJSON(path.join(ROOT, fileName)).catch(() => defaultReturn) + } else { + try { + const importedFile = await import(path.join(ROOT, fileName)) + const adapters = preferDefault(importedFile) + return adapters + } catch (e) { + // no-op + return defaultReturn + } + } + } +} + +export const getLatestAPIs = async (): Promise => + _getFile({ + fileName: FILE_NAMES.APIS, + outputFileName: OUTPUT_FILES.APIS, + defaultReturn: { + browser: {}, + node: {}, + ssr: {}, + }, + }) + +export const getLatestAdapters = async (): Promise< + Array +> => + _getFile({ + fileName: FILE_NAMES.ADAPTERS, + outputFileName: OUTPUT_FILES.ADAPTERS, + defaultReturn: [], + }) 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 c85b298bcda0f..b9f2f612857ec 100644 --- a/packages/gatsby/src/utils/page-ssr-module/bundle-webpack.ts +++ b/packages/gatsby/src/utils/page-ssr-module/bundle-webpack.ts @@ -12,6 +12,7 @@ import { } from "../client-assets-for-template" import { IGatsbyState } from "../../redux/types" import { store } from "../../redux" +import { LmdbOnCdnPath, shouldBundleDatastore } from "../engines-helpers" type Reporter = typeof reporter @@ -187,6 +188,7 @@ export async function createPageSSRBundle({ plugins: [ new webpack.DefinePlugin({ INLINED_TEMPLATE_TO_DETAILS: JSON.stringify(toInline), + INLINED_HEADERS_CONFIG: JSON.stringify(state.config.headers), WEBPACK_COMPILATION_HASH: JSON.stringify(webpackCompilationHash), GATSBY_SLICES: JSON.stringify(slicesStateObject), GATSBY_SLICES_BY_TEMPLATE: JSON.stringify(slicesByTemplateStateObject), @@ -217,6 +219,20 @@ export async function createPageSSRBundle({ ].filter(Boolean) as Array, }) + let functionCode = await fs.readFile( + path.join(__dirname, `lambda.js`), + `utf-8` + ) + + functionCode = functionCode.replace( + `%CDN_DATASTORE_PATH%`, + shouldBundleDatastore() + ? `` + : `${state.adapter.config.deployURL ?? ``}/${LmdbOnCdnPath}` + ) + + await fs.outputFile(path.join(outputDir, `lambda.js`), functionCode) + return new Promise((resolve, reject) => { compiler.run((err, stats) => { compiler.close(closeErr => { diff --git a/packages/gatsby/src/utils/page-ssr-module/entry.ts b/packages/gatsby/src/utils/page-ssr-module/entry.ts index 1069d24c51441..de6c333f53d13 100644 --- a/packages/gatsby/src/utils/page-ssr-module/entry.ts +++ b/packages/gatsby/src/utils/page-ssr-module/entry.ts @@ -5,7 +5,12 @@ import "../engines-fs-provider" // just types - those should not be bundled import type { GraphQLEngine } from "../../schema/graphql-engine/entry" import type { IExecutionResult } from "../../query/types" -import type { IGatsbyPage, IGatsbySlice, IGatsbyState } from "../../redux/types" +import type { + IGatsbyPage, + IGatsbySlice, + IGatsbyState, + IHeader, +} from "../../redux/types" import { IGraphQLTelemetryRecord } from "../../schema/type-definitions" import type { IScriptsAndStyles } from "../client-assets-for-template" import type { IPageDataWithQueryResult, ISliceData } from "../page-data" @@ -26,6 +31,9 @@ import reporter from "gatsby-cli/lib/reporter" import { initTracer } from "../tracer" import { getCodeFrame } from "../../query/graphql-errors-codeframe" import { ICollectedSlice } from "../babel/find-slices" +import { createHeadersMatcher } from "../adapter/create-headers" +import { MUST_REVALIDATE_HEADERS } from "../adapter/constants" +import { getRoutePathFromPage } from "../adapter/get-route-path" export interface ITemplateDetails { query: string @@ -37,6 +45,10 @@ export interface ISSRData { page: IGatsbyPage templateDetails: ITemplateDetails potentialPagePath: string + /** + * This is no longer really just serverDataHeaders, as we add headers + * from user defined in gatsby-config + */ serverDataHeaders?: Record serverDataStatus?: number searchString: string @@ -46,6 +58,7 @@ export interface ISSRData { // with DefinePlugin declare global { const INLINED_TEMPLATE_TO_DETAILS: Record + const INLINED_HEADERS_CONFIG: Array | undefined const WEBPACK_COMPILATION_HASH: string const GATSBY_SLICES_SCRIPT: string } @@ -58,6 +71,8 @@ type MaybePhantomActivity = | ReturnType | undefined +const createHeaders = createHeadersMatcher(INLINED_HEADERS_CONFIG) + export async function getData({ pathName, graphqlEngine, @@ -210,6 +225,27 @@ export async function getData({ } results.pageContext = page.context + const serverDataHeaders = {} + + // get headers from defaults and config + const headersFromConfig = createHeaders( + getRoutePathFromPage(page), + MUST_REVALIDATE_HEADERS + ) + // convert headers array to object + for (const header of headersFromConfig) { + serverDataHeaders[header.key] = header.value + } + + if (serverData?.headers) { + // add headers from getServerData to object (which will overwrite headers from config if overlapping) + for (const [headerKey, headerValue] of Object.entries( + serverData.headers + )) { + serverDataHeaders[headerKey] = headerValue + } + } + let searchString = `` if (req?.query) { @@ -230,7 +266,7 @@ export async function getData({ page, templateDetails, potentialPagePath, - serverDataHeaders: serverData?.headers, + serverDataHeaders, serverDataStatus: serverData?.status, searchString, } diff --git a/packages/gatsby/src/utils/page-ssr-module/lambda.ts b/packages/gatsby/src/utils/page-ssr-module/lambda.ts new file mode 100644 index 0000000000000..c40c4416d3605 --- /dev/null +++ b/packages/gatsby/src/utils/page-ssr-module/lambda.ts @@ -0,0 +1,272 @@ +import type { GatsbyFunctionResponse, GatsbyFunctionRequest } from "gatsby" +import * as path from "path" +import * as fs from "fs-extra" +import { get as httpsGet } from "https" +import { get as httpGet, IncomingMessage, ClientRequest } from "http" +import { tmpdir } from "os" +import { pipeline } from "stream" +import { URL } from "url" +import { promisify } from "util" + +import type { IGatsbyPage } from "../../internal" +import type { ISSRData } from "./entry" +import { link } from "linkfs" + +const cdnDatastore = `%CDN_DATASTORE_PATH%` + +function setupFsWrapper(): string { + // setup global._fsWrapper + try { + fs.accessSync(__filename, fs.constants.W_OK) + // TODO: this seems funky - not sure if this is correct way to handle this, so just marking TODO to revisit this + return path.join(__dirname, `..`, `data`, `datastore`) + } catch (e) { + // we are in a read-only filesystem, so we need to use a temp dir + + const TEMP_CACHE_DIR = path.join(tmpdir(), `gatsby`, `.cache`) + + // TODO: don't hardcode this + const cacheDir = `/var/task/.cache` + + // we need to rewrite fs + const rewrites = [ + [path.join(cacheDir, `caches`), path.join(TEMP_CACHE_DIR, `caches`)], + [ + path.join(cacheDir, `caches-lmdb`), + path.join(TEMP_CACHE_DIR, `caches-lmdb`), + ], + [path.join(cacheDir, `data`), path.join(TEMP_CACHE_DIR, `data`)], + ] + + console.log(`Preparing Gatsby filesystem`, { + from: cacheDir, + to: TEMP_CACHE_DIR, + rewrites, + }) + // Alias the cache dir paths to the temp dir + const lfs = link(fs, rewrites) as typeof import("fs") + + // linkfs doesn't pass across the `native` prop, which graceful-fs needs + for (const key in lfs) { + if (Object.hasOwnProperty.call(fs[key], `native`)) { + lfs[key].native = fs[key].native + } + } + + const dbPath = path.join(TEMP_CACHE_DIR, `data`, `datastore`) + + // 'promises' is not initially linked within the 'linkfs' + // package, and is needed by underlying Gatsby code (the + // @graphql-tools/code-file-loader) + lfs.promises = link(fs.promises, rewrites) + + // Gatsby uses this instead of fs if present + // eslint-disable-next-line no-underscore-dangle + global._fsWrapper = lfs + + if (!cdnDatastore) { + const dir = `data` + if ( + !process.env.NETLIFY_LOCAL && + fs.existsSync(path.join(TEMP_CACHE_DIR, dir)) + ) { + console.log(`directory already exists`) + return dbPath + } + console.log(`Start copying ${dir}`) + + fs.copySync(path.join(cacheDir, dir), path.join(TEMP_CACHE_DIR, dir)) + console.log(`End copying ${dir}`) + } + + return dbPath + } +} + +const dbPath = setupFsWrapper() + +// using require instead of import here for now because of type hell + import path doesn't exist in current context +// as this file will be copied elsewhere + +type GraphQLEngineType = + import("../../schema/graphql-engine/entry").GraphQLEngine + +const { GraphQLEngine } = + require(`../query-engine`) as typeof import("../../schema/graphql-engine/entry") + +const { getData, renderPageData, renderHTML } = + require(`./index`) as typeof import("./entry") + +const streamPipeline = promisify(pipeline) + +function get( + url: string, + callback?: (res: IncomingMessage) => void +): ClientRequest { + return new URL(url).protocol === `https:` + ? httpsGet(url, callback) + : httpGet(url, callback) +} + +async function getEngine(): Promise { + if (cdnDatastore) { + // if this variable is set we need to download the datastore from the CDN + const downloadPath = dbPath + `/data.mdb` + console.log( + `Downloading datastore from CDN (${cdnDatastore} -> ${downloadPath})` + ) + + await fs.ensureDir(dbPath) + await new Promise((resolve, reject) => { + const req = get(cdnDatastore, response => { + if ( + !response.statusCode || + response.statusCode < 200 || + response.statusCode > 299 + ) { + reject( + new Error( + `Failed to download ${cdnDatastore}: ${response.statusCode} ${ + response.statusMessage || `` + }` + ) + ) + return + } + + const fileStream = fs.createWriteStream(downloadPath) + streamPipeline(response, fileStream) + .then(resolve) + .catch(error => { + console.log(`Error downloading ${cdnDatastore}`, error) + reject(error) + }) + }) + + req.on(`error`, error => { + console.log(`Error downloading ${cdnDatastore}`, error) + reject(error) + }) + }) + } + console.log(`Downloaded datastore from CDN`) + + const graphqlEngine = new GraphQLEngine({ + dbPath, + }) + + await graphqlEngine.ready + + return graphqlEngine +} + +const engineReadyPromise = getEngine() + +function reverseFixedPagePath(pageDataRequestPath: string): string { + return pageDataRequestPath === `index` ? `/` : pageDataRequestPath +} + +function getPathInfo(req: GatsbyFunctionRequest): + | { + 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) + for (const [, requestedPagePath] of matches) { + return { + isPageData: true, + pagePath: reverseFixedPagePath(requestedPagePath), + } + } + + // 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, + } +} + +function setStatusAndHeaders({ + page, + data, + res, +}: { + page: IGatsbyPage + data: ISSRData + res: GatsbyFunctionResponse +}): void { + if (page.mode === `SSR`) { + if (data.serverDataStatus) { + res.status(data.serverDataStatus) + } + } + if (data.serverDataHeaders) { + for (const [name, value] of Object.entries(data.serverDataHeaders)) { + res.setHeader(name, value) + } + } +} + +function getErrorBody(statusCode: number): string { + let body = `

${statusCode}

${ + statusCode === 404 ? `Not found` : `Internal Server Error` + }

` + + if (statusCode === 404 || statusCode === 500) { + const filename = path.join(process.cwd(), `public`, `${statusCode}.html`) + + if (fs.existsSync(filename)) { + body = fs.readFileSync(filename, `utf8`) + } + } + + return body +} + +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 + } + + const { isPageData, pagePath } = pathInfo + + const page = graphqlEngine.findPageByPath(pagePath) + if (!page) { + res.status(404).send(getErrorBody(404)) + return + } + + const data = await getData({ + pathName: pagePath, + graphqlEngine, + req, + }) + + if (isPageData) { + const results = await renderPageData({ data }) + setStatusAndHeaders({ page, data, res }) + res.json(results) + return + } else { + const results = await renderHTML({ data }) + setStatusAndHeaders({ page, data, res }) + res.send(results) + return + } + } catch (e) { + console.error(`Engine failed to handle request`, e) + res.status(500).send(getErrorBody(500)) + } +} + +export default engineHandler diff --git a/packages/gatsby/src/utils/rank-route.ts b/packages/gatsby/src/utils/rank-route.ts new file mode 100644 index 0000000000000..ef0b7a3af0c00 --- /dev/null +++ b/packages/gatsby/src/utils/rank-route.ts @@ -0,0 +1,30 @@ +// path ranking algorithm copied (with small adjustments) from `@reach/router` (internal util, not exported from the package) +// https://github.com/reach/router/blob/28a79e7fc3a3487cb3304210dc3501efb8a50eba/src/lib/utils.js#L216-L254 +const paramRe = /^:(.+)/ + +const SEGMENT_POINTS = 4 +const STATIC_POINTS = 3 +const DYNAMIC_POINTS = 2 +const SPLAT_PENALTY = 1 +const ROOT_POINTS = 1 + +const isRootSegment = (segment: string): boolean => segment === `` +const isDynamic = (segment: string): boolean => paramRe.test(segment) +const isSplat = (segment: string): boolean => segment === `*` + +const segmentize = (uri: string): Array => + uri + // strip starting/ending slashes + .replace(/(^\/+|\/+$)/g, ``) + .split(`/`) + +export const rankRoute = (path: string): number => + segmentize(path).reduce((score, segment) => { + score += SEGMENT_POINTS + if (isRootSegment(segment)) score += ROOT_POINTS + else if (isDynamic(segment)) score += DYNAMIC_POINTS + else if (isSplat(segment)) score -= SEGMENT_POINTS + SPLAT_PENALTY + else score += STATIC_POINTS + return score + }, 0) +// end of copied `@reach/router` internals diff --git a/packages/gatsby/src/utils/start-server.ts b/packages/gatsby/src/utils/start-server.ts index f1cec341ed901..2e3d177ca00c7 100644 --- a/packages/gatsby/src/utils/start-server.ts +++ b/packages/gatsby/src/utils/start-server.ts @@ -594,7 +594,7 @@ export async function startServer( const { developMiddleware } = store.getState().config if (developMiddleware) { - developMiddleware(app, program) + developMiddleware(app) } const { proxy, trailingSlash } = store.getState().config diff --git a/yarn.lock b/yarn.lock index 8c44fc9174500..76b90b498bec7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3248,6 +3248,27 @@ resolved "https://registry.yarnpkg.com/@n1ru4l/push-pull-async-iterable-iterator/-/push-pull-async-iterable-iterator-3.2.0.tgz#c15791112db68dd9315d329d652b7e797f737655" integrity sha512-3fkKj25kEjsfObL6IlKPAlHYPq/oYwUkkQ03zsTTiDjD7vg/RxjdiLeCydqtxHZP0JgsXL3D/X5oAkMGzuUp/Q== +"@netlify/cache-utils@^5.1.5": + version "5.1.5" + resolved "https://registry.yarnpkg.com/@netlify/cache-utils/-/cache-utils-5.1.5.tgz#848c59003e576fa0b2f9c6ca270eff27af938b25" + integrity sha512-lMNdFmy2Yu3oVquSPooRDLxJ8QOsIX6X6vzA2pKz/9V2LQFJiqBukggXM+Rnqzk1regPpdJ0jK3dPGvOKaRQgg== + dependencies: + cpy "^9.0.0" + get-stream "^6.0.0" + globby "^13.0.0" + junk "^4.0.0" + locate-path "^7.0.0" + move-file "^3.0.0" + path-exists "^5.0.0" + readdirp "^3.4.0" + +"@netlify/functions@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@netlify/functions/-/functions-1.6.0.tgz#c373423e6fef0e6f7422ac0345e8bbf2cb692366" + integrity sha512-6G92AlcpFrQG72XU8YH8pg94eDnq7+Q0YJhb8x4qNpdGsvuzvrfHWBmqFGp/Yshmv4wex9lpsTRZOocdrA2erQ== + dependencies: + is-promise "^4.0.0" + "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3": version "2.1.8-no-fsevents.3" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz#323d72dd25103d0c4fbdce89dadf574a787b1f9b" @@ -12309,6 +12330,17 @@ globby@^11.0.1, globby@^11.0.3, globby@^11.0.4, globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" +globby@^13.0.0: + version "13.1.4" + resolved "https://registry.yarnpkg.com/globby/-/globby-13.1.4.tgz#2f91c116066bcec152465ba36e5caa4a13c01317" + integrity sha512-iui/IiiW+QrJ1X1hKH5qwlMQyv34wJAYwH1vrf8b9kBA4sNiif3gKsMHa+BrdnOpEudWjpotfa7LrTzB1ERS/g== + dependencies: + dir-glob "^3.0.1" + fast-glob "^3.2.11" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^4.0.0" + globby@^13.1.1, globby@^13.1.2: version "13.1.3" resolved "https://registry.yarnpkg.com/globby/-/globby-13.1.3.tgz#f62baf5720bcb2c1330c8d4ef222ee12318563ff" @@ -14053,6 +14085,11 @@ is-promise@^2.1.0, is-promise@^2.2.2: resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== +is-promise@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== + is-redirect@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" @@ -15376,6 +15413,11 @@ lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" +linkfs@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/linkfs/-/linkfs-2.1.0.tgz#5cc774ad8ed6b0aae5a858bd67e3334cc300a917" + integrity sha512-kmsGcmpvjStZ0ATjuHycBujtNnXiZR28BTivEu0gAMDTT7GEyodcK6zSRtu6xsrdorrPZEIN380x7BD7xEYkew== + linkify-it@^3.0.1: version "3.0.3" resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-3.0.3.tgz#a98baf44ce45a550efb4d49c769d07524cc2fa2e" @@ -15561,6 +15603,13 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +locate-path@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-7.2.0.tgz#69cb1779bd90b35ab1e771e1f2f89a202c2a8a8a" + integrity sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA== + dependencies: + p-locate "^6.0.0" + lock@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/lock/-/lock-1.1.0.tgz#53157499d1653b136ca66451071fca615703fa55" @@ -17293,6 +17342,13 @@ move-concurrently@^1.0.1: rimraf "^2.5.4" run-queue "^1.0.3" +move-file@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/move-file/-/move-file-3.1.0.tgz#ea9675d54852708242462bfe60d56b3c3854cdf7" + integrity sha512-4aE3U7CCBWgrQlQDMq8da4woBWDGHioJFiOZ8Ie6Yq2uwYQ9V2kGhTz4x3u6Wc+OU17nw0yc3rJ/lQ4jIiPe3A== + dependencies: + path-exists "^5.0.0" + mri@^1.1.0: version "1.1.4" resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.4.tgz#7cb1dd1b9b40905f1fac053abe25b6720f44744a" @@ -18226,6 +18282,13 @@ p-limit@^2.0.0, p-limit@^2.1.0, p-limit@^2.2.0: dependencies: p-try "^2.0.0" +p-limit@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-4.0.0.tgz#914af6544ed32bfa54670b061cafcbd04984b644" + integrity sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ== + dependencies: + yocto-queue "^1.0.0" + p-locate@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" @@ -18251,6 +18314,13 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +p-locate@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-6.0.0.tgz#3da9a49d4934b901089dca3302fa65dc5a05c04f" + integrity sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw== + dependencies: + p-limit "^4.0.0" + p-map-series@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-map-series/-/p-map-series-1.0.0.tgz#bf98fe575705658a9e1351befb85ae4c1f07bdca" @@ -18635,6 +18705,11 @@ path-exists@^4.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== +path-exists@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-5.0.0.tgz#a6aad9489200b21fab31e49cf09277e5116fb9e7" + integrity sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -20481,7 +20556,7 @@ readdir-scoped-modules@^1.0.0: graceful-fs "^4.1.2" once "^1.3.0" -readdirp@~3.6.0: +readdirp@^3.4.0, readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== @@ -25518,6 +25593,11 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +yocto-queue@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" + integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== + yoga-layout-prebuilt@^1.10.0, yoga-layout-prebuilt@^1.9.6: version "1.10.0" resolved "https://registry.yarnpkg.com/yoga-layout-prebuilt/-/yoga-layout-prebuilt-1.10.0.tgz#2936fbaf4b3628ee0b3e3b1df44936d6c146faa6"