diff --git a/packages/next/src/bin/next.ts b/packages/next/src/bin/next.ts index 91b15669d179a..b38e42f1e8617 100755 --- a/packages/next/src/bin/next.ts +++ b/packages/next/src/bin/next.ts @@ -1,18 +1,18 @@ #!/usr/bin/env node +import '../server/require-hook' import * as log from '../build/output/log' import arg from 'next/dist/compiled/arg/index.js' import { NON_STANDARD_NODE_ENV } from '../lib/constants' import { commands } from '../lib/commands' -;['react', 'react-dom'].forEach((dependency) => { - try { - // When 'npm link' is used it checks the clone location. Not the project. - require.resolve(dependency) - } catch (err) { - console.warn( - `The module '${dependency}' was not found. Next.js requires that you include it in 'dependencies' of your 'package.json'. To add it, run 'npm install ${dependency}'` - ) - } -}) +import { commandArgs } from '../lib/command-args' +import loadConfig from '../server/config' +import { + PHASE_PRODUCTION_SERVER, + PHASE_DEVELOPMENT_SERVER, +} from '../shared/lib/constants' +import { getProjectDir } from '../lib/get-project-dir' +import { getValidatedArgs } from '../lib/get-validated-args' +import { findPagesDir } from '../lib/find-pages-dir' const defaultCommand = 'dev' const args = arg( @@ -122,13 +122,69 @@ if (!process.env.NEXT_MANUAL_SIG_HANDLE && command !== 'dev') { process.on('SIGTERM', () => process.exit(0)) process.on('SIGINT', () => process.exit(0)) } +async function main() { + const currentArgsSpec = commandArgs[command]() + const validatedArgs = getValidatedArgs(currentArgsSpec, forwardedArgs) + + if ( + (command === 'start' || command === 'dev') && + !process.env.NEXT_PRIVATE_WORKER + ) { + const dir = getProjectDir( + process.env.NEXT_PRIVATE_DEV_DIR || validatedArgs._[0] + ) + process.env.NEXT_PRIVATE_DIR = dir + const origEnv = Object.assign({}, process.env) + + // TODO: set config to env variable to be re-used so we don't reload + // un-necessarily + const config = await loadConfig( + command === 'dev' ? PHASE_DEVELOPMENT_SERVER : PHASE_PRODUCTION_SERVER, + dir + ) + let dirsResult: ReturnType | undefined = undefined + + try { + dirsResult = findPagesDir(dir) + } catch (_) { + // handle this error further down + } -commands[command]() - .then((exec) => exec(forwardedArgs)) - .then(() => { - if (command === 'build' || command === 'experimental-compile') { - // ensure process exits after build completes so open handles/connections - // don't cause process to hang - process.exit(0) + if (dirsResult?.appDir || process.env.NODE_ENV === 'development') { + process.env = origEnv } - }) + + if (dirsResult?.appDir) { + // we need to reset env if we are going to create + // the worker process with the esm loader so that the + // initial env state is correct + process.env.__NEXT_PRIVATE_PREBUNDLED_REACT = config.experimental + .serverActions + ? 'experimental' + : 'next' + } + } + + for (const dependency of ['react', 'react-dom']) { + try { + // When 'npm link' is used it checks the clone location. Not the project. + require.resolve(dependency) + } catch (err) { + console.warn( + `The module '${dependency}' was not found. Next.js requires that you include it in 'dependencies' of your 'package.json'. To add it, run 'npm install ${dependency}'` + ) + } + } + + await commands[command]() + .then((exec) => exec(validatedArgs)) + .then(() => { + if (command === 'build' || command === 'experimental-compile') { + // ensure process exits after build completes so open handles/connections + // don't cause process to hang + process.exit(0) + } + }) +} + +main() diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index e7d525373c3f5..8d92c8ae5587d 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -149,9 +149,10 @@ import { baseOverrides, defaultOverrides, experimentalOverrides, -} from '../server/require-hook' -import { initialize } from '../server/lib/incremental-cache-server' +} from '../server/import-overrides' +import { initialize as initializeIncrementalCache } from '../server/lib/incremental-cache-server' import { nodeFs } from '../server/lib/node-fs-methods' +import { getEsmLoaderPath } from '../server/lib/get-esm-loader-path' export type SsgRoute = { initialRevalidateSeconds: number | false @@ -1207,9 +1208,8 @@ export default async function build( : config.experimental.cpus || 4 function createStaticWorker( - type: 'app' | 'pages', - ipcPort: number, - ipcValidationKey: string + incrementalCacheIpcPort: number, + incrementalCacheIpcValidationKey: string ) { let infoPrinted = false @@ -1246,16 +1246,21 @@ export default async function build( }, numWorkers, forkOptions: { + execArgv: [ + '--experimental-loader', + getEsmLoaderPath(), + '--no-warnings', + ], env: { ...process.env, - __NEXT_INCREMENTAL_CACHE_IPC_PORT: ipcPort + '', - __NEXT_INCREMENTAL_CACHE_IPC_KEY: ipcValidationKey, - __NEXT_PRIVATE_PREBUNDLED_REACT: - type === 'app' - ? config.experimental.serverActions - ? 'experimental' - : 'next' - : undefined, + __NEXT_INCREMENTAL_CACHE_IPC_PORT: incrementalCacheIpcPort + '', + __NEXT_INCREMENTAL_CACHE_IPC_KEY: + incrementalCacheIpcValidationKey, + __NEXT_PRIVATE_PREBUNDLED_REACT: hasAppDir + ? config.experimental.serverActions + ? 'experimental' + : 'next' + : '', }, }, enableWorkerThreads: config.experimental.workerThreads, @@ -1290,7 +1295,10 @@ export default async function build( CacheHandler = CacheHandler.default || CacheHandler } - const { ipcPort, ipcValidationKey } = await initialize({ + const { + ipcPort: incrementalCacheIpcPort, + ipcValidationKey: incrementalCacheIpcValidationKey, + } = await initializeIncrementalCache({ fs: nodeFs, dev: false, appDir: isAppDirEnabled, @@ -1315,12 +1323,14 @@ export default async function build( }) const pagesStaticWorkers = createStaticWorker( - 'pages', - ipcPort, - ipcValidationKey + incrementalCacheIpcPort, + incrementalCacheIpcValidationKey ) const appStaticWorkers = isAppDirEnabled - ? createStaticWorker('app', ipcPort, ipcValidationKey) + ? createStaticWorker( + incrementalCacheIpcPort, + incrementalCacheIpcValidationKey + ) : undefined const analysisBegin = process.hrtime() @@ -2124,9 +2134,14 @@ export default async function build( const vanillaServerEntries = [ ...sharedEntriesSet, - isStandalone - ? require.resolve('next/dist/server/lib/start-server') - : null, + ...(isStandalone + ? [ + require.resolve('next/dist/server/lib/start-server'), + require.resolve('next/dist/server/next'), + require.resolve('next/dist/esm/server/esm-loader.mjs'), + require.resolve('next/dist/server/import-overrides'), + ] + : []), require.resolve('next/dist/server/next-server'), ].filter(Boolean) as string[] @@ -2402,7 +2417,8 @@ export default async function build( outputFileTracingRoot, requiredServerFiles.config, middlewareManifest, - hasInstrumentationHook + hasInstrumentationHook, + hasAppDir ) }) } @@ -3315,11 +3331,13 @@ export default async function build( require('../export').default const pagesWorker = createStaticWorker( - 'pages', - ipcPort, - ipcValidationKey + incrementalCacheIpcPort, + incrementalCacheIpcValidationKey + ) + const appWorker = createStaticWorker( + incrementalCacheIpcPort, + incrementalCacheIpcValidationKey ) - const appWorker = createStaticWorker('app', ipcPort, ipcValidationKey) const options: ExportOptions = { isInvokedFromCli: false, diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 70f8ac8b27423..6f47380af82e9 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -1830,7 +1830,8 @@ export async function copyTracedFiles( tracingRoot: string, serverConfig: { [key: string]: any }, middlewareManifest: MiddlewareManifest, - hasInstrumentationHook: boolean + hasInstrumentationHook: boolean, + hasAppDir: boolean ) { const outputPath = path.join(distDir, 'standalone') let moduleType = false @@ -1963,12 +1964,11 @@ export async function copyTracedFiles( moduleType ? `import path from 'path' import { fileURLToPath } from 'url' +import module from 'module' +const require = module.createRequire(import.meta.url) const __dirname = fileURLToPath(new URL('.', import.meta.url)) -import { startServer } from 'next/dist/server/lib/start-server.js' ` - : ` -const path = require('path') -const { startServer } = require('next/dist/server/lib/start-server')` + : `const path = require('path')` } const dir = path.join(__dirname) @@ -1993,9 +1993,14 @@ const nextConfig = ${JSON.stringify({ })} process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig) -process.env.__NEXT_PRIVATE_PREBUNDLED_REACT = nextConfig.experimental && nextConfig.experimental.serverActions - ? 'experimental' - : 'next' +process.env.__NEXT_PRIVATE_PREBUNDLED_REACT = ${hasAppDir} + ? nextConfig.experimental && nextConfig.experimental.serverActions + ? 'experimental' + : 'next' + : ''; + +require('next') +const { startServer } = require('next/dist/server/lib/start-server') if ( Number.isNaN(keepAliveTimeout) || diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index dbc805edaf1fd..00f85439e00cd 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -68,6 +68,7 @@ import { NextFontManifestPlugin } from './webpack/plugins/next-font-manifest-plu import { getSupportedBrowsers } from './utils' import { MemoryWithGcCachePlugin } from './webpack/plugins/memory-with-gc-cache-plugin' import { getBabelConfigFile } from './get-babel-config-file' +import { defaultOverrides } from '../server/import-overrides' type ExcludesFalse = (x: T | false) => x is T type ClientEntries = { @@ -1127,6 +1128,14 @@ export default async function getBaseWebpackConfig( '@opentelemetry/api': 'next/dist/compiled/@opentelemetry/api', }), + ...(hasAppDir + ? createRSCAliases(bundledReactChannel, { + reactSharedSubset: false, + reactDomServerRenderingStub: false, + reactProductionProfiling, + }) + : {}), + ...(config.images.loaderFile ? { 'next/dist/shared/lib/image-loader': config.images.loaderFile, @@ -1138,8 +1147,8 @@ export default async function getBaseWebpackConfig( next: NEXT_PROJECT_ROOT, - 'styled-jsx/style$': require.resolve(`styled-jsx/style`), - 'styled-jsx$': require.resolve(`styled-jsx`), + 'styled-jsx/style$': defaultOverrides['styled-jsx/style'], + 'styled-jsx$': defaultOverrides['styled-jsx'], ...customAppAliases, ...customErrorAlias, @@ -1273,7 +1282,16 @@ export default async function getBaseWebpackConfig( } } - for (const packageName of ['react', 'react-dom']) { + for (const packageName of [ + 'react', + 'react-dom', + ...(hasAppDir + ? [ + `next/dist/compiled/react${bundledReactChannel}`, + `next/dist/compiled/react-dom${bundledReactChannel}`, + ] + : []), + ]) { addPackagePath(packageName, dir) } @@ -1541,7 +1559,7 @@ export default async function getBaseWebpackConfig( // Forcedly resolve the styled-jsx installed by next.js, // since `resolveExternal` cannot find the styled-jsx dep with pnpm if (request === 'styled-jsx/style') { - resolveResult.res = require.resolve(request) + resolveResult.res = defaultOverrides['styled-jsx/style'] } const { res, isEsm } = resolveResult @@ -2116,11 +2134,6 @@ export default async function getBaseWebpackConfig( [require.resolve('next/dynamic')]: require.resolve( 'next/dist/shared/lib/app-dynamic' ), - ...createRSCAliases(bundledReactChannel, { - reactSharedSubset: false, - reactDomServerRenderingStub: false, - reactProductionProfiling, - }), }, }, }, diff --git a/packages/next/src/build/webpack/plugins/nextjs-require-cache-hot-reloader.ts b/packages/next/src/build/webpack/plugins/nextjs-require-cache-hot-reloader.ts index 107e2e03f07bb..f14071a5df009 100644 --- a/packages/next/src/build/webpack/plugins/nextjs-require-cache-hot-reloader.ts +++ b/packages/next/src/build/webpack/plugins/nextjs-require-cache-hot-reloader.ts @@ -20,16 +20,7 @@ const originModules = [ const RUNTIME_NAMES = ['webpack-runtime', 'webpack-api-runtime'] -const nextDeleteCacheRpc = async (filePaths: string[]) => { - if ((global as any)._nextDeleteCache) { - return (global as any)._nextDeleteCache(filePaths) - } -} - export function deleteAppClientCache() { - if ((global as any)._nextDeleteAppClientCache) { - return (global as any)._nextDeleteAppClientCache() - } // ensure we reset the cache for rsc components // loaded via react-server-dom-webpack const reactServerDomModId = require.resolve( @@ -88,43 +79,41 @@ export class NextJsRequireCacheHotReloader implements WebpackPluginInstance { apply(compiler: Compiler) { compiler.hooks.assetEmitted.tap(PLUGIN_NAME, (_file, { targetPath }) => { - nextDeleteCacheRpc([targetPath]) - - // Clear module context in other processes - if ((global as any)._nextClearModuleContext) { - ;(global as any)._nextClearModuleContext(targetPath) - } // Clear module context in this process clearModuleContext(targetPath) + deleteCache(targetPath) }) compiler.hooks.afterEmit.tapPromise(PLUGIN_NAME, async (compilation) => { - const cacheEntriesToDelete = [] - for (const name of RUNTIME_NAMES) { const runtimeChunkPath = path.join( compilation.outputOptions.path!, `${name}.js` ) - cacheEntriesToDelete.push(runtimeChunkPath) + deleteCache(runtimeChunkPath) } // we need to make sure to clear all server entries from cache // since they can have a stale webpack-runtime cache // which needs to always be in-sync + let hasAppEntry = false const entries = [...compilation.entries.keys()].filter((entry) => { const isAppPath = entry.toString().startsWith('app/') + if (isAppPath) hasAppEntry = true return entry.toString().startsWith('pages/') || isAppPath }) + if (hasAppEntry) { + deleteAppClientCache() + } + for (const page of entries) { const outputPath = path.join( compilation.outputOptions.path!, page + '.js' ) - cacheEntriesToDelete.push(outputPath) + deleteCache(outputPath) } - await nextDeleteCacheRpc(cacheEntriesToDelete) }) } } diff --git a/packages/next/src/cli/next-build-args.ts b/packages/next/src/cli/next-build-args.ts new file mode 100755 index 0000000000000..814ac16df8396 --- /dev/null +++ b/packages/next/src/cli/next-build-args.ts @@ -0,0 +1,17 @@ +import arg from 'next/dist/compiled/arg/index.js' + +export const validArgs: arg.Spec = { + // Types + '--help': Boolean, + '--profile': Boolean, + '--debug': Boolean, + '--no-lint': Boolean, + '--no-mangling': Boolean, + '--experimental-app-only': Boolean, + '--experimental-turbo': Boolean, + '--experimental-turbo-root': String, + '--build-mode': String, + // Aliases + '-h': '--help', + '-d': '--debug', +} diff --git a/packages/next/src/cli/next-build.ts b/packages/next/src/cli/next-build.ts index fc7decbe032f3..223b772ff6578 100755 --- a/packages/next/src/cli/next-build.ts +++ b/packages/next/src/cli/next-build.ts @@ -1,33 +1,13 @@ #!/usr/bin/env node import { existsSync } from 'fs' -import arg from 'next/dist/compiled/arg/index.js' import * as Log from '../build/output/log' import { CliCommand } from '../lib/commands' import build from '../build' import { printAndExit } from '../server/lib/utils' import isError from '../lib/is-error' import { getProjectDir } from '../lib/get-project-dir' -import { getValidatedArgs } from '../lib/get-validated-args' - -const nextBuild: CliCommand = (argv) => { - const validArgs: arg.Spec = { - // Types - '--help': Boolean, - '--profile': Boolean, - '--debug': Boolean, - '--no-lint': Boolean, - '--no-mangling': Boolean, - '--experimental-app-only': Boolean, - '--experimental-turbo': Boolean, - '--experimental-turbo-root': String, - '--build-mode': String, - // Aliases - '-h': '--help', - '-d': '--debug', - } - - const args = getValidatedArgs(validArgs, argv) +const nextBuild: CliCommand = (args) => { if (args['--help']) { printAndExit( ` diff --git a/packages/next/src/cli/next-dev-args.ts b/packages/next/src/cli/next-dev-args.ts new file mode 100644 index 0000000000000..3ab378660c386 --- /dev/null +++ b/packages/next/src/cli/next-dev-args.ts @@ -0,0 +1,25 @@ +import arg from 'next/dist/compiled/arg/index.js' + +export const validArgs: arg.Spec = { + // Types + '--help': Boolean, + '--port': Number, + '--hostname': String, + '--turbo': Boolean, + '--experimental-turbo': Boolean, + '--experimental-https': Boolean, + '--experimental-https-key': String, + '--experimental-https-cert': String, + '--experimental-test-proxy': Boolean, + '--experimental-upload-trace': String, + + // To align current messages with native binary. + // Will need to adjust subcommand later. + '--show-all': Boolean, + '--root': String, + + // Aliases + '-h': '--help', + '-p': '--port', + '-H': '--hostname', +} diff --git a/packages/next/src/cli/next-dev.ts b/packages/next/src/cli/next-dev.ts index 17792914bbc8c..e6b03751a9342 100644 --- a/packages/next/src/cli/next-dev.ts +++ b/packages/next/src/cli/next-dev.ts @@ -1,15 +1,10 @@ #!/usr/bin/env node -import arg from 'next/dist/compiled/arg/index.js' import type { StartServerOptions } from '../server/lib/start-server' -import { - genRouterWorkerExecArgv, - getNodeOptionsWithoutInspect, -} from '../server/lib/utils' import { getPort, printAndExit } from '../server/lib/utils' import * as Log from '../build/output/log' import { CliCommand } from '../lib/commands' import { getProjectDir } from '../lib/get-project-dir' -import { CONFIG_FILES, PHASE_DEVELOPMENT_SERVER } from '../shared/lib/constants' +import { PHASE_DEVELOPMENT_SERVER } from '../shared/lib/constants' import path from 'path' import { NextConfigComplete } from '../server/config-shared' import { setGlobal, traceGlobals } from '../trace/shared' @@ -18,14 +13,9 @@ import loadConfig, { getEnabledExperimentalFeatures } from '../server/config' import { findPagesDir } from '../lib/find-pages-dir' import { fileExists, FileType } from '../lib/file-exists' import { getNpxCommand } from '../lib/helpers/get-npx-command' -import Watchpack from 'watchpack' -import { initialEnv } from '@next/env' -import { getValidatedArgs } from '../lib/get-validated-args' -import { Worker } from 'next/dist/compiled/jest-worker' -import type { ChildProcess } from 'child_process' -import { checkIsNodeDebugging } from '../server/lib/is-node-debugging' import { createSelfSignedCertificate } from '../lib/mkcert' import uploadTrace from '../trace/upload-trace' +import { startServer } from '../server/lib/start-server' import { loadEnvConfig } from '@next/env' import { trace } from '../trace' @@ -110,125 +100,7 @@ const handleSessionStop = async () => { process.on('SIGINT', handleSessionStop) process.on('SIGTERM', handleSessionStop) -function watchConfigFiles( - dirToWatch: string, - onChange: (filename: string) => void -) { - const wp = new Watchpack() - wp.watch({ files: CONFIG_FILES.map((file) => path.join(dirToWatch, file)) }) - wp.on('change', onChange) -} - -type StartServerWorker = Worker & - Pick - -async function createRouterWorker(fullConfig: NextConfigComplete): Promise<{ - worker: StartServerWorker - cleanup: () => Promise -}> { - const isNodeDebugging = checkIsNodeDebugging() - const worker = new Worker(require.resolve('../server/lib/start-server'), { - numWorkers: 1, - // TODO: do we want to allow more than 8 OOM restarts? - maxRetries: 8, - forkOptions: { - execArgv: await genRouterWorkerExecArgv( - isNodeDebugging === undefined ? false : isNodeDebugging - ), - env: { - FORCE_COLOR: '1', - ...(initialEnv as any), - NODE_OPTIONS: getNodeOptionsWithoutInspect(), - ...(process.env.NEXT_CPU_PROF - ? { __NEXT_PRIVATE_CPU_PROFILE: `CPU.router` } - : {}), - WATCHPACK_WATCHER_LIMIT: '20', - TURBOPACK: process.env.TURBOPACK, - __NEXT_PRIVATE_PREBUNDLED_REACT: !!fullConfig.experimental.serverActions - ? 'experimental' - : 'next', - }, - }, - exposedMethods: ['startServer'], - }) as Worker & - Pick - - const cleanup = () => { - for (const curWorker of ((worker as any)._workerPool?._workers || []) as { - _child?: ChildProcess - }[]) { - curWorker._child?.kill('SIGINT') - } - process.exit(0) - } - - // If the child routing worker exits we need to exit the entire process - for (const curWorker of ((worker as any)._workerPool?._workers || []) as { - _child?: ChildProcess - }[]) { - curWorker._child?.on('exit', cleanup) - } - - process.on('exit', cleanup) - process.on('SIGINT', cleanup) - process.on('SIGTERM', cleanup) - process.on('uncaughtException', cleanup) - process.on('unhandledRejection', cleanup) - - const workerStdout = worker.getStdout() - const workerStderr = worker.getStderr() - - workerStdout.on('data', (data) => { - process.stdout.write(data) - }) - workerStderr.on('data', (data) => { - process.stderr.write(data) - }) - - return { - worker, - cleanup: async () => { - // Remove process listeners for childprocess too. - for (const curWorker of ((worker as any)._workerPool?._workers || []) as { - _child?: ChildProcess - }[]) { - curWorker._child?.off('exit', cleanup) - } - process.off('exit', cleanup) - process.off('SIGINT', cleanup) - process.off('SIGTERM', cleanup) - process.off('uncaughtException', cleanup) - process.off('unhandledRejection', cleanup) - await worker.end() - }, - } -} - -const nextDev: CliCommand = async (argv) => { - const validArgs: arg.Spec = { - // Types - '--help': Boolean, - '--port': Number, - '--hostname': String, - '--turbo': Boolean, - '--experimental-turbo': Boolean, - '--experimental-https': Boolean, - '--experimental-https-key': String, - '--experimental-https-cert': String, - '--experimental-test-proxy': Boolean, - '--experimental-upload-trace': String, - - // To align current messages with native binary. - // Will need to adjust subcommand later. - '--show-all': Boolean, - '--root': String, - - // Aliases - '-h': '--help', - '-p': '--port', - '-H': '--hostname', - } - const args = getValidatedArgs(validArgs, argv) +const nextDev: CliCommand = async (args) => { if (args['--help']) { console.log(` Description @@ -354,8 +226,6 @@ const nextDev: CliCommand = async (argv) => { const runDevServer = async (reboot: boolean) => { try { - const workerInit = await createRouterWorker(config) - if (!!args['--experimental-https']) { Log.warn( 'Self-signed certificates are currently an experimental feature, use at your own risk.' @@ -375,54 +245,23 @@ const nextDev: CliCommand = async (argv) => { certificate = await createSelfSignedCertificate(host) } - await workerInit.worker.startServer({ + await startServer({ ...devServerOptions, selfSignedCertificate: certificate, }) } else { - await workerInit.worker.startServer(devServerOptions) + await startServer(devServerOptions) } await preflight(reboot) - return { - cleanup: workerInit.cleanup, - } } catch (err) { console.error(err) process.exit(1) } } - let runningServer: Awaited> | undefined - - watchConfigFiles(devServerOptions.dir, async (filename) => { - if (process.env.__NEXT_DISABLE_MEMORY_WATCHER) { - Log.info( - `Detected change, manual restart required due to '__NEXT_DISABLE_MEMORY_WATCHER' usage` - ) - return - } - // Adding a new line to avoid the logs going directly after the spinner in `next build` - Log.warn('') - Log.warn( - `Found a change in ${path.basename( - filename - )}. Restarting the server to apply the changes...` - ) - - try { - if (runningServer) { - await runningServer.cleanup() - } - runningServer = await runDevServer(true) - } catch (err) { - console.error(err) - process.exit(1) - } - }) - await trace('start-dev-server').traceAsyncFn(async (_) => { - runningServer = await runDevServer(false) + await runDevServer(false) }) } diff --git a/packages/next/src/cli/next-export-args.ts b/packages/next/src/cli/next-export-args.ts new file mode 100755 index 0000000000000..e6c66da77dd30 --- /dev/null +++ b/packages/next/src/cli/next-export-args.ts @@ -0,0 +1,14 @@ +import arg from 'next/dist/compiled/arg/index.js' + +export const validArgs: arg.Spec = { + // Types + '--help': Boolean, + '--silent': Boolean, + '--outdir': String, + '--threads': Number, + + // Aliases + '-h': '--help', + '-o': '--outdir', + '-s': '--silent', +} diff --git a/packages/next/src/cli/next-export.ts b/packages/next/src/cli/next-export.ts index b3fe1d21b1dc7..bf94fdf3f2e2b 100755 --- a/packages/next/src/cli/next-export.ts +++ b/packages/next/src/cli/next-export.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import { resolve, join } from 'path' import { existsSync } from 'fs' -import arg from 'next/dist/compiled/arg/index.js' import chalk from 'next/dist/compiled/chalk' import exportApp, { ExportError, ExportOptions } from '../export' import * as Log from '../build/output/log' @@ -9,23 +8,9 @@ import { printAndExit } from '../server/lib/utils' import { CliCommand } from '../lib/commands' import { trace } from '../trace' import { getProjectDir } from '../lib/get-project-dir' -import { getValidatedArgs } from '../lib/get-validated-args' -const nextExport: CliCommand = (argv) => { +const nextExport: CliCommand = (args) => { const nextExportCliSpan = trace('next-export-cli') - const validArgs: arg.Spec = { - // Types - '--help': Boolean, - '--silent': Boolean, - '--outdir': String, - '--threads': Number, - - // Aliases - '-h': '--help', - '-o': '--outdir', - '-s': '--silent', - } - const args = getValidatedArgs(validArgs, argv) if (args['--help']) { console.log(` Description diff --git a/packages/next/src/cli/next-info-args.ts b/packages/next/src/cli/next-info-args.ts new file mode 100755 index 0000000000000..dafea92660d84 --- /dev/null +++ b/packages/next/src/cli/next-info-args.ts @@ -0,0 +1,13 @@ +import arg from 'next/dist/compiled/arg/index.js' + +/** + * Supported CLI arguments. + */ +export const validArgs: arg.Spec = { + // Types + '--help': Boolean, + // Aliases + '-h': '--help', + // Detailed diagnostics + '--verbose': Boolean, +} diff --git a/packages/next/src/cli/next-info.ts b/packages/next/src/cli/next-info.ts index 54bf0b58fd52a..b8095852c9484 100755 --- a/packages/next/src/cli/next-info.ts +++ b/packages/next/src/cli/next-info.ts @@ -4,14 +4,12 @@ import os from 'os' import childProcess from 'child_process' import chalk from 'next/dist/compiled/chalk' -import arg from 'next/dist/compiled/arg/index.js' const { fetch } = require('next/dist/compiled/undici') as { fetch: typeof global.fetch } import { CliCommand } from '../lib/commands' import { PHASE_INFO } from '../shared/lib/constants' import loadConfig from '../server/config' -import { getValidatedArgs } from '../lib/get-validated-args' const dir = process.cwd() @@ -51,18 +49,6 @@ type PlatformTaskScript = darwin?: TaskScript } -/** - * Supported CLI arguments. - */ -const validArgs: arg.Spec = { - // Types - '--help': Boolean, - // Aliases - '-h': '--help', - // Detailed diagnostics - '--verbose': Boolean, -} - function getPackageVersion(packageName: string) { try { return require(`${packageName}/package.json`).version @@ -590,9 +576,7 @@ async function printVerbose() { * There are 2 modes, by default it collects basic next.js installation with runtime information. If * `--verbose` mode is enabled it'll try to collect, verify more data for next-swc installation and others. */ -const nextInfo: CliCommand = async (argv) => { - const args = getValidatedArgs(validArgs, argv) - +const nextInfo: CliCommand = async (args) => { if (args['--help']) { printHelp() return diff --git a/packages/next/src/cli/next-lint-args.ts b/packages/next/src/cli/next-lint-args.ts new file mode 100755 index 0000000000000..854af64be303f --- /dev/null +++ b/packages/next/src/cli/next-lint-args.ts @@ -0,0 +1,44 @@ +import arg from 'next/dist/compiled/arg/index.js' + +const validEslintArgs: arg.Spec = { + // Types + '--config': String, + '--ext': [String], + '--resolve-plugins-relative-to': String, + '--rulesdir': [String], + '--fix': Boolean, + '--fix-type': [String], + '--ignore-path': String, + '--no-ignore': Boolean, + '--quiet': Boolean, + '--max-warnings': Number, + '--no-inline-config': Boolean, + '--report-unused-disable-directives': String, + '--cache': Boolean, // Although cache is enabled by default, this dummy flag still exists to not cause any breaking changes + '--no-cache': Boolean, + '--cache-location': String, + '--cache-strategy': String, + '--error-on-unmatched-pattern': Boolean, + '--format': String, + '--output-file': String, + + // Aliases + '-c': '--config', + '-f': '--format', + '-o': '--output-file', +} + +export const validArgs: arg.Spec = { + // Types + '--help': Boolean, + '--base-dir': String, + '--dir': [String], + '--file': [String], + '--strict': Boolean, + + // Aliases + '-h': '--help', + '-b': '--base-dir', + '-d': '--dir', + ...validEslintArgs, +} diff --git a/packages/next/src/cli/next-lint.ts b/packages/next/src/cli/next-lint.ts index 1bb782c7f23d5..0a9acfbd1c25a 100755 --- a/packages/next/src/cli/next-lint.ts +++ b/packages/next/src/cli/next-lint.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node +import type arg from 'next/dist/compiled/arg/index.js' import { existsSync } from 'fs' -import arg from 'next/dist/compiled/arg/index.js' import { join } from 'path' import chalk from 'next/dist/compiled/chalk' @@ -16,7 +16,6 @@ import { CompileError } from '../lib/compile-error' import { getProjectDir } from '../lib/get-project-dir' import { findPagesDir } from '../lib/find-pages-dir' import { verifyTypeScriptSetup } from '../lib/verifyTypeScriptSetup' -import { getValidatedArgs } from '../lib/get-validated-args' const eslintOptions = (args: arg.Spec, defaultCacheLocation: string) => ({ overrideConfigFile: args['--config'] || null, @@ -47,51 +46,7 @@ const eslintOptions = (args: arg.Spec, defaultCacheLocation: string) => ({ : false, }) -const nextLint: CliCommand = async (argv) => { - const validArgs: arg.Spec = { - // Types - '--help': Boolean, - '--base-dir': String, - '--dir': [String], - '--file': [String], - '--strict': Boolean, - - // Aliases - '-h': '--help', - '-b': '--base-dir', - '-d': '--dir', - } - - const validEslintArgs: arg.Spec = { - // Types - '--config': String, - '--ext': [String], - '--resolve-plugins-relative-to': String, - '--rulesdir': [String], - '--fix': Boolean, - '--fix-type': [String], - '--ignore-path': String, - '--no-ignore': Boolean, - '--quiet': Boolean, - '--max-warnings': Number, - '--no-inline-config': Boolean, - '--report-unused-disable-directives': String, - '--cache': Boolean, // Although cache is enabled by default, this dummy flag still exists to not cause any breaking changes - '--no-cache': Boolean, - '--cache-location': String, - '--cache-strategy': String, - '--error-on-unmatched-pattern': Boolean, - '--format': String, - '--output-file': String, - - // Aliases - '-c': '--config', - '-f': '--format', - '-o': '--output-file', - } - - const args = getValidatedArgs({ ...validArgs, ...validEslintArgs }, argv) - +const nextLint: CliCommand = async (args) => { if (args['--help']) { printAndExit( ` diff --git a/packages/next/src/cli/next-start-args.ts b/packages/next/src/cli/next-start-args.ts new file mode 100755 index 0000000000000..1c95bafd0bc71 --- /dev/null +++ b/packages/next/src/cli/next-start-args.ts @@ -0,0 +1,15 @@ +import arg from 'next/dist/compiled/arg/index.js' + +export const validArgs: arg.Spec = { + // Types + '--help': Boolean, + '--port': Number, + '--hostname': String, + '--keepAliveTimeout': Number, + '--experimental-test-proxy': Boolean, + + // Aliases + '-h': '--help', + '-p': '--port', + '-H': '--hostname', +} diff --git a/packages/next/src/cli/next-start.ts b/packages/next/src/cli/next-start.ts index ef54c2723de47..6af517729492d 100755 --- a/packages/next/src/cli/next-start.ts +++ b/packages/next/src/cli/next-start.ts @@ -1,27 +1,11 @@ #!/usr/bin/env node -import arg from 'next/dist/compiled/arg/index.js' import { startServer } from '../server/lib/start-server' import { getPort, printAndExit } from '../server/lib/utils' import { getProjectDir } from '../lib/get-project-dir' import { CliCommand } from '../lib/commands' -import { getValidatedArgs } from '../lib/get-validated-args' -const nextStart: CliCommand = async (argv) => { - const validArgs: arg.Spec = { - // Types - '--help': Boolean, - '--port': Number, - '--hostname': String, - '--keepAliveTimeout': Number, - '--experimental-test-proxy': Boolean, - - // Aliases - '-h': '--help', - '-p': '--port', - '-H': '--hostname', - } - const args = getValidatedArgs(validArgs, argv) +const nextStart: CliCommand = async (args) => { if (args['--help']) { console.log(` Description diff --git a/packages/next/src/cli/next-telemetry-args.ts b/packages/next/src/cli/next-telemetry-args.ts new file mode 100755 index 0000000000000..91b07af36d4fe --- /dev/null +++ b/packages/next/src/cli/next-telemetry-args.ts @@ -0,0 +1,10 @@ +import arg from 'next/dist/compiled/arg/index.js' + +export const validArgs: arg.Spec = { + // Types + '--enable': Boolean, + '--disable': Boolean, + '--help': Boolean, + // Aliases + '-h': '--help', +} diff --git a/packages/next/src/cli/next-telemetry.ts b/packages/next/src/cli/next-telemetry.ts index 7b6f458b17b55..e8fae460cf1ea 100755 --- a/packages/next/src/cli/next-telemetry.ts +++ b/packages/next/src/cli/next-telemetry.ts @@ -1,21 +1,9 @@ #!/usr/bin/env node import chalk from 'next/dist/compiled/chalk' -import arg from 'next/dist/compiled/arg/index.js' import { CliCommand } from '../lib/commands' import { Telemetry } from '../telemetry/storage' -import { getValidatedArgs } from '../lib/get-validated-args' - -const nextTelemetry: CliCommand = (argv) => { - const validArgs: arg.Spec = { - // Types - '--enable': Boolean, - '--disable': Boolean, - '--help': Boolean, - // Aliases - '-h': '--help', - } - const args = getValidatedArgs(validArgs, argv) +const nextTelemetry: CliCommand = (args) => { if (args['--help']) { console.log( ` diff --git a/packages/next/src/lib/command-args.ts b/packages/next/src/lib/command-args.ts new file mode 100644 index 0000000000000..00d1e4164e020 --- /dev/null +++ b/packages/next/src/lib/command-args.ts @@ -0,0 +1,17 @@ +import { getValidatedArgs } from './get-validated-args' + +export type CliCommand = (args: ReturnType) => void + +export const commandArgs: { + [command: string]: () => Parameters[0] +} = { + build: () => require('../cli/next-build-args').validArgs, + start: () => require('../cli/next-start-args').validArgs, + export: () => require('../cli/next-export-args').validArgs, + dev: () => require('../cli/next-dev-args').validArgs, + lint: () => require('../cli/next-lint-args').validArgs, + telemetry: () => require('../cli/next-telemetry-args').validArgs, + info: () => require('../cli/next-info-args').validArgs, + 'experimental-compile': () => require('../cli/next-build-args').validArgs, + 'experimental-generate': () => require('../cli/next-build-args').validArgs, +} diff --git a/packages/next/src/lib/commands.ts b/packages/next/src/lib/commands.ts index fdc6c0ff51976..d921a8583cb56 100644 --- a/packages/next/src/lib/commands.ts +++ b/packages/next/src/lib/commands.ts @@ -1,4 +1,6 @@ -export type CliCommand = (argv?: string[]) => void +import { getValidatedArgs } from './get-validated-args' + +export type CliCommand = (args: ReturnType) => void export const commands: { [command: string]: () => Promise } = { build: () => Promise.resolve(require('../cli/next-build').nextBuild), diff --git a/packages/next/src/server/api-utils/node.ts b/packages/next/src/server/api-utils/node.ts index 44f36814bc74f..40ea977cf9f9b 100644 --- a/packages/next/src/server/api-utils/node.ts +++ b/packages/next/src/server/api-utils/node.ts @@ -35,7 +35,6 @@ import { PRERENDER_REVALIDATE_HEADER, PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER, } from '../../lib/constants' -import { invokeRequest } from '../lib/server-ipc/invoke-request' export function tryGetPreviewData( req: IncomingMessage | BaseNextRequest | Request, @@ -460,30 +459,6 @@ async function revalidate( throw new Error(`Invalid response ${res.status}`) } } else if (context.revalidate) { - // We prefer to use the IPC call if running under the workers mode. - const ipcPort = process.env.__NEXT_PRIVATE_ROUTER_IPC_PORT - if (ipcPort) { - const ipcKey = process.env.__NEXT_PRIVATE_ROUTER_IPC_KEY - const res = await invokeRequest( - `http://${ - context.hostname || 'localhost' - }:${ipcPort}?key=${ipcKey}&method=revalidate&args=${encodeURIComponent( - JSON.stringify([{ urlPath, revalidateHeaders, opts }]) - )}`, - { - method: 'GET', - headers: {}, - } - ) - const result = await res.json() - - if (result.err) { - throw new Error(result.err.message) - } - - return - } - await context.revalidate({ urlPath, revalidateHeaders, diff --git a/packages/next/src/server/base-http/node.ts b/packages/next/src/server/base-http/node.ts index 7d99b8c198b1e..64d8f8cedf96e 100644 --- a/packages/next/src/server/base-http/node.ts +++ b/packages/next/src/server/base-http/node.ts @@ -17,7 +17,7 @@ type Req = IncomingMessage & { export class NodeNextRequest extends BaseNextRequest { public headers = this._req.headers; - [NEXT_REQUEST_META]: RequestMeta = {} + [NEXT_REQUEST_META]: RequestMeta = this._req[NEXT_REQUEST_META] || {} get originalRequest() { // Need to mimic these changes to the original req object for places where we use it: diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 03c940413ebc9..f36931610e769 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -384,6 +384,7 @@ export default abstract class Server { hostname, port, } = options + this.serverOptions = options this.isRenderWorker = options._renderWorker @@ -1271,12 +1272,21 @@ export default abstract class Server { res, parsedUrl ) - if (!result.finished) { - res.setHeader('x-middleware-next', '1') - res.body('') - res.send() + + if (result.finished) { + return + } else { + const err = new Error() + ;(err as any).result = { + response: new Response(null, { + headers: { + 'x-middleware-next': '1', + }, + }), + } + ;(err as any).bubble = true + throw err } - return } // ensure we strip the basePath when not using an invoke header @@ -1290,6 +1300,10 @@ export default abstract class Server { res.statusCode = 200 return await this.run(req, res, parsedUrl) } catch (err: any) { + if (err instanceof NoFallbackError) { + throw err + } + if ( (err && typeof err === 'object' && err.code === 'ERR_INVALID_URL') || err instanceof DecodeError || @@ -1299,7 +1313,7 @@ export default abstract class Server { return this.renderError(null, req, res, '/_error', {}) } - if (this.minimalMode || this.renderOpts.dev) { + if (this.minimalMode || this.renderOpts.dev || (err as any).bubble) { throw err } this.logError(getProperError(err)) diff --git a/packages/next/src/server/config-utils.ts b/packages/next/src/server/config-utils.ts index 9270cf3c46f22..bf6431ad8672c 100644 --- a/packages/next/src/server/config-utils.ts +++ b/packages/next/src/server/config-utils.ts @@ -10,7 +10,7 @@ export function loadWebpackHook() { // hook the Node.js require so that webpack requires are // routed to the bundled and now initialized webpack version - require('../server/require-hook').addHookAliases( + require('../server/import-overrides').addHookAliases( [ ['webpack', 'next/dist/compiled/webpack/webpack-lib'], ['webpack/package', 'next/dist/compiled/webpack/package'], diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 64eb5985699a2..9c08a33db917e 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -19,6 +19,7 @@ import { loadEnvConfig, updateInitialEnv } from '@next/env' import { flushAndExit } from '../telemetry/flush-and-exit' import { findRootDir } from '../lib/find-root' import { setHttpClientAndAgentOptions } from './setup-http-agent-env' +import { pathHasPrefix } from '../shared/lib/router/utils/path-has-prefix' export { DomainLocale, NextConfig, normalizeConfig } from './config-shared' @@ -266,19 +267,21 @@ function assignDefaults( ) } - if (images.path === imageConfigDefault.path && result.basePath) { + if ( + images.path === imageConfigDefault.path && + result.basePath && + !pathHasPrefix(images.path, result.basePath) + ) { images.path = `${result.basePath}${images.path}` } // Append trailing slash for non-default loaders and when trailingSlash is set - if (images.path) { - if ( - (images.loader !== 'default' && - images.path[images.path.length - 1] !== '/') || - result.trailingSlash - ) { - images.path += '/' - } + if ( + images.path && + !images.path.endsWith('/') && + (images.loader !== 'default' || result.trailingSlash) + ) { + images.path += '/' } if (images.loaderFile) { diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index 912708a591b70..5512665b79eef 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -714,9 +714,11 @@ export default class HotReloader implements NextJsHotReloaderInterface { const startSpan = this.hotReloaderSpan.traceChild('start') startSpan.stop() // Stop immediately to create an artificial parent span + const testMode = process.env.NEXT_TEST_MODE || process.env.__NEXT_TEST_MODE + this.versionInfo = await this.getVersionInfo( startSpan, - !!process.env.NEXT_TEST_MODE || this.telemetry.isEnabled + !!testMode || this.telemetry.isEnabled ) await this.clean(startSpan) diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 839fa1b13c0e4..e8c3f8e68ccec 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -63,12 +63,7 @@ import { DefaultFileReader } from '../future/route-matcher-providers/dev/helpers import { NextBuildContext } from '../../build/build-context' import { IncrementalCache } from '../lib/incremental-cache' import LRUCache from 'next/dist/compiled/lru-cache' -import { errorToJSON } from '../render' import { getMiddlewareRouteMatcher } from '../../shared/lib/router/utils/middleware-route-matcher' -import { - deserializeErr, - invokeIpcMethod, -} from '../lib/server-ipc/request-utils' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: FunctionComponent @@ -102,6 +97,10 @@ export default class DevServer extends Server { UnwrapPromise> > + private invokeDevMethod({ method, args }: { method: string; args: any[] }) { + return (global as any)._nextDevHandlers[method](this.dir, ...args) + } + protected staticPathsWorker?: { [key: string]: any } & { loadStaticPaths: typeof import('./static-paths-worker').loadStaticPaths } @@ -390,7 +389,7 @@ export default class DevServer extends Server { } response.statusCode = 500 - this.renderError(err, request, response, parsedUrl.pathname) + await this.renderError(err, request, response, parsedUrl.pathname) return { finished: true } } } @@ -419,7 +418,7 @@ export default class DevServer extends Server { const err = getProperError(error) const { req, res, page } = params res.statusCode = 500 - this.renderError(err, req, res, page) + await this.renderError(err, req, res, page) return null } } @@ -489,12 +488,9 @@ export default class DevServer extends Server { type?: 'unhandledRejection' | 'uncaughtException' | 'warning' | 'app-dir' ): Promise { if (this.isRenderWorker) { - await invokeIpcMethod({ - fetchHostname: this.fetchHostname, + await this.invokeDevMethod({ method: 'logErrorWithOriginalStack', - args: [errorToJSON(err as Error), type], - ipcPort: process.env.__NEXT_PRIVATE_ROUTER_IPC_PORT, - ipcKey: process.env.__NEXT_PRIVATE_ROUTER_IPC_KEY, + args: [err, type], }) return } @@ -738,12 +734,9 @@ export default class DevServer extends Server { throw new Error('Invariant ensurePage called outside render worker') } - await invokeIpcMethod({ - fetchHostname: this.fetchHostname, + await this.invokeDevMethod({ method: 'ensurePage', args: [opts], - ipcPort: process.env.__NEXT_PRIVATE_ROUTER_IPC_PORT, - ipcKey: process.env.__NEXT_PRIVATE_ROUTER_IPC_KEY, }) } @@ -802,12 +795,9 @@ export default class DevServer extends Server { protected async getFallbackErrorComponents(): Promise { if (this.isRenderWorker) { - await invokeIpcMethod({ - fetchHostname: this.fetchHostname, + await this.invokeDevMethod({ method: 'getFallbackErrorComponents', args: [], - ipcPort: process.env.__NEXT_PRIVATE_ROUTER_IPC_PORT, - ipcKey: process.env.__NEXT_PRIVATE_ROUTER_IPC_KEY, }) return await loadDefaultErrorComponents(this.distDir) } @@ -818,14 +808,10 @@ export default class DevServer extends Server { async getCompilationError(page: string): Promise { if (this.isRenderWorker) { - const err = await invokeIpcMethod({ - fetchHostname: this.fetchHostname, + return await this.invokeDevMethod({ method: 'getCompilationError', args: [page], - ipcPort: process.env.__NEXT_PRIVATE_ROUTER_IPC_PORT, - ipcKey: process.env.__NEXT_PRIVATE_ROUTER_IPC_KEY, }) - return deserializeErr(err) } throw new Error( 'Invariant getCompilationError called outside render worker' diff --git a/packages/next/src/server/esm-loader.mts b/packages/next/src/server/esm-loader.mts new file mode 100644 index 0000000000000..313bde8b39aae --- /dev/null +++ b/packages/next/src/server/esm-loader.mts @@ -0,0 +1,27 @@ +import module from 'module' + +const require = module.createRequire(import.meta.url) + +export function resolve(specifier: string, context: any, nextResolve: any) { + const { overrideReact, hookPropertyMap } = require(process.env.NEXT_YARN_PNP + ? './import-overrides' + : 'next/dist/server/import-overrides') as typeof import('./import-overrides') + + // In case the environment variable is set after the module is loaded. + overrideReact() + + const hookResolved = hookPropertyMap.get(specifier) + if (hookResolved) { + specifier = hookResolved + } + + if (specifier.endsWith('next/dist/bin/next')) { + return { + url: specifier, + shortCircuit: true, + format: 'commonjs', + } + } + + return nextResolve(specifier, context) +} diff --git a/packages/next/src/server/require-hook.ts b/packages/next/src/server/import-overrides.ts similarity index 56% rename from packages/next/src/server/require-hook.ts rename to packages/next/src/server/import-overrides.ts index 1f8688d25dcf2..db203e6d04644 100644 --- a/packages/next/src/server/require-hook.ts +++ b/packages/next/src/server/import-overrides.ts @@ -1,29 +1,26 @@ -// Synchronously inject a require hook for webpack and webpack/. It's required to use the internal ncc webpack version. -// This is needed for userland plugins to attach to the same webpack instance as Next.js'. -// Individually compiled modules are as defined for the compilation in bundles/webpack/packages/*. +const { dirname } = require('path') as typeof import('path') -import path, { dirname } from 'path' - -// This module will only be loaded once per process. - -const mod = require('module') -const resolveFilename = mod._resolveFilename -const originalRequire = mod.prototype.require -const hookPropertyMap = new Map() - -let aliasedPrebundledReact = false - -const resolve = process.env.NEXT_MINIMAL +let resolve: typeof require.resolve = process.env.NEXT_MINIMAL ? // @ts-ignore __non_webpack_require__.resolve : require.resolve -const toResolveMap = (map: Record): [string, string][] => - Object.entries(map).map(([key, value]) => [key, resolve(value)]) +let nextPaths: undefined | { paths: string[] | undefined } = undefined + +if (!process.env.NEXT_MINIMAL) { + nextPaths = { + paths: resolve.paths('next/package.json') || undefined, + } +} +export const hookPropertyMap = new Map() export const defaultOverrides = { - 'styled-jsx': dirname(resolve('styled-jsx/package.json')), - 'styled-jsx/style': resolve('styled-jsx/style'), + 'styled-jsx': process.env.NEXT_MINIMAL + ? dirname(resolve('styled-jsx/package.json')) + : dirname(resolve('styled-jsx/package.json', nextPaths)), + 'styled-jsx/style': process.env.NEXT_MINIMAL + ? resolve('styled-jsx/style') + : resolve('styled-jsx/style', nextPaths), } export const baseOverrides = { @@ -73,6 +70,11 @@ export const experimentalOverrides = { 'next/dist/compiled/react-server-dom-webpack-experimental/server.node', } +let aliasedPrebundledReact = false + +const toResolveMap = (map: Record): [string, string][] => + Object.entries(map).map(([key, value]) => [key, resolve(value, nextPaths)]) + export function addHookAliases(aliases: [string, string][] = []) { for (const [key, value] of aliases) { hookPropertyMap.set(key, value) @@ -82,8 +84,8 @@ export function addHookAliases(aliases: [string, string][] = []) { addHookAliases(toResolveMap(defaultOverrides)) // Override built-in React packages if necessary -function overrideReact() { - if (process.env.__NEXT_PRIVATE_PREBUNDLED_REACT) { +export function overrideReact() { + if (process.env.__NEXT_PRIVATE_PREBUNDLED_REACT && !aliasedPrebundledReact) { aliasedPrebundledReact = true // Require these modules with static paths to make sure they are tracked by @@ -97,45 +99,3 @@ function overrideReact() { } } overrideReact() - -mod._resolveFilename = function ( - originalResolveFilename: typeof resolveFilename, - requestMap: Map, - request: string, - parent: any, - isMain: boolean, - options: any -) { - if (process.env.__NEXT_PRIVATE_PREBUNDLED_REACT && !aliasedPrebundledReact) { - // In case the environment variable is set after the module is loaded. - overrideReact() - } - - const hookResolved = requestMap.get(request) - if (hookResolved) request = hookResolved - return originalResolveFilename.call(mod, request, parent, isMain, options) - - // We use `bind` here to avoid referencing outside variables to create potential memory leaks. -}.bind(null, resolveFilename, hookPropertyMap) - -// This is a hack to make sure that if a user requires a Next.js module that wasn't bundled -// that needs to point to the rendering runtime version, it will point to the correct one. -// This can happen on `pages` when a user requires a dependency that uses next/image for example. -// This is only needed in production as in development we fallback to the external version. -if (process.env.NODE_ENV !== 'development' && !process.env.TURBOPACK) { - mod.prototype.require = function (request: string) { - if (request.endsWith('.shared-runtime')) { - const currentRuntime = `${ - // this env var is only set in app router - !!process.env.__NEXT_PRIVATE_PREBUNDLED_REACT - ? 'next/dist/compiled/next-server/app-page.runtime' - : 'next/dist/compiled/next-server/pages.runtime' - }.prod` - const base = path.basename(request, '.shared-runtime') - const camelized = base.replace(/-([a-z])/g, (g) => g[1].toUpperCase()) - const instance = originalRequire.call(this, currentRuntime) - return instance.default.sharedModules[camelized] - } - return originalRequire.call(this, request) - } -} diff --git a/packages/next/src/server/lib/get-esm-loader-path.ts b/packages/next/src/server/lib/get-esm-loader-path.ts new file mode 100644 index 0000000000000..7038142d4d00d --- /dev/null +++ b/packages/next/src/server/lib/get-esm-loader-path.ts @@ -0,0 +1,30 @@ +export function getEsmLoaderPath() { + let esmLoaderPath = 'next/dist/esm/server/esm-loader.mjs' + + // Since loaders don't stack Yarn PnP's loader isn't + // applied when loading our loader so we need to move + // it outside of the PnP cache + // x-ref: https://github.com/yarnpkg/berry/issues/3700 + if (process.versions.pnp) { + process.env.NEXT_YARN_PNP = '1' + const fs = require('fs') as typeof import('fs') + const path = require('path') as typeof import('path') + const tmpDir = path.join( + process.env.NEXT_PRIVATE_DIR || process.cwd(), + `.next/loader` + ) + fs.mkdirSync(tmpDir, { recursive: true }) + + for (const file of [esmLoaderPath, 'next/dist/server/import-overrides']) { + const resolvedFile = require.resolve(file) + const newFile = path.join(tmpDir, path.basename(file)) + + fs.copyFileSync(resolvedFile, newFile) + + if (file === esmLoaderPath) { + esmLoaderPath = newFile + } + } + } + return esmLoaderPath +} diff --git a/packages/next/src/server/lib/mock-request.ts b/packages/next/src/server/lib/mock-request.ts index 2038751122596..90a643328ac42 100644 --- a/packages/next/src/server/lib/mock-request.ts +++ b/packages/next/src/server/lib/mock-request.ts @@ -18,6 +18,7 @@ interface MockedRequestOptions { url: string headers: IncomingHttpHeaders method: string + readable?: Stream.Readable socket?: Socket | null } @@ -33,6 +34,8 @@ export class MockedRequest extends Stream.Readable implements IncomingMessage { public readonly httpVersionMajor = 1 public readonly httpVersionMinor = 0 + private bodyReadable?: Stream.Readable + // If we don't actually have a socket, we'll just use a mock one that // always returns false for the `encrypted` property. public socket: Socket = new Proxy({} as TLSSocket, { @@ -47,13 +50,25 @@ export class MockedRequest extends Stream.Readable implements IncomingMessage { }, }) - constructor({ url, headers, method, socket = null }: MockedRequestOptions) { + constructor({ + url, + headers, + method, + socket = null, + readable, + }: MockedRequestOptions) { super() this.url = url this.headers = headers this.method = method + if (readable) { + this.bodyReadable = readable + this.bodyReadable.on('end', () => this.emit('end')) + this.bodyReadable.on('close', () => this.emit('close')) + } + if (socket) { this.socket = socket } @@ -70,9 +85,13 @@ export class MockedRequest extends Stream.Readable implements IncomingMessage { return headers } - public _read(): void { - this.emit('end') - this.emit('close') + public _read(size: number): void { + if (this.bodyReadable) { + return this.bodyReadable._read(size) + } else { + this.emit('end') + this.emit('close') + } } /** @@ -120,6 +139,7 @@ export interface MockedResponseOptions { statusCode?: number socket?: Socket | null headers?: OutgoingHttpHeaders + resWriter?: (chunk: Buffer | string) => boolean } export class MockedResponse extends Stream.Writable implements ServerResponse { @@ -151,6 +171,11 @@ export class MockedResponse extends Stream.Writable implements ServerResponse { */ public readonly headers: Headers + private resWriter: MockedResponseOptions['resWriter'] + + public readonly headPromise: Promise + private headPromiseResolve?: () => void + constructor(res: MockedResponseOptions = {}) { super() @@ -160,13 +185,24 @@ export class MockedResponse extends Stream.Writable implements ServerResponse { ? fromNodeOutgoingHttpHeaders(res.headers) : new Headers() + this.headPromise = new Promise((resolve) => { + this.headPromiseResolve = resolve + }) + // Attach listeners for the `finish`, `end`, and `error` events to the // `MockedResponse` instance. this.hasStreamed = new Promise((resolve, reject) => { this.on('finish', () => resolve(true)) this.on('end', () => resolve(true)) this.on('error', (err) => reject(err)) + }).then((val) => { + this.headPromiseResolve?.() + return val }) + + if (res.resWriter) { + this.resWriter = res.resWriter + } } public appendHeader(name: string, value: string | string[]): this { @@ -197,6 +233,9 @@ export class MockedResponse extends Stream.Writable implements ServerResponse { } public write(chunk: Buffer | string) { + if (this.resWriter) { + return this.resWriter(chunk) + } this.buffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) return true @@ -285,6 +324,7 @@ export class MockedResponse extends Stream.Writable implements ServerResponse { this.statusCode = statusCode this.headersSent = true + this.headPromiseResolve?.() return this } @@ -395,6 +435,8 @@ interface RequestResponseMockerOptions { url: string headers?: IncomingHttpHeaders method?: string + bodyReadable?: Stream.Readable + resWriter?: (chunk: Buffer | string) => boolean socket?: Socket | null } @@ -402,10 +444,18 @@ export function createRequestResponseMocks({ url, headers = {}, method = 'GET', + bodyReadable, + resWriter, socket = null, }: RequestResponseMockerOptions) { return { - req: new MockedRequest({ url, headers, method, socket }), - res: new MockedResponse({ socket }), + req: new MockedRequest({ + url, + headers, + method, + socket, + readable: bodyReadable, + }), + res: new MockedResponse({ socket, resWriter }), } } diff --git a/packages/next/src/server/lib/render-server.ts b/packages/next/src/server/lib/render-server.ts index 9c2eee1fd2086..03dfd056b6077 100644 --- a/packages/next/src/server/lib/render-server.ts +++ b/packages/next/src/server/lib/render-server.ts @@ -1,21 +1,22 @@ -import type { RequestHandler } from '../next' +import type { NextServer, RequestHandler } from '../next' -// this must come first as it includes require hooks -import { initializeServerWorker } from './setup-server-worker' -import { formatHostname } from './format-hostname' import next from '../next' import { PropagateToWorkersField } from './router-utils/types' -export const WORKER_SELF_EXIT_CODE = 77 - -let result: +const result: Record< + string, | undefined | { - port: number - hostname: string + requestHandler: ReturnType< + InstanceType['getRequestHandler'] + > + upgradeHandler: ReturnType< + InstanceType['getUpgradeHandler'] + > } +> = {} -let app: ReturnType | undefined +let apps: Record | undefined> = {} let sandboxContext: undefined | typeof import('../web/sandbox/context') let requireCacheHotReloader: @@ -42,9 +43,11 @@ export function deleteCache(filePaths: string[]) { } export async function propagateServerField( + dir: string, field: PropagateToWorkersField, value: any ) { + const app = apps[dir] if (!app) { throw new Error('Invariant cant propagate server field, no app initialized') } @@ -72,12 +75,15 @@ export async function initialize(opts: { isNodeDebugging: boolean keepAliveTimeout?: number serverFields?: any + server?: any experimentalTestProxy: boolean -}): Promise> { + _ipcPort?: string + _ipcKey?: string +}) { // if we already setup the server return as we only need to do // this on first worker boot - if (result) { - return result + if (result[opts.dir]) { + return result[opts.dir] } const type = process.env.__NEXT_PRIVATE_RENDER_WORKER @@ -88,35 +94,25 @@ export async function initialize(opts: { let requestHandler: RequestHandler let upgradeHandler: any - const { port, server, hostname } = await initializeServerWorker( - (...args) => { - return requestHandler(...args) - }, - (...args) => { - return upgradeHandler(...args) - }, - opts - ) - - app = next({ + const app = next({ ...opts, _routerWorker: opts.workerType === 'router', _renderWorker: opts.workerType === 'render', - hostname, + hostname: opts.hostname || 'localhost', customServer: false, - httpServer: server, + httpServer: opts.server, port: opts.port, isNodeDebugging: opts.isNodeDebugging, }) - + apps[opts.dir] = app requestHandler = app.getRequestHandler() upgradeHandler = app.getUpgradeHandler() + await app.prepare(opts.serverFields) - result = { - port, - hostname: formatHostname(hostname), + result[opts.dir] = { + requestHandler, + upgradeHandler, } - - return result + return result[opts.dir] } diff --git a/packages/next/src/server/lib/route-resolver.ts b/packages/next/src/server/lib/route-resolver.ts index 8fd36f3967af8..45d013f819082 100644 --- a/packages/next/src/server/lib/route-resolver.ts +++ b/packages/next/src/server/lib/route-resolver.ts @@ -6,7 +6,6 @@ import '../node-polyfill-fetch' import url from 'url' import path from 'path' -import http from 'http' import { findPageFile } from './find-page-file' import { getRequestMeta } from '../request-meta' import setupDebug from 'next/dist/compiled/debug' @@ -16,7 +15,6 @@ import { setupFsCheck } from './router-utils/filesystem' import { proxyRequest } from './router-utils/proxy-request' import { getResolveRoutes } from './router-utils/resolve-routes' import { PERMANENT_REDIRECT_STATUS } from '../../shared/lib/constants' -import { splitCookiesString, toNodeOutgoingHttpHeaders } from '../web/utils' import { formatHostname } from './format-hostname' import { signalFromNodeResponse } from '../web/spec-extension/adapters/next-request' import { getMiddlewareRouteMatcher } from '../../shared/lib/router/utils/middleware-route-matcher' @@ -109,83 +107,6 @@ export async function makeResolver( } : {} - const middlewareServerAddr = await new Promise<{ - hostname: string - port: number - }>((resolve) => { - const srv = http.createServer(async (req, res) => { - const cloneableBody = getCloneableBody(req) - try { - const { run } = - require('../web/sandbox') as typeof import('../web/sandbox') - - const result = await run({ - distDir, - name: middlewareInfo.name || '/', - paths: middlewareInfo.paths || [], - edgeFunctionEntry: middlewareInfo, - request: { - headers: req.headers, - method: req.method || 'GET', - nextConfig: { - i18n: nextConfig.i18n, - basePath: nextConfig.basePath, - trailingSlash: nextConfig.trailingSlash, - }, - url: `http://${fetchHostname}:${port}${req.url}`, - body: cloneableBody, - signal: signalFromNodeResponse(res), - }, - useCache: true, - onWarning: console.warn, - }) - - for (let [key, value] of result.response.headers) { - if (key.toLowerCase() !== 'set-cookie') continue - - // Clear existing header. - result.response.headers.delete(key) - - // Append each cookie individually. - const cookies = splitCookiesString(value) - for (const cookie of cookies) { - result.response.headers.append(key, cookie) - } - } - - for (const [key, value] of Object.entries( - toNodeOutgoingHttpHeaders(result.response.headers) - )) { - if (key !== 'content-encoding' && value !== undefined) { - res.setHeader(key, value as string | string[]) - } - } - res.statusCode = result.response.status - - if (result.response.body) { - await pipeReadable(result.response.body, res) - } else { - res.end() - } - } catch (err) { - console.error(err) - res.statusCode = 500 - res.end('Internal Server Error') - } - }) - srv.on('listening', () => { - const srvAddr = srv.address() - if (!srvAddr || typeof srvAddr === 'string') { - throw new Error("Failed to determine middleware's host/port.") - } - resolve({ - hostname: srvAddr.address, - port: srvAddr.port, - }) - }) - srv.listen(0) - }) - if (middleware?.files.length) { fsChecker.middlewareMatcher = getMiddlewareRouteMatcher( middleware.matcher?.map((item) => ({ @@ -210,13 +131,48 @@ export async function makeResolver( pages: { async initialize() { return { - port: middlewareServerAddr.port, - hostname: formatHostname(middlewareServerAddr.hostname), + async requestHandler(req, res) { + if (!req.headers['x-middleware-invoke']) { + throw new Error(`Invariant unexpected request handler call`) + } + + const cloneableBody = getCloneableBody(req) + const { run } = + require('../web/sandbox') as typeof import('../web/sandbox') + + const result = await run({ + distDir, + name: middlewareInfo.name || '/', + paths: middlewareInfo.paths || [], + edgeFunctionEntry: middlewareInfo, + request: { + headers: req.headers, + method: req.method || 'GET', + nextConfig: { + i18n: nextConfig.i18n, + basePath: nextConfig.basePath, + trailingSlash: nextConfig.trailingSlash, + }, + url: `http://${fetchHostname}:${port}${req.url}`, + body: cloneableBody, + signal: signalFromNodeResponse(res), + }, + useCache: true, + onWarning: console.warn, + }) + + const err = new Error() + ;(err as any).result = result + throw err + }, + async upgradeHandler() { + throw new Error(`Invariant: unexpected upgrade handler call`) + }, } }, + deleteAppClientCache() {}, async deleteCache() {}, async clearModuleContext() {}, - async deleteAppClientCache() {}, async propagateServerField() {}, } as Partial as any, }, @@ -229,6 +185,7 @@ export async function makeResolver( ): Promise { const routeResult = await resolveRoutes({ req, + res, isUpgradeReq: false, signal: signalFromNodeResponse(res), }) diff --git a/packages/next/src/server/lib/router-server.ts b/packages/next/src/server/lib/router-server.ts index 29c0dacc49335..f95bbdf6dbbb4 100644 --- a/packages/next/src/server/lib/router-server.ts +++ b/packages/next/src/server/lib/router-server.ts @@ -1,4 +1,5 @@ import type { IncomingMessage } from 'http' +import type { createWorker } from './server-ipc' // this must come first as it includes require hooks import type { @@ -16,30 +17,27 @@ import path from 'path' import loadConfig from '../config' import { serveStatic } from '../serve-static' import setupDebug from 'next/dist/compiled/debug' -import { splitCookiesString, toNodeOutgoingHttpHeaders } from '../web/utils' import { Telemetry } from '../../telemetry/storage' import { DecodeError } from '../../shared/lib/utils' -import { filterReqHeaders, ipcForbiddenHeaders } from './server-ipc/utils' import { findPagesDir } from '../../lib/find-pages-dir' import { setupFsCheck } from './router-utils/filesystem' import { proxyRequest } from './router-utils/proxy-request' -import { invokeRequest } from './server-ipc/invoke-request' import { isAbortError, pipeReadable } from '../pipe-readable' import { createRequestResponseMocks } from './mock-request' -import { createIpcServer, createWorker } from './server-ipc' import { UnwrapPromise } from '../../lib/coalesced-function' import { getResolveRoutes } from './router-utils/resolve-routes' import { NextUrlWithParsedQuery, getRequestMeta } from '../request-meta' import { pathHasPrefix } from '../../shared/lib/router/utils/path-has-prefix' import { removePathPrefix } from '../../shared/lib/router/utils/remove-path-prefix' import setupCompression from 'next/dist/compiled/compression' +import { NoFallbackError } from '../base-server' +import { signalFromNodeResponse } from '../web/spec-extension/adapters/next-request' import { PHASE_PRODUCTION_SERVER, PHASE_DEVELOPMENT_SERVER, PERMANENT_REDIRECT_STATUS, } from '../../shared/lib/constants' -import { signalFromNodeResponse } from '../web/spec-extension/adapters/next-request' const debug = setupDebug('next:router-server:main') @@ -57,10 +55,18 @@ export interface RenderWorkers { pages?: Awaited> } +const devInstances: Record< + string, + UnwrapPromise> +> = {} + +const requestHandlers: Record = {} + export async function initialize(opts: { dir: string port: number dev: boolean + server?: import('http').Server minimalMode?: boolean hostname?: string workerType: 'router' | 'render' @@ -78,10 +84,7 @@ export async function initialize(opts: { const config = await loadConfig( opts.dev ? PHASE_DEVELOPMENT_SERVER : PHASE_PRODUCTION_SERVER, - opts.dir, - undefined, - undefined, - true + opts.dir ) let compress: ReturnType | undefined @@ -113,6 +116,7 @@ export async function initialize(opts: { const { setupDev } = (await require('./router-utils/setup-dev')) as typeof import('./router-utils/setup-dev') + devInstance = await setupDev({ // Passed here but the initialization of this object happens below, doing the initialization before the setupDev call breaks. renderWorkers, @@ -126,89 +130,84 @@ export async function initialize(opts: { turbo: !!process.env.TURBOPACK, port: opts.port, }) - } - - const { ipcPort, ipcValidationKey } = await createIpcServer({ - async ensurePage( - match: Parameters< - InstanceType< - typeof import('../dev/hot-reloader-webpack').default - >['ensurePage'] - >[0] - ) { - // TODO: remove after ensure is pulled out of server - return await devInstance?.hotReloader.ensurePage(match) - }, - async logErrorWithOriginalStack(...args: any[]) { - // @ts-ignore - return await devInstance?.logErrorWithOriginalStack(...args) - }, - async getFallbackErrorComponents() { - await devInstance?.hotReloader?.buildFallbackError() - // Build the error page to ensure the fallback is built too. - // TODO: See if this can be moved into hotReloader or removed. - await devInstance?.hotReloader.ensurePage({ - page: '/_error', - clientOnly: false, - }) - }, - async getCompilationError(page: string) { - const errors = await devInstance?.hotReloader?.getCompilationErrors(page) - if (!errors) return - - // Return the very first error we found. - return errors[0] - }, - async revalidate({ - urlPath, - revalidateHeaders, - opts: revalidateOpts, - }: { - urlPath: string - revalidateHeaders: IncomingMessage['headers'] - opts: any - }) { - const mocked = createRequestResponseMocks({ - url: urlPath, - headers: revalidateHeaders, - }) - - // eslint-disable-next-line @typescript-eslint/no-use-before-define - await requestHandler(mocked.req, mocked.res) - await mocked.res.hasStreamed - - if ( - mocked.res.getHeader('x-nextjs-cache') !== 'REVALIDATED' && - !( - mocked.res.statusCode === 404 && revalidateOpts.unstable_onlyGenerated + devInstances[opts.dir] = devInstance + ;(global as any)._nextDevHandlers = { + async ensurePage( + dir: string, + match: Parameters< + InstanceType< + typeof import('../dev/hot-reloader-webpack').default + >['ensurePage'] + >[0] + ) { + const curDevInstance = devInstances[dir] + // TODO: remove after ensure is pulled out of server + return await curDevInstance?.hotReloader.ensurePage(match) + }, + async logErrorWithOriginalStack(dir: string, ...args: any[]) { + const curDevInstance = devInstances[dir] + // @ts-ignore + return await curDevInstance?.logErrorWithOriginalStack(...args) + }, + async getFallbackErrorComponents(dir: string) { + const curDevInstance = devInstances[dir] + await curDevInstance.hotReloader.buildFallbackError() + // Build the error page to ensure the fallback is built too. + // TODO: See if this can be moved into hotReloader or removed. + await curDevInstance.hotReloader.ensurePage({ + page: '/_error', + clientOnly: false, + }) + }, + async getCompilationError(dir: string, page: string) { + const curDevInstance = devInstances[dir] + const errors = await curDevInstance?.hotReloader?.getCompilationErrors( + page ) + if (!errors) return + + // Return the very first error we found. + return errors[0] + }, + async revalidate( + dir: string, + { + urlPath, + revalidateHeaders, + opts: revalidateOpts, + }: { + urlPath: string + revalidateHeaders: IncomingMessage['headers'] + opts: any + } ) { - throw new Error(`Invalid response ${mocked.res.statusCode}`) - } - return {} - }, - } as any) - - // Set global environment variables for the app render server to use. - process.env.__NEXT_PRIVATE_ROUTER_IPC_PORT = ipcPort + '' - process.env.__NEXT_PRIVATE_ROUTER_IPC_KEY = ipcValidationKey - process.env.__NEXT_PRIVATE_PREBUNDLED_REACT = config.experimental - .serverActions - ? 'experimental' - : 'next' + const mocked = createRequestResponseMocks({ + url: urlPath, + headers: revalidateHeaders, + }) + const curRequestHandler = requestHandlers[dir] + // eslint-disable-next-line @typescript-eslint/no-use-before-define + await curRequestHandler(mocked.req, mocked.res) + await mocked.res.hasStreamed + + if ( + mocked.res.getHeader('x-nextjs-cache') !== 'REVALIDATED' && + !( + mocked.res.statusCode === 404 && + revalidateOpts.unstable_onlyGenerated + ) + ) { + throw new Error(`Invalid response ${mocked.res.statusCode}`) + } + return {} + }, + } as any + } renderWorkers.app = require('./render-server') as typeof import('./render-server') - const { initialEnv } = require('@next/env') as typeof import('@next/env') - renderWorkers.pages = await createWorker( - ipcPort, - ipcValidationKey, - opts.isNodeDebugging, - 'pages', - config, - initialEnv - ) + renderWorkers.pages = renderWorkers.app const renderWorkerOpts: Parameters[0] = { port: opts.port, @@ -217,65 +216,17 @@ export async function initialize(opts: { hostname: opts.hostname, minimalMode: opts.minimalMode, dev: !!opts.dev, + server: opts.server, isNodeDebugging: !!opts.isNodeDebugging, serverFields: devInstance?.serverFields || {}, experimentalTestProxy: !!opts.experimentalTestProxy, } // pre-initialize workers + const handlers = await renderWorkers.app?.initialize(renderWorkerOpts) const initialized = { - app: await renderWorkers.app?.initialize(renderWorkerOpts), - pages: await renderWorkers.pages?.initialize(renderWorkerOpts), - } - - if (devInstance) { - const originalNextDeleteCache = (global as any)._nextDeleteCache - ;(global as any)._nextDeleteCache = async (filePaths: string[]) => { - // Multiple instances of Next.js can be instantiated, since this is a global we have to call the original if it exists. - if (originalNextDeleteCache) { - await originalNextDeleteCache(filePaths) - } - try { - await Promise.all([ - renderWorkers.pages?.deleteCache(filePaths), - renderWorkers.app?.deleteCache(filePaths), - ]) - } catch (err) { - console.error(err) - } - } - const originalNextDeleteAppClientCache = (global as any) - ._nextDeleteAppClientCache - ;(global as any)._nextDeleteAppClientCache = async () => { - // Multiple instances of Next.js can be instantiated, since this is a global we have to call the original if it exists. - if (originalNextDeleteAppClientCache) { - await originalNextDeleteAppClientCache() - } - try { - await Promise.all([ - renderWorkers.pages?.deleteAppClientCache(), - renderWorkers.app?.deleteAppClientCache(), - ]) - } catch (err) { - console.error(err) - } - } - const originalNextClearModuleContext = (global as any) - ._nextClearModuleContext - ;(global as any)._nextClearModuleContext = async (targetPath: string) => { - // Multiple instances of Next.js can be instantiated, since this is a global we have to call the original if it exists. - if (originalNextClearModuleContext) { - await originalNextClearModuleContext() - } - try { - await Promise.all([ - renderWorkers.pages?.clearModuleContext(targetPath), - renderWorkers.app?.clearModuleContext(targetPath), - ]) - } catch (err) { - console.error(err) - } - } + app: handlers, + pages: handlers, } const logError = async ( @@ -331,8 +282,8 @@ export async function initialize(opts: { async function invokeRender( parsedUrl: NextUrlWithParsedQuery, type: keyof typeof renderWorkers, - handleIndex: number, invokePath: string, + handleIndex: number, additionalInvokeHeaders: Record = {} ) { // invokeRender expects /api routes to not be locale prefixed @@ -366,8 +317,6 @@ export async function initialize(opts: { throw new Error(`Failed to initialize render worker ${type}`) } - const renderUrl = `http://${workerResult.hostname}:${workerResult.port}${req.url}` - const invokeHeaders: typeof req.headers = { ...req.headers, 'x-middleware-invoke': '', @@ -375,20 +324,26 @@ export async function initialize(opts: { 'x-invoke-query': encodeURIComponent(JSON.stringify(parsedUrl.query)), ...(additionalInvokeHeaders || {}), } + Object.assign(req.headers, invokeHeaders) - debug('invokeRender', renderUrl, invokeHeaders) + debug('invokeRender', req.url, invokeHeaders) - let invokeRes try { - invokeRes = await invokeRequest( - renderUrl, - { - headers: invokeHeaders, - method: req.method, - signal: signalFromNodeResponse(res), - }, - getRequestMeta(req, '__NEXT_CLONABLE_BODY')?.cloneBodyStream() + const initResult = await renderWorkers.pages?.initialize( + renderWorkerOpts ) + + try { + await initResult?.requestHandler(req, res) + } catch (err) { + if (err instanceof NoFallbackError) { + // eslint-disable-next-line + await handleRequest(handleIndex + 1) + return + } + throw err + } + return } catch (e) { // If the client aborts before we can receive a response object (when // the headers are flushed), then we can early exit without further @@ -398,54 +353,6 @@ export async function initialize(opts: { } throw e } - - debug('invokeRender res', invokeRes.status, invokeRes.headers) - - // when we receive x-no-fallback we restart - if (invokeRes.headers.get('x-no-fallback')) { - // eslint-disable-next-line - await handleRequest(handleIndex + 1) - return - } - - for (const [key, value] of Object.entries( - filterReqHeaders( - toNodeOutgoingHttpHeaders(invokeRes.headers), - ipcForbiddenHeaders - ) - )) { - if (value !== undefined) { - if (key === 'set-cookie') { - const curValue = res.getHeader(key) as string - const newValue: string[] = [] as string[] - - for (const cookie of Array.isArray(curValue) - ? curValue - : splitCookiesString(curValue || '')) { - newValue.push(cookie) - } - for (const val of (Array.isArray(value) - ? value - : value - ? [value] - : []) as string[]) { - newValue.push(val) - } - res.setHeader(key, newValue) - } else { - res.setHeader(key, value as string) - } - } - } - res.statusCode = invokeRes.status || 200 - res.statusMessage = invokeRes.statusText || '' - - if (invokeRes.body) { - await pipeReadable(invokeRes.body, res) - } else { - res.end() - } - return } const handleRequest = async (handleIndex: number) => { @@ -483,11 +390,16 @@ export async function initialize(opts: { matchedOutput, } = await resolveRoutes({ req, + res, isUpgradeReq: false, signal: signalFromNodeResponse(res), invokedOutputs, }) + if (res.closed || res.finished) { + return + } + if (devInstance && matchedOutput?.type === 'devVirtualFsItem') { const origUrl = req.url || '/' @@ -561,7 +473,8 @@ export async function initialize(opts: { (fsChecker.appFiles.has(matchedOutput.itemPath) || fsChecker.pageFiles.has(matchedOutput.itemPath)) ) { - await invokeRender(parsedUrl, 'pages', handleIndex, '/_error', { + res.statusCode = 500 + await invokeRender(parsedUrl, 'pages', '/_error', handleIndex, { 'x-invoke-status': '500', 'x-invoke-error': JSON.stringify({ message: `A conflicting public file and page file was found for path ${matchedOutput.itemPath} https://nextjs.org/docs/messages/conflicting-public-file-page`, @@ -585,11 +498,12 @@ export async function initialize(opts: { } if (!(req.method === 'GET' || req.method === 'HEAD')) { res.setHeader('Allow', ['GET', 'HEAD']) + res.statusCode = 405 return await invokeRender( url.parse('/405', true), 'pages', - handleIndex, '/405', + handleIndex, { 'x-invoke-status': '405', } @@ -648,11 +562,12 @@ export async function initialize(opts: { if (typeof err.statusCode === 'number') { const invokePath = `/${err.statusCode}` const invokeStatus = `${err.statusCode}` + res.statusCode = err.statusCode return await invokeRender( url.parse(invokePath, true), 'pages', - handleIndex, invokePath, + handleIndex, { 'x-invoke-status': invokeStatus, } @@ -668,8 +583,8 @@ export async function initialize(opts: { return await invokeRender( parsedUrl, matchedOutput.type === 'appFile' ? 'app' : 'pages', - handleIndex, parsedUrl.pathname || '/', + handleIndex, { 'x-invoke-output': matchedOutput.itemPath, } @@ -693,18 +608,21 @@ export async function initialize(opts: { ? devInstance?.serverFields.hasAppNotFound : await fsChecker.getItem('/_not-found') + res.statusCode = 404 + if (appNotFound) { return await invokeRender( parsedUrl, 'app', - handleIndex, opts.dev ? '/not-found' : '/_not-found', + handleIndex, { 'x-invoke-status': '404', } ) } - await invokeRender(parsedUrl, 'pages', handleIndex, '/404', { + + await invokeRender(parsedUrl, 'pages', '/404', handleIndex, { 'x-invoke-status': '404', }) } @@ -722,11 +640,12 @@ export async function initialize(opts: { } else { console.error(err) } + res.statusCode = Number(invokeStatus) return await invokeRender( url.parse(invokePath, true), 'pages', - 0, invokePath, + 0, { 'x-invoke-status': invokeStatus, } @@ -749,6 +668,7 @@ export async function initialize(opts: { requestHandler = wrapRequestHandlerWorker(requestHandler) interceptTestApis() } + requestHandlers[opts.dir] = requestHandler const upgradeHandler: WorkerUpgradeHandler = async (req, socket, head) => { try { @@ -769,6 +689,7 @@ export async function initialize(opts: { const { matchedOutput, parsedUrl } = await resolveRoutes({ req, + res: socket as any, isUpgradeReq: true, signal: signalFromNodeResponse(socket), }) diff --git a/packages/next/src/server/lib/router-utils/resolve-routes.ts b/packages/next/src/server/lib/router-utils/resolve-routes.ts index 3ae8d5995036d..11097b180bc8c 100644 --- a/packages/next/src/server/lib/router-utils/resolve-routes.ts +++ b/packages/next/src/server/lib/router-utils/resolve-routes.ts @@ -1,6 +1,6 @@ import type { TLSSocket } from 'tls' import type { FsOutput } from './filesystem' -import type { IncomingMessage } from 'http' +import type { IncomingMessage, ServerResponse } from 'http' import type { NextConfigComplete } from '../../config-shared' import type { RenderWorker, initialize } from '../router-server' import type { PatchMatcher } from '../../../shared/lib/router/utils/path-match' @@ -14,7 +14,6 @@ import { Header } from '../../../lib/load-custom-routes' import { stringifyQuery } from '../../server-route-utils' import { formatHostname } from '../format-hostname' import { toNodeOutgoingHttpHeaders } from '../../web/utils' -import { invokeRequest } from '../server-ipc/invoke-request' import { isAbortError } from '../../pipe-readable' import { getCookieParser, setLazyProp } from '../../api-utils' import { getHostname } from '../../../shared/lib/get-hostname' @@ -28,16 +27,15 @@ import { detectDomainLocale } from '../../../shared/lib/i18n/detect-domain-local import { normalizeLocalePath } from '../../../shared/lib/i18n/normalize-locale-path' import { removePathPrefix } from '../../../shared/lib/router/utils/remove-path-prefix' -import { - NextUrlWithParsedQuery, - addRequestMeta, - getRequestMeta, -} from '../../request-meta' +import { NextUrlWithParsedQuery, addRequestMeta } from '../../request-meta' import { compileNonPath, matchHas, prepareDestination, } from '../../../shared/lib/router/utils/prepare-destination' +import { createRequestResponseMocks } from '../mock-request' + +import '../../node-polyfill-web-streams' const debug = setupDebug('next:router-server:resolve-routes') @@ -98,11 +96,12 @@ export function getResolveRoutes( async function resolveRoutes({ req, + res, isUpgradeReq, - signal, invokedOutputs, }: { req: IncomingMessage + res: ServerResponse isUpgradeReq: boolean signal: AbortSignal invokedOutputs?: Set @@ -445,44 +444,59 @@ export function getResolveRoutes( if (!workerResult) { throw new Error(`Failed to initialize render worker "middleware"`) } - const stringifiedQuery = stringifyQuery( - req as any, - getRequestMeta(req, '__NEXT_INIT_QUERY') || {} - ) - const parsedInitUrl = new URL( - getRequestMeta(req, '__NEXT_INIT_URL') || '/', - 'http://n' - ) - - const curUrl = config.skipMiddlewareUrlNormalize - ? `${parsedInitUrl.pathname}${parsedInitUrl.search}` - : `${parsedUrl.pathname}${stringifiedQuery ? '?' : ''}${ - stringifiedQuery || '' - }` - - const renderUrl = `http://${workerResult.hostname}:${workerResult.port}${curUrl}` const invokeHeaders: typeof req.headers = { - ...req.headers, 'x-invoke-path': '', 'x-invoke-query': '', 'x-invoke-output': '', 'x-middleware-invoke': '1', } + Object.assign(req.headers, invokeHeaders) - debug('invoking middleware', renderUrl, invokeHeaders) + debug('invoking middleware', req.url, invokeHeaders) - let middlewareRes + let middlewareRes: Response | undefined = undefined + let bodyStream: ReadableStream | undefined = undefined try { - middlewareRes = await invokeRequest( - renderUrl, - { - headers: invokeHeaders, - method: req.method, - signal, + let readableController: ReadableStreamController + const { res: mockedRes } = await createRequestResponseMocks({ + url: req.url || '/', + method: req.method || 'GET', + headers: filterReqHeaders(invokeHeaders, ipcForbiddenHeaders), + resWriter(chunk) { + readableController.enqueue(Buffer.from(chunk)) + return true }, - getRequestMeta(req, '__NEXT_CLONABLE_BODY')?.cloneBodyStream() + }) + + const initResult = await renderWorkers.pages?.initialize( + renderWorkerOpts ) + + mockedRes.on('close', () => { + readableController.close() + }) + + try { + await initResult?.requestHandler(req, res, parsedUrl) + } catch (err: any) { + if (!('result' in err) || !('response' in err.result)) { + throw err + } + middlewareRes = err.result.response as Response + res.statusCode = middlewareRes.status + + if (middlewareRes.body) { + bodyStream = middlewareRes.body + } else if (middlewareRes.status) { + bodyStream = new ReadableStream({ + start(controller) { + controller.enqueue('') + controller.close() + }, + }) + } + } } catch (e) { // If the client aborts before we can receive a response object // (when the headers are flushed), then we can early exit without @@ -497,6 +511,14 @@ export function getResolveRoutes( throw e } + if (res.closed || res.finished || !middlewareRes) { + return { + parsedUrl, + resHeaders, + finished: true, + } + } + const middlewareHeaders = toNodeOutgoingHttpHeaders( middlewareRes.headers ) as Record @@ -622,7 +644,7 @@ export function getResolveRoutes( parsedUrl, resHeaders, finished: true, - bodyStream: middlewareRes.body, + bodyStream, statusCode: middlewareRes.status, } } diff --git a/packages/next/src/server/lib/router-utils/setup-dev.ts b/packages/next/src/server/lib/router-utils/setup-dev.ts index 425506a3d3460..c0019bff3ed29 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev.ts @@ -103,6 +103,7 @@ import { TurboPackConnectedAction, } from '../../dev/hot-reloader-types' import { debounce } from '../../utils' +import { deleteCache } from '../../../build/webpack/plugins/nextjs-require-cache-hot-reloader' import { normalizeMetadataRoute } from '../../../lib/metadata/get-metadata-route' const wsServer = new ws.Server({ noServer: true }) @@ -157,8 +158,8 @@ async function startWatcher(opts: SetupOpts) { ) async function propagateToWorkers(field: PropagateToWorkersField, args: any) { - await opts.renderWorkers.app?.propagateServerField(field, args) - await opts.renderWorkers.pages?.propagateServerField(field, args) + await opts.renderWorkers.app?.propagateServerField(opts.dir, field, args) + await opts.renderWorkers.pages?.propagateServerField(opts.dir, field, args) } const serverFields: { @@ -310,21 +311,20 @@ async function startWatcher(opts: SetupOpts) { async function processResult( result: TurbopackResult ): Promise> { - await (global as any)._nextDeleteCache?.( - result.serverPaths - .map((p) => path.join(distDir, p)) - .concat([ - // We need to clear the chunk cache in react - require.resolve( - 'next/dist/compiled/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js' - ), - // And this redirecting module as well - require.resolve( - 'next/dist/compiled/react-server-dom-webpack/client.edge.js' - ), - ]) - ) - + for (const file of result.serverPaths + .map((p) => path.join(distDir, p)) + .concat([ + // We need to clear the chunk cache in react + require.resolve( + 'next/dist/compiled/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js' + ), + // And this redirecting module as well + require.resolve( + 'next/dist/compiled/react-server-dom-webpack/client.edge.js' + ), + ])) { + deleteCache(file) + } return result } @@ -380,8 +380,7 @@ async function startWatcher(opts: SetupOpts) { sendHmrDebounce() } - const clearCache = (filePath: string) => - (global as any)._nextDeleteCache?.([filePath]) + const clearCache = (filePath: string) => deleteCache(filePath) async function loadPartialManifest( name: string, diff --git a/packages/next/src/server/lib/setup-server-worker.ts b/packages/next/src/server/lib/setup-server-worker.ts index ba7d195fa4ff2..eb08e1bc5d1da 100644 --- a/packages/next/src/server/lib/setup-server-worker.ts +++ b/packages/next/src/server/lib/setup-server-worker.ts @@ -17,7 +17,7 @@ process.on('uncaughtException', (err) => { console.error(err) }) -export const WORKER_SELF_EXIT_CODE = 77 +export const RESTART_EXIT_CODE = 77 const MAXIMUM_HEAP_SIZE_ALLOWED = (v8.getHeapStatistics().heap_size_limit / 1024 / 1024) * 0.9 @@ -67,7 +67,7 @@ export async function initializeServerWorker( 'The server is running out of memory, restarting to free up memory.' ) server.close() - process.exit(WORKER_SELF_EXIT_CODE) + process.exit(RESTART_EXIT_CODE) } }) }) diff --git a/packages/next/src/server/lib/start-server.ts b/packages/next/src/server/lib/start-server.ts index 656162e850b84..b6e43bbb10130 100644 --- a/packages/next/src/server/lib/start-server.ts +++ b/packages/next/src/server/lib/start-server.ts @@ -1,21 +1,26 @@ +import '../next' import '../node-polyfill-fetch' import '../require-hook' import type { IncomingMessage, ServerResponse } from 'http' +import fs from 'fs' +import path from 'path' import http from 'http' import https from 'https' +import Watchpack from 'watchpack' import * as Log from '../../build/output/log' import setupDebug from 'next/dist/compiled/debug' import { getDebugPort } from './utils' import { formatHostname } from './format-hostname' import { initialize } from './router-server' -import fs from 'fs' import { + RESTART_EXIT_CODE, WorkerRequestHandler, WorkerUpgradeHandler, } from './setup-server-worker' import { checkIsNodeDebugging } from './is-node-debugging' +import { CONFIG_FILES } from '../../shared/lib/constants' import chalk from '../../lib/chalk' const debug = setupDebug('next:start-server') @@ -50,6 +55,7 @@ export async function getRequestHandlers({ dir, port, isDev, + server, hostname, minimalMode, isNodeDebugging, @@ -59,6 +65,7 @@ export async function getRequestHandlers({ dir: string port: number isDev: boolean + server?: import('http').Server hostname: string minimalMode?: boolean isNodeDebugging?: boolean @@ -71,6 +78,7 @@ export async function getRequestHandlers({ hostname, dev: isDev, minimalMode, + server, workerType: 'router', isNodeDebugging: isNodeDebugging || false, keepAliveTimeout, @@ -261,10 +269,10 @@ export async function startServer({ } try { - const cleanup = () => { + const cleanup = (code: number | null) => { debug('start-server process cleanup') server.close() - process.exit(0) + process.exit(code ?? 0) } const exception = (err: Error) => { // This is the render worker, we keep the process alive @@ -280,6 +288,7 @@ export async function startServer({ dir, port, isDev, + server, hostname, minimalMode, isNodeDebugging: Boolean(isNodeDebugging), @@ -300,4 +309,34 @@ export async function startServer({ }) server.listen(port, hostname) }) + + if (isDev) { + function watchConfigFiles( + dirToWatch: string, + onChange: (filename: string) => void + ) { + const wp = new Watchpack() + wp.watch({ + files: CONFIG_FILES.map((file) => path.join(dirToWatch, file)), + }) + wp.on('change', onChange) + } + watchConfigFiles(dir, async (filename) => { + if (process.env.__NEXT_DISABLE_MEMORY_WATCHER) { + Log.info( + `Detected change, manual restart required due to '__NEXT_DISABLE_MEMORY_WATCHER' usage` + ) + return + } + + // Adding a new line to avoid the logs going directly after the spinner in `next build` + Log.warn('') + Log.warn( + `Found a change in ${path.basename( + filename + )}. Restarting the server to apply the changes...` + ) + process.exit(RESTART_EXIT_CODE) + }) + } } diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 656db3bbb5fb6..7153958ef0e2f 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -43,6 +43,7 @@ import { SERVER_DIRECTORY, NEXT_FONT_MANIFEST, PHASE_PRODUCTION_BUILD, + INTERNAL_HEADERS, } from '../shared/lib/constants' import { findDir } from '../lib/find-pages-dir' import { UrlWithParsedQuery } from 'url' @@ -918,11 +919,7 @@ export default class NextNodeServer extends BaseServer { } catch (err: any) { if (err instanceof NoFallbackError) { if (this.isRenderWorker) { - res.setHeader('x-no-fallback', '1') - res.send() - return { - finished: true, - } + throw err } return { @@ -1000,13 +997,17 @@ export default class NextNodeServer extends BaseServer { private normalizeReq( req: BaseNextRequest | IncomingMessage ): BaseNextRequest { - return req instanceof IncomingMessage ? new NodeNextRequest(req) : req + return !(req instanceof NodeNextRequest) + ? new NodeNextRequest(req as IncomingMessage) + : req } private normalizeRes( res: BaseNextResponse | ServerResponse ): BaseNextResponse { - return res instanceof ServerResponse ? new NodeNextResponse(res) : res + return !(res instanceof NodeNextResponse) + ? new NodeNextResponse(res as ServerResponse) + : res } public getRequestHandler(): NodeRequestHandler { @@ -1464,7 +1465,9 @@ export default class NextNodeServer extends BaseServer { checkIsOnDemandRevalidate(params.request, this.renderOpts.previewProps) .isOnDemandRevalidate ) { - return { finished: false } + return { + response: new Response(null, { headers: { 'x-middleware-next': '1' } }), + } as FetchEventResult } let url: string @@ -1611,6 +1614,11 @@ export default class NextNodeServer extends BaseServer { let result: Awaited< ReturnType > + let bubblingResult = false + + for (const key of INTERNAL_HEADERS) { + delete req.headers[key] + } // Strip the internal headers. this.stripInternalHeaders(req) @@ -1625,7 +1633,15 @@ export default class NextNodeServer extends BaseServer { parsed: parsed, }) - if (isMiddlewareInvoke && 'response' in result) { + if ('response' in result) { + if (isMiddlewareInvoke) { + bubblingResult = true + const err = new Error() + ;(err as any).result = result + ;(err as any).bubble = true + throw err + } + for (const [key, value] of Object.entries( toNodeOutgoingHttpHeaders(result.response.headers) )) { @@ -1643,7 +1659,11 @@ export default class NextNodeServer extends BaseServer { } return { finished: true } } - } catch (err) { + } catch (err: any) { + if (bubblingResult) { + throw err + } + if (isError(err) && err.code === 'ENOENT') { await this.render404(req, res, parsed) return { finished: true } @@ -1651,14 +1671,14 @@ export default class NextNodeServer extends BaseServer { if (err instanceof DecodeError) { res.statusCode = 400 - this.renderError(err, req, res, parsed.pathname || '') + await this.renderError(err, req, res, parsed.pathname || '') return { finished: true } } const error = getProperError(err) console.error(error) res.statusCode = 500 - this.renderError(error, req, res, parsed.pathname || '') + await this.renderError(error, req, res, parsed.pathname || '') return { finished: true } } diff --git a/packages/next/src/server/next.ts b/packages/next/src/server/next.ts index 6cdd4282007b9..0c1ddd2bc6f11 100644 --- a/packages/next/src/server/next.ts +++ b/packages/next/src/server/next.ts @@ -4,6 +4,56 @@ import type { UrlWithParsedQuery } from 'url' import type { NextConfigComplete } from './config-shared' import type { IncomingMessage, ServerResponse } from 'http' import type { NextUrlWithParsedQuery } from './request-meta' +import { spawnSync } from 'child_process' +import { getEsmLoaderPath } from './lib/get-esm-loader-path' +import { + RESTART_EXIT_CODE, + WorkerRequestHandler, + WorkerUpgradeHandler, +} from './lib/setup-server-worker' + +// if we are not inside of the esm loader enabled +// worker we need to re-spawn with correct args +// we can't do this if imported in jest test file otherwise +// it duplicates tests +if ( + typeof jest === 'undefined' && + !process.env.NEXT_PRIVATE_WORKER && + (process.env.__NEXT_PRIVATE_PREBUNDLED_REACT || + process.env.NODE_ENV === 'development') +) { + const nodePath = process.argv0 + + const newArgs = [ + '--experimental-loader', + getEsmLoaderPath(), + '--no-warnings', + ...process.argv.splice(1), + ] + function startWorker() { + try { + const result = spawnSync(nodePath, newArgs, { + stdio: 'inherit', + env: { + ...process.env, + NEXT_PRIVATE_WORKER: '1', + }, + }) + + if ( + result.status === RESTART_EXIT_CODE && + process.env.NODE_ENV === 'development' + ) { + startWorker() + } + process.exit(0) + } catch (err) { + console.error(err) + process.exit(1) + } + } + startWorker() +} import './require-hook' import './node-polyfill-fetch' @@ -19,10 +69,6 @@ import { PHASE_PRODUCTION_SERVER } from '../shared/lib/constants' import { getTracer } from './lib/trace/tracer' import { NextServerSpan } from './lib/trace/constants' import { formatUrl } from '../shared/lib/router/utils/format-url' -import { - WorkerRequestHandler, - WorkerUpgradeHandler, -} from './lib/setup-server-worker' import { checkIsNodeDebugging } from './lib/is-node-debugging' let ServerImpl: typeof Server diff --git a/packages/next/src/server/require-hook.js b/packages/next/src/server/require-hook.js new file mode 100644 index 0000000000000..ce9e30e6e9ffe --- /dev/null +++ b/packages/next/src/server/require-hook.js @@ -0,0 +1,52 @@ +// Synchronously inject a require hook for webpack and webpack/. It's required to use the internal ncc webpack version. +// This is needed for userland plugins to attach to the same webpack instance as Next.js'. +// Individually compiled modules are as defined for the compilation in bundles/webpack/packages/*. + +// This module will only be loaded once per process. +const path = require('path') +const mod = require('module') +const originalRequire = mod.prototype.require +const resolveFilename = mod._resolveFilename + +const { overrideReact, hookPropertyMap } = require('./import-overrides') + +mod._resolveFilename = function ( + originalResolveFilename, + requestMap, + request, + parent, + isMain, + options +) { + // In case the environment variable is set after the module is loaded. + overrideReact() + + const hookResolved = requestMap.get(request) + if (hookResolved) request = hookResolved + + return originalResolveFilename.call(mod, request, parent, isMain, options) + + // We use `bind` here to avoid referencing outside variables to create potential memory leaks. +}.bind(null, resolveFilename, hookPropertyMap) + +// This is a hack to make sure that if a user requires a Next.js module that wasn't bundled +// that needs to point to the rendering runtime version, it will point to the correct one. +// This can happen on `pages` when a user requires a dependency that uses next/image for example. +// This is only needed in production as in development we fallback to the external version. +if (process.env.NODE_ENV !== 'development' && !process.env.TURBOPACK) { + mod.prototype.require = function (request) { + if (request.endsWith('.shared-runtime')) { + const isAppRequire = process.env.__NEXT_PRIVATE_RUNTIME_TYPE === 'app' + const currentRuntime = `${ + isAppRequire + ? 'next/dist/compiled/next-server/app-page.runtime' + : 'next/dist/compiled/next-server/pages.runtime' + }.prod` + const base = path.basename(request, '.shared-runtime') + const camelized = base.replace(/-([a-z])/g, (g) => g[1].toUpperCase()) + const instance = originalRequire.call(this, currentRuntime) + return instance.default.sharedModules[camelized] + } + return originalRequire.call(this, request) + } +} diff --git a/packages/next/src/server/require.ts b/packages/next/src/server/require.ts index bbc8e91dc2835..f020ea27e7eba 100644 --- a/packages/next/src/server/require.ts +++ b/packages/next/src/server/require.ts @@ -116,10 +116,18 @@ export function requirePage( }) } - return process.env.NEXT_MINIMAL - ? // @ts-ignore - __non_webpack_require__(pagePath) - : require(pagePath) + // since require is synchronous we can set the specific runtime + // we are requiring for the require-hook and then clear after + try { + process.env.__NEXT_PRIVATE_RUNTIME_TYPE = isAppPath ? 'app' : 'pages' + const mod = process.env.NEXT_MINIMAL + ? // @ts-ignore + __non_webpack_require__(pagePath) + : require(pagePath) + return mod + } finally { + process.env.__NEXT_PRIVATE_RUNTIME_TYPE = '' + } } export function requireFontManifest(distDir: string) { diff --git a/packages/next/src/shared/lib/constants.ts b/packages/next/src/shared/lib/constants.ts index 3d0f78e7bd018..86a1c2059b585 100644 --- a/packages/next/src/shared/lib/constants.ts +++ b/packages/next/src/shared/lib/constants.ts @@ -10,6 +10,14 @@ export const COMPILER_NAMES = { edgeServer: 'edge-server', } as const +export const INTERNAL_HEADERS = [ + 'x-invoke-path', + 'x-invoke-status', + 'x-invoke-error', + 'x-invoke-query', + 'x-middleware-invoke', +] as const + export type CompilerNameValues = ValueOf export const COMPILER_INDEXES: { diff --git a/packages/next/taskfile-swc.js b/packages/next/taskfile-swc.js index 8ff3427c60e36..0d28ca9d0b5c0 100644 --- a/packages/next/taskfile-swc.js +++ b/packages/next/taskfile-swc.js @@ -140,7 +140,10 @@ module.exports = function (task) { if (ext) { const extRegex = new RegExp(ext.replace('.', '\\.') + '$', 'i') // Remove the extension if stripExtension is enabled or replace it with `.js` - file.base = file.base.replace(extRegex, stripExtension ? '' : '.js') + file.base = file.base.replace( + extRegex, + stripExtension ? '' : `.${ext === '.mts' ? 'm' : ''}js` + ) } if (output.map) { diff --git a/packages/next/taskfile.js b/packages/next/taskfile.js index 321490234153b..43da4071afb25 100644 --- a/packages/next/taskfile.js +++ b/packages/next/taskfile.js @@ -2443,7 +2443,7 @@ export async function server(task, opts) { export async function server_esm(task, opts) { await task - .source('src/server/**/!(*.test).+(js|ts|tsx)') + .source('src/server/**/!(*.test).+(js|mts|ts|tsx)') .swc('server', { dev: opts.dev, esm: true }) .target('dist/esm/server') } diff --git a/test/development/basic/node-builtins.test.ts b/test/development/basic/node-builtins.test.ts index 27797f6c564d0..3c5c6a6a335f8 100644 --- a/test/development/basic/node-builtins.test.ts +++ b/test/development/basic/node-builtins.test.ts @@ -86,7 +86,7 @@ createNextDescribe( expect(parsedData.https).toBe(true) expect(parsedData.os).toBe('\n') expect(parsedData.path).toBe('/hello/world/test.txt') - expect(parsedData.process).toInclude('next-render-worker-pages') + expect(parsedData.process).toInclude('next-router-worker') expect(parsedData.querystring).toBe('a=b') expect(parsedData.stringDecoder).toBe(true) expect(parsedData.sys).toBe(true) @@ -112,7 +112,7 @@ createNextDescribe( expect(parsedData.https).toBe(true) expect(parsedData.os).toBe('\n') expect(parsedData.path).toBe('/hello/world/test.txt') - expect(parsedData.process).toInclude('next-render-worker-pages') + expect(parsedData.process).toInclude('next-router-worker') expect(parsedData.querystring).toBe('a=b') expect(parsedData.stringDecoder).toBe(true) expect(parsedData.sys).toBe(true) diff --git a/test/development/watch-config-file/index.test.ts b/test/development/watch-config-file/index.test.ts index 4051ffe312bf6..1d7ddb36f3e11 100644 --- a/test/development/watch-config-file/index.test.ts +++ b/test/development/watch-config-file/index.test.ts @@ -9,28 +9,28 @@ createNextDescribe( ({ next }) => { it('should output config file change', async () => { await check(async () => next.cliOutput, /ready/) - await next.patchFile( - 'next.config.js', - ` - const nextConfig = { - reactStrictMode: true, - async redirects() { - return [ - { - source: '/about', - destination: '/', - permanent: false, - }, - ] - }, - } - module.exports = nextConfig` - ) - await check( - async () => next.cliOutput, - /Found a change in next\.config\.js\. Restarting the server to apply the changes\.\.\./ - ) + await check(async () => { + await next.patchFile( + 'next.config.js', + ` + console.log(${Date.now()}) + const nextConfig = { + reactStrictMode: true, + async redirects() { + return [ + { + source: '/about', + destination: '/', + permanent: false, + }, + ] + }, + } + module.exports = nextConfig` + ) + return next.cliOutput + }, /Found a change in next\.config\.js\. Restarting the server to apply the changes\.\.\./) await check(() => next.fetch('/about').then((res) => res.status), 200) }) diff --git a/test/e2e/app-dir/app/index.test.ts b/test/e2e/app-dir/app/index.test.ts index 2a6d221a5cf9f..2c0bc2045e431 100644 --- a/test/e2e/app-dir/app/index.test.ts +++ b/test/e2e/app-dir/app/index.test.ts @@ -305,9 +305,7 @@ createNextDescribe( const res = await next.fetch('/dashboard') expect(res.headers.get('x-edge-runtime')).toBe('1') expect(res.headers.get('vary')).toBe( - isNextDeploy - ? 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url' - : 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url, Accept-Encoding' + 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url' ) }) diff --git a/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts b/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts index 1f83176fa745f..fc1916897c808 100644 --- a/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts +++ b/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts @@ -450,8 +450,8 @@ createNextDescribe( expect(await res.text()).toBe('Hello from import-test.js') }) - it('should use stable react for pages', async () => { - const ssrPaths = ['/pages-react', '/pages-react-edge'] + it('should use bundled react for pages with app', async () => { + const ssrPaths = ['/pages-react', '/edge-pages-react'] const promises = ssrPaths.map(async (pathname) => { const resPages$ = await next.render$(pathname) const ssrPagesReactVersions = [ @@ -461,7 +461,7 @@ createNextDescribe( ] ssrPagesReactVersions.forEach((version) => { - expect(version).not.toMatch('-canary-') + expect(version).toMatch('-canary-') }) }) await Promise.all(promises) @@ -496,10 +496,10 @@ createNextDescribe( `) browserPagesReactVersions.forEach((version) => - expect(version).not.toMatch('-canary-') + expect(version).toMatch('-canary-') ) browserEdgePagesReactVersions.forEach((version) => - expect(version).not.toMatch('-canary-') + expect(version).toMatch('-canary-') ) }) diff --git a/test/integration/amphtml-fragment-style/pages/index.js b/test/integration/amphtml-fragment-style/pages/index.js index f57bb4c1e36fe..c45defad01f66 100644 --- a/test/integration/amphtml-fragment-style/pages/index.js +++ b/test/integration/amphtml-fragment-style/pages/index.js @@ -1,6 +1,6 @@ export const config = { amp: true } -export default () => ( +const Comp = () => (

Hello world!

) + +Comp.getInitialProps = () => ({}) +export default Comp diff --git a/test/integration/api-body-parser/pages/api/index.js b/test/integration/api-body-parser/pages/api/index.js index 28ef9244fcefe..bb894d917ddfc 100644 --- a/test/integration/api-body-parser/pages/api/index.js +++ b/test/integration/api-body-parser/pages/api/index.js @@ -1,5 +1,12 @@ -export default ({ method, body }, res) => { - if (method === 'POST') { - res.status(200).json(body) +export default (req, res) => { + if ( + process.env.CUSTOM_SERVER && + typeof req.fromCustomServer === 'undefined' + ) { + throw new Error('missing custom req field') + } + + if (req.method === 'POST') { + res.status(200).json(req.body) } } diff --git a/test/integration/api-body-parser/server.js b/test/integration/api-body-parser/server.js index 0f786557e5fce..f37ee6845f7db 100644 --- a/test/integration/api-body-parser/server.js +++ b/test/integration/api-body-parser/server.js @@ -14,6 +14,7 @@ app.prepare().then(() => { server.use(express.json({ limit: '5mb' })) server.all('*', (req, res) => { + req.fromCustomServer = true handleNextRequests(req, res) }) diff --git a/test/integration/api-body-parser/test/index.test.js b/test/integration/api-body-parser/test/index.test.js index 83501cfa63727..ae6d540c14462 100644 --- a/test/integration/api-body-parser/test/index.test.js +++ b/test/integration/api-body-parser/test/index.test.js @@ -27,9 +27,7 @@ function runTests() { killApp(app) }) - // TODO: we can't allow req fields with the proxying required for separate - // workers - it.skip('should not throw if request body is already parsed in custom middleware', async () => { + it('should not throw if request body is already parsed in custom middleware', async () => { await startServer() const data = await makeRequest() expect(data).toEqual([{ title: 'Nextjs' }]) @@ -71,7 +69,11 @@ async function makeRequestWithInvalidContentType() { const startServer = async (optEnv = {}, opts) => { const scriptPath = join(appDir, 'server.js') context.appPort = appPort = await getPort() - const env = Object.assign({ ...process.env }, { PORT: `${appPort}` }, optEnv) + const env = Object.assign( + { ...process.env }, + { PORT: `${appPort}`, CUSTOM_SERVER: 'true' }, + optEnv + ) server = await initNextServerScript( scriptPath, diff --git a/test/integration/cli/test/index.test.js b/test/integration/cli/test/index.test.js index b1df03915f75b..06bc0fe2eb993 100644 --- a/test/integration/cli/test/index.test.js +++ b/test/integration/cli/test/index.test.js @@ -8,6 +8,7 @@ import { nextBuild, runNextCommand, runNextCommandDev, + killProcess, } from 'next-test-utils' import fs from 'fs-extra' import path, { join } from 'path' @@ -21,7 +22,8 @@ const dirDuplicateSass = join(__dirname, '../duplicate-sass') const testExitSignal = async ( killSignal = '', args = [], - readyRegex = /Creating an optimized production/ + readyRegex = /Creating an optimized production/, + expectedExitSignal ) => { let instance const killSigint = (inst) => { @@ -38,14 +40,18 @@ const testExitSignal = async ( }).catch((err) => expect.fail(err.message)) await check(() => output, readyRegex) - instance.kill(killSignal) + await killProcess(instance.pid, killSignal) const { code, signal } = await cmdPromise - // Node can only partially emulate signals on Windows. Our signal handlers won't affect the exit code. - // See: https://nodejs.org/api/process.html#process_signal_events - const expectedExitSignal = process.platform === `win32` ? killSignal : null - expect(signal).toBe(expectedExitSignal) - expect(code).toBe(0) + + if (!expectedExitSignal) { + // Node can only partially emulate signals on Windows. Our signal handlers won't affect the exit code. + // See: https://nodejs.org/api/process.html#process_signal_events + expectedExitSignal = process.platform === `win32` ? killSignal : null + expect(code).toBe(0) + } else { + expect(signal).toBe(expectedExitSignal) + } } describe('CLI Usage', () => { @@ -628,11 +634,21 @@ describe('CLI Usage', () => { test('should exit when SIGINT is signalled', async () => { const port = await findPort() - await testExitSignal('SIGINT', ['dev', dirBasic, '-p', port], /- Local:/) + await testExitSignal( + 'SIGINT', + ['dev', dirBasic, '-p', port], + /- Local:/, + 'SIGINT' + ) }) test('should exit when SIGTERM is signalled', async () => { const port = await findPort() - await testExitSignal('SIGTERM', ['dev', dirBasic, '-p', port], /- Local:/) + await testExitSignal( + 'SIGTERM', + ['dev', dirBasic, '-p', port], + /- Local:/, + 'SIGTERM' + ) }) test('invalid directory', async () => { diff --git a/test/integration/middleware-overrides-node.js-api/middleware.js b/test/integration/middleware-overrides-node.js-api/middleware.js index 1bea9d9ca3cb3..a9110fb87f022 100644 --- a/test/integration/middleware-overrides-node.js-api/middleware.js +++ b/test/integration/middleware-overrides-node.js-api/middleware.js @@ -1,5 +1,5 @@ export default function middleware() { process.cwd = () => 'fixed-value' - console.log(process.cwd(), process.env) + console.log(process.cwd(), !!process.env) return new Response() } diff --git a/test/lib/next-test-utils.ts b/test/lib/next-test-utils.ts index 5e28fd7c602a1..792d333380fce 100644 --- a/test/lib/next-test-utils.ts +++ b/test/lib/next-test-utils.ts @@ -509,9 +509,12 @@ export function buildTS( }) } -export async function killProcess(pid: number): Promise { +export async function killProcess( + pid: number, + signal: string | number = 'SIGTERM' +): Promise { return await new Promise((resolve, reject) => { - treeKill(pid, (err) => { + treeKill(pid, signal, (err) => { if (err) { if ( process.platform === 'win32' && diff --git a/test/production/custom-server/custom-server.test.ts b/test/production/custom-server/custom-server.test.ts index a26c6c1e313a2..cb643e015b545 100644 --- a/test/production/custom-server/custom-server.test.ts +++ b/test/production/custom-server/custom-server.test.ts @@ -21,10 +21,10 @@ createNextDescribe( expect($('body').text()).toMatch(/app: .+-canary/) }) - it('should render pages with react stable', async () => { + it('should render pages with react canary', async () => { const $ = await next.render$(`/2`) expect($('body').text()).toMatch(/pages:/) - expect($('body').text()).not.toMatch(/canary/) + expect($('body').text()).toMatch(/canary/) }) }) } diff --git a/test/production/custom-server/server.js b/test/production/custom-server/server.js index 113493cc75eae..81c9b3a203a2b 100644 --- a/test/production/custom-server/server.js +++ b/test/production/custom-server/server.js @@ -30,7 +30,7 @@ async function main() { res.statusCode = 500 res.end('Internal Server Error') } - }).listen(port, '0.0.0.0', (err) => { + }).listen(port, undefined, (err) => { if (err) throw err // Start mode console.log(`- Local: http://${hostname}:${port}`)