diff --git a/cypress/integration/default/revalidate.spec.ts b/cypress/integration/default/revalidate.spec.ts new file mode 100644 index 0000000000..e600cc30b6 --- /dev/null +++ b/cypress/integration/default/revalidate.spec.ts @@ -0,0 +1,69 @@ +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', failOnStatusCode: false }).then((res) => { + expect(res.status).to.eq(500) + 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', () => { + 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(200) + expect(res.body).to.have.property('message', 'success') + }) + }) + 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') + }) + }) +}) diff --git a/demos/default/pages/api/revalidate.js b/demos/default/pages/api/revalidate.js new file mode 100644 index 0000000000..bd901b2cff --- /dev/null +++ b/demos/default/pages/api/revalidate.js @@ -0,0 +1,26 @@ +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) + '/', // valid path (index) + '/fr/', // valid path (index with locale) + '/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 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 { + await res.revalidate(paths[select]) + return res.json({ code: 200, message: 'success' }) + } catch (err) { + return res.status(500).send({ code: 500, message: err.message }) + } +} diff --git a/demos/default/pages/getStaticProps/with-revalidate.js b/demos/default/pages/getStaticProps/with-revalidate.js index 97f3b0d62c..c816345012 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: 1, diff --git a/packages/runtime/src/helpers/functions.ts b/packages/runtime/src/helpers/functions.ts index f451abb321..8370650273 100644 --- a/packages/runtime/src/helpers/functions.ts +++ b/packages/runtime/src/helpers/functions.ts @@ -45,8 +45,16 @@ 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'), + join(functionsDir, functionName, 'server.js'), + ) await copyFile( join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'), join(functionsDir, functionName, 'handlerUtils.js'), @@ -65,8 +73,16 @@ 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'), + 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..cde440b4f8 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 = { @@ -32,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 @@ -68,7 +65,11 @@ 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 => { + const { + clientContext: { custom: customContext }, + } = context + if (bridge) { return bridge } @@ -76,14 +77,18 @@ 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({ - conf, - dir, - customServer: false, - hostname: url.hostname, - port, - }) + 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 { @@ -119,7 +124,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 @@ -180,6 +185,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/handlerUtils.ts b/packages/runtime/src/templates/handlerUtils.ts index 8d7df830e2..4b34769009 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,71 @@ export const normalizePath = (event: HandlerEvent) => { // Ensure that paths are encoded - but don't double-encode them return new URL(event.rawUrl).pathname } + +// Simple Netlify API client +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() + }) + +// Remove trailing slash from a route (except for the root route) +export const normalizeRoute = (route: string): string => (route.endsWith('/') ? route.slice(0, -1) || '/' : route) + +// 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}/`)) + +// 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 new file mode 100644 index 0000000000..ad0908bc3b --- /dev/null +++ b/packages/runtime/src/templates/server.ts @@ -0,0 +1,104 @@ +import { PrerenderManifest } from 'next/dist/build' +import { NodeRequestHandler, Options } from 'next/dist/server/next-server' + +import { + netlifyApiFetch, + getNextServer, + NextServerType, + normalizeRoute, + localizeRoute, + localizeDataRoute, + unlocalizeRoute, +} from './handlerUtils' + +const NextServer: NextServerType = getNextServer() + +interface NetlifyConfig { + revalidateToken?: string +} + +class NetlifyNextServer extends NextServer { + private netlifyConfig: NetlifyConfig + private netlifyPrerenderManifest: PrerenderManifest + + public constructor(options: Options, netlifyConfig: NetlifyConfig) { + super(options) + 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 { 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 && headers['x-prerender-revalidate'] && this.netlifyConfig.revalidateToken) { + await this.netlifyRevalidate(url) + } + } + } + + private async netlifyRevalidate(route: string) { + try { + // 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: this.getNetlifyPathsForRoute(route), + domain: this.hostname, + }, + token: this.netlifyConfig.revalidateToken, + method: 'POST', + }) + if (!result.ok) { + throw new Error(result.message) + } + } catch (error) { + console.log(`Error revalidating ${route}:`, error.message) + throw error + } + } + + private getNetlifyPathsForRoute(route: string): string[] { + const { i18n } = this.nextConfig + const { routes, dynamicRoutes } = this.netlifyPrerenderManifest + + // matches static routes + const normalizedRoute = normalizeRoute(i18n ? localizeRoute(route, i18n) : route) + if (normalizedRoute in routes) { + const { dataRoute } = routes[normalizedRoute] + 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 { 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 = dataRoute.replace(/\[(.*?)]/g, () => matches.shift()) + const normalizedDataRoute = i18n + ? localizeDataRoute(interpolatedDataRoute, normalizedRoute) + : interpolatedDataRoute + return [route, normalizedDataRoute] + } + } + + throw new Error(`not an ISR route`) + } +} + +export { NetlifyNextServer, NetlifyConfig } diff --git a/test/__snapshots__/index.spec.js.snap b/test/__snapshots__/index.spec.js.snap index bcc1c998a3..2ed4316548 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') diff --git a/test/handlerUtils.spec.ts b/test/handlerUtils.spec.ts new file mode 100644 index 0000000000..caf41d5c7d --- /dev/null +++ b/test/handlerUtils.spec.ts @@ -0,0 +1,87 @@ +import { + normalizeRoute, + unlocalizeRoute, + localizeRoute, + localizeDataRoute, +} from '../packages/runtime/src/templates/handlerUtils' + +describe('normalizeRoute', () => { + it('removes a trailing slash from a route', () => { + expect(normalizeRoute('/foo/')).toEqual('/foo') + }) + it('ignores a string without a trailing slash', () => { + expect(normalizeRoute('/foo')).toEqual('/foo') + }) + it('does not remove a lone slash', () => { + expect(normalizeRoute('/')).toEqual('/') + }) +}) + +describe('unlocalizeRoute', () => { + it('removes the locale prefix from an i18n route', () => { + expect( + 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('adds the locale prefix to a root i18n route', () => { + expect( + localizeRoute('/', { + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }), + ).toEqual('/en') + }) + it('does not modify a prefixed i18n route', () => { + expect( + localizeRoute('/en/foo', { + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }), + ).toEqual('/en/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/test/server.spec.ts b/test/server.spec.ts new file mode 100644 index 0000000000..330f82d41a --- /dev/null +++ b/test/server.spec.ts @@ -0,0 +1,184 @@ +import { mockRequest } from 'next/dist/server/lib/mock-request' +import { Options } from 'next/dist/server/next-server' + +import { getNextServer, NextServerType, netlifyApiFetch } from '../packages/runtime/src/templates/handlerUtils' +import { NetlifyNextServer, NetlifyConfig } from '../packages/runtime/src/templates/server' + +jest.mock('../packages/runtime/src/templates/handlerUtils', () => { + const originalModule = jest.requireActual('../packages/runtime/src/templates/handlerUtils') + + return { + __esModule: true, + ...originalModule, + netlifyApiFetch: jest.fn().mockResolvedValue({ ok: true }), + } +}) +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: { + '/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', + }, + }, + }), + { virtual: true }, +) + +beforeAll(() => { + const NextServer: NextServerType = getNextServer() + jest.spyOn(NextServer.prototype, 'getRequestHandler').mockImplementation(() => () => Promise.resolve()) + + const MockNetlifyNextServerConstructor = function (nextOptions: Options, netlifyConfig: NetlifyConfig) { + this.distDir = '.' + this.buildId = mockBuildId + this.nextConfig = nextOptions.conf + this.netlifyConfig = netlifyConfig + } + Object.setPrototypeOf(NetlifyNextServer, MockNetlifyNextServerConstructor) +}) + +describe('the netlify next server', () => { + 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( + '/non-i18n/with-revalidate/', + { 'x-prerender-revalidate': 'test' }, + 'GET', + ) + await requestHandler(mockReq, mockRes) + + expect(mockedApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + paths: ['/non-i18n/with-revalidate/', `/_next/data/${mockBuildId}/non-i18n/with-revalidate.json`], + }), + }), + ) + }) + + 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( + '/i18n/with-revalidate/', + { 'x-prerender-revalidate': 'test' }, + 'GET', + ) + await requestHandler(mockReq, mockRes) + + expect(mockedApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + paths: ['/i18n/with-revalidate/', `/_next/data/${mockBuildId}/en/i18n/with-revalidate.json`], + }), + }), + ) + }) + + 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') + await requestHandler(mockReq, mockRes) + + expect(mockedApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + paths: ['/blog/rob/hello', '/blog/rob/hello.rsc'], + }), + }), + ) + }) + + 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: {} }, mockTokenConfig) + const requestHandler = netlifyNextServer.getRequestHandler() + + const { req: mockReq, res: mockRes } = mockRequest( + '/not-a-valid-path/', + { 'x-prerender-revalidate': 'test' }, + 'GET', + ) + + 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: {} }, mockTokenConfig) + const requestHandler = netlifyNextServer.getRequestHandler() + + 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: {} }, mockTokenConfig) + const requestHandler = netlifyNextServer.getRequestHandler() + + 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') + }) +})