From 4cd8b23032895ab18878d443625e671b291c87d6 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 15 Aug 2022 16:29:51 +0200 Subject: [PATCH] Enable @typescript-eslint/no-use-before-define for functions (#39602) Follow-up to the earlier enabling of classes/variables etc. Bug Related issues linked using fixes #number Integration tests added Errors have helpful link attached, see contributing.md Feature Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. Related issues linked using fixes #number Integration tests added Documentation added Telemetry added. In case of a feature if it's used or not. Errors have helpful link attached, see contributing.md Documentation / Examples Make sure the linting passes by running pnpm lint The examples guidelines are followed from our contributing doc Co-authored-by: Steven --- .eslintrc.json | 2 +- .../build/analysis/extract-const-value.ts | 92 +- .../build/analysis/get-page-static-info.ts | 188 ++--- packages/next/build/entries.ts | 84 +- packages/next/build/index.ts | 178 ++-- packages/next/build/utils.ts | 406 ++++----- packages/next/build/webpack-config.ts | 22 +- .../loaders/next-middleware-wasm-loader.ts | 8 +- .../font-stylesheet-gathering-plugin.ts | 34 +- .../webpack/plugins/middleware-plugin.ts | 784 +++++++++--------- .../build/webpack/plugins/telemetry-plugin.ts | 62 +- packages/next/cli/next-info.ts | 38 +- .../client/components/hot-reloader.client.tsx | 34 +- .../client/dev/error-overlay/websocket.ts | 51 +- packages/next/client/future/image.tsx | 134 +-- packages/next/client/image.tsx | 240 +++--- packages/next/client/index.tsx | 778 ++++++++--------- packages/next/client/router.ts | 20 +- packages/next/client/use-intersection.tsx | 136 +-- packages/next/lib/patch-incorrect-lockfile.ts | 71 +- packages/next/lib/try-to-parse-path.ts | 46 +- packages/next/pages/_document.tsx | 542 ++++++------ packages/next/server/accept-header.ts | 18 +- packages/next/server/api-utils/node.ts | 442 +++++----- packages/next/server/base-server.ts | 30 +- packages/next/server/config.ts | 34 +- packages/next/server/dev/hot-middleware.ts | 40 +- packages/next/server/dev/hot-reloader.ts | 8 +- .../server/dev/on-demand-entry-handler.ts | 260 +++--- packages/next/server/image-optimizer.ts | 220 ++--- packages/next/server/lib/find-page-file.ts | 22 +- packages/next/server/next-server.ts | 48 +- .../next/server/node-web-streams-helper.ts | 146 ++-- packages/next/server/render.tsx | 74 +- packages/next/server/send-payload/index.ts | 48 +- packages/next/server/web/adapter.ts | 38 +- packages/next/server/web/next-url.ts | 20 +- packages/next/server/web/sandbox/context.ts | 228 ++--- packages/next/server/web/sandbox/sandbox.ts | 40 +- packages/next/server/web/utils.ts | 26 +- .../shared/lib/i18n/get-locale-redirect.ts | 96 +-- packages/next/shared/lib/router/router.ts | 464 +++++------ .../lib/router/utils/prepare-destination.ts | 62 +- .../shared/lib/router/utils/route-regex.ts | 116 +-- packages/next/telemetry/storage.ts | 20 +- packages/next/types/webpack.d.ts | 4 +- .../src/internal/helpers/nodeStackFrames.ts | 18 +- .../src/internal/helpers/stack-frame.ts | 20 +- .../react-refresh-utils/internal/helpers.ts | 38 +- .../e2e/edge-can-use-wasm-files/index.test.ts | 8 +- .../e2e/middleware-general/test/index.test.ts | 31 +- .../middleware-redirects/test/index.test.ts | 8 +- .../middleware-responses/test/index.test.ts | 6 +- .../middleware-rewrites/test/index.test.ts | 32 +- .../test/index.test.ts | 6 +- 55 files changed, 3312 insertions(+), 3309 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index cda24c1d8079..262fed1ad758 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -68,7 +68,7 @@ "@typescript-eslint/no-use-before-define": [ "warn", { - "functions": false, + "functions": true, "classes": true, "variables": true, "enums": true, diff --git a/packages/next/build/analysis/extract-const-value.ts b/packages/next/build/analysis/extract-const-value.ts index a08c9d40b9a9..209f3e813ae1 100644 --- a/packages/next/build/analysis/extract-const-value.ts +++ b/packages/next/build/analysis/extract-const-value.ts @@ -17,52 +17,6 @@ import type { export class NoSuchDeclarationError extends Error {} -/** - * Extracts the value of an exported const variable named `exportedName` - * (e.g. "export const config = { runtime: 'experimental-edge' }") from swc's AST. - * The value must be one of (or throws UnsupportedValueError): - * - string - * - boolean - * - number - * - null - * - undefined - * - array containing values listed in this list - * - object containing values listed in this list - * - * Throws NoSuchDeclarationError if the declaration is not found. - */ -export function extractExportedConstValue( - module: Module, - exportedName: string -): any { - for (const moduleItem of module.body) { - if (!isExportDeclaration(moduleItem)) { - continue - } - - const declaration = moduleItem.declaration - if (!isVariableDeclaration(declaration)) { - continue - } - - if (declaration.kind !== 'const') { - continue - } - - for (const decl of declaration.declarations) { - if ( - isIdentifier(decl.id) && - decl.id.value === exportedName && - decl.init - ) { - return extractValue(decl.init, [exportedName]) - } - } - } - - throw new NoSuchDeclarationError() -} - function isExportDeclaration(node: Node): node is ExportDeclaration { return node.type === 'ExportDeclaration' } @@ -247,3 +201,49 @@ function extractValue(node: Node, path?: string[]): any { ) } } + +/** + * Extracts the value of an exported const variable named `exportedName` + * (e.g. "export const config = { runtime: 'experimental-edge' }") from swc's AST. + * The value must be one of (or throws UnsupportedValueError): + * - string + * - boolean + * - number + * - null + * - undefined + * - array containing values listed in this list + * - object containing values listed in this list + * + * Throws NoSuchDeclarationError if the declaration is not found. + */ +export function extractExportedConstValue( + module: Module, + exportedName: string +): any { + for (const moduleItem of module.body) { + if (!isExportDeclaration(moduleItem)) { + continue + } + + const declaration = moduleItem.declaration + if (!isVariableDeclaration(declaration)) { + continue + } + + if (declaration.kind !== 'const') { + continue + } + + for (const decl of declaration.declarations) { + if ( + isIdentifier(decl.id) && + decl.id.value === exportedName && + decl.init + ) { + return extractValue(decl.init, [exportedName]) + } + } + } + + throw new NoSuchDeclarationError() +} diff --git a/packages/next/build/analysis/get-page-static-info.ts b/packages/next/build/analysis/get-page-static-info.ts index d2692d1450d2..98347d077035 100644 --- a/packages/next/build/analysis/get-page-static-info.ts +++ b/packages/next/build/analysis/get-page-static-info.ts @@ -22,79 +22,6 @@ export interface PageStaticInfo { middleware?: Partial } -/** - * For a given pageFilePath and nextConfig, if the config supports it, this - * function will read the file and return the runtime that should be used. - * It will look into the file content only if the page *requires* a runtime - * to be specified, that is, when gSSP or gSP is used. - * Related discussion: https://github.com/vercel/next.js/discussions/34179 - */ -export async function getPageStaticInfo(params: { - nextConfig: Partial - pageFilePath: string - isDev?: boolean - page?: string -}): Promise { - const { isDev, pageFilePath, nextConfig, page } = params - - const fileContent = (await tryToReadFile(pageFilePath, !isDev)) || '' - if (/runtime|getStaticProps|getServerSideProps|matcher/.test(fileContent)) { - const swcAST = await parseModule(pageFilePath, fileContent) - const { ssg, ssr } = checkExports(swcAST) - - // default / failsafe value for config - let config: any = {} - try { - config = extractExportedConstValue(swcAST, 'config') - } catch (e) { - if (e instanceof UnsupportedValueError) { - warnAboutUnsupportedValue(pageFilePath, page, e) - } - // `export config` doesn't exist, or other unknown error throw by swc, silence them - } - - if ( - typeof config.runtime !== 'string' && - typeof config.runtime !== 'undefined' - ) { - throw new Error(`Provided runtime `) - } else if (!isServerRuntime(config.runtime)) { - const options = Object.values(SERVER_RUNTIME).join(', ') - if (typeof config.runtime !== 'string') { - throw new Error( - `The \`runtime\` config must be a string. Please leave it empty or choose one of: ${options}` - ) - } else { - throw new Error( - `Provided runtime "${config.runtime}" is not supported. Please leave it empty or choose one of: ${options}` - ) - } - } - - let runtime = - SERVER_RUNTIME.edge === config?.runtime - ? SERVER_RUNTIME.edge - : ssr || ssg - ? config?.runtime || nextConfig.experimental?.runtime - : undefined - - if (runtime === SERVER_RUNTIME.edge) { - warnAboutExperimentalEdgeApiFunctions() - } - - const middlewareConfig = getMiddlewareConfig(config, nextConfig) - - return { - ssr, - ssg, - ...(middlewareConfig && { middleware: middlewareConfig }), - ...(runtime && { runtime }), - } - } - - return { ssr: false, ssg: false } -} - /** * Receives a parsed AST from SWC and checks if it belongs to a module that * requires a runtime to be specified. Those are: @@ -154,27 +81,6 @@ async function tryToReadFile(filePath: string, shouldThrow: boolean) { } } -function getMiddlewareConfig( - config: any, - nextConfig: NextConfig -): Partial { - const result: Partial = {} - - if (config.matcher) { - result.pathMatcher = new RegExp( - getMiddlewareRegExpStrings(config.matcher, nextConfig).join('|') - ) - - if (result.pathMatcher.source.length > 4096) { - throw new Error( - `generated matcher config must be less than 4096 characters.` - ) - } - } - - return result -} - function getMiddlewareRegExpStrings( matcherOrMatchers: unknown, nextConfig: NextConfig @@ -226,6 +132,27 @@ function getMiddlewareRegExpStrings( } } +function getMiddlewareConfig( + config: any, + nextConfig: NextConfig +): Partial { + const result: Partial = {} + + if (config.matcher) { + result.pathMatcher = new RegExp( + getMiddlewareRegExpStrings(config.matcher, nextConfig).join('|') + ) + + if (result.pathMatcher.source.length > 4096) { + throw new Error( + `generated matcher config must be less than 4096 characters.` + ) + } + } + + return result +} + let warnedAboutExperimentalEdgeApiFunctions = false function warnAboutExperimentalEdgeApiFunctions() { if (warnedAboutExperimentalEdgeApiFunctions) { @@ -258,3 +185,76 @@ function warnAboutUnsupportedValue( warnedUnsupportedValueMap.set(pageFilePath, true) } + +/** + * For a given pageFilePath and nextConfig, if the config supports it, this + * function will read the file and return the runtime that should be used. + * It will look into the file content only if the page *requires* a runtime + * to be specified, that is, when gSSP or gSP is used. + * Related discussion: https://github.com/vercel/next.js/discussions/34179 + */ +export async function getPageStaticInfo(params: { + nextConfig: Partial + pageFilePath: string + isDev?: boolean + page?: string +}): Promise { + const { isDev, pageFilePath, nextConfig, page } = params + + const fileContent = (await tryToReadFile(pageFilePath, !isDev)) || '' + if (/runtime|getStaticProps|getServerSideProps|matcher/.test(fileContent)) { + const swcAST = await parseModule(pageFilePath, fileContent) + const { ssg, ssr } = checkExports(swcAST) + + // default / failsafe value for config + let config: any = {} + try { + config = extractExportedConstValue(swcAST, 'config') + } catch (e) { + if (e instanceof UnsupportedValueError) { + warnAboutUnsupportedValue(pageFilePath, page, e) + } + // `export config` doesn't exist, or other unknown error throw by swc, silence them + } + + if ( + typeof config.runtime !== 'string' && + typeof config.runtime !== 'undefined' + ) { + throw new Error(`Provided runtime `) + } else if (!isServerRuntime(config.runtime)) { + const options = Object.values(SERVER_RUNTIME).join(', ') + if (typeof config.runtime !== 'string') { + throw new Error( + `The \`runtime\` config must be a string. Please leave it empty or choose one of: ${options}` + ) + } else { + throw new Error( + `Provided runtime "${config.runtime}" is not supported. Please leave it empty or choose one of: ${options}` + ) + } + } + + let runtime = + SERVER_RUNTIME.edge === config?.runtime + ? SERVER_RUNTIME.edge + : ssr || ssg + ? config?.runtime || nextConfig.experimental?.runtime + : undefined + + if (runtime === SERVER_RUNTIME.edge) { + warnAboutExperimentalEdgeApiFunctions() + } + + const middlewareConfig = getMiddlewareConfig(config, nextConfig) + + return { + ssr, + ssg, + ...(middlewareConfig && { middleware: middlewareConfig }), + ...(runtime && { runtime }), + } + } + + return { ssr: false, ssg: false } +} diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index 9cd266fd9535..4bea5cdfce4e 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -284,6 +284,48 @@ export function getClientEntry(opts: { : pageLoader } +export async function runDependingOnPageType(params: { + onClient: () => T + onEdgeServer: () => T + onServer: () => T + page: string + pageRuntime: ServerRuntime +}): Promise { + if (isMiddlewareFile(params.page)) { + await params.onEdgeServer() + return + } + if (params.page.match(API_ROUTE)) { + if (params.pageRuntime === SERVER_RUNTIME.edge) { + await params.onEdgeServer() + return + } + + await params.onServer() + return + } + if (params.page === '/_document') { + await params.onServer() + return + } + if ( + params.page === '/_app' || + params.page === '/_error' || + params.page === '/404' || + params.page === '/500' + ) { + await Promise.all([params.onClient(), params.onServer()]) + return + } + if (params.pageRuntime === SERVER_RUNTIME.edge) { + await Promise.all([params.onClient(), params.onEdgeServer()]) + return + } + + await Promise.all([params.onClient(), params.onServer()]) + return +} + export async function createEntrypoints(params: CreateEntrypointsParams) { const { config, @@ -439,48 +481,6 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { } } -export async function runDependingOnPageType(params: { - onClient: () => T - onEdgeServer: () => T - onServer: () => T - page: string - pageRuntime: ServerRuntime -}): Promise { - if (isMiddlewareFile(params.page)) { - await params.onEdgeServer() - return - } - if (params.page.match(API_ROUTE)) { - if (params.pageRuntime === SERVER_RUNTIME.edge) { - await params.onEdgeServer() - return - } - - await params.onServer() - return - } - if (params.page === '/_document') { - await params.onServer() - return - } - if ( - params.page === '/_app' || - params.page === '/_error' || - params.page === '/404' || - params.page === '/500' - ) { - await Promise.all([params.onClient(), params.onServer()]) - return - } - if (params.pageRuntime === SERVER_RUNTIME.edge) { - await Promise.all([params.onClient(), params.onEdgeServer()]) - return - } - - await Promise.all([params.onClient(), params.onServer()]) - return -} - export function finalizeEntrypoint({ name, compilerType, diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 91e4e9ff1505..458d733c8583 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -154,6 +154,95 @@ type SingleCompilerResult = { stats: webpack.Stats | undefined } +/** + * typescript will be loaded in "next/lib/verifyTypeScriptSetup" and + * then passed to "next/lib/typescript/runTypeCheck" as a parameter. + * + * Since it is impossible to pass a function from main thread to a worker, + * instead of running "next/lib/typescript/runTypeCheck" in a worker, + * we will run entire "next/lib/verifyTypeScriptSetup" in a worker instead. + */ +function verifyTypeScriptSetup( + dir: string, + intentDirs: string[], + typeCheckPreflight: boolean, + tsconfigPath: string, + disableStaticImages: boolean, + cacheDir: string | undefined, + numWorkers: number | undefined, + enableWorkerThreads: boolean | undefined +) { + const typeCheckWorker = new JestWorker( + require.resolve('../lib/verifyTypeScriptSetup'), + { + numWorkers, + enableWorkerThreads, + maxRetries: 0, + } + ) as JestWorker & { + verifyTypeScriptSetup: typeof import('../lib/verifyTypeScriptSetup').verifyTypeScriptSetup + } + + typeCheckWorker.getStdout().pipe(process.stdout) + typeCheckWorker.getStderr().pipe(process.stderr) + + return typeCheckWorker + .verifyTypeScriptSetup( + dir, + intentDirs, + typeCheckPreflight, + tsconfigPath, + disableStaticImages, + cacheDir + ) + .then((result) => { + typeCheckWorker.end() + return result + }) +} + +function generateClientSsgManifest( + prerenderManifest: PrerenderManifest, + { + buildId, + distDir, + locales, + }: { buildId: string; distDir: string; locales: string[] } +) { + const ssgPages = new Set( + [ + ...Object.entries(prerenderManifest.routes) + // Filter out dynamic routes + .filter(([, { srcRoute }]) => srcRoute == null) + .map(([route]) => normalizeLocalePath(route, locales).pathname), + ...Object.keys(prerenderManifest.dynamicRoutes), + ].sort() + ) + + const clientSsgManifestContent = `self.__SSG_MANIFEST=${devalue( + ssgPages + )};self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()` + + writeFileSync( + path.join(distDir, CLIENT_STATIC_FILES_PATH, buildId, '_ssgManifest.js'), + clientSsgManifestContent + ) +} + +function isTelemetryPlugin(plugin: unknown): plugin is TelemetryPlugin { + return plugin instanceof TelemetryPlugin +} + +function pageToRoute(page: string) { + const routeRegex = getNamedRouteRegex(page) + return { + page, + regex: normalizeRouteRegex(routeRegex.re.source), + routeKeys: routeRegex.routeKeys, + namedRegex: routeRegex.namedRegex, + } +} + export default async function build( dir: string, conf = null, @@ -2406,92 +2495,3 @@ export default async function build( teardownCrashReporter() } } - -/** - * typescript will be loaded in "next/lib/verifyTypeScriptSetup" and - * then passed to "next/lib/typescript/runTypeCheck" as a parameter. - * - * Since it is impossible to pass a function from main thread to a worker, - * instead of running "next/lib/typescript/runTypeCheck" in a worker, - * we will run entire "next/lib/verifyTypeScriptSetup" in a worker instead. - */ -function verifyTypeScriptSetup( - dir: string, - intentDirs: string[], - typeCheckPreflight: boolean, - tsconfigPath: string, - disableStaticImages: boolean, - cacheDir: string | undefined, - numWorkers: number | undefined, - enableWorkerThreads: boolean | undefined -) { - const typeCheckWorker = new JestWorker( - require.resolve('../lib/verifyTypeScriptSetup'), - { - numWorkers, - enableWorkerThreads, - maxRetries: 0, - } - ) as JestWorker & { - verifyTypeScriptSetup: typeof import('../lib/verifyTypeScriptSetup').verifyTypeScriptSetup - } - - typeCheckWorker.getStdout().pipe(process.stdout) - typeCheckWorker.getStderr().pipe(process.stderr) - - return typeCheckWorker - .verifyTypeScriptSetup( - dir, - intentDirs, - typeCheckPreflight, - tsconfigPath, - disableStaticImages, - cacheDir - ) - .then((result) => { - typeCheckWorker.end() - return result - }) -} - -function generateClientSsgManifest( - prerenderManifest: PrerenderManifest, - { - buildId, - distDir, - locales, - }: { buildId: string; distDir: string; locales: string[] } -) { - const ssgPages = new Set( - [ - ...Object.entries(prerenderManifest.routes) - // Filter out dynamic routes - .filter(([, { srcRoute }]) => srcRoute == null) - .map(([route]) => normalizeLocalePath(route, locales).pathname), - ...Object.keys(prerenderManifest.dynamicRoutes), - ].sort() - ) - - const clientSsgManifestContent = `self.__SSG_MANIFEST=${devalue( - ssgPages - )};self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()` - - writeFileSync( - path.join(distDir, CLIENT_STATIC_FILES_PATH, buildId, '_ssgManifest.js'), - clientSsgManifestContent - ) -} - -function isTelemetryPlugin(plugin: unknown): plugin is TelemetryPlugin { - return plugin instanceof TelemetryPlugin -} - -function pageToRoute(page: string) { - const routeRegex = getNamedRouteRegex(page) - return { - page, - regex: normalizeRouteRegex(routeRegex.re.source), - routeKeys: routeRegex.routeKeys, - namedRegex: routeRegex.namedRegex, - } -} diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 5cc24805b4e2..e37d1fa55420 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -65,6 +65,209 @@ const fsStat = (file: string) => { loadRequireHook() +export function unique(main: ReadonlyArray, sub: ReadonlyArray): T[] { + return [...new Set([...main, ...sub])] +} + +export function difference( + main: ReadonlyArray | ReadonlySet, + sub: ReadonlyArray | ReadonlySet +): T[] { + const a = new Set(main) + const b = new Set(sub) + return [...a].filter((x) => !b.has(x)) +} + +/** + * Return an array of the items shared by both arrays. + */ +function intersect(main: ReadonlyArray, sub: ReadonlyArray): T[] { + const a = new Set(main) + const b = new Set(sub) + return [...new Set([...a].filter((x) => b.has(x)))] +} + +function sum(a: ReadonlyArray): number { + return a.reduce((size, stat) => size + stat, 0) +} + +function denormalizeAppPagePath(page: string): string { + return page + '/page' +} + +type ComputeFilesGroup = { + files: ReadonlyArray + size: { + total: number + } +} + +type ComputeFilesManifest = { + unique: ComputeFilesGroup + common: ComputeFilesGroup +} + +type ComputeFilesManifestResult = { + router: { + pages: ComputeFilesManifest + app?: ComputeFilesManifest + } + sizes: Map +} + +let cachedBuildManifest: BuildManifest | undefined +let cachedAppBuildManifest: AppBuildManifest | undefined + +let lastCompute: ComputeFilesManifestResult | undefined +let lastComputePageInfo: boolean | undefined + +export async function computeFromManifest( + manifests: { + build: BuildManifest + app?: AppBuildManifest + }, + distPath: string, + gzipSize: boolean = true, + pageInfos?: Map +): Promise { + if ( + Object.is(cachedBuildManifest, manifests.build) && + lastComputePageInfo === !!pageInfos && + Object.is(cachedAppBuildManifest, manifests.app) + ) { + return lastCompute! + } + + // Determine the files that are in pages and app and count them, this will + // tell us if they are unique or common. + + const countBuildFiles = ( + map: Map, + key: string, + manifest: Record> + ) => { + for (const file of manifest[key]) { + if (key === '/_app') { + map.set(file, Infinity) + } else if (map.has(file)) { + map.set(file, map.get(file)! + 1) + } else { + map.set(file, 1) + } + } + } + + const files: { + pages: { + each: Map + expected: number + } + app?: { + each: Map + expected: number + } + } = { + pages: { each: new Map(), expected: 0 }, + } + + for (const key in manifests.build.pages) { + if (pageInfos) { + const pageInfo = pageInfos.get(key) + // don't include AMP pages since they don't rely on shared bundles + // AMP First pages are not under the pageInfos key + if (pageInfo?.isHybridAmp) { + continue + } + } + + files.pages.expected++ + countBuildFiles(files.pages.each, key, manifests.build.pages) + } + + // Collect the build files form the app manifest. + if (manifests.app?.pages) { + files.app = { each: new Map(), expected: 0 } + + for (const key in manifests.app.pages) { + files.app.expected++ + countBuildFiles(files.app.each, key, manifests.app.pages) + } + } + + const getSize = gzipSize ? fsStatGzip : fsStat + const stats = new Map() + + // For all of the files in the pages and app manifests, compute the file size + // at once. + + await Promise.all( + [ + ...new Set([ + ...files.pages.each.keys(), + ...(files.app?.each.keys() ?? []), + ]), + ].map(async (f) => { + try { + // Add the file size to the stats. + stats.set(f, await getSize(path.join(distPath, f))) + } catch {} + }) + ) + + const groupFiles = async (listing: { + each: Map + expected: number + }): Promise => { + const entries = [...listing.each.entries()] + + const shapeGroup = (group: [string, number][]): ComputeFilesGroup => + group.reduce( + (acc, [f]) => { + acc.files.push(f) + + const size = stats.get(f) + if (typeof size === 'number') { + acc.size.total += size + } + + return acc + }, + { + files: [] as string[], + size: { + total: 0, + }, + } + ) + + return { + unique: shapeGroup(entries.filter(([, len]) => len === 1)), + common: shapeGroup( + entries.filter( + ([, len]) => len === listing.expected || len === Infinity + ) + ), + } + } + + lastCompute = { + router: { + pages: await groupFiles(files.pages), + app: files.app ? await groupFiles(files.app) : undefined, + }, + sizes: stats, + } + + cachedBuildManifest = manifests.build + cachedAppBuildManifest = manifests.app + lastComputePageInfo = !!pageInfos + return lastCompute! +} + +export function isMiddlewareFilename(file?: string) { + return file === MIDDLEWARE_FILENAME || file === `src/${MIDDLEWARE_FILENAME}` +} + export interface PageInfo { isHybridAmp?: boolean size: number @@ -526,205 +729,6 @@ export function printCustomRoutes({ } } -type ComputeFilesGroup = { - files: ReadonlyArray - size: { - total: number - } -} - -type ComputeFilesManifest = { - unique: ComputeFilesGroup - common: ComputeFilesGroup -} - -type ComputeFilesManifestResult = { - router: { - pages: ComputeFilesManifest - app?: ComputeFilesManifest - } - sizes: Map -} - -let cachedBuildManifest: BuildManifest | undefined -let cachedAppBuildManifest: AppBuildManifest | undefined - -let lastCompute: ComputeFilesManifestResult | undefined -let lastComputePageInfo: boolean | undefined - -export async function computeFromManifest( - manifests: { - build: BuildManifest - app?: AppBuildManifest - }, - distPath: string, - gzipSize: boolean = true, - pageInfos?: Map -): Promise { - if ( - Object.is(cachedBuildManifest, manifests.build) && - lastComputePageInfo === !!pageInfos && - Object.is(cachedAppBuildManifest, manifests.app) - ) { - return lastCompute! - } - - // Determine the files that are in pages and app and count them, this will - // tell us if they are unique or common. - - const countBuildFiles = ( - map: Map, - key: string, - manifest: Record> - ) => { - for (const file of manifest[key]) { - if (key === '/_app') { - map.set(file, Infinity) - } else if (map.has(file)) { - map.set(file, map.get(file)! + 1) - } else { - map.set(file, 1) - } - } - } - - const files: { - pages: { - each: Map - expected: number - } - app?: { - each: Map - expected: number - } - } = { - pages: { each: new Map(), expected: 0 }, - } - - for (const key in manifests.build.pages) { - if (pageInfos) { - const pageInfo = pageInfos.get(key) - // don't include AMP pages since they don't rely on shared bundles - // AMP First pages are not under the pageInfos key - if (pageInfo?.isHybridAmp) { - continue - } - } - - files.pages.expected++ - countBuildFiles(files.pages.each, key, manifests.build.pages) - } - - // Collect the build files form the app manifest. - if (manifests.app?.pages) { - files.app = { each: new Map(), expected: 0 } - - for (const key in manifests.app.pages) { - files.app.expected++ - countBuildFiles(files.app.each, key, manifests.app.pages) - } - } - - const getSize = gzipSize ? fsStatGzip : fsStat - const stats = new Map() - - // For all of the files in the pages and app manifests, compute the file size - // at once. - - await Promise.all( - [ - ...new Set([ - ...files.pages.each.keys(), - ...(files.app?.each.keys() ?? []), - ]), - ].map(async (f) => { - try { - // Add the file size to the stats. - stats.set(f, await getSize(path.join(distPath, f))) - } catch {} - }) - ) - - const groupFiles = async (listing: { - each: Map - expected: number - }): Promise => { - const entries = [...listing.each.entries()] - - const shapeGroup = (group: [string, number][]): ComputeFilesGroup => - group.reduce( - (acc, [f]) => { - acc.files.push(f) - - const size = stats.get(f) - if (typeof size === 'number') { - acc.size.total += size - } - - return acc - }, - { - files: [] as string[], - size: { - total: 0, - }, - } - ) - - return { - unique: shapeGroup(entries.filter(([, len]) => len === 1)), - common: shapeGroup( - entries.filter( - ([, len]) => len === listing.expected || len === Infinity - ) - ), - } - } - - lastCompute = { - router: { - pages: await groupFiles(files.pages), - app: files.app ? await groupFiles(files.app) : undefined, - }, - sizes: stats, - } - - cachedBuildManifest = manifests.build - cachedAppBuildManifest = manifests.app - lastComputePageInfo = !!pageInfos - return lastCompute! -} - -export function unique(main: ReadonlyArray, sub: ReadonlyArray): T[] { - return [...new Set([...main, ...sub])] -} - -export function difference( - main: ReadonlyArray | ReadonlySet, - sub: ReadonlyArray | ReadonlySet -): T[] { - const a = new Set(main) - const b = new Set(sub) - return [...a].filter((x) => !b.has(x)) -} - -/** - * Return an array of the items shared by both arrays. - */ -function intersect(main: ReadonlyArray, sub: ReadonlyArray): T[] { - const a = new Set(main) - const b = new Set(sub) - return [...new Set([...a].filter((x) => b.has(x)))] -} - -function sum(a: ReadonlyArray): number { - return a.reduce((size, stat) => size + stat, 0) -} - -function denormalizeAppPagePath(page: string): string { - return page + '/page' -} - export async function getJsPageSizeInKb( routerType: ROUTER_TYPE, page: string, @@ -1430,10 +1434,6 @@ export function isMiddlewareFile(file: string) { ) } -export function isMiddlewareFilename(file?: string) { - return file === MIDDLEWARE_FILENAME || file === `src/${MIDDLEWARE_FILENAME}` -} - export function getPossibleMiddlewareFilenames( folder: string, extensions: string[] diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 20eb0ea57fa9..d8d74a636589 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -66,6 +66,17 @@ const NEXT_PROJECT_ROOT = pathJoin(__dirname, '..', '..') const NEXT_PROJECT_ROOT_DIST = pathJoin(NEXT_PROJECT_ROOT, 'dist') const NEXT_PROJECT_ROOT_DIST_CLIENT = pathJoin(NEXT_PROJECT_ROOT_DIST, 'client') +function errorIfEnvConflicted(config: NextConfigComplete, key: string) { + const isPrivateKey = /^(?:NODE_.+)|^(?:__.+)$/i.test(key) + const hasNextRuntimeKey = key === 'NEXT_RUNTIME' + + if (isPrivateKey || hasNextRuntimeKey) { + throw new Error( + `The key "${key}" under "env" in ${config.configFileName} is not allowed. https://nextjs.org/docs/messages/env-key-not-allowed` + ) + } +} + const watchOptions = Object.freeze({ aggregateTimeout: 5, ignored: ['**/.git/**', '**/.next/**'], @@ -2433,14 +2444,3 @@ export default async function getBaseWebpackConfig( return webpackConfig } - -function errorIfEnvConflicted(config: NextConfigComplete, key: string) { - const isPrivateKey = /^(?:NODE_.+)|^(?:__.+)$/i.test(key) - const hasNextRuntimeKey = key === 'NEXT_RUNTIME' - - if (isPrivateKey || hasNextRuntimeKey) { - throw new Error( - `The key "${key}" under "env" in ${config.configFileName} is not allowed. https://nextjs.org/docs/messages/env-key-not-allowed` - ) - } -} diff --git a/packages/next/build/webpack/loaders/next-middleware-wasm-loader.ts b/packages/next/build/webpack/loaders/next-middleware-wasm-loader.ts index 8ed7dd7212dc..c2a2f069e1e7 100644 --- a/packages/next/build/webpack/loaders/next-middleware-wasm-loader.ts +++ b/packages/next/build/webpack/loaders/next-middleware-wasm-loader.ts @@ -1,6 +1,10 @@ import { getModuleBuildInfo } from './get-module-build-info' import crypto from 'crypto' +function sha1(source: string | Buffer) { + return crypto.createHash('sha1').update(source).digest('hex') +} + export default function MiddlewareWasmLoader(this: any, source: Buffer) { const name = `wasm_${sha1(source)}` const filePath = `edge-chunks/${name}.wasm` @@ -11,7 +15,3 @@ export default function MiddlewareWasmLoader(this: any, source: Buffer) { } export const raw = true - -function sha1(source: string | Buffer) { - return crypto.createHash('sha1').update(source).digest('hex') -} diff --git a/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts b/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts index 7c881255afaf..a35d3124da2d 100644 --- a/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts +++ b/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts @@ -30,6 +30,23 @@ function minifyCss(css: string): Promise { .then((res) => res.css) } +function isNodeCreatingLinkElement(node: any) { + const callee = node.callee as any + if (callee.type !== 'Identifier') { + return false + } + const componentNode = node.arguments[0] as any + if (componentNode.type !== 'Literal') { + return false + } + // React has pragma: _jsx. + // Next has pragma: __jsx. + return ( + (callee.name === '_jsx' || callee.name === '__jsx') && + componentNode.value === 'link' + ) +} + export class FontStylesheetGatheringPlugin { compiler?: webpack.Compiler gatheredStylesheets: Array = [] @@ -240,20 +257,3 @@ export class FontStylesheetGatheringPlugin { }) } } - -function isNodeCreatingLinkElement(node: any) { - const callee = node.callee as any - if (callee.type !== 'Identifier') { - return false - } - const componentNode = node.arguments[0] as any - if (componentNode.type !== 'Literal') { - return false - } - // React has pragma: _jsx. - // Next has pragma: __jsx. - return ( - (callee.name === '_jsx' || callee.name === '__jsx') && - componentNode.value === 'link' - ) -} diff --git a/packages/next/build/webpack/plugins/middleware-plugin.ts b/packages/next/build/webpack/plugins/middleware-plugin.ts index 7ebf2bd1bc91..3678b8a960a0 100644 --- a/packages/next/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/build/webpack/plugins/middleware-plugin.ts @@ -51,192 +51,368 @@ const middlewareManifest: MiddlewareManifest = { version: 1, } -export default class MiddlewarePlugin { - dev: boolean +/** + * Checks the value of usingIndirectEval and when it is a set of modules it + * check if any of the modules is actually being used. If the value is + * simply truthy it will return true. + */ +function isUsingIndirectEvalAndUsedByExports(args: { + entryModule: webpack5.Module + moduleGraph: webpack5.ModuleGraph + runtime: any + usingIndirectEval: true | Set + wp: typeof webpack5 +}): boolean { + const { moduleGraph, runtime, entryModule, usingIndirectEval, wp } = args + if (typeof usingIndirectEval === 'boolean') { + return usingIndirectEval + } - constructor({ dev }: { dev: boolean }) { - this.dev = dev + const exportsInfo = moduleGraph.getExportsInfo(entryModule) + for (const exportName of usingIndirectEval) { + if (exportsInfo.getUsed(exportName, runtime) !== wp.UsageState.Unused) { + return true + } } - apply(compiler: webpack5.Compiler) { - compiler.hooks.compilation.tap(NAME, (compilation, params) => { - const { hooks } = params.normalModuleFactory - /** - * This is the static code analysis phase. - */ - const codeAnalyzer = getCodeAnalizer({ - dev: this.dev, - compiler, - compilation, - }) - hooks.parser.for('javascript/auto').tap(NAME, codeAnalyzer) - hooks.parser.for('javascript/dynamic').tap(NAME, codeAnalyzer) - hooks.parser.for('javascript/esm').tap(NAME, codeAnalyzer) + return false +} - /** - * Extract all metadata for the entry points in a Map object. - */ - const metadataByEntry = new Map() - compilation.hooks.afterOptimizeModules.tap( - NAME, - getExtractMetadata({ - compilation, - compiler, - dev: this.dev, - metadataByEntry, - }) +function getEntryFiles(entryFiles: string[], meta: EntryMetadata) { + const files: string[] = [] + if (meta.edgeSSR) { + if (meta.edgeSSR.isServerComponent) { + files.push(`server/${FLIGHT_MANIFEST}.js`) + files.push( + ...entryFiles + .filter( + (file) => + file.startsWith('pages/') && !file.endsWith('.hot-update.js') + ) + .map( + (file) => + 'server/' + + // TODO-APP: seems this should be removed. + file.replace('.js', NEXT_CLIENT_SSR_ENTRY_SUFFIX + '.js') + ) ) + } - /** - * Emit the middleware manifest. - */ - compilation.hooks.processAssets.tap( - { - name: 'NextJsMiddlewareManifest', - stage: (webpack as any).Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, - }, - getCreateAssets({ compilation, metadataByEntry }) - ) - }) + files.push( + `server/${MIDDLEWARE_BUILD_MANIFEST}.js`, + `server/${MIDDLEWARE_REACT_LOADABLE_MANIFEST}.js` + ) } -} -export async function handleWebpackExtenalForEdgeRuntime({ - request, - context, - contextInfo, - getResolve, -}: { - request: string - context: string - contextInfo: any - getResolve: () => any -}) { - if (contextInfo.issuerLayer === 'middleware' && isNodeJsModule(request)) { - // allows user to provide and use their polyfills, as we do with buffer. - try { - await getResolve()(context, request) - } catch { - return `root globalThis.__import_unsupported('${request}')` - } - } + files.push( + ...entryFiles + .filter((file) => !file.endsWith('.hot-update.js')) + .map((file) => 'server/' + file) + ) + return files } -function getCodeAnalizer(params: { - dev: boolean - compiler: webpack5.Compiler +function getCreateAssets(params: { compilation: webpack5.Compilation + metadataByEntry: Map }) { - return (parser: webpack5.javascript.JavascriptParser) => { - const { - dev, - compiler: { webpack: wp }, - compilation, - } = params - const { hooks } = parser - - /** - * For an expression this will check the graph to ensure it is being used - * by exports. Then it will store in the module buildInfo a boolean to - * express that it contains dynamic code and, if it is available, the - * module path that is using it. - */ - const handleExpression = () => { - if (!isInMiddlewareLayer(parser)) { - return + const { compilation, metadataByEntry } = params + return (assets: any) => { + for (const entrypoint of compilation.entrypoints.values()) { + if (!entrypoint.name) { + continue } - wp.optimize.InnerGraph.onUsage(parser.state, (used = true) => { - const buildInfo = getModuleBuildInfo(parser.state.module) - if (buildInfo.usingIndirectEval === true || used === false) { - return - } - - if (!buildInfo.usingIndirectEval || used === true) { - buildInfo.usingIndirectEval = used - return - } + // There should always be metadata for the entrypoint. + const metadata = metadataByEntry.get(entrypoint.name) + const page = + metadata?.edgeMiddleware?.page || + metadata?.edgeSSR?.page || + metadata?.edgeApiFunction?.page + if (!page) { + continue + } - buildInfo.usingIndirectEval = new Set([ - ...Array.from(buildInfo.usingIndirectEval), - ...Array.from(used), - ]) + const { namedRegex } = getNamedMiddlewareRegex(page, { + catchAll: !metadata.edgeSSR && !metadata.edgeApiFunction, }) - } + const regexp = metadata?.edgeMiddleware?.matcherRegexp || namedRegex - /** - * This expression handler allows to wrap a dynamic code expression with a - * function call where we can warn about dynamic code not being allowed - * but actually execute the expression. - */ - const handleWrapExpression = (expr: any) => { - if (!isInMiddlewareLayer(parser)) { - return + const edgeFunctionDefinition: EdgeFunctionDefinition = { + env: Array.from(metadata.env), + files: getEntryFiles(entrypoint.getFiles(), metadata), + name: entrypoint.name, + page: page, + regexp, + wasm: Array.from(metadata.wasmBindings, ([name, filePath]) => ({ + name, + filePath, + })), + assets: Array.from(metadata.assetBindings, ([name, filePath]) => ({ + name, + filePath, + })), } - if (dev) { - const { ConstDependency } = wp.dependencies - const dep1 = new ConstDependency( - '__next_eval__(function() { return ', - expr.range[0] - ) - dep1.loc = expr.loc - parser.state.module.addPresentationalDependency(dep1) - const dep2 = new ConstDependency('})', expr.range[1]) - dep2.loc = expr.loc - parser.state.module.addPresentationalDependency(dep2) + if (metadata.edgeApiFunction || metadata.edgeSSR) { + middlewareManifest.functions[page] = edgeFunctionDefinition + } else { + middlewareManifest.middleware[page] = edgeFunctionDefinition } - - handleExpression() - return true } - /** - * This expression handler allows to wrap a WebAssembly.compile invocation with a - * function call where we can warn about WASM code generation not being allowed - * but actually execute the expression. - */ - const handleWrapWasmCompileExpression = (expr: any) => { - if (!isInMiddlewareLayer(parser)) { - return - } - - if (dev) { - const { ConstDependency } = wp.dependencies - const dep1 = new ConstDependency( - '__next_webassembly_compile__(function() { return ', - expr.range[0] - ) - dep1.loc = expr.loc - parser.state.module.addPresentationalDependency(dep1) - const dep2 = new ConstDependency('})', expr.range[1]) - dep2.loc = expr.loc - parser.state.module.addPresentationalDependency(dep2) - } - - handleExpression() - } + middlewareManifest.sortedMiddleware = getSortedRoutes( + Object.keys(middlewareManifest.middleware) + ) - /** - * This expression handler allows to wrap a WebAssembly.instatiate invocation with a - * function call where we can warn about WASM code generation not being allowed - * but actually execute the expression. - * - * Note that we don't update `usingIndirectEval`, i.e. we don't abort a production build - * since we can't determine statically if the first parameter is a module (legit use) or - * a buffer (dynamic code generation). - */ - const handleWrapWasmInstantiateExpression = (expr: any) => { - if (!isInMiddlewareLayer(parser)) { - return - } + assets[MIDDLEWARE_MANIFEST] = new sources.RawSource( + JSON.stringify(middlewareManifest, null, 2) + ) + } +} - if (dev) { - const { ConstDependency } = wp.dependencies - const dep1 = new ConstDependency( - '__next_webassembly_instantiate__(function() { return ', - expr.range[0] - ) +function buildWebpackError({ + message, + loc, + compilation, + entryModule, + parser, +}: { + message: string + loc?: any + compilation: webpack5.Compilation + entryModule?: webpack5.Module + parser?: webpack5.javascript.JavascriptParser +}) { + const error = new compilation.compiler.webpack.WebpackError(message) + error.name = NAME + const module = entryModule ?? parser?.state.current + if (module) { + error.module = module + } + error.loc = loc + return error +} + +function isInMiddlewareLayer(parser: webpack5.javascript.JavascriptParser) { + return parser.state.module?.layer === 'middleware' +} + +function isInMiddlewareFile(parser: webpack5.javascript.JavascriptParser) { + return ( + parser.state.current?.layer === 'middleware' && + /middleware\.\w+$/.test(parser.state.current?.rawRequest) + ) +} + +function isNullLiteral(expr: any) { + return expr.value === null +} + +function isUndefinedIdentifier(expr: any) { + return expr.name === 'undefined' +} + +function isProcessEnvMemberExpression(memberExpression: any): boolean { + return ( + memberExpression.object?.type === 'Identifier' && + memberExpression.object.name === 'process' && + ((memberExpression.property?.type === 'Literal' && + memberExpression.property.value === 'env') || + (memberExpression.property?.type === 'Identifier' && + memberExpression.property.name === 'env')) + ) +} + +function isNodeJsModule(moduleName: string) { + return require('module').builtinModules.includes(moduleName) +} + +function buildUnsupportedApiError({ + apiName, + loc, + ...rest +}: { + apiName: string + loc: any + compilation: webpack5.Compilation + parser: webpack5.javascript.JavascriptParser +}) { + return buildWebpackError({ + message: `A Node.js API is used (${apiName} at line: ${loc.start.line}) which is not supported in the Edge Runtime. +Learn more: https://nextjs.org/docs/api-reference/edge-runtime`, + loc, + ...rest, + }) +} + +function registerUnsupportedApiHooks( + parser: webpack5.javascript.JavascriptParser, + compilation: webpack5.Compilation +) { + for (const expression of EDGE_UNSUPPORTED_NODE_APIS) { + const warnForUnsupportedApi = (node: any) => { + if (!isInMiddlewareLayer(parser)) { + return + } + compilation.warnings.push( + buildUnsupportedApiError({ + compilation, + parser, + apiName: expression, + ...node, + }) + ) + return true + } + parser.hooks.call.for(expression).tap(NAME, warnForUnsupportedApi) + parser.hooks.expression.for(expression).tap(NAME, warnForUnsupportedApi) + parser.hooks.callMemberChain + .for(expression) + .tap(NAME, warnForUnsupportedApi) + parser.hooks.expressionMemberChain + .for(expression) + .tap(NAME, warnForUnsupportedApi) + } + + const warnForUnsupportedProcessApi = (node: any, [callee]: string[]) => { + if (!isInMiddlewareLayer(parser) || callee === 'env') { + return + } + compilation.warnings.push( + buildUnsupportedApiError({ + compilation, + parser, + apiName: `process.${callee}`, + ...node, + }) + ) + return true + } + + parser.hooks.callMemberChain + .for('process') + .tap(NAME, warnForUnsupportedProcessApi) + parser.hooks.expressionMemberChain + .for('process') + .tap(NAME, warnForUnsupportedProcessApi) +} + +function getCodeAnalyzer(params: { + dev: boolean + compiler: webpack5.Compiler + compilation: webpack5.Compilation +}) { + return (parser: webpack5.javascript.JavascriptParser) => { + const { + dev, + compiler: { webpack: wp }, + compilation, + } = params + const { hooks } = parser + + /** + * For an expression this will check the graph to ensure it is being used + * by exports. Then it will store in the module buildInfo a boolean to + * express that it contains dynamic code and, if it is available, the + * module path that is using it. + */ + const handleExpression = () => { + if (!isInMiddlewareLayer(parser)) { + return + } + + wp.optimize.InnerGraph.onUsage(parser.state, (used = true) => { + const buildInfo = getModuleBuildInfo(parser.state.module) + if (buildInfo.usingIndirectEval === true || used === false) { + return + } + + if (!buildInfo.usingIndirectEval || used === true) { + buildInfo.usingIndirectEval = used + return + } + + buildInfo.usingIndirectEval = new Set([ + ...Array.from(buildInfo.usingIndirectEval), + ...Array.from(used), + ]) + }) + } + + /** + * This expression handler allows to wrap a dynamic code expression with a + * function call where we can warn about dynamic code not being allowed + * but actually execute the expression. + */ + const handleWrapExpression = (expr: any) => { + if (!isInMiddlewareLayer(parser)) { + return + } + + if (dev) { + const { ConstDependency } = wp.dependencies + const dep1 = new ConstDependency( + '__next_eval__(function() { return ', + expr.range[0] + ) + dep1.loc = expr.loc + parser.state.module.addPresentationalDependency(dep1) + const dep2 = new ConstDependency('})', expr.range[1]) + dep2.loc = expr.loc + parser.state.module.addPresentationalDependency(dep2) + } + + handleExpression() + return true + } + + /** + * This expression handler allows to wrap a WebAssembly.compile invocation with a + * function call where we can warn about WASM code generation not being allowed + * but actually execute the expression. + */ + const handleWrapWasmCompileExpression = (expr: any) => { + if (!isInMiddlewareLayer(parser)) { + return + } + + if (dev) { + const { ConstDependency } = wp.dependencies + const dep1 = new ConstDependency( + '__next_webassembly_compile__(function() { return ', + expr.range[0] + ) + dep1.loc = expr.loc + parser.state.module.addPresentationalDependency(dep1) + const dep2 = new ConstDependency('})', expr.range[1]) + dep2.loc = expr.loc + parser.state.module.addPresentationalDependency(dep2) + } + + handleExpression() + } + + /** + * This expression handler allows to wrap a WebAssembly.instatiate invocation with a + * function call where we can warn about WASM code generation not being allowed + * but actually execute the expression. + * + * Note that we don't update `usingIndirectEval`, i.e. we don't abort a production build + * since we can't determine statically if the first parameter is a module (legit use) or + * a buffer (dynamic code generation). + */ + const handleWrapWasmInstantiateExpression = (expr: any) => { + if (!isInMiddlewareLayer(parser)) { + return + } + + if (dev) { + const { ConstDependency } = wp.dependencies + const dep1 = new ConstDependency( + '__next_webassembly_instantiate__(function() { return ', + expr.range[0] + ) dep1.loc = expr.loc parser.state.module.addPresentationalDependency(dep1) const dep2 = new ConstDependency('})', expr.range[1]) @@ -526,249 +702,73 @@ function getExtractMetadata(params: { } } -/** - * Checks the value of usingIndirectEval and when it is a set of modules it - * check if any of the modules is actually being used. If the value is - * simply truthy it will return true. - */ -function isUsingIndirectEvalAndUsedByExports(args: { - entryModule: webpack5.Module - moduleGraph: webpack5.ModuleGraph - runtime: any - usingIndirectEval: true | Set - wp: typeof webpack5 -}): boolean { - const { moduleGraph, runtime, entryModule, usingIndirectEval, wp } = args - if (typeof usingIndirectEval === 'boolean') { - return usingIndirectEval - } +export default class MiddlewarePlugin { + dev: boolean - const exportsInfo = moduleGraph.getExportsInfo(entryModule) - for (const exportName of usingIndirectEval) { - if (exportsInfo.getUsed(exportName, runtime) !== wp.UsageState.Unused) { - return true - } + constructor({ dev }: { dev: boolean }) { + this.dev = dev } - return false -} - -function getCreateAssets(params: { - compilation: webpack5.Compilation - metadataByEntry: Map -}) { - const { compilation, metadataByEntry } = params - return (assets: any) => { - for (const entrypoint of compilation.entrypoints.values()) { - if (!entrypoint.name) { - continue - } - - // There should always be metadata for the entrypoint. - const metadata = metadataByEntry.get(entrypoint.name) - const page = - metadata?.edgeMiddleware?.page || - metadata?.edgeSSR?.page || - metadata?.edgeApiFunction?.page - if (!page) { - continue - } - - const { namedRegex } = getNamedMiddlewareRegex(page, { - catchAll: !metadata.edgeSSR && !metadata.edgeApiFunction, + apply(compiler: webpack5.Compiler) { + compiler.hooks.compilation.tap(NAME, (compilation, params) => { + const { hooks } = params.normalModuleFactory + /** + * This is the static code analysis phase. + */ + const codeAnalyzer = getCodeAnalyzer({ + dev: this.dev, + compiler, + compilation, }) - const regexp = metadata?.edgeMiddleware?.matcherRegexp || namedRegex - - const edgeFunctionDefinition: EdgeFunctionDefinition = { - env: Array.from(metadata.env), - files: getEntryFiles(entrypoint.getFiles(), metadata), - name: entrypoint.name, - page: page, - regexp, - wasm: Array.from(metadata.wasmBindings, ([name, filePath]) => ({ - name, - filePath, - })), - assets: Array.from(metadata.assetBindings, ([name, filePath]) => ({ - name, - filePath, - })), - } - - if (metadata.edgeApiFunction || metadata.edgeSSR) { - middlewareManifest.functions[page] = edgeFunctionDefinition - } else { - middlewareManifest.middleware[page] = edgeFunctionDefinition - } - } - - middlewareManifest.sortedMiddleware = getSortedRoutes( - Object.keys(middlewareManifest.middleware) - ) - - assets[MIDDLEWARE_MANIFEST] = new sources.RawSource( - JSON.stringify(middlewareManifest, null, 2) - ) - } -} - -function getEntryFiles(entryFiles: string[], meta: EntryMetadata) { - const files: string[] = [] - if (meta.edgeSSR) { - if (meta.edgeSSR.isServerComponent) { - files.push(`server/${FLIGHT_MANIFEST}.js`) - files.push( - ...entryFiles - .filter( - (file) => - file.startsWith('pages/') && !file.endsWith('.hot-update.js') - ) - .map( - (file) => - 'server/' + - // TODO-APP: seems this should be removed. - file.replace('.js', NEXT_CLIENT_SSR_ENTRY_SUFFIX + '.js') - ) - ) - } - - files.push( - `server/${MIDDLEWARE_BUILD_MANIFEST}.js`, - `server/${MIDDLEWARE_REACT_LOADABLE_MANIFEST}.js` - ) - } - - files.push( - ...entryFiles - .filter((file) => !file.endsWith('.hot-update.js')) - .map((file) => 'server/' + file) - ) - return files -} + hooks.parser.for('javascript/auto').tap(NAME, codeAnalyzer) + hooks.parser.for('javascript/dynamic').tap(NAME, codeAnalyzer) + hooks.parser.for('javascript/esm').tap(NAME, codeAnalyzer) -function registerUnsupportedApiHooks( - parser: webpack5.javascript.JavascriptParser, - compilation: webpack5.Compilation -) { - for (const expression of EDGE_UNSUPPORTED_NODE_APIS) { - const warnForUnsupportedApi = (node: any) => { - if (!isInMiddlewareLayer(parser)) { - return - } - compilation.warnings.push( - buildUnsupportedApiError({ + /** + * Extract all metadata for the entry points in a Map object. + */ + const metadataByEntry = new Map() + compilation.hooks.afterOptimizeModules.tap( + NAME, + getExtractMetadata({ compilation, - parser, - apiName: expression, - ...node, + compiler, + dev: this.dev, + metadataByEntry, }) ) - return true - } - parser.hooks.call.for(expression).tap(NAME, warnForUnsupportedApi) - parser.hooks.expression.for(expression).tap(NAME, warnForUnsupportedApi) - parser.hooks.callMemberChain - .for(expression) - .tap(NAME, warnForUnsupportedApi) - parser.hooks.expressionMemberChain - .for(expression) - .tap(NAME, warnForUnsupportedApi) - } - const warnForUnsupportedProcessApi = (node: any, [callee]: string[]) => { - if (!isInMiddlewareLayer(parser) || callee === 'env') { - return - } - compilation.warnings.push( - buildUnsupportedApiError({ - compilation, - parser, - apiName: `process.${callee}`, - ...node, - }) - ) - return true + /** + * Emit the middleware manifest. + */ + compilation.hooks.processAssets.tap( + { + name: 'NextJsMiddlewareManifest', + stage: (webpack as any).Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + }, + getCreateAssets({ compilation, metadataByEntry }) + ) + }) } - - parser.hooks.callMemberChain - .for('process') - .tap(NAME, warnForUnsupportedProcessApi) - parser.hooks.expressionMemberChain - .for('process') - .tap(NAME, warnForUnsupportedProcessApi) } -function buildUnsupportedApiError({ - apiName, - loc, - ...rest -}: { - apiName: string - loc: any - compilation: webpack5.Compilation - parser: webpack5.javascript.JavascriptParser -}) { - return buildWebpackError({ - message: `A Node.js API is used (${apiName} at line: ${loc.start.line}) which is not supported in the Edge Runtime. -Learn more: https://nextjs.org/docs/api-reference/edge-runtime`, - loc, - ...rest, - }) -} - -function buildWebpackError({ - message, - loc, - compilation, - entryModule, - parser, +export async function handleWebpackExtenalForEdgeRuntime({ + request, + context, + contextInfo, + getResolve, }: { - message: string - loc?: any - compilation: webpack5.Compilation - entryModule?: webpack5.Module - parser?: webpack5.javascript.JavascriptParser + request: string + context: string + contextInfo: any + getResolve: () => any }) { - const error = new compilation.compiler.webpack.WebpackError(message) - error.name = NAME - const module = entryModule ?? parser?.state.current - if (module) { - error.module = module + if (contextInfo.issuerLayer === 'middleware' && isNodeJsModule(request)) { + // allows user to provide and use their polyfills, as we do with buffer. + try { + await getResolve()(context, request) + } catch { + return `root globalThis.__import_unsupported('${request}')` + } } - error.loc = loc - return error -} - -function isInMiddlewareLayer(parser: webpack5.javascript.JavascriptParser) { - return parser.state.module?.layer === 'middleware' -} - -function isInMiddlewareFile(parser: webpack5.javascript.JavascriptParser) { - return ( - parser.state.current?.layer === 'middleware' && - /middleware\.\w+$/.test(parser.state.current?.rawRequest) - ) -} - -function isNullLiteral(expr: any) { - return expr.value === null -} - -function isUndefinedIdentifier(expr: any) { - return expr.name === 'undefined' -} - -function isProcessEnvMemberExpression(memberExpression: any): boolean { - return ( - memberExpression.object?.type === 'Identifier' && - memberExpression.object.name === 'process' && - ((memberExpression.property?.type === 'Literal' && - memberExpression.property.value === 'env') || - (memberExpression.property?.type === 'Identifier' && - memberExpression.property.name === 'env')) - ) -} - -function isNodeJsModule(moduleName: string) { - return require('module').builtinModules.includes(moduleName) } diff --git a/packages/next/build/webpack/plugins/telemetry-plugin.ts b/packages/next/build/webpack/plugins/telemetry-plugin.ts index 16526b6f7419..5844a64408d2 100644 --- a/packages/next/build/webpack/plugins/telemetry-plugin.ts +++ b/packages/next/build/webpack/plugins/telemetry-plugin.ts @@ -93,6 +93,37 @@ const BUILD_FEATURES: Array = [ const ELIMINATED_PACKAGES = new Set() +/** + * Determine if there is a feature of interest in the specified 'module'. + */ +function findFeatureInModule(module: Module): Feature | undefined { + if (module.type !== 'javascript/auto') { + return + } + for (const [feature, path] of FEATURE_MODULE_MAP) { + if (module.identifier().replace(/\\/g, '/').endsWith(path)) { + return feature + } + } +} + +/** + * Find unique origin modules in the specified 'connections', which possibly + * contains more than one connection for a module due to different types of + * dependency. + */ +function findUniqueOriginModulesInConnections( + connections: Connection[] +): Set { + const originModules = new Set() + for (const connection of connections) { + if (!originModules.has(connection.originModule)) { + originModules.add(connection.originModule) + } + } + return originModules +} + /** * Plugin that queries the ModuleGraph to look for modules that correspond to * certain features (e.g. next/image and next/script) and record how many times @@ -162,34 +193,3 @@ export class TelemetryPlugin implements webpack.WebpackPluginInstance { return Array.from(ELIMINATED_PACKAGES) } } - -/** - * Determine if there is a feature of interest in the specified 'module'. - */ -function findFeatureInModule(module: Module): Feature | undefined { - if (module.type !== 'javascript/auto') { - return - } - for (const [feature, path] of FEATURE_MODULE_MAP) { - if (module.identifier().replace(/\\/g, '/').endsWith(path)) { - return feature - } - } -} - -/** - * Find unique origin modules in the specified 'connections', which possibly - * contains more than one connection for a module due to different types of - * dependency. - */ -function findUniqueOriginModulesInConnections( - connections: Connection[] -): Set { - const originModules = new Set() - for (const connection of connections) { - if (!originModules.has(connection.originModule)) { - originModules.add(connection.originModule) - } - } - return originModules -} diff --git a/packages/next/cli/next-info.ts b/packages/next/cli/next-info.ts index c9c468b26ff5..2377b715b26b 100755 --- a/packages/next/cli/next-info.ts +++ b/packages/next/cli/next-info.ts @@ -9,6 +9,25 @@ import { printAndExit } from '../server/lib/utils' import { cliCommand } from '../lib/commands' import isError from '../lib/is-error' +function getPackageVersion(packageName: string) { + try { + return require(`${packageName}/package.json`).version + } catch { + return 'N/A' + } +} + +function getBinaryVersion(binaryName: string) { + try { + return childProcess + .execFileSync(binaryName, ['--version']) + .toString() + .trim() + } catch { + return 'N/A' + } +} + const nextInfo: cliCommand = async (argv) => { const validArgs: arg.Spec = { // Types @@ -92,22 +111,3 @@ const nextInfo: cliCommand = async (argv) => { } export { nextInfo } - -function getPackageVersion(packageName: string) { - try { - return require(`${packageName}/package.json`).version - } catch { - return 'N/A' - } -} - -function getBinaryVersion(binaryName: string) { - try { - return childProcess - .execFileSync(binaryName, ['--version']) - .toString() - .trim() - } catch { - return 'N/A' - } -} diff --git a/packages/next/client/components/hot-reloader.client.tsx b/packages/next/client/components/hot-reloader.client.tsx index c8b9f18d5b59..f96d3b681d85 100644 --- a/packages/next/client/components/hot-reloader.client.tsx +++ b/packages/next/client/components/hot-reloader.client.tsx @@ -100,6 +100,23 @@ function canApplyUpdates() { // } // } +function performFullReload(err: any, sendMessage: any) { + const stackTrace = + err && + ((err.stack && err.stack.split('\n').slice(0, 5).join('\n')) || + err.message || + err + '') + + sendMessage( + JSON.stringify({ + event: 'client-full-reload', + stackTrace, + }) + ) + + window.location.reload() +} + // Attempt to update code on the fly, fall back to a hard reload. function tryApplyUpdates(onHotUpdateSuccess: any, sendMessage: any) { // @ts-expect-error module.hot exists @@ -169,23 +186,6 @@ function tryApplyUpdates(onHotUpdateSuccess: any, sendMessage: any) { ) } -function performFullReload(err: any, sendMessage: any) { - const stackTrace = - err && - ((err.stack && err.stack.split('\n').slice(0, 5).join('\n')) || - err.message || - err + '') - - sendMessage( - JSON.stringify({ - event: 'client-full-reload', - stackTrace, - }) - ) - - window.location.reload() -} - function processMessage( e: any, sendMessage: any, diff --git a/packages/next/client/dev/error-overlay/websocket.ts b/packages/next/client/dev/error-overlay/websocket.ts index 46f81de4c4a7..fe3e47a7802e 100644 --- a/packages/next/client/dev/error-overlay/websocket.ts +++ b/packages/next/client/dev/error-overlay/websocket.ts @@ -32,16 +32,34 @@ export function connectHMR(options: { options.timeout = 5 * 1000 } - init() + function init() { + if (source) source.close() - let timer = setInterval(function () { - if (Date.now() - lastActivity > options.timeout) { - handleDisconnect() + function handleOnline() { + if (options.log) console.log('[HMR] connected') + lastActivity = Date.now() } - }, options.timeout / 2) - function init() { - if (source) source.close() + function handleMessage(event: any) { + lastActivity = Date.now() + + eventCallbacks.forEach((cb) => { + cb(event) + }) + } + + let timer: NodeJS.Timeout + function handleDisconnect() { + clearInterval(timer) + source.close() + setTimeout(init, options.timeout) + } + timer = setInterval(function () { + if (Date.now() - lastActivity > options.timeout) { + handleDisconnect() + } + }, options.timeout / 2) + const { hostname, port } = location const protocol = getSocketProtocol(options.assetPrefix || '') const assetPrefix = options.assetPrefix.replace(/^\/+/, '') @@ -60,22 +78,5 @@ export function connectHMR(options: { source.onmessage = handleMessage } - function handleOnline() { - if (options.log) console.log('[HMR] connected') - lastActivity = Date.now() - } - - function handleMessage(event: any) { - lastActivity = Date.now() - - eventCallbacks.forEach((cb) => { - cb(event) - }) - } - - function handleDisconnect() { - clearInterval(timer) - source.close() - setTimeout(init, options.timeout) - } + init() } diff --git a/packages/next/client/future/image.tsx b/packages/next/client/future/image.tsx index c305631d0147..81cc5e45d964 100644 --- a/packages/next/client/future/image.tsx +++ b/packages/next/client/future/image.tsx @@ -429,6 +429,73 @@ const ImageElement = ({ ) } +function defaultLoader({ + config, + src, + width, + quality, +}: ImageLoaderPropsWithConfig): string { + if (process.env.NODE_ENV !== 'production') { + const missingValues = [] + + // these should always be provided but make sure they are + if (!src) missingValues.push('src') + if (!width) missingValues.push('width') + + if (missingValues.length > 0) { + throw new Error( + `Next Image Optimization requires ${missingValues.join( + ', ' + )} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( + { src, width, quality } + )}` + ) + } + + if (src.startsWith('//')) { + throw new Error( + `Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)` + ) + } + + if ( + !src.startsWith('/') && + (config.domains || experimentalRemotePatterns) + ) { + let parsedSrc: URL + try { + parsedSrc = new URL(src) + } catch (err) { + console.error(err) + throw new Error( + `Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)` + ) + } + + if (process.env.NODE_ENV !== 'test') { + // We use dynamic require because this should only error in development + const { hasMatch } = require('../../shared/lib/match-remote-pattern') + if (!hasMatch(config.domains, experimentalRemotePatterns, parsedSrc)) { + throw new Error( + `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + + `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` + ) + } + } + } + } + + if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) { + // Special case to make svg serve as-is to avoid proxying + // through the built-in Image Optimization API. + return src + } + + return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${ + quality || 75 + }` +} + export default function Image({ src, sizes, @@ -803,70 +870,3 @@ export default function Image({ ) } - -function defaultLoader({ - config, - src, - width, - quality, -}: ImageLoaderPropsWithConfig): string { - if (process.env.NODE_ENV !== 'production') { - const missingValues = [] - - // these should always be provided but make sure they are - if (!src) missingValues.push('src') - if (!width) missingValues.push('width') - - if (missingValues.length > 0) { - throw new Error( - `Next Image Optimization requires ${missingValues.join( - ', ' - )} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( - { src, width, quality } - )}` - ) - } - - if (src.startsWith('//')) { - throw new Error( - `Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)` - ) - } - - if ( - !src.startsWith('/') && - (config.domains || experimentalRemotePatterns) - ) { - let parsedSrc: URL - try { - parsedSrc = new URL(src) - } catch (err) { - console.error(err) - throw new Error( - `Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)` - ) - } - - if (process.env.NODE_ENV !== 'test') { - // We use dynamic require because this should only error in development - const { hasMatch } = require('../../shared/lib/match-remote-pattern') - if (!hasMatch(config.domains, experimentalRemotePatterns, parsedSrc)) { - throw new Error( - `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + - `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` - ) - } - } - } - } - - if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) { - // Special case to make svg serve as-is to avoid proxying - // through the built-in Image Optimization API. - return src - } - - return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${ - quality || 75 - }` -} diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 20e17aff73e3..f4338203a4e3 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -18,6 +18,10 @@ import { ImageConfigContext } from '../shared/lib/image-config-context' import { warnOnce } from '../shared/lib/utils' import { normalizePathTrailingSlash } from './normalize-trailing-slash' +function normalizeSrc(src: string): string { + return src[0] === '/' ? src.slice(1) : src +} + const { experimentalRemotePatterns = [], experimentalUnoptimized } = (process.env.__NEXT_IMAGE_OPTS as any) || {} const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete @@ -55,6 +59,122 @@ type ImageLoaderPropsWithConfig = ImageLoaderProps & { config: Readonly } +function imgixLoader({ + config, + src, + width, + quality, +}: ImageLoaderPropsWithConfig): string { + // Demo: https://static.imgix.net/daisy.png?auto=format&fit=max&w=300 + const url = new URL(`${config.path}${normalizeSrc(src)}`) + const params = url.searchParams + + // auto params can be combined with comma separation, or reiteration + params.set('auto', params.getAll('auto').join(',') || 'format') + params.set('fit', params.get('fit') || 'max') + params.set('w', params.get('w') || width.toString()) + + if (quality) { + params.set('q', quality.toString()) + } + + return url.href +} + +function akamaiLoader({ + config, + src, + width, +}: ImageLoaderPropsWithConfig): string { + return `${config.path}${normalizeSrc(src)}?imwidth=${width}` +} + +function cloudinaryLoader({ + config, + src, + width, + quality, +}: ImageLoaderPropsWithConfig): string { + // Demo: https://res.cloudinary.com/demo/image/upload/w_300,c_limit,q_auto/turtles.jpg + const params = ['f_auto', 'c_limit', 'w_' + width, 'q_' + (quality || 'auto')] + const paramsString = params.join(',') + '/' + return `${config.path}${paramsString}${normalizeSrc(src)}` +} + +function customLoader({ src }: ImageLoaderProps): string { + throw new Error( + `Image with src "${src}" is missing "loader" prop.` + + `\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader` + ) +} + +function defaultLoader({ + config, + src, + width, + quality, +}: ImageLoaderPropsWithConfig): string { + if (process.env.NODE_ENV !== 'production') { + const missingValues = [] + + // these should always be provided but make sure they are + if (!src) missingValues.push('src') + if (!width) missingValues.push('width') + + if (missingValues.length > 0) { + throw new Error( + `Next Image Optimization requires ${missingValues.join( + ', ' + )} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( + { src, width, quality } + )}` + ) + } + + if (src.startsWith('//')) { + throw new Error( + `Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)` + ) + } + + if ( + !src.startsWith('/') && + (config.domains || experimentalRemotePatterns) + ) { + let parsedSrc: URL + try { + parsedSrc = new URL(src) + } catch (err) { + console.error(err) + throw new Error( + `Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)` + ) + } + + if (process.env.NODE_ENV !== 'test') { + // We use dynamic require because this should only error in development + const { hasMatch } = require('../shared/lib/match-remote-pattern') + if (!hasMatch(config.domains, experimentalRemotePatterns, parsedSrc)) { + throw new Error( + `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + + `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` + ) + } + } + } + } + + if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) { + // Special case to make svg serve as-is to avoid proxying + // through the built-in Image Optimization API. + return src + } + + return `${normalizePathTrailingSlash(config.path)}?url=${encodeURIComponent( + src + )}&w=${width}&q=${quality || 75}` +} + const loaders = new Map< LoaderValue, (props: ImageLoaderPropsWithConfig) => string @@ -959,123 +1079,3 @@ export default function Image({ ) } - -function normalizeSrc(src: string): string { - return src[0] === '/' ? src.slice(1) : src -} - -function imgixLoader({ - config, - src, - width, - quality, -}: ImageLoaderPropsWithConfig): string { - // Demo: https://static.imgix.net/daisy.png?auto=format&fit=max&w=300 - const url = new URL(`${config.path}${normalizeSrc(src)}`) - const params = url.searchParams - - // auto params can be combined with comma separation, or reiteration - params.set('auto', params.getAll('auto').join(',') || 'format') - params.set('fit', params.get('fit') || 'max') - params.set('w', params.get('w') || width.toString()) - - if (quality) { - params.set('q', quality.toString()) - } - - return url.href -} - -function akamaiLoader({ - config, - src, - width, -}: ImageLoaderPropsWithConfig): string { - return `${config.path}${normalizeSrc(src)}?imwidth=${width}` -} - -function cloudinaryLoader({ - config, - src, - width, - quality, -}: ImageLoaderPropsWithConfig): string { - // Demo: https://res.cloudinary.com/demo/image/upload/w_300,c_limit,q_auto/turtles.jpg - const params = ['f_auto', 'c_limit', 'w_' + width, 'q_' + (quality || 'auto')] - const paramsString = params.join(',') + '/' - return `${config.path}${paramsString}${normalizeSrc(src)}` -} - -function customLoader({ src }: ImageLoaderProps): string { - throw new Error( - `Image with src "${src}" is missing "loader" prop.` + - `\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader` - ) -} - -function defaultLoader({ - config, - src, - width, - quality, -}: ImageLoaderPropsWithConfig): string { - if (process.env.NODE_ENV !== 'production') { - const missingValues = [] - - // these should always be provided but make sure they are - if (!src) missingValues.push('src') - if (!width) missingValues.push('width') - - if (missingValues.length > 0) { - throw new Error( - `Next Image Optimization requires ${missingValues.join( - ', ' - )} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( - { src, width, quality } - )}` - ) - } - - if (src.startsWith('//')) { - throw new Error( - `Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)` - ) - } - - if ( - !src.startsWith('/') && - (config.domains || experimentalRemotePatterns) - ) { - let parsedSrc: URL - try { - parsedSrc = new URL(src) - } catch (err) { - console.error(err) - throw new Error( - `Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)` - ) - } - - if (process.env.NODE_ENV !== 'test') { - // We use dynamic require because this should only error in development - const { hasMatch } = require('../shared/lib/match-remote-pattern') - if (!hasMatch(config.domains, experimentalRemotePatterns, parsedSrc)) { - throw new Error( - `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + - `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` - ) - } - } - } - } - - if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) { - // Special case to make svg serve as-is to avoid proxying - // through the built-in Image Optimization API. - return src - } - - return `${normalizePathTrailingSlash(config.path)}?url=${encodeURIComponent( - src - )}&w=${width}&q=${quality || 75}` -} diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index 9b5086028300..a4d62af4256a 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -289,193 +289,190 @@ export async function initialize(opts: { webpackHMR?: any } = {}): Promise<{ return { assetPrefix: prefix } } -const wrapApp = - (App: AppComponent) => - (wrappedAppProps: Record): JSX.Element => { - const appProps: AppProps = { - ...wrappedAppProps, - Component: CachedComponent, - err: initialData.err, - router, - } - return {renderApp(App, appProps)} +let RSCComponent: (props: any) => JSX.Element +if (process.env.__NEXT_RSC) { + const getCacheKey = () => { + const { pathname, search } = location + return pathname + search } -export async function hydrate(opts?: { beforeRender?: () => Promise }) { - let initialErr = initialData.err - - try { - const appEntrypoint = await pageLoader.routeLoader.whenEntrypoint('/_app') - if ('error' in appEntrypoint) { - throw appEntrypoint.error - } + const { + createFromFetch, + createFromReadableStream, + } = require('next/dist/compiled/react-server-dom-webpack') + const encoder = new TextEncoder() - const { component: app, exports: mod } = appEntrypoint - CachedApp = app as AppComponent - if (mod && mod.reportWebVitals) { - onPerfEntry = ({ - id, - name, - startTime, - value, - duration, - entryType, - entries, - }: any): void => { - // Combines timestamp with random number for unique ID - const uniqueID: string = `${Date.now()}-${ - Math.floor(Math.random() * (9e12 - 1)) + 1e12 - }` - let perfStartEntry: string | undefined + let initialServerDataBuffer: string[] | undefined = undefined + let initialServerDataWriter: ReadableStreamDefaultController | undefined = + undefined + let initialServerDataLoaded = false + let initialServerDataFlushed = false - if (entries && entries.length) { - perfStartEntry = entries[0].startTime - } + function nextServerDataCallback(seg: [number, string, string]) { + if (seg[0] === 0) { + initialServerDataBuffer = [] + } else { + if (!initialServerDataBuffer) + throw new Error('Unexpected server data: missing bootstrap script.') - const webVitals: NextWebVitalsMetric = { - id: id || uniqueID, - name, - startTime: startTime || perfStartEntry, - value: value == null ? duration : value, - label: - entryType === 'mark' || entryType === 'measure' - ? 'custom' - : 'web-vital', - } - mod.reportWebVitals(webVitals) + if (initialServerDataWriter) { + initialServerDataWriter.enqueue(encoder.encode(seg[2])) + } else { + initialServerDataBuffer.push(seg[2]) } } + } - const pageEntrypoint = - // The dev server fails to serve script assets when there's a hydration - // error, so we need to skip waiting for the entrypoint. - process.env.NODE_ENV === 'development' && initialData.err - ? { error: initialData.err } - : await pageLoader.routeLoader.whenEntrypoint(initialData.page) - if ('error' in pageEntrypoint) { - throw pageEntrypoint.error + // There might be race conditions between `nextServerDataRegisterWriter` and + // `DOMContentLoaded`. The former will be called when React starts to hydrate + // the root, the latter will be called when the DOM is fully loaded. + // For streaming, the former is called first due to partial hydration. + // For non-streaming, the latter can be called first. + // Hence, we use two variables `initialServerDataLoaded` and + // `initialServerDataFlushed` to make sure the writer will be closed and + // `initialServerDataBuffer` will be cleared in the right time. + function nextServerDataRegisterWriter(ctr: ReadableStreamDefaultController) { + if (initialServerDataBuffer) { + initialServerDataBuffer.forEach((val) => { + ctr.enqueue(encoder.encode(val)) + }) + if (initialServerDataLoaded && !initialServerDataFlushed) { + ctr.close() + initialServerDataFlushed = true + initialServerDataBuffer = undefined + } } - CachedComponent = pageEntrypoint.component - if (process.env.NODE_ENV !== 'production') { - const { isValidElementType } = require('next/dist/compiled/react-is') - if (!isValidElementType(CachedComponent)) { - throw new Error( - `The default export is not a React Component in page: "${initialData.page}"` - ) - } + initialServerDataWriter = ctr + } + + // When `DOMContentLoaded`, we can close all pending writers to finish hydration. + const DOMContentLoaded = function () { + if (initialServerDataWriter && !initialServerDataFlushed) { + initialServerDataWriter.close() + initialServerDataFlushed = true + initialServerDataBuffer = undefined } - } catch (error) { - // This catches errors like throwing in the top level of a module - initialErr = getProperError(error) + initialServerDataLoaded = true + } + // It's possible that the DOM is already loaded. + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', DOMContentLoaded, false) + } else { + DOMContentLoaded() } - if (process.env.NODE_ENV === 'development') { - const { - getServerError, - } = require('next/dist/compiled/@next/react-dev-overlay/dist/client') - // Server-side runtime errors need to be re-thrown on the client-side so - // that the overlay is rendered. - if (initialErr) { - if (initialErr === initialData.err) { - setTimeout(() => { - let error - try { - // Generate a new error object. We `throw` it because some browsers - // will set the `stack` when thrown, and we want to ensure ours is - // not overridden when we re-throw it below. - throw new Error(initialErr!.message) - } catch (e) { - error = e as Error - } + const nextServerDataLoadingGlobal = ((self as any).__next_s = + (self as any).__next_s || []) + nextServerDataLoadingGlobal.forEach(nextServerDataCallback) + nextServerDataLoadingGlobal.push = nextServerDataCallback - error.name = initialErr!.name - error.stack = initialErr!.stack - throw getServerError(error, initialErr!.source) - }) - } - // We replaced the server-side error with a client-side error, and should - // no longer rewrite the stack trace to a Node error. - else { - setTimeout(() => { - throw initialErr - }) - } - } + function createResponseCache() { + return new Map() } + const rscCache = createResponseCache() - if (window.__NEXT_PRELOADREADY) { - await window.__NEXT_PRELOADREADY(initialData.dynamicIds) + function fetchFlight(href: string, props?: any) { + const url = new URL(href, location.origin) + const searchParams = url.searchParams + searchParams.append('__flight__', '1') + if (props) { + searchParams.append('__props__', JSON.stringify(props)) + } + return fetch(url.toString()) } - router = createRouter(initialData.page, initialData.query, asPath, { - initialProps: initialData.props, - pageLoader, - App: CachedApp, - Component: CachedComponent, - wrapApp, - err: initialErr, - isFallback: Boolean(initialData.isFallback), - subscription: (info, App, scroll) => - render( - Object.assign< - {}, - Omit, - Pick - >({}, info, { - App, - scroll, - }) as RenderRouteInfo - ), - locale: initialData.locale, - locales: initialData.locales, - defaultLocale, - domainLocales: initialData.domainLocales, - isPreview: initialData.isPreview, - isRsc: initialData.rsc, - }) + function useServerResponse(cacheKey: string, serialized?: string) { + let response = rscCache.get(cacheKey) + if (response) return response - initialMatchesMiddleware = await router._initialMatchesMiddlewarePromise + if (initialServerDataBuffer) { + const readable = new ReadableStream({ + start(controller) { + nextServerDataRegisterWriter(controller) + }, + }) + response = createFromReadableStream(readable) + } else { + if (serialized) { + const readable = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(serialized)) + controller.close() + }, + }) + response = createFromReadableStream(readable) + } else { + response = createFromFetch(fetchFlight(getCacheKey())) + } + } - const renderCtx: RenderRouteInfo = { - App: CachedApp, - initial: true, - Component: CachedComponent, - props: initialData.props, - err: initialErr, + rscCache.set(cacheKey, response) + return response } - if (opts?.beforeRender) { - await opts.beforeRender() + const ServerRoot = ({ + cacheKey, + serialized, + }: { + cacheKey: string + serialized?: string + }) => { + React.useEffect(() => { + rscCache.delete(cacheKey) + }) + const response = useServerResponse(cacheKey, serialized) + return response.readRoot() } - render(renderCtx) + RSCComponent = (props: any) => { + const cacheKey = getCacheKey() + const { __flight__ } = props + return + } } -async function render(renderingProps: RenderRouteInfo): Promise { - if (renderingProps.err) { - await renderError(renderingProps) - return - } +function renderApp(App: AppComponent, appProps: AppProps) { + return +} - try { - await doRender(renderingProps) - } catch (err) { - const renderErr = getProperError(err) - // bubble up cancelation errors - if ((renderErr as Error & { cancelled?: boolean }).cancelled) { - throw renderErr - } +function AppContainer({ + children, +}: React.PropsWithChildren<{}>): React.ReactElement { + return ( + + // TODO: Fix disabled eslint rule + // eslint-disable-next-line @typescript-eslint/no-use-before-define + renderError({ App: CachedApp, err: error }).catch((err) => + console.error('Error rendering page: ', err) + ) + } + > + + + + {children} + + + + + ) +} - if (process.env.NODE_ENV === 'development') { - // Ensure this error is displayed in the overlay in development - setTimeout(() => { - throw renderErr - }) +const wrapApp = + (App: AppComponent) => + (wrappedAppProps: Record): JSX.Element => { + const appProps: AppProps = { + ...wrappedAppProps, + Component: CachedComponent, + err: initialData.err, + router, } - await renderError({ ...renderingProps, err: renderErr }) + return {renderApp(App, appProps)} } -} // This method handles all runtime and debug errors. // 404 and 500 errors are special kind of errors @@ -492,6 +489,8 @@ function renderError(renderErrorProps: RenderErrorProps): Promise { // We need to render an empty so that the `` can // render itself. + // TODO: Fix disabled eslint rule + // eslint-disable-next-line @typescript-eslint/no-use-before-define return doRender({ App: () => null, props: {}, @@ -546,6 +545,8 @@ function renderError(renderErrorProps: RenderErrorProps): Promise { ? renderErrorProps.props : loadGetInitialProps(App, appCtx) ).then((initProps) => + // TODO: Fix disabled eslint rule + // eslint-disable-next-line @typescript-eslint/no-use-before-define doRender({ ...renderErrorProps, err, @@ -557,41 +558,23 @@ function renderError(renderErrorProps: RenderErrorProps): Promise { }) } +// Dummy component that we render as a child of Root so that we can +// toggle the correct styles before the page is rendered. +function Head({ callback }: { callback: () => void }): null { + // We use `useLayoutEffect` to guarantee the callback is executed + // as soon as React flushes the update. + React.useLayoutEffect(() => callback(), [callback]) + return null +} + let reactRoot: any = null // On initial render a hydrate should always happen let shouldHydrate: boolean = true -function renderReactElement( - domEl: HTMLElement, - fn: (cb: () => void) => JSX.Element -): void { - // mark start of hydrate/render - if (ST) { - performance.mark('beforeRender') - } - - const reactEl = fn(shouldHydrate ? markHydrateComplete : markRenderComplete) - if (process.env.__NEXT_REACT_ROOT) { - if (!reactRoot) { - // Unlike with createRoot, you don't need a separate root.render() call here - reactRoot = ReactDOM.hydrateRoot(domEl, reactEl) - // TODO: Remove shouldHydrate variable when React 18 is stable as it can depend on `reactRoot` existing - shouldHydrate = false - } else { - const startTransition = (React as any).startTransition - startTransition(() => { - reactRoot.render(reactEl) - }) - } - } else { - // The check for `.hydrate` is there to support React alternatives like preact - if (shouldHydrate) { - ReactDOM.hydrate(reactEl, domEl) - shouldHydrate = false - } else { - ReactDOM.render(reactEl, domEl) - } - } +function clearMarks(): void { + ;['beforeRender', 'afterHydrate', 'afterRender', 'routeChange'].forEach( + (mark) => performance.clearMarks(mark) + ) } function markHydrateComplete(): void { @@ -642,181 +625,69 @@ function markRenderComplete(): void { ) } -function clearMarks(): void { - ;['beforeRender', 'afterHydrate', 'afterRender', 'routeChange'].forEach( - (mark) => performance.clearMarks(mark) - ) -} - -function AppContainer({ - children, -}: React.PropsWithChildren<{}>): React.ReactElement { - return ( - - renderError({ App: CachedApp, err: error }).catch((err) => - console.error('Error rendering page: ', err) - ) - } - > - - - - {children} - - - - - ) -} - -function renderApp(App: AppComponent, appProps: AppProps) { - return -} - -let RSCComponent: (props: any) => JSX.Element -if (process.env.__NEXT_RSC) { - const getCacheKey = () => { - const { pathname, search } = location - return pathname + search +function renderReactElement( + domEl: HTMLElement, + fn: (cb: () => void) => JSX.Element +): void { + // mark start of hydrate/render + if (ST) { + performance.mark('beforeRender') } - const { - createFromFetch, - createFromReadableStream, - } = require('next/dist/compiled/react-server-dom-webpack') - const encoder = new TextEncoder() - - let initialServerDataBuffer: string[] | undefined = undefined - let initialServerDataWriter: ReadableStreamDefaultController | undefined = - undefined - let initialServerDataLoaded = false - let initialServerDataFlushed = false - - function nextServerDataCallback(seg: [number, string, string]) { - if (seg[0] === 0) { - initialServerDataBuffer = [] + const reactEl = fn(shouldHydrate ? markHydrateComplete : markRenderComplete) + if (process.env.__NEXT_REACT_ROOT) { + if (!reactRoot) { + // Unlike with createRoot, you don't need a separate root.render() call here + reactRoot = ReactDOM.hydrateRoot(domEl, reactEl) + // TODO: Remove shouldHydrate variable when React 18 is stable as it can depend on `reactRoot` existing + shouldHydrate = false } else { - if (!initialServerDataBuffer) - throw new Error('Unexpected server data: missing bootstrap script.') - - if (initialServerDataWriter) { - initialServerDataWriter.enqueue(encoder.encode(seg[2])) - } else { - initialServerDataBuffer.push(seg[2]) - } - } - } - - // There might be race conditions between `nextServerDataRegisterWriter` and - // `DOMContentLoaded`. The former will be called when React starts to hydrate - // the root, the latter will be called when the DOM is fully loaded. - // For streaming, the former is called first due to partial hydration. - // For non-streaming, the latter can be called first. - // Hence, we use two variables `initialServerDataLoaded` and - // `initialServerDataFlushed` to make sure the writer will be closed and - // `initialServerDataBuffer` will be cleared in the right time. - function nextServerDataRegisterWriter(ctr: ReadableStreamDefaultController) { - if (initialServerDataBuffer) { - initialServerDataBuffer.forEach((val) => { - ctr.enqueue(encoder.encode(val)) - }) - if (initialServerDataLoaded && !initialServerDataFlushed) { - ctr.close() - initialServerDataFlushed = true - initialServerDataBuffer = undefined - } - } - - initialServerDataWriter = ctr - } - - // When `DOMContentLoaded`, we can close all pending writers to finish hydration. - const DOMContentLoaded = function () { - if (initialServerDataWriter && !initialServerDataFlushed) { - initialServerDataWriter.close() - initialServerDataFlushed = true - initialServerDataBuffer = undefined - } - initialServerDataLoaded = true - } - // It's possible that the DOM is already loaded. - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', DOMContentLoaded, false) - } else { - DOMContentLoaded() - } - - const nextServerDataLoadingGlobal = ((self as any).__next_s = - (self as any).__next_s || []) - nextServerDataLoadingGlobal.forEach(nextServerDataCallback) - nextServerDataLoadingGlobal.push = nextServerDataCallback - - function createResponseCache() { - return new Map() - } - const rscCache = createResponseCache() - - function fetchFlight(href: string, props?: any) { - const url = new URL(href, location.origin) - const searchParams = url.searchParams - searchParams.append('__flight__', '1') - if (props) { - searchParams.append('__props__', JSON.stringify(props)) - } - return fetch(url.toString()) - } - - function useServerResponse(cacheKey: string, serialized?: string) { - let response = rscCache.get(cacheKey) - if (response) return response - - if (initialServerDataBuffer) { - const readable = new ReadableStream({ - start(controller) { - nextServerDataRegisterWriter(controller) - }, + const startTransition = (React as any).startTransition + startTransition(() => { + reactRoot.render(reactEl) }) - response = createFromReadableStream(readable) - } else { - if (serialized) { - const readable = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(serialized)) - controller.close() - }, - }) - response = createFromReadableStream(readable) - } else { - response = createFromFetch(fetchFlight(getCacheKey())) - } } - - rscCache.set(cacheKey, response) - return response + } else { + // The check for `.hydrate` is there to support React alternatives like preact + if (shouldHydrate) { + ReactDOM.hydrate(reactEl, domEl) + shouldHydrate = false + } else { + ReactDOM.render(reactEl, domEl) + } } +} - const ServerRoot = ({ - cacheKey, - serialized, - }: { - cacheKey: string - serialized?: string - }) => { +function Root({ + callbacks, + children, +}: React.PropsWithChildren<{ + callbacks: Array<() => void> +}>): React.ReactElement { + // We use `useLayoutEffect` to guarantee the callbacks are executed + // as soon as React flushes the update + React.useLayoutEffect( + () => callbacks.forEach((callback) => callback()), + [callbacks] + ) + // We should ask to measure the Web Vitals after rendering completes so we + // don't cause any hydration delay: + React.useEffect(() => { + measureWebVitals(onPerfEntry) + }, []) + + if (process.env.__NEXT_TEST_MODE) { + // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { - rscCache.delete(cacheKey) - }) - const response = useServerResponse(cacheKey, serialized) - return response.readRoot() - } + window.__NEXT_HYDRATED = true - RSCComponent = (props: any) => { - const cacheKey = getCacheKey() - const { __flight__ } = props - return + if (window.__NEXT_HYDRATED_CB) { + window.__NEXT_HYDRATED_CB() + } + }, []) } + + return children as React.ReactElement } function doRender(input: RenderRouteInfo): Promise { @@ -999,43 +870,178 @@ function doRender(input: RenderRouteInfo): Promise { return renderPromise } -function Root({ - callbacks, - children, -}: React.PropsWithChildren<{ - callbacks: Array<() => void> -}>): React.ReactElement { - // We use `useLayoutEffect` to guarantee the callbacks are executed - // as soon as React flushes the update - React.useLayoutEffect( - () => callbacks.forEach((callback) => callback()), - [callbacks] - ) - // We should ask to measure the Web Vitals after rendering completes so we - // don't cause any hydration delay: - React.useEffect(() => { - measureWebVitals(onPerfEntry) - }, []) +async function render(renderingProps: RenderRouteInfo): Promise { + if (renderingProps.err) { + await renderError(renderingProps) + return + } - if (process.env.__NEXT_TEST_MODE) { - // eslint-disable-next-line react-hooks/rules-of-hooks - React.useEffect(() => { - window.__NEXT_HYDRATED = true + try { + await doRender(renderingProps) + } catch (err) { + const renderErr = getProperError(err) + // bubble up cancelation errors + if ((renderErr as Error & { cancelled?: boolean }).cancelled) { + throw renderErr + } - if (window.__NEXT_HYDRATED_CB) { - window.__NEXT_HYDRATED_CB() + if (process.env.NODE_ENV === 'development') { + // Ensure this error is displayed in the overlay in development + setTimeout(() => { + throw renderErr + }) + } + await renderError({ ...renderingProps, err: renderErr }) + } +} + +export async function hydrate(opts?: { beforeRender?: () => Promise }) { + let initialErr = initialData.err + + try { + const appEntrypoint = await pageLoader.routeLoader.whenEntrypoint('/_app') + if ('error' in appEntrypoint) { + throw appEntrypoint.error + } + + const { component: app, exports: mod } = appEntrypoint + CachedApp = app as AppComponent + if (mod && mod.reportWebVitals) { + onPerfEntry = ({ + id, + name, + startTime, + value, + duration, + entryType, + entries, + }: any): void => { + // Combines timestamp with random number for unique ID + const uniqueID: string = `${Date.now()}-${ + Math.floor(Math.random() * (9e12 - 1)) + 1e12 + }` + let perfStartEntry: string | undefined + + if (entries && entries.length) { + perfStartEntry = entries[0].startTime + } + + const webVitals: NextWebVitalsMetric = { + id: id || uniqueID, + name, + startTime: startTime || perfStartEntry, + value: value == null ? duration : value, + label: + entryType === 'mark' || entryType === 'measure' + ? 'custom' + : 'web-vital', + } + mod.reportWebVitals(webVitals) } - }, []) + } + + const pageEntrypoint = + // The dev server fails to serve script assets when there's a hydration + // error, so we need to skip waiting for the entrypoint. + process.env.NODE_ENV === 'development' && initialData.err + ? { error: initialData.err } + : await pageLoader.routeLoader.whenEntrypoint(initialData.page) + if ('error' in pageEntrypoint) { + throw pageEntrypoint.error + } + CachedComponent = pageEntrypoint.component + + if (process.env.NODE_ENV !== 'production') { + const { isValidElementType } = require('next/dist/compiled/react-is') + if (!isValidElementType(CachedComponent)) { + throw new Error( + `The default export is not a React Component in page: "${initialData.page}"` + ) + } + } + } catch (error) { + // This catches errors like throwing in the top level of a module + initialErr = getProperError(error) } - return children as React.ReactElement -} + if (process.env.NODE_ENV === 'development') { + const { + getServerError, + } = require('next/dist/compiled/@next/react-dev-overlay/dist/client') + // Server-side runtime errors need to be re-thrown on the client-side so + // that the overlay is rendered. + if (initialErr) { + if (initialErr === initialData.err) { + setTimeout(() => { + let error + try { + // Generate a new error object. We `throw` it because some browsers + // will set the `stack` when thrown, and we want to ensure ours is + // not overridden when we re-throw it below. + throw new Error(initialErr!.message) + } catch (e) { + error = e as Error + } -// Dummy component that we render as a child of Root so that we can -// toggle the correct styles before the page is rendered. -function Head({ callback }: { callback: () => void }): null { - // We use `useLayoutEffect` to guarantee the callback is executed - // as soon as React flushes the update. - React.useLayoutEffect(() => callback(), [callback]) - return null + error.name = initialErr!.name + error.stack = initialErr!.stack + throw getServerError(error, initialErr!.source) + }) + } + // We replaced the server-side error with a client-side error, and should + // no longer rewrite the stack trace to a Node error. + else { + setTimeout(() => { + throw initialErr + }) + } + } + } + + if (window.__NEXT_PRELOADREADY) { + await window.__NEXT_PRELOADREADY(initialData.dynamicIds) + } + + router = createRouter(initialData.page, initialData.query, asPath, { + initialProps: initialData.props, + pageLoader, + App: CachedApp, + Component: CachedComponent, + wrapApp, + err: initialErr, + isFallback: Boolean(initialData.isFallback), + subscription: (info, App, scroll) => + render( + Object.assign< + {}, + Omit, + Pick + >({}, info, { + App, + scroll, + }) as RenderRouteInfo + ), + locale: initialData.locale, + locales: initialData.locales, + defaultLocale, + domainLocales: initialData.domainLocales, + isPreview: initialData.isPreview, + isRsc: initialData.rsc, + }) + + initialMatchesMiddleware = await router._initialMatchesMiddlewarePromise + + const renderCtx: RenderRouteInfo = { + App: CachedApp, + initial: true, + Component: CachedComponent, + props: initialData.props, + err: initialErr, + } + + if (opts?.beforeRender) { + await opts.beforeRender() + } + + render(renderCtx) } diff --git a/packages/next/client/router.ts b/packages/next/client/router.ts index db3190f72fea..f739b291209b 100644 --- a/packages/next/client/router.ts +++ b/packages/next/client/router.ts @@ -71,6 +71,16 @@ Object.defineProperty(singletonRouter, 'events', { }, }) +function getRouter(): Router { + if (!singletonRouter.router) { + const message = + 'No router instance found.\n' + + 'You should only use "next/router" on the client side of your app.\n' + throw new Error(message) + } + return singletonRouter.router +} + urlPropertyFields.forEach((field: string) => { // Here we need to use Object.defineProperty because we need to return // the property assigned to the actual router @@ -113,16 +123,6 @@ routerEvents.forEach((event) => { }) }) -function getRouter(): Router { - if (!singletonRouter.router) { - const message = - 'No router instance found.\n' + - 'You should only use "next/router" on the client side of your app.\n' - throw new Error(message) - } - return singletonRouter.router -} - // Export the singletonRouter and this is the public API. export default singletonRouter as SingletonRouter diff --git a/packages/next/client/use-intersection.tsx b/packages/next/client/use-intersection.tsx index f13dd22f0b5c..8d3ecd1bece5 100644 --- a/packages/next/client/use-intersection.tsx +++ b/packages/next/client/use-intersection.tsx @@ -25,6 +25,74 @@ type Observer = { const hasIntersectionObserver = typeof IntersectionObserver === 'function' +const observers = new Map() +const idList: Identifier[] = [] + +function createObserver(options: UseIntersectionObserverInit): Observer { + const id = { + root: options.root || null, + margin: options.rootMargin || '', + } + const existing = idList.find( + (obj) => obj.root === id.root && obj.margin === id.margin + ) + let instance: Observer | undefined + + if (existing) { + instance = observers.get(existing) + if (instance) { + return instance + } + } + + const elements = new Map() + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + const callback = elements.get(entry.target) + const isVisible = entry.isIntersecting || entry.intersectionRatio > 0 + if (callback && isVisible) { + callback(isVisible) + } + }) + }, options) + instance = { + id, + observer, + elements, + } + + idList.push(id) + observers.set(id, instance) + return instance +} + +function observe( + element: Element, + callback: ObserveCallback, + options: UseIntersectionObserverInit +): () => void { + const { id, observer, elements } = createObserver(options) + elements.set(element, callback) + + observer.observe(element) + return function unobserve(): void { + elements.delete(element) + observer.unobserve(element) + + // Destroy observer when there's nothing left to watch: + if (elements.size === 0) { + observer.disconnect() + observers.delete(id) + const index = idList.findIndex( + (obj) => obj.root === id.root && obj.margin === id.margin + ) + if (index > -1) { + idList.splice(index, 1) + } + } + } +} + export function useIntersection({ rootRef, rootMargin, @@ -71,71 +139,3 @@ export function useIntersection({ return [setElement, visible, resetVisible] } - -const observers = new Map() -const idList: Identifier[] = [] - -function observe( - element: Element, - callback: ObserveCallback, - options: UseIntersectionObserverInit -): () => void { - const { id, observer, elements } = createObserver(options) - elements.set(element, callback) - - observer.observe(element) - return function unobserve(): void { - elements.delete(element) - observer.unobserve(element) - - // Destroy observer when there's nothing left to watch: - if (elements.size === 0) { - observer.disconnect() - observers.delete(id) - const index = idList.findIndex( - (obj) => obj.root === id.root && obj.margin === id.margin - ) - if (index > -1) { - idList.splice(index, 1) - } - } - } -} - -function createObserver(options: UseIntersectionObserverInit): Observer { - const id = { - root: options.root || null, - margin: options.rootMargin || '', - } - const existing = idList.find( - (obj) => obj.root === id.root && obj.margin === id.margin - ) - let instance: Observer | undefined - - if (existing) { - instance = observers.get(existing) - if (instance) { - return instance - } - } - - const elements = new Map() - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - const callback = elements.get(entry.target) - const isVisible = entry.isIntersecting || entry.intersectionRatio > 0 - if (callback && isVisible) { - callback(isVisible) - } - }) - }, options) - instance = { - id, - observer, - elements, - } - - idList.push(id) - observers.set(id, instance) - return instance -} diff --git a/packages/next/lib/patch-incorrect-lockfile.ts b/packages/next/lib/patch-incorrect-lockfile.ts index b440dbd4c0ec..f14f067c7dc6 100644 --- a/packages/next/lib/patch-incorrect-lockfile.ts +++ b/packages/next/lib/patch-incorrect-lockfile.ts @@ -8,6 +8,42 @@ import nextPkgJson from 'next/package.json' import type { UnwrapPromise } from './coalesced-function' import { isCI } from '../telemetry/ci-info' +let registry: string | undefined + +async function fetchPkgInfo(pkg: string) { + if (!registry) { + try { + const output = execSync('npm config get registry').toString().trim() + if (output.startsWith('http')) { + registry = output + + if (!registry.endsWith('/')) { + registry += '/' + } + } + } catch (_) { + registry = `https://registry.npmjs.org/` + } + } + const res = await fetch(`${registry}${pkg}`) + + if (!res.ok) { + throw new Error( + `Failed to fetch registry info for ${pkg}, got status ${res.status}` + ) + } + const data = await res.json() + const versionData = data.versions[nextPkgJson.version] + + return { + os: versionData.os, + cpu: versionData.cpu, + engines: versionData.engines, + tarball: versionData.dist.tarball, + integrity: versionData.dist.integrity, + } +} + /** * Attempts to patch npm package-lock.json when it * fails to include optionalDependencies for other platforms @@ -151,38 +187,3 @@ export async function patchIncorrectLockfile(dir: string) { console.error(err) } } -let registry: string | undefined - -async function fetchPkgInfo(pkg: string) { - if (!registry) { - try { - const output = execSync('npm config get registry').toString().trim() - if (output.startsWith('http')) { - registry = output - - if (!registry.endsWith('/')) { - registry += '/' - } - } - } catch (_) { - registry = `https://registry.npmjs.org/` - } - } - const res = await fetch(`${registry}${pkg}`) - - if (!res.ok) { - throw new Error( - `Failed to fetch registry info for ${pkg}, got status ${res.status}` - ) - } - const data = await res.json() - const versionData = data.versions[nextPkgJson.version] - - return { - os: versionData.os, - cpu: versionData.cpu, - engines: versionData.engines, - tarball: versionData.dist.tarball, - integrity: versionData.dist.integrity, - } -} diff --git a/packages/next/lib/try-to-parse-path.ts b/packages/next/lib/try-to-parse-path.ts index fac03f16db8d..699d27c434e8 100644 --- a/packages/next/lib/try-to-parse-path.ts +++ b/packages/next/lib/try-to-parse-path.ts @@ -11,6 +11,29 @@ interface ParseResult { tokens?: Token[] } +/** + * If there is an error show our error link but still show original error or + * a formatted one if we can + */ +function reportError({ route, parsedPath }: ParseResult, err: any) { + let errMatches + if (isError(err) && (errMatches = err.message.match(/at (\d{0,})/))) { + const position = parseInt(errMatches[1], 10) + console.error( + `\nError parsing \`${route}\` ` + + `https://nextjs.org/docs/messages/invalid-route-source\n` + + `Reason: ${err.message}\n\n` + + ` ${parsedPath}\n` + + ` ${new Array(position).fill(' ').join('')}^\n` + ) + } else { + console.error( + `\nError parsing ${route} https://nextjs.org/docs/messages/invalid-route-source`, + err + ) + } +} + /** * Attempts to parse a given route with `path-to-regexp` and returns an object * with the result. Whenever an error happens on parse, it will print an error @@ -40,26 +63,3 @@ export function tryToParsePath( return result } - -/** - * If there is an error show our error link but still show original error or - * a formatted one if we can - */ -function reportError({ route, parsedPath }: ParseResult, err: any) { - let errMatches - if (isError(err) && (errMatches = err.message.match(/at (\d{0,})/))) { - const position = parseInt(errMatches[1], 10) - console.error( - `\nError parsing \`${route}\` ` + - `https://nextjs.org/docs/messages/invalid-route-source\n` + - `Reason: ${err.message}\n\n` + - ` ${parsedPath}\n` + - ` ${new Array(position).fill(' ').join('')}^\n` - ) - } else { - console.error( - `\nError parsing ${route} https://nextjs.org/docs/messages/invalid-route-source`, - err - ) - } -} diff --git a/packages/next/pages/_document.tsx b/packages/next/pages/_document.tsx index f875a34d1d0a..b0a4a65f38a7 100644 --- a/packages/next/pages/_document.tsx +++ b/packages/next/pages/_document.tsx @@ -133,6 +133,226 @@ function AmpStyles({ ) } +function getDynamicChunks( + context: HtmlProps, + props: OriginProps, + files: DocumentFiles +) { + const { + dynamicImports, + assetPrefix, + isDevelopment, + devOnlyCacheBusterQueryString, + disableOptimizedLoading, + crossOrigin, + } = context + + return dynamicImports.map((file) => { + if (!file.endsWith('.js') || files.allFiles.includes(file)) return null + + return ( +