diff --git a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts index 6fb65f84139b6..e4835c150a15e 100644 --- a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts +++ b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts @@ -7,7 +7,10 @@ import { NextRequest } from '../../../../server/web/spec-extension/request' import { toNodeHeaders } from '../../../../server/web/utils' import WebServer from '../../../../server/web-server' -import { WebNextRequest, WebNextResponse } from '../../../../server/base-http' +import { + WebNextRequest, + WebNextResponse, +} from '../../../../server/base-http/web' const createHeaders = (args?: any) => ({ ...args, diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/api-handler.ts b/packages/next/build/webpack/loaders/next-serverless-loader/api-handler.ts index 0d85e3731d89b..0e699a74d5cd4 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/api-handler.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/api-handler.ts @@ -1,9 +1,12 @@ import { parse as parseUrl } from 'url' import { IncomingMessage, ServerResponse } from 'http' -import { apiResolver } from '../../../../server/api-utils' +import { apiResolver } from '../../../../server/api-utils/node' import { getUtils, vercelHeader, ServerlessHandlerCtx } from './utils' import { DecodeError } from '../../../../shared/lib/utils' -import { NodeNextResponse, NodeNextRequest } from '../../../../server/base-http' +import { + NodeNextResponse, + NodeNextRequest, +} from '../../../../server/base-http/node' export function getApiHandler(ctx: ServerlessHandlerCtx) { const { pageModule, encodedPreviewProps, pageIsDynamic } = ctx diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts b/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts index 968024c4683ac..24271c33ba9df 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts @@ -5,7 +5,7 @@ import { sendRenderResult } from '../../../../server/send-payload' import { getUtils, vercelHeader, ServerlessHandlerCtx } from './utils' import { renderToHTML } from '../../../../server/render' -import { tryGetPreviewData } from '../../../../server/api-utils' +import { tryGetPreviewData } from '../../../../server/api-utils/node' import { denormalizePagePath } from '../../../../server/denormalize-page-path' import { setLazyProp, getCookieParser } from '../../../../server/api-utils' import { getRedirectStatus } from '../../../../lib/load-custom-routes' diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts b/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts index cf01d3ac3a37e..c5c2b51979062 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts @@ -7,6 +7,7 @@ import type { GetStaticPaths, GetStaticProps, } from '../../../../types' +import type { BaseNextRequest } from '../../../../server/base-http' import { format as formatUrl, UrlWithParsedQuery, parse as parseUrl } from 'url' import { parse as parseQs, ParsedUrlQuery } from 'querystring' @@ -26,7 +27,6 @@ import { denormalizePagePath } from '../../../../server/denormalize-page-path' import cookie from 'next/dist/compiled/cookie' import { TEMPORARY_REDIRECT_STATUS } from '../../../../shared/lib/constants' import { addRequestMeta } from '../../../../server/request-meta' -import { BaseNextRequest } from '../../../../server/base-http' const getCustomRouteMatcher = pathMatch(true) diff --git a/packages/next/server/api-utils/index.ts b/packages/next/server/api-utils/index.ts new file mode 100644 index 0000000000000..5b3dce43889ba --- /dev/null +++ b/packages/next/server/api-utils/index.ts @@ -0,0 +1,191 @@ +import type { IncomingMessage } from 'http' +import type { BaseNextRequest } from '../base-http' + +import { NextApiRequest, NextApiResponse } from '../../shared/lib/utils' + +export type NextApiRequestCookies = { [key: string]: string } +export type NextApiRequestQuery = { [key: string]: string | string[] } + +export type __ApiPreviewProps = { + previewModeId: string + previewModeEncryptionKey: string + previewModeSigningKey: string +} + +/** + * Parse cookies from the `headers` of request + * @param req request object + */ +export function getCookieParser(headers: { + [key: string]: undefined | string | string[] +}): () => NextApiRequestCookies { + return function parseCookie(): NextApiRequestCookies { + const header: undefined | string | string[] = headers.cookie + + if (!header) { + return {} + } + + const { parse: parseCookieFn } = require('next/dist/compiled/cookie') + return parseCookieFn(Array.isArray(header) ? header.join(';') : header) + } +} + +/** + * + * @param res response object + * @param statusCode `HTTP` status code of response + */ +export function sendStatusCode( + res: NextApiResponse, + statusCode: number +): NextApiResponse { + res.statusCode = statusCode + return res +} + +/** + * + * @param res response object + * @param [statusOrUrl] `HTTP` status code of redirect + * @param url URL of redirect + */ +export function redirect( + res: NextApiResponse, + statusOrUrl: string | number, + url?: string +): NextApiResponse { + if (typeof statusOrUrl === 'string') { + url = statusOrUrl + statusOrUrl = 307 + } + if (typeof statusOrUrl !== 'number' || typeof url !== 'string') { + throw new Error( + `Invalid redirect arguments. Please use a single argument URL, e.g. res.redirect('/destination') or use a status code and URL, e.g. res.redirect(307, '/destination').` + ) + } + res.writeHead(statusOrUrl, { Location: url }) + res.write(url) + res.end() + return res +} + +export const PRERENDER_REVALIDATE_HEADER = 'x-prerender-revalidate' + +export function checkIsManualRevalidate( + req: IncomingMessage | BaseNextRequest, + previewProps: __ApiPreviewProps +): boolean { + return req.headers[PRERENDER_REVALIDATE_HEADER] === previewProps.previewModeId +} + +export const COOKIE_NAME_PRERENDER_BYPASS = `__prerender_bypass` +export const COOKIE_NAME_PRERENDER_DATA = `__next_preview_data` + +export const SYMBOL_PREVIEW_DATA = Symbol(COOKIE_NAME_PRERENDER_DATA) +export const SYMBOL_CLEARED_COOKIES = Symbol(COOKIE_NAME_PRERENDER_BYPASS) + +export function clearPreviewData( + res: NextApiResponse +): NextApiResponse { + if (SYMBOL_CLEARED_COOKIES in res) { + return res + } + + 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, '', { + // To delete a cookie, set `expires` to a date in the past: + // https://tools.ietf.org/html/rfc6265#section-4.1.1 + // `Max-Age: 0` is not valid, thus ignored, and the cookie is persisted. + expires: new Date(0), + httpOnly: true, + sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax', + secure: process.env.NODE_ENV !== 'development', + path: '/', + }), + serialize(COOKIE_NAME_PRERENDER_DATA, '', { + // To delete a cookie, set `expires` to a date in the past: + // https://tools.ietf.org/html/rfc6265#section-4.1.1 + // `Max-Age: 0` is not valid, thus ignored, and the cookie is persisted. + expires: new Date(0), + httpOnly: true, + sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax', + secure: process.env.NODE_ENV !== 'development', + path: '/', + }), + ]) + + Object.defineProperty(res, SYMBOL_CLEARED_COOKIES, { + value: true, + enumerable: false, + }) + return res +} + +/** + * Custom error class + */ +export class ApiError extends Error { + readonly statusCode: number + + constructor(statusCode: number, message: string) { + super(message) + this.statusCode = statusCode + } +} + +/** + * Sends error in `response` + * @param res response object + * @param statusCode of response + * @param message of response + */ +export function sendError( + res: NextApiResponse, + statusCode: number, + message: string +): void { + res.statusCode = statusCode + res.statusMessage = message + res.end(message) +} + +interface LazyProps { + req: NextApiRequest +} + +/** + * Execute getter function only if its needed + * @param LazyProps `req` and `params` for lazyProp + * @param prop name of property + * @param getter function to get data + */ +export function setLazyProp( + { req }: LazyProps, + prop: string, + getter: () => T +): void { + const opts = { configurable: true, enumerable: true } + const optsReset = { ...opts, writable: true } + + Object.defineProperty(req, prop, { + ...opts, + get: () => { + const value = getter() + // we set the property on the object to avoid recalculating it + Object.defineProperty(req, prop, { ...optsReset, value }) + return value + }, + set: (value) => { + Object.defineProperty(req, prop, { ...optsReset, value }) + }, + }) +} diff --git a/packages/next/server/api-utils.ts b/packages/next/server/api-utils/node.ts similarity index 67% rename from packages/next/server/api-utils.ts rename to packages/next/server/api-utils/node.ts index a4e5706173c45..42bac696a3fb9 100644 --- a/packages/next/server/api-utils.ts +++ b/packages/next/server/api-utils/node.ts @@ -1,24 +1,150 @@ import type { IncomingMessage, ServerResponse } from 'http' - -import { parse } from 'next/dist/compiled/content-type' -import { CookieSerializeOptions } from 'next/dist/compiled/cookie' -import { PageConfig, PreviewData } from 'next/types' -import { Stream } from 'stream' -import { isResSent, NextApiRequest, NextApiResponse } from '../shared/lib/utils' -import { decryptWithSecret, encryptWithSecret } from './crypto-utils' -import { sendEtagResponse } from './send-payload' +import type { NextApiRequest, NextApiResponse } from '../../shared/lib/utils' +import type { PageConfig } from 'next/types' +import type { __ApiPreviewProps } from '.' +import type { BaseNextRequest, BaseNextResponse } from '../base-http' +import type { CookieSerializeOptions } from 'next/dist/compiled/cookie' +import type { PreviewData } from 'next/types' + +import jsonwebtoken from 'next/dist/compiled/jsonwebtoken' +import { decryptWithSecret, encryptWithSecret } from '../crypto-utils' import generateETag from 'next/dist/compiled/etag' -import isError from '../lib/is-error' -import { interopDefault } from '../lib/interop-default' -import { BaseNextRequest, BaseNextResponse } from './base-http' +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, +} from './index' + +export function tryGetPreviewData( + req: IncomingMessage | BaseNextRequest, + res: ServerResponse | BaseNextResponse, + options: __ApiPreviewProps +): PreviewData { + // 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 + } -export type NextApiRequestCookies = { [key: string]: string } -export type NextApiRequestQuery = { [key: string]: string | string[] } + 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] -export type __ApiPreviewProps = { - previewModeId: string - previewModeEncryptionKey: string - previewModeSigningKey: 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 incoming message like `json` or `urlencoded` + * @param req request object + */ +export async function parseBody( + req: IncomingMessage, + limit: string | number +): Promise { + 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 + } } export async function apiResolver( @@ -143,46 +269,44 @@ export async function apiResolver( } } -/** - * Parse incoming message like `json` or `urlencoded` - * @param req request object - */ -export async function parseBody( - req: IncomingMessage, - limit: string | number -): Promise { - let contentType - try { - contentType = parse(req.headers['content-type'] || 'text/plain') - } catch { - contentType = parse('text/plain') +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 { 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') - } + 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 body = buffer.toString() + const baseUrl = context.trustHostHeader + ? `https://${req.headers.host}` + : `http://${context.hostname}:${context.port}` - 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 + try { + const res = await fetch(`${baseUrl}${urlPath}`, { + headers: { + [PRERENDER_REVALIDATE_HEADER]: context.previewModeId, + }, + }) + + if (!res.ok) { + throw new Error(`Invalid response ${res.status}`) + } + } catch (err) { + throw new Error(`Failed to revalidate ${urlPath}`) } } @@ -203,75 +327,13 @@ function parseJson(str: string): object { } } -/** - * Parse cookies from the `headers` of request - * @param req request object - */ -export function getCookieParser(headers: { - [key: string]: undefined | string | string[] -}): () => NextApiRequestCookies { - return function parseCookie(): NextApiRequestCookies { - const header: undefined | string | string[] = headers.cookie - - if (!header) { - return {} - } - - const { parse: parseCookieFn } = require('next/dist/compiled/cookie') - return parseCookieFn(Array.isArray(header) ? header.join(';') : header) - } -} - -/** - * - * @param res response object - * @param statusCode `HTTP` status code of response - */ -export function sendStatusCode( - res: NextApiResponse, - statusCode: number -): NextApiResponse { - res.statusCode = statusCode - return res -} - -/** - * - * @param res response object - * @param [statusOrUrl] `HTTP` status code of redirect - * @param url URL of redirect - */ -export function redirect( - res: NextApiResponse, - statusOrUrl: string | number, - url?: string -): NextApiResponse { - if (typeof statusOrUrl === 'string') { - url = statusOrUrl - statusOrUrl = 307 - } - if (typeof statusOrUrl !== 'number' || typeof url !== 'string') { - throw new Error( - `Invalid redirect arguments. Please use a single argument URL, e.g. res.redirect('/destination') or use a status code and URL, e.g. res.redirect(307, '/destination').` - ) - } - res.writeHead(statusOrUrl, { Location: url }) - res.write(url) - res.end() - return res -} - /** * Send `any` body to response * @param req request object * @param res response object * @param body of response */ -export function sendData( - req: NextApiRequest, - res: NextApiResponse, - body: any -): void { +function sendData(req: NextApiRequest, res: NextApiResponse, body: any): void { if (body === null || body === undefined) { res.end() return @@ -332,7 +394,7 @@ export function sendData( * @param res response object * @param jsonBody of data */ -export function sendJson(res: NextApiResponse, jsonBody: any): void { +function sendJson(res: NextApiResponse, jsonBody: any): void { // Set header to application/json res.setHeader('Content-Type', 'application/json; charset=utf-8') @@ -340,138 +402,6 @@ 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` - ) - } - - 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}` - - try { - const res = await fetch(`${baseUrl}${urlPath}`, { - headers: { - [PRERENDER_REVALIDATE_HEADER]: context.previewModeId, - }, - }) - - if (!res.ok) { - throw new Error(`Invalid response ${res.status}`) - } - } catch (err) { - throw new Error(`Failed to revalidate ${urlPath}`) - } -} - -const COOKIE_NAME_PRERENDER_BYPASS = `__prerender_bypass` -const COOKIE_NAME_PRERENDER_DATA = `__next_preview_data` - -export const SYMBOL_PREVIEW_DATA = Symbol(COOKIE_NAME_PRERENDER_DATA) -export const SYMBOL_CLEARED_COOKIES = Symbol(COOKIE_NAME_PRERENDER_BYPASS) - -export function tryGetPreviewData( - req: IncomingMessage | BaseNextRequest, - res: ServerResponse | BaseNextResponse, - options: __ApiPreviewProps -): PreviewData { - // 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] - - const jsonwebtoken = - require('next/dist/compiled/jsonwebtoken') as typeof import('jsonwebtoken') - 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 - } -} - function isNotValidData(str: string): boolean { return typeof str !== 'string' || str.length < 16 } @@ -493,9 +423,6 @@ function setPreviewData( throw new Error('invariant: invalid previewModeSigningKey') } - const jsonwebtoken = - require('next/dist/compiled/jsonwebtoken') as typeof import('jsonwebtoken') - const payload = jsonwebtoken.sign( { data: encryptWithSecret( @@ -550,106 +477,3 @@ function setPreviewData( ]) return res } - -function clearPreviewData(res: NextApiResponse): NextApiResponse { - if (SYMBOL_CLEARED_COOKIES in res) { - return res - } - - 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, '', { - // To delete a cookie, set `expires` to a date in the past: - // https://tools.ietf.org/html/rfc6265#section-4.1.1 - // `Max-Age: 0` is not valid, thus ignored, and the cookie is persisted. - expires: new Date(0), - httpOnly: true, - sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax', - secure: process.env.NODE_ENV !== 'development', - path: '/', - }), - serialize(COOKIE_NAME_PRERENDER_DATA, '', { - // To delete a cookie, set `expires` to a date in the past: - // https://tools.ietf.org/html/rfc6265#section-4.1.1 - // `Max-Age: 0` is not valid, thus ignored, and the cookie is persisted. - expires: new Date(0), - httpOnly: true, - sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax', - secure: process.env.NODE_ENV !== 'development', - path: '/', - }), - ]) - - Object.defineProperty(res, SYMBOL_CLEARED_COOKIES, { - value: true, - enumerable: false, - }) - return res -} - -/** - * Custom error class - */ -export class ApiError extends Error { - readonly statusCode: number - - constructor(statusCode: number, message: string) { - super(message) - this.statusCode = statusCode - } -} - -/** - * Sends error in `response` - * @param res response object - * @param statusCode of response - * @param message of response - */ -export function sendError( - res: NextApiResponse, - statusCode: number, - message: string -): void { - res.statusCode = statusCode - res.statusMessage = message - res.end(message) -} - -interface LazyProps { - req: NextApiRequest -} - -/** - * Execute getter function only if its needed - * @param LazyProps `req` and `params` for lazyProp - * @param prop name of property - * @param getter function to get data - */ -export function setLazyProp( - { req }: LazyProps, - prop: string, - getter: () => T -): void { - const opts = { configurable: true, enumerable: true } - const optsReset = { ...opts, writable: true } - - Object.defineProperty(req, prop, { - ...opts, - get: () => { - const value = getter() - // we set the property on the object to avoid recalculating it - Object.defineProperty(req, prop, { ...optsReset, value }) - return value - }, - set: (value) => { - Object.defineProperty(req, prop, { ...optsReset, value }) - }, - }) -} diff --git a/packages/next/server/base-http.ts b/packages/next/server/base-http.ts deleted file mode 100644 index e53d2ef811325..0000000000000 --- a/packages/next/server/base-http.ts +++ /dev/null @@ -1,289 +0,0 @@ -import type { ServerResponse, IncomingMessage, IncomingHttpHeaders } from 'http' -import type { Writable, Readable } from 'stream' - -import { PERMANENT_REDIRECT_STATUS } from '../shared/lib/constants' -import { - getCookieParser, - NextApiRequestCookies, - parseBody, - SYMBOL_CLEARED_COOKIES, -} from './api-utils' -import { I18NConfig } from './config-shared' -import { NEXT_REQUEST_META, RequestMeta } from './request-meta' - -export interface BaseNextRequestConfig { - basePath: string | undefined - i18n?: I18NConfig - trailingSlash?: boolean | undefined -} - -export abstract class BaseNextRequest { - protected _cookies: NextApiRequestCookies | undefined - public abstract headers: IncomingHttpHeaders - - constructor(public method: string, public url: string, public body: Body) {} - - abstract parseBody(limit: string | number): Promise - - // Utils implemented using the abstract methods above - - public get cookies() { - if (this._cookies) return this._cookies - return (this._cookies = getCookieParser(this.headers)()) - } -} - -export class NodeNextRequest extends BaseNextRequest { - public headers = this._req.headers; - - [NEXT_REQUEST_META]: RequestMeta - - get originalRequest() { - // Need to mimic these changes to the original req object for places where we use it: - // render.tsx, api/ssg requests - this._req[NEXT_REQUEST_META] = this[NEXT_REQUEST_META] - this._req.url = this.url - this._req.cookies = this.cookies - return this._req - } - - constructor( - private _req: IncomingMessage & { - [NEXT_REQUEST_META]?: RequestMeta - cookies?: NextApiRequestCookies - } - ) { - super(_req.method!.toUpperCase(), _req.url!, _req) - } - - async parseBody(limit: string | number): Promise { - return parseBody(this._req, limit) - } -} - -export class WebNextRequest extends BaseNextRequest { - public request: Request - public headers: IncomingHttpHeaders - - constructor(request: Request) { - const url = new URL(request.url) - - super( - request.method, - url.href.slice(url.origin.length), - request.clone().body - ) - this.request = request - - this.headers = {} - for (const [name, value] of request.headers.entries()) { - this.headers[name] = value - } - } - - async parseBody(_limit: string | number): Promise { - throw new Error('parseBody is not implemented in the web runtime') - } -} - -export abstract class BaseNextResponse { - abstract statusCode: number | undefined - abstract statusMessage: string | undefined - abstract get sent(): boolean - - constructor(public destination: Destination) {} - - /** - * Sets a value for the header overwriting existing values - */ - abstract setHeader(name: string, value: string | string[]): this - - /** - * Appends value for the given header name - */ - abstract appendHeader(name: string, value: string): this - - /** - * Get all vaues for a header as an array or undefined if no value is present - */ - abstract getHeaderValues(name: string): string[] | undefined - - abstract hasHeader(name: string): boolean - - /** - * Get vaues for a header concatenated using `,` or undefined if no value is present - */ - abstract getHeader(name: string): string | undefined - - abstract body(value: string): this - - abstract send(): void - - // Utils implemented using the abstract methods above - - redirect(destination: string, statusCode: number) { - this.setHeader('Location', destination) - this.statusCode = statusCode - - // Since IE11 doesn't support the 308 header add backwards - // compatibility using refresh header - if (statusCode === PERMANENT_REDIRECT_STATUS) { - this.setHeader('Refresh', `0;url=${destination}`) - } - return this - } -} - -export class NodeNextResponse extends BaseNextResponse { - private textBody: string | undefined = undefined - - public [SYMBOL_CLEARED_COOKIES]?: boolean - - get originalResponse() { - if (SYMBOL_CLEARED_COOKIES in this) { - this._res[SYMBOL_CLEARED_COOKIES] = this[SYMBOL_CLEARED_COOKIES] - } - - return this._res - } - - constructor( - private _res: ServerResponse & { [SYMBOL_CLEARED_COOKIES]?: boolean } - ) { - super(_res) - } - - get sent() { - return this._res.finished || this._res.headersSent - } - - get statusCode() { - return this._res.statusCode - } - - set statusCode(value: number) { - this._res.statusCode = value - } - - get statusMessage() { - return this._res.statusMessage - } - - set statusMessage(value: string) { - this._res.statusMessage = value - } - - setHeader(name: string, value: string | string[]): this { - this._res.setHeader(name, value) - return this - } - - getHeaderValues(name: string): string[] | undefined { - const values = this._res.getHeader(name) - - if (values === undefined) return undefined - - return (Array.isArray(values) ? values : [values]).map((value) => - value.toString() - ) - } - - hasHeader(name: string): boolean { - return this._res.hasHeader(name) - } - - getHeader(name: string): string | undefined { - const values = this.getHeaderValues(name) - return Array.isArray(values) ? values.join(',') : undefined - } - - appendHeader(name: string, value: string): this { - const currentValues = this.getHeaderValues(name) ?? [] - - if (!currentValues.includes(value)) { - this._res.setHeader(name, [...currentValues, value]) - } - - return this - } - - body(value: string) { - this.textBody = value - return this - } - - send() { - this._res.end(this.textBody) - } -} - -export class WebNextResponse extends BaseNextResponse { - private headers = new Headers() - private textBody: string | undefined = undefined - private _sent = false - - private sendPromise = new Promise((resolve) => { - this.sendResolve = resolve - }) - private sendResolve?: () => void - private response = this.sendPromise.then(() => { - return new Response(this.textBody ?? this.transformStream.readable, { - headers: this.headers, - status: this.statusCode, - statusText: this.statusMessage, - }) - }) - - public statusCode: number | undefined - public statusMessage: string | undefined - - get sent() { - return this._sent - } - - constructor(public transformStream = new TransformStream()) { - super(transformStream.writable) - } - - setHeader(name: string, value: string | string[]): this { - this.headers.delete(name) - for (const val of Array.isArray(value) ? value : [value]) { - this.headers.append(name, val) - } - return this - } - - getHeaderValues(name: string): string[] | undefined { - // https://developer.mozilla.org/en-US/docs/Web/API/Headers/get#example - return this.getHeader(name) - ?.split(',') - .map((v) => v.trimStart()) - } - - getHeader(name: string): string | undefined { - return this.headers.get(name) ?? undefined - } - - hasHeader(name: string): boolean { - return this.headers.has(name) - } - - appendHeader(name: string, value: string): this { - this.headers.append(name, value) - return this - } - - body(value: string) { - this.textBody = value - return this - } - - send() { - this.sendResolve?.() - this._sent = true - } - - toResponse() { - return this.response - } -} diff --git a/packages/next/server/base-http/index.ts b/packages/next/server/base-http/index.ts new file mode 100644 index 0000000000000..f72cdb3f34ae6 --- /dev/null +++ b/packages/next/server/base-http/index.ts @@ -0,0 +1,75 @@ +import type { IncomingHttpHeaders } from 'http' +import type { I18NConfig } from '../config-shared' + +import { PERMANENT_REDIRECT_STATUS } from '../../shared/lib/constants' +import { getCookieParser, NextApiRequestCookies } from '../api-utils' + +export interface BaseNextRequestConfig { + basePath: string | undefined + i18n?: I18NConfig + trailingSlash?: boolean | undefined +} + +export abstract class BaseNextRequest { + protected _cookies: NextApiRequestCookies | undefined + public abstract headers: IncomingHttpHeaders + + constructor(public method: string, public url: string, public body: Body) {} + + abstract parseBody(limit: string | number): Promise + + // Utils implemented using the abstract methods above + + public get cookies() { + if (this._cookies) return this._cookies + return (this._cookies = getCookieParser(this.headers)()) + } +} + +export abstract class BaseNextResponse { + abstract statusCode: number | undefined + abstract statusMessage: string | undefined + abstract get sent(): boolean + + constructor(public destination: Destination) {} + + /** + * Sets a value for the header overwriting existing values + */ + abstract setHeader(name: string, value: string | string[]): this + + /** + * Appends value for the given header name + */ + abstract appendHeader(name: string, value: string): this + + /** + * Get all vaues for a header as an array or undefined if no value is present + */ + abstract getHeaderValues(name: string): string[] | undefined + + abstract hasHeader(name: string): boolean + + /** + * Get vaues for a header concatenated using `,` or undefined if no value is present + */ + abstract getHeader(name: string): string | undefined + + abstract body(value: string): this + + abstract send(): void + + // Utils implemented using the abstract methods above + + redirect(destination: string, statusCode: number) { + this.setHeader('Location', destination) + this.statusCode = statusCode + + // Since IE11 doesn't support the 308 header add backwards + // compatibility using refresh header + if (statusCode === PERMANENT_REDIRECT_STATUS) { + this.setHeader('Refresh', `0;url=${destination}`) + } + return this + } +} diff --git a/packages/next/server/base-http/node.ts b/packages/next/server/base-http/node.ts new file mode 100644 index 0000000000000..5d5c54ce00643 --- /dev/null +++ b/packages/next/server/base-http/node.ts @@ -0,0 +1,119 @@ +import type { ServerResponse, IncomingMessage } from 'http' +import type { Writable, Readable } from 'stream' + +import { NextApiRequestCookies, SYMBOL_CLEARED_COOKIES } from '../api-utils' +import { parseBody } from '../api-utils/node' +import { NEXT_REQUEST_META, RequestMeta } from '../request-meta' + +import { BaseNextRequest, BaseNextResponse } from './index' + +export class NodeNextRequest extends BaseNextRequest { + public headers = this._req.headers; + + [NEXT_REQUEST_META]: RequestMeta + + get originalRequest() { + // Need to mimic these changes to the original req object for places where we use it: + // render.tsx, api/ssg requests + this._req[NEXT_REQUEST_META] = this[NEXT_REQUEST_META] + this._req.url = this.url + this._req.cookies = this.cookies + return this._req + } + + constructor( + private _req: IncomingMessage & { + [NEXT_REQUEST_META]?: RequestMeta + cookies?: NextApiRequestCookies + } + ) { + super(_req.method!.toUpperCase(), _req.url!, _req) + } + + async parseBody(limit: string | number): Promise { + return parseBody(this._req, limit) + } +} + +export class NodeNextResponse extends BaseNextResponse { + private textBody: string | undefined = undefined + + public [SYMBOL_CLEARED_COOKIES]?: boolean + + get originalResponse() { + if (SYMBOL_CLEARED_COOKIES in this) { + this._res[SYMBOL_CLEARED_COOKIES] = this[SYMBOL_CLEARED_COOKIES] + } + + return this._res + } + + constructor( + private _res: ServerResponse & { [SYMBOL_CLEARED_COOKIES]?: boolean } + ) { + super(_res) + } + + get sent() { + return this._res.finished || this._res.headersSent + } + + get statusCode() { + return this._res.statusCode + } + + set statusCode(value: number) { + this._res.statusCode = value + } + + get statusMessage() { + return this._res.statusMessage + } + + set statusMessage(value: string) { + this._res.statusMessage = value + } + + setHeader(name: string, value: string | string[]): this { + this._res.setHeader(name, value) + return this + } + + getHeaderValues(name: string): string[] | undefined { + const values = this._res.getHeader(name) + + if (values === undefined) return undefined + + return (Array.isArray(values) ? values : [values]).map((value) => + value.toString() + ) + } + + hasHeader(name: string): boolean { + return this._res.hasHeader(name) + } + + getHeader(name: string): string | undefined { + const values = this.getHeaderValues(name) + return Array.isArray(values) ? values.join(',') : undefined + } + + appendHeader(name: string, value: string): this { + const currentValues = this.getHeaderValues(name) ?? [] + + if (!currentValues.includes(value)) { + this._res.setHeader(name, [...currentValues, value]) + } + + return this + } + + body(value: string) { + this.textBody = value + return this + } + + send() { + this._res.end(this.textBody) + } +} diff --git a/packages/next/server/base-http/web.ts b/packages/next/server/base-http/web.ts new file mode 100644 index 0000000000000..e6705089c262c --- /dev/null +++ b/packages/next/server/base-http/web.ts @@ -0,0 +1,99 @@ +import type { IncomingHttpHeaders } from 'http' + +import { BaseNextRequest, BaseNextResponse } from './index' + +export class WebNextRequest extends BaseNextRequest { + public request: Request + public headers: IncomingHttpHeaders + + constructor(request: Request) { + const url = new URL(request.url) + + super( + request.method, + url.href.slice(url.origin.length), + request.clone().body + ) + this.request = request + + this.headers = {} + for (const [name, value] of request.headers.entries()) { + this.headers[name] = value + } + } + + async parseBody(_limit: string | number): Promise { + throw new Error('parseBody is not implemented in the web runtime') + } +} + +export class WebNextResponse extends BaseNextResponse { + private headers = new Headers() + private textBody: string | undefined = undefined + private _sent = false + + private sendPromise = new Promise((resolve) => { + this.sendResolve = resolve + }) + private sendResolve?: () => void + private response = this.sendPromise.then(() => { + return new Response(this.textBody ?? this.transformStream.readable, { + headers: this.headers, + status: this.statusCode, + statusText: this.statusMessage, + }) + }) + + public statusCode: number | undefined + public statusMessage: string | undefined + + get sent() { + return this._sent + } + + constructor(public transformStream = new TransformStream()) { + super(transformStream.writable) + } + + setHeader(name: string, value: string | string[]): this { + this.headers.delete(name) + for (const val of Array.isArray(value) ? value : [value]) { + this.headers.append(name, val) + } + return this + } + + getHeaderValues(name: string): string[] | undefined { + // https://developer.mozilla.org/en-US/docs/Web/API/Headers/get#example + return this.getHeader(name) + ?.split(',') + .map((v) => v.trimStart()) + } + + getHeader(name: string): string | undefined { + return this.headers.get(name) ?? undefined + } + + hasHeader(name: string): boolean { + return this.headers.has(name) + } + + appendHeader(name: string, value: string): this { + this.headers.append(name, value) + return this + } + + body(value: string) { + this.textBody = value + return this + } + + send() { + this.sendResolve?.() + this._sent = true + } + + toResponse() { + return this.response + } +} diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index 6bb231b65ae01..d14f8240417ed 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -33,9 +33,13 @@ import { getSortedRoutes, isDynamicRoute, } from '../shared/lib/router/utils' +import { + setLazyProp, + getCookieParser, + checkIsManualRevalidate, +} from './api-utils' import * as envConfig from '../shared/lib/runtime-config' import { DecodeError, normalizeRepeatedSlashes } from '../shared/lib/utils' -import { setLazyProp, getCookieParser, tryGetPreviewData } from './api-utils' import { isTargetLikeServerless } from './utils' import Router, { replaceBasePath, route } from './router' import { PayloadOptions, setRevalidateHeaders } from './send-payload' @@ -59,7 +63,6 @@ import { addRequestMeta, getRequestMeta } from './request-meta' import { createHeaderRoute, createRedirectRoute } from './server-route-utils' import { PrerenderManifest } from '../build' import { ImageConfigComplete } from './image-config' -import { checkIsManualRevalidate } from '../server/api-utils' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -1176,8 +1179,13 @@ export default abstract class Server { let isPreviewMode = false if (hasServerProps || isSSG) { - previewData = tryGetPreviewData(req, res, this.renderOpts.previewProps) - isPreviewMode = previewData !== false + // For the edge runtime, we don't support preview mode in SSG. + if (!process.browser) { + const { tryGetPreviewData } = + require('./api-utils/node') as typeof import('./api-utils/node') + previewData = tryGetPreviewData(req, res, this.renderOpts.previewProps) + isPreviewMode = previewData !== false + } } let isManualRevalidate = false diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index dc4d5185a1492..e04b1668d19a6 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -9,6 +9,7 @@ import type { ParsedNextUrl } from '../../shared/lib/router/utils/parse-next-url import type { ParsedUrlQuery } from 'querystring' import type { Server as HTTPServer } from 'http' import type { UrlWithParsedQuery } from 'url' +import type { BaseNextRequest, BaseNextResponse } from '../base-http' import crypto from 'crypto' import fs from 'fs' @@ -58,12 +59,7 @@ import * as Log from '../../build/output/log' import isError, { getProperError } from '../../lib/is-error' import { getMiddlewareRegex } from '../../shared/lib/router/utils/get-middleware-regex' import { isCustomErrorPage, isReservedPage } from '../../build/utils' -import { - BaseNextRequest, - BaseNextResponse, - NodeNextResponse, - NodeNextRequest, -} from '../base-http' +import { NodeNextResponse, NodeNextRequest } from '../base-http/node' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: React.FunctionComponent diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index b99b88da47b96..ad0bdca7a0ab0 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -7,6 +7,7 @@ import type { FetchEventResult } from './web/types' import type { ParsedNextUrl } from '../shared/lib/router/utils/parse-next-url' import type { PrerenderManifest } from '../build' import type { Rewrite } from '../lib/load-custom-routes' +import type { BaseNextRequest, BaseNextResponse } from './base-http' import { execOnce } from '../shared/lib/utils' import { @@ -41,16 +42,11 @@ import Proxy from 'next/dist/compiled/http-proxy' import { route } from './router' import { run } from './web/sandbox' -import { - BaseNextRequest, - BaseNextResponse, - NodeNextRequest, - NodeNextResponse, -} from './base-http' +import { NodeNextRequest, NodeNextResponse } from './base-http/node' import { PayloadOptions, sendRenderResult } from './send-payload' import { getExtension, serveStatic } from './serve-static' import { ParsedUrlQuery } from 'querystring' -import { apiResolver } from './api-utils' +import { apiResolver } from './api-utils/node' import { RenderOpts, renderToHTML } from './render' import { ParsedUrl } from '../shared/lib/router/utils/parse-url' import * as Log from '../build/output/log' diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index 8daac1499eb00..e5dcfe32ffd44 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -66,7 +66,7 @@ import { ImageConfigComplete } from './image-config' let optimizeAmp: typeof import('./optimize-amp').default let getFontDefinitionFromManifest: typeof import('./font-utils').getFontDefinitionFromManifest -let tryGetPreviewData: typeof import('./api-utils').tryGetPreviewData +let tryGetPreviewData: typeof import('./api-utils/node').tryGetPreviewData let warn: typeof import('../build/output/log').warn let postProcess: typeof import('../shared/lib/post-process').default @@ -77,7 +77,7 @@ if (!process.browser) { optimizeAmp = require('./optimize-amp').default getFontDefinitionFromManifest = require('./font-utils').getFontDefinitionFromManifest - tryGetPreviewData = require('./api-utils').tryGetPreviewData + tryGetPreviewData = require('./api-utils/node').tryGetPreviewData warn = require('../build/output/log').warn postProcess = require('../shared/lib/post-process').default } else { diff --git a/packages/next/server/request-meta.ts b/packages/next/server/request-meta.ts index 271af409ac667..e2fed315088dc 100644 --- a/packages/next/server/request-meta.ts +++ b/packages/next/server/request-meta.ts @@ -2,8 +2,7 @@ import type { IncomingMessage } from 'http' import type { ParsedUrlQuery } from 'querystring' import type { UrlWithParsedQuery } from 'url' - -import { BaseNextRequest } from './base-http' +import type { BaseNextRequest } from './base-http' export const NEXT_REQUEST_META = Symbol('NextRequestMeta') diff --git a/packages/next/server/router.ts b/packages/next/server/router.ts index 1c9fc3edfffb3..1c618e7616a3f 100644 --- a/packages/next/server/router.ts +++ b/packages/next/server/router.ts @@ -1,13 +1,13 @@ import type { ParsedUrlQuery } from 'querystring' -import { getNextInternalQuery, NextUrlWithParsedQuery } from './request-meta' +import type { BaseNextRequest, BaseNextResponse } from './base-http' +import { getNextInternalQuery, NextUrlWithParsedQuery } from './request-meta' import pathMatch from '../shared/lib/router/utils/path-match' import { removePathTrailingSlash } from '../client/normalize-trailing-slash' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import { RouteHas } from '../lib/load-custom-routes' import { matchHas } from '../shared/lib/router/utils/prepare-destination' import { getRequestMeta } from './request-meta' -import { BaseNextRequest, BaseNextResponse } from './base-http' export const route = pathMatch() diff --git a/packages/next/server/web-server.ts b/packages/next/server/web-server.ts index 0975b47c6d07c..b3e11bc0bf4a4 100644 --- a/packages/next/server/web-server.ts +++ b/packages/next/server/web-server.ts @@ -1,4 +1,4 @@ -import type { WebNextRequest, WebNextResponse } from './base-http' +import type { WebNextRequest, WebNextResponse } from './base-http/web' import type { RenderOpts } from './render' import type RenderResult from './render-result' import type { NextParsedUrlQuery } from './request-meta'