Skip to content

Commit 848f03f

Browse files
committed
add proxy loader
1 parent 9f4a2b1 commit 848f03f

File tree

4 files changed

+199
-1
lines changed

4 files changed

+199
-1
lines changed

packages/nextjs/rollup.npm.config.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,15 @@ export default [
4242
...makeNPMConfigVariants(
4343
makeBaseNPMConfig({
4444
entrypoints: ['src/config/loaders/index.ts'],
45+
// Needed in order to successfully import sucrase
46+
esModuleInterop: true,
4547

4648
packageSpecificConfig: {
4749
output: {
48-
// make it so Rollup calms down about the fact that we're doing `export { loader as default }`
50+
// make it so Rollup calms down about the fact that we're combining default and named exports
4951
exports: 'named',
5052
},
53+
external: ['@rollup/plugin-sucrase', 'rollup'],
5154
},
5255
}),
5356
),
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { default as prefixLoader } from './prefixLoader';
22
export { default as dataFetchersLoader } from './dataFetchersLoader';
3+
export { default as proxyLoader } from './proxyLoader';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { escapeStringForRegex } from '@sentry/utils';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
5+
import { rollupize } from './rollup';
6+
import { LoaderThis } from './types';
7+
8+
type LoaderOptions = {
9+
pagesDir: string;
10+
};
11+
12+
/**
13+
* Replace the loaded file with a proxy module "wrapping" the original file. In the proxy, the original file is loaded,
14+
* any data-fetching functions (`getInitialProps`, `getStaticProps`, and `getServerSideProps`) it contains are wrapped,
15+
* and then everything is re-exported.
16+
*/
17+
export default async function proxyLoader(this: LoaderThis<LoaderOptions>, userCode: string): Promise<string> {
18+
// We know one or the other will be defined, depending on the version of webpack being used
19+
const { pagesDir } = 'getOptions' in this ? this.getOptions() : this.query;
20+
21+
// Get the parameterized route name from this page's filepath
22+
const parameterizedRoute = path
23+
// Get the path of the file insde of the pages directory
24+
.relative(pagesDir, this.resourcePath)
25+
// Add a slash at the beginning
26+
.replace(/(.*)/, '/$1')
27+
// Pull off the file extension
28+
.replace(/\.(jsx?|tsx?)/, '')
29+
// Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into
30+
// just `/xyz`
31+
.replace(/\/index$/, '')
32+
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
33+
// homepage), sub back in the root route
34+
.replace(/^$/, '/');
35+
36+
// TODO: For the moment we skip API routes. Those will need to be handled slightly differently because of the manual
37+
// wrapping we've already been having people do using `withSentry`.
38+
if (parameterizedRoute.startsWith('api')) {
39+
return userCode;
40+
}
41+
42+
// We don't want to wrap twice (or infinitely), so in the proxy we add this query string onto references to the
43+
// wrapped file, so that we know that it's already been processed. (Adding this query string is also necessary to
44+
// convince webpack that it's a different file than the one it's in the middle of loading now, so that the originals
45+
// themselves will have a chance to load.)
46+
if (this.resourceQuery.includes('__sentry_wrapped__')) {
47+
return userCode;
48+
}
49+
50+
const templatePath = path.resolve(__dirname, '../templates/proxyLoaderTemplate.js');
51+
let templateCode = fs.readFileSync(templatePath).toString();
52+
// Make sure the template is included when runing `webpack watch`
53+
this.addDependency(templatePath);
54+
55+
// Inject the route into the template
56+
templateCode = templateCode.replace(/__ROUTE__/g, parameterizedRoute);
57+
58+
// Fill in the path to the file we're wrapping and save the result as a temporary file in the same folder (so that
59+
// relative imports and exports are calculated correctly).
60+
//
61+
// TODO: We're saving the filled-in template to disk, however temporarily, because Rollup expects a path to a code
62+
// file, not code itself. There is a rollup plugin which can fake this (`@rollup/plugin-virtual`) but the virtual file
63+
// seems to be inside of a virtual directory (in other words, one level down from where you'd expect it) and that
64+
// messes up relative imports and exports. Presumably there's a way to make it work, though, and if we can, it would
65+
// be cleaner than having to first write and then delete a temporary file each time we run this loader.
66+
templateCode = templateCode.replace(/__RESOURCE_PATH__/g, this.resourcePath);
67+
const tempFilePath = path.resolve(path.dirname(this.resourcePath), `temp${Math.random()}.js`);
68+
fs.writeFileSync(tempFilePath, templateCode);
69+
70+
// Run the proxy module code through Rollup, in order to split the `export * from '<wrapped file>'` out into
71+
// individual exports (which nextjs seems to require), then delete the tempoary file.
72+
let proxyCode = await rollupize(tempFilePath, this.resourcePath);
73+
fs.unlinkSync(tempFilePath);
74+
75+
if (!proxyCode) {
76+
// We will already have thrown a warning in `rollupize`, so no need to do it again here
77+
return userCode;
78+
}
79+
80+
// Add a query string onto all references to the wrapped file, so that webpack will consider it different from the
81+
// non-query-stringged version (which we're already in the middle of loading as we speak), and load it separately from
82+
// this. When the second load happens this loader will run again, but we'll be able to see the query string and will
83+
// know to immediately return without processing. This avoids an infinite loop.
84+
const resourceFilename = path.basename(this.resourcePath);
85+
proxyCode = proxyCode.replace(
86+
new RegExp(`/${escapeStringForRegex(resourceFilename)}'`, 'g'),
87+
`/${resourceFilename}?__sentry_wrapped__'`,
88+
);
89+
90+
return proxyCode;
91+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { RollupSucraseOptions } from '@rollup/plugin-sucrase';
2+
import sucrase from '@rollup/plugin-sucrase';
3+
import { logger } from '@sentry/utils';
4+
import * as path from 'path';
5+
import type { InputOptions as RollupInputOptions, OutputOptions as RollupOutputOptions } from 'rollup';
6+
import { rollup } from 'rollup';
7+
8+
const getRollupInputOptions: (proxyPath: string, resourcePath: string) => RollupInputOptions = (
9+
proxyPath,
10+
resourcePath,
11+
) => ({
12+
input: proxyPath,
13+
plugins: [
14+
// For some reason, even though everything in `RollupSucraseOptions` besides `transforms` is supposed to be
15+
// optional, TS complains that there are a bunch of missing properties (hence the typecast). Similar to
16+
// https://github.com/microsoft/TypeScript/issues/20722, though that's been fixed. (In this case it's an interface
17+
// exporting a `Pick` picking optional properties which is turning them required somehow.)'
18+
sucrase({
19+
transforms: ['jsx', 'typescript'],
20+
} as unknown as RollupSucraseOptions),
21+
],
22+
23+
// We want to process as few files as possible, so as not to slow down the build any more than we have to. We need the
24+
// proxy module (living in the temporary file we've created) and the file we're wrapping not to be external, because
25+
// otherwise they won't be processed. (We need Rollup to process the former so that we can use the code, and we need
26+
// it to process the latter so it knows what exports to re-export from the proxy module.) Past that, we don't care, so
27+
// don't bother to process anything else.
28+
external: importPath => importPath !== proxyPath && importPath !== resourcePath,
29+
30+
// Prevent rollup from stressing out about TS's use of global `this` when polyfilling await. (TS will polyfill if the
31+
// user's tsconfig `target` is set to anything before `es2017`. See https://stackoverflow.com/a/72822340 and
32+
// https://stackoverflow.com/a/60347490.)
33+
context: 'this',
34+
35+
// Rollup's path-resolution logic when handling re-exports can go wrong when wrapping pages which aren't at the root
36+
// level of the `pages` directory. This may be a bug, as it doesn't match the behavior described in the docs, but what
37+
// seems to happen is this:
38+
//
39+
// - We try to wrap `pages/xyz/userPage.js`, which contains `export { helperFunc } from '../../utils/helper'`
40+
// - Rollup converts '../../utils/helper' into an absolute path
41+
// - We mark the helper module as external
42+
// - Rollup then converts it back to a relative path, but relative to `pages/` rather than `pages/xyz/`. (This is
43+
// the part which doesn't match the docs. They say that Rollup will use the common ancestor of all modules in the
44+
// bundle as the basis for the relative path calculation, but both our temporary file and the page being wrapped
45+
// live in `pages/xyz/`, and they're the only two files in the bundle, so `pages/xyz/`` should be used as the
46+
// root. Unclear why it's not.)
47+
// - As a result of the miscalculation, our proxy module will include `export { helperFunc } from '../utils/helper'`
48+
// rather than the expected `export { helperFunc } from '../../utils/helper'`, thereby causing a build error in
49+
// nextjs..
50+
//
51+
// It's not 100% clear why, but telling it not to do the conversion back from absolute to relative (by setting
52+
// `makeAbsoluteExternalsRelative` to `false`) seems to also prevent it from going from relative to absolute in the
53+
// first place, with the result that the path remains untouched (which is what we want.)
54+
makeAbsoluteExternalsRelative: false,
55+
});
56+
57+
const rollupOutputOptions: RollupOutputOptions = {
58+
format: 'esm',
59+
60+
// Don't create a bundle - we just want the transformed entrypoint file
61+
preserveModules: true,
62+
};
63+
64+
/**
65+
* Use Rollup to process the proxy module file (located at `tempProxyFilePath`) in order to split its `export * from
66+
* '<wrapped file>'` call into individual exports (which nextjs seems to need).
67+
*
68+
* @param tempProxyFilePath The path to the temporary file containing the proxy module code
69+
* @param resourcePath The path to the file being wrapped
70+
* @returns The processed proxy module code, or undefined if an error occurs
71+
*/
72+
export async function rollupize(tempProxyFilePath: string, resourcePath: string): Promise<string | undefined> {
73+
let finalBundle;
74+
75+
try {
76+
const intermediateBundle = await rollup(getRollupInputOptions(tempProxyFilePath, resourcePath));
77+
finalBundle = await intermediateBundle.generate(rollupOutputOptions);
78+
} catch (err) {
79+
__DEBUG_BUILD__ &&
80+
logger.warn(
81+
`Could not wrap ${resourcePath}. An error occurred while processing the proxy module template:\n${err}`,
82+
);
83+
return undefined;
84+
}
85+
86+
// The module at index 0 is always the entrypoint, which in this case is the proxy module.
87+
let { code } = finalBundle.output[0];
88+
89+
// Rollup does a few things to the code we *don't* want. Undo those changes before returning the code.
90+
//
91+
// Nextjs uses square brackets surrounding a path segment to denote a parameter in the route, but Rollup turns those
92+
// square brackets into underscores. Further, Rollup adds file extensions to bare-path-type import and export sources.
93+
// Because it assumes that everything will have already been processed, it always uses `.js` as the added extension.
94+
// We need to restore the original name and extension so that Webpack will be able to find the wrapped file.
95+
const resourceFilename = path.basename(resourcePath);
96+
const mutatedResourceFilename = resourceFilename
97+
// `[\\[\\]]` is the character class containing `[` and `]`
98+
.replace(new RegExp('[\\[\\]]', 'g'), '_')
99+
.replace(/(jsx?|tsx?)$/, 'js');
100+
code = code.replace(new RegExp(mutatedResourceFilename, 'g'), resourceFilename);
101+
102+
return code;
103+
}

0 commit comments

Comments
 (0)