Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 133 additions & 51 deletions packages/unplugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -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>((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:
Comment on lines +32 to +65
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love this, explains it very well!

*
* 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>((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<string>();

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
Expand Down