Skip to content

Commit

Permalink
Merge pull request #14104 from getsentry/sig/nuxt-reexport-functions
Browse files Browse the repository at this point in the history
feat(nuxt): Add `entrypointWrappedFunctions` to define async wrapped server functions
  • Loading branch information
s1gr1d authored Nov 4, 2024
2 parents 984a6bd + 62e95b2 commit c579a45
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 60 deletions.
13 changes: 13 additions & 0 deletions packages/nuxt/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default defineNuxtModule<ModuleOptions>({
const moduleOptions = {
...moduleOptionsParam,
dynamicImportForServerEntry: moduleOptionsParam.dynamicImportForServerEntry !== false, // default: true
entrypointWrappedFunctions: moduleOptionsParam.entrypointWrappedFunctions || ['default', 'handler', 'server'],
};

const moduleDirResolver = createResolver(import.meta.url);
Expand Down Expand Up @@ -101,7 +102,7 @@ export default defineNuxtModule<ModuleOptions>({
});
}
} else {
addDynamicImportEntryFileWrapper(nitro, serverConfigFile);
addDynamicImportEntryFileWrapper(nitro, serverConfigFile, moduleOptions);

if (moduleOptions.debug) {
consoleSandbox(() => {
Expand Down
39 changes: 26 additions & 13 deletions packages/nuxt/src/vite/addServerConfig.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<SentryNuxtModuleOptions, 'entrypointWrappedFunctions'> &
Required<Pick<SentryNuxtModuleOptions, 'entrypointWrappedFunctions'>>,
): void {
if (!nitro.options.rollupConfig) {
nitro.options.rollupConfig = { output: {} };
}
Expand All @@ -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,
}),
);
}

Expand All @@ -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) {
Expand All @@ -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;
Expand All @@ -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
Expand Down
126 changes: 98 additions & 28 deletions packages/nuxt/src/vite/utils.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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';

/**
Expand All @@ -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<string, string[]> | 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)};`),
'',
),
'',
);
);
}
Loading

0 comments on commit c579a45

Please sign in to comment.