diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index 22b02706070b..46f64649cf85 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -50,8 +50,8 @@ use turbo_tasks::{ }; use turbo_tasks_env::{EnvMap, ProcessEnv}; use turbo_tasks_fs::{ - DiskFileSystem, DiskFileSystemMap, DiskWatcherConfig, FileContent, FileSystem, FileSystemPath, - VirtualFileSystem, invalidation, + DiskFileSystem, DiskFileSystemMap, DiskWatcherConfig, File, FileContent, FileSystem, + FileSystemPath, VirtualFileSystem, invalidation, }; use turbo_unix_path::join_path; use turbopack::{ @@ -60,6 +60,7 @@ use turbopack::{ }; use turbopack_core::{ PROJECT_FILESYSTEM_NAME, + asset::AssetContent, changed::content_changed, chunk::{ ChunkingContext, EvaluatableAssets, UnusedReferences, @@ -89,9 +90,11 @@ use turbopack_core::{ reference::all_assets_from_entries, reference_type::{CommonJsReferenceSubType, ReferenceType}, resolve::{FindContextFileResult, find_context_file}, + source::{OptionSource, Source}, version::{ NotFoundVersion, OptionVersionedContent, Update, Version, VersionState, VersionedContent, }, + virtual_source::VirtualSource, }; use turbopack_ecmascript::async_chunk::proxy::{ activation_key_from_chunk_path, lazy_compilation_state, @@ -2207,8 +2210,7 @@ impl Project { None }; - let instrumentation = self.find_instrumentation(); - let instrumentation = if let FindContextFileResult::Found(..) = *instrumentation.await? { + let instrumentation = if self.instrumentation_source().await?.is_some() { Some(Instrumentation { node_js: self.instrumentation_endpoint(false).to_resolved().await?, edge: self.instrumentation_endpoint(true).to_resolved().await?, @@ -2547,16 +2549,68 @@ impl Project { )) } + /// The source compiled into the instrumentation slot. Without `instrumentationInject`, that is + /// the project's instrumentation file, including one in a parent directory. Otherwise a + /// virtual module at the project root requires every `instrumentationInject` entry, so the + /// bundler resolves them there, then the project's file, and runs all of their hooks. + /// `entrypoints` and `instrumentation_endpoint` both read this. + #[turbo_tasks::function] + async fn instrumentation_source(self: Vc) -> Result> { + let project_file = match &*self.find_instrumentation().await? { + FindContextFileResult::Found(fs_path, _) => Some(fs_path.clone()), + _ => None, + }; + let inject = self.next_config().instrumentation_inject().await?; + if inject.is_empty() { + let Some(fs_path) = project_file else { + return Ok(Vc::cell(None)); + }; + let source = Vc::upcast::>(FileSource::new(fs_path)); + return Ok(Vc::cell(Some(source.to_resolved().await?))); + } + let project_path = self.project_path().owned().await?; + let mut specifiers: Vec = inject.iter().cloned().collect(); + if let Some(fs_path) = &project_file { + let request = project_path + .get_relative_request_to(fs_path) + .context("project instrumentation file is outside the project filesystem")?; + specifiers.push(request); + } + // Keep in sync with + // packages/next/src/build/webpack/loaders/next-instrumentation-inject-loader.ts. + let requires = specifiers + .iter() + .map(|spec| { + Ok(format!( + "require({})", + serde_json::to_string(spec.as_str())? + )) + }) + .collect::>>()? + .join(", "); + let source_text = format!( + "const {{ combineInstrumentation }} = \ + require('next/dist/lib/combine-instrumentation');\nmodule.exports = \ + combineInstrumentation([{requires}]);\n" + ); + // CommonJS so a `type: module` package.json can't change how it parses. Not a convention + // file name, so discovery never treats it as a project file. + let path = project_path.join("instrumentation.inject.cjs")?; + let source = Vc::upcast::>(VirtualSource::new( + path, + AssetContent::file(FileContent::Content(File::from(source_text)).cell()), + )); + Ok(Vc::cell(Some(source.to_resolved().await?))) + } + #[turbo_tasks::function] async fn instrumentation_endpoint( self: Vc, is_edge: bool, ) -> Result>> { - let instrumentation = self.find_instrumentation(); - let FindContextFileResult::Found(fs_path, _) = &*instrumentation.await? else { + let Some(source) = *self.instrumentation_source().await? else { return Ok(Vc::upcast(EmptyEndpoint::new(self))); }; - let source = Vc::upcast(FileSource::new(fs_path.clone())); let app_dir = find_app_dir(self.project_path().owned().await?) .owned() .await?; @@ -2573,7 +2627,7 @@ impl Project { Ok(Vc::upcast(InstrumentationEndpoint::new( self, instrumentation_asset_context, - source, + *source, is_edge, app_dir.clone(), ecmascript_client_reference_transition_name, diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index ef52e1b732a4..16ee193696b3 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -108,6 +108,9 @@ pub struct NextConfig { images: ImageConfig, page_extensions: Vec, instrumentation_client_inject: Option>, + /// Server instrumentation module specifiers. They run in order, before the project's own + /// instrumentation file. Next.js does not install the modules. + instrumentation_inject: Option>, react_compiler: Option, react_production_profiling: Option, react_strict_mode: Option, @@ -1700,6 +1703,11 @@ impl NextConfig { Vc::cell(self.react_production_profiling.unwrap_or_default()) } + #[turbo_tasks::function] + pub fn instrumentation_inject(&self) -> Vc> { + Vc::cell(self.instrumentation_inject.clone().unwrap_or_default()) + } + #[turbo_tasks::function] pub fn server_external_packages(&self) -> Vc> { Vc::cell( diff --git a/packages/next/src/build/create-compiler-aliases.ts b/packages/next/src/build/create-compiler-aliases.ts index 06a70cc75a34..7482abd16beb 100644 --- a/packages/next/src/build/create-compiler-aliases.ts +++ b/packages/next/src/build/create-compiler-aliases.ts @@ -170,9 +170,6 @@ export function createWebpackAliases({ 'private-next-empty-module', ], - // disable typechecker, webpack5 allows aliases to be set to false to create a no-op module - 'private-next-empty-module': false as any, - // In the browser bundle, swap every module that has a `.browser` sibling // (see browser-variant-modules.ts, generated from the filesystem) for that // sibling. The default module holds the full server logic; bundling it would @@ -220,7 +217,27 @@ export function createWebpackAliases({ } : {}), } - : {}), + : { + // The project's own `instrumentation.{pageExt}` file, chained after + // the `instrumentationInject` entries by + // `next-instrumentation-inject-loader`. Checks the project root, + // then `src/`, trying each page extension in config order. Exact + // names, so compound extensions like `page.ts` match. Turbopack also + // checks parent folders and tries longer extensions first, so with + // more than one candidate file the two bundlers can pick different + // ones. + 'private-next-instrumentation-user': [ + ...['', 'src'].flatMap((subDir) => + pageExtensions.map((ext) => + path.join(dir, subDir, `instrumentation.${ext}`) + ) + ), + 'private-next-empty-module', + ], + }), + + // disable typechecker, webpack5 allows aliases to be set to false to create a no-op module + 'private-next-empty-module': false as any, [DOT_NEXT_ALIAS]: distDir, ...(isClient || isEdgeServer ? getOptimizedModuleAliases() : {}), diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 93fef6638d43..9d7f0e948b3b 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -147,6 +147,7 @@ import { pageToRoute, } from './utils' import type { DynamicManifestRoute, PageInfo, PageInfos } from './utils' +import { INSTRUMENTATION_INJECT_STUB_PATH } from './next-dir-paths' import type { FallbackRouteParam, PrerenderRouteMatcher, @@ -1535,7 +1536,10 @@ export default async function build( ) } - const hasInstrumentationHook = Boolean(instrumentationHookFilePath) + const hasInstrumentationInject = + (config.instrumentationInject?.length ?? 0) > 0 + const hasInstrumentationHook = + Boolean(instrumentationHookFilePath) || hasInstrumentationInject const hasMiddlewareFile = Boolean(middlewareFilePath) const hasProxyFile = Boolean(proxyFilePath) @@ -1577,13 +1581,28 @@ export default async function build( createPagesMapping({ isDev: false, pageExtensions: config.pageExtensions, - pagePaths: rootPaths, + // The stub loads the project's instrumentation file, so don't + // create a separate entry for it when instrumentation is injected. + pagePaths: hasInstrumentationInject + ? rootPaths.filter( + (rootPath) => + !instrumentationHookDetectionRegExp.test( + path.basename(rootPath) + ) + ) + : rootPaths, pagesType: PAGE_TYPES.ROOT, pagesDir, appDir, appDirOnly, }) ) + if (hasInstrumentationInject) { + // The stub chains the project's instrumentation file after the + // injected entries, so it replaces that file as the entry. + NextBuildContext.mappedRootPaths[`/${INSTRUMENTATION_HOOK_FILENAME}`] = + INSTRUMENTATION_INJECT_STUB_PATH + } const { pageRoutes, diff --git a/packages/next/src/build/next-dir-paths.ts b/packages/next/src/build/next-dir-paths.ts index 99900bcf2563..67ddb9b5d3eb 100644 --- a/packages/next/src/build/next-dir-paths.ts +++ b/packages/next/src/build/next-dir-paths.ts @@ -6,3 +6,14 @@ export const NEXT_PROJECT_ROOT_DIST_CLIENT = path.join( NEXT_PROJECT_ROOT_DIST, 'client' ) + +/** + * The webpack entry for the instrumentation slot when `instrumentationInject` + * is set. `next-instrumentation-inject-loader` replaces its contents with a + * module that runs every entry's hooks, then the project's own + * `instrumentation` file. + */ +export const INSTRUMENTATION_INJECT_STUB_PATH = path.join( + NEXT_PROJECT_ROOT_DIST, + 'build/webpack/loaders/instrumentation-inject-stub.js' +) diff --git a/packages/next/src/build/swc/index.ts b/packages/next/src/build/swc/index.ts index 5af81e397768..865a9e89ef85 100644 --- a/packages/next/src/build/swc/index.ts +++ b/packages/next/src/build/swc/index.ts @@ -989,6 +989,17 @@ function bindingToApi( ) } + // Turbopack resolves a leading `/` from the project root, so make absolute + // instrumentationInject entries relative. Package names stay as they are. + if (nextConfigSerializable.instrumentationInject?.length) { + nextConfigSerializable.instrumentationInject = + nextConfigSerializable.instrumentationInject.map((entry: string) => + path.isAbsolute(entry) + ? './' + normalizePathOnWindows(path.relative(projectPath, entry)) + : entry + ) + } + if (nextConfigSerializable.turbopack != null) { // clone to allow in-place mutations const turbopack = { ...nextConfigSerializable.turbopack } diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index e4a641ea9980..2df2825b5b40 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -88,6 +88,7 @@ import { getReactCompilerLoader, } from './get-babel-loader-config' import { + INSTRUMENTATION_INJECT_STUB_PATH, NEXT_PROJECT_ROOT, NEXT_PROJECT_ROOT_DIST_CLIENT, } from './next-dir-paths' @@ -1373,6 +1374,7 @@ export default async function getBaseWebpackConfig( 'next-swc-loader', 'next-client-pages-loader', 'next-instrumentation-client-loader', + 'next-instrumentation-inject-loader', 'next-image-loader', 'next-metadata-image-loader', 'next-style-loader', @@ -1954,6 +1956,18 @@ export default async function getBaseWebpackConfig( }, }, }, + // The server instrumentation entry when `instrumentationInject` is set. + // The loader emits one module that runs every entry's hooks, then the + // project's own instrumentation file. + { + test: /[\\/]next[\\/]dist[\\/](esm[\\/])?build[\\/]webpack[\\/]loaders[\\/]instrumentation-inject-stub\.js$/, + use: { + loader: 'next-instrumentation-inject-loader', + options: { + modules: config.instrumentationInject ?? [], + }, + }, + }, { // This loader rule should be before other rules, as it can output code // that still contains `"use client"` or `"use server"` statements that @@ -1993,6 +2007,18 @@ export default async function getBaseWebpackConfig( ], }, plugins: [ + !isClient && + config.instrumentationInject?.length && + new bundler.NormalModuleReplacementPlugin( + new RegExp( + `^${escapeStringRegexp(INSTRUMENTATION_INJECT_STUB_PATH)}$` + ), + ({ createData }) => { + if (createData?.resource === INSTRUMENTATION_INJECT_STUB_PATH) { + createData.context = dir + } + } + ), // In prod Webpack will already have a runtime for all reachable chunks. // During dev, it will update the runtime as chunks come in which may be too late for Flight. dev && new CompleteRuntimePlugin(), @@ -2366,6 +2392,8 @@ export default async function getBaseWebpackConfig( optimizePackageImports: config?.experimental?.optimizePackageImports, crossOrigin: config.crossOrigin, pageExtensions: pageExtensions, + instrumentationInject: config.instrumentationInject, + instrumentationClientInject: config.instrumentationClientInject, trailingSlash: config.trailingSlash, devIndicatorsPosition: config.devIndicators === false diff --git a/packages/next/src/build/webpack/loaders/instrumentation-inject-stub.js b/packages/next/src/build/webpack/loaders/instrumentation-inject-stub.js new file mode 100644 index 000000000000..c0fec95b56b5 --- /dev/null +++ b/packages/next/src/build/webpack/loaders/instrumentation-inject-stub.js @@ -0,0 +1,5 @@ +// Placeholder module. When `instrumentationInject` is set, this file is the +// instrumentation entry. Its contents are replaced by +// `next-instrumentation-inject-loader` via a `module.rules` entry in +// webpack-config.ts. +module.exports = {} diff --git a/packages/next/src/build/webpack/loaders/next-instrumentation-inject-loader.ts b/packages/next/src/build/webpack/loaders/next-instrumentation-inject-loader.ts new file mode 100644 index 000000000000..65739d6315a8 --- /dev/null +++ b/packages/next/src/build/webpack/loaders/next-instrumentation-inject-loader.ts @@ -0,0 +1,27 @@ +import type { webpack } from 'next/dist/compiled/webpack/webpack' + +export type InstrumentationInjectLoaderOptions = { + modules: string[] +} + +// Keep in sync with `instrumentation_source` in crates/next-api/src/project.rs. +const NextInstrumentationInjectLoader: webpack.LoaderDefinitionFunction = + function () { + const { modules } = this.getOptions() + + // Keep package requests intact so webpack's externals handler can apply + // serverExternalPackages and the built-in opt-out list. The webpack config + // sets this module's context to the project root before module creation. + // The project's own `instrumentation` file runs last, or an empty module + // when there is none (see `create-compiler-aliases.ts`). + const allModules = [...modules, 'private-next-instrumentation-user'] + + return ( + `const { combineInstrumentation } = require('next/dist/lib/combine-instrumentation');\n` + + `module.exports = combineInstrumentation([${allModules + .map((spec) => `require(${JSON.stringify(spec)})`) + .join(', ')}]);\n` + ) + } + +export default NextInstrumentationInjectLoader diff --git a/packages/next/src/lib/combine-instrumentation.test.ts b/packages/next/src/lib/combine-instrumentation.test.ts new file mode 100644 index 000000000000..d832521e68e1 --- /dev/null +++ b/packages/next/src/lib/combine-instrumentation.test.ts @@ -0,0 +1,198 @@ +import { combineInstrumentation } from './combine-instrumentation' + +const request = { path: '/', method: 'GET', headers: {} } +const context = { + routerKind: 'App Router', + routePath: '/', + routeType: 'render', + revalidateReason: undefined, +} as const + +describe('combineInstrumentation', () => { + it('runs every register in array order', async () => { + const calls: string[] = [] + const combined = combineInstrumentation([ + { register: async () => void calls.push('a') }, + { register: () => void calls.push('b') }, + ]) + await combined.register() + expect(calls).toEqual(['a', 'b']) + }) + + it('keeps running after a register throws, then rethrows that error', async () => { + const calls: string[] = [] + const failure = new Error('a failed') + const combined = combineInstrumentation([ + { + register: () => { + throw failure + }, + }, + { register: () => void calls.push('b') }, + ]) + await expect(combined.register()).rejects.toBe(failure) + expect(calls).toEqual(['b']) + }) + + it('throws an AggregateError when several hooks fail', async () => { + const combined = combineInstrumentation([ + { + register: () => { + throw new Error('a') + }, + }, + { + register: async () => { + throw new Error('b') + }, + }, + ]) + const err: AggregateError = await combined.register().catch((e) => e) + expect(err).toBeInstanceOf(AggregateError) + expect(err.errors.map((e: Error) => e.message)).toEqual(['a', 'b']) + }) + + it('passes onRequestError arguments to every module', async () => { + const seen: unknown[][] = [] + const error = new Error('request failed') + const combined = combineInstrumentation([ + { + onRequestError: () => { + throw new Error('reporter down') + }, + }, + { onRequestError: (...args: unknown[]) => void seen.push(args) }, + ]) + await expect( + combined.onRequestError(error, request, context) + ).rejects.toThrow('reporter down') + expect(seen).toEqual([[error, request, context]]) + }) + + it('runs every onRequestError even when an earlier one never settles', async () => { + const calls: string[] = [] + const combined = combineInstrumentation([ + { onRequestError: () => new Promise(() => {}) }, + { onRequestError: () => void calls.push('project') }, + ]) + void combined.onRequestError(new Error('boom'), request, context) + await new Promise((resolve) => setImmediate(resolve)) + expect(calls).toEqual(['project']) + }) + + it('collects onRequestError failures from every module', async () => { + const first = new Error('a failed') + const second = new Error('b failed') + const combined = combineInstrumentation([ + { onRequestError: () => Promise.reject(first) }, + { + onRequestError: () => { + throw second + }, + }, + ]) + const error = await combined + .onRequestError(new Error('boom'), request, context) + .catch((err: unknown) => err) + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([first, second]) + }) + + it('awaits modules that require() returns as promises', async () => { + const calls: string[] = [] + const combined = combineInstrumentation([ + Promise.resolve({ register: () => void calls.push('async module') }), + { register: () => void calls.push('sync module') }, + ]) + await combined.register() + expect(calls).toEqual(['async module', 'sync module']) + }) + + it('collects a module that fails to load and runs the rest', async () => { + const calls: string[] = [] + const failure = new Error('load failed') + const combined = combineInstrumentation([ + Promise.reject(failure), + { register: () => void calls.push('b') }, + ]) + await expect(combined.register()).rejects.toBe(failure) + expect(calls).toEqual(['b']) + }) + + it('does not leave a later module rejection unhandled while an earlier hook awaits', async () => { + const unhandled: unknown[] = [] + const onUnhandled = (reason: unknown) => void unhandled.push(reason) + process.on('unhandledRejection', onUnhandled) + try { + const failure = new Error('load failed') + let rejectLater!: (err: unknown) => void + const later = new Promise((_, reject) => (rejectLater = reject)) + const combined = combineInstrumentation([ + { + register: async () => { + rejectLater(failure) + // Let Node detect unhandled rejections while this hook waits. + await new Promise((resolve) => setTimeout(resolve, 10)) + }, + }, + later, + ]) + await expect(combined.register()).rejects.toBe(failure) + expect(unhandled).toEqual([]) + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('skips missing hooks and reads hooks from a default export', async () => { + const calls: string[] = [] + const combined = combineInstrumentation([ + {}, + { default: { register: () => void calls.push('default') } }, + undefined, + ]) + await combined.register() + await combined.onRequestError(new Error('x'), request, context) + expect(calls).toEqual(['default']) + }) + + it('prefers the default export over named hooks after awaiting the module', async () => { + const register = jest.fn() + const onRequestError = jest.fn() + const namedRegister = jest.fn() + const namedOnRequestError = jest.fn() + const instrumentation = { register, onRequestError } + const combined = combineInstrumentation([ + Promise.resolve({ + default: instrumentation, + register: namedRegister, + onRequestError: namedOnRequestError, + }), + ]) + const error = new Error('request failed') + + await combined.register() + await combined.onRequestError(error, request, context) + + expect(register).toHaveBeenCalledTimes(1) + expect(register.mock.contexts).toEqual([instrumentation]) + expect(onRequestError).toHaveBeenCalledWith(error, request, context) + expect(onRequestError.mock.contexts).toEqual([instrumentation]) + expect(namedRegister).not.toHaveBeenCalled() + expect(namedOnRequestError).not.toHaveBeenCalled() + }) + + it('does not fall back to named hooks when the default export omits them', async () => { + const register = jest.fn() + const onRequestError = jest.fn() + const combined = combineInstrumentation([ + { default: {}, register, onRequestError }, + ]) + + await combined.register() + await combined.onRequestError(new Error('request failed'), request, context) + + expect(register).not.toHaveBeenCalled() + expect(onRequestError).not.toHaveBeenCalled() + }) +}) diff --git a/packages/next/src/lib/combine-instrumentation.ts b/packages/next/src/lib/combine-instrumentation.ts new file mode 100644 index 000000000000..c241e4b610b1 --- /dev/null +++ b/packages/next/src/lib/combine-instrumentation.ts @@ -0,0 +1,94 @@ +import type { + InstrumentationModule, + InstrumentationOnRequestError, +} from '../server/instrumentation/types' +import { interopDefault } from './interop-default' + +type Hook = keyof InstrumentationModule + +type CombinedInstrumentation = { + register(): Promise + onRequestError( + ...args: Parameters + ): Promise +} + +/** + * Combines the `instrumentationInject` modules and the project's own + * instrumentation file into one instrumentation module. `register` runs every + * module's export in array order. `onRequestError` runs them concurrently, so + * one handler can't hold up another. A hook that throws, or a module with + * top-level await that fails to load, does not stop the others. Errors are + * rethrown after all have run. + */ +export function combineInstrumentation( + modules: readonly unknown[] +): CombinedInstrumentation { + // Every module is required before any hook runs, so an async module can + // reject while an earlier hook is still awaiting. Mark those promises as + // handled now. `callEach` still awaits them and collects the error. + for (const mod of modules) { + if (typeof (mod as PromiseLike | undefined)?.then === 'function') { + ;(mod as PromiseLike).then(undefined, () => {}) + } + } + + async function callHook(mod: unknown, name: Hook, args: unknown[]) { + // `require()` returns a promise for a module that uses top-level await. If + // one of those fails to load, its error is collected like a hook error. A + // module without top-level await that throws while loading stops startup + // before this runs. + const instrumentation = interopDefault((await mod) ?? {}) as Record< + string, + unknown + > + const hook = instrumentation[name] + if (typeof hook === 'function') await hook.apply(instrumentation, args) + } + + function throwCollected(name: Hook, errors: unknown[]) { + if (errors.length === 1) { + throw errors[0] + } + if (errors.length > 1) { + throw new AggregateError( + errors, + `${errors.length} instrumentation modules failed in ${name}()` + ) + } + } + + // `register` runs in array order, so a later module can rely on an earlier + // one having set up. + async function registerInOrder() { + const errors: unknown[] = [] + for (const mod of modules) { + try { + await callHook(mod, 'register', []) + } catch (err) { + errors.push(err) + } + } + throwCollected('register', errors) + } + + // `onRequestError` handlers are independent, so they run together: a slow or + // hanging injected handler can't stop the project's own handler from running. + async function reportToAll(args: unknown[]) { + const results = await Promise.allSettled( + modules.map((mod) => callHook(mod, 'onRequestError', args)) + ) + throwCollected( + 'onRequestError', + results + .filter((result) => result.status === 'rejected') + .map((result) => (result as PromiseRejectedResult).reason) + ) + } + + return { + register: registerInOrder, + onRequestError: (...args: Parameters) => + reportToAll(args), + } +} diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 3fd717dad24b..c775835fbcbe 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -856,6 +856,7 @@ export const configSchema: zod.ZodType = z.lazy(() => .optional(), pageExtensions: z.array(z.string()).min(1).optional(), instrumentationClientInject: z.array(z.string()).optional(), + instrumentationInject: z.array(z.string()).optional(), partialPrefetching: z.boolean().optional(), poweredByHeader: z.boolean().optional(), productionBrowserSourceMaps: z.boolean().optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 93e155e5d98c..c0e4a3cd4360 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -1953,6 +1953,15 @@ export interface NextConfig { */ instrumentationClientInject?: string[] + /** + * Module specifiers compiled into the server `instrumentation` slot, ahead + * of the project's own `instrumentation.{ts,js}` file. Every entry's + * `register` and `onRequestError` run in array order, then the project + * file's. A hook that throws does not stop the others. Each entry may be a bare + * npm package name, a path relative to the project root, or an absolute path. + */ + instrumentationInject?: string[] + /** @see [Compression documentation](https://nextjs.org/docs/app/api-reference/config/next-config-js/compress) */ compress?: boolean @@ -2328,6 +2337,7 @@ export const defaultConfig = Object.freeze({ generateEtags: true, pageExtensions: ['tsx', 'ts', 'jsx', 'js'], instrumentationClientInject: [], + instrumentationInject: [], poweredByHeader: true, compress: true, images: imageConfigDefault, diff --git a/packages/next/src/server/config.test.ts b/packages/next/src/server/config.test.ts index 35f91c48209c..f87f92227ca2 100644 --- a/packages/next/src/server/config.test.ts +++ b/packages/next/src/server/config.test.ts @@ -1,4 +1,8 @@ -import { PHASE_INFO, PHASE_PRODUCTION_BUILD } from '../api/constants' +import { + PHASE_INFO, + PHASE_PRODUCTION_BUILD, + PHASE_PRODUCTION_SERVER, +} from '../api/constants' import { getStrictRouteMatchingDefaultWarning, STRICT_ROUTE_MATCHING_DEFAULT_WARNING, @@ -385,4 +389,39 @@ describe('loadConfig', () => { expect(result.experimental.durableUseCacheEntries).toBe(true) }) }) + + describe('instrumentationInject', () => { + it('defaults to an empty array, so modifyConfig can push to it', async () => { + const result = await loadConfig(PHASE_PRODUCTION_BUILD, __dirname, { + customConfig: {}, + }) + expect(result.instrumentationInject).toEqual([]) + }) + + it('gives each load its own array, so a push does not leak into later loads', async () => { + const first = await loadConfig(PHASE_PRODUCTION_BUILD, __dirname, { + customConfig: {}, + }) + first.instrumentationInject.push('./x.js') + first.instrumentationClientInject.push('./y.js') + + const second = await loadConfig(PHASE_PRODUCTION_SERVER, __dirname, { + customConfig: {}, + }) + expect(second).toMatchObject({ + instrumentationInject: [], + instrumentationClientInject: [], + }) + }) + + it('is accepted by the config schema', () => { + expect( + configSchema.safeParse({ instrumentationInject: ['./script.js'] }) + .success + ).toBe(true) + expect( + configSchema.safeParse({ instrumentationInject: 'script.js' }).success + ).toBe(false) + }) + }) }) diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 7b1386f97f3c..cc8534621a72 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -458,6 +458,13 @@ function assignDefaultsAndValidate( }, } + // Copy so a push in `modifyConfig` can't change the shared default or a + // cached user config. + result.instrumentationInject = [...(result.instrumentationInject ?? [])] + result.instrumentationClientInject = [ + ...(result.instrumentationClientInject ?? []), + ] + result.experimental.strictRouteMatching = !result.deprecated.looseRouteMatching diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index 053d14aef3eb..658c4092b894 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -267,6 +267,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { private isSrcDir: boolean private cacheStatusesByRequestId = new Map() private hasVulnerabilityInsight: Promise + private hasInstrumentationInject: boolean public serverStats: webpack.Stats | null public edgeServerStats: webpack.Stats | null @@ -292,6 +293,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { lockfile, onDevServerCleanup, hasVulnerabilityInsight, + hasInstrumentationInject, }: { config: NextConfigComplete isSrcDir: boolean @@ -307,6 +309,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { lockfile: Lockfile | undefined onDevServerCleanup: ((listener: () => Promise) => void) | undefined hasVulnerabilityInsight: Promise + hasInstrumentationInject: boolean } ) { this.hasAppRouterEntrypoints = false @@ -327,6 +330,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { this.resetFetch = resetFetch this.lockfile = lockfile this.hasVulnerabilityInsight = hasVulnerabilityInsight + this.hasInstrumentationInject = hasInstrumentationInject this.config = config this.previewProps = previewProps @@ -1677,6 +1681,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { appDir: this.appDir, rootDir: this.dir, nextConfig: this.config, + hasInstrumentationInject: this.hasInstrumentationInject, ...(this.config.onDemandEntries as { maxInactiveAge: number pagesBufferLength: number diff --git a/packages/next/src/server/dev/on-demand-entry-handler.ts b/packages/next/src/server/dev/on-demand-entry-handler.ts index 1958aa0bffd8..072a6a93c573 100644 --- a/packages/next/src/server/dev/on-demand-entry-handler.ts +++ b/packages/next/src/server/dev/on-demand-entry-handler.ts @@ -10,6 +10,7 @@ import type { CompilerNameValues } from '../../shared/lib/constants' import type { RouteDefinition } from '../route-definitions/route-definition' import createDebug from 'next/dist/compiled/debug' +import { INSTRUMENTATION_INJECT_STUB_PATH } from '../../build/next-dir-paths' import { EventEmitter } from 'events' import { findPageFile } from '../lib/find-page-file' import { runDependingOnPageType, isDeferredEntry } from '../../build/entries' @@ -44,6 +45,7 @@ import { scheduleOnNextTick } from '../../lib/scheduler' import { Batcher } from '../../lib/batcher' import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' import { PAGE_TYPES } from '../../lib/page-types' +import { INSTRUMENTATION_HOOK_FILENAME } from '../../lib/constants' import { getNextFlightSegmentPath } from '../../client/flight-data-helpers' import { handleErrorStateResponse } from '../mcp/tools/get-errors' import { handlePageMetadataResponse } from '../mcp/tools/get-page-metadata' @@ -389,13 +391,16 @@ interface PagePathData { /** * Attempts to find a page file path from the given pages absolute directory, * a page and allowed extensions. If the page can't be found it will throw an - * error. It defaults the `/_error` page to Next.js internal error page. + * error. It defaults the `/_error` page to Next.js internal error page. When + * `hasInstrumentationInject` is true, the instrumentation hook is its stub, + * which chains the project's instrumentation file after the injected entries. * * @param rootDir Absolute path to the project root. * @param page The page normalized (it will be denormalized). * @param extensions Array of page extensions. * @param pagesDir Absolute path to the pages folder with trailing `/pages`. * @param appDir Absolute path to the app folder with trailing `/app`. + * @param hasInstrumentationInject Whether `instrumentationInject` has entries. */ export async function findPagePathData( rootDir: string, @@ -403,12 +408,20 @@ export async function findPagePathData( extensions: string[], pagesDir: string | undefined, appDir: string | undefined, - isGlobalNotFoundEnabled: boolean + isGlobalNotFoundEnabled: boolean, + hasInstrumentationInject = false ): Promise { const normalizedPagePath = tryToNormalizePagePath(page) let pagePath: string | null = null const isInstrumentation = isInstrumentationHookFile(normalizedPagePath) + if (isInstrumentation && hasInstrumentationInject) { + return { + filename: INSTRUMENTATION_INJECT_STUB_PATH, + bundlePath: INSTRUMENTATION_HOOK_FILENAME, + page: `/${INSTRUMENTATION_HOOK_FILENAME}`, + } + } if (isMiddlewareFile(normalizedPagePath) || isInstrumentation) { pagePath = await findPageFile( rootDir, @@ -547,6 +560,7 @@ export function onDemandEntryHandler({ pagesDir, rootDir, appDir, + hasInstrumentationInject, }: { hotReloader: NextJsHotReloaderInterface maxInactiveAge: number @@ -556,6 +570,7 @@ export function onDemandEntryHandler({ pagesDir?: string rootDir: string appDir?: string + hasInstrumentationInject: boolean }) { const hasAppDir = !!appDir let curInvalidator: Invalidator = getInvalidator( @@ -841,7 +856,8 @@ export function onDemandEntryHandler({ nextConfig.pageExtensions, pagesDir, appDir, - !!nextConfig.experimental.globalNotFound + !!nextConfig.experimental.globalNotFound, + hasInstrumentationInject ) } diff --git a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts index ebe2ea80fc85..00aed459e158 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts @@ -91,6 +91,7 @@ import { TurbopackInternalError } from '../../../shared/lib/turbopack/internal-e import { recursiveReadDir } from '../../../lib/recursive-readdir' import { JSON_CONTENT_TYPE_HEADER, + INSTRUMENTATION_HOOK_FILENAME, MIDDLEWARE_FILENAME, PROXY_FILENAME, } from '../../../lib/constants' @@ -256,6 +257,14 @@ async function startWatcher( logging: nextConfig.logging !== false, }) + const hasInstrumentationInject = + (nextConfig.instrumentationInject?.length ?? 0) > 0 + // Turbopack reports instrumentation through its entrypoints + // (turbopack-utils.ts). Webpack has no such list. + if (hasInstrumentationInject && !opts.turbo) { + serverFields.actualInstrumentationHookFile = `/${INSTRUMENTATION_HOOK_FILENAME}` + } + const hotReloader: NextJsHotReloaderInterface = opts.turbo ? await (async () => { const createHotReloaderTurbopack = ( @@ -296,6 +305,7 @@ async function startWatcher( lockfile, onDevServerCleanup: opts.onDevServerCleanup, hasVulnerabilityInsight: opts.hasVulnerabilityInsight, + hasInstrumentationInject, }) })() diff --git a/test/e2e/app-dir/inject-instrumentation/app/layout.tsx b/test/e2e/app-dir/inject-instrumentation/app/layout.tsx new file mode 100644 index 000000000000..7c3f422f0039 --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/app/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/inject-instrumentation/app/node/route.ts b/test/e2e/app-dir/inject-instrumentation/app/node/route.ts new file mode 100644 index 000000000000..8f6a90aee99d --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/app/node/route.ts @@ -0,0 +1,7 @@ +import { connection } from 'next/server' +import { probe } from '../probe' + +export async function GET() { + await connection() + return probe() +} diff --git a/test/e2e/app-dir/inject-instrumentation/app/probe.ts b/test/e2e/app-dir/inject-instrumentation/app/probe.ts new file mode 100644 index 000000000000..52206c0f66c6 --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/app/probe.ts @@ -0,0 +1,12 @@ +type Store = typeof globalThis & { + __instrumentation?: string + __injected?: string[] +} + +export function probe() { + const store = globalThis as Store + return Response.json({ + instrumentation: store.__instrumentation ?? 'none', + injected: store.__injected ?? [], + }) +} diff --git a/test/e2e/app-dir/inject-instrumentation/fixtures/externals/next.config.ts b/test/e2e/app-dir/inject-instrumentation/fixtures/externals/next.config.ts new file mode 100644 index 000000000000..4bb9580a80d7 --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/fixtures/externals/next.config.ts @@ -0,0 +1,14 @@ +import path from 'path' +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + serverExternalPackages: ['external-instrumentation'], + instrumentationInject: [ + 'external-instrumentation/init', + 'bundled-instrumentation/init', + './injected/instrumentation.js', + path.join(__dirname, 'injected', 'second.js'), + ], +} + +export default nextConfig diff --git a/test/e2e/app-dir/inject-instrumentation/fixtures/externals/route.ts b/test/e2e/app-dir/inject-instrumentation/fixtures/externals/route.ts new file mode 100644 index 000000000000..0c8886e1eb72 --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/fixtures/externals/route.ts @@ -0,0 +1,13 @@ +import { connection } from 'next/server' + +export async function GET() { + await connection() + const store = globalThis as typeof globalThis & { + __instrumentationPackages?: { name: string; marker: string }[] + __injected?: string[] + } + return Response.json({ + packages: store.__instrumentationPackages ?? [], + injected: store.__injected ?? [], + }) +} diff --git a/test/e2e/app-dir/inject-instrumentation/fixtures/inject/next.config.ts b/test/e2e/app-dir/inject-instrumentation/fixtures/inject/next.config.ts new file mode 100644 index 000000000000..d43bfc63f186 --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/fixtures/inject/next.config.ts @@ -0,0 +1,12 @@ +import path from 'path' +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + instrumentationInject: [ + './injected/instrumentation.js', + // Config wrappers often pass absolute paths. + path.join(__dirname, 'injected', 'second.js'), + ], +} + +export default nextConfig diff --git a/test/e2e/app-dir/inject-instrumentation/fixtures/omitted/next.config.ts b/test/e2e/app-dir/inject-instrumentation/fixtures/omitted/next.config.ts new file mode 100644 index 000000000000..e4f5738a310b --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/fixtures/omitted/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = {} + +export default nextConfig diff --git a/test/e2e/app-dir/inject-instrumentation/fixtures/packages/bundled-instrumentation/init.js b/test/e2e/app-dir/inject-instrumentation/fixtures/packages/bundled-instrumentation/init.js new file mode 100644 index 000000000000..ed455a088ecd --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/fixtures/packages/bundled-instrumentation/init.js @@ -0,0 +1,7 @@ +exports.register = function () { + const packages = (globalThis.__instrumentationPackages ??= []) + packages.push({ + name: 'bundled-instrumentation', + marker: 'bundled-instrumentation:register-implementation', + }) +} diff --git a/test/e2e/app-dir/inject-instrumentation/fixtures/packages/bundled-instrumentation/package.json b/test/e2e/app-dir/inject-instrumentation/fixtures/packages/bundled-instrumentation/package.json new file mode 100644 index 000000000000..7b7869d0ee3d --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/fixtures/packages/bundled-instrumentation/package.json @@ -0,0 +1,5 @@ +{ + "name": "bundled-instrumentation", + "version": "1.0.0", + "main": "init.js" +} diff --git a/test/e2e/app-dir/inject-instrumentation/fixtures/packages/external-instrumentation/init.js b/test/e2e/app-dir/inject-instrumentation/fixtures/packages/external-instrumentation/init.js new file mode 100644 index 000000000000..822a1b5f307a --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/fixtures/packages/external-instrumentation/init.js @@ -0,0 +1,7 @@ +exports.register = function () { + const packages = (globalThis.__instrumentationPackages ??= []) + packages.push({ + name: 'external-instrumentation', + marker: 'external-instrumentation:register-implementation', + }) +} diff --git a/test/e2e/app-dir/inject-instrumentation/fixtures/packages/external-instrumentation/package.json b/test/e2e/app-dir/inject-instrumentation/fixtures/packages/external-instrumentation/package.json new file mode 100644 index 000000000000..8c9f1f760a87 --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/fixtures/packages/external-instrumentation/package.json @@ -0,0 +1,5 @@ +{ + "name": "external-instrumentation", + "version": "1.0.0", + "main": "init.js" +} diff --git a/test/e2e/app-dir/inject-instrumentation/fixtures/page-ext/next.config.ts b/test/e2e/app-dir/inject-instrumentation/fixtures/page-ext/next.config.ts new file mode 100644 index 000000000000..4af2bf8ece62 --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/fixtures/page-ext/next.config.ts @@ -0,0 +1,11 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + pageExtensions: ['page.ts', 'tsx', 'ts'], + instrumentationInject: [ + './injected/instrumentation.js', + './injected/second.js', + ], +} + +export default nextConfig diff --git a/test/e2e/app-dir/inject-instrumentation/fixtures/user-file/instrumentation.ts b/test/e2e/app-dir/inject-instrumentation/fixtures/user-file/instrumentation.ts new file mode 100644 index 000000000000..a4f56f5d051f --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/fixtures/user-file/instrumentation.ts @@ -0,0 +1,9 @@ +type Store = typeof globalThis & { __instrumentation?: string } + +// Top-level await makes this an async module, so require() returns a promise. +await Promise.resolve() + +export function register() { + ;(globalThis as Store).__instrumentation = 'user' + console.log(`[user-instrumentation] register ${process.env.NEXT_RUNTIME}`) +} diff --git a/test/e2e/app-dir/inject-instrumentation/inject-instrumentation.test.ts b/test/e2e/app-dir/inject-instrumentation/inject-instrumentation.test.ts new file mode 100644 index 000000000000..ad40a9150e0a --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/inject-instrumentation.test.ts @@ -0,0 +1,208 @@ +import { FileRef, nextTestSetup } from 'e2e-utils' +import glob from 'glob' +import { join, resolve } from 'path' + +function fixture( + config: 'inject' | 'page-ext' | 'omitted' | 'externals', + extra = {} +) { + return { + app: new FileRef(join(__dirname, 'app')), + // The externals fixture exercises Node.js package resolution. Middleware + // behavior is covered by the other fixtures. + ...(config !== 'externals' + ? { 'middleware.ts': new FileRef(join(__dirname, 'middleware.ts')) } + : {}), + ...(config !== 'omitted' + ? { injected: new FileRef(join(__dirname, 'injected')) } + : {}), + 'next.config.ts': new FileRef( + join(__dirname, 'fixtures', config, 'next.config.ts') + ), + ...extra, + } +} + +describe('inject-instrumentation', () => { + describe('injected package resolution', () => { + const { next } = nextTestSetup({ + files: fixture('externals', { + packages: new FileRef(join(__dirname, 'fixtures', 'packages')), + 'app/instrumentation-externals/route.ts': new FileRef( + join(__dirname, 'fixtures', 'externals', 'route.ts') + ), + }), + dependencies: { + 'external-instrumentation': 'file:./packages/external-instrumentation', + 'bundled-instrumentation': 'file:./packages/bundled-instrumentation', + }, + }) + + it('runs injected packages and filesystem entries', async () => { + const result = await next + .fetch('/instrumentation-externals') + .then((res) => res.json()) + + expect(result.packages).toEqual([ + { + name: 'external-instrumentation', + marker: 'external-instrumentation:register-implementation', + }, + { + name: 'bundled-instrumentation', + marker: 'bundled-instrumentation:register-implementation', + }, + ]) + expect(result.injected).toEqual(['first', 'second']) + }) + + // Deploy mode has no local build output; hook execution is asserted above. + // @force-gate !deploy + it('keeps the opted-out implementation out of emitted server bundles', async () => { + await next.fetch('/instrumentation-externals') + + // Both bundlers emit Edge instrumentation even without an Edge route. + // Only inspect the Node entry and chunks: Edge must bundle the packages. + const files = glob.sync( + '{instrumentation.js,chunks/**/*.js,vendor-chunks/**/*.js}', + { cwd: join(next.testDir, next.distDir, 'server') } + ) + expect(files.length).toBeGreaterThan(0) + const bundles = ( + await Promise.all( + files.map((file) => next.readFile(join(next.distDir, 'server', file))) + ) + ).join('\n') + + expect(bundles).toContain( + 'bundled-instrumentation:register-implementation' + ) + expect(bundles).not.toContain( + 'external-instrumentation:register-implementation' + ) + }) + + // NFT manifests are only available in local production builds. + // @force-gate start + it('traces the external instrumentation package', async () => { + const trace = (await next.readJSON( + join(next.distDir, 'server', 'instrumentation.js.nft.json') + )) as { files: string[] } + const traceDir = join(next.testDir, next.distDir, 'server') + const tracedFiles = trace.files.map((file) => + resolve(traceDir, file).replace(/\\/g, '/') + ) + + expect(tracedFiles).toContainEqual( + expect.stringMatching( + /\/node_modules\/external-instrumentation\/init\.js$/ + ) + ) + }) + }) + + describe('instrumentationInject without a project file', () => { + const { next, isNextStart } = nextTestSetup({ + files: fixture('inject'), + }) + + it('runs every injected register in Node.js, in order', async () => { + expect(await next.fetch('/node').then((res) => res.json())).toEqual({ + instrumentation: 'inject', + injected: ['first', 'second'], + }) + }) + + it('runs every injected register in the Edge runtime, in order', async () => { + expect(await next.fetch('/edge').then((res) => res.json())).toEqual({ + instrumentation: 'inject', + injected: ['first', 'second'], + }) + }) + + if (isNextStart) { + it('emits the instrumentation entry', async () => { + expect(await next.hasFile('.next/server/instrumentation.js')).toBe(true) + }) + } + }) + + describe('a project instrumentation file chains after the inject', () => { + const { next, isNextDeploy } = nextTestSetup({ + files: fixture('inject', { + 'instrumentation.ts': new FileRef( + join(__dirname, 'fixtures', 'user-file', 'instrumentation.ts') + ), + }), + }) + + it('runs the injected registers, then the project register', async () => { + // Each register sets `instrumentation`, so 'user' means the project + // file ran last. + expect(await next.fetch('/node').then((res) => res.json())).toEqual({ + instrumentation: 'user', + injected: ['first', 'second'], + }) + expect(await next.fetch('/edge').then((res) => res.json())).toEqual({ + instrumentation: 'user', + injected: ['first', 'second'], + }) + + if (!isNextDeploy) { + const nodeRegisters = next.cliOutput.match( + /\[user-instrumentation\] register nodejs/g + ) + expect(nodeRegisters).toHaveLength(1) + expect(next.cliOutput).toContain( + '[inject-instrumentation] register first nodejs' + ) + } + }) + }) + + describe('a project instrumentation file with a compound page extension', () => { + const { next } = nextTestSetup({ + files: fixture('page-ext', { + 'instrumentation.page.ts': new FileRef( + join(__dirname, 'fixtures', 'user-file', 'instrumentation.ts') + ), + }), + }) + + it('chains instrumentation.page.ts after the inject', async () => { + expect(await next.fetch('/node').then((res) => res.json())).toEqual({ + instrumentation: 'user', + injected: ['first', 'second'], + }) + expect(await next.fetch('/edge').then((res) => res.json())).toEqual({ + instrumentation: 'user', + injected: ['first', 'second'], + }) + }) + }) + + describe('no inject and no project file', () => { + const { next, isNextStart } = nextTestSetup({ + files: fixture('omitted'), + }) + + it('runs no instrumentation', async () => { + expect(await next.fetch('/node').then((res) => res.json())).toEqual({ + instrumentation: 'none', + injected: [], + }) + expect(await next.fetch('/edge').then((res) => res.json())).toEqual({ + instrumentation: 'none', + injected: [], + }) + }) + + if (isNextStart) { + it('emits no instrumentation entry', async () => { + expect(await next.hasFile('.next/server/instrumentation.js')).toBe( + false + ) + }) + } + }) +}) diff --git a/test/e2e/app-dir/inject-instrumentation/injected/instrumentation.js b/test/e2e/app-dir/inject-instrumentation/injected/instrumentation.js new file mode 100644 index 000000000000..5ff9a6af6a03 --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/injected/instrumentation.js @@ -0,0 +1,5 @@ +import { markInjected } from './setup' + +export function register() { + markInjected('first') +} diff --git a/test/e2e/app-dir/inject-instrumentation/injected/second.js b/test/e2e/app-dir/inject-instrumentation/injected/second.js new file mode 100644 index 000000000000..145de69ddfaa --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/injected/second.js @@ -0,0 +1,8 @@ +import { markInjected } from './setup' + +// Top-level await makes this an async module, so require() returns a promise. +await Promise.resolve() + +export async function register() { + markInjected('second') +} diff --git a/test/e2e/app-dir/inject-instrumentation/injected/setup.js b/test/e2e/app-dir/inject-instrumentation/injected/setup.js new file mode 100644 index 000000000000..2fb29549c079 --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/injected/setup.js @@ -0,0 +1,7 @@ +export function markInjected(name) { + globalThis.__instrumentation = 'inject' + globalThis.__injected = [...(globalThis.__injected ?? []), name] + console.log( + `[inject-instrumentation] register ${name} ${process.env.NEXT_RUNTIME}` + ) +} diff --git a/test/e2e/app-dir/inject-instrumentation/middleware.ts b/test/e2e/app-dir/inject-instrumentation/middleware.ts new file mode 100644 index 000000000000..7cfa452aa649 --- /dev/null +++ b/test/e2e/app-dir/inject-instrumentation/middleware.ts @@ -0,0 +1,9 @@ +import { probe } from './app/probe' + +// `middleware.ts` runs in the Edge runtime. An Edge route handler would not +// build with Cache Components. +export function middleware() { + return probe() +} + +export const config = { matcher: '/edge' } diff --git a/test/production/next-server-nft/next-server-nft.test.ts b/test/production/next-server-nft/next-server-nft.test.ts index 9bb69e6b092e..0d64ff3d7526 100644 --- a/test/production/next-server-nft/next-server-nft.test.ts +++ b/test/production/next-server-nft/next-server-nft.test.ts @@ -224,6 +224,7 @@ async function readNormalizedNFT(next, name) { "/node_modules/next/dist/lib/bundler.js", "/node_modules/next/dist/lib/client-and-server-references.js", "/node_modules/next/dist/lib/coalesced-function.js", + "/node_modules/next/dist/lib/combine-instrumentation.js", "/node_modules/next/dist/lib/compile-error.js", "/node_modules/next/dist/lib/constants.js", "/node_modules/next/dist/lib/create-client-router-filter.js",