diff --git a/package.json b/package.json index f5bf15c7eb..7d34c7823b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index b6049f2bc9..74a69f767a 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -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 => { diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index ad776ecfd2..02f6747d8b 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -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 { @@ -198,8 +201,20 @@ export default async function build(dir: string, conf = null): Promise { const allStaticPages = new Set() let allPageInfos = new Map() + 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 { version: 1, routes: finalPrerenderRoutes, dynamicRoutes: finalDynamicRoutes, + preview: previewProps, } await fsWriteFile( @@ -814,6 +830,7 @@ export default async function build(dir: string, conf = null): Promise { version: 1, routes: {}, dynamicRoutes: {}, + preview: previewProps, } await fsWriteFile( path.join(distDir, PRERENDER_MANIFEST), diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 3f322cb322..7767ec0538 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -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 diff --git a/packages/next/next-server/lib/utils.ts b/packages/next/next-server/lib/utils.ts index f1adc16a11..3d7a6f8ddd 100644 --- a/packages/next/next-server/lib/utils.ts +++ b/packages/next/next-server/lib/utils.ts @@ -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 = ServerResponse & { */ json: Send status: (statusCode: number) => NextApiResponse + + /** + * 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 + clearPreviewData: () => NextApiResponse } /** diff --git a/packages/next/next-server/server/api-utils.ts b/packages/next/next-server/server/api-utils.ts index 85d0156b97..17c3332bcb 100644 --- a/packages/next/next-server/server/api-utils.ts +++ b/packages/next/next-server/server/api-utils.ts @@ -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 ) { 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( + res: NextApiResponse, + data: object | string, // TODO: strict runtime type checking + options: { + maxAge?: number + } & __ApiPreviewProps +): NextApiResponse { + 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(res: NextApiResponse): NextApiResponse { + 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 */ diff --git a/packages/next/next-server/server/crypto-utils.ts b/packages/next/next-server/server/crypto-utils.ts new file mode 100644 index 0000000000..537ae65bf4 --- /dev/null +++ b/packages/next/next-server/server/crypto-utils.ts @@ -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`) +} diff --git a/packages/next/next-server/server/load-components.ts b/packages/next/next-server/server/load-components.ts index 4f4378a24b..e4852ab526 100644 --- a/packages/next/next-server/server/load-components.ts +++ b/packages/next/next-server/server/load-components.ts @@ -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 diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 0c47737809..5dd9b0ac19 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -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 diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index 50d5326a79..afaeff62cf 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -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( diff --git a/packages/next/next-server/server/spr-cache.ts b/packages/next/next-server/server/spr-cache.ts index cc474a1675..072496d9c0 100644 --- a/packages/next/next-server/server/spr-cache.ts +++ b/packages/next/next-server/server/spr-cache.ts @@ -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') diff --git a/packages/next/package.json b/packages/next/package.json index 09539d6677..8aee92bd15 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -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", diff --git a/packages/next/server/hot-reloader.ts b/packages/next/server/hot-reloader.ts index 7400b9fc95..1d2250f57f 100644 --- a/packages/next/server/hot-reloader.ts +++ b/packages/next/server/hot-reloader.ts @@ -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 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 ) diff --git a/packages/next/server/next-dev-server.ts b/packages/next/server/next-dev-server.ts index 4339df2938..79d2318504 100644 --- a/packages/next/server/next-dev-server.ts +++ b/packages/next/server/next-dev-server.ts @@ -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: [], diff --git a/test/integration/prerender-preview/pages/api/preview.js b/test/integration/prerender-preview/pages/api/preview.js new file mode 100644 index 0000000000..b201b0e02b --- /dev/null +++ b/test/integration/prerender-preview/pages/api/preview.js @@ -0,0 +1,4 @@ +export default (req, res) => { + res.setPreviewData(req.query) + res.status(200).end() +} diff --git a/test/integration/prerender-preview/pages/api/reset.js b/test/integration/prerender-preview/pages/api/reset.js new file mode 100644 index 0000000000..0434bf5b64 --- /dev/null +++ b/test/integration/prerender-preview/pages/api/reset.js @@ -0,0 +1,4 @@ +export default (req, res) => { + res.clearPreviewData() + res.status(200).end() +} diff --git a/test/integration/prerender-preview/pages/index.js b/test/integration/prerender-preview/pages/index.js new file mode 100644 index 0000000000..a9da9aa293 --- /dev/null +++ b/test/integration/prerender-preview/pages/index.js @@ -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
Has No Props
+ } + + return ( +
+      {JSON.stringify(preview) + ' and ' + JSON.stringify(previewData)}
+    
+ ) +} diff --git a/test/integration/prerender-preview/test/index.test.js b/test/integration/prerender-preview/test/index.test.js new file mode 100644 index 0000000000..af78b60b07 --- /dev/null +++ b/test/integration/prerender-preview/test/index.test.js @@ -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() + }) +}) diff --git a/yarn.lock b/yarn.lock index 9515063e26..b1b8104edc 100644 --- a/yarn.lock +++ b/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"