@honeybadger-io/nextjs
Version:
Next.js integration for Honeybadger
583 lines (573 loc) • 24.6 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
var fs = require('fs');
var path = require('path');
var HoneybadgerSourceMapPlugin = require('@honeybadger-io/webpack');
var Honeybadger = require('@honeybadger-io/js');
var nextServer = require('next/server');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
function _interopNamespace(e) {
if (e && e.__esModule) return e;
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n["default"] = e;
return Object.freeze(n);
}
var fs__default = /*#__PURE__*/_interopDefaultLegacy(fs);
var path__default = /*#__PURE__*/_interopDefaultLegacy(path);
var HoneybadgerSourceMapPlugin__default = /*#__PURE__*/_interopDefaultLegacy(HoneybadgerSourceMapPlugin);
var Honeybadger__default = /*#__PURE__*/_interopDefaultLegacy(Honeybadger);
var nextServer__namespace = /*#__PURE__*/_interopNamespace(nextServer);
const URL_DOCS_SOURCE_MAPS_UPLOAD = 'https://docs.honeybadger.io/lib/javascript/integration/nextjs/#source-map-upload-and-tracking-deploys';
let _silent = true;
function log(type, msg) {
if (['error', 'warn'].includes(type) || !_silent) {
console[type]('[HoneybadgerNextJs]', msg);
}
}
function shouldUploadSourceMaps(honeybadgerNextJsConfig, context) {
const { dev } = context;
if (honeybadgerNextJsConfig.disableSourceMapUpload) {
return false;
}
if (!honeybadgerNextJsConfig.webpackPluginOptions || !honeybadgerNextJsConfig.webpackPluginOptions.apiKey) {
log('warn', `skipping source map upload; here's how to enable: ${URL_DOCS_SOURCE_MAPS_UPLOAD}`);
return false;
}
if (dev || process.env.NODE_ENV === 'development') {
return false;
}
return true;
}
function mergeWithExistingWebpackConfig(nextJsWebpackConfig, honeybadgerNextJsConfig) {
return function webpackFunctionMergedWithHb(webpackConfig, context) {
const { isServer, dir: projectDir, nextRuntime } = context;
const configType = isServer ? (nextRuntime === 'edge' ? 'edge' : 'server') : 'browser';
log('debug', `reached webpackFunctionMergedWithHb isServer[${isServer}] configType[${configType}]`);
let result = { ...webpackConfig };
if (typeof nextJsWebpackConfig === 'function') {
result = nextJsWebpackConfig(result, context);
}
const originalEntry = result.entry;
result.entry = async () => injectHoneybadgerConfigToEntry(originalEntry, projectDir, configType);
if (shouldUploadSourceMaps(honeybadgerNextJsConfig, context)) {
// `result.devtool` must be 'hidden-source-map' or 'source-map' to properly pass sourcemaps.
// Next.js uses regular `source-map` which doesnt pass its sourcemaps to Webpack.
// https://github.com/vercel/next.js/blob/89ec21ed686dd79a5770b5c669abaff8f55d8fef/packages/next/build/webpack/config/blocks/base.ts#L40
// Use the hidden-source-map option when you don't want the source maps to be
// publicly available on the servers, only to the error reporting
result.devtool = 'hidden-source-map';
if (!result.plugins) {
result.plugins = [];
}
const options = getWebpackPluginOptions(honeybadgerNextJsConfig);
if (options) {
result.plugins.push(new HoneybadgerSourceMapPlugin__default["default"](options));
}
}
return result;
};
}
async function injectHoneybadgerConfigToEntry(originalEntry, projectDir, configType) {
const result = typeof originalEntry === 'function' ? await originalEntry() : { ...originalEntry };
const hbConfigFile = getHoneybadgerConfigFile(projectDir, configType);
if (!hbConfigFile) {
return result;
}
const hbConfigFileRelativePath = `./${hbConfigFile}`;
if (!Object.keys(result).length) {
log('debug', `no entry points for configType[${configType}]`);
}
for (const entryName in result) {
addHoneybadgerConfigToEntry(result, entryName, hbConfigFileRelativePath, configType);
}
return result;
}
function addHoneybadgerConfigToEntry(entry, entryName, hbConfigFile, configType) {
log('debug', `adding entry[${entryName}] to configType[${configType}]`);
switch (configType) {
case 'server':
if (!entryName.startsWith('pages/')) {
return;
}
break;
case 'browser':
if (!['pages/_app', 'main-app'].includes(entryName)) {
return;
}
break;
}
const currentEntryPoint = entry[entryName];
let newEntryPoint = currentEntryPoint;
if (typeof currentEntryPoint === 'string') {
newEntryPoint = [hbConfigFile, currentEntryPoint];
}
else if (Array.isArray(currentEntryPoint)) {
newEntryPoint = [hbConfigFile, ...currentEntryPoint];
} // descriptor object (webpack 5+)
else if (typeof currentEntryPoint === 'object' && currentEntryPoint && 'import' in currentEntryPoint) {
const currentImportValue = currentEntryPoint['import'];
const newImportValue = [hbConfigFile];
if (typeof currentImportValue === 'string') {
newImportValue.push(currentImportValue);
}
else {
newImportValue.push(...(currentImportValue));
}
newEntryPoint = {
...currentEntryPoint,
import: newImportValue,
};
}
else {
log('error', 'Could not inject Honeybadger config to entry point: ' + JSON.stringify(currentEntryPoint, null, 2));
}
entry[entryName] = newEntryPoint;
}
function getHoneybadgerConfigFile(projectDir, configType) {
const possibilities = [`honeybadger.${configType}.config.ts`, `honeybadger.${configType}.config.js`];
for (const filename of possibilities) {
if (fs__default["default"].existsSync(path__default["default"].resolve(projectDir, filename))) {
return filename;
}
}
log('debug', `could not find config file in ${projectDir} for ${configType}`);
return null;
}
function getWebpackPluginOptions(honeybadgerNextJsConfig) {
var _a, _b, _c;
const apiKey = ((_a = honeybadgerNextJsConfig.webpackPluginOptions) === null || _a === void 0 ? void 0 : _a.apiKey) || process.env.NEXT_PUBLIC_HONEYBADGER_API_KEY;
const assetsUrl = ((_b = honeybadgerNextJsConfig.webpackPluginOptions) === null || _b === void 0 ? void 0 : _b.assetsUrl) || process.env.NEXT_PUBLIC_HONEYBADGER_ASSETS_URL;
if (!apiKey || !assetsUrl) {
log('error', 'Missing Honeybadger required configuration for webpack plugin. Source maps will not be uploaded to Honeybadger.');
return null;
}
return {
...honeybadgerNextJsConfig.webpackPluginOptions,
apiKey,
assetsUrl,
revision: ((_c = honeybadgerNextJsConfig.webpackPluginOptions) === null || _c === void 0 ? void 0 : _c.revision) || process.env.NEXT_PUBLIC_HONEYBADGER_REVISION,
silent: _silent,
};
}
function getNextJsVersionInstalled() {
var _a;
try {
return (_a = require('next/package.json').version) === null || _a === void 0 ? void 0 : _a.split('.');
}
catch (e) {
return null;
}
}
/**
* NextJs will report a warning if the `serverExternalPackages` option is not present.
* This is because @honeybadger-io/js will try to require configuration files dynamically (https://github.com/honeybadger-io/honeybadger-js/pull/1268).
*
* First reported here: https://github.com/honeybadger-io/honeybadger-js/issues/1351
*/
function addServerExternalPackagesOption(config) {
var _a, _b;
// this should be available in the upcoming version of Next.js (14.3.0)
if (config.serverExternalPackages && Array.isArray(config.serverExternalPackages)) {
log('debug', 'adding @honeybadger-io/js to serverExternalPackages');
config.serverExternalPackages.push('@honeybadger-io/js');
return;
}
if (((_a = config.experimental) === null || _a === void 0 ? void 0 : _a.serverComponentsExternalPackages) && Array.isArray((_b = config.experimental) === null || _b === void 0 ? void 0 : _b.serverComponentsExternalPackages)) {
log('debug', 'adding @honeybadger-io/js to experimental.serverComponentsExternalPackages');
config.experimental.serverComponentsExternalPackages.push('@honeybadger-io/js');
return;
}
const nextJsVersion = getNextJsVersionInstalled();
if (nextJsVersion) {
if ((+nextJsVersion[0] === 14 && +nextJsVersion[1] >= 3) || +nextJsVersion[0] > 14) {
log('debug', 'adding serverExternalPackages option with value ["@honeybadger-io/js"]');
config.serverExternalPackages = ['@honeybadger-io/js'];
}
else {
log('debug', 'adding experimental.serverComponentsExternalPackages option with value ["@honeybadger-io/js"]');
if (!config.experimental) {
config.experimental = {};
}
config.experimental.serverComponentsExternalPackages = ['@honeybadger-io/js'];
}
}
}
function setupHoneybadger(config, honeybadgerNextJsConfig) {
var _a;
if (!honeybadgerNextJsConfig) {
honeybadgerNextJsConfig = {
silent: true,
disableSourceMapUpload: false,
};
}
_silent = (_a = honeybadgerNextJsConfig.silent) !== null && _a !== void 0 ? _a : true;
addServerExternalPackagesOption(config);
return {
...config,
webpack: mergeWithExistingWebpackConfig(config.webpack, honeybadgerNextJsConfig)
};
}
/**
* Edge-safe equivalents of the inbound instrumentation helpers in
* `@honeybadger-io/js` (src/server/instrumentation/http_event.ts). They are
* duplicated here because this module must also load on the edge runtime where
* Node builtins (the `crypto` module, `process.hrtime`) are unavailable. Keep
* the header names and the `request_id` / `correlation_id` contract in sync
* with that file.
*
* Both request shapes Next.js uses are supported: the `*RequestEventContext` /
* `*RequestEvent` pairs come in a web-`Headers`/`Request` variant (App Router
* route handlers and middleware) and a Node-bag variant (Pages Router API
* routes, which only ever run on the Node runtime).
*/
function generateId() {
const webCrypto = globalThis.crypto;
if (webCrypto && typeof webCrypto.randomUUID === 'function') {
try {
return webCrypto.randomUUID();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
catch (error) {
// fall through to manual generation
}
}
// v4-shaped, not crypto-quality. Acceptable since this is a correlation id,
// not a security token.
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function readHeader(headers, name) {
const value = headers.get(name);
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length ? trimmed : undefined;
}
function readNodeHeader(headers, name) {
if (!headers) {
return undefined;
}
const lower = name.toLowerCase();
let value = headers[lower];
if (value === undefined) {
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === lower) {
value = headers[key];
break;
}
}
}
if (Array.isArray(value)) {
value = value[0];
}
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length ? trimmed : undefined;
}
// Shared id precedence. Kept in one place (rather than once per request shape)
// so the header-name contract documented above is only spelled out once.
function seedIds(read) {
var _a, _b, _c, _d;
const requestId = (_b = (_a = read('x-request-id')) !== null && _a !== void 0 ? _a : read('request-id')) !== null && _b !== void 0 ? _b : generateId();
const correlationId = (_d = (_c = read('x-correlation-id')) !== null && _c !== void 0 ? _c : read('x-amzn-trace-id')) !== null && _d !== void 0 ? _d : requestId;
return { request_id: requestId, correlation_id: correlationId };
}
// App Router / middleware: headers are a web `Headers` instance.
function seedRequestEventContext(headers) {
return seedIds((name) => readHeader(headers, name));
}
// Pages Router: headers are a Node bag (Pages routes are Node-only, never edge).
function seedNodeRequestEventContext(headers) {
return seedIds((name) => readNodeHeader(headers, name));
}
function now() {
return typeof performance !== 'undefined' ? performance.now() : Date.now();
}
// Mirrors Util.resolveInsights from @honeybadger-io/core: the master gate and
// the per-source flag must both be on.
function insightsHttpEnabled() {
const insights = Honeybadger__default["default"].config.insights;
return (insights === null || insights === void 0 ? void 0 : insights.enabled) === true && (insights === null || insights === void 0 ? void 0 : insights.http) === true;
}
// The ids are embedded directly in the payload (instead of relying on the
// store's eventContext merge) so the event carries them even on the edge
// runtime, where there is no per-request store isolation. On the Node.js
// runtime they match the seeded event context, so embedding is a no-op.
function emitHandledEvent(method, path, status, start, ids) {
const payload = {
method,
duration: Math.round(now() - start),
...ids,
};
if (typeof path === 'string') {
payload.path = path;
}
if (typeof status === 'number') {
payload.status = status;
}
Honeybadger__default["default"].event('request.handled', payload);
}
// App Router / middleware: `req.url` is absolute, so parse out the pathname.
function emitRequestEvent(req, status, start, ids) {
let path;
try {
path = new URL(req.url).pathname;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
catch (error) {
// relative or malformed URL — leave path unset
}
emitHandledEvent(req.method, path, status, start, ids);
}
// Pages Router: `req.url` is a relative path that may carry a query string.
function emitNodeRequestEvent(req, status, start, ids) {
const path = typeof req.url === 'string' ? req.url.split('?')[0] : undefined;
emitHandledEvent(req.method, path, status, start, ids);
}
/**
* The `waitUntil` primitive the hosting platform injects per request. Next.js
* resolves `after()` through this same accessor, and it is the only channel
* available in Pages Router API routes, which are invoked as `(req, res)` with
* no context argument to read it from.
*/
function requestContextWaitUntil() {
var _a, _b;
const context = globalThis[Symbol.for('@next/request-context')];
const waitUntil = (_b = (_a = context === null || context === void 0 ? void 0 : context.get) === null || _a === void 0 ? void 0 : _a.call(context)) === null || _b === void 0 ? void 0 : _b.waitUntil;
return typeof waitUntil === 'function' ? waitUntil : undefined;
}
/**
* Middleware receives a `NextFetchEvent` as its second argument. Duck-typed
* rather than `instanceof` so the edge bundle needs no runtime import, and
* bound because `waitUntil` is a class method that collects into the event.
*/
function eventWaitUntil(event) {
const waitUntil = event === null || event === void 0 ? void 0 : event.waitUntil;
return typeof waitUntil === 'function' ? waitUntil.bind(event) : undefined;
}
/**
* Ensure Insights events are delivered before the serverless/edge runtime
* freezes, without delaying the response where the runtime lets us avoid it.
*
* In order of preference: Next.js `after()` (stable in 15.1, App Router only —
* it needs App Router request context, so Pages Router must not call it), then
* a `waitUntil` from the middleware event or the platform request context,
* then a blocking `flushAsync()` when the runtime offers neither. Blocking is
* correct in that last case: no `waitUntil` means nothing is going to freeze
* the invocation out from under us.
*
* Delivery failures are logged by the events worker and must not break the handler.
*/
function scheduleFlush(options = {}) {
var _a;
const flush = () => Honeybadger__default["default"].flushAsync().catch(() => { });
if (options.useAfter) {
const after = nextServer__namespace.after;
if (typeof after === 'function') {
// Exported but still refusable: `after()` throws outside a supported
// context. Fall through to the remaining strategies rather than failing
// the request.
try {
after(flush);
return;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
catch (error) {
// try waitUntil / blocking flush below
}
}
}
const waitUntil = (_a = options.waitUntil) !== null && _a !== void 0 ? _a : requestContextWaitUntil();
if (waitUntil) {
waitUntil(flush());
return;
}
return flush();
}
function configure(overrides) {
var _a;
if (((_a = Honeybadger__default["default"].config.apiKey) === null || _a === void 0 ? void 0 : _a.length) > 0) {
return;
}
let projectRoot = undefined;
try {
// not available on edge runtime
projectRoot = process.cwd();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
catch (error) {
// do nothing
}
Honeybadger__default["default"]
.configure({
apiKey: process.env.NEXT_PUBLIC_HONEYBADGER_API_KEY,
environment: process.env.NEXT_PUBLIC_VERCEL_ENV || process.env.VERCEL_ENV || process.env.NODE_ENV,
revision: process.env.NEXT_PUBLIC_HONEYBADGER_REVISION,
projectRoot: 'webpack://_N_E/./',
...overrides,
})
.beforeNotify((notice) => {
if (!projectRoot) {
return;
}
notice === null || notice === void 0 ? void 0 : notice.backtrace.forEach((line) => {
if (line.file) {
line.file = line.file.replace(`${projectRoot}/.next/server`, `${process.env.NEXT_PUBLIC_HONEYBADGER_ASSETS_URL}/..`);
}
return line;
});
});
}
/**
* Next.js uses thrown errors for control flow: `redirect()`, `notFound()`,
* `forbidden()` and `unauthorized()` all throw an error carrying a `digest`
* string (`NEXT_REDIRECT;...`, `NEXT_NOT_FOUND`, `NEXT_HTTP_ERROR_FALLBACK;...`).
* These are not real failures — the framework catches them upstream to produce
* the redirect/404/etc. — so we must let them propagate without reporting them,
* otherwise every redirect shows up as an error in Honeybadger.
*
* We match on the `NEXT_` prefix rather than an exhaustive list so that any
* present or future framework control-flow digest is covered. This is safe:
* genuine errors that React tags with a `digest` use an opaque hash, and other
* Next.js bailout signals (e.g. `BAILOUT_TO_CLIENT_SIDE_RENDERING`,
* `DYNAMIC_SERVER_USAGE`) are not `NEXT_`-prefixed, so neither is skipped.
*/
function isNextControlFlowError(error) {
const digest = error === null || error === void 0 ? void 0 : error.digest;
return typeof digest === 'string' && digest.startsWith('NEXT_');
}
/**
* Detects a Pages Router API invocation: `(req, res)` where `res` is a Node
* `ServerResponse`. We branch on this structurally because — unlike an App
* Router route handler — there is no returned `Response` to read the status
* from; it lives on `res.statusCode`.
*/
function isPagesApiInvocation(args) {
const req = args[0];
const res = args[1];
return (!!req && typeof req.headers === 'object' && req.headers !== null &&
!!res && typeof res.statusCode === 'number' && typeof res.end === 'function');
}
/**
* App Router route handlers and middleware: a web `Request`/`NextRequest` in, a
* `Response`/`NextResponse` out. The status comes from the returned response.
*
* `waitUntil` is present for middleware (from its `NextFetchEvent`); route
* handlers get `{ params }` as their second argument and rely on `after()`.
*/
async function handleAppRouterRequest(call, req, canIsolate, waitUntil) {
const ids = seedRequestEventContext(req.headers);
if (canIsolate) {
Honeybadger__default["default"].setEventContext(ids);
}
const start = insightsHttpEnabled() ? now() : null;
try {
const response = await call();
if (start !== null) {
emitRequestEvent(req, response === null || response === void 0 ? void 0 : response.status, start, ids);
await scheduleFlush({ useAfter: true, waitUntil });
}
return response;
}
catch (error) {
if (isNextControlFlowError(error)) {
throw error;
}
if (start !== null) {
emitRequestEvent(req, 500, start, ids);
await scheduleFlush({ useAfter: true, waitUntil });
}
await Honeybadger__default["default"].notifyAsync(error);
throw error;
}
}
/**
* Pages Router API routes: a Node `req`/`res` pair. The handler writes to `res`
* and returns nothing meaningful, so the final status is read from
* `res.statusCode` once it resolves.
*/
async function handlePagesApiRequest(call, req, res, canIsolate) {
const ids = seedNodeRequestEventContext(req.headers);
if (canIsolate) {
Honeybadger__default["default"].setEventContext(ids);
}
const start = insightsHttpEnabled() ? now() : null;
try {
const result = await call();
if (start !== null) {
emitNodeRequestEvent(req, res.statusCode, start, ids);
// No after() here: Pages Router lacks the App Router request context it
// needs. scheduleFlush falls through to the platform waitUntil instead.
await scheduleFlush({ useAfter: false });
}
return result;
}
catch (error) {
if (isNextControlFlowError(error)) {
throw error;
}
if (start !== null) {
emitNodeRequestEvent(req, 500, start, ids);
await scheduleFlush({ useAfter: false });
}
await Honeybadger__default["default"].notifyAsync(error);
throw error;
}
}
/**
* Unrecognised invocation shape: still report errors, but emit no insights
* event since we can't reliably read the request.
*/
async function handleUninstrumented(call) {
try {
return await call();
}
catch (error) {
if (isNextControlFlowError(error)) {
throw error;
}
await Honeybadger__default["default"].notifyAsync(error);
throw error;
}
}
function withHoneybadger(handler, config) {
configure(config);
return new Proxy(handler, {
apply: (target, thisArg, args) => {
const canIsolate = typeof Honeybadger__default["default"].run === 'function';
const call = () => Reflect.apply(target, thisArg, args);
const invoke = () => {
// App Router / middleware first: a web Request as the first argument.
if (typeof Request !== 'undefined' && args[0] instanceof Request) {
return handleAppRouterRequest(call, args[0], canIsolate, eventWaitUntil(args[1]));
}
// Pages Router API route: a Node req/res pair.
if (isPagesApiInvocation(args)) {
return handlePagesApiRequest(call, args[0], args[1], canIsolate);
}
return handleUninstrumented(call);
};
return canIsolate ? Honeybadger__default["default"].run(invoke) : invoke();
},
});
}
exports.setupHoneybadger = setupHoneybadger;
exports.withHoneybadger = withHoneybadger;
//# sourceMappingURL=honeybadger-nextjs.cjs.js.map