From cd8c4c34e0e48a82f21098b1a4c11354f0afb39f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 25 Sep 2024 10:52:54 -0700 Subject: [PATCH] refactor: added more strict app segment config validation --- packages/next/package.json | 3 +- .../analysis/get-page-static-info.test.ts | 3 +- .../build/analysis/get-page-static-info.ts | 795 +++++++++--------- packages/next/src/build/entries.ts | 69 +- packages/next/src/build/index.ts | 21 +- .../app}/app-segment-config.ts | 41 +- .../app/app-segments.ts} | 37 +- .../middleware/middleware-config.ts | 147 ++++ .../pages/pages-segment-config.ts | 107 +++ packages/next/src/build/utils.ts | 37 +- .../webpack/plugins/middleware-plugin.ts | 4 +- .../components/work-async-storage.external.ts | 2 +- .../src/compiled/zod-validation-error/LICENSE | 9 + .../compiled/zod-validation-error/index.js | 1 + .../zod-validation-error/package.json | 1 + .../server/async-storage/with-work-store.ts | 2 +- .../src/server/dev/hot-reloader-webpack.ts | 36 +- .../src/server/dev/static-paths-worker.ts | 2 +- packages/next/src/server/lib/patch-fetch.ts | 2 +- .../server/route-modules/app-route/module.ts | 4 +- packages/next/src/server/web/types.ts | 2 +- packages/next/src/shared/lib/zod.ts | 10 + packages/next/taskfile.js | 9 + packages/next/types/$$compiled.internal.d.ts | 5 + pnpm-lock.yaml | 13 + .../app/legacy-runtime-config/page.js | 13 - .../index.test.ts | 36 - .../next.config.js | 1 - .../app-invalid-revalidate.test.ts | 6 +- .../test/index.test.js | 39 +- .../telemetry/test/page-features.test.js | 2 +- .../index.test.ts | 2 +- .../edge-config-validations/index.test.ts | 2 +- .../middleware-typescript/app/middleware.ts | 1 - test/unit/parse-page-static-info.test.ts | 57 +- 35 files changed, 899 insertions(+), 622 deletions(-) rename test/unit/get-page-static-infos.test.ts => packages/next/src/build/analysis/get-page-static-info.test.ts (91%) rename packages/next/src/build/{app-segments => segment-config/app}/app-segment-config.ts (71%) rename packages/next/src/build/{app-segments/collect-app-segments.ts => segment-config/app/app-segments.ts} (79%) create mode 100644 packages/next/src/build/segment-config/middleware/middleware-config.ts create mode 100644 packages/next/src/build/segment-config/pages/pages-segment-config.ts create mode 100644 packages/next/src/compiled/zod-validation-error/LICENSE create mode 100644 packages/next/src/compiled/zod-validation-error/index.js create mode 100644 packages/next/src/compiled/zod-validation-error/package.json delete mode 100644 test/e2e/app-dir-legacy-edge-runtime-config/app/legacy-runtime-config/page.js delete mode 100644 test/e2e/app-dir-legacy-edge-runtime-config/index.test.ts delete mode 100644 test/e2e/app-dir-legacy-edge-runtime-config/next.config.js diff --git a/packages/next/package.json b/packages/next/package.json index d0552565830e60..bf34956fb8e032 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -329,7 +329,8 @@ "webpack-sources1": "npm:webpack-sources@1.4.3", "webpack-sources3": "npm:webpack-sources@3.2.3", "ws": "8.2.3", - "zod": "3.22.3" + "zod": "3.22.3", + "zod-validation-error": "3.4.0" }, "keywords": [ "react", diff --git a/test/unit/get-page-static-infos.test.ts b/packages/next/src/build/analysis/get-page-static-info.test.ts similarity index 91% rename from test/unit/get-page-static-infos.test.ts rename to packages/next/src/build/analysis/get-page-static-info.test.ts index 4cdbeb8d8edab9..2cda3f69f30fde 100644 --- a/test/unit/get-page-static-infos.test.ts +++ b/packages/next/src/build/analysis/get-page-static-info.test.ts @@ -1,5 +1,4 @@ -/* eslint-env jest */ -import { getMiddlewareMatchers } from 'next/dist/build/analysis/get-page-static-info' +import { getMiddlewareMatchers } from './get-page-static-info' describe('get-page-static-infos', () => { describe('getMiddlewareMatchers', () => { diff --git a/packages/next/src/build/analysis/get-page-static-info.ts b/packages/next/src/build/analysis/get-page-static-info.ts index fa836500fc09bf..55b3aec4fe0653 100644 --- a/packages/next/src/build/analysis/get-page-static-info.ts +++ b/packages/next/src/build/analysis/get-page-static-info.ts @@ -1,10 +1,8 @@ import type { NextConfig } from '../../server/config-shared' -import type { Middleware, RouteHas } from '../../lib/load-custom-routes' +import type { RouteHas } from '../../lib/load-custom-routes' import { promises as fs } from 'fs' import LRUCache from 'next/dist/compiled/lru-cache' -import picomatch from 'next/dist/compiled/picomatch' -import type { ServerRuntime } from '../../types' import { extractExportedConstValue, UnsupportedValueError, @@ -12,60 +10,98 @@ import { import { parseModule } from './parse-module' import * as Log from '../output/log' import { SERVER_RUNTIME } from '../../lib/constants' -import { checkCustomRoutes } from '../../lib/load-custom-routes' import { tryToParsePath } from '../../lib/try-to-parse-path' import { isAPIRoute } from '../../lib/is-api-route' import { isEdgeRuntime } from '../../lib/is-edge-runtime' import { RSC_MODULE_TYPES } from '../../shared/lib/constants' import type { RSCMeta } from '../webpack/loaders/get-module-build-info' import { PAGE_TYPES } from '../../lib/page-types' +import { + AppSegmentConfigSchemaKeys, + parseAppSegmentConfig, + type AppSegmentConfig, +} from '../segment-config/app/app-segment-config' +import { reportZodError } from '../../shared/lib/zod' +import { + PagesSegmentConfigSchemaKeys, + parsePagesSegmentConfig, + type PagesSegmentConfig, + type PagesSegmentConfigConfig, +} from '../segment-config/pages/pages-segment-config' +import { + MiddlewareConfigInputSchema, + SourceSchema, + type MiddlewareConfigMatcherInput, +} from '../segment-config/middleware/middleware-config' +import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' +import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' -// TODO: migrate preferredRegion here -// Don't forget to update the next-types-plugin file as well -const AUTHORIZED_EXTRA_ROUTER_PROPS = ['maxDuration'] +const PARSE_PATTERN = + /(? { - matchers?: MiddlewareMatcher[] +export type MiddlewareMatcher = { + regexp: string + locale?: false + has?: RouteHas[] + missing?: RouteHas[] + originalSource: string } -/** - * This interface represents the exported `config` object in a `middleware.ts` file. - * - * Read more: [Next.js Docs: Middleware `config` object](https://nextjs.org/docs/app/api-reference/file-conventions/middleware#config-object-optional) - */ -export interface MiddlewareConfig { +export type MiddlewareConfig = { /** - * Read more: [Next.js Docs: Middleware `matcher`](https://nextjs.org/docs/app/api-reference/file-conventions/middleware#matcher), + * The matcher for the middleware. Read more: [Next.js Docs: Middleware `matcher`](https://nextjs.org/docs/app/api-reference/file-conventions/middleware#matcher), * [Next.js Docs: Middleware matching paths](https://nextjs.org/docs/app/building-your-application/routing/middleware#matching-paths) */ - matcher?: string | string[] | MiddlewareMatcher[] - unstable_allowDynamicGlobs?: string[] - regions?: string[] | string -} + matchers?: MiddlewareMatcher[] -export interface MiddlewareMatcher { - regexp: string - locale?: false - has?: RouteHas[] - missing?: RouteHas[] - originalSource: string + /** + * The regions that the middleware should run in. + */ + regions?: string | string[] + + /** + * A glob, or an array of globs, ignoring dynamic code evaluation for specific + * files. The globs are relative to your application root folder. + */ + unstable_allowDynamic?: string[] } -export interface PageStaticInfo { - runtime?: ServerRuntime - preferredRegion?: string | string[] +export interface AppPageStaticInfo { + type: PAGE_TYPES.APP ssg?: boolean ssr?: boolean rsc?: RSCModuleType generateStaticParams?: boolean generateSitemaps?: boolean generateImageMetadata?: boolean - middleware?: MiddlewareConfigParsed - amp?: boolean | 'hybrid' - extraConfig?: Record + middleware?: MiddlewareConfig + config: Omit | undefined + runtime: AppSegmentConfig['runtime'] | undefined + preferredRegion: AppSegmentConfig['preferredRegion'] | undefined + maxDuration: number | undefined } +export interface PagesPageStaticInfo { + type: PAGE_TYPES.PAGES + getStaticProps?: boolean + getServerSideProps?: boolean + rsc?: RSCModuleType + generateStaticParams?: boolean + generateSitemaps?: boolean + generateImageMetadata?: boolean + middleware?: MiddlewareConfig + config: + | (Omit & { + config?: Omit + }) + | undefined + runtime: PagesSegmentConfig['runtime'] | undefined + preferredRegion: PagesSegmentConfigConfig['regions'] | undefined + maxDuration: number | undefined +} + +export type PageStaticInfo = AppPageStaticInfo | PagesPageStaticInfo + const CLIENT_MODULE_LABEL = /\/\* __next_internal_client_entry_do_not_use__ ([^ ]*) (cjs|auto) \*\// @@ -113,25 +149,6 @@ export function getRSCModuleInformation( } } -const warnedInvalidValueMap = { - runtime: new Map(), - preferredRegion: new Map(), -} as const -function warnInvalidValue( - pageFilePath: string, - key: keyof typeof warnedInvalidValueMap, - message: string -): void { - if (warnedInvalidValueMap[key].has(pageFilePath)) return - - Log.warn( - `Next.js can't recognize the exported \`${key}\` field in "${pageFilePath}" as ${message}.` + - '\n' + - 'The default runtime will be used instead.' - ) - - warnedInvalidValueMap[key].set(pageFilePath, true) -} /** * Receives a parsed AST from SWC and checks if it belongs to a module that * requires a runtime to be specified. Those are: @@ -140,18 +157,17 @@ function warnInvalidValue( * - Modules with `export const runtime = ...` */ function checkExports( - swcAST: any, - pageFilePath: string + ast: any, + expectedExports: string[], + page: string ): { - ssr: boolean - ssg: boolean - runtime?: string - preferredRegion?: string | string[] - generateImageMetadata: boolean - generateSitemaps: boolean - generateStaticParams: boolean - extraProperties?: Set + getStaticProps?: boolean + getServerSideProps?: boolean + generateImageMetadata?: boolean + generateSitemaps?: boolean + generateStaticParams?: boolean directives?: Set + exports?: Set } { const exportsSet = new Set([ 'getStaticProps', @@ -160,149 +176,123 @@ function checkExports( 'generateSitemaps', 'generateStaticParams', ]) - if (Array.isArray(swcAST?.body)) { - try { - let runtime: string | undefined - let preferredRegion: string | string[] | undefined - let ssr: boolean = false - let ssg: boolean = false - let generateImageMetadata: boolean = false - let generateSitemaps: boolean = false - let generateStaticParams = false - let extraProperties = new Set() - let directives = new Set() - let hasLeadingNonDirectiveNode = false - - for (const node of swcAST.body) { - // There should be no non-string literals nodes before directives - if ( - node.type === 'ExpressionStatement' && - node.expression.type === 'StringLiteral' - ) { - if (!hasLeadingNonDirectiveNode) { - const directive = node.expression.value - if (CLIENT_DIRECTIVE === directive) { - directives.add('client') - } - if (SERVER_ACTION_DIRECTIVE === directive) { - directives.add('server') - } + if (!Array.isArray(ast?.body)) { + return {} + } + + try { + let getStaticProps: boolean = false + let getServerSideProps: boolean = false + let generateImageMetadata: boolean = false + let generateSitemaps: boolean = false + let generateStaticParams = false + let exports = new Set() + let directives = new Set() + let hasLeadingNonDirectiveNode = false + + for (const node of ast.body) { + // There should be no non-string literals nodes before directives + if ( + node.type === 'ExpressionStatement' && + node.expression.type === 'StringLiteral' + ) { + if (!hasLeadingNonDirectiveNode) { + const directive = node.expression.value + if (CLIENT_DIRECTIVE === directive) { + directives.add('client') + } + if (SERVER_ACTION_DIRECTIVE === directive) { + directives.add('server') } - } else { - hasLeadingNonDirectiveNode = true } - if ( - node.type === 'ExportDeclaration' && - node.declaration?.type === 'VariableDeclaration' - ) { - for (const declaration of node.declaration?.declarations) { - if (declaration.id.value === 'runtime') { - runtime = declaration.init.value - } else if (declaration.id.value === 'preferredRegion') { - if (declaration.init.type === 'ArrayExpression') { - const elements: string[] = [] - for (const element of declaration.init.elements) { - const { expression } = element - if (expression.type !== 'StringLiteral') { - continue - } - elements.push(expression.value) - } - preferredRegion = elements - } else { - preferredRegion = declaration.init.value - } - } else { - extraProperties.add(declaration.id.value) - } + } else { + hasLeadingNonDirectiveNode = true + } + if ( + node.type === 'ExportDeclaration' && + node.declaration?.type === 'VariableDeclaration' + ) { + for (const declaration of node.declaration?.declarations) { + if (expectedExports.includes(declaration.id.value)) { + exports.add(declaration.id.value) } } + } - if ( - node.type === 'ExportDeclaration' && - node.declaration?.type === 'FunctionDeclaration' && - exportsSet.has(node.declaration.identifier?.value) - ) { - const id = node.declaration.identifier.value - ssg = id === 'getStaticProps' - ssr = id === 'getServerSideProps' + if ( + node.type === 'ExportDeclaration' && + node.declaration?.type === 'FunctionDeclaration' && + exportsSet.has(node.declaration.identifier?.value) + ) { + const id = node.declaration.identifier.value + getServerSideProps = id === 'getServerSideProps' + getStaticProps = id === 'getStaticProps' + generateImageMetadata = id === 'generateImageMetadata' + generateSitemaps = id === 'generateSitemaps' + generateStaticParams = id === 'generateStaticParams' + } + + if ( + node.type === 'ExportDeclaration' && + node.declaration?.type === 'VariableDeclaration' + ) { + const id = node.declaration?.declarations[0]?.id.value + if (exportsSet.has(id)) { + getServerSideProps = id === 'getServerSideProps' + getStaticProps = id === 'getStaticProps' generateImageMetadata = id === 'generateImageMetadata' generateSitemaps = id === 'generateSitemaps' generateStaticParams = id === 'generateStaticParams' } + } - if ( - node.type === 'ExportDeclaration' && - node.declaration?.type === 'VariableDeclaration' - ) { - const id = node.declaration?.declarations[0]?.id.value - if (exportsSet.has(id)) { - ssg = id === 'getStaticProps' - ssr = id === 'getServerSideProps' - generateImageMetadata = id === 'generateImageMetadata' - generateSitemaps = id === 'generateSitemaps' - generateStaticParams = id === 'generateStaticParams' - } - } + if (node.type === 'ExportNamedDeclaration') { + for (const specifier of node.specifiers) { + if ( + specifier.type === 'ExportSpecifier' && + specifier.orig?.type === 'Identifier' + ) { + const value = specifier.orig.value - if (node.type === 'ExportNamedDeclaration') { - const values = node.specifiers.map( - (specifier: any) => - specifier.type === 'ExportSpecifier' && - specifier.orig?.type === 'Identifier' && - specifier.orig?.value - ) - - for (const value of values) { - if (!ssg && value === 'getStaticProps') ssg = true - if (!ssr && value === 'getServerSideProps') ssr = true - if (!generateImageMetadata && value === 'generateImageMetadata') + if (!getServerSideProps && value === 'getServerSideProps') { + getServerSideProps = true + } + if (!getStaticProps && value === 'getStaticProps') { + getStaticProps = true + } + if (!generateImageMetadata && value === 'generateImageMetadata') { generateImageMetadata = true - if (!generateSitemaps && value === 'generateSitemaps') + } + if (!generateSitemaps && value === 'generateSitemaps') { generateSitemaps = true - if (!generateStaticParams && value === 'generateStaticParams') + } + if (!generateStaticParams && value === 'generateStaticParams') { generateStaticParams = true - if (!runtime && value === 'runtime') - warnInvalidValue( - pageFilePath, - 'runtime', - 'it was not assigned to a string literal' - ) - if (!preferredRegion && value === 'preferredRegion') - warnInvalidValue( - pageFilePath, - 'preferredRegion', - 'it was not assigned to a string literal or an array of string literals' + } + if (expectedExports.includes(value) && !exports.has(value)) { + // An export was found that was actually a re-export, and not a + // literal value. We should warn here. + Log.warn( + `Next.js can't recognize the exported \`${value}\` field in "${page}", it may be re-exported from another file. The default config will be used instead.` ) + } } } } + } - return { - ssr, - ssg, - runtime, - preferredRegion, - generateImageMetadata, - generateSitemaps, - generateStaticParams, - extraProperties, - directives, - } - } catch (err) {} - } + return { + getStaticProps, + getServerSideProps, + generateImageMetadata, + generateSitemaps, + generateStaticParams, + directives, + exports, + } + } catch {} - return { - ssg: false, - ssr: false, - runtime: undefined, - preferredRegion: undefined, - generateImageMetadata: false, - generateSitemaps: false, - generateStaticParams: false, - extraProperties: undefined, - directives: undefined, - } + return {} } async function tryToReadFile(filePath: string, shouldThrow: boolean) { @@ -318,37 +308,29 @@ async function tryToReadFile(filePath: string, shouldThrow: boolean) { } } +/** + * @internal - required to exclude zod types from the build + */ export function getMiddlewareMatchers( - matcherOrMatchers: unknown, + matcherOrMatchers: MiddlewareConfigMatcherInput, nextConfig: Pick ): MiddlewareMatcher[] { - let matchers: unknown[] = [] - if (Array.isArray(matcherOrMatchers)) { - matchers = matcherOrMatchers - } else { - matchers.push(matcherOrMatchers) - } + const matchers = Array.isArray(matcherOrMatchers) + ? matcherOrMatchers + : [matcherOrMatchers] + const { i18n } = nextConfig - const originalSourceMap = new Map() - let routes = matchers.map((m) => { - let middleware = (typeof m === 'string' ? { source: m } : m) as Middleware - if (middleware) { - originalSourceMap.set(middleware, middleware.source) - } - return middleware - }) + return matchers.map((matcher) => { + matcher = typeof matcher === 'string' ? { source: matcher } : matcher - // check before we process the routes and after to ensure - // they are still valid - checkCustomRoutes(routes, 'middleware') + const originalSource = matcher.source - routes = routes.map((r) => { - let { source } = r + let { source, ...rest } = matcher const isRoot = source === '/' - if (i18n?.locales && r.locale !== false) { + if (i18n?.locales && matcher.locale !== false) { source = `/:nextInternalLocale((?!_next/)[^/.]{1,})${ isRoot ? '' : source }` @@ -364,69 +346,64 @@ export function getMiddlewareMatchers( source = `${nextConfig.basePath}${source}` } - r.source = source - return r - }) - - checkCustomRoutes(routes, 'middleware') - - return routes.map((r) => { - const { source, ...rest } = r - const parsedPage = tryToParsePath(source) + // Validate that the source is still. + const result = SourceSchema.safeParse(source) + if (!result.success) { + reportZodError('Failed to parse middleware source', result.error) - if (parsedPage.error || !parsedPage.regexStr) { - throw new Error(`Invalid source: ${source}`) + // We need to exit here because middleware being built occurs before we + // finish setting up the server. Exiting here is the only way to ensure + // that we don't hang. + process.exit(1) } - const originalSource = originalSourceMap.get(r) - return { ...rest, - regexp: parsedPage.regexStr, + // We know that parsed.regexStr is not undefined because we already + // checked that the source is valid. + regexp: tryToParsePath(result.data).regexStr!, originalSource: originalSource || source, } }) } -function getMiddlewareConfig( - pageFilePath: string, - config: any, +function parseMiddlewareConfig( + page: string, + rawConfig: unknown, nextConfig: NextConfig -): Partial { - const result: Partial = {} +): MiddlewareConfig { + // If there's no config to parse, then return nothing. + if (typeof rawConfig !== 'object' || !rawConfig) return {} + + const input = MiddlewareConfigInputSchema.safeParse(rawConfig) + if (!input.success) { + reportZodError(`${page} contains invalid middleware config`, input.error) + + // We need to exit here because middleware being built occurs before we + // finish setting up the server. Exiting here is the only way to ensure + // that we don't hang. + process.exit(1) + } - if (config.matcher) { - result.matchers = getMiddlewareMatchers(config.matcher, nextConfig) + const config: MiddlewareConfig = {} + + if (input.data.matcher) { + config.matchers = getMiddlewareMatchers(input.data.matcher, nextConfig) } - if (typeof config.regions === 'string' || Array.isArray(config.regions)) { - result.regions = config.regions - } else if (typeof config.regions !== 'undefined') { - Log.warn( - `The \`regions\` config was ignored: config must be empty, a string or an array of strings. (${pageFilePath})` + if (input.data.unstable_allowDynamic) { + config.unstable_allowDynamic = Array.isArray( + input.data.unstable_allowDynamic ) + ? input.data.unstable_allowDynamic + : [input.data.unstable_allowDynamic] } - if (config.unstable_allowDynamic) { - result.unstable_allowDynamicGlobs = Array.isArray( - config.unstable_allowDynamic - ) - ? config.unstable_allowDynamic - : [config.unstable_allowDynamic] - for (const glob of result.unstable_allowDynamicGlobs ?? []) { - try { - picomatch(glob) - } catch (err) { - throw new Error( - `${pageFilePath} exported 'config.unstable_allowDynamic' contains invalid pattern '${glob}': ${ - (err as Error).message - }` - ) - } - } + if (input.data.regions) { + config.regions = input.data.regions } - return result + return config } const apiRouteWarnings = new LRUCache({ max: 250 }) @@ -489,196 +466,196 @@ function warnAboutUnsupportedValue( } } -/** - * 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: { +type GetPageStaticInfoParams = { pageFilePath: string nextConfig: Partial isDev?: boolean - page?: string + page: string pageType: PAGE_TYPES -}): Promise { - const { isDev, pageFilePath, nextConfig, page, pageType } = params +} - const fileContent = (await tryToReadFile(pageFilePath, !isDev)) || '' - if ( - /(? { + const content = await tryToReadFile(pageFilePath, !isDev) + if (!content || !PARSE_PATTERN.test(content)) { + return { + type: PAGE_TYPES.APP, + config: undefined, + runtime: undefined, + preferredRegion: undefined, + maxDuration: undefined, } + } - const extraConfig: Record = {} + const ast = await parseModule(pageFilePath, content) - if (extraProperties && pageType === PAGE_TYPES.APP) { - for (const prop of extraProperties) { - if (!AUTHORIZED_EXTRA_ROUTER_PROPS.includes(prop)) continue - try { - extraConfig[prop] = extractExportedConstValue(swcAST, prop) - } catch (e) { - if (e instanceof UnsupportedValueError) { - warnAboutUnsupportedValue(pageFilePath, page, e) - } + const { + generateStaticParams, + generateImageMetadata, + generateSitemaps, + exports, + directives, + } = checkExports(ast, AppSegmentConfigSchemaKeys, page) + + const { type: rsc } = getRSCModuleInformation(content, true) + + const exportedConfig: Record = {} + if (exports) { + for (const property of exports) { + try { + exportedConfig[property] = extractExportedConstValue(ast, property) + } catch (e) { + if (e instanceof UnsupportedValueError) { + warnAboutUnsupportedValue(pageFilePath, page, e) } } - } else if (pageType === PAGE_TYPES.PAGES) { - for (const key in config) { - if (!AUTHORIZED_EXTRA_ROUTER_PROPS.includes(key)) continue - extraConfig[key] = config[key] - } } + } - if (pageType === PAGE_TYPES.APP) { - if (config) { - let message = `Page config in ${pageFilePath} is deprecated. Replace \`export const config=…\` with the following:` + try { + exportedConfig.config = extractExportedConstValue(ast, 'config') + } catch (e) { + if (e instanceof UnsupportedValueError) { + warnAboutUnsupportedValue(pageFilePath, page, e) + } + // `export config` doesn't exist, or other unknown error thrown by swc, silence them + } - if (config.runtime) { - message += `\n - \`export const runtime = ${JSON.stringify( - config.runtime - )}\`` - } + const route = normalizeAppPath(page) + const config = parseAppSegmentConfig(exportedConfig, route) - if (config.regions) { - message += `\n - \`export const preferredRegion = ${JSON.stringify( - config.regions - )}\`` - } + // Prevent edge runtime and generateStaticParams in the same file. + if (isEdgeRuntime(config.runtime) && generateStaticParams) { + throw new Error( + `Page "${page}" cannot use both \`export const runtime = 'edge'\` and export \`generateStaticParams\`.` + ) + } - message += `\nVisit https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config for more information.` + // Prevent use client and generateStaticParams in the same file. + if (directives?.has('client') && generateStaticParams) { + throw new Error( + `Page "${page}" cannot use both "use client" and export function "generateStaticParams()".` + ) + } - if (isDev) { - Log.warnOnce(message) - } else { - throw new Error(message) - } - config = {} - } - } - if (!config) config = {} - - // We use `export const config = { runtime: '...' }` to specify the page runtime for pages/. - // In the new app directory, we prefer to use `export const runtime = '...'` - // and deprecate the old way. To prevent breaking changes for `pages`, we use the exported config - // as the fallback value. - let resolvedRuntime - if (pageType === PAGE_TYPES.APP) { - resolvedRuntime = runtime - } else { - resolvedRuntime = runtime || config.runtime - } + return { + type: PAGE_TYPES.APP, + rsc, + generateImageMetadata, + generateSitemaps, + generateStaticParams, + config, + middleware: parseMiddlewareConfig(page, exportedConfig.config, nextConfig), + runtime: config.runtime, + preferredRegion: config.preferredRegion, + maxDuration: config.maxDuration, + } +} - if ( - typeof resolvedRuntime !== 'undefined' && - resolvedRuntime !== SERVER_RUNTIME.nodejs && - !isEdgeRuntime(resolvedRuntime) - ) { - const options = Object.values(SERVER_RUNTIME).join(', ') - const message = - typeof resolvedRuntime !== 'string' - ? `The \`runtime\` config must be a string. Please leave it empty or choose one of: ${options}` - : `Provided runtime "${resolvedRuntime}" is not supported. Please leave it empty or choose one of: ${options}` - if (isDev) { - Log.error(message) - } else { - throw new Error(message) - } +export async function getPagesPageStaticInfo({ + pageFilePath, + nextConfig, + isDev, + page, +}: GetPageStaticInfoParams): Promise { + const content = await tryToReadFile(pageFilePath, !isDev) + if (!content || !PARSE_PATTERN.test(content)) { + return { + type: PAGE_TYPES.PAGES, + config: undefined, + runtime: undefined, + preferredRegion: undefined, + maxDuration: undefined, } + } - const requiresServerRuntime = ssr || ssg || pageType === PAGE_TYPES.APP + const ast = await parseModule(pageFilePath, content) - const isAnAPIRoute = isAPIRoute(page?.replace(/^(?:\/src)?\/pages\//, '/')) + const { getServerSideProps, getStaticProps, exports } = checkExports( + ast, + PagesSegmentConfigSchemaKeys, + page + ) - resolvedRuntime = - isEdgeRuntime(resolvedRuntime) || requiresServerRuntime - ? resolvedRuntime - : undefined + const { type: rsc } = getRSCModuleInformation(content, true) - if (resolvedRuntime === SERVER_RUNTIME.experimentalEdge) { - warnAboutExperimentalEdge(isAnAPIRoute ? page! : null) + const exportedConfig: Record = {} + if (exports) { + for (const property of exports) { + try { + exportedConfig[property] = extractExportedConstValue(ast, property) + } catch (e) { + if (e instanceof UnsupportedValueError) { + warnAboutUnsupportedValue(pageFilePath, page, e) + } + } } + } - if ( - resolvedRuntime === SERVER_RUNTIME.edge && - pageType === PAGE_TYPES.PAGES && - page && - !isAnAPIRoute - ) { - const message = `Page ${page} provided runtime 'edge', the edge runtime for rendering is currently experimental. Use runtime 'experimental-edge' instead.` - if (isDev) { - Log.error(message) - } else { - throw new Error(message) - } + try { + exportedConfig.config = extractExportedConstValue(ast, 'config') + } catch (e) { + if (e instanceof UnsupportedValueError) { + warnAboutUnsupportedValue(pageFilePath, page, e) } + // `export config` doesn't exist, or other unknown error thrown by swc, silence them + } - const middlewareConfig = getMiddlewareConfig( - page ?? 'middleware/edge API route', - config, - nextConfig - ) + // Validate the config. + const route = normalizePagePath(page) + const config = parsePagesSegmentConfig(exportedConfig, route) + const isAnAPIRoute = isAPIRoute(route) - if ( - pageType === PAGE_TYPES.APP && - directives?.has('client') && - generateStaticParams - ) { - throw new Error( - `Page "${page}" cannot use both "use client" and export function "generateStaticParams()".` - ) - } + const resolvedRuntime = + isEdgeRuntime(config.runtime ?? config.config?.runtime) || + getServerSideProps || + getStaticProps + ? config.runtime ?? config.config?.runtime + : undefined - return { - ssr, - ssg, - rsc, - generateStaticParams, - generateImageMetadata, - generateSitemaps, - amp: config.amp || false, - ...(middlewareConfig && { middleware: middlewareConfig }), - ...(resolvedRuntime && { runtime: resolvedRuntime }), - preferredRegion, - extraConfig, + if (resolvedRuntime === SERVER_RUNTIME.experimentalEdge) { + warnAboutExperimentalEdge(isAnAPIRoute ? page! : null) + } + + if (resolvedRuntime === SERVER_RUNTIME.edge && page && !isAnAPIRoute) { + const message = `Page ${page} provided runtime 'edge', the edge runtime for rendering is currently experimental. Use runtime 'experimental-edge' instead.` + if (isDev) { + Log.error(message) + } else { + throw new Error(message) } } return { - ssr: false, - ssg: false, - rsc: RSC_MODULE_TYPES.server, - generateStaticParams: false, - generateImageMetadata: false, - generateSitemaps: false, - amp: false, - runtime: undefined, + type: PAGE_TYPES.PAGES, + getStaticProps, + getServerSideProps, + rsc, + config, + middleware: parseMiddlewareConfig(page, exportedConfig.config, nextConfig), + runtime: resolvedRuntime, + preferredRegion: config.config?.regions, + maxDuration: config.maxDuration ?? config.config?.maxDuration, + } +} + +/** + * 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: GetPageStaticInfoParams +): Promise { + if (params.pageType === PAGE_TYPES.APP) { + return getAppPageStaticInfo(params) } + + return getPagesPageStaticInfo(params) } diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index 95a9079ae561cf..6adc19233b0e55 100644 --- a/packages/next/src/build/entries.ts +++ b/packages/next/src/build/entries.ts @@ -5,7 +5,6 @@ import type { EdgeAppRouteLoaderQuery } from './webpack/loaders/next-edge-app-ro import type { NextConfigComplete } from '../server/config-shared' import type { webpack } from 'next/dist/compiled/webpack/webpack' import type { - MiddlewareConfigParsed, MiddlewareConfig, MiddlewareMatcher, PageStaticInfo, @@ -46,8 +45,12 @@ import { isMiddlewareFilename, isInstrumentationHookFile, isInstrumentationHookFilename, + reduceAppConfig, } from './utils' -import { getPageStaticInfo } from './analysis/get-page-static-info' +import { + getAppPageStaticInfo, + getPageStaticInfo, +} from './analysis/get-page-static-info' import { normalizePathSep } from '../shared/lib/page-path/normalize-path-sep' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' import type { ServerRuntime } from '../types' @@ -100,7 +103,7 @@ export async function getStaticInfoIncludingLayouts({ pageExtensions, pageFilePath, appDir, - config, + config: nextConfig, isDev, page, }: { @@ -112,23 +115,22 @@ export async function getStaticInfoIncludingLayouts({ isDev: boolean | undefined page: string }): Promise { + // TODO: sync types for pages: PAGE_TYPES, ROUTER_TYPE, 'app' | 'pages', etc. + const pageType = isInsideAppDir ? PAGE_TYPES.APP : PAGE_TYPES.PAGES + const pageStaticInfo = await getPageStaticInfo({ - nextConfig: config, + nextConfig, pageFilePath, isDev, page, - // TODO: sync types for pages: PAGE_TYPES, ROUTER_TYPE, 'app' | 'pages', etc. - pageType: isInsideAppDir ? PAGE_TYPES.APP : PAGE_TYPES.PAGES, + pageType, }) - if (!isInsideAppDir || !appDir) { + if (pageStaticInfo.type === PAGE_TYPES.PAGES || !appDir) { return pageStaticInfo } - const staticInfo: PageStaticInfo = { - // TODO-APP: Remove the rsc key altogether. It's no longer required. - rsc: 'server', - } + const segments = [pageStaticInfo] // inherit from layout files only if it's a page route if (isAppPageRoute(page)) { @@ -148,45 +150,32 @@ export async function getStaticInfoIncludingLayouts({ dir = join(dir, '..') } + // Reverse the layout files so we can use unshift to add them to the + // segments array. + layoutFiles.reverse() + for (const layoutFile of layoutFiles) { - const layoutStaticInfo = await getPageStaticInfo({ - nextConfig: config, + const layoutStaticInfo = await getAppPageStaticInfo({ + nextConfig, pageFilePath: layoutFile, isDev, page, pageType: isInsideAppDir ? PAGE_TYPES.APP : PAGE_TYPES.PAGES, }) - // Only runtime is relevant here. - if (layoutStaticInfo.runtime) { - staticInfo.runtime = layoutStaticInfo.runtime - } - if (layoutStaticInfo.preferredRegion) { - staticInfo.preferredRegion = layoutStaticInfo.preferredRegion - } - if (layoutStaticInfo.extraConfig) { - staticInfo.extraConfig = { - ...staticInfo.extraConfig, - ...layoutStaticInfo.extraConfig, - } - } + segments.unshift(layoutStaticInfo) } } - if (pageStaticInfo.runtime) { - staticInfo.runtime = pageStaticInfo.runtime - } - if (pageStaticInfo.preferredRegion) { - staticInfo.preferredRegion = pageStaticInfo.preferredRegion - } - if (pageStaticInfo.extraConfig) { - staticInfo.extraConfig = { - ...staticInfo.extraConfig, - ...pageStaticInfo.extraConfig, - } - } + const config = reduceAppConfig(segments) - return staticInfo + return { + ...pageStaticInfo, + config, + runtime: config.runtime, + preferredRegion: config.preferredRegion, + maxDuration: config.maxDuration, + } } type ObjectValue = T extends { [key: string]: infer V } ? V : never @@ -371,7 +360,7 @@ export function getEdgeServerEntry(opts: { isServerComponent: boolean page: string pages: MappedPages - middleware?: Partial + middleware?: Partial pagesType: PAGE_TYPES appDirLoader?: string hasInstrumentationHook?: boolean diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 1f2ab770fea3ff..3f1a08f67b120d 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -131,7 +131,7 @@ import { // getSupportedBrowsers, } from './utils' import type { PageInfo, PageInfos, PrerenderedRoute } from './utils' -import type { AppSegmentConfig } from './app-segments/app-segment-config' +import type { AppSegmentConfig } from './segment-config/app/app-segment-config' import { writeBuildId } from './write-build-id' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import isError from '../lib/is-error' @@ -462,7 +462,12 @@ async function writeClientSsgManifest( interface FunctionsConfigManifest { version: number - functions: Record> + functions: Record< + string, + { + maxDuration?: number | undefined + } + > } async function writeFunctionsConfigManifest( @@ -2080,9 +2085,15 @@ export default async function build( }) : undefined - if (staticInfo?.extraConfig) { - functionsConfigManifest.functions[page] = - staticInfo.extraConfig + // If there's any thing that would contribute to the functions + // configuration, we need to add it to the manifest. + if ( + typeof staticInfo?.runtime !== 'undefined' || + typeof staticInfo?.maxDuration !== 'undefined' + ) { + functionsConfigManifest.functions[page] = { + maxDuration: staticInfo?.maxDuration, + } } const pageRuntime = middlewareManifest.functions[ diff --git a/packages/next/src/build/app-segments/app-segment-config.ts b/packages/next/src/build/segment-config/app/app-segment-config.ts similarity index 71% rename from packages/next/src/build/app-segments/app-segment-config.ts rename to packages/next/src/build/segment-config/app/app-segment-config.ts index aaed6dbfd0494b..b159cddfe598b9 100644 --- a/packages/next/src/build/app-segments/app-segment-config.ts +++ b/packages/next/src/build/segment-config/app/app-segment-config.ts @@ -1,11 +1,10 @@ import { z } from 'next/dist/compiled/zod' +import { formatZodError } from '../../../shared/lib/zod' /** * The schema for configuration for a page. - * - * @internal */ -export const AppSegmentConfigSchema = z.object({ +const AppSegmentConfigSchema = z.object({ /** * The number of seconds to revalidate the page or false to disable revalidation. */ @@ -63,6 +62,40 @@ export const AppSegmentConfigSchema = z.object({ maxDuration: z.number().int().nonnegative().optional(), }) +/** + * Parse the app segment config. + * @param data - The data to parse. + * @param route - The route of the app. + * @returns The parsed app segment config. + */ +export function parseAppSegmentConfig( + data: unknown, + route: string +): AppSegmentConfig { + const parsed = AppSegmentConfigSchema.safeParse(data, { + errorMap: (issue, ctx) => { + if (issue.path.length === 1 && issue.path[0] === 'revalidate') { + return { + message: `Invalid revalidate value ${JSON.stringify( + ctx.data + )} on "${route}", must be a non-negative number or false`, + } + } + + return { message: ctx.defaultError } + }, + }) + + if (!parsed.success) { + throw formatZodError( + `Invalid segment configuration options detected for "${route}". Read more at https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config`, + parsed.error + ) + } + + return parsed.data +} + /** * The configuration for a page. */ @@ -120,6 +153,6 @@ export type AppSegmentConfig = { /** * The keys of the configuration for a page. * - * @internal + * @internal - required to exclude zod types from the build */ export const AppSegmentConfigSchemaKeys = AppSegmentConfigSchema.keyof().options diff --git a/packages/next/src/build/app-segments/collect-app-segments.ts b/packages/next/src/build/segment-config/app/app-segments.ts similarity index 79% rename from packages/next/src/build/app-segments/collect-app-segments.ts rename to packages/next/src/build/segment-config/app/app-segments.ts index b907c4b409027b..02538bddc62ea8 100644 --- a/packages/next/src/build/app-segments/collect-app-segments.ts +++ b/packages/next/src/build/segment-config/app/app-segments.ts @@ -1,43 +1,44 @@ -import type { LoadComponentsReturnType } from '../../server/load-components' -import type { Params } from '../../server/request/params' +import type { LoadComponentsReturnType } from '../../../server/load-components' +import type { Params } from '../../../server/request/params' import type { AppPageRouteModule, AppPageModule, -} from '../../server/route-modules/app-page/module.compiled' +} from '../../../server/route-modules/app-page/module.compiled' import type { AppRouteRouteModule, AppRouteModule, -} from '../../server/route-modules/app-route/module.compiled' +} from '../../../server/route-modules/app-route/module.compiled' import { type AppSegmentConfig, - AppSegmentConfigSchema, + parseAppSegmentConfig, } from './app-segment-config' -import { InvariantError } from '../../shared/lib/invariant-error' +import { InvariantError } from '../../../shared/lib/invariant-error' import { isAppRouteRouteModule, isAppPageRouteModule, -} from '../../server/route-modules/checks' -import { isClientReference } from '../../lib/client-reference' -import { getSegmentParam } from '../../server/app-render/get-segment-param' -import { getLayoutOrPageModule } from '../../server/lib/app-dir-module' +} from '../../../server/route-modules/checks' +import { isClientReference } from '../../../lib/client-reference' +import { getSegmentParam } from '../../../server/app-render/get-segment-param' +import { getLayoutOrPageModule } from '../../../server/lib/app-dir-module' type GenerateStaticParams = (options: { params?: Params }) => Promise /** * Parses the app config and attaches it to the segment. */ -function attach(segment: AppSegment, userland: unknown) { +function attach(segment: AppSegment, userland: unknown, route: string) { // If the userland is not an object, then we can't do anything with it. if (typeof userland !== 'object' || userland === null) { return } - // Try to parse the application configuration. If there were any keys, attach - // it to the segment. - const config = AppSegmentConfigSchema.safeParse(userland) - if (config.success && Object.keys(config.data).length > 0) { - segment.config = config.data + // Try to parse the application configuration. + const config = parseAppSegmentConfig(userland, route) + + // If there was any keys on the config, then attach it to the segment. + if (Object.keys(config).length > 0) { + segment.config = config } if ( @@ -96,7 +97,7 @@ async function collectAppPageSegments(routeModule: AppPageRouteModule) { // an object, then we should skip it. This can happen when parsing the // error components. if (!isClientComponent) { - attach(segment, userland) + attach(segment, userland, routeModule.definition.pathname) } segments.push(segment) @@ -145,7 +146,7 @@ function collectAppRouteSegments( segment.filePath = routeModule.definition.filename // Extract the segment config from the userland module. - attach(segment, routeModule.userland) + attach(segment, routeModule.userland, routeModule.definition.pathname) return segments } diff --git a/packages/next/src/build/segment-config/middleware/middleware-config.ts b/packages/next/src/build/segment-config/middleware/middleware-config.ts new file mode 100644 index 00000000000000..e911ac4dac9e1c --- /dev/null +++ b/packages/next/src/build/segment-config/middleware/middleware-config.ts @@ -0,0 +1,147 @@ +import picomatch from 'next/dist/compiled/picomatch' +import { z } from 'next/dist/compiled/zod' +import { tryToParsePath } from '../../../lib/try-to-parse-path' +import type { RouteHas } from '../../../lib/load-custom-routes' + +const RouteHasSchema = z.discriminatedUnion('type', [ + z + .object({ + type: z.enum(['header', 'query', 'cookie']), + key: z.string({ + required_error: 'key is required when type is header, query or cookie', + }), + value: z + .string({ + invalid_type_error: 'value must be a string', + }) + .optional(), + }) + .strict(), + z + .object({ + type: z.literal('host'), + value: z.string({ + required_error: 'host must have a value', + }), + }) + .strict(), +]) + +/** + * @internal - required to exclude zod types from the build + */ +export const SourceSchema = z + .string({ + required_error: 'source is required', + }) + .max(4096, 'exceeds max built length of 4096 for route') + .superRefine((val, ctx) => { + if (!val.startsWith('/')) { + return ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `source must start with /`, + }) + } + + const { error, regexStr } = tryToParsePath(val) + + if (error || !regexStr) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid source '${val}': ${error.message}`, + }) + } + }) + +const MiddlewareMatcherInputSchema = z + .object({ + locale: z.union([z.literal(false), z.undefined()]).optional(), + has: z.array(RouteHasSchema).optional(), + missing: z.array(RouteHasSchema).optional(), + source: SourceSchema, + }) + .strict() + +const MiddlewareConfigMatcherInputSchema = z.union([ + SourceSchema, + z.array( + z.union([SourceSchema, MiddlewareMatcherInputSchema], { + invalid_type_error: 'must be an array of strings or middleware matchers', + }) + ), +]) + +/** + * @internal - required to exclude zod types from the build + */ +export type MiddlewareConfigMatcherInput = z.infer< + typeof MiddlewareConfigMatcherInputSchema +> + +const GlobSchema = z.string().superRefine((val, ctx) => { + try { + picomatch(val) + } catch (err: any) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid glob pattern '${val}': ${err.message}`, + }) + } +}) + +/** + * @internal - required to exclude zod types from the build + */ +export const MiddlewareConfigInputSchema = z.object({ + /** + * The matcher for the middleware. + */ + matcher: MiddlewareConfigMatcherInputSchema.optional(), + + /** + * The regions that the middleware should run in. + */ + regions: z.union([z.string(), z.array(z.string())]).optional(), + + /** + * A glob, or an array of globs, ignoring dynamic code evaluation for specific + * files. The globs are relative to your application root folder. + */ + unstable_allowDynamic: z.union([GlobSchema, z.array(GlobSchema)]).optional(), +}) + +export type MiddlewareConfigInput = { + /** + * The matcher for the middleware. + */ + matcher?: + | string + | Array< + | { + locale?: false + has?: RouteHas[] + missing?: RouteHas[] + source: string + } + | string + > + + /** + * The regions that the middleware should run in. + */ + regions?: string | string[] + + /** + * A glob, or an array of globs, ignoring dynamic code evaluation for specific + * files. The globs are relative to your application root folder. + */ + unstable_allowDynamic?: string | string[] +} + +/** + * The keys of the configuration for a middleware. + * + * @internal - required to exclude zod types from the build + */ +export const MiddlewareConfigInputSchemaKeys = + MiddlewareConfigInputSchema.keyof().options diff --git a/packages/next/src/build/segment-config/pages/pages-segment-config.ts b/packages/next/src/build/segment-config/pages/pages-segment-config.ts new file mode 100644 index 00000000000000..510ae7d73dc7f6 --- /dev/null +++ b/packages/next/src/build/segment-config/pages/pages-segment-config.ts @@ -0,0 +1,107 @@ +import { z } from 'next/dist/compiled/zod' +import { formatZodError } from '../../../shared/lib/zod' + +/** + * The schema for the page segment config. + */ +const PagesSegmentConfigSchema = z.object({ + /** + * The runtime to use for the page. + */ + runtime: z.enum(['edge', 'experimental-edge', 'nodejs']).optional(), + + /** + * The maximum duration for the page render. + */ + maxDuration: z.number().optional(), + + /** + * The exported config object for the page. + */ + config: z + .object({ + /** + * Enables AMP for the page. + */ + amp: z.union([z.boolean(), z.literal('hybrid')]).optional(), + + /** + * The runtime to use for the page. + */ + runtime: z.enum(['edge', 'experimental-edge', 'nodejs']).optional(), + + /** + * The maximum duration for the page render. + */ + maxDuration: z.number().optional(), + }) + .optional(), +}) + +/** + * Parse the page segment config. + * @param data - The data to parse. + * @param route - The route of the page. + * @returns The parsed page segment config. + */ +export function parsePagesSegmentConfig( + data: unknown, + route: string +): PagesSegmentConfig { + const parsed = PagesSegmentConfigSchema.safeParse(data, {}) + if (!parsed.success) { + throw formatZodError( + `Invalid segment configuration options detected for "${route}". Read more at https://nextjs.org/docs/messages/invalid-page-config`, + parsed.error + ) + } + + return parsed.data +} + +/** + * The keys of the configuration for a page. + * + * @internal - required to exclude zod types from the build + */ +export const PagesSegmentConfigSchemaKeys = + PagesSegmentConfigSchema.keyof().options + +export type PagesSegmentConfigConfig = { + /** + * Enables AMP for the page. + */ + amp?: boolean | 'hybrid' + + /** + * The maximum duration for the page render. + */ + maxDuration?: number + + /** + * The runtime to use for the page. + */ + runtime?: 'edge' | 'experimental-edge' | 'nodejs' + + /** + * The preferred region for the page. + */ + regions?: string[] +} + +export type PagesSegmentConfig = { + /** + * The runtime to use for the page. + */ + runtime?: 'edge' | 'experimental-edge' | 'nodejs' + + /** + * The maximum duration for the page render. + */ + maxDuration?: number + + /** + * The exported config object for the page. + */ + config?: PagesSegmentConfigConfig +} diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 21980c6dc12395..efa434c7b4bc4d 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -96,9 +96,9 @@ import { } from '../lib/fallback' import { getParamKeys } from '../server/request/fallback-params' import type { OutgoingHttpHeaders } from 'http' -import type { AppSegmentConfig } from './app-segments/app-segment-config' -import type { AppSegment } from './app-segments/collect-app-segments' -import { collectSegments } from './app-segments/collect-app-segments' +import type { AppSegmentConfig } from './segment-config/app/app-segment-config' +import type { AppSegment } from './segment-config/app/app-segments' +import { collectSegments } from './segment-config/app/app-segments' export type ROUTER_TYPE = 'pages' | 'app' @@ -1733,11 +1733,13 @@ export async function isPageStatic({ type ReducedAppConfig = Pick< AppSegmentConfig, + | 'revalidate' | 'dynamic' | 'fetchCache' | 'preferredRegion' - | 'revalidate' | 'experimental_ppr' + | 'runtime' + | 'maxDuration' > /** @@ -1747,7 +1749,9 @@ type ReducedAppConfig = Pick< * @param segments the generate param segments * @returns the reduced app config */ -export function reduceAppConfig(segments: AppSegment[]): ReducedAppConfig { +export function reduceAppConfig( + segments: Pick[] +): ReducedAppConfig { const config: ReducedAppConfig = {} for (const segment of segments) { @@ -1757,23 +1761,26 @@ export function reduceAppConfig(segments: AppSegment[]): ReducedAppConfig { preferredRegion, revalidate, experimental_ppr, + runtime, + maxDuration, } = segment.config || {} // TODO: should conflicting configs here throw an error // e.g. if layout defines one region but page defines another - // Get the first value of preferredRegion, dynamic, revalidate, and - // fetchCache. - if (typeof config.preferredRegion === 'undefined') { + if (typeof preferredRegion !== 'undefined') { config.preferredRegion = preferredRegion } - if (typeof config.dynamic === 'undefined') { + + if (typeof dynamic !== 'undefined') { config.dynamic = dynamic } - if (typeof config.fetchCache === 'undefined') { + + if (typeof fetchCache !== 'undefined') { config.fetchCache = fetchCache } - if (typeof config.revalidate === 'undefined') { + + if (typeof revalidate !== 'undefined') { config.revalidate = revalidate } @@ -1791,6 +1798,14 @@ export function reduceAppConfig(segments: AppSegment[]): ReducedAppConfig { if (typeof experimental_ppr !== 'undefined') { config.experimental_ppr = experimental_ppr } + + if (typeof runtime !== 'undefined') { + config.runtime = runtime + } + + if (typeof maxDuration !== 'undefined') { + config.maxDuration = maxDuration + } } return config diff --git a/packages/next/src/build/webpack/plugins/middleware-plugin.ts b/packages/next/src/build/webpack/plugins/middleware-plugin.ts index 54dfd9938fc078..2326779ad8d9ae 100644 --- a/packages/next/src/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/src/build/webpack/plugins/middleware-plugin.ts @@ -295,7 +295,7 @@ function isDynamicCodeEvaluationAllowed( const name = fileName.replace(rootDir ?? '', '') - return picomatch(middlewareConfig?.unstable_allowDynamicGlobs ?? [])(name) + return picomatch(middlewareConfig?.unstable_allowDynamic ?? [])(name) } function buildUnsupportedApiError({ @@ -647,7 +647,7 @@ function getExtractMetadata(params: { if (/node_modules[\\/]regenerator-runtime[\\/]runtime\.js/.test(id)) { continue } - if (route?.middlewareConfig?.unstable_allowDynamicGlobs) { + if (route?.middlewareConfig?.unstable_allowDynamic) { telemetry?.record({ eventName: 'NEXT_EDGE_ALLOW_DYNAMIC_USED', payload: { diff --git a/packages/next/src/client/components/work-async-storage.external.ts b/packages/next/src/client/components/work-async-storage.external.ts index 8b43193bd2af00..eed2a21d00619f 100644 --- a/packages/next/src/client/components/work-async-storage.external.ts +++ b/packages/next/src/client/components/work-async-storage.external.ts @@ -4,10 +4,10 @@ import type { DynamicServerError } from './hooks-server-context' import type { FetchMetrics } from '../../server/base-http' import type { Revalidate } from '../../server/lib/revalidate' import type { FallbackRouteParams } from '../../server/request/fallback-params' +import type { AppSegmentConfig } from '../../build/segment-config/app/app-segment-config' // Share the instance module in the next-shared layer import { workAsyncStorage } from './work-async-storage-instance' with { 'turbopack-transition': 'next-shared' } -import type { AppSegmentConfig } from '../../build/app-segments/app-segment-config' export interface WorkStore { readonly isStaticGeneration: boolean diff --git a/packages/next/src/compiled/zod-validation-error/LICENSE b/packages/next/src/compiled/zod-validation-error/LICENSE new file mode 100644 index 00000000000000..c2d088e4a5afe7 --- /dev/null +++ b/packages/next/src/compiled/zod-validation-error/LICENSE @@ -0,0 +1,9 @@ +(The MIT License) + +Copyright 2022 Causaly, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/next/src/compiled/zod-validation-error/index.js b/packages/next/src/compiled/zod-validation-error/index.js new file mode 100644 index 00000000000000..47dcc3e048157f --- /dev/null +++ b/packages/next/src/compiled/zod-validation-error/index.js @@ -0,0 +1 @@ +(()=>{"use strict";var r={652:(r,e,o)=>{var t=Object.create;var s=Object.defineProperty;var n=Object.getOwnPropertyDescriptor;var i=Object.getOwnPropertyNames;var a=Object.getPrototypeOf;var u=Object.prototype.hasOwnProperty;var __export=(r,e)=>{for(var o in e)s(r,o,{get:e[o],enumerable:true})};var __copyProps=(r,e,o,t)=>{if(e&&typeof e==="object"||typeof e==="function"){for(let a of i(e))if(!u.call(r,a)&&a!==o)s(r,a,{get:()=>e[a],enumerable:!(t=n(e,a))||t.enumerable})}return r};var __toESM=(r,e,o)=>(o=r!=null?t(a(r)):{},__copyProps(e||!r||!r.__esModule?s(o,"default",{value:r,enumerable:true}):o,r));var __toCommonJS=r=>__copyProps(s({},"__esModule",{value:true}),r);var d={};__export(d,{ValidationError:()=>c,createMessageBuilder:()=>createMessageBuilder,errorMap:()=>errorMap,fromError:()=>fromError,fromZodError:()=>fromZodError,fromZodIssue:()=>fromZodIssue,isValidationError:()=>isValidationError,isValidationErrorLike:()=>isValidationErrorLike,isZodErrorLike:()=>isZodErrorLike,toValidationError:()=>toValidationError});r.exports=__toCommonJS(d);function isZodErrorLike(r){return r instanceof Error&&r.name==="ZodError"&&"issues"in r&&Array.isArray(r.issues)}var c=class extends Error{name;details;constructor(r,e){super(r,e);this.name="ZodValidationError";this.details=getIssuesFromErrorOptions(e)}toString(){return this.message}};function getIssuesFromErrorOptions(r){if(r){const e=r.cause;if(isZodErrorLike(e)){return e.issues}}return[]}function isValidationError(r){return r instanceof c}function isValidationErrorLike(r){return r instanceof Error&&r.name==="ZodValidationError"}var f=__toESM(o(788));var p=__toESM(o(788));function isNonEmptyArray(r){return r.length!==0}var l=/[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*/u;function joinPath(r){if(r.length===1){return r[0].toString()}return r.reduce(((r,e)=>{if(typeof e==="number"){return r+"["+e.toString()+"]"}if(e.includes('"')){return r+'["'+escapeQuotes(e)+'"]'}if(!l.test(e)){return r+'["'+e+'"]'}const o=r.length===0?"":".";return r+o+e}),"")}function escapeQuotes(r){return r.replace(/"/g,'\\"')}var m="; ";var g=99;var E="Validation error";var _=": ";var v=", or ";function createMessageBuilder(r={}){const{issueSeparator:e=m,unionSeparator:o=v,prefixSeparator:t=_,prefix:s=E,includePath:n=true,maxIssuesInMessage:i=g}=r;return r=>{const a=r.slice(0,i).map((r=>getMessageFromZodIssue({issue:r,issueSeparator:e,unionSeparator:o,includePath:n}))).join(e);return prefixMessage(a,s,t)}}function getMessageFromZodIssue(r){const{issue:e,issueSeparator:o,unionSeparator:t,includePath:s}=r;if(e.code===p.ZodIssueCode.invalid_union){return e.unionErrors.reduce(((r,e)=>{const n=e.issues.map((r=>getMessageFromZodIssue({issue:r,issueSeparator:o,unionSeparator:t,includePath:s}))).join(o);if(!r.includes(n)){r.push(n)}return r}),[]).join(t)}if(e.code===p.ZodIssueCode.invalid_arguments){return[e.message,...e.argumentsError.issues.map((r=>getMessageFromZodIssue({issue:r,issueSeparator:o,unionSeparator:t,includePath:s})))].join(o)}if(e.code===p.ZodIssueCode.invalid_return_type){return[e.message,...e.returnTypeError.issues.map((r=>getMessageFromZodIssue({issue:r,issueSeparator:o,unionSeparator:t,includePath:s})))].join(o)}if(s&&isNonEmptyArray(e.path)){if(e.path.length===1){const r=e.path[0];if(typeof r==="number"){return`${e.message} at index ${r}`}}return`${e.message} at "${joinPath(e.path)}"`}return e.message}function prefixMessage(r,e,o){if(e!==null){if(r.length>0){return[e,r].join(o)}return e}if(r.length>0){return r}return E}function fromZodIssue(r,e={}){const o=createMessageBuilderFromOptions(e);const t=o([r]);return new c(t,{cause:new f.ZodError([r])})}function createMessageBuilderFromOptions(r){if("messageBuilder"in r){return r.messageBuilder}return createMessageBuilder(r)}var errorMap=(r,e)=>{const o=fromZodIssue({...r,message:r.message??e.defaultError});return{message:o.message}};function fromZodError(r,e={}){if(!isZodErrorLike(r)){throw new TypeError(`Invalid zodError param; expected instance of ZodError. Did you mean to use the "${fromError.name}" method instead?`)}return fromZodErrorWithoutRuntimeCheck(r,e)}function fromZodErrorWithoutRuntimeCheck(r,e={}){const o=r.errors;let t;if(isNonEmptyArray(o)){const r=createMessageBuilderFromOptions2(e);t=r(o)}else{t=r.message}return new c(t,{cause:r})}function createMessageBuilderFromOptions2(r){if("messageBuilder"in r){return r.messageBuilder}return createMessageBuilder(r)}var toValidationError=(r={})=>e=>{if(isZodErrorLike(e)){return fromZodErrorWithoutRuntimeCheck(e,r)}if(e instanceof Error){return new c(e.message,{cause:e})}return new c("Unknown error")};function fromError(r,e={}){return toValidationError(e)(r)}0&&0},788:r=>{r.exports=require("next/dist/compiled/zod")}};var e={};function __nccwpck_require__(o){var t=e[o];if(t!==undefined){return t.exports}var s=e[o]={exports:{}};var n=true;try{r[o](s,s.exports,__nccwpck_require__);n=false}finally{if(n)delete e[o]}return s.exports}if(typeof __nccwpck_require__!=="undefined")__nccwpck_require__.ab=__dirname+"/";var o=__nccwpck_require__(652);module.exports=o})(); \ No newline at end of file diff --git a/packages/next/src/compiled/zod-validation-error/package.json b/packages/next/src/compiled/zod-validation-error/package.json new file mode 100644 index 00000000000000..1a00bdc170a961 --- /dev/null +++ b/packages/next/src/compiled/zod-validation-error/package.json @@ -0,0 +1 @@ +{"name":"zod-validation-error","main":"index.js","author":{"name":"Dimitrios C. Michalakos","email":"dimitris@jmike.gr","url":"https://github.com/jmike"},"license":"MIT"} diff --git a/packages/next/src/server/async-storage/with-work-store.ts b/packages/next/src/server/async-storage/with-work-store.ts index 80376a7b77b1ac..ffdeca67a766fc 100644 --- a/packages/next/src/server/async-storage/with-work-store.ts +++ b/packages/next/src/server/async-storage/with-work-store.ts @@ -6,7 +6,7 @@ import type { RenderOptsPartial } from '../app-render/types' import type { FetchMetric } from '../base-http' import type { RequestLifecycleOpts } from '../base-server' import type { FallbackRouteParams } from '../request/fallback-params' -import type { AppSegmentConfig } from '../../build/app-segments/app-segment-config' +import type { AppSegmentConfig } from '../../build/segment-config/app/app-segment-config' import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index 9afebd409e6ae4..53e4947a8d87f3 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -852,13 +852,19 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { isDev: true, page, }) - : {} - - if (staticInfo.amp === true || staticInfo.amp === 'hybrid') { - this.hasAmpEntrypoints = true + : undefined + + if (staticInfo?.type === PAGE_TYPES.PAGES) { + if ( + staticInfo.config?.config?.amp === true || + staticInfo.config?.config?.amp === 'hybrid' + ) { + this.hasAmpEntrypoints = true + } } + const isServerComponent = - isAppPath && staticInfo.rsc !== RSC_MODULE_TYPES.client + isAppPath && staticInfo?.rsc !== RSC_MODULE_TYPES.client const pageType: PAGE_TYPES = entryData.bundlePath.startsWith( 'pages/' @@ -880,7 +886,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { runDependingOnPageType({ page, - pageRuntime: staticInfo.runtime, + pageRuntime: staticInfo?.runtime, pageType, onEdgeServer: () => { // TODO-APP: verify if child entry should support. @@ -922,9 +928,9 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { basePath: this.config.basePath, assetPrefix: this.config.assetPrefix, nextConfigOutput: this.config.output, - preferredRegion: staticInfo.preferredRegion, + preferredRegion: staticInfo?.preferredRegion, middlewareConfig: Buffer.from( - JSON.stringify(staticInfo.middleware || {}) + JSON.stringify(staticInfo?.middleware || {}) ).toString('base64'), }).import : undefined @@ -944,7 +950,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { isServerComponent, appDirLoader, pagesType: isAppPath ? PAGE_TYPES.APP : PAGE_TYPES.PAGES, - preferredRegion: staticInfo.preferredRegion, + preferredRegion: staticInfo?.preferredRegion, }), hasAppDir, }) @@ -1021,9 +1027,9 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { basePath: this.config.basePath, assetPrefix: this.config.assetPrefix, nextConfigOutput: this.config.output, - preferredRegion: staticInfo.preferredRegion, + preferredRegion: staticInfo?.preferredRegion, middlewareConfig: Buffer.from( - JSON.stringify(staticInfo.middleware || {}) + JSON.stringify(staticInfo?.middleware || {}) ).toString('base64'), }) } else if (isAPIRoute(page)) { @@ -1031,8 +1037,8 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { kind: RouteKind.PAGES_API, page, absolutePagePath: relativeRequest, - preferredRegion: staticInfo.preferredRegion, - middlewareConfig: staticInfo.middleware || {}, + preferredRegion: staticInfo?.preferredRegion, + middlewareConfig: staticInfo?.middleware || {}, }) } else if ( !isMiddlewareFile(page) && @@ -1045,8 +1051,8 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { page, pages: this.pagesMapping, absolutePagePath: relativeRequest, - preferredRegion: staticInfo.preferredRegion, - middlewareConfig: staticInfo.middleware ?? {}, + preferredRegion: staticInfo?.preferredRegion, + middlewareConfig: staticInfo?.middleware ?? {}, }) } else { value = relativeRequest diff --git a/packages/next/src/server/dev/static-paths-worker.ts b/packages/next/src/server/dev/static-paths-worker.ts index cde0bdd14d44cc..cd2ef0a7507c55 100644 --- a/packages/next/src/server/dev/static-paths-worker.ts +++ b/packages/next/src/server/dev/static-paths-worker.ts @@ -8,7 +8,7 @@ import { buildStaticPaths, reduceAppConfig, } from '../../build/utils' -import { collectSegments } from '../../build/app-segments/collect-app-segments' +import { collectSegments } from '../../build/segment-config/app/app-segments' import type { PartialStaticPathsResult } from '../../build/utils' import { loadComponents } from '../load-components' import { setHttpClientAndAgentOptions } from '../setup-http-agent-env' diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index 9625b6a65eb4ab..1dbced6daf9c2a 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -59,7 +59,7 @@ export function validateRevalidate( normalizedRevalidate = revalidateVal } else if (typeof revalidateVal !== 'undefined') { throw new Error( - `Invalid revalidate value "${revalidateVal}" on "${route}", must be a non-negative number or "false"` + `Invalid revalidate value "${revalidateVal}" on "${route}", must be a non-negative number or false` ) } return normalizedRevalidate diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index cc1a21a6c2a2d1..e9b12c77c59b1c 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -1,6 +1,6 @@ import type { NextConfig } from '../../config-shared' import type { AppRouteRouteDefinition } from '../../route-definitions/app-route-route-definition' -import type { AppSegmentConfig } from '../../../build/app-segments/app-segment-config' +import type { AppSegmentConfig } from '../../../build/segment-config/app/app-segment-config' import type { NextRequest } from '../../web/spec-extension/request' import type { PrerenderManifest } from '../../../build' import type { NextURL } from '../../web/next-url' @@ -69,7 +69,7 @@ import type { RenderOptsPartial } from '../../app-render/types' import { CacheSignal } from '../../app-render/cache-signal' import { scheduleImmediate } from '../../../lib/scheduler' import { createServerParamsForRoute } from '../../request/params' -import type { AppSegment } from '../../../build/app-segments/collect-app-segments' +import type { AppSegment } from '../../../build/segment-config/app/app-segments' /** * The AppRouteModule is the type of the module exported by the bundled App diff --git a/packages/next/src/server/web/types.ts b/packages/next/src/server/web/types.ts index 85412d0f72edc6..9b8576f78edd78 100644 --- a/packages/next/src/server/web/types.ts +++ b/packages/next/src/server/web/types.ts @@ -6,7 +6,7 @@ import type { CloneableBody } from '../body-streams' import type { OutgoingHttpHeaders } from 'http' import type { FetchMetrics } from '../base-http' -export type { MiddlewareConfig } from '../../build/analysis/get-page-static-info' +export type { MiddlewareConfigInput as MiddlewareConfig } from '../../build/segment-config/middleware/middleware-config' export interface RequestData { headers: OutgoingHttpHeaders diff --git a/packages/next/src/shared/lib/zod.ts b/packages/next/src/shared/lib/zod.ts index 5070394672516e..b14be948368683 100644 --- a/packages/next/src/shared/lib/zod.ts +++ b/packages/next/src/shared/lib/zod.ts @@ -1,5 +1,7 @@ import type { ZodError } from 'next/dist/compiled/zod' import { ZodParsedType, util, type ZodIssue } from 'next/dist/compiled/zod' +import { fromZodError } from 'next/dist/compiled/zod-validation-error' +import * as Log from '../../build/output/log' function processZodErrorMessage(issue: ZodIssue) { let message = issue.message @@ -65,3 +67,11 @@ export function normalizeZodErrors(error: ZodError) { return issues }) } + +export function formatZodError(prefix: string, error: ZodError) { + return fromZodError(error, { prefix: prefix }) +} + +export function reportZodError(prefix: string, error: ZodError) { + Log.error(formatZodError(prefix, error).toString()) +} diff --git a/packages/next/taskfile.js b/packages/next/taskfile.js index 57d462d96f7e0b..8a332e5cb8754a 100644 --- a/packages/next/taskfile.js +++ b/packages/next/taskfile.js @@ -2049,6 +2049,14 @@ export async function ncc_zod(task, opts) { .target('src/compiled/zod') } +externals['zod-validation-error'] = 'next/dist/compiled/zod-validation-error' +export async function ncc_zod_validation_error(task, opts) { + await task + .source(relative(__dirname, require.resolve('zod-validation-error'))) + .ncc({ packageName: 'zod-validation-error', externals }) + .target('src/compiled/zod-validation-error') +} + // eslint-disable-next-line camelcase externals['web-vitals'] = 'next/dist/compiled/web-vitals' export async function ncc_web_vitals(task, opts) { @@ -2384,6 +2392,7 @@ export async function ncc(task, opts) { 'ncc_strip_ansi', 'ncc_superstruct', 'ncc_zod', + 'ncc_zod_validation_error', 'ncc_nft', 'ncc_tar', 'ncc_terser', diff --git a/packages/next/types/$$compiled.internal.d.ts b/packages/next/types/$$compiled.internal.d.ts index ba1de837ef4092..1ede7f53fa82bc 100644 --- a/packages/next/types/$$compiled.internal.d.ts +++ b/packages/next/types/$$compiled.internal.d.ts @@ -527,6 +527,11 @@ declare module 'next/dist/compiled/zod' { export = z } +declare module 'next/dist/compiled/zod-validation-error' { + import * as zve from 'zod-validation-error' + export = zve +} + declare module 'mini-css-extract-plugin' declare module 'next/dist/compiled/loader-utils3' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c2b902067842d..698ebb3f85eb5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1493,6 +1493,9 @@ importers: zod: specifier: 3.22.3 version: 3.22.3 + zod-validation-error: + specifier: 3.4.0 + version: 3.4.0(zod@3.22.3) packages/next-bundle-analyzer: dependencies: @@ -15129,6 +15132,12 @@ packages: peerDependencies: zod: ^3.18.0 + zod-validation-error@3.4.0: + resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -31980,6 +31989,10 @@ snapshots: dependencies: zod: 3.23.8 + zod-validation-error@3.4.0(zod@3.22.3): + dependencies: + zod: 3.22.3 + zod@3.22.3: {} zod@3.23.8: {} diff --git a/test/e2e/app-dir-legacy-edge-runtime-config/app/legacy-runtime-config/page.js b/test/e2e/app-dir-legacy-edge-runtime-config/app/legacy-runtime-config/page.js deleted file mode 100644 index d1387c9d240209..00000000000000 --- a/test/e2e/app-dir-legacy-edge-runtime-config/app/legacy-runtime-config/page.js +++ /dev/null @@ -1,13 +0,0 @@ -/*global globalThis*/ - -export default function Page() { - if ('EdgeRuntime' in globalThis) { - return

Edge!

- } - return

Node!

-} - -export const config = { - runtime: 'edge', - regions: ['us-east-1'], -} diff --git a/test/e2e/app-dir-legacy-edge-runtime-config/index.test.ts b/test/e2e/app-dir-legacy-edge-runtime-config/index.test.ts deleted file mode 100644 index 459eece10d78a1..00000000000000 --- a/test/e2e/app-dir-legacy-edge-runtime-config/index.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { nextTestSetup } from 'e2e-utils' - -describe('app-dir edge runtime config', () => { - const { next, isNextDev, skipped } = nextTestSetup({ - files: __dirname, - skipStart: true, - skipDeployment: true, - }) - - if (skipped) { - return - } - - it('should warn the legacy object config export', async () => { - let error - await next.start().catch((err) => { - error = err - }) - if (isNextDev) { - expect(error).not.toBeDefined() - await next.fetch('/legacy-runtime-config') - } else { - expect(error).toBeDefined() - } - - expect(next.cliOutput).toContain('Page config in ') - expect(next.cliOutput).toContain( - // the full path is more complex, we only care about this part - 'app/legacy-runtime-config/page.js is deprecated. Replace `export const config=…` with the following:' - ) - expect(next.cliOutput).toContain('- `export const runtime = "edge"`') - expect(next.cliOutput).toContain( - '- `export const preferredRegion = ["us-east-1"]`' - ) - }) -}) diff --git a/test/e2e/app-dir-legacy-edge-runtime-config/next.config.js b/test/e2e/app-dir-legacy-edge-runtime-config/next.config.js deleted file mode 100644 index 4ba52ba2c8df67..00000000000000 --- a/test/e2e/app-dir-legacy-edge-runtime-config/next.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {} diff --git a/test/e2e/app-dir/app-invalid-revalidate/app-invalid-revalidate.test.ts b/test/e2e/app-dir/app-invalid-revalidate/app-invalid-revalidate.test.ts index 03c95a0e13ccb5..014ffa8a157bd3 100644 --- a/test/e2e/app-dir/app-invalid-revalidate/app-invalid-revalidate.test.ts +++ b/test/e2e/app-dir/app-invalid-revalidate/app-invalid-revalidate.test.ts @@ -28,7 +28,7 @@ describe('app-invalid-revalidate', () => { await next.fetch('/') } return next.cliOutput - }, /Invalid revalidate value "1" on "\/", must be a non-negative number or "false"/) + }, /Invalid revalidate value "1" on "\/", must be a non-negative number or false/) } finally { await next.patchFile('app/layout.tsx', origText) } @@ -50,7 +50,7 @@ describe('app-invalid-revalidate', () => { await next.fetch('/') } return next.cliOutput - }, /Invalid revalidate value "1" on "\/", must be a non-negative number or "false"/) + }, /Invalid revalidate value "1" on "\/", must be a non-negative number or false/) } finally { await next.patchFile('app/page.tsx', origText) } @@ -72,7 +72,7 @@ describe('app-invalid-revalidate', () => { await next.fetch('/') } return next.cliOutput - }, /Invalid revalidate value "1" on "\/", must be a non-negative number or "false"/) + }, /Invalid revalidate value "1" on "\/", must be a non-negative number or false/) } finally { await next.patchFile('app/page.tsx', origText) } diff --git a/test/integration/invalid-middleware-matchers/test/index.test.js b/test/integration/invalid-middleware-matchers/test/index.test.js index 016018de382abb..f7daf8f9b59332 100644 --- a/test/integration/invalid-middleware-matchers/test/index.test.js +++ b/test/integration/invalid-middleware-matchers/test/index.test.js @@ -30,9 +30,7 @@ const runTests = () => { it('should error when source length is exceeded', async () => { await writeMiddleware([{ source: `/${Array(4096).join('a')}` }]) const stderr = await getStderr() - expect(stderr).toContain( - '`source` exceeds max built length of 4096 for route {"source":"/aaaaaaaaaaaaaaaaaa' - ) + expect(stderr).toContain('exceeds max built length of 4096 for route') }) it('should error during next build for invalid matchers', async () => { @@ -87,48 +85,35 @@ const runTests = () => { ]) const stderr = await getStderr() - expect(stderr).toContain(`\`source\` is missing for route {}`) - expect(stderr).toContain( - `\`source\` is not a string for route {"source":123}` + 'Expected string, received object at "matcher[0]", or source is required at "matcher[0].source"' ) - expect(stderr).toContain( - `\`source\` does not start with / for route {"source":"hello"}` + 'Expected string, received number at "matcher[1].source"' ) - - expect(stderr).toContain( - `invalid field: destination for route {"source":"/hello","destination":"/not-allowed"}` - ) - - expect(stderr).toContain( - `The route null is not a valid object with \`source\`` - ) - - expect(stderr).toContain('Invalid `has` item:') + expect(stderr).toContain('source must start with / at "matcher[2]"') expect(stderr).toContain( - `invalid type "cookiee" for {"type":"cookiee","key":"loggedIn"}` + 'Unrecognized key(s) in object: \'destination\' at "matcher[3]"' ) + expect(stderr).toContain('Expected string, received null at "matcher[4]"') expect(stderr).toContain( - `invalid \`has\` item found for route {"source":"/hello","has":[{"type":"cookiee","key":"loggedIn"}]}` + "Expected 'header' | 'query' | 'cookie' | 'host' at \"matcher[6].has[1].type\"" ) - expect(stderr).toContain('Invalid `has` items:') expect(stderr).toContain( - `invalid type "headerr", invalid key "undefined" for {"type":"headerr"}` + "Expected 'header' | 'query' | 'cookie' | 'host' at \"matcher[5].has[0].type\"" ) expect(stderr).toContain( - `invalid type "queryr" for {"type":"queryr","key":"hello"}` + "Expected 'header' | 'query' | 'cookie' | 'host' at \"matcher[6].has[0].type\"" ) expect(stderr).toContain( - `invalid \`has\` items found for route {"source":"/hello","has":[{"type":"headerr"},{"type":"queryr","key":"hello"}]}` + "Expected 'header' | 'query' | 'cookie' | 'host' at \"matcher[6].has[1].type\"" ) - expect(stderr).toContain(`Valid \`has\` object shape is {`) expect(stderr).toContain( - `invalid field: basePath for route {"source":"/hello","basePath":false}` + 'Unrecognized key(s) in object: \'basePath\' at "matcher[7]"' ) expect(stderr).toContain( - '`locale` must be undefined or false for route {"source":"/hello","locale":true}' + 'Expected string, received object at "matcher[8]", or Invalid literal value, expected false at "matcher[8].locale", or Expected undefined, received boolean at "matcher[8].locale"' ) }) } diff --git a/test/integration/telemetry/test/page-features.test.js b/test/integration/telemetry/test/page-features.test.js index 17f2c99c6c43ba..4ca9ecbd760f2a 100644 --- a/test/integration/telemetry/test/page-features.test.js +++ b/test/integration/telemetry/test/page-features.test.js @@ -183,7 +183,7 @@ describe('page features telemetry', () => { await fs.writeFile( path.join(__dirname, '../app/edge-ssr/page.js'), ` - export const runtime = 'experimental-edge' + export const runtime = 'edge' export default function Page() { return

edge-ssr page

} diff --git a/test/production/app-dir-edge-runtime-with-wasm/index.test.ts b/test/production/app-dir-edge-runtime-with-wasm/index.test.ts index 213e58a04d52d6..b37a2b4ab9d839 100644 --- a/test/production/app-dir-edge-runtime-with-wasm/index.test.ts +++ b/test/production/app-dir-edge-runtime-with-wasm/index.test.ts @@ -32,7 +32,7 @@ const files = { return \`1 + 1 is: $\{two}\` } - export const runtime = "experimental-edge" + export const runtime = "edge" `, 'wasm/add.wasm': new FileRef(path.join(__dirname, 'add.wasm')), } diff --git a/test/production/edge-config-validations/index.test.ts b/test/production/edge-config-validations/index.test.ts index 23da4dae3071fd..af06f565ca3496 100644 --- a/test/production/edge-config-validations/index.test.ts +++ b/test/production/edge-config-validations/index.test.ts @@ -32,7 +32,7 @@ describe('Edge config validations', () => { await expect(next.start()).rejects.toThrow('next build failed') // eslint-disable-next-line jest/no-standalone-expect expect(next.cliOutput).toMatch( - `/middleware exported 'config.unstable_allowDynamic' contains invalid pattern 'true': Expected pattern to be a non-empty string` + '/middleware contains invalid middleware config: Expected string, received boolean at "unstable_allowDynamic", or Expected array, received boolean at "unstable_allowDynamic"' ) } ) diff --git a/test/production/middleware-typescript/app/middleware.ts b/test/production/middleware-typescript/app/middleware.ts index f04666387d1cdf..8119997c3bf435 100644 --- a/test/production/middleware-typescript/app/middleware.ts +++ b/test/production/middleware-typescript/app/middleware.ts @@ -27,5 +27,4 @@ export const middleware: NextMiddleware = function (request) { export const config = { matcher: ['/:path*'], regions: [], - unstable_allowDynamicGlobs: undefined, } satisfies MiddlewareConfig diff --git a/test/unit/parse-page-static-info.test.ts b/test/unit/parse-page-static-info.test.ts index b2bed9e6d9fed1..0c0d088c90c3f6 100644 --- a/test/unit/parse-page-static-info.test.ts +++ b/test/unit/parse-page-static-info.test.ts @@ -1,4 +1,4 @@ -import { getPageStaticInfo } from 'next/dist/build/analysis/get-page-static-info' +import { getPagesPageStaticInfo } from 'next/dist/build/analysis/get-page-static-info' import { PAGE_TYPES } from 'next/dist/lib/page-types' import { join } from 'path' @@ -10,29 +10,34 @@ function createNextConfig() { describe('parse page static info', () => { it('should parse nodejs runtime correctly', async () => { - const { runtime, ssr, ssg } = await getPageStaticInfo({ - pageFilePath: join(fixtureDir, 'page-runtime/nodejs-ssr.js'), - nextConfig: createNextConfig(), - pageType: PAGE_TYPES.PAGES, - }) + const { runtime, getServerSideProps, getStaticProps } = + await getPagesPageStaticInfo({ + page: 'nodejs-ssr', + pageFilePath: join(fixtureDir, 'page-runtime/nodejs-ssr.js'), + nextConfig: createNextConfig(), + pageType: PAGE_TYPES.PAGES, + }) expect(runtime).toBe('nodejs') - expect(ssr).toBe(true) - expect(ssg).toBe(false) + expect(getServerSideProps).toBe(true) + expect(getStaticProps).toBe(false) }) it('should parse static runtime correctly', async () => { - const { runtime, ssr, ssg } = await getPageStaticInfo({ - pageFilePath: join(fixtureDir, 'page-runtime/nodejs.js'), - nextConfig: createNextConfig(), - pageType: PAGE_TYPES.PAGES, - }) + const { runtime, getServerSideProps, getStaticProps } = + await getPagesPageStaticInfo({ + page: 'nodejs', + pageFilePath: join(fixtureDir, 'page-runtime/nodejs.js'), + nextConfig: createNextConfig(), + pageType: PAGE_TYPES.PAGES, + }) expect(runtime).toBe(undefined) - expect(ssr).toBe(false) - expect(ssg).toBe(false) + expect(getServerSideProps).toBe(false) + expect(getStaticProps).toBe(false) }) it('should parse edge runtime correctly', async () => { - const { runtime } = await getPageStaticInfo({ + const { runtime } = await getPagesPageStaticInfo({ + page: 'edge', pageFilePath: join(fixtureDir, 'page-runtime/edge.js'), nextConfig: createNextConfig(), pageType: PAGE_TYPES.PAGES, @@ -41,7 +46,8 @@ describe('parse page static info', () => { }) it('should return undefined if no runtime is specified', async () => { - const { runtime } = await getPageStaticInfo({ + const { runtime } = await getPagesPageStaticInfo({ + page: 'static', pageFilePath: join(fixtureDir, 'page-runtime/static.js'), nextConfig: createNextConfig(), pageType: PAGE_TYPES.PAGES, @@ -50,12 +56,15 @@ describe('parse page static info', () => { }) it('should parse ssr info with variable exported gSSP correctly', async () => { - const { ssr, ssg } = await getPageStaticInfo({ - pageFilePath: join(fixtureDir, 'page-runtime/ssr-variable-gssp.js'), - nextConfig: createNextConfig(), - pageType: PAGE_TYPES.PAGES, - }) - expect(ssr).toBe(true) - expect(ssg).toBe(false) + const { getServerSideProps, getStaticProps } = await getPagesPageStaticInfo( + { + page: 'ssr-variable-gssp', + pageFilePath: join(fixtureDir, 'page-runtime/ssr-variable-gssp.js'), + nextConfig: createNextConfig(), + pageType: PAGE_TYPES.PAGES, + } + ) + expect(getStaticProps).toBe(false) + expect(getServerSideProps).toBe(true) }) })