Skip to content

Commit 6616ad1

Browse files
committed
feat(solidstart): Add autoInjectServerSentry: 'experimental_dynamic-import'
1 parent b402fe1 commit 6616ad1

File tree

6 files changed

+388
-9
lines changed

6 files changed

+388
-9
lines changed

packages/solidstart/src/config/addInstrumentation.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import * as fs from 'fs';
22
import * as path from 'path';
33
import { consoleSandbox } from '@sentry/core';
44
import type { Nitro } from 'nitropack';
5+
import type { SentrySolidStartPluginOptions } from '../vite/types';
6+
import type { RollupConfig } from './types';
7+
import { wrapServerEntryWithDynamicImport } from './wrapServerEntryWithDynamicImport';
58

69
// Nitro presets for hosts that only host static files
710
export const staticHostPresets = ['github_pages'];
@@ -134,3 +137,47 @@ export async function addSentryTopImport(nitro: Nitro): Promise<void> {
134137
}
135138
});
136139
}
140+
141+
/**
142+
* This function modifies the Rollup configuration to include a plugin that wraps the entry file with a dynamic import (`import()`)
143+
* and adds the Sentry server config with the static `import` declaration.
144+
*
145+
* With this, the Sentry server config can be loaded before all other modules of the application (which is needed for import-in-the-middle).
146+
* See: https://nodejs.org/api/module.html#enabling
147+
*/
148+
export async function addDynamicImportEntryFileWrapper({
149+
nitro,
150+
rollupConfig,
151+
sentryPluginOptions,
152+
}: {
153+
nitro: Nitro;
154+
rollupConfig: RollupConfig;
155+
sentryPluginOptions: Omit<SentrySolidStartPluginOptions, 'experimental_entrypointWrappedFunctions'> &
156+
Required<Pick<SentrySolidStartPluginOptions, 'experimental_entrypointWrappedFunctions'>>;
157+
}): Promise<void> {
158+
// Static file hosts have no server component so there's nothing to do
159+
if (staticHostPresets.includes(nitro.options.preset)) {
160+
return;
161+
}
162+
163+
const srcDir = nitro.options.srcDir;
164+
// todo allow other instrumentation paths
165+
const serverInstrumentationPath = path.resolve(srcDir, 'src', 'instrument.server.ts');
166+
167+
const instrumentationFileName = sentryPluginOptions.instrumentation
168+
? path.basename(sentryPluginOptions.instrumentation)
169+
: '';
170+
171+
rollupConfig.plugins.push(
172+
wrapServerEntryWithDynamicImport({
173+
serverConfigFileName: sentryPluginOptions.instrumentation
174+
? path.join(path.dirname(instrumentationFileName), path.parse(instrumentationFileName).name)
175+
: 'instrument.server',
176+
serverEntrypointFileName: sentryPluginOptions.serverEntrypointFileName || nitro.options.preset,
177+
resolvedServerConfigPath: serverInstrumentationPath,
178+
entrypointWrappedFunctions: sentryPluginOptions.experimental_entrypointWrappedFunctions,
179+
additionalImports: ['import-in-the-middle/hook.mjs'],
180+
debug: sentryPluginOptions.debug,
181+
}),
182+
);
183+
}

packages/solidstart/src/config/withSentry.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
1+
import { logger } from '@sentry/core';
12
import type { Nitro } from 'nitropack';
23
import { addSentryPluginToVite } from '../vite';
34
import type { SentrySolidStartPluginOptions } from '../vite/types';
4-
import { addInstrumentationFileToBuild, addSentryTopImport } from './addInstrumentation';
5-
import type { SolidStartInlineConfig, SolidStartInlineServerConfig } from './types';
5+
import {
6+
addDynamicImportEntryFileWrapper,
7+
addInstrumentationFileToBuild,
8+
addSentryTopImport,
9+
} from './addInstrumentation';
10+
import type { RollupConfig, SolidStartInlineConfig, SolidStartInlineServerConfig } from './types';
11+
12+
const defaultSentrySolidStartPluginOptions: Omit<
13+
SentrySolidStartPluginOptions,
14+
'experimental_entrypointWrappedFunctions'
15+
> &
16+
Required<Pick<SentrySolidStartPluginOptions, 'experimental_entrypointWrappedFunctions'>> = {
17+
experimental_entrypointWrappedFunctions: ['default', 'handler', 'server'],
18+
};
619

720
/**
821
* Modifies the passed in Solid Start configuration with build-time enhancements such as
@@ -19,6 +32,7 @@ export function withSentry(
1932
): SolidStartInlineConfig {
2033
const sentryPluginOptions = {
2134
...sentrySolidStartPluginOptions,
35+
...defaultSentrySolidStartPluginOptions,
2236
};
2337

2438
const server = (solidStartConfig.server || {}) as SolidStartInlineServerConfig;
@@ -35,11 +49,20 @@ export function withSentry(
3549
...server,
3650
hooks: {
3751
...hooks,
38-
async 'rollup:before'(nitro: Nitro) {
39-
await addInstrumentationFileToBuild(nitro);
52+
async 'rollup:before'(nitro: Nitro, config: RollupConfig) {
53+
if (sentrySolidStartPluginOptions?.autoInjectServerSentry === 'experimental_dynamic-import') {
54+
await addDynamicImportEntryFileWrapper({ nitro, rollupConfig: config, sentryPluginOptions });
55+
56+
sentrySolidStartPluginOptions.debug &&
57+
logger.log(
58+
'Wrapping the server entry file with a dynamic `import()`, so Sentry can be preloaded before the server initializes.',
59+
);
60+
} else {
61+
await addInstrumentationFileToBuild(nitro);
4062

41-
if (sentrySolidStartPluginOptions?.autoInjectServerSentry === 'top-level-import') {
42-
await addSentryTopImport(nitro);
63+
if (sentrySolidStartPluginOptions?.autoInjectServerSentry === 'top-level-import') {
64+
await addSentryTopImport(nitro);
65+
}
4366
}
4467

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

packages/solidstart/src/vite/sentrySolidStartVite.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {}
2121
// Because the file is just copied over to the output server
2222
// directory the release injection file from sentry vite plugin
2323
// wouldn't resolve correctly otherwise.
24-
sentryPlugins.push(makeBuildInstrumentationFilePlugin(options));
24+
if (options.autoInjectServerSentry !== 'experimental_dynamic-import') {
25+
sentryPlugins.push(makeBuildInstrumentationFilePlugin(options));
26+
}
2527

2628
return sentryPlugins;
2729
};

0 commit comments

Comments
 (0)