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:
Joe Haddad 2020-02-11 20:16:42 -05:00 committed by GitHub
parent 9a9c0d2c4f
commit 3cb3498324
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 768 additions and 75 deletions

View file

@ -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",

View file

@ -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 => {

View file

@ -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),

View file

@ -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

View file

@ -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>
}
/**

View file

@ -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
*/

View 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`)
}

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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')

View file

@ -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",

View file

@ -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
)

View file

@ -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: [],

View file

@ -0,0 +1,4 @@
export default (req, res) => {
res.setPreviewData(req.query)
res.status(200).end()
}

View file

@ -0,0 +1,4 @@
export default (req, res) => {
res.clearPreviewData()
res.status(200).end()
}

View 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>
)
}

View 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()
})
})

View file

@ -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"