diff --git a/packages/unplugin/src/index.ts b/packages/unplugin/src/index.ts index 240fbb06..f521e313 100644 --- a/packages/unplugin/src/index.ts +++ b/packages/unplugin/src/index.ts @@ -1,112 +1,194 @@ import { createUnplugin } from "unplugin"; import MagicString from "magic-string"; import { getReleaseName } from "./getReleaseName"; -import * as path from "path"; import { Options } from "./types"; import { makeSentryFacade } from "./facade"; -function generateGlobalInjectorCode({ release }: { release: string }) { - return ` - var _global = - typeof window !== 'undefined' ? - window : - typeof global !== 'undefined' ? - global : - typeof self !== 'undefined' ? - self : - {}; - - _global.SENTRY_RELEASE={id:"${release}"};`; -} - -const unplugin = createUnplugin((options) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function debugLog(...args: any) { +/** + * The sentry-unplugin concerns itself with two things: + * - Release injection + * - Sourcemaps upload + * + * Release injection: + * + * The sentry-unpugin will inject a global `SENTRY_RELEASE` variable into all bundles. On a technical level this is done + * by appending an import (`import "sentry-release-injector;"`) to all files of the user code (see `transformInclude` + * and `transform` hooks). This import is then resolved by the sentry-unplugin to a virtual module that sets the global + * variable (see `resolveId` and `load` hooks). + * + * It sounds a bit dubious that we're injecting appending the same import to *all* user files but it has its reasons: + * With the way `unplugin` and bundlers work, we do not always have the information we need to only inject code at the + * top of an entry file. The `transform` and `transformInclude` hooks don't have any information as to whether a file is + * an entry file - so they need context from another hook for that. The only location where we can determine if a file + * is an entry file is the `resolveId` hook - sadly in this hook, we do not have an guaranteed absolute path of the file + * we're looking at, which would require us to do a heuristic (using `process.cwd()`) which isn't bulletproof. + * + * Since 1) sharing context across bundler hooks is a bit of an anti-pattern and 2) having a heuristic that is + * potentially producing false results, we went for the approach of simply importing the "global injector file" in every + * file. Luckily bundlers are smart enough to only include it once in a bundle. :) + * + * The resulting output approximately looks like this: + * + * ```text + * index.js (user file) + * ┌───────────────────┐ ┌─────────────────────────────────────────────────┐ + * │ │ │ import { myFunction } from "./my-library.js"; │ + * │ sentry-unplugin │ │ │ + * │ │ │ const myResult = myFunction(); │ + * └---------│---------┘ │ export { myResult }; │ + * │ │ │ + * │ injects │ // injected by sentry-unplugin │ + * ├───────────────────► import "sentry-release-injector"; ─────────────────────┐ + * │ └─────────────────────────────────────────────────┘ │ + * │ │ + * │ │ + * │ my-library.js (user file) │ + * │ ┌─────────────────────────────────────────────────┐ │ + * │ │ export function myFunction() { │ │ + * │ │ return "Hello world!"; │ │ + * │ │ } │ │ + * │ │ │ │ + * │ injects │ // injected by sentry-unplugin │ │ + * └───────────────────► import "sentry-release-injector"; ─────────────────────┤ + * └─────────────────────────────────────────────────┘ │ + * │ + * │ + * sentry-release-injector │ + * ┌──────────────────────────────────┐ │ + * │ │ is resolved │ + * │ global.SENTRY_RELEASE = { ... } │ by unplugin │ + * │ // + a little more logic │<─────────────────────┘ + * │ │ (only once) + * └──────────────────────────────────┘ + * ``` + * + * Source maps upload: + * + * The sentry-unplugin will also take care of uploading source maps to Sentry. This is all done in the `buildEnd` hook. + * TODO: elaborate a bit on how sourcemaps upload works + */ +const unplugin = createUnplugin((options, unpluginMetaContext) => { + function debugLog(...args: unknown[]) { if (options?.debugLogging) { // eslint-disable-next-line no-console - console.log("[Sentry-plugin]}", args); + console.log("[Sentry-plugin]", ...args); } } - const entrypoints = new Set(); - return { name: "sentry-plugin", enforce: "pre", // needed for Vite to call resolveId hook /** - * In the sentry-unplugin, this hook is responsible for creating a Set containing the entrypoints as absolute paths. + * Responsible for returning the "sentry-release-injector" ID when we encounter it. We return the ID so load is + * called and we can "virtually" load the module. See `load` hook for more info on why it's virtual. * * @param id For imports: The absolute path of the module to be imported. For entrypoints: The path the user defined as entrypoint - may also be relative. * @param importer For imports: The absolute path of the module that imported this module. For entrypoints: `undefined`. * @param options Additional information to use for making a resolving decision. - * @returns undefined. + * @returns `"sentry-release-injector"` when the imported file is called `"sentry-release-injector"`. Otherwise returns `undefined`. */ resolveId(id, importer, { isEntry }) { debugLog( `Called "resolveId": ${JSON.stringify({ id, importer: importer, options: { isEntry } })}` ); - // We only store the absolute path when we encounter an entrypoint - if (isEntry) { - // If we're looking at an entrypoint, which is the case here, `id` is either an absolute path or a relative path. - // If it's an absolute path we can just store it. If it's a relative path, we can assume the path got defined - // from a config file and when bundlers are run via a config file the process CWD is usually the one the config - // file is located in, so we can simply join CWD and id and we get the absolute path. - const entrypoint = path.normalize(path.isAbsolute(id) ? id : path.join(process.cwd(), id)); - entrypoints.add(entrypoint); - debugLog(`Added entrypoint: ${entrypoint}`); + if (id === "sentry-release-injector") { + return id; + } else { + return undefined; } + }, - return undefined; + /** + * Responsible for "virtually" loading the "sentry-release-injector" module. "Virtual" means that the module is not + * read from somewhere on disk but rather just returned via a string. + * + * @param id Always the absolute (fully resolved) path to the module. + * @returns The global injector code when we load the "sentry-release-injector" module. Otherwise returns `undefined`. + */ + load(id) { + debugLog(`Called "transform": ${JSON.stringify({ id })}`); + + if (id === "sentry-release-injector") { + return generateGlobalInjectorCode({ release: getReleaseName(options.release) }); + } else { + return undefined; + } }, /** - * Determines whether we want to transform a module. + * This hook determines whether we want to transform a module. In the unplugin we want to transform every file + * except for the release injector file * * @param id Always the absolute (fully resolved) path to the module. * @returns `true` or `false` depending on whether we want to transform the module. For the sentry-unplugin we only - * want to transform entrypoints. + * want to transform the release injector file. */ transformInclude(id) { - const shouldTransform = entrypoints.has(path.normalize(id)); - debugLog(`Called "transformInclude": ${JSON.stringify({ id })}`); - debugLog(`Will transform "${id}": ${String(shouldTransform)}`); - return shouldTransform; + // We want to transform (release injection) every module except for "sentry-release-injector". + return id !== "sentry-release-injector"; }, /** - * Responsible for injecting the global release value code. + * This hook is responsible for injecting the "sentry release injector" imoprt statement into each user file. * - * @param code Unprocessed code of the module. + * @param code Code of the file to transform. * @param id Always the absolute (fully resolved) path to the module. - * @returns Code and source map if we decide to inject code. `undefined` otherwise. + * @returns transformed code + source map */ transform(code, id) { - if (entrypoints.has(path.normalize(id))) { - const ms = new MagicString(code); - ms.prepend( - generateGlobalInjectorCode({ release: getReleaseName(options.release || "0.0.1") }) - ); + debugLog(`Called "transform": ${JSON.stringify({ code, id })}`); + + // The MagicString library allows us to generate sourcemaps for the changes we make to the user code. + const ms = new MagicString(code); // Very stupid author's note: For some absurd reason, when we add a JSDoc to this hook, the TS language server starts complaining about `ms` and adding a type annotation helped so that's why it's here. (┛ಠ_ಠ)┛彡┻━┻ + + // appending instead of prepending has less probability of mucking with user's + // source maps and import statements get to the top anyways + ms.append('import "sentry-release-injector";'); + + if (unpluginMetaContext.framework === "esbuild") { + // esbuild + unplugin is buggy at the moment when we return an object with a `map` (sourcemap) property. + // Currently just returning a string here seems to work and even correctly sourcemaps the code we generate. + // However, other bundlers need the `map` property + return ms.toString(); + } else { return { code: ms.toString(), map: ms.generateMap(), }; - } else { - // Don't transform - return undefined; } }, buildEnd() { - const sentryFacade = makeSentryFacade(getReleaseName(options.release || "0.0.1"), options); + const sentryFacade = makeSentryFacade(getReleaseName(options.release), options); //TODO: do stuff with the facade here lol debugLog("this is my facade:", sentryFacade); }, }; }); +/** + * Generates code for the "sentry-release-injector" which is responsible for setting the global `SENTRY_RELEASE` + * variable. + */ +function generateGlobalInjectorCode({ release }: { release: string }) { + // The code below is mostly ternary operators because it saves bundle size. + // The checks are to support as many environments as possible. (Node.js, Browser, webworkers, etc.) + return ` + var _global = + typeof window !== 'undefined' ? + window : + typeof global !== 'undefined' ? + global : + typeof self !== 'undefined' ? + self : + {}; + + _global.SENTRY_RELEASE={id:"${release}"};`; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const sentryVitePlugin: (options: Options) => any = unplugin.vite; // eslint-disable-next-line @typescript-eslint/no-explicit-any