SSG Preview Mode (#10459)
* checkpoint: api impl * Add support for tryGetPreviewData * snapshot: server(less) support * Add X-Prerender-Bypass-Mode header support * Pass preview data to getStaticProps call * add TODO * setPreviewData * 100k iterations * Handle jwt error * Write out preview values * forgot file * set preview props * Send preview props * add preview props * Pass around more data * update yarn lock * Fail on Invalid Prerender Manifest * Make Missing Prerender Manifest Fatal * fix ts errors * fix test * Fix setting cookies + maxage * Secure is not needed as we encrypt necessary data * Set on domain root * Set cookie max ages * Render a fallback on-demand for non-dynamic pages * Test preview mode * remove old build * remove snapshots * Add serverless tests * use afterAll * Remove object assigns * fix cookie spread * add comment
This commit is contained in:
parent
9a9c0d2c4f
commit
3cb3498324
19 changed files with 768 additions and 75 deletions
|
@ -40,6 +40,7 @@
|
|||
"@babel/preset-react": "7.7.0",
|
||||
"@fullhuman/postcss-purgecss": "1.3.0",
|
||||
"@mdx-js/loader": "0.18.0",
|
||||
"@types/cheerio": "0.22.16",
|
||||
"@types/http-proxy": "1.17.3",
|
||||
"@types/jest": "24.0.13",
|
||||
"@types/string-hash": "1.1.1",
|
||||
|
@ -59,6 +60,7 @@
|
|||
"caniuse-lite": "^1.0.30001019",
|
||||
"cheerio": "0.22.0",
|
||||
"clone": "2.1.2",
|
||||
"cookie": "0.4.0",
|
||||
"coveralls": "3.0.3",
|
||||
"cross-env": "6.0.3",
|
||||
"cross-spawn": "6.0.5",
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import chalk from 'chalk'
|
||||
import { join } from 'path'
|
||||
import { stringify } from 'querystring'
|
||||
|
||||
import { API_ROUTE, DOT_NEXT_ALIAS, PAGES_DIR_ALIAS } from '../lib/constants'
|
||||
import { __ApiPreviewProps } from '../next-server/server/api-utils'
|
||||
import { isTargetLikeServerless } from '../next-server/server/config'
|
||||
import { normalizePagePath } from '../next-server/server/normalize-page-path'
|
||||
import { warn } from './output/log'
|
||||
import { ServerlessLoaderQuery } from './webpack/loaders/next-serverless-loader'
|
||||
import { normalizePagePath } from '../next-server/server/normalize-page-path'
|
||||
|
||||
type PagesMapping = {
|
||||
[page: string]: string
|
||||
|
@ -63,6 +63,7 @@ export function createEntrypoints(
|
|||
pages: PagesMapping,
|
||||
target: 'server' | 'serverless' | 'experimental-serverless-trace',
|
||||
buildId: string,
|
||||
previewMode: __ApiPreviewProps,
|
||||
config: any
|
||||
): Entrypoints {
|
||||
const client: WebpackEntrypoints = {}
|
||||
|
@ -88,6 +89,7 @@ export function createEntrypoints(
|
|||
serverRuntimeConfig: config.serverRuntimeConfig,
|
||||
})
|
||||
: '',
|
||||
previewProps: JSON.stringify(previewMode),
|
||||
}
|
||||
|
||||
Object.keys(pages).forEach(page => {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import chalk from 'chalk'
|
||||
import ciEnvironment from 'ci-info'
|
||||
import crypto from 'crypto'
|
||||
import escapeStringRegexp from 'escape-string-regexp'
|
||||
import findUp from 'find-up'
|
||||
import fs from 'fs'
|
||||
|
@ -41,9 +42,11 @@ import {
|
|||
getSortedRoutes,
|
||||
isDynamicRoute,
|
||||
} from '../next-server/lib/router/utils'
|
||||
import { __ApiPreviewProps } from '../next-server/server/api-utils'
|
||||
import loadConfig, {
|
||||
isTargetLikeServerless,
|
||||
} from '../next-server/server/config'
|
||||
import { normalizePagePath } from '../next-server/server/normalize-page-path'
|
||||
import {
|
||||
eventBuildCompleted,
|
||||
eventBuildOptimize,
|
||||
|
@ -67,7 +70,6 @@ import {
|
|||
} from './utils'
|
||||
import getBaseWebpackConfig from './webpack-config'
|
||||
import { writeBuildId } from './write-build-id'
|
||||
import { normalizePagePath } from '../next-server/server/normalize-page-path'
|
||||
|
||||
const fsAccess = promisify(fs.access)
|
||||
const fsUnlink = promisify(fs.unlink)
|
||||
|
@ -97,6 +99,7 @@ export type PrerenderManifest = {
|
|||
version: number
|
||||
routes: { [route: string]: SsgRoute }
|
||||
dynamicRoutes: { [route: string]: DynamicSsgRoute }
|
||||
preview: __ApiPreviewProps
|
||||
}
|
||||
|
||||
export default async function build(dir: string, conf = null): Promise<void> {
|
||||
|
@ -198,8 +201,20 @@ export default async function build(dir: string, conf = null): Promise<void> {
|
|||
const allStaticPages = new Set<string>()
|
||||
let allPageInfos = new Map<string, PageInfo>()
|
||||
|
||||
const previewProps: __ApiPreviewProps = {
|
||||
previewModeId: crypto.randomBytes(16).toString('hex'),
|
||||
previewModeSigningKey: crypto.randomBytes(32).toString('hex'),
|
||||
previewModeEncryptionKey: crypto.randomBytes(32).toString('hex'),
|
||||
}
|
||||
|
||||
const mappedPages = createPagesMapping(pagePaths, config.pageExtensions)
|
||||
const entrypoints = createEntrypoints(mappedPages, target, buildId, config)
|
||||
const entrypoints = createEntrypoints(
|
||||
mappedPages,
|
||||
target,
|
||||
buildId,
|
||||
previewProps,
|
||||
config
|
||||
)
|
||||
const pageKeys = Object.keys(mappedPages)
|
||||
const dynamicRoutes = pageKeys.filter(page => isDynamicRoute(page))
|
||||
const conflictingPublicFiles: string[] = []
|
||||
|
@ -802,6 +817,7 @@ export default async function build(dir: string, conf = null): Promise<void> {
|
|||
version: 1,
|
||||
routes: finalPrerenderRoutes,
|
||||
dynamicRoutes: finalDynamicRoutes,
|
||||
preview: previewProps,
|
||||
}
|
||||
|
||||
await fsWriteFile(
|
||||
|
@ -814,6 +830,7 @@ export default async function build(dir: string, conf = null): Promise<void> {
|
|||
version: 1,
|
||||
routes: {},
|
||||
dynamicRoutes: {},
|
||||
preview: previewProps,
|
||||
}
|
||||
await fsWriteFile(
|
||||
path.join(distDir, PRERENDER_MANIFEST),
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import { loader } from 'webpack'
|
||||
import devalue from 'devalue'
|
||||
import escapeRegexp from 'escape-string-regexp'
|
||||
import { join } from 'path'
|
||||
import { parse } from 'querystring'
|
||||
import { loader } from 'webpack'
|
||||
import { API_ROUTE } from '../../../lib/constants'
|
||||
import {
|
||||
BUILD_MANIFEST,
|
||||
ROUTES_MANIFEST,
|
||||
REACT_LOADABLE_MANIFEST,
|
||||
ROUTES_MANIFEST,
|
||||
} from '../../../next-server/lib/constants'
|
||||
import { isDynamicRoute } from '../../../next-server/lib/router/utils'
|
||||
import { API_ROUTE } from '../../../lib/constants'
|
||||
import escapeRegexp from 'escape-string-regexp'
|
||||
import { __ApiPreviewProps } from '../../../next-server/server/api-utils'
|
||||
|
||||
export type ServerlessLoaderQuery = {
|
||||
page: string
|
||||
|
@ -23,6 +25,7 @@ export type ServerlessLoaderQuery = {
|
|||
canonicalBase: string
|
||||
basePath: string
|
||||
runtimeConfig: string
|
||||
previewProps: string
|
||||
}
|
||||
|
||||
const nextServerlessLoader: loader.Loader = function() {
|
||||
|
@ -39,6 +42,7 @@ const nextServerlessLoader: loader.Loader = function() {
|
|||
generateEtags,
|
||||
basePath,
|
||||
runtimeConfig,
|
||||
previewProps,
|
||||
}: ServerlessLoaderQuery =
|
||||
typeof this.query === 'string' ? parse(this.query.substr(1)) : this.query
|
||||
|
||||
|
@ -52,6 +56,10 @@ const nextServerlessLoader: loader.Loader = function() {
|
|||
const escapedBuildId = escapeRegexp(buildId)
|
||||
const pageIsDynamicRoute = isDynamicRoute(page)
|
||||
|
||||
const encodedPreviewProps = devalue(
|
||||
JSON.parse(previewProps) as __ApiPreviewProps
|
||||
)
|
||||
|
||||
const runtimeConfigImports = runtimeConfig
|
||||
? `
|
||||
const { setConfig } = require('next/dist/next-server/lib/runtime-config')
|
||||
|
@ -176,6 +184,7 @@ const nextServerlessLoader: loader.Loader = function() {
|
|||
res,
|
||||
Object.assign({}, parsedUrl.query, params ),
|
||||
resolver,
|
||||
${encodedPreviewProps},
|
||||
onError
|
||||
)
|
||||
} catch (err) {
|
||||
|
@ -243,6 +252,7 @@ const nextServerlessLoader: loader.Loader = function() {
|
|||
buildId: "${buildId}",
|
||||
assetPrefix: "${assetPrefix}",
|
||||
runtimeConfig: runtimeConfig.publicRuntimeConfig || {},
|
||||
previewProps: ${encodedPreviewProps},
|
||||
..._renderOpts
|
||||
}
|
||||
let _nextData = false
|
||||
|
|
|
@ -2,7 +2,6 @@ import { IncomingMessage, ServerResponse } from 'http'
|
|||
import { ParsedUrlQuery } from 'querystring'
|
||||
import { ComponentType } from 'react'
|
||||
import { format, URLFormatOptions, UrlObject } from 'url'
|
||||
|
||||
import { ManifestItem } from '../server/load-components'
|
||||
import { NextRouter } from './router/router'
|
||||
|
||||
|
@ -201,6 +200,23 @@ export type NextApiResponse<T = any> = ServerResponse & {
|
|||
*/
|
||||
json: Send<T>
|
||||
status: (statusCode: number) => NextApiResponse<T>
|
||||
|
||||
/**
|
||||
* Set preview data for Next.js' prerender mode
|
||||
*/
|
||||
setPreviewData: (
|
||||
data: object | string,
|
||||
options?: {
|
||||
/**
|
||||
* Specifies the number (in seconds) for the preview session to last for.
|
||||
* The given number will be converted to an integer by rounding down.
|
||||
* By default, no maximum age is set and the preview session finishes
|
||||
* when the client shuts down (browser is closed).
|
||||
*/
|
||||
maxAge?: number
|
||||
}
|
||||
) => NextApiResponse<T>
|
||||
clearPreviewData: () => NextApiResponse<T>
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,21 +1,29 @@
|
|||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { NextApiResponse, NextApiRequest } from '../lib/utils'
|
||||
import { Stream } from 'stream'
|
||||
import getRawBody from 'raw-body'
|
||||
import { parse } from 'content-type'
|
||||
import { Params } from './router'
|
||||
import { CookieSerializeOptions } from 'cookie'
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { PageConfig } from 'next/types'
|
||||
import getRawBody from 'raw-body'
|
||||
import { Stream } from 'stream'
|
||||
import { isResSent, NextApiRequest, NextApiResponse } from '../lib/utils'
|
||||
import { decryptWithSecret, encryptWithSecret } from './crypto-utils'
|
||||
import { interopDefault } from './load-components'
|
||||
import { isResSent } from '../lib/utils'
|
||||
import { Params } from './router'
|
||||
|
||||
export type NextApiRequestCookies = { [key: string]: string }
|
||||
export type NextApiRequestQuery = { [key: string]: string | string[] }
|
||||
|
||||
export type __ApiPreviewProps = {
|
||||
previewModeId: string
|
||||
previewModeEncryptionKey: string
|
||||
previewModeSigningKey: string
|
||||
}
|
||||
|
||||
export async function apiResolver(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
params: any,
|
||||
resolverModule: any,
|
||||
apiContext: __ApiPreviewProps,
|
||||
onError?: ({ err }: { err: any }) => Promise<void>
|
||||
) {
|
||||
const apiReq = req as NextApiRequest
|
||||
|
@ -53,6 +61,9 @@ export async function apiResolver(
|
|||
apiRes.status = statusCode => sendStatusCode(apiRes, statusCode)
|
||||
apiRes.send = data => sendData(apiRes, data)
|
||||
apiRes.json = data => sendJson(apiRes, data)
|
||||
apiRes.setPreviewData = (data, options = {}) =>
|
||||
setPreviewData(apiRes, data, Object.assign({}, apiContext, options))
|
||||
apiRes.clearPreviewData = () => clearPreviewData(apiRes)
|
||||
|
||||
const resolver = interopDefault(resolverModule)
|
||||
let wasPiped = false
|
||||
|
@ -245,6 +256,179 @@ export function sendJson(res: NextApiResponse, jsonBody: any): void {
|
|||
res.send(jsonBody)
|
||||
}
|
||||
|
||||
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 function tryGetPreviewData(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
options: __ApiPreviewProps
|
||||
): object | string | 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)
|
||||
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('jsonwebtoken') as typeof import('jsonwebtoken')
|
||||
let encryptedPreviewData: string
|
||||
try {
|
||||
encryptedPreviewData = jsonwebtoken.verify(
|
||||
tokenPreviewData,
|
||||
options.previewModeSigningKey
|
||||
) as string
|
||||
} catch {
|
||||
// TODO: warn
|
||||
clearPreviewData(res as NextApiResponse)
|
||||
return false
|
||||
}
|
||||
|
||||
const decryptedPreviewData = decryptWithSecret(
|
||||
Buffer.from(options.previewModeEncryptionKey),
|
||||
encryptedPreviewData
|
||||
)
|
||||
|
||||
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 setPreviewData<T>(
|
||||
res: NextApiResponse<T>,
|
||||
data: object | string, // TODO: strict runtime type checking
|
||||
options: {
|
||||
maxAge?: number
|
||||
} & __ApiPreviewProps
|
||||
): NextApiResponse<T> {
|
||||
if (
|
||||
typeof options.previewModeId !== 'string' ||
|
||||
options.previewModeId.length < 16
|
||||
) {
|
||||
throw new Error('invariant: invalid previewModeId')
|
||||
}
|
||||
if (
|
||||
typeof options.previewModeEncryptionKey !== 'string' ||
|
||||
options.previewModeEncryptionKey.length < 16
|
||||
) {
|
||||
throw new Error('invariant: invalid previewModeEncryptionKey')
|
||||
}
|
||||
if (
|
||||
typeof options.previewModeSigningKey !== 'string' ||
|
||||
options.previewModeSigningKey.length < 16
|
||||
) {
|
||||
throw new Error('invariant: invalid previewModeSigningKey')
|
||||
}
|
||||
|
||||
const jsonwebtoken = require('jsonwebtoken') as typeof import('jsonwebtoken')
|
||||
|
||||
const payload = jsonwebtoken.sign(
|
||||
encryptWithSecret(
|
||||
Buffer.from(options.previewModeEncryptionKey),
|
||||
JSON.stringify(data)
|
||||
),
|
||||
options.previewModeSigningKey,
|
||||
{
|
||||
algorithm: 'HS256',
|
||||
...(options.maxAge !== undefined
|
||||
? { expiresIn: options.maxAge }
|
||||
: undefined),
|
||||
}
|
||||
)
|
||||
|
||||
const { serialize } = require('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: 'strict',
|
||||
path: '/',
|
||||
...(options.maxAge !== undefined
|
||||
? ({ maxAge: options.maxAge } as CookieSerializeOptions)
|
||||
: undefined),
|
||||
}),
|
||||
serialize(COOKIE_NAME_PRERENDER_DATA, payload, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
...(options.maxAge !== undefined
|
||||
? ({ maxAge: options.maxAge } as CookieSerializeOptions)
|
||||
: undefined),
|
||||
}),
|
||||
])
|
||||
return res
|
||||
}
|
||||
|
||||
function clearPreviewData<T>(res: NextApiResponse<T>): NextApiResponse<T> {
|
||||
const { serialize } = require('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, '', {
|
||||
maxAge: 0,
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
}),
|
||||
serialize(COOKIE_NAME_PRERENDER_DATA, '', {
|
||||
maxAge: 0,
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
}),
|
||||
])
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class
|
||||
*/
|
||||
|
|
74
packages/next/next-server/server/crypto-utils.ts
Normal file
74
packages/next/next-server/server/crypto-utils.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import crypto from 'crypto'
|
||||
|
||||
// Background:
|
||||
// https://security.stackexchange.com/questions/184305/why-would-i-ever-use-aes-256-cbc-if-aes-256-gcm-is-more-secure
|
||||
|
||||
const CIPHER_ALGORITHM = `aes-256-gcm`,
|
||||
CIPHER_KEY_LENGTH = 32, // https://stackoverflow.com/a/28307668/4397028
|
||||
CIPHER_IV_LENGTH = 16, // https://stackoverflow.com/a/28307668/4397028
|
||||
CIPHER_TAG_LENGTH = 16,
|
||||
CIPHER_SALT_LENGTH = 64
|
||||
|
||||
const PBKDF2_ITERATIONS = 100_000 // https://support.1password.com/pbkdf2/
|
||||
|
||||
export function encryptWithSecret(secret: Buffer, data: string) {
|
||||
const iv = crypto.randomBytes(CIPHER_IV_LENGTH)
|
||||
const salt = crypto.randomBytes(CIPHER_SALT_LENGTH)
|
||||
|
||||
// https://nodejs.org/api/crypto.html#crypto_crypto_pbkdf2sync_password_salt_iterations_keylen_digest
|
||||
const key = crypto.pbkdf2Sync(
|
||||
secret,
|
||||
salt,
|
||||
PBKDF2_ITERATIONS,
|
||||
CIPHER_KEY_LENGTH,
|
||||
`sha512`
|
||||
)
|
||||
|
||||
const cipher = crypto.createCipheriv(CIPHER_ALGORITHM, key, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(data, `utf8`), cipher.final()])
|
||||
|
||||
// https://nodejs.org/api/crypto.html#crypto_cipher_getauthtag
|
||||
const tag = cipher.getAuthTag()
|
||||
|
||||
return Buffer.concat([
|
||||
// Data as required by:
|
||||
// Salt for Key: https://nodejs.org/api/crypto.html#crypto_crypto_pbkdf2sync_password_salt_iterations_keylen_digest
|
||||
// IV: https://nodejs.org/api/crypto.html#crypto_class_decipher
|
||||
// Tag: https://nodejs.org/api/crypto.html#crypto_decipher_setauthtag_buffer
|
||||
salt,
|
||||
iv,
|
||||
tag,
|
||||
encrypted,
|
||||
]).toString(`hex`)
|
||||
}
|
||||
|
||||
export function decryptWithSecret(secret: Buffer, encryptedData: string) {
|
||||
const buffer = Buffer.from(encryptedData, `hex`)
|
||||
|
||||
const salt = buffer.slice(0, CIPHER_SALT_LENGTH)
|
||||
const iv = buffer.slice(
|
||||
CIPHER_SALT_LENGTH,
|
||||
CIPHER_SALT_LENGTH + CIPHER_IV_LENGTH
|
||||
)
|
||||
const tag = buffer.slice(
|
||||
CIPHER_SALT_LENGTH + CIPHER_IV_LENGTH,
|
||||
CIPHER_SALT_LENGTH + CIPHER_IV_LENGTH + CIPHER_TAG_LENGTH
|
||||
)
|
||||
const encrypted = buffer.slice(
|
||||
CIPHER_SALT_LENGTH + CIPHER_IV_LENGTH + CIPHER_TAG_LENGTH
|
||||
)
|
||||
|
||||
// https://nodejs.org/api/crypto.html#crypto_crypto_pbkdf2sync_password_salt_iterations_keylen_digest
|
||||
const key = crypto.pbkdf2Sync(
|
||||
secret,
|
||||
salt,
|
||||
PBKDF2_ITERATIONS,
|
||||
CIPHER_KEY_LENGTH,
|
||||
`sha512`
|
||||
)
|
||||
|
||||
const decipher = crypto.createDecipheriv(CIPHER_ALGORITHM, key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
|
||||
return decipher.update(encrypted) + decipher.final(`utf8`)
|
||||
}
|
|
@ -25,8 +25,10 @@ export type ManifestItem = {
|
|||
|
||||
type ReactLoadableManifest = { [moduleId: string]: ManifestItem[] }
|
||||
|
||||
type Unstable_getStaticProps = (params: {
|
||||
type Unstable_getStaticProps = (ctx: {
|
||||
params: ParsedUrlQuery | undefined
|
||||
preview?: boolean
|
||||
previewData?: any
|
||||
}) => Promise<{
|
||||
props: { [key: string]: any }
|
||||
revalidate?: number | boolean
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
import compression from 'compression'
|
||||
import fs from 'fs'
|
||||
import Proxy from 'http-proxy'
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import Proxy from 'http-proxy'
|
||||
import nanoid from 'next/dist/compiled/nanoid/index.js'
|
||||
import { join, resolve, sep } from 'path'
|
||||
import { parse as parseQs, ParsedUrlQuery } from 'querystring'
|
||||
import { format as formatUrl, parse as parseUrl, UrlWithParsedQuery } from 'url'
|
||||
|
||||
import {
|
||||
getRedirectStatus,
|
||||
Header,
|
||||
Redirect,
|
||||
Rewrite,
|
||||
RouteType,
|
||||
} from '../../lib/check-custom-routes'
|
||||
import { withCoalescedInvoke } from '../../lib/coalesced-function'
|
||||
import {
|
||||
BUILD_ID_FILE,
|
||||
|
@ -14,9 +21,10 @@ import {
|
|||
CLIENT_STATIC_FILES_RUNTIME,
|
||||
PAGES_MANIFEST,
|
||||
PHASE_PRODUCTION_SERVER,
|
||||
PRERENDER_MANIFEST,
|
||||
ROUTES_MANIFEST,
|
||||
SERVER_DIRECTORY,
|
||||
SERVERLESS_DIRECTORY,
|
||||
SERVER_DIRECTORY,
|
||||
} from '../lib/constants'
|
||||
import {
|
||||
getRouteMatcher,
|
||||
|
@ -26,38 +34,31 @@ import {
|
|||
} from '../lib/router/utils'
|
||||
import * as envConfig from '../lib/runtime-config'
|
||||
import { isResSent, NextApiRequest, NextApiResponse } from '../lib/utils'
|
||||
import { apiResolver } from './api-utils'
|
||||
import { apiResolver, tryGetPreviewData, __ApiPreviewProps } from './api-utils'
|
||||
import loadConfig, { isTargetLikeServerless } from './config'
|
||||
import pathMatch from './lib/path-match'
|
||||
import { recursiveReadDirSync } from './lib/recursive-readdir-sync'
|
||||
import { loadComponents, LoadComponentsReturnType } from './load-components'
|
||||
import { normalizePagePath } from './normalize-page-path'
|
||||
import { renderToHTML } from './render'
|
||||
import { getPagePath } from './require'
|
||||
import Router, {
|
||||
Params,
|
||||
route,
|
||||
Route,
|
||||
DynamicRoutes,
|
||||
PageChecker,
|
||||
Params,
|
||||
prepareDestination,
|
||||
route,
|
||||
Route,
|
||||
} from './router'
|
||||
import { sendHTML } from './send-html'
|
||||
import { serveStatic } from './serve-static'
|
||||
import {
|
||||
getFallback,
|
||||
getSprCache,
|
||||
initializeSprCache,
|
||||
setSprCache,
|
||||
getFallback,
|
||||
} from './spr-cache'
|
||||
import { isBlockedPage } from './utils'
|
||||
import {
|
||||
Redirect,
|
||||
Rewrite,
|
||||
RouteType,
|
||||
Header,
|
||||
getRedirectStatus,
|
||||
} from '../../lib/check-custom-routes'
|
||||
import { normalizePagePath } from './normalize-page-path'
|
||||
|
||||
const getCustomRouteMatcher = pathMatch(true)
|
||||
|
||||
|
@ -283,6 +284,17 @@ export default class Server {
|
|||
return require(join(this.distDir, ROUTES_MANIFEST))
|
||||
}
|
||||
|
||||
private _cachedPreviewProps: __ApiPreviewProps | undefined
|
||||
protected getPreviewProps(): __ApiPreviewProps {
|
||||
if (this._cachedPreviewProps) {
|
||||
return this._cachedPreviewProps
|
||||
}
|
||||
return (this._cachedPreviewProps = require(join(
|
||||
this.distDir,
|
||||
PRERENDER_MANIFEST
|
||||
)).preview)
|
||||
}
|
||||
|
||||
protected generateRoutes(): {
|
||||
headers: Route[]
|
||||
rewrites: Route[]
|
||||
|
@ -645,7 +657,15 @@ export default class Server {
|
|||
}
|
||||
}
|
||||
|
||||
await apiResolver(req, res, query, pageModule, this.onErrorMiddleware)
|
||||
const previewProps = this.getPreviewProps()
|
||||
await apiResolver(
|
||||
req,
|
||||
res,
|
||||
query,
|
||||
pageModule,
|
||||
{ ...previewProps },
|
||||
this.onErrorMiddleware
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -902,11 +922,20 @@ export default class Server {
|
|||
})
|
||||
}
|
||||
|
||||
const previewProps = this.getPreviewProps()
|
||||
const previewData = tryGetPreviewData(req, res, { ...previewProps })
|
||||
const isPreviewMode = previewData !== false
|
||||
|
||||
// Compute the SPR cache key
|
||||
const ssgCacheKey = parseUrl(req.url || '').pathname!
|
||||
const ssgCacheKey = isPreviewMode
|
||||
? `__` + nanoid() // Preview mode uses a throw away key to not coalesce preview invokes
|
||||
: parseUrl(req.url || '').pathname!
|
||||
|
||||
// Complete the response with cached data if its present
|
||||
const cachedData = await getSprCache(ssgCacheKey)
|
||||
const cachedData = isPreviewMode
|
||||
? // Preview data bypasses the cache
|
||||
undefined
|
||||
: await getSprCache(ssgCacheKey)
|
||||
if (cachedData) {
|
||||
const data = isDataReq
|
||||
? JSON.stringify(cachedData.pageData)
|
||||
|
@ -963,11 +992,20 @@ export default class Server {
|
|||
return { html, pageData, sprRevalidate }
|
||||
})
|
||||
|
||||
// render fallback if cached data wasn't available
|
||||
if (!isResSent(res) && !isDataReq && isDynamicRoute(pathname)) {
|
||||
// render fallback if for a preview path or a non-seeded dynamic path
|
||||
const isDynamicPathname = isDynamicRoute(pathname)
|
||||
if (
|
||||
!isResSent(res) &&
|
||||
!isDataReq &&
|
||||
((isPreviewMode &&
|
||||
// A header can opt into the blocking behavior.
|
||||
req.headers['X-Prerender-Bypass-Mode'] !== 'Blocking') ||
|
||||
isDynamicPathname)
|
||||
) {
|
||||
let html = ''
|
||||
|
||||
if (!this.renderOpts.dev) {
|
||||
const isProduction = !this.renderOpts.dev
|
||||
if (isProduction && (isDynamicPathname || !isPreviewMode)) {
|
||||
html = await getFallback(pathname)
|
||||
} else {
|
||||
query.__nextFallback = 'true'
|
||||
|
@ -999,11 +1037,14 @@ export default class Server {
|
|||
|
||||
// Update the SPR cache if the head request
|
||||
if (isOrigin) {
|
||||
await setSprCache(
|
||||
ssgCacheKey,
|
||||
{ html: html!, pageData },
|
||||
sprRevalidate
|
||||
)
|
||||
// Preview mode should not be stored in cache
|
||||
if (!isPreviewMode) {
|
||||
await setSprCache(
|
||||
ssgCacheKey,
|
||||
{ html: html!, pageData },
|
||||
sprRevalidate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
|
|
|
@ -1,37 +1,38 @@
|
|||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
import React from 'react'
|
||||
import { renderToString, renderToStaticMarkup } from 'react-dom/server'
|
||||
import { NextRouter } from '../lib/router/router'
|
||||
import mitt, { MittEmitter } from '../lib/mitt'
|
||||
import { renderToStaticMarkup, renderToString } from 'react-dom/server'
|
||||
import {
|
||||
loadGetInitialProps,
|
||||
isResSent,
|
||||
getDisplayName,
|
||||
ComponentsEnhancer,
|
||||
RenderPage,
|
||||
DocumentInitialProps,
|
||||
NextComponentType,
|
||||
DocumentType,
|
||||
AppType,
|
||||
} from '../lib/utils'
|
||||
PAGES_404_GET_INITIAL_PROPS_ERROR,
|
||||
SERVER_PROPS_GET_INIT_PROPS_CONFLICT,
|
||||
SERVER_PROPS_SSG_CONFLICT,
|
||||
SSG_GET_INITIAL_PROPS_CONFLICT,
|
||||
} from '../../lib/constants'
|
||||
import { isInAmpMode } from '../lib/amp'
|
||||
import { AmpStateContext } from '../lib/amp-context'
|
||||
import { AMP_RENDER_TARGET } from '../lib/constants'
|
||||
import Head, { defaultHead } from '../lib/head'
|
||||
import Loadable from '../lib/loadable'
|
||||
import { LoadableContext } from '../lib/loadable-context'
|
||||
import mitt, { MittEmitter } from '../lib/mitt'
|
||||
import { RouterContext } from '../lib/router-context'
|
||||
import { getPageFiles } from './get-page-files'
|
||||
import { AmpStateContext } from '../lib/amp-context'
|
||||
import optimizeAmp from './optimize-amp'
|
||||
import { isInAmpMode } from '../lib/amp'
|
||||
import { NextRouter } from '../lib/router/router'
|
||||
import { isDynamicRoute } from '../lib/router/utils/is-dynamic'
|
||||
import {
|
||||
SSG_GET_INITIAL_PROPS_CONFLICT,
|
||||
SERVER_PROPS_GET_INIT_PROPS_CONFLICT,
|
||||
SERVER_PROPS_SSG_CONFLICT,
|
||||
PAGES_404_GET_INITIAL_PROPS_ERROR,
|
||||
} from '../../lib/constants'
|
||||
import { AMP_RENDER_TARGET } from '../lib/constants'
|
||||
AppType,
|
||||
ComponentsEnhancer,
|
||||
DocumentInitialProps,
|
||||
DocumentType,
|
||||
getDisplayName,
|
||||
isResSent,
|
||||
loadGetInitialProps,
|
||||
NextComponentType,
|
||||
RenderPage,
|
||||
} from '../lib/utils'
|
||||
import { tryGetPreviewData, __ApiPreviewProps } from './api-utils'
|
||||
import { getPageFiles } from './get-page-files'
|
||||
import { LoadComponentsReturnType, ManifestItem } from './load-components'
|
||||
import optimizeAmp from './optimize-amp'
|
||||
|
||||
function noRouter() {
|
||||
const message =
|
||||
|
@ -137,6 +138,7 @@ type RenderOpts = LoadComponentsReturnType & {
|
|||
isDataReq?: boolean
|
||||
params?: ParsedUrlQuery
|
||||
pages404?: boolean
|
||||
previewProps: __ApiPreviewProps
|
||||
}
|
||||
|
||||
function renderDocument(
|
||||
|
@ -269,6 +271,7 @@ export async function renderToHTML(
|
|||
isDataReq,
|
||||
params,
|
||||
pages404,
|
||||
previewProps,
|
||||
} = renderOpts
|
||||
|
||||
const callMiddleware = async (method: string, args: any[], props = false) => {
|
||||
|
@ -442,8 +445,19 @@ export async function renderToHTML(
|
|||
})
|
||||
|
||||
if (isSpr && !isFallback) {
|
||||
// Reads of this are cached on the `req` object, so this should resolve
|
||||
// instantly. There's no need to pass this data down from a previous
|
||||
// invoke, where we'd have to consider server & serverless.
|
||||
const previewData = tryGetPreviewData(req, res, previewProps)
|
||||
const data = await unstable_getStaticProps!({
|
||||
params: isDynamicRoute(pathname) ? (query as any) : undefined,
|
||||
...(isDynamicRoute(pathname)
|
||||
? {
|
||||
params: query as ParsedUrlQuery,
|
||||
}
|
||||
: { params: undefined }),
|
||||
...(previewData !== false
|
||||
? { preview: true, previewData: previewData }
|
||||
: undefined),
|
||||
})
|
||||
|
||||
const invalidKeys = Object.keys(data).filter(
|
||||
|
|
|
@ -73,7 +73,12 @@ export function initializeSprCache({
|
|||
}
|
||||
|
||||
if (dev) {
|
||||
prerenderManifest = { version: -1, routes: {}, dynamicRoutes: {} }
|
||||
prerenderManifest = {
|
||||
version: -1,
|
||||
routes: {},
|
||||
dynamicRoutes: {},
|
||||
preview: null as any, // `preview` is special case read in next-dev-server
|
||||
}
|
||||
} else {
|
||||
prerenderManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(distDir, PRERENDER_MANIFEST), 'utf8')
|
||||
|
|
|
@ -108,6 +108,7 @@
|
|||
"is-wsl": "2.1.1",
|
||||
"jest-worker": "24.9.0",
|
||||
"json5": "2.1.1",
|
||||
"jsonwebtoken": "8.5.1",
|
||||
"launch-editor": "2.2.1",
|
||||
"loader-utils": "1.2.3",
|
||||
"lodash.curry": "4.1.1",
|
||||
|
@ -172,6 +173,7 @@
|
|||
"@types/find-up": "2.1.1",
|
||||
"@types/fresh": "0.5.0",
|
||||
"@types/json5": "0.0.30",
|
||||
"@types/jsonwebtoken": "8.3.7",
|
||||
"@types/loader-utils": "1.1.3",
|
||||
"@types/lodash.curry": "4.1.6",
|
||||
"@types/lru-cache": "5.1.0",
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { NextHandleFunction } from 'connect'
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { join, normalize, relative as relativePath, sep } from 'path'
|
||||
import { UrlObject } from 'url'
|
||||
import webpack from 'webpack'
|
||||
import WebpackDevMiddleware from 'webpack-dev-middleware'
|
||||
import WebpackHotMiddleware from 'webpack-hot-middleware'
|
||||
|
||||
import { createEntrypoints, createPagesMapping } from '../build/entries'
|
||||
import { watchCompilers } from '../build/output'
|
||||
import getBaseWebpackConfig from '../build/webpack-config'
|
||||
|
@ -16,12 +17,11 @@ import {
|
|||
IS_BUNDLED_PAGE_REGEX,
|
||||
ROUTE_NAME_REGEX,
|
||||
} from '../next-server/lib/constants'
|
||||
import { __ApiPreviewProps } from '../next-server/server/api-utils'
|
||||
import { route } from '../next-server/server/router'
|
||||
import errorOverlayMiddleware from './lib/error-overlay-middleware'
|
||||
import { findPageFile } from './lib/find-page-file'
|
||||
import onDemandEntryHandler, { normalizePage } from './on-demand-entry-handler'
|
||||
import { NextHandleFunction } from 'connect'
|
||||
import { UrlObject } from 'url'
|
||||
|
||||
export async function renderScriptError(res: ServerResponse, error: Error) {
|
||||
// Asks CDNs and others to not to cache the errored page
|
||||
|
@ -129,6 +129,7 @@ export default class HotReloader {
|
|||
private serverPrevDocumentHash: string | null
|
||||
private prevChunkNames?: Set<any>
|
||||
private onDemandEntries: any
|
||||
private previewProps: __ApiPreviewProps
|
||||
|
||||
constructor(
|
||||
dir: string,
|
||||
|
@ -136,7 +137,13 @@ export default class HotReloader {
|
|||
config,
|
||||
pagesDir,
|
||||
buildId,
|
||||
}: { config: object; pagesDir: string; buildId: string }
|
||||
previewProps,
|
||||
}: {
|
||||
config: object
|
||||
pagesDir: string
|
||||
buildId: string
|
||||
previewProps: __ApiPreviewProps
|
||||
}
|
||||
) {
|
||||
this.buildId = buildId
|
||||
this.dir = dir
|
||||
|
@ -149,6 +156,7 @@ export default class HotReloader {
|
|||
this.serverPrevDocumentHash = null
|
||||
|
||||
this.config = config
|
||||
this.previewProps = previewProps
|
||||
}
|
||||
|
||||
async run(req: IncomingMessage, res: ServerResponse, parsedUrl: UrlObject) {
|
||||
|
@ -247,6 +255,7 @@ export default class HotReloader {
|
|||
pages,
|
||||
'server',
|
||||
this.buildId,
|
||||
this.previewProps,
|
||||
this.config
|
||||
)
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import AmpHtmlValidator from 'amphtml-validator'
|
||||
import crypto from 'crypto'
|
||||
import findUp from 'find-up'
|
||||
import fs from 'fs'
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { join, relative } from 'path'
|
||||
|
@ -6,9 +8,9 @@ import React from 'react'
|
|||
import { UrlWithParsedQuery } from 'url'
|
||||
import { promisify } from 'util'
|
||||
import Watchpack from 'watchpack'
|
||||
import findUp from 'find-up'
|
||||
import { ampValidation } from '../build/output/index'
|
||||
import * as Log from '../build/output/log'
|
||||
import checkCustomRoutes from '../lib/check-custom-routes'
|
||||
import { PUBLIC_DIR_MIDDLEWARE_CONFLICT } from '../lib/constants'
|
||||
import { findPagesDir } from '../lib/find-pages-dir'
|
||||
import { verifyTypeScriptSetup } from '../lib/verifyTypeScriptSetup'
|
||||
|
@ -19,15 +21,15 @@ import {
|
|||
getSortedRoutes,
|
||||
isDynamicRoute,
|
||||
} from '../next-server/lib/router/utils'
|
||||
import { __ApiPreviewProps } from '../next-server/server/api-utils'
|
||||
import Server, { ServerConstructor } from '../next-server/server/next-server'
|
||||
import { normalizePagePath } from '../next-server/server/normalize-page-path'
|
||||
import Router, { route, Params } from '../next-server/server/router'
|
||||
import Router, { Params, route } from '../next-server/server/router'
|
||||
import { eventVersion } from '../telemetry/events'
|
||||
import { Telemetry } from '../telemetry/storage'
|
||||
import ErrorDebug from './error-debug'
|
||||
import HotReloader from './hot-reloader'
|
||||
import { findPageFile } from './lib/find-page-file'
|
||||
import checkCustomRoutes from '../lib/check-custom-routes'
|
||||
|
||||
if (typeof React.Suspense === 'undefined') {
|
||||
throw new Error(
|
||||
|
@ -220,6 +222,7 @@ export default class DevServer extends Server {
|
|||
this.hotReloader = new HotReloader(this.dir, {
|
||||
pagesDir: this.pagesDir!,
|
||||
config: this.nextConfig,
|
||||
previewProps: this.getPreviewProps(),
|
||||
buildId: this.buildId,
|
||||
})
|
||||
await super.prepare()
|
||||
|
@ -311,6 +314,18 @@ export default class DevServer extends Server {
|
|||
return this.customRoutes
|
||||
}
|
||||
|
||||
private _devCachedPreviewProps: __ApiPreviewProps | undefined
|
||||
protected getPreviewProps() {
|
||||
if (this._devCachedPreviewProps) {
|
||||
return this._devCachedPreviewProps
|
||||
}
|
||||
return (this._devCachedPreviewProps = {
|
||||
previewModeId: crypto.randomBytes(16).toString('hex'),
|
||||
previewModeSigningKey: crypto.randomBytes(32).toString('hex'),
|
||||
previewModeEncryptionKey: crypto.randomBytes(32).toString('hex'),
|
||||
})
|
||||
}
|
||||
|
||||
private async loadCustomRoutes() {
|
||||
const result = {
|
||||
redirects: [],
|
||||
|
|
4
test/integration/prerender-preview/pages/api/preview.js
Normal file
4
test/integration/prerender-preview/pages/api/preview.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default (req, res) => {
|
||||
res.setPreviewData(req.query)
|
||||
res.status(200).end()
|
||||
}
|
4
test/integration/prerender-preview/pages/api/reset.js
Normal file
4
test/integration/prerender-preview/pages/api/reset.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default (req, res) => {
|
||||
res.clearPreviewData()
|
||||
res.status(200).end()
|
||||
}
|
15
test/integration/prerender-preview/pages/index.js
Normal file
15
test/integration/prerender-preview/pages/index.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
export function unstable_getStaticProps({ preview, previewData }) {
|
||||
return { props: { hasProps: true, preview, previewData } }
|
||||
}
|
||||
|
||||
export default function({ hasProps, preview, previewData }) {
|
||||
if (!hasProps) {
|
||||
return <pre id="props-pre">Has No Props</pre>
|
||||
}
|
||||
|
||||
return (
|
||||
<pre id="props-pre">
|
||||
{JSON.stringify(preview) + ' and ' + JSON.stringify(previewData)}
|
||||
</pre>
|
||||
)
|
||||
}
|
183
test/integration/prerender-preview/test/index.test.js
Normal file
183
test/integration/prerender-preview/test/index.test.js
Normal file
|
@ -0,0 +1,183 @@
|
|||
/* eslint-env jest */
|
||||
/* global jasmine */
|
||||
import cheerio from 'cheerio'
|
||||
import cookie from 'cookie'
|
||||
import fs from 'fs-extra'
|
||||
import {
|
||||
fetchViaHTTP,
|
||||
findPort,
|
||||
killApp,
|
||||
nextBuild,
|
||||
nextStart,
|
||||
renderViaHTTP,
|
||||
} from 'next-test-utils'
|
||||
import webdriver from 'next-webdriver'
|
||||
import os from 'os'
|
||||
import { join } from 'path'
|
||||
import qs from 'querystring'
|
||||
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2
|
||||
const appDir = join(__dirname, '..')
|
||||
const nextConfigPath = join(appDir, 'next.config.js')
|
||||
|
||||
function getData(html) {
|
||||
const $ = cheerio.load(html)
|
||||
const nextData = $('#__NEXT_DATA__')
|
||||
const preEl = $('#props-pre')
|
||||
return { nextData: JSON.parse(nextData.html()), pre: preEl.text() }
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
it('should compile successfully', async () => {
|
||||
await fs.remove(join(appDir, '.next'))
|
||||
const { code, stdout } = await nextBuild(appDir, [], {
|
||||
stdout: true,
|
||||
})
|
||||
expect(code).toBe(0)
|
||||
expect(stdout).toMatch(/Compiled successfully/)
|
||||
})
|
||||
|
||||
let appPort, app
|
||||
it('should start production application', async () => {
|
||||
appPort = await findPort()
|
||||
app = await nextStart(appDir, appPort)
|
||||
})
|
||||
|
||||
it('should return prerendered page on first request', async () => {
|
||||
const html = await renderViaHTTP(appPort, '/')
|
||||
const { nextData, pre } = getData(html)
|
||||
expect(nextData).toMatchObject({ isFallback: false })
|
||||
expect(pre).toBe('undefined and undefined')
|
||||
})
|
||||
|
||||
it('should return prerendered page on second request', async () => {
|
||||
const html = await renderViaHTTP(appPort, '/')
|
||||
const { nextData, pre } = getData(html)
|
||||
expect(nextData).toMatchObject({ isFallback: false })
|
||||
expect(pre).toBe('undefined and undefined')
|
||||
})
|
||||
|
||||
let previewCookieString
|
||||
it('should enable preview mode', async () => {
|
||||
const res = await fetchViaHTTP(appPort, '/api/preview', { lets: 'goooo' })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const cookies = res.headers
|
||||
.get('set-cookie')
|
||||
.split(',')
|
||||
.map(cookie.parse)
|
||||
|
||||
expect(cookies.length).toBe(2)
|
||||
expect(cookies[0]).toMatchObject({ Path: '/', SameSite: 'Strict' })
|
||||
expect(cookies[0]).toHaveProperty('__prerender_bypass')
|
||||
expect(cookies[0]).not.toHaveProperty('Max-Age')
|
||||
expect(cookies[1]).toMatchObject({ Path: '/', SameSite: 'Strict' })
|
||||
expect(cookies[1]).toHaveProperty('__next_preview_data')
|
||||
expect(cookies[1]).not.toHaveProperty('Max-Age')
|
||||
|
||||
previewCookieString =
|
||||
cookie.serialize('__prerender_bypass', cookies[0].__prerender_bypass) +
|
||||
'; ' +
|
||||
cookie.serialize('__next_preview_data', cookies[1].__next_preview_data)
|
||||
})
|
||||
|
||||
it('should return fallback page on preview request', async () => {
|
||||
const res = await fetchViaHTTP(
|
||||
appPort,
|
||||
'/',
|
||||
{},
|
||||
{ headers: { Cookie: previewCookieString } }
|
||||
)
|
||||
const html = await res.text()
|
||||
|
||||
const { nextData, pre } = getData(html)
|
||||
expect(nextData).toMatchObject({ isFallback: true })
|
||||
expect(pre).toBe('Has No Props')
|
||||
})
|
||||
|
||||
it('should return cookies to be expired on reset request', async () => {
|
||||
const res = await fetchViaHTTP(
|
||||
appPort,
|
||||
'/api/reset',
|
||||
{},
|
||||
{ headers: { Cookie: previewCookieString } }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const cookies = res.headers
|
||||
.get('set-cookie')
|
||||
.split(',')
|
||||
.map(cookie.parse)
|
||||
|
||||
expect(cookies.length).toBe(2)
|
||||
expect(cookies[0]).toMatchObject({
|
||||
Path: '/',
|
||||
SameSite: 'Strict',
|
||||
'Max-Age': '0',
|
||||
})
|
||||
expect(cookies[0]).toHaveProperty('__prerender_bypass')
|
||||
expect(cookies[1]).toMatchObject({
|
||||
Path: '/',
|
||||
SameSite: 'Strict',
|
||||
'Max-Age': '0',
|
||||
})
|
||||
expect(cookies[1]).toHaveProperty('__next_preview_data')
|
||||
})
|
||||
|
||||
/** @type import('next-webdriver').Chain */
|
||||
let browser
|
||||
it('should start the client-side browser', async () => {
|
||||
browser = await webdriver(
|
||||
appPort,
|
||||
'/api/preview?' + qs.stringify({ client: 'mode' })
|
||||
)
|
||||
})
|
||||
|
||||
it('should fetch preview data', async () => {
|
||||
await browser.get(`http://localhost:${appPort}/`)
|
||||
await browser.waitForElementByCss('#props-pre')
|
||||
expect(await browser.elementById('props-pre').text()).toBe('Has No Props')
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
expect(await browser.elementById('props-pre').text()).toBe(
|
||||
'true and {"client":"mode"}'
|
||||
)
|
||||
})
|
||||
|
||||
it('should fetch prerendered data', async () => {
|
||||
await browser.get(`http://localhost:${appPort}/api/reset`)
|
||||
|
||||
await browser.get(`http://localhost:${appPort}/`)
|
||||
await browser.waitForElementByCss('#props-pre')
|
||||
expect(await browser.elementById('props-pre').text()).toBe(
|
||||
'undefined and undefined'
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser.close()
|
||||
await killApp(app)
|
||||
})
|
||||
}
|
||||
|
||||
describe('Prerender Preview Mode', () => {
|
||||
describe('Server Mode', () => {
|
||||
beforeAll(async () => {
|
||||
await fs.remove(nextConfigPath)
|
||||
})
|
||||
|
||||
runTests()
|
||||
})
|
||||
describe('Serverless Mode', () => {
|
||||
beforeAll(async () => {
|
||||
await fs.writeFile(
|
||||
nextConfigPath,
|
||||
`module.exports = { target: 'experimental-serverless-trace' }` + os.EOL
|
||||
)
|
||||
})
|
||||
afterAll(async () => {
|
||||
await fs.remove(nextConfigPath)
|
||||
})
|
||||
|
||||
runTests()
|
||||
})
|
||||
})
|
94
yarn.lock
94
yarn.lock
|
@ -2475,6 +2475,13 @@
|
|||
"@types/connect" "*"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/cheerio@0.22.16":
|
||||
version "0.22.16"
|
||||
resolved "https://registry.yarnpkg.com/@types/cheerio/-/cheerio-0.22.16.tgz#c748a97b8a6f781b04bbda4a552e11b35bcc77e4"
|
||||
integrity sha512-bSbnU/D4yzFdzLpp3+rcDj0aQQMIRUBNJU7azPxdqMpnexjUSvGJyDuOBQBHeOZh1mMKgsJm6Dy+LLh80Ew4tQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/ci-info@2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/ci-info/-/ci-info-2.0.0.tgz#51848cc0f5c30c064f4b25f7f688bf35825b3971"
|
||||
|
@ -2629,6 +2636,13 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.30.tgz#44cb52f32a809734ca562e685c6473b5754a7818"
|
||||
integrity sha512-sqm9g7mHlPY/43fcSNrCYfOeX9zkTTK+euO5E6+CVijSMm5tTjkVdwdqRkY3ljjIAf8679vps5jKUoJBCLsMDA==
|
||||
|
||||
"@types/jsonwebtoken@8.3.7":
|
||||
version "8.3.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.3.7.tgz#ab79ad55b9435834d24cca3112f42c08eedb1a54"
|
||||
integrity sha512-B5SSifLkjB0ns7VXpOOtOUlynE78/hKcY8G8pOAhkLJZinwofIBYqz555nRj2W9iDWZqFhK5R+7NZDaRmKWAoQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/loader-utils@1.1.3":
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/loader-utils/-/loader-utils-1.1.3.tgz#82b9163f2ead596c68a8c03e450fbd6e089df401"
|
||||
|
@ -4179,6 +4193,11 @@ buffer-crc32@~0.2.3:
|
|||
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
|
||||
integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
|
||||
integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=
|
||||
|
||||
buffer-from@^1.0.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
|
||||
|
@ -6187,6 +6206,13 @@ ecc-jsbn@~0.1.1:
|
|||
jsbn "~0.1.0"
|
||||
safer-buffer "^2.1.0"
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
|
||||
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
ee-first@1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||
|
@ -9569,6 +9595,22 @@ jsonparse@^1.2.0:
|
|||
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
|
||||
integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
|
||||
|
||||
jsonwebtoken@8.5.1:
|
||||
version "8.5.1"
|
||||
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d"
|
||||
integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==
|
||||
dependencies:
|
||||
jws "^3.2.2"
|
||||
lodash.includes "^4.3.0"
|
||||
lodash.isboolean "^3.0.3"
|
||||
lodash.isinteger "^4.0.4"
|
||||
lodash.isnumber "^3.0.3"
|
||||
lodash.isplainobject "^4.0.6"
|
||||
lodash.isstring "^4.0.1"
|
||||
lodash.once "^4.0.0"
|
||||
ms "^2.1.1"
|
||||
semver "^5.6.0"
|
||||
|
||||
jsprim@^1.2.2:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
|
||||
|
@ -9597,6 +9639,23 @@ jszip@^3.1.5:
|
|||
readable-stream "~2.3.6"
|
||||
set-immediate-shim "~1.0.1"
|
||||
|
||||
jwa@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
|
||||
integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==
|
||||
dependencies:
|
||||
buffer-equal-constant-time "1.0.1"
|
||||
ecdsa-sig-formatter "1.0.11"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
jws@^3.2.2:
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
|
||||
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
|
||||
dependencies:
|
||||
jwa "^1.4.1"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
keyv@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
|
||||
|
@ -9960,11 +10019,41 @@ lodash.get@^4.4.2:
|
|||
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
|
||||
integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
|
||||
|
||||
lodash.includes@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
||||
integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=
|
||||
|
||||
lodash.isboolean@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
|
||||
integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=
|
||||
|
||||
lodash.isinteger@^4.0.4:
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
|
||||
integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=
|
||||
|
||||
lodash.ismatch@^4.4.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37"
|
||||
integrity sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=
|
||||
|
||||
lodash.isnumber@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
|
||||
integrity sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=
|
||||
|
||||
lodash.isplainobject@^4.0.6:
|
||||
version "4.0.6"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
|
||||
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
|
||||
|
||||
lodash.isstring@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
|
||||
integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=
|
||||
|
||||
lodash.map@^4.4.0:
|
||||
version "4.6.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.map/-/lodash.map-4.6.0.tgz#771ec7839e3473d9c4cde28b19394c3562f4f6d3"
|
||||
|
@ -9980,6 +10069,11 @@ lodash.merge@^4.4.0:
|
|||
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
||||
|
||||
lodash.once@^4.0.0:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
|
||||
integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=
|
||||
|
||||
lodash.pick@^4.2.1:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3"
|
||||
|
|
Loading…
Reference in a new issue