diff --git a/.circleci/config.yml b/.circleci/config.yml index e17a153324ebc..b9d39a3f86996 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -455,6 +455,8 @@ jobs: e2e_tests_adapters: <<: *e2e-executor + docker: + - image: cypress/browsers:node-18.16.1-chrome-114.0.5735.133-1-ff-114.0.2-edge-114.0.1823.51-1 steps: - run: echo 'export CYPRESS_RECORD_KEY="${CY_CLOUD_ADAPTERS}"' >> "$BASH_ENV" - e2e-test: diff --git a/e2e-tests/adapters/cypress/e2e/remote-file.cy.ts b/e2e-tests/adapters/cypress/e2e/remote-file.cy.ts new file mode 100644 index 0000000000000..5f168eab76c80 --- /dev/null +++ b/e2e-tests/adapters/cypress/e2e/remote-file.cy.ts @@ -0,0 +1,254 @@ +Cypress.on("uncaught:exception", err => { + if ( + (err.message.includes("Minified React error #418") || + err.message.includes("Minified React error #423") || + err.message.includes("Minified React error #425")) && + Cypress.env(`TEST_PLUGIN_OFFLINE`) + ) { + return false + } +}) + +const PATH_PREFIX = Cypress.env(`PATH_PREFIX`) || `` + +// there are multiple scenarios we want to test and ensure that custom image cdn url is used: +// - child build process (SSG, Page Query) +// - main build process (SSG, Page Context) +// - query engine (SSR, Page Query) +const configs = [ + { + title: `remote-file (SSG, Page Query)`, + pagePath: `/routes/remote-file/`, + placeholders: true, + }, + { + title: `remote-file (SSG, Page Context)`, + pagePath: `/routes/remote-file-data-from-context/`, + placeholders: true, + }, + { + title: `remote-file (SSR, Page Query)`, + pagePath: `/routes/ssr/remote-file/`, + placeholders: false, + }, +] + +for (const config of configs) { + describe( + config.title, + { + retries: { + runMode: 4, + }, + }, + () => { + beforeEach(() => { + cy.visit(config.pagePath).waitForRouteChange() + + // trigger intersection observer + cy.scrollTo("top") + cy.wait(200) + cy.scrollTo("bottom", { + duration: 600, + }) + cy.wait(600) + }) + + describe(`Image CDN`, () => { + async function testImages(images, expectations) { + for (let i = 0; i < images.length; i++) { + const expectation = expectations[i] + + const url = images[i].currentSrc + + const { href, origin } = new URL(url) + const urlWithoutOrigin = href.replace(origin, ``) + + // using Netlify Image CDN + expect(urlWithoutOrigin).to.match(/^\/.netlify\/images/) + + const res = await fetch(url, { + method: "HEAD", + }) + expect(res.ok).to.be.true + + const expectedNaturalWidth = + expectation.naturalWidth ?? expectation.width + const expectedNaturalHeight = + expectation.naturalHeight ?? expectation.height + + if (expectation.width) { + expect( + Math.ceil(images[i].getBoundingClientRect().width) + ).to.be.equal(expectation.width) + } + if (expectation.height) { + expect( + Math.ceil(images[i].getBoundingClientRect().height) + ).to.be.equal(expectation.height) + } + if (expectedNaturalWidth) { + expect(Math.ceil(images[i].naturalWidth)).to.be.equal( + expectedNaturalWidth + ) + } + if (expectedNaturalHeight) { + expect(Math.ceil(images[i].naturalHeight)).to.be.equal( + expectedNaturalHeight + ) + } + } + } + + it(`should render correct dimensions`, () => { + cy.get('[data-testid="image-public"]').then(async $urls => { + const urls = Array.from( + $urls.map((_, $url) => $url.getAttribute("href")) + ) + + // urls is array of href attribute, not absolute urls, so it already is stripped of origin + for (const urlWithoutOrigin of urls) { + // using Netlify Image CDN + expect(urlWithoutOrigin).to.match(/^\/.netlify\/images/) + const res = await fetch(urlWithoutOrigin, { + method: "HEAD", + }) + expect(res.ok).to.be.true + } + }) + + cy.get(".resize").then({ timeout: 60000 }, async $imgs => { + await testImages(Array.from($imgs), [ + { + width: 100, + height: 133, + }, + { + width: 100, + height: 160, + }, + { + width: 100, + height: 67, + }, + ]) + }) + + cy.get(".fixed img:not([aria-hidden=true])").then( + { timeout: 60000 }, + async $imgs => { + await testImages(Array.from($imgs), [ + { + width: 100, + height: 133, + }, + { + width: 100, + height: 160, + }, + { + width: 100, + height: 67, + }, + ]) + } + ) + + cy.get(".constrained img:not([aria-hidden=true])").then( + { timeout: 60000 }, + async $imgs => { + await testImages(Array.from($imgs), [ + { + width: 300, + height: 400, + }, + { + width: 300, + height: 481, + }, + { + width: 300, + height: 200, + }, + ]) + } + ) + + cy.get(".full img:not([aria-hidden=true])").then( + { timeout: 60000 }, + async $imgs => { + await testImages(Array.from($imgs), [ + { + naturalHeight: 1333, + }, + { + naturalHeight: 1603, + }, + { + naturalHeight: 666, + }, + ]) + } + ) + }) + + it(`should render a placeholder`, () => { + if (config.placeholders) { + cy.get(".fixed [data-placeholder-image]") + .first() + .should("have.css", "background-color", "rgb(232, 184, 8)") + cy.get(".constrained [data-placeholder-image]") + .first() + .should($el => { + expect($el.prop("tagName")).to.be.equal("IMG") + expect($el.prop("src")).to.contain("data:image/jpg;base64") + }) + cy.get(".constrained_traced [data-placeholder-image]") + .first() + .should($el => { + // traced falls back to DOMINANT_COLOR + expect($el.prop("tagName")).to.be.equal("DIV") + expect($el).to.be.empty + }) + } + cy.get(".full [data-placeholder-image]") + .first() + .should($el => { + expect($el.prop("tagName")).to.be.equal("DIV") + expect($el).to.be.empty + }) + }) + }) + + it(`File CDN`, () => { + cy.get('[data-testid="file-public"]').then(async $urls => { + const fileCdnFixtures = Array.from( + $urls.map((_, $url) => { + return { + urlWithoutOrigin: $url.getAttribute("href"), + allowed: $url.getAttribute("data-allowed") === "true", + } + }) + ) + + // urls is array of href attribute, not absolute urls, so it already is stripped of origin + for (const { urlWithoutOrigin, allowed } of fileCdnFixtures) { + // using Netlify Image CDN + expect(urlWithoutOrigin).to.match( + new RegExp(`^${PATH_PREFIX}/_gatsby/file`) + ) + const res = await fetch(urlWithoutOrigin, { + method: "HEAD", + }) + if (allowed) { + expect(res.ok).to.be.true + } else { + expect(res.ok).to.be.false + expect(res.status).to.be.equal(500) + } + } + }) + }) + } + ) +} diff --git a/e2e-tests/adapters/gatsby-config.ts b/e2e-tests/adapters/gatsby-config.ts index 1ae605f8e074f..3bc55e9977d8a 100644 --- a/e2e-tests/adapters/gatsby-config.ts +++ b/e2e-tests/adapters/gatsby-config.ts @@ -24,7 +24,11 @@ const config: GatsbyConfig = { }, trailingSlash, pathPrefix, - plugins: [], + plugins: [ + `gatsby-plugin-image`, + `gatsby-plugin-sharp`, + `gatsby-transformer-sharp`, + ], headers: [ { source: `/*`, diff --git a/e2e-tests/adapters/gatsby-node.ts b/e2e-tests/adapters/gatsby-node.ts index 85a9e3b2ca007..9342bb30fa191 100644 --- a/e2e-tests/adapters/gatsby-node.ts +++ b/e2e-tests/adapters/gatsby-node.ts @@ -1,13 +1,67 @@ import * as path from "path" import type { GatsbyNode, GatsbyConfig } from "gatsby" +import { addRemoteFilePolyfillInterface } from "gatsby-plugin-utils/polyfill-remote-file" import { applyTrailingSlashOption } from "./utils" const TRAILING_SLASH = (process.env.TRAILING_SLASH || `never`) as GatsbyConfig["trailingSlash"] -export const createPages: GatsbyNode["createPages"] = ({ - actions: { createRedirect, createSlice }, +export const createPages: GatsbyNode["createPages"] = async ({ + actions: { createPage, createRedirect, createSlice }, + graphql, }) => { + const { data: ImageCDNRemoteFileFromPageContextData } = await graphql(` + query ImageCDNGatsbyNode { + allMyRemoteImage { + nodes { + id + url + filename + publicUrl + resize(width: 100) { + height + width + src + } + fixed: gatsbyImage( + layout: FIXED + width: 100 + placeholder: DOMINANT_COLOR + ) + constrained: gatsbyImage( + layout: CONSTRAINED + width: 300 + placeholder: BLURRED + ) + constrained_traced: gatsbyImage( + layout: CONSTRAINED + width: 300 + placeholder: TRACED_SVG + ) + full: gatsbyImage(layout: FULL_WIDTH, width: 500, placeholder: NONE) + } + } + allMyRemoteFile { + nodes { + id + url + filename + publicUrl + isAllowed + } + } + } + `) + + createPage({ + path: applyTrailingSlashOption( + `/routes/remote-file-data-from-context/`, + TRAILING_SLASH + ), + component: path.resolve(`./src/templates/remote-file-from-context.jsx`), + context: ImageCDNRemoteFileFromPageContextData, + }) + createRedirect({ fromPath: applyTrailingSlashOption("/redirect", TRAILING_SLASH), toPath: applyTrailingSlashOption("/routes/redirect/hit", TRAILING_SLASH), @@ -62,3 +116,112 @@ export const createPages: GatsbyNode["createPages"] = ({ context: {}, }) } + +// Image CDN +export const createSchemaCustomization: GatsbyNode["createSchemaCustomization"] = + function createSchemaCustomization({ actions, schema, store }) { + actions.createTypes( + addRemoteFilePolyfillInterface( + schema.buildObjectType({ + name: "MyRemoteImage", + fields: {}, + interfaces: ["Node", "RemoteFile"], + }), + { + schema, + actions, + store, + } + ) + ) + + actions.createTypes( + addRemoteFilePolyfillInterface( + schema.buildObjectType({ + name: "MyRemoteFile", + fields: { + isAllowed: `String!`, + }, + interfaces: ["Node", "RemoteFile"], + }), + { + schema, + actions, + store, + } + ) + ) + + if (typeof actions.addRemoteFileAllowedUrl === `function`) { + actions.addRemoteFileAllowedUrl([ + `https://images.unsplash.com/*`, + `https://www.gatsbyjs.com/*`, + ]) + } + } + +export const sourceNodes: GatsbyNode["sourceNodes"] = function sourceNodes({ + actions, + createNodeId, + createContentDigest, +}) { + const items = [ + { + name: "photoA.jpg", + url: "https://images.unsplash.com/photo-1517849845537-4d257902454a?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2000&q=80", + placeholderUrl: + "https://images.unsplash.com/photo-1517849845537-4d257902454a?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=%width%&h=%height%", + mimeType: "image/jpg", + filename: "photo-1517849845537.jpg", + width: 2000, + height: 2667, + }, + { + name: "photoB.jpg", + url: "https://images.unsplash.com/photo-1552053831-71594a27632d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&h=2000&q=10", + mimeType: "image/jpg", + filename: "photo-1552053831.jpg", + width: 1247, + height: 2000, + }, + { + name: "photoC.jpg", + url: "https://images.unsplash.com/photo-1561037404-61cd46aa615b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2000&q=80", + placeholderUrl: + "https://images.unsplash.com/photo-1561037404-61cd46aa615b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=%width%&h=%height%", + mimeType: "image/jpg", + filename: "photo-1561037404.jpg", + width: 2000, + height: 1333, + }, + { + // svg is not considered for image cdn - file cdn will be used + name: "fileA.svg", + url: "https://www.gatsbyjs.com/Gatsby-Logo.svg", + mimeType: "image/svg+xml", + filename: "Gatsby-Logo.svg", + type: `MyRemoteFile`, + isAllowed: true, + }, + { + // svg is not considered for image cdn - file cdn will be used + name: "fileB.svg", + url: "https://www.not-allowed.com/not-allowed.svg", + mimeType: "image/svg+xml", + filename: "Gatsby-Logo.svg", + type: `MyRemoteFile`, + isAllowed: false, + }, + ] + + items.forEach((item, index) => { + actions.createNode({ + id: createNodeId(`remote-file-${index}`), + ...item, + internal: { + type: item.type ?? "MyRemoteImage", + contentDigest: createContentDigest(item.url), + }, + }) + }) +} diff --git a/e2e-tests/adapters/netlify.toml b/e2e-tests/adapters/netlify.toml index d1e965d4a8f4c..edbccdb76ed51 100644 --- a/e2e-tests/adapters/netlify.toml +++ b/e2e-tests/adapters/netlify.toml @@ -1,3 +1,12 @@ [build] command = "npm run build" - publish = "public/" \ No newline at end of file + publish = "public/" + +[build.environment] + NETLIFY_IMAGE_CDN = "true" + +[images] + remote_images = [ + "^https:\\/\\/images\\.unsplash\\.com\\/(.*)", + "^https:\\/\\/www\\.gatsbyjs\\.com\\/(.*)", + ] diff --git a/e2e-tests/adapters/package.json b/e2e-tests/adapters/package.json index f6a684d8aeccb..bec04e60328e9 100644 --- a/e2e-tests/adapters/package.json +++ b/e2e-tests/adapters/package.json @@ -24,7 +24,10 @@ }, "dependencies": { "gatsby": "next", - "gatsby-adapter-netlify": "latest", + "gatsby-adapter-netlify": "next", + "gatsby-plugin-image": "next", + "gatsby-plugin-sharp": "next", + "gatsby-transformer-sharp": "next", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -32,8 +35,9 @@ "cross-env": "^7.0.3", "cypress": "^12.14.0", "dotenv": "^8.6.0", + "execa": "^6.1.0", "gatsby-cypress": "^3.11.0", - "netlify-cli": "^15.8.0", + "netlify-cli": "^17.9.0", "npm-run-all": "^4.1.5", "start-server-and-test": "^2.0.3", "typescript": "^5.1.6" diff --git a/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs b/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs index f3a5525f48081..1326826e6b7ba 100644 --- a/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs +++ b/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs @@ -20,7 +20,7 @@ await execa(`npm`, [`run`, `clean`], { stdio: `inherit` }) const deployResults = await execa( "ntl", - ["deploy", "--build", "--json", "--message", deployTitle], + ["deploy", "--build", "--json", "--cwd=.", "--message", deployTitle], { reject: false, } @@ -49,20 +49,17 @@ try { } finally { if (!process.env.GATSBY_TEST_SKIP_CLEANUP) { console.log(`Deleting project with deploy_id ${deployInfo.deploy_id}`) - const deleteResponse = await execa("ntl", [ "api", "deleteDeploy", "--data", `{ "deploy_id": "${deployInfo.deploy_id}" }`, ]) - if (deleteResponse.exitCode !== 0) { throw new Error( `Failed to delete project ${deleteResponse.stdout} ${deleteResponse.stderr} (${deleteResponse.exitCode})` ) } - console.log( `Successfully deleted project with deploy_id ${deployInfo.deploy_id}` ) diff --git a/e2e-tests/adapters/src/pages/index.jsx b/e2e-tests/adapters/src/pages/index.jsx index 9cbcccbe6ac45..0032374b21b5f 100644 --- a/e2e-tests/adapters/src/pages/index.jsx +++ b/e2e-tests/adapters/src/pages/index.jsx @@ -39,6 +39,18 @@ const routes = [ text: "Client-Only Named Wildcard", url: "/routes/client-only/named-wildcard/corinno/fenring", }, + { + text: "RemoteFile (ImageCDN and FileCDN) (SSG, Page Query)", + url: "/routes/remote-file", + }, + { + text: "RemoteFile (ImageCDN and FileCDN) (SSG, Page Context)", + url: "/routes/remote-file-data-from-context", + }, + { + text: "RemoteFile (ImageCDN and FileCDN) (SSR, Page Query)", + url: "/routes/ssr/remote-file", + }, ] const functions = [ diff --git a/e2e-tests/adapters/src/pages/routes/remote-file.jsx b/e2e-tests/adapters/src/pages/routes/remote-file.jsx new file mode 100644 index 0000000000000..8f2ceba8a1756 --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/remote-file.jsx @@ -0,0 +1,104 @@ +import { graphql } from "gatsby" +import React from "react" + +import { GatsbyImage } from "gatsby-plugin-image" +import Layout from "../../components/layout" + +const RemoteFile = ({ data }) => { + return ( + + {data.allMyRemoteImage.nodes.map(node => { + return ( +
+

+ + {node.filename} + +

+ +
+ + + + +
+
+ ) + })} + {data.allMyRemoteFile.nodes.map(node => { + return ( +
+

+ + {node.filename} + +

+
+ ) + })} +
+ ) +} + +export const pageQuery = graphql` + query SSGImageCDNPageQuery { + allMyRemoteImage { + nodes { + id + url + filename + publicUrl + resize(width: 100) { + height + width + src + } + fixed: gatsbyImage( + layout: FIXED + width: 100 + placeholder: DOMINANT_COLOR + ) + constrained: gatsbyImage( + layout: CONSTRAINED + width: 300 + placeholder: BLURRED + ) + constrained_traced: gatsbyImage( + layout: CONSTRAINED + width: 300 + placeholder: TRACED_SVG + ) + full: gatsbyImage(layout: FULL_WIDTH, width: 500, placeholder: NONE) + } + } + allMyRemoteFile { + nodes { + id + url + filename + publicUrl + isAllowed + } + } + } +` + +export default RemoteFile diff --git a/e2e-tests/adapters/src/pages/routes/ssr/remote-file.jsx b/e2e-tests/adapters/src/pages/routes/ssr/remote-file.jsx new file mode 100644 index 0000000000000..87477e2ea3b9c --- /dev/null +++ b/e2e-tests/adapters/src/pages/routes/ssr/remote-file.jsx @@ -0,0 +1,111 @@ +import { graphql } from "gatsby" +import React from "react" + +import { GatsbyImage } from "gatsby-plugin-image" +import Layout from "../../../components/layout" + +const RemoteFile = ({ data }) => { + return ( + + {data.allMyRemoteImage.nodes.map(node => { + return ( +
+

+ + {node.filename} + +

+ +
+ + + + +
+
+ ) + })} + {data.allMyRemoteFile.nodes.map(node => { + return ( +
+

+ + {node.filename} + +

+
+ ) + })} +
+ ) +} + +export const pageQuery = graphql` + query SSRImageCDNPageQuery { + allMyRemoteImage { + nodes { + id + url + filename + # FILE_CDN is not supported in SSR/DSG yet + # publicUrl + resize(width: 100) { + height + width + src + } + fixed: gatsbyImage( + layout: FIXED + width: 100 + # only NONE placeholder is supported in SSR/DSG + # placeholder: DOMINANT_COLOR + placeholder: NONE + ) + constrained: gatsbyImage( + layout: CONSTRAINED + width: 300 + # only NONE placeholder is supported in SSR/DSG + # placeholder: DOMINANT_COLOR + placeholder: NONE + ) + constrained_traced: gatsbyImage( + layout: CONSTRAINED + width: 300 + # only NONE placeholder is supported in SSR/DSG + # placeholder: DOMINANT_COLOR + placeholder: NONE + ) + full: gatsbyImage(layout: FULL_WIDTH, width: 500, placeholder: NONE) + } + } + allMyRemoteFile { + nodes { + id + url + filename + publicUrl + isAllowed + } + } + } +` + +export default RemoteFile diff --git a/e2e-tests/adapters/src/templates/remote-file-from-context.jsx b/e2e-tests/adapters/src/templates/remote-file-from-context.jsx new file mode 100644 index 0000000000000..4773a88beb1cb --- /dev/null +++ b/e2e-tests/adapters/src/templates/remote-file-from-context.jsx @@ -0,0 +1,60 @@ +import React from "react" + +import { GatsbyImage } from "gatsby-plugin-image" +import Layout from "../components/layout" + +const RemoteFile = ({ pageContext: data }) => { + return ( + + {data.allMyRemoteImage.nodes.map(node => { + return ( +
+

+ + {node.filename} + +

+ +
+ + + + +
+
+ ) + })} + {data.allMyRemoteFile.nodes.map(node => { + return ( +
+

+ + {node.filename} + +

+
+ ) + })} +
+ ) +} + +export default RemoteFile diff --git a/e2e-tests/production-runtime/cypress/integration/remote-file.js b/e2e-tests/production-runtime/cypress/integration/remote-file.js index 8849650da194f..ddf3d475e7f06 100644 --- a/e2e-tests/production-runtime/cypress/integration/remote-file.js +++ b/e2e-tests/production-runtime/cypress/integration/remote-file.js @@ -37,6 +37,12 @@ describe( method: "HEAD", }) expect(res.ok).to.be.true + + const expectedNaturalWidth = + expectation.naturalWidth ?? expectation.width + const expectedNaturalHeight = + expectation.naturalHeight ?? expectation.height + if (expectation.width) { expect( Math.ceil(images[i].getBoundingClientRect().width) @@ -47,6 +53,17 @@ describe( Math.ceil(images[i].getBoundingClientRect().height) ).to.be.equal(expectation.height) } + + if (expectedNaturalWidth) { + expect(Math.ceil(images[i].naturalWidth)).to.be.equal( + expectedNaturalWidth + ) + } + if (expectedNaturalHeight) { + expect(Math.ceil(images[i].naturalHeight)).to.be.equal( + expectedNaturalHeight + ) + } } } @@ -81,7 +98,7 @@ describe( ]) }) - cy.get(".fixed").then(async $imgs => { + cy.get(".fixed img:not([aria-hidden=true])").then(async $imgs => { await testImages(Array.from($imgs), [ { width: 100, @@ -98,7 +115,7 @@ describe( ]) }) - cy.get(".constrained").then(async $imgs => { + cy.get(".constrained img:not([aria-hidden=true])").then(async $imgs => { await testImages(Array.from($imgs), [ { width: 300, @@ -115,16 +132,19 @@ describe( ]) }) - cy.get(".full").then(async $imgs => { + cy.get(".full img:not([aria-hidden=true])").then(async $imgs => { await testImages(Array.from($imgs), [ { height: 1229, + naturalHeight: 1333, }, { height: 1478, + naturalHeight: 1603, }, { height: 614, + naturalHeight: 666, }, ]) }) diff --git a/packages/gatsby-adapter-netlify/.babelrc b/packages/gatsby-adapter-netlify/.babelrc index 84af48678d3f0..264f094569e08 100644 --- a/packages/gatsby-adapter-netlify/.babelrc +++ b/packages/gatsby-adapter-netlify/.babelrc @@ -3,7 +3,7 @@ [ "babel-preset-gatsby-package", { - "keepDynamicImports": ["./src/index.ts"] + "keepDynamicImports": ["./src/index.ts", "./src/allowed-remote-urls.ts"] } ] ] diff --git a/packages/gatsby-adapter-netlify/README.md b/packages/gatsby-adapter-netlify/README.md index 945d9fe60f9e8..b336547cb44d0 100644 --- a/packages/gatsby-adapter-netlify/README.md +++ b/packages/gatsby-adapter-netlify/README.md @@ -11,6 +11,7 @@ This adapter enables following features on Netlify: - [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 +- Gatsby Image and File CDN (optional, requires configuration, see [`imageCDN` option](#imagecdn)) 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. @@ -30,12 +31,75 @@ const adapter = require("gatsby-adapter-netlify") module.exports = { adapter: adapter({ excludeDatastoreFromEngineFunction: false, + imageCDN: false, }), } ``` ### Options -**excludeDatastoreFromEngineFunction** (optional, default: `false`) +#### 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. + +You can also enable this option by setting `GATSBY_EXCLUDE_DATASTORE_FROM_BUNDLE=true` environment variable (useful when using [zero-configuration deployments](https://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/zero-configuration-deployments/)) + +#### imageCDN + +(optional, default: `false`) + +If `true` instead of downloading and processing images at build time, it defers processing until request time using [Netlify Image CDN](https://docs.netlify.com/image-cdn/overview/). This can greatly improve build times for sites with remote images, such as those that use a CMS. + +You can also enable this option by setting `NETLIFY_IMAGE_CDN=true` environment variable (useful when using [zero-configuration deployments](https://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/zero-configuration-deployments/)) + +You will need additional configuration in your `netlify.toml` configuration file to allow external domains for images. See [Netlify Image CDN Remote Path docs](https://docs.netlify.com/image-cdn/overview/#remote-path) for more information. + +Exact Remote Path regular expressions to use will depend on CMS you use and possibly your configuration of it. + +- `gatsby-source-contentful`: + + ```toml + [images] + remote_images = [ + # is specified in the `spaceId` option for the + # gatsby-source-contentful plugin in your gatsby-config file. + "^https:\\/\\/images\\.ctfassets\\.net\\/\\/(.*)" + ] + ``` + +- `gatsby-source-drupal`: + + ```toml + [images] + remote_images = [ + # is speciafied in the `baseUrl` option for the + # gatsby-source-drupal plugin in your gatsby-config file. + "\\/(.*)" + ] + ``` + +- `gatsby-source-wordpress`: + + ```toml + [images] + remote_images = [ + # is specified in the `url` option for the + # gatsby-source-wordpress plugin in your gatsby-config file. + # There is no need to include `/graphql in the path here` + "\\/(.*)" + ] + ``` + +Above examples are the most likely ones to be needed. However if you configure +your CMS to host assets on different domain or path, you might need to adjust +the patterns accordingly. + +Keep in mind that you will need to escape special regular expression symbols to avoid creating too permisible patterns: + +- `.` should be escaped as `\\.` (dot) +- `/` should be escaped as `\\/` (slash) +- your rule should start with `^` so domain matching is exact + +If you are using recent versions of Contentful, Drupal or Wordpress source plugins, Gatsby and Netlify Adapter will automatically detect missing Remote Path patterns and will warn you about it and provide require patterns to add to your configuration. diff --git a/packages/gatsby-adapter-netlify/package.json b/packages/gatsby-adapter-netlify/package.json index c00c3cf0ebdb6..f8978d63be3f3 100644 --- a/packages/gatsby-adapter-netlify/package.json +++ b/packages/gatsby-adapter-netlify/package.json @@ -33,6 +33,7 @@ "dependencies": { "@babel/runtime": "^7.20.13", "@netlify/cache-utils": "^5.1.5", + "@netlify/config": "^20.10.0", "@netlify/functions": "^1.6.0", "cookie": "^0.5.0", "fastq": "^1.15.0", @@ -41,6 +42,7 @@ "devDependencies": { "@babel/cli": "^7.20.7", "@babel/core": "^7.20.12", + "@netlify/edge-functions": "^2.2.0", "babel-preset-gatsby-package": "^3.13.0-next.0", "cross-env": "^7.0.3", "memfs": "^4.6.0", diff --git a/packages/gatsby-adapter-netlify/src/__tests__/file-cdn-url-generator.ts b/packages/gatsby-adapter-netlify/src/__tests__/file-cdn-url-generator.ts new file mode 100644 index 0000000000000..97c8338d181b5 --- /dev/null +++ b/packages/gatsby-adapter-netlify/src/__tests__/file-cdn-url-generator.ts @@ -0,0 +1,198 @@ +import { generateFileUrl } from "../file-cdn-url-generator" + +describe(`generateFileUrl`, () => { + describe(`image`, () => { + // pathPrefix is not used for images + const pathPrefix = `/prefix` + + it(`should return a file based url`, () => { + const source = { + url: `https://example.com/file.jpg`, + filename: `file.jpg`, + mimeType: `image/jpeg`, + internal: { + contentDigest: `1234`, + }, + } + + expect(generateFileUrl(source, pathPrefix)).toMatchInlineSnapshot( + `"/.netlify/images?url=https%3A%2F%2Fexample.com%2Ffile.jpg&cd=1234"` + ) + }) + + it(`should handle special characters`, () => { + const source = { + url: `https://example.com/file-éà.jpg`, + filename: `file-éà.jpg`, + mimeType: `image/jpeg`, + internal: { + contentDigest: `1234`, + }, + } + + expect(generateFileUrl(source, pathPrefix)).toMatchInlineSnapshot( + `"/.netlify/images?url=https%3A%2F%2Fexample.com%2Ffile-%C3%A9%C3%A0.jpg&cd=1234"` + ) + }) + + it(`should handle spaces`, () => { + const source = { + url: `https://example.com/file test.jpg`, + filename: `file test.jpg`, + mimeType: `image/jpeg`, + internal: { + contentDigest: `1234`, + }, + } + + expect(generateFileUrl(source, pathPrefix)).toMatchInlineSnapshot( + `"/.netlify/images?url=https%3A%2F%2Fexample.com%2Ffile+test.jpg&cd=1234"` + ) + }) + + it(`should handle html encoded urls`, () => { + const source = { + url: `https://example.com/file%20test.jpg`, + filename: `file test.jpg`, + mimeType: `image/jpeg`, + internal: { + contentDigest: `1234`, + }, + } + + expect(generateFileUrl(source, pathPrefix)).toMatchInlineSnapshot( + `"/.netlify/images?url=https%3A%2F%2Fexample.com%2Ffile%2520test.jpg&cd=1234"` + ) + }) + }) + + describe(`no image`, () => { + describe(`no pathPrefix`, () => { + const pathPrefix = `` + + it(`should return a file based url`, () => { + const source = { + url: `https://example.com/file.pdf`, + filename: `file.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, + } + + expect(generateFileUrl(source, pathPrefix)).toMatchInlineSnapshot( + `"/_gatsby/file/9f2eba7a1dbc78363c52aeb0daec9031/file.pdf?url=https%3A%2F%2Fexample.com%2Ffile.pdf&cd=1234"` + ) + }) + + it(`should handle special characters`, () => { + const source = { + url: `https://example.com/file-éà.pdf`, + filename: `file-éà.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, + } + + expect(generateFileUrl(source, pathPrefix)).toMatchInlineSnapshot( + `"/_gatsby/file/8802451220032a66565f179e89e00a83/file-%C3%A9%C3%A0.pdf?url=https%3A%2F%2Fexample.com%2Ffile-%C3%A9%C3%A0.pdf&cd=1234"` + ) + }) + + it(`should handle spaces`, () => { + const source = { + url: `https://example.com/file test.pdf`, + filename: `file test.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, + } + + expect(generateFileUrl(source, pathPrefix)).toMatchInlineSnapshot( + `"/_gatsby/file/6e41758c045f4509e19938d738d2a23c/file%20test.pdf?url=https%3A%2F%2Fexample.com%2Ffile+test.pdf&cd=1234"` + ) + }) + + it(`should handle html encoded urls`, () => { + const source = { + url: `https://example.com/file%20test.pdf`, + filename: `file test.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, + } + + expect(generateFileUrl(source, pathPrefix)).toMatchInlineSnapshot( + `"/_gatsby/file/799c0b15477311f5b8d9f635594671f2/file%20test.pdf?url=https%3A%2F%2Fexample.com%2Ffile%2520test.pdf&cd=1234"` + ) + }) + }) + + describe(`with pathPrefix`, () => { + const pathPrefix = `/prefix` + + it(`should return a file based url`, () => { + const source = { + url: `https://example.com/file.pdf`, + filename: `file.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, + } + + expect(generateFileUrl(source, pathPrefix)).toMatchInlineSnapshot( + `"/prefix/_gatsby/file/9f2eba7a1dbc78363c52aeb0daec9031/file.pdf?url=https%3A%2F%2Fexample.com%2Ffile.pdf&cd=1234"` + ) + }) + + it(`should handle special characters`, () => { + const source = { + url: `https://example.com/file-éà.pdf`, + filename: `file-éà.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, + } + + expect(generateFileUrl(source, pathPrefix)).toMatchInlineSnapshot( + `"/prefix/_gatsby/file/8802451220032a66565f179e89e00a83/file-%C3%A9%C3%A0.pdf?url=https%3A%2F%2Fexample.com%2Ffile-%C3%A9%C3%A0.pdf&cd=1234"` + ) + }) + + it(`should handle spaces`, () => { + const source = { + url: `https://example.com/file test.pdf`, + filename: `file test.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, + } + + expect(generateFileUrl(source, pathPrefix)).toMatchInlineSnapshot( + `"/prefix/_gatsby/file/6e41758c045f4509e19938d738d2a23c/file%20test.pdf?url=https%3A%2F%2Fexample.com%2Ffile+test.pdf&cd=1234"` + ) + }) + + it(`should handle html encoded urls`, () => { + const source = { + url: `https://example.com/file%20test.pdf`, + filename: `file test.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, + } + + expect(generateFileUrl(source, pathPrefix)).toMatchInlineSnapshot( + `"/prefix/_gatsby/file/799c0b15477311f5b8d9f635594671f2/file%20test.pdf?url=https%3A%2F%2Fexample.com%2Ffile%2520test.pdf&cd=1234"` + ) + }) + }) + }) +}) diff --git a/packages/gatsby-adapter-netlify/src/__tests__/image-cdn-url-generator.ts b/packages/gatsby-adapter-netlify/src/__tests__/image-cdn-url-generator.ts new file mode 100644 index 0000000000000..58567e42e3f06 --- /dev/null +++ b/packages/gatsby-adapter-netlify/src/__tests__/image-cdn-url-generator.ts @@ -0,0 +1,141 @@ +import type { ImageCdnTransformArgs } from "gatsby" + +import { generateImageUrl, generateImageArgs } from "../image-cdn-url-generator" + +describe(`generateImageUrl`, () => { + // pathPrefix is not used for images + const pathPrefix = `/prefix` + + const source = { + url: `https://example.com/image.jpg`, + filename: `image.jpg`, + mimeType: `image/jpeg`, + internal: { + contentDigest: `1234`, + }, + } + + it(`should return an image based url`, () => { + expect( + generateImageUrl( + source, + { + width: 100, + height: 100, + cropFocus: `top`, + format: `webp`, + quality: 80, + }, + pathPrefix + ) + ).toMatchInlineSnapshot( + `"/.netlify/images?w=100&h=100&fit=cover&position=top&fm=webp&q=80&url=https%3A%2F%2Fexample.com%2Fimage.jpg&cd=1234"` + ) + }) + + it(`should handle special characters`, () => { + const source = { + url: `https://example.com/image-éà.jpg`, + filename: `image-éà.jpg`, + mimeType: `image/jpeg`, + internal: { + contentDigest: `1234`, + }, + } + + expect( + generateImageUrl( + source, + { + width: 100, + height: 100, + cropFocus: `top`, + format: `webp`, + quality: 80, + }, + pathPrefix + ) + ).toMatchInlineSnapshot( + `"/.netlify/images?w=100&h=100&fit=cover&position=top&fm=webp&q=80&url=https%3A%2F%2Fexample.com%2Fimage-%C3%A9%C3%A0.jpg&cd=1234"` + ) + }) + + it(`should handle spaces`, () => { + const source = { + url: `https://example.com/image test.jpg`, + filename: `image test.jpg`, + mimeType: `image/jpeg`, + internal: { + contentDigest: `1234`, + }, + } + + expect( + generateImageUrl( + source, + { + width: 100, + height: 100, + cropFocus: `top`, + format: `webp`, + quality: 80, + }, + pathPrefix + ) + ).toMatchInlineSnapshot( + `"/.netlify/images?w=100&h=100&fit=cover&position=top&fm=webp&q=80&url=https%3A%2F%2Fexample.com%2Fimage+test.jpg&cd=1234"` + ) + }) + + it(`should handle encoded urls`, () => { + const source = { + url: `https://example.com/image%20test.jpg`, + filename: `image test.jpg`, + mimeType: `image/jpeg`, + internal: { + contentDigest: `1234`, + }, + } + + expect( + generateImageUrl( + source, + { + width: 100, + height: 100, + cropFocus: `top`, + format: `webp`, + quality: 80, + }, + pathPrefix + ) + ).toMatchInlineSnapshot( + `"/.netlify/images?w=100&h=100&fit=cover&position=top&fm=webp&q=80&url=https%3A%2F%2Fexample.com%2Fimage%2520test.jpg&cd=1234"` + ) + }) + + it.each([ + [`width`, `w`, 100], + [`height`, `h`, 50], + [`cropFocus`, `position`, `center,right`], + [`format`, `fm`, `webp`], + [`quality`, `q`, 60], + ] as Array<[keyof ImageCdnTransformArgs, string, ImageCdnTransformArgs[keyof ImageCdnTransformArgs]]>)( + `should set %s in image args`, + (key, queryKey, value) => { + const url = new URL( + // @ts-ignore remove typings + `https://netlify.com${generateImageUrl( + source, + { + format: `webp`, + [key]: value, + }, + pathPrefix + )}` + ) + + expect(url.searchParams.get(queryKey)).toEqual(value.toString()) + } + ) +}) diff --git a/packages/gatsby-adapter-netlify/src/allowed-remote-urls.ts b/packages/gatsby-adapter-netlify/src/allowed-remote-urls.ts new file mode 100644 index 0000000000000..daddb94d64a3d --- /dev/null +++ b/packages/gatsby-adapter-netlify/src/allowed-remote-urls.ts @@ -0,0 +1,54 @@ +import type { Reporter, RemoteFileAllowedUrls } from "gatsby" + +export async function handleAllowedRemoteUrlsNetlifyConfig({ + remoteFileAllowedUrls, + reporter, +}: { + remoteFileAllowedUrls: RemoteFileAllowedUrls + reporter: Reporter +}): Promise { + const { resolveConfig } = await import(`@netlify/config`) + const cfg = await resolveConfig() + + if (cfg?.config) { + const allowedUrlsInNetlifyToml: Array = + cfg.config.images?.remote_images ?? [] + + const allowedUrlsInNetlifyTomlRegexes = allowedUrlsInNetlifyToml.map( + regexSource => new RegExp(regexSource) + ) + + const missingAllowedUrlsInNetlifyToml: Array = [] + for (const remoteFileAllowedUrl of remoteFileAllowedUrls) { + // test if url pattern already passes one of the regexes in netlify.toml + const isAlreadyAllowed = allowedUrlsInNetlifyTomlRegexes.some( + allowedRegex => allowedRegex.test(remoteFileAllowedUrl.urlPattern) + ) + + if (!isAlreadyAllowed) { + missingAllowedUrlsInNetlifyToml.push(remoteFileAllowedUrl.regexSource) + } + } + + if (missingAllowedUrlsInNetlifyToml.length > 0) { + const entriesToAddToToml = `${missingAllowedUrlsInNetlifyToml + .map( + missingAllowedUrlInNetlifyToml => + ` ${JSON.stringify(missingAllowedUrlInNetlifyToml)}` + ) + .join(`,\n`)},\n` + + if (typeof cfg.config.images?.remote_images === `undefined`) { + reporter.warn( + `Missing allowed URLs in your Netlify configuration. Add following to your netlify.toml:\n\`\`\`toml\n[images]\nremote_images = [\n${entriesToAddToToml}]\n\`\`\`` + ) + } else { + reporter.warn( + `Missing allowed URLs in your Netlify configuration. Add following entries to your existing \`images.remote_images\` configuration in netlify.toml:\n\`\`\`toml\n${entriesToAddToToml}\`\`\`` + ) + } + } + } else { + reporter.verbose(`[gatsby-adapter-netlify] no netlify.toml found`) + } +} diff --git a/packages/gatsby-adapter-netlify/src/file-cdn-handler.ts b/packages/gatsby-adapter-netlify/src/file-cdn-handler.ts new file mode 100644 index 0000000000000..39f26c18c3023 --- /dev/null +++ b/packages/gatsby-adapter-netlify/src/file-cdn-handler.ts @@ -0,0 +1,90 @@ +import fs from "fs-extra" +import * as path from "path" + +import packageJson from "gatsby-adapter-netlify/package.json" + +import type { RemoteFileAllowedUrls } from "gatsby" + +export interface IFunctionManifest { + version: 1 + functions: Array< + | { + function: string + name?: string + path: string + cache?: "manual" + generator: string + } + | { + function: string + name?: string + pattern: string + cache?: "manual" + generator: string + } + > + layers?: Array<{ name: `https://${string}/mod.ts`; flag: string }> + import_map?: string +} + +export async function prepareFileCdnHandler({ + pathPrefix, + remoteFileAllowedUrls, +}: { + pathPrefix: string + remoteFileAllowedUrls: RemoteFileAllowedUrls +}): Promise { + const functionId = `file-cdn` + + const edgeFunctionsManifestPath = path.join( + process.cwd(), + `.netlify`, + `edge-functions`, + `manifest.json` + ) + + const fileCdnEdgeFunction = path.join( + process.cwd(), + `.netlify`, + `edge-functions`, + `${functionId}`, + `${functionId}.mts` + ) + + const handlerSource = /* typescript */ ` + const allowedUrlPatterns = [${remoteFileAllowedUrls.map( + allowedUrl => `new RegExp(\`${allowedUrl.regexSource}\`)` + )}] + + export default async (req: Request): Promise => { + const url = new URL(req.url) + const remoteUrl = url.searchParams.get("url") + + const isAllowed = allowedUrlPatterns.some(allowedUrlPattern => allowedUrlPattern.test(remoteUrl)) + if (isAllowed) { + return fetch(remoteUrl); + } else { + console.error(\`URL not allowed: \${remoteUrl}\`) + return new Response("Bad request", { status: 500 }) + } + } + ` + + await fs.outputFileSync(fileCdnEdgeFunction, handlerSource) + + const manifest: IFunctionManifest = { + functions: [ + { + path: `${pathPrefix}/_gatsby/file/*`, + function: functionId, + generator: `gatsby-adapter-netlify@${ + packageJson?.version ?? `unknown` + }`, + }, + ], + layers: [], + version: 1, + } + + await fs.outputJSON(edgeFunctionsManifestPath, manifest) +} diff --git a/packages/gatsby-adapter-netlify/src/file-cdn-url-generator.ts b/packages/gatsby-adapter-netlify/src/file-cdn-url-generator.ts new file mode 100644 index 0000000000000..f24efd9c8281c --- /dev/null +++ b/packages/gatsby-adapter-netlify/src/file-cdn-url-generator.ts @@ -0,0 +1,37 @@ +import crypto from "crypto" +import { basename } from "path" + +import type { FileCdnUrlGeneratorFn, FileCdnSourceImage } from "gatsby" + +function isImage(node: FileCdnSourceImage): boolean { + return node.mimeType.startsWith(`image/`) && node.mimeType !== `image/svg+xml` +} + +const placeholderOrigin = `http://netlify.com` + +export const generateFileUrl: FileCdnUrlGeneratorFn = function generateFileUrl( + source: FileCdnSourceImage, + pathPrefix: string +): string { + // use image cdn for images and file lambda for other files + let baseURL: URL + if (isImage(source)) { + baseURL = new URL(`${placeholderOrigin}/.netlify/images`) + baseURL.searchParams.append(`url`, source.url) + baseURL.searchParams.append(`cd`, source.internal.contentDigest) + } else { + baseURL = new URL( + `${placeholderOrigin}${pathPrefix}/_gatsby/file/${crypto + .createHash(`md5`) + .update(source.url) + .digest(`hex`)}/${basename(source.filename)}` + ) + + baseURL.searchParams.append(`url`, source.url) + baseURL.searchParams.append(`cd`, source.internal.contentDigest) + } + + return `${baseURL.pathname}${baseURL.search}` +} + +export default generateFileUrl diff --git a/packages/gatsby-adapter-netlify/src/image-cdn-url-generator.ts b/packages/gatsby-adapter-netlify/src/image-cdn-url-generator.ts new file mode 100644 index 0000000000000..b1db1b5e3ec94 --- /dev/null +++ b/packages/gatsby-adapter-netlify/src/image-cdn-url-generator.ts @@ -0,0 +1,60 @@ +import type { + ImageCdnUrlGeneratorFn, + ImageCdnSourceImage, + ImageCdnTransformArgs, +} from "gatsby" + +export const generateImageUrl: ImageCdnUrlGeneratorFn = + function generateImageUrl( + source: ImageCdnSourceImage, + imageArgs: ImageCdnTransformArgs + ): string { + const placeholderOrigin = `http://netlify.com` + const imageParams = generateImageArgs(imageArgs) + + const baseURL = new URL(`${placeholderOrigin}/.netlify/images`) + + baseURL.search = imageParams.toString() + baseURL.searchParams.append(`url`, source.url) + baseURL.searchParams.append(`cd`, source.internal.contentDigest) + + return `${baseURL.pathname}${baseURL.search}` + } + +export function generateImageArgs({ + width, + height, + format, + cropFocus, + quality, +}: ImageCdnTransformArgs): URLSearchParams { + const params = new URLSearchParams() + + if (width) { + params.append(`w`, width.toString()) + } + if (height) { + params.append(`h`, height.toString()) + } + if (cropFocus) { + params.append(`fit`, `cover`) + if (Array.isArray(cropFocus)) { + // For array of cropFocus values, append them as comma-separated string + params.append(`position`, cropFocus.join(`,`)) + } else { + params.append(`position`, cropFocus) + } + } + + if (format) { + params.append(`fm`, format) + } + + if (quality) { + params.append(`q`, quality.toString()) + } + + return params +} + +export default generateImageUrl diff --git a/packages/gatsby-adapter-netlify/src/index.ts b/packages/gatsby-adapter-netlify/src/index.ts index 2e20a63a35508..b34fafbb8718a 100644 --- a/packages/gatsby-adapter-netlify/src/index.ts +++ b/packages/gatsby-adapter-netlify/src/index.ts @@ -1,8 +1,10 @@ import { join } from "path" import type { AdapterInit, IAdapterConfig } from "gatsby" import { prepareFunctionVariants } from "./lambda-handler" +import { prepareFileCdnHandler } from "./file-cdn-handler" import { handleRoutesManifest } from "./route-handler" import packageJson from "gatsby-adapter-netlify/package.json" +import { handleAllowedRemoteUrlsNetlifyConfig } from "./allowed-remote-urls" interface INetlifyCacheUtils { restore: (paths: Array) => Promise @@ -11,6 +13,7 @@ interface INetlifyCacheUtils { interface INetlifyAdapterOptions { excludeDatastoreFromEngineFunction?: boolean + imageCDN?: boolean } let _cacheUtils: INetlifyCacheUtils | undefined @@ -34,6 +37,16 @@ async function getCacheUtils(): Promise { } const createNetlifyAdapter: AdapterInit = options => { + let useNetlifyImageCDN = options?.imageCDN + if ( + typeof useNetlifyImageCDN === `undefined` && + typeof process.env.NETLIFY_IMAGE_CDN !== `undefined` + ) { + useNetlifyImageCDN = + process.env.NETLIFY_IMAGE_CDN === `true` || + process.env.NETLIFY_IMAGE_CDN === `1` + } + return { name: `gatsby-adapter-netlify`, cache: { @@ -71,7 +84,22 @@ const createNetlifyAdapter: AdapterInit = options => { routesManifest, functionsManifest, headerRoutes, + pathPrefix, + remoteFileAllowedUrls, + reporter, }): Promise { + if (useNetlifyImageCDN) { + await handleAllowedRemoteUrlsNetlifyConfig({ + remoteFileAllowedUrls, + reporter, + }) + + await prepareFileCdnHandler({ + pathPrefix, + remoteFileAllowedUrls, + }) + } + const { lambdasThatUseCaching } = await handleRoutesManifest( routesManifest, headerRoutes @@ -128,6 +156,12 @@ const createNetlifyAdapter: AdapterInit = options => { `gatsby-plugin-netlify-cache`, `gatsby-plugin-netlify`, ], + imageCDNUrlGeneratorModulePath: useNetlifyImageCDN + ? require.resolve(`./image-cdn-url-generator`) + : undefined, + fileCDNUrlGeneratorModulePath: useNetlifyImageCDN + ? require.resolve(`./file-cdn-url-generator`) + : undefined, } }, } diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts index 5c065601a1067..bdaa5907954e7 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts @@ -9,7 +9,9 @@ import { PlaceholderType } from "../placeholder-handler" import { generateImageUrl } from "../utils/url-generator" import type { Actions, Store } from "gatsby" -jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) +jest + .spyOn(dispatchers, `shouldDispatchLocalImageServiceJob`) + .mockImplementation(() => false) jest.mock(`import-from`) jest.mock(`gatsby-core-utils/fetch-remote-file`, () => { return { @@ -64,7 +66,7 @@ describe(`gatsbyImageData`, () => { afterAll(() => remove(cacheDir)) beforeEach(() => { - dispatchers.shouldDispatch.mockClear() + dispatchers.shouldDispatchLocalImageServiceJob.mockClear() fetchRemoteFile.mockClear() }) @@ -179,7 +181,9 @@ describe(`gatsbyImageData`, () => { store ) ).toBe(null) - expect(dispatchers.shouldDispatch).not.toHaveBeenCalled() + expect( + dispatchers.shouldDispatchLocalImageServiceJob + ).not.toHaveBeenCalled() }) it(`should return proper image props for fixed layout`, async () => { diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts index 2d250adf0826e..615c7140c6e68 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts @@ -4,7 +4,9 @@ import { publicUrlResolver } from "../index" import { generateFileUrl } from "../utils/url-generator" import * as dispatchers from "../jobs/dispatchers" -jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) +jest + .spyOn(dispatchers, `shouldDispatchLocalFileServiceJob`) + .mockImplementation(() => false) const store = { getState: (): { requestHeaders: Map> } => { @@ -36,6 +38,10 @@ describe(`publicResolver`, () => { generateFileUrl({ filename: source.filename, url: source.url, + mimeType: source.mimeType, + internal: { + contentDigest: source.internal.contentDigest, + }, }) ) }) @@ -59,6 +65,10 @@ describe(`publicResolver`, () => { generateFileUrl({ filename: source.filename, url: source.url, + mimeType: source.mimeType, + internal: { + contentDigest: source.internal.contentDigest, + }, }) ) }) @@ -68,7 +78,9 @@ describe(`publicResolver`, () => { createJobV2: jest.fn(() => jest.fn()), } - dispatchers.shouldDispatch.mockImplementationOnce(() => true) + dispatchers.shouldDispatchLocalFileServiceJob.mockImplementationOnce( + () => true + ) const source = { id: `1`, @@ -105,7 +117,9 @@ describe(`publicResolver`, () => { const actions = { createJobV2: jest.fn(() => jest.fn()), } - dispatchers.shouldDispatch.mockImplementationOnce(() => true) + dispatchers.shouldDispatchLocalFileServiceJob.mockImplementationOnce( + () => true + ) const source = { id: `1`, @@ -145,7 +159,9 @@ describe(`publicResolver`, () => { createJobV2: jest.fn(() => jest.fn()), } - dispatchers.shouldDispatch.mockImplementationOnce(() => true) + dispatchers.shouldDispatchLocalFileServiceJob.mockImplementationOnce( + () => true + ) const file = { id: `1`, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts index 979b1391ba49e..9e57aadf15d64 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts @@ -9,11 +9,13 @@ import * as dispatchers from "../jobs/dispatchers" import type { Actions } from "gatsby" import type { ImageFit, IRemoteImageNode } from "../types" -jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) +jest + .spyOn(dispatchers, `shouldDispatchLocalImageServiceJob`) + .mockImplementation(() => false) describe(`resizeResolver`, () => { beforeEach(() => { - dispatchers.shouldDispatch.mockClear() + dispatchers.shouldDispatchLocalImageServiceJob.mockClear() }) const actions = {} as Actions @@ -124,7 +126,9 @@ describe(`resizeResolver`, () => { actions ) ).toBe(null) - expect(dispatchers.shouldDispatch).not.toHaveBeenCalled() + expect( + dispatchers.shouldDispatchLocalImageServiceJob + ).not.toHaveBeenCalled() }) it(`should allow you to change the format of the file`, async () => { @@ -303,7 +307,9 @@ describe(`resizeResolver`, () => { height: 160, quality: 75, } - dispatchers.shouldDispatch.mockImplementationOnce(() => true) + dispatchers.shouldDispatchLocalImageServiceJob.mockImplementationOnce( + () => true + ) resizeResolver(portraitSource, { width: 100 }, actions) expect(actions.createJobV2).toHaveBeenCalledWith( diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts index 4c9f4f5ec0ae7..1848b4da42f21 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts @@ -4,7 +4,7 @@ import { getImageFormatFromMimeType } from "../utils/mime-type-helpers" import { stripIndent } from "../utils/strip-indent" import { dispatchLocalImageServiceJob, - shouldDispatch, + shouldDispatchLocalImageServiceJob, } from "../jobs/dispatchers" import { generatePlaceholder, PlaceholderType } from "../placeholder-handler" import { ImageCropFocus, isImage } from "../types" @@ -185,7 +185,7 @@ export async function gatsbyImageResolver( for (const format of sortedFormats) { let fallbackSrc: string | undefined = undefined const images = imageSizes.sizes.map(width => { - if (shouldDispatch()) { + if (shouldDispatchLocalImageServiceJob()) { dispatchLocalImageServiceJob( { url: source.url, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts index f7e44d5e3fe56..6d831a864975b 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts @@ -1,7 +1,7 @@ import { generateFileUrl } from "../utils/url-generator" import { dispatchLocalFileServiceJob, - shouldDispatch, + shouldDispatchLocalFileServiceJob, } from "../jobs/dispatchers" import type { Actions, Store } from "gatsby" import type { IRemoteFileNode, IGraphQLFieldConfigDefinition } from "../types" @@ -11,10 +11,11 @@ export function publicUrlResolver( actions: Actions, store?: Store ): string { - if (shouldDispatch()) { + if (shouldDispatchLocalFileServiceJob()) { dispatchLocalFileServiceJob( { url: source.url, + mimeType: source.mimeType, filename: source.filename, contentDigest: source.internal.contentDigest, }, @@ -23,7 +24,17 @@ export function publicUrlResolver( ) } - return generateFileUrl({ url: source.url, filename: source.filename }, store) + return generateFileUrl( + { + url: source.url, + mimeType: source.mimeType, + filename: source.filename, + internal: { + contentDigest: source.internal.contentDigest, + }, + }, + store + ) } export function generatePublicUrlFieldConfig( diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts index d71bb28ecb715..840b097843687 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts @@ -3,7 +3,7 @@ import { getImageFormatFromMimeType } from "../utils/mime-type-helpers" import { stripIndent } from "../utils/strip-indent" import { dispatchLocalImageServiceJob, - shouldDispatch, + shouldDispatchLocalImageServiceJob, } from "../jobs/dispatchers" import { isImage } from "../types" import { validateAndNormalizeFormats, calculateImageDimensions } from "./utils" @@ -79,7 +79,7 @@ export async function resizeResolver( args as IResizeArgs & WidthOrHeight ) - if (shouldDispatch()) { + if (shouldDispatchLocalImageServiceJob()) { dispatchLocalImageServiceJob( { url: source.url, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts index 4352cb6a0af0e..e0ec8a3e5a84a 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -4,9 +4,20 @@ import { generateFileUrl, generateImageUrl } from "../utils/url-generator" import type { Actions, Store } from "gatsby" import { getRequestHeadersForUrl } from "../utils/get-request-headers-for-url" -export function shouldDispatch(): boolean { +export function shouldDispatchLocalFileServiceJob(): boolean { return ( !( + global.__GATSBY?.fileCDNUrlGeneratorModulePath || + process.env.GATSBY_CLOUD_IMAGE_CDN === `1` || + process.env.GATSBY_CLOUD_IMAGE_CDN === `true` + ) && process.env.NODE_ENV === `production` + ) +} + +export function shouldDispatchLocalImageServiceJob(): boolean { + return ( + !( + global.__GATSBY?.imageCDNUrlGeneratorModulePath || process.env.GATSBY_CLOUD_IMAGE_CDN === `1` || process.env.GATSBY_CLOUD_IMAGE_CDN === `true` ) && process.env.NODE_ENV === `production` @@ -17,8 +28,14 @@ export function dispatchLocalFileServiceJob( { url, filename, + mimeType, contentDigest, - }: { url: string; filename: string; contentDigest: string }, + }: { + url: string + filename: string + mimeType: string + contentDigest: string + }, actions: Actions, store?: Store ): void { @@ -26,7 +43,9 @@ export function dispatchLocalFileServiceJob( const publicUrl = generateFileUrl( { url, + mimeType, filename, + internal: { contentDigest }, }, store ).split(`/`) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts index 99cc86e69f7aa..ffcfc519ca6ca 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts @@ -93,3 +93,52 @@ export function isImage(node: { return node.mimeType.startsWith(`image/`) && node.mimeType !== `image/svg+xml` } + +export type ImageCdnTransformArgs = WidthOrHeight & { + format: string + cropFocus?: ImageCropFocus | Array + quality: number +} + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type CdnSourceImage = { + url: string + mimeType: string + filename: string + internal: { contentDigest: string } +} + +export type ImageCdnSourceImage = CdnSourceImage + +/** + * The function is used to optimize image delivery by generating URLs that leverage CDN capabilities + * @param {ImageCdnSourceImage} source - An object representing the source image, including properties like + * URL, filename, and MIME type. + * @param {ImageCdnTransformArgs} imageArgs - An object containing arguments for image transformation, such as + * format, quality, and crop focus. + * @param {string} pathPrefix - This parameter allows for an optional path prefix in the generated relative URL, + * primarily influencing the location of the image transformation endpoint, particularly if not in the domain root. + * @returns {string} A string representing the generated URL for the image on the CDN. Ideally it is relative url + * (starting with `/`, resulting in usage of same domain as site itself), but it can also be absolute URL. + */ +export type ImageCdnUrlGeneratorFn = ( + source: ImageCdnSourceImage, + imageArgs: ImageCdnTransformArgs, + pathPrefix: string +) => string + +export type FileCdnSourceImage = CdnSourceImage + +/** + * The function is used to optimize image delivery by generating URLs that leverage CDN capabilities + * @param {FileCdnSourceImage} source - An object representing the source file, including properties like + * URL, filename, and MIME type. + * @param {string} pathPrefix - A string representing the path prefix to be prepended to the + * generated URL. + * @returns {string} A string representing the generated URL for the file on the CDN. Ideally it is relative url + * (starting with `/`, resulting in usage of same domain as site itself), but it can also be absolute URL. + */ +export type FileCdnUrlGeneratorFn = ( + source: FileCdnSourceImage, + pathPrefix: string +) => string diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts index 4223bc230ca15..606a300eb7373 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts @@ -56,6 +56,10 @@ describe(`url-generator`, () => { const fileSource = { url: `https://example.com/file.pdf`, filename: `file.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, } expect( @@ -108,6 +112,10 @@ describe(`url-generator`, () => { file: generateFileUrl({ url: fileUrlToEncrypt, filename: `file.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, }), image: generateImageUrl(imageNode, resizeArgs), }[type] @@ -186,6 +194,10 @@ describe(`url-generator`, () => { const source = { url: `https://example.com/file.pdf`, filename: `file.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, } expect(generateFileUrl(source)).toMatchInlineSnapshot( @@ -197,6 +209,10 @@ describe(`url-generator`, () => { const source = { url: `https://example.com/file-éà.pdf`, filename: `file-éà.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, } expect(generateFileUrl(source)).toMatchInlineSnapshot( @@ -208,6 +224,10 @@ describe(`url-generator`, () => { const source = { url: `https://example.com/file test.pdf`, filename: `file test.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, } expect(generateFileUrl(source)).toMatchInlineSnapshot( @@ -219,6 +239,10 @@ describe(`url-generator`, () => { const source = { url: `https://example.com/file%20test.pdf`, filename: `file test.pdf`, + mimeType: `application/pdf`, + internal: { + contentDigest: `1234`, + }, } expect(generateFileUrl(source)).toMatchInlineSnapshot( diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts index 32a88a9e6517f..fd044a49b7ba1 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts @@ -3,7 +3,12 @@ import { basename, extname } from "path" import { URL } from "url" import { createContentDigest } from "gatsby-core-utils/create-content-digest" import { isImage } from "../types" -import type { ImageCropFocus, WidthOrHeight } from "../types" +import type { + ImageCdnUrlGeneratorFn, + ImageCdnSourceImage, + ImageCdnTransformArgs, + FileCdnUrlGeneratorFn, +} from "../types" import type { Store } from "gatsby" // this is an arbitrary origin that we use #branding so we can construct a full url for the URL constructor @@ -56,16 +61,32 @@ function appendUrlParamToSearchParams( const frontendHostName = process.env.IMAGE_CDN_HOSTNAME || `` +let customImageCDNUrlGenerator: ImageCdnUrlGeneratorFn | undefined = undefined +let customFileCDNUrlGenerator: FileCdnUrlGeneratorFn | undefined = undefined + +const preferDefault = (m: any): any => (m && m.default) || m + export function generateFileUrl( - { - url, - filename, - }: { - url: string - filename: string - }, + source: ImageCdnSourceImage, store?: Store ): string { + const state = store?.getState() + + const pathPrefix = state?.program?.prefixPaths + ? state?.config?.pathPrefix + : `` + + if (global.__GATSBY?.fileCDNUrlGeneratorModulePath) { + if (!customFileCDNUrlGenerator) { + customFileCDNUrlGenerator = preferDefault( + require(global.__GATSBY.fileCDNUrlGeneratorModulePath) + ) as FileCdnUrlGeneratorFn + } + return customFileCDNUrlGenerator(source, pathPrefix) + } + + const { url, filename } = source + const fileExt = extname(filename) const filenameWithoutExt = basename(filename, fileExt) @@ -74,7 +95,7 @@ export function generateFileUrl( { url, }, - store + pathPrefix )}/${filenameWithoutExt}${fileExt}` ) @@ -84,20 +105,30 @@ export function generateFileUrl( } export function generateImageUrl( - source: { - url: string - mimeType: string - filename: string - internal: { contentDigest: string } - }, - imageArgs: Parameters[0], + source: ImageCdnSourceImage, + imageArgs: ImageCdnTransformArgs, store?: Store ): string { + const state = store?.getState() + + const pathPrefix = state?.program?.prefixPaths + ? state?.config?.pathPrefix + : `` + + if (global.__GATSBY?.imageCDNUrlGeneratorModulePath) { + if (!customImageCDNUrlGenerator) { + customImageCDNUrlGenerator = preferDefault( + require(global.__GATSBY.imageCDNUrlGeneratorModulePath) + ) as ImageCdnUrlGeneratorFn + } + return customImageCDNUrlGenerator(source, imageArgs, pathPrefix) + } + const filenameWithoutExt = basename(source.filename, extname(source.filename)) const queryStr = generateImageArgs(imageArgs) const parsedURL = new URL( - `${ORIGIN}${generatePublicUrl(source, store)}/${createContentDigest( + `${ORIGIN}${generatePublicUrl(source, pathPrefix)}/${createContentDigest( queryStr )}/${filenameWithoutExt}.${imageArgs.format}` ) @@ -122,14 +153,8 @@ function generatePublicUrl( url: string mimeType?: string }, - store?: Store + pathPrefix: string ): string { - const state = store?.getState() - - const pathPrefix = state?.program?.prefixPaths - ? state?.config?.pathPrefix - : `` - const remoteUrl = createContentDigest(url) let publicUrl = @@ -149,11 +174,7 @@ function generateImageArgs({ format, cropFocus, quality, -}: WidthOrHeight & { - format: string - cropFocus?: ImageCropFocus | Array - quality: number -}): string { +}: ImageCdnTransformArgs): string { const args: Array = [] if (width) { args.push(`w=${width}`) diff --git a/packages/gatsby-source-contentful/src/gatsby-node.js b/packages/gatsby-source-contentful/src/gatsby-node.js index 99528f805a6c1..8f582ab7a9916 100644 --- a/packages/gatsby-source-contentful/src/gatsby-node.js +++ b/packages/gatsby-source-contentful/src/gatsby-node.js @@ -43,7 +43,10 @@ const validateContentfulAccess = async pluginOptions => { return undefined } -export const onPreInit = async ({ store, reporter }) => { +export const onPreInit = async ( + { store, reporter, actions }, + pluginOptions +) => { // if gatsby-plugin-image is not installed try { await import(`gatsby-plugin-image/graphql-utils`) @@ -69,6 +72,12 @@ export const onPreInit = async ({ store, reporter }) => { }, }) } + + if (typeof actions?.addRemoteFileAllowedUrl === `function`) { + actions.addRemoteFileAllowedUrl( + `https://images.ctfassets.net/${pluginOptions.spaceId}/*` + ) + } } export const pluginOptionsSchema = ({ Joi }) => diff --git a/packages/gatsby-source-drupal/src/gatsby-node.ts b/packages/gatsby-source-drupal/src/gatsby-node.ts index 275aea5c3f949..8317b65758058 100644 --- a/packages/gatsby-source-drupal/src/gatsby-node.ts +++ b/packages/gatsby-source-drupal/src/gatsby-node.ts @@ -132,8 +132,12 @@ function gracefullyRethrow(activity, error) { } } -exports.onPreBootstrap = (_, pluginOptions) => { +exports.onPreBootstrap = ({ actions }, pluginOptions) => { setOptions(pluginOptions) + + if (typeof actions?.addRemoteFileAllowedUrl === `function`) { + actions.addRemoteFileAllowedUrl(urlJoin(pluginOptions.baseUrl, `*`)) + } } exports.sourceNodes = async ( diff --git a/packages/gatsby-source-wordpress/src/gatsby-node.ts b/packages/gatsby-source-wordpress/src/gatsby-node.ts index a14f388f84c02..4c6c62f21bf98 100644 --- a/packages/gatsby-source-wordpress/src/gatsby-node.ts +++ b/packages/gatsby-source-wordpress/src/gatsby-node.ts @@ -24,6 +24,7 @@ exports.createSchemaCustomization = runApiSteps( steps.ensurePluginRequirementsAreMet, steps.ingestRemoteSchema, steps.createSchemaCustomization, + steps.addRemoteFileAllowedUrl, ], `createSchemaCustomization` ) diff --git a/packages/gatsby-source-wordpress/src/steps/add-remote-file-allowed-url.ts b/packages/gatsby-source-wordpress/src/steps/add-remote-file-allowed-url.ts new file mode 100644 index 0000000000000..249a01827e041 --- /dev/null +++ b/packages/gatsby-source-wordpress/src/steps/add-remote-file-allowed-url.ts @@ -0,0 +1,21 @@ +import nodePath from "path" +import { getStore } from "~/store" + +import type { Step } from "~/utils/run-steps" + +export const addRemoteFileAllowedUrl: Step = ({ actions }): void => { + if (typeof actions?.addRemoteFileAllowedUrl !== `function`) { + return + } + + const { wpUrl } = getStore().getState().remoteSchema + + if (!wpUrl) { + return + } + + const wordpressUrl = new URL(wpUrl) + wordpressUrl.pathname = nodePath.posix.join(wordpressUrl.pathname, `*`) + + actions.addRemoteFileAllowedUrl(wordpressUrl.href) +} diff --git a/packages/gatsby-source-wordpress/src/steps/index.ts b/packages/gatsby-source-wordpress/src/steps/index.ts index 7f3845abd5510..e0bfdec0a1c24 100644 --- a/packages/gatsby-source-wordpress/src/steps/index.ts +++ b/packages/gatsby-source-wordpress/src/steps/index.ts @@ -21,6 +21,7 @@ export { logPostBuildWarnings } from "~/steps/log-post-build-warnings" export { imageRoutes } from "~/steps/image-routes" export { setRequestHeaders } from "./set-request-headers" +export { addRemoteFileAllowedUrl } from "./add-remote-file-allowed-url" export { hideAuthPluginOptions, diff --git a/packages/gatsby/index.d.ts b/packages/gatsby/index.d.ts index 3adbc7584892e..6ef0ef1a31c02 100644 --- a/packages/gatsby/index.d.ts +++ b/packages/gatsby/index.d.ts @@ -45,6 +45,12 @@ export { HeaderRoutes, FunctionsManifest, IAdapterConfig, + ImageCdnUrlGeneratorFn, + ImageCdnSourceImage, + ImageCdnTransformArgs, + FileCdnUrlGeneratorFn, + FileCdnSourceImage, + RemoteFileAllowedUrls, } from "./dist/utils/adapter/types" export const useScrollRestoration: (key: string) => { @@ -1513,7 +1519,15 @@ export interface Actions { /** * Marks the source plugin that called this function as stateful. Gatsby will not check for stale nodes for any plugin that calls this. */ - enableStatefulSourceNodes?(this: void, plugin?: ActionPlugin) + enableStatefulSourceNodes?(this: void, plugin?: ActionPlugin): void + + /** @see https://www.gatsbyjs.com/docs/actions/#addRemoteFileAllowedUrl */ + addRemoteFileAllowedUrl?( + this: void, + url: string | Array, + plugin?: ActionPlugin, + traceId?: string + ): void } export interface Store { diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 486122786df6b..c4b7cb3cb17ed 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -137,6 +137,7 @@ "opentracing": "^0.14.7", "p-defer": "^3.0.0", "parseurl": "^1.3.3", + "path-to-regexp": "0.1.7", "physical-cpu-count": "^2.0.0", "platform": "^1.3.6", "postcss": "^8.4.24", diff --git a/packages/gatsby/src/redux/actions/restricted.ts b/packages/gatsby/src/redux/actions/restricted.ts index d74000d2977a5..0764b763f8010 100644 --- a/packages/gatsby/src/redux/actions/restricted.ts +++ b/packages/gatsby/src/redux/actions/restricted.ts @@ -22,6 +22,7 @@ import { ICreateResolverContext, IGatsbyPluginContext, ICreateSliceAction, + IAddImageCdnAllowedUrl, } from "../types" import { generateComponentChunkName } from "../../utils/js-chunk-names" import { store } from "../index" @@ -36,6 +37,7 @@ type RestrictionActionNames = | "addThirdPartySchema" | "printTypeDefinitions" | "createSlice" + | "addRemoteFileAllowedUrl" type SomeActionCreator = | ActionCreator @@ -533,6 +535,31 @@ export const actions = { throw new Error(`createSlice is only available in Gatsby v5`) } }, + /** + * Declares URL Pattern that should be allowed to be used for Image or File CDN to prevent using of unexpected RemoteFile URLs. + * + * @availableIn [onPreInit, onPreBootstrap, onPluginInit, createSchemaCustomization] + * + * @param {string | string []} url URLPattern or Array of URL Patternss that should be allowed. + * URLPattern is a string that can contain wildcards (*) or parameter placeholders (e.g. :id). + * @example + * exports.onPreInit = ({ actions }) => { + * actions.addRemoteFileAllowedUrl(`https://your-wordpress-instance.com/*`) + * } + */ + addRemoteFileAllowedUrl: ( + url: string | Array, + plugin: IGatsbyPlugin, + traceId?: string + ): IAddImageCdnAllowedUrl => { + const urls = Array.isArray(url) ? url : [url] + return { + type: `ADD_REMOTE_FILE_ALLOWED_URL`, + payload: { urls }, + plugin, + traceId, + } + }, } const withDeprecationWarning = @@ -656,4 +683,12 @@ export const availableActionsByAPI = mapAvailableActionsToAPIs({ createSlice: { [ALLOWED_IN]: [`createPages`], }, + addRemoteFileAllowedUrl: { + [ALLOWED_IN]: [ + `onPreInit`, + `onPreBootstrap`, + `onPluginInit`, + `createSchemaCustomization`, + ], + }, }) diff --git a/packages/gatsby/src/redux/reducers/index.ts b/packages/gatsby/src/redux/reducers/index.ts index 54e4ef76e47ed..e2b394a5de986 100644 --- a/packages/gatsby/src/redux/reducers/index.ts +++ b/packages/gatsby/src/redux/reducers/index.ts @@ -38,6 +38,7 @@ import { slicesReducer } from "./slices" import { componentsUsingSlicesReducer } from "./components-using-slices" import { slicesByTemplateReducer } from "./slices-by-template" import { adapterReducer } from "./adapter" +import { remoteFileAllowedUrlsReducer } from "./remote-file-allowed-urls" /** * @property exports.nodesTouched Set @@ -83,4 +84,5 @@ export { slicesByTemplateReducer as slicesByTemplate, telemetryReducer as telemetry, adapterReducer as adapter, + remoteFileAllowedUrlsReducer as remoteFileAllowedUrls, } diff --git a/packages/gatsby/src/redux/reducers/remote-file-allowed-urls.ts b/packages/gatsby/src/redux/reducers/remote-file-allowed-urls.ts new file mode 100644 index 0000000000000..b076cdea80642 --- /dev/null +++ b/packages/gatsby/src/redux/reducers/remote-file-allowed-urls.ts @@ -0,0 +1,18 @@ +import { IGatsbyState, ActionsUnion } from "../types" + +export const remoteFileAllowedUrlsReducer = ( + state: IGatsbyState["remoteFileAllowedUrls"] = new Set(), + action: ActionsUnion +): IGatsbyState["remoteFileAllowedUrls"] => { + switch (action.type) { + case `ADD_REMOTE_FILE_ALLOWED_URL`: { + for (const url of action.payload.urls) { + state.add(url) + } + + return state + } + default: + return state + } +} diff --git a/packages/gatsby/src/redux/types.ts b/packages/gatsby/src/redux/types.ts index 01c409da25475..44e8abaafcfa2 100644 --- a/packages/gatsby/src/redux/types.ts +++ b/packages/gatsby/src/redux/types.ts @@ -425,6 +425,7 @@ export interface IGatsbyState { manager: IAdapterManager config: IAdapterFinalConfig } + remoteFileAllowedUrls: Set } export type GatsbyStateKeys = keyof IGatsbyState @@ -539,6 +540,7 @@ export type ActionsUnion = | IClearGatsbyImageSourceUrlAction | ISetAdapterAction | IDisablePluginsByNameAction + | IAddImageCdnAllowedUrl export interface IInitAction { type: `INIT` @@ -1218,6 +1220,15 @@ export interface IDisablePluginsByNameAction { } } +export interface IAddImageCdnAllowedUrl { + type: `ADD_REMOTE_FILE_ALLOWED_URL` + payload: { + urls: Array + } + plugin: IGatsbyPlugin + traceId?: string +} + export interface ITelemetry { gatsbyImageSourceUrls: Set } diff --git a/packages/gatsby/src/utils/adapter/manager.ts b/packages/gatsby/src/utils/adapter/manager.ts index 5c56f16d64c2d..fbe10be215dd0 100644 --- a/packages/gatsby/src/utils/adapter/manager.ts +++ b/packages/gatsby/src/utils/adapter/manager.ts @@ -7,6 +7,7 @@ import { posix } from "path" import { sync as globSync } from "glob" import telemetry from "gatsby-telemetry" import { copy, pathExists, unlink } from "fs-extra" +import pathToRegexp from "path-to-regexp" import type { FunctionsManifest, IAdaptContext, @@ -18,6 +19,7 @@ import type { IAdapterFinalConfig, IAdapterConfig, HeaderRoutes, + RemoteFileAllowedUrls, } from "./types" import { store, readState } from "../../redux" import { getPageMode } from "../page-mode" @@ -204,6 +206,7 @@ export async function initAdapterManager(): Promise { let _routesManifest: RoutesManifest | undefined = undefined let _functionsManifest: FunctionsManifest | undefined = undefined let _headerRoutes: HeaderRoutes | undefined = undefined + let _imageCdnAllowedUrls: RemoteFileAllowedUrls | undefined = undefined const adaptContext: IAdaptContext = { get routesManifest(): RoutesManifest { if (!_routesManifest) { @@ -230,6 +233,20 @@ export async function initAdapterManager(): Promise { return _headerRoutes }, + get remoteFileAllowedUrls(): RemoteFileAllowedUrls { + if (!_imageCdnAllowedUrls) { + _imageCdnAllowedUrls = Array.from( + store.getState().remoteFileAllowedUrls + ).map(urlPattern => { + return { + urlPattern, + regexSource: pathToRegexp(urlPattern).source, + } + }) + } + + return _imageCdnAllowedUrls + }, 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, @@ -251,6 +268,16 @@ export async function initAdapterManager(): Promise { `Can't exclude datastore from engine function without adapter providing deployURL` ) } + + if (configFromAdapter?.imageCDNUrlGeneratorModulePath) { + global.__GATSBY.imageCDNUrlGeneratorModulePath = + configFromAdapter.imageCDNUrlGeneratorModulePath + } + + if (configFromAdapter?.fileCDNUrlGeneratorModulePath) { + global.__GATSBY.fileCDNUrlGeneratorModulePath = + configFromAdapter.fileCDNUrlGeneratorModulePath + } } return { diff --git a/packages/gatsby/src/utils/adapter/types.ts b/packages/gatsby/src/utils/adapter/types.ts index 8e004b93d16b1..c22e15019c5d1 100644 --- a/packages/gatsby/src/utils/adapter/types.ts +++ b/packages/gatsby/src/utils/adapter/types.ts @@ -2,6 +2,22 @@ import type reporter from "gatsby-cli/lib/reporter" import type { TrailingSlash } from "gatsby-page-utils" import type { IHeader, HttpStatusCode } from "../../redux/types" +import type { + ImageCdnUrlGeneratorFn, + ImageCdnSourceImage, + ImageCdnTransformArgs, + FileCdnUrlGeneratorFn, + FileCdnSourceImage, +} from "gatsby-plugin-utils/dist/polyfill-remote-file/types" + +export type { + ImageCdnUrlGeneratorFn, + ImageCdnSourceImage, + ImageCdnTransformArgs, + FileCdnUrlGeneratorFn, + FileCdnSourceImage, +} + interface IBaseRoute { /** * Request path that should be matched for this route. @@ -101,6 +117,17 @@ interface IDefaultContext { reporter: typeof reporter } +export type RemoteFileAllowedUrls = Array<{ + /** + * Allowed url in URLPattern format. In particular it uses wildcard `*` and param `:param` syntax. + */ + urlPattern: string + /** + *Allowed url in regex source format + */ + regexSource: string +}> + export interface IAdaptContext extends IDefaultContext { routesManifest: RoutesManifest functionsManifest: FunctionsManifest @@ -113,6 +140,12 @@ export interface IAdaptContext extends IDefaultContext { * @see https://www.gatsbyjs.com/docs/reference/config-files/gatsby-config/#trailingslash */ trailingSlash: TrailingSlash + /** + * List of allowed remote file URLs represented in URLPattern and Regex formats. + * Allowed urls are provided by user or plugins using `addRemoteFileAllowedUrl` action. + * @see https://www.gatsbyjs.com/docs/reference/config-files/actions/#addRemoteFileAllowedUrl + */ + remoteFileAllowedUrls: RemoteFileAllowedUrls } export interface ICacheContext extends IDefaultContext { @@ -149,6 +182,25 @@ export interface IAdapterConfig { * plugin and adapter is used at the same time. */ pluginsToDisable?: Array + /** + * Path to a CommonJS module that implements an image CDN URL generation function. The function + * is used to optimize image delivery by generating URLs that leverage CDN capabilities. This module + * should have a default export function that conforms to the {@link ImageCdnUrlGeneratorFn} type: + * + * Adapters should provide an absolute path to this module. + * See 'packages/gatsby-adapter-netlify/src/image-cdn-url-generator.ts' as an implementation + * example for the Netlify adapter. + */ + imageCDNUrlGeneratorModulePath?: string + /** + * Path to a CommonJS module that implements an file CDN URL generation function. This module + * should have a default export function that conforms to the {@link FileCdnUrlGeneratorFn} type: + * + * Adapters should provide an absolute path to this module. + * See 'packages/gatsby-adapter-netlify/src/file-cdn-url-generator.ts' as an implementation + * example for the Netlify adapter. + */ + fileCDNUrlGeneratorModulePath?: string } type WithRequired = T & { [P in K]-?: T[P] } 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 6b253093f6c2a..c0641a5336d84 100644 --- a/packages/gatsby/src/utils/page-ssr-module/bundle-webpack.ts +++ b/packages/gatsby/src/utils/page-ssr-module/bundle-webpack.ts @@ -222,19 +222,45 @@ export async function createPageSSRBundle({ ].filter(Boolean) as Array, }) + let IMAGE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH = `` + if (global.__GATSBY?.imageCDNUrlGeneratorModulePath) { + await fs.copyFile( + global.__GATSBY.imageCDNUrlGeneratorModulePath, + path.join(outputDir, `image-cdn-url-generator.js`) + ) + IMAGE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH = `./image-cdn-url-generator.js` + } + + let FILE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH = `` + if (global.__GATSBY?.fileCDNUrlGeneratorModulePath) { + await fs.copyFile( + global.__GATSBY.fileCDNUrlGeneratorModulePath, + path.join(outputDir, `file-cdn-url-generator.js`) + ) + FILE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH = `./file-cdn-url-generator.js` + } + let functionCode = await fs.readFile( path.join(__dirname, `lambda.js`), `utf-8` ) functionCode = functionCode - .replace( + .replaceAll( `%CDN_DATASTORE_PATH%`, shouldBundleDatastore() ? `` : `${state.adapter.config.deployURL ?? ``}/${LmdbOnCdnPath}` ) - .replace(`%PATH_PREFIX%`, pathPrefix) + .replaceAll(`%PATH_PREFIX%`, pathPrefix) + .replaceAll( + `%IMAGE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH%`, + IMAGE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH + ) + .replaceAll( + `%FILE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH%`, + FILE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH + ) await fs.outputFile(path.join(outputDir, `lambda.js`), functionCode) diff --git a/packages/gatsby/src/utils/page-ssr-module/lambda.ts b/packages/gatsby/src/utils/page-ssr-module/lambda.ts index c051aff44010b..c60cb5f37e77f 100644 --- a/packages/gatsby/src/utils/page-ssr-module/lambda.ts +++ b/packages/gatsby/src/utils/page-ssr-module/lambda.ts @@ -27,10 +27,7 @@ function setupFsWrapper(): string { const TEMP_DIR = path.join(tmpdir(), `gatsby`) const TEMP_CACHE_DIR = path.join(TEMP_DIR, `.cache`) - global.__GATSBY = { - root: TEMP_DIR, - buildId: ``, - } + global.__GATSBY.root = TEMP_DIR // TODO: don't hardcode this const cacheDir = `/var/task/.cache` @@ -90,6 +87,24 @@ function setupFsWrapper(): string { } } +global.__GATSBY = { + root: process.cwd(), + buildId: ``, +} + +// eslint-disable-next-line no-constant-condition +if (`%IMAGE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH%`) { + global.__GATSBY.imageCDNUrlGeneratorModulePath = require.resolve( + `%IMAGE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH%` + ) +} +// eslint-disable-next-line no-constant-condition +if (`%FILE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH%`) { + global.__GATSBY.fileCDNUrlGeneratorModulePath = require.resolve( + `%FILE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH%` + ) +} + const dbPath = setupFsWrapper() // using require instead of import here for now because of type hell + import path doesn't exist in current context diff --git a/types/gatsby-monorepo/global.d.ts b/types/gatsby-monorepo/global.d.ts index 192414cb53765..cbca12bca9189 100644 --- a/types/gatsby-monorepo/global.d.ts +++ b/types/gatsby-monorepo/global.d.ts @@ -7,6 +7,8 @@ declare module NodeJS { __GATSBY: { buildId: string root: string + imageCDNUrlGeneratorModulePath?: string + fileCDNUrlGeneratorModulePath?: string } _polyfillRemoteFileCache?: import("gatsby").GatsbyCache diff --git a/yarn.lock b/yarn.lock index 8cfa0a32abb32..272c66fc0580c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3274,6 +3274,41 @@ path-exists "^5.0.0" readdirp "^3.4.0" +"@netlify/config@^20.10.0": + version "20.10.0" + resolved "https://registry.yarnpkg.com/@netlify/config/-/config-20.10.0.tgz#df51f277c7eaa984105f4e90c3ea676b935c5df3" + integrity sha512-7CNoL5IPSRBzDVzxuQgltZ71D/vZ/oYR29sfN8EXjAFOZPSLtnZgborcPa9V9BXLN4N5h0hFp2A26lnnCttEFg== + dependencies: + chalk "^5.0.0" + cron-parser "^4.1.0" + deepmerge "^4.2.2" + dot-prop "^7.0.0" + execa "^6.0.0" + fast-safe-stringify "^2.0.7" + figures "^5.0.0" + filter-obj "^5.0.0" + find-up "^6.0.0" + indent-string "^5.0.0" + is-plain-obj "^4.0.0" + js-yaml "^4.0.0" + map-obj "^5.0.0" + netlify "^13.1.11" + netlify-headers-parser "^7.1.2" + netlify-redirect-parser "^14.2.0" + node-fetch "^3.3.1" + omit.js "^2.0.2" + p-locate "^6.0.0" + path-type "^5.0.0" + toml "^3.0.0" + tomlify-j0.4 "^3.0.0" + validate-npm-package-name "^4.0.0" + yargs "^17.6.0" + +"@netlify/edge-functions@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@netlify/edge-functions/-/edge-functions-2.2.0.tgz#5f7f5c7602a7f98888a4b4421576ca609dad2083" + integrity sha512-8UeKA2nUDB0oWE+Z0gLpA7wpLq8nM+NrZEQMfSdzfMJNvmYVabil/mS07rb0EBrUxM9PCKidKenaiCRnPTBSKw== + "@netlify/functions@^1.6.0": version "1.6.0" resolved "https://registry.yarnpkg.com/@netlify/functions/-/functions-1.6.0.tgz#c373423e6fef0e6f7422ac0345e8bbf2cb692366" @@ -3281,6 +3316,11 @@ dependencies: is-promise "^4.0.0" +"@netlify/open-api@^2.26.0": + version "2.26.0" + resolved "https://registry.yarnpkg.com/@netlify/open-api/-/open-api-2.26.0.tgz#cdc8033371079955501f4b9a5323f2dcd76741c2" + integrity sha512-B7q+ySzQm6rJhaGbY0Pzqnb1p3FsBqwiPLnLtA17JgTsqmXgQ7j6OQImW9fRJy/Al1ob9M6Oxng/FA2c7aIW1g== + "@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" @@ -7167,6 +7207,13 @@ builtins@^2.0.0: dependencies: semver "^6.0.0" +builtins@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/builtins/-/builtins-5.0.1.tgz#87f6db9ab0458be728564fa81d876d8d74552fa9" + integrity sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ== + dependencies: + semver "^7.0.0" + busboy@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" @@ -7525,6 +7572,11 @@ chalk@^4.0, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.0.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" + integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== + change-case-all@1.0.14: version "1.0.14" resolved "https://registry.yarnpkg.com/change-case-all/-/change-case-all-1.0.14.tgz#bac04da08ad143278d0ac3dda7eccd39280bfba1" @@ -8745,6 +8797,13 @@ createerror@1.3.0, createerror@^1.2.0, createerror@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/createerror/-/createerror-1.3.0.tgz#c666bd4cd6b94e35415396569d4649dd0cdb3313" +cron-parser@^4.1.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5" + integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q== + dependencies: + luxon "^3.2.1" + cross-env@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" @@ -9268,6 +9327,11 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +data-uri-to-buffer@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" + integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== + data-urls@^1.0.0, data-urls@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-1.1.0.tgz#15ee0582baa5e22bb59c77140da8f9c76963bbfe" @@ -10003,6 +10067,13 @@ dot-prop@^5.1.0, dot-prop@^5.2.0: dependencies: is-obj "^2.0.0" +dot-prop@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-7.2.0.tgz#468172a3529779814d21a779c1ba2f6d76609809" + integrity sha512-Ol/IPXUARn9CSbkrdV4VJo7uCy1I3VuSiWCaFSg+8BdUOzF9n3jefIpcgAydvUZbTdEBZs2vEiTiS9m61ssiDA== + dependencies: + type-fest "^2.11.2" + dotenv-expand@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" @@ -10400,7 +10471,7 @@ escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" -escape-string-regexp@5.0.0: +escape-string-regexp@5.0.0, escape-string-regexp@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== @@ -11012,6 +11083,21 @@ execa@^5.0.0, execa@^5.1.1: signal-exit "^3.0.3" strip-final-newline "^2.0.0" +execa@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-6.1.0.tgz#cea16dee211ff011246556388effa0818394fb20" + integrity sha512-QVWlX2e50heYJcCPG0iWtf8r0xjEYfz/OYLGDYH+IyjWezzPNxz63qNFOu0l4YftGWuizFVZHHs8PrLU5p2IDA== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.1" + human-signals "^3.0.1" + is-stream "^3.0.0" + merge-stream "^2.0.0" + npm-run-path "^5.1.0" + onetime "^6.0.0" + signal-exit "^3.0.7" + strip-final-newline "^3.0.0" + execall@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/execall/-/execall-2.0.0.tgz#16a06b5fe5099df7d00be5d9c06eecded1663b45" @@ -11260,7 +11346,7 @@ fast-redact@^3.0.0: resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.1.2.tgz#d58e69e9084ce9fa4c1a6fa98a3e1ecf5d7839aa" integrity sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw== -fast-safe-stringify@2.1.1: +fast-safe-stringify@2.1.1, fast-safe-stringify@^2.0.7, fast-safe-stringify@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== @@ -11317,6 +11403,14 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" + integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + figgy-pudding@^3.4.1, figgy-pudding@^3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" @@ -11341,6 +11435,14 @@ figures@^3.0.0, figures@^3.1.0, figures@^3.2.0: dependencies: escape-string-regexp "^1.0.5" +figures@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-5.0.0.tgz#126cd055052dea699f8a54e8c9450e6ecfc44d5f" + integrity sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg== + dependencies: + escape-string-regexp "^5.0.0" + is-unicode-supported "^1.2.0" + file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -11436,6 +11538,11 @@ filter-obj@^1.1.0: resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" integrity sha1-mzERErxsYSehbgFsbF1/GeCAXFs= +filter-obj@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-5.1.0.tgz#5bd89676000a713d7db2e197f660274428e524ed" + integrity sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng== + finalhandler@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" @@ -11514,6 +11621,14 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" +find-up@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-6.3.0.tgz#2abab3d3280b2dc7ac10199ef324c4e002c8c790" + integrity sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw== + dependencies: + locate-path "^7.1.0" + path-exists "^5.0.0" + find-yarn-workspace-root@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd" @@ -11711,6 +11826,13 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -13231,6 +13353,11 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +human-signals@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-3.0.1.tgz#c740920859dafa50e5a3222da9d3bf4bb0e5eef5" + integrity sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ== + humanize-ms@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" @@ -14162,6 +14289,11 @@ is-stream@^2.0.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== +is-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" + integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== + is-string@^1.0.5, is-string@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" @@ -14209,6 +14341,11 @@ is-unicode-supported@^0.1.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== +is-unicode-supported@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz#d824984b616c292a2e198207d4a609983842f714" + integrity sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ== + is-upper-case@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-upper-case/-/is-upper-case-2.0.2.tgz#f1105ced1fe4de906a5f39553e7d3803fd804649" @@ -14864,7 +15001,7 @@ js-levenshtein@^1.1.6: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" -js-yaml@4.1.0, js-yaml@^4.1.0: +js-yaml@4.1.0, js-yaml@^4.0.0, js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== @@ -15594,7 +15731,7 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -locate-path@^7.0.0: +locate-path@^7.0.0, locate-path@^7.1.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== @@ -15988,6 +16125,11 @@ lru-queue@^0.1.0: dependencies: es5-ext "~0.10.2" +luxon@^3.2.1: + version "3.4.4" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.4.4.tgz#cf20dc27dc532ba41a169c43fdcc0063601577af" + integrity sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA== + lz-string@^1.4.4, lz-string@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" @@ -16080,6 +16222,11 @@ map-obj@^4.0.0, map-obj@^4.1.0: resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a" integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ== +map-obj@^5.0.0: + version "5.0.2" + resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-5.0.2.tgz#174ad9f7e5e4e777a219126d9a734ff3e14a1c68" + integrity sha512-K6K2NgKnTXimT3779/4KxSvobxOtMmx1LBZ3NwRxT/MDIR3Br/fQ4Q+WCX5QxjyUR8zg5+RV9Tbf2c5pAWTD2A== + map-stream@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" @@ -16646,6 +16793,11 @@ methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" +micro-api-client@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/micro-api-client/-/micro-api-client-3.3.0.tgz#52dd567d322f10faffe63d19d4feeac4e4ffd215" + integrity sha512-y0y6CUB9RLVsy3kfgayU28746QrNMpSm9O/AYGNsBgOkJr/X/Jk0VLGoO8Ude7Bpa8adywzF+MzXNZRFRsNPhg== + microbundle@^0.15.0, microbundle@^0.15.1: version "0.15.1" resolved "https://registry.yarnpkg.com/microbundle/-/microbundle-0.15.1.tgz#3fa67128934b31736823b5c868dae4b92d94e766" @@ -17123,6 +17275,11 @@ mimic-fn@^3.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-3.1.0.tgz#65755145bbf3e36954b949c16450427451d5ca74" integrity sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ== +mimic-fn@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" + integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== + mimic-response@^1.0.0, mimic-response@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" @@ -17517,6 +17674,42 @@ nested-error-stacks@^2.0.0, nested-error-stacks@^2.1.0: resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.1.0.tgz#0fbdcf3e13fe4994781280524f8b96b0cdff9c61" integrity sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug== +netlify-headers-parser@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/netlify-headers-parser/-/netlify-headers-parser-7.1.2.tgz#5b2f76e030eb8ba423c370c4e4814ddcc9c65e3b" + integrity sha512-DfoboA8PrcLXMan3jIVyLsQtKS+nepKDx6WwZKk5EQDMr2AJoBPCtSHTOLuABzkde1UXdOITf3snmcAmzlNLqw== + dependencies: + escape-string-regexp "^5.0.0" + fast-safe-stringify "^2.0.7" + is-plain-obj "^4.0.0" + map-obj "^5.0.0" + path-exists "^5.0.0" + toml "^3.0.0" + +netlify-redirect-parser@^14.2.0: + version "14.2.0" + resolved "https://registry.yarnpkg.com/netlify-redirect-parser/-/netlify-redirect-parser-14.2.0.tgz#8da1b911b43ea51e0c5fa5dd1401157d1301a8f5" + integrity sha512-3Mi7sMH7XXZhjvXx/5RtJ/rU/E6zKkE4etcYQbEu8B3r872D0opoYyGdPW/MvaYQyVdfg23XEFaEI4zzQTupaw== + dependencies: + fast-safe-stringify "^2.1.1" + filter-obj "^5.0.0" + is-plain-obj "^4.0.0" + path-exists "^5.0.0" + toml "^3.0.0" + +netlify@^13.1.11: + version "13.1.11" + resolved "https://registry.yarnpkg.com/netlify/-/netlify-13.1.11.tgz#f5151bbd5e05cd5a67713f89c05a57dd6377b599" + integrity sha512-exrD6cqwo5avDNtU7YT9iuN0+yoW+aaEUxvr/39oP36wZRKreoPm6KNVisTR9d4lXrPeUG8XI+rERuLhwMQmdw== + dependencies: + "@netlify/open-api" "^2.26.0" + lodash-es "^4.17.21" + micro-api-client "^3.3.0" + node-fetch "^3.0.0" + omit.js "^2.0.2" + p-wait-for "^4.0.0" + qs "^6.9.6" + next-tick@1, next-tick@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" @@ -17591,6 +17784,11 @@ node-dir@^0.1.10, node-dir@^0.1.17: dependencies: minimatch "^3.0.2" +node-domexception@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + node-environment-flags@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088" @@ -17629,6 +17827,15 @@ node-fetch@^2.5.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.11, nod dependencies: whatwg-url "^5.0.0" +node-fetch@^3.0.0, node-fetch@^3.3.1: + version "3.3.2" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b" + integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + node-gyp-build-optional-packages@5.0.3: version "5.0.3" resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.3.tgz#92a89d400352c44ad3975010368072b41ad66c17" @@ -17871,6 +18078,13 @@ npm-run-path@^4.0.0, npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" +npm-run-path@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-5.1.0.tgz#bc62f7f3f6952d9894bd08944ba011a6ee7b7e00" + integrity sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q== + dependencies: + path-key "^4.0.0" + npmlog@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" @@ -18033,6 +18247,11 @@ octokit-pagination-methods@^1.1.0: resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4" integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ== +omit.js@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/omit.js/-/omit.js-2.0.2.tgz#dd9b8436fab947a5f3ff214cb2538631e313ec2f" + integrity sha512-hJmu9D+bNB40YpL9jYebQl4lsTW6yEHRTroJzNLqQJYHm7c+NQnJGfZmIWh8S3q3KoaxV1aLhV6B3+0N0/kyJg== + on-exit-leak-free@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-0.2.0.tgz#b39c9e3bf7690d890f4861558b0d7b90a442d209" @@ -18069,6 +18288,13 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +onetime@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-6.0.0.tgz#7c24c18ed1fd2e9bca4bd26806a33613c77d34b4" + integrity sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ== + dependencies: + mimic-fn "^4.0.0" + open@^7.0.3: version "7.3.1" resolved "https://registry.yarnpkg.com/open/-/open-7.3.1.tgz#111119cb919ca1acd988f49685c4fdd0f4755356" @@ -18379,6 +18605,11 @@ p-timeout@^3.1.0, p-timeout@^3.2.0: dependencies: p-finally "^1.0.0" +p-timeout@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-5.1.0.tgz#b3c691cf4415138ce2d9cfe071dba11f0fee085b" + integrity sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew== + p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" @@ -18388,6 +18619,13 @@ p-try@^2.0.0, p-try@^2.1.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +p-wait-for@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-wait-for/-/p-wait-for-4.1.0.tgz#290f126f49bbd7c84e0cedccb342cd631aaa0f16" + integrity sha512-i8nE5q++9h8oaQHWltS1Tnnv4IoMDOlqN7C0KFG2OdbK0iFJIt6CROZ8wfBM+K4Pxqfnq4C4lkkpXqTEpB5DZw== + dependencies: + p-timeout "^5.0.0" + p-waterfall@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-waterfall/-/p-waterfall-1.0.0.tgz#7ed94b3ceb3332782353af6aae11aa9fc235bb00" @@ -18710,6 +18948,11 @@ path-key@^3.0.0, path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== +path-key@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18" + integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== + path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" @@ -18767,6 +19010,11 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +path-type@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-5.0.0.tgz#14b01ed7aea7ddf9c7c3f46181d4d04f9c785bb8" + integrity sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg== + pause-stream@0.0.11: version "0.0.11" resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" @@ -20046,7 +20294,7 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" -qs@^6.1.0, qs@^6.11.0, qs@^6.4.0, qs@^6.5.2, qs@^6.9.4: +qs@^6.1.0, qs@^6.11.0, qs@^6.4.0, qs@^6.5.2, qs@^6.9.4, qs@^6.9.6: version "6.11.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9" integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA== @@ -21686,7 +21934,7 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.1.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4: +semver@^7.0.0, semver@^7.1.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== @@ -22714,6 +22962,11 @@ strip-final-newline@^2.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== +strip-final-newline@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" + integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== + strip-indent@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2" @@ -23490,6 +23743,16 @@ toml@^2.3.2, toml@^2.3.6: resolved "https://registry.yarnpkg.com/toml/-/toml-2.3.6.tgz#25b0866483a9722474895559088b436fd11f861b" integrity sha512-gVweAectJU3ebq//Ferr2JUY4WKSDe5N+z0FvjDncLGyHmIDoxgY/2Ie4qfEIDm4IS7OA6Rmdm7pdEEdMcV/xQ== +toml@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee" + integrity sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w== + +tomlify-j0.4@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tomlify-j0.4/-/tomlify-j0.4-3.0.0.tgz#99414d45268c3a3b8bf38be82145b7bba34b7473" + integrity sha512-2Ulkc8T7mXJ2l0W476YC/A209PR38Nw8PuaCNtk9uI3t1zzFdGQeWYGQvmj2PZkVvRC/Yoi4xQKMRnWc/N29tQ== + topo@3.x.x: version "3.0.3" resolved "https://registry.yarnpkg.com/topo/-/topo-3.0.3.tgz#d5a67fb2e69307ebeeb08402ec2a2a6f5f7ad95c" @@ -23747,7 +24010,7 @@ type-fest@^1.0.1, type-fest@^1.2.1, type-fest@^1.2.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== -type-fest@^2.19.0: +type-fest@^2.11.2, type-fest@^2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== @@ -24525,6 +24788,13 @@ validate-npm-package-name@^3.0.0: dependencies: builtins "^1.0.3" +validate-npm-package-name@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-4.0.0.tgz#fe8f1c50ac20afdb86f177da85b3600f0ac0d747" + integrity sha512-mzR0L8ZDktZjpX4OB46KT+56MAhl4EIazWP/+G/HPGuvfdaqg4YsCdtOm6U9+LOFyYDoh4dpnpxZRB9MQQns5Q== + dependencies: + builtins "^5.0.0" + validator@13.9.0: version "13.9.0" resolved "https://registry.yarnpkg.com/validator/-/validator-13.9.0.tgz#33e7b85b604f3bbce9bb1a05d5c3e22e1c2ff855" @@ -24837,6 +25107,11 @@ web-namespaces@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.2.tgz#c8dc267ab639505276bae19e129dbd6ae72b22b4" +web-streams-polyfill@^3.0.3: + version "3.2.1" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" + integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== + web-vitals@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-1.1.2.tgz#06535308168986096239aa84716e68b4c6ae6d1c" @@ -25620,6 +25895,19 @@ yargs@^17.2.1, yargs@^17.3.1: y18n "^5.0.5" yargs-parser "^21.1.1" +yargs@^17.6.0: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yauzl@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"