@netlify/plugin-nextjs 4.0.0-beta.9 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,89 +1,32 @@
1
- const { promises, createWriteStream, existsSync } = require('fs');
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getHandler = void 0;
4
+ const { promises } = require('fs');
2
5
  const { Server } = require('http');
3
- const { tmpdir } = require('os');
4
6
  const path = require('path');
5
- const { promisify } = require('util');
6
- const streamPipeline = promisify(require('stream').pipeline);
7
+ // eslint-disable-next-line node/prefer-global/url, node/prefer-global/url-search-params
8
+ const { URLSearchParams, URL } = require('url');
7
9
  const { Bridge } = require('@vercel/node/dist/bridge');
8
- const fetch = require('node-fetch');
10
+ const { augmentFsModule, getMaxAge, getMultiValueHeaders, getNextServer } = require('./handlerUtils');
9
11
  const makeHandler = () =>
10
12
  // We return a function and then call `toString()` on it to serialise it as the launcher function
11
- (conf, app, pageRoot, staticManifest = []) => {
13
+ // eslint-disable-next-line max-params
14
+ (conf, app, pageRoot, staticManifest = [], mode = 'ssr') => {
12
15
  // This is just so nft knows about the page entrypoints. It's not actually used
13
16
  try {
14
17
  // eslint-disable-next-line node/no-missing-require
15
18
  require.resolve('./pages.js');
16
19
  }
17
20
  catch { }
21
+ // eslint-disable-next-line no-underscore-dangle
22
+ process.env._BYPASS_SSG = 'true';
23
+ const ONE_YEAR_IN_SECONDS = 31536000;
24
+ // We don't want to write ISR files to disk in the lambda environment
25
+ conf.experimental.isrFlushToDisk = false;
18
26
  // Set during the request as it needs the host header. Hoisted so we can define the function once
19
27
  let base;
20
- // Only do this if we have some static files moved to the CDN
21
- if (staticManifest.length !== 0) {
22
- // These are static page files that have been removed from the function bundle
23
- // In most cases these are served from the CDN, but for rewrites Next may try to read them
24
- // from disk. We need to intercept these and load them from the CDN instead
25
- // Sadly the only way to do this is to monkey-patch fs.promises. Yeah, I know.
26
- const staticFiles = new Set(staticManifest);
27
- // Yes, you can cache stuff locally in a Lambda
28
- const cacheDir = path.join(tmpdir(), 'next-static-cache');
29
- // Grab the real fs.promises.readFile...
30
- const readfileOrig = promises.readFile;
31
- // ...then money-patch it to see if it's requesting a CDN file
32
- promises.readFile = async (file, options) => {
33
- // We only care about page files
34
- if (file.startsWith(pageRoot)) {
35
- // We only want the part after `pages/`
36
- const filePath = file.slice(pageRoot.length + 1);
37
- // Is it in the CDN and not local?
38
- if (staticFiles.has(filePath) && !existsSync(file)) {
39
- // This name is safe to use, because it's one that was already created by Next
40
- const cacheFile = path.join(cacheDir, filePath);
41
- // Have we already cached it? We ignore the cache if running locally to avoid staleness
42
- if ((!existsSync(cacheFile) || process.env.NETLIFY_DEV) && base) {
43
- await promises.mkdir(path.dirname(cacheFile), { recursive: true });
44
- // Append the path to our host and we can load it like a regular page
45
- const url = `${base}/${filePath}`;
46
- console.log(`Downloading ${url} to ${cacheFile}`);
47
- const response = await fetch(url);
48
- if (!response.ok) {
49
- // Next catches this and returns it as a not found file
50
- throw new Error(`Failed to fetch ${url}`);
51
- }
52
- // Stream it to disk
53
- await streamPipeline(response.body, createWriteStream(cacheFile));
54
- }
55
- // Return the cache file
56
- return readfileOrig(cacheFile, options);
57
- }
58
- }
59
- return readfileOrig(file, options);
60
- };
61
- }
62
- let NextServer;
63
- try {
64
- // next >= 11.0.1. Yay breaking changes in patch releases!
65
- NextServer = require('next/dist/server/next-server').default;
66
- }
67
- catch (error) {
68
- if (!error.message.includes("Cannot find module 'next/dist/server/next-server'")) {
69
- // A different error, so rethrow it
70
- throw error;
71
- }
72
- // Probably an old version of next
73
- }
74
- if (!NextServer) {
75
- try {
76
- // next < 11.0.1
77
- // eslint-disable-next-line node/no-missing-require, import/no-unresolved
78
- NextServer = require('next/dist/next-server/server/next-server').default;
79
- }
80
- catch (error) {
81
- if (!error.message.includes("Cannot find module 'next/dist/next-server/server/next-server'")) {
82
- throw error;
83
- }
84
- throw new Error('Could not find Next.js server');
85
- }
86
- }
28
+ augmentFsModule({ promises, staticManifest, pageRoot, getBase: () => base });
29
+ const NextServer = getNextServer();
87
30
  const nextServer = new NextServer({
88
31
  conf,
89
32
  dir: path.resolve(__dirname, app),
@@ -102,9 +45,10 @@ const makeHandler = () =>
102
45
  const bridge = new Bridge(server);
103
46
  bridge.listen();
104
47
  return async (event, context) => {
105
- var _a, _b, _c, _d;
48
+ var _a, _b, _c;
49
+ let requestMode = mode;
106
50
  // Ensure that paths are encoded - but don't double-encode them
107
- event.path = new URL(event.path, event.rawUrl).pathname;
51
+ event.path = new URL(event.rawUrl).pathname;
108
52
  // Next expects to be able to parse the query from the URL
109
53
  const query = new URLSearchParams(event.queryStringParameters).toString();
110
54
  event.path = query ? `${event.path}?${query}` : event.path;
@@ -115,25 +59,27 @@ const makeHandler = () =>
115
59
  base = `${protocol}://${host}`;
116
60
  }
117
61
  const { headers, ...result } = await bridge.launcher(event, context);
118
- /** @type import("@netlify/functions").HandlerResponse */
119
62
  // Convert all headers to multiValueHeaders
120
- const multiValueHeaders = {};
121
- for (const key of Object.keys(headers)) {
122
- if (Array.isArray(headers[key])) {
123
- multiValueHeaders[key] = headers[key];
124
- }
125
- else {
126
- multiValueHeaders[key] = [headers[key]];
127
- }
128
- }
63
+ const multiValueHeaders = getMultiValueHeaders(headers);
129
64
  if ((_b = (_a = multiValueHeaders['set-cookie']) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.includes('__prerender_bypass')) {
130
65
  delete multiValueHeaders.etag;
131
66
  multiValueHeaders['cache-control'] = ['no-cache'];
132
67
  }
133
68
  // Sending SWR headers causes undefined behaviour with the Netlify CDN
134
- if ((_d = (_c = multiValueHeaders['cache-control']) === null || _c === void 0 ? void 0 : _c[0]) === null || _d === void 0 ? void 0 : _d.includes('stale-while-revalidate')) {
69
+ const cacheHeader = (_c = multiValueHeaders['cache-control']) === null || _c === void 0 ? void 0 : _c[0];
70
+ if (cacheHeader === null || cacheHeader === void 0 ? void 0 : cacheHeader.includes('stale-while-revalidate')) {
71
+ if (requestMode === 'odb') {
72
+ requestMode = 'isr';
73
+ const ttl = getMaxAge(cacheHeader);
74
+ // Long-expiry TTL is basically no TTL
75
+ if (ttl > 0 && ttl < ONE_YEAR_IN_SECONDS) {
76
+ result.ttl = ttl;
77
+ }
78
+ multiValueHeaders['x-rendered-at'] = [new Date().toISOString()];
79
+ }
135
80
  multiValueHeaders['cache-control'] = ['public, max-age=0, must-revalidate'];
136
81
  }
82
+ multiValueHeaders['x-render-mode'] = [requestMode];
137
83
  return {
138
84
  ...result,
139
85
  multiValueHeaders,
@@ -143,13 +89,10 @@ const makeHandler = () =>
143
89
  };
144
90
  const getHandler = ({ isODB = false, publishDir = '../../../.next', appDir = '../../..' }) => `
145
91
  const { Server } = require("http");
146
- const { tmpdir } = require('os')
147
- const { promises, createWriteStream, existsSync } = require("fs");
148
- const { promisify } = require('util')
149
- const streamPipeline = promisify(require('stream').pipeline)
92
+ const { promises } = require("fs");
150
93
  // We copy the file here rather than requiring from the node module
151
94
  const { Bridge } = require("./bridge");
152
- const fetch = require('node-fetch')
95
+ const { augmentFsModule, getMaxAge, getMultiValueHeaders, getNextServer } = require('./handlerUtils')
153
96
 
154
97
  const { builder } = require("@netlify/functions");
155
98
  const { config } = require("${publishDir}/required-server-files.json")
@@ -160,7 +103,8 @@ try {
160
103
  const path = require("path");
161
104
  const pageRoot = path.resolve(path.join(__dirname, "${publishDir}", config.target === "server" ? "server" : "serverless", "pages"));
162
105
  exports.handler = ${isODB
163
- ? `builder((${makeHandler().toString()})(config, "${appDir}", pageRoot, staticManifest));`
164
- : `(${makeHandler().toString()})(config, "${appDir}", pageRoot, staticManifest);`}
106
+ ? `builder((${makeHandler().toString()})(config, "${appDir}", pageRoot, staticManifest, 'odb'));`
107
+ : `(${makeHandler().toString()})(config, "${appDir}", pageRoot, staticManifest, 'ssr');`}
165
108
  `;
166
- module.exports = getHandler;
109
+ exports.getHandler = getHandler;
110
+ /* eslint-enable @typescript-eslint/no-var-requires */
@@ -1,19 +1,27 @@
1
- const { posix: { resolve, join, relative }, } = require('path');
2
- const { outdent } = require('outdent');
3
- const slash = require('slash');
4
- const glob = require('tiny-glob');
5
- const { HANDLER_FUNCTION_NAME } = require('../constants');
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getPageResolver = void 0;
7
+ const path_1 = require("path");
8
+ const outdent_1 = require("outdent");
9
+ const slash_1 = __importDefault(require("slash"));
10
+ const tiny_glob_1 = __importDefault(require("tiny-glob"));
11
+ const constants_1 = require("../constants");
6
12
  // Generate a file full of require.resolve() calls for all the pages in the
7
13
  // build. This is used by the nft bundler to find all the pages.
8
- exports.getPageResolver = async ({ netlifyConfig, target }) => {
9
- const functionDir = resolve(join('.netlify', 'functions', HANDLER_FUNCTION_NAME));
10
- const root = join(netlifyConfig.build.publish, target === 'server' ? 'server' : 'serverless', 'pages');
11
- const pages = await glob('**/*.js', {
14
+ const getPageResolver = async ({ publish, target }) => {
15
+ const functionDir = path_1.posix.resolve(path_1.posix.join('.netlify', 'functions', constants_1.HANDLER_FUNCTION_NAME));
16
+ const root = path_1.posix.resolve(slash_1.default(publish), target === 'server' ? 'server' : 'serverless', 'pages');
17
+ const pages = await tiny_glob_1.default('**/*.js', {
12
18
  cwd: root,
13
19
  dot: true,
14
20
  });
15
- const pageFiles = pages.map((page) => `require.resolve('${relative(functionDir, join(root, slash(page)))}')`).sort();
16
- return outdent `
21
+ const pageFiles = pages
22
+ .map((page) => `require.resolve('${path_1.posix.relative(functionDir, path_1.posix.join(root, slash_1.default(page)))}')`)
23
+ .sort();
24
+ return outdent_1.outdent `
17
25
  // This file is purely to allow nft to know about these pages. It should be temporary.
18
26
  exports.resolvePages = () => {
19
27
  try {
@@ -22,3 +30,4 @@ exports.getPageResolver = async ({ netlifyConfig, target }) => {
22
30
  }
23
31
  `;
24
32
  };
33
+ exports.getPageResolver = getPageResolver;
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getNextServer = exports.augmentFsModule = exports.getMultiValueHeaders = exports.getMaxAge = exports.downloadFile = void 0;
7
+ const fs_1 = require("fs");
8
+ const http_1 = __importDefault(require("http"));
9
+ const https_1 = __importDefault(require("https"));
10
+ const os_1 = require("os");
11
+ const path_1 = __importDefault(require("path"));
12
+ const stream_1 = require("stream");
13
+ const util_1 = require("util");
14
+ const streamPipeline = util_1.promisify(stream_1.pipeline);
15
+ const downloadFile = async (url, destination) => {
16
+ console.log(`Downloading ${url} to ${destination}`);
17
+ const httpx = url.startsWith('https') ? https_1.default : http_1.default;
18
+ await new Promise((resolve, reject) => {
19
+ const req = httpx.get(url, { timeout: 10000 }, (response) => {
20
+ if (response.statusCode < 200 || response.statusCode > 299) {
21
+ reject(new Error(`Failed to download ${url}: ${response.statusCode} ${response.statusMessage || ''}`));
22
+ return;
23
+ }
24
+ const fileStream = fs_1.createWriteStream(destination);
25
+ streamPipeline(response, fileStream)
26
+ .then(resolve)
27
+ .catch((error) => {
28
+ console.log(`Error downloading ${url}`, error);
29
+ reject(error);
30
+ });
31
+ });
32
+ req.on('error', (error) => {
33
+ console.log(`Error downloading ${url}`, error);
34
+ reject(error);
35
+ });
36
+ });
37
+ };
38
+ exports.downloadFile = downloadFile;
39
+ const getMaxAge = (header) => {
40
+ const parts = header.split(',');
41
+ let maxAge;
42
+ for (const part of parts) {
43
+ const [key, value] = part.split('=');
44
+ if ((key === null || key === void 0 ? void 0 : key.trim()) === 's-maxage') {
45
+ maxAge = value === null || value === void 0 ? void 0 : value.trim();
46
+ }
47
+ }
48
+ if (maxAge) {
49
+ const result = Number.parseInt(maxAge);
50
+ return Number.isNaN(result) ? 0 : result;
51
+ }
52
+ return 0;
53
+ };
54
+ exports.getMaxAge = getMaxAge;
55
+ const getMultiValueHeaders = (headers) => {
56
+ const multiValueHeaders = {};
57
+ for (const key of Object.keys(headers)) {
58
+ const header = headers[key];
59
+ if (Array.isArray(header)) {
60
+ multiValueHeaders[key] = header;
61
+ }
62
+ else {
63
+ multiValueHeaders[key] = [header];
64
+ }
65
+ }
66
+ return multiValueHeaders;
67
+ };
68
+ exports.getMultiValueHeaders = getMultiValueHeaders;
69
+ const augmentFsModule = ({ promises, staticManifest, pageRoot, getBase, }) => {
70
+ // Only do this if we have some static files moved to the CDN
71
+ if (staticManifest.length === 0) {
72
+ return;
73
+ }
74
+ // These are static page files that have been removed from the function bundle
75
+ // In most cases these are served from the CDN, but for rewrites Next may try to read them
76
+ // from disk. We need to intercept these and load them from the CDN instead
77
+ // Sadly the only way to do this is to monkey-patch fs.promises. Yeah, I know.
78
+ const staticFiles = new Map(staticManifest);
79
+ const downloadPromises = new Map();
80
+ // Yes, you can cache stuff locally in a Lambda
81
+ const cacheDir = path_1.default.join(os_1.tmpdir(), 'next-static-cache');
82
+ // Grab the real fs.promises.readFile...
83
+ const readfileOrig = promises.readFile;
84
+ const statsOrig = promises.stat;
85
+ // ...then money-patch it to see if it's requesting a CDN file
86
+ promises.readFile = (async (file, options) => {
87
+ const base = getBase();
88
+ // We only care about page files
89
+ if (file.startsWith(pageRoot)) {
90
+ // We only want the part after `pages/`
91
+ const filePath = file.slice(pageRoot.length + 1);
92
+ // Is it in the CDN and not local?
93
+ if (staticFiles.has(filePath) && !fs_1.existsSync(file)) {
94
+ // This name is safe to use, because it's one that was already created by Next
95
+ const cacheFile = path_1.default.join(cacheDir, filePath);
96
+ const url = `${base}/${staticFiles.get(filePath)}`;
97
+ // If it's already downloading we can wait for it to finish
98
+ if (downloadPromises.has(url)) {
99
+ await downloadPromises.get(url);
100
+ }
101
+ // Have we already cached it? We download every time if running locally to avoid staleness
102
+ if ((!fs_1.existsSync(cacheFile) || process.env.NETLIFY_DEV) && base) {
103
+ await promises.mkdir(path_1.default.dirname(cacheFile), { recursive: true });
104
+ try {
105
+ // Append the path to our host and we can load it like a regular page
106
+ const downloadPromise = exports.downloadFile(url, cacheFile);
107
+ downloadPromises.set(url, downloadPromise);
108
+ await downloadPromise;
109
+ }
110
+ finally {
111
+ downloadPromises.delete(url);
112
+ }
113
+ }
114
+ // Return the cache file
115
+ return readfileOrig(cacheFile, options);
116
+ }
117
+ }
118
+ return readfileOrig(file, options);
119
+ });
120
+ promises.stat = ((file, options) => {
121
+ // We only care about page files
122
+ if (file.startsWith(pageRoot)) {
123
+ // We only want the part after `pages/`
124
+ const cacheFile = path_1.default.join(cacheDir, file.slice(pageRoot.length + 1));
125
+ if (fs_1.existsSync(cacheFile)) {
126
+ return statsOrig(cacheFile, options);
127
+ }
128
+ }
129
+ return statsOrig(file, options);
130
+ });
131
+ };
132
+ exports.augmentFsModule = augmentFsModule;
133
+ const getNextServer = () => {
134
+ let NextServer;
135
+ try {
136
+ // next >= 11.0.1. Yay breaking changes in patch releases!
137
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
138
+ NextServer = require('next/dist/server/next-server').default;
139
+ }
140
+ catch (error) {
141
+ if (!error.message.includes("Cannot find module 'next/dist/server/next-server'")) {
142
+ // A different error, so rethrow it
143
+ throw error;
144
+ }
145
+ // Probably an old version of next
146
+ }
147
+ if (!NextServer) {
148
+ try {
149
+ // next < 11.0.1
150
+ // eslint-disable-next-line node/no-missing-require, import/no-unresolved, @typescript-eslint/no-var-requires
151
+ NextServer = require('next/dist/next-server/server/next-server').default;
152
+ }
153
+ catch (error) {
154
+ if (!error.message.includes("Cannot find module 'next/dist/next-server/server/next-server'")) {
155
+ throw error;
156
+ }
157
+ throw new Error('Could not find Next.js server');
158
+ }
159
+ }
160
+ return NextServer;
161
+ };
162
+ exports.getNextServer = getNextServer;
@@ -1,8 +1,12 @@
1
- const { createIPXHandler } = require('@netlify/ipx');
2
- // Injected at build time
3
- // eslint-disable-next-line import/no-unresolved, node/no-missing-require
4
- const { basePath, domains } = require('./imageconfig.json');
5
- exports.handler = createIPXHandler({
6
- basePath,
7
- domains,
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handler = void 0;
4
+ /* eslint-disable node/no-missing-import, import/no-unresolved, @typescript-eslint/ban-ts-comment */
5
+ const ipx_1 = require("@netlify/ipx");
6
+ // @ts-ignore Injected at build time
7
+ const imageconfig_json_1 = require("./imageconfig.json");
8
+ exports.handler = ipx_1.createIPXHandler({
9
+ basePath: imageconfig_json_1.basePath,
10
+ domains: imageconfig_json_1.domains,
8
11
  });
12
+ /* eslint-enable node/no-missing-import, import/no-unresolved, @typescript-eslint/ban-ts-comment */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netlify/plugin-nextjs",
3
- "version": "4.0.0-beta.9",
3
+ "version": "4.0.0",
4
4
  "description": "Run Next.js seamlessly on Netlify",
5
5
  "main": "lib/index.js",
6
6
  "files": [
@@ -8,10 +8,9 @@
8
8
  "manifest.yml"
9
9
  ],
10
10
  "scripts": {
11
- "build:demo": "next build demo",
11
+ "build:demo": "next build demos/default",
12
12
  "cy:open": "cypress open --config-file cypress/config/all.json",
13
- "cy:run": "cypress run --config-file ../cypress/config/ci.json",
14
- "dev:demo": "next dev demo",
13
+ "dev:demo": "next dev demos/default",
15
14
  "format": "run-s format:check-fix:*",
16
15
  "format:ci": "run-s format:check:*",
17
16
  "format:check-fix:lint": "run-e format:check:lint format:fix:lint",
@@ -26,14 +25,16 @@
26
25
  "publish:test": "npm test",
27
26
  "test": "run-s build build:demo test:jest",
28
27
  "test:jest": "jest",
28
+ "test:jest:update": "jest --updateSnapshot",
29
+ "test:update": "run-s build build:demo test:jest:update",
29
30
  "prepare": "npm run build",
30
31
  "clean": "rimraf lib",
31
32
  "build": "tsc",
32
33
  "watch": "tsc --watch"
33
34
  },
34
35
  "config": {
35
- "eslint": "--cache --format=codeframe --max-warnings=0 \"{src,scripts,tests,.github}/**/*.{js,md,html}\" \"*.{js,md,html}\" \".*.{js,md,html}\"",
36
- "prettier": "--loglevel=warn \"{src,scripts,tests,.github}/**/*.{js,md,yml,json,html}\" \"*.{js,yml,json,html}\" \".*.{js,yml,json,html}\" \"!package-lock.json\""
36
+ "eslint": "--cache --format=codeframe --max-warnings=0 \"{src,scripts,tests,.github}/**/*.{ts,js,md,html}\" \"*.{ts,js,md,html}\" \".*.{ts,js,md,html}\"",
37
+ "prettier": "--loglevel=warn \"{src,scripts,tests,.github}/**/*.{ts,js,md,yml,json,html}\" \"*.{ts,js,yml,json,html}\" \".*.{ts,js,yml,json,html}\" \"!package-lock.json\""
37
38
  },
38
39
  "repository": {
39
40
  "type": "git",
@@ -52,8 +53,8 @@
52
53
  },
53
54
  "homepage": "https://github.com/netlify/netlify-plugin-nextjs#readme",
54
55
  "dependencies": {
55
- "@netlify/functions": "^0.9.0",
56
- "@netlify/ipx": "^0.0.7",
56
+ "@netlify/functions": "^0.10.0",
57
+ "@netlify/ipx": "^0.0.8",
57
58
  "@vercel/node": "^1.11.2-canary.4",
58
59
  "chalk": "^4.1.2",
59
60
  "fs-extra": "^10.0.0",
@@ -72,16 +73,17 @@
72
73
  "devDependencies": {
73
74
  "@babel/core": "^7.15.8",
74
75
  "@babel/preset-env": "^7.15.8",
75
- "@netlify/build": "^18.25.1",
76
- "@netlify/eslint-config-node": "^3.3.7",
76
+ "@babel/preset-typescript": "^7.16.0",
77
+ "@netlify/build": "^20.3.0",
78
+ "@netlify/eslint-config-node": "^4.0.0",
77
79
  "@testing-library/cypress": "^8.0.1",
78
80
  "@types/fs-extra": "^9.0.13",
79
81
  "@types/jest": "^27.0.2",
80
82
  "@types/mocha": "^9.0.0",
81
83
  "babel-jest": "^27.2.5",
82
84
  "cpy": "^8.1.2",
83
- "cypress": "^8.5.0",
84
- "eslint-config-next": "^11.0.0",
85
+ "cypress": "^9.0.0",
86
+ "eslint-config-next": "^12.0.0",
85
87
  "husky": "^4.3.0",
86
88
  "jest": "^27.0.0",
87
89
  "netlify-plugin-cypress": "^2.2.0",