|
| 1 | +import { consoleSandbox } from '@sentry/core'; |
| 2 | +import type { InputPluginOption } from 'rollup'; |
| 3 | + |
| 4 | +/** THIS FILE IS AN UTILITY FOR NITRO-BASED PACKAGES AND SHOULD BE KEPT IN SYNC IN NUXT, SOLIDSTART, ETC. */ |
| 5 | + |
| 6 | +export const SENTRY_WRAPPED_ENTRY = '?sentry-query-wrapped-entry'; |
| 7 | +export const SENTRY_WRAPPED_FUNCTIONS = '?sentry-query-wrapped-functions='; |
| 8 | +export const SENTRY_REEXPORTED_FUNCTIONS = '?sentry-query-reexported-functions='; |
| 9 | +export const QUERY_END_INDICATOR = 'SENTRY-QUERY-END'; |
| 10 | + |
| 11 | +export type WrapServerEntryPluginOptions = { |
| 12 | + serverEntrypointFileName: string; |
| 13 | + serverConfigFileName: string; |
| 14 | + resolvedServerConfigPath: string; |
| 15 | + entrypointWrappedFunctions: string[]; |
| 16 | + additionalImports?: string[]; |
| 17 | + debug?: boolean; |
| 18 | +}; |
| 19 | + |
| 20 | +/** |
| 21 | + * A Rollup plugin which wraps the server entry with a dynamic `import()`. This makes it possible to initialize Sentry first |
| 22 | + * by using a regular `import` and load the server after that. |
| 23 | + * This also works with serverless `handler` functions, as it re-exports the `handler`. |
| 24 | + * |
| 25 | + * @param config Configuration options for the Rollup Plugin |
| 26 | + * @param config.serverConfigFileName Name of the Sentry server config (without file extension). E.g. 'sentry.server.config' |
| 27 | + * @param config.serverEntrypointFileName The server entrypoint (with file extension). Usually, this is defined by the Nitro preset and is something like 'node-server.mjs' |
| 28 | + * @param config.resolvedServerConfigPath Resolved path of the Sentry server config (based on `src` directory) |
| 29 | + * @param config.entryPointWrappedFunctions Exported bindings of the server entry file, which are wrapped as async function. E.g. ['default', 'handler', 'server'] |
| 30 | + * @param config.additionalImports Adds additional imports to the entry file. Can be e.g. 'import-in-the-middle/hook.mjs' |
| 31 | + * @param config.debug Whether debug logs are enabled in the build time environment |
| 32 | + */ |
| 33 | +export function wrapServerEntryWithDynamicImport(config: WrapServerEntryPluginOptions): InputPluginOption { |
| 34 | + const { |
| 35 | + serverConfigFileName, |
| 36 | + serverEntrypointFileName, |
| 37 | + resolvedServerConfigPath, |
| 38 | + entrypointWrappedFunctions, |
| 39 | + additionalImports, |
| 40 | + debug, |
| 41 | + } = config; |
| 42 | + |
| 43 | + // In order to correctly import the server config file |
| 44 | + // and dynamically import the nitro runtime, we need to |
| 45 | + // mark the resolutionId with '\0raw' to fall into the |
| 46 | + // raw chunk group, c.f. https://github.com/nitrojs/nitro/commit/8b4a408231bdc222569a32ce109796a41eac4aa6#diff-e58102d2230f95ddeef2662957b48d847a6e891e354cfd0ae6e2e03ce848d1a2R142 |
| 47 | + const resolutionIdPrefix = '\0raw'; |
| 48 | + |
| 49 | + return { |
| 50 | + name: 'sentry-wrap-server-entry-with-dynamic-import', |
| 51 | + async resolveId(source, importer, options) { |
| 52 | + if (source.includes(`/${serverConfigFileName}`)) { |
| 53 | + return { id: source, moduleSideEffects: true }; |
| 54 | + } |
| 55 | + |
| 56 | + if (additionalImports && additionalImports.includes(source)) { |
| 57 | + // When importing additional imports like "import-in-the-middle/hook.mjs" in the returned code of the `load()` function below: |
| 58 | + // By setting `moduleSideEffects` to `true`, the import is added to the bundle, although nothing is imported from it |
| 59 | + // By importing "import-in-the-middle/hook.mjs", we can make sure this file is included, as not all node builders are including files imported with `module.register()`. |
| 60 | + // Prevents the error "Failed to register ESM hook Error: Cannot find module 'import-in-the-middle/hook.mjs'" |
| 61 | + return { id: source, moduleSideEffects: true, external: true }; |
| 62 | + } |
| 63 | + |
| 64 | + if ( |
| 65 | + options.isEntry && |
| 66 | + source.includes(serverEntrypointFileName) && |
| 67 | + source.includes('.mjs') && |
| 68 | + !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`) |
| 69 | + ) { |
| 70 | + const resolution = await this.resolve(source, importer, options); |
| 71 | + |
| 72 | + // If it cannot be resolved or is external, just return it so that Rollup can display an error |
| 73 | + if (!resolution || (resolution && resolution.external)) return resolution; |
| 74 | + |
| 75 | + const moduleInfo = await this.load(resolution); |
| 76 | + |
| 77 | + moduleInfo.moduleSideEffects = true; |
| 78 | + |
| 79 | + // The enclosing `if` already checks for the suffix in `source`, but a check in `resolution.id` is needed as well to prevent multiple attachment of the suffix |
| 80 | + return resolution.id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`) |
| 81 | + ? resolution.id |
| 82 | + : `${resolutionIdPrefix}${resolution.id |
| 83 | + // Concatenates the query params to mark the file (also attaches names of re-exports - this is needed for serverless functions to re-export the handler) |
| 84 | + .concat(SENTRY_WRAPPED_ENTRY) |
| 85 | + .concat( |
| 86 | + constructWrappedFunctionExportQuery(moduleInfo.exportedBindings, entrypointWrappedFunctions, debug), |
| 87 | + ) |
| 88 | + .concat(QUERY_END_INDICATOR)}`; |
| 89 | + } |
| 90 | + return null; |
| 91 | + }, |
| 92 | + load(id: string) { |
| 93 | + if (id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) { |
| 94 | + const entryId = removeSentryQueryFromPath(id).slice(resolutionIdPrefix.length); |
| 95 | + |
| 96 | + // Mostly useful for serverless `handler` functions |
| 97 | + const reExportedFunctions = |
| 98 | + id.includes(SENTRY_WRAPPED_FUNCTIONS) || id.includes(SENTRY_REEXPORTED_FUNCTIONS) |
| 99 | + ? constructFunctionReExport(id, entryId) |
| 100 | + : ''; |
| 101 | + |
| 102 | + return ( |
| 103 | + // Regular `import` of the Sentry config |
| 104 | + `import ${JSON.stringify(resolvedServerConfigPath)};\n` + |
| 105 | + // Dynamic `import()` for the previous, actual entry point. |
| 106 | + // `import()` can be used for any code that should be run after the hooks are registered (https://nodejs.org/api/module.html#enabling) |
| 107 | + `import(${JSON.stringify(entryId)});\n` + |
| 108 | + // By importing additional imports like "import-in-the-middle/hook.mjs", we can make sure this file wil be included, as not all node builders are including files imported with `module.register()`. |
| 109 | + `${additionalImports ? additionalImports.map(importPath => `import "${importPath}";\n`) : ''}` + |
| 110 | + `${reExportedFunctions}\n` |
| 111 | + ); |
| 112 | + } |
| 113 | + |
| 114 | + return null; |
| 115 | + }, |
| 116 | + }; |
| 117 | +} |
| 118 | + |
| 119 | +/** |
| 120 | + * Strips the Sentry query part from a path. |
| 121 | + * Example: example/path?sentry-query-wrapped-entry?sentry-query-functions-reexport=foo,SENTRY-QUERY-END -> /example/path |
| 122 | + * |
| 123 | + * **Only exported for testing** |
| 124 | + */ |
| 125 | +export function removeSentryQueryFromPath(url: string): string { |
| 126 | + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor |
| 127 | + const regex = new RegExp(`\\${SENTRY_WRAPPED_ENTRY}.*?\\${QUERY_END_INDICATOR}`); |
| 128 | + return url.replace(regex, ''); |
| 129 | +} |
| 130 | + |
| 131 | +/** |
| 132 | + * Extracts and sanitizes function re-export and function wrap query parameters from a query string. |
| 133 | + * If it is a default export, it is not considered for re-exporting. |
| 134 | + * |
| 135 | + * **Only exported for testing** |
| 136 | + */ |
| 137 | +export function extractFunctionReexportQueryParameters(query: string): { wrap: string[]; reexport: string[] } { |
| 138 | + // Regex matches the comma-separated params between the functions query |
| 139 | + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor |
| 140 | + const wrapRegex = new RegExp( |
| 141 | + `\\${SENTRY_WRAPPED_FUNCTIONS}(.*?)(\\${QUERY_END_INDICATOR}|\\${SENTRY_REEXPORTED_FUNCTIONS})`, |
| 142 | + ); |
| 143 | + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor |
| 144 | + const reexportRegex = new RegExp(`\\${SENTRY_REEXPORTED_FUNCTIONS}(.*?)(\\${QUERY_END_INDICATOR})`); |
| 145 | + |
| 146 | + const wrapMatch = query.match(wrapRegex); |
| 147 | + const reexportMatch = query.match(reexportRegex); |
| 148 | + |
| 149 | + const wrap = |
| 150 | + wrapMatch && wrapMatch[1] |
| 151 | + ? wrapMatch[1] |
| 152 | + .split(',') |
| 153 | + .filter(param => param !== '') |
| 154 | + // Sanitize, as code could be injected with another rollup plugin |
| 155 | + .map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) |
| 156 | + : []; |
| 157 | + |
| 158 | + const reexport = |
| 159 | + reexportMatch && reexportMatch[1] |
| 160 | + ? reexportMatch[1] |
| 161 | + .split(',') |
| 162 | + .filter(param => param !== '' && param !== 'default') |
| 163 | + // Sanitize, as code could be injected with another rollup plugin |
| 164 | + .map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) |
| 165 | + : []; |
| 166 | + |
| 167 | + return { wrap, reexport }; |
| 168 | +} |
| 169 | + |
| 170 | +/** |
| 171 | + * Constructs a comma-separated string with all functions that need to be re-exported later from the server entry. |
| 172 | + * It uses Rollup's `exportedBindings` to determine the functions to re-export. Functions which should be wrapped |
| 173 | + * (e.g. serverless handlers) are wrapped by Sentry. |
| 174 | + * |
| 175 | + * **Only exported for testing** |
| 176 | + */ |
| 177 | +export function constructWrappedFunctionExportQuery( |
| 178 | + exportedBindings: Record<string, string[]> | null, |
| 179 | + entrypointWrappedFunctions: string[], |
| 180 | + debug?: boolean, |
| 181 | +): string { |
| 182 | + const functionsToExport: { wrap: string[]; reexport: string[] } = { |
| 183 | + wrap: [], |
| 184 | + reexport: [], |
| 185 | + }; |
| 186 | + |
| 187 | + // `exportedBindings` can look like this: `{ '.': [ 'handler' ] }` or `{ '.': [], './firebase-gen-1.mjs': [ 'server' ] }` |
| 188 | + // The key `.` refers to exports within the current file, while other keys show from where exports were imported first. |
| 189 | + Object.values(exportedBindings || {}).forEach(functions => |
| 190 | + functions.forEach(fn => { |
| 191 | + if (entrypointWrappedFunctions.includes(fn)) { |
| 192 | + functionsToExport.wrap.push(fn); |
| 193 | + } else { |
| 194 | + functionsToExport.reexport.push(fn); |
| 195 | + } |
| 196 | + }), |
| 197 | + ); |
| 198 | + |
| 199 | + if (debug && functionsToExport.wrap.length === 0) { |
| 200 | + consoleSandbox(() => |
| 201 | + // eslint-disable-next-line no-console |
| 202 | + console.warn( |
| 203 | + '[Sentry] No functions found to wrap. In case the server needs to export async functions other than `handler` or `server`, consider adding the name(s) to `entrypointWrappedFunctions`.', |
| 204 | + ), |
| 205 | + ); |
| 206 | + } |
| 207 | + |
| 208 | + const wrapQuery = functionsToExport.wrap.length |
| 209 | + ? `${SENTRY_WRAPPED_FUNCTIONS}${functionsToExport.wrap.join(',')}` |
| 210 | + : ''; |
| 211 | + const reexportQuery = functionsToExport.reexport.length |
| 212 | + ? `${SENTRY_REEXPORTED_FUNCTIONS}${functionsToExport.reexport.join(',')}` |
| 213 | + : ''; |
| 214 | + |
| 215 | + return [wrapQuery, reexportQuery].join(''); |
| 216 | +} |
| 217 | + |
| 218 | +/** |
| 219 | + * Constructs a code snippet with function reexports (can be used in Rollup plugins as a return value for `load()`) |
| 220 | + * |
| 221 | + * **Only exported for testing** |
| 222 | + */ |
| 223 | +export function constructFunctionReExport(pathWithQuery: string, entryId: string): string { |
| 224 | + const { wrap: wrapFunctions, reexport: reexportFunctions } = extractFunctionReexportQueryParameters(pathWithQuery); |
| 225 | + |
| 226 | + return wrapFunctions |
| 227 | + .reduce( |
| 228 | + (functionsCode, currFunctionName) => |
| 229 | + functionsCode.concat( |
| 230 | + `async function ${currFunctionName}_sentryWrapped(...args) {\n` + |
| 231 | + ` const res = await import(${JSON.stringify(entryId)});\n` + |
| 232 | + ` return res.${currFunctionName}.call(this, ...args);\n` + |
| 233 | + '}\n' + |
| 234 | + `export { ${currFunctionName}_sentryWrapped as ${currFunctionName} };\n`, |
| 235 | + ), |
| 236 | + '', |
| 237 | + ) |
| 238 | + .concat( |
| 239 | + reexportFunctions.reduce( |
| 240 | + (functionsCode, currFunctionName) => |
| 241 | + functionsCode.concat(`export { ${currFunctionName} } from ${JSON.stringify(entryId)};`), |
| 242 | + '', |
| 243 | + ), |
| 244 | + ); |
| 245 | +} |
0 commit comments