From 0f1876ef8150f7dfeb56639b2676d72b339c06d3 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sun, 14 Aug 2022 22:21:24 +0200 Subject: [PATCH 1/4] Enable @typescript-eslint/no-use-before-define functions --- .eslintrc.json | 2 +- 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 | 406 +++++++++--------- .../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 +- 18 files changed, 467 insertions(+), 472 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/server/web/sandbox/sandbox.ts b/packages/next/server/web/sandbox/sandbox.ts index 4f1008d119d9..947d0d6132af 100644 --- a/packages/next/server/web/sandbox/sandbox.ts +++ b/packages/next/server/web/sandbox/sandbox.ts @@ -17,6 +17,26 @@ type RunnerFn = (params: { distDir: string }) => Promise +/** + * Decorates the runner function making sure all errors it can produce are + * tagged with `edge-server` so they can properly be rendered in dev. + */ +function withTaggedErrors(fn: RunnerFn): RunnerFn { + return (params) => + fn(params) + .then((result) => ({ + ...result, + waitUntil: result?.waitUntil?.catch((error) => { + // TODO: used COMPILER_NAMES.edgeServer instead. Verify that it does not increase the runtime size. + throw getServerError(error, 'edge-server') + }), + })) + .catch((error) => { + // TODO: used COMPILER_NAMES.edgeServer instead + throw getServerError(error, 'edge-server') + }) +} + export const run = withTaggedErrors(async (params) => { const { runtime, evaluateInContext } = await getModuleContext({ moduleName: params.name, @@ -64,23 +84,3 @@ export const run = withTaggedErrors(async (params) => { await params.request.body?.finalize() } }) - -/** - * Decorates the runner function making sure all errors it can produce are - * tagged with `edge-server` so they can properly be rendered in dev. - */ -function withTaggedErrors(fn: RunnerFn): RunnerFn { - return (params) => - fn(params) - .then((result) => ({ - ...result, - waitUntil: result?.waitUntil?.catch((error) => { - // TODO: used COMPILER_NAMES.edgeServer instead. Verify that it does not increase the runtime size. - throw getServerError(error, 'edge-server') - }), - })) - .catch((error) => { - // TODO: used COMPILER_NAMES.edgeServer instead - throw getServerError(error, 'edge-server') - }) -} diff --git a/packages/next/server/web/utils.ts b/packages/next/server/web/utils.ts index ea6e9f493fb2..1b86c98f60c8 100644 --- a/packages/next/server/web/utils.ts +++ b/packages/next/server/web/utils.ts @@ -13,19 +13,6 @@ export function fromNodeHeaders(object: NodeHeaders): Headers { return headers } -export function toNodeHeaders(headers?: Headers): NodeHeaders { - const result: NodeHeaders = {} - if (headers) { - for (const [key, value] of headers.entries()) { - result[key] = value - if (key.toLowerCase() === 'set-cookie') { - result[key] = splitCookiesString(value) - } - } - } - return result -} - /* Set-Cookie header field-values are sometimes comma joined in one string. This splits them without choking on commas that are within a single set-cookie field-value, such as in the Expires portion. @@ -102,6 +89,19 @@ export function splitCookiesString(cookiesString: string) { return cookiesStrings } +export function toNodeHeaders(headers?: Headers): NodeHeaders { + const result: NodeHeaders = {} + if (headers) { + for (const [key, value] of headers.entries()) { + result[key] = value + if (key.toLowerCase() === 'set-cookie') { + result[key] = splitCookiesString(value) + } + } + } + return result +} + /** * Validate the correctness of a user-provided URL. */ diff --git a/packages/next/shared/lib/i18n/get-locale-redirect.ts b/packages/next/shared/lib/i18n/get-locale-redirect.ts index 96b898ebcca5..9a141632de7b 100644 --- a/packages/next/shared/lib/i18n/get-locale-redirect.ts +++ b/packages/next/shared/lib/i18n/get-locale-redirect.ts @@ -19,6 +19,54 @@ interface Options { urlParsed: { hostname?: string | null; pathname: string } } +function getLocaleFromCookie( + i18n: I18NConfig, + headers: { [key: string]: string | string[] | undefined } = {} +) { + const nextLocale = getCookieParser( + headers || {} + )()?.NEXT_LOCALE?.toLowerCase() + return nextLocale + ? i18n.locales.find((locale) => nextLocale === locale.toLowerCase()) + : undefined +} + +function detectLocale({ + i18n, + headers, + domainLocale, + preferredLocale, + pathLocale, +}: { + i18n: I18NConfig + preferredLocale?: string + headers?: { [key: string]: string | string[] | undefined } + domainLocale?: DomainLocale + pathLocale?: string +}) { + return ( + pathLocale || + domainLocale?.defaultLocale || + getLocaleFromCookie(i18n, headers) || + preferredLocale || + i18n.defaultLocale + ) +} + +function getAcceptPreferredLocale( + i18n: I18NConfig, + headers?: { [key: string]: string | string[] | undefined } +) { + if ( + headers?.['accept-language'] && + !Array.isArray(headers['accept-language']) + ) { + try { + return acceptLanguage(headers['accept-language'], i18n.locales) + } catch (err) {} + } +} + export function getLocaleRedirect({ defaultLocale, domainLocale, @@ -65,51 +113,3 @@ export function getLocaleRedirect({ } } } - -function detectLocale({ - i18n, - headers, - domainLocale, - preferredLocale, - pathLocale, -}: { - i18n: I18NConfig - preferredLocale?: string - headers?: { [key: string]: string | string[] | undefined } - domainLocale?: DomainLocale - pathLocale?: string -}) { - return ( - pathLocale || - domainLocale?.defaultLocale || - getLocaleFromCookie(i18n, headers) || - preferredLocale || - i18n.defaultLocale - ) -} - -function getLocaleFromCookie( - i18n: I18NConfig, - headers: { [key: string]: string | string[] | undefined } = {} -) { - const nextLocale = getCookieParser( - headers || {} - )()?.NEXT_LOCALE?.toLowerCase() - return nextLocale - ? i18n.locales.find((locale) => nextLocale === locale.toLowerCase()) - : undefined -} - -function getAcceptPreferredLocale( - i18n: I18NConfig, - headers?: { [key: string]: string | string[] | undefined } -) { - if ( - headers?.['accept-language'] && - !Array.isArray(headers['accept-language']) - ) { - try { - return acceptLanguage(headers['accept-language'], i18n.locales) - } catch (err) {} - } -} diff --git a/packages/next/shared/lib/router/router.ts b/packages/next/shared/lib/router/router.ts index 72b7c98bd86c..6ce14507a1e0 100644 --- a/packages/next/shared/lib/router/router.ts +++ b/packages/next/shared/lib/router/router.ts @@ -84,6 +84,209 @@ function buildCancellationError() { }) } +interface MiddlewareEffectParams { + fetchData?: () => Promise + locale?: string + asPath: string + router: Router +} + +function matchesMiddleware( + options: MiddlewareEffectParams +): Promise { + return Promise.resolve(options.router.pageLoader.getMiddleware()).then( + (middleware) => { + const { pathname: asPathname } = parsePath(options.asPath) + const cleanedAs = hasBasePath(asPathname) + ? removeBasePath(asPathname) + : asPathname + + const regex = middleware?.location + return ( + !!regex && new RegExp(regex).test(addLocale(cleanedAs, options.locale)) + ) + } + ) +} + +function getMiddlewareData( + source: string, + response: Response, + options: MiddlewareEffectParams +) { + const nextConfig = { + basePath: options.router.basePath, + i18n: { locales: options.router.locales }, + trailingSlash: Boolean(process.env.__NEXT_TRAILING_SLASH), + } + const rewriteHeader = response.headers.get('x-nextjs-rewrite') + + let rewriteTarget = + rewriteHeader || response.headers.get('x-nextjs-matched-path') + + const matchedPath = response.headers.get('x-matched-path') + + if ( + matchedPath && + !rewriteTarget && + !matchedPath.includes('__next_data_catchall') && + !matchedPath.includes('/_error') && + !matchedPath.includes('/404') + ) { + // leverage x-matched-path to detect next.config.js rewrites + rewriteTarget = matchedPath + } + + if (rewriteTarget) { + if (rewriteTarget.startsWith('/')) { + const parsedRewriteTarget = parseRelativeUrl(rewriteTarget) + const pathnameInfo = getNextPathnameInfo(parsedRewriteTarget.pathname, { + nextConfig, + parseData: true, + }) + + let fsPathname = removeTrailingSlash(pathnameInfo.pathname) + return Promise.all([ + options.router.pageLoader.getPageList(), + getClientBuildManifest(), + ]).then(([pages, { __rewrites: rewrites }]: any) => { + let as = addLocale(pathnameInfo.pathname, pathnameInfo.locale) + + if ( + isDynamicRoute(as) || + (!rewriteHeader && + pages.includes( + normalizeLocalePath(removeBasePath(as), options.router.locales) + .pathname + )) + ) { + const parsedSource = getNextPathnameInfo( + parseRelativeUrl(source).pathname, + { parseData: true } + ) + + as = addBasePath(parsedSource.pathname) + parsedRewriteTarget.pathname = as + } + + if (process.env.__NEXT_HAS_REWRITES) { + const result = resolveRewrites( + as, + pages, + rewrites, + parsedRewriteTarget.query, + (path: string) => resolveDynamicRoute(path, pages), + options.router.locales + ) + + if (result.matchedPage) { + parsedRewriteTarget.pathname = result.parsedAs.pathname + as = parsedRewriteTarget.pathname + Object.assign(parsedRewriteTarget.query, result.parsedAs.query) + } + } else if (!pages.includes(fsPathname)) { + const resolvedPathname = resolveDynamicRoute(fsPathname, pages) + + if (resolvedPathname !== fsPathname) { + fsPathname = resolvedPathname + } + } + + const resolvedHref = !pages.includes(fsPathname) + ? resolveDynamicRoute( + normalizeLocalePath( + removeBasePath(parsedRewriteTarget.pathname), + options.router.locales + ).pathname, + pages + ) + : fsPathname + + if (isDynamicRoute(resolvedHref)) { + const matches = getRouteMatcher(getRouteRegex(resolvedHref))(as) + Object.assign(parsedRewriteTarget.query, matches || {}) + } + + return { + type: 'rewrite' as const, + parsedAs: parsedRewriteTarget, + resolvedHref, + } + }) + } + + const src = parsePath(source) + const pathname = formatNextPathnameInfo({ + ...getNextPathnameInfo(src.pathname, { nextConfig, parseData: true }), + defaultLocale: options.router.defaultLocale, + buildId: '', + }) + + return Promise.resolve({ + type: 'redirect-external' as const, + destination: `${pathname}${src.query}${src.hash}`, + }) + } + + const redirectTarget = response.headers.get('x-nextjs-redirect') + + if (redirectTarget) { + if (redirectTarget.startsWith('/')) { + const src = parsePath(redirectTarget) + const pathname = formatNextPathnameInfo({ + ...getNextPathnameInfo(src.pathname, { nextConfig, parseData: true }), + defaultLocale: options.router.defaultLocale, + buildId: '', + }) + + return Promise.resolve({ + type: 'redirect-internal' as const, + newAs: `${pathname}${src.query}${src.hash}`, + newUrl: `${pathname}${src.query}${src.hash}`, + }) + } + + return Promise.resolve({ + type: 'redirect-external' as const, + destination: redirectTarget, + }) + } + + return Promise.resolve({ type: 'next' as const }) +} + +function withMiddlewareEffects( + options: MiddlewareEffectParams +) { + return matchesMiddleware(options).then((matches) => { + if (matches && options.fetchData) { + return options + .fetchData() + .then((data) => + getMiddlewareData(data.dataHref, data.response, options).then( + (effect) => ({ + dataHref: data.dataHref, + json: data.json, + response: data.response, + text: data.text, + effect, + }) + ) + ) + .catch((_err) => { + /** + * TODO: Revisit this in the future. + * For now we will not consider middleware data errors to be fatal. + * maybe we should revisit in the future. + */ + return null + }) + } + + return null + }) +} + /** * Detects whether a given url is routable by the Next.js router (browser only). */ @@ -2252,206 +2455,3 @@ export default class Router implements BaseRouter { return this.state.isPreview } } - -interface MiddlewareEffectParams { - fetchData?: () => Promise - locale?: string - asPath: string - router: Router -} - -function matchesMiddleware( - options: MiddlewareEffectParams -): Promise { - return Promise.resolve(options.router.pageLoader.getMiddleware()).then( - (middleware) => { - const { pathname: asPathname } = parsePath(options.asPath) - const cleanedAs = hasBasePath(asPathname) - ? removeBasePath(asPathname) - : asPathname - - const regex = middleware?.location - return ( - !!regex && new RegExp(regex).test(addLocale(cleanedAs, options.locale)) - ) - } - ) -} - -function withMiddlewareEffects( - options: MiddlewareEffectParams -) { - return matchesMiddleware(options).then((matches) => { - if (matches && options.fetchData) { - return options - .fetchData() - .then((data) => - getMiddlewareData(data.dataHref, data.response, options).then( - (effect) => ({ - dataHref: data.dataHref, - json: data.json, - response: data.response, - text: data.text, - effect, - }) - ) - ) - .catch((_err) => { - /** - * TODO: Revisit this in the future. - * For now we will not consider middleware data errors to be fatal. - * maybe we should revisit in the future. - */ - return null - }) - } - - return null - }) -} - -function getMiddlewareData( - source: string, - response: Response, - options: MiddlewareEffectParams -) { - const nextConfig = { - basePath: options.router.basePath, - i18n: { locales: options.router.locales }, - trailingSlash: Boolean(process.env.__NEXT_TRAILING_SLASH), - } - const rewriteHeader = response.headers.get('x-nextjs-rewrite') - - let rewriteTarget = - rewriteHeader || response.headers.get('x-nextjs-matched-path') - - const matchedPath = response.headers.get('x-matched-path') - - if ( - matchedPath && - !rewriteTarget && - !matchedPath.includes('__next_data_catchall') && - !matchedPath.includes('/_error') && - !matchedPath.includes('/404') - ) { - // leverage x-matched-path to detect next.config.js rewrites - rewriteTarget = matchedPath - } - - if (rewriteTarget) { - if (rewriteTarget.startsWith('/')) { - const parsedRewriteTarget = parseRelativeUrl(rewriteTarget) - const pathnameInfo = getNextPathnameInfo(parsedRewriteTarget.pathname, { - nextConfig, - parseData: true, - }) - - let fsPathname = removeTrailingSlash(pathnameInfo.pathname) - return Promise.all([ - options.router.pageLoader.getPageList(), - getClientBuildManifest(), - ]).then(([pages, { __rewrites: rewrites }]: any) => { - let as = addLocale(pathnameInfo.pathname, pathnameInfo.locale) - - if ( - isDynamicRoute(as) || - (!rewriteHeader && - pages.includes( - normalizeLocalePath(removeBasePath(as), options.router.locales) - .pathname - )) - ) { - const parsedSource = getNextPathnameInfo( - parseRelativeUrl(source).pathname, - { parseData: true } - ) - - as = addBasePath(parsedSource.pathname) - parsedRewriteTarget.pathname = as - } - - if (process.env.__NEXT_HAS_REWRITES) { - const result = resolveRewrites( - as, - pages, - rewrites, - parsedRewriteTarget.query, - (path: string) => resolveDynamicRoute(path, pages), - options.router.locales - ) - - if (result.matchedPage) { - parsedRewriteTarget.pathname = result.parsedAs.pathname - as = parsedRewriteTarget.pathname - Object.assign(parsedRewriteTarget.query, result.parsedAs.query) - } - } else if (!pages.includes(fsPathname)) { - const resolvedPathname = resolveDynamicRoute(fsPathname, pages) - - if (resolvedPathname !== fsPathname) { - fsPathname = resolvedPathname - } - } - - const resolvedHref = !pages.includes(fsPathname) - ? resolveDynamicRoute( - normalizeLocalePath( - removeBasePath(parsedRewriteTarget.pathname), - options.router.locales - ).pathname, - pages - ) - : fsPathname - - if (isDynamicRoute(resolvedHref)) { - const matches = getRouteMatcher(getRouteRegex(resolvedHref))(as) - Object.assign(parsedRewriteTarget.query, matches || {}) - } - - return { - type: 'rewrite' as const, - parsedAs: parsedRewriteTarget, - resolvedHref, - } - }) - } - - const src = parsePath(source) - const pathname = formatNextPathnameInfo({ - ...getNextPathnameInfo(src.pathname, { nextConfig, parseData: true }), - defaultLocale: options.router.defaultLocale, - buildId: '', - }) - - return Promise.resolve({ - type: 'redirect-external' as const, - destination: `${pathname}${src.query}${src.hash}`, - }) - } - - const redirectTarget = response.headers.get('x-nextjs-redirect') - - if (redirectTarget) { - if (redirectTarget.startsWith('/')) { - const src = parsePath(redirectTarget) - const pathname = formatNextPathnameInfo({ - ...getNextPathnameInfo(src.pathname, { nextConfig, parseData: true }), - defaultLocale: options.router.defaultLocale, - buildId: '', - }) - - return Promise.resolve({ - type: 'redirect-internal' as const, - newAs: `${pathname}${src.query}${src.hash}`, - newUrl: `${pathname}${src.query}${src.hash}`, - }) - } - - return Promise.resolve({ - type: 'redirect-external' as const, - destination: redirectTarget, - }) - } - - return Promise.resolve({ type: 'next' as const }) -} diff --git a/packages/next/shared/lib/router/utils/prepare-destination.ts b/packages/next/shared/lib/router/utils/prepare-destination.ts index 72c48fd3c778..e699e82cac20 100644 --- a/packages/next/shared/lib/router/utils/prepare-destination.ts +++ b/packages/next/shared/lib/router/utils/prepare-destination.ts @@ -9,6 +9,37 @@ import { compile, pathToRegexp } from 'next/dist/compiled/path-to-regexp' import { escapeStringRegexp } from '../../escape-regexp' import { parseUrl } from './parse-url' +/** + * Ensure only a-zA-Z are used for param names for proper interpolating + * with path-to-regexp + */ +function getSafeParamName(paramName: string) { + let newParamName = '' + + for (let i = 0; i < paramName.length; i++) { + const charCode = paramName.charCodeAt(i) + + if ( + (charCode > 64 && charCode < 91) || // A-Z + (charCode > 96 && charCode < 123) // a-z + ) { + newParamName += paramName[i] + } + } + return newParamName +} + +function escapeSegment(str: string, segmentName: string) { + return str.replace( + new RegExp(`:${escapeStringRegexp(segmentName)}`, 'g'), + `__ESC_COLON_${segmentName}` + ) +} + +function unescapeSegments(str: string) { + return str.replace(/__ESC_COLON_/gi, ':') +} + export function matchHas( req: BaseNextRequest | IncomingMessage, has: RouteHas[], @@ -222,34 +253,3 @@ export function prepareDestination(args: { parsedDestination, } } - -/** - * Ensure only a-zA-Z are used for param names for proper interpolating - * with path-to-regexp - */ -function getSafeParamName(paramName: string) { - let newParamName = '' - - for (let i = 0; i < paramName.length; i++) { - const charCode = paramName.charCodeAt(i) - - if ( - (charCode > 64 && charCode < 91) || // A-Z - (charCode > 96 && charCode < 123) // a-z - ) { - newParamName += paramName[i] - } - } - return newParamName -} - -function escapeSegment(str: string, segmentName: string) { - return str.replace( - new RegExp(`:${escapeStringRegexp(segmentName)}`, 'g'), - `__ESC_COLON_${segmentName}` - ) -} - -function unescapeSegments(str: string) { - return str.replace(/__ESC_COLON_/gi, ':') -} diff --git a/packages/next/shared/lib/router/utils/route-regex.ts b/packages/next/shared/lib/router/utils/route-regex.ts index da352438918d..0f6069555087 100644 --- a/packages/next/shared/lib/router/utils/route-regex.ts +++ b/packages/next/shared/lib/router/utils/route-regex.ts @@ -13,30 +13,22 @@ export interface RouteRegex { } /** - * From a normalized route this function generates a regular expression and - * a corresponding groups object inteded to be used to store matching groups - * from the regular expression. + * Parses a given parameter from a route to a data structure that can be used + * to generate the parametrized route. Examples: + * - `[...slug]` -> `{ name: 'slug', repeat: true, optional: true }` + * - `[foo]` -> `{ name: 'foo', repeat: false, optional: true }` + * - `bar` -> `{ name: 'bar', repeat: false, optional: false }` */ -export function getRouteRegex(normalizedRoute: string): RouteRegex { - const { parameterizedRoute, groups } = getParametrizedRoute(normalizedRoute) - return { - re: new RegExp(`^${parameterizedRoute}(?:/)?$`), - groups: groups, +function parseParameter(param: string) { + const optional = param.startsWith('[') && param.endsWith(']') + if (optional) { + param = param.slice(1, -1) } -} - -/** - * This function extends `getRouteRegex` generating also a named regexp where - * each group is named along with a routeKeys object that indexes the assigned - * named group with its corresponding key. - */ -export function getNamedRouteRegex(normalizedRoute: string) { - const result = getNamedParametrizedRoute(normalizedRoute) - return { - ...getRouteRegex(normalizedRoute), - namedRegex: `^${result.namedParameterizedRoute}(?:/)?$`, - routeKeys: result.routeKeys, + const repeat = param.startsWith('...') + if (repeat) { + param = param.slice(3) } + return { key: param, repeat, optional } } function getParametrizedRoute(route: string) { @@ -59,6 +51,42 @@ function getParametrizedRoute(route: string) { } } +/** + * From a normalized route this function generates a regular expression and + * a corresponding groups object inteded to be used to store matching groups + * from the regular expression. + */ +export function getRouteRegex(normalizedRoute: string): RouteRegex { + const { parameterizedRoute, groups } = getParametrizedRoute(normalizedRoute) + return { + re: new RegExp(`^${parameterizedRoute}(?:/)?$`), + groups: groups, + } +} + +/** + * Builds a function to generate a minimal routeKey using only a-z and minimal + * number of characters. + */ +function buildGetSafeRouteKey() { + let routeKeyCharCode = 97 + let routeKeyCharLength = 1 + + return () => { + let routeKey = '' + for (let i = 0; i < routeKeyCharLength; i++) { + routeKey += String.fromCharCode(routeKeyCharCode) + routeKeyCharCode++ + + if (routeKeyCharCode > 122) { + routeKeyCharLength++ + routeKeyCharCode = 97 + } + } + return routeKey + } +} + function getNamedParametrizedRoute(route: string) { const segments = removeTrailingSlash(route).slice(1).split('/') const getSafeRouteKey = buildGetSafeRouteKey() @@ -102,44 +130,16 @@ function getNamedParametrizedRoute(route: string) { } /** - * Parses a given parameter from a route to a data structure that can be used - * to generate the parametrized route. Examples: - * - `[...slug]` -> `{ name: 'slug', repeat: true, optional: true }` - * - `[foo]` -> `{ name: 'foo', repeat: false, optional: true }` - * - `bar` -> `{ name: 'bar', repeat: false, optional: false }` - */ -function parseParameter(param: string) { - const optional = param.startsWith('[') && param.endsWith(']') - if (optional) { - param = param.slice(1, -1) - } - const repeat = param.startsWith('...') - if (repeat) { - param = param.slice(3) - } - return { key: param, repeat, optional } -} - -/** - * Builds a function to generate a minimal routeKey using only a-z and minimal - * number of characters. + * This function extends `getRouteRegex` generating also a named regexp where + * each group is named along with a routeKeys object that indexes the assigned + * named group with its corresponding key. */ -function buildGetSafeRouteKey() { - let routeKeyCharCode = 97 - let routeKeyCharLength = 1 - - return () => { - let routeKey = '' - for (let i = 0; i < routeKeyCharLength; i++) { - routeKey += String.fromCharCode(routeKeyCharCode) - routeKeyCharCode++ - - if (routeKeyCharCode > 122) { - routeKeyCharLength++ - routeKeyCharCode = 97 - } - } - return routeKey +export function getNamedRouteRegex(normalizedRoute: string) { + const result = getNamedParametrizedRoute(normalizedRoute) + return { + ...getRouteRegex(normalizedRoute), + namedRegex: `^${result.namedParameterizedRoute}(?:/)?$`, + routeKeys: result.routeKeys, } } diff --git a/packages/next/telemetry/storage.ts b/packages/next/telemetry/storage.ts index c68b94d73ef0..8c2274378028 100644 --- a/packages/next/telemetry/storage.ts +++ b/packages/next/telemetry/storage.ts @@ -45,6 +45,16 @@ type RecordObject = { reason?: any } +function getStorageDirectory(distDir: string): string | undefined { + const isLikelyEphemeral = ciEnvironment.isCI || isDockerFunction() + + if (isLikelyEphemeral) { + return path.join(distDir, 'cache') + } + + return undefined +} + export class Telemetry { private conf: Conf | null private sessionId: string @@ -244,13 +254,3 @@ export class Telemetry { }) } } - -function getStorageDirectory(distDir: string): string | undefined { - const isLikelyEphemeral = ciEnvironment.isCI || isDockerFunction() - - if (isLikelyEphemeral) { - return path.join(distDir, 'cache') - } - - return undefined -} diff --git a/packages/next/types/webpack.d.ts b/packages/next/types/webpack.d.ts index c0a871952c4e..afac524b5f88 100644 --- a/packages/next/types/webpack.d.ts +++ b/packages/next/types/webpack.d.ts @@ -57,8 +57,6 @@ declare module 'webpack4' { import { RawSourceMap } from 'source-map' import { ConcatSource } from 'webpack-sources1' - export = webpack - function webpack( options: webpack.Configuration, handler: webpack.Compiler.Handler @@ -75,6 +73,8 @@ declare module 'webpack4' { options: webpack.Configuration | webpack.Configuration[] ): webpack.Compiler | webpack.MultiCompiler + export = webpack + namespace webpack { /** Webpack package version. */ const version: string | undefined diff --git a/packages/react-dev-overlay/src/internal/helpers/nodeStackFrames.ts b/packages/react-dev-overlay/src/internal/helpers/nodeStackFrames.ts index a01ec4b1c890..382b33c8b470 100644 --- a/packages/react-dev-overlay/src/internal/helpers/nodeStackFrames.ts +++ b/packages/react-dev-overlay/src/internal/helpers/nodeStackFrames.ts @@ -27,6 +27,15 @@ export function getErrorSource(error: Error): 'server' | 'edge-server' | null { type ErrorType = 'edge-server' | 'server' +export function decorateServerError(error: Error, type: ErrorType) { + Object.defineProperty(error, symbolError, { + writable: false, + enumerable: false, + configurable: false, + value: type, + }) +} + export function getServerError(error: Error, type: ErrorType): Error { let n: Error try { @@ -61,12 +70,3 @@ export function getServerError(error: Error, type: ErrorType): Error { decorateServerError(n, type) return n } - -export function decorateServerError(error: Error, type: ErrorType) { - Object.defineProperty(error, symbolError, { - writable: false, - enumerable: false, - configurable: false, - value: type, - }) -} diff --git a/packages/react-dev-overlay/src/internal/helpers/stack-frame.ts b/packages/react-dev-overlay/src/internal/helpers/stack-frame.ts index f799115e2a2e..36f326d1b54e 100644 --- a/packages/react-dev-overlay/src/internal/helpers/stack-frame.ts +++ b/packages/react-dev-overlay/src/internal/helpers/stack-frame.ts @@ -30,16 +30,6 @@ export type OriginalStackFrame = originalCodeFrame: null } -export function getOriginalStackFrames( - frames: StackFrame[], - type: 'server' | 'edge-server' | null, - errorMessage: string -) { - return Promise.all( - frames.map((frame) => getOriginalStackFrame(frame, type, errorMessage)) - ) -} - export function getOriginalStackFrame( source: StackFrame, type: 'server' | 'edge-server' | null, @@ -117,6 +107,16 @@ export function getOriginalStackFrame( })) } +export function getOriginalStackFrames( + frames: StackFrame[], + type: 'server' | 'edge-server' | null, + errorMessage: string +) { + return Promise.all( + frames.map((frame) => getOriginalStackFrame(frame, type, errorMessage)) + ) +} + export function getFrameSource(frame: StackFrame): string { let str = '' try { diff --git a/packages/react-refresh-utils/internal/helpers.ts b/packages/react-refresh-utils/internal/helpers.ts index 4344c3e98c40..7428b73196d1 100644 --- a/packages/react-refresh-utils/internal/helpers.ts +++ b/packages/react-refresh-utils/internal/helpers.ts @@ -74,6 +74,25 @@ function registerExportsForReactRefresh( } } +function getRefreshBoundarySignature(moduleExports: unknown): Array { + var signature = [] + signature.push(RefreshRuntime.getFamilyByType(moduleExports)) + if (moduleExports == null || typeof moduleExports !== 'object') { + // Exit if we can't iterate over exports. + // (This is important for legacy environments.) + return signature + } + for (var key in moduleExports) { + if (isSafeExport(key)) { + continue + } + var exportValue = moduleExports[key] + signature.push(key) + signature.push(RefreshRuntime.getFamilyByType(exportValue)) + } + return signature +} + function isReactRefreshBoundary(moduleExports: unknown): boolean { if (RefreshRuntime.isLikelyComponentType(moduleExports)) { return true @@ -114,25 +133,6 @@ function shouldInvalidateReactRefreshBoundary( return false } -function getRefreshBoundarySignature(moduleExports: unknown): Array { - var signature = [] - signature.push(RefreshRuntime.getFamilyByType(moduleExports)) - if (moduleExports == null || typeof moduleExports !== 'object') { - // Exit if we can't iterate over exports. - // (This is important for legacy environments.) - return signature - } - for (var key in moduleExports) { - if (isSafeExport(key)) { - continue - } - var exportValue = moduleExports[key] - signature.push(key) - signature.push(RefreshRuntime.getFamilyByType(exportValue)) - } - return signature -} - var isUpdateScheduled: boolean = false function scheduleUpdate() { if (isUpdateScheduled) { diff --git a/test/e2e/edge-can-use-wasm-files/index.test.ts b/test/e2e/edge-can-use-wasm-files/index.test.ts index 5311542d15d3..48050bc0c584 100644 --- a/test/e2e/edge-can-use-wasm-files/index.test.ts +++ b/test/e2e/edge-can-use-wasm-files/index.test.ts @@ -4,6 +4,10 @@ import { fetchViaHTTP } from 'next-test-utils' import path from 'path' import fs from 'fs-extra' +function extractJSON(response) { + return JSON.parse(response.headers.get('data') ?? '{}') +} + function baseNextConfig(): Parameters[0] { return { files: { @@ -144,7 +148,3 @@ describe('middleware can use wasm files with the experimental modes on', () => { }) }) }) - -function extractJSON(response) { - return JSON.parse(response.headers.get('data') ?? '{}') -} diff --git a/test/e2e/middleware-general/test/index.test.ts b/test/e2e/middleware-general/test/index.test.ts index 071391cdb0a5..44adbdf0f1ae 100644 --- a/test/e2e/middleware-general/test/index.test.ts +++ b/test/e2e/middleware-general/test/index.test.ts @@ -62,15 +62,13 @@ describe('Middleware Runtime', () => { }) } - describe('with i18n', () => { - setup({ i18n: true }) - runTests({ i18n: true }) - }) + function readMiddlewareJSON(response) { + return JSON.parse(response.headers.get('data')) + } - describe('without i18n', () => { - setup({ i18n: false }) - runTests({ i18n: false }) - }) + function readMiddlewareError(response) { + return response.headers.get('error') + } function runTests({ i18n }: { i18n?: boolean }) { if ((global as any).isNextDev) { @@ -618,12 +616,13 @@ describe('Middleware Runtime', () => { ]) }) } -}) - -function readMiddlewareJSON(response) { - return JSON.parse(response.headers.get('data')) -} + describe('with i18n', () => { + setup({ i18n: true }) + runTests({ i18n: true }) + }) -function readMiddlewareError(response) { - return response.headers.get('error') -} + describe('without i18n', () => { + setup({ i18n: false }) + runTests({ i18n: false }) + }) +}) diff --git a/test/e2e/middleware-redirects/test/index.test.ts b/test/e2e/middleware-redirects/test/index.test.ts index 106599a04f07..f245b22fb38f 100644 --- a/test/e2e/middleware-redirects/test/index.test.ts +++ b/test/e2e/middleware-redirects/test/index.test.ts @@ -20,11 +20,6 @@ describe('Middleware Redirect', () => { }, }) }) - - tests() - testsWithLocale() - testsWithLocale('/fr') - function tests() { it('should redirect correctly with redirect in next.config.js', async () => { const browser = await webdriver(next.url, '/') @@ -163,4 +158,7 @@ describe('Middleware Redirect', () => { expect(errors).not.toContain('Failed to lookup route') }) } + tests() + testsWithLocale() + testsWithLocale('/fr') }) diff --git a/test/e2e/middleware-responses/test/index.test.ts b/test/e2e/middleware-responses/test/index.test.ts index 07e3f1d5b67a..1cade1371abd 100644 --- a/test/e2e/middleware-responses/test/index.test.ts +++ b/test/e2e/middleware-responses/test/index.test.ts @@ -18,10 +18,6 @@ describe('Middleware Responses', () => { }, }) }) - - testsWithLocale() - testsWithLocale('/fr') - function testsWithLocale(locale = '') { const label = locale ? `${locale} ` : `` @@ -92,4 +88,6 @@ describe('Middleware Responses', () => { expect(res.headers.raw()['set-cookie']).toEqual(['bar=chocochip']) }) } + testsWithLocale() + testsWithLocale('/fr') }) diff --git a/test/e2e/middleware-rewrites/test/index.test.ts b/test/e2e/middleware-rewrites/test/index.test.ts index 131282868802..59b2393868fb 100644 --- a/test/e2e/middleware-rewrites/test/index.test.ts +++ b/test/e2e/middleware-rewrites/test/index.test.ts @@ -22,10 +22,6 @@ describe('Middleware Rewrite', () => { }) }) - tests() - testsWithLocale() - testsWithLocale('/fr') - function tests() { it('should not have un-necessary data request on rewrite', async () => { const browser = await webdriver(next.url, '/to-blog/first', { @@ -576,6 +572,19 @@ describe('Middleware Rewrite', () => { function testsWithLocale(locale = '') { const label = locale ? `${locale} ` : `` + function getCookieFromResponse(res, cookieName) { + // node-fetch bundles the cookies as string in the Response + const cookieArray = res.headers.raw()['set-cookie'] + for (const cookie of cookieArray) { + let individualCookieParams = cookie.split(';') + let individualCookie = individualCookieParams[0].split('=') + if (individualCookie[0] === cookieName) { + return individualCookie[1] + } + } + return -1 + } + it(`${label}should add a cookie and rewrite to a/b test`, async () => { const res = await fetchViaHTTP(next.url, `${locale}/rewrite-to-ab-test`) const html = await res.text() @@ -766,16 +775,7 @@ describe('Middleware Rewrite', () => { }) } - function getCookieFromResponse(res, cookieName) { - // node-fetch bundles the cookies as string in the Response - const cookieArray = res.headers.raw()['set-cookie'] - for (const cookie of cookieArray) { - let individualCookieParams = cookie.split(';') - let individualCookie = individualCookieParams[0].split('=') - if (individualCookie[0] === cookieName) { - return individualCookie[1] - } - } - return -1 - } + tests() + testsWithLocale() + testsWithLocale('/fr') }) diff --git a/test/e2e/proxy-request-with-middleware/test/index.test.ts b/test/e2e/proxy-request-with-middleware/test/index.test.ts index 0b47e9d7d593..3795ca3052b2 100644 --- a/test/e2e/proxy-request-with-middleware/test/index.test.ts +++ b/test/e2e/proxy-request-with-middleware/test/index.test.ts @@ -21,9 +21,6 @@ describe('Requests not effected when middleware used', () => { }) }) - sendRequest('GET') - sendRequest('POST') - function sendRequest(method) { const body = !['get', 'head'].includes(method.toLowerCase()) ? JSON.stringify({ @@ -50,4 +47,7 @@ describe('Requests not effected when middleware used', () => { expect(data.headers).toEqual(expect.objectContaining(headers)) }) } + + sendRequest('GET') + sendRequest('POST') }) From e15f98ced51a741febeb56d3813ea0d537fd60d4 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sun, 14 Aug 2022 22:36:02 +0200 Subject: [PATCH 2/4] update context.tsx --- packages/next/server/web/sandbox/context.ts | 228 ++++++++++---------- 1 file changed, 114 insertions(+), 114 deletions(-) diff --git a/packages/next/server/web/sandbox/context.ts b/packages/next/server/web/sandbox/context.ts index 6705ab73e297..65b076daa3ae 100644 --- a/packages/next/server/web/sandbox/context.ts +++ b/packages/next/server/web/sandbox/context.ts @@ -47,63 +47,84 @@ export function clearModuleContext(path: string, content: Buffer | string) { } } -interface ModuleContextOptions { - moduleName: string - onWarning: (warn: Error) => void - useCache: boolean - env: string[] - distDir: string - edgeFunctionEntry: Pick +async function loadWasm( + wasm: AssetBinding[] +): Promise> { + const modules: Record = {} + + await Promise.all( + wasm.map(async (binding) => { + const module = await WebAssembly.compile( + await fs.readFile(binding.filePath) + ) + modules[binding.name] = module + }) + ) + + return modules } -const pendingModuleCaches = new Map>() +function buildEnvironmentVariablesFrom( + keys: string[] +): Record { + const pairs = keys.map((key) => [key, process.env[key]]) + const env = Object.fromEntries(pairs) + env.NEXT_RUNTIME = 'edge' + return env +} -function getModuleContextShared(options: ModuleContextOptions) { - let deferredModuleContext = pendingModuleCaches.get(options.moduleName) - if (!deferredModuleContext) { - deferredModuleContext = createModuleContext(options) - pendingModuleCaches.set(options.moduleName, deferredModuleContext) - } - return deferredModuleContext +function throwUnsupportedAPIError(name: string) { + const error = + new Error(`A Node.js API is used (${name}) which is not supported in the Edge Runtime. +Learn more: https://nextjs.org/docs/api-reference/edge-runtime`) + decorateServerError(error, COMPILER_NAMES.edgeServer) + throw error } -/** - * For a given module name this function will get a cached module - * context or create it. It will return the module context along - * with a function that allows to run some code from a given - * filepath within the context. - */ -export async function getModuleContext(options: ModuleContextOptions): Promise<{ - evaluateInContext: (filepath: string) => void - runtime: EdgeRuntime - paths: Map - warnedEvals: Set -}> { - let moduleContext = options.useCache - ? moduleContexts.get(options.moduleName) - : await getModuleContextShared(options) +function createProcessPolyfill(options: Pick) { + const env = buildEnvironmentVariablesFrom(options.env) - if (!moduleContext) { - moduleContext = await createModuleContext(options) - moduleContexts.set(options.moduleName, moduleContext) + const processPolyfill = { env } + const overridenValue: Record = {} + for (const key of Object.keys(process)) { + if (key === 'env') continue + Object.defineProperty(processPolyfill, key, { + get() { + if (overridenValue[key]) { + return overridenValue[key] + } + if (typeof (process as any)[key] === 'function') { + return () => throwUnsupportedAPIError(`process.${key}`) + } + return undefined + }, + set(value) { + overridenValue[key] = value + }, + enumerable: false, + }) } + return processPolyfill +} - const evaluateInContext = (filepath: string) => { - if (!moduleContext!.paths.has(filepath)) { - const content = readFileSync(filepath, 'utf-8') - try { - moduleContext?.runtime.evaluate(content) - moduleContext!.paths.set(filepath, content) - } catch (error) { - if (options.useCache) { - moduleContext?.paths.delete(options.moduleName) - } - throw error +function addStub(context: EdgeRuntime['context'], name: string) { + Object.defineProperty(context, name, { + get() { + return function () { + throwUnsupportedAPIError(name) } + }, + enumerable: false, + }) +} + +function getDecorateUnhandledError(runtime: EdgeRuntime) { + const EdgeRuntimeError = runtime.evaluate(`Error`) + return (error: any) => { + if (error instanceof EdgeRuntimeError) { + decorateServerError(error, COMPILER_NAMES.edgeServer) } } - - return { ...moduleContext, evaluateInContext } } /** @@ -270,82 +291,61 @@ Learn More: https://nextjs.org/docs/messages/middleware-dynamic-wasm-compilation } } -async function loadWasm( - wasm: AssetBinding[] -): Promise> { - const modules: Record = {} - - await Promise.all( - wasm.map(async (binding) => { - const module = await WebAssembly.compile( - await fs.readFile(binding.filePath) - ) - modules[binding.name] = module - }) - ) - - return modules -} - -function buildEnvironmentVariablesFrom( - keys: string[] -): Record { - const pairs = keys.map((key) => [key, process.env[key]]) - const env = Object.fromEntries(pairs) - env.NEXT_RUNTIME = 'edge' - return env +interface ModuleContextOptions { + moduleName: string + onWarning: (warn: Error) => void + useCache: boolean + env: string[] + distDir: string + edgeFunctionEntry: Pick } -function createProcessPolyfill(options: Pick) { - const env = buildEnvironmentVariablesFrom(options.env) +const pendingModuleCaches = new Map>() - const processPolyfill = { env } - const overridenValue: Record = {} - for (const key of Object.keys(process)) { - if (key === 'env') continue - Object.defineProperty(processPolyfill, key, { - get() { - if (overridenValue[key]) { - return overridenValue[key] - } - if (typeof (process as any)[key] === 'function') { - return () => throwUnsupportedAPIError(`process.${key}`) - } - return undefined - }, - set(value) { - overridenValue[key] = value - }, - enumerable: false, - }) +function getModuleContextShared(options: ModuleContextOptions) { + let deferredModuleContext = pendingModuleCaches.get(options.moduleName) + if (!deferredModuleContext) { + deferredModuleContext = createModuleContext(options) + pendingModuleCaches.set(options.moduleName, deferredModuleContext) } - return processPolyfill + return deferredModuleContext } -function addStub(context: EdgeRuntime['context'], name: string) { - Object.defineProperty(context, name, { - get() { - return function () { - throwUnsupportedAPIError(name) - } - }, - enumerable: false, - }) -} +/** + * For a given module name this function will get a cached module + * context or create it. It will return the module context along + * with a function that allows to run some code from a given + * filepath within the context. + */ +export async function getModuleContext(options: ModuleContextOptions): Promise<{ + evaluateInContext: (filepath: string) => void + runtime: EdgeRuntime + paths: Map + warnedEvals: Set +}> { + let moduleContext = options.useCache + ? moduleContexts.get(options.moduleName) + : await getModuleContextShared(options) -function throwUnsupportedAPIError(name: string) { - const error = - new Error(`A Node.js API is used (${name}) which is not supported in the Edge Runtime. -Learn more: https://nextjs.org/docs/api-reference/edge-runtime`) - decorateServerError(error, COMPILER_NAMES.edgeServer) - throw error -} + if (!moduleContext) { + moduleContext = await createModuleContext(options) + moduleContexts.set(options.moduleName, moduleContext) + } -function getDecorateUnhandledError(runtime: EdgeRuntime) { - const EdgeRuntimeError = runtime.evaluate(`Error`) - return (error: any) => { - if (error instanceof EdgeRuntimeError) { - decorateServerError(error, COMPILER_NAMES.edgeServer) + const evaluateInContext = (filepath: string) => { + if (!moduleContext!.paths.has(filepath)) { + const content = readFileSync(filepath, 'utf-8') + try { + moduleContext?.runtime.evaluate(content) + moduleContext!.paths.set(filepath, content) + } catch (error) { + if (options.useCache) { + moduleContext?.paths.delete(options.moduleName) + } + throw error + } } } + + return { ...moduleContext, evaluateInContext } } From c67192d058b529711e040510f9f0b41ea5fc6ee9 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sun, 14 Aug 2022 22:37:41 +0200 Subject: [PATCH 3/4] fix --- .../build/analysis/extract-const-value.ts | 92 +++++++++---------- packages/next/build/entries.ts | 84 ++++++++--------- 2 files changed, 88 insertions(+), 88 deletions(-) diff --git a/packages/next/build/analysis/extract-const-value.ts b/packages/next/build/analysis/extract-const-value.ts index a08c9d40b9a9..a848351107ac 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' } @@ -111,6 +65,52 @@ function isTemplateLiteral(node: Node): node is TemplateLiteral { return node.type === 'TemplateLiteral' } +/** + * 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() +} + export class UnsupportedValueError extends Error { /** @example `config.runtime[0].value` */ path?: string 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, From e797b08bc944c670ec66f66fb6183301f38497df Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 15 Aug 2022 11:00:41 +0200 Subject: [PATCH 4/4] Fix --- .../build/analysis/extract-const-value.ts | 92 +- .../build/analysis/get-page-static-info.ts | 188 ++--- 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/shared/lib/router/router.ts | 400 ++++----- 36 files changed, 2860 insertions(+), 2852 deletions(-) diff --git a/packages/next/build/analysis/extract-const-value.ts b/packages/next/build/analysis/extract-const-value.ts index a848351107ac..209f3e813ae1 100644 --- a/packages/next/build/analysis/extract-const-value.ts +++ b/packages/next/build/analysis/extract-const-value.ts @@ -65,52 +65,6 @@ function isTemplateLiteral(node: Node): node is TemplateLiteral { return node.type === 'TemplateLiteral' } -/** - * 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() -} - export class UnsupportedValueError extends Error { /** @example `config.runtime[0].value` */ path?: string @@ -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/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 ( +