Skip to content

ref(nextjs): [Experiment] Automatically wrap API route handlers #3469

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

Closed
wants to merge 2 commits into from
Closed
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
3 changes: 3 additions & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@
"access": "public"
},
"dependencies": {
"@babel/core": "7.x",
"@babel/plugin-transform-modules-commonjs": "^7.13.8",
"@sentry/core": "6.3.5",
"@sentry/integrations": "6.3.5",
"@sentry/node": "6.3.5",
"@sentry/react": "6.3.5",
"@sentry/utils": "6.3.5",
"@sentry/webpack-plugin": "1.15.0",
"babel-loader": "^8.2.2",
"tslib": "^1.9.3"
},
"devDependencies": {
Expand Down
104 changes: 104 additions & 0 deletions packages/nextjs/src/utils/api-wrapping-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// there's apparently no way to get at this with `import`
// TODO - really?
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Module = require('module');

import { NextApiHandler } from 'next';

import * as Sentry from '../index.server';

type ModuleObject = {
_compile: (code: string, filename: string) => void;
exports: { default: unknown };
};

type LoaderContext = { resource: string; loaders: Loader[] };
type Loader = { options: LoaderOptions; path: string };
type LoaderOptions = { sdkPath: string };

type WrappedNextApiHandler = NextApiHandler; // purely for ease of reading

/**
* Replace the API route handler in the given code with a wrapped version.
*
* @param this Context data passed to the loader
* @param rawInput The stringified code we're modifying
* @returns Modified stringified code
*/
export default function load(this: LoaderContext, rawInput: string): string {
const options = getOptions(this.loaders) as LoaderOptions;

// Wherever this is running, it can't seem to resolve the Sentry SDK when referred to by
// name (which it will have to do below when it compiles the stringified code into an actual module). Fortunately,
// we're able to do so from within our config file, so we just pass the absolute path through in `options`
const origCode = rawInput.replace('@sentry/nextjs', options.sdkPath);

// `module.parent` comes back as `null` rather than `undefined` when there is no parent, but the `Module` constructor
// below needs `undefined` instead. Because reasons. (We need to ignore the deprecation warning because `parent` may
// be deprecated, but the `Module` constructor still uses it as of 16.1.0. See
// https://github.com/nodejs/node/blob/26e318a321a872bc0f41e60706bb49381684afb2/lib/internal/modules/cjs/loader.js#L168.)
// eslint-disable-next-line deprecation/deprecation
const parent = module.parent || undefined;
// It's unclear what this does for us, if anything
const filename = 'lookIMadeAModule';

// Compile the stringified code into an actual Module object so we can grab its default export (the route handler) for
// wrapping
const routeModule = new Module(filename, parent) as ModuleObject;
routeModule._compile(origCode, filename);
const origHandler = routeModule.exports.default;

if (typeof origHandler !== 'function') {
// eslint-disable-next-line no-console
console.warn(`[Sentry] Could not wrap ${this.resource} for error handling. Default export is not a function.`);
return rawInput;
}

// Wrap the route handler in a try/catch to catch any errors which it generates
const newHandler = makeWrappedRequestHandler(origHandler as NextApiHandler);

// Ultimately we have to return a string, and we need the wrapped handler to take the place of the original one (as
// the default export) so literally substitute it in
let newCode = origCode.replace(origHandler.toString(), newHandler.toString());

// The new function we just subbed in is, character for character, the code written below as the return value of
// `makeWrappedRequestHandler`, which means we have to define `origHandler`, since its code has now been replaced
newCode = `${newCode}\n\nconst origHandler = ${origHandler.toString()}`;

return newCode;
}

/** Extract the options for this loader out of the array of loaders in scope */
function getOptions(loaders: Loader[]): LoaderOptions | undefined {
for (const loader of loaders) {
if (loader.path.includes('nextjs/dist/utils/api-wrapping-loader')) {
return loader.options;
}
}
// we shouldn't ever get here - one of the given loaders should be this loader
return undefined;
}

/** Wrap the given request handler for error-catching purposes */
function makeWrappedRequestHandler(origHandler: NextApiHandler): WrappedNextApiHandler {
// TODO are there any overloads we need to worry about?
return async (req, res) => {
try {
return await origHandler(req, res);
} catch (err) {
if (Sentry !== undefined) {
Sentry.withScope(scope => {
scope.addEventProcessor(event => Sentry.Handlers.parseRequest(event, req));
Sentry.captureException(err);
});
Sentry.flush(2000).catch(() => {
// Never throws
});
} else {
// eslint-disable-next-line no-console
console.warn('[Sentry] SDK is disabled. Please make sure to initialize Sentry in `sentry.server.config.js`.');
}
throw err;
}
};
}
48 changes: 43 additions & 5 deletions packages/nextjs/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getSentryRelease } from '@sentry/node';
import { logger } from '@sentry/utils';
import defaultWebpackPlugin, { SentryCliPluginOptions } from '@sentry/webpack-plugin';
import * as SentryWebpackPlugin from '@sentry/webpack-plugin';
import * as path from 'path';

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

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

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

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

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

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

// Wherever the webpack process ultimately runs, it's not somewhere where modules resolve successfully, so get the
// absolute paths here, where resolution does work, and pass those into the config instead of the module names
const sdkResolvedMain = require.resolve('@sentry/nextjs');
const loaderPath = path.join(path.dirname(sdkResolvedMain), 'utils/api-wrapping-loader.js');
const babelLoaderResolvedMain = require.resolve('babel-loader');
const babelPluginResolvedMain = require.resolve('@babel/plugin-transform-modules-commonjs');

newConfig.module = {
...newConfig.module,
rules: [
...newConfig.module.rules,
{
test: /pages\/api\/.*/,
use: [
{
// `sdkResolvedMain` is the path to `dist/index.server.js`
loader: loaderPath,
options: {
// pass the path into the loader so it can be used there as well (it's another place where modules don't
// resolve well)
sdkPath: sdkResolvedMain,
},
},
{
loader: babelLoaderResolvedMain,
options: {
// this is here to turn any `import`s into `requires`, so that our loader can load the string as a
// module (loaders apply top to bottom, so this happens before ours)
plugins: [babelPluginResolvedMain],
},
},
],
},
],
};

// Inject user config files (`sentry.client.confg.js` and `sentry.server.config.js`), which is where `Sentry.init()`
// is called. By adding them here, we ensure that they're bundled by webpack as part of both server code and client code.
newConfig.entry = (injectSentry(newConfig.entry, options.isServer) as unknown) as EntryProperty;
Expand Down
102 changes: 101 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,32 @@
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.13.12.tgz#a8a5ccac19c200f9dd49624cac6e19d7be1236a1"
integrity sha512-3eJJ841uKxeV8dcN/2yGEUy+RfgQspPEgQat85umsE1rotuquQ2AbIub4S6j7c50a2d+4myc+zSlnXeIHrOnhQ==

"@babel/compat-data@^7.13.15":
version "7.14.0"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.14.0.tgz#a901128bce2ad02565df95e6ecbf195cf9465919"
integrity sha512-vu9V3uMM/1o5Hl5OekMUowo3FqXLJSw+s+66nt0fSWVWTtmosdzn45JHOB3cPtZoe6CTBDzvSw0RdOY85Q37+Q==

"@babel/core@7.x":
version "7.14.0"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.14.0.tgz#47299ff3ec8d111b493f1a9d04bf88c04e728d88"
integrity sha512-8YqpRig5NmIHlMLw09zMlPTvUVMILjqCOtVgu+TVNWEBvy9b5I3RRyhqnrV4hjgEK7n8P9OqvkWJAFmEL6Wwfw==
dependencies:
"@babel/code-frame" "^7.12.13"
"@babel/generator" "^7.14.0"
"@babel/helper-compilation-targets" "^7.13.16"
"@babel/helper-module-transforms" "^7.14.0"
"@babel/helpers" "^7.14.0"
"@babel/parser" "^7.14.0"
"@babel/template" "^7.12.13"
"@babel/traverse" "^7.14.0"
"@babel/types" "^7.14.0"
convert-source-map "^1.7.0"
debug "^4.1.0"
gensync "^1.0.0-beta.2"
json5 "^2.1.2"
semver "^6.3.0"
source-map "^0.5.0"

"@babel/core@^7.1.0", "@babel/core@^7.1.6", "@babel/core@^7.12.0", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.3.4":
version "7.13.14"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.14.tgz#8e46ebbaca460a63497c797e574038ab04ae6d06"
Expand Down Expand Up @@ -72,6 +98,15 @@
jsesc "^2.5.1"
source-map "^0.5.0"

"@babel/generator@^7.14.0":
version "7.14.1"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.14.1.tgz#1f99331babd65700183628da186f36f63d615c93"
integrity sha512-TMGhsXMXCP/O1WtQmZjpEYDhCYC9vFhayWZPJSZCGkPJgUqX0rF0wwtrYvnzVxIjcF80tkUertXVk5cwqi5cAQ==
dependencies:
"@babel/types" "^7.14.1"
jsesc "^2.5.1"
source-map "^0.5.0"

"@babel/helper-annotate-as-pure@^7.12.13":
version "7.12.13"
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.13.tgz#0f58e86dfc4bb3b1fcd7db806570e177d439b6ab"
Expand All @@ -97,6 +132,16 @@
browserslist "^4.14.5"
semver "^6.3.0"

"@babel/helper-compilation-targets@^7.13.16":
version "7.13.16"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.16.tgz#6e91dccf15e3f43e5556dffe32d860109887563c"
integrity sha512-3gmkYIrpqsLlieFwjkGgLaSHmhnvlAYzZLlYVjlW+QwI+1zE17kGxuJGmIqDQdYp56XdmGeD+Bswx0UTyG18xA==
dependencies:
"@babel/compat-data" "^7.13.15"
"@babel/helper-validator-option" "^7.12.17"
browserslist "^4.14.5"
semver "^6.3.0"

"@babel/helper-create-class-features-plugin@^7.13.0", "@babel/helper-create-class-features-plugin@^7.5.5", "@babel/helper-create-class-features-plugin@^7.8.3":
version "7.13.11"
resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.13.11.tgz#30d30a005bca2c953f5653fc25091a492177f4f6"
Expand Down Expand Up @@ -189,6 +234,20 @@
"@babel/traverse" "^7.13.13"
"@babel/types" "^7.13.14"

"@babel/helper-module-transforms@^7.14.0":
version "7.14.0"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.14.0.tgz#8fcf78be220156f22633ee204ea81f73f826a8ad"
integrity sha512-L40t9bxIuGOfpIGA3HNkJhU9qYrf4y5A5LUSw7rGMSn+pcG8dfJ0g6Zval6YJGd2nEjI7oP00fRdnhLKndx6bw==
dependencies:
"@babel/helper-module-imports" "^7.13.12"
"@babel/helper-replace-supers" "^7.13.12"
"@babel/helper-simple-access" "^7.13.12"
"@babel/helper-split-export-declaration" "^7.12.13"
"@babel/helper-validator-identifier" "^7.14.0"
"@babel/template" "^7.12.13"
"@babel/traverse" "^7.14.0"
"@babel/types" "^7.14.0"

"@babel/helper-optimise-call-expression@^7.12.13":
version "7.12.13"
resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.13.tgz#5c02d171b4c8615b1e7163f888c1c81c30a2aaea"
Expand Down Expand Up @@ -246,6 +305,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed"
integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==

"@babel/helper-validator-identifier@^7.14.0":
version "7.14.0"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.0.tgz#d26cad8a47c65286b15df1547319a5d0bcf27288"
integrity sha512-V3ts7zMSu5lfiwWDVWzRDGIN+lnCEUdaXgtVHJgLb1rGaA6jMrtB9EmE7L18foXJIE8Un/A/h6NJfGQp/e1J4A==

"@babel/helper-validator-option@^7.12.17":
version "7.12.17"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.17.tgz#d1fbf012e1a79b7eebbfdc6d270baaf8d9eb9831"
Expand All @@ -270,6 +334,15 @@
"@babel/traverse" "^7.13.0"
"@babel/types" "^7.13.0"

"@babel/helpers@^7.14.0":
version "7.14.0"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.14.0.tgz#ea9b6be9478a13d6f961dbb5f36bf75e2f3b8f62"
integrity sha512-+ufuXprtQ1D1iZTO/K9+EBRn+qPWMJjZSw/S0KlFrxCw4tkrzv9grgpDHkY9MeQTjTY8i2sp7Jep8DfU6tN9Mg==
dependencies:
"@babel/template" "^7.12.13"
"@babel/traverse" "^7.14.0"
"@babel/types" "^7.14.0"

"@babel/highlight@^7.10.4", "@babel/highlight@^7.12.13":
version "7.13.10"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.13.10.tgz#a8b2a66148f5b27d666b15d81774347a731d52d1"
Expand All @@ -284,6 +357,11 @@
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.13.tgz#42f03862f4aed50461e543270916b47dd501f0df"
integrity sha512-OhsyMrqygfk5v8HmWwOzlYjJrtLaFhF34MrfG/Z73DgYCI6ojNUTUp2TYbtnjo8PegeJp12eamsNettCQjKjVw==

"@babel/parser@^7.14.0":
version "7.14.1"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.14.1.tgz#1bd644b5db3f5797c4479d89ec1817fe02b84c47"
integrity sha512-muUGEKu8E/ftMTPlNp+mc6zL3E9zKWmF5sDHZ5MSsoTP9Wyz64AhEf9kD08xYJ7w6Hdcu8H550ircnPyWSIF0Q==

"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.13.12":
version "7.13.12"
resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.13.12.tgz#a3484d84d0b549f3fc916b99ee4783f26fabad2a"
Expand Down Expand Up @@ -957,6 +1035,20 @@
debug "^4.1.0"
globals "^11.1.0"

"@babel/traverse@^7.14.0":
version "7.14.0"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.14.0.tgz#cea0dc8ae7e2b1dec65f512f39f3483e8cc95aef"
integrity sha512-dZ/a371EE5XNhTHomvtuLTUyx6UEoJmYX+DT5zBCQN3McHemsuIaKKYqsc/fs26BEkHs/lBZy0J571LP5z9kQA==
dependencies:
"@babel/code-frame" "^7.12.13"
"@babel/generator" "^7.14.0"
"@babel/helper-function-name" "^7.12.13"
"@babel/helper-split-export-declaration" "^7.12.13"
"@babel/parser" "^7.14.0"
"@babel/types" "^7.14.0"
debug "^4.1.0"
globals "^11.1.0"

"@babel/types@7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.3.tgz#5a383dffa5416db1b73dedffd311ffd0788fb31c"
Expand All @@ -975,6 +1067,14 @@
lodash "^4.17.19"
to-fast-properties "^2.0.0"

"@babel/types@^7.14.0", "@babel/types@^7.14.1":
version "7.14.1"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.14.1.tgz#095bd12f1c08ab63eff6e8f7745fa7c9cc15a9db"
integrity sha512-S13Qe85fzLs3gYRUnrpyeIrBJIMYv33qSTg1qoBwiG6nPKwUWAD9odSzWhEedpwOIzSEI6gbdQIWEMiCI42iBA==
dependencies:
"@babel/helper-validator-identifier" "^7.14.0"
to-fast-properties "^2.0.0"

"@cnakazawa/watch@^1.0.3":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a"
Expand Down Expand Up @@ -4600,7 +4700,7 @@ babel-jest@^24.9.0:
chalk "^2.4.2"
slash "^2.0.0"

babel-loader@^8.0.6:
babel-loader@^8.0.6, babel-loader@^8.2.2:
version "8.2.2"
resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.2.2.tgz#9363ce84c10c9a40e6c753748e1441b60c8a0b81"
integrity sha512-JvTd0/D889PQBtUXJ2PXaKU/pjZDMtHA9V2ecm+eNRmmBCMR09a+fmpGTNwnJtFmFl5Ei7Vy47LjBb+L0wQ99g==
Expand Down