Skip to content

Commit

Permalink
Add api-utils helper for testing (#34078)
Browse files Browse the repository at this point in the history
  • Loading branch information
ijjk committed Feb 8, 2022
1 parent c0dc008 commit 7cd9de3
Show file tree
Hide file tree
Showing 11 changed files with 248 additions and 13 deletions.
9 changes: 8 additions & 1 deletion packages/next/build/index.ts
Expand Up @@ -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,
Expand Down
44 changes: 43 additions & 1 deletion packages/next/server/api-utils.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down
23 changes: 21 additions & 2 deletions packages/next/server/base-server.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
? ''
Expand Down Expand Up @@ -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
Expand All @@ -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.
//
Expand Down Expand Up @@ -1456,6 +1472,9 @@ export default abstract class Server {
? result.revalidate
: /* default to minimum revalidate (this should be an invariant) */ 1,
}
},
{
isManualRevalidate,
}
)

Expand Down
8 changes: 7 additions & 1 deletion packages/next/server/next-server.ts
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions packages/next/server/response-cache.ts
Expand Up @@ -20,7 +20,8 @@ export type ResponseCacheEntry = {
}

type ResponseGenerator = (
hasResolved: boolean
hasResolved: boolean,
hadCache: boolean
) => Promise<ResponseCacheEntry | null>

export default class ResponseCache {
Expand All @@ -34,7 +35,8 @@ export default class ResponseCache {

public get(
key: string | null,
responseGenerator: ResponseGenerator
responseGenerator: ResponseGenerator,
context: { isManualRevalidate?: boolean }
): Promise<ResponseCacheEntry | null> {
const pendingResponse = key ? this.pendingResponses.get(key) : null
if (pendingResponse) {
Expand Down Expand Up @@ -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:
Expand All @@ -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') {
Expand Down
2 changes: 2 additions & 0 deletions packages/next/shared/lib/utils.ts
Expand Up @@ -293,6 +293,8 @@ export type NextApiResponse<T = any> = ServerResponse & {
}
) => NextApiResponse<T>
clearPreviewData: () => NextApiResponse<T>

unstable_revalidate: (urlPath: string) => Promise<Response>
}

/**
Expand Down
141 changes: 141 additions & 0 deletions test/e2e/prerender.test.ts
Expand Up @@ -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)
})
10 changes: 10 additions & 0 deletions 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(),
})
}
2 changes: 1 addition & 1 deletion test/e2e/prerender/pages/blocking-fallback-once/[slug].js
Expand Up @@ -32,7 +32,7 @@ export default ({ post, time, params }) => {
return (
<>
<p>Post: {post}</p>
<span>time: {time}</span>
<span id="time">time: {time}</span>
<div id="params">{JSON.stringify(params)}</div>
<div id="query">{JSON.stringify(useRouter().query)}</div>
<Link href="/">
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/prerender/pages/blocking-fallback/[slug].js
Expand Up @@ -32,7 +32,7 @@ export default ({ post, time, params }) => {
return (
<>
<p>Post: {post}</p>
<span>time: {time}</span>
<span id="time">time: {time}</span>
<div id="params">{JSON.stringify(params)}</div>
<div id="query">{JSON.stringify(useRouter().query)}</div>
<Link href="/">
Expand Down

0 comments on commit 7cd9de3

Please sign in to comment.