Skip to content

Commit 8cd67c1

Browse files
s1gr1dbillyvg
authored andcommitted
feat(nuxt): Add Rollup plugin to wrap server entry with import() (#13945)
Feature Issue: #13943 Adds a Rollup plugin to wrap the server entry with `import()` to load it after Sentry was initialized. The plugin is not yet in use (will do this in another PR - see linked issue above)
1 parent bdbaf6b commit 8cd67c1

File tree

3 files changed

+238
-5
lines changed

3 files changed

+238
-5
lines changed

packages/nuxt/src/vite/addServerConfig.ts

+113-4
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,17 @@ import { createResolver } from '@nuxt/kit';
33
import type { Nuxt } from '@nuxt/schema';
44
import { consoleSandbox } from '@sentry/utils';
55
import type { Nitro } from 'nitropack';
6+
import type { InputPluginOption } from 'rollup';
67
import type { SentryNuxtModuleOptions } from '../common/types';
8+
import {
9+
QUERY_END_INDICATOR,
10+
SENTRY_FUNCTIONS_REEXPORT,
11+
SENTRY_WRAPPED_ENTRY,
12+
constructFunctionReExport,
13+
removeSentryQueryFromPath,
14+
} from './utils';
15+
16+
const SERVER_CONFIG_FILENAME = 'sentry.server.config';
717

818
/**
919
* 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(
2333
'server' in viteInlineConfig.build.rollupOptions.input
2434
) {
2535
// Create a rollup entry for the server config to add it as `sentry.server.config.mjs` to the build
26-
(viteInlineConfig.build.rollupOptions.input as { [entryName: string]: string })['sentry.server.config'] =
36+
(viteInlineConfig.build.rollupOptions.input as { [entryName: string]: string })[SERVER_CONFIG_FILENAME] =
2737
createResolver(nuxt.options.srcDir).resolve(`/${serverConfigFile}`);
2838
}
2939

@@ -34,8 +44,8 @@ export function addServerConfigToBuild(
3444
nitro.hooks.hook('close', async () => {
3545
const buildDirResolver = createResolver(nitro.options.buildDir);
3646
const serverDirResolver = createResolver(nitro.options.output.serverDir);
37-
const source = buildDirResolver.resolve('dist/server/sentry.server.config.mjs');
38-
const destination = serverDirResolver.resolve('sentry.server.config.mjs');
47+
const source = buildDirResolver.resolve(`dist/server/${SERVER_CONFIG_FILENAME}.mjs`);
48+
const destination = serverDirResolver.resolve(`${SERVER_CONFIG_FILENAME}.mjs`);
3949

4050
try {
4151
await fs.promises.access(source, fs.constants.F_OK);
@@ -85,7 +95,7 @@ export function addSentryTopImport(moduleOptions: SentryNuxtModuleOptions, nitro
8595

8696
try {
8797
fs.readFile(entryFilePath, 'utf8', (err, data) => {
88-
const updatedContent = `import './sentry.server.config.mjs';\n${data}`;
98+
const updatedContent = `import './${SERVER_CONFIG_FILENAME}.mjs';\n${data}`;
8999

90100
fs.writeFile(entryFilePath, updatedContent, 'utf8', () => {
91101
if (moduleOptions.debug) {
@@ -111,3 +121,102 @@ export function addSentryTopImport(moduleOptions: SentryNuxtModuleOptions, nitro
111121
}
112122
});
113123
}
124+
125+
/**
126+
* This function modifies the Rollup configuration to include a plugin that wraps the entry file with a dynamic import (`import()`)
127+
* and adds the Sentry server config with the static `import` declaration.
128+
*
129+
* With this, the Sentry server config can be loaded before all other modules of the application (which is needed for import-in-the-middle).
130+
* See: https://nodejs.org/api/module.html#enabling
131+
*/
132+
export function addDynamicImportEntryFileWrapper(nitro: Nitro, serverConfigFile: string): void {
133+
if (!nitro.options.rollupConfig) {
134+
nitro.options.rollupConfig = { output: {} };
135+
}
136+
137+
if (nitro.options.rollupConfig?.plugins === null || nitro.options.rollupConfig?.plugins === undefined) {
138+
nitro.options.rollupConfig.plugins = [];
139+
} else if (!Array.isArray(nitro.options.rollupConfig.plugins)) {
140+
// `rollupConfig.plugins` can be a single plugin, so we want to put it into an array so that we can push our own plugin
141+
nitro.options.rollupConfig.plugins = [nitro.options.rollupConfig.plugins];
142+
}
143+
144+
nitro.options.rollupConfig.plugins.push(
145+
// @ts-expect-error - This is the correct type, but it shows an error because of two different definitions
146+
wrapEntryWithDynamicImport(createResolver(nitro.options.srcDir).resolve(`/${serverConfigFile}`)),
147+
);
148+
}
149+
150+
/**
151+
* A Rollup plugin which wraps the server entry with a dynamic `import()`. This makes it possible to initialize Sentry first
152+
* by using a regular `import` and load the server after that.
153+
* This also works with serverless `handler` functions, as it re-exports the `handler`.
154+
*/
155+
function wrapEntryWithDynamicImport(resolvedSentryConfigPath: string): InputPluginOption {
156+
return {
157+
name: 'sentry-wrap-entry-with-dynamic-import',
158+
async resolveId(source, importer, options) {
159+
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {
160+
return { id: source, moduleSideEffects: true };
161+
}
162+
163+
if (source === 'import-in-the-middle/hook.mjs') {
164+
// We are importing "import-in-the-middle" in the returned code of the `load()` function below
165+
// By setting `moduleSideEffects` to `true`, the import is added to the bundle, although nothing is imported from it
166+
// 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()`.
167+
// Prevents the error "Failed to register ESM hook Error: Cannot find module 'import-in-the-middle/hook.mjs'"
168+
return { id: source, moduleSideEffects: true, external: true };
169+
}
170+
171+
if (options.isEntry && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
172+
const resolution = await this.resolve(source, importer, options);
173+
174+
// If it cannot be resolved or is external, just return it so that Rollup can display an error
175+
if (!resolution || resolution?.external) return resolution;
176+
177+
const moduleInfo = await this.load(resolution);
178+
179+
moduleInfo.moduleSideEffects = true;
180+
181+
// The key `.` in `exportedBindings` refer to the exports within the file
182+
const exportedFunctions = moduleInfo.exportedBindings?.['.'];
183+
184+
// 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
185+
return resolution.id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)
186+
? resolution.id
187+
: resolution.id
188+
// 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)
189+
.concat(SENTRY_WRAPPED_ENTRY)
190+
.concat(
191+
exportedFunctions?.length
192+
? SENTRY_FUNCTIONS_REEXPORT.concat(exportedFunctions.join(',')).concat(QUERY_END_INDICATOR)
193+
: '',
194+
);
195+
}
196+
return null;
197+
},
198+
load(id: string) {
199+
if (id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
200+
const entryId = removeSentryQueryFromPath(id);
201+
202+
// Mostly useful for serverless `handler` functions
203+
const reExportedFunctions = id.includes(SENTRY_FUNCTIONS_REEXPORT)
204+
? constructFunctionReExport(id, entryId)
205+
: '';
206+
207+
return (
208+
// Regular `import` of the Sentry config
209+
`import ${JSON.stringify(resolvedSentryConfigPath)};\n` +
210+
// Dynamic `import()` for the previous, actual entry point.
211+
// `import()` can be used for any code that should be run after the hooks are registered (https://nodejs.org/api/module.html#enabling)
212+
`import(${JSON.stringify(entryId)});\n` +
213+
// 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()`.
214+
"import 'import-in-the-middle/hook.mjs'\n" +
215+
`${reExportedFunctions}\n`
216+
);
217+
}
218+
219+
return null;
220+
},
221+
};
222+
}

packages/nuxt/src/vite/utils.ts

+56
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,59 @@ export function findDefaultSdkInitFile(type: 'server' | 'client'): string | unde
2424

2525
return filePaths.find(filename => fs.existsSync(filename));
2626
}
27+
28+
export const SENTRY_WRAPPED_ENTRY = '?sentry-query-wrapped-entry';
29+
export const SENTRY_FUNCTIONS_REEXPORT = '?sentry-query-functions-reexport=';
30+
export const QUERY_END_INDICATOR = 'SENTRY-QUERY-END';
31+
32+
/**
33+
* Strips the Sentry query part from a path.
34+
* Example: example/path?sentry-query-wrapped-entry?sentry-query-functions-reexport=foo,SENTRY-QUERY-END -> /example/path
35+
*
36+
* Only exported for testing.
37+
*/
38+
export function removeSentryQueryFromPath(url: string): string {
39+
// eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor
40+
const regex = new RegExp(`\\${SENTRY_WRAPPED_ENTRY}.*?\\${QUERY_END_INDICATOR}`);
41+
return url.replace(regex, '');
42+
}
43+
44+
/**
45+
* Extracts and sanitizes function re-export query parameters from a query string.
46+
* If it is a default export, it is not considered for re-exporting. This function is mostly relevant for re-exporting
47+
* serverless `handler` functions.
48+
*
49+
* Only exported for testing.
50+
*/
51+
export function extractFunctionReexportQueryParameters(query: string): string[] {
52+
// Regex matches the comma-separated params between the functions query
53+
// eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor
54+
const regex = new RegExp(`\\${SENTRY_FUNCTIONS_REEXPORT}(.*?)\\${QUERY_END_INDICATOR}`);
55+
const match = query.match(regex);
56+
57+
return match && match[1]
58+
? match[1]
59+
.split(',')
60+
.filter(param => param !== '' && param !== 'default')
61+
// Sanitize, as code could be injected with another rollup plugin
62+
.map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
63+
: [];
64+
}
65+
66+
/**
67+
* Constructs a code snippet with function reexports (can be used in Rollup plugins)
68+
*/
69+
export function constructFunctionReExport(pathWithQuery: string, entryId: string): string {
70+
const functionNames = extractFunctionReexportQueryParameters(pathWithQuery);
71+
72+
return functionNames.reduce(
73+
(functionsCode, currFunctionName) =>
74+
functionsCode.concat(
75+
`export async function ${currFunctionName}(...args) {\n` +
76+
` const res = await import(${JSON.stringify(entryId)});\n` +
77+
` return res.${currFunctionName}.call(this, ...args);\n` +
78+
'}\n',
79+
),
80+
'',
81+
);
82+
}

packages/nuxt/test/vite/utils.test.ts

+69-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import * as fs from 'fs';
22
import { afterEach, describe, expect, it, vi } from 'vitest';
3-
import { findDefaultSdkInitFile } from '../../src/vite/utils';
3+
import {
4+
QUERY_END_INDICATOR,
5+
SENTRY_FUNCTIONS_REEXPORT,
6+
SENTRY_WRAPPED_ENTRY,
7+
constructFunctionReExport,
8+
extractFunctionReexportQueryParameters,
9+
findDefaultSdkInitFile,
10+
removeSentryQueryFromPath,
11+
} from '../../src/vite/utils';
412

513
vi.mock('fs');
614

@@ -59,3 +67,63 @@ describe('findDefaultSdkInitFile', () => {
5967
expect(result).toMatch('packages/nuxt/sentry.server.config.js');
6068
});
6169
});
70+
71+
describe('removeSentryQueryFromPath', () => {
72+
it('strips the Sentry query part from the path', () => {
73+
const url = `/example/path${SENTRY_WRAPPED_ENTRY}${SENTRY_FUNCTIONS_REEXPORT}foo,${QUERY_END_INDICATOR}`;
74+
const result = removeSentryQueryFromPath(url);
75+
expect(result).toBe('/example/path');
76+
});
77+
78+
it('returns the same path if the specific query part is not present', () => {
79+
const url = '/example/path?other-query=param';
80+
const result = removeSentryQueryFromPath(url);
81+
expect(result).toBe(url);
82+
});
83+
});
84+
85+
describe('extractFunctionReexportQueryParameters', () => {
86+
it.each([
87+
[`${SENTRY_FUNCTIONS_REEXPORT}foo,bar,${QUERY_END_INDICATOR}`, ['foo', 'bar']],
88+
[`${SENTRY_FUNCTIONS_REEXPORT}foo,bar,default${QUERY_END_INDICATOR}`, ['foo', 'bar']],
89+
[
90+
`${SENTRY_FUNCTIONS_REEXPORT}foo,a.b*c?d[e]f(g)h|i\\\\j(){hello},${QUERY_END_INDICATOR}`,
91+
['foo', 'a\\.b\\*c\\?d\\[e\\]f\\(g\\)h\\|i\\\\\\\\j\\(\\)\\{hello\\}'],
92+
],
93+
[`/example/path/${SENTRY_FUNCTIONS_REEXPORT}foo,bar${QUERY_END_INDICATOR}`, ['foo', 'bar']],
94+
[`${SENTRY_FUNCTIONS_REEXPORT}${QUERY_END_INDICATOR}`, []],
95+
['?other-query=param', []],
96+
])('extracts parameters from the query string: %s', (query, expected) => {
97+
const result = extractFunctionReexportQueryParameters(query);
98+
expect(result).toEqual(expected);
99+
});
100+
});
101+
102+
describe('constructFunctionReExport', () => {
103+
it('constructs re-export code for given query parameters and entry ID', () => {
104+
const query = `${SENTRY_FUNCTIONS_REEXPORT}foo,bar,${QUERY_END_INDICATOR}}`;
105+
const query2 = `${SENTRY_FUNCTIONS_REEXPORT}foo,bar${QUERY_END_INDICATOR}}`;
106+
const entryId = './module';
107+
const result = constructFunctionReExport(query, entryId);
108+
const result2 = constructFunctionReExport(query2, entryId);
109+
110+
const expected = `
111+
export async function foo(...args) {
112+
const res = await import("./module");
113+
return res.foo.call(this, ...args);
114+
}
115+
export async function bar(...args) {
116+
const res = await import("./module");
117+
return res.bar.call(this, ...args);
118+
}`;
119+
expect(result.trim()).toBe(expected.trim());
120+
expect(result2.trim()).toBe(expected.trim());
121+
});
122+
123+
it('returns an empty string if the query string is empty', () => {
124+
const query = '';
125+
const entryId = './module';
126+
const result = constructFunctionReExport(query, entryId);
127+
expect(result).toBe('');
128+
});
129+
});

0 commit comments

Comments
 (0)