diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index 45ce053f3ac6..3d0047efc9bd 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -39,11 +39,11 @@ export function createPagesMapping( { isDev, hasServerComponents, - runtime, + globalRuntime, }: { isDev: boolean hasServerComponents: boolean - runtime?: 'nodejs' | 'edge' + globalRuntime?: 'nodejs' | 'edge' } ): PagesMapping { const previousPages: PagesMapping = {} @@ -83,7 +83,7 @@ export function createPagesMapping( // we alias these in development and allow webpack to // allow falling back to the correct source file so // that HMR can work properly when a file is added/removed - const documentPage = `_document${runtime ? '-concurrent' : ''}` + const documentPage = `_document${globalRuntime ? '-concurrent' : ''}` if (isDev) { pages['/_app'] = `${PAGES_DIR_ALIAS}/_app` pages['/_error'] = `${PAGES_DIR_ALIAS}/_error` @@ -103,68 +103,108 @@ type Entrypoints = { edgeServer: webpack5.EntryObject } -export async function getPageRuntime(pageFilePath: string) { - let pageRuntime: string | undefined = undefined - const pageContent = await fs.promises.readFile(pageFilePath, { - encoding: 'utf8', - }) - // branch prunes for entry page without runtime option - if (pageContent.includes('runtime')) { - const { body } = await parse(pageContent, { - filename: pageFilePath, - isModule: true, +const cachedPageRuntimeConfig = new Map< + string, + [number, 'nodejs' | 'edge' | undefined] +>() + +// @TODO: We should limit the maximum concurrency of this function as there +// could be thousands of pages existing. +export async function getPageRuntime( + pageFilePath: string, + globalRuntimeFallback?: 'nodejs' | 'edge' +): Promise<'nodejs' | 'edge' | undefined> { + const cached = cachedPageRuntimeConfig.get(pageFilePath) + if (cached) { + return cached[1] + } + + let pageContent: string + try { + pageContent = await fs.promises.readFile(pageFilePath, { + encoding: 'utf8', }) - body.some((node: any) => { - const { type, declaration } = node - const valueNode = declaration?.declarations?.[0] - if (type === 'ExportDeclaration' && valueNode?.id?.value === 'config') { - const props = valueNode.init.properties - const runtimeKeyValue = props.find( - (prop: any) => prop.key.value === 'runtime' - ) - const runtime = runtimeKeyValue?.value?.value - pageRuntime = - runtime === 'edge' || runtime === 'nodejs' ? runtime : pageRuntime - return true + } catch (err) { + return undefined + } + + // When gSSP or gSP is used, this page requires an execution runtime. If the + // page config is not present, we fallback to the global runtime. Related + // discussion: + // https://github.com/vercel/next.js/discussions/34179 + let isRuntimeRequired: boolean = false + let pageRuntime: 'nodejs' | 'edge' | undefined = undefined + + // Since these configurations should always be static analyzable, we can + // skip these cases that "runtime" and "gSP", "gSSP" are not included in the + // source code. + if (/runtime|getStaticProps|getServerSideProps/.test(pageContent)) { + try { + const { body } = await parse(pageContent, { + filename: pageFilePath, + isModule: true, + }) + + for (const node of body) { + const { type, declaration } = node + if (type === 'ExportDeclaration') { + // `export const config` + const valueNode = declaration?.declarations?.[0] + if (valueNode?.id?.value === 'config') { + const props = valueNode.init.properties + const runtimeKeyValue = props.find( + (prop: any) => prop.key.value === 'runtime' + ) + const runtime = runtimeKeyValue?.value?.value + pageRuntime = + runtime === 'edge' || runtime === 'nodejs' ? runtime : pageRuntime + } else if (declaration?.type === 'FunctionDeclaration') { + // `export function getStaticProps` and + // `export function getServerSideProps` + if ( + declaration.identifier?.value === 'getStaticProps' || + declaration.identifier?.value === 'getServerSideProps' + ) { + isRuntimeRequired = true + } + } + } } - return false - }) + } catch (err) {} + } + + if (!pageRuntime) { + if (isRuntimeRequired) { + pageRuntime = globalRuntimeFallback + } else { + // @TODO: Remove this branch to fully implement the RFC. + pageRuntime = globalRuntimeFallback + } } + cachedPageRuntimeConfig.set(pageFilePath, [Date.now(), pageRuntime]) return pageRuntime } -export async function createPagesRuntimeMapping( - pagesDir: string, - pages: PagesMapping +export function invalidatePageRuntimeCache( + pageFilePath: string, + safeTime: number ) { - const pagesRuntime: Record = {} - - const promises = Object.keys(pages).map(async (page) => { - const absolutePagePath = pages[page] - const isReserved = isReservedPage(page) - if (!isReserved) { - const pageFilePath = join( - pagesDir, - absolutePagePath.replace(PAGES_DIR_ALIAS, '') - ) - const runtime = await getPageRuntime(pageFilePath) - if (runtime) { - pagesRuntime[page] = runtime - } - } - }) - return await Promise.all(promises) + const cached = cachedPageRuntimeConfig.get(pageFilePath) + if (cached && cached[0] < safeTime) { + cachedPageRuntimeConfig.delete(pageFilePath) + } } -export function createEntrypoints( +export async function createEntrypoints( pages: PagesMapping, target: 'server' | 'serverless' | 'experimental-serverless-trace', buildId: string, previewMode: __ApiPreviewProps, config: NextConfigComplete, - loadedEnvFiles: LoadedEnvFiles -): Entrypoints { + loadedEnvFiles: LoadedEnvFiles, + pagesDir: string +): Promise { const client: webpack5.EntryObject = {} const server: webpack5.EntryObject = {} const edgeServer: webpack5.EntryObject = {} @@ -201,103 +241,109 @@ export function createEntrypoints( } const globalRuntime = config.experimental.runtime - const edgeRuntime = globalRuntime === 'edge' - Object.keys(pages).forEach((page) => { - const absolutePagePath = pages[page] - const bundleFile = normalizePagePath(page) - const isApiRoute = page.match(API_ROUTE) - - const clientBundlePath = posix.join('pages', bundleFile) - const serverBundlePath = posix.join('pages', bundleFile) - - const isLikeServerless = isTargetLikeServerless(target) - const isReserved = isReservedPage(page) - const isCustomError = isCustomErrorPage(page) - const isFlight = isFlightPage(config, absolutePagePath) + await Promise.all( + Object.keys(pages).map(async (page) => { + const absolutePagePath = pages[page] + const bundleFile = normalizePagePath(page) + const isApiRoute = page.match(API_ROUTE) + + const clientBundlePath = posix.join('pages', bundleFile) + const serverBundlePath = posix.join('pages', bundleFile) + + const isLikeServerless = isTargetLikeServerless(target) + const isReserved = isReservedPage(page) + const isCustomError = isCustomErrorPage(page) + const isFlight = isFlightPage(config, absolutePagePath) + const isEdgeRuntime = + (await getPageRuntime( + join(pagesDir, absolutePagePath.slice(PAGES_DIR_ALIAS.length + 1)), + globalRuntime + )) === 'edge' + + if (page.match(MIDDLEWARE_ROUTE)) { + const loaderOpts: MiddlewareLoaderOptions = { + absolutePagePath: pages[page], + page, + } - if (page.match(MIDDLEWARE_ROUTE)) { - const loaderOpts: MiddlewareLoaderOptions = { - absolutePagePath: pages[page], - page, + client[clientBundlePath] = `next-middleware-loader?${stringify( + loaderOpts + )}!` + return } - client[clientBundlePath] = `next-middleware-loader?${stringify( - loaderOpts - )}!` - return - } + if (isEdgeRuntime && !isReserved && !isCustomError && !isApiRoute) { + ssrEntries.set(clientBundlePath, { requireFlightManifest: isFlight }) + edgeServer[serverBundlePath] = finalizeEntrypoint({ + name: '[name].js', + value: `next-middleware-ssr-loader?${stringify({ + dev: false, + page, + stringifiedConfig: JSON.stringify(config), + absolute500Path: pages['/500'] || '', + absolutePagePath, + isServerComponent: isFlight, + ...defaultServerlessOptions, + } as any)}!`, + isServer: false, + isEdgeServer: true, + }) + } - if (edgeRuntime && !isReserved && !isCustomError && !isApiRoute) { - ssrEntries.set(clientBundlePath, { requireFlightManifest: isFlight }) - edgeServer[serverBundlePath] = finalizeEntrypoint({ - name: '[name].js', - value: `next-middleware-ssr-loader?${stringify({ - dev: false, + if (isApiRoute && isLikeServerless) { + const serverlessLoaderOptions: ServerlessLoaderQuery = { page, - stringifiedConfig: JSON.stringify(config), - absolute500Path: pages['/500'] || '', absolutePagePath, - isServerComponent: isFlight, ...defaultServerlessOptions, - } as any)}!`, - isServer: false, - isEdgeServer: true, - }) - } - - if (isApiRoute && isLikeServerless) { - const serverlessLoaderOptions: ServerlessLoaderQuery = { - page, - absolutePagePath, - ...defaultServerlessOptions, - } - server[serverBundlePath] = `next-serverless-loader?${stringify( - serverlessLoaderOptions - )}!` - } else if (isApiRoute || target === 'server') { - if (!edgeRuntime || isReserved || isCustomError) { - server[serverBundlePath] = [absolutePagePath] - } - } else if ( - isLikeServerless && - page !== '/_app' && - page !== '/_document' && - !edgeRuntime - ) { - const serverlessLoaderOptions: ServerlessLoaderQuery = { - page, - absolutePagePath, - ...defaultServerlessOptions, + } + server[serverBundlePath] = `next-serverless-loader?${stringify( + serverlessLoaderOptions + )}!` + } else if (isApiRoute || target === 'server') { + if (!isEdgeRuntime || isReserved || isCustomError) { + server[serverBundlePath] = [absolutePagePath] + } + } else if ( + isLikeServerless && + page !== '/_app' && + page !== '/_document' && + !isEdgeRuntime + ) { + const serverlessLoaderOptions: ServerlessLoaderQuery = { + page, + absolutePagePath, + ...defaultServerlessOptions, + } + server[serverBundlePath] = `next-serverless-loader?${stringify( + serverlessLoaderOptions + )}!` } - server[serverBundlePath] = `next-serverless-loader?${stringify( - serverlessLoaderOptions - )}!` - } - if (page === '/_document') { - return - } + if (page === '/_document') { + return + } - if (!isApiRoute) { - const pageLoaderOpts: ClientPagesLoaderOptions = { - page, - absolutePagePath, + if (!isApiRoute) { + const pageLoaderOpts: ClientPagesLoaderOptions = { + page, + absolutePagePath, + } + const pageLoader = `next-client-pages-loader?${stringify( + pageLoaderOpts + )}!` + + // Make sure next/router is a dependency of _app or else chunk splitting + // might cause the router to not be able to load causing hydration + // to fail + + client[clientBundlePath] = + page === '/_app' + ? [pageLoader, require.resolve('../client/router')] + : pageLoader } - const pageLoader = `next-client-pages-loader?${stringify( - pageLoaderOpts - )}!` - - // Make sure next/router is a dependency of _app or else chunk splitting - // might cause the router to not be able to load causing hydration - // to fail - - client[clientBundlePath] = - page === '/_app' - ? [pageLoader, require.resolve('../client/router')] - : pageLoader - } - }) + }) + ) return { client, diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 27ed5cbe45e0..25cf163cb6e4 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -104,6 +104,7 @@ import { TelemetryPlugin } from './webpack/plugins/telemetry-plugin' import { MiddlewareManifest } from './webpack/plugins/middleware-plugin' import type { webpack5 as webpack } from 'next/dist/compiled/webpack/webpack' import { recursiveCopy } from '../lib/recursive-copy' +import { shouldUseReactRoot } from '../server/config' export type SsgRoute = { initialRevalidateSeconds: number | false @@ -154,10 +155,11 @@ export default async function build( // Currently, when the runtime option is set (either `nodejs` or `edge`), // we enable concurrent features (Fizz-related rendering architecture). const runtime = config.experimental.runtime + const hasReactRoot = shouldUseReactRoot() const hasConcurrentFeatures = !!runtime const hasServerComponents = - hasConcurrentFeatures && !!config.experimental.serverComponents + hasReactRoot && !!config.experimental.serverComponents const { target } = config const buildId: string = await nextBuildSpan @@ -296,20 +298,21 @@ export default async function build( createPagesMapping(pagePaths, config.pageExtensions, { isDev: false, hasServerComponents, - runtime, + globalRuntime: runtime, }) ) - const entrypoints = nextBuildSpan + const entrypoints = await nextBuildSpan .traceChild('create-entrypoints') - .traceFn(() => + .traceAsyncFn(() => createEntrypoints( mappedPages, target, buildId, previewProps, config, - loadedEnvFiles + loadedEnvFiles, + pagesDir ) ) const pageKeys = Object.keys(mappedPages) @@ -576,13 +579,15 @@ export default async function build( BUILD_MANIFEST, PRERENDER_MANIFEST, path.join(SERVER_DIRECTORY, MIDDLEWARE_MANIFEST), - hasServerComponents - ? path.join( - SERVER_DIRECTORY, - MIDDLEWARE_FLIGHT_MANIFEST + - (runtime === 'edge' ? '.js' : '.json') - ) - : null, + ...(hasServerComponents + ? [ + path.join(SERVER_DIRECTORY, MIDDLEWARE_FLIGHT_MANIFEST + '.js'), + path.join( + SERVER_DIRECTORY, + MIDDLEWARE_FLIGHT_MANIFEST + '.json' + ), + ] + : []), REACT_LOADABLE_MANIFEST, config.optimizeFonts ? path.join( @@ -629,7 +634,7 @@ export default async function build( rewrites, runWebpackSpan, }), - runtime === 'edge' + hasReactRoot ? getBaseWebpackConfig(dir, { buildId, reactProductionProfiling, diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 081ca1edada7..618a826307de 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -1113,12 +1113,7 @@ export function isFlightPage( nextConfig: NextConfigComplete, filePath: string ): boolean { - if ( - !( - nextConfig.experimental.serverComponents && - nextConfig.experimental.runtime - ) - ) { + if (!nextConfig.experimental.serverComponents) { return false } diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index fba561465fd2..c0f242f5f473 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1173,44 +1173,41 @@ export default async function getBaseWebpackConfig( } as any, ] : []), - // Loaders for the client compilation when RSC is enabled. - ...(hasServerComponents && !isServer - ? [ - { - ...codeCondition, - test: serverComponentsRegex, - use: { - loader: 'next-flight-server-loader', - options: { - client: 1, - pageExtensions: rawPageExtensions, + ...(hasServerComponents + ? isServer + ? [ + // RSC server compilation loaders + { + ...codeCondition, + use: { + loader: 'next-flight-server-loader', + options: { + pageExtensions: rawPageExtensions, + }, }, }, - }, - ] - : []), - // Loaders for the server compilation when RSC is enabled. - ...(hasServerComponents && - ((runtime === 'edge' && isEdgeRuntime) || - (runtime === 'nodejs' && isServer)) - ? [ - { - ...codeCondition, - use: { - loader: 'next-flight-server-loader', - options: { - pageExtensions: rawPageExtensions, + { + test: codeCondition.test, + resourceQuery: /__sc_client__/, + use: { + loader: 'next-flight-client-loader', }, }, - }, - { - test: codeCondition.test, - resourceQuery: /__sc_client__/, - use: { - loader: 'next-flight-client-loader', + ] + : [ + // RSC client compilation loaders + { + ...codeCondition, + test: serverComponentsRegex, + use: { + loader: 'next-flight-server-loader', + options: { + client: 1, + pageExtensions: rawPageExtensions, + }, + }, }, - }, - ] + ] : []), { test: /\.(js|cjs|mjs)$/, @@ -1493,7 +1490,7 @@ export default async function getBaseWebpackConfig( }), hasServerComponents && !isServer && - new FlightManifestPlugin({ dev, clientComponentsRegex, runtime }), + new FlightManifestPlugin({ dev, clientComponentsRegex }), !dev && !isServer && new TelemetryPlugin( diff --git a/packages/next/build/webpack/plugins/flight-manifest-plugin.ts b/packages/next/build/webpack/plugins/flight-manifest-plugin.ts index dbc3ecc70f5c..09b526f75266 100644 --- a/packages/next/build/webpack/plugins/flight-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/flight-manifest-plugin.ts @@ -18,14 +18,12 @@ import { MIDDLEWARE_FLIGHT_MANIFEST } from '../../../shared/lib/constants' type Options = { dev: boolean clientComponentsRegex: RegExp - runtime?: 'nodejs' | 'edge' } const PLUGIN_NAME = 'FlightManifestPlugin' export class FlightManifestPlugin { dev: boolean = false - runtime?: 'nodejs' | 'edge' clientComponentsRegex: RegExp constructor(options: Options) { @@ -33,7 +31,6 @@ export class FlightManifestPlugin { this.dev = options.dev } this.clientComponentsRegex = options.clientComponentsRegex - this.runtime = options.runtime } apply(compiler: any) { @@ -65,7 +62,7 @@ export class FlightManifestPlugin { } createAsset(assets: any, compilation: any) { - const json: any = {} + const manifest: any = {} const { clientComponentsRegex } = this compilation.chunkGroups.forEach((chunkGroup: any) => { function recordModule(id: string, _chunk: any, mod: any) { @@ -79,7 +76,7 @@ export class FlightManifestPlugin { return } - const moduleExports: any = json[resource] || {} + const moduleExports: any = manifest[resource] || {} const exportsInfo = compilation.moduleGraph.getExportsInfo(mod) const moduleExportedKeys = ['', '*'].concat( @@ -102,7 +99,7 @@ export class FlightManifestPlugin { } } }) - json[resource] = moduleExports + manifest[resource] = moduleExports } chunkGroup.chunks.forEach((chunk: any) => { @@ -126,13 +123,12 @@ export class FlightManifestPlugin { }) }) - const output = - (this.runtime === 'edge' ? 'self.__RSC_MANIFEST=' : '') + - JSON.stringify(json) - assets[ - `server/${MIDDLEWARE_FLIGHT_MANIFEST}${ - this.runtime === 'edge' ? '.js' : '.json' - }` - ] = new sources.RawSource(output) + // With switchable runtime, we need to emit the manifest files for both + // runtimes. + const file = `server/${MIDDLEWARE_FLIGHT_MANIFEST}` + const json = JSON.stringify(manifest) + + assets[file + '.js'] = new sources.RawSource('self.__RSC_MANIFEST=' + json) + assets[file + '.json'] = new sources.RawSource(json) } } diff --git a/packages/next/server/dev/hot-reloader.ts b/packages/next/server/dev/hot-reloader.ts index dc539aee872b..d5e56dbf4f7b 100644 --- a/packages/next/server/dev/hot-reloader.ts +++ b/packages/next/server/dev/hot-reloader.ts @@ -42,6 +42,8 @@ import { Span, trace } from '../../trace' import { getProperError } from '../../lib/is-error' import ws from 'next/dist/compiled/ws' import { promises as fs } from 'fs' +import { getPageRuntime } from '../../build/entries' +import { shouldUseReactRoot } from '../config' const wsServer = new ws.Server({ noServer: true }) @@ -195,9 +197,7 @@ export default class HotReloader { this.config = config this.runtime = config.experimental.runtime - this.hasServerComponents = !!( - this.runtime && config.experimental.serverComponents - ) + this.hasServerComponents = !!config.experimental.serverComponents this.previewProps = previewProps this.rewrites = rewrites this.hotReloaderSpan = trace('hot-reloader', undefined, { @@ -321,26 +321,27 @@ export default class HotReloader { this.config.pageExtensions, { isDev: true, - runtime: this.config.experimental.runtime, + globalRuntime: this.runtime, hasServerComponents: this.hasServerComponents, } ) ) - const entrypoints = webpackConfigSpan + const entrypoints = await webpackConfigSpan .traceChild('create-entrypoints') - .traceFn(() => + .traceAsyncFn(() => createEntrypoints( this.pagesMapping, 'server', this.buildId, this.previewProps, this.config, - [] + [], + this.pagesDir ) ) - const hasEdgeRuntimePages = this.runtime === 'edge' + const hasReactRoot = shouldUseReactRoot() return webpackConfigSpan .traceChild('generate-webpack-config') @@ -367,9 +368,8 @@ export default class HotReloader { entrypoints: entrypoints.server, runWebpackSpan: this.hotReloaderSpan, }), - // For the edge runtime, we need an extra compiler to generate the - // web-targeted server bundle for now. - hasEdgeRuntimePages + // The edge runtime is only supported with React root. + hasReactRoot ? getBaseWebpackConfig(this.dir, { dev: true, isServer: true, @@ -404,16 +404,19 @@ export default class HotReloader { fallback: [], }, isDevFallback: true, - entrypoints: createEntrypoints( - { - '/_app': 'next/dist/pages/_app', - '/_error': 'next/dist/pages/_error', - }, - 'server', - this.buildId, - this.previewProps, - this.config, - [] + entrypoints: ( + await createEntrypoints( + { + '/_app': 'next/dist/pages/_app', + '/_error': 'next/dist/pages/_error', + }, + 'server', + this.buildId, + this.previewProps, + this.config, + [], + this.pagesDir + ) ).client, }) const fallbackCompiler = webpack(fallbackConfig) @@ -499,11 +502,19 @@ export default class HotReloader { const isServerComponent = this.hasServerComponents && isFlightPage(this.config, absolutePagePath) - const isEdgeSSRPage = this.runtime === 'edge' && !isApiRoute + + const pageRuntimeConfig = await getPageRuntime( + absolutePagePath, + this.runtime + ) + const isEdgeSSRPage = pageRuntimeConfig === 'edge' && !isApiRoute if (isNodeServerCompilation && isEdgeSSRPage && !isCustomError) { return } + if (isEdgeServerCompilation && !isEdgeSSRPage) { + return + } entries[pageKey].status = BUILDING const pageLoaderOpts: ClientPagesLoaderOptions = { diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index cb23b707bedb..66ad197086af 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -60,6 +60,7 @@ import isError, { getProperError } from '../../lib/is-error' import { getMiddlewareRegex } from '../../shared/lib/router/utils/get-middleware-regex' import { isCustomErrorPage, isReservedPage } from '../../build/utils' import { NodeNextResponse, NodeNextRequest } from '../base-http/node' +import { getPageRuntime, invalidatePageRuntimeCache } from '../../build/entries' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: React.FunctionComponent @@ -254,17 +255,13 @@ export default class DevServer extends Server { let wp = (this.webpackWatcher = new Watchpack()) wp.watch([], [pagesDir!], 0) - wp.on('aggregated', () => { + wp.on('aggregated', async () => { const routedMiddleware = [] const routedPages = [] const knownFiles = wp.getTimeInfoEntries() const ssrMiddleware = new Set() - const runtime = this.nextConfig.experimental.runtime - const isEdgeRuntime = runtime === 'edge' - const hasServerComponents = - runtime && this.nextConfig.experimental.serverComponents - for (const [fileName, { accuracy }] of knownFiles) { + for (const [fileName, { accuracy, safeTime }] of knownFiles) { if (accuracy === undefined || !regexPageExtension.test(fileName)) { continue } @@ -283,10 +280,14 @@ export default class DevServer extends Server { pageName = pageName.replace(regexPageExtension, '') pageName = pageName.replace(/\/index$/, '') || '/' - if (hasServerComponents && pageName.endsWith('.server')) { - routedMiddleware.push(pageName) - ssrMiddleware.add(pageName) - } else if ( + invalidatePageRuntimeCache(fileName, safeTime) + const pageRuntimeConfig = await getPageRuntime( + fileName, + this.nextConfig.experimental.runtime + ) + const isEdgeRuntime = pageRuntimeConfig === 'edge' + + if ( isEdgeRuntime && !(isReservedPage(pageName) || isCustomErrorPage(pageName)) ) { diff --git a/packages/next/server/dev/on-demand-entry-handler.ts b/packages/next/server/dev/on-demand-entry-handler.ts index a4b8a2ca5c0c..edb06c39c7e6 100644 --- a/packages/next/server/dev/on-demand-entry-handler.ts +++ b/packages/next/server/dev/on-demand-entry-handler.ts @@ -10,6 +10,7 @@ import { reportTrigger } from '../../build/output' import type ws from 'ws' import { NextConfigComplete } from '../config-shared' import { isCustomErrorPage } from '../../build/utils' +import { getPageRuntime } from '../../build/entries' export const ADDED = Symbol('added') export const BUILDING = Symbol('building') @@ -204,7 +205,12 @@ export default function onDemandEntryHandler( const isMiddleware = normalizedPage.match(MIDDLEWARE_ROUTE) const isApiRoute = normalizedPage.match(API_ROUTE) && !isMiddleware - const isEdgeServer = nextConfig.experimental.runtime === 'edge' + const pageRuntimeConfig = await getPageRuntime( + absolutePagePath, + nextConfig.experimental.runtime + ) + const isEdgeServer = pageRuntimeConfig === 'edge' + const isCustomError = isCustomErrorPage(page) let entriesChanged = false diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index efe6800d06fc..74c40ae65bbb 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -690,7 +690,7 @@ export default class NextNodeServer extends BaseServer { } protected getServerComponentManifest() { - if (this.nextConfig.experimental.runtime !== 'nodejs') return undefined + if (!this.nextConfig.experimental.runtime) return undefined return require(join( this.distDir, 'server', diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index cd7d6186c98f..d2e68b23669f 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -359,22 +359,16 @@ function createServerComponentRenderer( cachePrefix, transformStream, serverComponentManifest, - runtime, }: { cachePrefix: string transformStream: TransformStream serverComponentManifest: NonNullable - runtime: 'nodejs' | 'edge' } ) { - if (runtime === 'nodejs') { - // For the nodejs runtime, we need to expose the `__webpack_require__` API - // globally for react-server-dom-webpack. - // This is a hack until we find a better way. - // @ts-ignore - globalThis.__webpack_require__ = - ComponentMod.__next_rsc__.__webpack_require__ - } + // We need to expose the `__webpack_require__` API globally for + // react-server-dom-webpack. This is a hack until we find a better way. + // @ts-ignore + globalThis.__webpack_require__ = ComponentMod.__next_rsc__.__webpack_require__ const writable = transformStream.writable const ServerComponentWrapper = (props: any) => { @@ -490,7 +484,6 @@ export async function renderToHTML( cachePrefix: pathname + (search ? `?${search}` : ''), transformStream: serverComponentsInlinedTransformStream, serverComponentManifest, - runtime, } ) } diff --git a/test/integration/react-18/app/pages/index.js b/test/integration/react-18/app/pages/index.js index 9ce5416ab738..a5079245fd67 100644 --- a/test/integration/react-18/app/pages/index.js +++ b/test/integration/react-18/app/pages/index.js @@ -20,6 +20,6 @@ export default function Index() { ) } -export const config = { - runtime: 'edge', -} +// export const config = { +// runtime: 'edge', +// } diff --git a/test/integration/react-streaming-and-server-components/app/pages/index.server.js b/test/integration/react-streaming-and-server-components/app/pages/index.server.js index cec07820c64b..929f47615a2b 100644 --- a/test/integration/react-streaming-and-server-components/app/pages/index.server.js +++ b/test/integration/react-streaming-and-server-components/app/pages/index.server.js @@ -31,9 +31,3 @@ export function getServerSideProps({ req }) { }, } } - -export const config = { - amp: false, - unstable_runtimeJS: false, - runtime: 'nodejs', -} diff --git a/test/integration/react-streaming-and-server-components/app/pages/partial-hydration.server.js b/test/integration/react-streaming-and-server-components/app/pages/partial-hydration.server.js index afd39e8d601d..c3740b8719fe 100644 --- a/test/integration/react-streaming-and-server-components/app/pages/partial-hydration.server.js +++ b/test/integration/react-streaming-and-server-components/app/pages/partial-hydration.server.js @@ -26,12 +26,9 @@ function Data() { export default function () { return ( <> - Current Runtime:{' '} - {typeof window === 'undefined' - ? typeof ReadableStream === 'undefined' - ? 'node-server' - : 'edge-server' - : 'browser'} + {process.version + ? `Runtime: Node.js ${process.version}` + : 'Runtime: Edge/Browser'}
diff --git a/test/integration/react-streaming-and-server-components/app/pages/runtime-rsc.server.js b/test/integration/react-streaming-and-server-components/app/pages/runtime-rsc.server.js new file mode 100644 index 000000000000..05a3bd70a5af --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/pages/runtime-rsc.server.js @@ -0,0 +1,9 @@ +export default function () { + return process.version + ? `Runtime: Node.js ${process.version}` + : 'Runtime: Edge/Browser' +} + +export const config = { + runtime: 'nodejs', +} diff --git a/test/integration/react-streaming-and-server-components/app/pages/runtime.js b/test/integration/react-streaming-and-server-components/app/pages/runtime.js new file mode 100644 index 000000000000..05a3bd70a5af --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/pages/runtime.js @@ -0,0 +1,9 @@ +export default function () { + return process.version + ? `Runtime: Node.js ${process.version}` + : 'Runtime: Edge/Browser' +} + +export const config = { + runtime: 'nodejs', +} diff --git a/test/integration/react-streaming-and-server-components/test/functions.js b/test/integration/react-streaming-and-server-components/test/functions.js index 6283ff46b5a1..965190486c2f 100644 --- a/test/integration/react-streaming-and-server-components/test/functions.js +++ b/test/integration/react-streaming-and-server-components/test/functions.js @@ -31,12 +31,20 @@ export default function (context) { const { pages } = content const pageNames = Object.keys(pages) - const paths = ['/', '/next-api/link', '/routes/[dynamic]'] + const paths = [ + '/', + '/next-api/link', + '/routes/[dynamic]', + // @TODO: Implement per-page runtime in functions-manifest. + // '/runtime' + ] + paths.forEach((path) => { const { runtime, files } = pages[path] expect(pageNames).toContain(path) - // Runtime of page `/` is undefined since it's configured as nodejs. - expect(runtime).toBe(path === '/' ? undefined : 'web') + + // Runtime of page `/runtime` is configured as `nodejs`. + expect(runtime).toBe(path === '/runtime' ? 'nodejs' : 'web') expect(files.every((f) => f.startsWith('server/'))).toBe(true) }) diff --git a/test/integration/react-streaming-and-server-components/test/index.test.js b/test/integration/react-streaming-and-server-components/test/index.test.js index 6e3ea87ee229..41d882417d0a 100644 --- a/test/integration/react-streaming-and-server-components/test/index.test.js +++ b/test/integration/react-streaming-and-server-components/test/index.test.js @@ -25,6 +25,7 @@ import rsc from './rsc' import streaming from './streaming' import basic from './basic' import functions from './functions' +import runtime from './runtime' const documentWithGip = ` import { Html, Head, Main, NextScript } from 'next/document' @@ -105,8 +106,9 @@ describe('Edge runtime - prod', () => { const distServerDir = join(distDir, 'server') const files = [ 'middleware-build-manifest.js', - 'middleware-flight-manifest.js', 'middleware-ssr-runtime.js', + 'middleware-flight-manifest.js', + 'middleware-flight-manifest.json', 'middleware-manifest.json', ] @@ -154,6 +156,7 @@ describe('Edge runtime - prod', () => { basic(context, options) streaming(context, options) rsc(context, options) + runtime(context, options) }) describe('Edge runtime - dev', () => { @@ -188,6 +191,7 @@ describe('Edge runtime - dev', () => { basic(context, options) streaming(context, options) rsc(context, options) + runtime(context, options) }) const nodejsRuntimeBasicSuite = { diff --git a/test/integration/react-streaming-and-server-components/test/runtime.js b/test/integration/react-streaming-and-server-components/test/runtime.js new file mode 100644 index 000000000000..9258833bc4c4 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/test/runtime.js @@ -0,0 +1,12 @@ +import { renderViaHTTP } from 'next-test-utils' + +export default async function runtime(context, { runtime }) { + if (runtime === 'edge') { + it('should support per-page runtime configuration', async () => { + const html1 = await renderViaHTTP(context.appPort, '/runtime') + expect(html1).toContain('Runtime: Node.js') + const html2 = await renderViaHTTP(context.appPort, '/runtime-rsc') + expect(html2).toContain('Runtime: Node.js') + }) + } +} diff --git a/test/unit/fixtures/page-runtime/fallback.js b/test/unit/fixtures/page-runtime/fallback.js new file mode 100644 index 000000000000..ecdb7932372e --- /dev/null +++ b/test/unit/fixtures/page-runtime/fallback.js @@ -0,0 +1,9 @@ +export default function Fallback() { + return null +} + +export async function getStaticProps() { + return { + props: {}, + } +} diff --git a/test/unit/parse-page-runtime.test.ts b/test/unit/parse-page-runtime.test.ts index 701363b27a3f..d567e5e70bf3 100644 --- a/test/unit/parse-page-runtime.test.ts +++ b/test/unit/parse-page-runtime.test.ts @@ -24,4 +24,12 @@ describe('parse page runtime config', () => { ) expect(runtime).toBe(undefined) }) + + it('should fallback to the global runtime configuration if a runtime is needed', async () => { + const runtime = await getPageRuntime( + join(fixtureDir, 'page-runtime/fallback.js'), + 'edge' + ) + expect(runtime).toBe('edge') + }) })