2022-01-20 22:25:44 +01:00
import type { IncomingMessage , ServerResponse } from 'http'
2022-02-11 20:56:25 +01:00
import type { NextApiRequest , NextApiResponse } from '../../shared/lib/utils'
import type { PageConfig } from 'next/types'
2022-04-13 18:56:58 +02:00
import {
2022-08-04 15:23:54 +02:00
checkIsManualRevalidate ,
2022-04-13 18:56:58 +02:00
PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER ,
__ApiPreviewProps ,
} from '.'
2022-02-11 20:56:25 +01:00
import type { BaseNextRequest , BaseNextResponse } from '../base-http'
import type { CookieSerializeOptions } from 'next/dist/compiled/cookie'
import type { PreviewData } from 'next/types'
2022-02-25 03:04:02 +01:00
import bytes from 'next/dist/compiled/bytes'
2022-02-11 20:56:25 +01:00
import jsonwebtoken from 'next/dist/compiled/jsonwebtoken'
import { decryptWithSecret , encryptWithSecret } from '../crypto-utils'
2022-07-18 11:20:44 +02:00
import { generateETag } from '../lib/etag'
2022-02-11 20:56:25 +01:00
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 ,
2022-02-25 03:04:02 +01:00
RESPONSE_LIMIT_DEFAULT ,
2022-02-11 20:56:25 +01:00
} from './index'
2022-03-17 18:06:44 +01:00
import { mockRequest } from '../lib/mock-request'
2022-02-11 20:56:25 +01:00
export function tryGetPreviewData (
req : IncomingMessage | BaseNextRequest ,
res : ServerResponse | BaseNextResponse ,
options : __ApiPreviewProps
) : PreviewData {
2022-08-04 15:23:54 +02:00
// if an On-Demand revalidation is being done preview mode
// is disabled
if ( options && checkIsManualRevalidate ( req , options ) . isManualRevalidate ) {
return false
}
2022-02-11 20:56:25 +01:00
// 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
}
2019-06-28 11:31:32 +02:00
2022-02-11 20:56:25 +01:00
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
}
fix NextApiRequestCookies and NextApiRequestQuery types (#25532)
Hello! Thanks for making next.js so great.
## Bug
Right now, these types give false confidence. These `key`s are treated as though [a value is defined for _every_ string](https://dev.to/sarioglu/avoiding-unintended-undefined-values-while-using-typescript-record-4igo). However, given an arbitrary request, a particular cookie or query param could be `undefined`.
For example, when building an `/api` endpoint, the code might look like this:
```ts
import type { NextApiRequest, NextApiResponse } from "next"
export default function handler(req: NextApiRequest, res: NextApiResponse) {
// According to the old types, `value` is a string
const value = req.cookies.value
// Type-checking passes but leads to a runtime error when no `value` cookie is provided in the request
// Uncaught TypeError: Cannot read property 'toLowerCase' of undefined
value.toLowerCause()
// ...
}
```
By using `Partial`, TypeScript now knows that these objects don't have values defined for every `key` and accessing a given `key` might resolve to `undefined`.
---
The only obvious error this caused within this repo was on line 333 of the same file. For better or worse, I ended up casting that cookie value to a `string`. There's a series of `if` statements before it that, I guess, are guaranteeing that it's truly a string. Potentially, that stretch could be refactored such that TypeScript _knows_ it's a string.
Also, I tried to follow the contributing guidelines. However, running `yarn types` kicked out a bunch of errors about overwriting files:
```
$ yarn types
yarn run v1.22.10
$ lerna run types --stream
lerna notice cli v4.0.0
lerna info Executing command in 2 packages: "yarn run types"
@next/env: $ tsc index.ts --declaration --emitDeclarationOnly --declarationDir types --esModuleInterop
next: $ tsc --declaration --emitDeclarationOnly --declarationDir dist
next: error TS5055: Cannot write file '/Users/mbrandly/code/next.js/packages/next/dist/build/index.d.ts' because it would overwrite input file.
next: error TS5055: Cannot write file '/Users/mbrandly/code/next.js/packages/next/dist/build/webpack/plugins/build-manifest-plugin.d.ts' because it would overwrite input file.
...
...
...
```
Let me know if there's anything I can improve here! Thanks again.
2022-05-23 02:48:26 +02:00
const tokenPreviewData = cookies [ COOKIE_NAME_PRERENDER_DATA ] as string
2019-06-05 13:22:09 +02:00
2022-02-11 20:56:25 +01:00
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
}
}
2022-08-15 16:29:51 +02:00
/ * *
* 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' )
}
}
2022-02-11 20:56:25 +01:00
/ * *
* 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
}
2020-02-12 02:16:42 +01:00
}
2022-03-17 18:06:44 +01:00
type ApiContext = __ApiPreviewProps & {
trustHostHeader? : boolean
revalidate ? : ( _req : IncomingMessage , _res : ServerResponse ) = > Promise < any >
}
2022-08-15 16:29:51 +02:00
function getMaxContentLength ( responseLimit? : number | string | boolean ) {
if ( responseLimit && typeof responseLimit !== 'boolean' ) {
return bytes . parse ( responseLimit )
2019-06-05 13:22:09 +02:00
}
2022-08-15 16:29:51 +02:00
return RESPONSE_LIMIT_DEFAULT
2019-06-05 13:22:09 +02:00
}
/ * *
* Send ` any ` body to response
2020-05-30 21:23:24 +02:00
* @param req request object
2019-06-05 13:22:09 +02:00
* @param res response object
* @param body of response
* /
2022-02-11 20:56:25 +01:00
function sendData ( req : NextApiRequest , res : NextApiResponse , body : any ) : void {
2021-01-26 16:24:48 +01:00
if ( body === null || body === undefined ) {
2019-06-05 13:22:09 +02:00
res . end ( )
return
}
2021-08-25 17:52:43 +02:00
// 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
}
2019-06-05 13:22:09 +02:00
const contentType = res . getHeader ( 'Content-Type' )
2020-05-30 21:23:24 +02:00
if ( body instanceof Stream ) {
2019-06-05 13:22:09 +02:00
if ( ! contentType ) {
res . setHeader ( 'Content-Type' , 'application/octet-stream' )
}
2020-05-30 21:23:24 +02:00
body . pipe ( res )
2019-06-05 13:22:09 +02:00
return
}
2020-05-30 21:23:24 +02:00
const isJSONLike = [ 'object' , 'number' , 'boolean' ] . includes ( typeof body )
const stringifiedBody = isJSONLike ? JSON . stringify ( body ) : body
2020-11-10 05:40:26 +01:00
const etag = generateETag ( stringifiedBody )
if ( sendEtagResponse ( req , res , etag ) ) {
2020-05-30 21:23:24 +02:00
return
}
if ( Buffer . isBuffer ( body ) ) {
2019-06-05 13:22:09 +02:00
if ( ! contentType ) {
res . setHeader ( 'Content-Type' , 'application/octet-stream' )
}
2020-05-30 21:23:24 +02:00
res . setHeader ( 'Content-Length' , body . length )
res . end ( body )
2019-06-05 13:22:09 +02:00
return
}
2020-05-30 21:23:24 +02:00
if ( isJSONLike ) {
2019-06-05 13:22:09 +02:00
res . setHeader ( 'Content-Type' , 'application/json; charset=utf-8' )
}
2020-05-30 21:23:24 +02:00
res . setHeader ( 'Content-Length' , Buffer . byteLength ( stringifiedBody ) )
res . end ( stringifiedBody )
2019-06-05 13:22:09 +02:00
}
/ * *
* Send ` JSON ` object
* @param res response object
* @param jsonBody of data
* /
2022-02-11 20:56:25 +01:00
function sendJson ( res : NextApiResponse , jsonBody : any ) : void {
2019-06-05 13:22:09 +02:00
// Set header to application/json
res . setHeader ( 'Content-Type' , 'application/json; charset=utf-8' )
// Use send to handle request
2022-04-15 16:04:00 +02:00
res . send ( JSON . stringify ( jsonBody ) )
2019-06-05 13:22:09 +02:00
}
2021-01-26 10:52:00 +01:00
function isNotValidData ( str : string ) : boolean {
return typeof str !== 'string' || str . length < 16
}
2020-02-12 02:16:42 +01:00
function setPreviewData < T > (
res : NextApiResponse < T > ,
data : object | string , // TODO: strict runtime type checking
options : {
maxAge? : number
2022-08-08 03:45:30 +02:00
path? : string
2020-02-12 02:16:42 +01:00
} & __ApiPreviewProps
) : NextApiResponse < T > {
2021-01-26 10:52:00 +01:00
if ( isNotValidData ( options . previewModeId ) ) {
2020-02-12 02:16:42 +01:00
throw new Error ( 'invariant: invalid previewModeId' )
}
2021-01-26 10:52:00 +01:00
if ( isNotValidData ( options . previewModeEncryptionKey ) ) {
2020-02-12 02:16:42 +01:00
throw new Error ( 'invariant: invalid previewModeEncryptionKey' )
}
2021-01-26 10:52:00 +01:00
if ( isNotValidData ( options . previewModeSigningKey ) ) {
2020-02-12 02:16:42 +01:00
throw new Error ( 'invariant: invalid previewModeSigningKey' )
}
const payload = jsonwebtoken . sign (
2020-05-02 07:20:32 +02:00
{
data : encryptWithSecret (
Buffer . from ( options . previewModeEncryptionKey ) ,
JSON . stringify ( data )
) ,
} ,
2020-02-12 02:16:42 +01:00
options . previewModeSigningKey ,
{
algorithm : 'HS256' ,
. . . ( options . maxAge !== undefined
? { expiresIn : options.maxAge }
: undefined ) ,
}
)
2020-03-04 21:37:53 +01:00
// 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 `
)
}
2021-08-17 09:18:08 +02:00
const { serialize } =
require ( 'next/dist/compiled/cookie' ) as typeof import ( 'cookie' )
2020-02-12 02:16:42 +01:00
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 ,
2020-04-03 19:18:04 +02:00
sameSite : process.env.NODE_ENV !== 'development' ? 'none' : 'lax' ,
secure : process.env.NODE_ENV !== 'development' ,
2020-02-12 02:16:42 +01:00
path : '/' ,
. . . ( options . maxAge !== undefined
? ( { maxAge : options.maxAge } as CookieSerializeOptions )
: undefined ) ,
2022-08-08 03:45:30 +02:00
. . . ( options . path !== undefined
? ( { path : options.path } as CookieSerializeOptions )
: undefined ) ,
2020-02-12 02:16:42 +01:00
} ) ,
serialize ( COOKIE_NAME_PRERENDER_DATA , payload , {
httpOnly : true ,
2020-04-03 19:18:04 +02:00
sameSite : process.env.NODE_ENV !== 'development' ? 'none' : 'lax' ,
secure : process.env.NODE_ENV !== 'development' ,
2020-02-12 02:16:42 +01:00
path : '/' ,
. . . ( options . maxAge !== undefined
? ( { maxAge : options.maxAge } as CookieSerializeOptions )
: undefined ) ,
2022-08-08 03:45:30 +02:00
. . . ( options . path !== undefined
? ( { path : options.path } as CookieSerializeOptions )
: undefined ) ,
2020-02-12 02:16:42 +01:00
} ) ,
] )
return res
}
2022-02-25 03:04:02 +01:00
2022-08-15 16:29:51 +02:00
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 ) )
2022-09-05 22:37:08 +02:00
apiRes . clearPreviewData = ( options = { } ) = >
clearPreviewData ( apiRes , options )
2022-08-15 16:29:51 +02:00
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' )
}
2022-02-25 03:04:02 +01:00
}
}