diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 0bd38d0a3dbb..f1e1ffae40fd 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@rollup/plugin-sucrase": "4.0.4", + "@rollup/plugin-virtual": "3.0.0", "@sentry/core": "7.16.0", "@sentry/integrations": "7.16.0", "@sentry/node": "7.16.0", diff --git a/packages/nextjs/src/config/loaders/proxyLoader.ts b/packages/nextjs/src/config/loaders/proxyLoader.ts index e4e7e743ae53..323294a38504 100644 --- a/packages/nextjs/src/config/loaders/proxyLoader.ts +++ b/packages/nextjs/src/config/loaders/proxyLoader.ts @@ -50,34 +50,21 @@ export default async function proxyLoader(this: LoaderThis, userC // Make sure the template is included when runing `webpack watch` this.addDependency(templatePath); - // Inject the route into the template + // Inject the route and the path to the file we're wrapping into the template templateCode = templateCode.replace(/__ROUTE__/g, parameterizedRoute); - - // Fill in the path to the file we're wrapping and save the result as a temporary file in the same folder (so that - // relative imports and exports are calculated correctly). - // - // TODO: We're saving the filled-in template to disk, however temporarily, because Rollup expects a path to a code - // file, not code itself. There is a rollup plugin which can fake this (`@rollup/plugin-virtual`) but the virtual file - // seems to be inside of a virtual directory (in other words, one level down from where you'd expect it) and that - // messes up relative imports and exports. Presumably there's a way to make it work, though, and if we can, it would - // be cleaner than having to first write and then delete a temporary file each time we run this loader. templateCode = templateCode.replace(/__RESOURCE_PATH__/g, this.resourcePath); - const tempFilePath = path.resolve(path.dirname(this.resourcePath), `temp${Math.random()}.js`); - fs.writeFileSync(tempFilePath, templateCode); // Run the proxy module code through Rollup, in order to split the `export * from ''` out into - // individual exports (which nextjs seems to require), then delete the tempoary file. + // individual exports (which nextjs seems to require). let proxyCode; try { - proxyCode = await rollupize(tempFilePath, this.resourcePath); + proxyCode = await rollupize(templateCode, this.resourcePath); } catch (err) { __DEBUG_BUILD__ && logger.warn( `Could not wrap ${this.resourcePath}. An error occurred while processing the proxy module template:\n${err}`, ); return userCode; - } finally { - fs.unlinkSync(tempFilePath); } // Add a query string onto all references to the wrapped file, so that webpack will consider it different from the diff --git a/packages/nextjs/src/config/loaders/rollup.ts b/packages/nextjs/src/config/loaders/rollup.ts index d04646da58a9..6c7861827b95 100644 --- a/packages/nextjs/src/config/loaders/rollup.ts +++ b/packages/nextjs/src/config/loaders/rollup.ts @@ -1,12 +1,20 @@ import sucrase from '@rollup/plugin-sucrase'; +import virtual from '@rollup/plugin-virtual'; +import { escapeStringForRegex } from '@sentry/utils'; import * as path from 'path'; import type { InputOptions as RollupInputOptions, OutputOptions as RollupOutputOptions } from 'rollup'; import { rollup } from 'rollup'; -const getRollupInputOptions = (proxyPath: string, userModulePath: string): RollupInputOptions => ({ - input: proxyPath, +const SENTRY_PROXY_MODULE_NAME = 'sentry-proxy-module'; + +const getRollupInputOptions = (templateCode: string, userModulePath: string): RollupInputOptions => ({ + input: SENTRY_PROXY_MODULE_NAME, plugins: [ + virtual({ + [SENTRY_PROXY_MODULE_NAME]: templateCode, + }), + sucrase({ transforms: ['jsx', 'typescript'], }), @@ -17,7 +25,7 @@ const getRollupInputOptions = (proxyPath: string, userModulePath: string): Rollu // otherwise they won't be processed. (We need Rollup to process the former so that we can use the code, and we need // it to process the latter so it knows what exports to re-export from the proxy module.) Past that, we don't care, so // don't bother to process anything else. - external: importPath => importPath !== proxyPath && importPath !== userModulePath, + external: importPath => importPath !== SENTRY_PROXY_MODULE_NAME && importPath !== userModulePath, // 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 @@ -53,34 +61,44 @@ const rollupOutputOptions: RollupOutputOptions = { }; /** - * Use Rollup to process the proxy module file (located at `tempProxyFilePath`) in order to split its `export * from - * ''` call into individual exports (which nextjs seems to need). + * Use Rollup to process the proxy module code, in order to split its `export * from ''` call into + * individual exports (which nextjs seems to need). * * Note: Any errors which occur are handled by the proxy loader which calls this function. * - * @param tempProxyFilePath The path to the temporary file containing the proxy module code + * @param templateCode The proxy module code * @param userModulePath The path to the file being wrapped * @returns The processed proxy module code */ -export async function rollupize(tempProxyFilePath: string, userModulePath: string): Promise { - const intermediateBundle = await rollup(getRollupInputOptions(tempProxyFilePath, userModulePath)); +export async function rollupize(templateCode: string, userModulePath: string): Promise { + const intermediateBundle = await rollup(getRollupInputOptions(templateCode, userModulePath)); const finalBundle = await intermediateBundle.generate(rollupOutputOptions); // The module at index 0 is always the entrypoint, which in this case is the proxy module. let { code } = finalBundle.output[0]; - // Rollup does a few things to the code we *don't* want. Undo those changes before returning the code. + // In addition to doing the desired work, Rollup also does a few things we *don't* want. Specifically, in messes up + // the path in both `import * as origModule from ''` and `export * from ''`. // - // Nextjs uses square brackets surrounding a path segment to denote a parameter in the route, but Rollup turns those - // square brackets into underscores. Further, Rollup adds file extensions to bare-path-type import and export sources. - // Because it assumes that everything will have already been processed, it always uses `.js` as the added extension. - // We need to restore the original name and extension so that Webpack will be able to find the wrapped file. - const userModuleFilename = path.basename(userModulePath); - const mutatedUserModuleFilename = userModuleFilename - // `[\\[\\]]` is the character class containing `[` and `]` - .replace(new RegExp('[\\[\\]]', 'g'), '_') - .replace(/(jsx?|tsx?)$/, 'js'); - code = code.replace(new RegExp(mutatedUserModuleFilename, 'g'), userModuleFilename); + // - It turns the square brackets surrounding each parameterized path segment into underscores. + // - It always adds `.js` to the end of the filename. + // - It converts the path from aboslute to relative, which would be fine except that when used with the virual plugin, + // it uses an incorrect (and not entirely predicable) base for that relative path. + // + // To fix this, we overwrite the messed up path with what we know it should be: `./`. (We can + // find the value of the messed up path by looking at what `import * as origModule from ''` becomes. + // Because it's the first line of the template, it's also the first line of the result, and is therefore easy to + // find.) + + const importStarStatement = code.split('\n')[0]; + // This regex should always match (we control both the input and the process which generates it, so we can guarantee + // the outcome of that processing), but just in case it somehow doesn't, we need it to throw an error so that the + // proxy loader will know to return the user's code untouched rather than returning proxy module code including a + // broken path. The non-null assertion asserts that a match has indeed been found. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const messedUpPath = /^import \* as .* from '(.*)';$/.exec(importStarStatement)![1]; + + code = code.replace(new RegExp(escapeStringForRegex(messedUpPath), 'g'), `./${path.basename(userModulePath)}`); return code; } diff --git a/yarn.lock b/yarn.lock index 6d755cd6fcee..a12d1b672215 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4231,6 +4231,11 @@ "@rollup/pluginutils" "^4.1.1" sucrase "^3.20.0" +"@rollup/plugin-virtual@3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@rollup/plugin-virtual/-/plugin-virtual-3.0.0.tgz#8c3f54b4ab4b267d9cd3dcbaedc58d4fd1deddca" + integrity sha512-K9KORe1myM62o0lKkNR4MmCxjwuAXsZEtIHpaILfv4kILXTOrXt/R2ha7PzMcCHPYdnkWPiBZK8ed4Zr3Ll5lQ== + "@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.0.9", "@rollup/pluginutils@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"