Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update on-demand ISR to skip fetch locally #35386

Merged
merged 2 commits into from Mar 17, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
94 changes: 50 additions & 44 deletions packages/next/server/api-utils/node.ts
Expand Up @@ -31,6 +31,7 @@ import {
SYMBOL_PREVIEW_DATA,
RESPONSE_LIMIT_DEFAULT,
} from './index'
import { mockRequest } from '../lib/mock-request'

export function tryGetPreviewData(
req: IncomingMessage | BaseNextRequest,
Expand Down Expand Up @@ -149,16 +150,17 @@ export async function parseBody(
}
}

type ApiContext = __ApiPreviewProps & {
trustHostHeader?: boolean
revalidate?: (_req: IncomingMessage, _res: ServerResponse) => Promise<any>
}

export async function apiResolver(
req: IncomingMessage,
res: ServerResponse,
query: any,
resolverModule: any,
apiContext: __ApiPreviewProps & {
trustHostHeader?: boolean
hostname?: string
port?: number
},
apiContext: ApiContext,
propagateError: boolean,
dev?: boolean,
page?: string
Expand Down Expand Up @@ -277,55 +279,59 @@ export async function apiResolver(

async function unstable_revalidate(
urlPath: string,
req: IncomingMessage | BaseNextRequest,
context: {
hostname?: string
port?: number
previewModeId: string
trustHostHeader?: boolean
}
req: IncomingMessage,
context: ApiContext
) {
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`
)
}

if (typeof urlPath !== 'string' || !urlPath.startsWith('/')) {
throw new Error(
`Invalid urlPath provided to revalidate(), must be a path e.g. /blog/post-1, received ${urlPath}`
)
}

const baseUrl = context.trustHostHeader
? `https://${req.headers.host}`
: `http://${context.hostname}:${context.port}`

const extraHeaders: Record<string, string | undefined> = {}

if (context.trustHostHeader) {
extraHeaders.cookie = req.headers.cookie
}

try {
const res = await fetch(`${baseUrl}${urlPath}`, {
headers: {
[PRERENDER_REVALIDATE_HEADER]: context.previewModeId,
...extraHeaders,
},
})

// we use the cache header to determine successful revalidate as
// a non-200 status code can be returned from a successful revalidate
// e.g. notFound: true returns 404 status code but is successful
const cacheHeader =
res.headers.get('x-vercel-cache') || res.headers.get('x-nextjs-cache')
if (context.trustHostHeader) {
const res = await fetch(`https://${req.headers.host}${urlPath}`, {
headers: {
[PRERENDER_REVALIDATE_HEADER]: context.previewModeId,
cookie: req.headers.cookie || '',
},
})
// we use the cache header to determine successful revalidate as
// a non-200 status code can be returned from a successful revalidate
// e.g. notFound: true returns 404 status code but is successful
const cacheHeader =
res.headers.get('x-vercel-cache') || res.headers.get('x-nextjs-cache')

if (cacheHeader?.toUpperCase() !== 'REVALIDATED') {
throw new Error(`Invalid response ${res.status}`)
}
} else if (context.revalidate) {
const {
req: mockReq,
res: mockRes,
streamPromise,
} = mockRequest(
urlPath,
{
[PRERENDER_REVALIDATE_HEADER]: context.previewModeId,
},
'GET'
)
await context.revalidate(mockReq, mockRes)
await streamPromise

if (cacheHeader?.toUpperCase() !== 'REVALIDATED') {
throw new Error(`Invalid response ${res.status}`)
if (mockRes.getHeader('x-nextjs-cache') !== 'REVALIDATED') {
throw new Error(`Invalid response ${mockRes.status}`)
}
} else {
throw new Error(
`Invariant: required internal revalidate method not passed to api-utils`
)
}
} catch (err) {
throw new Error(`Failed to revalidate ${urlPath}`)
} catch (err: unknown) {
throw new Error(
`Failed to revalidate ${urlPath}: ${isError(err) ? err.message : err}`
)
}
}

Expand Down
62 changes: 7 additions & 55 deletions packages/next/server/image-optimizer.ts
Expand Up @@ -8,7 +8,6 @@ import { IncomingMessage, ServerResponse } from 'http'
import isAnimated from 'next/dist/compiled/is-animated'
import contentDisposition from 'next/dist/compiled/content-disposition'
import { join } from 'path'
import Stream from 'stream'
import nodeUrl, { UrlWithParsedQuery } from 'url'
import { NextConfigComplete } from './config-shared'
import { processBuffer, decodeBuffer, Operation } from './lib/squoosh/main'
Expand All @@ -17,6 +16,7 @@ import { getContentType, getExtension } from './serve-static'
import chalk from 'next/dist/compiled/chalk'
import { NextUrlWithParsedQuery } from './request-meta'
import { IncrementalCacheEntry, IncrementalCacheValue } from './response-cache'
import { mockRequest } from './lib/mock-request'

type XCacheHeader = 'MISS' | 'HIT' | 'STALE'

Expand Down Expand Up @@ -307,60 +307,12 @@ export async function imageOptimizer(
maxAge = getMaxAge(upstreamRes.headers.get('Cache-Control'))
} else {
try {
const resBuffers: Buffer[] = []
const mockRes: any = new Stream.Writable()

const isStreamFinished = new Promise(function (resolve, reject) {
mockRes.on('finish', () => resolve(true))
mockRes.on('end', () => resolve(true))
mockRes.on('error', (err: any) => reject(err))
})

mockRes.write = (chunk: Buffer | string) => {
resBuffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
mockRes._write = (
chunk: Buffer | string,
_encoding: string,
callback: () => void
) => {
mockRes.write(chunk)
// According to Node.js documentation, the callback MUST be invoked to signal that
// the write completed successfully. If this callback is not invoked, the 'finish' event
// will not be emitted.
// https://nodejs.org/docs/latest-v16.x/api/stream.html#writable_writechunk-encoding-callback
callback()
}

const mockHeaders: Record<string, string | string[]> = {}

mockRes.writeHead = (_status: any, _headers: any) =>
Object.assign(mockHeaders, _headers)
mockRes.getHeader = (name: string) => mockHeaders[name.toLowerCase()]
mockRes.getHeaders = () => mockHeaders
mockRes.getHeaderNames = () => Object.keys(mockHeaders)
mockRes.setHeader = (name: string, value: string | string[]) =>
(mockHeaders[name.toLowerCase()] = value)
mockRes.removeHeader = (name: string) => {
delete mockHeaders[name.toLowerCase()]
}
mockRes._implicitHeader = () => {}
mockRes.connection = _res.connection
mockRes.finished = false
mockRes.statusCode = 200

const mockReq: any = new Stream.Readable()

mockReq._read = () => {
mockReq.emit('end')
mockReq.emit('close')
return Buffer.from('')
}

mockReq.headers = _req.headers
mockReq.method = _req.method
mockReq.url = href
mockReq.connection = _req.connection
const {
resBuffers,
req: mockReq,
res: mockRes,
streamPromise: isStreamFinished,
} = mockRequest(href, _req.headers, _req.method || 'GET', _req.connection)

await handleRequest(mockReq, mockRes, nodeUrl.parse(href, true))
await isStreamFinished
Expand Down
70 changes: 70 additions & 0 deletions packages/next/server/lib/mock-request.ts
@@ -0,0 +1,70 @@
import Stream from 'stream'

export function mockRequest(
requestUrl: string,
requestHeaders: Record<string, string | string[] | undefined>,
requestMethod: string,
requestConnection?: any
) {
const resBuffers: Buffer[] = []
const mockRes: any = new Stream.Writable()

const isStreamFinished = new Promise(function (resolve, reject) {
mockRes.on('finish', () => resolve(true))
mockRes.on('end', () => resolve(true))
mockRes.on('error', (err: any) => reject(err))
})

mockRes.write = (chunk: Buffer | string) => {
resBuffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
mockRes._write = (
chunk: Buffer | string,
_encoding: string,
callback: () => void
) => {
mockRes.write(chunk)
// According to Node.js documentation, the callback MUST be invoked to signal that
// the write completed successfully. If this callback is not invoked, the 'finish' event
// will not be emitted.
// https://nodejs.org/docs/latest-v16.x/api/stream.html#writable_writechunk-encoding-callback
callback()
}

const mockHeaders: Record<string, string | string[]> = {}

mockRes.writeHead = (_status: any, _headers: any) =>
Object.assign(mockHeaders, _headers)
mockRes.getHeader = (name: string) => mockHeaders[name.toLowerCase()]
mockRes.getHeaders = () => mockHeaders
mockRes.getHeaderNames = () => Object.keys(mockHeaders)
mockRes.setHeader = (name: string, value: string | string[]) =>
(mockHeaders[name.toLowerCase()] = value)
mockRes.removeHeader = (name: string) => {
delete mockHeaders[name.toLowerCase()]
}
mockRes._implicitHeader = () => {}
mockRes.connection = requestConnection
mockRes.finished = false
mockRes.statusCode = 200

const mockReq: any = new Stream.Readable()

mockReq._read = () => {
mockReq.emit('end')
mockReq.emit('close')
return Buffer.from('')
}

mockReq.headers = requestHeaders
mockReq.method = requestMethod
mockReq.url = requestUrl
mockReq.connection = requestConnection

return {
resBuffers,
req: mockReq,
res: mockRes,
streamPromise: isStreamFinished,
}
}
7 changes: 5 additions & 2 deletions packages/next/server/next-server.ts
Expand Up @@ -554,8 +554,11 @@ export default class NextNodeServer extends BaseServer {
pageModule,
{
...this.renderOpts.previewProps,
port: this.port,
hostname: this.hostname,
revalidate: (newReq: IncomingMessage, newRes: ServerResponse) =>
this.getRequestHandler()(
new NodeNextRequest(newReq),
new NodeNextResponse(newRes)
),
// internal config so is not typed
trustHostHeader: (this.nextConfig.experimental as any).trustHostHeader,
},
Expand Down