wrappingLoader.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. import { _optionalChain } from '@sentry/utils';
  2. import * as fs from 'fs';
  3. import * as path from 'path';
  4. import commonjs from '@rollup/plugin-commonjs';
  5. import { stringMatchesSomePattern } from '@sentry/utils';
  6. import * as chalk from 'chalk';
  7. import { rollup } from 'rollup';
  8. // Just a simple placeholder to make referencing module consistent
  9. const SENTRY_WRAPPER_MODULE_NAME = 'sentry-wrapper-module';
  10. // Needs to end in .cjs in order for the `commonjs` plugin to pick it up
  11. const WRAPPING_TARGET_MODULE_NAME = '__SENTRY_WRAPPING_TARGET_FILE__.cjs';
  12. const apiWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'apiWrapperTemplate.js');
  13. const apiWrapperTemplateCode = fs.readFileSync(apiWrapperTemplatePath, { encoding: 'utf8' });
  14. const pageWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'pageWrapperTemplate.js');
  15. const pageWrapperTemplateCode = fs.readFileSync(pageWrapperTemplatePath, { encoding: 'utf8' });
  16. const middlewareWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'middlewareWrapperTemplate.js');
  17. const middlewareWrapperTemplateCode = fs.readFileSync(middlewareWrapperTemplatePath, { encoding: 'utf8' });
  18. let showedMissingAsyncStorageModuleWarning = false;
  19. const sentryInitWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'sentryInitWrapperTemplate.js');
  20. const sentryInitWrapperTemplateCode = fs.readFileSync(sentryInitWrapperTemplatePath, { encoding: 'utf8' });
  21. const serverComponentWrapperTemplatePath = path.resolve(
  22. __dirname,
  23. '..',
  24. 'templates',
  25. 'serverComponentWrapperTemplate.js',
  26. );
  27. const serverComponentWrapperTemplateCode = fs.readFileSync(serverComponentWrapperTemplatePath, { encoding: 'utf8' });
  28. const routeHandlerWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'routeHandlerWrapperTemplate.js');
  29. const routeHandlerWrapperTemplateCode = fs.readFileSync(routeHandlerWrapperTemplatePath, { encoding: 'utf8' });
  30. /**
  31. * Replace the loaded file with a wrapped version the original file. In the wrapped version, the original file is loaded,
  32. * any data-fetching functions (`getInitialProps`, `getStaticProps`, and `getServerSideProps`) or API routes it contains
  33. * are wrapped, and then everything is re-exported.
  34. */
  35. // eslint-disable-next-line complexity
  36. function wrappingLoader(
  37. userCode,
  38. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  39. userModuleSourceMap,
  40. ) {
  41. // We know one or the other will be defined, depending on the version of webpack being used
  42. const {
  43. pagesDir,
  44. appDir,
  45. pageExtensionRegex,
  46. excludeServerRoutes = [],
  47. wrappingTargetKind,
  48. sentryConfigFilePath,
  49. vercelCronsConfig,
  50. nextjsRequestAsyncStorageModulePath,
  51. } = 'getOptions' in this ? this.getOptions() : this.query;
  52. this.async();
  53. let templateCode;
  54. if (wrappingTargetKind === 'sentry-init') {
  55. templateCode = sentryInitWrapperTemplateCode;
  56. // Absolute paths to the sentry config do not work with Windows: https://github.com/getsentry/sentry-javascript/issues/8133
  57. // Se we need check whether `this.resourcePath` is absolute because there is no contract by webpack that says it is absolute.
  58. // Examples where `this.resourcePath` could possibly be non-absolute are virtual modules.
  59. if (sentryConfigFilePath && path.isAbsolute(this.resourcePath)) {
  60. const sentryConfigImportPath = path
  61. .relative(path.dirname(this.resourcePath), sentryConfigFilePath)
  62. .replace(/\\/g, '/');
  63. // path.relative() may return something like `sentry.server.config.js` which is not allowed. Imports from the
  64. // current directory need to start with './'.This is why we prepend the path with './', which should always again
  65. // be a valid relative path.
  66. // https://github.com/getsentry/sentry-javascript/issues/8798
  67. templateCode = templateCode.replace(/__SENTRY_CONFIG_IMPORT_PATH__/g, `./${sentryConfigImportPath}`);
  68. } else {
  69. // Bail without doing any wrapping
  70. this.callback(null, userCode, userModuleSourceMap);
  71. return;
  72. }
  73. } else if (wrappingTargetKind === 'page' || wrappingTargetKind === 'api-route') {
  74. if (pagesDir === undefined) {
  75. this.callback(null, userCode, userModuleSourceMap);
  76. return;
  77. }
  78. // Get the parameterized route name from this page's filepath
  79. const parameterizedPagesRoute = path
  80. // Get the path of the file insde of the pages directory
  81. .relative(pagesDir, this.resourcePath)
  82. // Replace all backslashes with forward slashes (windows)
  83. .replace(/\\/g, '/')
  84. // Add a slash at the beginning
  85. .replace(/(.*)/, '/$1')
  86. // Pull off the file extension
  87. // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- not end user input
  88. .replace(new RegExp(`\\.(${pageExtensionRegex})`), '')
  89. // Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into
  90. // just `/xyz`
  91. .replace(/\/index$/, '')
  92. // In case all of the above have left us with an empty string (which will happen if we're dealing with the
  93. // homepage), sub back in the root route
  94. .replace(/^$/, '/');
  95. // Skip explicitly-ignored pages
  96. if (stringMatchesSomePattern(parameterizedPagesRoute, excludeServerRoutes, true)) {
  97. this.callback(null, userCode, userModuleSourceMap);
  98. return;
  99. }
  100. if (wrappingTargetKind === 'page') {
  101. templateCode = pageWrapperTemplateCode;
  102. } else if (wrappingTargetKind === 'api-route') {
  103. templateCode = apiWrapperTemplateCode;
  104. } else {
  105. throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`);
  106. }
  107. templateCode = templateCode.replace(/__VERCEL_CRONS_CONFIGURATION__/g, JSON.stringify(vercelCronsConfig));
  108. // Inject the route and the path to the file we're wrapping into the template
  109. templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\'));
  110. } else if (wrappingTargetKind === 'server-component' || wrappingTargetKind === 'route-handler') {
  111. if (appDir === undefined) {
  112. this.callback(null, userCode, userModuleSourceMap);
  113. return;
  114. }
  115. // Get the parameterized route name from this page's filepath
  116. const parameterizedPagesRoute = path
  117. // Get the path of the file insde of the app directory
  118. .relative(appDir, this.resourcePath)
  119. // Replace all backslashes with forward slashes (windows)
  120. .replace(/\\/g, '/')
  121. // Add a slash at the beginning
  122. .replace(/(.*)/, '/$1')
  123. // Pull off the file name
  124. .replace(/\/[^/]+\.(js|ts|jsx|tsx)$/, '')
  125. // In case all of the above have left us with an empty string (which will happen if we're dealing with the
  126. // homepage), sub back in the root route
  127. .replace(/^$/, '/');
  128. // Skip explicitly-ignored pages
  129. if (stringMatchesSomePattern(parameterizedPagesRoute, excludeServerRoutes, true)) {
  130. this.callback(null, userCode, userModuleSourceMap);
  131. return;
  132. }
  133. // The following string is what Next.js injects in order to mark client components:
  134. // https://github.com/vercel/next.js/blob/295f9da393f7d5a49b0c2e15a2f46448dbdc3895/packages/next/build/analysis/get-page-static-info.ts#L37
  135. // https://github.com/vercel/next.js/blob/a1c15d84d906a8adf1667332a3f0732be615afa0/packages/next-swc/crates/core/src/react_server_components.rs#L247
  136. // We do not want to wrap client components
  137. if (userCode.includes('__next_internal_client_entry_do_not_use__')) {
  138. this.callback(null, userCode, userModuleSourceMap);
  139. return;
  140. }
  141. if (wrappingTargetKind === 'server-component') {
  142. templateCode = serverComponentWrapperTemplateCode;
  143. } else {
  144. templateCode = routeHandlerWrapperTemplateCode;
  145. }
  146. if (nextjsRequestAsyncStorageModulePath !== undefined) {
  147. templateCode = templateCode.replace(
  148. /__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__/g,
  149. nextjsRequestAsyncStorageModulePath,
  150. );
  151. } else {
  152. if (!showedMissingAsyncStorageModuleWarning) {
  153. // eslint-disable-next-line no-console
  154. console.warn(
  155. `${chalk.yellow('warn')} - The Sentry SDK could not access the ${chalk.bold.cyan(
  156. 'RequestAsyncStorage',
  157. )} module. Certain features may not work. There is nothing you can do to fix this yourself, but future SDK updates may resolve this.\n`,
  158. );
  159. showedMissingAsyncStorageModuleWarning = true;
  160. }
  161. templateCode = templateCode.replace(
  162. /__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__/g,
  163. '@sentry/nextjs/esm/config/templates/requestAsyncStorageShim.js',
  164. );
  165. }
  166. templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\'));
  167. const componentTypeMatch = path.posix
  168. .normalize(path.relative(appDir, this.resourcePath))
  169. .match(/\/?([^/]+)\.(?:js|ts|jsx|tsx)$/);
  170. if (componentTypeMatch && componentTypeMatch[1]) {
  171. let componentType;
  172. switch (componentTypeMatch[1]) {
  173. case 'page':
  174. componentType = 'Page';
  175. break;
  176. case 'layout':
  177. componentType = 'Layout';
  178. break;
  179. case 'head':
  180. componentType = 'Head';
  181. break;
  182. case 'not-found':
  183. componentType = 'Not-found';
  184. break;
  185. case 'loading':
  186. componentType = 'Loading';
  187. break;
  188. default:
  189. componentType = 'Unknown';
  190. }
  191. templateCode = templateCode.replace(/__COMPONENT_TYPE__/g, componentType);
  192. } else {
  193. templateCode = templateCode.replace(/__COMPONENT_TYPE__/g, 'Unknown');
  194. }
  195. } else if (wrappingTargetKind === 'middleware') {
  196. templateCode = middlewareWrapperTemplateCode;
  197. } else {
  198. throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`);
  199. }
  200. // Replace the import path of the wrapping target in the template with a path that the `wrapUserCode` function will understand.
  201. templateCode = templateCode.replace(/__SENTRY_WRAPPING_TARGET_FILE__/g, WRAPPING_TARGET_MODULE_NAME);
  202. // Run the proxy module code through Rollup, in order to split the `export * from '<wrapped file>'` out into
  203. // individual exports (which nextjs seems to require).
  204. wrapUserCode(templateCode, userCode, userModuleSourceMap)
  205. .then(({ code: wrappedCode, map: wrappedCodeSourceMap }) => {
  206. this.callback(null, wrappedCode, wrappedCodeSourceMap);
  207. })
  208. .catch(err => {
  209. // eslint-disable-next-line no-console
  210. console.warn(
  211. `[@sentry/nextjs] Could not instrument ${this.resourcePath}. An error occurred while auto-wrapping:\n${err}`,
  212. );
  213. this.callback(null, userCode, userModuleSourceMap);
  214. });
  215. }
  216. /**
  217. * Use Rollup to process the proxy module code, in order to split its `export * from '<wrapped file>'` call into
  218. * individual exports (which nextjs seems to need).
  219. *
  220. * Wraps provided user code (located under the import defined via WRAPPING_TARGET_MODULE_NAME) with provided wrapper
  221. * code. Under the hood, this function uses rollup to bundle the modules together. Rollup is convenient for us because
  222. * it turns `export * from '<wrapped file>'` (which Next.js doesn't allow) into individual named exports.
  223. *
  224. * Note: This function may throw in case something goes wrong while bundling.
  225. *
  226. * @param wrapperCode The wrapper module code
  227. * @param userModuleCode The user module code
  228. * @returns The wrapped user code and a source map that describes the transformations done by this function
  229. */
  230. async function wrapUserCode(
  231. wrapperCode,
  232. userModuleCode,
  233. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  234. userModuleSourceMap,
  235. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  236. ) {
  237. const wrap = (withDefaultExport) =>
  238. rollup({
  239. input: SENTRY_WRAPPER_MODULE_NAME,
  240. plugins: [
  241. // We're using a simple custom plugin that virtualizes our wrapper module and the user module, so we don't have to
  242. // mess around with file paths and so that we can pass the original user module source map to rollup so that
  243. // rollup gives us a bundle with correct source mapping to the original file
  244. {
  245. name: 'virtualize-sentry-wrapper-modules',
  246. resolveId: id => {
  247. if (id === SENTRY_WRAPPER_MODULE_NAME || id === WRAPPING_TARGET_MODULE_NAME) {
  248. return id;
  249. } else {
  250. return null;
  251. }
  252. },
  253. load(id) {
  254. if (id === SENTRY_WRAPPER_MODULE_NAME) {
  255. return withDefaultExport ? wrapperCode : wrapperCode.replace('export { default } from', 'export {} from');
  256. } else if (id === WRAPPING_TARGET_MODULE_NAME) {
  257. return {
  258. code: userModuleCode,
  259. map: userModuleSourceMap, // give rollup acces to original user module source map
  260. };
  261. } else {
  262. return null;
  263. }
  264. },
  265. },
  266. // People may use `module.exports` in their API routes or page files. Next.js allows that and we also need to
  267. // handle that correctly so we let a plugin to take care of bundling cjs exports for us.
  268. commonjs({
  269. sourceMap: true,
  270. strictRequires: true, // Don't hoist require statements that users may define
  271. ignoreDynamicRequires: true, // Don't break dynamic requires and things like Webpack's `require.context`
  272. ignore() {
  273. // We basically only want to use this plugin for handling the case where users export their handlers with module.exports.
  274. // 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.
  275. // (Also, modifying require may break user code)
  276. return true;
  277. },
  278. }),
  279. ],
  280. // We only want to bundle our wrapper module and the wrappee module into one, so we mark everything else as external.
  281. external: sourceId => sourceId !== SENTRY_WRAPPER_MODULE_NAME && sourceId !== WRAPPING_TARGET_MODULE_NAME,
  282. // Prevent rollup from stressing out about TS's use of global `this` when polyfilling await. (TS will polyfill if the
  283. // user's tsconfig `target` is set to anything before `es2017`. See https://stackoverflow.com/a/72822340 and
  284. // https://stackoverflow.com/a/60347490.)
  285. context: 'this',
  286. // Rollup's path-resolution logic when handling re-exports can go wrong when wrapping pages which aren't at the root
  287. // level of the `pages` directory. This may be a bug, as it doesn't match the behavior described in the docs, but what
  288. // seems to happen is this:
  289. //
  290. // - We try to wrap `pages/xyz/userPage.js`, which contains `export { helperFunc } from '../../utils/helper'`
  291. // - Rollup converts '../../utils/helper' into an absolute path
  292. // - We mark the helper module as external
  293. // - Rollup then converts it back to a relative path, but relative to `pages/` rather than `pages/xyz/`. (This is
  294. // the part which doesn't match the docs. They say that Rollup will use the common ancestor of all modules in the
  295. // bundle as the basis for the relative path calculation, but both our temporary file and the page being wrapped
  296. // live in `pages/xyz/`, and they're the only two files in the bundle, so `pages/xyz/`` should be used as the
  297. // root. Unclear why it's not.)
  298. // - As a result of the miscalculation, our proxy module will include `export { helperFunc } from '../utils/helper'`
  299. // rather than the expected `export { helperFunc } from '../../utils/helper'`, thereby causing a build error in
  300. // nextjs..
  301. //
  302. // Setting `makeAbsoluteExternalsRelative` to `false` prevents all of the above by causing Rollup to ignore imports of
  303. // externals entirely, with the result that their paths remain untouched (which is what we want).
  304. makeAbsoluteExternalsRelative: false,
  305. onwarn: (_warning, _warn) => {
  306. // Suppress all warnings - we don't want to bother people with this output
  307. // Might be stuff like "you have unused imports"
  308. // _warn(_warning); // uncomment to debug
  309. },
  310. });
  311. // Next.js sometimes complains if you define a default export (e.g. in route handlers in dev mode).
  312. // This is why we want to avoid unnecessarily creating default exports, even if they're just `undefined`.
  313. // For this reason we try to bundle/wrap the user code once including a re-export of `default`.
  314. // If the user code didn't have a default export, rollup will throw.
  315. // We then try bundling/wrapping agian, but without including a re-export of `default`.
  316. let rollupBuild;
  317. try {
  318. rollupBuild = await wrap(true);
  319. } catch (e) {
  320. if (_optionalChain([(e ), 'optionalAccess', _ => _.code]) === 'MISSING_EXPORT') {
  321. rollupBuild = await wrap(false);
  322. } else {
  323. throw e;
  324. }
  325. }
  326. const finalBundle = await rollupBuild.generate({
  327. format: 'esm',
  328. sourcemap: 'hidden', // put source map data in the bundle but don't generate a source map commment in the output
  329. });
  330. // The module at index 0 is always the entrypoint, which in this case is the proxy module.
  331. return finalBundle.output[0];
  332. }
  333. export { wrappingLoader as default };
  334. //# sourceMappingURL=wrappingLoader.js.map