From 55d6022fcdf32b2e3f4e111ada0499e3eaeae5e8 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Thu, 23 Feb 2023 11:19:44 +0000 Subject: [PATCH 01/43] feat: subclass NextServer to override request handler --- packages/runtime/src/helpers/functions.ts | 8 ++++++++ packages/runtime/src/templates/getHandler.ts | 9 +++------ packages/runtime/src/templates/server.ts | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 packages/runtime/src/templates/server.ts diff --git a/packages/runtime/src/helpers/functions.ts b/packages/runtime/src/helpers/functions.ts index f451abb321..d2923a8860 100644 --- a/packages/runtime/src/helpers/functions.ts +++ b/packages/runtime/src/helpers/functions.ts @@ -47,6 +47,10 @@ export const generateFunctions = async ( await ensureDir(join(functionsDir, functionName)) await writeFile(join(functionsDir, functionName, `${functionName}.js`), apiHandlerSource) await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js')) + await copyFile( + join(__dirname, '..', '..', 'lib', 'templates', 'server.js'), + join(functionsDir, functionName, 'server.js'), + ) await copyFile( join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'), join(functionsDir, functionName, 'handlerUtils.js'), @@ -67,6 +71,10 @@ export const generateFunctions = async ( await ensureDir(join(functionsDir, functionName)) await writeFile(join(functionsDir, functionName, `${functionName}.js`), handlerSource) await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js')) + await copyFile( + join(__dirname, '..', '..', 'lib', 'templates', 'server.js'), + join(functionsDir, functionName, 'server.js'), + ) await copyFile( join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'), join(functionsDir, functionName, 'handlerUtils.js'), diff --git a/packages/runtime/src/templates/getHandler.ts b/packages/runtime/src/templates/getHandler.ts index b5cc1b322b..9e6633feab 100644 --- a/packages/runtime/src/templates/getHandler.ts +++ b/packages/runtime/src/templates/getHandler.ts @@ -5,10 +5,7 @@ import { outdent as javascript } from 'outdent' import type { NextConfig } from '../helpers/config' -import type { NextServerType } from './handlerUtils' - /* eslint-disable @typescript-eslint/no-var-requires */ - const { promises } = require('fs') const { Server } = require('http') const path = require('path') @@ -22,9 +19,9 @@ const { getMaxAge, getMultiValueHeaders, getPrefetchResponse, - getNextServer, normalizePath, } = require('./handlerUtils') +const { NetlifyNextServer } = require('./server') /* eslint-enable @typescript-eslint/no-var-requires */ type Mutable = { @@ -76,8 +73,7 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str const port = Number.parseInt(url.port) || 80 base = url.origin - const NextServer: NextServerType = getNextServer() - const nextServer = new NextServer({ + const nextServer = new NetlifyNextServer({ conf, dir, customServer: false, @@ -180,6 +176,7 @@ export const getHandler = ({ isODB = false, publishDir = '../../../.next', appDi // We copy the file here rather than requiring from the node module const { Bridge } = require("./bridge"); const { augmentFsModule, getMaxAge, getMultiValueHeaders, getPrefetchResponse, getNextServer, normalizePath } = require('./handlerUtils') + const { NetlifyNextServer } = require('./server') ${isODB ? `const { builder } = require("@netlify/functions")` : ''} const { config } = require("${publishDir}/required-server-files.json") diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts new file mode 100644 index 0000000000..84f35ed3da --- /dev/null +++ b/packages/runtime/src/templates/server.ts @@ -0,0 +1,19 @@ +import { NodeRequestHandler } from 'next/dist/server/next-server' + +import { getNextServer, NextServerType } from './handlerUtils' + +const NextServer: NextServerType = getNextServer() + +class NetlifyNextServer extends NextServer { + public getRequestHandler(): NodeRequestHandler { + const handler = super.getRequestHandler() + return (req, res, parsedUrl) => { + if (req.headers['x-prerender-revalidate']) { + console.log('Revalidate request') + } + return handler(req, res, parsedUrl) + } + } +} + +export { NetlifyNextServer } From c3d560711a09f9897338e1df2af4abc7db749b42 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Thu, 23 Feb 2023 11:19:58 +0000 Subject: [PATCH 02/43] feat: add revalidate API route --- demos/default/pages/api/revalidate.js | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 demos/default/pages/api/revalidate.js diff --git a/demos/default/pages/api/revalidate.js b/demos/default/pages/api/revalidate.js new file mode 100644 index 0000000000..ed5e0aa5ae --- /dev/null +++ b/demos/default/pages/api/revalidate.js @@ -0,0 +1,8 @@ +export default async function handler(req, res) { + try { + await res.revalidate('/getStaticProps/with-revalidate/') + return res.json({ revalidated: true }) + } catch (err) { + return res.status(500).send('Error revalidating') + } +} From 3030689493f9549ea5d0fdae3185bad38d573297 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Thu, 23 Feb 2023 11:19:44 +0000 Subject: [PATCH 03/43] feat: subclass NextServer to override request handler --- packages/runtime/src/helpers/functions.ts | 8 ++++++++ packages/runtime/src/templates/getHandler.ts | 9 +++------ packages/runtime/src/templates/server.ts | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 packages/runtime/src/templates/server.ts diff --git a/packages/runtime/src/helpers/functions.ts b/packages/runtime/src/helpers/functions.ts index f451abb321..d2923a8860 100644 --- a/packages/runtime/src/helpers/functions.ts +++ b/packages/runtime/src/helpers/functions.ts @@ -47,6 +47,10 @@ export const generateFunctions = async ( await ensureDir(join(functionsDir, functionName)) await writeFile(join(functionsDir, functionName, `${functionName}.js`), apiHandlerSource) await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js')) + await copyFile( + join(__dirname, '..', '..', 'lib', 'templates', 'server.js'), + join(functionsDir, functionName, 'server.js'), + ) await copyFile( join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'), join(functionsDir, functionName, 'handlerUtils.js'), @@ -67,6 +71,10 @@ export const generateFunctions = async ( await ensureDir(join(functionsDir, functionName)) await writeFile(join(functionsDir, functionName, `${functionName}.js`), handlerSource) await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js')) + await copyFile( + join(__dirname, '..', '..', 'lib', 'templates', 'server.js'), + join(functionsDir, functionName, 'server.js'), + ) await copyFile( join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'), join(functionsDir, functionName, 'handlerUtils.js'), diff --git a/packages/runtime/src/templates/getHandler.ts b/packages/runtime/src/templates/getHandler.ts index b5cc1b322b..9e6633feab 100644 --- a/packages/runtime/src/templates/getHandler.ts +++ b/packages/runtime/src/templates/getHandler.ts @@ -5,10 +5,7 @@ import { outdent as javascript } from 'outdent' import type { NextConfig } from '../helpers/config' -import type { NextServerType } from './handlerUtils' - /* eslint-disable @typescript-eslint/no-var-requires */ - const { promises } = require('fs') const { Server } = require('http') const path = require('path') @@ -22,9 +19,9 @@ const { getMaxAge, getMultiValueHeaders, getPrefetchResponse, - getNextServer, normalizePath, } = require('./handlerUtils') +const { NetlifyNextServer } = require('./server') /* eslint-enable @typescript-eslint/no-var-requires */ type Mutable = { @@ -76,8 +73,7 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str const port = Number.parseInt(url.port) || 80 base = url.origin - const NextServer: NextServerType = getNextServer() - const nextServer = new NextServer({ + const nextServer = new NetlifyNextServer({ conf, dir, customServer: false, @@ -180,6 +176,7 @@ export const getHandler = ({ isODB = false, publishDir = '../../../.next', appDi // We copy the file here rather than requiring from the node module const { Bridge } = require("./bridge"); const { augmentFsModule, getMaxAge, getMultiValueHeaders, getPrefetchResponse, getNextServer, normalizePath } = require('./handlerUtils') + const { NetlifyNextServer } = require('./server') ${isODB ? `const { builder } = require("@netlify/functions")` : ''} const { config } = require("${publishDir}/required-server-files.json") diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts new file mode 100644 index 0000000000..84f35ed3da --- /dev/null +++ b/packages/runtime/src/templates/server.ts @@ -0,0 +1,19 @@ +import { NodeRequestHandler } from 'next/dist/server/next-server' + +import { getNextServer, NextServerType } from './handlerUtils' + +const NextServer: NextServerType = getNextServer() + +class NetlifyNextServer extends NextServer { + public getRequestHandler(): NodeRequestHandler { + const handler = super.getRequestHandler() + return (req, res, parsedUrl) => { + if (req.headers['x-prerender-revalidate']) { + console.log('Revalidate request') + } + return handler(req, res, parsedUrl) + } + } +} + +export { NetlifyNextServer } From 699ad3a24e17e77ee94705c7daa22ce7e79423cd Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Thu, 23 Feb 2023 11:19:58 +0000 Subject: [PATCH 04/43] feat: add revalidate API route --- demos/default/pages/api/revalidate.js | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 demos/default/pages/api/revalidate.js diff --git a/demos/default/pages/api/revalidate.js b/demos/default/pages/api/revalidate.js new file mode 100644 index 0000000000..ed5e0aa5ae --- /dev/null +++ b/demos/default/pages/api/revalidate.js @@ -0,0 +1,8 @@ +export default async function handler(req, res) { + try { + await res.revalidate('/getStaticProps/with-revalidate/') + return res.json({ revalidated: true }) + } catch (err) { + return res.status(500).send('Error revalidating') + } +} From 3c805d4896a17a651fa7e54f83c5e887f658a77e Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 1 Mar 2023 09:26:19 +0000 Subject: [PATCH 05/43] feat: add refresh hooks api implementation --- packages/runtime/src/templates/getHandler.ts | 5 +- packages/runtime/src/templates/server.ts | 68 +++++++++++++++++++- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/templates/getHandler.ts b/packages/runtime/src/templates/getHandler.ts index 9e6633feab..feef873418 100644 --- a/packages/runtime/src/templates/getHandler.ts +++ b/packages/runtime/src/templates/getHandler.ts @@ -65,7 +65,7 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str // We memoize this because it can be shared between requests, but don't instantiate it until // the first request because we need the host and port. let bridge: NodeBridge - const getBridge = (event: HandlerEvent): NodeBridge => { + const getBridge = (event: HandlerEvent, context: HandlerContext): NodeBridge => { if (bridge) { return bridge } @@ -79,6 +79,7 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str customServer: false, hostname: url.hostname, port, + netlifyRevalidateToken: context.clientContext?.custom?.odb_refresh_hooks, }) const requestHandler = nextServer.getRequestHandler() const server = new Server(async (req, res) => { @@ -115,7 +116,7 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str process.env._NETLIFY_GRAPH_TOKEN = graphToken } - const { headers, ...result } = await getBridge(event).launcher(event, context) + const { headers, ...result } = await getBridge(event, context).launcher(event, context) // Convert all headers to multiValueHeaders diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index 84f35ed3da..1f69cf5033 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -1,19 +1,81 @@ -import { NodeRequestHandler } from 'next/dist/server/next-server' +import https from 'https' + +import { NodeRequestHandler, Options } from 'next/dist/server/next-server' import { getNextServer, NextServerType } from './handlerUtils' const NextServer: NextServerType = getNextServer() +interface NetlifyNextServerOptions extends Options { + netlifyRevalidateToken: string +} + class NetlifyNextServer extends NextServer { + private netlifyRevalidateToken?: string + + public constructor(options: NetlifyNextServerOptions) { + super(options) + this.netlifyRevalidateToken = options.netlifyRevalidateToken + } + public getRequestHandler(): NodeRequestHandler { const handler = super.getRequestHandler() - return (req, res, parsedUrl) => { + return async (req, res, parsedUrl) => { if (req.headers['x-prerender-revalidate']) { - console.log('Revalidate request') + if (this.netlifyRevalidateToken) { + try { + await this.netlifyRevalidate(req.url) + console.log('Revalidated', req.url) + } catch { + // TODO: status 500 error refreshing ODB cache + console.log('Error revalidating', req.url) + } + } else { + // TODO: status 400 refresh hooks not enabled for site in proxy + console.log('Missing revalidate token', req.url) + } } return handler(req, res, parsedUrl) } } + + private netlifyRevalidate(url: string) { + const path = new URL(url).pathname + const domain = this.hostname + const siteId = process.env.SITE_ID + + return new Promise((resolve, reject) => { + const body = JSON.stringify({ paths: [path], domain }) + + const req = https + .request( + { + hostname: 'api.netlify.com', + port: 443, + path: `/api/v1/sites/${siteId}/refresh_on_demand_builders`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': body.length, + Authorization: `Bearer ${this.netlifyRevalidateToken}`, + }, + }, + (res) => { + let data = '' + res.on('data', (chunk) => { + data += chunk + }) + res.on('end', () => { + resolve(JSON.parse(data)) + }) + }, + ) + .on('error', reject) + + req.write(body) + req.end() + }) + } } export { NetlifyNextServer } From 796e45868704e86bc0797d5126db99f4c7a60f9a Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 1 Mar 2023 09:26:35 +0000 Subject: [PATCH 06/43] chore: increase revalidate ttl for testing --- demos/default/pages/getStaticProps/with-revalidate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/default/pages/getStaticProps/with-revalidate.js b/demos/default/pages/getStaticProps/with-revalidate.js index 97f3b0d62c..7c92f765ae 100644 --- a/demos/default/pages/getStaticProps/with-revalidate.js +++ b/demos/default/pages/getStaticProps/with-revalidate.js @@ -24,7 +24,7 @@ export async function getStaticProps(context) { show: data, }, // ODB handler will use the minimum TTL=60s - revalidate: 1, + revalidate: 300, } } From bf3f8d9629a0774db6346d18dd134d55da902cba Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 1 Mar 2023 10:52:48 +0000 Subject: [PATCH 07/43] chore: add time to page for testing --- demos/default/pages/getStaticProps/with-revalidate.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/demos/default/pages/getStaticProps/with-revalidate.js b/demos/default/pages/getStaticProps/with-revalidate.js index 7c92f765ae..d0c0066b95 100644 --- a/demos/default/pages/getStaticProps/with-revalidate.js +++ b/demos/default/pages/getStaticProps/with-revalidate.js @@ -1,8 +1,8 @@ import Link from 'next/link' -const Show = ({ show }) => ( +const Show = ({ show, time }) => (
-

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

+

This page uses getStaticProps() to pre-fetch a TV show at {time}


@@ -22,6 +22,7 @@ export async function getStaticProps(context) { return { props: { show: data, + time: new Date().toISOString(), }, // ODB handler will use the minimum TTL=60s revalidate: 300, From 2a2da7e73df3e5578db787aa33b4ff3b32e55323 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 1 Mar 2023 13:31:00 +0000 Subject: [PATCH 08/43] feat: add error reporting for caught exceptions --- packages/runtime/src/templates/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index 1f69cf5033..e67206510b 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -26,9 +26,9 @@ class NetlifyNextServer extends NextServer { try { await this.netlifyRevalidate(req.url) console.log('Revalidated', req.url) - } catch { + } catch (error) { // TODO: status 500 error refreshing ODB cache - console.log('Error revalidating', req.url) + console.log('Error revalidating', req.url, error) } } else { // TODO: status 400 refresh hooks not enabled for site in proxy From cae0a915fe9a8ba518f79afc6feaa1fa184c26df Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 1 Mar 2023 13:41:49 +0000 Subject: [PATCH 09/43] fix: use req.url verbatim --- packages/runtime/src/templates/server.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index e67206510b..1909e95531 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -40,12 +40,11 @@ class NetlifyNextServer extends NextServer { } private netlifyRevalidate(url: string) { - const path = new URL(url).pathname const domain = this.hostname const siteId = process.env.SITE_ID return new Promise((resolve, reject) => { - const body = JSON.stringify({ paths: [path], domain }) + const body = JSON.stringify({ paths: [url], domain }) const req = https .request( From b04cb8e677f30014e31e535e849fad330063a4ad Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 1 Mar 2023 17:41:32 +0000 Subject: [PATCH 10/43] chore: test throw in revalidate handler --- packages/runtime/src/templates/server.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index 1909e95531..49dfbf062b 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -22,18 +22,13 @@ class NetlifyNextServer extends NextServer { const handler = super.getRequestHandler() return async (req, res, parsedUrl) => { if (req.headers['x-prerender-revalidate']) { - if (this.netlifyRevalidateToken) { - try { - await this.netlifyRevalidate(req.url) - console.log('Revalidated', req.url) - } catch (error) { - // TODO: status 500 error refreshing ODB cache - console.log('Error revalidating', req.url, error) - } - } else { - // TODO: status 400 refresh hooks not enabled for site in proxy - console.log('Missing revalidate token', req.url) - } + // if (this.netlifyRevalidateToken) { + throw new Error(`Test throw`) + // await this.netlifyRevalidate(req.url) + // console.log('Revalidated', req.url) + // } else { + // throw new Error(`Missing revalidate token`) + // } } return handler(req, res, parsedUrl) } From de649e977709f9d72bfdaa9632d90d79b229784a Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 1 Mar 2023 17:42:56 +0000 Subject: [PATCH 11/43] chore: fix lint issue --- packages/runtime/src/templates/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index 49dfbf062b..fd5821e4c4 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -20,7 +20,7 @@ class NetlifyNextServer extends NextServer { public getRequestHandler(): NodeRequestHandler { const handler = super.getRequestHandler() - return async (req, res, parsedUrl) => { + return (req, res, parsedUrl) => { if (req.headers['x-prerender-revalidate']) { // if (this.netlifyRevalidateToken) { throw new Error(`Test throw`) From 98407eacd87c3edb48fb9583a60cade6d3199d55 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 1 Mar 2023 17:52:25 +0000 Subject: [PATCH 12/43] fix: update revalidate error handling --- demos/default/pages/api/revalidate.js | 1 + packages/runtime/src/templates/server.ts | 14 ++++++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/demos/default/pages/api/revalidate.js b/demos/default/pages/api/revalidate.js index ed5e0aa5ae..e0d67b1da1 100644 --- a/demos/default/pages/api/revalidate.js +++ b/demos/default/pages/api/revalidate.js @@ -1,6 +1,7 @@ export default async function handler(req, res) { try { await res.revalidate('/getStaticProps/with-revalidate/') + console.log('Revalidated', req.url) return res.json({ revalidated: true }) } catch (err) { return res.status(500).send('Error revalidating') diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index fd5821e4c4..4b2499ff33 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -20,15 +20,13 @@ class NetlifyNextServer extends NextServer { public getRequestHandler(): NodeRequestHandler { const handler = super.getRequestHandler() - return (req, res, parsedUrl) => { + return async (req, res, parsedUrl) => { if (req.headers['x-prerender-revalidate']) { - // if (this.netlifyRevalidateToken) { - throw new Error(`Test throw`) - // await this.netlifyRevalidate(req.url) - // console.log('Revalidated', req.url) - // } else { - // throw new Error(`Missing revalidate token`) - // } + if (this.netlifyRevalidateToken) { + await this.netlifyRevalidate(req.url) + } else { + throw new Error(`Missing revalidate token`) + } } return handler(req, res, parsedUrl) } From f691362ceec59a124c5fde61b7224b34d392337b Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 1 Mar 2023 18:02:21 +0000 Subject: [PATCH 13/43] fix: error handling in revalidate api function --- demos/default/pages/api/revalidate.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/demos/default/pages/api/revalidate.js b/demos/default/pages/api/revalidate.js index e0d67b1da1..6531427159 100644 --- a/demos/default/pages/api/revalidate.js +++ b/demos/default/pages/api/revalidate.js @@ -1,9 +1,10 @@ export default async function handler(req, res) { try { - await res.revalidate('/getStaticProps/with-revalidate/') - console.log('Revalidated', req.url) + const path = '/getStaticProps/with-revalidate/' + await res.revalidate(path) + console.log('Revalidated:', path) return res.json({ revalidated: true }) } catch (err) { - return res.status(500).send('Error revalidating') + return res.status(500).send('Error revalidating:', err) } } From 443becacb778aee43c4068314a3c879707f70d8c Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Mon, 6 Mar 2023 11:19:35 +0000 Subject: [PATCH 14/43] chore: refactor revalidate handling for easier testing --- packages/runtime/src/templates/getHandler.ts | 4 +- .../runtime/src/templates/handlerUtils.ts | 44 +++++++++++++ packages/runtime/src/templates/server.ts | 65 +++++-------------- 3 files changed, 64 insertions(+), 49 deletions(-) diff --git a/packages/runtime/src/templates/getHandler.ts b/packages/runtime/src/templates/getHandler.ts index feef873418..24b3890a55 100644 --- a/packages/runtime/src/templates/getHandler.ts +++ b/packages/runtime/src/templates/getHandler.ts @@ -21,7 +21,7 @@ const { getPrefetchResponse, normalizePath, } = require('./handlerUtils') -const { NetlifyNextServer } = require('./server') +const NetlifyNextServer = require('./server') /* eslint-enable @typescript-eslint/no-var-requires */ type Mutable = { @@ -177,7 +177,7 @@ export const getHandler = ({ isODB = false, publishDir = '../../../.next', appDi // We copy the file here rather than requiring from the node module const { Bridge } = require("./bridge"); const { augmentFsModule, getMaxAge, getMultiValueHeaders, getPrefetchResponse, getNextServer, normalizePath } = require('./handlerUtils') - const { NetlifyNextServer } = require('./server') + const NetlifyNextServer = require('./server') ${isODB ? `const { builder } = require("@netlify/functions")` : ''} const { config } = require("${publishDir}/required-server-files.json") diff --git a/packages/runtime/src/templates/handlerUtils.ts b/packages/runtime/src/templates/handlerUtils.ts index 8d7df830e2..d5e0959393 100644 --- a/packages/runtime/src/templates/handlerUtils.ts +++ b/packages/runtime/src/templates/handlerUtils.ts @@ -1,4 +1,5 @@ import fs, { createWriteStream, existsSync } from 'fs' +import { ServerResponse } from 'http' import { tmpdir } from 'os' import path from 'path' import { pipeline } from 'stream' @@ -222,3 +223,46 @@ export const normalizePath = (event: HandlerEvent) => { // Ensure that paths are encoded - but don't double-encode them return new URL(event.rawUrl).pathname } + +export const netlifyApiFetch = ({ + endpoint, + payload, + token, + method = 'GET', +}: { + endpoint: string + payload: unknown + token: string + method: 'GET' | 'POST' +}): Promise => + new Promise((resolve, reject) => { + const body = JSON.stringify(payload) + + const req = https.request( + { + hostname: 'api.netlify.com', + port: 443, + path: `/api/v1/${endpoint}`, + method, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': body.length, + Authorization: `Bearer ${token}`, + }, + }, + (res: ServerResponse) => { + let data = '' + res.on('data', (chunk) => { + data += chunk + }) + res.on('end', () => { + resolve(JSON.parse(data)) + }) + }, + ) + + req.on('error', reject) + + req.write(body) + req.end() + }) diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index 4b2499ff33..22597b65f4 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -1,16 +1,14 @@ -import https from 'https' - import { NodeRequestHandler, Options } from 'next/dist/server/next-server' -import { getNextServer, NextServerType } from './handlerUtils' +import { netlifyApiFetch, getNextServer, NextServerType } from './handlerUtils' const NextServer: NextServerType = getNextServer() interface NetlifyNextServerOptions extends Options { - netlifyRevalidateToken: string + netlifyRevalidateToken?: string } -class NetlifyNextServer extends NextServer { +export default class NetlifyNextServer extends NextServer { private netlifyRevalidateToken?: string public constructor(options: NetlifyNextServerOptions) { @@ -22,52 +20,25 @@ class NetlifyNextServer extends NextServer { const handler = super.getRequestHandler() return async (req, res, parsedUrl) => { if (req.headers['x-prerender-revalidate']) { - if (this.netlifyRevalidateToken) { - await this.netlifyRevalidate(req.url) - } else { - throw new Error(`Missing revalidate token`) - } + await this.netlifyRevalidate(req.url) } return handler(req, res, parsedUrl) } } - private netlifyRevalidate(url: string) { - const domain = this.hostname - const siteId = process.env.SITE_ID - - return new Promise((resolve, reject) => { - const body = JSON.stringify({ paths: [url], domain }) - - const req = https - .request( - { - hostname: 'api.netlify.com', - port: 443, - path: `/api/v1/sites/${siteId}/refresh_on_demand_builders`, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': body.length, - Authorization: `Bearer ${this.netlifyRevalidateToken}`, - }, - }, - (res) => { - let data = '' - res.on('data', (chunk) => { - data += chunk - }) - res.on('end', () => { - resolve(JSON.parse(data)) - }) - }, - ) - .on('error', reject) - - req.write(body) - req.end() - }) + private async netlifyRevalidate(url: string) { + try { + const result = await netlifyApiFetch<{ code: number; message: string }>({ + endpoint: `sites/${process.env.SITE_ID}/refresh_on_demand_builders`, + payload: { paths: [url], domain: this.hostname }, + token: this.netlifyRevalidateToken, + method: 'POST', + }) + if (result.code !== 200) { + throw result + } + } catch (error) { + throw new Error(`Unsuccessful revalidate - ${error.message}`) + } } } - -export { NetlifyNextServer } From e043e9d2c29d0bb43faccedfd0e55033b0d3850b Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Mon, 6 Mar 2023 11:20:01 +0000 Subject: [PATCH 15/43] test: add tests for refresh hooks --- package.json | 1 + packages/runtime/src/templates/server.test.ts | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 packages/runtime/src/templates/server.test.ts diff --git a/package.json b/package.json index 0a89579e89..13da098112 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "testMatch": [ "**/test/**/*.spec.js", "**/test/**/*.spec.ts", + "**/*.test.ts", "!**/test/e2e/**", "!**/test/fixtures/**", "!**/test/sample/**", diff --git a/packages/runtime/src/templates/server.test.ts b/packages/runtime/src/templates/server.test.ts new file mode 100644 index 0000000000..640a0d7750 --- /dev/null +++ b/packages/runtime/src/templates/server.test.ts @@ -0,0 +1,63 @@ +import { mockRequest } from 'next/dist/server/lib/mock-request' + +import { netlifyApiFetch, getNextServer, NextServerType } from './handlerUtils' +import NetlifyNextServer from './server' + +const NextServer: NextServerType = getNextServer() + +jest.mock('./handlerUtils', () => { + const originalModule = jest.requireActual('./handlerUtils') + + return { + __esModule: true, + ...originalModule, + netlifyApiFetch: jest.fn(({ payload }) => { + switch (payload.paths[0]) { + case '/getStaticProps/with-revalidate/': + return Promise.resolve({ code: 200, message: 'Revalidated' }) + case '/not-a-path/': + return Promise.resolve({ code: 404, message: '404' }) + default: + return Promise.reject(new Error('Error')) + } + }), + } +}) + +jest.spyOn(NextServer.prototype, 'getRequestHandler').mockImplementation(() => () => Promise.resolve()) + +Object.setPrototypeOf(NetlifyNextServer, jest.fn()) + +const nextServer = new NetlifyNextServer({ conf: {}, dev: false }) +const requestHandler = nextServer.getRequestHandler() + +describe('the netlify next server', () => { + it('intercepts a request containing an x-prerender-revalidate header', async () => { + const { req: mockReq, res: mockRes } = mockRequest( + '/getStaticProps/with-revalidate/', + { 'x-prerender-revalidate': 'test' }, + 'GET', + ) + await requestHandler(mockReq, mockRes) + expect(netlifyApiFetch).toHaveBeenCalled() + }) + + it('silently revalidates and returns the original handler response', async () => { + const { req: mockReq, res: mockRes } = mockRequest( + '/getStaticProps/with-revalidate/', + { 'x-prerender-revalidate': 'test' }, + 'GET', + ) + await expect(requestHandler(mockReq, mockRes)).resolves.toBe(undefined) + }) + + it('throws an error when the revalidate API returns a 404 response', async () => { + const { req: mockReq, res: mockRes } = mockRequest('/not-a-path/', { 'x-prerender-revalidate': 'test' }, 'GET') + await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unsuccessful revalidate - 404') + }) + + it('throws an error when the revalidate API is unreachable', async () => { + const { req: mockReq, res: mockRes } = mockRequest('', { 'x-prerender-revalidate': 'test' }, 'GET') + await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unsuccessful revalidate - Error') + }) +}) From 95f7c94e5f90cdfb54a01f7200ec161ab69c3a56 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Mon, 6 Mar 2023 11:29:53 +0000 Subject: [PATCH 16/43] test: add e2e test for refresh hooks revalidate --- cypress/integration/default/dynamic-routes.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cypress/integration/default/dynamic-routes.spec.ts b/cypress/integration/default/dynamic-routes.spec.ts index 266f6faa43..48c8b9181f 100644 --- a/cypress/integration/default/dynamic-routes.spec.ts +++ b/cypress/integration/default/dynamic-routes.spec.ts @@ -21,6 +21,12 @@ describe('Static Routing', () => { expect(res.body).to.contain('Dancing with the Stars') }) }) + it('revalidates page via refresh hooks on a static route', () => { + cy.request({ url: '/api/revalidate/' }).then((res) => { + expect(res.status).to.eq(200) + expect(res.body).to.equal({ revalidated: true }) + }) + }) }) describe('Dynamic Routing', () => { From 238596e9b92fc0c468b4b5f81ccb1aece3bd3803 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Mon, 6 Mar 2023 12:29:27 +0000 Subject: [PATCH 17/43] feat: revalidate data routes --- packages/runtime/src/templates/server.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index 22597b65f4..85a034f8b1 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -19,6 +19,7 @@ export default class NetlifyNextServer extends NextServer { public getRequestHandler(): NodeRequestHandler { const handler = super.getRequestHandler() return async (req, res, parsedUrl) => { + // on-demand revalidation request if (req.headers['x-prerender-revalidate']) { await this.netlifyRevalidate(req.url) } @@ -27,10 +28,17 @@ export default class NetlifyNextServer extends NextServer { } private async netlifyRevalidate(url: string) { + const siteId = process.env.SITE_ID + const trailingSlash = this.nextConfig.trailingSlash ? '/' : '' + try { + // call netlify API to revalidate the path, including its data routes const result = await netlifyApiFetch<{ code: number; message: string }>({ - endpoint: `sites/${process.env.SITE_ID}/refresh_on_demand_builders`, - payload: { paths: [url], domain: this.hostname }, + endpoint: `sites/${siteId}/refresh_on_demand_builders`, + payload: { + paths: [url, `/_next/data/${this.buildId}${url}.json`, `${url}.rsc${trailingSlash}`], + domain: this.hostname, + }, token: this.netlifyRevalidateToken, method: 'POST', }) From cf0fc724acdadd92a9fed37f0a292240c6781ea6 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Mon, 6 Mar 2023 12:40:28 +0000 Subject: [PATCH 18/43] fix: export named NetlifyNextServer for module interop --- packages/runtime/src/templates/getHandler.ts | 4 ++-- packages/runtime/src/templates/server.test.ts | 2 +- packages/runtime/src/templates/server.ts | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/templates/getHandler.ts b/packages/runtime/src/templates/getHandler.ts index 24b3890a55..feef873418 100644 --- a/packages/runtime/src/templates/getHandler.ts +++ b/packages/runtime/src/templates/getHandler.ts @@ -21,7 +21,7 @@ const { getPrefetchResponse, normalizePath, } = require('./handlerUtils') -const NetlifyNextServer = require('./server') +const { NetlifyNextServer } = require('./server') /* eslint-enable @typescript-eslint/no-var-requires */ type Mutable = { @@ -177,7 +177,7 @@ export const getHandler = ({ isODB = false, publishDir = '../../../.next', appDi // We copy the file here rather than requiring from the node module const { Bridge } = require("./bridge"); const { augmentFsModule, getMaxAge, getMultiValueHeaders, getPrefetchResponse, getNextServer, normalizePath } = require('./handlerUtils') - const NetlifyNextServer = require('./server') + const { NetlifyNextServer } = require('./server') ${isODB ? `const { builder } = require("@netlify/functions")` : ''} const { config } = require("${publishDir}/required-server-files.json") diff --git a/packages/runtime/src/templates/server.test.ts b/packages/runtime/src/templates/server.test.ts index 640a0d7750..456620f35c 100644 --- a/packages/runtime/src/templates/server.test.ts +++ b/packages/runtime/src/templates/server.test.ts @@ -1,7 +1,7 @@ import { mockRequest } from 'next/dist/server/lib/mock-request' import { netlifyApiFetch, getNextServer, NextServerType } from './handlerUtils' -import NetlifyNextServer from './server' +import { NetlifyNextServer } from './server' const NextServer: NextServerType = getNextServer() diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index 85a034f8b1..bded3941cc 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -8,7 +8,7 @@ interface NetlifyNextServerOptions extends Options { netlifyRevalidateToken?: string } -export default class NetlifyNextServer extends NextServer { +class NetlifyNextServer extends NextServer { private netlifyRevalidateToken?: string public constructor(options: NetlifyNextServerOptions) { @@ -50,3 +50,5 @@ export default class NetlifyNextServer extends NextServer { } } } + +export { NetlifyNextServer } From 0f7d1dbb920413af9cdda9aa3ec12c1b6c070e94 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Mon, 6 Mar 2023 12:47:02 +0000 Subject: [PATCH 19/43] fix: revalidate error messaging in demo --- demos/default/pages/api/revalidate.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demos/default/pages/api/revalidate.js b/demos/default/pages/api/revalidate.js index 6531427159..544a9faa1b 100644 --- a/demos/default/pages/api/revalidate.js +++ b/demos/default/pages/api/revalidate.js @@ -3,8 +3,8 @@ export default async function handler(req, res) { const path = '/getStaticProps/with-revalidate/' await res.revalidate(path) console.log('Revalidated:', path) - return res.json({ revalidated: true }) + return res.json({ code: 200, message: 'success' }) } catch (err) { - return res.status(500).send('Error revalidating:', err) + return res.status(500).send({ code: 200, message: err.message }) } } From 8cc7abecb643794c95fb748a2d61c7f8b2dba6ee Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Mon, 6 Mar 2023 15:51:18 +0000 Subject: [PATCH 20/43] fix: remove revalidate data routes for milestone 1 --- packages/runtime/src/templates/server.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index bded3941cc..a175984440 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -1,5 +1,7 @@ import { NodeRequestHandler, Options } from 'next/dist/server/next-server' +// import { netlifyRoutesForNextRoute } from '../helpers/utils' + import { netlifyApiFetch, getNextServer, NextServerType } from './handlerUtils' const NextServer: NextServerType = getNextServer() @@ -28,15 +30,20 @@ class NetlifyNextServer extends NextServer { } private async netlifyRevalidate(url: string) { - const siteId = process.env.SITE_ID - const trailingSlash = this.nextConfig.trailingSlash ? '/' : '' - try { // call netlify API to revalidate the path, including its data routes const result = await netlifyApiFetch<{ code: number; message: string }>({ - endpoint: `sites/${siteId}/refresh_on_demand_builders`, + endpoint: `sites/${process.env.SITE_ID}/refresh_on_demand_builders`, payload: { - paths: [url, `/_next/data/${this.buildId}${url}.json`, `${url}.rsc${trailingSlash}`], + paths: [ + url, + // ...netlifyRoutesForNextRoute({ + // route: url, + // buildId: this.buildId, + // i18n: this.nextConfig.i18n, + // }), + // url.endsWith('/') ? `${url.slice(0, -1)}.rsc/` : `${url}.rsc`, + ], domain: this.hostname, }, token: this.netlifyRevalidateToken, From fef98a13af2c176189afb1ba9885bf86bbd5075d Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Mon, 6 Mar 2023 16:30:21 +0000 Subject: [PATCH 21/43] fix: amend test for revalidate success/failure --- demos/default/pages/api/revalidate.js | 2 +- packages/runtime/src/templates/server.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/demos/default/pages/api/revalidate.js b/demos/default/pages/api/revalidate.js index 544a9faa1b..2eec96e07e 100644 --- a/demos/default/pages/api/revalidate.js +++ b/demos/default/pages/api/revalidate.js @@ -5,6 +5,6 @@ export default async function handler(req, res) { console.log('Revalidated:', path) return res.json({ code: 200, message: 'success' }) } catch (err) { - return res.status(500).send({ code: 200, message: err.message }) + return res.status(500).send({ code: 500, message: err.message }) } } diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index a175984440..79a663930c 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -32,7 +32,7 @@ class NetlifyNextServer extends NextServer { private async netlifyRevalidate(url: string) { try { // call netlify API to revalidate the path, including its data routes - const result = await netlifyApiFetch<{ code: number; message: string }>({ + const result = await netlifyApiFetch<{ ok: boolean; code: number; message: string }>({ endpoint: `sites/${process.env.SITE_ID}/refresh_on_demand_builders`, payload: { paths: [ @@ -49,11 +49,12 @@ class NetlifyNextServer extends NextServer { token: this.netlifyRevalidateToken, method: 'POST', }) - if (result.code !== 200) { - throw result + if (result.ok !== true) { + throw new Error(result.message) } } catch (error) { - throw new Error(`Unsuccessful revalidate - ${error.message}`) + console.log('Error revalidating', error.message) + throw error } } } From bf97d738d49161903f2ee31a6c2eb963bfb38515 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Fri, 10 Mar 2023 15:23:41 +0000 Subject: [PATCH 22/43] feat: add additional paths for routes --- packages/runtime/src/templates/getHandler.ts | 26 +++++++---- .../runtime/src/templates/handlerUtils.ts | 44 +++++++++++++++++++ packages/runtime/src/templates/server.ts | 33 +++++--------- 3 files changed, 73 insertions(+), 30 deletions(-) diff --git a/packages/runtime/src/templates/getHandler.ts b/packages/runtime/src/templates/getHandler.ts index feef873418..cde440b4f8 100644 --- a/packages/runtime/src/templates/getHandler.ts +++ b/packages/runtime/src/templates/getHandler.ts @@ -29,7 +29,7 @@ type Mutable = { } // We return a function and then call `toString()` on it to serialise it as the launcher function -// eslint-disable-next-line max-params +// eslint-disable-next-line max-params, max-lines-per-function const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[string, string]> = [], mode = 'ssr') => { // Change working directory into the site root, unless using Nx, which moves the // dist directory and handles this itself @@ -66,6 +66,10 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str // the first request because we need the host and port. let bridge: NodeBridge const getBridge = (event: HandlerEvent, context: HandlerContext): NodeBridge => { + const { + clientContext: { custom: customContext }, + } = context + if (bridge) { return bridge } @@ -73,14 +77,18 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str const port = Number.parseInt(url.port) || 80 base = url.origin - const nextServer = new NetlifyNextServer({ - conf, - dir, - customServer: false, - hostname: url.hostname, - port, - netlifyRevalidateToken: context.clientContext?.custom?.odb_refresh_hooks, - }) + const nextServer = new NetlifyNextServer( + { + conf, + dir, + customServer: false, + hostname: url.hostname, + port, + }, + { + revalidateToken: customContext.odb_refresh_hooks, + }, + ) const requestHandler = nextServer.getRequestHandler() const server = new Server(async (req, res) => { try { diff --git a/packages/runtime/src/templates/handlerUtils.ts b/packages/runtime/src/templates/handlerUtils.ts index d5e0959393..5f44627302 100644 --- a/packages/runtime/src/templates/handlerUtils.ts +++ b/packages/runtime/src/templates/handlerUtils.ts @@ -224,6 +224,9 @@ export const normalizePath = (event: HandlerEvent) => { return new URL(event.rawUrl).pathname } +/** + * Simple Netlify API client + */ export const netlifyApiFetch = ({ endpoint, payload, @@ -266,3 +269,44 @@ export const netlifyApiFetch = ({ req.write(body) req.end() }) + +/** + * Get all paths related to a route including data, i18n and rsc routes + */ +export const getPathsForRoute = ( + route: string, + buildId: string, + i18n?: { + defaultLocale: string + locales: string[] + }, +): string[] => { + const routes = [] + // static files + routes.push(...localizeRoute(route, i18n)) + // data routes + routes.push( + ...localizeRoute(route.endsWith('/') ? route.slice(0, -1) || '/index' : route, i18n, true).map( + (localizeRoute) => `/_next/data/${buildId}${localizeRoute}.json`, + ), + ) + // rsc routes + routes.push(route.endsWith('/') ? `${route.slice(0, -1) || '/index'}.rsc/` : `${route}.rsc`) + return routes +} + +/** + * Localize a route based on i18n config + * (don't localize if i18n is not configured or if the route is the default locale and not a data route) + */ +export const localizeRoute = ( + route: string, + i18n?: { + defaultLocale: string + locales: string[] + }, + data = false, +): string[] => + i18n + ? i18n.locales.map((locale) => (locale === i18n.defaultLocale && data === false ? route : `/${locale}${route}`)) + : [route] diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index 79a663930c..dcc7301517 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -1,52 +1,43 @@ import { NodeRequestHandler, Options } from 'next/dist/server/next-server' -// import { netlifyRoutesForNextRoute } from '../helpers/utils' - -import { netlifyApiFetch, getNextServer, NextServerType } from './handlerUtils' +import { netlifyApiFetch, getNextServer, NextServerType, getPathsForRoute } from './handlerUtils' const NextServer: NextServerType = getNextServer() -interface NetlifyNextServerOptions extends Options { - netlifyRevalidateToken?: string +interface NetlifyOptions { + revalidateToken?: string } class NetlifyNextServer extends NextServer { - private netlifyRevalidateToken?: string + private netlifyOptions: NetlifyOptions - public constructor(options: NetlifyNextServerOptions) { + public constructor(options: Options, netlifyOptions: NetlifyOptions) { super(options) - this.netlifyRevalidateToken = options.netlifyRevalidateToken + this.netlifyOptions = netlifyOptions } public getRequestHandler(): NodeRequestHandler { const handler = super.getRequestHandler() return async (req, res, parsedUrl) => { - // on-demand revalidation request if (req.headers['x-prerender-revalidate']) { + // handle on-demand revalidation by purging the ODB cache await this.netlifyRevalidate(req.url) } + // handle the original res.revalidate() request return handler(req, res, parsedUrl) } } - private async netlifyRevalidate(url: string) { + private async netlifyRevalidate(route: string) { try { - // call netlify API to revalidate the path, including its data routes + // call netlify API to revalidate the path const result = await netlifyApiFetch<{ ok: boolean; code: number; message: string }>({ endpoint: `sites/${process.env.SITE_ID}/refresh_on_demand_builders`, payload: { - paths: [ - url, - // ...netlifyRoutesForNextRoute({ - // route: url, - // buildId: this.buildId, - // i18n: this.nextConfig.i18n, - // }), - // url.endsWith('/') ? `${url.slice(0, -1)}.rsc/` : `${url}.rsc`, - ], + paths: getPathsForRoute(route, this.buildId, this.nextConfig?.i18n), domain: this.hostname, }, - token: this.netlifyRevalidateToken, + token: this.netlifyOptions.revalidateToken, method: 'POST', }) if (result.ok !== true) { From fef1c59af20df9fb95df61a7e834157296a9feaf Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Fri, 10 Mar 2023 15:24:42 +0000 Subject: [PATCH 23/43] chore: refactor tests --- .../default/dynamic-routes.spec.ts | 2 +- packages/runtime/src/templates/server.test.ts | 63 +++++++++---------- 2 files changed, 31 insertions(+), 34 deletions(-) diff --git a/cypress/integration/default/dynamic-routes.spec.ts b/cypress/integration/default/dynamic-routes.spec.ts index 48c8b9181f..6ac972e460 100644 --- a/cypress/integration/default/dynamic-routes.spec.ts +++ b/cypress/integration/default/dynamic-routes.spec.ts @@ -24,7 +24,7 @@ describe('Static Routing', () => { it('revalidates page via refresh hooks on a static route', () => { cy.request({ url: '/api/revalidate/' }).then((res) => { expect(res.status).to.eq(200) - expect(res.body).to.equal({ revalidated: true }) + expect(res.body).to.equal({ code: 200, message: 'success' }) }) }) }) diff --git a/packages/runtime/src/templates/server.test.ts b/packages/runtime/src/templates/server.test.ts index 456620f35c..1c8363514b 100644 --- a/packages/runtime/src/templates/server.test.ts +++ b/packages/runtime/src/templates/server.test.ts @@ -1,63 +1,60 @@ +/* eslint-disable max-nested-callbacks */ import { mockRequest } from 'next/dist/server/lib/mock-request' -import { netlifyApiFetch, getNextServer, NextServerType } from './handlerUtils' +import { getNextServer, NextServerType, netlifyApiFetch } from './handlerUtils' import { NetlifyNextServer } from './server' -const NextServer: NextServerType = getNextServer() - jest.mock('./handlerUtils', () => { const originalModule = jest.requireActual('./handlerUtils') return { __esModule: true, ...originalModule, - netlifyApiFetch: jest.fn(({ payload }) => { - switch (payload.paths[0]) { - case '/getStaticProps/with-revalidate/': - return Promise.resolve({ code: 200, message: 'Revalidated' }) - case '/not-a-path/': - return Promise.resolve({ code: 404, message: '404' }) - default: - return Promise.reject(new Error('Error')) - } - }), + netlifyApiFetch: jest.fn().mockResolvedValue({ ok: true }), } }) +const mockedApiFetch = netlifyApiFetch as jest.MockedFunction -jest.spyOn(NextServer.prototype, 'getRequestHandler').mockImplementation(() => () => Promise.resolve()) - -Object.setPrototypeOf(NetlifyNextServer, jest.fn()) - -const nextServer = new NetlifyNextServer({ conf: {}, dev: false }) -const requestHandler = nextServer.getRequestHandler() +beforeAll(() => { + const NextServer: NextServerType = getNextServer() + jest.spyOn(NextServer.prototype, 'getRequestHandler').mockImplementation(() => () => Promise.resolve()) + Object.setPrototypeOf(NetlifyNextServer, jest.fn()) +}) describe('the netlify next server', () => { - it('intercepts a request containing an x-prerender-revalidate header', async () => { - const { req: mockReq, res: mockRes } = mockRequest( - '/getStaticProps/with-revalidate/', - { 'x-prerender-revalidate': 'test' }, - 'GET', - ) - await requestHandler(mockReq, mockRes) - expect(netlifyApiFetch).toHaveBeenCalled() - }) + it('revalidates a request containing an `x-prerender-revalidate` header', async () => { + const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) + const requestHandler = netlifyNextServer.getRequestHandler() - it('silently revalidates and returns the original handler response', async () => { const { req: mockReq, res: mockRes } = mockRequest( '/getStaticProps/with-revalidate/', { 'x-prerender-revalidate': 'test' }, 'GET', ) - await expect(requestHandler(mockReq, mockRes)).resolves.toBe(undefined) + const response = await requestHandler(mockReq, mockRes) + + expect(mockedApiFetch).toHaveBeenCalled() + expect(response).toBe(undefined) }) - it('throws an error when the revalidate API returns a 404 response', async () => { + it('throws an error when invalid paths are revalidated', async () => { + const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) + const requestHandler = netlifyNextServer.getRequestHandler() + const { req: mockReq, res: mockRes } = mockRequest('/not-a-path/', { 'x-prerender-revalidate': 'test' }, 'GET') - await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unsuccessful revalidate - 404') + + mockedApiFetch.mockResolvedValueOnce({ code: 404, message: 'Invalid paths' }) + await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Invalid paths') }) it('throws an error when the revalidate API is unreachable', async () => { + const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) + const requestHandler = netlifyNextServer.getRequestHandler() + const { req: mockReq, res: mockRes } = mockRequest('', { 'x-prerender-revalidate': 'test' }, 'GET') - await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unsuccessful revalidate - Error') + + mockedApiFetch.mockRejectedValueOnce(new Error('Unable to connect')) + await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unable to connect') }) }) +/* eslint-enable max-nested-callbacks */ From 99ed48ddbcac0e90b894db7f8370ac47973afa54 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Fri, 10 Mar 2023 15:24:50 +0000 Subject: [PATCH 24/43] chore: add tests for handlerUtils --- .../src/templates/handlerUtils.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/runtime/src/templates/handlerUtils.test.ts diff --git a/packages/runtime/src/templates/handlerUtils.test.ts b/packages/runtime/src/templates/handlerUtils.test.ts new file mode 100644 index 0000000000..400469ec28 --- /dev/null +++ b/packages/runtime/src/templates/handlerUtils.test.ts @@ -0,0 +1,52 @@ +import { getPathsForRoute, localizeRoute } from './handlerUtils' + +describe('getPathsForRoute', () => { + it('transforms / (root level) data routes to /index', () => { + expect(getPathsForRoute('/', 'buildId')).toContainEqual(expect.stringMatching(/index.json/)) + }) + it('removes the trailing slash from data routes', () => { + expect(getPathsForRoute('/foo/', 'buildId')).toContainEqual(expect.stringMatching(/foo.json$/)) + }) + it('respects the trailing slash for rsc routes', () => { + expect(getPathsForRoute('/foo', 'buildId')).toContainEqual(expect.stringMatching(/foo.rsc$/)) + expect(getPathsForRoute('/foo/', 'buildId')).toContainEqual(expect.stringMatching(/foo.rsc\/$/)) + }) +}) + +describe('localizeRoute', () => { + it('returns a non-localized path for the default locale', () => { + expect( + localizeRoute('/foo', { + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }), + ).toContain('/foo') + }) + it('returns a localized path for each non-default locale', () => { + expect( + localizeRoute('/foo', { + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }), + ).toEqual(expect.arrayContaining(['/fr/foo', '/de/foo'])) + }) + it('returns every locale for data routes', () => { + expect( + localizeRoute( + '/foo', + { + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }, + true, + ), + ).toEqual([ + expect.stringMatching(/\/en\/foo/), + expect.stringMatching(/\/fr\/foo/), + expect.stringMatching(/\/de\/foo/), + ]) + }) + it('skips localization if i18n not configured', () => { + expect(localizeRoute('/foo')).toEqual(['/foo']) + }) +}) From 3ec181f4c5234e3320198c5a99e9fd6364dc3880 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Fri, 10 Mar 2023 15:26:45 +0000 Subject: [PATCH 25/43] chore: remove eslint directive --- packages/runtime/src/templates/server.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/runtime/src/templates/server.test.ts b/packages/runtime/src/templates/server.test.ts index 1c8363514b..d4de6c83dc 100644 --- a/packages/runtime/src/templates/server.test.ts +++ b/packages/runtime/src/templates/server.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-nested-callbacks */ import { mockRequest } from 'next/dist/server/lib/mock-request' import { getNextServer, NextServerType, netlifyApiFetch } from './handlerUtils' @@ -57,4 +56,3 @@ describe('the netlify next server', () => { await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unable to connect') }) }) -/* eslint-enable max-nested-callbacks */ From 6b4a2cecbbca1d6fcc64c271754065cbefe1c6a1 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Fri, 10 Mar 2023 16:09:39 +0000 Subject: [PATCH 26/43] test: remove rsc trailing slash --- packages/runtime/src/templates/handlerUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/templates/handlerUtils.ts b/packages/runtime/src/templates/handlerUtils.ts index 5f44627302..23e6168a90 100644 --- a/packages/runtime/src/templates/handlerUtils.ts +++ b/packages/runtime/src/templates/handlerUtils.ts @@ -291,7 +291,7 @@ export const getPathsForRoute = ( ), ) // rsc routes - routes.push(route.endsWith('/') ? `${route.slice(0, -1) || '/index'}.rsc/` : `${route}.rsc`) + routes.push(`${route.endsWith('/') ? route.slice(0, -1) || '/index' : route}.rsc`) return routes } From 021f825e3bacf7a8f087e9041b1db0b963edbda3 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Fri, 10 Mar 2023 16:15:54 +0000 Subject: [PATCH 27/43] Revert "test: remove rsc trailing slash" This reverts commit 6b4a2cecbbca1d6fcc64c271754065cbefe1c6a1. --- packages/runtime/src/templates/handlerUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/templates/handlerUtils.ts b/packages/runtime/src/templates/handlerUtils.ts index 23e6168a90..5f44627302 100644 --- a/packages/runtime/src/templates/handlerUtils.ts +++ b/packages/runtime/src/templates/handlerUtils.ts @@ -291,7 +291,7 @@ export const getPathsForRoute = ( ), ) // rsc routes - routes.push(`${route.endsWith('/') ? route.slice(0, -1) || '/index' : route}.rsc`) + routes.push(route.endsWith('/') ? `${route.slice(0, -1) || '/index'}.rsc/` : `${route}.rsc`) return routes } From a40925f712c31976b87652ea412e301767437530 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Tue, 14 Mar 2023 14:58:02 +0000 Subject: [PATCH 28/43] feat: source and transform data routes from prerenderManifest --- .../src/templates/handlerUtils.test.ts | 53 ++++------ .../runtime/src/templates/handlerUtils.ts | 58 ++++++----- packages/runtime/src/templates/server.test.ts | 98 +++++++++++++++++-- packages/runtime/src/templates/server.ts | 64 ++++++++++-- 4 files changed, 204 insertions(+), 69 deletions(-) diff --git a/packages/runtime/src/templates/handlerUtils.test.ts b/packages/runtime/src/templates/handlerUtils.test.ts index 400469ec28..26465e84bf 100644 --- a/packages/runtime/src/templates/handlerUtils.test.ts +++ b/packages/runtime/src/templates/handlerUtils.test.ts @@ -1,52 +1,41 @@ -import { getPathsForRoute, localizeRoute } from './handlerUtils' +import { removeTrailingSlash, ensureLocalePrefix } from './handlerUtils' -describe('getPathsForRoute', () => { - it('transforms / (root level) data routes to /index', () => { - expect(getPathsForRoute('/', 'buildId')).toContainEqual(expect.stringMatching(/index.json/)) +describe('removeTrailingSlash', () => { + it('removes a trailing slash from a string', () => { + expect(removeTrailingSlash('/foo/')).toEqual('/foo') }) - it('removes the trailing slash from data routes', () => { - expect(getPathsForRoute('/foo/', 'buildId')).toContainEqual(expect.stringMatching(/foo.json$/)) + it('ignores a string without a trailing slash', () => { + expect(removeTrailingSlash('/foo')).toEqual('/foo') }) - it('respects the trailing slash for rsc routes', () => { - expect(getPathsForRoute('/foo', 'buildId')).toContainEqual(expect.stringMatching(/foo.rsc$/)) - expect(getPathsForRoute('/foo/', 'buildId')).toContainEqual(expect.stringMatching(/foo.rsc\/$/)) + it('does not remove a slash on its own', () => { + expect(removeTrailingSlash('/')).toEqual('/') }) }) -describe('localizeRoute', () => { - it('returns a non-localized path for the default locale', () => { +describe('ensureLocalePrefix', () => { + it('adds default locale prefix if missing', () => { expect( - localizeRoute('/foo', { + ensureLocalePrefix('/foo', { defaultLocale: 'en', locales: ['en', 'fr', 'de'], }), - ).toContain('/foo') + ).toEqual('/en/foo') }) - it('returns a localized path for each non-default locale', () => { + it('skips prefixing if locale is present', () => { expect( - localizeRoute('/foo', { + ensureLocalePrefix('/fr/foo', { defaultLocale: 'en', locales: ['en', 'fr', 'de'], }), - ).toEqual(expect.arrayContaining(['/fr/foo', '/de/foo'])) - }) - it('returns every locale for data routes', () => { + ).toEqual('/fr/foo') expect( - localizeRoute( - '/foo', - { - defaultLocale: 'en', - locales: ['en', 'fr', 'de'], - }, - true, - ), - ).toEqual([ - expect.stringMatching(/\/en\/foo/), - expect.stringMatching(/\/fr\/foo/), - expect.stringMatching(/\/de\/foo/), - ]) + ensureLocalePrefix('/en/foo', { + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }), + ).toEqual('/en/foo') }) it('skips localization if i18n not configured', () => { - expect(localizeRoute('/foo')).toEqual(['/foo']) + expect(ensureLocalePrefix('/foo')).toEqual('/foo') }) }) diff --git a/packages/runtime/src/templates/handlerUtils.ts b/packages/runtime/src/templates/handlerUtils.ts index 5f44627302..1bdf3ff83d 100644 --- a/packages/runtime/src/templates/handlerUtils.ts +++ b/packages/runtime/src/templates/handlerUtils.ts @@ -271,42 +271,56 @@ export const netlifyApiFetch = ({ }) /** - * Get all paths related to a route including data, i18n and rsc routes + * Remove trailing slash from a route (but not the root route) */ -export const getPathsForRoute = ( +export const removeTrailingSlash = (route: string): string => (route.endsWith('/') ? route.slice(0, -1) || '/' : route) + +/** + * Normalize a data route to include the build ID and index suffix + * + * @param route The route to normalize + * @param buildId The Next.js build ID + * @param i18n The i18n config from next.config.js + * @returns The normalized route + * @example + * normalizeDataRoute('/_next/data/en.json', 'dev', { defaultLocale: 'en', locales: ['en', 'fr'] }) // '/_next/data/dev/en/index.json' + */ +export const normalizeDataRoute = ( route: string, buildId: string, i18n?: { defaultLocale: string locales: string[] }, -): string[] => { - const routes = [] - // static files - routes.push(...localizeRoute(route, i18n)) - // data routes - routes.push( - ...localizeRoute(route.endsWith('/') ? route.slice(0, -1) || '/index' : route, i18n, true).map( - (localizeRoute) => `/_next/data/${buildId}${localizeRoute}.json`, - ), - ) - // rsc routes - routes.push(route.endsWith('/') ? `${route.slice(0, -1) || '/index'}.rsc/` : `${route}.rsc`) - return routes +): string => { + if (route.endsWith('.rsc')) return route + const withBuildId = route.replace(/^\/_next\/data\//, `/_next/data/${buildId}/`) + return i18n && i18n.locales.some((locale) => withBuildId.endsWith(`${buildId}/${locale}.json`)) + ? withBuildId.replace(/\.json$/, '/index.json') + : withBuildId } /** - * Localize a route based on i18n config - * (don't localize if i18n is not configured or if the route is the default locale and not a data route) + * Ensure that a route has a locale prefix + * + * @param route The route to ensure has a locale prefix + * @param i18n The i18n config from next.config.js + * @returns The route with a locale prefix + * @example + * ensureLocalePrefix('/', { defaultLocale: 'en', locales: ['en', 'fr'] }) // '/en' + * ensureLocalePrefix('/foo', { defaultLocale: 'en', locales: ['en', 'fr'] }) // '/en/foo' + * ensureLocalePrefix('/en/foo', { defaultLocale: 'en', locales: ['en', 'fr'] }) // '/en/foo' */ -export const localizeRoute = ( +export const ensureLocalePrefix = ( route: string, i18n?: { defaultLocale: string locales: string[] }, - data = false, -): string[] => +): string => i18n - ? i18n.locales.map((locale) => (locale === i18n.defaultLocale && data === false ? route : `/${locale}${route}`)) - : [route] + ? // eslint-disable-next-line unicorn/no-nested-ternary + i18n.locales.some((locale) => route === `/${locale}` || route.startsWith(`/${locale}/`)) + ? route + : `/${i18n.defaultLocale}${route === '/' ? '' : route}` + : route diff --git a/packages/runtime/src/templates/server.test.ts b/packages/runtime/src/templates/server.test.ts index d4de6c83dc..5e2e77acb6 100644 --- a/packages/runtime/src/templates/server.test.ts +++ b/packages/runtime/src/templates/server.test.ts @@ -14,10 +14,39 @@ jest.mock('./handlerUtils', () => { }) const mockedApiFetch = netlifyApiFetch as jest.MockedFunction +jest.mock( + 'prerender-manifest.json', + () => ({ + routes: { + '/en/getStaticProps/with-revalidate': { + dataRoute: '/_next/data/en/getStaticProps/with-revalidate.json', + }, + }, + dynamicRoutes: { + '/blog/[author]/[slug]': { + routeRegex: '^/blog/([^/]+?)/([^/]+?)(?:/)?$', + dataRoute: '/blog/[author]/[slug].rsc', + }, + }, + }), + { virtual: true }, +) + beforeAll(() => { const NextServer: NextServerType = getNextServer() jest.spyOn(NextServer.prototype, 'getRequestHandler').mockImplementation(() => () => Promise.resolve()) - Object.setPrototypeOf(NetlifyNextServer, jest.fn()) + + const MockNetlifyNextServerConstructor = function () { + this.distDir = '.' + this.buildId = 'build-id' + this.nextConfig = { + i18n: { + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }, + } + } + Object.setPrototypeOf(NetlifyNextServer, MockNetlifyNextServerConstructor) }) describe('the netlify next server', () => { @@ -36,21 +65,78 @@ describe('the netlify next server', () => { expect(response).toBe(undefined) }) - it('throws an error when invalid paths are revalidated', async () => { + it('matches a normalized static route to find the data route', async () => { + const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) + const requestHandler = netlifyNextServer.getRequestHandler() + + const { req: mockReq, res: mockRes } = mockRequest( + '/getStaticProps/with-revalidate/', + { 'x-prerender-revalidate': 'test' }, + 'GET', + ) + await requestHandler(mockReq, mockRes) + + expect(mockedApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + paths: ['/getStaticProps/with-revalidate/', '/_next/data/build-id/en/getStaticProps/with-revalidate.json'], + }), + }), + ) + }) + + it('matches a normalized dynamic route to find the data', async () => { + const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) + const requestHandler = netlifyNextServer.getRequestHandler() + + const { req: mockReq, res: mockRes } = mockRequest('/blog/rob/hello', { 'x-prerender-revalidate': 'test' }, 'GET') + await requestHandler(mockReq, mockRes) + + expect(mockedApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + paths: ['/blog/rob/hello', '/blog/rob/hello.rsc'], + }), + }), + ) + }) + + it('throws an error when route is not found in the manifest', async () => { const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) const requestHandler = netlifyNextServer.getRequestHandler() - const { req: mockReq, res: mockRes } = mockRequest('/not-a-path/', { 'x-prerender-revalidate': 'test' }, 'GET') + const { req: mockReq, res: mockRes } = mockRequest( + '/not-a-valid-path/', + { 'x-prerender-revalidate': 'test' }, + 'GET', + ) - mockedApiFetch.mockResolvedValueOnce({ code: 404, message: 'Invalid paths' }) - await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Invalid paths') + await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('could not find a route') + }) + + it('throws an error when paths are not found by the API', async () => { + const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) + const requestHandler = netlifyNextServer.getRequestHandler() + + const { req: mockReq, res: mockRes } = mockRequest( + '/getStaticProps/with-revalidate/', + { 'x-prerender-revalidate': 'test' }, + 'GET', + ) + + mockedApiFetch.mockResolvedValueOnce({ code: 500, message: 'Failed to revalidate' }) + await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Failed to revalidate') }) it('throws an error when the revalidate API is unreachable', async () => { const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) const requestHandler = netlifyNextServer.getRequestHandler() - const { req: mockReq, res: mockRes } = mockRequest('', { 'x-prerender-revalidate': 'test' }, 'GET') + const { req: mockReq, res: mockRes } = mockRequest( + '/getStaticProps/with-revalidate/', + { 'x-prerender-revalidate': 'test' }, + 'GET', + ) mockedApiFetch.mockRejectedValueOnce(new Error('Unable to connect')) await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unable to connect') diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index dcc7301517..70f159a74e 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -1,6 +1,13 @@ import { NodeRequestHandler, Options } from 'next/dist/server/next-server' -import { netlifyApiFetch, getNextServer, NextServerType, getPathsForRoute } from './handlerUtils' +import { + netlifyApiFetch, + getNextServer, + NextServerType, + removeTrailingSlash, + ensureLocalePrefix, + normalizeDataRoute, +} from './handlerUtils' const NextServer: NextServerType = getNextServer() @@ -19,12 +26,16 @@ class NetlifyNextServer extends NextServer { public getRequestHandler(): NodeRequestHandler { const handler = super.getRequestHandler() return async (req, res, parsedUrl) => { - if (req.headers['x-prerender-revalidate']) { - // handle on-demand revalidation by purging the ODB cache - await this.netlifyRevalidate(req.url) - } + // preserve the URL before Next.js mutates it for i18n + const originalUrl = req.url + // handle the original res.revalidate() request - return handler(req, res, parsedUrl) + await handler(req, res, parsedUrl) + + // handle on-demand revalidation by purging the ODB cache + if (res.statusCode === 200 && req.headers['x-prerender-revalidate']) { + await this.netlifyRevalidate(originalUrl) + } } } @@ -34,20 +45,55 @@ class NetlifyNextServer extends NextServer { const result = await netlifyApiFetch<{ ok: boolean; code: number; message: string }>({ endpoint: `sites/${process.env.SITE_ID}/refresh_on_demand_builders`, payload: { - paths: getPathsForRoute(route, this.buildId, this.nextConfig?.i18n), + paths: this.getNetlifyPathsForRoute(route), domain: this.hostname, }, token: this.netlifyOptions.revalidateToken, method: 'POST', }) - if (result.ok !== true) { + if (!result.ok) { throw new Error(result.message) } } catch (error) { - console.log('Error revalidating', error.message) + console.log(`Error revalidating ${route}:`, error.message) throw error } } + + private getNetlifyPathsForRoute(route: string): string[] { + const { routes, dynamicRoutes } = this.getPrerenderManifest() + + // matches static appDir and non-i18n routes + const normalizedRoute = removeTrailingSlash(route) + if (normalizedRoute in routes) { + const dataRoute = normalizeDataRoute(routes[normalizedRoute].dataRoute, this.buildId) + return [route, dataRoute] + } + + // matches static pageDir i18n routes + const localizedRoute = ensureLocalePrefix(normalizedRoute, this.nextConfig?.i18n) + if (localizedRoute in routes) { + const dataRoute = normalizeDataRoute(routes[localizedRoute].dataRoute, this.buildId, this.nextConfig?.i18n) + return [route, dataRoute] + } + + // matches dynamic routes + for (const dynamicRoute in dynamicRoutes) { + const matches = normalizedRoute.match(dynamicRoutes[dynamicRoute].routeRegex) + if (matches && matches.length !== 0) { + // remove the first match, which is the full route + matches.shift() + // replace the dynamic segments with the actual values + const dataRoute = normalizeDataRoute( + dynamicRoutes[dynamicRoute].dataRoute.replace(/\[(.*?)]/g, () => matches.shift()), + this.buildId, + ) + return [route, dataRoute] + } + } + + throw new Error(`could not find a route to revalidate`) + } } export { NetlifyNextServer } From 6aa81df0d955e1023b0125ce9cc361b31382d130 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Tue, 14 Mar 2023 14:58:14 +0000 Subject: [PATCH 29/43] feat: update revalidate page with more test scenarios --- demos/default/pages/api/revalidate.js | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/demos/default/pages/api/revalidate.js b/demos/default/pages/api/revalidate.js index 2eec96e07e..3ad7762144 100644 --- a/demos/default/pages/api/revalidate.js +++ b/demos/default/pages/api/revalidate.js @@ -1,8 +1,27 @@ export default async function handler(req, res) { + const query = req.query + const select = Number(query.select) || 0 + + const paths = [ + 'getStaticProps/with-revalidate', // missing leading/trailing slash + '/getStaticProps/with-revalidate', // missing trailing slash + '/getStaticProps/with-revalidate/', // valid path + '/en/getStaticProps/with-revalidate/', // valid path (with locale) + '/fr/getStaticProps/with-revalidate/', // valid path (with locale) + '/', // valid path (index) + '/en', // missing trailing slash (index) + '/fr/', // valid path (index with locale) + '/nothing-here/', // 404 + '/getStaticProps/withRevalidate/2/', // valid path (with dynamic route) + '/getStaticProps/withRevalidate/3/', // invalid path (fallback false with dynamic route) + '/getStaticProps/withRevalidate/withFallbackBlocking/3/', // valid path (fallback blocking with dynamic route) + '/blog/nick/', // valid path (with appDir dynamic route) + '/blog/greg/', // invalid path (with appDir dynamic route) + '/blog/rob/hello/', // valid path (with appDir dynamic route) + ] + try { - const path = '/getStaticProps/with-revalidate/' - await res.revalidate(path) - console.log('Revalidated:', path) + await res.revalidate(paths[select]) return res.json({ code: 200, message: 'success' }) } catch (err) { return res.status(500).send({ code: 500, message: err.message }) From 8f60f434e1dbb216e2740e17d5025595f709433d Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Tue, 14 Mar 2023 15:13:44 +0000 Subject: [PATCH 30/43] fix: revalidate api endpoint path order --- demos/default/pages/api/revalidate.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demos/default/pages/api/revalidate.js b/demos/default/pages/api/revalidate.js index 3ad7762144..89595dcd27 100644 --- a/demos/default/pages/api/revalidate.js +++ b/demos/default/pages/api/revalidate.js @@ -3,9 +3,9 @@ export default async function handler(req, res) { const select = Number(query.select) || 0 const paths = [ - 'getStaticProps/with-revalidate', // missing leading/trailing slash - '/getStaticProps/with-revalidate', // missing trailing slash '/getStaticProps/with-revalidate/', // valid path + '/getStaticProps/with-revalidate', // missing trailing slash + 'getStaticProps/with-revalidate', // missing leading/trailing slash '/en/getStaticProps/with-revalidate/', // valid path (with locale) '/fr/getStaticProps/with-revalidate/', // valid path (with locale) '/', // valid path (index) From 9fc412329371116f1d882e9f0b78c633377fe8c6 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Fri, 17 Mar 2023 13:04:32 +0000 Subject: [PATCH 31/43] chore: update revalidate test paths --- demos/default/pages/api/revalidate.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/demos/default/pages/api/revalidate.js b/demos/default/pages/api/revalidate.js index 89595dcd27..c9fbd609e5 100644 --- a/demos/default/pages/api/revalidate.js +++ b/demos/default/pages/api/revalidate.js @@ -4,17 +4,13 @@ export default async function handler(req, res) { const paths = [ '/getStaticProps/with-revalidate/', // valid path - '/getStaticProps/with-revalidate', // missing trailing slash - 'getStaticProps/with-revalidate', // missing leading/trailing slash - '/en/getStaticProps/with-revalidate/', // valid path (with locale) '/fr/getStaticProps/with-revalidate/', // valid path (with locale) '/', // valid path (index) - '/en', // missing trailing slash (index) '/fr/', // valid path (index with locale) - '/nothing-here/', // 404 '/getStaticProps/withRevalidate/2/', // valid path (with dynamic route) '/getStaticProps/withRevalidate/3/', // invalid path (fallback false with dynamic route) '/getStaticProps/withRevalidate/withFallbackBlocking/3/', // valid path (fallback blocking with dynamic route) + '/fr/getStaticProps/withRevalidate/withFallbackBlocking/3/', // valid path (fallback blocking with dynamic route and locale) '/blog/nick/', // valid path (with appDir dynamic route) '/blog/greg/', // invalid path (with appDir dynamic route) '/blog/rob/hello/', // valid path (with appDir dynamic route) From 0e61a2678eb0057a172eb9d14b8e4c2c2dafcb79 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Mon, 20 Mar 2023 17:29:31 +0000 Subject: [PATCH 32/43] fix: cache and normalize manifest routes --- .../runtime/src/templates/handlerUtils.ts | 77 ++++++------------- packages/runtime/src/templates/server.ts | 70 ++++++++++------- 2 files changed, 63 insertions(+), 84 deletions(-) diff --git a/packages/runtime/src/templates/handlerUtils.ts b/packages/runtime/src/templates/handlerUtils.ts index 1bdf3ff83d..4b34769009 100644 --- a/packages/runtime/src/templates/handlerUtils.ts +++ b/packages/runtime/src/templates/handlerUtils.ts @@ -224,9 +224,7 @@ export const normalizePath = (event: HandlerEvent) => { return new URL(event.rawUrl).pathname } -/** - * Simple Netlify API client - */ +// Simple Netlify API client export const netlifyApiFetch = ({ endpoint, payload, @@ -270,57 +268,26 @@ export const netlifyApiFetch = ({ req.end() }) -/** - * Remove trailing slash from a route (but not the root route) - */ -export const removeTrailingSlash = (route: string): string => (route.endsWith('/') ? route.slice(0, -1) || '/' : route) +// Remove trailing slash from a route (except for the root route) +export const normalizeRoute = (route: string): string => (route.endsWith('/') ? route.slice(0, -1) || '/' : route) -/** - * Normalize a data route to include the build ID and index suffix - * - * @param route The route to normalize - * @param buildId The Next.js build ID - * @param i18n The i18n config from next.config.js - * @returns The normalized route - * @example - * normalizeDataRoute('/_next/data/en.json', 'dev', { defaultLocale: 'en', locales: ['en', 'fr'] }) // '/_next/data/dev/en/index.json' - */ -export const normalizeDataRoute = ( - route: string, - buildId: string, - i18n?: { - defaultLocale: string - locales: string[] - }, -): string => { - if (route.endsWith('.rsc')) return route - const withBuildId = route.replace(/^\/_next\/data\//, `/_next/data/${buildId}/`) - return i18n && i18n.locales.some((locale) => withBuildId.endsWith(`${buildId}/${locale}.json`)) - ? withBuildId.replace(/\.json$/, '/index.json') - : withBuildId -} +// Check if a route has a locale prefix (including the root route) +const isLocalized = (route: string, i18n: { defaultLocale: string; locales: string[] }): boolean => + i18n.locales.some((locale) => route === `/${locale}` || route.startsWith(`/${locale}/`)) -/** - * Ensure that a route has a locale prefix - * - * @param route The route to ensure has a locale prefix - * @param i18n The i18n config from next.config.js - * @returns The route with a locale prefix - * @example - * ensureLocalePrefix('/', { defaultLocale: 'en', locales: ['en', 'fr'] }) // '/en' - * ensureLocalePrefix('/foo', { defaultLocale: 'en', locales: ['en', 'fr'] }) // '/en/foo' - * ensureLocalePrefix('/en/foo', { defaultLocale: 'en', locales: ['en', 'fr'] }) // '/en/foo' - */ -export const ensureLocalePrefix = ( - route: string, - i18n?: { - defaultLocale: string - locales: string[] - }, -): string => - i18n - ? // eslint-disable-next-line unicorn/no-nested-ternary - i18n.locales.some((locale) => route === `/${locale}` || route.startsWith(`/${locale}/`)) - ? route - : `/${i18n.defaultLocale}${route === '/' ? '' : route}` - : route +// Remove the locale prefix from a route (if any) +export const unlocalizeRoute = (route: string, i18n: { defaultLocale: string; locales: string[] }): string => + isLocalized(route, i18n) ? `/${route.split('/').slice(2).join('/')}` : route + +// Add the default locale prefix to a route (if necessary) +export const localizeRoute = (route: string, i18n: { defaultLocale: string; locales: string[] }): string => + isLocalized(route, i18n) ? route : normalizeRoute(`/${i18n.defaultLocale}${route}`) + +// Normalize a data route to include the locale prefix and remove the index suffix +export const localizeDataRoute = (dataRoute: string, localizedRoute: string): string => { + if (dataRoute.endsWith('.rsc')) return dataRoute + const locale = localizedRoute.split('/').find(Boolean) + return dataRoute + .replace(new RegExp(`/_next/data/(.+?)/(${locale}/)?`), `/_next/data/$1/${locale}/`) + .replace(/\/index\.json$/, '.json') +} diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index 70f159a74e..086cfc3497 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -1,40 +1,48 @@ +import { PrerenderManifest } from 'next/dist/build' import { NodeRequestHandler, Options } from 'next/dist/server/next-server' import { netlifyApiFetch, getNextServer, NextServerType, - removeTrailingSlash, - ensureLocalePrefix, - normalizeDataRoute, + normalizeRoute, + localizeRoute, + localizeDataRoute, + unlocalizeRoute, } from './handlerUtils' const NextServer: NextServerType = getNextServer() -interface NetlifyOptions { +interface NetlifyConfig { revalidateToken?: string } class NetlifyNextServer extends NextServer { - private netlifyOptions: NetlifyOptions + private netlifyConfig: NetlifyConfig + private netlifyPrerenderManifest: PrerenderManifest - public constructor(options: Options, netlifyOptions: NetlifyOptions) { + public constructor(options: Options, netlifyConfig: NetlifyConfig) { super(options) - this.netlifyOptions = netlifyOptions + this.netlifyConfig = netlifyConfig + // copy the prerender manifest so it doesn't get mutated by Next.js + const manifest = this.getPrerenderManifest() + this.netlifyPrerenderManifest = { + ...manifest, + routes: { ...manifest.routes }, + dynamicRoutes: { ...manifest.dynamicRoutes }, + } } public getRequestHandler(): NodeRequestHandler { const handler = super.getRequestHandler() return async (req, res, parsedUrl) => { // preserve the URL before Next.js mutates it for i18n - const originalUrl = req.url - + const { url, headers } = req // handle the original res.revalidate() request await handler(req, res, parsedUrl) - // handle on-demand revalidation by purging the ODB cache - if (res.statusCode === 200 && req.headers['x-prerender-revalidate']) { - await this.netlifyRevalidate(originalUrl) + if (res.statusCode === 200 && headers['x-prerender-revalidate'] && this.netlifyConfig.revalidateToken) { + await this.netlifyRevalidate(url) } } } @@ -48,7 +56,7 @@ class NetlifyNextServer extends NextServer { paths: this.getNetlifyPathsForRoute(route), domain: this.hostname, }, - token: this.netlifyOptions.revalidateToken, + token: this.netlifyConfig.revalidateToken, method: 'POST', }) if (!result.ok) { @@ -61,39 +69,43 @@ class NetlifyNextServer extends NextServer { } private getNetlifyPathsForRoute(route: string): string[] { - const { routes, dynamicRoutes } = this.getPrerenderManifest() + const { i18n } = this.nextConfig + const { routes, dynamicRoutes } = this.netlifyPrerenderManifest - // matches static appDir and non-i18n routes - const normalizedRoute = removeTrailingSlash(route) + // matches static non-i18n routes + const normalizedRoute = normalizeRoute(route) if (normalizedRoute in routes) { - const dataRoute = normalizeDataRoute(routes[normalizedRoute].dataRoute, this.buildId) + const { dataRoute } = routes[normalizedRoute] return [route, dataRoute] } - // matches static pageDir i18n routes - const localizedRoute = ensureLocalePrefix(normalizedRoute, this.nextConfig?.i18n) - if (localizedRoute in routes) { - const dataRoute = normalizeDataRoute(routes[localizedRoute].dataRoute, this.buildId, this.nextConfig?.i18n) - return [route, dataRoute] + // matches static i18n routes + if (i18n) { + const localizedRoute = localizeRoute(normalizedRoute, i18n) + if (localizedRoute in routes) { + const dataRoute = localizeDataRoute(routes[localizedRoute].dataRoute, localizedRoute) + return [route, dataRoute] + } } // matches dynamic routes for (const dynamicRoute in dynamicRoutes) { - const matches = normalizedRoute.match(dynamicRoutes[dynamicRoute].routeRegex) + const unlocalizedRoute = i18n ? unlocalizeRoute(normalizedRoute, i18n) : normalizedRoute + const matches = unlocalizedRoute.match(dynamicRoutes[dynamicRoute].routeRegex) if (matches && matches.length !== 0) { // remove the first match, which is the full route matches.shift() // replace the dynamic segments with the actual values - const dataRoute = normalizeDataRoute( - dynamicRoutes[dynamicRoute].dataRoute.replace(/\[(.*?)]/g, () => matches.shift()), - this.buildId, - ) + const interpolatedDataRoute = dynamicRoutes[dynamicRoute].dataRoute.replace(/\[(.*?)]/g, () => matches.shift()) + const dataRoute = i18n + ? localizeDataRoute(interpolatedDataRoute, localizeRoute(normalizedRoute, i18n)) + : interpolatedDataRoute return [route, dataRoute] } } - throw new Error(`could not find a route to revalidate`) + throw new Error(`not an ISR route`) } } -export { NetlifyNextServer } +export { NetlifyNextServer, NetlifyConfig } From a8ea2bd7da73caec330536ae2217798cd2891106 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Mon, 20 Mar 2023 17:29:44 +0000 Subject: [PATCH 33/43] chore: update tests for caching and localizing routes --- .../src/templates/handlerUtils.test.ts | 73 ++++++++--- packages/runtime/src/templates/server.test.ts | 116 ++++++++++++------ 2 files changed, 136 insertions(+), 53 deletions(-) diff --git a/packages/runtime/src/templates/handlerUtils.test.ts b/packages/runtime/src/templates/handlerUtils.test.ts index 26465e84bf..7ea6aef7ab 100644 --- a/packages/runtime/src/templates/handlerUtils.test.ts +++ b/packages/runtime/src/templates/handlerUtils.test.ts @@ -1,41 +1,82 @@ -import { removeTrailingSlash, ensureLocalePrefix } from './handlerUtils' +import { normalizeRoute, unlocalizeRoute, localizeRoute, localizeDataRoute } from './handlerUtils' -describe('removeTrailingSlash', () => { - it('removes a trailing slash from a string', () => { - expect(removeTrailingSlash('/foo/')).toEqual('/foo') +describe('normalizeRoute', () => { + it('removes a trailing slash from a route', () => { + expect(normalizeRoute('/foo/')).toEqual('/foo') }) it('ignores a string without a trailing slash', () => { - expect(removeTrailingSlash('/foo')).toEqual('/foo') + expect(normalizeRoute('/foo')).toEqual('/foo') }) - it('does not remove a slash on its own', () => { - expect(removeTrailingSlash('/')).toEqual('/') + it('does not remove a lone slash', () => { + expect(normalizeRoute('/')).toEqual('/') }) }) -describe('ensureLocalePrefix', () => { - it('adds default locale prefix if missing', () => { +describe('unlocalizeRoute', () => { + it('removes the locale prefix from an i18n route', () => { expect( - ensureLocalePrefix('/foo', { + unlocalizeRoute('/fr/foo', { + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }), + ).toEqual('/foo') + }) + it('removes the locale prefix from a root i18n route', () => { + expect( + unlocalizeRoute('/de', { + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }), + ).toEqual('/') + }) + it('does not modify a default locale route', () => { + expect( + unlocalizeRoute('/foo', { + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }), + ).toEqual('/foo') + }) +}) + +describe('localizeRoute', () => { + it('adds the locale prefix to an i18n route', () => { + expect( + localizeRoute('/foo', { defaultLocale: 'en', locales: ['en', 'fr', 'de'], }), ).toEqual('/en/foo') }) - it('skips prefixing if locale is present', () => { + it('adds the locale prefix to a root i18n route', () => { expect( - ensureLocalePrefix('/fr/foo', { + localizeRoute('/', { defaultLocale: 'en', locales: ['en', 'fr', 'de'], }), - ).toEqual('/fr/foo') + ).toEqual('/en') + }) + it('does not modify a prefixed i18n route', () => { expect( - ensureLocalePrefix('/en/foo', { + localizeRoute('/en/foo', { defaultLocale: 'en', locales: ['en', 'fr', 'de'], }), ).toEqual('/en/foo') }) - it('skips localization if i18n not configured', () => { - expect(ensureLocalePrefix('/foo')).toEqual('/foo') +}) + +describe('localizeDataRoute', () => { + it('adds the locale prefix to a data route', () => { + expect(localizeDataRoute('/_next/data/build/foo.json', '/en/foo')).toEqual('/_next/data/build/en/foo.json') + }) + it('removes the index suffix from a root route', () => { + expect(localizeDataRoute('/_next/data/build/index.json', '/en')).toEqual('/_next/data/build/en.json') + }) + it('does not add the locale prefix if it already exists in the data route', () => { + expect(localizeDataRoute('/_next/data/build/en/foo.json', '/en/foo')).toEqual('/_next/data/build/en/foo.json') + }) + it('does not modify an RSC data route', () => { + expect(localizeDataRoute('/foo.rsc', '/foo')).toEqual('/foo.rsc') }) }) diff --git a/packages/runtime/src/templates/server.test.ts b/packages/runtime/src/templates/server.test.ts index 5e2e77acb6..deed45753b 100644 --- a/packages/runtime/src/templates/server.test.ts +++ b/packages/runtime/src/templates/server.test.ts @@ -1,7 +1,9 @@ +/* eslint-disable max-lines-per-function */ import { mockRequest } from 'next/dist/server/lib/mock-request' +import { Options } from 'next/dist/server/next-server' import { getNextServer, NextServerType, netlifyApiFetch } from './handlerUtils' -import { NetlifyNextServer } from './server' +import { NetlifyNextServer, NetlifyConfig } from './server' jest.mock('./handlerUtils', () => { const originalModule = jest.requireActual('./handlerUtils') @@ -14,15 +16,35 @@ jest.mock('./handlerUtils', () => { }) const mockedApiFetch = netlifyApiFetch as jest.MockedFunction +const mocki18nConfig = { + i18n: { + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }, +} + +const mockTokenConfig = { + revalidateToken: 'test', +} + +const mockBuildId = 'build-id' + jest.mock( 'prerender-manifest.json', () => ({ routes: { - '/en/getStaticProps/with-revalidate': { - dataRoute: '/_next/data/en/getStaticProps/with-revalidate.json', + '/non-i18n/with-revalidate': { + dataRoute: `/_next/data/${mockBuildId}/non-i18n/with-revalidate.json`, + }, + '/en/i18n/with-revalidate': { + dataRoute: `/_next/data/${mockBuildId}/i18n/with-revalidate.json`, }, }, dynamicRoutes: { + '/posts/[title]': { + routeRegex: '^/posts/([^/]+?)(?:/)?$', + dataRoute: `/_next/data/${mockBuildId}/posts/[title].json`, + }, '/blog/[author]/[slug]': { routeRegex: '^/blog/([^/]+?)/([^/]+?)(?:/)?$', dataRoute: '/blog/[author]/[slug].rsc', @@ -36,41 +58,52 @@ beforeAll(() => { const NextServer: NextServerType = getNextServer() jest.spyOn(NextServer.prototype, 'getRequestHandler').mockImplementation(() => () => Promise.resolve()) - const MockNetlifyNextServerConstructor = function () { + const MockNetlifyNextServerConstructor = function (nextOptions: Options, netlifyConfig: NetlifyConfig) { this.distDir = '.' - this.buildId = 'build-id' - this.nextConfig = { - i18n: { - defaultLocale: 'en', - locales: ['en', 'fr', 'de'], - }, - } + this.buildId = mockBuildId + this.nextConfig = nextOptions.conf + this.netlifyConfig = netlifyConfig } Object.setPrototypeOf(NetlifyNextServer, MockNetlifyNextServerConstructor) }) describe('the netlify next server', () => { - it('revalidates a request containing an `x-prerender-revalidate` header', async () => { - const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) + it('does not revalidate a request without an `x-prerender-revalidate` header', async () => { + const netlifyNextServer = new NetlifyNextServer({ conf: {} }, { ...mockTokenConfig }) + const requestHandler = netlifyNextServer.getRequestHandler() + + const { req: mockReq, res: mockRes } = mockRequest('/getStaticProps/with-revalidate/', {}, 'GET') + await requestHandler(mockReq, mockRes) + + expect(mockedApiFetch).not.toHaveBeenCalled() + }) + + it('revalidates a static non-i18n route with an `x-prerender-revalidate` header', async () => { + const netlifyNextServer = new NetlifyNextServer({ conf: {} }, { ...mockTokenConfig }) const requestHandler = netlifyNextServer.getRequestHandler() const { req: mockReq, res: mockRes } = mockRequest( - '/getStaticProps/with-revalidate/', + '/non-i18n/with-revalidate/', { 'x-prerender-revalidate': 'test' }, 'GET', ) - const response = await requestHandler(mockReq, mockRes) + await requestHandler(mockReq, mockRes) - expect(mockedApiFetch).toHaveBeenCalled() - expect(response).toBe(undefined) + expect(mockedApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + paths: ['/non-i18n/with-revalidate/', `/_next/data/${mockBuildId}/non-i18n/with-revalidate.json`], + }), + }), + ) }) - it('matches a normalized static route to find the data route', async () => { - const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) + it('revalidates a static i18n route with an `x-prerender-revalidate` header', async () => { + const netlifyNextServer = new NetlifyNextServer({ conf: { ...mocki18nConfig } }, { ...mockTokenConfig }) const requestHandler = netlifyNextServer.getRequestHandler() const { req: mockReq, res: mockRes } = mockRequest( - '/getStaticProps/with-revalidate/', + '/i18n/with-revalidate/', { 'x-prerender-revalidate': 'test' }, 'GET', ) @@ -79,14 +112,14 @@ describe('the netlify next server', () => { expect(mockedApiFetch).toHaveBeenCalledWith( expect.objectContaining({ payload: expect.objectContaining({ - paths: ['/getStaticProps/with-revalidate/', '/_next/data/build-id/en/getStaticProps/with-revalidate.json'], + paths: ['/i18n/with-revalidate/', `/_next/data/${mockBuildId}/en/i18n/with-revalidate.json`], }), }), ) }) - it('matches a normalized dynamic route to find the data', async () => { - const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) + it('revalidates a dynamic non-i18n route with an `x-prerender-revalidate` header', async () => { + const netlifyNextServer = new NetlifyNextServer({ conf: {} }, { ...mockTokenConfig }) const requestHandler = netlifyNextServer.getRequestHandler() const { req: mockReq, res: mockRes } = mockRequest('/blog/rob/hello', { 'x-prerender-revalidate': 'test' }, 'GET') @@ -101,8 +134,24 @@ describe('the netlify next server', () => { ) }) + it('revalidates a dynamic i18n route with an `x-prerender-revalidate` header', async () => { + const netlifyNextServer = new NetlifyNextServer({ conf: { ...mocki18nConfig } }, { ...mockTokenConfig }) + const requestHandler = netlifyNextServer.getRequestHandler() + + const { req: mockReq, res: mockRes } = mockRequest('/fr/posts/hello', { 'x-prerender-revalidate': 'test' }, 'GET') + await requestHandler(mockReq, mockRes) + + expect(mockedApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + paths: ['/fr/posts/hello', `/_next/data/${mockBuildId}/fr/posts/hello.json`], + }), + }), + ) + }) + it('throws an error when route is not found in the manifest', async () => { - const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) + const netlifyNextServer = new NetlifyNextServer({ conf: {} }, mockTokenConfig) const requestHandler = netlifyNextServer.getRequestHandler() const { req: mockReq, res: mockRes } = mockRequest( @@ -111,34 +160,27 @@ describe('the netlify next server', () => { 'GET', ) - await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('could not find a route') + await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('not an ISR route') }) it('throws an error when paths are not found by the API', async () => { - const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) + const netlifyNextServer = new NetlifyNextServer({ conf: {} }, mockTokenConfig) const requestHandler = netlifyNextServer.getRequestHandler() - const { req: mockReq, res: mockRes } = mockRequest( - '/getStaticProps/with-revalidate/', - { 'x-prerender-revalidate': 'test' }, - 'GET', - ) + const { req: mockReq, res: mockRes } = mockRequest('/posts/hello/', { 'x-prerender-revalidate': 'test' }, 'GET') mockedApiFetch.mockResolvedValueOnce({ code: 500, message: 'Failed to revalidate' }) await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Failed to revalidate') }) it('throws an error when the revalidate API is unreachable', async () => { - const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {}) + const netlifyNextServer = new NetlifyNextServer({ conf: {} }, mockTokenConfig) const requestHandler = netlifyNextServer.getRequestHandler() - const { req: mockReq, res: mockRes } = mockRequest( - '/getStaticProps/with-revalidate/', - { 'x-prerender-revalidate': 'test' }, - 'GET', - ) + const { req: mockReq, res: mockRes } = mockRequest('/posts/hello', { 'x-prerender-revalidate': 'test' }, 'GET') mockedApiFetch.mockRejectedValueOnce(new Error('Unable to connect')) await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unable to connect') }) }) +/* eslint-enable max-lines-per-function */ From 36acbdba3b8954813e6736877212ca81338d020d Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Mon, 20 Mar 2023 17:31:09 +0000 Subject: [PATCH 34/43] chore: fix eslint complaint --- packages/runtime/src/templates/server.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/runtime/src/templates/server.test.ts b/packages/runtime/src/templates/server.test.ts index deed45753b..b9c3bac22a 100644 --- a/packages/runtime/src/templates/server.test.ts +++ b/packages/runtime/src/templates/server.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines-per-function */ import { mockRequest } from 'next/dist/server/lib/mock-request' import { Options } from 'next/dist/server/next-server' @@ -183,4 +182,3 @@ describe('the netlify next server', () => { await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unable to connect') }) }) -/* eslint-enable max-lines-per-function */ From d53a11dcb7483c39db31853c7b879faa4e06ec1f Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Tue, 21 Mar 2023 09:32:23 +0000 Subject: [PATCH 35/43] chore: update cypress tests --- .../default/dynamic-routes.spec.ts | 6 -- .../integration/default/revalidate.spec.ts | 68 +++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 cypress/integration/default/revalidate.spec.ts diff --git a/cypress/integration/default/dynamic-routes.spec.ts b/cypress/integration/default/dynamic-routes.spec.ts index 6ac972e460..266f6faa43 100644 --- a/cypress/integration/default/dynamic-routes.spec.ts +++ b/cypress/integration/default/dynamic-routes.spec.ts @@ -21,12 +21,6 @@ describe('Static Routing', () => { expect(res.body).to.contain('Dancing with the Stars') }) }) - it('revalidates page via refresh hooks on a static route', () => { - cy.request({ url: '/api/revalidate/' }).then((res) => { - expect(res.status).to.eq(200) - expect(res.body).to.equal({ code: 200, message: 'success' }) - }) - }) }) describe('Dynamic Routing', () => { diff --git a/cypress/integration/default/revalidate.spec.ts b/cypress/integration/default/revalidate.spec.ts new file mode 100644 index 0000000000..b4dc33ae10 --- /dev/null +++ b/cypress/integration/default/revalidate.spec.ts @@ -0,0 +1,68 @@ +describe('On-demand revalidation', () => { + it('revalidates static ISR route with default locale', () => { + cy.request({ url: '/api/revalidate/?select=0' }).then((res) => { + expect(res.status).to.eq(200) + expect(res.body).to.have.property('message', 'success') + }) + }) + it('revalidates static ISR route with non-default locale', () => { + cy.request({ url: '/api/revalidate/?select=1' }).then((res) => { + expect(res.status).to.eq(200) + expect(res.body).to.have.property('message', 'success') + }) + }) + it('revalidates root static ISR route with default locale', () => { + cy.request({ url: '/api/revalidate/?select=2' }).then((res) => { + expect(res.status).to.eq(200) + expect(res.body).to.have.property('message', 'success') + }) + }) + it('revalidates root static ISR route with non-default locale', () => { + cy.request({ url: '/api/revalidate/?select=3' }).then((res) => { + expect(res.status).to.eq(200) + expect(res.body).to.have.property('message', 'success') + }) + }) + it('revalidates dynamic prerendered ISR route with default locale', () => { + cy.request({ url: '/api/revalidate/?select=4' }).then((res) => { + expect(res.status).to.eq(200) + expect(res.body).to.have.property('message', 'success') + }) + }) + it('fails to revalidate dynamic non-prerendered ISR route with fallback false', () => { + cy.request({ url: '/api/revalidate/?select=5' }).then((res) => { + expect(res.status).to.eq(500) + expect(res.body).to.have.property('message', '404') + }) + }) + it('revalidates dynamic non-prerendered ISR route with fallback blocking', () => { + cy.request({ url: '/api/revalidate/?select=6' }).then((res) => { + expect(res.status).to.eq(200) + expect(res.body).to.have.property('message', 'success') + }) + }) + it('revalidates dynamic non-prerendered ISR route with fallback blocking and non-default locale', () => { + cy.request({ url: '/api/revalidate/?select=7' }).then((res) => { + expect(res.status).to.eq(200) + expect(res.body).to.have.property('message', 'success') + }) + }) + it('revalidates dynamic prerendered appDir route', () => { + cy.request({ url: '/api/revalidate/?select=8' }).then((res) => { + expect(res.status).to.eq(200) + expect(res.body).to.have.property('message', 'success') + }) + }) + it('fails to revalidate dynamic non-prerendered appDir route', () => { + cy.request({ url: '/api/revalidate/?select=9' }).then((res) => { + expect(res.status).to.eq(500) + expect(res.body).to.have.property('message', '404') + }) + }) + it('revalidates dynamic prerendered appDir route with catch-all params', () => { + cy.request({ url: '/api/revalidate/?select=10' }).then((res) => { + expect(res.status).to.eq(200) + expect(res.body).to.have.property('message', 'success') + }) + }) +}) From 4e8daa582cb20c1710c6f62d73b8951cf20fbb5e Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Tue, 21 Mar 2023 15:05:10 +0000 Subject: [PATCH 36/43] fix: consolidated static route matching for revalidate with i18n --- packages/runtime/src/templates/server.ts | 29 +++++++++--------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index 086cfc3497..ad0908bc3b 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -72,35 +72,28 @@ class NetlifyNextServer extends NextServer { const { i18n } = this.nextConfig const { routes, dynamicRoutes } = this.netlifyPrerenderManifest - // matches static non-i18n routes - const normalizedRoute = normalizeRoute(route) + // matches static routes + const normalizedRoute = normalizeRoute(i18n ? localizeRoute(route, i18n) : route) if (normalizedRoute in routes) { const { dataRoute } = routes[normalizedRoute] - return [route, dataRoute] - } - - // matches static i18n routes - if (i18n) { - const localizedRoute = localizeRoute(normalizedRoute, i18n) - if (localizedRoute in routes) { - const dataRoute = localizeDataRoute(routes[localizedRoute].dataRoute, localizedRoute) - return [route, dataRoute] - } + const normalizedDataRoute = i18n ? localizeDataRoute(dataRoute, normalizedRoute) : dataRoute + return [route, normalizedDataRoute] } // matches dynamic routes + const unlocalizedRoute = i18n ? unlocalizeRoute(normalizedRoute, i18n) : normalizedRoute for (const dynamicRoute in dynamicRoutes) { - const unlocalizedRoute = i18n ? unlocalizeRoute(normalizedRoute, i18n) : normalizedRoute - const matches = unlocalizedRoute.match(dynamicRoutes[dynamicRoute].routeRegex) + const { dataRoute, routeRegex } = dynamicRoutes[dynamicRoute] + const matches = unlocalizedRoute.match(routeRegex) if (matches && matches.length !== 0) { // remove the first match, which is the full route matches.shift() // replace the dynamic segments with the actual values - const interpolatedDataRoute = dynamicRoutes[dynamicRoute].dataRoute.replace(/\[(.*?)]/g, () => matches.shift()) - const dataRoute = i18n - ? localizeDataRoute(interpolatedDataRoute, localizeRoute(normalizedRoute, i18n)) + const interpolatedDataRoute = dataRoute.replace(/\[(.*?)]/g, () => matches.shift()) + const normalizedDataRoute = i18n + ? localizeDataRoute(interpolatedDataRoute, normalizedRoute) : interpolatedDataRoute - return [route, dataRoute] + return [route, normalizedDataRoute] } } From 8bcdb1cc245e7c30420ec7328702236065bda4c7 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Tue, 21 Mar 2023 15:06:28 +0000 Subject: [PATCH 37/43] chore: improve comments on revalidate api route --- demos/default/pages/api/revalidate.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/demos/default/pages/api/revalidate.js b/demos/default/pages/api/revalidate.js index c9fbd609e5..b9e89583fb 100644 --- a/demos/default/pages/api/revalidate.js +++ b/demos/default/pages/api/revalidate.js @@ -11,9 +11,9 @@ export default async function handler(req, res) { '/getStaticProps/withRevalidate/3/', // invalid path (fallback false with dynamic route) '/getStaticProps/withRevalidate/withFallbackBlocking/3/', // valid path (fallback blocking with dynamic route) '/fr/getStaticProps/withRevalidate/withFallbackBlocking/3/', // valid path (fallback blocking with dynamic route and locale) - '/blog/nick/', // valid path (with appDir dynamic route) - '/blog/greg/', // invalid path (with appDir dynamic route) - '/blog/rob/hello/', // valid path (with appDir dynamic route) + '/blog/nick/', // valid path (with prerendered appDir dynamic route) + '/blog/greg/', // invalid path (with non-prerendered appDir dynamic route) + '/blog/rob/hello/', // valid path (with appDir dynamic route catch-all) ] try { From ce62c925d22dcb9921e3fe1dd1f1d9f01f184f2f Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Tue, 21 Mar 2023 15:06:46 +0000 Subject: [PATCH 38/43] chore: update snapshots and cypress tests --- cypress/integration/default/dynamic-routes.spec.ts | 2 +- cypress/integration/default/revalidate.spec.ts | 9 +++++---- test/__snapshots__/index.spec.js.snap | 5 +++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/cypress/integration/default/dynamic-routes.spec.ts b/cypress/integration/default/dynamic-routes.spec.ts index 266f6faa43..6c0d9e8cd4 100644 --- a/cypress/integration/default/dynamic-routes.spec.ts +++ b/cypress/integration/default/dynamic-routes.spec.ts @@ -17,7 +17,7 @@ describe('Static Routing', () => { it('renders correct page via ODB on a static route', () => { cy.request({ url: '/getStaticProps/with-revalidate/', headers: { 'x-nf-debug-logging': '1' } }).then((res) => { expect(res.status).to.eq(200) - expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=300') expect(res.body).to.contain('Dancing with the Stars') }) }) diff --git a/cypress/integration/default/revalidate.spec.ts b/cypress/integration/default/revalidate.spec.ts index b4dc33ae10..e600cc30b6 100644 --- a/cypress/integration/default/revalidate.spec.ts +++ b/cypress/integration/default/revalidate.spec.ts @@ -30,9 +30,10 @@ describe('On-demand revalidation', () => { }) }) it('fails to revalidate dynamic non-prerendered ISR route with fallback false', () => { - cy.request({ url: '/api/revalidate/?select=5' }).then((res) => { + cy.request({ url: '/api/revalidate/?select=5', failOnStatusCode: false }).then((res) => { expect(res.status).to.eq(500) - expect(res.body).to.have.property('message', '404') + expect(res.body).to.have.property('message') + expect(res.body.message).to.include('Invalid response 404') }) }) it('revalidates dynamic non-prerendered ISR route with fallback blocking', () => { @@ -55,8 +56,8 @@ describe('On-demand revalidation', () => { }) it('fails to revalidate dynamic non-prerendered appDir route', () => { cy.request({ url: '/api/revalidate/?select=9' }).then((res) => { - expect(res.status).to.eq(500) - expect(res.body).to.have.property('message', '404') + expect(res.status).to.eq(200) + expect(res.body).to.have.property('message', 'success') }) }) it('revalidates dynamic prerendered appDir route with catch-all params', () => { diff --git a/test/__snapshots__/index.spec.js.snap b/test/__snapshots__/index.spec.js.snap index 31b8ba4181..0c670c522a 100644 --- a/test/__snapshots__/index.spec.js.snap +++ b/test/__snapshots__/index.spec.js.snap @@ -26,6 +26,7 @@ Array [ ".next/server/pages/api/hello-scheduled.js", ".next/server/pages/api/hello.js", ".next/server/pages/api/og.js", + ".next/server/pages/api/revalidate.js", ".next/server/pages/api/shows/[...params].js", ".next/server/pages/api/shows/[id].js", ".next/server/pages/deep/import.js", @@ -123,6 +124,7 @@ exports.resolvePages = () => { require.resolve('../../../.next/server/pages/api/hello-scheduled.js') require.resolve('../../../.next/server/pages/api/hello.js') require.resolve('../../../.next/server/pages/api/og.js') + require.resolve('../../../.next/server/pages/api/revalidate.js') require.resolve('../../../.next/server/pages/api/shows/[...params].js') require.resolve('../../../.next/server/pages/api/shows/[id].js') require.resolve('../../../.next/server/pages/deep/import.js') @@ -183,6 +185,7 @@ exports.resolvePages = () => { require.resolve('../../../.next/server/pages/api/hello-scheduled.js') require.resolve('../../../.next/server/pages/api/hello.js') require.resolve('../../../.next/server/pages/api/og.js') + require.resolve('../../../.next/server/pages/api/revalidate.js') require.resolve('../../../.next/server/pages/api/shows/[...params].js') require.resolve('../../../.next/server/pages/api/shows/[id].js') require.resolve('../../../.next/server/pages/deep/import.js') @@ -243,6 +246,7 @@ exports.resolvePages = () => { require.resolve('../../../web/.next/server/pages/api/hello-scheduled.js') require.resolve('../../../web/.next/server/pages/api/hello.js') require.resolve('../../../web/.next/server/pages/api/og.js') + require.resolve('../../../web/.next/server/pages/api/revalidate.js') require.resolve('../../../web/.next/server/pages/api/shows/[...params].js') require.resolve('../../../web/.next/server/pages/api/shows/[id].js') require.resolve('../../../web/.next/server/pages/deep/import.js') @@ -303,6 +307,7 @@ exports.resolvePages = () => { require.resolve('../../../web/.next/server/pages/api/hello-scheduled.js') require.resolve('../../../web/.next/server/pages/api/hello.js') require.resolve('../../../web/.next/server/pages/api/og.js') + require.resolve('../../../web/.next/server/pages/api/revalidate.js') require.resolve('../../../web/.next/server/pages/api/shows/[...params].js') require.resolve('../../../web/.next/server/pages/api/shows/[id].js') require.resolve('../../../web/.next/server/pages/deep/import.js') From f85d2f5f9ae9cb7eb09a6131c4987fc2769c5b8d Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 22 Mar 2023 11:04:35 +0000 Subject: [PATCH 39/43] chore: reset with-revalidate TTL after testing --- demos/default/pages/getStaticProps/with-revalidate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/default/pages/getStaticProps/with-revalidate.js b/demos/default/pages/getStaticProps/with-revalidate.js index d0c0066b95..c816345012 100644 --- a/demos/default/pages/getStaticProps/with-revalidate.js +++ b/demos/default/pages/getStaticProps/with-revalidate.js @@ -25,7 +25,7 @@ export async function getStaticProps(context) { time: new Date().toISOString(), }, // ODB handler will use the minimum TTL=60s - revalidate: 300, + revalidate: 1, } } From a19778353c23c837be2449823f4060394be49d93 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 22 Mar 2023 11:04:51 +0000 Subject: [PATCH 40/43] chore: add comment about paths to revalidate endpoint --- demos/default/pages/api/revalidate.js | 1 + 1 file changed, 1 insertion(+) diff --git a/demos/default/pages/api/revalidate.js b/demos/default/pages/api/revalidate.js index b9e89583fb..bd901b2cff 100644 --- a/demos/default/pages/api/revalidate.js +++ b/demos/default/pages/api/revalidate.js @@ -2,6 +2,7 @@ export default async function handler(req, res) { const query = req.query const select = Number(query.select) || 0 + // these paths are used for e2e testing res.revalidate() const paths = [ '/getStaticProps/with-revalidate/', // valid path '/fr/getStaticProps/with-revalidate/', // valid path (with locale) From d029976e84b72f892be985ad092ccb9ac1df4f13 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 22 Mar 2023 11:27:45 +0000 Subject: [PATCH 41/43] chore: update cypress test to match old ttl for revalidate api --- cypress/integration/default/dynamic-routes.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/integration/default/dynamic-routes.spec.ts b/cypress/integration/default/dynamic-routes.spec.ts index 6c0d9e8cd4..266f6faa43 100644 --- a/cypress/integration/default/dynamic-routes.spec.ts +++ b/cypress/integration/default/dynamic-routes.spec.ts @@ -17,7 +17,7 @@ describe('Static Routing', () => { it('renders correct page via ODB on a static route', () => { cy.request({ url: '/getStaticProps/with-revalidate/', headers: { 'x-nf-debug-logging': '1' } }).then((res) => { expect(res.status).to.eq(200) - expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=300') + expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') expect(res.body).to.contain('Dancing with the Stars') }) }) From 1445c261f12ec152bcb48ff6583ef502c09236a6 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 22 Mar 2023 12:10:48 +0000 Subject: [PATCH 42/43] chore: move tests to resolve edge clash --- package.json | 1 - .../handlerUtils.test.ts => test/handlerUtils.spec.ts | 7 ++++++- .../src/templates/server.test.ts => test/server.spec.ts | 8 ++++---- 3 files changed, 10 insertions(+), 6 deletions(-) rename packages/runtime/src/templates/handlerUtils.test.ts => test/handlerUtils.spec.ts (94%) rename packages/runtime/src/templates/server.test.ts => test/server.spec.ts (95%) diff --git a/package.json b/package.json index 7f49fea1d2..d71d9b98d3 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,6 @@ "testMatch": [ "**/test/**/*.spec.js", "**/test/**/*.spec.ts", - "**/*.test.ts", "!**/test/e2e/**", "!**/test/fixtures/**", "!**/test/sample/**", diff --git a/packages/runtime/src/templates/handlerUtils.test.ts b/test/handlerUtils.spec.ts similarity index 94% rename from packages/runtime/src/templates/handlerUtils.test.ts rename to test/handlerUtils.spec.ts index 7ea6aef7ab..caf41d5c7d 100644 --- a/packages/runtime/src/templates/handlerUtils.test.ts +++ b/test/handlerUtils.spec.ts @@ -1,4 +1,9 @@ -import { normalizeRoute, unlocalizeRoute, localizeRoute, localizeDataRoute } from './handlerUtils' +import { + normalizeRoute, + unlocalizeRoute, + localizeRoute, + localizeDataRoute, +} from '../packages/runtime/src/templates/handlerUtils' describe('normalizeRoute', () => { it('removes a trailing slash from a route', () => { diff --git a/packages/runtime/src/templates/server.test.ts b/test/server.spec.ts similarity index 95% rename from packages/runtime/src/templates/server.test.ts rename to test/server.spec.ts index b9c3bac22a..330f82d41a 100644 --- a/packages/runtime/src/templates/server.test.ts +++ b/test/server.spec.ts @@ -1,11 +1,11 @@ import { mockRequest } from 'next/dist/server/lib/mock-request' import { Options } from 'next/dist/server/next-server' -import { getNextServer, NextServerType, netlifyApiFetch } from './handlerUtils' -import { NetlifyNextServer, NetlifyConfig } from './server' +import { getNextServer, NextServerType, netlifyApiFetch } from '../packages/runtime/src/templates/handlerUtils' +import { NetlifyNextServer, NetlifyConfig } from '../packages/runtime/src/templates/server' -jest.mock('./handlerUtils', () => { - const originalModule = jest.requireActual('./handlerUtils') +jest.mock('../packages/runtime/src/templates/handlerUtils', () => { + const originalModule = jest.requireActual('../packages/runtime/src/templates/handlerUtils') return { __esModule: true, From fd32f38d9d4e1f8c89f99770f82fb312cfab5998 Mon Sep 17 00:00:00 2001 From: Rob Stanford Date: Wed, 22 Mar 2023 14:40:15 +0000 Subject: [PATCH 43/43] chore: add comments to functions generation --- packages/runtime/src/helpers/functions.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/runtime/src/helpers/functions.ts b/packages/runtime/src/helpers/functions.ts index d2923a8860..8370650273 100644 --- a/packages/runtime/src/helpers/functions.ts +++ b/packages/runtime/src/helpers/functions.ts @@ -45,7 +45,11 @@ export const generateFunctions = async ( }) const functionName = getFunctionNameForPage(route, config.type === ApiRouteType.BACKGROUND) await ensureDir(join(functionsDir, functionName)) + + // write main API handler file await writeFile(join(functionsDir, functionName, `${functionName}.js`), apiHandlerSource) + + // copy handler dependencies (VercelNodeBridge, NetlifyNextServer, etc.) await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js')) await copyFile( join(__dirname, '..', '..', 'lib', 'templates', 'server.js'), @@ -69,7 +73,11 @@ export const generateFunctions = async ( const writeHandler = async (functionName: string, isODB: boolean) => { const handlerSource = await getHandler({ isODB, publishDir, appDir: relative(functionDir, appDir) }) await ensureDir(join(functionsDir, functionName)) + + // write main handler file (standard or ODB) await writeFile(join(functionsDir, functionName, `${functionName}.js`), handlerSource) + + // copy handler dependencies (VercelNodeBridge, NetlifyNextServer, etc.) await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js')) await copyFile( join(__dirname, '..', '..', 'lib', 'templates', 'server.js'),