diff --git a/packages/bundler-plugin-core/src/index.ts b/packages/bundler-plugin-core/src/index.ts index aeeffe44..e90f62b4 100644 --- a/packages/bundler-plugin-core/src/index.ts +++ b/packages/bundler-plugin-core/src/index.ts @@ -495,3 +495,5 @@ export type { Logger } from "./logger"; export type { Options, SentrySDKBuildFlags } from "./types"; export { CodeInjection, replaceBooleanFlagsInCode, stringToUUID } from "./utils"; export { createSentryBuildPluginManager } from "./build-plugin-manager"; +export { generateGlobalInjectorCode, generateModuleMetadataInjectorCode } from "./utils"; +export { createDebugIdUploadFunction } from "./debug-id-upload"; diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json index 6e2eaee1..a3441504 100644 --- a/packages/webpack-plugin/package.json +++ b/packages/webpack-plugin/package.json @@ -54,7 +54,6 @@ }, "dependencies": { "@sentry/bundler-plugin-core": "4.9.1", - "unplugin": "1.0.1", "uuid": "^9.0.0" }, "devDependencies": { diff --git a/packages/webpack-plugin/rollup.config.js b/packages/webpack-plugin/rollup.config.js index ea1dc3b6..bb57a7d4 100644 --- a/packages/webpack-plugin/rollup.config.js +++ b/packages/webpack-plugin/rollup.config.js @@ -3,7 +3,7 @@ import babel from "@rollup/plugin-babel"; import packageJson from "./package.json"; import modulePackage from "module"; -const input = ["src/index.ts", "src/webpack5.ts"]; +const input = ["src/index.ts", "src/webpack5.ts", "src/component-annotation-transform.ts"]; const extensions = [".ts"]; diff --git a/packages/webpack-plugin/src/component-annotation-transform.ts b/packages/webpack-plugin/src/component-annotation-transform.ts new file mode 100644 index 00000000..bf6c6612 --- /dev/null +++ b/packages/webpack-plugin/src/component-annotation-transform.ts @@ -0,0 +1,43 @@ +// Webpack loader for component annotation transform +// Based on unplugin v1.0.1 transform loader pattern + +export default async function transform( + this: { + async: () => (err: Error | null, content?: string, sourceMap?: unknown) => void; + resourcePath: string; + query: { + transform?: ( + code: string, + id: string + ) => Promise<{ code: string; map?: unknown } | null | undefined | string>; + }; + }, + source: string, + map: unknown +): Promise { + const callback = this.async(); + const { transform: transformFn } = this.query; + + if (!transformFn) { + return callback(null, source, map); + } + + try { + const id = this.resourcePath; + const result = await transformFn(source, id); + + if (result == null) { + callback(null, source, map); + } else if (typeof result === "string") { + callback(null, result, map); + } else { + callback(null, result.code, result.map || map); + } + } catch (error) { + if (error instanceof Error) { + callback(error); + } else { + callback(new Error(String(error))); + } + } +} diff --git a/packages/webpack-plugin/src/index.ts b/packages/webpack-plugin/src/index.ts index 9b1bfa24..a13d9b2b 100644 --- a/packages/webpack-plugin/src/index.ts +++ b/packages/webpack-plugin/src/index.ts @@ -1,4 +1,4 @@ -import { SentryWebpackPluginOptions, sentryWebpackUnpluginFactory } from "./webpack4and5"; +import { SentryWebpackPluginOptions, sentryWebpackPluginFactory } from "./webpack4and5"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore webpack is a peer dep @@ -8,14 +8,12 @@ const BannerPlugin = webpack4or5?.BannerPlugin || webpack4or5?.default?.BannerPl const DefinePlugin = webpack4or5?.DefinePlugin || webpack4or5?.default?.DefinePlugin; -const sentryUnplugin = sentryWebpackUnpluginFactory({ - BannerPlugin, - DefinePlugin, -}); - // eslint-disable-next-line @typescript-eslint/no-explicit-any export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = - sentryUnplugin.webpack; + sentryWebpackPluginFactory({ + BannerPlugin, + DefinePlugin, + }); export { sentryCliBinaryExists } from "@sentry/bundler-plugin-core"; diff --git a/packages/webpack-plugin/src/webpack4and5.ts b/packages/webpack-plugin/src/webpack4and5.ts index 5ad81eaa..a9d31b4c 100644 --- a/packages/webpack-plugin/src/webpack4and5.ts +++ b/packages/webpack-plugin/src/webpack4and5.ts @@ -1,18 +1,30 @@ import { Options, - sentryUnpluginFactory, + createSentryBuildPluginManager, + generateGlobalInjectorCode, + generateModuleMetadataInjectorCode, stringToUUID, - SentrySDKBuildFlags, createComponentNameAnnotateHooks, - Logger, CodeInjection, getDebugIdSnippet, + createDebugIdUploadFunction, } from "@sentry/bundler-plugin-core"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; -import * as path from "path"; -import { UnpluginOptions } from "unplugin"; import { v4 as uuidv4 } from "uuid"; +// 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)); + +const COMPONENT_ANNOTATION_LOADER = path.resolve( + dirname, + typeof __dirname !== "undefined" + ? "component-annotation-transform.js" // CJS + : "component-annotation-transform.mjs" // ESM +); + // since webpack 5.1 compiler contains webpack module so plugins always use correct webpack version // https://github.com/webpack/webpack/commit/65eca2e529ce1d79b79200d4bdb1ce1b81141459 @@ -35,121 +47,80 @@ type UnsafeDefinePlugin = { new (options: any): unknown; }; -function webpackInjectionPlugin( - UnsafeBannerPlugin: UnsafeBannerPlugin | undefined -): (injectionCode: CodeInjection, debugIds: boolean) => UnpluginOptions { - return (injectionCode: CodeInjection, debugIds: boolean): UnpluginOptions => ({ - name: "sentry-webpack-injection-plugin", - webpack(compiler) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore webpack version compatibility shenanigans - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const BannerPlugin = - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore webpack version compatibility shenanigans - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - compiler?.webpack?.BannerPlugin || UnsafeBannerPlugin; - - compiler.options.plugins = compiler.options.plugins || []; - compiler.options.plugins.push( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call - new BannerPlugin({ - raw: true, - include: /\.(js|ts|jsx|tsx|mjs|cjs)(\?[^?]*)?(#[^#]*)?$/, - banner: (arg?: BannerPluginCallbackArg) => { - const codeToInject = injectionCode.clone(); - if (debugIds) { - const hash = arg?.chunk?.contentHash?.javascript ?? arg?.chunk?.hash; - const debugId = hash ? stringToUUID(hash) : uuidv4(); - codeToInject.append(getDebugIdSnippet(debugId)); - } - return codeToInject.code(); - }, - }) - ); - }, - }); -} +type WebpackModule = { + resource?: string; +}; -function webpackComponentNameAnnotatePlugin(): ( - ignoredComponents: string[], - injectIntoHtml: boolean -) => UnpluginOptions { - return (ignoredComponents: string[], injectIntoHtml: boolean) => ({ - name: "sentry-webpack-component-name-annotate-plugin", - enforce: "pre", - // Webpack needs this hook for loader logic, so the plugin is not run on unsupported file types - transformInclude(id) { - return id.endsWith(".tsx") || id.endsWith(".jsx"); - }, - transform: createComponentNameAnnotateHooks(ignoredComponents, injectIntoHtml).transform, - }); -} +type WebpackLoaderCallback = (err: Error | null, content?: string, sourceMap?: unknown) => void; -function webpackBundleSizeOptimizationsPlugin( - UnsafeDefinePlugin: UnsafeDefinePlugin | undefined -): (replacementValues: SentrySDKBuildFlags) => UnpluginOptions { - return (replacementValues: SentrySDKBuildFlags) => ({ - name: "sentry-webpack-bundle-size-optimizations-plugin", - webpack(compiler) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore webpack version compatibility shenanigans - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const DefinePlugin = - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore webpack version compatibility shenanigans - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - compiler?.webpack?.DefinePlugin || UnsafeDefinePlugin; - - compiler.options.plugins = compiler.options.plugins || []; - compiler.options.plugins.push( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call - new DefinePlugin({ - ...replacementValues, - }) - ); - }, - }); -} +type WebpackLoaderContext = { + callback: WebpackLoaderCallback; +}; -function webpackDebugIdUploadPlugin( - upload: (buildArtifacts: string[]) => Promise, - logger: Logger, - createDependencyOnBuildArtifacts: () => () => void, - forceExitOnBuildCompletion?: boolean -): UnpluginOptions { - const pluginName = "sentry-webpack-debug-id-upload-plugin"; - return { - name: pluginName, - webpack(compiler) { - const freeGlobalDependencyOnDebugIdSourcemapArtifacts = createDependencyOnBuildArtifacts(); - - compiler.hooks.afterEmit.tapAsync(pluginName, (compilation, callback: () => void) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const outputPath = (compilation.outputOptions.path as string | undefined) ?? path.resolve(); - const buildArtifacts = Object.keys(compilation.assets as Record).map( - (asset) => path.join(outputPath, asset) - ); - void upload(buildArtifacts) - .then(() => { - callback(); - }) - .finally(() => { - freeGlobalDependencyOnDebugIdSourcemapArtifacts(); - }); - }); +type WebpackCompilationContext = { + compiler: { + webpack?: { + NormalModule?: { + getCompilationHooks: (compilation: WebpackCompilationContext) => { + loader: { + tap: ( + name: string, + callback: (loaderContext: WebpackLoaderContext, module: WebpackModule) => void + ) => void; + }; + }; + }; + }; + }; + hooks: { + normalModuleLoader?: { + tap: ( + name: string, + callback: (loaderContext: WebpackLoaderContext, module: WebpackModule) => void + ) => void; + }; + }; +}; - if (forceExitOnBuildCompletion && compiler.options.mode === "production") { - compiler.hooks.done.tap(pluginName, () => { - setTimeout(() => { - logger.debug("Exiting process after debug file upload"); - process.exit(0); - }); - }); - } - }, +type WebpackCompiler = { + options: { + plugins?: unknown[]; + mode?: string; + module?: { + rules?: unknown[]; + }; }; -} + hooks: { + thisCompilation: { + tap: (name: string, callback: (compilation: WebpackCompilationContext) => 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; + }; +}; + +type WebpackCompilation = { + outputOptions: { + path?: string; + }; + assets: Record; + 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 { @@ -174,20 +145,177 @@ function getWebpackMajorVersion(): string | undefined { * * Since webpack 5.1 compiler contains webpack module so plugins always use correct webpack version. */ -export function sentryWebpackUnpluginFactory({ - BannerPlugin, - DefinePlugin, +export function sentryWebpackPluginFactory({ + BannerPlugin: UnsafeBannerPlugin, + DefinePlugin: UnsafeDefinePlugin, }: { BannerPlugin?: UnsafeBannerPlugin; DefinePlugin?: UnsafeDefinePlugin; -} = {}): ReturnType { - return sentryUnpluginFactory({ - injectionPlugin: webpackInjectionPlugin(BannerPlugin), - componentNameAnnotatePlugin: webpackComponentNameAnnotatePlugin(), - debugIdUploadPlugin: webpackDebugIdUploadPlugin, - bundleSizeOptimizationsPlugin: webpackBundleSizeOptimizationsPlugin(DefinePlugin), - getBundlerMajorVersion: getWebpackMajorVersion, - }); +} = {}) { + 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( + generateGlobalInjectorCode({ + 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) : uuidv4(); + codeToInject.append(getDebugIdSnippet(debugId)); + } + return codeToInject.code(); + }, + }) + ); + } + } + + // 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: () => void) => { + const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); + const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); + + void sentryBuildPluginManager + .createRelease() + .then(async () => { + 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); + } + }) + .then(() => { + callback(); + }) + .finally(() => { + freeGlobalDependencyOnBuildArtifacts(); + void sentryBuildPluginManager.deleteArtifacts(); + }); + } + ); + + 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 & { diff --git a/packages/webpack-plugin/src/webpack5.ts b/packages/webpack-plugin/src/webpack5.ts index 421f5eb8..51f611ef 100644 --- a/packages/webpack-plugin/src/webpack5.ts +++ b/packages/webpack-plugin/src/webpack5.ts @@ -1,10 +1,10 @@ -import { SentryWebpackPluginOptions, sentryWebpackUnpluginFactory } from "./webpack4and5"; +import { SentryWebpackPluginOptions, sentryWebpackPluginFactory } from "./webpack4and5"; -const sentryUnplugin = sentryWebpackUnpluginFactory(); +const createSentryWebpackPlugin = sentryWebpackPluginFactory(); // eslint-disable-next-line @typescript-eslint/no-explicit-any export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = - sentryUnplugin.webpack; + createSentryWebpackPlugin; export { sentryCliBinaryExists } from "@sentry/bundler-plugin-core";