Skip to content

Commit d993426

Browse files
committed
feat(nitro-utils): Export Rollup Plugin wrapServerEntryWithDynamicImport
1 parent 7011146 commit d993426

File tree

7 files changed

+419
-26
lines changed

7 files changed

+419
-26
lines changed

packages/nuxt/build.config.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.

packages/nuxt/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@
5353
},
5454
"devDependencies": {
5555
"@nuxt/module-builder": "^0.8.4",
56-
"@sentry-internal/nitro-utils": "8.42.0",
5756
"nuxt": "^3.13.2"
5857
},
5958
"scripts": {

packages/nuxt/src/common/types.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,6 @@ export type SentryNuxtModuleOptions = {
130130
*/
131131
entrypointWrappedFunctions?: string[];
132132

133-
/**
134-
* By default—unless you configure `dynamicImportForServerEntry: false`—the SDK will try to wrap your Nitro server entrypoint
135-
* with a dynamic `import()` to ensure all dependencies can be properly instrumented.
136-
*
137-
* The server entrypoint filename is automatically set by the Sentry SDK depending on the Nitro present.
138-
* In case the server entrypoint has a different filename, you can overwrite it here.
139-
*/
140-
serverEntrypointFileName?: string;
141-
142133
/**
143134
* 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.
144135
* You can use this option to override any options the SDK passes to the Vite (for Nuxt) and Rollup (for Nitro) plugin.

packages/nuxt/src/vite/addServerConfig.ts

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import * as fs from 'fs';
22
import { createResolver } from '@nuxt/kit';
33
import type { Nuxt } from '@nuxt/schema';
4-
import { wrapServerEntryWithDynamicImport } from '@sentry-internal/nitro-utils';
54
import { consoleSandbox } from '@sentry/core';
65
import type { Nitro } from 'nitropack';
6+
import type { InputPluginOption } from 'rollup';
77
import type { SentryNuxtModuleOptions } from '../common/types';
8+
import {
9+
QUERY_END_INDICATOR,
10+
SENTRY_REEXPORTED_FUNCTIONS,
11+
SENTRY_WRAPPED_ENTRY,
12+
SENTRY_WRAPPED_FUNCTIONS,
13+
constructFunctionReExport,
14+
constructWrappedFunctionExportQuery,
15+
removeSentryQueryFromPath,
16+
} from './utils';
817

918
const SERVER_CONFIG_FILENAME = 'sentry.server.config';
1019

@@ -92,13 +101,91 @@ export function addDynamicImportEntryFileWrapper(
92101
}
93102

94103
nitro.options.rollupConfig.plugins.push(
95-
wrapServerEntryWithDynamicImport({
96-
serverEntrypointFileName: moduleOptions.serverEntrypointFileName || nitro.options.preset,
97-
serverConfigFileName: SERVER_CONFIG_FILENAME,
98-
resolvedServerConfigPath: createResolver(nitro.options.srcDir).resolve(`/${serverConfigFile}`),
104+
wrapEntryWithDynamicImport({
105+
resolvedSentryConfigPath: createResolver(nitro.options.srcDir).resolve(`/${serverConfigFile}`),
99106
entrypointWrappedFunctions: moduleOptions.entrypointWrappedFunctions,
100-
additionalImports: ['import-in-the-middle/hook.mjs'],
101-
debug: moduleOptions.debug,
102107
}),
103108
);
104109
}
110+
111+
112+
/**
113+
* A Rollup plugin which wraps the server entry with a dynamic `import()`. This makes it possible to initialize Sentry first
114+
* by using a regular `import` and load the server after that.
115+
* This also works with serverless `handler` functions, as it re-exports the `handler`.
116+
*/
117+
function wrapEntryWithDynamicImport({
118+
resolvedSentryConfigPath,
119+
entrypointWrappedFunctions,
120+
debug,
121+
}: { resolvedSentryConfigPath: string; entrypointWrappedFunctions: string[]; debug?: boolean }): InputPluginOption {
122+
// In order to correctly import the server config file
123+
// and dynamically import the nitro runtime, we need to
124+
// mark the resolutionId with '\0raw' to fall into the
125+
// raw chunk group, c.f. https://github.com/nitrojs/nitro/commit/8b4a408231bdc222569a32ce109796a41eac4aa6#diff-e58102d2230f95ddeef2662957b48d847a6e891e354cfd0ae6e2e03ce848d1a2R142
126+
const resolutionIdPrefix = '\0raw';
127+
128+
return {
129+
name: 'sentry-wrap-entry-with-dynamic-import',
130+
async resolveId(source, importer, options) {
131+
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {
132+
return { id: source, moduleSideEffects: true };
133+
}
134+
135+
if (source === 'import-in-the-middle/hook.mjs') {
136+
// We are importing "import-in-the-middle" in the returned code of the `load()` function below
137+
// By setting `moduleSideEffects` to `true`, the import is added to the bundle, although nothing is imported from it
138+
// 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()`.
139+
// Prevents the error "Failed to register ESM hook Error: Cannot find module 'import-in-the-middle/hook.mjs'"
140+
return { id: source, moduleSideEffects: true, external: true };
141+
}
142+
143+
if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
144+
const resolution = await this.resolve(source, importer, options);
145+
146+
// If it cannot be resolved or is external, just return it so that Rollup can display an error
147+
if (!resolution || resolution?.external) return resolution;
148+
149+
const moduleInfo = await this.load(resolution);
150+
151+
moduleInfo.moduleSideEffects = true;
152+
153+
// 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
154+
return resolution.id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)
155+
? resolution.id
156+
: `${resolutionIdPrefix}${resolution.id
157+
// 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)
158+
.concat(SENTRY_WRAPPED_ENTRY)
159+
.concat(
160+
constructWrappedFunctionExportQuery(moduleInfo.exportedBindings, entrypointWrappedFunctions, debug),
161+
)
162+
.concat(QUERY_END_INDICATOR)}`;
163+
}
164+
return null;
165+
},
166+
load(id: string) {
167+
if (id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
168+
const entryId = removeSentryQueryFromPath(id).slice(resolutionIdPrefix.length);
169+
170+
// Mostly useful for serverless `handler` functions
171+
const reExportedFunctions =
172+
id.includes(SENTRY_WRAPPED_FUNCTIONS) || id.includes(SENTRY_REEXPORTED_FUNCTIONS)
173+
? constructFunctionReExport(id, entryId)
174+
: '';
175+
176+
return (
177+
// Regular `import` of the Sentry config
178+
`import ${JSON.stringify(resolvedSentryConfigPath)};\n` +
179+
// Dynamic `import()` for the previous, actual entry point.
180+
// `import()` can be used for any code that should be run after the hooks are registered (https://nodejs.org/api/module.html#enabling)
181+
`import(${JSON.stringify(entryId)});\n` +
182+
// 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()`.
183+
"import 'import-in-the-middle/hook.mjs';\n" +
184+
`${reExportedFunctions}\n`
185+
);
186+
}
187+
188+
return null;
189+
},
190+
};
191+
}

packages/nuxt/src/vite/utils.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,133 @@ export function findDefaultSdkInitFile(type: 'server' | 'client'): string | unde
2424

2525
return filePaths.find(filename => fs.existsSync(filename));
2626
}
27+
28+
29+
export const SENTRY_WRAPPED_ENTRY = '?sentry-query-wrapped-entry';
30+
export const SENTRY_WRAPPED_FUNCTIONS = '?sentry-query-wrapped-functions=';
31+
export const SENTRY_REEXPORTED_FUNCTIONS = '?sentry-query-reexported-functions=';
32+
export const QUERY_END_INDICATOR = 'SENTRY-QUERY-END';
33+
34+
/**
35+
* Strips the Sentry query part from a path.
36+
* Example: example/path?sentry-query-wrapped-entry?sentry-query-functions-reexport=foo,SENTRY-QUERY-END -> /example/path
37+
*
38+
* Only exported for testing.
39+
*/
40+
export function removeSentryQueryFromPath(url: string): string {
41+
// eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor
42+
const regex = new RegExp(`\\${SENTRY_WRAPPED_ENTRY}.*?\\${QUERY_END_INDICATOR}`);
43+
return url.replace(regex, '');
44+
}
45+
46+
/**
47+
* Extracts and sanitizes function re-export and function wrap query parameters from a query string.
48+
* If it is a default export, it is not considered for re-exporting.
49+
*
50+
* Only exported for testing.
51+
*/
52+
export function extractFunctionReexportQueryParameters(query: string): { wrap: string[]; reexport: string[] } {
53+
// Regex matches the comma-separated params between the functions query
54+
// eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor
55+
const wrapRegex = new RegExp(
56+
`\\${SENTRY_WRAPPED_FUNCTIONS}(.*?)(\\${QUERY_END_INDICATOR}|\\${SENTRY_REEXPORTED_FUNCTIONS})`,
57+
);
58+
// eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor
59+
const reexportRegex = new RegExp(`\\${SENTRY_REEXPORTED_FUNCTIONS}(.*?)(\\${QUERY_END_INDICATOR})`);
60+
61+
const wrapMatch = query.match(wrapRegex);
62+
const reexportMatch = query.match(reexportRegex);
63+
64+
const wrap =
65+
wrapMatch && wrapMatch[1]
66+
? wrapMatch[1]
67+
.split(',')
68+
.filter(param => param !== '')
69+
// Sanitize, as code could be injected with another rollup plugin
70+
.map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
71+
: [];
72+
73+
const reexport =
74+
reexportMatch && reexportMatch[1]
75+
? reexportMatch[1]
76+
.split(',')
77+
.filter(param => param !== '' && param !== 'default')
78+
// Sanitize, as code could be injected with another rollup plugin
79+
.map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
80+
: [];
81+
82+
return { wrap, reexport };
83+
}
84+
85+
/**
86+
* Constructs a comma-separated string with all functions that need to be re-exported later from the server entry.
87+
* It uses Rollup's `exportedBindings` to determine the functions to re-export. Functions which should be wrapped
88+
* (e.g. serverless handlers) are wrapped by Sentry.
89+
*/
90+
export function constructWrappedFunctionExportQuery(
91+
exportedBindings: Record<string, string[]> | null,
92+
entrypointWrappedFunctions: string[],
93+
debug?: boolean,
94+
): string {
95+
const functionsToExport: { wrap: string[]; reexport: string[] } = {
96+
wrap: [],
97+
reexport: [],
98+
};
99+
100+
// `exportedBindings` can look like this: `{ '.': [ 'handler' ] }` or `{ '.': [], './firebase-gen-1.mjs': [ 'server' ] }`
101+
// The key `.` refers to exports within the current file, while other keys show from where exports were imported first.
102+
Object.values(exportedBindings || {}).forEach(functions =>
103+
functions.forEach(fn => {
104+
if (entrypointWrappedFunctions.includes(fn)) {
105+
functionsToExport.wrap.push(fn);
106+
} else {
107+
functionsToExport.reexport.push(fn);
108+
}
109+
}),
110+
);
111+
112+
if (debug && functionsToExport.wrap.length === 0) {
113+
consoleSandbox(() =>
114+
// eslint-disable-next-line no-console
115+
console.warn(
116+
"[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`.",
117+
),
118+
);
119+
}
120+
121+
const wrapQuery = functionsToExport.wrap.length
122+
? `${SENTRY_WRAPPED_FUNCTIONS}${functionsToExport.wrap.join(',')}`
123+
: '';
124+
const reexportQuery = functionsToExport.reexport.length
125+
? `${SENTRY_REEXPORTED_FUNCTIONS}${functionsToExport.reexport.join(',')}`
126+
: '';
127+
128+
return [wrapQuery, reexportQuery].join('');
129+
}
130+
131+
/**
132+
* Constructs a code snippet with function reexports (can be used in Rollup plugins as a return value for `load()`)
133+
*/
134+
export function constructFunctionReExport(pathWithQuery: string, entryId: string): string {
135+
const { wrap: wrapFunctions, reexport: reexportFunctions } = extractFunctionReexportQueryParameters(pathWithQuery);
136+
137+
return wrapFunctions
138+
.reduce(
139+
(functionsCode, currFunctionName) =>
140+
functionsCode.concat(
141+
`async function ${currFunctionName}_sentryWrapped(...args) {\n` +
142+
` const res = await import(${JSON.stringify(entryId)});\n` +
143+
` return res.${currFunctionName}.call(this, ...args);\n` +
144+
'}\n' +
145+
`export { ${currFunctionName}_sentryWrapped as ${currFunctionName} };\n`,
146+
),
147+
'',
148+
)
149+
.concat(
150+
reexportFunctions.reduce(
151+
(functionsCode, currFunctionName) =>
152+
functionsCode.concat(`export { ${currFunctionName} } from ${JSON.stringify(entryId)};`),
153+
'',
154+
),
155+
);
156+
}

0 commit comments

Comments
 (0)