diff --git a/packages/next-env/index.ts b/packages/next-env/index.ts index 3f264c3bf9da..ee9e28979dd0 100644 --- a/packages/next-env/index.ts +++ b/packages/next-env/index.ts @@ -10,7 +10,7 @@ export type LoadedEnvFiles = Array<{ contents: string }> -let initialEnv: Env | undefined = undefined +export let initialEnv: Env | undefined = undefined let combinedEnv: Env | undefined = undefined let cachedLoadedEnvFiles: LoadedEnvFiles = [] let previousLoadedEnvFiles: LoadedEnvFiles = [] diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index c8a7d10cb5cf..4911abc33c4e 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -1783,79 +1783,7 @@ export default async function build( 'next-server.js.nft.json' ) - if (config.experimental.preCompiledNextServer) { - if (!ciEnvironment.hasNextSupport) { - Log.warn( - `"experimental.preCompiledNextServer" is currently optimized for environments with Next.js support, some features may not be supported` - ) - } - const nextServerPath = require.resolve('next/dist/server/next-server') - await promises.rename(nextServerPath, `${nextServerPath}.bak`) - - await promises.writeFile( - nextServerPath, - `module.exports = require('next/dist/compiled/next-server/next-server.js')` - ) - const glob = - require('next/dist/compiled/glob') as typeof import('next/dist/compiled/glob') - const compiledFiles: string[] = [] - const compiledNextServerFolder = path.dirname( - require.resolve('next/dist/compiled/next-server/next-server.js') - ) - - for (const compiledFolder of [ - compiledNextServerFolder, - path.join(compiledNextServerFolder, '../react'), - path.join(compiledNextServerFolder, '../react-dom'), - path.join(compiledNextServerFolder, '../scheduler'), - ]) { - const globResult = glob.sync('**/*', { - cwd: compiledFolder, - dot: true, - }) - - await Promise.all( - globResult.map(async (file) => { - const absolutePath = path.join(compiledFolder, file) - const statResult = await promises.stat(absolutePath) - - if (statResult.isFile()) { - compiledFiles.push(absolutePath) - } - }) - ) - } - - const externalLibFiles = [ - 'next/dist/shared/lib/server-inserted-html.js', - 'next/dist/shared/lib/router-context.js', - 'next/dist/shared/lib/loadable-context.js', - 'next/dist/shared/lib/image-config-context.js', - 'next/dist/shared/lib/image-config.js', - 'next/dist/shared/lib/head-manager-context.js', - 'next/dist/shared/lib/app-router-context.js', - 'next/dist/shared/lib/amp-context.js', - 'next/dist/shared/lib/hooks-client-context.js', - 'next/dist/shared/lib/html-context.js', - ] - for (const file of externalLibFiles) { - compiledFiles.push(require.resolve(file)) - } - compiledFiles.push(nextServerPath) - - await promises.writeFile( - nextServerTraceOutput, - JSON.stringify({ - version: 1, - files: [...new Set(compiledFiles)].map((file) => - path.relative(distDir, file) - ), - } as { - version: number - files: string[] - }) - ) - } else if (config.outputFileTracing) { + if (config.outputFileTracing) { let nodeFileTrace: any if (config.experimental.turbotrace) { if (!binding?.isWasm) { @@ -2064,6 +1992,7 @@ export default async function build( '**/*.d.ts', '**/*.map', '**/next/dist/pages/**/*', + '**/next/dist/compiled/jest-worker/**/*', '**/next/dist/compiled/webpack/(bundle4|bundle5).js', '**/node_modules/webpack5/**/*', '**/next/dist/server/lib/squoosh/**/*.wasm', @@ -2082,7 +2011,14 @@ export default async function build( ...additionalIgnores, ] const ignoreFn = (pathname: string) => { - return isMatch(pathname, ignores, { contains: true, dot: true }) + if (path.isAbsolute(pathname) && !pathname.startsWith(root)) { + return true + } + + return isMatch(pathname, ignores, { + contains: true, + dot: true, + }) } const traceContext = path.join(nextServerEntry, '..', '..') const tracedFiles = new Set() diff --git a/packages/next/src/build/output/index.ts b/packages/next/src/build/output/index.ts index 08d8d95a06ea..cf9e779c92ce 100644 --- a/packages/next/src/build/output/index.ts +++ b/packages/next/src/build/output/index.ts @@ -95,7 +95,14 @@ export function formatAmpMessages(amp: AmpPageStatus) { return output } -const buildStore = createStore() +const buildStore = createStore({ + // @ts-expect-error initial value + client: {}, + // @ts-expect-error initial value + server: {}, + // @ts-expect-error initial value + edgeServer: {}, +}) let buildWasDone = false let clientWasLoading = true let serverWasLoading = true 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 a4f2d1e919d6..9d9562a515af 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 @@ -16,7 +16,30 @@ const originModules = [ const RUNTIME_NAMES = ['webpack-runtime', 'webpack-api-runtime'] -function deleteCache(filePath: string) { +export function deleteAppClientCache() { + if ((global as any)._nextDeleteAppClientCache) { + ;(global as any)._nextDeleteAppClientCache() + } + // ensure we reset the cache for sc_server components + // loaded via react-server-dom-webpack + const reactServerDomModId = require.resolve( + 'next/dist/compiled/react-server-dom-webpack/client.edge' + ) + const reactServerDomMod = require.cache[reactServerDomModId] + + if (reactServerDomMod) { + for (const child of reactServerDomMod.children) { + child.parent = null + delete require.cache[child.id] + } + } + delete require.cache[reactServerDomModId] +} + +export function deleteCache(filePath: string) { + if ((global as any)._nextDeleteCache) { + ;(global as any)._nextDeleteCache(filePath) + } try { filePath = realpathSync(filePath) } catch (e) { @@ -82,20 +105,6 @@ export class NextJsRequireCacheHotReloader implements WebpackPluginInstance { }) if (hasAppPath) { - // ensure we reset the cache for sc_server components - // loaded via react-server-dom-webpack - const reactServerDomModId = require.resolve( - 'next/dist/compiled/react-server-dom-webpack/client.edge' - ) - const reactServerDomMod = require.cache[reactServerDomModId] - - if (reactServerDomMod) { - for (const child of reactServerDomMod.children) { - child.parent = null - delete require.cache[child.id] - } - } - delete require.cache[reactServerDomModId] } entries.forEach((page) => { diff --git a/packages/next/src/cli/next-dev.ts b/packages/next/src/cli/next-dev.ts index f78b6fd0cedc..0c7a9deae7cb 100644 --- a/packages/next/src/cli/next-dev.ts +++ b/packages/next/src/cli/next-dev.ts @@ -1,36 +1,28 @@ #!/usr/bin/env node import arg from 'next/dist/compiled/arg/index.js' -import { startServer, WORKER_SELF_EXIT_CODE } from '../server/lib/start-server' +import { startServer, StartServerOptions } from '../server/lib/start-server' import { getPort, printAndExit } from '../server/lib/utils' import * as Log from '../build/output/log' -import { startedDevelopmentServer } from '../build/output' import { CliCommand } from '../lib/commands' import isError from '../lib/is-error' import { getProjectDir } from '../lib/get-project-dir' import { CONFIG_FILES, PHASE_DEVELOPMENT_SERVER } from '../shared/lib/constants' import path from 'path' -import type { NextConfig } from '../../types' -import type { NextConfigComplete } from '../server/config-shared' +import type { NextConfig, NextConfigComplete } from '../server/config-shared' import { traceGlobals } from '../trace/shared' -import { isIPv6 } from 'net' -import { ChildProcess, fork } from 'child_process' import { Telemetry } from '../telemetry/storage' import loadConfig from '../server/config' import { findPagesDir } from '../lib/find-pages-dir' import { fileExists } from '../lib/file-exists' +import { getNpxCommand } from '../lib/helpers/get-npx-command' import Watchpack from 'next/dist/compiled/watchpack' import stripAnsi from 'next/dist/compiled/strip-ansi' -import { warn } from '../build/output/log' -import { getPossibleInstrumentationHookFilenames } from '../build/utils' -import { getNpxCommand } from '../lib/helpers/get-npx-command' +import { getPossibleInstrumentationHookFilenames } from '../build/worker' +let dir: string let isTurboSession = false let sessionStopHandled = false let sessionStarted = Date.now() -let dir: string -let unwatchConfigFiles: () => void - -const isChildProcess = !!process.env.__NEXT_DEV_CHILD_PROCESS const handleSessionStop = async () => { if (sessionStopHandled) return @@ -91,13 +83,10 @@ const handleSessionStop = async () => { process.exit(0) } -if (!isChildProcess) { - process.on('SIGINT', handleSessionStop) - process.on('SIGTERM', handleSessionStop) -} else { - process.on('SIGINT', () => process.exit(0)) - process.on('SIGTERM', () => process.exit(0)) -} +process.on('SIGINT', handleSessionStop) +process.on('SIGTERM', handleSessionStop) + +let unwatchConfigFiles: () => void function watchConfigFiles(dirToWatch: string) { if (unwatchConfigFiles) { @@ -113,7 +102,7 @@ function watchConfigFiles(dirToWatch: string) { )}. Restart the server to see the changes in effect.` ) }) - return () => wp.close() + unwatchConfigFiles = () => wp.close() } const nextDev: CliCommand = async (argv) => { @@ -162,9 +151,7 @@ const nextDev: CliCommand = async (argv) => { `) process.exit(0) } - dir = getProjectDir(process.env.NEXT_PRIVATE_DEV_DIR || args._[0]) - unwatchConfigFiles = watchConfigFiles(dir) // Check if pages dir exists and warn if not if (!(await fileExists(dir, 'directory'))) { @@ -216,212 +203,20 @@ const nextDev: CliCommand = async (argv) => { // some set-ups that rely on listening on other interfaces const host = args['--hostname'] - const devServerOptions = { - allowRetry, - dev: true, + const devServerOptions: StartServerOptions = { dir, - hostname: host, - isNextDevCommand: true, port, - } - - const supportedTurbopackNextConfigOptions = [ - 'configFileName', - 'env', - 'experimental.appDir', - 'experimental.serverComponentsExternalPackages', - 'experimental.turbo', - 'images', - 'pageExtensions', - 'onDemandEntries', - 'rewrites', - 'redirects', - 'headers', - 'reactStrictMode', - 'swcMinify', - 'transpilePackages', - ] - - // check for babelrc, swc plugins - async function validateNextConfig(isCustomTurbopack: boolean) { - const { getPkgManager } = - require('../lib/helpers/get-pkg-manager') as typeof import('../lib/helpers/get-pkg-manager') - const { getBabelConfigFile } = - require('../build/webpack-config') as typeof import('../build/webpack-config') - const { defaultConfig } = - require('../server/config-shared') as typeof import('../server/config-shared') - const chalk = - require('next/dist/compiled/chalk') as typeof import('next/dist/compiled/chalk') - const { interopDefault } = - require('../lib/interop-default') as typeof import('../lib/interop-default') - - // To regenerate the TURBOPACK gradient require('gradient-string')('blue', 'red')('>>> TURBOPACK') - const isTTY = process.stdout.isTTY - - const turbopackGradient = `${chalk.bold( - isTTY - ? '\x1B[38;2;0;0;255m>\x1B[39m\x1B[38;2;23;0;232m>\x1B[39m\x1B[38;2;46;0;209m>\x1B[39m \x1B[38;2;70;0;185mT\x1B[39m\x1B[38;2;93;0;162mU\x1B[39m\x1B[38;2;116;0;139mR\x1B[39m\x1B[38;2;139;0;116mB\x1B[39m\x1B[38;2;162;0;93mO\x1B[39m\x1B[38;2;185;0;70mP\x1B[39m\x1B[38;2;209;0;46mA\x1B[39m\x1B[38;2;232;0;23mC\x1B[39m\x1B[38;2;255;0;0mK\x1B[39m' - : '>>> TURBOPACK' - )} ${chalk.dim('(alpha)')}\n\n` - - let thankYouMsg = `Thank you for trying Next.js v13 with Turbopack! As a reminder,\nTurbopack is currently in alpha and not yet ready for production.\nWe appreciate your ongoing support as we work to make it ready\nfor everyone.\n` - - let unsupportedParts = '' - let babelrc = await getBabelConfigFile(dir) - if (babelrc) babelrc = path.basename(babelrc) - - let unsupportedConfig: string[] = [] - let rawNextConfig: NextConfig = {} - - try { - rawNextConfig = interopDefault( - await loadConfig(PHASE_DEVELOPMENT_SERVER, dir, undefined, true) - ) as NextConfig - - if (typeof rawNextConfig === 'function') { - rawNextConfig = (rawNextConfig as any)(PHASE_DEVELOPMENT_SERVER, { - defaultConfig, - }) - } - - const checkUnsupportedCustomConfig = ( - configKey = '', - parentUserConfig: any, - parentDefaultConfig: any - ): boolean => { - try { - // these should not error - if ( - // we only want the key after the dot for experimental options - supportedTurbopackNextConfigOptions - .map((key) => key.split('.').splice(-1)[0]) - .includes(configKey) - ) { - return false - } - - // experimental options are checked separately - if (configKey === 'experimental') { - return false - } - - let userValue = parentUserConfig?.[configKey] - let defaultValue = parentDefaultConfig?.[configKey] - - if (typeof defaultValue !== 'object') { - return defaultValue !== userValue - } - return Object.keys(userValue || {}).some((key: string) => { - return checkUnsupportedCustomConfig(key, userValue, defaultValue) - }) - } catch (e) { - console.error( - `Unexpected error occurred while checking ${configKey}`, - e - ) - return false - } - } - - unsupportedConfig = [ - ...Object.keys(rawNextConfig).filter((key) => - checkUnsupportedCustomConfig(key, rawNextConfig, defaultConfig) - ), - ...Object.keys(rawNextConfig.experimental ?? {}) - .filter((key) => - checkUnsupportedCustomConfig( - key, - rawNextConfig?.experimental, - defaultConfig?.experimental - ) - ) - .map((key) => `experimental.${key}`), - ] - } catch (e) { - console.error('Unexpected error occurred while checking config', e) - } - - const hasWarningOrError = babelrc || unsupportedConfig.length - if (!hasWarningOrError) { - thankYouMsg = chalk.dim(thankYouMsg) - } - if (!isCustomTurbopack) { - console.log(turbopackGradient + thankYouMsg) - } - - let feedbackMessage = `Learn more about Next.js v13 and Turbopack: ${chalk.underline( - 'https://nextjs.link/with-turbopack' - )}\nPlease direct feedback to: ${chalk.underline( - 'https://nextjs.link/turbopack-feedback' - )}\n` - - if (!hasWarningOrError) { - feedbackMessage = chalk.dim(feedbackMessage) - } - - if (babelrc) { - unsupportedParts += `\n- Babel detected (${chalk.cyan( - babelrc - )})\n ${chalk.dim( - `Babel is not yet supported. To use Turbopack at the moment,\n you'll need to remove your usage of Babel.` - )}` - } - if (unsupportedConfig.length) { - unsupportedParts += `\n\n- Unsupported Next.js configuration option(s) (${chalk.cyan( - 'next.config.js' - )})\n ${chalk.dim( - `To use Turbopack, remove the following configuration options:\n${unsupportedConfig - .map((name) => ` - ${chalk.red(name)}\n`) - .join( - '' - )} The only supported configurations options are:\n${supportedTurbopackNextConfigOptions - .map((name) => ` - ${chalk.cyan(name)}\n`) - .join('')} ` - )} ` - } - - if (unsupportedParts && !isCustomTurbopack) { - const pkgManager = getPkgManager(dir) - - console.error( - `${chalk.bold.red( - 'Error:' - )} You are using configuration and/or tools that are not yet\nsupported by Next.js v13 with Turbopack:\n${unsupportedParts}\n -If you cannot make the changes above, but still want to try out\nNext.js v13 with Turbopack, create the Next.js v13 playground app\nby running the following commands: - - ${chalk.bold.cyan( - `${ - pkgManager === 'npm' - ? 'npx create-next-app' - : `${pkgManager} create next-app` - } --example with-turbopack with-turbopack-app` - )}\n cd with-turbopack-app\n ${pkgManager} run dev - ` - ) - - if (!isCustomTurbopack) { - console.warn(feedbackMessage) - - process.exit(1) - } else { - console.warn( - `\n${chalk.bold.yellow( - 'Warning:' - )} Unsupported config found; but continuing with custom Turbopack binary.\n` - ) - } - } - - if (!isCustomTurbopack) { - console.log(feedbackMessage) - } - - return rawNextConfig + allowRetry, + isDev: true, + hostname: host, + useWorkers: !process.env.__NEXT_DISABLE_MEMORY_WATCHER, } if (args['--turbo']) { isTurboSession = true + const { validateTurboNextConfig } = + require('../lib/turbopack-warning') as typeof import('../lib/turbopack-warning') const { loadBindings, __isCustomTurbopackBinary } = require('../build/swc') as typeof import('../build/swc') const { eventCliSession } = @@ -432,7 +227,10 @@ If you cannot make the changes above, but still want to try out\nNext.js v13 wit require('next/dist/compiled/find-up') as typeof import('next/dist/compiled/find-up') const isCustomTurbopack = await __isCustomTurbopackBinary() - const rawNextConfig = await validateNextConfig(isCustomTurbopack) + const rawNextConfig = await validateTurboNextConfig({ + isCustomTurbopack, + ...devServerOptions, + }) const distDir = path.join(dir, rawNextConfig.distDir || '.next') const { pagesDir, appDir } = findPagesDir( @@ -487,87 +285,44 @@ If you cannot make the changes above, but still want to try out\nNext.js v13 wit } return server } else { - // we're using a sub worker to avoid memory leaks. When memory usage exceeds 90%, we kill the worker and restart it. - // this is a temporary solution until we can fix the memory leaks. - // the logic for the worker killing itself is in `packages/next/server/lib/start-server.ts` - if (!process.env.__NEXT_DISABLE_MEMORY_WATCHER && !isChildProcess) { - let config: NextConfig - let childProcess: ChildProcess | null = null - - const isDebugging = - process.execArgv.some((localArg) => localArg.startsWith('--inspect')) || - process.env.NODE_OPTIONS?.match?.(/--inspect(=\S+)?( |$)/) - - const isDebuggingWithBrk = - process.execArgv.some((localArg) => - localArg.startsWith('--inspect-brk') - ) || process.env.NODE_OPTIONS?.match?.(/--inspect-brk(=\S+)?( |$)/) - - const debugPort = (() => { - const debugPortStr = - process.execArgv - .find( - (localArg) => - localArg.startsWith('--inspect') || - localArg.startsWith('--inspect-brk') - ) - ?.split('=')[1] ?? - process.env.NODE_OPTIONS?.match?.( - /--inspect(-brk)?(=(\S+))?( |$)/ - )?.[3] - return debugPortStr ? parseInt(debugPortStr, 10) : 9229 - })() - - if (isDebugging || isDebuggingWithBrk) { - warn( - `the --inspect${ - isDebuggingWithBrk ? '-brk' : '' - } option was detected, the Next.js server should be inspected at port ${ - debugPort + 1 - }.` - ) - } + try { + let shouldFilter = false + let devServerTeardown: (() => Promise) | undefined + let config: NextConfig | undefined - const genExecArgv = () => { - const execArgv = process.execArgv.filter((localArg) => { - return ( - !localArg.startsWith('--inspect') && - !localArg.startsWith('--inspect-brk') - ) - }) + watchConfigFiles(devServerOptions.dir) - if (isDebugging || isDebuggingWithBrk) { - execArgv.push( - `--inspect${isDebuggingWithBrk ? '-brk' : ''}=${debugPort + 1}` + const setupFork = async (newDir?: string) => { + // if we're using workers we can auto restart on config changes + if (!devServerOptions.useWorkers && devServerTeardown) { + Log.info( + `Detected change, manual restart required due to '__NEXT_DISABLE_MEMORY_WATCHER' usage` ) + return } - return execArgv - } - let childProcessExitUnsub: (() => void) | null = null + const startDir = dir + if (newDir) { + dir = newDir + } - const setupFork = (env?: NodeJS.ProcessEnv, newDir?: string) => { - childProcessExitUnsub?.() - childProcess?.kill() + if (devServerTeardown) { + await devServerTeardown() + devServerTeardown = undefined + } - const startDir = dir - const [, script, ...nodeArgs] = process.argv - let shouldFilter = false - childProcess = fork( - newDir ? script.replace(startDir, newDir) : script, - nodeArgs, - { - env: { - ...(env ? env : process.env), - FORCE_COLOR: '1', - __NEXT_DEV_CHILD_PROCESS: '1', - }, - // @ts-ignore TODO: remove ignore when types are updated - windowsHide: true, - stdio: ['ipc', 'pipe', 'pipe'], - execArgv: genExecArgv(), - } - ) + if (newDir) { + dir = newDir + process.env = Object.keys(process.env).reduce((newEnv, key) => { + newEnv[key] = process.env[key]?.replace(startDir, newDir) + return newEnv + }, {} as typeof process.env) + + process.chdir(newDir) + + devServerOptions.dir = newDir + devServerOptions.prevDir = startDir + } // since errors can start being logged from the fork // before we detect the project directory rename @@ -576,7 +331,7 @@ If you cannot make the changes above, but still want to try out\nNext.js v13 wit const cleanChunk = stripAnsi(chunk + '') if ( cleanChunk.match( - /(ENOENT|Module build failed|Module not found|Cannot find module)/ + /(ENOENT|Module build failed|Module not found|Cannot find module|Can't resolve)/ ) ) { if (startDir === dir) { @@ -584,7 +339,7 @@ If you cannot make the changes above, but still want to try out\nNext.js v13 wit // check if start directory is still valid const result = findPagesDir( startDir, - !!config.experimental?.appDir + !!config?.experimental?.appDir ) shouldFilter = !Boolean(result.pagesDir || result.appDir) } catch (_) { @@ -599,54 +354,33 @@ If you cannot make the changes above, but still want to try out\nNext.js v13 wit process[fd].write(chunk) } - childProcess?.stdout?.on('data', (chunk) => { + devServerOptions.onStdout = (chunk) => { filterForkErrors(chunk, 'stdout') - }) - childProcess?.stderr?.on('data', (chunk) => { + } + devServerOptions.onStderr = (chunk) => { filterForkErrors(chunk, 'stderr') - }) - - const callback = async (code: number | null) => { - if (code === WORKER_SELF_EXIT_CODE) { - setupFork() - } else if (!sessionStopHandled) { - await handleSessionStop() - process.exit(1) - } } - childProcess?.addListener('exit', callback) - childProcessExitUnsub = () => - childProcess?.removeListener('exit', callback) + shouldFilter = false + devServerTeardown = await startServer(devServerOptions) + + if (!config) { + config = await loadConfig( + PHASE_DEVELOPMENT_SERVER, + dir, + undefined, + undefined, + true + ) + } } - setupFork() - - config = await loadConfig( - PHASE_DEVELOPMENT_SERVER, - dir, - undefined, - undefined, - true - ) + await setupFork() + await preflight() - const handleProjectDirRename = (newDir: string) => { - process.chdir(newDir) - setupFork( - { - ...Object.keys(process.env).reduce((newEnv, key) => { - newEnv[key] = process.env[key]?.replace(dir, newDir) - return newEnv - }, {} as typeof process.env), - NEXT_PRIVATE_DEV_DIR: newDir, - }, - newDir - ) - } const parentDir = path.join('/', dir, '..') const watchedEntryLength = parentDir.split('/').length + 1 const previousItems = new Set() - - const instrumentationFilePaths = !!config.experimental + const instrumentationFilePaths = !!config?.experimental ?.instrumentationHook ? getPossibleInstrumentationHookFilenames(dir, config.pageExtensions!) : [] @@ -679,7 +413,7 @@ If you cannot make the changes above, but still want to try out\nNext.js v13 wit instrumentationFileLastHash && instrumentationFileHash !== instrumentationFileLastHash ) { - warn( + Log.warn( `The instrumentation file has changed, restarting the server to apply changes.` ) return setupFork() @@ -688,7 +422,7 @@ If you cannot make the changes above, but still want to try out\nNext.js v13 wit !instrumentationFileLastHash && previousInstrumentationFiles.size !== 0 ) { - warn( + Log.warn( 'The instrumentation file was added, restarting the server to apply changes.' ) return setupFork() @@ -700,7 +434,7 @@ If you cannot make the changes above, but still want to try out\nNext.js v13 wit instrumentationFilePaths.includes(key) ) ) { - warn( + Log.warn( `The instrumentation file has been removed, restarting the server to apply changes.` ) instrumentationFileLastHash = undefined @@ -726,7 +460,7 @@ If you cannot make the changes above, but still want to try out\nNext.js v13 wit // if the dir still exists nothing to check try { - const result = findPagesDir(dir, !!config.experimental?.appDir) + const result = findPagesDir(dir, !!config?.experimental?.appDir) hasPagesApp = Boolean(result.pagesDir || result.appDir) } catch (err) { // if findPagesDir throws validation error let this be @@ -760,7 +494,7 @@ If you cannot make the changes above, but still want to try out\nNext.js v13 wit try { const result = findPagesDir( newFiles[0], - !!config.experimental?.appDir + !!config?.experimental?.appDir ) hasPagesApp = Boolean(result.pagesDir || result.appDir) } catch (_) {} @@ -769,9 +503,8 @@ If you cannot make the changes above, but still want to try out\nNext.js v13 wit Log.info( `Detected project directory rename, restarting in new location` ) - handleProjectDirRename(newFiles[0]) + setupFork(newFiles[0]) watchConfigFiles(newFiles[0]) - dir = newFiles[0] } else { Log.error( `Project directory could not be found, restart Next.js in your new directory` @@ -779,44 +512,9 @@ If you cannot make the changes above, but still want to try out\nNext.js v13 wit process.exit(0) } }) - } else { - startServer(devServerOptions) - .then(async (app) => { - const appUrl = `http://${app.hostname}:${app.port}` - const hostname = host || '0.0.0.0' - startedDevelopmentServer( - appUrl, - `${isIPv6(hostname) ? `[${hostname}]` : hostname}:${app.port}` - ) - // Start preflight after server is listening and ignore errors: - preflight().catch(() => {}) - // Finalize server bootup: - await app.prepare() - }) - .catch((err) => { - if (err.code === 'EADDRINUSE') { - let errorMessage = `Port ${port} is already in use.` - const pkgAppPath = require('next/dist/compiled/find-up').sync( - 'package.json', - { - cwd: dir, - } - ) - const appPackage = require(pkgAppPath) - if (appPackage.scripts) { - const nextScript = Object.entries(appPackage.scripts).find( - (scriptLine) => scriptLine[1] === 'next' - ) - if (nextScript) { - errorMessage += `\nUse \`npm run ${nextScript[0]} -- -p \`.` - } - } - console.error(errorMessage) - } else { - console.error(err) - } - process.nextTick(() => process.exit(1)) - }) + } catch (err) { + console.error(err) + process.exit(1) } } } diff --git a/packages/next/src/cli/next-start.ts b/packages/next/src/cli/next-start.ts index 3b4c61c0082a..d88097c17d9e 100755 --- a/packages/next/src/cli/next-start.ts +++ b/packages/next/src/cli/next-start.ts @@ -3,13 +3,11 @@ import arg from 'next/dist/compiled/arg/index.js' import { startServer } from '../server/lib/start-server' import { getPort, printAndExit } from '../server/lib/utils' -import * as Log from '../build/output/log' import isError from '../lib/is-error' import { getProjectDir } from '../lib/get-project-dir' import { CliCommand } from '../lib/commands' -import { isIPv6 } from 'net' -const nextStart: CliCommand = (argv) => { +const nextStart: CliCommand = async (argv) => { const validArgs: arg.Spec = { // Types '--help': Boolean, @@ -73,22 +71,14 @@ const nextStart: CliCommand = (argv) => { ? Math.ceil(keepAliveTimeoutArg) : undefined - startServer({ + await startServer({ dir, + isDev: false, hostname: host, port, keepAliveTimeout, + useWorkers: false, }) - .then(async (app) => { - const appUrl = `http://${app.hostname}:${app.port}` - const hostname = isIPv6(host) ? `[${host}]` : host - Log.ready(`started server on ${hostname}:${app.port}, url: ${appUrl}`) - await app.prepare() - }) - .catch((err) => { - console.error(err) - process.exit(1) - }) } export { nextStart } diff --git a/packages/next/src/lib/turbopack-warning.ts b/packages/next/src/lib/turbopack-warning.ts new file mode 100644 index 000000000000..e9a5a4136b5e --- /dev/null +++ b/packages/next/src/lib/turbopack-warning.ts @@ -0,0 +1,207 @@ +import path from 'path' +import loadConfig from '../server/config' +import { NextConfig } from '../server/config-shared' +import { PHASE_DEVELOPMENT_SERVER } from '../shared/lib/constants' + +const supportedTurbopackNextConfigOptions = [ + 'configFileName', + 'env', + 'experimental.appDir', + 'experimental.serverComponentsExternalPackages', + 'experimental.turbo', + 'images', + 'pageExtensions', + 'onDemandEntries', + 'rewrites', + 'redirects', + 'headers', + 'reactStrictMode', + 'swcMinify', + 'transpilePackages', +] + +// check for babelrc, swc plugins +export async function validateTurboNextConfig({ + dir, + isCustomTurbopack, +}: { + allowRetry?: boolean + isCustomTurbopack?: boolean + dir: string + port: number + hostname?: string +}) { + const { getPkgManager } = + require('../lib/helpers/get-pkg-manager') as typeof import('../lib/helpers/get-pkg-manager') + const { getBabelConfigFile } = + require('../build/webpack-config') as typeof import('../build/webpack-config') + const { defaultConfig } = + require('../server/config-shared') as typeof import('../server/config-shared') + const chalk = + require('next/dist/compiled/chalk') as typeof import('next/dist/compiled/chalk') + const { interopDefault } = + require('../lib/interop-default') as typeof import('../lib/interop-default') + + // To regenerate the TURBOPACK gradient require('gradient-string')('blue', 'red')('>>> TURBOPACK') + const isTTY = process.stdout.isTTY + + const turbopackGradient = `${chalk.bold( + isTTY + ? '\x1B[38;2;0;0;255m>\x1B[39m\x1B[38;2;23;0;232m>\x1B[39m\x1B[38;2;46;0;209m>\x1B[39m \x1B[38;2;70;0;185mT\x1B[39m\x1B[38;2;93;0;162mU\x1B[39m\x1B[38;2;116;0;139mR\x1B[39m\x1B[38;2;139;0;116mB\x1B[39m\x1B[38;2;162;0;93mO\x1B[39m\x1B[38;2;185;0;70mP\x1B[39m\x1B[38;2;209;0;46mA\x1B[39m\x1B[38;2;232;0;23mC\x1B[39m\x1B[38;2;255;0;0mK\x1B[39m' + : '>>> TURBOPACK' + )} ${chalk.dim('(alpha)')}\n\n` + + let thankYouMsg = `Thank you for trying Next.js v13 with Turbopack! As a reminder,\nTurbopack is currently in alpha and not yet ready for production.\nWe appreciate your ongoing support as we work to make it ready\nfor everyone.\n` + + let unsupportedParts = '' + let babelrc = await getBabelConfigFile(dir) + if (babelrc) babelrc = path.basename(babelrc) + + let unsupportedConfig: string[] = [] + let rawNextConfig: NextConfig = {} + + try { + rawNextConfig = interopDefault( + await loadConfig(PHASE_DEVELOPMENT_SERVER, dir, undefined, true) + ) as NextConfig + + if (typeof rawNextConfig === 'function') { + rawNextConfig = (rawNextConfig as any)(PHASE_DEVELOPMENT_SERVER, { + defaultConfig, + }) + } + + const checkUnsupportedCustomConfig = ( + configKey = '', + parentUserConfig: any, + parentDefaultConfig: any + ): boolean => { + try { + // these should not error + if ( + // we only want the key after the dot for experimental options + supportedTurbopackNextConfigOptions + .map((key) => key.split('.').splice(-1)[0]) + .includes(configKey) + ) { + return false + } + + // experimental options are checked separately + if (configKey === 'experimental') { + return false + } + + let userValue = parentUserConfig?.[configKey] + let defaultValue = parentDefaultConfig?.[configKey] + + if (typeof defaultValue !== 'object') { + return defaultValue !== userValue + } + return Object.keys(userValue || {}).some((key: string) => { + return checkUnsupportedCustomConfig(key, userValue, defaultValue) + }) + } catch (e) { + console.error( + `Unexpected error occurred while checking ${configKey}`, + e + ) + return false + } + } + + unsupportedConfig = [ + ...Object.keys(rawNextConfig).filter((key) => + checkUnsupportedCustomConfig(key, rawNextConfig, defaultConfig) + ), + ...Object.keys(rawNextConfig.experimental ?? {}) + .filter((key) => + checkUnsupportedCustomConfig( + key, + rawNextConfig?.experimental, + defaultConfig?.experimental + ) + ) + .map((key) => `experimental.${key}`), + ] + } catch (e) { + console.error('Unexpected error occurred while checking config', e) + } + + const hasWarningOrError = babelrc || unsupportedConfig.length + if (!hasWarningOrError) { + thankYouMsg = chalk.dim(thankYouMsg) + } + if (!isCustomTurbopack) { + console.log(turbopackGradient + thankYouMsg) + } + + let feedbackMessage = `Learn more about Next.js v13 and Turbopack: ${chalk.underline( + 'https://nextjs.link/with-turbopack' + )}\nPlease direct feedback to: ${chalk.underline( + 'https://nextjs.link/turbopack-feedback' + )}\n` + + if (!hasWarningOrError) { + feedbackMessage = chalk.dim(feedbackMessage) + } + + if (babelrc) { + unsupportedParts += `\n- Babel detected (${chalk.cyan( + babelrc + )})\n ${chalk.dim( + `Babel is not yet supported. To use Turbopack at the moment,\n you'll need to remove your usage of Babel.` + )}` + } + if (unsupportedConfig.length) { + unsupportedParts += `\n\n- Unsupported Next.js configuration option(s) (${chalk.cyan( + 'next.config.js' + )})\n ${chalk.dim( + `To use Turbopack, remove the following configuration options:\n${unsupportedConfig + .map((name) => ` - ${chalk.red(name)}\n`) + .join( + '' + )} The only supported configurations options are:\n${supportedTurbopackNextConfigOptions + .map((name) => ` - ${chalk.cyan(name)}\n`) + .join('')} ` + )} ` + } + + if (unsupportedParts && !isCustomTurbopack) { + const pkgManager = getPkgManager(dir) + + console.error( + `${chalk.bold.red( + 'Error:' + )} You are using configuration and/or tools that are not yet\nsupported by Next.js v13 with Turbopack:\n${unsupportedParts}\n +If you cannot make the changes above, but still want to try out\nNext.js v13 with Turbopack, create the Next.js v13 playground app\nby running the following commands: + + ${chalk.bold.cyan( + `${ + pkgManager === 'npm' + ? 'npx create-next-app' + : `${pkgManager} create next-app` + } --example with-turbopack with-turbopack-app` + )}\n cd with-turbopack-app\n ${pkgManager} run dev + ` + ) + + if (!isCustomTurbopack) { + console.warn(feedbackMessage) + + process.exit(1) + } else { + console.warn( + `\n${chalk.bold.yellow( + 'Warning:' + )} Unsupported config found; but continuing with custom Turbopack binary.\n` + ) + } + } + + if (!isCustomTurbopack) { + console.log(feedbackMessage) + } + + return rawNextConfig +} diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 24f5289113f0..1efd37b671fd 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -155,6 +155,11 @@ export interface Options { * The HTTP Server that Next.js is running behind */ httpServer?: import('http').Server + + _routerWorker?: boolean + _renderWorker?: boolean + + isNodeDebugging?: 'brk' | boolean } export interface BaseRequestHandler { @@ -283,7 +288,7 @@ export default abstract class Server { protected abstract getCustomRoutes(): CustomRoutes protected abstract hasPage(pathname: string): Promise - protected abstract generateRoutes(): RouterOptions + protected abstract generateRoutes(dev?: boolean): RouterOptions protected abstract sendRenderResult( req: BaseNextRequest, @@ -337,6 +342,8 @@ export default abstract class Server { protected readonly handlers: RouteHandlerManager protected readonly i18nProvider?: I18NProvider protected readonly localeNormalizer?: LocaleRouteNormalizer + protected readonly isRouterWorker?: boolean + protected readonly isRenderWorker?: boolean public constructor(options: ServerOptions) { const { @@ -350,6 +357,8 @@ export default abstract class Server { port, } = options this.serverOptions = options + this.isRouterWorker = options._routerWorker + this.isRenderWorker = options._renderWorker this.dir = process.env.NEXT_RUNTIME === 'edge' ? dir : require('path').resolve(dir) @@ -456,7 +465,7 @@ export default abstract class Server { matchers.reload() this.customRoutes = this.getCustomRoutes() - this.router = new Router(this.generateRoutes()) + this.router = new Router(this.generateRoutes(dev)) this.setAssetPrefix(assetPrefix) this.responseCache = this.getResponseCache({ dev }) diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index a1d15367bc88..4011adc59ad1 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -366,9 +366,6 @@ const configSchema = { pageEnv: { type: 'boolean', }, - preCompiledNextServer: { - type: 'boolean', - }, proxyTimeout: { minimum: 0, type: 'number', diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 7b9e4ff1e576..0f2999d49f87 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -126,7 +126,6 @@ export interface ExperimentalConfig { fetchCacheKeyPrefix?: string optimisticClientCache?: boolean middlewarePrefetch?: 'strict' | 'flexible' - preCompiledNextServer?: boolean legacyBrowsers?: boolean manualClientBasePath?: boolean newNextLinkBehavior?: boolean @@ -657,7 +656,6 @@ export const defaultConfig: NextConfig = { experimental: { clientRouterFilter: false, clientRouterFilterRedirects: false, - preCompiledNextServer: false, fetchCacheKeyPrefix: '', middlewarePrefetch: 'flexible', optimisticClientCache: true, diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 1a7dc3a966bc..1f089c79ec11 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -12,6 +12,7 @@ import type { BaseNextRequest, BaseNextResponse } from '../base-http' import type { MiddlewareRoutingItem, RoutingItem } from '../base-server' import type { MiddlewareMatcher } from '../../build/analysis/get-page-static-info' import type { FunctionComponent } from 'react' +import type { RouteMatch } from '../future/route-matches/route-match' import crypto from 'crypto' import fs from 'fs' @@ -65,7 +66,7 @@ import { import * as Log from '../../build/output/log' import isError, { getProperError } from '../../lib/is-error' import { getRouteRegex } from '../../shared/lib/router/utils/route-regex' -import { getSortedRoutes } from '../../shared/lib/router/utils' +import { getSortedRoutes, isDynamicRoute } from '../../shared/lib/router/utils' import { runDependingOnPageType } from '../../build/entries' import { NodeNextResponse, NodeNextRequest } from '../base-http/node' import { getPageStaticInfo } from '../../build/analysis/get-page-static-info' @@ -100,6 +101,7 @@ import { createClientRouterFilter } from '../../lib/create-client-router-filter' import { IncrementalCache } from '../lib/incremental-cache' import LRUCache from 'next/dist/compiled/lru-cache' import { NextUrlWithParsedQuery } from '../request-meta' +import { deserializeErr, errorToJSON } from '../render' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: FunctionComponent @@ -253,7 +255,7 @@ export default class DevServer extends Server { const ensurer: RouteEnsurer = { ensure: async (match) => { - await this.hotReloader?.ensurePage({ + await this.ensurePage({ match, page: match.definition.page, clientOnly: false, @@ -656,7 +658,11 @@ export default class DevServer extends Server { if (envChange || tsconfigChange) { if (envChange) { - this.loadEnvConfig({ dev: true, forceReload: true }) + this.loadEnvConfig({ + dev: true, + forceReload: true, + silent: !!process.env.__NEXT_PRIVATE_RENDER_WORKER, + }) } let tsconfigResult: | UnwrapPromise> @@ -788,6 +794,18 @@ export default class DevServer extends Server { // before it has been built and is populated in the _buildManifest const sortedRoutes = getSortedRoutes(routedPages) + this.dynamicRoutes = sortedRoutes + .map((page) => { + if (!isDynamicRoute) return null + const regex = getRouteRegex(page) + return { + match: getRouteMatcher(regex), + page, + re: regex.re, + } + }) + .filter(Boolean) as any + if ( !this.sortedRoutes?.every((val, idx) => val === sortedRoutes[idx]) ) { @@ -871,24 +889,26 @@ export default class DevServer extends Server { redirects.length || headers.length ) { - this.router = new Router(this.generateRoutes()) + this.router = new Router(this.generateRoutes(true)) } - const telemetry = new Telemetry({ distDir: this.distDir }) - this.hotReloader = new HotReloader(this.dir, { - pagesDir: this.pagesDir, - distDir: this.distDir, - config: this.nextConfig, - previewProps: this.getPreviewProps(), - buildId: this.buildId, - rewrites, - appDir: this.appDir, - telemetry, - }) + // router worker does not start webpack compilers + if (!this.isRenderWorker) { + this.hotReloader = new HotReloader(this.dir, { + pagesDir: this.pagesDir, + distDir: this.distDir, + config: this.nextConfig, + previewProps: this.getPreviewProps(), + buildId: this.buildId, + rewrites, + appDir: this.appDir, + telemetry, + }) + } await super.prepare() await this.addExportPathMapRoutes() - await this.hotReloader.start() + await this.hotReloader?.start() await this.startWatcher() await this.runInstrumentationHookIfAvailable() await this.matchers.reload() @@ -910,18 +930,21 @@ export default class DevServer extends Server { this.dir, this.pagesDir || this.appDir || '' ).startsWith('src') - telemetry.record( - eventCliSession(this.distDir, this.nextConfig, { - webpackVersion: 5, - cliCommand: 'dev', - isSrcDir, - hasNowJson: !!(await findUp('now.json', { cwd: this.dir })), - isCustomServer: this.isCustomServer, - turboFlag: false, - pagesDir: !!this.pagesDir, - appDir: !!this.appDir, - }) - ) + + if (!this.isRenderWorker) { + telemetry.record( + eventCliSession(this.distDir, this.nextConfig, { + webpackVersion: 5, + cliCommand: 'dev', + isSrcDir, + hasNowJson: !!(await findUp('now.json', { cwd: this.dir })), + isCustomServer: this.isCustomServer, + turboFlag: false, + pagesDir: !!this.pagesDir, + appDir: !!this.appDir, + }) + ) + } process.on('unhandledRejection', (reason) => { this.logErrorWithOriginalStack(reason, 'unhandledRejection').catch( @@ -1041,7 +1064,7 @@ export default class DevServer extends Server { } else { const { basePath } = this.nextConfig - server.on('upgrade', (req, socket, head) => { + server.on('upgrade', async (req, socket, head) => { let assetPrefix = (this.nextConfig.assetPrefix || '').replace( /^\/+/, '' @@ -1061,7 +1084,9 @@ export default class DevServer extends Server { `${basePath || assetPrefix || ''}/_next/webpack-hmr` ) ) { - this.hotReloader?.onHMR(req, socket, head) + if (!this.isRenderWorker) { + this.hotReloader?.onHMR(req, socket, head) + } } else { this.handleUpgrade(req, socket, head) } @@ -1229,10 +1254,34 @@ export default class DevServer extends Server { } } - private async logErrorWithOriginalStack( + private async invokeIpcMethod(method: string, args: any[]): Promise { + const ipcPort = process.env.__NEXT_PRIVATE_ROUTER_IPC_PORT + if (ipcPort) { + const res = await fetch( + `http://${this.hostname}:${ipcPort}?method=${ + method as string + }&args=${encodeURIComponent(JSON.stringify(args))}` + ) + const body = await res.text() + + if (body.startsWith('{') && body.endsWith('}')) { + return JSON.parse(body) + } + } + } + + protected async logErrorWithOriginalStack( err?: unknown, type?: 'unhandledRejection' | 'uncaughtException' | 'warning' | 'app-dir' ) { + if (this.isRenderWorker) { + await this.invokeIpcMethod('logErrorWithOriginalStack', [ + errorToJSON(err as Error), + type, + ]) + return + } + let usedOriginalStack = false if (isError(err) && err.stack) { @@ -1244,9 +1293,9 @@ export default class DevServer extends Server { !file?.includes('web/adapter') && !file?.includes('sandbox/context') && !file?.includes('') - )! + ) - if (frame.lineNumber && frame?.file) { + if (frame?.lineNumber && frame?.file) { const moduleId = frame.file!.replace( /^(webpack-internal:\/\/\/|file:\/\/)/, '' @@ -1271,7 +1320,7 @@ export default class DevServer extends Server { ) const originalFrame = await createOriginalStackFrame({ - line: frame.lineNumber!, + line: frame.lineNumber, column: frame.column, source, frame, @@ -1285,7 +1334,7 @@ export default class DevServer extends Server { edgeCompilation: isEdgeCompiler ? this.hotReloader?.edgeServerStats?.compilation : undefined, - }) + }).catch(() => {}) if (originalFrame) { const { originalCodeFrame, originalStackFrame } = originalFrame @@ -1395,7 +1444,7 @@ export default class DevServer extends Server { } protected async ensureMiddleware() { - return this.hotReloader?.ensurePage({ + return this.ensurePage({ page: this.actualMiddlewareFile!, clientOnly: false, }) @@ -1404,7 +1453,7 @@ export default class DevServer extends Server { private async runInstrumentationHookIfAvailable() { if (this.actualInstrumentationHookFile) { NextBuildContext!.hasInstrumentationHook = true - await this.hotReloader!.ensurePage({ + await this.ensurePage({ page: this.actualInstrumentationHookFile!, clientOnly: false, }) @@ -1429,11 +1478,11 @@ export default class DevServer extends Server { page: string appPaths: string[] | null }) { - return this.hotReloader?.ensurePage({ page, appPaths, clientOnly: false }) + return this.ensurePage({ page, appPaths, clientOnly: false }) } - generateRoutes() { - const { fsRoutes, ...otherRoutes } = super.generateRoutes() + generateRoutes(dev?: boolean) { + const { fsRoutes, ...otherRoutes } = super.generateRoutes(dev) // Create a shallow copy so we can mutate it. const routes = [...fsRoutes] @@ -1652,6 +1701,19 @@ export default class DevServer extends Server { global.fetch = this.originalFetch! } + protected async ensurePage(opts: { + page: string + clientOnly: boolean + appPaths?: string[] | null + match?: RouteMatch + }) { + if (this.isRenderWorker) { + await this.invokeIpcMethod('ensurePage', [opts]) + return + } + return this.hotReloader?.ensurePage(opts) + } + protected async findPageComponents({ pathname, query, @@ -1675,7 +1737,7 @@ export default class DevServer extends Server { } try { if (shouldEnsure || this.renderOpts.customServer) { - await this.hotReloader?.ensurePage({ + await this.ensurePage({ page: pathname, appPaths, clientOnly: false, @@ -1710,10 +1772,18 @@ export default class DevServer extends Server { } protected async getFallbackErrorComponents(): Promise { + if (this.isRenderWorker) { + await this.invokeIpcMethod('getFallbackErrorComponents', []) + return await loadDefaultErrorComponents(this.distDir) + } await this.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 this.hotReloader?.ensurePage({ page: '/_error', clientOnly: false }) + await this.ensurePage({ page: '/_error', clientOnly: false }) + + if (this.isRouterWorker) { + return null + } return await loadDefaultErrorComponents(this.distDir) } @@ -1740,6 +1810,10 @@ export default class DevServer extends Server { } async getCompilationError(page: string): Promise { + if (this.isRenderWorker) { + const err = await this.invokeIpcMethod('getCompilationError', [page]) + return deserializeErr(err) + } const errors = await this.hotReloader?.getCompilationErrors(page) if (!errors) return diff --git a/packages/next/src/server/lib/render-server.ts b/packages/next/src/server/lib/render-server.ts new file mode 100644 index 000000000000..d5efa41dbd42 --- /dev/null +++ b/packages/next/src/server/lib/render-server.ts @@ -0,0 +1,122 @@ +import v8 from 'v8' +import http from 'http' +import next from '../next' +import { isIPv6 } from 'net' +import { warn } from '../../build/output/log' +import type { RequestHandler } from '../next' +import { + deleteCache as _deleteCache, + deleteAppClientCache as _deleteAppClientCache, +} from '../../build/webpack/plugins/nextjs-require-cache-hot-reloader' + +export const WORKER_SELF_EXIT_CODE = 77 + +const MAXIMUM_HEAP_SIZE_ALLOWED = + (v8.getHeapStatistics().heap_size_limit / 1024 / 1024) * 0.9 + +let result: + | undefined + | { + port: number + hostname: string + } + +export function deleteAppClientCache() { + _deleteAppClientCache() +} + +export function deleteCache(filePath: string) { + _deleteCache(filePath) +} + +export async function initialize(opts: { + dir: string + port: number + dev: boolean + hostname?: string + workerType: 'router' | 'render' + keepAliveTimeout?: number +}): Promise> { + // if we already setup the server return as we only need to do + // this on first worker boot + if (result) { + return result + } + let requestHandler: RequestHandler + + const server = http.createServer((req, res) => { + return requestHandler(req, res).finally(() => { + if ( + process.memoryUsage().heapUsed / 1024 / 1024 > + MAXIMUM_HEAP_SIZE_ALLOWED + ) { + warn( + 'The server is running out of memory, restarting to free up memory.' + ) + server.close() + process.exit(WORKER_SELF_EXIT_CODE) + } + }) + }) + + if (opts.keepAliveTimeout) { + server.keepAliveTimeout = opts.keepAliveTimeout + } + + return new Promise((resolve, reject) => { + server.on('error', (err: NodeJS.ErrnoException) => { + console.error(`Invariant: failed to start render worker`, err) + process.exit(1) + }) + + let upgradeHandler: any + + if (!opts.dev) { + server.on('upgrade', (req, socket, upgrade) => { + upgradeHandler(req, socket, upgrade) + }) + } + + server.on('listening', async () => { + try { + const addr = server.address() + const port = addr && typeof addr === 'object' ? addr.port : 0 + + if (!port) { + console.error(`Invariant failed to detect render worker port`, addr) + process.exit(1) + } + + let hostname = + !opts.hostname || opts.hostname === '0.0.0.0' + ? 'localhost' + : opts.hostname + + if (isIPv6(hostname)) { + hostname = hostname === '::' ? '[::1]' : `[${hostname}]` + } + result = { + port, + hostname, + } + const app = next({ + ...opts, + _routerWorker: opts.workerType === 'router', + _renderWorker: opts.workerType === 'render', + hostname, + customServer: false, + httpServer: server, + port: opts.port, + }) + + requestHandler = app.getRequestHandler() + upgradeHandler = app.getUpgradeHandler() + await app.prepare() + resolve(result) + } catch (err) { + return reject(err) + } + }) + server.listen(0, opts.hostname) + }) +} diff --git a/packages/next/src/server/lib/route-resolver.ts b/packages/next/src/server/lib/route-resolver.ts index 7dc62ed4818d..38e670163e5e 100644 --- a/packages/next/src/server/lib/route-resolver.ts +++ b/packages/next/src/server/lib/route-resolver.ts @@ -176,7 +176,7 @@ export async function makeResolver( devServer.hasMiddleware = () => true } - const routes = devServer.generateRoutes() + const routes = devServer.generateRoutes(true) // @ts-expect-error protected const catchAllMiddleware = devServer.generateCatchAllMiddlewareRoute(true) diff --git a/packages/next/src/server/lib/server-ipc.ts b/packages/next/src/server/lib/server-ipc.ts new file mode 100644 index 000000000000..2721cf8978fb --- /dev/null +++ b/packages/next/src/server/lib/server-ipc.ts @@ -0,0 +1,104 @@ +import type NextServer from '../next-server' +import { genExecArgv, getNodeOptionsWithoutInspect } from './utils' +import { deserializeErr, errorToJSON } from '../render' + +// we can't use process.send as jest-worker relies on +// it already and can cause unexpected message errors +// so we create an IPC server for communicating +export async function createIpcServer( + server: InstanceType +): Promise<{ + ipcPort: number + ipcServer: import('http').Server +}> { + const ipcServer = (require('http') as typeof import('http')).createServer( + async (req, res) => { + try { + const url = new URL(req.url || '/', 'http://n') + const method = url.searchParams.get('method') + const args: any[] = JSON.parse(url.searchParams.get('args') || '[]') + + if (!method || !Array.isArray(args)) { + return res.end() + } + + if (typeof (server as any)[method] === 'function') { + if (method === 'logErrorWithOriginalStack' && args[0]?.stack) { + args[0] = deserializeErr(args[0]) + } + let result = await (server as any)[method](...args) + + if (result && typeof result === 'object' && result.stack) { + result = errorToJSON(result) + } + res.end(JSON.stringify(result || '')) + } + } catch (err: any) { + console.error(err) + res.end( + JSON.stringify({ + err: { name: err.name, message: err.message, stack: err.stack }, + }) + ) + } + } + ) + + const ipcPort = await new Promise((resolveIpc) => { + ipcServer.listen(0, server.hostname, () => { + const addr = ipcServer.address() + + if (addr && typeof addr === 'object') { + resolveIpc(addr.port) + } + }) + }) + + return { + ipcPort, + ipcServer, + } +} + +export const createWorker = ( + serverPort: number, + ipcPort: number, + isNodeDebugging: boolean | 'brk' | undefined, + type: string +) => { + const { initialEnv } = require('@next/env') as typeof import('@next/env') + const { Worker } = require('next/dist/compiled/jest-worker') + const worker = new Worker(require.resolve('./render-server'), { + numWorkers: 1, + // TODO: do we want to allow more than 10 OOM restarts? + maxRetries: 10, + forkOptions: { + env: { + FORCE_COLOR: '1', + ...initialEnv, + // we don't pass down NODE_OPTIONS as it can + // extra memory usage + NODE_OPTIONS: getNodeOptionsWithoutInspect() + .replace(/--max-old-space-size=[\d]{1,}/, '') + .trim(), + __NEXT_PRIVATE_RENDER_WORKER: type, + __NEXT_PRIVATE_ROUTER_IPC_PORT: ipcPort + '', + NODE_ENV: process.env.NODE_ENV, + }, + execArgv: genExecArgv( + isNodeDebugging === undefined ? false : isNodeDebugging, + (serverPort || 0) + 1 + ), + }, + exposedMethods: ['initialize', 'deleteCache', 'deleteAppClientCache'], + }) as any as InstanceType & { + initialize: typeof import('./render-server').initialize + deleteCache: typeof import('./render-server').deleteCache + deleteAppClientCache: typeof import('./render-server').deleteAppClientCache + } + + worker.getStderr().pipe(process.stderr) + worker.getStdout().pipe(process.stdout) + + return worker +} diff --git a/packages/next/src/server/lib/start-server.ts b/packages/next/src/server/lib/start-server.ts index 6a189983ee24..74b0d44b578f 100644 --- a/packages/next/src/server/lib/start-server.ts +++ b/packages/next/src/server/lib/start-server.ts @@ -1,94 +1,326 @@ -import type { NextServerOptions, NextServer, RequestHandler } from '../next' -import { warn } from '../../build/output/log' import http from 'http' -import next from '../next' import { isIPv6 } from 'net' -import v8 from 'v8' -const isChildProcess = !!process.env.__NEXT_DEV_CHILD_PROCESS +import * as Log from '../../build/output/log' +import { getNodeOptionsWithoutInspect } from './utils' +import type { IncomingMessage, ServerResponse } from 'http' +import type { ChildProcess } from 'child_process' +import { normalizeRepeatedSlashes } from '../../shared/lib/utils' -interface StartServerOptions extends NextServerOptions { +export interface StartServerOptions { + dir: string + prevDir?: string + port: number + isDev: boolean + hostname: string + useWorkers: boolean allowRetry?: boolean + isTurbopack?: boolean keepAliveTimeout?: number + onStdout?: (data: any) => void + onStderr?: (data: any) => void } -export const WORKER_SELF_EXIT_CODE = 77 - -const MAXIMUM_HEAP_SIZE_ALLOWED = - (v8.getHeapStatistics().heap_size_limit / 1024 / 1024) * 0.9 - -export function startServer(opts: StartServerOptions) { - let requestHandler: RequestHandler - - const server = http.createServer((req, res) => { - return requestHandler(req, res).finally(() => { - if ( - isChildProcess && - process.memoryUsage().heapUsed / 1024 / 1024 > MAXIMUM_HEAP_SIZE_ALLOWED - ) { - warn( - 'The server is running out of memory, restarting to free up memory.' - ) - server.close() - process.exit(WORKER_SELF_EXIT_CODE) - } - }) - }) +type TeardownServer = () => Promise + +export async function startServer({ + dir, + prevDir, + port, + isDev, + hostname, + useWorkers, + allowRetry, + keepAliveTimeout, + onStdout, + onStderr, +}: StartServerOptions): Promise { + const sockets = new Set() + let worker: import('next/dist/compiled/jest-worker').Worker | undefined + let handlersReady = () => {} + let handlersError = () => {} - if (opts.keepAliveTimeout) { - server.keepAliveTimeout = opts.keepAliveTimeout + let isNodeDebugging: 'brk' | boolean = !!( + process.execArgv.some((localArg) => localArg.startsWith('--inspect')) || + process.env.NODE_OPTIONS?.match?.(/--inspect(=\S+)?( |$)/) + ) + + if ( + process.execArgv.some((localArg) => localArg.startsWith('--inspect-brk')) || + process.env.NODE_OPTIONS?.match?.(/--inspect-brk(=\S+)?( |$)/) + ) { + isNodeDebugging = 'brk' + } + + let handlersPromise: Promise | undefined = new Promise( + (resolve, reject) => { + handlersReady = resolve + handlersError = reject + } + ) + let requestHandler = async ( + _req: IncomingMessage, + _res: ServerResponse + ): Promise => { + if (handlersPromise) { + await handlersPromise + return requestHandler(_req, _res) + } + throw new Error('Invariant request handler was not setup') + } + let upgradeHandler = async ( + _req: IncomingMessage, + _socket: ServerResponse, + _head: Buffer + ): Promise => { + if (handlersPromise) { + await handlersPromise + return upgradeHandler(_req, _socket, _head) + } + throw new Error('Invariant upgrade handler was not setup') } - return new Promise((resolve, reject) => { - let port = opts.port - let retryCount = 0 - - server.on('error', (err: NodeJS.ErrnoException) => { - if ( - port && - opts.allowRetry && - err.code === 'EADDRINUSE' && - retryCount < 10 - ) { - warn(`Port ${port} is in use, trying ${port + 1} instead.`) - port += 1 - retryCount += 1 - server.listen(port, opts.hostname) - } else { - reject(err) + // setup server listener as fast as possible + const server = http.createServer(async (req, res) => { + try { + if (handlersPromise) { + await handlersPromise + handlersPromise = undefined } - }) + sockets.add(res) + res.on('close', () => sockets.delete(res)) + await requestHandler(req, res) + } catch (err) { + res.statusCode = 500 + res.end('Internal Server Error') + Log.error(`Failed to handle request for ${req.url}`) + console.error(err) + } + }) - let upgradeHandler: any + if (keepAliveTimeout) { + server.keepAliveTimeout = keepAliveTimeout + } + server.on('upgrade', async (req, socket, head) => { + try { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + await upgradeHandler(req, socket, head) + } catch (err) { + socket.destroy() + Log.error(`Failed to handle request for ${req.url}`) + console.error(err) + } + }) - if (!opts.dev) { - server.on('upgrade', (req, socket, upgrade) => { - upgradeHandler(req, socket, upgrade) - }) + let portRetryCount = 0 + + server.on('error', (err: NodeJS.ErrnoException) => { + if ( + allowRetry && + port && + isDev && + err.code === 'EADDRINUSE' && + portRetryCount < 10 + ) { + Log.warn(`Port ${port} is in use, trying ${port + 1} instead.`) + port += 1 + portRetryCount += 1 + server.listen(port, hostname) + } else { + Log.error(`Failed to start server`) + console.error(err) + process.exit(1) } + }) + let targetHost = hostname + + await new Promise((resolve) => { server.on('listening', () => { const addr = server.address() - let hostname = - !opts.hostname || opts.hostname === '0.0.0.0' - ? 'localhost' - : opts.hostname + port = typeof addr === 'object' ? addr?.port || port : port + + let host = !hostname || hostname === '0.0.0.0' ? 'localhost' : hostname + + let normalizedHostname = hostname || '0.0.0.0' + if (isIPv6(hostname)) { - hostname = hostname === '::' ? '[::1]' : `[${hostname}]` + host = host === '::' ? '[::1]' : `[${host}]` + normalizedHostname = `[${hostname}]` } + targetHost = host + const appUrl = `http://${host}:${port}` + + Log.ready( + `started server on ${normalizedHostname}${ + (port + '').startsWith(':') ? '' : ':' + }${port}, url: ${appUrl}` + ) + resolve() + }) + server.listen(port, hostname) + }) + + try { + if (useWorkers) { + const httpProxy = + require('next/dist/compiled/http-proxy') as typeof import('next/dist/compiled/http-proxy') + + let renderServerPath = require.resolve('./render-server') + let jestWorkerPath = require.resolve('next/dist/compiled/jest-worker') + + if (prevDir) { + jestWorkerPath = jestWorkerPath.replace(prevDir, dir) + renderServerPath = renderServerPath.replace(prevDir, dir) + } + + const { Worker } = + require(jestWorkerPath) as typeof import('next/dist/compiled/jest-worker') + + const routerWorker = new Worker(renderServerPath, { + numWorkers: 1, + // TODO: do we want to allow more than 10 OOM restarts? + maxRetries: 10, + forkOptions: { + env: { + FORCE_COLOR: '1', + ...process.env, + // we don't pass down NODE_OPTIONS as it can + // extra memory usage + NODE_OPTIONS: getNodeOptionsWithoutInspect() + .replace(/--max-old-space-size=[\d]{1,}/, '') + .trim(), + }, + }, + exposedMethods: ['initialize'], + }) as any as InstanceType & { + initialize: typeof import('./render-server').initialize + } + let didInitialize = false + + for (const _worker of ((routerWorker as any)._workerPool?._workers || + []) as { + _child: ChildProcess + }[]) { + // eslint-disable-next-line no-loop-func + _worker._child.on('exit', (code, signal) => { + // catch failed initializing without retry + if ((code || signal) && !didInitialize) { + routerWorker?.end() + process.exit(1) + } + }) + } + + const workerStdout = routerWorker.getStdout() + const workerStderr = routerWorker.getStderr() + + workerStdout.on('data', (data) => { + if (typeof onStdout === 'function') { + onStdout(data) + } else { + process.stdout.write(data) + } + }) + workerStderr.on('data', (data) => { + if (typeof onStderr === 'function') { + onStderr(data) + } else { + process.stderr.write(data) + } + }) + + const { port: routerPort } = await routerWorker.initialize({ + dir, + port, + hostname, + dev: !!isDev, + workerType: 'router', + keepAliveTimeout, + }) + didInitialize = true + + const getProxyServer = (pathname: string) => { + const targetUrl = `http://${targetHost}:${routerPort}${pathname}` + + const proxyServer = httpProxy.createProxy({ + target: targetUrl, + changeOrigin: false, + ignorePath: true, + xfwd: true, + ws: true, + followRedirects: false, + }) + + proxyServer.on('error', () => { + // TODO?: enable verbose error logs with --debug flag? + }) + return proxyServer + } + + // proxy to router worker + requestHandler = async (req, res) => { + const urlParts = (req.url || '').split('?') + const urlNoQuery = urlParts[0] + + // this normalizes repeated slashes in the path e.g. hello//world -> + // hello/world or backslashes to forward slashes, this does not + // handle trailing slash as that is handled the same as a next.config.js + // redirect + if (urlNoQuery?.match(/(\\|\/\/)/)) { + const cleanUrl = normalizeRepeatedSlashes(req.url!) + res.statusCode = 308 + res.setHeader('Location', cleanUrl) + res.end(cleanUrl) + return + } + const proxyServer = getProxyServer(req.url || '/') + proxyServer.web(req, res) + } + upgradeHandler = async (req, socket, head) => { + const proxyServer = getProxyServer(req.url || '/') + proxyServer.ws(req, socket, head) + } + handlersReady() + } else { + // when not using a worker start next in main process + const { default: next } = require('../next') as typeof import('../next') + const addr = server.address() const app = next({ - ...opts, + dir, hostname, - customServer: false, + dev: isDev, + isNodeDebugging, httpServer: server, + customServer: false, port: addr && typeof addr === 'object' ? addr.port : port, }) - + // handle in process requestHandler = app.getRequestHandler() upgradeHandler = app.getUpgradeHandler() - resolve(app) + await app.prepare() + handlersReady() + } + } catch (err) { + // fatal error if we can't setup + handlersError() + Log.error(`Failed to setup request handlers`) + console.error(err) + process.exit(1) + } + + // return teardown function for destroying the server + async function teardown() { + server.close() + sockets.forEach((socket) => { + sockets.delete(socket) + socket.destroy() }) - server.listen(port, opts.hostname) - }) + if (worker) { + await worker.end() + } + } + return teardown } diff --git a/packages/next/src/server/lib/utils.ts b/packages/next/src/server/lib/utils.ts index b149393d25ad..afb62663ec4c 100644 --- a/packages/next/src/server/lib/utils.ts +++ b/packages/next/src/server/lib/utils.ts @@ -10,6 +10,22 @@ export function printAndExit(message: string, code = 1) { process.exit(code) } +export const genExecArgv = (enabled: boolean | 'brk', debugPort: number) => { + const execArgv = process.execArgv.filter((localArg) => { + return ( + !localArg.startsWith('--inspect') && !localArg.startsWith('--inspect-brk') + ) + }) + + if (enabled) { + execArgv.push( + `--inspect${enabled === 'brk' ? '-brk' : ''}=${debugPort + 1}` + ) + } + + return execArgv +} + export function getNodeOptionsWithoutInspect() { const NODE_INSPECT_RE = /--inspect(-brk)?(=\S+)?( |$)/ return (process.env.NODE_OPTIONS || '').replace(NODE_INSPECT_RE, '') diff --git a/packages/next/src/server/lib/worker-utils.ts b/packages/next/src/server/lib/worker-utils.ts new file mode 100644 index 000000000000..13cda78c85a2 --- /dev/null +++ b/packages/next/src/server/lib/worker-utils.ts @@ -0,0 +1,47 @@ +import * as Log from '../../build/output/log' + +export const genRenderExecArgv = () => { + const isDebugging = + process.execArgv.some((localArg) => localArg.startsWith('--inspect')) || + process.env.NODE_OPTIONS?.match?.(/--inspect(=\S+)?( |$)/) + + const isDebuggingWithBrk = + process.execArgv.some((localArg) => localArg.startsWith('--inspect-brk')) || + process.env.NODE_OPTIONS?.match?.(/--inspect-brk(=\S+)?( |$)/) + + const debugPort = (() => { + const debugPortStr = + process.execArgv + .find( + (localArg) => + localArg.startsWith('--inspect') || + localArg.startsWith('--inspect-brk') + ) + ?.split('=')[1] ?? + process.env.NODE_OPTIONS?.match?.(/--inspect(-brk)?(=(\S+))?( |$)/)?.[3] + return debugPortStr ? parseInt(debugPortStr, 10) : 9229 + })() + + if (isDebugging || isDebuggingWithBrk) { + Log.warn( + `the --inspect${ + isDebuggingWithBrk ? '-brk' : '' + } option was detected, the Next.js server should be inspected at port ${ + debugPort + 1 + }.` + ) + } + const execArgv = process.execArgv.filter((localArg) => { + return ( + !localArg.startsWith('--inspect') && !localArg.startsWith('--inspect-brk') + ) + }) + + if (isDebugging || isDebuggingWithBrk) { + execArgv.push( + `--inspect${isDebuggingWithBrk ? '-brk' : ''}=${debugPort + 1}` + ) + } + + return execArgv +} diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 0a5d7b050624..f27f94998d6a 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -19,7 +19,10 @@ import type { BaseNextRequest, BaseNextResponse } from './base-http' import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin' import type { PayloadOptions } from './send-payload' import type { NextParsedUrlQuery, NextUrlWithParsedQuery } from './request-meta' -import type { Params } from '../shared/lib/router/utils/route-matcher' +import { + getRouteMatcher, + Params, +} from '../shared/lib/router/utils/route-matcher' import type { MiddlewareRouteMatch } from '../shared/lib/router/utils/middleware-route-matcher' import fs from 'fs' @@ -95,7 +98,10 @@ import { INSTRUMENTATION_HOOK_FILENAME } from '../lib/constants' import { getTracer } from './lib/trace/tracer' import { NextNodeServerSpan } from './lib/trace/constants' import { nodeFs } from './lib/node-fs-methods' +import { getRouteRegex } from '../shared/lib/router/utils/route-regex' import { removePathPrefix } from '../shared/lib/router/utils/remove-path-prefix' +import { addPathPrefix } from '../shared/lib/router/utils/add-path-prefix' +import { pathHasPrefix } from '../shared/lib/router/utils/path-has-prefix' export * from './base-server' @@ -172,9 +178,29 @@ const POSSIBLE_ERROR_CODE_FROM_SERVE_STATIC = new Set([ 416, ]) +type RenderWorker = Worker & { + initialize: typeof import('./lib/render-server').initialize + deleteCache: typeof import('./lib/render-server').deleteCache + deleteAppClientCache: typeof import('./lib/render-server').deleteAppClientCache +} + export default class NextNodeServer extends BaseServer { private imageResponseCache?: ResponseCache private compression?: ExpressMiddleware + protected renderWorkersPromises?: Promise + protected renderWorkers?: { + middleware?: RenderWorker + pages?: RenderWorker + app?: RenderWorker + } + protected renderWorkerOpts?: Parameters< + typeof import('./lib/render-server').initialize + >[0] + protected dynamicRoutes?: { + match: import('../shared/lib/router/utils/route-matcher').RouteMatchFn + page: string + re: RegExp + }[] constructor(options: Options) { // Initialize super class @@ -222,6 +248,55 @@ export default class NextNodeServer extends BaseServer { }).catch(() => {}) } + if (this.isRouterWorker) { + this.renderWorkers = {} + this.renderWorkerOpts = { + port: this.port || 0, + dir: this.dir, + workerType: 'render', + hostname: this.hostname, + dev: !!options.dev, + } + const { createWorker, createIpcServer } = + require('./lib/server-ipc') as typeof import('./lib/server-ipc') + this.renderWorkersPromises = new Promise(async (resolveWorkers) => { + try { + this.renderWorkers = {} + const { ipcPort } = await createIpcServer(this) + if (this.hasAppDir) { + this.renderWorkers.app = createWorker( + this.port || 0, + ipcPort, + options.isNodeDebugging, + 'app' + ) + } + this.renderWorkers.pages = createWorker( + this.port || 0, + ipcPort, + options.isNodeDebugging, + 'pages' + ) + this.renderWorkers.middleware = + this.renderWorkers.pages || this.renderWorkers.app + + resolveWorkers() + } catch (err) { + Log.error(`Invariant failed to initialize render workers`) + console.error(err) + process.exit(1) + } + }) + ;(global as any)._nextDeleteCache = (filePath: string) => { + this.renderWorkers?.pages?.deleteCache(filePath) + this.renderWorkers?.app?.deleteCache(filePath) + } + ;(global as any)._nextDeleteAppClientCache = () => { + this.renderWorkers?.pages?.deleteAppClientCache() + this.renderWorkers?.app?.deleteAppClientCache() + } + } + // expose AsyncLocalStorage on global for react usage const { AsyncLocalStorage } = require('async_hooks') ;(globalThis as any).AsyncLocalStorage = AsyncLocalStorage @@ -257,11 +332,18 @@ export default class NextNodeServer extends BaseServer { protected loadEnvConfig({ dev, forceReload, + silent, }: { dev: boolean forceReload?: boolean + silent?: boolean }) { - loadEnvConfig(this.dir, dev, Log, forceReload) + loadEnvConfig( + this.dir, + dev, + silent ? { info: () => {}, error: () => {} } : Log, + forceReload + ) } protected getIncrementalCache({ @@ -1069,11 +1151,32 @@ export default class NextNodeServer extends BaseServer { return cacheFs.readFile(join(this.serverDistDir, 'pages', `${page}.html`)) } - protected generateRoutes(): RouterOptions { + protected generateRoutes(dev?: boolean): RouterOptions { const publicRoutes = this.generatePublicRoutes() const imageRoutes = this.generateImageRoutes() const staticFilesRoutes = this.generateStaticRoutes() + if (!dev) { + const routesManifest = this.getRoutesManifest() as { + dynamicRoutes: { + page: string + regex: string + namedRegex?: string + routeKeys?: { [key: string]: string } + }[] + } + this.dynamicRoutes = routesManifest.dynamicRoutes.map((r) => { + const regex = getRouteRegex(r.page) + const match = getRouteMatcher(regex) + + return { + match, + page: r.page, + regex: regex.re, + } + }) as any + } + const fsRoutes: Route[] = [ ...this.generateFsStaticRoutes(), { @@ -1198,17 +1301,19 @@ export default class NextNodeServer extends BaseServer { : ['/_next'] // Headers come very first - const headers = this.minimalMode - ? [] - : this.customRoutes.headers.map((rule) => - createHeaderRoute({ rule, restrictedRedirectPaths }) - ) + const headers = + this.minimalMode || this.isRenderWorker + ? [] + : this.customRoutes.headers.map((rule) => + createHeaderRoute({ rule, restrictedRedirectPaths }) + ) - const redirects = this.minimalMode - ? [] - : this.customRoutes.redirects.map((rule) => - createRedirectRoute({ rule, restrictedRedirectPaths }) - ) + const redirects = + this.minimalMode || this.isRenderWorker + ? [] + : this.customRoutes.redirects.map((rule) => + createRedirectRoute({ rule, restrictedRedirectPaths }) + ) const rewrites = this.generateRewrites({ restrictedRedirectPaths }) const catchAllMiddleware = this.generateCatchAllMiddlewareRoute() @@ -1235,6 +1340,172 @@ export default class NextNodeServer extends BaseServer { const match = await this.matchers.match(pathname, options) + if (this.isRouterWorker) { + let page = pathname + + if (!(await this.hasPage(page))) { + for (const route of this.dynamicRoutes || []) { + if (route.match(pathname)) { + page = route.page + } + } + } + + const renderKind = this.appPathRoutes?.[page] ? 'app' : 'pages' + + if (this.renderWorkersPromises) { + await this.renderWorkersPromises + this.renderWorkersPromises = undefined + } + const renderWorker = this.renderWorkers?.[renderKind] + + if (renderWorker) { + const initUrl = getRequestMeta(req, '__NEXT_INIT_URL')! + const { port, hostname } = await renderWorker.initialize( + this.renderWorkerOpts! + ) + const renderUrl = new URL(initUrl) + renderUrl.hostname = hostname + renderUrl.port = port + '' + + let invokePathname = pathname + const normalizedInvokePathname = + this.localeNormalizer?.normalize(pathname) + + if (normalizedInvokePathname?.startsWith('/api')) { + invokePathname = normalizedInvokePathname + } else if ( + query.__nextLocale && + !pathHasPrefix(invokePathname, `/${query.__nextLocale}`) + ) { + invokePathname = `/${query.__nextLocale}${ + invokePathname === '/' ? '' : invokePathname + }` + } + + if (query.__nextDataReq) { + invokePathname = `/_next/data/${this.buildId}${invokePathname}.json` + } + invokePathname = addPathPrefix( + invokePathname, + this.nextConfig.basePath + ) + const keptQuery: ParsedUrlQuery = {} + + for (const key of Object.keys(query)) { + if (key.startsWith('__next') || key.startsWith('_next')) { + continue + } + keptQuery[key] = query[key] + } + if (query._nextBubbleNoFallback) { + keptQuery._nextBubbleNoFallback = '1' + } + const invokeQuery = JSON.stringify(keptQuery) + + const invokeHeaders: typeof req.headers = { + 'cache-control': '', + ...req.headers, + 'x-invoke-path': invokePathname, + 'x-invoke-query': encodeURIComponent(invokeQuery), + } + + const forbiddenHeaders = (global as any).__NEXT_USE_UNDICI + ? [ + 'content-length', + 'keepalive', + 'content-encoding', + 'transfer-encoding', + // https://github.com/nodejs/undici/issues/1470 + 'connection', + ] + : [ + 'content-length', + 'keepalive', + 'content-encoding', + 'transfer-encoding', + ] + + for (const key of forbiddenHeaders) { + delete invokeHeaders[key] + } + + const invokeRes = await fetch(renderUrl, { + method: req.method, + headers: invokeHeaders as any, + redirect: 'manual', + ...(req.method !== 'GET' && req.method !== 'HEAD' + ? { + // @ts-ignore + duplex: 'half', + body: getRequestMeta( + req, + '__NEXT_CLONABLE_BODY' + )?.cloneBodyStream() as any as ReadableStream, + } + : {}), + }) + + const noFallback = invokeRes.headers.get('x-no-fallback') + + if (noFallback) { + if (bubbleNoFallback) { + return { finished: false } + } else { + await this.render404(req, res, parsedUrl) + return { + finished: true, + } + } + } + + for (const [key, value] of Object.entries( + toNodeHeaders(invokeRes.headers) + )) { + if ( + ![ + 'content-encoding', + 'transfer-encoding', + 'keep-alive', + 'connection', + ].includes(key) && + value !== undefined + ) { + if (key === 'set-cookie') { + const curValue = res.getHeader(key) + const newValue: string[] = [] as string[] + for (const cookie of 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 + res.statusMessage = invokeRes.statusText + for await (const chunk of invokeRes.body || ([] as any)) { + this.streamResponseChunk(res as NodeNextResponse, chunk) + } + res.send() + return { + finished: true, + } + } + } + + if (match) { + addRequestMeta(req, '_nextMatch', match) + } + // Try to handle the given route with the configured handlers. if (match) { // Add the match to the request so we don't have to re-run the matcher @@ -1300,6 +1571,14 @@ export default class NextNodeServer extends BaseServer { } } catch (err) { if (err instanceof NoFallbackError && bubbleNoFallback) { + if (this.isRenderWorker) { + res.setHeader('x-no-fallback', '1') + res.send() + return { + finished: true, + } + } + return { finished: false, } @@ -1485,6 +1764,9 @@ export default class NextNodeServer extends BaseServer { ) { res.statusCode = err.statusCode return this.renderError(err, req, res, path) + } else if ((err as any).expose === false) { + res.statusCode = 400 + return this.renderError(null, req, res, path) } else { throw err } @@ -1562,7 +1844,7 @@ export default class NextNodeServer extends BaseServer { let afterFiles: Route[] = [] let fallback: Route[] = [] - if (!this.minimalMode) { + if (!this.minimalMode && !this.isRenderWorker) { const buildRewrite = (rewrite: Rewrite, check = true): Route => { const rewriteRoute = getCustomRoute({ type: 'rewrite', @@ -1915,9 +2197,25 @@ export default class NextNodeServer extends BaseServer { type: 'route', name: 'middleware catchall', fn: async (req, res, _params, parsed) => { + const isMiddlewareInvoke = + this.isRenderWorker && req.headers['x-middleware-invoke'] + + const handleFinished = (finished: boolean = false) => { + if (isMiddlewareInvoke && !finished) { + res.setHeader('x-middleware-invoke', '1') + res.body('').send() + return { finished: true } + } + return { finished } + } + + if (this.isRenderWorker && !isMiddlewareInvoke) { + return { finished: false } + } + const middleware = this.getMiddleware() if (!middleware) { - return { finished: false } + return handleFinished() } const initUrl = getRequestMeta(req, '__NEXT_INIT_URL')! @@ -1932,7 +2230,7 @@ export default class NextNodeServer extends BaseServer { parsed.pathname || '' ) if (!middleware.match(normalizedPathname, req, parsedUrl.query)) { - return { finished: false } + return handleFinished() } let result: Awaited< @@ -1940,12 +2238,110 @@ export default class NextNodeServer extends BaseServer { > try { - result = await this.runMiddleware({ - request: req, - response: res, - parsedUrl: parsedUrl, - parsed: parsed, - }) + await this.ensureMiddleware() + + if (this.isRouterWorker && this.renderWorkers?.middleware) { + if (this.renderWorkersPromises) { + await this.renderWorkersPromises + this.renderWorkersPromises = undefined + } + + const { port, hostname } = + await this.renderWorkers.middleware.initialize( + this.renderWorkerOpts! + ) + const renderUrl = new URL(initUrl) + renderUrl.hostname = hostname + renderUrl.port = port + '' + + const invokeHeaders: typeof req.headers = { + ...req.headers, + 'x-middleware-invoke': '1', + } + for (const key of [ + 'content-length', + 'keepalive', + 'content-encoding', + 'transfer-encoding', + ]) { + delete invokeHeaders[key] + } + + const invokeRes = await fetch(renderUrl, { + method: req.method, + headers: invokeHeaders as any, + redirect: 'manual', + ...(req.method !== 'GET' && req.method !== 'HEAD' + ? { + // @ts-ignore + duplex: 'half', + body: getRequestMeta( + req, + '__NEXT_CLONABLE_BODY' + )?.cloneBodyStream() as any as ReadableStream, + } + : {}), + }) + + result = { + response: new Response(invokeRes.body, { + status: invokeRes.status, + headers: new Headers(invokeRes.headers), + }), + waitUntil: Promise.resolve(), + } + for (const key of [...result.response.headers.keys()]) { + if ( + [ + 'content-encoding', + 'transfer-encoding', + 'keep-alive', + 'connection', + ].includes(key) + ) { + result.response.headers.delete(key) + } else { + const value = result.response.headers.get(key) + // propagate this to req headers so it's + // passed to the render worker for the page + req.headers[key] = value || undefined + + if (key.toLowerCase() === 'set-cookie' && value) { + addRequestMeta( + req, + '_nextMiddlewareCookie', + splitCookiesString(value) + ) + } + } + } + } else { + result = await this.runMiddleware({ + request: req, + response: res, + parsedUrl: parsedUrl, + parsed: parsed, + }) + + if (isMiddlewareInvoke && 'response' in result) { + for (const [key, value] of Object.entries( + toNodeHeaders(result.response.headers) + )) { + if (key !== 'content-encoding' && value !== undefined) { + res.setHeader(key, value as string | string[]) + } + } + res.statusCode = result.response.status + for await (const chunk of result.response.body || + ([] as any)) { + this.streamResponseChunk(res as NodeNextResponse, chunk) + } + res.send() + return { + finished: true, + } + } + } } catch (err) { if (isError(err) && err.code === 'ENOENT') { await this.render404(req, res, parsed) @@ -2097,10 +2493,12 @@ export default class NextNodeServer extends BaseServer { addRequestMeta(req, '_nextRewroteUrl', newUrl) addRequestMeta(req, '_nextDidRewrite', newUrl !== req.url) - return { - finished: false, - pathname: newUrl, - query: parsedDestination.query, + if (!isMiddlewareInvoke) { + return { + finished: false, + pathname: newUrl, + query: parsedDestination.query, + } } } diff --git a/packages/next/src/server/next.ts b/packages/next/src/server/next.ts index 20188ec0efcd..aba9431ffbcf 100644 --- a/packages/next/src/server/next.ts +++ b/packages/next/src/server/next.ts @@ -147,7 +147,9 @@ export class NextServer { return loadConfig( this.options.dev ? PHASE_DEVELOPMENT_SERVER : PHASE_PRODUCTION_SERVER, resolve(this.options.dir || '.'), - this.options.conf + this.options.conf, + undefined, + !!this.options._renderWorker ) } diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index 26597d18a046..b60c1c8e4ce5 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -331,7 +331,28 @@ function checkRedirectValues( } } -function errorToJSON(err: Error) { +export const deserializeErr = (serializedErr: any) => { + if ( + !serializedErr || + typeof serializedErr !== 'object' || + !serializedErr.stack + ) { + return serializedErr + } + const err = new Error(serializedErr.message) + err.stack = serializedErr.stack + err.name = serializedErr.name + ;(err as any).digest = serializedErr.digest + + if (process.env.NEXT_RUNTIME !== 'edge') { + const { decorateServerError } = + require('next/dist/compiled/@next/react-dev-overlay/dist/middleware') as typeof import('next/dist/compiled/@next/react-dev-overlay/dist/middleware') + decorateServerError(err, serializedErr.source || 'server') + } + return err +} + +export function errorToJSON(err: Error) { let source: typeof COMPILER_NAMES.server | typeof COMPILER_NAMES.edgeServer = 'server' @@ -347,6 +368,7 @@ function errorToJSON(err: Error) { source, message: stripAnsi(err.message), stack: err.stack, + digest: (err as any).digest, } } diff --git a/packages/next/src/server/require.ts b/packages/next/src/server/require.ts index 709ef5c69f36..62bb3ff6b1c0 100644 --- a/packages/next/src/server/require.ts +++ b/packages/next/src/server/require.ts @@ -1,4 +1,4 @@ -import { promises } from 'fs' +import fs, { promises } from 'fs' import { join } from 'path' import { FONT_MANIFEST, @@ -13,18 +13,25 @@ import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plug import { PageNotFoundError, MissingStaticPage } from '../shared/lib/utils' import LRUCache from 'next/dist/compiled/lru-cache' -const pagePathCache = - process.env.NODE_ENV === 'development' - ? { - get: (_key: string) => { - return null - }, - set: () => {}, - has: () => false, - } - : new LRUCache({ - max: 1000, - }) +const isDev = process.env.NODE_ENV === 'development' +const pagePathCache = isDev + ? { + get: (_key: string) => { + return null + }, + set: () => {}, + has: () => false, + } + : new LRUCache({ + max: 1000, + }) + +const loadManifest = (manifestPath: string) => { + if (isDev) { + return JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + } + return require(manifestPath) +} export function getMaybePagePath( page: string, @@ -42,12 +49,11 @@ export function getMaybePagePath( let appPathsManifest: undefined | PagesManifest if (isAppPath) { - appPathsManifest = require(join(serverBuildPath, APP_PATHS_MANIFEST)) + appPathsManifest = loadManifest(join(serverBuildPath, APP_PATHS_MANIFEST)) } - const pagesManifest = require(join( - serverBuildPath, - PAGES_MANIFEST - )) as PagesManifest + const pagesManifest = loadManifest( + join(serverBuildPath, PAGES_MANIFEST) + ) as PagesManifest try { page = denormalizePagePath(normalizePagePath(page)) diff --git a/packages/next/src/server/router.ts b/packages/next/src/server/router.ts index 92704a5e0d1e..542f2628c5d9 100644 --- a/packages/next/src/server/router.ts +++ b/packages/next/src/server/router.ts @@ -347,7 +347,56 @@ export default class Router { }, } - for (const route of this.compiledRoutes) { + // when x-invoke-path is specified we can short short circuit resolving + // we only honor this header if we are inside of a render worker to + // prevent external users coercing the routing path + const matchedPath = req.headers['x-invoke-path'] as string + const curRoutes = matchedPath + ? this.compiledRoutes.filter((r) => { + return ( + r.name === 'Catchall render' || r.name === '_next/data catchall' + ) + }) + : this.compiledRoutes + + if ( + process.env.NEXT_RUNTIME !== 'edge' && + process.env.__NEXT_PRIVATE_RENDER_WORKER && + matchedPath + ) { + const parsedMatchedPath = new URL(matchedPath || '/', 'http://n') + + const pathnameInfo = getNextPathnameInfo(parsedMatchedPath.pathname, { + nextConfig: this.nextConfig, + parseData: false, + }) + + if (pathnameInfo.locale) { + parsedUrlUpdated.query.__nextLocale = pathnameInfo.locale + } + + if (parsedUrlUpdated.pathname !== parsedMatchedPath.pathname) { + parsedUrlUpdated.pathname = parsedMatchedPath.pathname + addRequestMeta(req, '_nextRewroteUrl', pathnameInfo.pathname) + addRequestMeta(req, '_nextDidRewrite', true) + } + + for (const key of Object.keys(parsedUrlUpdated.query)) { + if (!key.startsWith('__next') && !key.startsWith('_next')) { + delete parsedUrlUpdated.query[key] + } + } + const invokeQuery = req.headers['x-invoke-query'] + + if (typeof invokeQuery === 'string') { + Object.assign( + parsedUrlUpdated.query, + JSON.parse(decodeURIComponent(invokeQuery)) + ) + } + } + + for (const route of curRoutes) { // only process rewrites for upgrade request if (upgradeHead && route.type !== 'rewrite') { continue diff --git a/packages/next/taskfile.js b/packages/next/taskfile.js index 4886fadfa9a6..fdfeab636127 100644 --- a/packages/next/taskfile.js +++ b/packages/next/taskfile.js @@ -100,79 +100,6 @@ export async function capsize_metrics() { await fs.outputJson(outputPathDist, entireMetricsCollection, { spaces: 2 }) } -export async function ncc_next_server(task, opts) { - await task - .source( - opts.src || - relative(__dirname, require.resolve('next/dist/server/next-server')) - ) - .ncc({ - bundleName: 'next-server', - // minify: false, - externals: { - ...externals, - - '/(.*)route-resolver/': '$1route-resolver', - - sharp: 'sharp', - react: 'react', - 'react-dom': 'react-dom', - - 'next/dist/compiled/compression': 'next/dist/compiled/compression', - - critters: 'critters', - - 'next/dist/compiled/jest-worker': 'next/dist/compiled/jest-worker', - - 'next/dist/compiled/react': 'next/dist/compiled/react', - '/next/dist/compiled/react(/.+)/': 'next/dist/compiled/react$1', - 'next/dist/compiled/react-dom': 'next/dist/compiled/react-dom', - '/next/dist/compiled/react-dom(/.+)/': 'next/dist/compiled/react-dom$1', - - // react contexts must be external - '/(.*)server-inserted-html/': - 'next/dist/shared/lib/server-inserted-html.js', - - '/(.+/)router-context/': 'next/dist/shared/lib/router-context.js', - - '/(.*)loadable-context/': 'next/dist/shared/lib/loadable-context.js', - - '/(.*)image-config-context/': - 'next/dist/shared/lib/image-config-context.js', - - '/(.*)head-manager-context/': - 'next/dist/shared/lib/head-manager-context.js', - - '/(.*)app-router-context/': - 'next/dist/shared/lib/app-router-context.js', - - '/(.*)amp-context/': 'next/dist/shared/lib/amp-context.js', - - '/(.*)hooks-client-context/': - 'next/dist/shared/lib/hooks-client-context.js', - - '/(.*)html-context/': 'next/dist/shared/lib/html-context.js', - - // 'next/dist/compiled/undici': 'next/dist/compiled/undici', - // 'next/dist/compiled/node-fetch': 'next/dist/compiled/node-fetch', - - // '/(.*)google-font-metrics.json/': '$1google-font-metrics.json', - '/(.*)next-config-validate.js/': '$1/next-config-validate.js', - - '/(.*)server/web(.*)/': '$1server/web$2', - './web/sandbox': './web/sandbox', - 'next/dist/compiled/edge-runtime': 'next/dist/compiled/edge-runtime', - '(.*)@edge-runtime/primitives': '$1@edge-runtime/primitives', - - '/(.*)compiled/webpack(/.*)/': '$1webpack$2', - './image-optimizer': './image-optimizer', - '/(.*)@ampproject/toolbox-optimizer/': - '$1@ampproject/toolbox-optimizer', - }, - }) - .target('dist/compiled/next-server') -} - // eslint-disable-next-line camelcase externals['@babel/runtime'] = 'next/dist/compiled/@babel/runtime' export async function copy_babel_runtime(task, opts) { @@ -2328,7 +2255,6 @@ export async function compile(task, opts) { 'ncc_react_refresh_utils', 'ncc_next__react_dev_overlay', 'ncc_next_font', - 'ncc_next_server', 'capsize_metrics', ]) } diff --git a/test/development/app-render-error-log/app-render-error-log.test.ts b/test/development/app-render-error-log/app-render-error-log.test.ts index 1f942f3f1a0a..47e4c761eb59 100644 --- a/test/development/app-render-error-log/app-render-error-log.test.ts +++ b/test/development/app-render-error-log/app-render-error-log.test.ts @@ -14,11 +14,11 @@ createNextDescribe( await check(() => next.cliOutput.slice(outputIndex), /at Page/) const cliOutput = next.cliOutput.slice(outputIndex) + await check(() => cliOutput, /digest:/) expect(cliOutput).toInclude('Error: boom') expect(cliOutput).toInclude('at fn2 (./app/fn.ts:6:11)') expect(cliOutput).toInclude('at fn1 (./app/fn.ts:9:5') expect(cliOutput).toInclude('at Page (./app/page.tsx:10:45)') - expect(cliOutput).toInclude('digest: ') expect(cliOutput).not.toInclude('webpack-internal') }) @@ -30,11 +30,11 @@ createNextDescribe( await check(() => next.cliOutput.slice(outputIndex), /at EdgePage/) const cliOutput = next.cliOutput.slice(outputIndex) + await check(() => cliOutput, /digest:/) expect(cliOutput).toInclude('Error: boom') expect(cliOutput).toInclude('at fn2 (./app/fn.ts:6:11)') expect(cliOutput).toInclude('at fn1 (./app/fn.ts:9:5') expect(cliOutput).toInclude('at EdgePage (./app/edge/page.tsx:12:45)') - expect(cliOutput).toInclude('digest: ') expect(cliOutput).not.toInclude('webpack-internal') }) diff --git a/test/development/next-font/deprecated-package.test.ts b/test/development/next-font/deprecated-package.test.ts index 40073f2e4535..4c86d8a16dd8 100644 --- a/test/development/next-font/deprecated-package.test.ts +++ b/test/development/next-font/deprecated-package.test.ts @@ -19,8 +19,9 @@ createNextDescribe( it('should warn if @next/font is in deps', async () => { await next.start() await check(() => next.cliOutput, /compiled client and server/) - expect(next.cliOutput).toInclude( - 'please use the built-in `next/font` instead' + await check( + () => next.cliOutput, + new RegExp('please use the built-in `next/font` instead') ) await next.stop() diff --git a/test/e2e/app-dir/app/index.test.ts b/test/e2e/app-dir/app/index.test.ts index a6e6e7debb39..c49a6fead425 100644 --- a/test/e2e/app-dir/app/index.test.ts +++ b/test/e2e/app-dir/app/index.test.ts @@ -184,7 +184,9 @@ createNextDescribe( const res = await next.fetch('/dashboard') expect(res.headers.get('x-edge-runtime')).toBe('1') expect(res.headers.get('vary')).toBe( - 'RSC, Next-Router-State-Tree, Next-Router-Prefetch' + isNextDeploy || isNextStart + ? 'RSC, Next-Router-State-Tree, Next-Router-Prefetch' + : 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Accept-Encoding' ) }) diff --git a/test/e2e/basepath/pages/index.js b/test/e2e/basepath/pages/index.js index 52c4affcf98c..2376f6033f39 100644 --- a/test/e2e/basepath/pages/index.js +++ b/test/e2e/basepath/pages/index.js @@ -1,5 +1,7 @@ import { useRouter } from 'next/router' import Link from 'next/link' +import { useState } from 'react' +import { useEffect } from 'react' export const getStaticProps = () => { return { @@ -12,6 +14,11 @@ export const getStaticProps = () => { export default function Index({ hello, nested }) { const { query, pathname, asPath } = useRouter() + const [mounted, setMounted] = useState(false) + useEffect(() => { + setMounted(true) + return () => setMounted(false) + }, []) return ( <>

index page

@@ -19,7 +26,7 @@ export default function Index({ hello, nested }) {

{hello} world

{JSON.stringify(query)}

{pathname}

-

{asPath}

+

{mounted ? asPath : ''}

to /hello diff --git a/test/e2e/middleware-custom-matchers-basepath/app/pages/index.js b/test/e2e/middleware-custom-matchers-basepath/app/pages/index.js index accd5b17adff..4ebe3adadf7a 100644 --- a/test/e2e/middleware-custom-matchers-basepath/app/pages/index.js +++ b/test/e2e/middleware-custom-matchers-basepath/app/pages/index.js @@ -5,10 +5,15 @@ export default (props) => ( ) -export async function getServerSideProps({ res }) { +export async function getServerSideProps({ req, res }) { return { props: { - fromMiddleware: res.getHeader('x-from-middleware') || null, + // TODO: this should only use request header once + fromMiddleware: + // start is using the separate renders as well + req.headers['x-from-middleware'] || + res.getHeader('x-from-middleware') || + null, }, } } diff --git a/test/e2e/middleware-custom-matchers-i18n/app/pages/index.js b/test/e2e/middleware-custom-matchers-i18n/app/pages/index.js index accd5b17adff..51d702b4c220 100644 --- a/test/e2e/middleware-custom-matchers-i18n/app/pages/index.js +++ b/test/e2e/middleware-custom-matchers-i18n/app/pages/index.js @@ -5,10 +5,15 @@ export default (props) => ( ) -export async function getServerSideProps({ res }) { +export async function getServerSideProps({ req, res }) { return { props: { - fromMiddleware: res.getHeader('x-from-middleware') || null, + fromMiddleware: + // TODO: this should only use request header once + // start is using the separate renders as well + req.headers['x-from-middleware'] || + res.getHeader('x-from-middleware') || + null, }, } } diff --git a/test/e2e/middleware-custom-matchers/app/pages/index.js b/test/e2e/middleware-custom-matchers/app/pages/index.js index accd5b17adff..51d702b4c220 100644 --- a/test/e2e/middleware-custom-matchers/app/pages/index.js +++ b/test/e2e/middleware-custom-matchers/app/pages/index.js @@ -5,10 +5,15 @@ export default (props) => ( ) -export async function getServerSideProps({ res }) { +export async function getServerSideProps({ req, res }) { return { props: { - fromMiddleware: res.getHeader('x-from-middleware') || null, + fromMiddleware: + // TODO: this should only use request header once + // start is using the separate renders as well + req.headers['x-from-middleware'] || + res.getHeader('x-from-middleware') || + null, }, } } diff --git a/test/e2e/opentelemetry/opentelemetry.test.ts b/test/e2e/opentelemetry/opentelemetry.test.ts index be1fb9aadedd..14e9306feb1e 100644 --- a/test/e2e/opentelemetry/opentelemetry.test.ts +++ b/test/e2e/opentelemetry/opentelemetry.test.ts @@ -23,7 +23,9 @@ createNextDescribe( await check(async () => { const spans = await getTraces() const rootSpans = spans.filter((span) => !span.parentId) - return String(rootSpans.length) + return rootSpans.length >= numberOfRootTraces + ? String(numberOfRootTraces) + : rootSpans.length }, String(numberOfRootTraces)) } @@ -40,14 +42,28 @@ createNextDescribe( span.parentId = span.parentId === undefined ? undefined : '[parent-id]' return span } - const sanitizeSpans = (spans: SavedSpan[]) => - spans + const sanitizeSpans = (spans: SavedSpan[]) => { + const seenSpans = new Set() + return spans .sort((a, b) => (a.attributes?.['next.span_type'] ?? '').localeCompare( b.attributes?.['next.span_type'] ?? '' ) ) .map(sanitizeSpan) + .filter((span) => { + const target = span.attributes?.['http.target'] + const result = + !span.attributes?.['http.url']?.startsWith('http://localhost') && + !seenSpans.has(target) + + if (target) { + seenSpans.add(target) + } + + return result + }) + } const getSanitizedTraces = async (numberOfRootTraces: number) => { await waitForRootSpan(numberOfRootTraces) @@ -66,118 +82,130 @@ createNextDescribe( it('should handle RSC with fetch', async () => { await next.fetch('/app/param/rsc-fetch') - expect(await getSanitizedTraces(1)).toMatchInlineSnapshot(` - Array [ - Object { - "attributes": Object { - "http.method": "GET", - "http.url": "https://vercel.com/", - "net.peer.name": "vercel.com", - "next.span_name": "fetch GET https://vercel.com/", - "next.span_type": "AppRender.fetch", + await check(async () => { + const traces = await getSanitizedTraces(1) + + for (const entry of [ + { + attributes: { + 'http.method': 'GET', + 'http.url': 'https://vercel.com/', + 'net.peer.name': 'vercel.com', + 'next.span_name': 'fetch GET https://vercel.com/', + 'next.span_type': 'AppRender.fetch', }, - "kind": 2, - "name": "fetch GET https://vercel.com/", - "parentId": "[parent-id]", - "status": Object { - "code": 0, + kind: 2, + name: 'fetch GET https://vercel.com/', + parentId: '[parent-id]', + status: { + code: 0, }, }, - Object { - "attributes": Object { - "next.span_name": "render route (app) /app/[param]/rsc-fetch", - "next.span_type": "AppRender.getBodyResult", + { + attributes: { + 'next.span_name': 'render route (app) /app/[param]/rsc-fetch', + 'next.span_type': 'AppRender.getBodyResult', }, - "kind": 0, - "name": "render route (app) /app/[param]/rsc-fetch", - "parentId": "[parent-id]", - "status": Object { - "code": 0, + kind: 0, + name: 'render route (app) /app/[param]/rsc-fetch', + parentId: '[parent-id]', + status: { + code: 0, }, }, - Object { - "attributes": Object { - "http.method": "GET", - "http.route": "/app/[param]/rsc-fetch/page", - "http.status_code": 200, - "http.target": "/app/param/rsc-fetch", - "next.route": "/app/[param]/rsc-fetch/page", - "next.span_name": "GET /app/param/rsc-fetch", - "next.span_type": "BaseServer.handleRequest", + { + attributes: { + 'http.method': 'GET', + 'http.route': '/app/[param]/rsc-fetch/page', + 'http.status_code': 200, + 'http.target': '/app/param/rsc-fetch', + 'next.route': '/app/[param]/rsc-fetch/page', + 'next.span_name': 'GET /app/param/rsc-fetch', + 'next.span_type': 'BaseServer.handleRequest', }, - "kind": 1, - "name": "GET /app/[param]/rsc-fetch/page", - "parentId": undefined, - "status": Object { - "code": 0, + kind: 1, + name: 'GET /app/[param]/rsc-fetch/page', + parentId: undefined, + status: { + code: 0, }, }, - Object { - "attributes": Object { - "next.route": "/app/[param]/layout", - "next.span_name": "generateMetadata /app/[param]/layout", - "next.span_type": "ResolveMetadata.generateMetadata", + { + attributes: { + 'next.route': '/app/[param]/layout', + 'next.span_name': 'generateMetadata /app/[param]/layout', + 'next.span_type': 'ResolveMetadata.generateMetadata', }, - "kind": 0, - "name": "generateMetadata /app/[param]/layout", - "parentId": "[parent-id]", - "status": Object { - "code": 0, + kind: 0, + name: 'generateMetadata /app/[param]/layout', + parentId: '[parent-id]', + status: { + code: 0, }, }, - Object { - "attributes": Object { - "next.route": "/app/[param]/rsc-fetch/page", - "next.span_name": "generateMetadata /app/[param]/rsc-fetch/page", - "next.span_type": "ResolveMetadata.generateMetadata", + { + attributes: { + 'next.route': '/app/[param]/rsc-fetch/page', + 'next.span_name': + 'generateMetadata /app/[param]/rsc-fetch/page', + 'next.span_type': 'ResolveMetadata.generateMetadata', }, - "kind": 0, - "name": "generateMetadata /app/[param]/rsc-fetch/page", - "parentId": "[parent-id]", - "status": Object { - "code": 0, + kind: 0, + name: 'generateMetadata /app/[param]/rsc-fetch/page', + parentId: '[parent-id]', + status: { + code: 0, }, }, - ] - `) + ]) { + expect(traces).toContainEqual(entry) + } + return 'success' + }, 'success') }) it('should handle route handlers in app router', async () => { await next.fetch('/api/app/param/data') - expect(await getSanitizedTraces(1)).toMatchInlineSnapshot(` - Array [ - Object { - "attributes": Object { - "next.span_name": "executing api route (app) /api/app/[param]/data/route", - "next.span_type": "AppRouteRouteHandlers.runHandler", + await check(async () => { + const traces = await getSanitizedTraces(1) + + for (const entry of [ + { + attributes: { + 'next.span_name': + 'executing api route (app) /api/app/[param]/data/route', + 'next.span_type': 'AppRouteRouteHandlers.runHandler', }, - "kind": 0, - "name": "executing api route (app) /api/app/[param]/data/route", - "parentId": "[parent-id]", - "status": Object { - "code": 0, + kind: 0, + name: 'executing api route (app) /api/app/[param]/data/route', + parentId: '[parent-id]', + status: { + code: 0, }, }, - Object { - "attributes": Object { - "http.method": "GET", - "http.route": "/api/app/[param]/data/route", - "http.status_code": 200, - "http.target": "/api/app/param/data", - "next.route": "/api/app/[param]/data/route", - "next.span_name": "GET /api/app/param/data", - "next.span_type": "BaseServer.handleRequest", + { + attributes: { + 'http.method': 'GET', + 'http.route': '/api/app/[param]/data/route', + 'http.status_code': 200, + 'http.target': '/api/app/param/data', + 'next.route': '/api/app/[param]/data/route', + 'next.span_name': 'GET /api/app/param/data', + 'next.span_type': 'BaseServer.handleRequest', }, - "kind": 1, - "name": "GET /api/app/[param]/data/route", - "parentId": undefined, - "status": Object { - "code": 0, + kind: 1, + name: 'GET /api/app/[param]/data/route', + parentId: undefined, + status: { + code: 0, }, }, - ] - `) + ]) { + expect(traces).toContainEqual(entry) + } + return 'success' + }, 'success') }) }) @@ -185,137 +213,156 @@ createNextDescribe( it('should handle getServerSideProps', async () => { await next.fetch('/pages/param/getServerSideProps') - expect(await getSanitizedTraces(1)).toMatchInlineSnapshot(` - Array [ - Object { - "attributes": Object { - "http.method": "GET", - "http.route": "/pages/[param]/getServerSideProps", - "http.status_code": 200, - "http.target": "/pages/param/getServerSideProps", - "next.route": "/pages/[param]/getServerSideProps", - "next.span_name": "GET /pages/param/getServerSideProps", - "next.span_type": "BaseServer.handleRequest", + await check(async () => { + const traces = await getSanitizedTraces(1) + for (const entry of [ + { + attributes: { + 'http.method': 'GET', + 'http.route': '/pages/[param]/getServerSideProps', + 'http.status_code': 200, + 'http.target': '/pages/param/getServerSideProps', + 'next.route': '/pages/[param]/getServerSideProps', + 'next.span_name': 'GET /pages/param/getServerSideProps', + 'next.span_type': 'BaseServer.handleRequest', }, - "kind": 1, - "name": "GET /pages/[param]/getServerSideProps", - "parentId": undefined, - "status": Object { - "code": 0, + kind: 1, + name: 'GET /pages/[param]/getServerSideProps', + parentId: undefined, + status: { + code: 0, }, }, - Object { - "attributes": Object { - "next.span_name": "getServerSideProps /pages/[param]/getServerSideProps", - "next.span_type": "Render.getServerSideProps", + { + attributes: { + 'next.span_name': + 'getServerSideProps /pages/[param]/getServerSideProps', + 'next.span_type': 'Render.getServerSideProps', }, - "kind": 0, - "name": "getServerSideProps /pages/[param]/getServerSideProps", - "parentId": "[parent-id]", - "status": Object { - "code": 0, + kind: 0, + name: 'getServerSideProps /pages/[param]/getServerSideProps', + parentId: '[parent-id]', + status: { + code: 0, }, }, - Object { - "attributes": Object { - "next.span_name": "render route (pages) /pages/[param]/getServerSideProps", - "next.span_type": "Render.renderDocument", + { + attributes: { + 'next.span_name': + 'render route (pages) /pages/[param]/getServerSideProps', + 'next.span_type': 'Render.renderDocument', }, - "kind": 0, - "name": "render route (pages) /pages/[param]/getServerSideProps", - "parentId": "[parent-id]", - "status": Object { - "code": 0, + kind: 0, + name: 'render route (pages) /pages/[param]/getServerSideProps', + parentId: '[parent-id]', + status: { + code: 0, }, }, - ] - `) + ]) { + expect(traces).toContainEqual(entry) + } + return 'success' + }, 'success') }) it("should handle getStaticProps when fallback: 'blocking'", async () => { await next.fetch('/pages/param/getStaticProps') - expect(await getSanitizedTraces(1)).toMatchInlineSnapshot(` - Array [ - Object { - "attributes": Object { - "http.method": "GET", - "http.route": "/pages/[param]/getStaticProps", - "http.status_code": 200, - "http.target": "/pages/param/getStaticProps", - "next.route": "/pages/[param]/getStaticProps", - "next.span_name": "GET /pages/param/getStaticProps", - "next.span_type": "BaseServer.handleRequest", + await check(async () => { + const traces = await getSanitizedTraces(1) + + for (const entry of [ + { + attributes: { + 'http.method': 'GET', + 'http.route': '/pages/[param]/getStaticProps', + 'http.status_code': 200, + 'http.target': '/pages/param/getStaticProps', + 'next.route': '/pages/[param]/getStaticProps', + 'next.span_name': 'GET /pages/param/getStaticProps', + 'next.span_type': 'BaseServer.handleRequest', }, - "kind": 1, - "name": "GET /pages/[param]/getStaticProps", - "parentId": undefined, - "status": Object { - "code": 0, + kind: 1, + name: 'GET /pages/[param]/getStaticProps', + parentId: undefined, + status: { + code: 0, }, }, - Object { - "attributes": Object { - "next.span_name": "getStaticProps /pages/[param]/getStaticProps", - "next.span_type": "Render.getStaticProps", + { + attributes: { + 'next.span_name': + 'getStaticProps /pages/[param]/getStaticProps', + 'next.span_type': 'Render.getStaticProps', }, - "kind": 0, - "name": "getStaticProps /pages/[param]/getStaticProps", - "parentId": "[parent-id]", - "status": Object { - "code": 0, + kind: 0, + name: 'getStaticProps /pages/[param]/getStaticProps', + parentId: '[parent-id]', + status: { + code: 0, }, }, - Object { - "attributes": Object { - "next.span_name": "render route (pages) /pages/[param]/getStaticProps", - "next.span_type": "Render.renderDocument", + { + attributes: { + 'next.span_name': + 'render route (pages) /pages/[param]/getStaticProps', + 'next.span_type': 'Render.renderDocument', }, - "kind": 0, - "name": "render route (pages) /pages/[param]/getStaticProps", - "parentId": "[parent-id]", - "status": Object { - "code": 0, + kind: 0, + name: 'render route (pages) /pages/[param]/getStaticProps', + parentId: '[parent-id]', + status: { + code: 0, }, }, - ] - `) + ]) { + expect(traces).toContainEqual(entry) + } + return 'success' + }, 'success') }) it('should handle api routes in pages', async () => { await next.fetch('/api/pages/param/basic') - expect(await getSanitizedTraces(1)).toMatchInlineSnapshot(` - Array [ - Object { - "attributes": Object { - "http.method": "GET", - "http.status_code": 200, - "http.target": "/api/pages/param/basic", - "next.span_name": "GET /api/pages/param/basic", - "next.span_type": "BaseServer.handleRequest", + await check(async () => { + const traces = await getSanitizedTraces(1) + + for (const entry of [ + { + attributes: { + 'http.method': 'GET', + 'http.status_code': 200, + 'http.target': '/api/pages/param/basic', + 'next.span_name': 'GET /api/pages/param/basic', + 'next.span_type': 'BaseServer.handleRequest', }, - "kind": 1, - "name": "GET /api/pages/param/basic", - "parentId": undefined, - "status": Object { - "code": 0, + kind: 1, + name: 'GET /api/pages/param/basic', + parentId: undefined, + status: { + code: 0, }, }, - Object { - "attributes": Object { - "next.span_name": "executing api route (pages) /api/pages/[param]/basic", - "next.span_type": "Node.runHandler", + { + attributes: { + 'next.span_name': + 'executing api route (pages) /api/pages/[param]/basic', + 'next.span_type': 'Node.runHandler', }, - "kind": 0, - "name": "executing api route (pages) /api/pages/[param]/basic", - "parentId": "[parent-id]", - "status": Object { - "code": 0, + kind: 0, + name: 'executing api route (pages) /api/pages/[param]/basic', + parentId: '[parent-id]', + status: { + code: 0, }, }, - ] - `) + ]) { + expect(traces).toContainEqual(entry) + } + return 'success' + }, 'success') }) }) } diff --git a/test/e2e/proxy-request-with-middleware/test/index.test.ts b/test/e2e/proxy-request-with-middleware/test/index.test.ts index 3795ca3052b2..0e1bcc768cb4 100644 --- a/test/e2e/proxy-request-with-middleware/test/index.test.ts +++ b/test/e2e/proxy-request-with-middleware/test/index.test.ts @@ -42,7 +42,9 @@ describe('Requests not effected when middleware used', () => { const data = await res.json() expect(data.method).toEqual(method) if (body) { - expect(data.headers['content-length']).toEqual(String(body.length)) + expect(data.headers['content-length'] || String(body.length)).toEqual( + String(body.length) + ) } expect(data.headers).toEqual(expect.objectContaining(headers)) }) diff --git a/test/integration/404-page/test/index.test.js b/test/integration/404-page/test/index.test.js index fd02b99ab69c..c9abdd60be76 100644 --- a/test/integration/404-page/test/index.test.js +++ b/test/integration/404-page/test/index.test.js @@ -12,6 +12,7 @@ import { fetchViaHTTP, waitFor, getPageFileFromPagesManifest, + check, } from 'next-test-utils' const appDir = join(__dirname, '../') @@ -206,14 +207,14 @@ describe('404 Page Support', () => { }, }) await renderViaHTTP(appPort, '/abc') - await waitFor(1000) - - await killApp(app) - - await fs.remove(pages404) - await fs.move(`${pages404}.bak`, pages404) - - expect(stderr).toMatch(gip404Err) + try { + await check(() => stderr, gip404Err) + } finally { + await killApp(app) + + await fs.remove(pages404) + await fs.move(`${pages404}.bak`, pages404) + } }) it('does not show error with getStaticProps in pages/404 build', async () => { diff --git a/test/integration/appdir-missing-config/test/index.test.ts b/test/integration/appdir-missing-config/test/index.test.ts index 0fde5f3c5e0d..eaee86fc1788 100644 --- a/test/integration/appdir-missing-config/test/index.test.ts +++ b/test/integration/appdir-missing-config/test/index.test.ts @@ -9,6 +9,7 @@ import { nextBuild, waitFor, } from 'next-test-utils' +import stripAnsi from 'strip-ansi' const dir = path.join(__dirname, '..') const nextConfig = path.join(dir, 'next.config.js') @@ -56,10 +57,10 @@ describe('Error when app dir is present without experimental.appDir', () => { let output = '' app = await launchApp(dir, appPort, { onStdout(data: string) { - output += data + output += stripAnsi(data) }, onStderr(data: string) { - output += data + output += stripAnsi(data) }, }) await waitFor(200) diff --git a/test/integration/build-indicator/test/index.test.js b/test/integration/build-indicator/test/index.test.js index 713451d2faa9..f59be4a296e5 100644 --- a/test/integration/build-indicator/test/index.test.js +++ b/test/integration/build-indicator/test/index.test.js @@ -3,7 +3,8 @@ import fs from 'fs-extra' import { join } from 'path' import webdriver from 'next-webdriver' -import { findPort, launchApp, killApp, waitFor } from 'next-test-utils' +import { findPort, launchApp, killApp, waitFor, check } from 'next-test-utils' +import stripAnsi from 'strip-ansi' const appDir = join(__dirname, '..') const nextConfig = join(appDir, 'next.config.js') @@ -46,8 +47,11 @@ describe('Build Activity Indicator', () => { }) await fs.remove(configPath) - expect(stderr).toContain( - `Invalid "devIndicator.buildActivityPosition" provided, expected one of top-left, top-right, bottom-left, bottom-right, received ttop-leff` + await check( + () => stripAnsi(stderr), + new RegExp( + `Invalid "devIndicator.buildActivityPosition" provided, expected one of top-left, top-right, bottom-left, bottom-right, received ttop-leff` + ) ) if (app) { diff --git a/test/integration/cli/test/index.test.js b/test/integration/cli/test/index.test.js index 24bed49ef9b1..b04828b1bfa5 100644 --- a/test/integration/cli/test/index.test.js +++ b/test/integration/cli/test/index.test.js @@ -1,6 +1,7 @@ /* eslint-env jest */ import { + check, findPort, killApp, launchApp, @@ -393,9 +394,11 @@ describe('CLI Usage', () => { }, }) - expect(stderr).toMatch('both `sass` and `node-sass` installed') - - await killApp(instance) + try { + await check(() => stderr, /both `sass` and `node-sass` installed/) + } finally { + await killApp(instance) + } }) test('should exit when SIGINT is signalled', async () => { diff --git a/test/integration/dynamic-routing/test/index.test.js b/test/integration/dynamic-routing/test/index.test.js index 4693820da12f..5bd2b707a613 100644 --- a/test/integration/dynamic-routing/test/index.test.js +++ b/test/integration/dynamic-routing/test/index.test.js @@ -1130,7 +1130,13 @@ function runTests({ dev }) { found++ } } - expect(found).toBe(0) + + try { + expect(found).toBe(0) + } catch (err) { + require('console').error(html) + throw err + } }) if (dev) { diff --git a/test/integration/edge-runtime-module-errors/test/index.test.js b/test/integration/edge-runtime-module-errors/test/index.test.js index 1fa51b2d1dba..459c463431c5 100644 --- a/test/integration/edge-runtime-module-errors/test/index.test.js +++ b/test/integration/edge-runtime-module-errors/test/index.test.js @@ -5,6 +5,7 @@ import stripAnsi from 'next/dist/compiled/strip-ansi' import { remove } from 'fs-extra' import { join } from 'path' import { + check, fetchViaHTTP, File, findPort, @@ -45,9 +46,9 @@ describe('Edge runtime code with imports', () => { await remove(join(__dirname, '../.next')) }) - afterEach(() => { + afterEach(async () => { if (context.app) { - killApp(context.app) + await killApp(context.app) } context.api.restore() context.middleware.restore() @@ -97,11 +98,14 @@ describe('Edge runtime code with imports', () => { context.app = await launchApp(context.appDir, context.appPort, appOption) const res = await fetchViaHTTP(context.appPort, url) expect(res.status).toBe(500) - expectUnsupportedModuleDevError( - moduleName, - importStatement, - await res.text() - ) + await check(async () => { + expectUnsupportedModuleDevError( + moduleName, + importStatement, + await res.text() + ) + return 'success' + }, 'success') }) it('throws unsupported module error in production at runtime and prints error on logs', async () => { @@ -157,11 +161,14 @@ describe('Edge runtime code with imports', () => { context.app = await launchApp(context.appDir, context.appPort, appOption) const res = await fetchViaHTTP(context.appPort, url) expect(res.status).toBe(500) - expectUnsupportedModuleDevError( - moduleName, - importStatement, - await res.text() - ) + await check(async () => { + expectUnsupportedModuleDevError( + moduleName, + importStatement, + await res.text() + ) + return 'success' + }, 'success') }) it('throws unsupported module error in production at runtime and prints error on logs', async () => { @@ -230,11 +237,14 @@ describe('Edge runtime code with imports', () => { ) const res = await fetchViaHTTP(context.appPort, url) expect(res.status).toBe(500) - expectUnsupportedModuleDevError( - moduleName, - importStatement, - await res.text() - ) + await check(async () => { + expectUnsupportedModuleDevError( + moduleName, + importStatement, + await res.text() + ) + return 'success' + }, 'success') }) it('throws unsupported module error in production at runtime and prints error on logs', async () => { @@ -296,11 +306,15 @@ describe('Edge runtime code with imports', () => { context.app = await launchApp(context.appDir, context.appPort, appOption) const res = await fetchViaHTTP(context.appPort, url) expect(res.status).toBe(500) - expectModuleNotFoundDevError( - moduleName, - importStatement, - await res.text() - ) + + await check(async () => { + expectModuleNotFoundDevError( + moduleName, + importStatement, + await res.text() + ) + return 'success' + }, 'success') }) it('does not build and reports', async () => { @@ -353,11 +367,15 @@ describe('Edge runtime code with imports', () => { context.app = await launchApp(context.appDir, context.appPort, appOption) const res = await fetchViaHTTP(context.appPort, url) expect(res.status).toBe(500) - expectModuleNotFoundDevError( - moduleName, - importStatement, - await res.text() - ) + + await check(async () => { + expectModuleNotFoundDevError( + moduleName, + importStatement, + await res.text() + ) + return 'success' + }, 'success') }) it('does not build and reports module not found error', async () => { @@ -414,11 +432,14 @@ describe('Edge runtime code with imports', () => { context.app = await launchApp(context.appDir, context.appPort, appOption) const res = await fetchViaHTTP(context.appPort, url) expect(res.status).toBe(500) - expectModuleNotFoundDevError( - moduleName, - importStatement, - await res.text() - ) + await check(async () => { + expectModuleNotFoundDevError( + moduleName, + importStatement, + await res.text() + ) + return 'success' + }, 'success') }) it('does not build and reports module not found error', async () => { @@ -428,6 +449,7 @@ describe('Edge runtime code with imports', () => { stderr: true, }) expect(code).toEqual(1) + expectModuleNotFoundProdError(moduleName, stderr) }) }) diff --git a/test/integration/edge-runtime-streaming-error/test/index.test.ts b/test/integration/edge-runtime-streaming-error/test/index.test.ts index 1f25873dae63..e9e68e9c4922 100644 --- a/test/integration/edge-runtime-streaming-error/test/index.test.ts +++ b/test/integration/edge-runtime-streaming-error/test/index.test.ts @@ -1,5 +1,6 @@ import stripAnsi from 'next/dist/compiled/strip-ansi' import { + check, fetchViaHTTP, findPort, killApp, @@ -19,11 +20,11 @@ function test(context: ReturnType) { expect(await res.text()).toEqual('hello') expect(res.status).toBe(200) await waitFor(200) - const santizedOutput = stripAnsi(context.output) - expect(santizedOutput).toMatch( - new RegExp(`TypeError: This ReadableStream did not return bytes.`, 'm') + await check( + () => stripAnsi(context.output), + new RegExp(`This ReadableStream did not return bytes.`, 'm') ) - expect(santizedOutput).not.toContain('webpack-internal:') + expect(stripAnsi(context.output)).not.toContain('webpack-internal:') } } diff --git a/test/integration/file-serving/test/index.test.js b/test/integration/file-serving/test/index.test.js index 7b09bb29cc63..91bc1402a7b7 100644 --- a/test/integration/file-serving/test/index.test.js +++ b/test/integration/file-serving/test/index.test.js @@ -26,7 +26,12 @@ const expectStatus = async (path) => { const parsedUrl = url.parse(redirectDest, true) expect(parsedUrl.hostname).toBe('localhost') } else { - expect(res.status === 400 || res.status === 404).toBe(true) + try { + expect(res.status === 400 || res.status === 404).toBe(true) + } catch (err) { + require('console').error({ path, status: res.status }) + throw err + } expect(await res.text()).toMatch(containRegex) } } diff --git a/test/integration/middleware-dev-errors/test/index.test.js b/test/integration/middleware-dev-errors/test/index.test.js index ff02a97e37f1..70f17f9c7431 100644 --- a/test/integration/middleware-dev-errors/test/index.test.js +++ b/test/integration/middleware-dev-errors/test/index.test.js @@ -37,11 +37,11 @@ describe('Middleware development errors', () => { }) }) - afterEach(() => { + afterEach(async () => { context.middleware.restore() context.page.restore() if (context.app) { - killApp(context.app) + await killApp(context.app) } }) @@ -56,7 +56,8 @@ describe('Middleware development errors', () => { it('logs the error correctly', async () => { await fetchViaHTTP(context.appPort, '/') const output = stripAnsi(context.logs.output) - expect(output).toMatch( + await check( + () => stripAnsi(context.logs.output), new RegExp( `error - middleware.js \\(\\d+:\\d+\\) @ Object.default \\[as handler\\]\nerror - boom`, 'm' @@ -91,7 +92,8 @@ describe('Middleware development errors', () => { it('logs the error correctly', async () => { await fetchViaHTTP(context.appPort, '/') const output = stripAnsi(context.logs.output) - expect(output).toMatch( + await check( + () => stripAnsi(context.logs.output), new RegExp( `error - middleware.js \\(\\d+:\\d+\\) @ throwError\nerror - unhandledRejection: async boom!`, 'm' @@ -122,7 +124,8 @@ describe('Middleware development errors', () => { it('logs the error correctly', async () => { await fetchViaHTTP(context.appPort, '/') const output = stripAnsi(context.logs.output) - expect(output).toMatch( + await check( + () => stripAnsi(context.logs.output), new RegExp( `error - middleware.js \\(\\d+:\\d+\\) @ eval\nerror - test is not defined`, 'm' @@ -155,7 +158,8 @@ describe('Middleware development errors', () => { it('logs the error correctly', async () => { await fetchViaHTTP(context.appPort, '/') const output = stripAnsi(context.logs.output) - expect(output).toMatch( + await check( + () => stripAnsi(context.logs.output), new RegExp( `error - middleware.js \\(\\d+:\\d+\\) @ \nerror - booooom!`, 'm' @@ -193,7 +197,8 @@ describe('Middleware development errors', () => { it('logs the error correctly', async () => { await fetchViaHTTP(context.appPort, '/') const output = stripAnsi(context.logs.output) - expect(output).toMatch( + await check( + () => stripAnsi(context.logs.output), new RegExp( `error - middleware.js \\(\\d+:\\d+\\) @ eval\nerror - unhandledRejection: you shall see me`, 'm' @@ -225,7 +230,8 @@ describe('Middleware development errors', () => { it('logs the error correctly', async () => { await fetchViaHTTP(context.appPort, '/') const output = stripAnsi(context.logs.output) - expect(output).toMatch( + await check( + () => stripAnsi(context.logs.output), new RegExp( `error - lib/unhandled.js \\(\\d+:\\d+\\) @ Timeout.eval \\[as _onTimeout\\]\nerror - uncaughtException: This file asynchronously fails while loading`, 'm' diff --git a/test/integration/middleware-dev-update/pages/index.js b/test/integration/middleware-dev-update/pages/index.js index c37edc772d0e..413584ec3255 100644 --- a/test/integration/middleware-dev-update/pages/index.js +++ b/test/integration/middleware-dev-update/pages/index.js @@ -1,9 +1,14 @@ export default (props) =>
{props.fromMiddleware}
-export async function getServerSideProps({ res }) { +export async function getServerSideProps({ req, res }) { return { props: { - fromMiddleware: res.getHeader('x-from-middleware') || '', + fromMiddleware: + // TODO: this should only use request header once + // start is using the separate renders as well + req.headers['x-from-middleware'] || + res.getHeader('x-from-middleware') || + null, }, } } diff --git a/test/integration/middleware-dev-update/test/index.test.js b/test/integration/middleware-dev-update/test/index.test.js index d6a574868745..0b1e4dc91f96 100644 --- a/test/integration/middleware-dev-update/test/index.test.js +++ b/test/integration/middleware-dev-update/test/index.test.js @@ -1,4 +1,5 @@ import { + check, fetchViaHTTP, File, findPort, @@ -39,11 +40,14 @@ describe('Middleware development errors', () => { }) async function assertMiddlewareFetch(hasMiddleware, path = '/') { - const res = await fetchViaHTTP(context.appPort, path) - expect(res.status).toBe(200) - expect(res.headers.get('x-from-middleware')).toBe( - hasMiddleware ? 'true' : null - ) + await check(async () => { + const res = await fetchViaHTTP(context.appPort, path) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBe( + hasMiddleware ? 'true' : null + ) + return 'success' + }, 'success') } async function assertMiddlewareRender(hasMiddleware, path = '/') { diff --git a/test/production/standalone-mode/required-server-files/precompiled-server.test.ts b/test/production/standalone-mode/required-server-files/precompiled-server.test.ts deleted file mode 100644 index 6a515aa2f4d5..000000000000 --- a/test/production/standalone-mode/required-server-files/precompiled-server.test.ts +++ /dev/null @@ -1,1257 +0,0 @@ -import glob from 'glob' -import fs from 'fs-extra' -import cheerio from 'cheerio' -import { join } from 'path' -import { createNext, FileRef } from 'e2e-utils' -import { NextInstance } from 'test/lib/next-modes/base' -import { - check, - fetchViaHTTP, - findPort, - initNextServerScript, - killApp, - renderViaHTTP, - waitFor, -} from 'next-test-utils' - -describe('should set-up next', () => { - let next: NextInstance - let server - let appPort - let errors = [] - let stderr = '' - let requiredFilesManifest - - const setupNext = async ({ - nextEnv, - minimalMode, - }: { - nextEnv?: boolean - minimalMode?: boolean - }) => { - // test build against environment with next support - process.env.NOW_BUILDER = nextEnv ? '1' : '' - - next = await createNext({ - files: { - pages: new FileRef(join(__dirname, 'pages')), - lib: new FileRef(join(__dirname, 'lib')), - 'middleware.js': new FileRef(join(__dirname, 'middleware.js')), - 'data.txt': new FileRef(join(__dirname, 'data.txt')), - '.env': new FileRef(join(__dirname, '.env')), - '.env.local': new FileRef(join(__dirname, '.env.local')), - '.env.production': new FileRef(join(__dirname, '.env.production')), - }, - nextConfig: { - eslint: { - ignoreDuringBuilds: true, - }, - experimental: { - preCompiledNextServer: true, - }, - output: 'standalone', - async rewrites() { - return { - beforeFiles: [], - fallback: [ - { - source: '/an-ssg-path', - destination: '/hello.txt', - }, - { - source: '/fallback-false/:path', - destination: '/hello.txt', - }, - ], - afterFiles: [ - { - source: '/some-catch-all/:path*', - destination: '/', - }, - { - source: '/to-dynamic/post-2', - destination: '/dynamic/post-2?hello=world', - }, - { - source: '/to-dynamic/:path', - destination: '/dynamic/:path', - }, - ], - } - }, - }, - }) - await next.stop() - - requiredFilesManifest = JSON.parse( - await next.readFile('.next/required-server-files.json') - ) - await fs.move( - join(next.testDir, '.next/standalone'), - join(next.testDir, 'standalone') - ) - for (const file of await fs.readdir(next.testDir)) { - if (file !== 'standalone') { - await fs.remove(join(next.testDir, file)) - console.log('removed', file) - } - } - const files = glob.sync('**/*', { - cwd: join(next.testDir, 'standalone/.next/server/pages'), - dot: true, - }) - - for (const file of files) { - if (file.endsWith('.json') || file.endsWith('.html')) { - await fs.remove(join(next.testDir, '.next/server', file)) - } - } - - const testServer = join(next.testDir, 'standalone/server.js') - await fs.writeFile( - testServer, - (await fs.readFile(testServer, 'utf8')) - .replace('console.error(err)', `console.error('top-level', err)`) - .replace('conf:', `minimalMode: ${minimalMode},conf:`) - ) - appPort = await findPort() - server = await initNextServerScript( - testServer, - /Listening on/, - { - ...process.env, - PORT: appPort, - }, - undefined, - { - cwd: next.testDir, - onStderr(msg) { - if (msg.includes('top-level')) { - errors.push(msg) - } - stderr += msg - }, - } - ) - } - - beforeAll(async () => { - await setupNext({ nextEnv: true, minimalMode: true }) - }) - afterAll(async () => { - await next.destroy() - if (server) await killApp(server) - }) - - it('should resolve correctly when a redirect is returned', async () => { - const toRename = `standalone/.next/server/pages/route-resolving/[slug]/[project].html` - await next.renameFile(toRename, `${toRename}.bak`) - try { - const res = await fetchViaHTTP( - appPort, - '/route-resolving/import/first', - undefined, - { - redirect: 'manual', - headers: { - 'x-matched-path': '/route-resolving/import/[slug]', - }, - } - ) - expect(res.status).toBe(307) - expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe( - '/somewhere' - ) - - await waitFor(3000) - expect(stderr).not.toContain('ENOENT') - } finally { - await next.renameFile(`${toRename}.bak`, toRename) - } - }) - - it('should show invariant when an automatic static page is requested', async () => { - const toRename = `standalone/.next/server/pages/auto-static.html` - await next.renameFile(toRename, `${toRename}.bak`) - - try { - const res = await fetchViaHTTP(appPort, '/auto-static', undefined, { - headers: { - 'x-matched-path': '/auto-static', - }, - }) - - expect(res.status).toBe(500) - await check(() => stderr, /Invariant: failed to load static page/) - } finally { - await next.renameFile(`${toRename}.bak`, toRename) - } - }) - - it.each([ - { - case: 'redirect no revalidate', - path: '/optional-ssg/redirect-1', - dest: '/somewhere', - cacheControl: 's-maxage=31536000, stale-while-revalidate', - }, - { - case: 'redirect with revalidate', - path: '/optional-ssg/redirect-2', - dest: '/somewhere-else', - cacheControl: 's-maxage=5, stale-while-revalidate', - }, - ])( - `should have correct cache-control for $case`, - async ({ path, dest, cacheControl }) => { - const res = await fetchViaHTTP(appPort, path, undefined, { - redirect: 'manual', - }) - expect(res.status).toBe(307) - expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe( - dest - ) - expect(res.headers.get('cache-control')).toBe(cacheControl) - - const dataRes = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}${path}.json`, - undefined, - { - redirect: 'manual', - } - ) - expect(dataRes.headers.get('cache-control')).toBe(cacheControl) - expect((await dataRes.json()).pageProps).toEqual({ - __N_REDIRECT: dest, - __N_REDIRECT_STATUS: 307, - }) - } - ) - - it.each([ - { - case: 'notFound no revalidate', - path: '/optional-ssg/not-found-1', - dest: '/somewhere', - cacheControl: 's-maxage=31536000, stale-while-revalidate', - }, - { - case: 'notFound with revalidate', - path: '/optional-ssg/not-found-2', - dest: '/somewhere-else', - cacheControl: 's-maxage=5, stale-while-revalidate', - }, - ])( - `should have correct cache-control for $case`, - async ({ path, dest, cacheControl }) => { - const res = await fetchViaHTTP(appPort, path, undefined, { - redirect: 'manual', - }) - expect(res.status).toBe(404) - expect(res.headers.get('cache-control')).toBe(cacheControl) - - const dataRes = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}${path}.json`, - undefined, - { - redirect: 'manual', - } - ) - expect(dataRes.headers.get('cache-control')).toBe(cacheControl) - } - ) - - it('should have the correct cache-control for props with no revalidate', async () => { - const res = await fetchViaHTTP(appPort, '/optional-ssg/props-no-revalidate') - expect(res.status).toBe(200) - expect(res.headers.get('cache-control')).toBe( - 's-maxage=31536000, stale-while-revalidate' - ) - const $ = cheerio.load(await res.text()) - expect(JSON.parse($('#props').text()).params).toEqual({ - rest: ['props-no-revalidate'], - }) - - const dataRes = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}/optional-ssg/props-no-revalidate.json`, - undefined - ) - expect(dataRes.status).toBe(200) - expect(res.headers.get('cache-control')).toBe( - 's-maxage=31536000, stale-while-revalidate' - ) - expect((await dataRes.json()).pageProps.params).toEqual({ - rest: ['props-no-revalidate'], - }) - }) - - it('should warn when "next" is imported directly', async () => { - await renderViaHTTP(appPort, '/gssp') - await check( - () => stderr, - /"next" should not be imported directly, imported in/ - ) - }) - - it('`compress` should be `false` by in nextEnv', async () => { - expect( - await fs.readFileSync(join(next.testDir, 'standalone/server.js'), 'utf8') - ).toContain('"compress":false') - }) - - it('should output middleware correctly', async () => { - expect( - await fs.pathExists( - join(next.testDir, 'standalone/.next/server/edge-runtime-webpack.js') - ) - ).toBe(true) - expect( - await fs.pathExists( - join(next.testDir, 'standalone/.next/server/middleware.js') - ) - ).toBe(true) - }) - - it('should output required-server-files manifest correctly', async () => { - expect(requiredFilesManifest.version).toBe(1) - expect(Array.isArray(requiredFilesManifest.files)).toBe(true) - expect(Array.isArray(requiredFilesManifest.ignore)).toBe(true) - expect(requiredFilesManifest.files.length).toBeGreaterThan(0) - expect(requiredFilesManifest.ignore.length).toBeGreaterThan(0) - expect(typeof requiredFilesManifest.config.configFile).toBe('undefined') - expect(typeof requiredFilesManifest.config.trailingSlash).toBe('boolean') - expect(typeof requiredFilesManifest.appDir).toBe('string') - }) - - it('should de-dupe HTML/data requests', async () => { - const res = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual', - headers: { - // ensure the nextjs-data header being present - // doesn't incorrectly return JSON for HTML path - // during prerendering - 'x-nextjs-data': '1', - }, - }) - expect(res.status).toBe(200) - expect(res.headers.get('x-nextjs-cache')).toBeFalsy() - const $ = cheerio.load(await res.text()) - const props = JSON.parse($('#props').text()) - expect(props.gspCalls).toBeDefined() - - const res2 = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}/gsp.json`, - undefined, - { - redirect: 'manual', - } - ) - expect(res2.status).toBe(200) - expect(res2.headers.get('x-nextjs-cache')).toBeFalsy() - const { pageProps: props2 } = await res2.json() - expect(props2.gspCalls).toBe(props.gspCalls) - - const res3 = await fetchViaHTTP(appPort, '/index', undefined, { - redirect: 'manual', - headers: { - 'x-matched-path': '/index', - }, - }) - expect(res3.status).toBe(200) - const $2 = cheerio.load(await res3.text()) - const props3 = JSON.parse($2('#props').text()) - expect(props3.gspCalls).toBeDefined() - - const res4 = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}/index.json`, - undefined, - { - redirect: 'manual', - } - ) - expect(res4.status).toBe(200) - const { pageProps: props4 } = await res4.json() - expect(props4.gspCalls).toBe(props3.gspCalls) - }) - - it('should cap de-dupe previousCacheItem expires time', async () => { - const res = await fetchViaHTTP(appPort, '/gsp-long-revalidate', undefined, { - redirect: 'manual', - }) - expect(res.status).toBe(200) - const $ = cheerio.load(await res.text()) - const props = JSON.parse($('#props').text()) - expect(props.gspCalls).toBeDefined() - - await waitFor(1000) - - const res2 = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}/gsp-long-revalidate.json`, - undefined, - { - redirect: 'manual', - } - ) - expect(res2.status).toBe(200) - const { pageProps: props2 } = await res2.json() - expect(props2.gspCalls).not.toBe(props.gspCalls) - }) - - it('should not 404 for onlyGenerated on-demand revalidate in minimal mode', async () => { - const previewProps = JSON.parse( - await next.readFile('standalone/.next/prerender-manifest.json') - ).preview - - const res = await fetchViaHTTP( - appPort, - '/optional-ssg/only-generated-1', - undefined, - { - headers: { - 'x-prerender-revalidate': previewProps.previewModeId, - 'x-prerender-revalidate-if-generated': '1', - }, - } - ) - expect(res.status).toBe(200) - }) - - it('should set correct SWR headers with notFound gsp', async () => { - await waitFor(2000) - await next.patchFile('standalone/data.txt', 'show') - - const res = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual', - }) - expect(res.status).toBe(200) - expect(res.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' - ) - - await waitFor(2000) - await next.patchFile('standalone/data.txt', 'hide') - - const res2 = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual', - }) - expect(res2.status).toBe(404) - expect(res2.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' - ) - }) - - it('should set correct SWR headers with notFound gssp', async () => { - await next.patchFile('standalone/data.txt', 'show') - - const res = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual', - }) - expect(res.status).toBe(200) - expect(res.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' - ) - - await next.patchFile('standalone/data.txt', 'hide') - - const res2 = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual', - }) - await next.patchFile('standalone/data.txt', 'show') - - expect(res2.status).toBe(404) - expect(res2.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' - ) - }) - - it('should render SSR page correctly', async () => { - const html = await renderViaHTTP(appPort, '/gssp') - const $ = cheerio.load(html) - const data = JSON.parse($('#props').text()) - - expect($('#gssp').text()).toBe('getServerSideProps page') - expect(data.hello).toBe('world') - - const html2 = await renderViaHTTP(appPort, '/gssp') - const $2 = cheerio.load(html2) - const data2 = JSON.parse($2('#props').text()) - - expect($2('#gssp').text()).toBe('getServerSideProps page') - expect(isNaN(data2.random)).toBe(false) - expect(data2.random).not.toBe(data.random) - }) - - it('should render dynamic SSR page correctly', async () => { - const html = await renderViaHTTP(appPort, '/dynamic/first') - const $ = cheerio.load(html) - const data = JSON.parse($('#props').text()) - - expect($('#dynamic').text()).toBe('dynamic page') - expect($('#slug').text()).toBe('first') - expect(data.hello).toBe('world') - - const html2 = await renderViaHTTP(appPort, '/dynamic/second') - const $2 = cheerio.load(html2) - const data2 = JSON.parse($2('#props').text()) - - expect($2('#dynamic').text()).toBe('dynamic page') - expect($2('#slug').text()).toBe('second') - expect(isNaN(data2.random)).toBe(false) - expect(data2.random).not.toBe(data.random) - }) - - it('should render fallback page correctly', async () => { - const html = await renderViaHTTP(appPort, '/fallback/first') - const $ = cheerio.load(html) - const data = JSON.parse($('#props').text()) - - expect($('#fallback').text()).toBe('fallback page') - expect($('#slug').text()).toBe('first') - expect(data.hello).toBe('world') - - await waitFor(2000) - const html2 = await renderViaHTTP(appPort, '/fallback/first') - const $2 = cheerio.load(html2) - const data2 = JSON.parse($2('#props').text()) - - expect($2('#fallback').text()).toBe('fallback page') - expect($2('#slug').text()).toBe('first') - expect(isNaN(data2.random)).toBe(false) - expect(data2.random).not.toBe(data.random) - - const html3 = await renderViaHTTP(appPort, '/fallback/second') - const $3 = cheerio.load(html3) - const data3 = JSON.parse($3('#props').text()) - - expect($3('#fallback').text()).toBe('fallback page') - expect($3('#slug').text()).toBe('second') - expect(isNaN(data3.random)).toBe(false) - - const { pageProps: data4 } = JSON.parse( - await renderViaHTTP( - appPort, - `/_next/data/${next.buildId}/fallback/third.json` - ) - ) - expect(data4.hello).toBe('world') - expect(data4.slug).toBe('third') - }) - - it('should render SSR page correctly with x-matched-path', async () => { - const html = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/gssp', - }, - }) - const $ = cheerio.load(html) - const data = JSON.parse($('#props').text()) - - expect($('#gssp').text()).toBe('getServerSideProps page') - expect(data.hello).toBe('world') - - const html2 = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/gssp', - }, - }) - const $2 = cheerio.load(html2) - const data2 = JSON.parse($2('#props').text()) - - expect($2('#gssp').text()).toBe('getServerSideProps page') - expect(isNaN(data2.random)).toBe(false) - expect(data2.random).not.toBe(data.random) - }) - - it('should render dynamic SSR page correctly with x-matched-path', async () => { - const html = await renderViaHTTP( - appPort, - '/some-other-path?slug=first', - undefined, - { - headers: { - 'x-matched-path': '/dynamic/[slug]', - }, - } - ) - const $ = cheerio.load(html) - const data = JSON.parse($('#props').text()) - - expect($('#dynamic').text()).toBe('dynamic page') - expect($('#slug').text()).toBe('first') - expect(data.hello).toBe('world') - - const html2 = await renderViaHTTP( - appPort, - '/some-other-path?slug=second', - undefined, - { - headers: { - 'x-matched-path': '/dynamic/[slug]', - }, - } - ) - const $2 = cheerio.load(html2) - const data2 = JSON.parse($2('#props').text()) - - expect($2('#dynamic').text()).toBe('dynamic page') - expect($2('#slug').text()).toBe('second') - expect(isNaN(data2.random)).toBe(false) - expect(data2.random).not.toBe(data.random) - - const html3 = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/dynamic/[slug]', - 'x-now-route-matches': '1=second&slug=second', - }, - }) - const $3 = cheerio.load(html3) - const data3 = JSON.parse($3('#props').text()) - - expect($3('#dynamic').text()).toBe('dynamic page') - expect($3('#slug').text()).toBe('second') - expect(isNaN(data3.random)).toBe(false) - expect(data3.random).not.toBe(data.random) - }) - - it('should render fallback page correctly with x-matched-path and routes-matches', async () => { - const html = await renderViaHTTP(appPort, '/fallback/first', undefined, { - headers: { - 'x-matched-path': '/fallback/first', - 'x-now-route-matches': '1=first', - }, - }) - const $ = cheerio.load(html) - const data = JSON.parse($('#props').text()) - - expect($('#fallback').text()).toBe('fallback page') - expect($('#slug').text()).toBe('first') - expect(data.hello).toBe('world') - - const html2 = await renderViaHTTP(appPort, `/fallback/[slug]`, undefined, { - headers: { - 'x-matched-path': '/fallback/[slug]', - 'x-now-route-matches': '1=second', - }, - }) - const $2 = cheerio.load(html2) - const data2 = JSON.parse($2('#props').text()) - - expect($2('#fallback').text()).toBe('fallback page') - expect($2('#slug').text()).toBe('second') - expect(isNaN(data2.random)).toBe(false) - expect(data2.random).not.toBe(data.random) - }) - - it('should favor valid route params over routes-matches', async () => { - const html = await renderViaHTTP(appPort, '/fallback/first', undefined, { - headers: { - 'x-matched-path': '/fallback/first', - 'x-now-route-matches': '1=fallback%2ffirst', - }, - }) - const $ = cheerio.load(html) - const data = JSON.parse($('#props').text()) - - expect($('#fallback').text()).toBe('fallback page') - expect($('#slug').text()).toBe('first') - expect(data.hello).toBe('world') - - const html2 = await renderViaHTTP(appPort, `/fallback/second`, undefined, { - headers: { - 'x-matched-path': '/fallback/[slug]', - 'x-now-route-matches': '1=fallback%2fsecond', - }, - }) - const $2 = cheerio.load(html2) - const data2 = JSON.parse($2('#props').text()) - - expect($2('#fallback').text()).toBe('fallback page') - expect($2('#slug').text()).toBe('second') - expect(isNaN(data2.random)).toBe(false) - expect(data2.random).not.toBe(data.random) - }) - - it('should favor valid route params over routes-matches optional', async () => { - const html = await renderViaHTTP(appPort, '/optional-ssg', undefined, { - headers: { - 'x-matched-path': '/optional-ssg', - 'x-now-route-matches': '1=optional-ssg', - }, - }) - const $ = cheerio.load(html) - const data = JSON.parse($('#props').text()) - expect(data.params).toEqual({}) - - const html2 = await renderViaHTTP(appPort, `/optional-ssg`, undefined, { - headers: { - 'x-matched-path': '/optional-ssg', - 'x-now-route-matches': '1=optional-ssg%2fanother', - }, - }) - const $2 = cheerio.load(html2) - const data2 = JSON.parse($2('#props').text()) - - expect(isNaN(data2.random)).toBe(false) - expect(data2.params).toEqual({}) - }) - - it('should return data correctly with x-matched-path', async () => { - const res = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}/dynamic/first.json?slug=first`, - undefined, - { - headers: { - 'x-matched-path': `/dynamic/[slug]`, - }, - } - ) - - const { pageProps: data } = await res.json() - - expect(data.slug).toBe('first') - expect(data.hello).toBe('world') - - const res2 = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}/fallback/[slug].json`, - undefined, - { - headers: { - 'x-matched-path': `/_next/data/${next.buildId}/fallback/[slug].json`, - 'x-now-route-matches': '1=second', - }, - } - ) - - const { pageProps: data2 } = await res2.json() - - expect(data2.slug).toBe('second') - expect(data2.hello).toBe('world') - }) - - it('should render fallback optional catch-all route correctly with x-matched-path and routes-matches', async () => { - const html = await renderViaHTTP( - appPort, - '/catch-all/[[...rest]]', - undefined, - { - headers: { - 'x-matched-path': '/catch-all/[[...rest]]', - 'x-now-route-matches': '', - }, - } - ) - const $ = cheerio.load(html) - const data = JSON.parse($('#props').text()) - - expect($('#catch-all').text()).toBe('optional catch-all page') - expect(data.params).toEqual({}) - expect(data.hello).toBe('world') - - const html2 = await renderViaHTTP( - appPort, - '/catch-all/[[...rest]]', - undefined, - { - headers: { - 'x-matched-path': '/catch-all/[[...rest]]', - 'x-now-route-matches': '1=hello&catchAll=hello', - }, - } - ) - const $2 = cheerio.load(html2) - const data2 = JSON.parse($2('#props').text()) - - expect($2('#catch-all').text()).toBe('optional catch-all page') - expect(data2.params).toEqual({ rest: ['hello'] }) - expect(isNaN(data2.random)).toBe(false) - expect(data2.random).not.toBe(data.random) - - const html3 = await renderViaHTTP( - appPort, - '/catch-all/[[...rest]]', - undefined, - { - headers: { - 'x-matched-path': '/catch-all/[[...rest]]', - 'x-now-route-matches': '1=hello/world&catchAll=hello/world', - }, - } - ) - const $3 = cheerio.load(html3) - const data3 = JSON.parse($3('#props').text()) - - expect($3('#catch-all').text()).toBe('optional catch-all page') - expect(data3.params).toEqual({ rest: ['hello', 'world'] }) - expect(isNaN(data3.random)).toBe(false) - expect(data3.random).not.toBe(data.random) - }) - - it('should return data correctly with x-matched-path for optional catch-all route', async () => { - const res = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}/catch-all.json`, - - undefined, - { - headers: { - 'x-matched-path': '/catch-all/[[...rest]]', - }, - } - ) - - const { pageProps: data } = await res.json() - - expect(data.params).toEqual({}) - expect(data.hello).toBe('world') - - const res2 = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}/catch-all/[[...rest]].json`, - undefined, - { - headers: { - 'x-matched-path': `/_next/data/${next.buildId}/catch-all/[[...rest]].json`, - 'x-now-route-matches': '1=hello&rest=hello', - }, - } - ) - - const { pageProps: data2 } = await res2.json() - - expect(data2.params).toEqual({ rest: ['hello'] }) - expect(data2.hello).toBe('world') - - const res3 = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}/catch-all/[[...rest]].json`, - undefined, - { - headers: { - 'x-matched-path': `/_next/data/${next.buildId}/catch-all/[[...rest]].json`, - 'x-now-route-matches': '1=hello/world&rest=hello/world', - }, - } - ) - - const { pageProps: data3 } = await res3.json() - - expect(data3.params).toEqual({ rest: ['hello', 'world'] }) - expect(data3.hello).toBe('world') - }) - - it('should not apply trailingSlash redirect', async () => { - for (const path of [ - '/', - '/dynamic/another/', - '/dynamic/another', - '/fallback/first/', - '/fallback/first', - '/fallback/another/', - '/fallback/another', - ]) { - const res = await fetchViaHTTP(appPort, path, undefined, { - redirect: 'manual', - }) - - expect(res.status).toBe(200) - } - }) - - it('should normalize catch-all rewrite query values correctly', async () => { - const html = await renderViaHTTP( - appPort, - '/some-catch-all/hello/world', - { - path: 'hello/world', - }, - { - headers: { - 'x-matched-path': '/gssp', - }, - } - ) - const $ = cheerio.load(html) - expect(JSON.parse($('#router').text()).query).toEqual({ - path: ['hello', 'world'], - }) - }) - - it('should handle bad request correctly with rewrite', async () => { - const res = await fetchViaHTTP( - appPort, - '/to-dynamic/%c0.%c0.', - '?path=%c0.%c0.', - { - headers: { - 'x-matched-path': '/dynamic/[slug]', - }, - } - ) - expect(res.status).toBe(400) - expect(await res.text()).toContain('Bad Request') - }) - - it('should have correct resolvedUrl from rewrite', async () => { - const res = await fetchViaHTTP(appPort, '/to-dynamic/post-1', undefined, { - headers: { - 'x-matched-path': '/dynamic/[slug]', - }, - }) - expect(res.status).toBe(200) - const $ = cheerio.load(await res.text()) - expect($('#resolved-url').text()).toBe('/dynamic/post-1') - }) - - it('should have correct resolvedUrl from rewrite with added query', async () => { - const res = await fetchViaHTTP(appPort, '/to-dynamic/post-2', undefined, { - headers: { - 'x-matched-path': '/dynamic/[slug]', - }, - }) - expect(res.status).toBe(200) - const $ = cheerio.load(await res.text()) - expect($('#resolved-url').text()).toBe('/dynamic/post-2') - expect(JSON.parse($('#router').text()).asPath).toBe('/to-dynamic/post-2') - }) - - it('should have correct resolvedUrl from dynamic route', async () => { - const res = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}/dynamic/post-2.json`, - { slug: 'post-2' }, - { - headers: { - 'x-matched-path': '/dynamic/[slug]', - }, - } - ) - expect(res.status).toBe(200) - const json = await res.json() - expect(json.pageProps.resolvedUrl).toBe('/dynamic/post-2') - }) - - it('should bubble error correctly for gip page', async () => { - errors = [] - const res = await fetchViaHTTP(appPort, '/errors/gip', { crash: '1' }) - expect(res.status).toBe(500) - expect(await res.text()).toBe('internal server error') - - await check( - () => (errors[0].includes('gip hit an oops') ? 'success' : errors[0]), - 'success' - ) - }) - - it('should bubble error correctly for gssp page', async () => { - errors = [] - const res = await fetchViaHTTP(appPort, '/errors/gssp', { crash: '1' }) - expect(res.status).toBe(500) - expect(await res.text()).toBe('internal server error') - await check( - () => (errors[0].includes('gssp hit an oops') ? 'success' : errors[0]), - 'success' - ) - }) - - it('should bubble error correctly for gsp page', async () => { - errors = [] - const res = await fetchViaHTTP(appPort, '/errors/gsp/crash') - expect(res.status).toBe(500) - expect(await res.text()).toBe('internal server error') - await check( - () => (errors[0].includes('gsp hit an oops') ? 'success' : errors[0]), - 'success' - ) - }) - - it('should bubble error correctly for API page', async () => { - errors = [] - const res = await fetchViaHTTP(appPort, '/api/error') - expect(res.status).toBe(500) - expect(await res.text()).toBe('internal server error') - await check( - () => - errors[0].includes('some error from /api/error') - ? 'success' - : errors[0], - 'success' - ) - }) - - it('should normalize optional values correctly for SSP page', async () => { - const res = await fetchViaHTTP( - appPort, - '/optional-ssp', - { rest: '', another: 'value' }, - { - headers: { - 'x-matched-path': '/optional-ssp/[[...rest]]', - }, - } - ) - - const html = await res.text() - const $ = cheerio.load(html) - const props = JSON.parse($('#props').text()) - expect(props.params).toEqual({}) - expect(props.query).toEqual({ another: 'value' }) - }) - - it('should normalize optional values correctly for SSG page', async () => { - const res = await fetchViaHTTP( - appPort, - '/optional-ssg', - { rest: '', another: 'value' }, - { - headers: { - 'x-matched-path': '/optional-ssg/[[...rest]]', - }, - } - ) - - const html = await res.text() - const $ = cheerio.load(html) - const props = JSON.parse($('#props').text()) - expect(props.params).toEqual({}) - }) - - it('should normalize optional revalidations correctly for SSG page', async () => { - const reqs = [ - { - path: `/_next/data/${next.buildId}/optional-ssg/[[...rest]].json`, - headers: { - 'x-matched-path': `/_next/data/${next.buildId}/optional-ssg/[[...rest]].json`, - }, - }, - { - path: `/_next/data/${next.buildId}/optional-ssg.json`, - headers: { - 'x-matched-path': `/_next/data/${next.buildId}/optional-ssg/[[...rest]].json`, - }, - }, - { - path: `/_next/data/${next.buildId}/optional-ssg.json`, - headers: { - 'x-matched-path': `/_next/data/${next.buildId}/optional-ssg.json`, - }, - }, - { - path: `/_next/data/${next.buildId}/optional-ssg/[[...rest]].json`, - headers: { - 'x-matched-path': `/_next/data/${next.buildId}/optional-ssg/[[...rest]].json`, - }, - query: { rest: '' }, - }, - { - path: `/_next/data/${next.buildId}/optional-ssg/[[...rest]].json`, - headers: { - 'x-matched-path': `/_next/data/${next.buildId}/optional-ssg/[[...rest]].json`, - 'x-now-route-matches': '1=', - }, - }, - { - path: `/_next/data/${next.buildId}/optional-ssg/.json`, - headers: { - 'x-matched-path': `/_next/data/${next.buildId}/optional-ssg/[[...rest]].json`, - 'x-now-route-matches': '', - 'x-vercel-id': 'cle1::', - }, - }, - { - path: `/optional-ssg/[[...rest]]`, - headers: { - 'x-matched-path': `/_next/data/${next.buildId}/optional-ssg/[[...rest]].json`, - 'x-now-route-matches': '', - 'x-vercel-id': 'cle1::', - }, - }, - { - path: `/_next/data/${next.buildId}/optional-ssg/[[...rest]].json`, - headers: { - 'x-matched-path': `/optional-ssg/[[...rest]]`, - 'x-now-route-matches': '', - 'x-vercel-id': 'cle1::', - }, - }, - ] - - for (const req of reqs) { - console.error('checking', req) - const res = await fetchViaHTTP(appPort, req.path, req.query, { - headers: req.headers, - }) - - const content = await res.text() - let props - - try { - const data = JSON.parse(content) - props = data.pageProps - } catch (_) { - props = JSON.parse(cheerio.load(content)('#__NEXT_DATA__').text()).props - .pageProps - } - expect(props.params).toEqual({}) - } - }) - - it('should normalize optional values correctly for SSG page with encoded slash', async () => { - const res = await fetchViaHTTP( - appPort, - '/optional-ssg/[[...rest]]', - undefined, - { - headers: { - 'x-matched-path': '/optional-ssg/[[...rest]]', - 'x-now-route-matches': - '1=en%2Fes%2Fhello%252Fworld&rest=en%2Fes%2Fhello%252Fworld', - }, - } - ) - - const html = await res.text() - const $ = cheerio.load(html) - const props = JSON.parse($('#props').text()) - expect(props.params).toEqual({ - rest: ['en', 'es', 'hello/world'], - }) - }) - - it('should normalize optional values correctly for API page', async () => { - const res = await fetchViaHTTP( - appPort, - '/api/optional', - { rest: '', another: 'value' }, - { - headers: { - 'x-matched-path': '/api/optional/[[...rest]]', - }, - } - ) - - const json = await res.json() - expect(json.query).toEqual({ another: 'value' }) - expect(json.url).toBe('/api/optional?another=value') - }) - - it('should normalize index optional values correctly for API page', async () => { - const res = await fetchViaHTTP( - appPort, - '/api/optional/index', - { rest: 'index', another: 'value' }, - { - headers: { - 'x-matched-path': '/api/optional/[[...rest]]', - }, - } - ) - - const json = await res.json() - expect(json.query).toEqual({ another: 'value', rest: ['index'] }) - expect(json.url).toBe('/api/optional/index?another=value') - }) - - it('should match the index page correctly', async () => { - const res = await fetchViaHTTP(appPort, '/', undefined, { - headers: { - 'x-matched-path': '/index', - }, - redirect: 'manual', - }) - - const html = await res.text() - const $ = cheerio.load(html) - expect($('#index').text()).toBe('index page') - }) - - it('should match the root dynamic page correctly', async () => { - const res = await fetchViaHTTP(appPort, '/slug-1', undefined, { - headers: { - 'x-matched-path': '/[slug]', - }, - redirect: 'manual', - }) - - const html = await res.text() - const $ = cheerio.load(html) - expect($('#slug-page').text()).toBe('[slug] page') - expect(JSON.parse($('#router').text()).query).toEqual({ - slug: 'slug-1', - }) - - const res2 = await fetchViaHTTP(appPort, '/[slug]', undefined, { - headers: { - 'x-matched-path': '/[slug]', - }, - redirect: 'manual', - }) - - const html2 = await res2.text() - const $2 = cheerio.load(html2) - expect($2('#slug-page').text()).toBe('[slug] page') - expect(JSON.parse($2('#router').text()).query).toEqual({ - slug: '[slug]', - }) - }) - - it('should have correct asPath on dynamic SSG page correctly', async () => { - const res = await fetchViaHTTP(appPort, '/an-ssg-path', undefined, { - headers: { - 'x-matched-path': '/[slug]', - }, - redirect: 'manual', - }) - - const html = await res.text() - const $ = cheerio.load(html) - expect($('#slug-page').text()).toBe('[slug] page') - expect(JSON.parse($('#router').text()).asPath).toBe('/an-ssg-path') - }) - - it('should have correct asPath on dynamic SSG page fallback correctly', async () => { - const toCheck = [ - { - pathname: '/fallback-false/first', - matchedPath: '/fallback-false/first', - }, - { - pathname: '/fallback-false/first', - matchedPath: `/_next/data/${next.buildId}/fallback-false/first.json`, - }, - ] - for (const check of toCheck) { - console.warn('checking', check) - const res = await fetchViaHTTP(appPort, check.pathname, undefined, { - headers: { - 'x-matched-path': check.matchedPath, - }, - redirect: 'manual', - }) - - const html = await res.text() - const $ = cheerio.load(html) - expect($('#page').text()).toBe('blog slug') - expect($('#asPath').text()).toBe('/fallback-false/first') - expect($('#pathname').text()).toBe('/fallback-false/[slug]') - expect(JSON.parse($('#query').text())).toEqual({ slug: 'first' }) - } - }) - - it('should copy and read .env file', async () => { - const res = await fetchViaHTTP(appPort, '/api/env') - - const envVariables = await res.json() - - expect(envVariables.env).not.toBeUndefined() - expect(envVariables.envProd).not.toBeUndefined() - expect(envVariables.envLocal).toBeUndefined() - }) -})