diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index 7aa7b83f6230..440cd60c3dfd 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -10,8 +10,20 @@ import fs from 'fs' import chalk from 'next/dist/compiled/chalk' import { posix, join } from 'path' import { stringify } from 'querystring' -import { API_ROUTE, DOT_NEXT_ALIAS, PAGES_DIR_ALIAS } from '../lib/constants' -import { EDGE_RUNTIME_WEBPACK } from '../shared/lib/constants' +import { + API_ROUTE, + DOT_NEXT_ALIAS, + PAGES_DIR_ALIAS, + ROOT_ALIAS, + ROOT_DIR_ALIAS, +} from '../lib/constants' +import { + CLIENT_STATIC_FILES_RUNTIME_AMP, + CLIENT_STATIC_FILES_RUNTIME_MAIN, + CLIENT_STATIC_FILES_RUNTIME_MAIN_ROOT, + CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH, + EDGE_RUNTIME_WEBPACK, +} from '../shared/lib/constants' import { MIDDLEWARE_ROUTE } from '../lib/constants' import { __ApiPreviewProps } from '../server/api-utils' import { isTargetLikeServerless } from '../server/utils' @@ -28,14 +40,22 @@ type ObjectValue = T extends { [key: string]: infer V } ? V : never * special case because it is the only page where we want to preserve the RSC * server extension. */ -export function getPageFromPath(pagePath: string, pageExtensions: string[]) { +export function getPageFromPath( + pagePath: string, + pageExtensions: string[], + isRoot?: boolean +) { const extensions = pagePath.includes('/_app.server.') ? withoutRSCExtensions(pageExtensions) : pageExtensions - const page = normalizePathSep( + let page = normalizePathSep( pagePath.replace(new RegExp(`\\.+(${extensions.join('|')})$`), '') - ).replace(/\/index$/, '') + ) + + if (!isRoot) { + page = page.replace(/\/index$/, '') + } return page === '' ? '/' : page } @@ -43,15 +63,18 @@ export function getPageFromPath(pagePath: string, pageExtensions: string[]) { export function createPagesMapping({ hasServerComponents, isDev, + isRoot, pageExtensions, pagePaths, }: { hasServerComponents: boolean isDev: boolean + isRoot?: boolean pageExtensions: string[] pagePaths: string[] }): { [page: string]: string } { const previousPages: { [key: string]: string } = {} + const pathAlias = isRoot ? ROOT_DIR_ALIAS : PAGES_DIR_ALIAS const pages = pagePaths.reduce<{ [key: string]: string }>( (result, pagePath) => { // Do not process .d.ts files inside the `pages` folder @@ -59,7 +82,7 @@ export function createPagesMapping({ return result } - const pageKey = getPageFromPath(pagePath, pageExtensions) + const pageKey = getPageFromPath(pagePath, pageExtensions, isRoot) // Assume that if there's a Client Component, that there is // a matching Server Component that will map to the page. @@ -80,7 +103,11 @@ export function createPagesMapping({ previousPages[pageKey] = pagePath } - result[pageKey] = normalizePathSep(join(PAGES_DIR_ALIAS, pagePath)) + if (pageKey === 'root') { + result['root'] = normalizePathSep(join(ROOT_ALIAS, pagePath)) + } else { + result[pageKey] = normalizePathSep(join(pathAlias, pagePath)) + } return result }, {} @@ -89,6 +116,16 @@ export function createPagesMapping({ // In development we always alias these to allow Webpack to fallback to // the correct source file so that HMR can work properly when a file is // added or removed. + + if (isRoot) { + if (isDev) { + pages['root'] = `${ROOT_ALIAS}/root` + } else { + pages['root'] = pages['root'] || 'next/dist/pages/root' + } + return pages + } + if (isDev) { delete pages['/_app'] delete pages['/_app.server'] @@ -222,6 +259,8 @@ interface CreateEntrypointsParams { pagesDir: string previewMode: __ApiPreviewProps target: 'server' | 'serverless' | 'experimental-serverless-trace' + rootDir?: string + rootPaths?: Record } export function getEdgeServerEntry(opts: { @@ -326,29 +365,45 @@ export function getClientEntry(opts: { } export async function createEntrypoints(params: CreateEntrypointsParams) { - const { config, pages, pagesDir, isDev, target } = params + const { config, pages, pagesDir, isDev, target, rootDir, rootPaths } = params const edgeServer: webpack5.EntryObject = {} const server: webpack5.EntryObject = {} const client: webpack5.EntryObject = {} - await Promise.all( - Object.keys(pages).map(async (page) => { + const getEntryHandler = + (mappings: Record, isRoot: boolean) => + async (page: string) => { const bundleFile = normalizePagePath(page) const clientBundlePath = posix.join('pages', bundleFile) - const serverBundlePath = posix.join('pages', bundleFile) + const serverBundlePath = posix.join( + isRoot ? (bundleFile === '/root' ? './' : 'root') : 'pages', + bundleFile + ) + + // Handle paths that have aliases + const pageFilePath = (() => { + const absolutePagePath = mappings[page] + if (absolutePagePath.startsWith(PAGES_DIR_ALIAS)) { + return absolutePagePath.replace(PAGES_DIR_ALIAS, pagesDir) + } + + if (absolutePagePath.startsWith(ROOT_DIR_ALIAS) && rootDir) { + return absolutePagePath.replace(ROOT_DIR_ALIAS, rootDir) + } + + if (absolutePagePath.startsWith(ROOT_ALIAS) && rootDir) { + return absolutePagePath.replace(ROOT_ALIAS, join(rootDir, '..')) + } + + return require.resolve(absolutePagePath) + })() runDependingOnPageType({ page, - pageRuntime: await getPageRuntime( - !pages[page].startsWith(PAGES_DIR_ALIAS) - ? require.resolve(pages[page]) - : join(pagesDir, pages[page].replace(PAGES_DIR_ALIAS, '')), - config, - isDev - ), + pageRuntime: await getPageRuntime(pageFilePath, config, isDev), onClient: () => { client[clientBundlePath] = getClientEntry({ - absolutePagePath: pages[page], + absolutePagePath: mappings[page], page, }) }, @@ -357,26 +412,31 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { if (page !== '/_app' && page !== '/_document') { server[serverBundlePath] = getServerlessEntry({ ...params, - absolutePagePath: pages[page], + absolutePagePath: mappings[page], page, }) } } else { - server[serverBundlePath] = [pages[page]] + server[serverBundlePath] = [mappings[page]] } }, onEdgeServer: () => { edgeServer[serverBundlePath] = getEdgeServerEntry({ ...params, - absolutePagePath: pages[page], + absolutePagePath: mappings[page], bundlePath: clientBundlePath, isDev: false, page, }) }, }) - }) - ) + } + + if (rootDir && rootPaths) { + const entryHandler = getEntryHandler(rootPaths, true) + await Promise.all(Object.keys(rootPaths).map(entryHandler)) + } + await Promise.all(Object.keys(pages).map(getEntryHandler(pages, false))) return { client, @@ -450,9 +510,10 @@ export function finalizeEntrypoint({ if ( // Client special cases name !== 'polyfills' && - name !== 'main' && - name !== 'amp' && - name !== 'react-refresh' + name !== CLIENT_STATIC_FILES_RUNTIME_MAIN && + name !== CLIENT_STATIC_FILES_RUNTIME_MAIN_ROOT && + name !== CLIENT_STATIC_FILES_RUNTIME_AMP && + name !== CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH ) { return { dependOn: diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 4390a97824b7..fa4aaad02ecc 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -114,6 +114,7 @@ import { MiddlewareManifest } from './webpack/plugins/middleware-plugin' import { recursiveCopy } from '../lib/recursive-copy' import { recursiveReadDir } from '../lib/recursive-readdir' import { lockfilePatchPromise, teardownTraceSubscriber } from './swc' +import { findPageFile } from '../server/lib/find-page-file' export type SsgRoute = { initialRevalidateSeconds: number | false @@ -309,6 +310,26 @@ export default async function build( new RegExp(`\\.(?:${config.pageExtensions.join('|')})$`) ) ) + + let rootPaths: string[] | undefined + + if (rootDir) { + rootPaths = await nextBuildSpan + .traceChild('collect-root-paths') + .traceAsyncFn(() => + recursiveReadDir( + rootDir, + new RegExp(`\\.(?:${config.pageExtensions.join('|')})$`) + ) + ) + + const rootFile = await findPageFile( + path.join(rootDir, '..'), + 'root', + config.pageExtensions + ) + if (rootFile) rootPaths.push(rootFile) + } // needed for static exporting since we want to replace with HTML // files @@ -332,6 +353,22 @@ export default async function build( }) ) + let mappedRootPaths: ReturnType | undefined + + if (rootPaths && rootDir) { + mappedRootPaths = nextBuildSpan + .traceChild('create-root-mapping') + .traceFn(() => + createPagesMapping({ + pagePaths: rootPaths!, + hasServerComponents, + isDev: false, + isRoot: true, + pageExtensions: config.pageExtensions, + }) + ) + } + const entrypoints = await nextBuildSpan .traceChild('create-entrypoints') .traceAsyncFn(() => @@ -344,6 +381,8 @@ export default async function build( pagesDir, previewMode: previewProps, target, + rootDir, + rootPaths: mappedRootPaths, }) ) @@ -649,6 +688,7 @@ export default async function build( rewrites, runWebpackSpan, target, + rootDir, } const configs = await runWebpackSpan diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 9a96c22689b5..b7afc07101fe 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -10,12 +10,15 @@ import { NEXT_PROJECT_ROOT, NEXT_PROJECT_ROOT_DIST_CLIENT, PAGES_DIR_ALIAS, + ROOT_ALIAS, + ROOT_DIR_ALIAS, } from '../lib/constants' import { fileExists } from '../lib/file-exists' import { CustomRoutes } from '../lib/load-custom-routes.js' import { CLIENT_STATIC_FILES_RUNTIME_AMP, CLIENT_STATIC_FILES_RUNTIME_MAIN, + CLIENT_STATIC_FILES_RUNTIME_MAIN_ROOT, CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL, CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH, CLIENT_STATIC_FILES_RUNTIME_WEBPACK, @@ -312,6 +315,7 @@ export default async function getBaseWebpackConfig( rewrites, runWebpackSpan, target = 'server', + rootDir, }: { buildId: string config: NextConfigComplete @@ -325,6 +329,7 @@ export default async function getBaseWebpackConfig( rewrites: CustomRoutes['rewrites'] runWebpackSpan: Span target?: string + rootDir?: string } ): Promise { const isClient = compilerType === 'client' @@ -537,6 +542,18 @@ export default async function getBaseWebpackConfig( ) ) .replace(/\\/g, '/'), + ...(config.experimental.rootDir + ? { + [CLIENT_STATIC_FILES_RUNTIME_MAIN_ROOT]: + `./` + + path + .relative( + dir, + path.join(NEXT_PROJECT_ROOT_DIST_CLIENT, 'root-next.js') + ) + .replace(/\\/g, '/'), + } + : {}), } as ClientEntries) : undefined @@ -559,6 +576,7 @@ export default async function getBaseWebpackConfig( const customAppAliases: { [key: string]: string[] } = {} const customErrorAlias: { [key: string]: string[] } = {} const customDocumentAliases: { [key: string]: string[] } = {} + const customRootAliases: { [key: string]: string[] } = {} if (dev) { customAppAliases[`${PAGES_DIR_ALIAS}/_app`] = [ @@ -589,6 +607,16 @@ export default async function getBaseWebpackConfig( }, [] as string[]), `next/dist/pages/_document.js`, ] + + if (config.experimental.rootDir && rootDir) { + customRootAliases[`${ROOT_ALIAS}/root`] = [ + ...config.pageExtensions.reduce((prev, ext) => { + prev.push(path.join(rootDir, `root.${ext}`)) + return prev + }, [] as string[]), + 'next/dist/pages/root.js', + ] + } } const resolveConfig = { @@ -620,8 +648,15 @@ export default async function getBaseWebpackConfig( ...customAppAliases, ...customErrorAlias, ...customDocumentAliases, + ...customRootAliases, [PAGES_DIR_ALIAS]: pagesDir, + ...(rootDir + ? { + [ROOT_DIR_ALIAS]: rootDir, + [ROOT_ALIAS]: path.join(rootDir, '..'), + } + : {}), [DOT_NEXT_ALIAS]: distDir, ...(isClient || isEdgeServer ? getOptimizedAliases() : {}), ...getReactProfilingInProduction(), @@ -1523,6 +1558,7 @@ export default async function getBaseWebpackConfig( serverless: isLikeServerless, dev, isEdgeRuntime: isEdgeServer, + rootEnabled: !!config.experimental.rootDir, }), // MiddlewarePlugin should be after DefinePlugin so NEXT_PUBLIC_* // replacement is done before its process.env.* handling @@ -1533,6 +1569,7 @@ export default async function getBaseWebpackConfig( rewrites, isDevFallback, exportRuntime: hasConcurrentFeatures, + rootEnabled: !!config.experimental.rootDir, }), new ProfilingPlugin({ runWebpackSpan }), config.optimizeFonts && diff --git a/packages/next/build/webpack/plugins/build-manifest-plugin.ts b/packages/next/build/webpack/plugins/build-manifest-plugin.ts index d69cbde5d13c..7d02aeb08d56 100644 --- a/packages/next/build/webpack/plugins/build-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/build-manifest-plugin.ts @@ -5,6 +5,7 @@ import { MIDDLEWARE_BUILD_MANIFEST, CLIENT_STATIC_FILES_PATH, CLIENT_STATIC_FILES_RUNTIME_MAIN, + CLIENT_STATIC_FILES_RUNTIME_MAIN_ROOT, CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL, CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH, CLIENT_STATIC_FILES_RUNTIME_AMP, @@ -95,12 +96,14 @@ export default class BuildManifestPlugin { private rewrites: CustomRoutes['rewrites'] private isDevFallback: boolean private exportRuntime: boolean + private rootEnabled: boolean constructor(options: { buildId: string rewrites: CustomRoutes['rewrites'] isDevFallback?: boolean exportRuntime?: boolean + rootEnabled: boolean }) { this.buildId = options.buildId this.isDevFallback = !!options.isDevFallback @@ -109,6 +112,7 @@ export default class BuildManifestPlugin { afterFiles: [], fallback: [], } + this.rootEnabled = options.rootEnabled this.rewrites.beforeFiles = options.rewrites.beforeFiles.map(processRoute) this.rewrites.afterFiles = options.rewrites.afterFiles.map(processRoute) this.rewrites.fallback = options.rewrites.fallback.map(processRoute) @@ -127,6 +131,7 @@ export default class BuildManifestPlugin { devFiles: [], ampDevFiles: [], lowPriorityFiles: [], + rootMainFiles: [], pages: { '/_app': [] }, ampFirstPages: [], } @@ -147,6 +152,16 @@ export default class BuildManifestPlugin { getEntrypointFiles(entrypoints.get(CLIENT_STATIC_FILES_RUNTIME_MAIN)) ) + if (this.rootEnabled) { + assetMap.rootMainFiles = [ + ...new Set( + getEntrypointFiles( + entrypoints.get(CLIENT_STATIC_FILES_RUNTIME_MAIN_ROOT) + ) + ), + ] + } + const compilationAssets: { name: string source: typeof sources.RawSource @@ -178,6 +193,7 @@ export default class BuildManifestPlugin { CLIENT_STATIC_FILES_RUNTIME_MAIN, CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH, CLIENT_STATIC_FILES_RUNTIME_AMP, + ...(this.rootEnabled ? [CLIENT_STATIC_FILES_RUNTIME_MAIN_ROOT] : []), ]) for (const entrypoint of compilation.entrypoints.values()) { diff --git a/packages/next/build/webpack/plugins/pages-manifest-plugin.ts b/packages/next/build/webpack/plugins/pages-manifest-plugin.ts index 723e841c8145..c68f7e944ad4 100644 --- a/packages/next/build/webpack/plugins/pages-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/pages-manifest-plugin.ts @@ -1,11 +1,17 @@ import { webpack, sources } from 'next/dist/compiled/webpack/webpack' -import { PAGES_MANIFEST } from '../../../shared/lib/constants' +import { + PAGES_MANIFEST, + ROOT_PATHS_MANIFEST, +} from '../../../shared/lib/constants' import getRouteFromEntrypoint from '../../../server/get-route-from-entrypoint' +import { normalizePathSep } from '../../../shared/lib/page-path/normalize-path-sep' export type PagesManifest = { [page: string]: string } let edgeServerPages = {} let nodeServerPages = {} +let edgeServerRootPaths = {} +let nodeServerRootPaths = {} // This plugin creates a pages-manifest.json from page entrypoints. // This is used for mapping paths like `/` to `.next/server/static//pages/index.js` when doing SSR @@ -14,27 +20,32 @@ export default class PagesManifestPlugin implements webpack.Plugin { serverless: boolean dev: boolean isEdgeRuntime: boolean + rootEnabled: boolean constructor({ serverless, dev, isEdgeRuntime, + rootEnabled, }: { serverless: boolean dev: boolean isEdgeRuntime: boolean + rootEnabled: boolean }) { this.serverless = serverless this.dev = dev this.isEdgeRuntime = isEdgeRuntime + this.rootEnabled = rootEnabled } createAssets(compilation: any, assets: any) { const entrypoints = compilation.entrypoints const pages: PagesManifest = {} + const rootPaths: PagesManifest = {} for (const entrypoint of entrypoints.values()) { - const pagePath = getRouteFromEntrypoint(entrypoint.name) + const pagePath = getRouteFromEntrypoint(entrypoint.name, this.rootEnabled) if (!pagePath) { continue @@ -54,22 +65,30 @@ export default class PagesManifestPlugin implements webpack.Plugin { continue } // Write filename, replace any backslashes in path (on windows) with forwardslashes for cross-platform consistency. - pages[pagePath] = files[files.length - 1] + let file = files[files.length - 1] if (!this.dev) { if (!this.isEdgeRuntime) { - pages[pagePath] = pages[pagePath].slice(3) + file = file.slice(3) } } - pages[pagePath] = pages[pagePath].replace(/\\/g, '/') + file = normalizePathSep(file) + + if (entrypoint.name.startsWith('root/')) { + rootPaths[pagePath] = file + } else { + pages[pagePath] = file + } } // This plugin is used by both the Node server and Edge server compilers, // we need to merge both pages to generate the full manifest. if (this.isEdgeRuntime) { edgeServerPages = pages + edgeServerRootPaths = rootPaths } else { nodeServerPages = pages + nodeServerRootPaths = rootPaths } assets[ @@ -84,6 +103,21 @@ export default class PagesManifestPlugin implements webpack.Plugin { 2 ) ) + + if (this.rootEnabled) { + assets[ + `${!this.dev && !this.isEdgeRuntime ? '../' : ''}` + ROOT_PATHS_MANIFEST + ] = new sources.RawSource( + JSON.stringify( + { + ...edgeServerRootPaths, + ...nodeServerRootPaths, + }, + null, + 2 + ) + ) + } } apply(compiler: webpack.Compiler): void { diff --git a/packages/next/export/index.ts b/packages/next/export/index.ts index 8191dd75e7f3..c6c8e2fef7b3 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -581,6 +581,7 @@ export default async function exportApp( outDir, pagesDataDir, renderOpts, + rootDir: nextConfig.experimental.rootDir, serverRuntimeConfig, subFolders, buildExport: options.buildExport, diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index e68d20456d08..8934c0d441b4 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -60,6 +60,7 @@ interface ExportPageInput { parentSpanId: any httpAgentOptions: NextConfigComplete['httpAgentOptions'] serverComponents?: boolean + rootDir?: boolean } interface ExportPageResults { @@ -84,6 +85,7 @@ interface RenderOpts { locale?: string defaultLocale?: string trailingSlash?: boolean + rootDir?: boolean } type ComponentModule = ComponentType<{}> & { @@ -97,6 +99,7 @@ export default async function exportPage({ pathMap, distDir, outDir, + rootDir, pagesDataDir, renderOpts, buildExport, @@ -262,7 +265,13 @@ export default async function exportPage({ getServerSideProps, getStaticProps, pageConfig, - } = await loadComponents(distDir, page, serverless, serverComponents) + } = await loadComponents( + distDir, + page, + serverless, + serverComponents, + rootDir + ) const ampState = { ampFirst: pageConfig?.amp === true, hasQuery: Boolean(query.amp), diff --git a/packages/next/lib/constants.ts b/packages/next/lib/constants.ts index 92746a3d3ae7..ea8e0690e117 100644 --- a/packages/next/lib/constants.ts +++ b/packages/next/lib/constants.ts @@ -25,6 +25,8 @@ export const MIDDLEWARE_ROUTE = /_middleware$/ // we have to use a private alias export const PAGES_DIR_ALIAS = 'private-next-pages' export const DOT_NEXT_ALIAS = 'private-dot-next' +export const ROOT_DIR_ALIAS = 'private-next-root-dir' +export const ROOT_ALIAS = 'private-next-root' export const PUBLIC_DIR_MIDDLEWARE_CONFLICT = `You can not have a '_next' folder inside of your public folder. This conflicts with the internal '/_next' route. https://nextjs.org/docs/messages/public-next-folder-conflict` diff --git a/packages/next/pages/root.tsx b/packages/next/pages/root.tsx new file mode 100644 index 000000000000..5f972e688ce6 --- /dev/null +++ b/packages/next/pages/root.tsx @@ -0,0 +1,18 @@ +import React from 'react' + +export type RootProps = { + headChildren: any + bodyChildren: any +} + +export default function Root({ headChildren, bodyChildren }: RootProps) { + return ( + + + {headChildren} + Test + + {bodyChildren} + + ) +} diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index f84d608e4c57..51baa1aedb35 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -70,6 +70,7 @@ import { createHeaderRoute, createRedirectRoute } from './server-route-utils' import { PrerenderManifest } from '../build' import { ImageConfigComplete } from '../shared/lib/image-config' import { replaceBasePath } from './router-utils' +import { normalizeRootPath } from '../shared/lib/router/utils/root-paths' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -141,6 +142,7 @@ export default abstract class Server { protected publicDir: string protected hasStaticDir: boolean protected pagesManifest?: PagesManifest + protected rootPathsManifest?: PagesManifest protected buildId: string protected minimalMode: boolean protected renderOpts: { @@ -180,6 +182,7 @@ export default abstract class Server { private responseCache: ResponseCache protected router: Router protected dynamicRoutes?: DynamicRoutes + protected rootPathRoutes?: Record protected customRoutes: CustomRoutes protected middlewareManifest?: MiddlewareManifest protected middleware?: RoutingItem[] @@ -190,6 +193,7 @@ export default abstract class Server { protected abstract getPublicDir(): string protected abstract getHasStaticDir(): boolean protected abstract getPagesManifest(): PagesManifest | undefined + protected abstract getRootPathsManifest(): PagesManifest | undefined protected abstract getBuildId(): string protected abstract generatePublicRoutes(): Route[] protected abstract generateImageRoutes(): Route[] @@ -352,6 +356,7 @@ export default abstract class Server { }) this.pagesManifest = this.getPagesManifest() + this.rootPathsManifest = this.getRootPathsManifest() this.middlewareManifest = this.getMiddlewareManifest() this.customRoutes = this.getCustomRoutes() @@ -869,6 +874,7 @@ export default abstract class Server { const { useFileSystemPublicRoutes } = this.nextConfig if (useFileSystemPublicRoutes) { + this.rootPathRoutes = this.getRootPathRoutes() this.dynamicRoutes = this.getDynamicRoutes() if (!this.minimalMode) { this.middleware = this.getMiddleware() @@ -890,6 +896,30 @@ export default abstract class Server { } } + protected isRoutableRootPath(pathname: string): boolean { + if (this.rootPathRoutes) { + const paths = Object.keys(this.rootPathRoutes) + + /** + * a root path is only routable if + * 1. has root/hello.js and no root/hello/ folder + * 2. has root/hello.js and a root/hello/index.js + */ + const hasFolderIndex = this.rootPathRoutes[`${pathname}/index`] + const hasFolder = paths.some((path) => { + return path.startsWith(`${pathname}/`) + }) + + if (hasFolder && hasFolderIndex) { + return true + } + if (!hasFolder && this.rootPathRoutes[pathname]) { + return true + } + } + return false + } + protected async hasPage(pathname: string): Promise { let found = false try { @@ -962,7 +992,10 @@ export default abstract class Server { const addedPages = new Set() return getSortedRoutes( - Object.keys(this.pagesManifest!).map( + [ + ...Object.keys(this.rootPathRoutes || {}), + ...Object.keys(this.pagesManifest!), + ].map( (page) => normalizeLocalePath(page, this.nextConfig.i18n?.locales).pathname ) @@ -978,6 +1011,35 @@ export default abstract class Server { .filter((item): item is RoutingItem => Boolean(item)) } + protected getRootPathRoutes(): Record { + const rootPathRoutes: Record = {} + + Object.keys(this.rootPathsManifest || {}).forEach((entry) => { + rootPathRoutes[normalizeRootPath(entry)] = entry + }) + return rootPathRoutes + } + + protected getRootPathLayouts(pathname: string): string[] { + const layoutPaths: string[] = [] + + if (this.rootPathRoutes) { + const paths = Object.values(this.rootPathRoutes) + const parts = pathname.split('/').filter(Boolean) + + for (let i = 1; i < parts.length; i++) { + const parentPath = `/${parts.slice(0, i).join('/')}` + + if (paths.includes(parentPath)) { + layoutPaths.push(parentPath) + } + } + // TODO: when should we bail on adding the root.js wrapper + layoutPaths.unshift('/_root') + } + return layoutPaths + } + protected async run( req: BaseNextRequest, res: BaseNextResponse, @@ -1688,14 +1750,63 @@ export default abstract class Server { let page = pathname const bubbleNoFallback = !!query._nextBubbleNoFallback delete query._nextBubbleNoFallback + // map the route to the actual bundle name e.g. + // `/dashboard/rootonly/hello` -> `/dashboard+rootonly/hello` + const getOriginalRootPath = (rootPath: string) => { + if (this.nextConfig.experimental.rootDir) { + const originalRootPath = + this.rootPathRoutes?.[`${pathname}/index`] || + this.rootPathRoutes?.[pathname] + + if (!originalRootPath) { + return null + } + const isRoutable = this.isRoutableRootPath(rootPath) + + // 404 when layout is hit and this isn't a routable path + // e.g. root/hello.js with root/hello/another.js but + // no root/hello/index.js + if (!isRoutable) { + return '' + } + return originalRootPath + } + return null + } + + const gatherRootLayouts = async ( + rootPath: string, + result: FindComponentsResult + ): Promise => { + const layoutPaths = this.getRootPathLayouts(rootPath) + result.components.rootLayouts = await Promise.all( + layoutPaths.map(async (path) => { + const layoutRes = await this.findPageComponents(path) + return { + isRoot: path === '/_root', + Component: layoutRes?.components.Component!, + getStaticProps: layoutRes?.components.getStaticProps, + getServerSideProps: layoutRes?.components.getServerSideProps, + } + }) + ) + } try { // Ensure a request to the URL /accounts/[id] will be treated as a dynamic // route correctly and not loaded immediately without parsing params. if (!isDynamicRoute(pathname)) { - const result = await this.findPageComponents(pathname, query) + const rootPath = getOriginalRootPath(pathname) + + if (typeof rootPath === 'string') { + page = rootPath + } + const result = await this.findPageComponents(page, query) if (result) { try { + if (result.components.isRootPath) { + await gatherRootLayouts(page, result) + } return await this.renderToResponseWithComponents(ctx, result) } catch (err) { const isNoFallbackError = err instanceof NoFallbackError @@ -1713,6 +1824,12 @@ export default abstract class Server { if (!params) { continue } + page = dynamicRoute.page + const rootPath = getOriginalRootPath(page) + + if (typeof rootPath === 'string') { + page = rootPath + } const dynamicRouteResult = await this.findPageComponents( dynamicRoute.page, @@ -1721,11 +1838,13 @@ export default abstract class Server { ) if (dynamicRouteResult) { try { - page = dynamicRoute.page + if (dynamicRouteResult.components.isRootPath) { + await gatherRootLayouts(page, dynamicRouteResult) + } return await this.renderToResponseWithComponents( { ...ctx, - pathname: dynamicRoute.page, + pathname: page, renderOpts: { ...ctx.renderOpts, params, diff --git a/packages/next/server/dev/hot-reloader.ts b/packages/next/server/dev/hot-reloader.ts index d17049d47966..4a71f8bfb5cd 100644 --- a/packages/next/server/dev/hot-reloader.ts +++ b/packages/next/server/dev/hot-reloader.ts @@ -168,6 +168,7 @@ export default class HotReloader { private fallbackWatcher: any private hotReloaderSpan: Span private pagesMapping: { [key: string]: string } = {} + private rootDir?: string constructor( dir: string, @@ -178,6 +179,7 @@ export default class HotReloader { buildId, previewProps, rewrites, + rootDir, }: { config: NextConfigComplete pagesDir: string @@ -185,12 +187,14 @@ export default class HotReloader { buildId: string previewProps: __ApiPreviewProps rewrites: CustomRoutes['rewrites'] + rootDir?: string } ) { this.buildId = buildId this.dir = dir this.middlewares = [] this.pagesDir = pagesDir + this.rootDir = rootDir this.distDir = distDir this.clientStats = null this.serverStats = null @@ -423,6 +427,7 @@ export default class HotReloader { pagesDir: this.pagesDir, rewrites: this.rewrites, runWebpackSpan: this.hotReloaderSpan, + rootDir: this.rootDir, } return webpackConfigSpan @@ -834,6 +839,7 @@ export default class HotReloader { multiCompiler, watcher: this.watcher, pagesDir: this.pagesDir, + rootDir: this.rootDir, nextConfig: this.config, ...(this.config.onDemandEntries as { maxInactiveAge: number diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index c935563c58b0..3a855aa3c624 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -65,6 +65,8 @@ import { getMiddlewareRegex } from '../../shared/lib/router/utils/get-middleware import { isCustomErrorPage, isReservedPage } from '../../build/utils' import { NodeNextResponse, NodeNextRequest } from '../base-http/node' import { getPageRuntime, invalidatePageRuntimeCache } from '../../build/entries' +import { normalizePathSep } from '../../shared/lib/page-path/normalize-path-sep' +import { normalizeRootPath } from '../../shared/lib/router/utils/root-paths' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: React.FunctionComponent @@ -96,7 +98,6 @@ export default class DevServer extends Server { protected sortedRoutes?: string[] private addedUpgradeListener = false private pagesDir: string - // @ts-ignore TODO: add implementation private rootDir?: string protected staticPathsWorker?: { [key: string]: any } & { @@ -264,24 +265,61 @@ export default class DevServer extends Server { }) let wp = (this.webpackWatcher = new Watchpack()) - wp.watch([], [this.pagesDir], 0) + const toWatch = [this.pagesDir!] + + if (this.rootDir) { + toWatch.push(this.rootDir) + } + wp.watch([], toWatch, 0) wp.on('aggregated', async () => { const routedMiddleware = [] - const routedPages = [] + const routedPages: string[] = [] const knownFiles = wp.getTimeInfoEntries() + const rootPaths: Record = {} const ssrMiddleware = new Set() for (const [fileName, { accuracy, safeTime }] of knownFiles) { if (accuracy === undefined || !regexPageExtension.test(fileName)) { continue } + let pageName: string = '' + let isRootPath = false - const pageName = absolutePathToPage( - this.pagesDir, - fileName, - this.nextConfig.pageExtensions - ) + if ( + this.rootDir && + normalizePathSep(fileName).startsWith( + normalizePathSep(this.rootDir) + ) + ) { + isRootPath = true + pageName = absolutePathToPage( + this.rootDir, + fileName, + this.nextConfig.pageExtensions, + false + ) + } else { + pageName = absolutePathToPage( + this.pagesDir, + fileName, + this.nextConfig.pageExtensions + ) + } + + if (isRootPath) { + // TODO: should only routes ending in /index.js be route-able? + const originalPageName = pageName + pageName = normalizeRootPath(pageName) + rootPaths[pageName] = originalPageName + + if (routedPages.includes(pageName)) { + continue + } + } else { + // /index is preserved for root folder + pageName = pageName.replace(/\/index$/, '') || '/' + } if (regexMiddleware.test(fileName)) { routedMiddleware.push( @@ -310,6 +348,7 @@ export default class DevServer extends Server { routedPages.push(pageName) } + this.rootPathRoutes = rootPaths this.middleware = getSortedRoutes(routedMiddleware).map((page) => ({ match: getRouteMatcher( getMiddlewareRegex(page, !ssrMiddleware.has(page)) @@ -398,6 +437,7 @@ export default class DevServer extends Server { previewProps: this.getPreviewProps(), buildId: this.buildId, rewrites, + rootDir: this.rootDir, }) await super.prepare() await this.addExportPathMapRoutes() @@ -445,7 +485,6 @@ export default class DevServer extends Server { protected async hasPage(pathname: string): Promise { let normalizedPath: string - try { normalizedPath = normalizePagePath(pathname) } catch (err) { @@ -456,6 +495,16 @@ export default class DevServer extends Server { return false } + // check rootDir first if enabled + if (this.rootDir) { + const pageFile = await findPageFile( + this.rootDir, + normalizedPath, + this.nextConfig.pageExtensions + ) + if (pageFile) return true + } + const pageFile = await findPageFile( this.pagesDir, normalizedPath, @@ -737,6 +786,10 @@ export default class DevServer extends Server { return undefined } + protected getRootPathsManifest(): undefined { + return undefined + } + protected getMiddleware(): never[] { return [] } diff --git a/packages/next/server/dev/on-demand-entry-handler.ts b/packages/next/server/dev/on-demand-entry-handler.ts index 08eabdff3a53..6ca20c9b068c 100644 --- a/packages/next/server/dev/on-demand-entry-handler.ts +++ b/packages/next/server/dev/on-demand-entry-handler.ts @@ -54,6 +54,7 @@ export function onDemandEntryHandler({ nextConfig, pagesBufferLength, pagesDir, + rootDir, watcher, }: { maxInactiveAge: number @@ -61,6 +62,7 @@ export function onDemandEntryHandler({ nextConfig: NextConfigComplete pagesBufferLength: number pagesDir: string + rootDir?: string watcher: any }) { const invalidator = new Invalidator(watcher) @@ -78,13 +80,16 @@ export function onDemandEntryHandler({ function getPagePathsFromEntrypoints( type: 'client' | 'server' | 'edge-server', - entrypoints: Map + entrypoints: Map, + root?: boolean ) { const pagePaths: string[] = [] for (const entrypoint of entrypoints.values()) { - const page = getRouteFromEntrypoint(entrypoint.name!) + const page = getRouteFromEntrypoint(entrypoint.name!, root) if (page) { pagePaths.push(`${type}${page}`) + } else if (root && entrypoint.name === 'root') { + pagePaths.push(`${type}/${entrypoint.name}`) } } @@ -96,19 +101,23 @@ export function onDemandEntryHandler({ return invalidator.doneBuilding() } const [clientStats, serverStats, edgeServerStats] = multiStats.stats + const root = !!rootDir const pagePaths = [ ...getPagePathsFromEntrypoints( 'client', - clientStats.compilation.entrypoints + clientStats.compilation.entrypoints, + root ), ...getPagePathsFromEntrypoints( 'server', - serverStats.compilation.entrypoints + serverStats.compilation.entrypoints, + root ), ...(edgeServerStats ? getPagePathsFromEntrypoints( 'edge-server', - edgeServerStats.compilation.entrypoints + edgeServerStats.compilation.entrypoints, + root ) : []), ] @@ -172,7 +181,8 @@ export function onDemandEntryHandler({ const pagePathData = await findPagePathData( pagesDir, page, - nextConfig.pageExtensions + nextConfig.pageExtensions, + rootDir ) let entryAdded = false @@ -329,18 +339,50 @@ class Invalidator { async function findPagePathData( pagesDir: string, page: string, - extensions: string[] + extensions: string[], + rootDir?: string ) { const normalizedPagePath = tryToNormalizePagePath(page) - const pagePath = await findPageFile(pagesDir, normalizedPagePath, extensions) + let pagePath: string | null = null + let isRoot = false + const isRootFile = rootDir && normalizedPagePath === '/_root' + + // check rootDir first + if (rootDir) { + pagePath = await findPageFile( + join(rootDir, isRootFile ? '..' : ''), + isRootFile ? 'root' : normalizedPagePath, + extensions + ) + + if (pagePath) { + isRoot = true + } + } + + if (!pagePath) { + pagePath = await findPageFile(pagesDir, normalizedPagePath, extensions) + } + if (pagePath !== null) { const pageUrl = ensureLeadingSlash( - removePagePathTail(normalizePathSep(pagePath), extensions) + removePagePathTail(normalizePathSep(pagePath), extensions, !isRoot) ) + const bundleFile = normalizePagePath(pageUrl) + let bundlePath + let absolutePagePath + + if (isRootFile) { + bundlePath = 'root' + absolutePagePath = join(rootDir!, '..', pagePath) + } else { + bundlePath = posix.join(isRoot ? 'root' : 'pages', bundleFile) + absolutePagePath = join(isRoot ? rootDir! : pagesDir, pagePath) + } return { - absolutePagePath: join(pagesDir, pagePath), - bundlePath: posix.join('pages', normalizePagePath(pageUrl)), + absolutePagePath, + bundlePath, page: posix.normalize(pageUrl), } } diff --git a/packages/next/server/get-page-files.ts b/packages/next/server/get-page-files.ts index 5e6945cbdf53..229729b3e397 100644 --- a/packages/next/server/get-page-files.ts +++ b/packages/next/server/get-page-files.ts @@ -6,6 +6,7 @@ export type BuildManifest = { ampDevFiles: readonly string[] polyfillFiles: readonly string[] lowPriorityFiles: readonly string[] + rootMainFiles: readonly string[] pages: { '/_app': readonly string[] [page: string]: readonly string[] diff --git a/packages/next/server/get-route-from-entrypoint.ts b/packages/next/server/get-route-from-entrypoint.ts index c412e2d886cc..ddeafbfff538 100644 --- a/packages/next/server/get-route-from-entrypoint.ts +++ b/packages/next/server/get-route-from-entrypoint.ts @@ -2,6 +2,8 @@ import getRouteFromAssetPath from '../shared/lib/router/utils/get-route-from-ass // matches pages/:page*.js const SERVER_ROUTE_NAME_REGEX = /^pages[/\\](.*)$/ +// matches root/:path*.js +const ROOT_ROUTE_NAME_REGEX = /^root[/\\](.*)$/ // matches static/pages/:page*.js const BROWSER_ROUTE_NAME_REGEX = /^static[/\\]pages[/\\](.*)$/ @@ -16,7 +18,8 @@ function matchBundle(regex: RegExp, input: string): string | null { } export default function getRouteFromEntrypoint( - entryFile: string + entryFile: string, + root?: boolean ): string | null { let pagePath = matchBundle(SERVER_ROUTE_NAME_REGEX, entryFile) @@ -24,6 +27,11 @@ export default function getRouteFromEntrypoint( return pagePath } + if (root) { + pagePath = matchBundle(ROOT_ROUTE_NAME_REGEX, entryFile) + if (pagePath) return pagePath + } + // Potentially the passed item is a browser bundle so we try to match that also return matchBundle(BROWSER_ROUTE_NAME_REGEX, entryFile) } diff --git a/packages/next/server/lib/find-page-file.ts b/packages/next/server/lib/find-page-file.ts index b7e60c2b7dd5..7c3e8bf838d3 100644 --- a/packages/next/server/lib/find-page-file.ts +++ b/packages/next/server/lib/find-page-file.ts @@ -20,7 +20,12 @@ export async function findPageFile( normalizedPagePath: string, pageExtensions: string[] ): Promise { - const pagePaths = getPagePaths(normalizedPagePath, pageExtensions) + const isRootPaths = pagesDir.replace(/\\/g, '/').endsWith('/root') + const pagePaths = getPagePaths( + normalizedPagePath, + pageExtensions, + isRootPaths + ) const [existingPath, ...others] = ( await Promise.all( pagePaths.map(async (path) => diff --git a/packages/next/server/load-components.ts b/packages/next/server/load-components.ts index 881598c769ee..862834e523f2 100644 --- a/packages/next/server/load-components.ts +++ b/packages/next/server/load-components.ts @@ -9,7 +9,7 @@ import { MIDDLEWARE_FLIGHT_MANIFEST, } from '../shared/lib/constants' import { join } from 'path' -import { requirePage } from './require' +import { requirePage, getPagePath } from './require' import { BuildManifest } from './get-page-files' import { interopDefault } from '../lib/interop-default' import { @@ -40,6 +40,13 @@ export type LoadComponentsReturnType = { ComponentMod: any AppMod: any AppServerMod: any + isRootPath?: boolean + rootLayouts?: Array<{ + isRoot?: boolean + Component: NextComponentType + getStaticProps?: GetStaticProps + getServerSideProps?: GetServerSideProps + }> } export async function loadDefaultErrorComponents(distDir: string) { @@ -67,7 +74,8 @@ export async function loadComponents( distDir: string, pathname: string, serverless: boolean, - serverComponents?: boolean + serverComponents?: boolean, + rootEnabled?: boolean ): Promise { if (serverless) { const ComponentMod = await requirePage(pathname, distDir, serverless) @@ -103,10 +111,12 @@ export async function loadComponents( } const [DocumentMod, AppMod, ComponentMod, AppServerMod] = await Promise.all([ - requirePage('/_document', distDir, serverless), - requirePage('/_app', distDir, serverless), - requirePage(pathname, distDir, serverless), - serverComponents ? requirePage('/_app.server', distDir, serverless) : null, + requirePage('/_document', distDir, serverless, rootEnabled), + requirePage('/_app', distDir, serverless, rootEnabled), + requirePage(pathname, distDir, serverless, rootEnabled), + serverComponents + ? requirePage('/_app.server', distDir, serverless, rootEnabled) + : null, ]) const [buildManifest, reactLoadableManifest, serverComponentManifest] = @@ -124,6 +134,20 @@ export async function loadComponents( const { getServerSideProps, getStaticProps, getStaticPaths } = ComponentMod + let isRootPath = false + + if (rootEnabled) { + const pagePath = getPagePath( + pathname, + distDir, + serverless, + false, + undefined, + rootEnabled + ) + isRootPath = !!pagePath?.match(/server[/\\]root[/\\]/) + } + return { App, Document, @@ -138,5 +162,6 @@ export async function loadComponents( getStaticProps, getStaticPaths, serverComponentManifest, + isRootPath, } } diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index c0a5e91ccef7..d0b527aea940 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -31,6 +31,7 @@ import { ROUTES_MANIFEST, MIDDLEWARE_FLIGHT_MANIFEST, CLIENT_PUBLIC_FILES_PATH, + ROOT_PATHS_MANIFEST, } from '../shared/lib/constants' import { recursiveReadDirSync } from './lib/recursive-readdir-sync' import { format as formatUrl, UrlWithParsedQuery } from 'url' @@ -45,6 +46,7 @@ import { getExtension, serveStatic } from './serve-static' import { ParsedUrlQuery } from 'querystring' import { apiResolver } from './api-utils/node' import { RenderOpts, renderToHTML } from './render' +import { renderToHTML as rootRenderToHTML } from './root-render' import { ParsedUrl, parseUrl } from '../shared/lib/router/utils/parse-url' import * as Log from '../build/output/log' @@ -157,6 +159,16 @@ export default class NextNodeServer extends BaseServer { return require(join(this.serverDistDir, PAGES_MANIFEST)) } + protected getRootPathsManifest(): PagesManifest | undefined { + if (this.nextConfig.experimental.rootDir) { + const rootPathsManifestPath = join( + this.serverDistDir, + ROOT_PATHS_MANIFEST + ) + return require(rootPathsManifestPath) + } + } + protected getBuildId(): string { const buildIdFile = join(this.distDir, BUILD_ID_FILE) try { @@ -572,6 +584,16 @@ export default class NextNodeServer extends BaseServer { // https://github.com/vercel/next.js/blob/df7cbd904c3bd85f399d1ce90680c0ecf92d2752/packages/next/server/render.tsx#L947-L952 renderOpts.serverComponentManifest = this.serverComponentManifest + if (renderOpts.isRootPath) { + return rootRenderToHTML( + req.originalRequest, + res.originalResponse, + pathname, + query, + renderOpts + ) + } + return renderToHTML( req.originalRequest, res.originalResponse, @@ -619,7 +641,8 @@ export default class NextNodeServer extends BaseServer { this.distDir, this._isLikeServerless, this.renderOpts.dev, - locales + locales, + this.nextConfig.experimental.rootDir ) } @@ -649,7 +672,8 @@ export default class NextNodeServer extends BaseServer { this.distDir, pagePath!, !this.renderOpts.dev && this._isLikeServerless, - this.renderOpts.serverComponents + this.renderOpts.serverComponents, + this.nextConfig.experimental.rootDir ) if ( diff --git a/packages/next/server/require.ts b/packages/next/server/require.ts index ee436ab9b64e..3205c04d624d 100644 --- a/packages/next/server/require.ts +++ b/packages/next/server/require.ts @@ -6,6 +6,7 @@ import { PAGES_MANIFEST, SERVER_DIRECTORY, SERVERLESS_DIRECTORY, + ROOT_PATHS_MANIFEST, } from '../shared/lib/constants' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' @@ -25,12 +26,21 @@ export function getPagePath( distDir: string, serverless: boolean, dev?: boolean, - locales?: string[] + locales?: string[], + rootEnabled?: boolean ): string { const serverBuildPath = join( distDir, serverless && !dev ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY ) + let rootPathsManifest: undefined | PagesManifest + + if (rootEnabled) { + if (page === '/_root') { + return join(serverBuildPath, 'root.js') + } + rootPathsManifest = require(join(serverBuildPath, ROOT_PATHS_MANIFEST)) + } const pagesManifest = require(join( serverBuildPath, PAGES_MANIFEST @@ -42,31 +52,51 @@ export function getPagePath( console.error(err) throw pageNotFoundError(page) } - let pagePath = pagesManifest[page] - if (!pagesManifest[page] && locales) { - const manifestNoLocales: typeof pagesManifest = {} + const checkManifest = (manifest: PagesManifest) => { + let curPath = manifest[page] + + if (!manifest[curPath] && locales) { + const manifestNoLocales: typeof pagesManifest = {} - for (const key of Object.keys(pagesManifest)) { - manifestNoLocales[normalizeLocalePath(key, locales).pathname] = - pagesManifest[key] + for (const key of Object.keys(manifest)) { + manifestNoLocales[normalizeLocalePath(key, locales).pathname] = + pagesManifest[key] + } + curPath = manifestNoLocales[page] } - pagePath = manifestNoLocales[page] + return curPath + } + let pagePath: string | undefined + + if (rootPathsManifest) { + pagePath = checkManifest(rootPathsManifest) } if (!pagePath) { - throw pageNotFoundError(page) + pagePath = checkManifest(pagesManifest) } + if (!pagePath) { + throw pageNotFoundError(page) + } return join(serverBuildPath, pagePath) } export function requirePage( page: string, distDir: string, - serverless: boolean + serverless: boolean, + rootEnabled?: boolean ): any { - const pagePath = getPagePath(page, distDir, serverless) + const pagePath = getPagePath( + page, + distDir, + serverless, + false, + undefined, + rootEnabled + ) if (pagePath.endsWith('.html')) { return promises.readFile(pagePath, 'utf8') } diff --git a/packages/next/server/root-render.tsx b/packages/next/server/root-render.tsx new file mode 100644 index 000000000000..6e9af6d9f7cd --- /dev/null +++ b/packages/next/server/root-render.tsx @@ -0,0 +1,496 @@ +import type { IncomingMessage, ServerResponse } from 'http' +import type { LoadComponentsReturnType } from './load-components' + +import React from 'react' +import { ParsedUrlQuery, stringify as stringifyQuery } from 'querystring' +import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack' +import { renderToReadableStream } from 'next/dist/compiled/react-server-dom-webpack/writer.browser.server' +import { StyleRegistry, createStyleRegistry } from 'styled-jsx' +import { NextParsedUrlQuery } from './request-meta' +import RenderResult from './render-result' +import { + readableStreamTee, + encodeText, + decodeText, + renderToInitialStream, + createBufferedTransformStream, + continueFromInitialStream, +} from './node-web-streams-helper' +import { FlushEffectsContext } from '../shared/lib/flush-effects' +// @ts-ignore react-dom/client exists when using React 18 +import ReactDOMServer from 'react-dom/server.browser' +import { isDynamicRoute } from '../shared/lib/router/utils' +import { tryGetPreviewData } from './api-utils/node' + +export type RenderOptsPartial = { + err?: Error | null + dev?: boolean + serverComponentManifest?: any + supportsDynamicHTML?: boolean + runtime?: 'nodejs' | 'edge' + serverComponents?: boolean + reactRoot: boolean +} + +export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial + +const rscCache = new Map() + +// Shadowing check does not work with TypeScript enums +// eslint-disable-next-line no-shadow +const enum RecordStatus { + Pending, + Resolved, + Rejected, +} + +type Record = { + status: RecordStatus + value: any +} + +function createRecordFromThenable(thenable: Promise) { + const record: Record = { + status: RecordStatus.Pending, + value: thenable, + } + thenable.then( + function (value) { + if (record.status === RecordStatus.Pending) { + const resolvedRecord = record + resolvedRecord.status = RecordStatus.Resolved + resolvedRecord.value = value + } + }, + function (err) { + if (record.status === RecordStatus.Pending) { + const rejectedRecord = record + rejectedRecord.status = RecordStatus.Rejected + rejectedRecord.value = err + } + } + ) + return record +} + +function readRecordValue(record: Record) { + if (record.status === RecordStatus.Resolved) { + return record.value + } else { + throw record.value + } +} + +function preloadDataFetchingRecord( + map: Map, + key: string, + fetcher: () => Promise | any +) { + let record = map.get(key) + + if (!record) { + const thenable = fetcher() + record = createRecordFromThenable(thenable) + map.set(key, record) + } + + return record +} + +function createFlightHook() { + return ( + writable: WritableStream, + id: string, + req: ReadableStream, + bootstrap: boolean + ) => { + let entry = rscCache.get(id) + if (!entry) { + const [renderStream, forwardStream] = readableStreamTee(req) + entry = createFromReadableStream(renderStream) + rscCache.set(id, entry) + + let bootstrapped = false + const forwardReader = forwardStream.getReader() + const writer = writable.getWriter() + function process() { + forwardReader.read().then(({ done, value }) => { + if (bootstrap && !bootstrapped) { + bootstrapped = true + writer.write( + encodeText( + `` + ) + ) + } + if (done) { + rscCache.delete(id) + writer.close() + } else { + writer.write( + encodeText( + `` + ) + ) + process() + } + }) + } + process() + } + return entry + } +} + +const useFlightResponse = createFlightHook() + +// Create the wrapper component for a Flight stream. +function createServerComponentRenderer( + ComponentToRender: React.ComponentType, + ComponentMod: any, + { + cachePrefix, + transformStream, + serverComponentManifest, + }: { + cachePrefix: string + transformStream: TransformStream + serverComponentManifest: NonNullable + } +) { + // We need to expose the `__webpack_require__` API globally for + // react-server-dom-webpack. This is a hack until we find a better way. + if (ComponentMod.__next_rsc__) { + // @ts-ignore + globalThis.__webpack_require__ = + ComponentMod.__next_rsc__.__webpack_require__ + + // @ts-ignore + globalThis.__webpack_chunk_load__ = () => Promise.resolve() + } + + const writable = transformStream.writable + const ServerComponentWrapper = (props: any) => { + const id = (React as any).useId() + const reqStream: ReadableStream = renderToReadableStream( + , + serverComponentManifest + ) + + const response = useFlightResponse( + writable, + cachePrefix + ',' + id, + reqStream, + true + ) + const root = response.readRoot() + rscCache.delete(id) + return root + } + + return ServerComponentWrapper +} + +export async function renderToHTML( + req: IncomingMessage, + res: ServerResponse, + pathname: string, + query: NextParsedUrlQuery, + renderOpts: RenderOpts +): Promise { + // don't modify original query object + query = Object.assign({}, query) + + const { + buildManifest, + serverComponentManifest, + supportsDynamicHTML, + runtime, + ComponentMod, + } = renderOpts + + const hasConcurrentFeatures = !!runtime + const pageIsDynamic = isDynamicRoute(pathname) + const layouts = renderOpts.rootLayouts || [] + + layouts.push({ + Component: renderOpts.Component, + getStaticProps: renderOpts.getStaticProps, + getServerSideProps: renderOpts.getServerSideProps, + }) + + // 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, + (renderOpts as any).previewProps + ) + const isPreview = previewData !== false + + let WrappedComponent: any + let RootLayout: any + + const dataCache = new Map() + + for (let i = layouts.length - 1; i >= 0; i--) { + const dataCacheKey = i.toString() + const layout = layouts[i] + + if (layout.isRoot) { + RootLayout = layout.Component + continue + } + let fetcher: any + + // TODO: pass a shared cache from previous getStaticProps/ + // getServerSideProps calls? + if (layout.getServerSideProps) { + fetcher = () => + Promise.resolve( + layout.getServerSideProps!({ + req: req as any, + res: res, + query, + resolvedUrl: (renderOpts as any).resolvedUrl as string, + ...(pageIsDynamic + ? { params: (renderOpts as any).params as ParsedUrlQuery } + : undefined), + ...(isPreview + ? { preview: true, previewData: previewData } + : undefined), + locales: (renderOpts as any).locales, + locale: (renderOpts as any).locale, + defaultLocale: (renderOpts as any).defaultLocale, + }) + ) + } + // TODO: implement layout specific caching for getStaticProps + if (layout.getStaticProps) { + fetcher = () => + Promise.resolve( + layout.getStaticProps!({ + ...(pageIsDynamic + ? { params: query as ParsedUrlQuery } + : undefined), + ...(isPreview + ? { preview: true, previewData: previewData } + : undefined), + locales: (renderOpts as any).locales, + locale: (renderOpts as any).locale, + defaultLocale: (renderOpts as any).defaultLocale, + }) + ) + } + + if (fetcher) { + // Kick off data fetching before rendering, this ensures there is no waterfall for layouts as + // all data fetching required to render the page is kicked off simultaneously + preloadDataFetchingRecord(dataCache, dataCacheKey, fetcher) + } + + // eslint-disable-next-line no-loop-func + const lastComponent = WrappedComponent + WrappedComponent = () => { + let props: any + if (fetcher) { + // The data fetching was kicked off before rendering (see above) + // if the data was not resolved yet the layout rendering will be suspended + const record = preloadDataFetchingRecord( + dataCache, + dataCacheKey, + fetcher + ) + // Result of calling getStaticProps or getServerSideProps. If promise is not resolve yet it will suspend. + const recordValue = readRecordValue(record) + props = recordValue.props + } + + return React.createElement( + layout.Component, + props, + React.createElement(lastComponent || React.Fragment, {}, null) + ) + } + // TODO: loading state + // const AfterWrap = WrappedComponent + // WrappedComponent = () => { + // return ( + // Loading...}> + // + // + // ) + // } + } + + if (!RootLayout) { + // TODO: fallback to our own root layout? + throw new Error('invariant RootLayout not loaded') + } + + const headChildren = buildManifest.rootMainFiles.map((src) => ( +