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')
+ })
+})