4cd8b23032
Follow-up to the earlier enabling of classes/variables etc. Bug Related issues linked using fixes #number Integration tests added Errors have helpful link attached, see contributing.md Feature Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. Related issues linked using fixes #number Integration tests added Documentation added Telemetry added. In case of a feature if it's used or not. Errors have helpful link attached, see contributing.md Documentation / Examples Make sure the linting passes by running pnpm lint The examples guidelines are followed from our contributing doc Co-authored-by: Steven <steven@ceriously.com>
551 lines
16 KiB
TypeScript
551 lines
16 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'http'
|
|
import type { NextApiRequest, NextApiResponse } from '../../shared/lib/utils'
|
|
import type { PageConfig } from 'next/types'
|
|
import {
|
|
checkIsManualRevalidate,
|
|
PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER,
|
|
__ApiPreviewProps,
|
|
} from '.'
|
|
import type { BaseNextRequest, BaseNextResponse } from '../base-http'
|
|
import type { CookieSerializeOptions } from 'next/dist/compiled/cookie'
|
|
import type { PreviewData } from 'next/types'
|
|
|
|
import bytes from 'next/dist/compiled/bytes'
|
|
import jsonwebtoken from 'next/dist/compiled/jsonwebtoken'
|
|
import { decryptWithSecret, encryptWithSecret } from '../crypto-utils'
|
|
import { generateETag } from '../lib/etag'
|
|
import { sendEtagResponse } from '../send-payload'
|
|
import { Stream } from 'stream'
|
|
import { parse } from 'next/dist/compiled/content-type'
|
|
import isError from '../../lib/is-error'
|
|
import { isResSent } from '../../shared/lib/utils'
|
|
import { interopDefault } from '../../lib/interop-default'
|
|
import {
|
|
getCookieParser,
|
|
setLazyProp,
|
|
sendStatusCode,
|
|
redirect,
|
|
clearPreviewData,
|
|
sendError,
|
|
ApiError,
|
|
NextApiRequestCookies,
|
|
PRERENDER_REVALIDATE_HEADER,
|
|
COOKIE_NAME_PRERENDER_BYPASS,
|
|
COOKIE_NAME_PRERENDER_DATA,
|
|
SYMBOL_PREVIEW_DATA,
|
|
RESPONSE_LIMIT_DEFAULT,
|
|
} from './index'
|
|
import { mockRequest } from '../lib/mock-request'
|
|
|
|
export function tryGetPreviewData(
|
|
req: IncomingMessage | BaseNextRequest,
|
|
res: ServerResponse | BaseNextResponse,
|
|
options: __ApiPreviewProps
|
|
): PreviewData {
|
|
// if an On-Demand revalidation is being done preview mode
|
|
// is disabled
|
|
if (options && checkIsManualRevalidate(req, options).isManualRevalidate) {
|
|
return false
|
|
}
|
|
|
|
// Read cached preview data if present
|
|
if (SYMBOL_PREVIEW_DATA in req) {
|
|
return (req as any)[SYMBOL_PREVIEW_DATA] as any
|
|
}
|
|
|
|
const getCookies = getCookieParser(req.headers)
|
|
let cookies: NextApiRequestCookies
|
|
try {
|
|
cookies = getCookies()
|
|
} catch {
|
|
// TODO: warn
|
|
return false
|
|
}
|
|
|
|
const hasBypass = COOKIE_NAME_PRERENDER_BYPASS in cookies
|
|
const hasData = COOKIE_NAME_PRERENDER_DATA in cookies
|
|
|
|
// Case: neither cookie is set.
|
|
if (!(hasBypass || hasData)) {
|
|
return false
|
|
}
|
|
|
|
// Case: one cookie is set, but not the other.
|
|
if (hasBypass !== hasData) {
|
|
clearPreviewData(res as NextApiResponse)
|
|
return false
|
|
}
|
|
|
|
// Case: preview session is for an old build.
|
|
if (cookies[COOKIE_NAME_PRERENDER_BYPASS] !== options.previewModeId) {
|
|
clearPreviewData(res as NextApiResponse)
|
|
return false
|
|
}
|
|
|
|
const tokenPreviewData = cookies[COOKIE_NAME_PRERENDER_DATA] as string
|
|
|
|
let encryptedPreviewData: {
|
|
data: string
|
|
}
|
|
try {
|
|
encryptedPreviewData = jsonwebtoken.verify(
|
|
tokenPreviewData,
|
|
options.previewModeSigningKey
|
|
) as typeof encryptedPreviewData
|
|
} catch {
|
|
// TODO: warn
|
|
clearPreviewData(res as NextApiResponse)
|
|
return false
|
|
}
|
|
|
|
const decryptedPreviewData = decryptWithSecret(
|
|
Buffer.from(options.previewModeEncryptionKey),
|
|
encryptedPreviewData.data
|
|
)
|
|
|
|
try {
|
|
// TODO: strict runtime type checking
|
|
const data = JSON.parse(decryptedPreviewData)
|
|
// Cache lookup
|
|
Object.defineProperty(req, SYMBOL_PREVIEW_DATA, {
|
|
value: data,
|
|
enumerable: false,
|
|
})
|
|
return data
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse `JSON` and handles invalid `JSON` strings
|
|
* @param str `JSON` string
|
|
*/
|
|
function parseJson(str: string): object {
|
|
if (str.length === 0) {
|
|
// special-case empty json body, as it's a common client-side mistake
|
|
return {}
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(str)
|
|
} catch (e) {
|
|
throw new ApiError(400, 'Invalid JSON')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse incoming message like `json` or `urlencoded`
|
|
* @param req request object
|
|
*/
|
|
export async function parseBody(
|
|
req: IncomingMessage,
|
|
limit: string | number
|
|
): Promise<any> {
|
|
let contentType
|
|
try {
|
|
contentType = parse(req.headers['content-type'] || 'text/plain')
|
|
} catch {
|
|
contentType = parse('text/plain')
|
|
}
|
|
const { type, parameters } = contentType
|
|
const encoding = parameters.charset || 'utf-8'
|
|
|
|
let buffer
|
|
|
|
try {
|
|
const getRawBody =
|
|
require('next/dist/compiled/raw-body') as typeof import('next/dist/compiled/raw-body')
|
|
buffer = await getRawBody(req, { encoding, limit })
|
|
} catch (e) {
|
|
if (isError(e) && e.type === 'entity.too.large') {
|
|
throw new ApiError(413, `Body exceeded ${limit} limit`)
|
|
} else {
|
|
throw new ApiError(400, 'Invalid body')
|
|
}
|
|
}
|
|
|
|
const body = buffer.toString()
|
|
|
|
if (type === 'application/json' || type === 'application/ld+json') {
|
|
return parseJson(body)
|
|
} else if (type === 'application/x-www-form-urlencoded') {
|
|
const qs = require('querystring')
|
|
return qs.decode(body)
|
|
} else {
|
|
return body
|
|
}
|
|
}
|
|
|
|
type ApiContext = __ApiPreviewProps & {
|
|
trustHostHeader?: boolean
|
|
revalidate?: (_req: IncomingMessage, _res: ServerResponse) => Promise<any>
|
|
}
|
|
|
|
function getMaxContentLength(responseLimit?: number | string | boolean) {
|
|
if (responseLimit && typeof responseLimit !== 'boolean') {
|
|
return bytes.parse(responseLimit)
|
|
}
|
|
return RESPONSE_LIMIT_DEFAULT
|
|
}
|
|
|
|
/**
|
|
* Send `any` body to response
|
|
* @param req request object
|
|
* @param res response object
|
|
* @param body of response
|
|
*/
|
|
function sendData(req: NextApiRequest, res: NextApiResponse, body: any): void {
|
|
if (body === null || body === undefined) {
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
// strip irrelevant headers/body
|
|
if (res.statusCode === 204 || res.statusCode === 304) {
|
|
res.removeHeader('Content-Type')
|
|
res.removeHeader('Content-Length')
|
|
res.removeHeader('Transfer-Encoding')
|
|
|
|
if (process.env.NODE_ENV === 'development' && body) {
|
|
console.warn(
|
|
`A body was attempted to be set with a 204 statusCode for ${req.url}, this is invalid and the body was ignored.\n` +
|
|
`See more info here https://nextjs.org/docs/messages/invalid-api-status-body`
|
|
)
|
|
}
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
const contentType = res.getHeader('Content-Type')
|
|
|
|
if (body instanceof Stream) {
|
|
if (!contentType) {
|
|
res.setHeader('Content-Type', 'application/octet-stream')
|
|
}
|
|
body.pipe(res)
|
|
return
|
|
}
|
|
|
|
const isJSONLike = ['object', 'number', 'boolean'].includes(typeof body)
|
|
const stringifiedBody = isJSONLike ? JSON.stringify(body) : body
|
|
const etag = generateETag(stringifiedBody)
|
|
if (sendEtagResponse(req, res, etag)) {
|
|
return
|
|
}
|
|
|
|
if (Buffer.isBuffer(body)) {
|
|
if (!contentType) {
|
|
res.setHeader('Content-Type', 'application/octet-stream')
|
|
}
|
|
res.setHeader('Content-Length', body.length)
|
|
res.end(body)
|
|
return
|
|
}
|
|
|
|
if (isJSONLike) {
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
}
|
|
|
|
res.setHeader('Content-Length', Buffer.byteLength(stringifiedBody))
|
|
res.end(stringifiedBody)
|
|
}
|
|
|
|
/**
|
|
* Send `JSON` object
|
|
* @param res response object
|
|
* @param jsonBody of data
|
|
*/
|
|
function sendJson(res: NextApiResponse, jsonBody: any): void {
|
|
// Set header to application/json
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
|
|
// Use send to handle request
|
|
res.send(JSON.stringify(jsonBody))
|
|
}
|
|
|
|
function isNotValidData(str: string): boolean {
|
|
return typeof str !== 'string' || str.length < 16
|
|
}
|
|
|
|
function setPreviewData<T>(
|
|
res: NextApiResponse<T>,
|
|
data: object | string, // TODO: strict runtime type checking
|
|
options: {
|
|
maxAge?: number
|
|
path?: string
|
|
} & __ApiPreviewProps
|
|
): NextApiResponse<T> {
|
|
if (isNotValidData(options.previewModeId)) {
|
|
throw new Error('invariant: invalid previewModeId')
|
|
}
|
|
if (isNotValidData(options.previewModeEncryptionKey)) {
|
|
throw new Error('invariant: invalid previewModeEncryptionKey')
|
|
}
|
|
if (isNotValidData(options.previewModeSigningKey)) {
|
|
throw new Error('invariant: invalid previewModeSigningKey')
|
|
}
|
|
|
|
const payload = jsonwebtoken.sign(
|
|
{
|
|
data: encryptWithSecret(
|
|
Buffer.from(options.previewModeEncryptionKey),
|
|
JSON.stringify(data)
|
|
),
|
|
},
|
|
options.previewModeSigningKey,
|
|
{
|
|
algorithm: 'HS256',
|
|
...(options.maxAge !== undefined
|
|
? { expiresIn: options.maxAge }
|
|
: undefined),
|
|
}
|
|
)
|
|
|
|
// limit preview mode cookie to 2KB since we shouldn't store too much
|
|
// data here and browsers drop cookies over 4KB
|
|
if (payload.length > 2048) {
|
|
throw new Error(
|
|
`Preview data is limited to 2KB currently, reduce how much data you are storing as preview data to continue`
|
|
)
|
|
}
|
|
|
|
const { serialize } =
|
|
require('next/dist/compiled/cookie') as typeof import('cookie')
|
|
const previous = res.getHeader('Set-Cookie')
|
|
res.setHeader(`Set-Cookie`, [
|
|
...(typeof previous === 'string'
|
|
? [previous]
|
|
: Array.isArray(previous)
|
|
? previous
|
|
: []),
|
|
serialize(COOKIE_NAME_PRERENDER_BYPASS, options.previewModeId, {
|
|
httpOnly: true,
|
|
sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax',
|
|
secure: process.env.NODE_ENV !== 'development',
|
|
path: '/',
|
|
...(options.maxAge !== undefined
|
|
? ({ maxAge: options.maxAge } as CookieSerializeOptions)
|
|
: undefined),
|
|
...(options.path !== undefined
|
|
? ({ path: options.path } as CookieSerializeOptions)
|
|
: undefined),
|
|
}),
|
|
serialize(COOKIE_NAME_PRERENDER_DATA, payload, {
|
|
httpOnly: true,
|
|
sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax',
|
|
secure: process.env.NODE_ENV !== 'development',
|
|
path: '/',
|
|
...(options.maxAge !== undefined
|
|
? ({ maxAge: options.maxAge } as CookieSerializeOptions)
|
|
: undefined),
|
|
...(options.path !== undefined
|
|
? ({ path: options.path } as CookieSerializeOptions)
|
|
: undefined),
|
|
}),
|
|
])
|
|
return res
|
|
}
|
|
|
|
async function revalidate(
|
|
urlPath: string,
|
|
opts: {
|
|
unstable_onlyGenerated?: boolean
|
|
},
|
|
req: IncomingMessage,
|
|
context: ApiContext
|
|
) {
|
|
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 revalidateHeaders = {
|
|
[PRERENDER_REVALIDATE_HEADER]: context.previewModeId,
|
|
...(opts.unstable_onlyGenerated
|
|
? {
|
|
[PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER]: '1',
|
|
}
|
|
: {}),
|
|
}
|
|
|
|
try {
|
|
if (context.trustHostHeader) {
|
|
const res = await fetch(`https://${req.headers.host}${urlPath}`, {
|
|
method: 'HEAD',
|
|
headers: {
|
|
...revalidateHeaders,
|
|
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' &&
|
|
!(res.status === 404 && opts.unstable_onlyGenerated)
|
|
) {
|
|
throw new Error(`Invalid response ${res.status}`)
|
|
}
|
|
} else if (context.revalidate) {
|
|
const {
|
|
req: mockReq,
|
|
res: mockRes,
|
|
streamPromise,
|
|
} = mockRequest(urlPath, revalidateHeaders, 'GET')
|
|
await context.revalidate(mockReq, mockRes)
|
|
await streamPromise
|
|
|
|
if (
|
|
mockRes.getHeader('x-nextjs-cache') !== 'REVALIDATED' &&
|
|
!(mockRes.statusCode === 404 && opts.unstable_onlyGenerated)
|
|
) {
|
|
throw new Error(`Invalid response ${mockRes.statusCode}`)
|
|
}
|
|
} else {
|
|
throw new Error(
|
|
`Invariant: required internal revalidate method not passed to api-utils`
|
|
)
|
|
}
|
|
} catch (err: unknown) {
|
|
throw new Error(
|
|
`Failed to revalidate ${urlPath}: ${isError(err) ? err.message : err}`
|
|
)
|
|
}
|
|
}
|
|
|
|
export async function apiResolver(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
query: any,
|
|
resolverModule: any,
|
|
apiContext: ApiContext,
|
|
propagateError: boolean,
|
|
dev?: boolean,
|
|
page?: string
|
|
): Promise<void> {
|
|
const apiReq = req as NextApiRequest
|
|
const apiRes = res as NextApiResponse
|
|
|
|
try {
|
|
if (!resolverModule) {
|
|
res.statusCode = 404
|
|
res.end('Not Found')
|
|
return
|
|
}
|
|
const config: PageConfig = resolverModule.config || {}
|
|
const bodyParser = config.api?.bodyParser !== false
|
|
const responseLimit = config.api?.responseLimit ?? true
|
|
const externalResolver = config.api?.externalResolver || false
|
|
|
|
// Parsing of cookies
|
|
setLazyProp({ req: apiReq }, 'cookies', getCookieParser(req.headers))
|
|
// Parsing query string
|
|
apiReq.query = query
|
|
// Parsing preview data
|
|
setLazyProp({ req: apiReq }, 'previewData', () =>
|
|
tryGetPreviewData(req, res, apiContext)
|
|
)
|
|
// Checking if preview mode is enabled
|
|
setLazyProp({ req: apiReq }, 'preview', () =>
|
|
apiReq.previewData !== false ? true : undefined
|
|
)
|
|
|
|
// Parsing of body
|
|
if (bodyParser && !apiReq.body) {
|
|
apiReq.body = await parseBody(
|
|
apiReq,
|
|
config.api && config.api.bodyParser && config.api.bodyParser.sizeLimit
|
|
? config.api.bodyParser.sizeLimit
|
|
: '1mb'
|
|
)
|
|
}
|
|
|
|
let contentLength = 0
|
|
const maxContentLength = getMaxContentLength(responseLimit)
|
|
const writeData = apiRes.write
|
|
const endResponse = apiRes.end
|
|
apiRes.write = (...args: any[2]) => {
|
|
contentLength += Buffer.byteLength(args[0] || '')
|
|
return writeData.apply(apiRes, args)
|
|
}
|
|
apiRes.end = (...args: any[2]) => {
|
|
if (args.length && typeof args[0] !== 'function') {
|
|
contentLength += Buffer.byteLength(args[0] || '')
|
|
}
|
|
|
|
if (responseLimit && contentLength >= maxContentLength) {
|
|
console.warn(
|
|
`API response for ${req.url} exceeds ${bytes.format(
|
|
maxContentLength
|
|
)}. API Routes are meant to respond quickly. https://nextjs.org/docs/messages/api-routes-response-size-limit`
|
|
)
|
|
}
|
|
|
|
endResponse.apply(apiRes, args)
|
|
}
|
|
apiRes.status = (statusCode) => sendStatusCode(apiRes, statusCode)
|
|
apiRes.send = (data) => sendData(apiReq, apiRes, data)
|
|
apiRes.json = (data) => sendJson(apiRes, data)
|
|
apiRes.redirect = (statusOrUrl: number | string, url?: string) =>
|
|
redirect(apiRes, statusOrUrl, url)
|
|
apiRes.setPreviewData = (data, options = {}) =>
|
|
setPreviewData(apiRes, data, Object.assign({}, apiContext, options))
|
|
apiRes.clearPreviewData = () => clearPreviewData(apiRes)
|
|
apiRes.revalidate = (
|
|
urlPath: string,
|
|
opts?: {
|
|
unstable_onlyGenerated?: boolean
|
|
}
|
|
) => revalidate(urlPath, opts || {}, req, apiContext)
|
|
|
|
// TODO: remove in next minor (current v12.2)
|
|
apiRes.unstable_revalidate = () => {
|
|
throw new Error(
|
|
`"unstable_revalidate" has been renamed to "revalidate" see more info here: https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration#on-demand-revalidation`
|
|
)
|
|
}
|
|
|
|
const resolver = interopDefault(resolverModule)
|
|
let wasPiped = false
|
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
// listen for pipe event and don't show resolve warning
|
|
res.once('pipe', () => (wasPiped = true))
|
|
}
|
|
|
|
// Call API route method
|
|
await resolver(req, res)
|
|
|
|
if (
|
|
process.env.NODE_ENV !== 'production' &&
|
|
!externalResolver &&
|
|
!isResSent(res) &&
|
|
!wasPiped
|
|
) {
|
|
console.warn(
|
|
`API resolved without sending a response for ${req.url}, this may result in stalled requests.`
|
|
)
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
sendError(apiRes, err.statusCode, err.message)
|
|
} else {
|
|
if (dev) {
|
|
if (isError(err)) {
|
|
err.page = page
|
|
}
|
|
throw err
|
|
}
|
|
|
|
console.error(err)
|
|
if (propagateError) {
|
|
throw err
|
|
}
|
|
sendError(apiRes, 500, 'Internal Server Error')
|
|
}
|
|
}
|
|
}
|