diff --git a/packages/nuxt/src/common/types.ts b/packages/nuxt/src/common/types.ts index 6ba29752a308..46f390120cfe 100644 --- a/packages/nuxt/src/common/types.ts +++ b/packages/nuxt/src/common/types.ts @@ -117,6 +117,19 @@ export type SentryNuxtModuleOptions = { */ dynamicImportForServerEntry?: boolean; + /** + * By default—unless you configure `dynamicImportForServerEntry: false`—the SDK will try to wrap your Nitro server entrypoint + * with a dynamic `import()` to ensure all dependencies can be properly instrumented. Any previous exports from the entrypoint are still exported. + * Most exports of the server entrypoint are serverless functions and those are wrapped by Sentry. Other exports stay as-is. + * + * By default, the SDK will wrap the default export as well as a `handler` or `server` export from the entrypoint. + * If your server has a different main export that is used to run the server, you can overwrite this by providing an array of export names to wrap. + * Any wrapped export is expected to be an async function. + * + * @default ['default', 'handler', 'server'] + */ + entrypointWrappedFunctions?: string[]; + /** * Options to be passed directly to the Sentry Rollup Plugin (`@sentry/rollup-plugin`) and Sentry Vite Plugin (`@sentry/vite-plugin`) that ship with the Sentry Nuxt SDK. * You can use this option to override any options the SDK passes to the Vite (for Nuxt) and Rollup (for Nitro) plugin. diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 56fa71ad95a3..426ab2f41edf 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -21,6 +21,7 @@ export default defineNuxtModule({ const moduleOptions = { ...moduleOptionsParam, dynamicImportForServerEntry: moduleOptionsParam.dynamicImportForServerEntry !== false, // default: true + entrypointWrappedFunctions: moduleOptionsParam.entrypointWrappedFunctions || ['default', 'handler', 'server'], }; const moduleDirResolver = createResolver(import.meta.url); @@ -101,7 +102,7 @@ export default defineNuxtModule({ }); } } else { - addDynamicImportEntryFileWrapper(nitro, serverConfigFile); + addDynamicImportEntryFileWrapper(nitro, serverConfigFile, moduleOptions); if (moduleOptions.debug) { consoleSandbox(() => { diff --git a/packages/nuxt/src/vite/addServerConfig.ts b/packages/nuxt/src/vite/addServerConfig.ts index fe86895a76f2..cf4b2a95473e 100644 --- a/packages/nuxt/src/vite/addServerConfig.ts +++ b/packages/nuxt/src/vite/addServerConfig.ts @@ -1,15 +1,17 @@ import * as fs from 'fs'; import { createResolver } from '@nuxt/kit'; import type { Nuxt } from '@nuxt/schema'; -import { consoleSandbox, flatten } from '@sentry/utils'; +import { consoleSandbox } from '@sentry/utils'; import type { Nitro } from 'nitropack'; import type { InputPluginOption } from 'rollup'; import type { SentryNuxtModuleOptions } from '../common/types'; import { QUERY_END_INDICATOR, - SENTRY_FUNCTIONS_REEXPORT, + SENTRY_REEXPORTED_FUNCTIONS, SENTRY_WRAPPED_ENTRY, + SENTRY_WRAPPED_FUNCTIONS, constructFunctionReExport, + constructWrappedFunctionExportQuery, removeSentryQueryFromPath, } from './utils'; @@ -81,7 +83,12 @@ export function addServerConfigToBuild( * With this, the Sentry server config can be loaded before all other modules of the application (which is needed for import-in-the-middle). * See: https://nodejs.org/api/module.html#enabling */ -export function addDynamicImportEntryFileWrapper(nitro: Nitro, serverConfigFile: string): void { +export function addDynamicImportEntryFileWrapper( + nitro: Nitro, + serverConfigFile: string, + moduleOptions: Omit & + Required>, +): void { if (!nitro.options.rollupConfig) { nitro.options.rollupConfig = { output: {} }; } @@ -94,7 +101,10 @@ export function addDynamicImportEntryFileWrapper(nitro: Nitro, serverConfigFile: } nitro.options.rollupConfig.plugins.push( - wrapEntryWithDynamicImport(createResolver(nitro.options.srcDir).resolve(`/${serverConfigFile}`)), + wrapEntryWithDynamicImport({ + resolvedSentryConfigPath: createResolver(nitro.options.srcDir).resolve(`/${serverConfigFile}`), + entrypointWrappedFunctions: moduleOptions.entrypointWrappedFunctions, + }), ); } @@ -103,7 +113,11 @@ export function addDynamicImportEntryFileWrapper(nitro: Nitro, serverConfigFile: * by using a regular `import` and load the server after that. * This also works with serverless `handler` functions, as it re-exports the `handler`. */ -function wrapEntryWithDynamicImport(resolvedSentryConfigPath: string): InputPluginOption { +function wrapEntryWithDynamicImport({ + resolvedSentryConfigPath, + entrypointWrappedFunctions, + debug, +}: { resolvedSentryConfigPath: string; entrypointWrappedFunctions: string[]; debug?: boolean }): InputPluginOption { return { name: 'sentry-wrap-entry-with-dynamic-import', async resolveId(source, importer, options) { @@ -129,17 +143,15 @@ function wrapEntryWithDynamicImport(resolvedSentryConfigPath: string): InputPlug moduleInfo.moduleSideEffects = true; - // `exportedBindings` can look like this: `{ '.': [ 'handler' ], './firebase-gen-1.mjs': [ 'server' ] }` - // The key `.` refers to exports within the current file, while other keys show from where exports were imported first. - const exportedFunctions = flatten(Object.values(moduleInfo.exportedBindings || {})); - // 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 return resolution.id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`) ? resolution.id : resolution.id // 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) .concat(SENTRY_WRAPPED_ENTRY) - .concat(exportedFunctions?.length ? SENTRY_FUNCTIONS_REEXPORT.concat(exportedFunctions.join(',')) : '') + .concat( + constructWrappedFunctionExportQuery(moduleInfo.exportedBindings, entrypointWrappedFunctions, debug), + ) .concat(QUERY_END_INDICATOR); } return null; @@ -149,9 +161,10 @@ function wrapEntryWithDynamicImport(resolvedSentryConfigPath: string): InputPlug const entryId = removeSentryQueryFromPath(id); // Mostly useful for serverless `handler` functions - const reExportedFunctions = id.includes(SENTRY_FUNCTIONS_REEXPORT) - ? constructFunctionReExport(id, entryId) - : ''; + const reExportedFunctions = + id.includes(SENTRY_WRAPPED_FUNCTIONS) || id.includes(SENTRY_REEXPORTED_FUNCTIONS) + ? constructFunctionReExport(id, entryId) + : ''; return ( // Regular `import` of the Sentry config diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index 1737a47e8062..8fffc8fe06c9 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; +import { consoleSandbox, flatten } from '@sentry/utils'; /** * Find the default SDK init file for the given type (client or server). @@ -26,7 +27,8 @@ export function findDefaultSdkInitFile(type: 'server' | 'client'): string | unde } export const SENTRY_WRAPPED_ENTRY = '?sentry-query-wrapped-entry'; -export const SENTRY_FUNCTIONS_REEXPORT = '?sentry-query-functions-reexport='; +export const SENTRY_WRAPPED_FUNCTIONS = '?sentry-query-wrapped-functions='; +export const SENTRY_REEXPORTED_FUNCTIONS = '?sentry-query-reexported-functions='; export const QUERY_END_INDICATOR = 'SENTRY-QUERY-END'; /** @@ -42,42 +44,110 @@ export function removeSentryQueryFromPath(url: string): string { } /** - * Extracts and sanitizes function re-export query parameters from a query string. - * If it is a default export, it is not considered for re-exporting. This function is mostly relevant for re-exporting - * serverless `handler` functions. + * Extracts and sanitizes function re-export and function wrap query parameters from a query string. + * If it is a default export, it is not considered for re-exporting. * * Only exported for testing. */ -export function extractFunctionReexportQueryParameters(query: string): string[] { +export function extractFunctionReexportQueryParameters(query: string): { wrap: string[]; reexport: string[] } { // Regex matches the comma-separated params between the functions query // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor - const regex = new RegExp(`\\${SENTRY_FUNCTIONS_REEXPORT}(.*?)\\${QUERY_END_INDICATOR}`); - const match = query.match(regex); - - return match && match[1] - ? match[1] - .split(',') - .filter(param => param !== '') - // Sanitize, as code could be injected with another rollup plugin - .map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - : []; + const wrapRegex = new RegExp( + `\\${SENTRY_WRAPPED_FUNCTIONS}(.*?)(\\${QUERY_END_INDICATOR}|\\${SENTRY_REEXPORTED_FUNCTIONS})`, + ); + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor + const reexportRegex = new RegExp(`\\${SENTRY_REEXPORTED_FUNCTIONS}(.*?)(\\${QUERY_END_INDICATOR})`); + + const wrapMatch = query.match(wrapRegex); + const reexportMatch = query.match(reexportRegex); + + const wrap = + wrapMatch && wrapMatch[1] + ? wrapMatch[1] + .split(',') + .filter(param => param !== '') + // Sanitize, as code could be injected with another rollup plugin + .map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + : []; + + const reexport = + reexportMatch && reexportMatch[1] + ? reexportMatch[1] + .split(',') + .filter(param => param !== '' && param !== 'default') + // Sanitize, as code could be injected with another rollup plugin + .map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + : []; + + return { wrap, reexport }; +} + +/** + * Constructs a comma-separated string with all functions that need to be re-exported later from the server entry. + * It uses Rollup's `exportedBindings` to determine the functions to re-export. Functions which should be wrapped + * (e.g. serverless handlers) are wrapped by Sentry. + */ +export function constructWrappedFunctionExportQuery( + exportedBindings: Record | null, + entrypointWrappedFunctions: string[], + debug?: boolean, +): string { + // `exportedBindings` can look like this: `{ '.': [ 'handler' ] }` or `{ '.': [], './firebase-gen-1.mjs': [ 'server' ] }` + // The key `.` refers to exports within the current file, while other keys show from where exports were imported first. + const functionsToExport = flatten(Object.values(exportedBindings || {})).reduce( + (functions, currFunctionName) => { + if (entrypointWrappedFunctions.includes(currFunctionName)) { + functions.wrap.push(currFunctionName); + } else { + functions.reexport.push(currFunctionName); + } + return functions; + }, + { wrap: [], reexport: [] } as { wrap: string[]; reexport: string[] }, + ); + + if (debug && functionsToExport.wrap.length === 0) { + consoleSandbox(() => + // eslint-disable-next-line no-console + console.warn( + "[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 Sentry's build options `sentry.entrypointWrappedFunctions` in `nuxt.config.ts`.", + ), + ); + } + + const wrapQuery = functionsToExport.wrap.length + ? `${SENTRY_WRAPPED_FUNCTIONS}${functionsToExport.wrap.join(',')}` + : ''; + const reexportQuery = functionsToExport.reexport.length + ? `${SENTRY_REEXPORTED_FUNCTIONS}${functionsToExport.reexport.join(',')}` + : ''; + + return [wrapQuery, reexportQuery].join(''); } /** - * Constructs a code snippet with function reexports (can be used in Rollup plugins) + * Constructs a code snippet with function reexports (can be used in Rollup plugins as a return value for `load()`) */ export function constructFunctionReExport(pathWithQuery: string, entryId: string): string { - const functionNames = extractFunctionReexportQueryParameters(pathWithQuery); - - return functionNames.reduce( - (functionsCode, currFunctionName) => - functionsCode.concat( - 'async function reExport(...args) {\n' + - ` const res = await import(${JSON.stringify(entryId)});\n` + - ` return res.${currFunctionName}.call(this, ...args);\n` + - '}\n' + - `export { reExport as ${currFunctionName} };\n`, + const { wrap: wrapFunctions, reexport: reexportFunctions } = extractFunctionReexportQueryParameters(pathWithQuery); + + return wrapFunctions + .reduce( + (functionsCode, currFunctionName) => + functionsCode.concat( + `async function ${currFunctionName}_sentryWrapped(...args) {\n` + + ` const res = await import(${JSON.stringify(entryId)});\n` + + ` return res.${currFunctionName}.call(this, ...args);\n` + + '}\n' + + `export { ${currFunctionName}_sentryWrapped as ${currFunctionName} };\n`, + ), + '', + ) + .concat( + reexportFunctions.reduce( + (functionsCode, currFunctionName) => + functionsCode.concat(`export { ${currFunctionName} } from ${JSON.stringify(entryId)};`), + '', ), - '', - ); + ); } diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index a38dbdc44793..a35f9cf8ca34 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -2,9 +2,11 @@ import * as fs from 'fs'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { QUERY_END_INDICATOR, - SENTRY_FUNCTIONS_REEXPORT, + SENTRY_REEXPORTED_FUNCTIONS, SENTRY_WRAPPED_ENTRY, + SENTRY_WRAPPED_FUNCTIONS, constructFunctionReExport, + constructWrappedFunctionExportQuery, extractFunctionReexportQueryParameters, findDefaultSdkInitFile, removeSentryQueryFromPath, @@ -70,7 +72,7 @@ describe('findDefaultSdkInitFile', () => { describe('removeSentryQueryFromPath', () => { it('strips the Sentry query part from the path', () => { - const url = `/example/path${SENTRY_WRAPPED_ENTRY}${SENTRY_FUNCTIONS_REEXPORT}foo,${QUERY_END_INDICATOR}`; + const url = `/example/path${SENTRY_WRAPPED_ENTRY}${SENTRY_WRAPPED_FUNCTIONS}foo,${QUERY_END_INDICATOR}`; const url2 = `/example/path${SENTRY_WRAPPED_ENTRY}${QUERY_END_INDICATOR}`; const result = removeSentryQueryFromPath(url); const result2 = removeSentryQueryFromPath(url2); @@ -87,56 +89,157 @@ describe('removeSentryQueryFromPath', () => { describe('extractFunctionReexportQueryParameters', () => { it.each([ - [`${SENTRY_FUNCTIONS_REEXPORT}foo,bar,${QUERY_END_INDICATOR}`, ['foo', 'bar']], - [`${SENTRY_FUNCTIONS_REEXPORT}foo,bar,default${QUERY_END_INDICATOR}`, ['foo', 'bar', 'default']], + [`${SENTRY_WRAPPED_FUNCTIONS}foo,bar,${QUERY_END_INDICATOR}`, { wrap: ['foo', 'bar'], reexport: [] }], [ - `${SENTRY_FUNCTIONS_REEXPORT}foo,a.b*c?d[e]f(g)h|i\\\\j(){hello},${QUERY_END_INDICATOR}`, - ['foo', 'a\\.b\\*c\\?d\\[e\\]f\\(g\\)h\\|i\\\\\\\\j\\(\\)\\{hello\\}'], + `${SENTRY_WRAPPED_FUNCTIONS}foo,bar,default${QUERY_END_INDICATOR}`, + { wrap: ['foo', 'bar', 'default'], reexport: [] }, ], - [`/example/path/${SENTRY_FUNCTIONS_REEXPORT}foo,bar${QUERY_END_INDICATOR}`, ['foo', 'bar']], - [`${SENTRY_FUNCTIONS_REEXPORT}${QUERY_END_INDICATOR}`, []], - ['?other-query=param', []], + [ + `${SENTRY_WRAPPED_FUNCTIONS}foo,a.b*c?d[e]f(g)h|i\\\\j(){hello},${QUERY_END_INDICATOR}`, + { wrap: ['foo', 'a\\.b\\*c\\?d\\[e\\]f\\(g\\)h\\|i\\\\\\\\j\\(\\)\\{hello\\}'], reexport: [] }, + ], + [`/example/path/${SENTRY_WRAPPED_FUNCTIONS}foo,bar${QUERY_END_INDICATOR}`, { wrap: ['foo', 'bar'], reexport: [] }], + [ + `${SENTRY_WRAPPED_FUNCTIONS}foo,bar,${SENTRY_REEXPORTED_FUNCTIONS}${QUERY_END_INDICATOR}`, + { wrap: ['foo', 'bar'], reexport: [] }, + ], + [`${SENTRY_REEXPORTED_FUNCTIONS}${QUERY_END_INDICATOR}`, { wrap: [], reexport: [] }], + [ + `/path${SENTRY_WRAPPED_FUNCTIONS}foo,bar${SENTRY_REEXPORTED_FUNCTIONS}bar${QUERY_END_INDICATOR}`, + { wrap: ['foo', 'bar'], reexport: ['bar'] }, + ], + ['?other-query=param', { wrap: [], reexport: [] }], ])('extracts parameters from the query string: %s', (query, expected) => { const result = extractFunctionReexportQueryParameters(query); expect(result).toEqual(expected); }); }); +describe('constructWrappedFunctionExportQuery', () => { + it.each([ + [{ '.': ['handler'] }, ['handler'], `${SENTRY_WRAPPED_FUNCTIONS}handler`], + [{ '.': ['handler'], './module': ['server'] }, [], `${SENTRY_REEXPORTED_FUNCTIONS}handler,server`], + [ + { '.': ['handler'], './module': ['server'] }, + ['server'], + `${SENTRY_WRAPPED_FUNCTIONS}server${SENTRY_REEXPORTED_FUNCTIONS}handler`, + ], + [ + { '.': ['handler', 'otherFunction'] }, + ['handler'], + `${SENTRY_WRAPPED_FUNCTIONS}handler${SENTRY_REEXPORTED_FUNCTIONS}otherFunction`, + ], + [{ '.': ['handler', 'otherFn'] }, ['handler', 'otherFn'], `${SENTRY_WRAPPED_FUNCTIONS}handler,otherFn`], + [{ '.': ['bar'], './module': ['foo'] }, ['bar', 'foo'], `${SENTRY_WRAPPED_FUNCTIONS}bar,foo`], + [{ '.': ['foo', 'bar'] }, ['foo'], `${SENTRY_WRAPPED_FUNCTIONS}foo${SENTRY_REEXPORTED_FUNCTIONS}bar`], + [{ '.': ['foo', 'bar'] }, ['bar'], `${SENTRY_WRAPPED_FUNCTIONS}bar${SENTRY_REEXPORTED_FUNCTIONS}foo`], + [{ '.': ['foo', 'bar'] }, ['foo', 'bar'], `${SENTRY_WRAPPED_FUNCTIONS}foo,bar`], + [{ '.': ['foo', 'bar'] }, [], `${SENTRY_REEXPORTED_FUNCTIONS}foo,bar`], + ])( + 'constructs re-export query for exportedBindings: %j and entrypointWrappedFunctions: %j', + (exportedBindings, entrypointWrappedFunctions, expected) => { + const result = constructWrappedFunctionExportQuery(exportedBindings, entrypointWrappedFunctions); + expect(result).toBe(expected); + }, + ); + + it('logs a warning if no functions are found for re-export and debug is true', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const exportedBindings = { '.': ['handler'] }; + const entrypointWrappedFunctions = ['nonExistentFunction']; + const debug = true; + + const result = constructWrappedFunctionExportQuery(exportedBindings, entrypointWrappedFunctions, debug); + expect(result).toBe('?sentry-query-reexported-functions=handler'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "[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 Sentry's build options `sentry.entrypointWrappedFunctions` in `nuxt.config.ts`.", + ); + + consoleWarnSpy.mockRestore(); + }); +}); + describe('constructFunctionReExport', () => { it('constructs re-export code for given query parameters and entry ID', () => { - const query = `${SENTRY_FUNCTIONS_REEXPORT}foo,bar,${QUERY_END_INDICATOR}}`; - const query2 = `${SENTRY_FUNCTIONS_REEXPORT}foo,bar${QUERY_END_INDICATOR}}`; + const query = `${SENTRY_WRAPPED_FUNCTIONS}foo,bar,${QUERY_END_INDICATOR}}`; + const query2 = `${SENTRY_WRAPPED_FUNCTIONS}foo,bar${QUERY_END_INDICATOR}}`; const entryId = './module'; const result = constructFunctionReExport(query, entryId); const result2 = constructFunctionReExport(query2, entryId); const expected = ` -async function reExport(...args) { +async function foo_sentryWrapped(...args) { const res = await import("./module"); return res.foo.call(this, ...args); } -export { reExport as foo }; -async function reExport(...args) { +export { foo_sentryWrapped as foo }; +async function bar_sentryWrapped(...args) { const res = await import("./module"); return res.bar.call(this, ...args); } -export { reExport as bar }; +export { bar_sentryWrapped as bar }; `; expect(result.trim()).toBe(expected.trim()); expect(result2.trim()).toBe(expected.trim()); }); it('constructs re-export code for a "default" query parameters and entry ID', () => { - const query = `${SENTRY_FUNCTIONS_REEXPORT}default${QUERY_END_INDICATOR}}`; + const query = `${SENTRY_WRAPPED_FUNCTIONS}default${QUERY_END_INDICATOR}}`; const entryId = './index'; const result = constructFunctionReExport(query, entryId); const expected = ` -async function reExport(...args) { +async function default_sentryWrapped(...args) { const res = await import("./index"); return res.default.call(this, ...args); } -export { reExport as default }; +export { default_sentryWrapped as default }; +`; + expect(result.trim()).toBe(expected.trim()); + }); + + it('constructs re-export code for a "default" query parameters and entry ID', () => { + const query = `${SENTRY_WRAPPED_FUNCTIONS}default${QUERY_END_INDICATOR}}`; + const entryId = './index'; + const result = constructFunctionReExport(query, entryId); + + const expected = ` +async function default_sentryWrapped(...args) { + const res = await import("./index"); + return res.default.call(this, ...args); +} +export { default_sentryWrapped as default }; +`; + expect(result.trim()).toBe(expected.trim()); + }); + + it('constructs re-export code for a mix of wrapped and re-exported functions', () => { + const query = `${SENTRY_WRAPPED_FUNCTIONS}foo,${SENTRY_REEXPORTED_FUNCTIONS}bar${QUERY_END_INDICATOR}`; + const entryId = './module'; + const result = constructFunctionReExport(query, entryId); + + const expected = ` +async function foo_sentryWrapped(...args) { + const res = await import("./module"); + return res.foo.call(this, ...args); +} +export { foo_sentryWrapped as foo }; +export { bar } from "./module"; +`; + expect(result.trim()).toBe(expected.trim()); + }); + + it('does not re-export a default export for regular re-exported functions', () => { + const query = `${SENTRY_WRAPPED_FUNCTIONS}foo${SENTRY_REEXPORTED_FUNCTIONS}default${QUERY_END_INDICATOR}`; + const entryId = './module'; + const result = constructFunctionReExport(query, entryId); + + const expected = ` +async function foo_sentryWrapped(...args) { + const res = await import("./module"); + return res.foo.call(this, ...args); +} +export { foo_sentryWrapped as foo }; `; expect(result.trim()).toBe(expected.trim()); });