Skip to content

Commit

Permalink
feat(nuxt): Add Rollup plugin to wrap server entry with import()
Browse files Browse the repository at this point in the history
  • Loading branch information
s1gr1d committed Oct 10, 2024
1 parent 86c626e commit d15f512
Show file tree
Hide file tree
Showing 3 changed files with 224 additions and 5 deletions.
109 changes: 105 additions & 4 deletions packages/nuxt/src/vite/addServerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}`);
}

Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
},
};
}
52 changes: 52 additions & 0 deletions packages/nuxt/src/vite/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
),
'',
);
}
68 changes: 67 additions & 1 deletion packages/nuxt/test/vite/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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');

Expand Down Expand Up @@ -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('');
});
});

0 comments on commit d15f512

Please sign in to comment.