039eb817e1
Removes `next-head-count`, improving support for 3rd party libraries that insert or append new elements to `<head>`. --- This is more or less what a solution with a `data-` attribute would look like, except that instead of directly searching for elements with that attribute, we serialize the elements expected in `<head>` and then find them/assume ownership of them during initialization (in a manner similar to React's reconciliation) based on their properties. There are two main assumptions here: 1. Content is served with compression, so duplicate serialization of e.g. inline script or style tags doesn't have a meaningful impact. Storing a hash would be a potential optimization. 2. 3rd party libraries primarily only insert new, unique elements to head. Libraries trying to actively manage elements that overlap with those that Next.js claims ownership of will still be unsupported. The reason for this roundabout approach is that I'd really like to avoid `data-` if possible, for maximum compatibility. Implicitly adding an attribute could be a breaking change for some class of tools or crawlers and makes it otherwise impossible to insert raw HTML into `<head>`. Adding an unexpected attribute is why the original `class="next-head"` approach was problematic in the first place! That said, while I don't expect this to be more problematic than `next-head-count` (anything that would break in this new model also should have broken in the old model), if that does end up being the case, it might make sense to just bite the bullet. Fixes #11012 Closes #16707 --- cc @Timer @timneutkens
972 lines
28 KiB
TypeScript
972 lines
28 KiB
TypeScript
import { IncomingMessage, ServerResponse } from 'http'
|
|
import { ParsedUrlQuery } from 'querystring'
|
|
import React from 'react'
|
|
import { renderToStaticMarkup, renderToString } from 'react-dom/server'
|
|
import { UnwrapPromise } from '../../lib/coalesced-function'
|
|
import {
|
|
GSP_NO_RETURNED_VALUE,
|
|
GSSP_COMPONENT_MEMBER_ERROR,
|
|
GSSP_NO_RETURNED_VALUE,
|
|
PAGES_404_GET_INITIAL_PROPS_ERROR,
|
|
SERVER_PROPS_GET_INIT_PROPS_CONFLICT,
|
|
SERVER_PROPS_SSG_CONFLICT,
|
|
SSG_GET_INITIAL_PROPS_CONFLICT,
|
|
UNSTABLE_REVALIDATE_RENAME_ERROR,
|
|
} from '../../lib/constants'
|
|
import { isSerializableProps } from '../../lib/is-serializable-props'
|
|
import { GetServerSideProps, GetStaticProps } from '../../types'
|
|
import { isInAmpMode } from '../lib/amp'
|
|
import { AmpStateContext } from '../lib/amp-context'
|
|
import {
|
|
AMP_RENDER_TARGET,
|
|
SERVER_PROPS_ID,
|
|
STATIC_PROPS_ID,
|
|
PERMANENT_REDIRECT_STATUS,
|
|
TEMPORARY_REDIRECT_STATUS,
|
|
} from '../lib/constants'
|
|
import { defaultHead } from '../lib/head'
|
|
import { HeadManagerContext } from '../lib/head-manager-context'
|
|
import Loadable from '../lib/loadable'
|
|
import { LoadableContext } from '../lib/loadable-context'
|
|
import mitt, { MittEmitter } from '../lib/mitt'
|
|
import postProcess from '../lib/post-process'
|
|
import { RouterContext } from '../lib/router-context'
|
|
import { NextRouter } from '../lib/router/router'
|
|
import { isDynamicRoute } from '../lib/router/utils/is-dynamic'
|
|
import {
|
|
AppType,
|
|
ComponentsEnhancer,
|
|
DocumentInitialProps,
|
|
DocumentType,
|
|
getDisplayName,
|
|
isResSent,
|
|
loadGetInitialProps,
|
|
NextComponentType,
|
|
RenderPage,
|
|
DocumentProps,
|
|
} from '../lib/utils'
|
|
import { tryGetPreviewData, __ApiPreviewProps } from './api-utils'
|
|
import { denormalizePagePath } from './denormalize-page-path'
|
|
import { FontManifest, getFontDefinitionFromManifest } from './font-utils'
|
|
import { LoadComponentsReturnType, ManifestItem } from './load-components'
|
|
import { normalizePagePath } from './normalize-page-path'
|
|
import optimizeAmp from './optimize-amp'
|
|
|
|
function noRouter() {
|
|
const message =
|
|
'No router instance found. you should only use "next/router" inside the client side of your app. https://err.sh/vercel/next.js/no-router-instance'
|
|
throw new Error(message)
|
|
}
|
|
|
|
class ServerRouter implements NextRouter {
|
|
route: string
|
|
pathname: string
|
|
query: ParsedUrlQuery
|
|
asPath: string
|
|
basePath: string
|
|
events: any
|
|
isFallback: boolean
|
|
// TODO: Remove in the next major version, as this would mean the user is adding event listeners in server-side `render` method
|
|
static events: MittEmitter = mitt()
|
|
|
|
constructor(
|
|
pathname: string,
|
|
query: ParsedUrlQuery,
|
|
as: string,
|
|
{ isFallback }: { isFallback: boolean },
|
|
basePath: string
|
|
) {
|
|
this.route = pathname.replace(/\/$/, '') || '/'
|
|
this.pathname = pathname
|
|
this.query = query
|
|
this.asPath = as
|
|
this.isFallback = isFallback
|
|
this.basePath = basePath
|
|
}
|
|
push(): any {
|
|
noRouter()
|
|
}
|
|
replace(): any {
|
|
noRouter()
|
|
}
|
|
reload() {
|
|
noRouter()
|
|
}
|
|
back() {
|
|
noRouter()
|
|
}
|
|
prefetch(): any {
|
|
noRouter()
|
|
}
|
|
beforePopState() {
|
|
noRouter()
|
|
}
|
|
}
|
|
|
|
function enhanceComponents(
|
|
options: ComponentsEnhancer,
|
|
App: AppType,
|
|
Component: NextComponentType
|
|
): {
|
|
App: AppType
|
|
Component: NextComponentType
|
|
} {
|
|
// For backwards compatibility
|
|
if (typeof options === 'function') {
|
|
return {
|
|
App,
|
|
Component: options(Component),
|
|
}
|
|
}
|
|
|
|
return {
|
|
App: options.enhanceApp ? options.enhanceApp(App) : App,
|
|
Component: options.enhanceComponent
|
|
? options.enhanceComponent(Component)
|
|
: Component,
|
|
}
|
|
}
|
|
|
|
export type RenderOptsPartial = {
|
|
buildId: string
|
|
canonicalBase: string
|
|
runtimeConfig?: { [key: string]: any }
|
|
assetPrefix?: string
|
|
err?: Error | null
|
|
autoExport?: boolean
|
|
nextExport?: boolean
|
|
dev?: boolean
|
|
ampMode?: any
|
|
ampPath?: string
|
|
inAmpMode?: boolean
|
|
hybridAmp?: boolean
|
|
ErrorDebug?: React.ComponentType<{ error: Error }>
|
|
ampValidator?: (html: string, pathname: string) => Promise<void>
|
|
ampSkipValidation?: boolean
|
|
ampOptimizerConfig?: { [key: string]: any }
|
|
isDataReq?: boolean
|
|
params?: ParsedUrlQuery
|
|
previewProps: __ApiPreviewProps
|
|
basePath: string
|
|
unstable_runtimeJS?: false
|
|
optimizeFonts: boolean
|
|
fontManifest?: FontManifest
|
|
optimizeImages: boolean
|
|
devOnlyCacheBusterQueryString?: string
|
|
}
|
|
|
|
export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial
|
|
|
|
function renderDocument(
|
|
Document: DocumentType,
|
|
{
|
|
buildManifest,
|
|
docComponentsRendered,
|
|
props,
|
|
docProps,
|
|
pathname,
|
|
query,
|
|
buildId,
|
|
canonicalBase,
|
|
assetPrefix,
|
|
runtimeConfig,
|
|
nextExport,
|
|
autoExport,
|
|
isFallback,
|
|
dynamicImportsIds,
|
|
dangerousAsPath,
|
|
err,
|
|
dev,
|
|
ampPath,
|
|
ampState,
|
|
inAmpMode,
|
|
hybridAmp,
|
|
dynamicImports,
|
|
headTags,
|
|
gsp,
|
|
gssp,
|
|
customServer,
|
|
gip,
|
|
appGip,
|
|
unstable_runtimeJS,
|
|
devOnlyCacheBusterQueryString,
|
|
}: RenderOpts & {
|
|
props: any
|
|
docComponentsRendered: DocumentProps['docComponentsRendered']
|
|
docProps: DocumentInitialProps
|
|
pathname: string
|
|
query: ParsedUrlQuery
|
|
dangerousAsPath: string
|
|
ampState: any
|
|
ampPath: string
|
|
inAmpMode: boolean
|
|
hybridAmp: boolean
|
|
dynamicImportsIds: string[]
|
|
dynamicImports: ManifestItem[]
|
|
headTags: any
|
|
isFallback?: boolean
|
|
gsp?: boolean
|
|
gssp?: boolean
|
|
customServer?: boolean
|
|
gip?: boolean
|
|
appGip?: boolean
|
|
devOnlyCacheBusterQueryString: string
|
|
}
|
|
): string {
|
|
return (
|
|
'<!DOCTYPE html>' +
|
|
renderToStaticMarkup(
|
|
<AmpStateContext.Provider value={ampState}>
|
|
{Document.renderDocument(Document, {
|
|
__NEXT_DATA__: {
|
|
props, // The result of getInitialProps
|
|
page: pathname, // The rendered page
|
|
query, // querystring parsed / passed by the user
|
|
buildId, // buildId is used to facilitate caching of page bundles, we send it to the client so that pageloader knows where to load bundles
|
|
assetPrefix: assetPrefix === '' ? undefined : assetPrefix, // send assetPrefix to the client side when configured, otherwise don't sent in the resulting HTML
|
|
runtimeConfig, // runtimeConfig if provided, otherwise don't sent in the resulting HTML
|
|
nextExport, // If this is a page exported by `next export`
|
|
autoExport, // If this is an auto exported page
|
|
isFallback,
|
|
dynamicIds:
|
|
dynamicImportsIds.length === 0 ? undefined : dynamicImportsIds,
|
|
err: err ? serializeError(dev, err) : undefined, // Error if one happened, otherwise don't sent in the resulting HTML
|
|
gsp, // whether the page is getStaticProps
|
|
gssp, // whether the page is getServerSideProps
|
|
customServer, // whether the user is using a custom server
|
|
gip, // whether the page has getInitialProps
|
|
appGip, // whether the _app has getInitialProps
|
|
head: React.Children.toArray(docProps.head || [])
|
|
.map((elem) => {
|
|
const { children } = elem?.props
|
|
return [
|
|
elem?.type,
|
|
{
|
|
...elem?.props,
|
|
children: children
|
|
? typeof children === 'string'
|
|
? children
|
|
: children.join('')
|
|
: undefined,
|
|
},
|
|
]
|
|
})
|
|
.filter(Boolean) as any,
|
|
},
|
|
buildManifest,
|
|
docComponentsRendered,
|
|
dangerousAsPath,
|
|
canonicalBase,
|
|
ampPath,
|
|
inAmpMode,
|
|
isDevelopment: !!dev,
|
|
hybridAmp,
|
|
dynamicImports,
|
|
assetPrefix,
|
|
headTags,
|
|
unstable_runtimeJS,
|
|
devOnlyCacheBusterQueryString,
|
|
...docProps,
|
|
})}
|
|
</AmpStateContext.Provider>
|
|
)
|
|
)
|
|
}
|
|
|
|
const invalidKeysMsg = (methodName: string, invalidKeys: string[]) => {
|
|
return (
|
|
`Additional keys were returned from \`${methodName}\`. Properties intended for your component must be nested under the \`props\` key, e.g.:` +
|
|
`\n\n\treturn { props: { title: 'My Title', content: '...' } }` +
|
|
`\n\nKeys that need to be moved: ${invalidKeys.join(', ')}.` +
|
|
`\nRead more: https://err.sh/next.js/invalid-getstaticprops-value`
|
|
)
|
|
}
|
|
|
|
type Redirect = {
|
|
permanent: boolean
|
|
destination: string
|
|
}
|
|
|
|
function checkRedirectValues(redirect: Redirect, req: IncomingMessage) {
|
|
const { destination, permanent } = redirect
|
|
let invalidPermanent = typeof permanent !== 'boolean'
|
|
let invalidDestination = typeof destination !== 'string'
|
|
|
|
if (invalidPermanent || invalidDestination) {
|
|
throw new Error(
|
|
`Invalid redirect object returned from getStaticProps for ${req.url}\n` +
|
|
`Expected${
|
|
invalidPermanent
|
|
? ` \`permanent\` to be boolean but received ${typeof permanent}`
|
|
: ''
|
|
}${invalidPermanent && invalidDestination ? ' and' : ''}${
|
|
invalidDestination
|
|
? ` \`destinatino\` to be string but received ${typeof destination}`
|
|
: ''
|
|
}\n` +
|
|
`See more info here: https://err.sh/vercel/next.js/invalid-redirect-gssp`
|
|
)
|
|
}
|
|
}
|
|
|
|
function handleRedirect(res: ServerResponse, redirect: Redirect) {
|
|
const statusCode = redirect.permanent
|
|
? PERMANENT_REDIRECT_STATUS
|
|
: TEMPORARY_REDIRECT_STATUS
|
|
|
|
if (redirect.permanent) {
|
|
res.setHeader('Refresh', `0;url=${redirect.destination}`)
|
|
}
|
|
res.statusCode = statusCode
|
|
res.setHeader('Location', redirect.destination)
|
|
res.end()
|
|
}
|
|
|
|
export async function renderToHTML(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
pathname: string,
|
|
query: ParsedUrlQuery,
|
|
renderOpts: RenderOpts
|
|
): Promise<string | null> {
|
|
// In dev we invalidate the cache by appending a timestamp to the resource URL.
|
|
// This is a workaround to fix https://github.com/vercel/next.js/issues/5860
|
|
// TODO: remove this workaround when https://bugs.webkit.org/show_bug.cgi?id=187726 is fixed.
|
|
renderOpts.devOnlyCacheBusterQueryString = renderOpts.dev
|
|
? renderOpts.devOnlyCacheBusterQueryString || `?ts=${Date.now()}`
|
|
: ''
|
|
|
|
const {
|
|
err,
|
|
dev = false,
|
|
ampPath = '',
|
|
App,
|
|
Document,
|
|
pageConfig = {},
|
|
Component,
|
|
buildManifest,
|
|
fontManifest,
|
|
reactLoadableManifest,
|
|
ErrorDebug,
|
|
getStaticProps,
|
|
getStaticPaths,
|
|
getServerSideProps,
|
|
isDataReq,
|
|
params,
|
|
previewProps,
|
|
basePath,
|
|
devOnlyCacheBusterQueryString,
|
|
} = renderOpts
|
|
|
|
const getFontDefinition = (url: string): string => {
|
|
if (fontManifest) {
|
|
return getFontDefinitionFromManifest(url, fontManifest)
|
|
}
|
|
return ''
|
|
}
|
|
|
|
const callMiddleware = async (method: string, args: any[], props = false) => {
|
|
let results: any = props ? {} : []
|
|
|
|
if ((Document as any)[`${method}Middleware`]) {
|
|
let middlewareFunc = await (Document as any)[`${method}Middleware`]
|
|
middlewareFunc = middlewareFunc.default || middlewareFunc
|
|
|
|
const curResults = await middlewareFunc(...args)
|
|
if (props) {
|
|
for (const result of curResults) {
|
|
results = {
|
|
...results,
|
|
...result,
|
|
}
|
|
}
|
|
} else {
|
|
results = curResults
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
const headTags = (...args: any) => callMiddleware('headTags', args)
|
|
|
|
const isFallback = !!query.__nextFallback
|
|
delete query.__nextFallback
|
|
|
|
const isSSG = !!getStaticProps
|
|
const isBuildTimeSSG = isSSG && renderOpts.nextExport
|
|
const defaultAppGetInitialProps =
|
|
App.getInitialProps === (App as any).origGetInitialProps
|
|
|
|
const hasPageGetInitialProps = !!(Component as any).getInitialProps
|
|
|
|
const pageIsDynamic = isDynamicRoute(pathname)
|
|
|
|
const isAutoExport =
|
|
!hasPageGetInitialProps &&
|
|
defaultAppGetInitialProps &&
|
|
!isSSG &&
|
|
!getServerSideProps
|
|
|
|
for (const methodName of [
|
|
'getStaticProps',
|
|
'getServerSideProps',
|
|
'getStaticPaths',
|
|
]) {
|
|
if ((Component as any)[methodName]) {
|
|
throw new Error(
|
|
`page ${pathname} ${methodName} ${GSSP_COMPONENT_MEMBER_ERROR}`
|
|
)
|
|
}
|
|
}
|
|
|
|
if (hasPageGetInitialProps && isSSG) {
|
|
throw new Error(SSG_GET_INITIAL_PROPS_CONFLICT + ` ${pathname}`)
|
|
}
|
|
|
|
if (hasPageGetInitialProps && getServerSideProps) {
|
|
throw new Error(SERVER_PROPS_GET_INIT_PROPS_CONFLICT + ` ${pathname}`)
|
|
}
|
|
|
|
if (getServerSideProps && isSSG) {
|
|
throw new Error(SERVER_PROPS_SSG_CONFLICT + ` ${pathname}`)
|
|
}
|
|
|
|
if (!!getStaticPaths && !isSSG) {
|
|
throw new Error(
|
|
`getStaticPaths was added without a getStaticProps in ${pathname}. Without getStaticProps, getStaticPaths does nothing`
|
|
)
|
|
}
|
|
|
|
if (isSSG && pageIsDynamic && !getStaticPaths) {
|
|
throw new Error(
|
|
`getStaticPaths is required for dynamic SSG pages and is missing for '${pathname}'.` +
|
|
`\nRead more: https://err.sh/next.js/invalid-getstaticpaths-value`
|
|
)
|
|
}
|
|
|
|
if (dev) {
|
|
const { isValidElementType } = require('react-is')
|
|
if (!isValidElementType(Component)) {
|
|
throw new Error(
|
|
`The default export is not a React Component in page: "${pathname}"`
|
|
)
|
|
}
|
|
|
|
if (!isValidElementType(App)) {
|
|
throw new Error(
|
|
`The default export is not a React Component in page: "/_app"`
|
|
)
|
|
}
|
|
|
|
if (!isValidElementType(Document)) {
|
|
throw new Error(
|
|
`The default export is not a React Component in page: "/_document"`
|
|
)
|
|
}
|
|
|
|
if (isAutoExport || isFallback) {
|
|
// remove query values except ones that will be set during export
|
|
query = {
|
|
...(query.amp
|
|
? {
|
|
amp: query.amp,
|
|
}
|
|
: {}),
|
|
}
|
|
req.url = pathname
|
|
renderOpts.nextExport = true
|
|
}
|
|
|
|
if (pathname === '/404' && (hasPageGetInitialProps || getServerSideProps)) {
|
|
throw new Error(PAGES_404_GET_INITIAL_PROPS_ERROR)
|
|
}
|
|
}
|
|
if (isAutoExport) renderOpts.autoExport = true
|
|
if (isSSG) renderOpts.nextExport = false
|
|
|
|
await Loadable.preloadAll() // Make sure all dynamic imports are loaded
|
|
|
|
// url will always be set
|
|
const asPath: string = req.url as string
|
|
const router = new ServerRouter(
|
|
pathname,
|
|
query,
|
|
asPath,
|
|
{
|
|
isFallback: isFallback,
|
|
},
|
|
basePath
|
|
)
|
|
const ctx = {
|
|
err,
|
|
req: isAutoExport ? undefined : req,
|
|
res: isAutoExport ? undefined : res,
|
|
pathname,
|
|
query,
|
|
asPath,
|
|
AppTree: (props: any) => {
|
|
return (
|
|
<AppContainer>
|
|
<App {...props} Component={Component} router={router} />
|
|
</AppContainer>
|
|
)
|
|
},
|
|
}
|
|
let props: any
|
|
|
|
const ampState = {
|
|
ampFirst: pageConfig.amp === true,
|
|
hasQuery: Boolean(query.amp),
|
|
hybrid: pageConfig.amp === 'hybrid',
|
|
}
|
|
|
|
const inAmpMode = isInAmpMode(ampState)
|
|
|
|
const reactLoadableModules: string[] = []
|
|
|
|
let head: JSX.Element[] = defaultHead(inAmpMode)
|
|
|
|
const AppContainer = ({ children }: any) => (
|
|
<RouterContext.Provider value={router}>
|
|
<AmpStateContext.Provider value={ampState}>
|
|
<HeadManagerContext.Provider
|
|
value={{
|
|
updateHead: (state) => {
|
|
head = state
|
|
},
|
|
mountedInstances: new Set(),
|
|
}}
|
|
>
|
|
<LoadableContext.Provider
|
|
value={(moduleName) => reactLoadableModules.push(moduleName)}
|
|
>
|
|
{children}
|
|
</LoadableContext.Provider>
|
|
</HeadManagerContext.Provider>
|
|
</AmpStateContext.Provider>
|
|
</RouterContext.Provider>
|
|
)
|
|
|
|
try {
|
|
props = await loadGetInitialProps(App, {
|
|
AppTree: ctx.AppTree,
|
|
Component,
|
|
router,
|
|
ctx,
|
|
})
|
|
|
|
if (isSSG) {
|
|
props[STATIC_PROPS_ID] = true
|
|
}
|
|
|
|
let previewData: string | false | object | undefined
|
|
|
|
if ((isSSG || getServerSideProps) && !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.
|
|
previewData = tryGetPreviewData(req, res, previewProps)
|
|
}
|
|
|
|
if (isSSG && !isFallback) {
|
|
let data: UnwrapPromise<ReturnType<GetStaticProps>>
|
|
|
|
try {
|
|
data = await getStaticProps!({
|
|
...(pageIsDynamic ? { params: query as ParsedUrlQuery } : undefined),
|
|
...(previewData !== false
|
|
? { preview: true, previewData: previewData }
|
|
: undefined),
|
|
})
|
|
} catch (staticPropsError) {
|
|
// remove not found error code to prevent triggering legacy
|
|
// 404 rendering
|
|
if (staticPropsError.code === 'ENOENT') {
|
|
delete staticPropsError.code
|
|
}
|
|
throw staticPropsError
|
|
}
|
|
|
|
if (data == null) {
|
|
throw new Error(GSP_NO_RETURNED_VALUE)
|
|
}
|
|
|
|
const invalidKeys = Object.keys(data).filter(
|
|
(key) =>
|
|
key !== 'revalidate' && key !== 'props' && key !== 'unstable_redirect'
|
|
)
|
|
|
|
if (invalidKeys.includes('unstable_revalidate')) {
|
|
throw new Error(UNSTABLE_REVALIDATE_RENAME_ERROR)
|
|
}
|
|
|
|
if (invalidKeys.length) {
|
|
throw new Error(invalidKeysMsg('getStaticProps', invalidKeys))
|
|
}
|
|
|
|
if (
|
|
data.unstable_redirect &&
|
|
typeof data.unstable_redirect === 'object'
|
|
) {
|
|
checkRedirectValues(data.unstable_redirect, req)
|
|
|
|
if (isBuildTimeSSG) {
|
|
throw new Error(
|
|
`\`redirect\` can not be returned from getStaticProps during prerendering (${req.url})\n` +
|
|
`See more info here: https://err.sh/next.js/gsp-redirect-during-prerender`
|
|
)
|
|
}
|
|
|
|
if (isDataReq) {
|
|
data.props = {
|
|
__N_REDIRECT: data.unstable_redirect.destination,
|
|
}
|
|
} else {
|
|
handleRedirect(res, data.unstable_redirect)
|
|
return null
|
|
}
|
|
}
|
|
|
|
if (
|
|
(dev || isBuildTimeSSG) &&
|
|
!isSerializableProps(pathname, 'getStaticProps', data.props)
|
|
) {
|
|
// this fn should throw an error instead of ever returning `false`
|
|
throw new Error(
|
|
'invariant: getStaticProps did not return valid props. Please report this.'
|
|
)
|
|
}
|
|
|
|
if (typeof data.revalidate === 'number') {
|
|
if (!Number.isInteger(data.revalidate)) {
|
|
throw new Error(
|
|
`A page's revalidate option must be seconds expressed as a natural number. Mixed numbers, such as '${data.revalidate}', cannot be used.` +
|
|
`\nTry changing the value to '${Math.ceil(
|
|
data.revalidate
|
|
)}' or using \`Math.ceil()\` if you're computing the value.`
|
|
)
|
|
} else if (data.revalidate <= 0) {
|
|
throw new Error(
|
|
`A page's revalidate option can not be less than or equal to zero. A revalidate option of zero means to revalidate after _every_ request, and implies stale data cannot be tolerated.` +
|
|
`\n\nTo never revalidate, you can set revalidate to \`false\` (only ran once at build-time).` +
|
|
`\nTo revalidate as soon as possible, you can set the value to \`1\`.`
|
|
)
|
|
} else if (data.revalidate > 31536000) {
|
|
// if it's greater than a year for some reason error
|
|
console.warn(
|
|
`Warning: A page's revalidate option was set to more than a year. This may have been done in error.` +
|
|
`\nTo only run getStaticProps at build-time and not revalidate at runtime, you can set \`revalidate\` to \`false\`!`
|
|
)
|
|
}
|
|
} else if (data.revalidate === true) {
|
|
// When enabled, revalidate after 1 second. This value is optimal for
|
|
// the most up-to-date page possible, but without a 1-to-1
|
|
// request-refresh ratio.
|
|
data.revalidate = 1
|
|
} else {
|
|
// By default, we never revalidate.
|
|
data.revalidate = false
|
|
}
|
|
|
|
props.pageProps = Object.assign({}, props.pageProps, data.props)
|
|
// pass up revalidate and props for export
|
|
// TODO: change this to a different passing mechanism
|
|
;(renderOpts as any).revalidate = data.revalidate
|
|
;(renderOpts as any).pageData = props
|
|
}
|
|
|
|
if (getServerSideProps) {
|
|
props[SERVER_PROPS_ID] = true
|
|
}
|
|
|
|
if (getServerSideProps && !isFallback) {
|
|
let data: UnwrapPromise<ReturnType<GetServerSideProps>>
|
|
|
|
try {
|
|
data = await getServerSideProps({
|
|
req,
|
|
res,
|
|
query,
|
|
...(pageIsDynamic ? { params: params as ParsedUrlQuery } : undefined),
|
|
...(previewData !== false
|
|
? { preview: true, previewData: previewData }
|
|
: undefined),
|
|
})
|
|
} catch (serverSidePropsError) {
|
|
// remove not found error code to prevent triggering legacy
|
|
// 404 rendering
|
|
if (serverSidePropsError.code === 'ENOENT') {
|
|
delete serverSidePropsError.code
|
|
}
|
|
throw serverSidePropsError
|
|
}
|
|
|
|
if (data == null) {
|
|
throw new Error(GSSP_NO_RETURNED_VALUE)
|
|
}
|
|
|
|
const invalidKeys = Object.keys(data).filter(
|
|
(key) => key !== 'props' && key !== 'unstable_redirect'
|
|
)
|
|
|
|
if (invalidKeys.length) {
|
|
throw new Error(invalidKeysMsg('getServerSideProps', invalidKeys))
|
|
}
|
|
|
|
if (
|
|
data.unstable_redirect &&
|
|
typeof data.unstable_redirect === 'object'
|
|
) {
|
|
checkRedirectValues(data.unstable_redirect, req)
|
|
|
|
if (isDataReq) {
|
|
data.props = {
|
|
__N_REDIRECT: data.unstable_redirect.destination,
|
|
}
|
|
} else {
|
|
handleRedirect(res, data.unstable_redirect)
|
|
return null
|
|
}
|
|
}
|
|
|
|
if (
|
|
(dev || isBuildTimeSSG) &&
|
|
!isSerializableProps(pathname, 'getServerSideProps', data.props)
|
|
) {
|
|
// this fn should throw an error instead of ever returning `false`
|
|
throw new Error(
|
|
'invariant: getServerSideProps did not return valid props. Please report this.'
|
|
)
|
|
}
|
|
|
|
props.pageProps = Object.assign({}, props.pageProps, data.props)
|
|
;(renderOpts as any).pageData = props
|
|
}
|
|
} catch (dataFetchError) {
|
|
if (isDataReq || !dev || !dataFetchError) throw dataFetchError
|
|
ctx.err = dataFetchError
|
|
renderOpts.err = dataFetchError
|
|
console.error(dataFetchError)
|
|
}
|
|
|
|
if (
|
|
!isSSG && // we only show this warning for legacy pages
|
|
!getServerSideProps &&
|
|
process.env.NODE_ENV !== 'production' &&
|
|
Object.keys(props?.pageProps || {}).includes('url')
|
|
) {
|
|
console.warn(
|
|
`The prop \`url\` is a reserved prop in Next.js for legacy reasons and will be overridden on page ${pathname}\n` +
|
|
`See more info here: https://err.sh/vercel/next.js/reserved-page-prop`
|
|
)
|
|
}
|
|
|
|
// We only need to do this if we want to support calling
|
|
// _app's getInitialProps for getServerSideProps if not this can be removed
|
|
if (isDataReq && !isSSG) return props
|
|
|
|
// We don't call getStaticProps or getServerSideProps while generating
|
|
// the fallback so make sure to set pageProps to an empty object
|
|
if (isFallback) {
|
|
props.pageProps = {}
|
|
}
|
|
|
|
// the response might be finished on the getInitialProps call
|
|
if (isResSent(res) && !isSSG) return null
|
|
|
|
// we preload the buildManifest for auto-export dynamic pages
|
|
// to speed up hydrating query values
|
|
let filteredBuildManifest = buildManifest
|
|
if (isAutoExport && pageIsDynamic) {
|
|
const page = denormalizePagePath(normalizePagePath(pathname))
|
|
// This code would be much cleaner using `immer` and directly pushing into
|
|
// the result from `getPageFiles`, we could maybe consider that in the
|
|
// future.
|
|
if (page in filteredBuildManifest.pages) {
|
|
filteredBuildManifest = {
|
|
...filteredBuildManifest,
|
|
pages: {
|
|
...filteredBuildManifest.pages,
|
|
[page]: [
|
|
...filteredBuildManifest.pages[page],
|
|
...filteredBuildManifest.lowPriorityFiles.filter((f) =>
|
|
f.includes('_buildManifest')
|
|
),
|
|
],
|
|
},
|
|
lowPriorityFiles: filteredBuildManifest.lowPriorityFiles.filter(
|
|
(f) => !f.includes('_buildManifest')
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
const renderPage: RenderPage = (
|
|
options: ComponentsEnhancer = {}
|
|
): { html: string; head: any } => {
|
|
if (ctx.err && ErrorDebug) {
|
|
return { html: renderToString(<ErrorDebug error={ctx.err} />), head }
|
|
}
|
|
|
|
if (dev && (props.router || props.Component)) {
|
|
throw new Error(
|
|
`'router' and 'Component' can not be returned in getInitialProps from _app.js https://err.sh/vercel/next.js/cant-override-next-props`
|
|
)
|
|
}
|
|
|
|
const {
|
|
App: EnhancedApp,
|
|
Component: EnhancedComponent,
|
|
} = enhanceComponents(options, App, Component)
|
|
|
|
const html = renderToString(
|
|
<AppContainer>
|
|
<EnhancedApp Component={EnhancedComponent} router={router} {...props} />
|
|
</AppContainer>
|
|
)
|
|
|
|
return { html, head }
|
|
}
|
|
const documentCtx = { ...ctx, renderPage }
|
|
const docProps: DocumentInitialProps = await loadGetInitialProps(
|
|
Document,
|
|
documentCtx
|
|
)
|
|
// the response might be finished on the getInitialProps call
|
|
if (isResSent(res) && !isSSG) return null
|
|
|
|
if (!docProps || typeof docProps.html !== 'string') {
|
|
const message = `"${getDisplayName(
|
|
Document
|
|
)}.getInitialProps()" should resolve to an object with a "html" prop set with a valid html string`
|
|
throw new Error(message)
|
|
}
|
|
|
|
const dynamicImportIdsSet = new Set<string>()
|
|
const dynamicImports: ManifestItem[] = []
|
|
|
|
for (const mod of reactLoadableModules) {
|
|
const manifestItem: ManifestItem[] = reactLoadableManifest[mod]
|
|
|
|
if (manifestItem) {
|
|
manifestItem.forEach((item) => {
|
|
dynamicImports.push(item)
|
|
dynamicImportIdsSet.add(item.id as string)
|
|
})
|
|
}
|
|
}
|
|
|
|
const dynamicImportsIds = [...dynamicImportIdsSet]
|
|
const hybridAmp = ampState.hybrid
|
|
|
|
// update renderOpts so export knows current state
|
|
renderOpts.inAmpMode = inAmpMode
|
|
renderOpts.hybridAmp = hybridAmp
|
|
|
|
const docComponentsRendered: DocumentProps['docComponentsRendered'] = {}
|
|
|
|
let html = renderDocument(Document, {
|
|
...renderOpts,
|
|
docComponentsRendered,
|
|
buildManifest: filteredBuildManifest,
|
|
// Only enabled in production as development mode has features relying on HMR (style injection for example)
|
|
unstable_runtimeJS:
|
|
process.env.NODE_ENV === 'production'
|
|
? pageConfig.unstable_runtimeJS
|
|
: undefined,
|
|
dangerousAsPath: router.asPath,
|
|
ampState,
|
|
props,
|
|
headTags: await headTags(documentCtx),
|
|
isFallback,
|
|
docProps,
|
|
pathname,
|
|
ampPath,
|
|
query,
|
|
inAmpMode,
|
|
hybridAmp,
|
|
dynamicImportsIds,
|
|
dynamicImports,
|
|
gsp: !!getStaticProps ? true : undefined,
|
|
gssp: !!getServerSideProps ? true : undefined,
|
|
gip: hasPageGetInitialProps ? true : undefined,
|
|
appGip: !defaultAppGetInitialProps ? true : undefined,
|
|
devOnlyCacheBusterQueryString,
|
|
})
|
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
const nonRenderedComponents = []
|
|
const expectedDocComponents = ['Main', 'Head', 'NextScript', 'Html']
|
|
|
|
for (const comp of expectedDocComponents) {
|
|
if (!(docComponentsRendered as any)[comp]) {
|
|
nonRenderedComponents.push(comp)
|
|
}
|
|
}
|
|
const plural = nonRenderedComponents.length !== 1 ? 's' : ''
|
|
|
|
if (nonRenderedComponents.length) {
|
|
console.warn(
|
|
`Expected Document Component${plural} ${nonRenderedComponents.join(
|
|
', '
|
|
)} ${
|
|
plural ? 'were' : 'was'
|
|
} not rendered. Make sure you render them in your custom \`_document\`\n` +
|
|
`See more info here https://err.sh/next.js/missing-document-component`
|
|
)
|
|
}
|
|
}
|
|
|
|
if (inAmpMode && html) {
|
|
// inject HTML to AMP_RENDER_TARGET to allow rendering
|
|
// directly to body in AMP mode
|
|
const ampRenderIndex = html.indexOf(AMP_RENDER_TARGET)
|
|
html =
|
|
html.substring(0, ampRenderIndex) +
|
|
`<!-- __NEXT_DATA__ -->${docProps.html}` +
|
|
html.substring(ampRenderIndex + AMP_RENDER_TARGET.length)
|
|
html = await optimizeAmp(html, renderOpts.ampOptimizerConfig)
|
|
|
|
if (!renderOpts.ampSkipValidation && renderOpts.ampValidator) {
|
|
await renderOpts.ampValidator(html, pathname)
|
|
}
|
|
}
|
|
|
|
html = await postProcess(
|
|
html,
|
|
{
|
|
getFontDefinition,
|
|
},
|
|
{
|
|
optimizeFonts: renderOpts.optimizeFonts,
|
|
optimizeImages: renderOpts.optimizeImages,
|
|
}
|
|
)
|
|
|
|
if (inAmpMode || hybridAmp) {
|
|
// fix & being escaped for amphtml rel link
|
|
html = html.replace(/&amp=1/g, '&=1')
|
|
}
|
|
|
|
return html
|
|
}
|
|
|
|
function errorToJSON(err: Error): Error {
|
|
const { name, message, stack } = err
|
|
return { name, message, stack }
|
|
}
|
|
|
|
function serializeError(
|
|
dev: boolean | undefined,
|
|
err: Error
|
|
): Error & { statusCode?: number } {
|
|
if (dev) {
|
|
return errorToJSON(err)
|
|
}
|
|
|
|
return {
|
|
name: 'Internal Server Error.',
|
|
message: '500 - Internal Server Error.',
|
|
statusCode: 500,
|
|
}
|
|
}
|