From 8a2fac96c9a64975f4b6272616db66e6174a9801 Mon Sep 17 00:00:00 2001 From: Sidhartha Chatterjee Date: Thu, 25 Feb 2021 01:08:20 +0530 Subject: [PATCH] Release gatsby plugin gatsby cloud for Gatsby v2 (#29738) * Add gatsby-plugin-gatsby-cloud * Fix babel-preset-gatsby-package version * Update engines --- packages/gatsby-plugin-gatsby-cloud/.babelrc | 3 + .../gatsby-plugin-gatsby-cloud/.gitignore | 2 + .../gatsby-plugin-gatsby-cloud/.npmignore | 34 ++ .../gatsby-plugin-gatsby-cloud/CHANGELOG.md | 8 + packages/gatsby-plugin-gatsby-cloud/README.md | 142 ++++++++ .../gatsby-plugin-gatsby-cloud/package.json | 47 +++ .../build-headers-program.js.snap | 9 + .../__snapshots__/create-redirects.js.snap | 3 + .../src/__tests__/build-headers-program.js | 292 ++++++++++++++++ .../src/__tests__/create-redirects.js | 215 ++++++++++++ .../src/build-headers-program.js | 315 ++++++++++++++++++ .../src/constants.js | 41 +++ .../src/create-redirects.js | 17 + .../src/gatsby-node.js | 92 +++++ .../src/plugin-data.js | 28 ++ 15 files changed, 1248 insertions(+) create mode 100644 packages/gatsby-plugin-gatsby-cloud/.babelrc create mode 100644 packages/gatsby-plugin-gatsby-cloud/.gitignore create mode 100644 packages/gatsby-plugin-gatsby-cloud/.npmignore create mode 100644 packages/gatsby-plugin-gatsby-cloud/CHANGELOG.md create mode 100644 packages/gatsby-plugin-gatsby-cloud/README.md create mode 100644 packages/gatsby-plugin-gatsby-cloud/package.json create mode 100644 packages/gatsby-plugin-gatsby-cloud/src/__tests__/__snapshots__/build-headers-program.js.snap create mode 100644 packages/gatsby-plugin-gatsby-cloud/src/__tests__/__snapshots__/create-redirects.js.snap create mode 100644 packages/gatsby-plugin-gatsby-cloud/src/__tests__/build-headers-program.js create mode 100644 packages/gatsby-plugin-gatsby-cloud/src/__tests__/create-redirects.js create mode 100644 packages/gatsby-plugin-gatsby-cloud/src/build-headers-program.js create mode 100644 packages/gatsby-plugin-gatsby-cloud/src/constants.js create mode 100644 packages/gatsby-plugin-gatsby-cloud/src/create-redirects.js create mode 100644 packages/gatsby-plugin-gatsby-cloud/src/gatsby-node.js create mode 100644 packages/gatsby-plugin-gatsby-cloud/src/plugin-data.js diff --git a/packages/gatsby-plugin-gatsby-cloud/.babelrc b/packages/gatsby-plugin-gatsby-cloud/.babelrc new file mode 100644 index 0000000000000..ac0ad292bb087 --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": [["babel-preset-gatsby-package"]] +} diff --git a/packages/gatsby-plugin-gatsby-cloud/.gitignore b/packages/gatsby-plugin-gatsby-cloud/.gitignore new file mode 100644 index 0000000000000..ce91172efccec --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/.gitignore @@ -0,0 +1,2 @@ +yarn.lock +/*.js diff --git a/packages/gatsby-plugin-gatsby-cloud/.npmignore b/packages/gatsby-plugin-gatsby-cloud/.npmignore new file mode 100644 index 0000000000000..e771d2c9fa299 --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/.npmignore @@ -0,0 +1,34 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules +*.un~ +yarn.lock +src +flow-typed +coverage +decls +examples diff --git a/packages/gatsby-plugin-gatsby-cloud/CHANGELOG.md b/packages/gatsby-plugin-gatsby-cloud/CHANGELOG.md new file mode 100644 index 0000000000000..3b156d570a5ad --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/CHANGELOG.md @@ -0,0 +1,8 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# 1.1.0-next.2 (2021-02-19) + +**Note:** Version bump only for package gatsby-plugin-gatsby-cloud diff --git a/packages/gatsby-plugin-gatsby-cloud/README.md b/packages/gatsby-plugin-gatsby-cloud/README.md new file mode 100644 index 0000000000000..06005903e9b77 --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/README.md @@ -0,0 +1,142 @@ +# gatsby-plugin-gatsby-cloud + +Automatically generates a `_headers.json` file and a `_redirects.json` file at the root of the public folder to configure +Headers and Redirects on Gatsby Cloud + +By default, the plugin will add some basic security headers. You can easily add or replace headers through the plugin config. + +## Install + +`npm install --save gatsby-plugin-gatsby-cloud` + +## How to use + +```javascript +// In your gatsby-config.js +plugins: [`gatsby-plugin-gatsby-cloud`] +``` + +## Configuration + +If you just need the critical assets, you don't need to add any additional +config. However, if you want to add headers, remove default headers, or +transform the given headers, you can use the following configuration options. + +```javascript +plugins: [ + { + resolve: `gatsby-plugin-gatsby-cloud`, + options: { + headers: {}, // option to add more headers. `Link` headers are transformed by the below criteria + allPageHeaders: [], // option to add headers for all pages. `Link` headers are transformed by the below criteria + mergeSecurityHeaders: true, // boolean to turn off the default security headers + mergeLinkHeaders: true, // boolean to turn off the default gatsby js headers + mergeCachingHeaders: true, // boolean to turn off the default caching headers + transformHeaders: (headers, path) => headers, // optional transform for manipulating headers under each path (e.g.sorting), etc. + generateMatchPathRewrites: true, // boolean to turn off automatic creation of redirect rules for client only paths + }, + }, +] +``` + +### Headers + +You should pass in an object with string keys (representing the paths) and an +array of strings for each header. + +An example: + +```javascript +{ + options: { + headers: { + "/*": [ + "Basic-Auth: someuser:somepassword anotheruser:anotherpassword", + ], + "/my-page": [ + // matching headers (by type) are replaced by Gatsby Cloud with more specific routes + "Basic-Auth: differentuser:differentpassword", + ], + }, + } +} +``` + +Link paths are specially handled by this plugin. Since most files are processed +and cache-busted through Gatsby (with a file hash), the plugin will transform +any base file names to the hashed variants. If the file is not hashed, it will +ensure the path is valid relative to the output `public` folder. You should be +able to reference assets imported through javascript in the `static` folder. + +Do not specify the public path in the config, as the plugin will provide it for +you. + +The `_headers.json` file does not inherit headers, and it will replace any +matching headers it finds in more specific routes. For example, if you add a +link to the root wildcard path (`/*`), it will be replaced by any more +specific path. If you want a resource to put linked across the site, you will +have to add to every path. To make this easier, the plugin provides the +`allPageHeaders` option to inject the same headers on every path. + +```javascript +{ + options: { + allPageHeaders: [ + "Link: ; rel=preload; as=image", + ], + headers: { + "/*": [ + "Basic-Auth: someuser:somepassword anotheruser:anotherpassword", + ], + }, + } +} +``` + +### Redirects + +You can create redirects using the [`createRedirect`](https://www.gatsbyjs.org/docs/actions/#createRedirect) action. + +In addition to the options provided by the Gatsby API, you can pass these options specific to this plugin: + +| Attribute | Description | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `statusCode` | Overrides the HTTP status code which is set to `302` by default or `301` when [`isPermanent`](https://www.gatsbyjs.org/docs/actions/#createRedirect) is `true`. You can set one here. For example, `200` for rewrites, or `404` for a custom error page. | + +An example: + +```javascript +createRedirect({ fromPath: "/old-url", toPath: "/new-url", isPermanent: true }) +createRedirect({ fromPath: "/url", toPath: "/zn-CH/url", Language: "zn" }) +createRedirect({ + fromPath: "/url_that_is/not_pretty", + toPath: "/pretty/url", + statusCode: 200, +}) +createRedirect({ + fromPath: "/packages/*", + toPath: "/plugins/*", +}) +``` + +Redirect rules are automatically added for [client only paths](https://www.gatsbyjs.org/docs/client-only-routes-and-user-authentication). The plugin uses the [matchPath](https://www.gatsbyjs.org/docs/gatsby-internals-terminology/#matchpath) syntax to match all possible requests in the range of your client-side routes and serves the HTML file for the client-side route. Without it, only the exact route of the client-side route works. + +If those rules are conflicting with custom rules or if you want to have more control over them you can disable them in [configuration](#configuration) by setting `generateMatchPathRewrites` to `false`. + +An asterix, `*`, will match anything that follows. i.e. `/packages/gatsby-plugin-gatsby-cloud/` will be redirected to `/plugins/gatsby-plugin-gatsby-cloud/`. + +### HTTP Strict Transport Security + +[HSTS Cheatsheet](https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Strict_Transport_Security_Cheat_Sheet.html) + +Since this header is an opt-in security enhancement with permanent consequences we don't include it as a default feature but use can use the `allPagesHeaders` to include it. + +```javascript +{ + options: { + allPageHeaders: [ + "Strict-Transport-Security: max-age=31536000; includeSubDomains; preload", + ], + } +} +``` diff --git a/packages/gatsby-plugin-gatsby-cloud/package.json b/packages/gatsby-plugin-gatsby-cloud/package.json new file mode 100644 index 0000000000000..1785a029c49d0 --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/package.json @@ -0,0 +1,47 @@ +{ + "name": "gatsby-plugin-gatsby-cloud", + "description": "A Gatsby plugin which optimizes working with Gatsby Cloud", + "version": "1.0.1", + "author": "Kyle Mathews ", + "bugs": { + "url": "https://github.com/gatsbyjs/gatsby/issues" + }, + "dependencies": { + "@babel/runtime": "^7.12.5", + "fs-extra": "^8.1.0", + "kebab-hash": "^0.1.2", + "lodash": "^4.17.20", + "webpack-assets-manifest": "^3.1.1" + }, + "devDependencies": { + "@babel/cli": "^7.12.1", + "@babel/core": "^7.12.3", + "babel-preset-gatsby-package": "^0.12.0", + "cross-env": "^7.0.3" + }, + "homepage": "https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-plugin-gatsby-cloud#readme", + "keywords": [ + "gatsby", + "gatsby-plugin", + "http/2-server-push", + "gatsby-cloud" + ], + "license": "MIT", + "main": "index.js", + "peerDependencies": { + "gatsby": "^2.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/gatsbyjs/gatsby.git", + "directory": "packages/gatsby-plugin-gatsby-cloud" + }, + "scripts": { + "build": "babel src --out-dir . --ignore \"**/__tests__\"", + "prepare": "cross-env NODE_ENV=production npm run build", + "watch": "babel -w src --out-dir . --ignore \"**/__tests__\"" + }, + "engines": { + "node": ">=10.13.0" + } +} diff --git a/packages/gatsby-plugin-gatsby-cloud/src/__tests__/__snapshots__/build-headers-program.js.snap b/packages/gatsby-plugin-gatsby-cloud/src/__tests__/__snapshots__/build-headers-program.js.snap new file mode 100644 index 0000000000000..147dd943233b9 --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/src/__tests__/__snapshots__/build-headers-program.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`build-headers-program with caching headers 1`] = `"{\\"/*\\":[\\"X-Frame-Options: DENY\\",\\"X-XSS-Protection: 1; mode=block\\",\\"X-Content-Type-Options: nosniff\\",\\"Referrer-Policy: same-origin\\"],\\"/component---node-modules-gatsby-plugin-offline-app-shell-js-78f9e4dea04737fa062d.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/0-0180cd94ef2497ac7db8.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/component---src-templates-blog-post-js-517987eae96e75cddbe7.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/component---src-pages-404-js-53e6c51a5a7e73090f50.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/component---src-pages-index-js-0bdd01c77ee09ef0224c.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/webpack-runtime-acaa8994f1f704475e21.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/styles.1025963f4f2ec7abbad4.css\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/styles-565f081c8374bbda155f.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/app-f33c13590352da20930f.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/static/*\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/sw.js\\":[\\"Cache-Control: public, max-age=0, must-revalidate\\"],\\"/offline-plugin-app-shell-fallback/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/hi-folks/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/my-second-post/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/hello-world/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/404/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/404.html\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"]}"`; + +exports[`build-headers-program with manifest['pages-manifest'] 1`] = `"{\\"/*\\":[\\"X-Frame-Options: DENY\\",\\"X-XSS-Protection: 1; mode=block\\",\\"X-Content-Type-Options: nosniff\\",\\"Referrer-Policy: same-origin\\"],\\"/component---node-modules-gatsby-plugin-offline-app-shell-js-78f9e4dea04737fa062d.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/0-0180cd94ef2497ac7db8.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/component---src-templates-blog-post-js-517987eae96e75cddbe7.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/component---src-pages-404-js-53e6c51a5a7e73090f50.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/component---src-pages-index-js-0bdd01c77ee09ef0224c.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/pages-manifest-ab11f09e0ca7ecd3b43e.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/webpack-runtime-acaa8994f1f704475e21.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/styles.1025963f4f2ec7abbad4.css\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/styles-565f081c8374bbda155f.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/app-f33c13590352da20930f.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/static/*\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/sw.js\\":[\\"Cache-Control: public, max-age=0, must-revalidate\\"],\\"/offline-plugin-app-shell-fallback/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\"],\\"/hi-folks/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\"],\\"/my-second-post/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\"],\\"/hello-world/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\"],\\"/404/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\"],\\"/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\"],\\"/404.html\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\"]}"`; + +exports[`build-headers-program with security headers 1`] = `"{\\"/*\\":[\\"X-Frame-Options: ALLOW-FROM https://app.storyblok.com/\\",\\"X-XSS-Protection: 1; mode=block\\",\\"X-Content-Type-Options: nosniff\\",\\"Referrer-Policy: same-origin\\",\\"Content-Security-Policy: frame-ancestors 'self' https://*.storyblok.com/\\"],\\"/hello\\":[\\"X-Frame-Options: SAMEORIGIN\\"],\\"/component---node-modules-gatsby-plugin-offline-app-shell-js-78f9e4dea04737fa062d.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/0-0180cd94ef2497ac7db8.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/component---src-templates-blog-post-js-517987eae96e75cddbe7.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/component---src-pages-404-js-53e6c51a5a7e73090f50.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/component---src-pages-index-js-0bdd01c77ee09ef0224c.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/webpack-runtime-acaa8994f1f704475e21.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/styles.1025963f4f2ec7abbad4.css\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/styles-565f081c8374bbda155f.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/app-f33c13590352da20930f.js\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/static/*\\":[\\"Cache-Control: public, max-age=31536000, immutable\\"],\\"/sw.js\\":[\\"Cache-Control: public, max-age=0, must-revalidate\\"],\\"/offline-plugin-app-shell-fallback/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/hi-folks/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/my-second-post/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/hello-world/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/404/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/404.html\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"]}"`; + +exports[`build-headers-program without caching headers 1`] = `"{\\"/*\\":[\\"X-Frame-Options: DENY\\",\\"X-XSS-Protection: 1; mode=block\\",\\"X-Content-Type-Options: nosniff\\",\\"Referrer-Policy: same-origin\\"],\\"/offline-plugin-app-shell-fallback/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/hi-folks/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/my-second-post/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/hello-world/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/404/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"],\\"/404.html\\":[\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=script; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\",\\"Link: ; rel=preload; as=fetch; crossorigin; nopush\\"]}"`; diff --git a/packages/gatsby-plugin-gatsby-cloud/src/__tests__/__snapshots__/create-redirects.js.snap b/packages/gatsby-plugin-gatsby-cloud/src/__tests__/__snapshots__/create-redirects.js.snap new file mode 100644 index 0000000000000..9202cd7c0838e --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/src/__tests__/__snapshots__/create-redirects.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`create-redirects should assemble a redirects file 1`] = `"{\\"redirects\\":[{\\"fromPath\\":\\"/old-url\\",\\"toPath\\":\\"/new-url\\",\\"isPermanent\\":true},{\\"fromPath\\":\\"/url_that_is/not_pretty\\",\\"toPath\\":\\"/pretty/url\\",\\"statusCode\\":201}],\\"rewrites\\":[{\\"fromPath\\":\\"/url_that_is/ugly\\",\\"toPath\\":\\"/not_ugly/url\\"},{\\"fromPath\\":\\"/path2/*splatparam\\",\\"toPath\\":\\"/path2/[...splatparam]/\\"},{\\"fromPath\\":\\"/path/*\\",\\"toPath\\":\\"/path/[...]/\\"},{\\"fromPath\\":\\"/path3/:level1/:level2\\",\\"toPath\\":\\"/path3/[level1]/[level2]/\\"},{\\"fromPath\\":\\"/path4/:param1\\",\\"toPath\\":\\"/path4/[param1]/\\"}]}"`; diff --git a/packages/gatsby-plugin-gatsby-cloud/src/__tests__/build-headers-program.js b/packages/gatsby-plugin-gatsby-cloud/src/__tests__/build-headers-program.js new file mode 100644 index 0000000000000..c9ec4c6e6d146 --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/src/__tests__/build-headers-program.js @@ -0,0 +1,292 @@ +import buildHeadersProgram from "../build-headers-program" +import * as path from "path" +import * as os from "os" +import * as fs from "fs-extra" +import { DEFAULT_OPTIONS, HEADERS_FILENAME } from "../constants" + +jest.mock(`fs-extra`, () => { + return { + ...jest.requireActual(`fs-extra`), + existsSync: jest.fn(), + } +}) + +describe(`build-headers-program`, () => { + let reporter + + beforeEach(() => { + reporter = { + warn: jest.fn(), + } + fs.existsSync.mockClear() + fs.existsSync.mockReturnValue(true) + }) + + const createPluginData = async () => { + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), `abhi-plugin-fastly-`) + ) + + return { + pages: new Map([ + [ + `/offline-plugin-app-shell-fallback/`, + { + jsonName: `offline-plugin-app-shell-fallback-a30`, + internalComponentName: `ComponentOfflinePluginAppShellFallback`, + path: `/offline-plugin-app-shell-fallback/`, + matchPath: undefined, + componentChunkName: `component---node-modules-gatsby-plugin-offline-app-shell-js`, + isCreatedByStatefulCreatePages: false, + context: {}, + updatedAt: 1557740602268, + pluginCreator___NODE: `63e5f7ff-e5f1-58f7-8e2c-55872ac42281`, + pluginCreatorId: `63e5f7ff-e5f1-58f7-8e2c-55872ac42281`, + }, + ], + [ + `/hi-folks/`, + { + jsonName: `hi-folks-a2b`, + internalComponentName: `ComponentHiFolks`, + path: `/hi-folks/`, + matchPath: undefined, + componentChunkName: `component---src-templates-blog-post-js`, + isCreatedByStatefulCreatePages: false, + context: {}, + updatedAt: 1557740602330, + pluginCreator___NODE: `7374ebf2-d961-52ee-92a2-c25e7cb387a9`, + pluginCreatorId: `7374ebf2-d961-52ee-92a2-c25e7cb387a9`, + }, + ], + [ + `/my-second-post/`, + { + jsonName: `my-second-post-2aa`, + internalComponentName: `ComponentMySecondPost`, + path: `/my-second-post/`, + matchPath: undefined, + componentChunkName: `component---src-templates-blog-post-js`, + isCreatedByStatefulCreatePages: false, + context: {}, + updatedAt: 1557740602333, + pluginCreator___NODE: `7374ebf2-d961-52ee-92a2-c25e7cb387a9`, + pluginCreatorId: `7374ebf2-d961-52ee-92a2-c25e7cb387a9`, + }, + ], + [ + `/hello-world/`, + { + jsonName: `hello-world-8bc`, + internalComponentName: `ComponentHelloWorld`, + path: `/hello-world/`, + matchPath: undefined, + componentChunkName: `component---src-templates-blog-post-js`, + isCreatedByStatefulCreatePages: false, + context: {}, + updatedAt: 1557740602335, + pluginCreator___NODE: `7374ebf2-d961-52ee-92a2-c25e7cb387a9`, + pluginCreatorId: `7374ebf2-d961-52ee-92a2-c25e7cb387a9`, + }, + ], + [ + `/404/`, + { + jsonName: `404-22d`, + internalComponentName: `Component404`, + path: `/404/`, + matchPath: undefined, + componentChunkName: `component---src-pages-404-js`, + isCreatedByStatefulCreatePages: true, + context: {}, + updatedAt: 1557740602358, + pluginCreator___NODE: `049c1cfd-95f7-5555-a4ac-9b396d098b26`, + pluginCreatorId: `049c1cfd-95f7-5555-a4ac-9b396d098b26`, + }, + ], + [ + `/`, + { + jsonName: `index`, + internalComponentName: `ComponentIndex`, + path: `/`, + matchPath: undefined, + componentChunkName: `component---src-pages-index-js`, + isCreatedByStatefulCreatePages: true, + context: {}, + updatedAt: 1557740602361, + pluginCreator___NODE: `049c1cfd-95f7-5555-a4ac-9b396d098b26`, + pluginCreatorId: `049c1cfd-95f7-5555-a4ac-9b396d098b26`, + }, + ], + [ + `/404.html`, + { + jsonName: `404-html-516`, + internalComponentName: `Component404Html`, + path: `/404.html`, + matchPath: undefined, + componentChunkName: `component---src-pages-404-js`, + isCreatedByStatefulCreatePages: true, + context: {}, + updatedAt: 1557740602382, + pluginCreator___NODE: `f795702c-a3b8-5a88-88ee-5d06019d44fa`, + pluginCreatorId: `f795702c-a3b8-5a88-88ee-5d06019d44fa`, + }, + ], + ]), + manifest: { + "main.js": `render-page.js`, + "main.js.map": `render-page.js.map`, + app: [ + `webpack-runtime-acaa8994f1f704475e21.js`, + `styles.1025963f4f2ec7abbad4.css`, + `styles-565f081c8374bbda155f.js`, + `app-f33c13590352da20930f.js`, + ], + "component---node-modules-gatsby-plugin-offline-app-shell-js": [ + `component---node-modules-gatsby-plugin-offline-app-shell-js-78f9e4dea04737fa062d.js`, + ], + "component---src-templates-blog-post-js": [ + `0-0180cd94ef2497ac7db8.js`, + `component---src-templates-blog-post-js-517987eae96e75cddbe7.js`, + ], + "component---src-pages-404-js": [ + `0-0180cd94ef2497ac7db8.js`, + `component---src-pages-404-js-53e6c51a5a7e73090f50.js`, + ], + "component---src-pages-index-js": [ + `0-0180cd94ef2497ac7db8.js`, + `component---src-pages-index-js-0bdd01c77ee09ef0224c.js`, + ], + }, + pathPrefix: ``, + publicFolder: (...files) => path.join(tmpDir, ...files), + } + } + + it(`with caching headers`, async () => { + const pluginData = await createPluginData() + + const pluginOptions = { + ...DEFAULT_OPTIONS, + mergeCachingHeaders: true, + } + + await buildHeadersProgram(pluginData, pluginOptions, reporter) + + expect(reporter.warn).not.toHaveBeenCalled() + const output = await fs.readFile( + pluginData.publicFolder(HEADERS_FILENAME), + `utf8` + ) + expect(output).toMatchSnapshot() + expect(output).toMatch(/app-data\.json/) + expect(output).toMatch(/page-data\.json/) + // we should only check page-data & app-data once which leads to 2 times + expect(fs.existsSync).toBeCalledTimes(2) + }) + + it(`with manifest['pages-manifest']`, async () => { + const pluginData = await createPluginData() + + fs.existsSync.mockImplementation(path => { + if (path.includes(`page-data.json`) || path.includes(`app-data.json`)) { + return false + } + + return true + }) + + // gatsby < 2.9 uses page-manifest + pluginData.manifest[`pages-manifest`] = [ + `pages-manifest-ab11f09e0ca7ecd3b43e.js`, + ] + + const pluginOptions = { + ...DEFAULT_OPTIONS, + mergeCachingHeaders: true, + } + + await buildHeadersProgram(pluginData, pluginOptions, reporter) + + expect(reporter.warn).not.toHaveBeenCalled() + const output = await fs.readFile( + pluginData.publicFolder(HEADERS_FILENAME), + `utf8` + ) + expect(output).toMatchSnapshot() + expect(output).toMatch(/\/pages-manifest-ab11f09e0ca7ecd3b43e\.js/g) + expect(output).not.toMatch(/\/app-data\.json/g) + expect(output).not.toMatch(/\/page-data\.json/g) + expect(output).not.toMatch(/\/undefined/g) + }) + + it(`without app-data file`, async () => { + const pluginData = await createPluginData() + + // gatsby 2.17.0+ adds an app-data file + delete pluginData.manifest[`pages-manifest`] + + const pluginOptions = { + ...DEFAULT_OPTIONS, + mergeCachingHeaders: true, + } + fs.existsSync.mockImplementation(path => { + if (path.includes(`app-data.json`)) { + return false + } + + return true + }) + + await buildHeadersProgram(pluginData, pluginOptions, reporter) + + expect(reporter.warn).not.toHaveBeenCalled() + const output = await fs.readFile( + pluginData.publicFolder(HEADERS_FILENAME), + `utf8` + ) + expect(output).not.toMatch(/app-data\.json/g) + expect(output).not.toMatch(/\/undefined/g) + }) + + it(`without caching headers`, async () => { + const pluginData = await createPluginData() + + const pluginOptions = { + ...DEFAULT_OPTIONS, + mergeCachingHeaders: false, + } + + await buildHeadersProgram(pluginData, pluginOptions, reporter) + + expect(reporter.warn).not.toHaveBeenCalled() + expect( + await fs.readFile(pluginData.publicFolder(HEADERS_FILENAME), `utf8`) + ).toMatchSnapshot() + }) + + it(`with security headers`, async () => { + const pluginData = await createPluginData() + + const pluginOptions = { + ...DEFAULT_OPTIONS, + mergeSecurityHeaders: true, + headers: { + "/*": [ + `Content-Security-Policy: frame-ancestors 'self' https://*.storyblok.com/`, + `X-Frame-Options: ALLOW-FROM https://app.storyblok.com/`, + ], + "/hello": [`X-Frame-Options: SAMEORIGIN`], + }, + } + + await buildHeadersProgram(pluginData, pluginOptions, reporter) + + expect(reporter.warn).not.toHaveBeenCalled() + expect( + await fs.readFile(pluginData.publicFolder(HEADERS_FILENAME), `utf8`) + ).toMatchSnapshot() + }) +}) diff --git a/packages/gatsby-plugin-gatsby-cloud/src/__tests__/create-redirects.js b/packages/gatsby-plugin-gatsby-cloud/src/__tests__/create-redirects.js new file mode 100644 index 0000000000000..1bbcb6e3c9fe3 --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/src/__tests__/create-redirects.js @@ -0,0 +1,215 @@ +import createRedirects from "../create-redirects" +import * as path from "path" +import * as os from "os" +import * as fs from "fs-extra" +import { REDIRECTS_FILENAME } from "../constants" + +jest.mock(`fs-extra`, () => { + return { + ...jest.requireActual(`fs-extra`), + existsSync: jest.fn(), + } +}) + +describe(`create-redirects`, () => { + let reporter + + beforeEach(() => { + reporter = { + warn: jest.fn(), + } + fs.existsSync.mockClear() + fs.existsSync.mockReturnValue(true) + }) + + const createPluginData = async () => { + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), `gatsby-plugin-gatsby-cloud-`) + ) + + return { + pages: new Map([ + [ + `/offline-plugin-app-shell-fallback/`, + { + jsonName: `offline-plugin-app-shell-fallback-a30`, + internalComponentName: `ComponentOfflinePluginAppShellFallback`, + path: `/offline-plugin-app-shell-fallback/`, + matchPath: undefined, + componentChunkName: `component---node-modules-gatsby-plugin-offline-app-shell-js`, + isCreatedByStatefulCreatePages: false, + context: {}, + updatedAt: 1557740602268, + pluginCreator___NODE: `63e5f7ff-e5f1-58f7-8e2c-55872ac42281`, + pluginCreatorId: `63e5f7ff-e5f1-58f7-8e2c-55872ac42281`, + }, + ], + [ + `/hi-folks/`, + { + jsonName: `hi-folks-a2b`, + internalComponentName: `ComponentHiFolks`, + path: `/hi-folks/`, + matchPath: undefined, + componentChunkName: `component---src-templates-blog-post-js`, + isCreatedByStatefulCreatePages: false, + context: {}, + updatedAt: 1557740602330, + pluginCreator___NODE: `7374ebf2-d961-52ee-92a2-c25e7cb387a9`, + pluginCreatorId: `7374ebf2-d961-52ee-92a2-c25e7cb387a9`, + }, + ], + [ + `/my-second-post/`, + { + jsonName: `my-second-post-2aa`, + internalComponentName: `ComponentMySecondPost`, + path: `/my-second-post/`, + matchPath: undefined, + componentChunkName: `component---src-templates-blog-post-js`, + isCreatedByStatefulCreatePages: false, + context: {}, + updatedAt: 1557740602333, + pluginCreator___NODE: `7374ebf2-d961-52ee-92a2-c25e7cb387a9`, + pluginCreatorId: `7374ebf2-d961-52ee-92a2-c25e7cb387a9`, + }, + ], + [ + `/hello-world/`, + { + jsonName: `hello-world-8bc`, + internalComponentName: `ComponentHelloWorld`, + path: `/hello-world/`, + matchPath: undefined, + componentChunkName: `component---src-templates-blog-post-js`, + isCreatedByStatefulCreatePages: false, + context: {}, + updatedAt: 1557740602335, + pluginCreator___NODE: `7374ebf2-d961-52ee-92a2-c25e7cb387a9`, + pluginCreatorId: `7374ebf2-d961-52ee-92a2-c25e7cb387a9`, + }, + ], + [ + `/404/`, + { + jsonName: `404-22d`, + internalComponentName: `Component404`, + path: `/404/`, + matchPath: undefined, + componentChunkName: `component---src-pages-404-js`, + isCreatedByStatefulCreatePages: true, + context: {}, + updatedAt: 1557740602358, + pluginCreator___NODE: `049c1cfd-95f7-5555-a4ac-9b396d098b26`, + pluginCreatorId: `049c1cfd-95f7-5555-a4ac-9b396d098b26`, + }, + ], + [ + `/`, + { + jsonName: `index`, + internalComponentName: `ComponentIndex`, + path: `/`, + matchPath: undefined, + componentChunkName: `component---src-pages-index-js`, + isCreatedByStatefulCreatePages: true, + context: {}, + updatedAt: 1557740602361, + pluginCreator___NODE: `049c1cfd-95f7-5555-a4ac-9b396d098b26`, + pluginCreatorId: `049c1cfd-95f7-5555-a4ac-9b396d098b26`, + }, + ], + [ + `/404.html`, + { + jsonName: `404-html-516`, + internalComponentName: `Component404Html`, + path: `/404.html`, + matchPath: undefined, + componentChunkName: `component---src-pages-404-js`, + isCreatedByStatefulCreatePages: true, + context: {}, + updatedAt: 1557740602382, + pluginCreator___NODE: `f795702c-a3b8-5a88-88ee-5d06019d44fa`, + pluginCreatorId: `f795702c-a3b8-5a88-88ee-5d06019d44fa`, + }, + ], + ]), + manifest: { + "main.js": `render-page.js`, + "main.js.map": `render-page.js.map`, + app: [ + `webpack-runtime-acaa8994f1f704475e21.js`, + `styles.1025963f4f2ec7abbad4.css`, + `styles-565f081c8374bbda155f.js`, + `app-f33c13590352da20930f.js`, + ], + "component---node-modules-gatsby-plugin-offline-app-shell-js": [ + `component---node-modules-gatsby-plugin-offline-app-shell-js-78f9e4dea04737fa062d.js`, + ], + "component---src-templates-blog-post-js": [ + `0-0180cd94ef2497ac7db8.js`, + `component---src-templates-blog-post-js-517987eae96e75cddbe7.js`, + ], + "component---src-pages-404-js": [ + `0-0180cd94ef2497ac7db8.js`, + `component---src-pages-404-js-53e6c51a5a7e73090f50.js`, + ], + "component---src-pages-index-js": [ + `0-0180cd94ef2497ac7db8.js`, + `component---src-pages-index-js-0bdd01c77ee09ef0224c.js`, + ], + }, + pathPrefix: ``, + publicFolder: (...files) => path.join(tmpDir, ...files), + } + } + + it(`should assemble a redirects file`, async () => { + const pluginData = await createPluginData() + + await createRedirects( + pluginData, + [ + { + fromPath: `/old-url`, + toPath: `/new-url`, + isPermanent: true, + }, + { + fromPath: `/url_that_is/not_pretty`, + toPath: `/pretty/url`, + statusCode: 201, + }, + ], + [ + { + fromPath: `/url_that_is/ugly`, + toPath: `/not_ugly/url`, + }, + { + fromPath: `/path2/*splatparam`, + toPath: `/path2/[...splatparam]/`, + }, + { + fromPath: `/path/*`, + toPath: `/path/[...]/`, + }, + { + fromPath: `/path3/:level1/:level2`, + toPath: `/path3/[level1]/[level2]/`, + }, + { + fromPath: `/path4/:param1`, + toPath: `/path4/[param1]/`, + }, + ] + ) + + const output = await fs.readFile( + pluginData.publicFolder(REDIRECTS_FILENAME), + `utf8` + ) + expect(output).toMatchSnapshot() + }) +}) diff --git a/packages/gatsby-plugin-gatsby-cloud/src/build-headers-program.js b/packages/gatsby-plugin-gatsby-cloud/src/build-headers-program.js new file mode 100644 index 0000000000000..1c7b584e05593 --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/src/build-headers-program.js @@ -0,0 +1,315 @@ +import _ from "lodash" +import { writeFile, existsSync } from "fs-extra" +import { parse, posix } from "path" +import kebabHash from "kebab-hash" +import { IMMUTABLE_CACHING_HEADER } from "./constants" + +import { + COMMON_BUNDLES, + SECURITY_HEADERS, + CACHING_HEADERS, + LINK_REGEX, + HEADERS_FILENAME, + PAGE_DATA_DIR, +} from "./constants" + +function getHeaderName(header) { + const matches = header.match(/^([^:]+):/) + return matches && matches[1] +} + +function validHeaders(headers, reporter) { + if (!headers || !_.isObject(headers)) { + return false + } + + return _.every( + headers, + (headersList, path) => + _.isArray(headersList) && + _.every(headersList, header => { + if (_.isString(header)) { + if (!getHeaderName(header)) { + // TODO panic on builds on v3 + reporter.warn( + `[gatsby-plugin-gatsby-cloud] ${path} contains an invalid header (${header}). Please check your plugin configuration` + ) + } + + return true + } + + return false + }) + ) +} + +function linkTemplate(assetPath, type = `script`) { + return `Link: <${assetPath}>; rel=preload; as=${type}${ + type === `fetch` ? `; crossorigin` : `` + }; nopush` +} + +function pathChunkName(path) { + const name = path === `/` ? `index` : kebabHash(path) + return `path---${name}` +} + +function getPageDataPath(path) { + const fixedPagePath = path === `/` ? `index` : path + return posix.join(`page-data`, fixedPagePath, `page-data.json`) +} + +function getScriptPath(file, manifest) { + const chunk = manifest[file] + + if (!chunk) { + return [] + } + + // convert to array if it's not already + const chunks = _.isArray(chunk) ? chunk : [chunk] + + return chunks.filter(script => { + const parsed = parse(script) + // handle only .js, .css content is inlined already + // and doesn't need to be pushed + return parsed.ext === `.js` + }) +} + +function linkHeaders(files, pathPrefix) { + const linkHeaders = [] + for (const resourceType in files) { + files[resourceType].forEach(file => { + linkHeaders.push(linkTemplate(`${pathPrefix}/${file}`, resourceType)) + }) + } + + return linkHeaders +} + +function headersPath(pathPrefix, path) { + return `${pathPrefix}${path}` +} + +function preloadHeadersByPage({ pages, manifest, pathPrefix, publicFolder }) { + let linksByPage = {} + + const appDataPath = publicFolder(PAGE_DATA_DIR, `app-data.json`) + const hasAppData = existsSync(appDataPath) + + let hasPageData = false + if (pages.size) { + // test if 1 page-data file exists, if it does we know we're on a gatsby version that supports page-data + const pageDataPath = publicFolder( + getPageDataPath(pages.get(pages.keys().next().value).path) + ) + hasPageData = existsSync(pageDataPath) + } + + pages.forEach(page => { + const scripts = _.flatMap(COMMON_BUNDLES, file => + getScriptPath(file, manifest) + ) + scripts.push(...getScriptPath(pathChunkName(page.path), manifest)) + scripts.push(...getScriptPath(page.componentChunkName, manifest)) + + const json = [] + if (hasAppData) { + json.push(posix.join(PAGE_DATA_DIR, `app-data.json`)) + } + + if (hasPageData) { + json.push(getPageDataPath(page.path)) + } + + const filesByResourceType = { + script: scripts.filter(Boolean), + fetch: json, + } + + const pathKey = headersPath(pathPrefix, page.path) + linksByPage[pathKey] = linkHeaders(filesByResourceType, pathPrefix) + }) + + return linksByPage +} + +function defaultMerge(...headers) { + function unionMerge(objValue, srcValue) { + if (_.isArray(objValue)) { + return _.union(objValue, srcValue) + } else { + return undefined // opt into default merge behavior + } + } + + return _.mergeWith({}, ...headers, unionMerge) +} + +function headersMerge(userHeaders, defaultHeaders) { + const merged = {} + Object.keys(defaultHeaders).forEach(path => { + if (!userHeaders[path]) { + merged[path] = defaultHeaders[path] + return + } + const headersMap = {} + defaultHeaders[path].forEach(header => { + headersMap[getHeaderName(header)] = header + }) + userHeaders[path].forEach(header => { + headersMap[getHeaderName(header)] = header // override if exists + }) + merged[path] = Object.values(headersMap) + }) + Object.keys(userHeaders).forEach(path => { + if (!merged[path]) { + merged[path] = userHeaders[path] + } + }) + return merged +} + +function transformLink(manifest, publicFolder, pathPrefix) { + return header => + header.replace(LINK_REGEX, (__, prefix, file, suffix) => { + const hashed = manifest[file] + if (hashed) { + return `${prefix}${pathPrefix}${hashed}${suffix}` + } else if (existsSync(publicFolder(file))) { + return `${prefix}${pathPrefix}${file}${suffix}` + } else { + throw new Error( + `Could not find the file specified in the Link header \`${header}\`.` + + `The gatsby-plugin-gatsby-cloud is looking for a matching file (with or without a ` + + `webpack hash). Check the public folder and your gatsby-config.js to ensure you are ` + + `pointing to a public file.` + ) + } + }) +} + +function stringifyHeaders(headers) { + return _.reduce( + headers, + (text, headerList, path) => { + const headersString = _.reduce( + headerList, + (accum, header) => `${accum} ${header}\n`, + `` + ) + return `${text}${path}\n${headersString}` + }, + `` + ) +} + +// program methods + +const mapUserLinkHeaders = ({ + manifest, + pathPrefix, + publicFolder, +}) => headers => + _.mapValues(headers, headerList => + _.map(headerList, transformLink(manifest, publicFolder, pathPrefix)) + ) + +const mapUserLinkAllPageHeaders = ( + pluginData, + { allPageHeaders } +) => headers => { + if (!allPageHeaders) { + return headers + } + + const { pages, manifest, publicFolder, pathPrefix } = pluginData + + const headersList = _.map( + allPageHeaders, + transformLink(manifest, publicFolder, pathPrefix) + ) + + const duplicateHeadersByPage = {} + pages.forEach(page => { + const pathKey = headersPath(pathPrefix, page.path) + duplicateHeadersByPage[pathKey] = headersList + }) + + return defaultMerge(headers, duplicateHeadersByPage) +} + +const applyLinkHeaders = (pluginData, { mergeLinkHeaders }) => headers => { + if (!mergeLinkHeaders) { + return headers + } + + const { pages, manifest, pathPrefix, publicFolder } = pluginData + const perPageHeaders = preloadHeadersByPage({ + pages, + manifest, + pathPrefix, + publicFolder, + }) + + return defaultMerge(headers, perPageHeaders) +} + +const applySecurityHeaders = ({ mergeSecurityHeaders }) => headers => { + if (!mergeSecurityHeaders) { + return headers + } + + return headersMerge(headers, SECURITY_HEADERS) +} + +const applyCachingHeaders = ( + pluginData, + { mergeCachingHeaders } +) => headers => { + if (!mergeCachingHeaders) { + return headers + } + + const chunks = Array.from(pluginData.pages.values()).map( + page => page.componentChunkName + ) + + chunks.push(`pages-manifest`, `app`) + + const files = [].concat(...chunks.map(chunk => pluginData.manifest[chunk])) + + const cachingHeaders = {} + + files.forEach(file => { + if (typeof file === `string`) { + cachingHeaders[`/` + file] = [IMMUTABLE_CACHING_HEADER] + } + }) + + return defaultMerge(headers, cachingHeaders, CACHING_HEADERS) +} + +const applyTransfromHeaders = ({ transformHeaders }) => headers => + _.mapValues(headers, transformHeaders) + +const writeHeadersFile = ({ publicFolder }) => contents => + writeFile(publicFolder(HEADERS_FILENAME), JSON.stringify(contents)) + +export default function buildHeadersProgram( + pluginData, + pluginOptions, + reporter +) { + return _.flow( + mapUserLinkHeaders(pluginData), + applySecurityHeaders(pluginOptions), + applyCachingHeaders(pluginData, pluginOptions), + mapUserLinkAllPageHeaders(pluginData, pluginOptions), + applyLinkHeaders(pluginData, pluginOptions), + applyTransfromHeaders(pluginOptions), + writeHeadersFile(pluginData) + )(pluginOptions.headers) +} diff --git a/packages/gatsby-plugin-gatsby-cloud/src/constants.js b/packages/gatsby-plugin-gatsby-cloud/src/constants.js new file mode 100644 index 0000000000000..7ab32a3e7745a --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/src/constants.js @@ -0,0 +1,41 @@ +import _ from "lodash" + +// Gatsby values +export const BUILD_HTML_STAGE = `build-html` +export const BUILD_CSS_STAGE = `build-css` + +// Plugin values +export const HEADERS_FILENAME = `_headers.json` +export const REDIRECTS_FILENAME = `_redirects.json` + +export const DEFAULT_OPTIONS = { + headers: {}, + mergeSecurityHeaders: true, + mergeLinkHeaders: true, + mergeCachingHeaders: true, + transformHeaders: _.identity, // optional transform for manipulating headers for sorting, etc + generateMatchPathRewrites: true, // generate rewrites for client only paths +} + +export const SECURITY_HEADERS = { + "/*": [ + `X-Frame-Options: DENY`, + `X-XSS-Protection: 1; mode=block`, + `X-Content-Type-Options: nosniff`, + `Referrer-Policy: same-origin`, + ], +} + +export const IMMUTABLE_CACHING_HEADER = `Cache-Control: public, max-age=31536000, immutable` +export const NEVER_CACHE_HEADER = `Cache-Control: public, max-age=0, must-revalidate` + +export const CACHING_HEADERS = { + "/static/*": [IMMUTABLE_CACHING_HEADER], + "/sw.js": [NEVER_CACHE_HEADER], +} + +export const LINK_REGEX = /^(Link: <\/)(.+)(>;.+)/ + +export const COMMON_BUNDLES = [`commons`, `app`] + +export const PAGE_DATA_DIR = `page-data/` diff --git a/packages/gatsby-plugin-gatsby-cloud/src/create-redirects.js b/packages/gatsby-plugin-gatsby-cloud/src/create-redirects.js new file mode 100644 index 0000000000000..c930dc3408c66 --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/src/create-redirects.js @@ -0,0 +1,17 @@ +import { writeFile } from "fs-extra" +import { REDIRECTS_FILENAME } from "./constants" + +export default async function writeRedirectsFile( + pluginData, + redirects, + rewrites +) { + const { publicFolder } = pluginData + + if (!redirects.length && !rewrites.length) return null + + // Is it ok to pass through the data or should we format it so that we don't have dependencies + // between the redirects and rewrites formats? What are the chances those will change? + const FILE_PATH = publicFolder(REDIRECTS_FILENAME) + return writeFile(FILE_PATH, JSON.stringify({ redirects, rewrites })) +} diff --git a/packages/gatsby-plugin-gatsby-cloud/src/gatsby-node.js b/packages/gatsby-plugin-gatsby-cloud/src/gatsby-node.js new file mode 100644 index 0000000000000..c5a083a7b6dcf --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/src/gatsby-node.js @@ -0,0 +1,92 @@ +import WebpackAssetsManifest from "webpack-assets-manifest" + +import makePluginData from "./plugin-data" +import buildHeadersProgram from "./build-headers-program" +import createRedirects from "./create-redirects" +import { readJSON } from "fs-extra" +import { joinPath } from "gatsby-core-utils" +import { DEFAULT_OPTIONS, BUILD_HTML_STAGE, BUILD_CSS_STAGE } from "./constants" + +let assetsManifest = {} + +// Inject a webpack plugin to get the file manifests so we can translate all link headers +exports.onCreateWebpackConfig = ({ actions, stage }) => { + if (stage !== BUILD_HTML_STAGE && stage !== BUILD_CSS_STAGE) { + return + } + + actions.setWebpackConfig({ + plugins: [ + new WebpackAssetsManifest({ + assets: assetsManifest, // mutates object with entries + merge: true, + }), + ], + }) +} + +exports.onPostBuild = async ( + { store, pathPrefix, reporter }, + userPluginOptions +) => { + const pluginData = makePluginData(store, assetsManifest, pathPrefix) + const pluginOptions = { ...DEFAULT_OPTIONS, ...userPluginOptions } + + const { redirects } = store.getState() + + let rewrites = [] + if (pluginOptions.generateMatchPathRewrites) { + const matchPathsFile = joinPath( + pluginData.program.directory, + `.cache`, + `match-paths.json` + ) + + const matchPaths = await readJSON(matchPathsFile) + + rewrites = matchPaths.map(({ matchPath, path }) => { + return { + fromPath: matchPath, + toPath: path, + } + }) + } + + await Promise.all([ + buildHeadersProgram(pluginData, pluginOptions, reporter), + createRedirects(pluginData, redirects, rewrites), + ]) +} + +const MATCH_ALL_KEYS = /^/ +const pluginOptionsSchema = function ({ Joi }) { + const headersSchema = Joi.object() + .pattern(MATCH_ALL_KEYS, Joi.array().items(Joi.string())) + .description(`Add more headers to specific pages`) + + return Joi.object({ + headers: headersSchema, + allPageHeaders: Joi.array() + .items(Joi.string()) + .description(`Add more headers to all the pages`), + mergeSecurityHeaders: Joi.boolean().description( + `When set to false, turns off the default security headers` + ), + mergeLinkHeaders: Joi.boolean().description( + `When set to false, turns off the default gatsby js headers` + ), + mergeCachingHeaders: Joi.boolean().description( + `When set to false, turns off the default caching headers` + ), + transformHeaders: Joi.function() + .maxArity(2) + .description( + `Transform function for manipulating headers under each path (e.g.sorting), etc. This should return an object of type: { key: Array }` + ), + generateMatchPathRewrites: Joi.boolean().description( + `When set to false, turns off automatic creation of redirect rules for client only paths` + ), + }) +} + +exports.pluginOptionsSchema = pluginOptionsSchema diff --git a/packages/gatsby-plugin-gatsby-cloud/src/plugin-data.js b/packages/gatsby-plugin-gatsby-cloud/src/plugin-data.js new file mode 100644 index 0000000000000..1ffd8c1cf7d8b --- /dev/null +++ b/packages/gatsby-plugin-gatsby-cloud/src/plugin-data.js @@ -0,0 +1,28 @@ +import path from "path" + +export function buildPrefixer(prefix, ...paths) { + return (...subpaths) => path.join(prefix, ...paths, ...subpaths) +} + +// This function assembles data across the manifests and store to match a similar +// shape of `static-entry.js`. With it, we can build headers that point to the correct +// hashed filenames and ensure we pull in the componentChunkName. +export default function makePluginData(store, assetsManifest, pathPrefix) { + const { program, pages: storePages } = store.getState() + const publicFolder = buildPrefixer(program.directory, `public`) + const stats = require(publicFolder(`webpack.stats.json`)) + // Get all the files, not just the first + const chunkManifest = stats.assetsByChunkName + const pages = storePages + + // We combine the manifest of JS and the manifest of assets to make a lookup table. + const manifest = { ...assetsManifest, ...chunkManifest } + + return { + pages, + manifest, + program, + pathPrefix, + publicFolder, + } +}