diff --git a/packages/next/server/api-utils/node.ts b/packages/next/server/api-utils/node.ts index 73f4d6d8cd77..68f928a23755 100644 --- a/packages/next/server/api-utils/node.ts +++ b/packages/next/server/api-utils/node.ts @@ -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, @@ -149,16 +150,17 @@ export async function parseBody( } } +type ApiContext = __ApiPreviewProps & { + trustHostHeader?: boolean + revalidate?: (_req: IncomingMessage, _res: ServerResponse) => Promise +} + 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 @@ -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 = {} - - 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}` + ) } } diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index 9adec2dd8a1d..8a84ce7a8bb5 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -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' @@ -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' @@ -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 = {} - - 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 diff --git a/packages/next/server/lib/mock-request.ts b/packages/next/server/lib/mock-request.ts new file mode 100644 index 000000000000..5d25ba0ddd10 --- /dev/null +++ b/packages/next/server/lib/mock-request.ts @@ -0,0 +1,70 @@ +import Stream from 'stream' + +export function mockRequest( + requestUrl: string, + requestHeaders: Record, + 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 = {} + + 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, + } +} diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 83a8d7e00787..d7fa9a4d5f8c 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -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, },