diff --git a/packages/nuxt/build.config.ts b/packages/nuxt/build.config.ts new file mode 100644 index 000000000000..4cb00345dc43 --- /dev/null +++ b/packages/nuxt/build.config.ts @@ -0,0 +1,7 @@ +import { defineBuildConfig } from 'unbuild'; + +// Build Config for the Nuxt Module Builder: https://github.com/nuxt/module-builder +export default defineBuildConfig({ + // The devDependency "@sentry-internal/nitro-utils" triggers "Inlined implicit external", but it's not external + failOnWarn: false, +}); diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 1fc8a05b6b3f..949680d467df 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -53,6 +53,7 @@ }, "devDependencies": { "@nuxt/module-builder": "^0.8.4", + "@sentry-internal/nitro-utils": "8.42.0", "nuxt": "^3.13.2" }, "scripts": { diff --git a/packages/nuxt/src/common/types.ts b/packages/nuxt/src/common/types.ts index 93ca94016924..41a07bfbc27b 100644 --- a/packages/nuxt/src/common/types.ts +++ b/packages/nuxt/src/common/types.ts @@ -143,6 +143,12 @@ export type SentryNuxtModuleOptions = { */ experimental_entrypointWrappedFunctions?: string[]; + /** + * The server entrypoint filename is automatically set by the Sentry SDK depending on the Nitro present. + * In case the server entrypoint has a different filename, you can overwrite it here. + */ + serverEntrypointFileName?: 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/vite/addServerConfig.ts b/packages/nuxt/src/vite/addServerConfig.ts index b5577830396b..da4f5bc11bce 100644 --- a/packages/nuxt/src/vite/addServerConfig.ts +++ b/packages/nuxt/src/vite/addServerConfig.ts @@ -1,20 +1,11 @@ import * as fs from 'fs'; import { createResolver } from '@nuxt/kit'; import type { Nuxt } from '@nuxt/schema'; +import { wrapServerEntryWithDynamicImport } from '@sentry-internal/nitro-utils'; import { consoleSandbox } from '@sentry/core'; import type { Nitro } from 'nitropack'; -import type { InputPluginOption } from 'rollup'; import type { SentryNuxtModuleOptions } from '../common/types'; -import { - QUERY_END_INDICATOR, - SENTRY_REEXPORTED_FUNCTIONS, - SENTRY_WRAPPED_ENTRY, - SENTRY_WRAPPED_FUNCTIONS, - constructFunctionReExport, - constructWrappedFunctionExportQuery, - getFilenameFromNodeStartCommand, - removeSentryQueryFromPath, -} from './utils'; +import { getFilenameFromNodeStartCommand } from './utils'; const SERVER_CONFIG_FILENAME = 'sentry.server.config'; @@ -151,98 +142,13 @@ export function addDynamicImportEntryFileWrapper( } nitro.options.rollupConfig.plugins.push( - wrapEntryWithDynamicImport({ - resolvedSentryConfigPath: createResolver(nitro.options.srcDir).resolve(`/${serverConfigFile}`), - experimental_entrypointWrappedFunctions: moduleOptions.experimental_entrypointWrappedFunctions, + wrapServerEntryWithDynamicImport({ + serverEntrypointFileName: moduleOptions.serverEntrypointFileName || nitro.options.preset, + serverConfigFileName: SERVER_CONFIG_FILENAME, + resolvedServerConfigPath: createResolver(nitro.options.srcDir).resolve(`/${serverConfigFile}`), + entrypointWrappedFunctions: moduleOptions.experimental_entrypointWrappedFunctions, + additionalImports: ['import-in-the-middle/hook.mjs'], + debug: moduleOptions.debug, }), ); } - -/** - * A Rollup plugin which wraps the server entry with a dynamic `import()`. This makes it possible to initialize Sentry first - * 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, - experimental_entrypointWrappedFunctions, - debug, -}: { - resolvedSentryConfigPath: string; - experimental_entrypointWrappedFunctions: string[]; - debug?: boolean; -}): InputPluginOption { - // In order to correctly import the server config file - // and dynamically import the nitro runtime, we need to - // mark the resolutionId with '\0raw' to fall into the - // raw chunk group, c.f. https://github.com/nitrojs/nitro/commit/8b4a408231bdc222569a32ce109796a41eac4aa6#diff-e58102d2230f95ddeef2662957b48d847a6e891e354cfd0ae6e2e03ce848d1a2R142 - const resolutionIdPrefix = '\0raw'; - - return { - name: 'sentry-wrap-entry-with-dynamic-import', - async resolveId(source, importer, options) { - if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) { - return { id: source, moduleSideEffects: true }; - } - - if (source === 'import-in-the-middle/hook.mjs') { - // We are importing "import-in-the-middle" in the returned code of the `load()` function below - // By setting `moduleSideEffects` to `true`, the import is added to the bundle, although nothing is imported from it - // 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()`. - // Prevents the error "Failed to register ESM hook Error: Cannot find module 'import-in-the-middle/hook.mjs'" - return { id: source, moduleSideEffects: true, external: true }; - } - - if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) { - const resolution = await this.resolve(source, importer, options); - - // If it cannot be resolved or is external, just return it so that Rollup can display an error - if (!resolution || resolution?.external) return resolution; - - const moduleInfo = await this.load(resolution); - - moduleInfo.moduleSideEffects = true; - - // 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 - : `${resolutionIdPrefix}${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( - constructWrappedFunctionExportQuery( - moduleInfo.exportedBindings, - experimental_entrypointWrappedFunctions, - debug, - ), - ) - .concat(QUERY_END_INDICATOR)}`; - } - return null; - }, - load(id: string) { - if (id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) { - const entryId = removeSentryQueryFromPath(id).slice(resolutionIdPrefix.length); - - // Mostly useful for serverless `handler` functions - const reExportedFunctions = - id.includes(SENTRY_WRAPPED_FUNCTIONS) || id.includes(SENTRY_REEXPORTED_FUNCTIONS) - ? constructFunctionReExport(id, entryId) - : ''; - - return ( - // Regular `import` of the Sentry config - `import ${JSON.stringify(resolvedSentryConfigPath)};\n` + - // Dynamic `import()` for the previous, actual entry point. - // `import()` can be used for any code that should be run after the hooks are registered (https://nodejs.org/api/module.html#enabling) - `import(${JSON.stringify(entryId)});\n` + - // By importing "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()`. - "import 'import-in-the-middle/hook.mjs';\n" + - `${reExportedFunctions}\n` - ); - } - - return null; - }, - }; -} diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index 85696f76f6ae..4accd44671a6 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -1,6 +1,5 @@ import * as fs from 'fs'; import * as path from 'path'; -import { consoleSandbox } from '@sentry/core'; /** * Find the default SDK init file for the given type (client or server). @@ -34,132 +33,3 @@ export function getFilenameFromNodeStartCommand(nodeCommand: string): string | n const match = nodeCommand.match(regex); return match ? match[0] : null; } - -export const SENTRY_WRAPPED_ENTRY = '?sentry-query-wrapped-entry'; -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'; - -/** - * Strips the Sentry query part from a path. - * Example: example/path?sentry-query-wrapped-entry?sentry-query-functions-reexport=foo,SENTRY-QUERY-END -> /example/path - * - * Only exported for testing. - */ -export function removeSentryQueryFromPath(url: string): string { - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor - const regex = new RegExp(`\\${SENTRY_WRAPPED_ENTRY}.*?\\${QUERY_END_INDICATOR}`); - return url.replace(regex, ''); -} - -/** - * 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): { 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 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 { - const functionsToExport: { wrap: string[]; reexport: string[] } = { - wrap: [], - reexport: [], - }; - - // `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. - Object.values(exportedBindings || {}).forEach(functions => - functions.forEach(fn => { - if (entrypointWrappedFunctions.includes(fn)) { - functionsToExport.wrap.push(fn); - } else { - functionsToExport.reexport.push(fn); - } - }), - ); - - 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.experimental_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 as a return value for `load()`) - */ -export function constructFunctionReExport(pathWithQuery: string, entryId: string): string { - 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 f2f6b2b23c8d..d9763241ba24 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -1,17 +1,6 @@ import * as fs from 'fs'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - QUERY_END_INDICATOR, - SENTRY_REEXPORTED_FUNCTIONS, - SENTRY_WRAPPED_ENTRY, - SENTRY_WRAPPED_FUNCTIONS, - constructFunctionReExport, - constructWrappedFunctionExportQuery, - extractFunctionReexportQueryParameters, - findDefaultSdkInitFile, - getFilenameFromNodeStartCommand, - removeSentryQueryFromPath, -} from '../../src/vite/utils'; +import { findDefaultSdkInitFile, getFilenameFromNodeStartCommand } from '../../src/vite/utils'; vi.mock('fs'); @@ -71,7 +60,7 @@ describe('findDefaultSdkInitFile', () => { }); }); -describe('getFilenameFromPath', () => { +describe('getFilenameFromNodeStartCommand', () => { it('should return the filename from a simple path', () => { const path = 'node ./server/index.mjs'; const filename = getFilenameFromNodeStartCommand(path); @@ -108,185 +97,3 @@ describe('getFilenameFromPath', () => { expect(filename).toBeNull(); }); }); - -describe('removeSentryQueryFromPath', () => { - it('strips the Sentry query part from the path', () => { - 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); - expect(result).toBe('/example/path'); - expect(result2).toBe('/example/path'); - }); - - it('returns the same path if the specific query part is not present', () => { - const url = '/example/path?other-query=param'; - const result = removeSentryQueryFromPath(url); - expect(result).toBe(url); - }); -}); - -describe('extractFunctionReexportQueryParameters', () => { - it.each([ - [`${SENTRY_WRAPPED_FUNCTIONS}foo,bar,${QUERY_END_INDICATOR}`, { wrap: ['foo', 'bar'], reexport: [] }], - [ - `${SENTRY_WRAPPED_FUNCTIONS}foo,bar,default${QUERY_END_INDICATOR}`, - { wrap: ['foo', 'bar', 'default'], reexport: [] }, - ], - [ - `${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.experimental_entrypointWrappedFunctions` in `nuxt.config.ts`.", - ); - - consoleWarnSpy.mockRestore(); - }); -}); - -describe('constructFunctionReExport', () => { - it('constructs re-export code for given query parameters and entry ID', () => { - 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 foo_sentryWrapped(...args) { - const res = await import("./module"); - return res.foo.call(this, ...args); -} -export { foo_sentryWrapped as foo }; -async function bar_sentryWrapped(...args) { - const res = await import("./module"); - return res.bar.call(this, ...args); -} -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_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 "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()); - }); - - it('returns an empty string if the query string is empty', () => { - const query = ''; - const entryId = './module'; - const result = constructFunctionReExport(query, entryId); - expect(result).toBe(''); - }); -}); diff --git a/packages/nuxt/tsconfig.json b/packages/nuxt/tsconfig.json index 7ddf2a7162c7..de9c931f2cd1 100644 --- a/packages/nuxt/tsconfig.json +++ b/packages/nuxt/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", - "include": ["src/**/*"], + "include": ["src/**/*", "build.config.ts"], "compilerOptions": { // package-specific options