Skip to content

Commit 21d30b6

Browse files
committed
feat(nitro-utils): Export Rollup Plugin wrapServerEntryWithDynamicImport
1 parent 738870d commit 21d30b6

19 files changed

+624
-407
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"packages/integration-shims",
6666
"packages/nestjs",
6767
"packages/nextjs",
68+
"packages/nitro-utils",
6869
"packages/node",
6970
"packages/nuxt",
7071
"packages/opentelemetry",

packages/nitro-utils/.eslintrc.js

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module.exports = {
2+
extends: ['../../.eslintrc.js'],
3+
env: {
4+
node: true,
5+
},
6+
overrides: [
7+
{
8+
files: ['src/**'],
9+
rules: {
10+
'@sentry-internal/sdk/no-optional-chaining': 'off',
11+
},
12+
},
13+
{
14+
files: ['src/metrics/**'],
15+
rules: {
16+
'@typescript-eslint/explicit-function-return-type': 'off',
17+
'@typescript-eslint/no-non-null-assertion': 'off',
18+
},
19+
},
20+
],
21+
};

packages/nitro-utils/LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2020-2024 Functional Software, Inc. dba Sentry
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of
6+
this software and associated documentation files (the "Software"), to deal in
7+
the Software without restriction, including without limitation the rights to
8+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
9+
of the Software, and to permit persons to whom the Software is furnished to do
10+
so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

packages/nitro-utils/README.md

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<p align="center">
2+
<a href="https://sentry.io/?utm_source=github&utm_medium=logo" target="_blank">
3+
<img src="https://sentry-brand.storage.googleapis.com/sentry-wordmark-dark-280x84.png" alt="Sentry" width="280" height="84">
4+
</a>
5+
</p>
6+
7+
# Sentry Utilities for Nitro-based SDKs
8+
9+
[![npm version](https://img.shields.io/npm/v/@sentry-internal/nitro-utils.svg)](https://www.npmjs.com/package/@sentry-internal/nitro-utils)
10+
[![npm dm](https://img.shields.io/npm/dm/@sentry-internal/nitro-utils.svg)](https://www.npmjs.com/package/@sentry-internal/nitro-utils)
11+
[![npm dt](https://img.shields.io/npm/dt/@sentry-internal/nitro-utils.svg)](https://www.npmjs.com/package/@sentry-internal/nitro-utils)
12+
13+
## Links
14+
15+
- [Official SDK Docs](https://docs.sentry.io/quickstart/)
16+
- [TypeDoc](http://getsentry.github.io/sentry-node/)
17+
18+
## General
19+
20+
Common utilities used by Sentry SDKs that use Nitro on the server-side.
21+
22+
Note: This package is only meant to be used internally, and as such is not part of our public API contract and does not
23+
follow semver.

packages/nitro-utils/package.json

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"name": "@sentry-internal/nitro-utils",
3+
"version": "8.36.0",
4+
"description": "Utilities for all Sentry SDKs with Nitro on the server-side",
5+
"repository": "git://github.com/getsentry/sentry-javascript.git",
6+
"homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nitro-utils",
7+
"author": "Sentry",
8+
"license": "MIT",
9+
"private": true,
10+
"engines": {
11+
"node": ">=14.18"
12+
},
13+
"files": [
14+
"/build"
15+
],
16+
"main": "build/cjs/index.js",
17+
"module": "build/esm/index.js",
18+
"types": "build/types/index.d.ts",
19+
"exports": {
20+
"./package.json": "./package.json",
21+
".": {
22+
"import": {
23+
"types": "./build/types/index.d.ts",
24+
"default": "./build/esm/index.js"
25+
},
26+
"require": {
27+
"types": "./build/types/index.d.ts",
28+
"default": "./build/cjs/index.js"
29+
}
30+
}
31+
},
32+
"typesVersions": {
33+
"<4.9": {
34+
"build/types/index.d.ts": [
35+
"build/types-ts3.8/index.d.ts"
36+
]
37+
}
38+
},
39+
"publishConfig": {
40+
"access": "public"
41+
},
42+
"dependencies": {
43+
"@sentry/core": "8.36.0",
44+
"@sentry/types": "8.36.0",
45+
"@sentry/utils": "8.36.0"
46+
},
47+
"scripts": {
48+
"build": "run-p build:transpile build:types",
49+
"build:dev": "yarn build",
50+
"build:transpile": "rollup -c rollup.npm.config.mjs",
51+
"build:types": "run-s build:types:core build:types:downlevel",
52+
"build:types:core": "tsc -p tsconfig.types.json",
53+
"build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8",
54+
"build:watch": "run-p build:transpile:watch build:types:watch",
55+
"build:dev:watch": "run-p build:transpile:watch build:types:watch",
56+
"build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch",
57+
"build:types:watch": "tsc -p tsconfig.types.json --watch",
58+
"build:tarball": "npm pack",
59+
"clean": "rimraf build coverage sentry-internal-nitro-utils-*.tgz",
60+
"fix": "eslint . --format stylish --fix",
61+
"lint": "eslint . --format stylish",
62+
"test": "yarn test:unit",
63+
"test:unit": "vitest run",
64+
"test:watch": "vitest --watch",
65+
"yalc:publish": "yalc publish --push --sig"
66+
},
67+
"volta": {
68+
"extends": "../../package.json"
69+
},
70+
"sideEffects": false
71+
}
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils';
2+
3+
export default makeNPMConfigVariants(
4+
makeBaseNPMConfig({
5+
packageSpecificConfig: {
6+
output: {
7+
// set exports to 'named' or 'auto' so that rollup doesn't warn
8+
exports: 'named',
9+
// set preserveModules to true because we don't want to bundle everything into one file.
10+
preserveModules:
11+
process.env.SENTRY_BUILD_PRESERVE_MODULES === undefined
12+
? true
13+
: Boolean(process.env.SENTRY_BUILD_PRESERVE_MODULES),
14+
},
15+
},
16+
}),
17+
);

packages/nitro-utils/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { wrapServerEntryWithDynamicImport } from './rollupPlugins/wrapServerEntryWithDynamicImport';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { consoleSandbox, flatten } from '@sentry/utils';
2+
import type { InputPluginOption } from 'rollup';
3+
4+
export const SENTRY_WRAPPED_ENTRY = '?sentry-query-wrapped-entry';
5+
export const SENTRY_WRAPPED_FUNCTIONS = '?sentry-query-wrapped-functions=';
6+
export const SENTRY_REEXPORTED_FUNCTIONS = '?sentry-query-reexported-functions=';
7+
export const QUERY_END_INDICATOR = 'SENTRY-QUERY-END';
8+
9+
/**
10+
* A Rollup plugin which wraps the server entry with a dynamic `import()`. This makes it possible to initialize Sentry first
11+
* by using a regular `import` and load the server after that.
12+
* This also works with serverless `handler` functions, as it re-exports the `handler`.
13+
*
14+
* @param config Configuration options for the Rollup Plugin
15+
* @param config.serverConfigFileName Name of the Sentry server config (without file extension). E.g. 'sentry.server.config'
16+
* @param config.resolvedServerConfigPath Resolved path of the Sentry server config (based on `src` directory)
17+
* @param config.entryPointWrappedFunctions Exported bindings of the server entry file, which are wrapped as async function. E.g. ['default', 'handler', 'server']
18+
* @param config.additionalImports Adds additional imports to the entry file. Can be e.g. 'import-in-the-middle/hook.mjs'
19+
* @param config.debug Whether debug logs are enabled in the build time environment
20+
*/
21+
export function wrapServerEntryWithDynamicImport(config: {
22+
serverConfigFileName: string;
23+
resolvedServerConfigPath: string;
24+
entrypointWrappedFunctions: string[];
25+
additionalImports?: string[];
26+
debug?: boolean;
27+
}): InputPluginOption {
28+
const { serverConfigFileName, resolvedServerConfigPath, entrypointWrappedFunctions, additionalImports, debug } =
29+
config;
30+
31+
return {
32+
name: 'sentry-wrap-server-entry-with-dynamic-import',
33+
async resolveId(source, importer, options) {
34+
if (source.includes(`/${serverConfigFileName}`)) {
35+
return { id: source, moduleSideEffects: true };
36+
}
37+
38+
if (additionalImports && additionalImports.includes(source)) {
39+
// When importing additional imports like "import-in-the-middle/hook.mjs" in the returned code of the `load()` function below:
40+
// By setting `moduleSideEffects` to `true`, the import is added to the bundle, although nothing is imported from it
41+
// 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()`.
42+
// Prevents the error "Failed to register ESM hook Error: Cannot find module 'import-in-the-middle/hook.mjs'"
43+
return { id: source, moduleSideEffects: true, external: true };
44+
}
45+
46+
if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
47+
const resolution = await this.resolve(source, importer, options);
48+
49+
// If it cannot be resolved or is external, just return it so that Rollup can display an error
50+
if (!resolution || (resolution && resolution.external)) return resolution;
51+
52+
const moduleInfo = await this.load(resolution);
53+
54+
moduleInfo.moduleSideEffects = true;
55+
56+
// 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
57+
return resolution.id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)
58+
? resolution.id
59+
: resolution.id
60+
// 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)
61+
.concat(SENTRY_WRAPPED_ENTRY)
62+
.concat(
63+
constructWrappedFunctionExportQuery(moduleInfo.exportedBindings, entrypointWrappedFunctions, debug),
64+
)
65+
.concat(QUERY_END_INDICATOR);
66+
}
67+
return null;
68+
},
69+
load(id: string) {
70+
if (id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
71+
const entryId = removeSentryQueryFromPath(id);
72+
73+
// Mostly useful for serverless `handler` functions
74+
const reExportedFunctions =
75+
id.includes(SENTRY_WRAPPED_FUNCTIONS) || id.includes(SENTRY_REEXPORTED_FUNCTIONS)
76+
? constructFunctionReExport(id, entryId)
77+
: '';
78+
79+
return (
80+
// Regular `import` of the Sentry config
81+
`import ${JSON.stringify(resolvedServerConfigPath)};\n` +
82+
// Dynamic `import()` for the previous, actual entry point.
83+
// `import()` can be used for any code that should be run after the hooks are registered (https://nodejs.org/api/module.html#enabling)
84+
`import(${JSON.stringify(entryId)});\n` +
85+
// By importing additional imports like "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()`.
86+
`${additionalImports ? additionalImports.map(importPath => `import "${importPath}";\n`) : ''}` +
87+
`${reExportedFunctions}\n`
88+
);
89+
}
90+
91+
return null;
92+
},
93+
};
94+
}
95+
96+
/**
97+
* Strips the Sentry query part from a path.
98+
* Example: example/path?sentry-query-wrapped-entry?sentry-query-functions-reexport=foo,SENTRY-QUERY-END -> /example/path
99+
*
100+
* **Only exported for testing**
101+
*/
102+
export function removeSentryQueryFromPath(url: string): string {
103+
// eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor
104+
const regex = new RegExp(`\\${SENTRY_WRAPPED_ENTRY}.*?\\${QUERY_END_INDICATOR}`);
105+
return url.replace(regex, '');
106+
}
107+
108+
/**
109+
* Extracts and sanitizes function re-export and function wrap query parameters from a query string.
110+
* If it is a default export, it is not considered for re-exporting.
111+
*
112+
* **Only exported for testing**
113+
*/
114+
export function extractFunctionReexportQueryParameters(query: string): { wrap: string[]; reexport: string[] } {
115+
// Regex matches the comma-separated params between the functions query
116+
// eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor
117+
const wrapRegex = new RegExp(
118+
`\\${SENTRY_WRAPPED_FUNCTIONS}(.*?)(\\${QUERY_END_INDICATOR}|\\${SENTRY_REEXPORTED_FUNCTIONS})`,
119+
);
120+
// eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor
121+
const reexportRegex = new RegExp(`\\${SENTRY_REEXPORTED_FUNCTIONS}(.*?)(\\${QUERY_END_INDICATOR})`);
122+
123+
const wrapMatch = query.match(wrapRegex);
124+
const reexportMatch = query.match(reexportRegex);
125+
126+
const wrap =
127+
wrapMatch && wrapMatch[1]
128+
? wrapMatch[1]
129+
.split(',')
130+
.filter(param => param !== '')
131+
// Sanitize, as code could be injected with another rollup plugin
132+
.map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
133+
: [];
134+
135+
const reexport =
136+
reexportMatch && reexportMatch[1]
137+
? reexportMatch[1]
138+
.split(',')
139+
.filter(param => param !== '' && param !== 'default')
140+
// Sanitize, as code could be injected with another rollup plugin
141+
.map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
142+
: [];
143+
144+
return { wrap, reexport };
145+
}
146+
147+
/**
148+
* Constructs a comma-separated string with all functions that need to be re-exported later from the server entry.
149+
* It uses Rollup's `exportedBindings` to determine the functions to re-export. Functions which should be wrapped
150+
* (e.g. serverless handlers) are wrapped by Sentry.
151+
*
152+
* **Only exported for testing**
153+
*/
154+
export function constructWrappedFunctionExportQuery(
155+
exportedBindings: Record<string, string[]> | null,
156+
entrypointWrappedFunctions: string[],
157+
debug?: boolean,
158+
): string {
159+
// `exportedBindings` can look like this: `{ '.': [ 'handler' ] }` or `{ '.': [], './firebase-gen-1.mjs': [ 'server' ] }`
160+
// The key `.` refers to exports within the current file, while other keys show from where exports were imported first.
161+
const functionsToExport = flatten(Object.values(exportedBindings || {})).reduce(
162+
(functions, currFunctionName) => {
163+
if (entrypointWrappedFunctions.includes(currFunctionName)) {
164+
functions.wrap.push(currFunctionName);
165+
} else {
166+
functions.reexport.push(currFunctionName);
167+
}
168+
return functions;
169+
},
170+
{ wrap: [], reexport: [] } as { wrap: string[]; reexport: string[] },
171+
);
172+
173+
if (debug && functionsToExport.wrap.length === 0) {
174+
consoleSandbox(() =>
175+
// eslint-disable-next-line no-console
176+
console.warn(
177+
"[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`.",
178+
),
179+
);
180+
}
181+
182+
const wrapQuery = functionsToExport.wrap.length
183+
? `${SENTRY_WRAPPED_FUNCTIONS}${functionsToExport.wrap.join(',')}`
184+
: '';
185+
const reexportQuery = functionsToExport.reexport.length
186+
? `${SENTRY_REEXPORTED_FUNCTIONS}${functionsToExport.reexport.join(',')}`
187+
: '';
188+
189+
return [wrapQuery, reexportQuery].join('');
190+
}
191+
192+
/**
193+
* Constructs a code snippet with function reexports (can be used in Rollup plugins as a return value for `load()`)
194+
*
195+
* **Only exported for testing**
196+
*/
197+
export function constructFunctionReExport(pathWithQuery: string, entryId: string): string {
198+
const { wrap: wrapFunctions, reexport: reexportFunctions } = extractFunctionReexportQueryParameters(pathWithQuery);
199+
200+
return wrapFunctions
201+
.reduce(
202+
(functionsCode, currFunctionName) =>
203+
functionsCode.concat(
204+
`async function ${currFunctionName}_sentryWrapped(...args) {\n` +
205+
` const res = await import(${JSON.stringify(entryId)});\n` +
206+
` return res.${currFunctionName}.call(this, ...args);\n` +
207+
'}\n' +
208+
`export { ${currFunctionName}_sentryWrapped as ${currFunctionName} };\n`,
209+
),
210+
'',
211+
)
212+
.concat(
213+
reexportFunctions.reduce(
214+
(functionsCode, currFunctionName) =>
215+
functionsCode.concat(`export { ${currFunctionName} } from ${JSON.stringify(entryId)};`),
216+
'',
217+
),
218+
);
219+
}

0 commit comments

Comments
 (0)