diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index d71ba9b1b8a3..967f76eb72a5 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -187,6 +187,12 @@ is the reference setup. because workerd does not allow it. `nextjs-16` has no Vite build: its Cloudflare variant builds with `opennextjs-cloudflare build --config wrangler.opennext.jsonc` and wraps the generated Worker in `worker.ts` with `withSentry` from `@sentry/nextjs/cloudflare`. +- **Cloudflare in `vite dev`**: to run the Worker in `vite dev` (for example the dev tests of the `hono-4` Cloudflare + variant), start it with `CLOUDFLARE_INCLUDE_PROCESS_ENV=true vite dev`. The runner gives `E2E_TEST_DSN` to the app + only as a process env var. `wrangler dev` adds it to the Worker `env` with `--var "E2E_TEST_DSN:$E2E_TEST_DSN"`, but + `@cloudflare/vite-plugin` has no such option. It fills the Worker `env` from the `vars` of the wrangler config and + from `.dev.vars` or `.env`, and adds process env vars only when `CLOUDFLARE_INCLUDE_PROCESS_ENV` is `true`. Without + it, `env.E2E_TEST_DSN` is `undefined`, the SDK sends no events, and the tests fail. - **Build flavors**: a variant can also build the same app with another tool, for example `nextjs-16 (vinext)` builds `nextjs-16` with vinext. A build flavor is not a runtime: its assert script sets its own env var (`VINEXT=1`, read by `tests/isVinext.ts`). It sets `runtime` only to run on another runtime, for example `nextjs-16 (vinext, bun)`. diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json index 82f47a8b7368..d1aca0e29ced 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -8,14 +8,16 @@ "dev:bun": "bun run dist/entry.bun.js", "dev:deno": "deno run --allow-net --allow-env --allow-read --preload=npm:@sentry/deno/import src/entry.deno.ts", "dev:cloudflare": "wrangler dev --config ./dist/hono_4/wrangler.json --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "dev:cloudflare:vite": "CLOUDFLARE_INCLUDE_PROCESS_ENV=true vite dev", "test:build": "pnpm install && pnpm typecheck", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "test": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", "test:assert": "pnpm test:assert:node", "test:assert:node": "RUNTIME=node pnpm test", "test:assert:bun": "bun run build-bun.ts && RUNTIME=bun pnpm test", "test:assert:deno": "RUNTIME=deno pnpm test", - "test:assert:cloudflare": "vite build && RUNTIME=cloudflare pnpm test" + "test:assert:cloudflare": "vite build && RUNTIME=cloudflare pnpm test && RUNTIME=cloudflare pnpm test:dev" }, "dependencies": { "@sentry/bun": "latest || *", diff --git a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts index 475e082970af..5b40a1bd48a0 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts @@ -10,7 +10,11 @@ if (!testEnv) { const APP_PORT = 8787; const startCommands: Record = { - cloudflare: `pnpm dev:cloudflare --port ${APP_PORT}`, + // `vite dev` pre-bundles `hono` with its Rolldown dependency optimizer, so this also tests the transform there. + cloudflare: + testEnv === 'development' + ? `pnpm dev:cloudflare:vite --port ${APP_PORT} --strictPort` + : `pnpm dev:cloudflare --port ${APP_PORT}`, node: `pnpm dev:node`, bun: `pnpm dev:bun`, deno: `pnpm dev:deno`, diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts index d6005250d9ee..a0a4b91fecdc 100644 --- a/packages/cloudflare/src/vite/index.ts +++ b/packages/cloudflare/src/vite/index.ts @@ -90,6 +90,7 @@ export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOp // Route the injected `tracingChannel` imports through the workerd-safe façade so a // dependency wrapped at module scope (`const app = new Hono()`) doesn't throw on workerd. dcModule: '@sentry/cloudflare/orchestrion-diagnostics-channel', + transformOptimizedDeps: true, }), sentryMastraObservabilityProviderPlugin(), sentryFlueRuntimeProviderPlugin(), diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts index ad6439cd5962..0d9aec844d59 100644 --- a/packages/server-utils/src/orchestrion/bundler/options.ts +++ b/packages/server-utils/src/orchestrion/bundler/options.ts @@ -36,6 +36,13 @@ export type PluginOptions = { * routing every channel through it is harmless. */ dcModule?: string; + /** + * When `true`, the Vite plugin also runs the transform in the dependency optimizer of server environments in + * `vite dev`. Needed for `@cloudflare/vite-plugin`, which pre-bundles the dependencies of a Worker there. + * + * @default false + */ + transformOptimizedDeps?: boolean; }; /** diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 16e642dc76fd..d1a40295c9ee 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -1,33 +1,34 @@ +import esbuildCodeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/esbuild'; import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; -import type { ConfigEnv, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { Plugin as EsbuildPlugin } from 'esbuild'; +import type { ConfigEnv, ConfigPluginContext, EnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite'; export type { Plugin as VitePlugin } from 'vite'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options'; import { createExternalRequireResolver } from './externalRequire'; -import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER } from './resolve'; +import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER, SNIPPET_IMPORT_SPECIFIER_FILTER } from './resolve'; type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown; +type ResolveIdHandler = Extract, (...args: never[]) => unknown>; // Key of a property on the plugin object. Its value is an object that is unique to each `sentryOrchestrionPlugin()` // call. An SDK that wraps the plugin with an object spread (`@sentry/remix`) copies the property, so `configResolved` // can find the first instance in a build also through a wrapper. const INSTANCE_KEY = '__sentryOrchestrionInstance'; -type OrchestrionVitePlugin = Plugin & { [INSTANCE_KEY]?: object }; +type OrchestrionInstance = { transformOptimizedDeps: boolean }; -// On Vite >= 6 `applyToEnvironment` (below) keeps the whole plugin out of -// client environments. Vite 5 (e.g. Remix v2) ignores that hook, so without -// this gate the transform would also run in the CLIENT build — where modules -// like `@remix-run/server-runtime` sit in the client graph, and the injected -// snippet's import of the subscriber factories (which import -// `node:diagnostics_channel`) breaks against Vite's browser builtin shim. Gate -// on the `ssr` flag, which Vite passes on both major versions. -function ssrOnlyTransform(transform: Plugin['transform'], isDuplicate: () => boolean): Plugin['transform'] { +type OrchestrionVitePlugin = Plugin & { [INSTANCE_KEY]?: OrchestrionInstance }; + +function gateTransform( + transform: Plugin['transform'], + skip: (opts?: { ssr?: boolean }) => boolean, +): Plugin['transform'] { const gate = (handler: TransformHandler): TransformHandler => function (code, id, opts) { - if (!opts?.ssr || isDuplicate()) { + if (skip(opts)) { return null; } return handler.call(this, code, id, opts); @@ -47,6 +48,74 @@ function toEsmEntry(id: string): string { return id.replace(/([/\\])build[/\\]cjs[/\\]index\.js$/, '$1build$1esm$1index.js'); } +// The module-injected snippet imports `@sentry/server-utils` from INSIDE +// transformed `node_modules` files. Under isolated installs (pnpm) that bare +// specifier doesn't resolve from an instrumented package's location, so when +// normal resolution fails, fall back to this package's own resolution so it +// gets bundled from its real on-disk path. +// +// Always bundle the ESM entry. In a CommonJS module the snippet `require()`s the specifier, +// which resolves to the CJS build. Vite 5-7 turn that build's `require()`s of external +// packages into default imports, and `@sentry/core` has no default export, so the server +// crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`. +async function resolveSnippetImport( + this: ThisParameterType, + ...[source, importer, resolveOptions]: Parameters +): Promise>> { + const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true }); + if (resolved) { + return resolved.external ? resolved : { ...resolved, id: toEsmEntry(resolved.id) }; + } + const fallback = resolveOrchestrionRuntimeRequest(source); + return fallback ? toEsmEntry(fallback) : null; +} + +// Rolldown plugin for the dependency optimizer of Vite 8. +function depOptimizerRolldownPlugin(options: PluginOptions, isDuplicate: () => boolean): Plugin { + const upstream = codeTransformer(orchestrionTransformOptions(options)); + return { + ...upstream, + name: 'sentry-orchestrion-dep-optimizer', + transform: gateTransform(upstream.transform, isDuplicate), + resolveId(source, importer, resolveOptions) { + return source === SNIPPET_IMPORT_SPECIFIER + ? resolveSnippetImport.call(this, source, importer, resolveOptions) + : null; + }, + }; +} + +// esbuild plugin for the dependency optimizer of Vite 6 and 7. +function depOptimizerEsbuildPlugin(options: PluginOptions, isDuplicate: () => boolean): EsbuildPlugin { + const upstream = esbuildCodeTransformer(orchestrionTransformOptions(options)); + return { + name: 'sentry-orchestrion-dep-optimizer', + setup(build) { + if (isDuplicate()) { + return; + } + // Same resolution as `resolveSnippetImport`. The `pluginData` marker stops the recursion back into this callback. + build.onResolve({ filter: SNIPPET_IMPORT_SPECIFIER_FILTER }, async args => { + if (args.pluginData === 'sentry-orchestrion-resolving') { + return null; + } + const resolved = await build.resolve(args.path, { + resolveDir: args.resolveDir, + importer: args.importer, + kind: args.kind, + pluginData: 'sentry-orchestrion-resolving', + }); + if (resolved.errors.length === 0) { + return resolved.external ? resolved : { ...resolved, path: toEsmEntry(resolved.path) }; + } + const fallback = resolveOrchestrionRuntimeRequest(args.path); + return fallback ? { path: toEsmEntry(fallback) } : null; + }); + return upstream.setup(build); + }, + }; +} + /** * Vite plugin that runs the orchestrion code transform on the bundled output. * @@ -76,38 +145,30 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { ]; const resolveExternalRequire = createExternalRequireResolver(noExternalModules()); - const instance = {}; + const instance: OrchestrionInstance = { transformOptimizedDeps: options.transformOptimizedDeps === true }; // `true` when an earlier instance of this plugin is in the same build, which then transforms the modules alone. let isDuplicate = false; + // `true` when an earlier instance with `transformOptimizedDeps` is in the same build, which then transforms the + // pre-bundled dependencies alone. + let isDuplicateOptimizer = false; const plugin: OrchestrionVitePlugin = { ...upstream, [INSTANCE_KEY]: instance, - transform: ssrOnlyTransform(upstream.transform, () => isDuplicate), - // The module-injected snippet imports `@sentry/server-utils` from INSIDE - // transformed `node_modules` files. Under isolated installs (pnpm) that bare - // specifier doesn't resolve from an instrumented package's location, so when - // normal resolution fails, fall back to this package's own resolution so it - // gets bundled from its real on-disk path. SSR-gated like the transform: the - // specifier only appears in SSR modules. - // - // Always bundle the ESM entry. In a CommonJS module the snippet `require()`s the specifier, - // which resolves to the CJS build. Vite 5-7 turn that build's `require()`s of external - // packages into default imports, and `@sentry/core` has no default export, so the server - // crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`. + // On Vite >= 6 `applyToEnvironment` (below) keeps the whole plugin out of + // client environments. Vite 5 (e.g. Remix v2) ignores that hook, so without + // this gate the transform would also run in the CLIENT build — where modules + // like `@remix-run/server-runtime` sit in the client graph, and the injected + // snippet's import of the subscriber factories (which import + // `node:diagnostics_channel`) breaks against Vite's browser builtin shim. Gate + // on the `ssr` flag, which Vite passes on both major versions. + transform: gateTransform(upstream.transform, opts => !opts?.ssr || isDuplicate), + // SSR-gated like the transform: the specifier only appears in SSR modules. async resolveId(source, importer, resolveOptions) { if (source !== SNIPPET_IMPORT_SPECIFIER) { return resolveExternalRequire.call(this, source, importer, resolveOptions); } - if (!resolveOptions?.ssr) { - return null; - } - const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true }); - if (resolved) { - return resolved.external ? resolved : { ...resolved, id: toEsmEntry(resolved.id) }; - } - const fallback = resolveOrchestrionRuntimeRequest(source); - return fallback ? toEsmEntry(fallback) : null; + return resolveOptions?.ssr ? resolveSnippetImport.call(this, source, importer, resolveOptions) : null; }, applyToEnvironment(environment) { // Orchestrion splices `node:diagnostics_channel` calls into instrumented modules, which only @@ -136,21 +197,50 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { }, }, configEnvironment( + // Vite 6 calls this hook without a context. + this: ConfigPluginContext | undefined, name: string, config: { consumer?: 'client' | 'server' }, env?: ConfigEnv, - ): { resolve: { noExternal: string[] } } | null { - if (env?.command === 'serve' || (config.consumer ?? (name === 'client' ? 'client' : 'server')) !== 'server') { + ): EnvironmentOptions | null { + if ((config.consumer ?? (name === 'client' ? 'client' : 'server')) !== 'server') { return null; } + // `@cloudflare/vite-plugin` pre-bundles the dependencies of a Worker in `serve`. The dependency + // optimizer runs no Vite plugins, so the transform has to run inside it. A Node server loads its + // dependencies with Node, where the runtime hook instruments them. + if (env?.command === 'serve') { + if (!options.transformOptimizedDeps) { + return null; + } + if (this?.meta?.rolldownVersion) { + return { + optimizeDeps: { + rolldownOptions: { plugins: [depOptimizerRolldownPlugin(options, () => isDuplicateOptimizer)] }, + }, + }; + } + return { + optimizeDeps: { + // Vite 6 and 7 have no `rolldownOptions`. + // oxlint-disable-next-line typescript/no-deprecated + esbuildOptions: { plugins: [depOptimizerEsbuildPlugin(options, () => isDuplicateOptimizer)] }, + }, + }; + } + return { resolve: { noExternal: noExternalModules() } }; }, configResolved(config: ResolvedConfig): void { // Two Sentry Vite plugins can each add this plugin to one build, for example `sentryCloudflareVitePlugin` and // `sentryReactRouter`. A module transformed twice declares the injected snippet twice and fails the build. - const first = (config.plugins as readonly OrchestrionVitePlugin[]).find(p => p[INSTANCE_KEY]); - isDuplicate = first !== undefined && first[INSTANCE_KEY] !== instance; + const instances = (config.plugins as readonly OrchestrionVitePlugin[]).flatMap(p => p[INSTANCE_KEY] ?? []); + isDuplicate = instances[0] !== undefined && instances[0] !== instance; + // The first instance can be one without `transformOptimizedDeps`, for example the one of `sentryReactRouter` + // before `sentryCloudflareVitePlugin`. + const firstOptimizer = instances.find(i => i.transformOptimizedDeps); + isDuplicateOptimizer = firstOptimizer !== undefined && firstOptimizer !== instance; // Nothing is force-bundled in `serve`, so an externalized module is expected there. if (isDuplicate || config.command === 'serve') { diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index b0690ab74903..33abf914aa74 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -1,8 +1,8 @@ import { existsSync } from 'node:fs'; import { isAbsolute } from 'node:path'; -import type { OnStartResult, PluginBuild } from 'esbuild'; +import type { OnResolveArgs, OnResolveResult, OnStartResult, Plugin as EsbuildPlugin, PluginBuild } from 'esbuild'; import type { NormalizedInputOptions, PluginContext } from 'rollup'; -import type { ResolvedConfig } from 'vite'; +import type { ResolvedConfig, Plugin as VitePlugin } from 'vite'; import type { Compiler } from 'webpack'; import { describe, expect, it, vi } from 'vitest'; import { sentryOrchestrionPlugin as bunPlugin } from '../../src/orchestrion/bundler/bun'; @@ -245,18 +245,34 @@ describe('sentryOrchestrionPlugin (vite)', () => { return config.handler.call(plugin, userConfig, { command, mode: 'production' }); } + type EnvironmentConfigResult = { + resolve?: { noExternal: string[] }; + optimizeDeps?: { + esbuildOptions?: { plugins: EsbuildPlugin[] }; + rolldownOptions?: { plugins: VitePlugin[] }; + }; + } | null; + function runConfigEnvironment( name: string, environmentConfig: Record, - command: 'build' | 'serve' = 'build', - ): { resolve: { noExternal: string[] } } | null { - const plugin = vitePlugin(); + { + command = 'build', + context, + plugin = vitePlugin(), + }: { + command?: 'build' | 'serve'; + // Vite 6 passes no context, Vite 8 passes `rolldownVersion`. + context?: { meta: { rolldownVersion?: string } }; + plugin?: VitePlugin; + } = {}, + ): EnvironmentConfigResult { const configEnvironment = plugin.configEnvironment as ( name: string, config: unknown, env: unknown, - ) => { resolve: { noExternal: string[] } } | null; - return configEnvironment.call(plugin, name, environmentConfig, { command, mode: 'production' }); + ) => EnvironmentConfigResult; + return configEnvironment.call(context, name, environmentConfig, { command, mode: 'production' }); } it('warns when instrumented modules are listed in ssr.external', () => { @@ -286,7 +302,145 @@ describe('sentryOrchestrionPlugin (vite)', () => { it('does not force-bundle instrumented modules on the dev server', () => { // Inlined in dev, the CommonJS drivers throw `exports is not defined` on import. expect(runConfig('serve')).toBeNull(); - expect(runConfigEnvironment('ssr', {}, 'serve')).toBeNull(); + expect( + runConfigEnvironment('ssr', {}, { command: 'serve', plugin: vitePlugin({ transformOptimizedDeps: true }) }) + ?.resolve, + ).toBeUndefined(); + }); + + it('does not change the dependency optimizer on the dev server without transformOptimizedDeps', () => { + // A Node server loads its dependencies with Node, where the runtime hook instruments them. + expect(runConfigEnvironment('ssr', {}, { command: 'serve' })).toBeNull(); + expect( + runConfigEnvironment( + 'worker', + { consumer: 'server' }, + { command: 'serve', context: { meta: { rolldownVersion: '1.2.14' } } }, + ), + ).toBeNull(); + }); + + it('runs the transform in the dependency optimizer of server environments with transformOptimizedDeps', () => { + const plugin = vitePlugin({ transformOptimizedDeps: true }); + const context = { meta: { rolldownVersion: '1.2.14' } }; + + expect(runConfigEnvironment('ssr', {}, { command: 'serve', plugin })).toEqual({ + optimizeDeps: { + esbuildOptions: { plugins: [expect.objectContaining({ name: 'sentry-orchestrion-dep-optimizer' })] }, + }, + }); + expect(runConfigEnvironment('worker', { consumer: 'server' }, { command: 'serve', context, plugin })).toEqual({ + optimizeDeps: { + rolldownOptions: { plugins: [expect.objectContaining({ name: 'sentry-orchestrion-dep-optimizer' })] }, + }, + }); + expect(runConfigEnvironment('client', {}, { command: 'serve', plugin })).toBeNull(); + expect(runConfigEnvironment('browser', { consumer: 'client' }, { command: 'serve', context, plugin })).toBeNull(); + }); + + it('transforms in the Rolldown dependency optimizer without the ssr flag, only in the first instance with transformOptimizedDeps', () => { + // For example the instance of `sentryReactRouter` before the one of `sentryCloudflareVitePlugin`. + const other = vitePlugin(); + const first = vitePlugin({ transformOptimizedDeps: true }); + const second = vitePlugin({ transformOptimizedDeps: true }); + const config = { + command: 'serve', + plugins: [other, first, second], + ssr: {}, + logger: { warn: vi.fn() }, + } as unknown as ResolvedConfig; + const context = { meta: { rolldownVersion: '1.2.14' } }; + const firstOptimizerPlugin = runConfigEnvironment('worker', {}, { command: 'serve', context, plugin: first }) + ?.optimizeDeps?.rolldownOptions?.plugins[0]; + const secondOptimizerPlugin = runConfigEnvironment('worker', {}, { command: 'serve', context, plugin: second }) + ?.optimizeDeps?.rolldownOptions?.plugins[0]; + + (other.configResolved as (config: unknown) => void)(config); + (first.configResolved as (config: unknown) => void)(config); + (second.configResolved as (config: unknown) => void)(config); + + const firstTransform = firstOptimizerPlugin?.transform as (this: unknown, code: string, id: string) => unknown; + const secondTransform = secondOptimizerPlugin?.transform as (this: unknown, code: string, id: string) => unknown; + expect(firstTransform.call({}, 'code', 'id')).toBe('transformed'); + expect(secondTransform.call({}, 'code', 'id')).toBeNull(); + }); + + it('resolves the snippet import to the ESM entry in the Rolldown dependency optimizer', async () => { + const optimizerPlugin = runConfigEnvironment( + 'worker', + {}, + { + command: 'serve', + context: { meta: { rolldownVersion: '1.2.14' } }, + plugin: vitePlugin({ transformOptimizedDeps: true }), + }, + )?.optimizeDeps?.rolldownOptions?.plugins[0]; + const resolveId = optimizerPlugin?.resolveId as ( + this: unknown, + source: string, + importer: string, + opts: { kind: string }, + ) => unknown; + const resolve = vi.fn().mockResolvedValue({ id: '/app/node_modules/@sentry/server-utils/build/cjs/index.js' }); + + await expect( + resolveId.call({ resolve }, '@sentry/server-utils', '/app/node_modules/mysql/index.js', { kind: 'require-call' }), + ).resolves.toEqual({ id: '/app/node_modules/@sentry/server-utils/build/esm/index.js' }); + expect(resolveId.call({ resolve }, 'mysql', '/app/src/index.ts', { kind: 'import-statement' })).toBeNull(); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it('resolves the snippet import to the ESM entry in the esbuild dependency optimizer', async () => { + const optimizerPlugin = runConfigEnvironment( + 'ssr', + {}, + { command: 'serve', plugin: vitePlugin({ transformOptimizedDeps: true }) }, + )?.optimizeDeps?.esbuildOptions?.plugins[0]; + const onResolve = vi.fn(); + const resolve = vi.fn().mockResolvedValue({ + errors: [], + path: '/app/node_modules/@sentry/server-utils/build/cjs/index.js', + }); + await optimizerPlugin?.setup({ onResolve, resolve } as unknown as PluginBuild); + + expect(onResolve).toHaveBeenCalledWith({ filter: /^@sentry\/server-utils$/ }, expect.any(Function)); + const callback = onResolve.mock.lastCall?.[1] as (args: Partial) => Promise; + await expect( + callback({ path: '@sentry/server-utils', importer: '/app/node_modules/mysql/index.js', kind: 'require-call' }), + ).resolves.toEqual({ errors: [], path: '/app/node_modules/@sentry/server-utils/build/esm/index.js' }); + + // When it fails (pnpm isolation), fall back to this package's own resolution. + resolve.mockResolvedValueOnce({ errors: [{ text: 'Could not resolve' }] }); + const fallback = await callback({ path: '@sentry/server-utils', importer: '/x.js', kind: 'require-call' }); + expect(fallback?.path).toMatch(/[/\\]build[/\\]esm[/\\]index\.js$/); + }); + + it('sets up the esbuild dependency optimizer plugin only in the first instance with transformOptimizedDeps', async () => { + const other = vitePlugin(); + const first = vitePlugin({ transformOptimizedDeps: true }); + const second = vitePlugin({ transformOptimizedDeps: true }); + const config = { + command: 'serve', + plugins: [other, first, second], + ssr: {}, + logger: { warn: vi.fn() }, + } as unknown as ResolvedConfig; + const firstOptimizerPlugin = runConfigEnvironment('ssr', {}, { command: 'serve', plugin: first })?.optimizeDeps + ?.esbuildOptions?.plugins[0]; + const secondOptimizerPlugin = runConfigEnvironment('ssr', {}, { command: 'serve', plugin: second })?.optimizeDeps + ?.esbuildOptions?.plugins[0]; + + (other.configResolved as (config: unknown) => void)(config); + (first.configResolved as (config: unknown) => void)(config); + (second.configResolved as (config: unknown) => void)(config); + + const firstOnResolve = vi.fn(); + const secondOnResolve = vi.fn(); + await firstOptimizerPlugin?.setup({ onResolve: firstOnResolve } as unknown as PluginBuild); + await secondOptimizerPlugin?.setup({ onResolve: secondOnResolve } as unknown as PluginBuild); + + expect(firstOnResolve).toHaveBeenCalledTimes(1); + expect(secondOnResolve).not.toHaveBeenCalled(); }); it('adds the top-level ssr option only when the config already has ssr or build.ssr', () => { @@ -296,10 +450,10 @@ describe('sentryOrchestrionPlugin (vite)', () => { }); it('force-bundles instrumented modules in server environments', () => { - expect(runConfigEnvironment('ssr', {})?.resolve.noExternal).toEqual( + expect(runConfigEnvironment('ssr', {})?.resolve?.noExternal).toEqual( expect.arrayContaining(['mysql', '@sentry/server-utils']), ); - expect(runConfigEnvironment('worker', { consumer: 'server' })?.resolve.noExternal).toContain('mysql'); + expect(runConfigEnvironment('worker', { consumer: 'server' })?.resolve?.noExternal).toContain('mysql'); expect(runConfigEnvironment('client', {})).toBeNull(); expect(runConfigEnvironment('browser', { consumer: 'client' })).toBeNull(); }); diff --git a/packages/server-utils/test/orchestrion/viteDepOptimizer.test.ts b/packages/server-utils/test/orchestrion/viteDepOptimizer.test.ts new file mode 100644 index 000000000000..d8ca916ede5a --- /dev/null +++ b/packages/server-utils/test/orchestrion/viteDepOptimizer.test.ts @@ -0,0 +1,60 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createServer, type ViteDevServer } from 'vite'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { InstrumentationConfig } from '../../src/orchestrion/apmTypes'; +import { sentryOrchestrionPlugin } from '../../src/orchestrion/bundler/vite'; + +const instrumentations: InstrumentationConfig[] = [ + { + channelName: 'query', + module: { name: 'fake-db', versionRange: '>=1.0.0', filePath: 'index.js' }, + functionQuery: { functionName: 'query', kind: 'Sync' }, + }, +]; + +describe('sentryOrchestrionPlugin (vite) in the Rolldown dependency optimizer', () => { + let root: string; + let server: ViteDevServer; + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), 'orchestrion-dep-optimizer-')); + const packageDir = join(root, 'node_modules', 'fake-db'); + mkdirSync(packageDir, { recursive: true }); + writeFileSync( + join(packageDir, 'package.json'), + JSON.stringify({ name: 'fake-db', version: '1.0.0', type: 'module' }), + ); + writeFileSync(join(packageDir, 'index.js'), 'export function query(sql) {\n return sql;\n}\n'); + + server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [sentryOrchestrionPlugin({ instrumentations, transformOptimizedDeps: true })], + server: { middlewareMode: true, ws: false, watch: null }, + environments: { + ssr: { + optimizeDeps: { include: ['fake-db'], rolldownOptions: { external: ['@sentry/server-utils'] } }, + }, + }, + }); + await server.environments.ssr.depsOptimizer?.init(); + }); + + afterAll(async () => { + await server.close(); + rmSync(root, { recursive: true, force: true }); + }); + + it('transforms a pre-bundled dependency and maps it only to its file in node_modules', () => { + const file = server.environments.ssr.depsOptimizer?.metadata.optimized['fake-db']?.file; + expect(file).toBeDefined(); + + expect(readFileSync(file!, 'utf8')).toContain('orchestrion:fake-db:query'); + const map = JSON.parse(readFileSync(`${file}.map`, 'utf8')) as { sources: string[] }; + // `node_modules/.vite/deps_ssr/fake-db.js` -> `node_modules/fake-db/index.js` + expect(map.sources).toEqual(['../../fake-db/index.js']); + }); +});