diff --git a/packages/nuxt/src/vite/addServerConfig.ts b/packages/nuxt/src/vite/addServerConfig.ts index 845228c58b0c..1ac7715e608c 100644 --- a/packages/nuxt/src/vite/addServerConfig.ts +++ b/packages/nuxt/src/vite/addServerConfig.ts @@ -3,7 +3,17 @@ import { createResolver } from '@nuxt/kit'; import type { Nuxt } from '@nuxt/schema'; 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_WRAPPED_ENTRY, + constructFunctionReExport, + stripQueryPart, +} from './utils'; + +const SERVER_CONFIG_FILENAME = 'sentry.server.config'; /** * Adds the `sentry.server.config.ts` file as `sentry.server.config.mjs` to the `.output` directory to be able to reference this file in the node --import option. @@ -23,7 +33,7 @@ export function addServerConfigToBuild( 'server' in viteInlineConfig.build.rollupOptions.input ) { // Create a rollup entry for the server config to add it as `sentry.server.config.mjs` to the build - (viteInlineConfig.build.rollupOptions.input as { [entryName: string]: string })['sentry.server.config'] = + (viteInlineConfig.build.rollupOptions.input as { [entryName: string]: string })[SERVER_CONFIG_FILENAME] = createResolver(nuxt.options.srcDir).resolve(`/${serverConfigFile}`); } @@ -34,8 +44,8 @@ export function addServerConfigToBuild( nitro.hooks.hook('close', async () => { const buildDirResolver = createResolver(nitro.options.buildDir); const serverDirResolver = createResolver(nitro.options.output.serverDir); - const source = buildDirResolver.resolve('dist/server/sentry.server.config.mjs'); - const destination = serverDirResolver.resolve('sentry.server.config.mjs'); + const source = buildDirResolver.resolve(`dist/server/${SERVER_CONFIG_FILENAME}.mjs`); + const destination = serverDirResolver.resolve(`${SERVER_CONFIG_FILENAME}.mjs`); try { await fs.promises.access(source, fs.constants.F_OK); @@ -85,7 +95,7 @@ export function addSentryTopImport(moduleOptions: SentryNuxtModuleOptions, nitro try { fs.readFile(entryFilePath, 'utf8', (err, data) => { - const updatedContent = `import './sentry.server.config.mjs';\n${data}`; + const updatedContent = `import './${SERVER_CONFIG_FILENAME}.mjs';\n${data}`; fs.writeFile(entryFilePath, updatedContent, 'utf8', () => { if (moduleOptions.debug) { @@ -111,3 +121,94 @@ export function addSentryTopImport(moduleOptions: SentryNuxtModuleOptions, nitro } }); } + +/** + * This function modifies the Rollup configuration to include a plugin that wraps the entry file with a dynamic import (`import()`) + * and adds the Sentry server config with the static `import` declaration. + * + * 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 { + if (!nitro.options.rollupConfig) { + nitro.options.rollupConfig = { output: {} }; + } + + if (nitro.options.rollupConfig?.plugins === null || nitro.options.rollupConfig?.plugins === undefined) { + nitro.options.rollupConfig.plugins = []; + } else if (!Array.isArray(nitro.options.rollupConfig.plugins)) { + // `rollupConfig.plugins` can be a single plugin, so we want to put it into an array so that we can push our own plugin + nitro.options.rollupConfig.plugins = [nitro.options.rollupConfig.plugins]; + } + + nitro.options.rollupConfig.plugins.push( + // @ts-expect-error - This is the correct type, but it shows an error because of two different definitions + wrapEntryWithDynamicImport(createResolver(nitro.options.srcDir).resolve(`/${serverConfigFile}`)), + ); +} + +function wrapEntryWithDynamicImport(resolvedSentryConfigPath: string): InputPluginOption { + const containsSuffix = (sourcePath: string): boolean => { + return sourcePath.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`) || sourcePath.includes(SENTRY_FUNCTIONS_REEXPORT); + }; + + 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') { + return { id: source, moduleSideEffects: true, external: true }; + } + + if (options.isEntry && !source.includes(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; + + const exportedFunctions = moduleInfo.exportedBindings?.['.']; + + // checks are needed to prevent multiple attachment of the suffix + return containsSuffix(source) || containsSuffix(resolution.id) + ? resolution.id + : resolution.id + // concat the query params to mark the file (also attaches names of 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(QUERY_END_INDICATOR) + : '', + ); + } + return null; + }, + load(id: string) { + if (id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) { + const entryId = stripQueryPart(id); + + const reExportedFunctions = id.includes(SENTRY_FUNCTIONS_REEXPORT) + ? constructFunctionReExport(id, entryId) + : ''; + + return ( + // Import the Sentry server 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` + + `${reExportedFunctions}\n` + ); + } + + return null; + }, + }; +} diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index e41d3fb06cab..32318cf8a8a8 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -24,3 +24,55 @@ export function findDefaultSdkInitFile(type: 'server' | 'client'): string | unde return filePaths.find(filename => fs.existsSync(filename)); } + +export const SENTRY_WRAPPED_ENTRY = '?sentry-query-wrapped-entry'; +export const SENTRY_FUNCTIONS_REEXPORT = '?sentry-query-functions-reexport='; +export const QUERY_END_INDICATOR = 'SENTRY-QUERY-END'; + +/** + * Strips a specific query part from a URL. + * + * Only exported for testing. + */ +export function stripQueryPart(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 reexport query parameters from a query string. + * + * Only exported for testing. + */ +export function extractFunctionReexportQueryParameters(query: string): 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 !== '' && param !== 'default') + // sanitize + .map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + : []; +} + +/** + * Constructs a code snippet with function reexports (can be used in Rollup plugins) + */ +export function constructFunctionReExport(pathWithQuery: string, entryId: string): string { + const functionNames = extractFunctionReexportQueryParameters(pathWithQuery); + + return functionNames.reduce( + (functionsCode, currFunctionName) => + functionsCode.concat( + `export function ${currFunctionName}(...args) {\n` + + ` return import(${JSON.stringify(entryId)}).then((res) => res.${currFunctionName}(...args));\n` + + '}\n', + ), + '', + ); +} diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index 5115742be0f0..f29c2f5f2bcc 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -1,6 +1,14 @@ import * as fs from 'fs'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { findDefaultSdkInitFile } from '../../src/vite/utils'; +import { + QUERY_END_INDICATOR, + SENTRY_FUNCTIONS_REEXPORT, + SENTRY_WRAPPED_ENTRY, + constructFunctionReExport, + extractFunctionReexportQueryParameters, + findDefaultSdkInitFile, + stripQueryPart, +} from '../../src/vite/utils'; vi.mock('fs'); @@ -59,3 +67,61 @@ describe('findDefaultSdkInitFile', () => { expect(result).toMatch('packages/nuxt/sentry.server.config.js'); }); }); + +describe('stripQueryPart', () => { + it('strips the specific query part from the URL', () => { + const url = `/example/path${SENTRY_WRAPPED_ENTRY}${SENTRY_FUNCTIONS_REEXPORT}foo,${QUERY_END_INDICATOR}`; + const result = stripQueryPart(url); + expect(result).toBe('/example/path'); + }); + + it('returns the same URL if the specific query part is not present', () => { + const url = '/example/path?other-query=param'; + const result = stripQueryPart(url); + expect(result).toBe(url); + }); +}); + +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']], + [ + `${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\\}'], + ], + [`/example/path/${SENTRY_FUNCTIONS_REEXPORT}foo,bar${QUERY_END_INDICATOR}`, ['foo', 'bar']], + [`${SENTRY_FUNCTIONS_REEXPORT}${QUERY_END_INDICATOR}`, []], + ['?other-query=param', []], + ])('extracts parameters from the query string: %s', (query, expected) => { + const result = extractFunctionReexportQueryParameters(query); + expect(result).toEqual(expected); + }); +}); + +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 entryId = './module'; + const result = constructFunctionReExport(query, entryId); + const result2 = constructFunctionReExport(query2, entryId); + + const expected = ` +export function foo(...args) { + return import("./module").then((res) => res.foo(...args)); +} +export function bar(...args) { + return import("./module").then((res) => res.bar(...args)); +}`; + expect(result.trim()).toBe(expected.trim()); + expect(result2.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(''); + }); +});