Skip to content
Open
77 changes: 77 additions & 0 deletions packages/vinext/src/client/instrumentation-client-inject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import fs from "node:fs";
import path from "node:path";

/** Resolve empty-module next to this file (.js in dist, .ts in source). */
function resolveInstrumentationClientEmptyModule(dir: string): string {
const jsPath = path.join(dir, "empty-module.js");
if (fs.existsSync(jsPath)) return jsPath;
return path.join(dir, "empty-module.ts");
}

/** Absolute path to the vinext empty-module fallback for composed client instrumentation. */
export const INSTRUMENTATION_CLIENT_EMPTY_MODULE = resolveInstrumentationClientEmptyModule(
import.meta.dirname,
);

/**
* Generate a virtual ESM module that implements the Next.js
* `instrumentationClientInject` contract for client bootstrap.
*
* Resolution follows two paths depending on whether injects are configured:
*
* **Empty injects (`injects.length === 0`):** Returns `export {}` and the
* plugin does not serve a virtual module. The `resolve.alias` for
* `private-next-instrumentation-client` resolves directly to the user's
* `instrumentation-client` file (or {@link INSTRUMENTATION_CLIENT_EMPTY_MODULE} when absent),
* so the user's `onRouterTransitionStart` is used as-is with no composition.
*
* **Non-empty injects:** The plugin serves this generated module via
* `resolveId`/`load`. It side-effect-imports each inject in config order, then
* the user's file last, and exports a single composed `onRouterTransitionStart`
* that fans out to every module's hook.
*
* **Specifier resolution:** Next.js webpack loader resolves every inject against
* the project root at build time (`this.resolve(rootContext, spec)`) and emits
* `require(resolvedPath)`. Vinext pre-resolves `./` and `../` in the plugin
* `config()` hook; bare specifiers rely on Vite resolution at bundle time.
*
* @param injects - Module specifiers from `nextConfig.instrumentationClientInject`
* @param userPath - Absolute path to the user's `instrumentation-client` file,
* or `null` when the file doesn't exist
* @param emptyModulePath - Absolute path to the empty-module fallback
*/
export function generateInstrumentationClientInjectModule(
injects: readonly string[],
userPath: string | null,
emptyModulePath: string = INSTRUMENTATION_CLIENT_EMPTY_MODULE,
): string {
if (injects.length === 0) {
return "export {};";
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When injects is empty, this returns "export {};" regardless of whether the user has an instrumentation-client file. The PR description says this is intentional ("the alias already handles the user file or empty-module"), which is correct — the resolve.alias at index.ts:1261 maps private-next-instrumentation-client to the user file or empty-module and takes effect when this plugin returns null from resolveId.

However, this means there's an asymmetry in the hook composition path: when injects are present, the composed onRouterTransitionStart fans out to all modules including the user file. When injects are empty, the user's onRouterTransitionStart flows directly through the alias. Both paths work, but it's worth documenting this explicitly in the function's JSDoc (the current doc hints at it but doesn't state the two resolution paths clearly).


const lines: string[] = [];

for (let i = 0; i < injects.length; i++) {
lines.push(`import * as __vinj_${i} from ${JSON.stringify(injects[i])};`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next.js resolves inject specifiers against the project root before emitting require() calls (via this.resolve(rootContext, spec) in the webpack loader). Here, bare specifiers and relative paths are left for Vite's resolver to handle at import resolution time, which should work correctly since the virtual module's importer context is the project root. Just flagging the difference — if a user reports that relative paths in instrumentationClientInject resolve against the wrong directory, this is the place to look.

}

const userSlot = injects.length;
lines.push(`import * as __vinj_${userSlot} from ${JSON.stringify(userPath ?? emptyModulePath)};`);

const hookCalls: string[] = [];
for (let i = 0; i <= userSlot; i++) {
hookCalls.push(
` if (typeof __vinj_${i}.onRouterTransitionStart === "function") {`,
` __vinj_${i}.onRouterTransitionStart(url, type);`,
` }`,
);
}

lines.push("");
lines.push("export function onRouterTransitionStart(url, type) {");
lines.push(...hookCalls);
lines.push(`}`);
lines.push("");

return lines.join("\n");
}
13 changes: 13 additions & 0 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ export type NextConfig = {
output?: "export" | "standalone";
/** File extensions treated as routable pages/routes (Next.js pageExtensions) */
pageExtensions?: string[];
/**
* Module specifiers that are required for side effects on the client before
* hydration, in array order, ahead of the user's `instrumentation-client.{ts,js}`.
* Each entry may be a bare npm package name or a path relative to the project root.
*/
instrumentationClientInject?: string[];
/** Extra origins allowed to access the dev server. */
allowedDevOrigins?: string[];
/** Maximum age in seconds for stale ISR entries before blocking regeneration. */
Expand Down Expand Up @@ -290,6 +296,7 @@ export type ResolvedNextConfig = {
trailingSlash: boolean;
output: "" | "export" | "standalone";
pageExtensions: string[];
instrumentationClientInject: string[];
cacheComponents: boolean;
redirects: NextRedirect[];
rewrites: {
Expand Down Expand Up @@ -951,6 +958,7 @@ export async function resolveNextConfig(
buildId,
deploymentId,
sassOptions: null,
instrumentationClientInject: [],
};
detectNextIntlConfig(root, resolved);
return resolved;
Expand Down Expand Up @@ -1138,6 +1146,11 @@ export async function resolveNextConfig(
trailingSlash: config.trailingSlash ?? false,
output: output === "export" || output === "standalone" ? output : "",
pageExtensions,
instrumentationClientInject: Array.isArray(config.instrumentationClientInject)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this validates that instrumentationClientInject is an array, but doesn't validate that each element is a string. Next.js uses Zod (z.array(z.string())) in config-schema.ts to reject non-string elements. Consider filtering:

Suggested change
instrumentationClientInject: Array.isArray(config.instrumentationClientInject)
instrumentationClientInject: Array.isArray(config.instrumentationClientInject)
? (config.instrumentationClientInject as unknown[]).filter(
(x): x is string => typeof x === 'string'
)
: [],

This is consistent with how optimizePackageImports is handled a few lines above (line 1048).

? (config.instrumentationClientInject as unknown[]).filter(
(x): x is string => typeof x === "string",
)
: [],
cacheComponents: config.cacheComponents ?? false,
redirects,
rewrites,
Expand Down
48 changes: 45 additions & 3 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js"
import { dataUrlCssPlugin } from "./plugins/css-data-url.js";
import { createRscClientReferenceLoadersPlugin } from "./plugins/rsc-client-reference-loaders.js";
import { createInstrumentationClientTransformPlugin } from "./plugins/instrumentation-client.js";
import {
generateInstrumentationClientInjectModule,
INSTRUMENTATION_CLIENT_EMPTY_MODULE,
} from "./client/instrumentation-client-inject.js";
import { createMiddlewareServerOnlyPlugin } from "./plugins/middleware-server-only.js";
import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js";
import { createOgInlineFetchAssetsPlugin, ogAssetsPlugin } from "./plugins/og-assets.js";
Expand Down Expand Up @@ -438,6 +442,9 @@ const VIRTUAL_APP_BROWSER_ENTRY = "virtual:vinext-app-browser-entry";
const RESOLVED_APP_BROWSER_ENTRY = "\0" + VIRTUAL_APP_BROWSER_ENTRY;
const VIRTUAL_ROOT_PARAMS = "virtual:vinext-root-params";
const RESOLVED_ROOT_PARAMS = "\0" + VIRTUAL_ROOT_PARAMS;
/** Virtual module for composed instrumentation-client bootstrap. */
const VIRTUAL_INSTRUMENTATION_CLIENT = "private-next-instrumentation-client";
const RESOLVED_INSTRUMENTATION_CLIENT = `\0${VIRTUAL_INSTRUMENTATION_CLIENT}.mjs`;
/** Image file extensions handled by the vinext:image-imports plugin.
* Shared between the Rolldown hook filter and the transform handler regex. */
const IMAGE_EXTS = "png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?";
Expand Down Expand Up @@ -650,10 +657,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
let hasAppDir = false;
let hasPagesDir = false;
let nextConfig: ResolvedNextConfig;
/** Tracks which project root {@link nextConfig} was loaded for (guards multi-env / test setups). */
let nextConfigRoot: string | undefined;
let fileMatcher: ReturnType<typeof createValidFileMatcher>;
let middlewarePath: string | null = null;
let instrumentationPath: string | null = null;
let instrumentationClientPath: string | null = null;
let clientInjectModule: string | null = null;
let hasCloudflarePlugin = false;
let warnedInlineNextConfigOverride = false;
let hasNitroPlugin = false;
Expand Down Expand Up @@ -1049,7 +1059,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// Note: fileMatcher, instrumentationPath, etc. are intentionally set
// outside this guard — they are cheap and deterministic, and keeping
// them here ensures they reflect the final resolved root on every call.
if (!nextConfig) {
// Reload when the project root changes. Vite's multi-environment config()
// can fire once per environment; the first call may see a different root
// (e.g. Vitest's workspace root) before the final createServer({ root }).
if (!nextConfig || nextConfigRoot !== root) {
nextConfigRoot = root;
const phase =
env?.command === "build" ? PHASE_PRODUCTION_BUILD : PHASE_DEVELOPMENT_SERVER;
let rawConfig: NextConfig | null;
Expand All @@ -1072,6 +1086,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
instrumentationPath = findInstrumentationFile(root, fileMatcher);
instrumentationClientPath = findInstrumentationClientFile(root, fileMatcher);
middlewarePath = findMiddlewareFile(root, fileMatcher);
const instrumentationClientInjects = nextConfig.instrumentationClientInject.map((spec) =>
spec.startsWith("./") || spec.startsWith("../") ? path.resolve(root, spec) : spec,
);
clientInjectModule = instrumentationClientInjects.length
? generateInstrumentationClientInjectModule(
instrumentationClientInjects,
instrumentationClientPath,
INSTRUMENTATION_CLIENT_EMPTY_MODULE,
)
: null;
if (env?.command === "build") {
await writeRouteTypes();
}
Expand Down Expand Up @@ -1265,8 +1289,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
"instrumentation-client",
),
"vinext/html": path.resolve(__dirname, "server", "html"),
"private-next-instrumentation-client":
instrumentationClientPath ?? path.resolve(__dirname, "client", "empty-module"),
...(clientInjectModule === null
? {
"private-next-instrumentation-client":
instrumentationClientPath ?? INSTRUMENTATION_CLIENT_EMPTY_MODULE,
}
: {}),
}).flatMap(([k, v]) =>
k.startsWith("next/")
? [
Expand Down Expand Up @@ -2321,6 +2349,20 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// Stub node:async_hooks in client builds — see src/plugins/async-hooks-stub.ts
asyncHooksStubPlugin,
createInstrumentationClientTransformPlugin(() => instrumentationClientPath),
{
name: "vinext:instrumentation-client-inject",
enforce: "pre",

resolveId(id) {
if (id !== VIRTUAL_INSTRUMENTATION_CLIENT) return null;
return clientInjectModule !== null ? RESOLVED_INSTRUMENTATION_CLIENT : null;
},

load(id) {
if (id !== RESOLVED_INSTRUMENTATION_CLIENT) return null;
return clientInjectModule;
},
},
// Dedup client references from RSC proxy modules — see src/plugins/client-reference-dedup.ts
...(options.experimental?.clientReferenceDedup ? [clientReferenceDedupPlugin()] : []),
// Proxy plugin for @mdx-js/rollup. The real MDX plugin is created lazily
Expand Down
Loading
Loading