From 8cb50b3f482b013a6bbe0ce60ae9cd5405952861 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 4 Feb 2022 16:10:14 -0600 Subject: [PATCH] Add support for on-demand revalidate --- packages/next/build/index.ts | 9 +- packages/next/server/api-utils.ts | 44 +++++- packages/next/server/base-server.ts | 23 ++- packages/next/server/next-server.ts | 8 +- packages/next/server/response-cache.ts | 14 +- packages/next/shared/lib/utils.ts | 2 + test/e2e/prerender.test.ts | 141 ++++++++++++++++++ .../prerender/pages/api/manual-revalidate.js | 10 ++ .../pages/blocking-fallback-once/[slug].js | 2 +- .../pages/blocking-fallback/[slug].js | 2 +- .../pages/catchall-explicit/[...slug].js | 6 +- 11 files changed, 248 insertions(+), 13 deletions(-) create mode 100644 test/e2e/prerender/pages/api/manual-revalidate.js diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index c10e46307b8b894..8190ae3beebfb1e 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -550,7 +550,14 @@ export default async function build( .traceChild('generate-required-server-files') .traceFn(() => ({ version: 1, - config: { ...config, configFile: undefined }, + config: { + ...config, + configFile: undefined, + experimental: { + ...config.experimental, + trustHostHeader: ciEnvironment.hasNextSupport, + }, + }, appDir: dir, files: [ ROUTES_MANIFEST, diff --git a/packages/next/server/api-utils.ts b/packages/next/server/api-utils.ts index a8bc956f4f6bfc3..d5244556a4319f7 100644 --- a/packages/next/server/api-utils.ts +++ b/packages/next/server/api-utils.ts @@ -26,7 +26,11 @@ export async function apiResolver( res: ServerResponse, query: any, resolverModule: any, - apiContext: __ApiPreviewProps, + apiContext: __ApiPreviewProps & { + trustHostHeader?: boolean + hostname?: string + port?: number + }, propagateError: boolean, dev?: boolean, page?: string @@ -95,6 +99,8 @@ export async function apiResolver( apiRes.setPreviewData = (data, options = {}) => setPreviewData(apiRes, data, Object.assign({}, apiContext, options)) apiRes.clearPreviewData = () => clearPreviewData(apiRes) + apiRes.unstable_revalidate = (urlPath: string) => + unstable_revalidate(urlPath, req, apiContext) const resolver = interopDefault(resolverModule) let wasPiped = false @@ -334,6 +340,42 @@ export function sendJson(res: NextApiResponse, jsonBody: any): void { res.send(jsonBody) } +const PRERENDER_REVALIDATE_HEADER = 'x-prerender-revalidate' + +export function checkIsManualRevalidate( + req: IncomingMessage | BaseNextRequest, + previewProps: __ApiPreviewProps +): boolean { + return req.headers[PRERENDER_REVALIDATE_HEADER] === previewProps.previewModeId +} + +async function unstable_revalidate( + urlPath: string, + req: IncomingMessage | BaseNextRequest, + context: { + hostname?: string + port?: number + previewModeId: string + trustHostHeader?: boolean + } +) { + if (!context.trustHostHeader && (!context.hostname || !context.port)) { + throw new Error( + `"hostname" and "port" must be provided when starting next to use "unstable_revalidate". See more here https://nextjs.org/docs/advanced-features/custom-server` + ) + } + + const baseUrl = context.trustHostHeader + ? `https://${req.headers.host}` + : `http://${context.hostname}:${context.port}` + + return fetch(`${baseUrl}${urlPath}`, { + headers: { + [PRERENDER_REVALIDATE_HEADER]: context.previewModeId, + }, + }) +} + const COOKIE_NAME_PRERENDER_BYPASS = `__prerender_bypass` const COOKIE_NAME_PRERENDER_DATA = `__next_preview_data` diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index 0d28b29ae6be5ff..cf1f5946978d648 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -58,6 +58,7 @@ import { MIDDLEWARE_ROUTE } from '../lib/constants' import { addRequestMeta, getRequestMeta } from './request-meta' import { createHeaderRoute, createRedirectRoute } from './server-route-utils' import { PrerenderManifest } from '../build' +import { checkIsManualRevalidate } from '../server/api-utils' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -1165,6 +1166,15 @@ export default abstract class Server { isPreviewMode = previewData !== false } + let isManualRevalidate = false + + if (isSSG) { + isManualRevalidate = checkIsManualRevalidate( + req, + this.renderOpts.previewProps + ) + } + // Compute the iSSG cache key. We use the rewroteUrl since // pages with fallback: false are allowed to be rewritten to // and we need to look up the path by the rewritten path @@ -1230,7 +1240,7 @@ export default abstract class Server { let ssgCacheKey = isPreviewMode || !isSSG || this.minimalMode || opts.supportsDynamicHTML - ? null // Preview mode bypasses the cache + ? null // Preview mode and manual revalidate bypasses the cache : `${locale ? `/${locale}` : ''}${ (pathname === '/' || resolvedUrlPathname === '/') && locale ? '' @@ -1356,7 +1366,7 @@ export default abstract class Server { const cacheEntry = await this.responseCache.get( ssgCacheKey, - async (hasResolved) => { + async (hasResolved, hadCache) => { const isProduction = !this.renderOpts.dev const isDynamicPathname = isDynamicRoute(pathname) const didRespond = hasResolved || res.sent @@ -1372,6 +1382,12 @@ export default abstract class Server { fallbackMode = 'blocking' } + // only allow manual revalidate for fallback: true/blocking + // or for prerendered fallback: false paths + if (isManualRevalidate && (fallbackMode !== false || hadCache)) { + fallbackMode = 'blocking' + } + // When we did not respond from cache, we need to choose to block on // rendering or return a skeleton. // @@ -1456,6 +1472,9 @@ export default abstract class Server { ? result.revalidate : /* default to minimum revalidate (this should be an invariant) */ 1, } + }, + { + isManualRevalidate, } ) diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 38ec642e66cef78..de5021e037d6c98 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -451,7 +451,13 @@ export default class NextNodeServer extends BaseServer { res.originalResponse, query, pageModule, - this.renderOpts.previewProps, + { + ...this.renderOpts.previewProps, + port: this.port, + hostname: this.hostname, + // internal config so is not typed + trustHostHeader: (this.nextConfig.experimental as any).trustHostHeader, + }, this.minimalMode, this.renderOpts.dev, page diff --git a/packages/next/server/response-cache.ts b/packages/next/server/response-cache.ts index 2279733e721e406..5cdc23fae7b36c5 100644 --- a/packages/next/server/response-cache.ts +++ b/packages/next/server/response-cache.ts @@ -20,7 +20,8 @@ export type ResponseCacheEntry = { } type ResponseGenerator = ( - hasResolved: boolean + hasResolved: boolean, + hadCache: boolean ) => Promise export default class ResponseCache { @@ -34,7 +35,8 @@ export default class ResponseCache { public get( key: string | null, - responseGenerator: ResponseGenerator + responseGenerator: ResponseGenerator, + context: { isManualRevalidate?: boolean } ): Promise { const pendingResponse = key ? this.pendingResponses.get(key) : null if (pendingResponse) { @@ -71,7 +73,11 @@ export default class ResponseCache { ;(async () => { try { const cachedResponse = key ? await this.incrementalCache.get(key) : null - if (cachedResponse) { + if ( + cachedResponse && + (!context.isManualRevalidate || + cachedResponse.revalidateAfter === false) + ) { resolve({ revalidate: cachedResponse.curRevalidate, value: @@ -90,7 +96,7 @@ export default class ResponseCache { } } - const cacheEntry = await responseGenerator(resolved) + const cacheEntry = await responseGenerator(resolved, !!cachedResponse) resolve(cacheEntry) if (key && cacheEntry && typeof cacheEntry.revalidate !== 'undefined') { diff --git a/packages/next/shared/lib/utils.ts b/packages/next/shared/lib/utils.ts index 62bf0bc356a298e..4ee84008d349e40 100644 --- a/packages/next/shared/lib/utils.ts +++ b/packages/next/shared/lib/utils.ts @@ -293,6 +293,8 @@ export type NextApiResponse = ServerResponse & { } ) => NextApiResponse clearPreviewData: () => NextApiResponse + + unstable_revalidate: (urlPath: string) => Promise } /** diff --git a/test/e2e/prerender.test.ts b/test/e2e/prerender.test.ts index 9e82de16ddf2e95..db468d359955eaa 100644 --- a/test/e2e/prerender.test.ts +++ b/test/e2e/prerender.test.ts @@ -1890,6 +1890,147 @@ describe('Prerender', () => { } }) } + + if (!(global as any).isNextDev) { + it('should handle manual revalidate for fallback: blocking', async () => { + const html = await renderViaHTTP( + next.url, + '/blocking-fallback/test-manual-1' + ) + const $ = cheerio.load(html) + const initialTime = $('#time').text() + + expect($('p').text()).toMatch(/Post:.*?test-manual-1/) + + const html2 = await renderViaHTTP( + next.url, + '/blocking-fallback/test-manual-1' + ) + const $2 = cheerio.load(html2) + + expect(initialTime).toBe($2('#time').text()) + + const res = await fetchViaHTTP( + next.url, + '/api/manual-revalidate', + { + pathname: '/blocking-fallback/test-manual-1', + }, + { redirect: 'manual' } + ) + + expect(res.status).toBe(200) + const revalidateData = await res.json() + const revalidatedText = revalidateData.text + const $3 = cheerio.load(revalidatedText) + expect(revalidateData.status).toBe(200) + expect($3('#time').text()).not.toBe(initialTime) + + const html4 = await renderViaHTTP( + next.url, + '/blocking-fallback/test-manual-1' + ) + const $4 = cheerio.load(html4) + expect($4('#time').text()).not.toBe(initialTime) + expect($3('#time').text()).toBe($4('#time').text()) + }) + + it('should not manual revalidate for revalidate: false', async () => { + const html = await renderViaHTTP( + next.url, + '/blocking-fallback-once/test-manual-1' + ) + const $ = cheerio.load(html) + const initialTime = $('#time').text() + + expect($('p').text()).toMatch(/Post:.*?test-manual-1/) + + const html2 = await renderViaHTTP( + next.url, + '/blocking-fallback-once/test-manual-1' + ) + const $2 = cheerio.load(html2) + + expect(initialTime).toBe($2('#time').text()) + + const res = await fetchViaHTTP( + next.url, + '/api/manual-revalidate', + { + pathname: '/blocking-fallback-once/test-manual-1', + }, + { redirect: 'manual' } + ) + + expect(res.status).toBe(200) + const revalidateData = await res.json() + const revalidatedText = revalidateData.text + const $3 = cheerio.load(revalidatedText) + expect(revalidateData.status).toBe(200) + expect($3('#time').text()).toBe(initialTime) + + const html4 = await renderViaHTTP( + next.url, + '/blocking-fallback-once/test-manual-1' + ) + const $4 = cheerio.load(html4) + expect($4('#time').text()).toBe(initialTime) + expect($3('#time').text()).toBe($4('#time').text()) + }) + + it('should handle manual revalidate for fallback: false', async () => { + const res = await fetchViaHTTP( + next.url, + '/catchall-explicit/test-manual-1' + ) + expect(res.status).toBe(404) + + // fallback: false pages should only manually revalidate + // prerendered paths + const res2 = await fetchViaHTTP( + next.url, + '/api/manual-revalidate', + { + pathname: '/catchall-explicity/test-manual-1', + }, + { redirect: 'manual' } + ) + + expect(res2.status).toBe(200) + const revalidateData = await res2.json() + expect(revalidateData.status).toBe(404) + + const res3 = await fetchViaHTTP( + next.url, + '/catchall-explicit/test-manual-1' + ) + expect(res3.status).toBe(404) + + const res4 = await fetchViaHTTP(next.url, '/catchall-explicit/first') + expect(res4.status).toBe(200) + const html = await res4.text() + const $ = cheerio.load(html) + const initialTime = $('#time').text() + + const res5 = await fetchViaHTTP( + next.url, + '/api/manual-revalidate', + { + pathname: '/catchall-explicit/first', + }, + { redirect: 'manual' } + ) + expect(res5.status).toBe(200) + expect((await res5.json()).status).toBe(200) + + const res6 = await fetchViaHTTP(next.url, '/catchall-explicit/first') + expect(res6.status).toBe(200) + const html2 = await res6.text() + const $2 = cheerio.load(html2) + + expect(initialTime).not.toBe($2('#time').text()) + }) + } } runTests((global as any).isNextDev) }) diff --git a/test/e2e/prerender/pages/api/manual-revalidate.js b/test/e2e/prerender/pages/api/manual-revalidate.js new file mode 100644 index 000000000000000..c1d9479c3292e72 --- /dev/null +++ b/test/e2e/prerender/pages/api/manual-revalidate.js @@ -0,0 +1,10 @@ +export default async function handler(req, res) { + // WARNING: don't use user input in production + // make sure to use trusted value for revalidating + const revalidateRes = await res.unstable_revalidate(req.query.pathname) + res.json({ + revalidated: true, + status: revalidateRes.status, + text: await revalidateRes.text(), + }) +} diff --git a/test/e2e/prerender/pages/blocking-fallback-once/[slug].js b/test/e2e/prerender/pages/blocking-fallback-once/[slug].js index 5b047ded890c0c6..4ca33a678ebeed0 100644 --- a/test/e2e/prerender/pages/blocking-fallback-once/[slug].js +++ b/test/e2e/prerender/pages/blocking-fallback-once/[slug].js @@ -32,7 +32,7 @@ export default ({ post, time, params }) => { return ( <>

Post: {post}

- time: {time} + time: {time}
{JSON.stringify(params)}
{JSON.stringify(useRouter().query)}
diff --git a/test/e2e/prerender/pages/blocking-fallback/[slug].js b/test/e2e/prerender/pages/blocking-fallback/[slug].js index 0b39f579352ca6f..aa6aeec99ac98a8 100644 --- a/test/e2e/prerender/pages/blocking-fallback/[slug].js +++ b/test/e2e/prerender/pages/blocking-fallback/[slug].js @@ -32,7 +32,7 @@ export default ({ post, time, params }) => { return ( <>

Post: {post}

- time: {time} + time: {time}
{JSON.stringify(params)}
{JSON.stringify(useRouter().query)}
diff --git a/test/e2e/prerender/pages/catchall-explicit/[...slug].js b/test/e2e/prerender/pages/catchall-explicit/[...slug].js index dcdd57e35e5701b..103b5650af58b25 100644 --- a/test/e2e/prerender/pages/catchall-explicit/[...slug].js +++ b/test/e2e/prerender/pages/catchall-explicit/[...slug].js @@ -8,6 +8,7 @@ export async function getStaticProps({ params: { slug } }) { return { props: { slug, + time: Date.now(), }, revalidate: 1, } @@ -27,12 +28,13 @@ export async function getStaticPaths() { } } -export default function Page({ slug }) { +export default function Page({ slug, time }) { // Important to not check for `slug` existence (testing that build does not // render fallback version and error) return ( <> -

Hi {slug.join(' ')}

{' '} +

Hi {slug.join(' ')}

+

time: {time}

to home