Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nuxt): Add entrypointWrappedFunctions to define async wrapped server functions #14104

Merged
merged 5 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
andreiborza marked this conversation as resolved.
Show resolved Hide resolved

/**
* By default—unless you configure `dynamicImportForServerEntry: false`—the SDK will try to wrap your application entrypoint
s1gr1d marked this conversation as resolved.
Show resolved Hide resolved
* 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 application has a different main export that is used to run the application, 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
124 changes: 96 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,108 @@ 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 !== '')
.map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
s1gr1d marked this conversation as resolved.
Show resolved Hide resolved
: [];

const reexport =
reexportMatch && reexportMatch[1]
? reexportMatch[1]
.split(',')
.filter(param => param !== '' && param !== 'default')
.map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
s1gr1d marked this conversation as resolved.
Show resolved Hide resolved
: [];

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 your server needs to export async functions other than `handler` or `server`, consider adding the name(s) to Sentry's build options `sentry.entrypointWrappedFunctions` in your `nuxt.config.ts`.",
s1gr1d marked this conversation as resolved.
Show resolved Hide resolved
),
);
}

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].filter(Boolean).join('');
s1gr1d marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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
Loading