diff --git a/CHANGELOG.md b/CHANGELOG.md index cdb7bed50410..aba9a71f9064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehaprasad-dev, @JealousGx, @Jxxunnn, @eddie333016, @davidmurdoch, @yashschandra, @atharv-sys32, @AG0708, @birkskyum, @mkly, @mcbbugu, @suhailopensource, @zkasuran, @mohd-akram, @RealBhupesh, @halillusion, @psang39, @hafzism, @JosephDoUrden, @Tyagiquamar, @Andarist, and @msnelling. Thank you for your contributions! +- ref(bundler-plugins)!: The webpack plugin now requires webpack 5.1 or newer and reads its plugin classes from `compiler.webpack` only, so the `webpack` peer dependency is now `>=5.1.0`. The `@sentry/bundler-plugins/webpack5` entry point was removed; import `sentryWebpackPlugin` from `@sentry/bundler-plugins/webpack` instead, which exports the same plugin. - feat(core): Add `createFetchIntegration`, the shared implementation behind the global-`fetch` integrations in `@sentry/bun`, `@sentry/cloudflare`, `@sentry/deno` and `@sentry/vercel-edge`. Those four packages carried four copies of it; they now share one. Two changes come out of that: - All four gain a `tracePropagation` option (default `true`). Turn it off to stop injecting `sentry-trace` and `baggage` without also turning off spans. To scope propagation to specific URLs, keep using `tracePropagationTargets` in the client options. - Integration options now follow the client. Previously a second `Sentry.init()` in the same process silently reused the options of the first one. diff --git a/MIGRATION.md b/MIGRATION.md index f2d127a7c465..480e5b6dd4bf 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -54,6 +54,7 @@ We raised the minimum supported versions of several frameworks and libraries: - **Astro:** dropped Astro 3 (minimum is now 4). - **React Router (framework mode):** minimum is now 7.15. - **Fastify:** dropped Fastify 3.0 through 3.20 (minimum is now 3.21). +- **webpack (bundler plugin):** dropped webpack 5.0.x (minimum is now 5.1). ### AWS Lambda Layer Changes @@ -1816,6 +1817,18 @@ The deprecated `sourceMapsUploadOptions` and other deprecated Vite/build plugin Deploys that the bundler plugins create automatically on Vercel now use the value of `VERCEL_TARGET_ENV` (`production`, `preview`, or a custom environment name) as their environment instead of `vercel-production` / `vercel-preview`. This matches the new default runtime `environment` of `@sentry/nextjs`, and the `production` default of all other SDKs. If your events use a different environment, set `release.deploy.env` to the same value, or set `release.deploy` to `false` to opt out. +### Bundler plugins: `@sentry/bundler-plugins/webpack5` was removed + +The `@sentry/bundler-plugins/webpack5` entry point was removed. It exported the same `sentryWebpackPlugin` as `@sentry/bundler-plugins/webpack`, minus a fallback that only mattered on webpack 4 and 5.0.x. The webpack plugin now requires webpack 5.1 or newer (the first version that exposes `compiler.webpack`), so there is nothing left to distinguish the two entry points. + +```js +// before +import { sentryWebpackPlugin } from '@sentry/bundler-plugins/webpack5'; + +// after +import { sentryWebpackPlugin } from '@sentry/bundler-plugins/webpack'; +``` + ### Removed `unstable_` bundler plugin options The `unstable_sentry*PluginOptions` escape hatch was removed from every SDK. It existed because the Sentry diff --git a/packages/bundler-plugins/package.json b/packages/bundler-plugins/package.json index c2b0f87babc2..ecd66a1b7ce4 100644 --- a/packages/bundler-plugins/package.json +++ b/packages/bundler-plugins/package.json @@ -24,11 +24,6 @@ "import": "./build/esm/webpack/index.js", "require": "./build/cjs/webpack/index.js" }, - "./webpack5": { - "types": "./build/types/webpack/webpack5.d.ts", - "import": "./build/esm/webpack/webpack5.js", - "require": "./build/cjs/webpack/webpack5.js" - }, "./rollup": { "types": "./build/types/rollup/index.d.ts", "import": "./build/esm/rollup/index.js", @@ -121,7 +116,7 @@ }, "peerDependencies": { "rollup": ">=3.2.0", - "webpack": ">=5.0.0" + "webpack": ">=5.1.0" }, "peerDependenciesMeta": { "rollup": { @@ -135,7 +130,6 @@ "@babel/preset-react": "^7.23.3", "@types/babel__core": "^7.20.5", "@types/node": "^18.6.3", - "@types/webpack": "npm:@types/webpack@^4", "premove": "^4.0.0", "rolldown": "^1.0.0", "vitest": "^3.2.7", diff --git a/packages/bundler-plugins/rollup.npm.config.mjs b/packages/bundler-plugins/rollup.npm.config.mjs index bd0f18d8a23d..fe1cb44cbe76 100644 --- a/packages/bundler-plugins/rollup.npm.config.mjs +++ b/packages/bundler-plugins/rollup.npm.config.mjs @@ -8,7 +8,6 @@ export default makeNPMConfigVariants( 'src/vite/index.ts', 'src/esbuild/index.ts', 'src/webpack/index.ts', - 'src/webpack/webpack5.ts', 'src/webpack/component-annotation-transform.ts', 'src/babel-plugin/index.ts', ], diff --git a/packages/bundler-plugins/src/webpack/index.ts b/packages/bundler-plugins/src/webpack/index.ts index 634f2c1e958f..0597b5794c08 100644 --- a/packages/bundler-plugins/src/webpack/index.ts +++ b/packages/bundler-plugins/src/webpack/index.ts @@ -1,37 +1,353 @@ -import type { SentryWebpackPluginOptions } from './webpack4and5'; -import { sentryWebpackPluginFactory } from './webpack4and5'; +import type { Options } from '../core/index'; +import { + createSentryBuildPluginManager, + generateReleaseInjectorCode, + generateModuleMetadataInjectorCode, + stringToUUID, + createComponentNameAnnotateHooks, + CodeInjection, + getDebugIdSnippet, + createDebugIdUploadFunction, + isJsFile, + stampDebugId, +} from '../core/index'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; +import { randomUUID } from 'node:crypto'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type PluginClass = new (options: any) => unknown; +const _req = createRequire(import.meta.url); + +// Resolve the loader path via the package's own exports. +// This module may end up in a shared chunk (_chunks/) whose import.meta.url +// does not point to the webpack/ directory where the transform file lives, so +// a path-relative lookup would fail. Using require.resolve on the package export +// always finds the correct installed file regardless of chunk placement. +let COMPONENT_ANNOTATION_LOADER: string; +try { + COMPONENT_ANNOTATION_LOADER = _req.resolve('@sentry/bundler-plugins/webpack-loader'); +} catch { + // Fallback for non-packaged environments (e.g., monorepo source runs without dist) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Rollup transpiles import.meta for us for CJS + const dirname = path.dirname(fileURLToPath(import.meta.url)); + // The Rollup build emits `.js` for both CJS and ESM, so the extension is the same in both. + COMPONENT_ANNOTATION_LOADER = path.resolve(dirname, 'component-annotation-transform.js'); +} + +interface BannerPluginCallbackArg { + chunk?: { + hash?: string; + contentHash?: { + javascript?: string; + }; + }; +} + +type PluginClass = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (options: any): unknown; +}; + +type WebpackSource = { + source: () => string | Buffer; +}; + +type WebpackRawSource = { + new (source: string): WebpackSource; +}; + +type WebpackAsset = { + name: string; + source: WebpackSource; + info: { + related?: { + sourceMap?: string | string[]; + }; + }; +}; + +type WebpackCompiler = { + options: { + plugins?: unknown[]; + mode?: string; + module?: { + rules?: unknown[]; + }; + }; + hooks: { + thisCompilation: { + tap: (name: string, callback: (compilation: WebpackCompilation) => void) => void; + }; + afterEmit: { + tapAsync: (name: string, callback: (compilation: WebpackCompilation, cb: () => void) => void) => void; + }; + done: { + tap: (name: string, callback: () => void) => void; + }; + }; + webpack?: { + BannerPlugin?: PluginClass; + DefinePlugin?: PluginClass; + Compilation?: { + PROCESS_ASSETS_STAGE_DEV_TOOLING?: number; + }; + sources?: { + RawSource?: WebpackRawSource; + }; + }; +}; + +type WebpackCompilation = { + outputOptions: { + path?: string; + }; + assets: Record; + getAssets: () => WebpackAsset[]; + getAsset: (name: string) => WebpackAsset | undefined; + updateAsset: (name: string, source: WebpackSource) => void; + hooks: { + processAssets: { + tap: (options: { name: string; stage: number }, callback: () => void) => void; + }; + }; +}; type WebpackModule = { - BannerPlugin?: PluginClass; - DefinePlugin?: PluginClass; - default?: WebpackModule; + version?: string; + default?: { version?: string }; }; -// `webpack` is an optional peer dependency. We require it lazily so the plugin doesn't -// crash on load in bundlers that don't ship `webpack` (e.g. rspack) — those provide -// the plugin classes via `compiler.webpack` at runtime instead. -function loadWebpack(): WebpackModule { +// Only used for telemetry; `webpack` is an optional peer dependency and may be absent (e.g. rspack). +function getWebpackMajorVersion(): string | undefined { try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore Rollup transpiles import.meta for CJS - return createRequire(import.meta.url)('webpack') as WebpackModule; + const webpack = _req('webpack') as WebpackModule; + const version = webpack.version ?? webpack.default?.version; + return version?.split('.')[0]; } catch { - return {}; + return undefined; + } +} + +/** + * Stamps each JS asset's debug ID into the asset itself and its source map asset. + * + * Runs after source maps have been generated, so the JS asset no longer needs to carry + * source map information and can be replaced with a plain `RawSource`. + */ +function addDebugIdsToAssets(compilation: WebpackCompilation, RawSource: WebpackRawSource): void { + for (const asset of compilation.getAssets()) { + if (!isJsFile(asset.name)) { + continue; + } + + const bundleSource = asset.source.source().toString(); + const relatedSourceMap = asset.info.related?.sourceMap; + const sourceMapName = typeof relatedSourceMap === 'string' ? relatedSourceMap : `${asset.name}.map`; + const sourceMapAsset = compilation.getAsset(sourceMapName); + + const stamped = stampDebugId(bundleSource, sourceMapAsset?.source.source().toString()); + if (!stamped) { + continue; + } + + compilation.updateAsset(asset.name, new RawSource(stamped.bundleSource)); + if (stamped.sourceMapSource !== undefined) { + compilation.updateAsset(sourceMapName, new RawSource(stamped.sourceMapSource)); + } } } -const webpack = loadWebpack(); -const BannerPlugin = webpack.BannerPlugin ?? webpack.default?.BannerPlugin; -const DefinePlugin = webpack.DefinePlugin ?? webpack.default?.DefinePlugin; +function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) { + const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { + loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? '[sentry-webpack-plugin]', + buildTool: 'webpack', + buildToolMajorVersion: getWebpackMajorVersion(), + }); + + const { + logger, + normalizedOptions: options, + bundleSizeOptimizationReplacementValues: replacementValues, + bundleMetadata, + createDependencyOnBuildArtifacts, + } = sentryBuildPluginManager; + + if (options.disable) { + return { + apply() { + // noop plugin + }, + }; + } + + if (process.cwd().match(/\\node_modules\\|\/node_modules\//)) { + logger.warn('Running Sentry plugin from within a `node_modules` folder. Some features may not work.'); + } + + const sourcemapsEnabled = options.sourcemaps?.disable !== true; + const staticInjectionCode = new CodeInjection(); + + if (!options.release.inject) { + logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); + } else if (!options.release.name) { + logger.debug( + 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', + ); + } else { + staticInjectionCode.append( + generateReleaseInjectorCode({ + release: options.release.name, + injectBuildInformation: options._experiments.injectBuildInformation || false, + }), + ); + } + + if (Object.keys(bundleMetadata).length > 0) { + staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + } + + const transformAnnotations = options.reactComponentAnnotation?.enabled + ? createComponentNameAnnotateHooks( + options.reactComponentAnnotation?.ignoredComponents || [], + !!options.reactComponentAnnotation?._experimentalInjectIntoHtml, + ) + : undefined; + + const transformReplace = Object.keys(replacementValues).length > 0; + + return { + apply(compiler: WebpackCompiler) { + void sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal().catch(() => { + // Telemetry failures are acceptable + }); + + const { BannerPlugin, DefinePlugin } = compiler.webpack ?? {}; + + // Add BannerPlugin for code injection (release, metadata, debug IDs) + if (!staticInjectionCode.isEmpty() || sourcemapsEnabled) { + if (!BannerPlugin) { + logger.warn( + 'BannerPlugin is not available. Skipping code injection. This usually means webpack is not properly configured.', + ); + } else { + compiler.options.plugins = compiler.options.plugins || []; + compiler.options.plugins.push( + new BannerPlugin({ + raw: true, + include: /\.(js|ts|jsx|tsx|mjs|cjs)(\?[^?]*)?(#[^#]*)?$/, + banner: (arg?: BannerPluginCallbackArg) => { + const codeToInject = staticInjectionCode.clone(); + if (sourcemapsEnabled) { + const hash = arg?.chunk?.contentHash?.javascript ?? arg?.chunk?.hash; + const debugId = hash ? stringToUUID(hash) : randomUUID(); + codeToInject.append(getDebugIdSnippet(debugId)); + } + return codeToInject.code(); + }, + }), + ); + } + } + + // The upload routine (which stamps debug IDs into temp copies of the artifacts) is skipped + // with `disable-upload`, so the emitted artifacts get stamped in the asset pipeline instead. + if (sourcemapsEnabled && options.sourcemaps?.disable === 'disable-upload') { + const RawSource = compiler.webpack?.sources?.RawSource; + // Right after source map generation (and thus after minification, which would strip the comment), + // so later stages (real content hashing, subresource integrity) see the final assets. + const stage = (compiler.webpack?.Compilation?.PROCESS_ASSETS_STAGE_DEV_TOOLING ?? 500) + 1; + + if (!RawSource) { + logger.warn( + 'Webpack sources are not available. Skipping debug ID injection into emitted source maps. This usually means webpack is not properly configured.', + ); + } else { + compiler.hooks.thisCompilation.tap('sentry-webpack-plugin', compilation => { + compilation.hooks.processAssets.tap({ name: 'sentry-webpack-plugin', stage }, () => { + addDebugIdsToAssets(compilation, RawSource); + }); + }); + } + } + + // Add DefinePlugin for bundle size optimizations + if (transformReplace && DefinePlugin) { + compiler.options.plugins = compiler.options.plugins || []; + compiler.options.plugins.push(new DefinePlugin(replacementValues)); + } + + // Add component name annotation transform + if (transformAnnotations?.transform) { + compiler.options.module = compiler.options.module || {}; + compiler.options.module.rules = compiler.options.module.rules || []; + compiler.options.module.rules.unshift({ + test: /\.[jt]sx$/, + exclude: /node_modules/, + enforce: 'pre', + use: [ + { + loader: COMPONENT_ANNOTATION_LOADER, + options: { + transform: transformAnnotations.transform, + }, + }, + ], + }); + } + + compiler.hooks.afterEmit.tapAsync( + 'sentry-webpack-plugin', + (compilation: WebpackCompilation, callback: (err?: Error) => void) => { + const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); + const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); + + const run = async (): Promise => { + try { + await sentryBuildPluginManager.createRelease(); + if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') { + const outputPath = compilation.outputOptions.path ?? path.resolve(); + const buildArtifacts = Object.keys(compilation.assets).map(asset => path.join(outputPath, asset)); + await upload(buildArtifacts); + } + } finally { + freeGlobalDependencyOnBuildArtifacts(); + await sentryBuildPluginManager.deleteArtifacts(); + } + }; + + run().then( + () => callback(), + (err: Error) => callback(err), + ); + }, + ); + + if (userOptions._experiments?.forceExitOnBuildCompletion && compiler.options.mode === 'production') { + compiler.hooks.done.tap('sentry-webpack-plugin', () => { + setTimeout(() => { + logger.debug('Exiting process after debug file upload'); + process.exit(0); + }); + }); + } + }, + }; +} // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = sentryWebpackPluginFactory({ - BannerPlugin, - DefinePlugin, -}); +export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = createSentryWebpackPlugin; -export type { SentryWebpackPluginOptions }; +export type SentryWebpackPluginOptions = Options & { + _experiments?: Options['_experiments'] & { + /** + * If enabled, the webpack plugin will exit the build process after the build completes. + * Use this with caution, as it will terminate the process. + * + * More information: https://github.com/getsentry/sentry-javascript-bundler-plugins/issues/345 + * + * @default false + */ + forceExitOnBuildCompletion?: boolean; + }; +}; diff --git a/packages/bundler-plugins/src/webpack/webpack4and5.ts b/packages/bundler-plugins/src/webpack/webpack4and5.ts deleted file mode 100644 index 42c635e37bb2..000000000000 --- a/packages/bundler-plugins/src/webpack/webpack4and5.ts +++ /dev/null @@ -1,375 +0,0 @@ -import type { Options } from '../core/index'; -import { - createSentryBuildPluginManager, - generateReleaseInjectorCode, - generateModuleMetadataInjectorCode, - stringToUUID, - createComponentNameAnnotateHooks, - CodeInjection, - getDebugIdSnippet, - createDebugIdUploadFunction, - isJsFile, - stampDebugId, -} from '../core/index'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createRequire } from 'node:module'; -import { randomUUID } from 'node:crypto'; - -const _req = createRequire(import.meta.url); - -// Resolve the loader path via the package's own exports. -// webpack4and5.ts may end up in a shared chunk (_chunks/) whose import.meta.url -// does not point to the webpack/ directory where the transform file lives, so -// a path-relative lookup would fail. Using require.resolve on the package export -// always finds the correct installed file regardless of chunk placement. -let COMPONENT_ANNOTATION_LOADER: string; -try { - COMPONENT_ANNOTATION_LOADER = _req.resolve('@sentry/bundler-plugins/webpack-loader'); -} catch { - // Fallback for non-packaged environments (e.g., monorepo source runs without dist) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore Rollup transpiles import.meta for us for CJS - const dirname = path.dirname(fileURLToPath(import.meta.url)); - // The Rollup build emits `.js` for both CJS and ESM, so the extension is the same in both. - COMPONENT_ANNOTATION_LOADER = path.resolve(dirname, 'component-annotation-transform.js'); -} - -// since webpack 5.1 compiler contains webpack module so plugins always use correct webpack version -// https://github.com/webpack/webpack/commit/65eca2e529ce1d79b79200d4bdb1ce1b81141459 - -interface BannerPluginCallbackArg { - chunk?: { - hash?: string; - contentHash?: { - javascript?: string; - }; - }; -} - -type UnsafeBannerPlugin = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (options: any): unknown; -}; - -type UnsafeDefinePlugin = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (options: any): unknown; -}; - -type WebpackSource = { - source: () => string | Buffer; -}; - -type WebpackRawSource = { - new (source: string): WebpackSource; -}; - -type WebpackAsset = { - name: string; - source: WebpackSource; - info: { - related?: { - sourceMap?: string | string[]; - }; - }; -}; - -type WebpackCompiler = { - options: { - plugins?: unknown[]; - mode?: string; - module?: { - rules?: unknown[]; - }; - }; - hooks: { - thisCompilation: { - tap: (name: string, callback: (compilation: WebpackCompilation) => void) => void; - }; - afterEmit: { - tapAsync: (name: string, callback: (compilation: WebpackCompilation, cb: () => void) => void) => void; - }; - done: { - tap: (name: string, callback: () => void) => void; - }; - }; - webpack?: { - BannerPlugin?: UnsafeBannerPlugin; - DefinePlugin?: UnsafeDefinePlugin; - Compilation?: { - PROCESS_ASSETS_STAGE_DEV_TOOLING?: number; - }; - sources?: { - RawSource?: WebpackRawSource; - }; - }; -}; - -type WebpackCompilation = { - outputOptions: { - path?: string; - }; - assets: Record; - getAssets: () => WebpackAsset[]; - getAsset: (name: string) => WebpackAsset | undefined; - updateAsset: (name: string, source: WebpackSource) => void; - hooks: { - processAssets: { - tap: (options: { name: string; stage: number }, callback: () => void) => void; - }; - }; -}; - -// Detect webpack major version for telemetry (helps differentiate webpack 4 vs 5 usage) -function getWebpackMajorVersion(): string | undefined { - try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Rollup already transpiles this for us - const req = createRequire(import.meta.url); - const webpack = req('webpack') as { version?: string; default?: { version?: string } }; - const version = webpack?.version ?? webpack?.default?.version; - const webpackMajorVersion = version?.split('.')[0]; // "4" or "5" - return webpackMajorVersion; - } catch { - return undefined; - } -} - -/** - * Stamps each JS asset's debug ID into the asset itself and its source map asset. - * - * Runs after source maps have been generated, so the JS asset no longer needs to carry - * source map information and can be replaced with a plain `RawSource`. - */ -function addDebugIdsToAssets(compilation: WebpackCompilation, RawSource: WebpackRawSource): void { - for (const asset of compilation.getAssets()) { - if (!isJsFile(asset.name)) { - continue; - } - - const bundleSource = asset.source.source().toString(); - const relatedSourceMap = asset.info.related?.sourceMap; - const sourceMapName = typeof relatedSourceMap === 'string' ? relatedSourceMap : `${asset.name}.map`; - const sourceMapAsset = compilation.getAsset(sourceMapName); - - const stamped = stampDebugId(bundleSource, sourceMapAsset?.source.source().toString()); - if (!stamped) { - continue; - } - - compilation.updateAsset(asset.name, new RawSource(stamped.bundleSource)); - if (stamped.sourceMapSource !== undefined) { - compilation.updateAsset(sourceMapName, new RawSource(stamped.sourceMapSource)); - } - } -} - -/** - * The factory function accepts BannerPlugin and DefinePlugin classes in - * order to avoid direct dependencies on webpack. - * - * This allow us to export version of the plugin for webpack 5.1+ and compatible environments. - * - * Since webpack 5.1 compiler contains webpack module so plugins always use correct webpack version. - */ -export function sentryWebpackPluginFactory({ - BannerPlugin: UnsafeBannerPlugin, - DefinePlugin: UnsafeDefinePlugin, -}: { - BannerPlugin?: UnsafeBannerPlugin; - DefinePlugin?: UnsafeDefinePlugin; -} = {}) { - return function sentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) { - const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { - loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? '[sentry-webpack-plugin]', - buildTool: 'webpack', - buildToolMajorVersion: getWebpackMajorVersion(), - }); - - const { - logger, - normalizedOptions: options, - bundleSizeOptimizationReplacementValues: replacementValues, - bundleMetadata, - createDependencyOnBuildArtifacts, - } = sentryBuildPluginManager; - - if (options.disable) { - return { - apply() { - // noop plugin - }, - }; - } - - if (process.cwd().match(/\\node_modules\\|\/node_modules\//)) { - logger.warn('Running Sentry plugin from within a `node_modules` folder. Some features may not work.'); - } - - const sourcemapsEnabled = options.sourcemaps?.disable !== true; - const staticInjectionCode = new CodeInjection(); - - if (!options.release.inject) { - logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); - } else if (!options.release.name) { - logger.debug( - 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', - ); - } else { - staticInjectionCode.append( - generateReleaseInjectorCode({ - release: options.release.name, - injectBuildInformation: options._experiments.injectBuildInformation || false, - }), - ); - } - - if (Object.keys(bundleMetadata).length > 0) { - staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); - } - - const transformAnnotations = options.reactComponentAnnotation?.enabled - ? createComponentNameAnnotateHooks( - options.reactComponentAnnotation?.ignoredComponents || [], - !!options.reactComponentAnnotation?._experimentalInjectIntoHtml, - ) - : undefined; - - const transformReplace = Object.keys(replacementValues).length > 0; - - return { - apply(compiler: WebpackCompiler) { - void sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal().catch(() => { - // Telemetry failures are acceptable - }); - - // Get the correct plugin classes (webpack 5.1+ vs older versions) - const BannerPlugin = compiler?.webpack?.BannerPlugin || UnsafeBannerPlugin; - const DefinePlugin = compiler?.webpack?.DefinePlugin || UnsafeDefinePlugin; - - // Add BannerPlugin for code injection (release, metadata, debug IDs) - if (!staticInjectionCode.isEmpty() || sourcemapsEnabled) { - if (!BannerPlugin) { - logger.warn( - 'BannerPlugin is not available. Skipping code injection. This usually means webpack is not properly configured.', - ); - } else { - compiler.options.plugins = compiler.options.plugins || []; - compiler.options.plugins.push( - new BannerPlugin({ - raw: true, - include: /\.(js|ts|jsx|tsx|mjs|cjs)(\?[^?]*)?(#[^#]*)?$/, - banner: (arg?: BannerPluginCallbackArg) => { - const codeToInject = staticInjectionCode.clone(); - if (sourcemapsEnabled) { - const hash = arg?.chunk?.contentHash?.javascript ?? arg?.chunk?.hash; - const debugId = hash ? stringToUUID(hash) : randomUUID(); - codeToInject.append(getDebugIdSnippet(debugId)); - } - return codeToInject.code(); - }, - }), - ); - } - } - - // The upload routine (which stamps debug IDs into temp copies of the artifacts) is skipped - // with `disable-upload`, so the emitted artifacts get stamped in the asset pipeline instead. - if (sourcemapsEnabled && options.sourcemaps?.disable === 'disable-upload') { - const RawSource = compiler.webpack?.sources?.RawSource; - // Right after source map generation (and thus after minification, which would strip the comment), - // so later stages (real content hashing, subresource integrity) see the final assets. - const stage = (compiler.webpack?.Compilation?.PROCESS_ASSETS_STAGE_DEV_TOOLING ?? 500) + 1; - - if (!RawSource) { - logger.warn( - 'Webpack sources are not available. Skipping debug ID injection into emitted source maps. This usually means webpack is not properly configured.', - ); - } else { - compiler.hooks.thisCompilation.tap('sentry-webpack-plugin', compilation => { - compilation.hooks.processAssets.tap({ name: 'sentry-webpack-plugin', stage }, () => { - addDebugIdsToAssets(compilation, RawSource); - }); - }); - } - } - - // Add DefinePlugin for bundle size optimizations - if (transformReplace && DefinePlugin) { - compiler.options.plugins = compiler.options.plugins || []; - compiler.options.plugins.push(new DefinePlugin(replacementValues)); - } - - // Add component name annotation transform - if (transformAnnotations?.transform) { - compiler.options.module = compiler.options.module || {}; - compiler.options.module.rules = compiler.options.module.rules || []; - compiler.options.module.rules.unshift({ - test: /\.[jt]sx$/, - exclude: /node_modules/, - enforce: 'pre', - use: [ - { - loader: COMPONENT_ANNOTATION_LOADER, - options: { - transform: transformAnnotations.transform, - }, - }, - ], - }); - } - - compiler.hooks.afterEmit.tapAsync( - 'sentry-webpack-plugin', - (compilation: WebpackCompilation, callback: (err?: Error) => void) => { - const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); - const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); - - const run = async (): Promise => { - try { - await sentryBuildPluginManager.createRelease(); - if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') { - const outputPath = compilation.outputOptions.path ?? path.resolve(); - const buildArtifacts = Object.keys(compilation.assets).map(asset => path.join(outputPath, asset)); - await upload(buildArtifacts); - } - } finally { - freeGlobalDependencyOnBuildArtifacts(); - await sentryBuildPluginManager.deleteArtifacts(); - } - }; - - run().then( - () => callback(), - (err: Error) => callback(err), - ); - }, - ); - - if (userOptions._experiments?.forceExitOnBuildCompletion && compiler.options.mode === 'production') { - compiler.hooks.done.tap('sentry-webpack-plugin', () => { - setTimeout(() => { - logger.debug('Exiting process after debug file upload'); - process.exit(0); - }); - }); - } - }, - }; - }; -} - -export type SentryWebpackPluginOptions = Options & { - _experiments?: Options['_experiments'] & { - /** - * If enabled, the webpack plugin will exit the build process after the build completes. - * Use this with caution, as it will terminate the process. - * - * More information: https://github.com/getsentry/sentry-javascript-bundler-plugins/issues/345 - * - * @default false - */ - forceExitOnBuildCompletion?: boolean; - }; -}; diff --git a/packages/bundler-plugins/src/webpack/webpack5.ts b/packages/bundler-plugins/src/webpack/webpack5.ts deleted file mode 100644 index 063aee71da02..000000000000 --- a/packages/bundler-plugins/src/webpack/webpack5.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { SentryWebpackPluginOptions } from './webpack4and5'; -import { sentryWebpackPluginFactory } from './webpack4and5'; - -const createSentryWebpackPlugin = sentryWebpackPluginFactory(); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = createSentryWebpackPlugin; - -export type { SentryWebpackPluginOptions }; diff --git a/packages/bundler-plugins/test/webpack/public-api.test.ts b/packages/bundler-plugins/test/webpack/public-api.test.ts index daa1f3e0d87b..97854c6352bf 100644 --- a/packages/bundler-plugins/test/webpack/public-api.test.ts +++ b/packages/bundler-plugins/test/webpack/public-api.test.ts @@ -1,12 +1,40 @@ import type { WebpackPluginInstance } from 'webpack'; import { sentryWebpackPlugin } from '../../src/webpack'; -import { describe, it, expect, test } from 'vitest'; +import { describe, it, expect, test, vi } from 'vitest'; test('Webpack plugin should exist', () => { expect(sentryWebpackPlugin).toBeDefined(); expect(typeof sentryWebpackPlugin).toBe('function'); }); +type PluginClass = new (options: unknown) => unknown; + +type Compiler = { + options: { plugins: unknown[] }; + hooks: Record>>; + webpack?: { BannerPlugin: PluginClass; DefinePlugin: PluginClass }; +}; + +class BannerPlugin { + public constructor(public options: unknown) {} +} + +class DefinePlugin { + public constructor(public options: unknown) {} +} + +function createCompiler(webpack?: Compiler['webpack']): Compiler { + return { + options: { plugins: [] }, + hooks: { + thisCompilation: { tap: vi.fn() }, + afterEmit: { tapAsync: vi.fn() }, + done: { tap: vi.fn() }, + }, + webpack, + }; +} + describe('sentryWebpackPlugin', () => { it('returns a webpack plugin', () => { const plugin = sentryWebpackPlugin({ @@ -18,4 +46,25 @@ describe('sentryWebpackPlugin', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment expect(plugin).toEqual({ apply: expect.any(Function) }); }); + + it('registers the plugin classes provided by `compiler.webpack`', () => { + const compiler = createCompiler({ BannerPlugin, DefinePlugin }); + + sentryWebpackPlugin({ telemetry: false, release: { name: 'my-release' } }).apply(compiler); + + expect(compiler.options.plugins).toEqual([expect.any(BannerPlugin)]); + }); + + it('warns instead of throwing when `compiler.webpack` is unavailable', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const compiler = createCompiler(undefined); + + expect(() => + sentryWebpackPlugin({ telemetry: false, release: { name: 'my-release' } }).apply(compiler), + ).not.toThrow(); + + expect(compiler.options.plugins).toEqual([]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('BannerPlugin is not available')); + warn.mockRestore(); + }); }); diff --git a/packages/bundler-plugins/test/webpack/webpack5.test.ts b/packages/bundler-plugins/test/webpack/webpack5.test.ts deleted file mode 100644 index b4d7b0b26f66..000000000000 --- a/packages/bundler-plugins/test/webpack/webpack5.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { WebpackPluginInstance } from 'webpack'; -import { sentryWebpackPlugin } from '../../src/webpack/index'; -import { describe, it, expect, test } from 'vitest'; - -test('Webpack plugin should exist', () => { - expect(sentryWebpackPlugin).toBeDefined(); - expect(typeof sentryWebpackPlugin).toBe('function'); -}); - -describe('sentryWebpackPlugin', () => { - it('returns a webpack plugin', () => { - const plugin = sentryWebpackPlugin({ - authToken: 'test-token', - org: 'test-org', - project: 'test-project', - }) as WebpackPluginInstance; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expect(plugin).toEqual({ apply: expect.any(Function) }); - }); -}); diff --git a/yarn.lock b/yarn.lock index 2c1b930b29dd..4b814df6be1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9696,21 +9696,11 @@ dependencies: "@types/node" "*" -"@types/source-list-map@*": - version "0.1.6" - resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.6.tgz#164e169dd061795b50b83c19e4d3be09f8d3a454" - integrity sha512-5JcVt1u5HDmlXkwOD2nslZVllBBc7HDuOICfiZah2Z0is8M8g+ddAEawbmd3VjedfDHBzxCaXLs07QEmb7y54g== - "@types/symlink-or-copy@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@types/symlink-or-copy/-/symlink-or-copy-1.2.0.tgz#4151a81b4052c80bc2becbae09f3a9ec010a9c7a" integrity sha512-Lja2xYuuf2B3knEsga8ShbOdsfNOtzT73GyJmZyY7eGl2+ajOqrs8yM5ze0fsSoYwvA6bw7/Qr7OZ7PEEmYwWg== -"@types/tapable@^1": - version "1.0.12" - resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.12.tgz#bc2cab12e87978eee89fb21576b670350d6d86ab" - integrity sha512-bTHG8fcxEqv1M9+TD14P8ok8hjxoOCkfKc8XXLaaD05kI7ohpeI956jtDOD3XHKBQrlyPughUtzm1jtVhHpA5Q== - "@types/tough-cookie@*": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d" @@ -9721,13 +9711,6 @@ resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== -"@types/uglify-js@*": - version "3.17.5" - resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.17.5.tgz#905ce03a3cbbf2e31cbefcbc68d15497ee2e17df" - integrity sha512-TU+fZFBTBcXj/GpDpDaBmgWk/gn96kMZ+uocaFUlV2f8a6WdMzzI44QBCmGcCiYR0Y6ZlNRiyUyKKt5nl/lbzQ== - dependencies: - source-map "^0.6.1" - "@types/unist@*", "@types/unist@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.0.tgz#988ae8af1e5239e89f9fbb1ade4c935f4eeedf9a" @@ -9743,27 +9726,6 @@ resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz#1306dbfa53768bcbcfc95a1c8cde367975581859" integrity sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA== -"@types/webpack-sources@*": - version "3.2.3" - resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-3.2.3.tgz#b667bd13e9fa15a9c26603dce502c7985418c3d8" - integrity sha512-4nZOdMwSPHZ4pTEZzSp0AsTM4K7Qmu40UKW4tJDiOVs20UzYF9l+qUe4s0ftfN0pin06n+5cWWDJXH+sbhAiDw== - dependencies: - "@types/node" "*" - "@types/source-list-map" "*" - source-map "^0.7.3" - -"@types/webpack@npm:@types/webpack@^4": - version "4.41.40" - resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.40.tgz#41ea11cfafe08de24c3ef410c58976350667e2d1" - integrity sha512-u6kMFSBM9HcoTpUXnL6mt2HSzftqb3JgYV6oxIgL2dl6sX6aCa5k6SOkzv5DuZjBTPUE/dJltKtwwuqrkZHpfw== - dependencies: - "@types/node" "*" - "@types/tapable" "^1" - "@types/uglify-js" "*" - "@types/webpack-sources" "*" - anymatch "^3.0.0" - source-map "^0.6.0" - "@types/whatwg-url@^13.0.0": version "13.0.0" resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-13.0.0.tgz#2b11e32772fd321c0dedf4d655953ea8ce587b2a" @@ -11210,7 +11172,7 @@ any-promise@^1.1.0: resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= -anymatch@^3.0.0, anymatch@^3.1.1, anymatch@^3.1.3, anymatch@~3.1.2: +anymatch@^3.1.1, anymatch@^3.1.3, anymatch@~3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==