-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
/
Copy pathwrappingLoader.ts
258 lines (229 loc) · 11.9 KB
/
wrappingLoader.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
import commonjs from '@rollup/plugin-commonjs';
import { stringMatchesSomePattern } from '@sentry/utils';
import * as fs from 'fs';
import * as path from 'path';
import { rollup } from 'rollup';
import type { LoaderThis } from './types';
const apiWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'apiWrapperTemplate.js');
const apiWrapperTemplateCode = fs.readFileSync(apiWrapperTemplatePath, { encoding: 'utf8' });
const pageWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'pageWrapperTemplate.js');
const pageWrapperTemplateCode = fs.readFileSync(pageWrapperTemplatePath, { encoding: 'utf8' });
const middlewareWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'middlewareWrapperTemplate.js');
const middlewareWrapperTemplateCode = fs.readFileSync(middlewareWrapperTemplatePath, { encoding: 'utf8' });
const serverComponentWrapperTemplatePath = path.resolve(
__dirname,
'..',
'templates',
'serverComponentWrapperTemplate.js',
);
const serverComponentWrapperTemplateCode = fs.readFileSync(serverComponentWrapperTemplatePath, { encoding: 'utf8' });
// Just a simple placeholder to make referencing module consistent
const SENTRY_WRAPPER_MODULE_NAME = 'sentry-wrapper-module';
// Needs to end in .cjs in order for the `commonjs` plugin to pick it up
const WRAPPING_TARGET_MODULE_NAME = '__SENTRY_WRAPPING_TARGET_FILE__.cjs';
type LoaderOptions = {
pagesDir: string;
appDir: string;
pageExtensionRegex: string;
excludeServerRoutes: Array<RegExp | string>;
wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'page-server-component';
};
/**
* Replace the loaded file with a wrapped version the original file. In the wrapped version, the original file is loaded,
* any data-fetching functions (`getInitialProps`, `getStaticProps`, and `getServerSideProps`) or API routes it contains
* are wrapped, and then everything is re-exported.
*/
export default function wrappingLoader(
this: LoaderThis<LoaderOptions>,
userCode: string,
userModuleSourceMap: any,
): void {
// We know one or the other will be defined, depending on the version of webpack being used
const {
pagesDir,
appDir,
pageExtensionRegex,
excludeServerRoutes = [],
wrappingTargetKind,
} = 'getOptions' in this ? this.getOptions() : this.query;
this.async();
let templateCode: string;
if (wrappingTargetKind === 'page' || wrappingTargetKind === 'api-route') {
// Get the parameterized route name from this page's filepath
const parameterizedPagesRoute = path.posix
.normalize(
path
// Get the path of the file insde of the pages directory
.relative(pagesDir, this.resourcePath),
)
// Add a slash at the beginning
.replace(/(.*)/, '/$1')
// Pull off the file extension
.replace(new RegExp(`\\.(${pageExtensionRegex})`), '')
// Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into
// just `/xyz`
.replace(/\/index$/, '')
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
// homepage), sub back in the root route
.replace(/^$/, '/');
// Skip explicitly-ignored pages
if (stringMatchesSomePattern(parameterizedPagesRoute, excludeServerRoutes, true)) {
this.callback(null, userCode, userModuleSourceMap);
return;
}
if (wrappingTargetKind === 'page') {
templateCode = pageWrapperTemplateCode;
} else if (wrappingTargetKind === 'api-route') {
templateCode = apiWrapperTemplateCode;
} else {
throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`);
}
// Inject the route and the path to the file we're wrapping into the template
templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\'));
} else if (wrappingTargetKind === 'page-server-component') {
// Get the parameterized route name from this page's filepath
const parameterizedPagesRoute = path.posix
.normalize(path.relative(appDir, this.resourcePath))
// Add a slash at the beginning
.replace(/(.*)/, '/$1')
// Pull off the file name
.replace(/\/page\.(js|jsx|tsx)$/, '')
// Remove routing groups: https://beta.nextjs.org/docs/routing/defining-routes#example-creating-multiple-root-layouts
.replace(/\/(\(.*?\)\/)+/g, '/')
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
// homepage), sub back in the root route
.replace(/^$/, '/');
// Skip explicitly-ignored pages
if (stringMatchesSomePattern(parameterizedPagesRoute, excludeServerRoutes, true)) {
this.callback(null, userCode, userModuleSourceMap);
return;
}
// The following string is what Next.js injects in order to mark client components:
// https://github.com/vercel/next.js/blob/295f9da393f7d5a49b0c2e15a2f46448dbdc3895/packages/next/build/analysis/get-page-static-info.ts#L37
// https://github.com/vercel/next.js/blob/a1c15d84d906a8adf1667332a3f0732be615afa0/packages/next-swc/crates/core/src/react_server_components.rs#L247
// We do not want to wrap client components
if (userCode.includes('/* __next_internal_client_entry_do_not_use__ */')) {
this.callback(null, userCode, userModuleSourceMap);
return;
}
templateCode = serverComponentWrapperTemplateCode;
} else if (wrappingTargetKind === 'middleware') {
templateCode = middlewareWrapperTemplateCode;
} else {
throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`);
}
// Replace the import path of the wrapping target in the template with a path that the `wrapUserCode` function will understand.
templateCode = templateCode.replace(/__SENTRY_WRAPPING_TARGET_FILE__/g, WRAPPING_TARGET_MODULE_NAME);
// Run the proxy module code through Rollup, in order to split the `export * from '<wrapped file>'` out into
// individual exports (which nextjs seems to require).
wrapUserCode(templateCode, userCode, userModuleSourceMap)
.then(({ code: wrappedCode, map: wrappedCodeSourceMap }) => {
this.callback(null, wrappedCode, wrappedCodeSourceMap);
})
.catch(err => {
// eslint-disable-next-line no-console
console.warn(
`[@sentry/nextjs] Could not instrument ${this.resourcePath}. An error occurred while auto-wrapping:\n${err}`,
);
this.callback(null, userCode, userModuleSourceMap);
});
}
/**
* Use Rollup to process the proxy module code, in order to split its `export * from '<wrapped file>'` call into
* individual exports (which nextjs seems to need).
*
* Wraps provided user code (located under the import defined via WRAPPING_TARGET_MODULE_NAME) with provided wrapper
* code. Under the hood, this function uses rollup to bundle the modules together. Rollup is convenient for us because
* it turns `export * from '<wrapped file>'` (which Next.js doesn't allow) into individual named exports.
*
* Note: This function may throw in case something goes wrong while bundling.
*
* @param wrapperCode The wrapper module code
* @param userModuleCode The user module code
* @returns The wrapped user code and a source map that describes the transformations done by this function
*/
async function wrapUserCode(
wrapperCode: string,
userModuleCode: string,
userModuleSourceMap: any,
): Promise<{ code: string; map?: any }> {
const rollupBuild = await rollup({
input: SENTRY_WRAPPER_MODULE_NAME,
plugins: [
// We're using a simple custom plugin that virtualizes our wrapper module and the user module, so we don't have to
// mess around with file paths and so that we can pass the original user module source map to rollup so that
// rollup gives us a bundle with correct source mapping to the original file
{
name: 'virtualize-sentry-wrapper-modules',
resolveId: id => {
if (id === SENTRY_WRAPPER_MODULE_NAME || id === WRAPPING_TARGET_MODULE_NAME) {
return id;
} else {
return null;
}
},
load(id) {
if (id === SENTRY_WRAPPER_MODULE_NAME) {
return wrapperCode;
} else if (id === WRAPPING_TARGET_MODULE_NAME) {
return {
code: userModuleCode,
map: userModuleSourceMap, // give rollup acces to original user module source map
};
} else {
return null;
}
},
},
// People may use `module.exports` in their API routes or page files. Next.js allows that and we also need to
// handle that correctly so we let a plugin to take care of bundling cjs exports for us.
commonjs({
sourceMap: true,
strictRequires: true, // Don't hoist require statements that users may define
ignoreDynamicRequires: true, // Don't break dynamic requires and things like Webpack's `require.context`
ignore() {
// We want basically only want to use this plugin for handling the case where users export their handlers with module.exports.
// This plugin would also be able to convert any `require` into something esm compatible but webpack does that anyways so we just skip that part of the plugin.
// (Also, modifying require may break user code)
return true;
},
}),
],
// We only want to bundle our wrapper module and the wrappee module into one, so we mark everything else as external.
external: sourceId => sourceId !== SENTRY_WRAPPER_MODULE_NAME && sourceId !== WRAPPING_TARGET_MODULE_NAME,
// Prevent rollup from stressing out about TS's use of global `this` when polyfilling await. (TS will polyfill if the
// user's tsconfig `target` is set to anything before `es2017`. See https://stackoverflow.com/a/72822340 and
// https://stackoverflow.com/a/60347490.)
context: 'this',
// Rollup's path-resolution logic when handling re-exports can go wrong when wrapping pages which aren't at the root
// level of the `pages` directory. This may be a bug, as it doesn't match the behavior described in the docs, but what
// seems to happen is this:
//
// - We try to wrap `pages/xyz/userPage.js`, which contains `export { helperFunc } from '../../utils/helper'`
// - Rollup converts '../../utils/helper' into an absolute path
// - We mark the helper module as external
// - Rollup then converts it back to a relative path, but relative to `pages/` rather than `pages/xyz/`. (This is
// the part which doesn't match the docs. They say that Rollup will use the common ancestor of all modules in the
// bundle as the basis for the relative path calculation, but both our temporary file and the page being wrapped
// live in `pages/xyz/`, and they're the only two files in the bundle, so `pages/xyz/`` should be used as the
// root. Unclear why it's not.)
// - As a result of the miscalculation, our proxy module will include `export { helperFunc } from '../utils/helper'`
// rather than the expected `export { helperFunc } from '../../utils/helper'`, thereby causing a build error in
// nextjs..
//
// Setting `makeAbsoluteExternalsRelative` to `false` prevents all of the above by causing Rollup to ignore imports of
// externals entirely, with the result that their paths remain untouched (which is what we want).
makeAbsoluteExternalsRelative: false,
onwarn: (_warning, _warn) => {
// Suppress all warnings - we don't want to bother people with this output
// Might be stuff like "you have unused imports"
// _warn(_warning); // uncomment to debug
},
});
const finalBundle = await rollupBuild.generate({
format: 'esm',
sourcemap: 'hidden', // put source map data in the bundle but don't generate a source map commment in the output
});
// The module at index 0 is always the entrypoint, which in this case is the proxy module.
return finalBundle.output[0];
}