diff --git a/packages/vinext/src/client/instrumentation-client-inject.ts b/packages/vinext/src/client/instrumentation-client-inject.ts new file mode 100644 index 000000000..2f5b9c9ac --- /dev/null +++ b/packages/vinext/src/client/instrumentation-client-inject.ts @@ -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 {};"; + } + + const lines: string[] = []; + + for (let i = 0; i < injects.length; i++) { + lines.push(`import * as __vinj_${i} from ${JSON.stringify(injects[i])};`); + } + + 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"); +} diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index d35c7566f..bcd3492f2 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -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. */ @@ -290,6 +296,7 @@ export type ResolvedNextConfig = { trailingSlash: boolean; output: "" | "export" | "standalone"; pageExtensions: string[]; + instrumentationClientInject: string[]; cacheComponents: boolean; redirects: NextRedirect[]; rewrites: { @@ -951,6 +958,7 @@ export async function resolveNextConfig( buildId, deploymentId, sassOptions: null, + instrumentationClientInject: [], }; detectNextIntlConfig(root, resolved); return resolved; @@ -1138,6 +1146,11 @@ export async function resolveNextConfig( trailingSlash: config.trailingSlash ?? false, output: output === "export" || output === "standalone" ? output : "", pageExtensions, + instrumentationClientInject: Array.isArray(config.instrumentationClientInject) + ? (config.instrumentationClientInject as unknown[]).filter( + (x): x is string => typeof x === "string", + ) + : [], cacheComponents: config.cacheComponents ?? false, redirects, rewrites, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 3762521dd..43edd7dbc 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -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"; @@ -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?"; @@ -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; 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; @@ -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; @@ -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(); } @@ -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/") ? [ @@ -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 diff --git a/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index dbda1f8cd..505dd3312 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -2,12 +2,90 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test" import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createServer } from "vite-plus"; +import vinext from "../packages/vinext/src/index.js"; import { findInstrumentationClientFile, findInstrumentationFile, } from "../packages/vinext/src/server/instrumentation.js"; +import { generateInstrumentationClientInjectModule } from "../packages/vinext/src/client/instrumentation-client-inject.js"; import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js"; +const RESOLVED_INSTRUMENTATION_CLIENT = "\0private-next-instrumentation-client.mjs"; +const ROOT_NODE_MODULES = path.resolve(import.meta.dirname, "..", "node_modules"); + +function getLoadedCode(loaded: unknown): string { + return typeof loaded === "string" ? loaded : ((loaded as { code?: string })?.code ?? ""); +} + +function setupInjectProject(options: { + instrumentationClientInject: string[]; + injectFiles?: Record; + userClientSource?: string; +}): string { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-instr-client-inject-")); + fs.writeFileSync( + path.join(tmpDir, "package.json"), + JSON.stringify({ name: "test-project", type: "module" }), + ); + fs.mkdirSync(path.join(tmpDir, "app"), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, "app", "layout.tsx"), + "export default function Layout({ children }) { return {children}; }\n", + ); + fs.writeFileSync( + path.join(tmpDir, "app", "page.tsx"), + "export default function Page() { return
home
; }\n", + ); + fs.writeFileSync( + path.join(tmpDir, "next.config.mjs"), + `export default { instrumentationClientInject: ${JSON.stringify(options.instrumentationClientInject)} };\n`, + ); + for (const [filename, source] of Object.entries(options.injectFiles ?? {})) { + fs.writeFileSync(path.join(tmpDir, filename), source); + } + if (options.userClientSource !== undefined) { + fs.writeFileSync(path.join(tmpDir, "instrumentation-client.js"), options.userClientSource); + } + try { + fs.symlinkSync(ROOT_NODE_MODULES, path.join(tmpDir, "node_modules"), "junction"); + } catch { + fs.symlinkSync(ROOT_NODE_MODULES, path.join(tmpDir, "node_modules"), "dir"); + } + return tmpDir; +} + +type InjectClientContainer = NonNullable< + Awaited>["environments"]["client"] +>["pluginContainer"]; + +async function withInjectClientServer( + options: { + instrumentationClientInject: string[]; + injectFiles?: Record; + userClientSource?: string; + }, + run: (ctx: { tmpDir: string; container: InjectClientContainer }) => Promise, +): Promise { + const tmpDir = setupInjectProject(options); + const testServer = await createServer({ + root: tmpDir, + configFile: false, + plugins: [vinext({ appDir: tmpDir })], + server: { port: 0 }, + logLevel: "silent", + }); + try { + const client = testServer.environments.client; + if (!client) throw new Error("client environment missing"); + await run({ tmpDir, container: client.pluginContainer }); + } finally { + await testServer.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + // The runInstrumentation/reportRequestError describe blocks re-import via // vi.resetModules() to get fresh module-level state (_onRequestError). // findInstrumentationFile is a pure function — no reset needed. @@ -342,3 +420,143 @@ describe("reportRequestError", () => { expect(onRequestError).toHaveBeenCalledOnce(); }); }); + +// Ported from Next.js: packages/next/src/build/webpack/loaders/next-instrumentation-client-loader.ts +// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/next-instrumentation-client-loader.ts +describe("instrumentationClientInject plugin pipeline", () => { + const INJECT_A = `export function onRouterTransitionStart() { + globalThis.__injectOrder = globalThis.__injectOrder ?? []; + globalThis.__injectOrder.push("a"); +} +`; + const INJECT_B = `export function onRouterTransitionStart() { + globalThis.__injectOrder = globalThis.__injectOrder ?? []; + globalThis.__injectOrder.push("b"); +} +`; + const USER_CLIENT = `export function onRouterTransitionStart() { + globalThis.__injectOrder = globalThis.__injectOrder ?? []; + globalThis.__injectOrder.push("user"); +} +`; + const SIDE_EFFECT_ONLY = "globalThis.__sideEffect = true;\n"; + + it("does not intercept private-next-instrumentation-client when injects is empty", async () => { + await withInjectClientServer( + { instrumentationClientInject: [], userClientSource: USER_CLIENT }, + async ({ container }) => { + const resolved = await container.resolveId("private-next-instrumentation-client"); + expect(resolved).toBeTruthy(); + expect(resolved!.id).not.toBe(RESOLVED_INSTRUMENTATION_CLIENT); + expect(resolved!.id.replace(/\\/g, "/")).toContain("instrumentation-client.js"); + }, + ); + }); + + it("serves and composes the virtual module in inject order", async () => { + await withInjectClientServer( + { + instrumentationClientInject: ["./inject-a.js", "./inject-b.js"], + injectFiles: { "inject-a.js": INJECT_A, "inject-b.js": INJECT_B }, + userClientSource: USER_CLIENT, + }, + async ({ tmpDir, container }) => { + const resolved = await container.resolveId("private-next-instrumentation-client"); + expect(resolved?.id).toBe(RESOLVED_INSTRUMENTATION_CLIENT); + + const code = getLoadedCode(await container.load(resolved!.id)); + expect(code.indexOf("inject-a.js")).toBeGreaterThanOrEqual(0); + expect(code.lastIndexOf("inject-b.js")).toBeGreaterThan(code.indexOf("inject-a.js")); + expect(code.indexOf("import * as __vinj_2 from")).toBeGreaterThan( + code.lastIndexOf("inject-b.js"), + ); + expect(code).toContain("export function onRouterTransitionStart(url, type)"); + + const entryPath = path.join(tmpDir, ".vinext-composed-instrumentation-client.mjs"); + fs.writeFileSync(entryPath, code); + delete (globalThis as { __injectOrder?: string[] }).__injectOrder; + const mod = (await import(pathToFileURL(entryPath).href)) as { + onRouterTransitionStart?: (url: string, type: string) => void; + }; + mod.onRouterTransitionStart?.("/x", "push"); + expect((globalThis as { __injectOrder?: string[] }).__injectOrder).toEqual([ + "a", + "b", + "user", + ]); + }, + ); + }); + + it("allows side-effect-only inject modules without onRouterTransitionStart", async () => { + await withInjectClientServer( + { + instrumentationClientInject: ["./inject-side.js"], + injectFiles: { "inject-side.js": SIDE_EFFECT_ONLY }, + }, + async ({ tmpDir, container }) => { + const resolved = await container.resolveId("private-next-instrumentation-client"); + const entryPath = path.join(tmpDir, ".vinext-composed-instrumentation-client.mjs"); + fs.writeFileSync(entryPath, getLoadedCode(await container.load(resolved!.id))); + + delete (globalThis as { __sideEffect?: boolean }).__sideEffect; + const mod = (await import(pathToFileURL(entryPath).href)) as { + onRouterTransitionStart?: (url: string, type: string) => void; + }; + expect((globalThis as { __sideEffect?: boolean }).__sideEffect).toBe(true); + expect(() => mod.onRouterTransitionStart?.("/x", "push")).not.toThrow(); + }, + ); + }); +}); + +describe("generateInstrumentationClientInjectModule", () => { + it("returns passthrough when injects is empty (userPath ignored)", () => { + expect(generateInstrumentationClientInjectModule([], null)).toBe("export {};"); + expect( + generateInstrumentationClientInjectModule([], "/project/instrumentation-client.ts"), + ).toBe("export {};"); + }); + + it("generates a single import for one inject entry", () => { + const code = generateInstrumentationClientInjectModule(["./inject-a.js"], null); + expect(code).toContain('import * as __vinj_0 from "./inject-a.js"'); + expect(code).toContain("export function onRouterTransitionStart(url, type)"); + expect(code).toContain('typeof __vinj_0.onRouterTransitionStart === "function"'); + expect(code).toContain("\n __vinj_0.onRouterTransitionStart(url, type);\n"); + }); + + it("generates imports in config order with user file last", () => { + const code = generateInstrumentationClientInjectModule( + ["./inject-a.js", "some-npm-pkg"], + "/project/instrumentation-client.ts", + ); + expect(code).toContain('import * as __vinj_0 from "./inject-a.js"'); + expect(code).toContain('import * as __vinj_1 from "some-npm-pkg"'); + expect(code).toContain('import * as __vinj_2 from "/project/instrumentation-client.ts"'); + }); + + it("falls back to empty-module when user file is absent", () => { + const code = generateInstrumentationClientInjectModule(["./inject-a.js"], null); + expect(code).toContain("import * as __vinj_1 from"); + expect(code).toContain("empty-module"); + }); + + it("composes hook calls for every module in array order", () => { + const code = generateInstrumentationClientInjectModule( + ["./inject-a.js", "./inject-b.js"], + "/project/instrumentation-client.ts", + ); + expect(code).toContain('typeof __vinj_0.onRouterTransitionStart === "function"'); + expect(code).toContain("__vinj_0.onRouterTransitionStart(url, type)"); + expect(code).toContain('typeof __vinj_1.onRouterTransitionStart === "function"'); + expect(code).toContain("__vinj_1.onRouterTransitionStart(url, type)"); + expect(code).toContain('typeof __vinj_2.onRouterTransitionStart === "function"'); + expect(code).toContain("__vinj_2.onRouterTransitionStart(url, type)"); + }); + + it("escapes special characters in specifier paths", () => { + const code = generateInstrumentationClientInjectModule(['./path/with"quote.js'], null); + expect(code).toContain('from "./path/with\\"quote.js"'); + }); +}); diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index 00ff496a2..17980aa59 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -832,6 +832,29 @@ describe("resolveNextConfig hashSalt", () => { }); }); +describe("resolveNextConfig instrumentationClientInject", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTempDir(); + fs.writeFileSync(path.join(tmpDir, "package.json"), `{ "type": "module" }\n`); + }); + + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("loads instrumentationClientInject from next.config.mjs", async () => { + fs.writeFileSync( + path.join(tmpDir, "next.config.mjs"), + `export default { instrumentationClientInject: ["./inject-a.js", "./inject-b.js"] };\n`, + ); + const raw = await loadNextConfig(tmpDir); + const resolved = await resolveNextConfig(raw, tmpDir); + expect(resolved.instrumentationClientInject).toEqual(["./inject-a.js", "./inject-b.js"]); + }); +}); + describe("resolveNextConfig expireTime", () => { it("defaults to the Next.js route expire fallback", async () => { const resolved = await resolveNextConfig(null); @@ -925,6 +948,7 @@ describe("detectNextIntlConfig", () => { buildId: "test-build-id", deploymentId: undefined, sassOptions: null, + instrumentationClientInject: [], ...overrides, }; }