From 53abeceffb161f2018472cb0e3b82c0ef6438561 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 22 Nov 2021 13:52:37 +0000 Subject: [PATCH 01/10] fix: correct redirect sorting --- src/helpers/config.js | 44 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/helpers/config.js b/src/helpers/config.js index f9246100b0..e97cbd7156 100644 --- a/src/helpers/config.js +++ b/src/helpers/config.js @@ -52,9 +52,9 @@ const getNetlifyRoutes = (nextRoute) => { } exports.generateRedirects = async ({ netlifyConfig, basePath, i18n }) => { - const { dynamicRoutes } = await readJSON(join(netlifyConfig.build.publish, 'prerender-manifest.json')) - - const redirects = [] + const { dynamicRoutes, routes: staticRoutes } = await readJSON( + join(netlifyConfig.build.publish, 'prerender-manifest.json'), + ) netlifyConfig.redirects.push( ...HIDDEN_PATHS.map((path) => ({ @@ -65,15 +65,36 @@ exports.generateRedirects = async ({ netlifyConfig, basePath, i18n }) => { })), ) + const dataRedirects = [] + const pageRedirects = [] + const isrRedirects = [] + let hasIsr = false + const dynamicRouteEntries = Object.entries(dynamicRoutes) - dynamicRouteEntries.sort((a, b) => a[0].localeCompare(b[0])) + + if (!process.env.EXPERIMENTAL_SWR_ODB) { + const staticRouteEntries = Object.entries(staticRoutes) + + staticRouteEntries.forEach(([route, { dataRoute, initialRevalidateSeconds }]) => { + // With revalidate we need to rewrite to SSR rather than ODB + if (initialRevalidateSeconds === false) { + return + } + if (i18n.defaultLocale && route.startsWith(`/${i18n.defaultLocale}/`)) { + route = route.slice(i18n.defaultLocale.length + 1) + } + hasIsr = true + isrRedirects.push(...getNetlifyRoutes(dataRoute), ...getNetlifyRoutes(route)) + }) + } dynamicRouteEntries.forEach(([route, { dataRoute, fallback }]) => { // Add redirects if fallback is "null" (aka blocking) or true/a string if (fallback === false) { return } - redirects.push(...getNetlifyRoutes(route), ...getNetlifyRoutes(dataRoute)) + pageRedirects.push(...getNetlifyRoutes(route)) + dataRedirects.push(...getNetlifyRoutes(dataRoute)) }) if (i18n) { @@ -90,7 +111,18 @@ exports.generateRedirects = async ({ netlifyConfig, basePath, i18n }) => { conditions: { Cookie: ['__prerender_bypass', '__next_preview_data'] }, force: true, }, - ...redirects.map((redirect) => ({ + ...isrRedirects.map((redirect) => ({ + from: `${basePath}${redirect}`, + to: HANDLER_FUNCTION_PATH, + status: 200, + force: true, + })), + ...dataRedirects.map((redirect) => ({ + from: `${basePath}${redirect}`, + to: ODB_FUNCTION_PATH, + status: 200, + })), + ...pageRedirects.map((redirect) => ({ from: `${basePath}${redirect}`, to: ODB_FUNCTION_PATH, status: 200, From b6856e96be541aa37f19c2cf57b4825d429c1632 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 22 Nov 2021 13:53:02 +0000 Subject: [PATCH 02/10] fix: don't move isr files --- src/helpers/files.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/helpers/files.js b/src/helpers/files.js index 729b225cc5..003adbd4f1 100644 --- a/src/helpers/files.js +++ b/src/helpers/files.js @@ -47,6 +47,18 @@ exports.moveStaticPages = async ({ netlifyConfig, target, i18n }) => { } } + const prerenderManifest = await readJson(join(netlifyConfig.build.publish, 'prerender-manifest.json')) + + const isrFiles = new Set() + + Object.entries(prerenderManifest.routes).forEach(([route, { initialRevalidateSeconds }]) => { + if (initialRevalidateSeconds) { + const trimmedPath = route.slice(1) + isrFiles.add(`${trimmedPath}.html`) + isrFiles.add(`${trimmedPath}.json`) + } + }) + const files = [] const moveFile = async (file) => { const source = join(root, file) @@ -67,6 +79,9 @@ exports.moveStaticPages = async ({ netlifyConfig, target, i18n }) => { const limit = pLimit(Math.max(2, cpus().length)) const promises = pages.map(async (rawPath) => { const filePath = slash(rawPath) + if (isrFiles.has(filePath)) { + return + } if (isDynamicRoute(filePath)) { return } From 15daf873b62fd519c6c4403d8130471a8bfeebdf Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 22 Nov 2021 13:54:02 +0000 Subject: [PATCH 03/10] chore: demo fixes --- .../getStaticProps/withRevalidate/[id].js | 8 +- demo/pages/index.js | 82 ++++++------------- 2 files changed, 31 insertions(+), 59 deletions(-) diff --git a/demo/pages/getStaticProps/withRevalidate/[id].js b/demo/pages/getStaticProps/withRevalidate/[id].js index 7bf51a7933..b22c45dba8 100644 --- a/demo/pages/getStaticProps/withRevalidate/[id].js +++ b/demo/pages/getStaticProps/withRevalidate/[id].js @@ -1,6 +1,6 @@ import Link from 'next/link' -const Show = ({ show }) => ( +const Show = ({ show, time }) => (

This page uses getStaticProps() to pre-fetch a TV show.

@@ -8,7 +8,7 @@ const Show = ({ show }) => (

Show #{show.id}

{show.name}

- +

Rendered at {time}


@@ -32,10 +32,12 @@ export async function getStaticProps({ params }) { const res = await fetch(`https://api.tvmaze.com/shows/${id}`) const data = await res.json() - + const time = new Date().toLocaleTimeString() + await new Promise((resolve) => setTimeout(resolve, 10000)) return { props: { show: data, + time, }, revalidate: 1, } diff --git a/demo/pages/index.js b/demo/pages/index.js index d7e4b6ac2c..dbcaf0cb91 100644 --- a/demo/pages/index.js +++ b/demo/pages/index.js @@ -4,29 +4,18 @@ const Header = dynamic(() => import(/* webpackChunkName: 'header' */ '../compone import { useRouter } from 'next/router' const Index = ({ shows }) => { - const { locale } = useRouter(); + const { locale } = useRouter() return (
NextJS on Netlify Banner - -
+ +

NextJS on Netlify

-

- This is a demo of a NextJS application with Server-Side Rendering (SSR). -
- It is hosted on Netlify. -
- Server-side rendering is handled by Netlify Functions. -
- Minimal configuration is required. -
- Everything is handled by the next-on-netlify npm - package. -

+

This is a demo of a NextJS application with Server-Side Rendering (SSR).

-

1. Server-Side Rendering Made Easy

+

Server-Side Rendering

This page is server-side rendered.
@@ -38,7 +27,7 @@ const Index = ({ shows }) => {

    {shows.map(({ id, name }) => (
  • - + #{id}: {name} @@ -47,17 +36,13 @@ const Index = ({ shows }) => { ))}
-

2. Full Support for Dynamic Pages

-

- Dynamic pages, introduced in NextJS 9.2, are fully supported. -
- Click on a show to check out a server-side rendered page with dynamic routing (/shows/:id). -

+

Dynamic Pages

+

Click on a show to check out a server-side rendered page with dynamic routing (/shows/:id).

    {shows.slice(0, 3).map(({ id, name }) => (
  • - + #{id}: {name} @@ -66,40 +51,27 @@ const Index = ({ shows }) => { ))}
-

3. Catch-All Routes? Included ✔

-

- You can even take advantage of{' '} - NextJS catch-all routes feature - . -
- Here are three examples: -

+

Catch-All Routess

+ -

4. Static Pages Stay Static

-

- next-on-netlify automatically determines which pages are dynamic and which ones are static. -
- Only dynamic pages are server-side rendered. -
- Static pages are pre-rendered and served directly by Netlify's CDN. -

+

Static Pages

-

5. Localization As Expected

+

Localization

- Localization (i18n) is supported! This demo uses fr with en as the default locale (at /). + Localization (i18n) is supported! This demo uses fr with en as the default locale (at{' '} + /).

The current locale is {locale}

Click on the links below to see the above text change

- -

Want to Learn More?

-

- Check out the source code on GitHub. -

) } Index.getInitialProps = async function () { - const dev = process.env.CONTEXT !== 'production'; + const dev = process.env.CONTEXT !== 'production' // Set a random page between 1 and 100 const randomPage = Math.floor(Math.random() * 100) + 1 // FIXME: stub out in dev - const server = dev ? `https://api.tvmaze.com/shows?page=${randomPage}` : `https://api.tvmaze.com/shows?page=${randomPage}`; + const server = dev + ? `https://api.tvmaze.com/shows?page=${randomPage}` + : `https://api.tvmaze.com/shows?page=${randomPage}` // Get the data - const res = await fetch(server); + const res = await fetch(server) const data = await res.json() return { shows: data.slice(0, 5) } From 8361468d2fb6cfaaac6118e2466d7ca0d3ae4fc0 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 22 Nov 2021 13:54:31 +0000 Subject: [PATCH 04/10] fix: patch fs write methods --- src/templates/getHandler.js | 46 +++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/templates/getHandler.js b/src/templates/getHandler.js index dc9ea461bb..f4e4b5e512 100644 --- a/src/templates/getHandler.js +++ b/src/templates/getHandler.js @@ -1,3 +1,4 @@ +/* eslint-disable max-lines-per-function, max-lines */ const { promises, createWriteStream, existsSync } = require('fs') const { Server } = require('http') const { tmpdir } = require('os') @@ -11,7 +12,8 @@ const fetch = require('node-fetch') const makeHandler = () => // We return a function and then call `toString()` on it to serialise it as the launcher function - (conf, app, pageRoot, staticManifest = []) => { + // eslint-disable-next-line max-params + (conf, app, pageRoot, staticManifest = [], mode = 'ssr') => { // This is just so nft knows about the page entrypoints. It's not actually used try { // eslint-disable-next-line node/no-missing-require @@ -33,16 +35,24 @@ const makeHandler = const cacheDir = path.join(tmpdir(), 'next-static-cache') // Grab the real fs.promises.readFile... const readfileOrig = promises.readFile + const writeFileOrig = promises.writeFile + const mkdirOrig = promises.mkdir // ...then money-patch it to see if it's requesting a CDN file promises.readFile = async (file, options) => { // We only care about page files if (file.startsWith(pageRoot)) { // We only want the part after `pages/` const filePath = file.slice(pageRoot.length + 1) + const cacheFile = path.join(cacheDir, filePath) + + if (existsSync(cacheFile)) { + console.log('returning from cache', cacheFile) + return readfileOrig(cacheFile, options) + } + // Is it in the CDN and not local? if (staticFiles.has(filePath) && !existsSync(file)) { // This name is safe to use, because it's one that was already created by Next - const cacheFile = path.join(cacheDir, filePath) // Have we already cached it? We ignore the cache if running locally to avoid staleness if ((!existsSync(cacheFile) || process.env.NETLIFY_DEV) && base) { await promises.mkdir(path.dirname(cacheFile), { recursive: true }) @@ -65,6 +75,29 @@ const makeHandler = return readfileOrig(file, options) } + + promises.writeFile = async (file, data, options) => { + if (file.startsWith(pageRoot)) { + const filePath = file.slice(pageRoot.length + 1) + const cacheFile = path.join(cacheDir, filePath) + console.log('writing', cacheFile) + await promises.mkdir(path.dirname(cacheFile), { recursive: true }) + return writeFileOrig(cacheFile, data, options) + } + + return writeFileOrig(file, data, options) + } + + promises.mkdir = async (dir, options) => { + if (dir.startsWith(pageRoot)) { + const filePath = dir.slice(pageRoot.length + 1) + const cachePath = path.join(cacheDir, filePath) + console.log('creating', cachePath) + return mkdirOrig(cachePath, options) + } + + return mkdirOrig(dir, options) + } } let NextServer try { @@ -139,9 +172,11 @@ const makeHandler = } // Sending SWR headers causes undefined behaviour with the Netlify CDN - if (multiValueHeaders['cache-control']?.[0]?.includes('stale-while-revalidate')) { + const cacheHeader = multiValueHeaders['cache-control']?.[0] + if (cacheHeader?.includes('stale-while-revalidate')) { multiValueHeaders['cache-control'] = ['public, max-age=0, must-revalidate'] } + multiValueHeaders['x-render-mode'] = [mode] return { ...result, @@ -171,9 +206,10 @@ const path = require("path"); const pageRoot = path.resolve(path.join(__dirname, "${publishDir}", config.target === "server" ? "server" : "serverless", "pages")); exports.handler = ${ isODB - ? `builder((${makeHandler().toString()})(config, "${appDir}", pageRoot, staticManifest));` - : `(${makeHandler().toString()})(config, "${appDir}", pageRoot, staticManifest);` + ? `builder((${makeHandler().toString()})(config, "${appDir}", pageRoot, staticManifest, 'odb'));` + : `(${makeHandler().toString()})(config, "${appDir}", pageRoot, staticManifest, 'ssr');` } ` module.exports = getHandler +/* eslint-enable max-lines-per-function, max-lines */ From 739e51356983e0c9c8bb43a27ae223018a73c947 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 22 Nov 2021 15:39:41 +0000 Subject: [PATCH 05/10] fix: update tests --- demo/next.config.js | 11 +- src/templates/getHandler.js | 4 +- test/__snapshots__/index.js.snap | 200 +++++++++++++++++++++---------- test/index.js | 6 +- 4 files changed, 146 insertions(+), 75 deletions(-) diff --git a/demo/next.config.js b/demo/next.config.js index 93c2cbab23..afdd162d01 100644 --- a/demo/next.config.js +++ b/demo/next.config.js @@ -1,10 +1,13 @@ module.exports = { // Configurable site features we support: // distDir: 'build', + experimental: { + isrFlushToDisk: false, + }, generateBuildId: () => 'build-id', i18n: { defaultLocale: 'en', - locales: ['en', 'es', 'fr'] + locales: ['en', 'es', 'fr'], }, async headers() { return [ @@ -14,7 +17,7 @@ module.exports = { { key: 'x-custom-header', value: 'my custom header value', - } + }, ], }, ] @@ -29,8 +32,8 @@ module.exports = { { source: '/old/:path*', destination: '/:path*', - } - ] + }, + ], } }, // Redirects allow you to redirect an incoming request path to a different destination path. diff --git a/src/templates/getHandler.js b/src/templates/getHandler.js index f4e4b5e512..a2fb22f4cf 100644 --- a/src/templates/getHandler.js +++ b/src/templates/getHandler.js @@ -46,7 +46,6 @@ const makeHandler = const cacheFile = path.join(cacheDir, filePath) if (existsSync(cacheFile)) { - console.log('returning from cache', cacheFile) return readfileOrig(cacheFile, options) } @@ -80,7 +79,6 @@ const makeHandler = if (file.startsWith(pageRoot)) { const filePath = file.slice(pageRoot.length + 1) const cacheFile = path.join(cacheDir, filePath) - console.log('writing', cacheFile) await promises.mkdir(path.dirname(cacheFile), { recursive: true }) return writeFileOrig(cacheFile, data, options) } @@ -92,7 +90,6 @@ const makeHandler = if (dir.startsWith(pageRoot)) { const filePath = dir.slice(pageRoot.length + 1) const cachePath = path.join(cacheDir, filePath) - console.log('creating', cachePath) return mkdirOrig(cachePath, options) } @@ -174,6 +171,7 @@ const makeHandler = // Sending SWR headers causes undefined behaviour with the Netlify CDN const cacheHeader = multiValueHeaders['cache-control']?.[0] if (cacheHeader?.includes('stale-while-revalidate')) { + console.log({ cacheHeader }) multiValueHeaders['cache-control'] = ['public, max-age=0, must-revalidate'] } multiValueHeaders['x-render-mode'] = [mode] diff --git a/test/__snapshots__/index.js.snap b/test/__snapshots__/index.js.snap index c933573901..a1e2a6ca7b 100644 --- a/test/__snapshots__/index.js.snap +++ b/test/__snapshots__/index.js.snap @@ -76,8 +76,6 @@ Array [ "en/getStaticProps/2.json", "en/getStaticProps/static.html", "en/getStaticProps/static.json", - "en/getStaticProps/with-revalidate.html", - "en/getStaticProps/with-revalidate.json", "en/getStaticProps/withFallback/3.html", "en/getStaticProps/withFallback/3.json", "en/getStaticProps/withFallback/4.html", @@ -90,30 +88,18 @@ Array [ "en/getStaticProps/withFallbackBlocking/3.json", "en/getStaticProps/withFallbackBlocking/4.html", "en/getStaticProps/withFallbackBlocking/4.json", - "en/getStaticProps/withRevalidate/1.html", - "en/getStaticProps/withRevalidate/1.json", - "en/getStaticProps/withRevalidate/2.html", - "en/getStaticProps/withRevalidate/2.json", - "en/getStaticProps/withRevalidate/withFallback/1.html", - "en/getStaticProps/withRevalidate/withFallback/1.json", - "en/getStaticProps/withRevalidate/withFallback/2.html", - "en/getStaticProps/withRevalidate/withFallback/2.json", "en/image.html", "en/previewTest.html", "en/previewTest.json", "en/static.html", "es/getStaticProps/static.html", "es/getStaticProps/static.json", - "es/getStaticProps/with-revalidate.html", - "es/getStaticProps/with-revalidate.json", "es/image.html", "es/previewTest.html", "es/previewTest.json", "es/static.html", "fr/getStaticProps/static.html", "fr/getStaticProps/static.json", - "fr/getStaticProps/with-revalidate.html", - "fr/getStaticProps/with-revalidate.json", "fr/image.html", "fr/previewTest.html", "fr/previewTest.json", @@ -123,16 +109,6 @@ Array [ exports[`onBuild() writes correct redirects to netlifyConfig 1`] = ` Array [ - Object { - "from": "/_next/image/*", - "query": Object { - "q": ":quality", - "url": ":url", - "w": ":width", - }, - "status": 301, - "to": "/_ipx/w_:width,q_:quality/:url", - }, Object { "from": "/_ipx/*", "status": 200, @@ -140,68 +116,86 @@ Array [ }, Object { "force": true, - "from": "/cache/*", - "status": 404, - "to": "/404.html", + "from": "/_next/data/build-id/en/getStaticProps/withRevalidate/1.json", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", }, Object { "force": true, - "from": "/server/*", - "status": 404, - "to": "/404.html", + "from": "/_next/data/build-id/en/getStaticProps/withRevalidate/2.json", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", }, Object { "force": true, - "from": "/serverless/*", - "status": 404, - "to": "/404.html", + "from": "/_next/data/build-id/en/getStaticProps/withRevalidate/withFallback/1.json", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", }, Object { "force": true, - "from": "/traces", - "status": 404, - "to": "/404.html", + "from": "/_next/data/build-id/en/getStaticProps/withRevalidate/withFallback/2.json", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", }, Object { "force": true, - "from": "/routes-manifest.json", - "status": 404, - "to": "/404.html", + "from": "/_next/data/build-id/getStaticProps/with-revalidate.json", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", }, Object { "force": true, - "from": "/build-manifest.json", - "status": 404, - "to": "/404.html", + "from": "/_next/data/build-id/getStaticProps/with-revalidate.json", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", }, Object { "force": true, - "from": "/prerender-manifest.json", - "status": 404, - "to": "/404.html", + "from": "/_next/data/build-id/getStaticProps/with-revalidate.json", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", }, Object { - "force": true, - "from": "/react-loadable-manifest.json", - "status": 404, - "to": "/404.html", + "from": "/_next/data/build-id/getStaticProps/withFallback/:id.json", + "status": 200, + "to": "/.netlify/builders/___netlify-odb-handler", }, Object { - "force": true, - "from": "/BUILD_ID", - "status": 404, - "to": "/404.html", + "from": "/_next/data/build-id/getStaticProps/withFallback/:slug/*", + "status": 200, + "to": "/.netlify/builders/___netlify-odb-handler", }, Object { - "from": "/:locale/_next/static/*", + "from": "/_next/data/build-id/getStaticProps/withFallbackBlocking/:id.json", "status": 200, - "to": "/static/:splat", + "to": "/.netlify/builders/___netlify-odb-handler", + }, + Object { + "from": "/_next/data/build-id/getStaticProps/withRevalidate/withFallback/:id.json", + "status": 200, + "to": "/.netlify/builders/___netlify-odb-handler", + }, + Object { + "from": "/_next/image/*", + "query": Object { + "q": ":quality", + "url": ":url", + "w": ":width", + }, + "status": 301, + "to": "/_ipx/w_:width,q_:quality/:url", }, Object { "from": "/_next/static/*", "status": 200, "to": "/static/:splat", }, + Object { + "from": "/:locale/_next/static/*", + "status": 200, + "to": "/static/:splat", + }, Object { "conditions": Object { "Cookie": Array [ @@ -215,14 +209,45 @@ Array [ "to": "/.netlify/functions/___netlify-handler", }, Object { - "from": "/getStaticProps/withFallback/:slug/*", + "from": "/*", "status": 200, - "to": "/.netlify/builders/___netlify-odb-handler", + "to": "/.netlify/functions/___netlify-handler", }, Object { - "from": "/_next/data/build-id/getStaticProps/withFallback/:slug/*", + "force": true, + "from": "/BUILD_ID", + "status": 404, + "to": "/404.html", + }, + Object { + "force": true, + "from": "/build-manifest.json", + "status": 404, + "to": "/404.html", + }, + Object { + "force": true, + "from": "/cache/*", + "status": 404, + "to": "/404.html", + }, + Object { + "force": true, + "from": "/es/getStaticProps/with-revalidate", "status": 200, - "to": "/.netlify/builders/___netlify-odb-handler", + "to": "/.netlify/functions/___netlify-handler", + }, + Object { + "force": true, + "from": "/fr/getStaticProps/with-revalidate", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", + }, + Object { + "force": true, + "from": "/getStaticProps/with-revalidate", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", }, Object { "from": "/getStaticProps/withFallback/:id", @@ -230,7 +255,7 @@ Array [ "to": "/.netlify/builders/___netlify-odb-handler", }, Object { - "from": "/_next/data/build-id/getStaticProps/withFallback/:id.json", + "from": "/getStaticProps/withFallback/:slug/*", "status": 200, "to": "/.netlify/builders/___netlify-odb-handler", }, @@ -240,9 +265,16 @@ Array [ "to": "/.netlify/builders/___netlify-odb-handler", }, Object { - "from": "/_next/data/build-id/getStaticProps/withFallbackBlocking/:id.json", + "force": true, + "from": "/getStaticProps/withRevalidate/1", "status": 200, - "to": "/.netlify/builders/___netlify-odb-handler", + "to": "/.netlify/functions/___netlify-handler", + }, + Object { + "force": true, + "from": "/getStaticProps/withRevalidate/2", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", }, Object { "from": "/getStaticProps/withRevalidate/withFallback/:id", @@ -250,14 +282,52 @@ Array [ "to": "/.netlify/builders/___netlify-odb-handler", }, Object { - "from": "/_next/data/build-id/getStaticProps/withRevalidate/withFallback/:id.json", + "force": true, + "from": "/getStaticProps/withRevalidate/withFallback/1", "status": 200, - "to": "/.netlify/builders/___netlify-odb-handler", + "to": "/.netlify/functions/___netlify-handler", }, Object { - "from": "/*", + "force": true, + "from": "/getStaticProps/withRevalidate/withFallback/2", "status": 200, "to": "/.netlify/functions/___netlify-handler", }, + Object { + "force": true, + "from": "/prerender-manifest.json", + "status": 404, + "to": "/404.html", + }, + Object { + "force": true, + "from": "/react-loadable-manifest.json", + "status": 404, + "to": "/404.html", + }, + Object { + "force": true, + "from": "/routes-manifest.json", + "status": 404, + "to": "/404.html", + }, + Object { + "force": true, + "from": "/server/*", + "status": 404, + "to": "/404.html", + }, + Object { + "force": true, + "from": "/serverless/*", + "status": 404, + "to": "/404.html", + }, + Object { + "force": true, + "from": "/traces", + "status": 404, + "to": "/404.html", + }, ] `; diff --git a/test/index.js b/test/index.js index cb6a16f03a..e8cfcb0532 100644 --- a/test/index.js +++ b/test/index.js @@ -195,7 +195,7 @@ describe('onBuild()', () => { await plugin.onBuild(defaultArgs) - expect(netlifyConfig.redirects).toMatchSnapshot() + expect([...netlifyConfig.redirects].sort((a, b) => a.from.localeCompare(b.from))).toMatchSnapshot() }) test('publish dir is/has next dist', async () => { @@ -302,8 +302,8 @@ describe('onBuild()', () => { expect(existsSync(handlerFile)).toBeTruthy() expect(existsSync(odbHandlerFile)).toBeTruthy() - expect(readFileSync(handlerFile, 'utf8')).toMatch(`(config, "../../..", pageRoot, staticManifest)`) - expect(readFileSync(odbHandlerFile, 'utf8')).toMatch(`(config, "../../..", pageRoot, staticManifest)`) + expect(readFileSync(handlerFile, 'utf8')).toMatch(`(config, "../../..", pageRoot, staticManifest, 'ssr')`) + expect(readFileSync(odbHandlerFile, 'utf8')).toMatch(`(config, "../../..", pageRoot, staticManifest, 'odb')`) expect(readFileSync(handlerFile, 'utf8')).toMatch(`require("../../../.next/required-server-files.json")`) expect(readFileSync(odbHandlerFile, 'utf8')).toMatch(`require("../../../.next/required-server-files.json")`) }) From 273b021891a656e2c30a9e85776ab85d95293dbd Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 22 Nov 2021 16:47:38 +0000 Subject: [PATCH 06/10] fix: handle error and remove patch --- src/helpers/files.js | 6 +++++- src/templates/getHandler.js | 29 ----------------------------- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/src/helpers/files.js b/src/helpers/files.js index 003adbd4f1..49c7f310b9 100644 --- a/src/helpers/files.js +++ b/src/helpers/files.js @@ -64,7 +64,11 @@ exports.moveStaticPages = async ({ netlifyConfig, target, i18n }) => { const source = join(root, file) files.push(file) const dest = join(netlifyConfig.build.publish, file) - await move(source, dest) + try { + await move(source, dest) + } catch (error) { + console.warn('Error moving file', source, error) + } } // Move all static files, except error documents and nft manifests const pages = await globby(['**/*.{html,json}', '!**/(500|404|*.js.nft).{html,json}'], { diff --git a/src/templates/getHandler.js b/src/templates/getHandler.js index a2fb22f4cf..0c84a561ae 100644 --- a/src/templates/getHandler.js +++ b/src/templates/getHandler.js @@ -1,4 +1,3 @@ -/* eslint-disable max-lines-per-function, max-lines */ const { promises, createWriteStream, existsSync } = require('fs') const { Server } = require('http') const { tmpdir } = require('os') @@ -35,8 +34,6 @@ const makeHandler = const cacheDir = path.join(tmpdir(), 'next-static-cache') // Grab the real fs.promises.readFile... const readfileOrig = promises.readFile - const writeFileOrig = promises.writeFile - const mkdirOrig = promises.mkdir // ...then money-patch it to see if it's requesting a CDN file promises.readFile = async (file, options) => { // We only care about page files @@ -45,10 +42,6 @@ const makeHandler = const filePath = file.slice(pageRoot.length + 1) const cacheFile = path.join(cacheDir, filePath) - if (existsSync(cacheFile)) { - return readfileOrig(cacheFile, options) - } - // Is it in the CDN and not local? if (staticFiles.has(filePath) && !existsSync(file)) { // This name is safe to use, because it's one that was already created by Next @@ -74,27 +67,6 @@ const makeHandler = return readfileOrig(file, options) } - - promises.writeFile = async (file, data, options) => { - if (file.startsWith(pageRoot)) { - const filePath = file.slice(pageRoot.length + 1) - const cacheFile = path.join(cacheDir, filePath) - await promises.mkdir(path.dirname(cacheFile), { recursive: true }) - return writeFileOrig(cacheFile, data, options) - } - - return writeFileOrig(file, data, options) - } - - promises.mkdir = async (dir, options) => { - if (dir.startsWith(pageRoot)) { - const filePath = dir.slice(pageRoot.length + 1) - const cachePath = path.join(cacheDir, filePath) - return mkdirOrig(cachePath, options) - } - - return mkdirOrig(dir, options) - } } let NextServer try { @@ -210,4 +182,3 @@ exports.handler = ${ ` module.exports = getHandler -/* eslint-enable max-lines-per-function, max-lines */ From 7595f7ea4347afcdb10301bb33b5af9488f4c842 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 22 Nov 2021 16:58:07 +0000 Subject: [PATCH 07/10] fix: disable isr disk cache --- demo/next.config.js | 4 +--- src/templates/getHandler.js | 3 +++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/demo/next.config.js b/demo/next.config.js index afdd162d01..85bdcb3aba 100644 --- a/demo/next.config.js +++ b/demo/next.config.js @@ -1,9 +1,7 @@ module.exports = { // Configurable site features we support: // distDir: 'build', - experimental: { - isrFlushToDisk: false, - }, + generateBuildId: () => 'build-id', i18n: { defaultLocale: 'en', diff --git a/src/templates/getHandler.js b/src/templates/getHandler.js index 0c84a561ae..498cf47a84 100644 --- a/src/templates/getHandler.js +++ b/src/templates/getHandler.js @@ -1,3 +1,4 @@ +/* eslint-disable max-lines-per-function */ const { promises, createWriteStream, existsSync } = require('fs') const { Server } = require('http') const { tmpdir } = require('os') @@ -21,6 +22,7 @@ const makeHandler = // Set during the request as it needs the host header. Hoisted so we can define the function once let base + conf.experimental.isrFlushToDisk = false // Only do this if we have some static files moved to the CDN if (staticManifest.length !== 0) { @@ -182,3 +184,4 @@ exports.handler = ${ ` module.exports = getHandler +/* eslint-enable max-lines-per-function */ From 287cb1f3d45470eb546012ac464a2905c8f0bbf9 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 22 Nov 2021 17:01:27 +0000 Subject: [PATCH 08/10] fix: add isr warning --- src/helpers/config.js | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/helpers/config.js b/src/helpers/config.js index e97cbd7156..17f1f92d20 100644 --- a/src/helpers/config.js +++ b/src/helpers/config.js @@ -1,5 +1,8 @@ -// @ts-check +/* eslint-disable max-lines */ + +const { yellowBright } = require('chalk') const { readJSON, existsSync } = require('fs-extra') +const { outdent } = require('outdent') const { join, dirname, relative } = require('pathe') const slash = require('slash') @@ -72,21 +75,19 @@ exports.generateRedirects = async ({ netlifyConfig, basePath, i18n }) => { const dynamicRouteEntries = Object.entries(dynamicRoutes) - if (!process.env.EXPERIMENTAL_SWR_ODB) { - const staticRouteEntries = Object.entries(staticRoutes) + const staticRouteEntries = Object.entries(staticRoutes) - staticRouteEntries.forEach(([route, { dataRoute, initialRevalidateSeconds }]) => { - // With revalidate we need to rewrite to SSR rather than ODB - if (initialRevalidateSeconds === false) { - return - } - if (i18n.defaultLocale && route.startsWith(`/${i18n.defaultLocale}/`)) { - route = route.slice(i18n.defaultLocale.length + 1) - } - hasIsr = true - isrRedirects.push(...getNetlifyRoutes(dataRoute), ...getNetlifyRoutes(route)) - }) - } + staticRouteEntries.forEach(([route, { dataRoute, initialRevalidateSeconds }]) => { + // With revalidate we need to rewrite to SSR rather than ODB + if (initialRevalidateSeconds === false) { + return + } + if (i18n.defaultLocale && route.startsWith(`/${i18n.defaultLocale}/`)) { + route = route.slice(i18n.defaultLocale.length + 1) + } + hasIsr = true + isrRedirects.push(...getNetlifyRoutes(dataRoute), ...getNetlifyRoutes(route)) + }) dynamicRouteEntries.forEach(([route, { dataRoute, fallback }]) => { // Add redirects if fallback is "null" (aka blocking) or true/a string @@ -129,6 +130,13 @@ exports.generateRedirects = async ({ netlifyConfig, basePath, i18n }) => { })), { from: `${basePath}/*`, to: HANDLER_FUNCTION_PATH, status: 200 }, ) + if (hasIsr) { + console.log( + yellowBright(outdent` + You have some pages that use ISR (pages that use getStaticProps with revalidate set), which is not currently fully-supported by this plugin. Be aware that results may be unreliable. + `), + ) + } } exports.getNextConfig = async function getNextConfig({ publish, failBuild = defaultFailBuild }) { @@ -191,3 +199,4 @@ exports.configureHandlerFunctions = ({ netlifyConfig, publish, ignore = [] }) => }) }) } +/* eslint-enable max-lines */ From 0521168a1e30e36495b5dfe7cf45f20d724eff95 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 23 Nov 2021 08:16:06 +0000 Subject: [PATCH 09/10] chore: add comments --- demo/pages/api/hello.js | 2 +- .../getStaticProps/withRevalidate/[id].js | 2 +- src/helpers/config.js | 21 ++++++++++++++++++- src/helpers/files.js | 2 ++ src/templates/getHandler.js | 6 ++++-- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/demo/pages/api/hello.js b/demo/pages/api/hello.js index 9987aff4c3..58bde4d655 100644 --- a/demo/pages/api/hello.js +++ b/demo/pages/api/hello.js @@ -1,5 +1,5 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction export default (req, res) => { - res.status(200).json({ name: 'John Doe' }) + res.status(200).json({ name: 'John Doe', query: req.query }) } diff --git a/demo/pages/getStaticProps/withRevalidate/[id].js b/demo/pages/getStaticProps/withRevalidate/[id].js index b22c45dba8..9bbcf1f72b 100644 --- a/demo/pages/getStaticProps/withRevalidate/[id].js +++ b/demo/pages/getStaticProps/withRevalidate/[id].js @@ -33,7 +33,7 @@ export async function getStaticProps({ params }) { const res = await fetch(`https://api.tvmaze.com/shows/${id}`) const data = await res.json() const time = new Date().toLocaleTimeString() - await new Promise((resolve) => setTimeout(resolve, 10000)) + await new Promise((resolve) => setTimeout(resolve, 1000)) return { props: { show: data, diff --git a/src/helpers/config.js b/src/helpers/config.js index 17f1f92d20..df83f885aa 100644 --- a/src/helpers/config.js +++ b/src/helpers/config.js @@ -78,8 +78,9 @@ exports.generateRedirects = async ({ netlifyConfig, basePath, i18n }) => { const staticRouteEntries = Object.entries(staticRoutes) staticRouteEntries.forEach(([route, { dataRoute, initialRevalidateSeconds }]) => { - // With revalidate we need to rewrite to SSR rather than ODB + // Only look for revalidate as we need to rewrite these to SSR rather than ODB if (initialRevalidateSeconds === false) { + // These can be ignored, as they're static files handled by the CDN return } if (i18n.defaultLocale && route.startsWith(`/${i18n.defaultLocale}/`)) { @@ -104,7 +105,20 @@ exports.generateRedirects = async ({ netlifyConfig, basePath, i18n }) => { // This is only used in prod, so dev uses `next dev` directly netlifyConfig.redirects.push( + // Static files are in `static` { from: `${basePath}/_next/static/*`, to: `/static/:splat`, status: 200 }, + // API routes always need to be served from the regular function + { + from: `${basePath}/api`, + to: HANDLER_FUNCTION_PATH, + status: 200, + }, + { + from: `${basePath}/api/*`, + to: HANDLER_FUNCTION_PATH, + status: 200, + }, + // Preview mode gets forced to the function, to bypess pre-rendered pages { from: `${basePath}/*`, to: HANDLER_FUNCTION_PATH, @@ -112,22 +126,27 @@ exports.generateRedirects = async ({ netlifyConfig, basePath, i18n }) => { conditions: { Cookie: ['__prerender_bypass', '__next_preview_data'] }, force: true, }, + // ISR redirects are handled by the regular function. Forced to avoid pre-rendered pages ...isrRedirects.map((redirect) => ({ from: `${basePath}${redirect}`, to: HANDLER_FUNCTION_PATH, status: 200, force: true, })), + // These are pages with fallback set, which need an ODB + // Data redirects go first, to avoid conflict with splat redirects ...dataRedirects.map((redirect) => ({ from: `${basePath}${redirect}`, to: ODB_FUNCTION_PATH, status: 200, })), + // ...then all the other fallback pages ...pageRedirects.map((redirect) => ({ from: `${basePath}${redirect}`, to: ODB_FUNCTION_PATH, status: 200, })), + // Everything else is handled by the regular function { from: `${basePath}/*`, to: HANDLER_FUNCTION_PATH, status: 200 }, ) if (hasIsr) { diff --git a/src/helpers/files.js b/src/helpers/files.js index 49c7f310b9..2e4ce6febf 100644 --- a/src/helpers/files.js +++ b/src/helpers/files.js @@ -53,6 +53,7 @@ exports.moveStaticPages = async ({ netlifyConfig, target, i18n }) => { Object.entries(prerenderManifest.routes).forEach(([route, { initialRevalidateSeconds }]) => { if (initialRevalidateSeconds) { + // Find all files used by ISR routes const trimmedPath = route.slice(1) isrFiles.add(`${trimmedPath}.html`) isrFiles.add(`${trimmedPath}.json`) @@ -83,6 +84,7 @@ exports.moveStaticPages = async ({ netlifyConfig, target, i18n }) => { const limit = pLimit(Math.max(2, cpus().length)) const promises = pages.map(async (rawPath) => { const filePath = slash(rawPath) + // Don't move ISR files, as they're used for the first request if (isrFiles.has(filePath)) { return } diff --git a/src/templates/getHandler.js b/src/templates/getHandler.js index 498cf47a84..a0e085c2e5 100644 --- a/src/templates/getHandler.js +++ b/src/templates/getHandler.js @@ -20,9 +20,11 @@ const makeHandler = require.resolve('./pages.js') } catch {} + // We don't want to write ISR files to disk in the lambda environment + conf.experimental.isrFlushToDisk = false + // Set during the request as it needs the host header. Hoisted so we can define the function once let base - conf.experimental.isrFlushToDisk = false // Only do this if we have some static files moved to the CDN if (staticManifest.length !== 0) { @@ -42,11 +44,11 @@ const makeHandler = if (file.startsWith(pageRoot)) { // We only want the part after `pages/` const filePath = file.slice(pageRoot.length + 1) - const cacheFile = path.join(cacheDir, filePath) // Is it in the CDN and not local? if (staticFiles.has(filePath) && !existsSync(file)) { // This name is safe to use, because it's one that was already created by Next + const cacheFile = path.join(cacheDir, filePath) // Have we already cached it? We ignore the cache if running locally to avoid staleness if ((!existsSync(cacheFile) || process.env.NETLIFY_DEV) && base) { await promises.mkdir(path.dirname(cacheFile), { recursive: true }) From 02d0834f67cd426b57e076375244318f23b54b20 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 23 Nov 2021 09:40:18 +0000 Subject: [PATCH 10/10] fix: snapshot --- demo/next.config.js | 9 ++++----- test/__snapshots__/index.js.snap | 10 ++++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/demo/next.config.js b/demo/next.config.js index 85bdcb3aba..93c2cbab23 100644 --- a/demo/next.config.js +++ b/demo/next.config.js @@ -1,11 +1,10 @@ module.exports = { // Configurable site features we support: // distDir: 'build', - generateBuildId: () => 'build-id', i18n: { defaultLocale: 'en', - locales: ['en', 'es', 'fr'], + locales: ['en', 'es', 'fr'] }, async headers() { return [ @@ -15,7 +14,7 @@ module.exports = { { key: 'x-custom-header', value: 'my custom header value', - }, + } ], }, ] @@ -30,8 +29,8 @@ module.exports = { { source: '/old/:path*', destination: '/:path*', - }, - ], + } + ] } }, // Redirects allow you to redirect an incoming request path to a different destination path. diff --git a/test/__snapshots__/index.js.snap b/test/__snapshots__/index.js.snap index a1e2a6ca7b..04e1fb49b5 100644 --- a/test/__snapshots__/index.js.snap +++ b/test/__snapshots__/index.js.snap @@ -213,6 +213,16 @@ Array [ "status": 200, "to": "/.netlify/functions/___netlify-handler", }, + Object { + "from": "/api", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", + }, + Object { + "from": "/api/*", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", + }, Object { "force": true, "from": "/BUILD_ID",