Skip to content

ref(nextjs): Use virtual rather than temporary file for proxy module #6021

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Oct 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 3 additions & 16 deletions packages/nextjs/src/config/loaders/proxyLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,34 +50,21 @@ export default async function proxyLoader(this: LoaderThis<LoaderOptions>, 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 '<wrapped file>'` 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
Expand Down
56 changes: 37 additions & 19 deletions packages/nextjs/src/config/loaders/rollup.ts
Original file line number Diff line number Diff line change
@@ -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'],
}),
Expand All @@ -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
Expand Down Expand Up @@ -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
* '<wrapped file>'` call into individual exports (which nextjs seems to need).
* 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).
*
* 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<string> {
const intermediateBundle = await rollup(getRollupInputOptions(tempProxyFilePath, userModulePath));
export async function rollupize(templateCode: string, userModulePath: string): Promise<string> {
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 '<userModulePath>'` and `export * from '<userModulePath>'`.
//
// 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: `./<userModulePathBasename>`. (We can
// find the value of the messed up path by looking at what `import * as origModule from '<userModulePath>'` 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;
}
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4231,6 +4231,11 @@
"@rollup/pluginutils" "^4.1.1"
sucrase "^3.20.0"

"@rollup/[email protected]":
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"
Expand Down