Skip to content

Commit de5b4d8

Browse files
committed
wrap API routes automatically using webpack
1 parent 199b988 commit de5b4d8

File tree

2 files changed

+147
-5
lines changed

2 files changed

+147
-5
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// there's apparently no way to get at this with `import`
2+
// TODO - really?
3+
// eslint-disable-next-line @typescript-eslint/no-var-requires
4+
const Module = require('module');
5+
6+
import { NextApiHandler } from 'next';
7+
8+
import * as Sentry from '../index.server';
9+
10+
type ModuleObject = {
11+
_compile: (code: string, filename: string) => void;
12+
exports: { default: unknown };
13+
};
14+
15+
type LoaderContext = { resource: string; loaders: Loader[] };
16+
type Loader = { options: LoaderOptions; path: string };
17+
type LoaderOptions = { sdkPath: string };
18+
19+
type WrappedNextApiHandler = NextApiHandler; // purely for ease of reading
20+
21+
/**
22+
* Replace the API route handler in the given code with a wrapped version.
23+
*
24+
* @param this Context data passed to the loader
25+
* @param rawInput The stringified code we're modifying
26+
* @returns Modified stringified code
27+
*/
28+
export default function load(this: LoaderContext, rawInput: string): string {
29+
const options = getOptions(this.loaders) as LoaderOptions;
30+
31+
// Wherever this is running, it can't seem to resolve the Sentry SDK when referred to by
32+
// name (which it will have to do below when it compiles the stringified code into an actual module). Fortunately,
33+
// we're able to do so from within our config file, so we just pass the absolute path through in `options`
34+
const origCode = rawInput.replace('@sentry/nextjs', options.sdkPath);
35+
36+
// `module.parent` comes back as `null` rather than `undefined` when there is no parent, but the `Module` constructor
37+
// below needs `undefined` instead. Because reasons. (We need to ignore the deprecation warning because `parent` may
38+
// be deprecated, but the `Module` constructor still uses it as of 16.1.0. See
39+
// https://github.com/nodejs/node/blob/26e318a321a872bc0f41e60706bb49381684afb2/lib/internal/modules/cjs/loader.js#L168.)
40+
// eslint-disable-next-line deprecation/deprecation
41+
const parent = module.parent || undefined;
42+
// It's unclear what this does for us, if anything
43+
const filename = 'lookIMadeAModule';
44+
45+
// Compile the stringified code into an actual Module object so we can grab its default export (the route handler) for
46+
// wrapping
47+
const routeModule = new Module(filename, parent) as ModuleObject;
48+
routeModule._compile(origCode, filename);
49+
const origHandler = routeModule.exports.default;
50+
51+
if (typeof origHandler !== 'function') {
52+
// eslint-disable-next-line no-console
53+
console.warn(`[Sentry] Could not wrap ${this.resource} for error handling. Default export is not a function.`);
54+
return rawInput;
55+
}
56+
57+
// Wrap the route handler in a try/catch to catch any errors which it generates
58+
const newHandler = makeWrappedRequestHandler(origHandler as NextApiHandler);
59+
60+
// Ultimately we have to return a string, and we need the wrapped handler to take the place of the original one (as
61+
// the default export) so literally substitute it in
62+
let newCode = origCode.replace(origHandler.toString(), newHandler.toString());
63+
64+
// The new function we just subbed in is, character for character, the code written below as the return value of
65+
// `makeWrappedRequestHandler`, which means we have to define `origHandler`, since its code has now been replaced
66+
newCode = `${newCode}\n\nconst origHandler = ${origHandler.toString()}`;
67+
68+
return newCode;
69+
}
70+
71+
/** Extract the options for this loader out of the array of loaders in scope */
72+
function getOptions(loaders: Loader[]): LoaderOptions | undefined {
73+
for (const loader of loaders) {
74+
if (loader.path.includes('nextjs/dist/utils/api-wrapping-loader')) {
75+
return loader.options;
76+
}
77+
}
78+
// we shouldn't ever get here - one of the given loaders should be this loader
79+
return undefined;
80+
}
81+
82+
/** Wrap the given request handler for error-catching purposes */
83+
function makeWrappedRequestHandler(origHandler: NextApiHandler): WrappedNextApiHandler {
84+
// TODO are there any overloads we need to worry about?
85+
return async (req, res) => {
86+
try {
87+
return await origHandler(req, res);
88+
} catch (err) {
89+
if (Sentry !== undefined) {
90+
Sentry.withScope(scope => {
91+
scope.addEventProcessor(event => Sentry.Handlers.parseRequest(event, req));
92+
Sentry.captureException(err);
93+
});
94+
Sentry.flush(2000).catch(() => {
95+
// Never throws
96+
});
97+
} else {
98+
// eslint-disable-next-line no-console
99+
console.warn('[Sentry] SDK is disabled. Please make sure to initialize Sentry in `sentry.server.config.js`.');
100+
}
101+
throw err;
102+
}
103+
};
104+
}

packages/nextjs/src/utils/config.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getSentryRelease } from '@sentry/node';
22
import { logger } from '@sentry/utils';
33
import defaultWebpackPlugin, { SentryCliPluginOptions } from '@sentry/webpack-plugin';
44
import * as SentryWebpackPlugin from '@sentry/webpack-plugin';
5+
import * as path from 'path';
56

67
// eslint-disable-next-line @typescript-eslint/no-explicit-any
78
type PlainObject<T = any> = { [key: string]: T };
@@ -14,7 +15,9 @@ type PlainObject<T = any> = { [key: string]: T };
1415
type WebpackExport = (config: WebpackConfig, options: WebpackOptions) => WebpackConfig;
1516

1617
// The two arguments passed to the exported `webpack` function, as well as the thing it returns
17-
type WebpackConfig = { devtool: string; plugins: PlainObject[]; entry: EntryProperty };
18+
// TODO use real webpack types?
19+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20+
type WebpackConfig = { devtool: string; plugins: PlainObject[]; entry: EntryProperty; module: { rules: any[] } };
1821
type WebpackOptions = { dev: boolean; isServer: boolean; buildId: string };
1922

2023
// For our purposes, the value for `entry` is either an object, or a function which returns such an object
@@ -41,8 +44,9 @@ const _injectFile = (entryProperty: EntryPropertyObject, injectionPoint: string,
4144
return;
4245
}
4346

44-
// In case we inject our client config, we need to add it after the frontend code
45-
// otherwise the runtime config isn't loaded. See: https://github.com/getsentry/sentry-javascript/issues/3485
47+
// We need to inject the user-provided config file after the frontend code so that it has access to the runtime config
48+
// (which won't have loaded yet otherwise). See: https://github.com/getsentry/sentry-javascript/issues/3485. On the
49+
// server side, we inject beforehand so that we can catch any errors in the code that follows.
4650
const isClient = injectee === sentryClientConfig;
4751

4852
if (typeof injectedInto === 'string') {
@@ -68,8 +72,6 @@ const _injectFile = (entryProperty: EntryPropertyObject, injectionPoint: string,
6872
};
6973

7074
const injectSentry = async (origEntryProperty: EntryProperty, isServer: boolean): Promise<EntryProperty> => {
71-
// Out of the box, nextjs uses the `() => Promise<EntryPropertyObject>)` flavor of EntryProperty, where the returned
72-
// object has string arrays for values.
7375
// The `entry` entry in a webpack config can be a string, array of strings, object, or function. By default, nextjs
7476
// sets it to an async function which returns the promise of an object of string arrays. Because we don't know whether
7577
// someone else has come along before us and changed that, we need to check a few things along the way. The one thing
@@ -151,6 +153,42 @@ export function withSentryConfig(
151153
newConfig.devtool = 'source-map';
152154
}
153155

156+
// Wherever the webpack process ultimately runs, it's not somewhere where modules resolve successfully, so get the
157+
// absolute paths here, where resolution does work, and pass those into the config instead of the module names
158+
const sdkResolvedMain = require.resolve('@sentry/nextjs');
159+
const loaderPath = path.join(path.dirname(sdkResolvedMain), 'utils/api-wrapping-loader.js');
160+
const babelLoaderResolvedMain = require.resolve('babel-loader');
161+
const babelPluginResolvedMain = require.resolve('@babel/plugin-transform-modules-commonjs');
162+
163+
newConfig.module = {
164+
...newConfig.module,
165+
rules: [
166+
...newConfig.module.rules,
167+
{
168+
test: /pages\/api\/.*/,
169+
use: [
170+
{
171+
// `sdkResolvedMain` is the path to `dist/index.server.js`
172+
loader: loaderPath,
173+
options: {
174+
// pass the path into the loader so it can be used there as well (it's another place where modules don't
175+
// resolve well)
176+
sdkPath: sdkResolvedMain,
177+
},
178+
},
179+
{
180+
loader: babelLoaderResolvedMain,
181+
options: {
182+
// this is here to turn any `import`s into `requires`, so that our loader can load the string as a
183+
// module (loaders apply top to bottom, so this happens before ours)
184+
plugins: [babelPluginResolvedMain],
185+
},
186+
},
187+
],
188+
},
189+
],
190+
};
191+
154192
// Inject user config files (`sentry.client.confg.js` and `sentry.server.config.js`), which is where `Sentry.init()`
155193
// is called. By adding them here, we ensure that they're bundled by webpack as part of both server code and client code.
156194
newConfig.entry = (injectSentry(newConfig.entry, options.isServer) as unknown) as EntryProperty;

0 commit comments

Comments
 (0)