diff --git a/packages/webpack-plugin/README_TEMPLATE.md b/packages/webpack-plugin/README_TEMPLATE.md index fa02bed1..2b25c4a2 100644 --- a/packages/webpack-plugin/README_TEMPLATE.md +++ b/packages/webpack-plugin/README_TEMPLATE.md @@ -37,6 +37,8 @@ pnpm add @sentry/webpack-plugin --save-dev ```js // webpack.config.js const { sentryWebpackPlugin } = require("@sentry/webpack-plugin"); +// for webpack 5.1 and webpack compatible environments +// const { sentryWebpackPlugin } = require("@sentry/webpack-plugin/webpack5"); module.exports = { // ... other config above ... diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json index 699d6cae..3731ef01 100644 --- a/packages/webpack-plugin/package.json +++ b/packages/webpack-plugin/package.json @@ -23,13 +23,18 @@ "import": "./dist/esm/index.mjs", "require": "./dist/cjs/index.js", "types": "./dist/types/index.d.ts" + }, + "./webpack5": { + "import": "./dist/esm/webpack5.mjs", + "require": "./dist/cjs/webpack5.js", + "types": "./dist/types/webpack5.d.ts" } }, "main": "dist/cjs/index.js", "module": "dist/esm/index.mjs", "types": "dist/types/index.d.ts", "scripts": { - "build": "rimraf ./out && run-p build:rollup build:types", + "build": "rimraf ./dist && run-p build:rollup build:types", "build:watch": "run-p build:rollup:watch build:types:watch", "build:rollup": "rollup --config rollup.config.js", "build:rollup:watch": "rollup --config rollup.config.js --watch --no-watch.clearScreen", diff --git a/packages/webpack-plugin/rollup.config.js b/packages/webpack-plugin/rollup.config.js index 8751124b..3f0be962 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"]; +const input = ["src/index.ts", "src/webpack5.ts"]; const extensions = [".ts"]; @@ -33,13 +33,14 @@ export default { ], output: [ { - file: packageJson.module, + dir: "./dist/esm", format: "esm", exports: "named", sourcemap: true, + entryFileNames: "[name].mjs", }, { - file: packageJson.main, + dir: "./dist/cjs", format: "cjs", exports: "named", sourcemap: true, diff --git a/packages/webpack-plugin/src/index.ts b/packages/webpack-plugin/src/index.ts index 089d9cf4..9b1bfa24 100644 --- a/packages/webpack-plugin/src/index.ts +++ b/packages/webpack-plugin/src/index.ts @@ -1,208 +1,18 @@ -import { - getDebugIdSnippet, - Options, - sentryUnpluginFactory, - stringToUUID, - SentrySDKBuildFlags, - createComponentNameAnnotateHooks, - Logger, -} from "@sentry/bundler-plugin-core"; -import * as path from "path"; -import { UnpluginOptions } from "unplugin"; -import { v4 as uuidv4 } from "uuid"; +import { SentryWebpackPluginOptions, sentryWebpackUnpluginFactory } from "./webpack4and5"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore webpack is a peer dep import * as webpack4or5 from "webpack"; -interface BannerPluginCallbackArg { - chunk?: { - hash?: string; - contentHash?: { - javascript?: string; - }; - }; -} +const BannerPlugin = webpack4or5?.BannerPlugin || webpack4or5?.default?.BannerPlugin; -function webpackReleaseInjectionPlugin(injectionCode: string): UnpluginOptions { - return { - name: "sentry-webpack-release-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 || - webpack4or5?.BannerPlugin || - webpack4or5?.default?.BannerPlugin; - 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: injectionCode, - }) - ); - }, - }; -} +const DefinePlugin = webpack4or5?.DefinePlugin || webpack4or5?.default?.DefinePlugin; -function webpackComponentNameAnnotatePlugin(ignoredComponents?: string[]): UnpluginOptions { - return { - 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).transform, - }; -} - -function webpackBundleSizeOptimizationsPlugin( - replacementValues: SentrySDKBuildFlags -): UnpluginOptions { - return { - 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 || - webpack4or5?.DefinePlugin || - webpack4or5?.default?.DefinePlugin; - 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, - }) - ); - }, - }; -} - -function webpackDebugIdInjectionPlugin(): UnpluginOptions { - return { - name: "sentry-webpack-debug-id-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 || - webpack4or5?.BannerPlugin || - webpack4or5?.default?.BannerPlugin; - 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 hash = arg?.chunk?.contentHash?.javascript ?? arg?.chunk?.hash; - const debugId = hash ? stringToUUID(hash) : uuidv4(); - return getDebugIdSnippet(debugId); - }, - }) - ); - }, - }; -} - -function webpackDebugIdUploadPlugin( - upload: (buildArtifacts: string[]) => Promise, - logger: Logger, - forceExitOnBuildCompletion?: boolean -): UnpluginOptions { - const pluginName = "sentry-webpack-debug-id-upload-plugin"; - return { - name: pluginName, - webpack(compiler) { - 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(); - }); - }); - - if (forceExitOnBuildCompletion && compiler.options.mode === "production") { - compiler.hooks.done.tap(pluginName, () => { - setTimeout(() => { - logger.debug("Exiting process after debug file upload"); - process.exit(0); - }); - }); - } - }, - }; -} - -function webpackModuleMetadataInjectionPlugin(injectionCode: string): UnpluginOptions { - return { - name: "sentry-webpack-module-metadata-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 || - webpack4or5?.BannerPlugin || - webpack4or5?.default?.BannerPlugin; - 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: injectionCode, - }) - ); - }, - }; -} - -const sentryUnplugin = sentryUnpluginFactory({ - releaseInjectionPlugin: webpackReleaseInjectionPlugin, - componentNameAnnotatePlugin: webpackComponentNameAnnotatePlugin, - moduleMetadataInjectionPlugin: webpackModuleMetadataInjectionPlugin, - debugIdInjectionPlugin: webpackDebugIdInjectionPlugin, - debugIdUploadPlugin: webpackDebugIdUploadPlugin, - bundleSizeOptimizationsPlugin: webpackBundleSizeOptimizationsPlugin, +const sentryUnplugin = sentryWebpackUnpluginFactory({ + BannerPlugin, + DefinePlugin, }); -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; - }; -}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = sentryUnplugin.webpack; diff --git a/packages/webpack-plugin/src/webpack4and5.ts b/packages/webpack-plugin/src/webpack4and5.ts new file mode 100644 index 00000000..e9304038 --- /dev/null +++ b/packages/webpack-plugin/src/webpack4and5.ts @@ -0,0 +1,235 @@ +import { + getDebugIdSnippet, + Options, + sentryUnpluginFactory, + stringToUUID, + SentrySDKBuildFlags, + createComponentNameAnnotateHooks, + Logger, +} from "@sentry/bundler-plugin-core"; +import * as path from "path"; +import { UnpluginOptions } from "unplugin"; +import { v4 as uuidv4 } from "uuid"; + +// 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; +}; + +function webpackReleaseInjectionPlugin( + UnsafeBannerPlugin: UnsafeBannerPlugin | undefined +): (injectionCode: string) => UnpluginOptions { + return (injectionCode: string): UnpluginOptions => ({ + name: "sentry-webpack-release-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: injectionCode, + }) + ); + }, + }); +} + +function webpackComponentNameAnnotatePlugin(): (ignoredComponents?: string[]) => UnpluginOptions { + return (ignoredComponents?: string[]) => ({ + 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).transform, + }); +} + +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, + }) + ); + }, + }); +} + +function webpackDebugIdInjectionPlugin( + UnsafeBannerPlugin: UnsafeBannerPlugin | undefined +): () => UnpluginOptions { + return () => ({ + name: "sentry-webpack-debug-id-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 hash = arg?.chunk?.contentHash?.javascript ?? arg?.chunk?.hash; + const debugId = hash ? stringToUUID(hash) : uuidv4(); + return getDebugIdSnippet(debugId); + }, + }) + ); + }, + }); +} + +function webpackDebugIdUploadPlugin(): ( + upload: (buildArtifacts: string[]) => Promise, + logger: Logger, + forceExitOnBuildCompletion?: boolean +) => UnpluginOptions { + const pluginName = "sentry-webpack-debug-id-upload-plugin"; + return ( + upload: (buildArtifacts: string[]) => Promise, + logger: Logger, + forceExitOnBuildCompletion?: boolean + ) => ({ + name: pluginName, + webpack(compiler) { + 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(); + }); + }); + + if (forceExitOnBuildCompletion && compiler.options.mode === "production") { + compiler.hooks.done.tap(pluginName, () => { + setTimeout(() => { + logger.debug("Exiting process after debug file upload"); + process.exit(0); + }); + }); + } + }, + }); +} + +function webpackModuleMetadataInjectionPlugin( + UnsafeBannerPlugin: UnsafeBannerPlugin | undefined +): (injectionCode: string) => UnpluginOptions { + return (injectionCode: string) => ({ + name: "sentry-webpack-module-metadata-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: injectionCode, + }) + ); + }, + }); +} + +/** + * 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 sentryWebpackUnpluginFactory({ + BannerPlugin, + DefinePlugin, +}: { + BannerPlugin?: UnsafeBannerPlugin; + DefinePlugin?: UnsafeDefinePlugin; +} = {}): ReturnType { + return sentryUnpluginFactory({ + releaseInjectionPlugin: webpackReleaseInjectionPlugin(BannerPlugin), + componentNameAnnotatePlugin: webpackComponentNameAnnotatePlugin(), + moduleMetadataInjectionPlugin: webpackModuleMetadataInjectionPlugin(BannerPlugin), + debugIdInjectionPlugin: webpackDebugIdInjectionPlugin(BannerPlugin), + debugIdUploadPlugin: webpackDebugIdUploadPlugin(), + bundleSizeOptimizationsPlugin: webpackBundleSizeOptimizationsPlugin(DefinePlugin), + }); +} + +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/webpack-plugin/src/webpack5.ts b/packages/webpack-plugin/src/webpack5.ts new file mode 100644 index 00000000..421f5eb8 --- /dev/null +++ b/packages/webpack-plugin/src/webpack5.ts @@ -0,0 +1,11 @@ +import { SentryWebpackPluginOptions, sentryWebpackUnpluginFactory } from "./webpack4and5"; + +const sentryUnplugin = sentryWebpackUnpluginFactory(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = + sentryUnplugin.webpack; + +export { sentryCliBinaryExists } from "@sentry/bundler-plugin-core"; + +export type { SentryWebpackPluginOptions }; diff --git a/packages/webpack-plugin/test/webpack5.test.ts b/packages/webpack-plugin/test/webpack5.test.ts new file mode 100644 index 00000000..09d7df05 --- /dev/null +++ b/packages/webpack-plugin/test/webpack5.test.ts @@ -0,0 +1,24 @@ +import { Plugin } from "webpack"; +import { sentryWebpackPlugin } from "../src/webpack5"; + +jest.mock("webpack", () => { + throw new Error("Webpack 5 version of the plugin should use module from compiler."); +}); + +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 Plugin; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect(plugin).toEqual({ apply: expect.any(Function) }); + }); +});