diff --git a/package-lock.json b/package-lock.json index 6488ac64bc..4c2566acbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11736,6 +11736,15 @@ "@types/node": "*" } }, + "node_modules/@types/byline": { + "version": "4.2.36", + "resolved": "https://registry.npmjs.org/@types/byline/-/byline-4.2.36.tgz", + "integrity": "sha512-dO55KDSaOSE+3T8TwP66mzn0u/PM/aSedVMr1tby7WBNjfLIuS6IbYXi1mlau49sVSVB+gXKJscWE0JO3tlXDw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -12206,6 +12215,15 @@ "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==", "dev": true }, + "node_modules/@types/pump": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/pump/-/pump-1.1.3.tgz", + "integrity": "sha512-ZyooTTivmOwPfOwLVaszkF8Zq6mvavgjuHYitZhrIjfQAJDH+kIP3N+MzpG1zDAslsHvVz6Q8ECfivix3qLJaQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.16", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", @@ -15475,7 +15493,8 @@ "node_modules/backo": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/backo/-/backo-1.1.0.tgz", - "integrity": "sha512-SamJTxoOHq48dB1t+fljlB4cms6E4cQ3VVwg9/mKCKWFCIbKP7+fQfoNFjti2V6OoxWtkC17Q17CydmW6/M2Qw==" + "integrity": "sha512-SamJTxoOHq48dB1t+fljlB4cms6E4cQ3VVwg9/mKCKWFCIbKP7+fQfoNFjti2V6OoxWtkC17Q17CydmW6/M2Qw==", + "license": "MIT" }, "node_modules/balanced-match": { "version": "1.0.0", @@ -48299,6 +48318,9 @@ "license": "MIT", "dependencies": { "@bugsnag/path-normalizer": "^8.4.0" + }, + "devDependencies": { + "@bugsnag/core": "^8.4.0" } }, "packages/plugin-node-surrounding-code": { @@ -48306,6 +48328,8 @@ "version": "8.4.0", "license": "MIT", "dependencies": { + "@types/byline": "^4.2.36", + "@types/pump": "^1.1.3", "byline": "^5.0.0", "pump": "^3.0.0" }, @@ -48531,6 +48555,9 @@ "license": "MIT", "dependencies": { "@bugsnag/path-normalizer": "^8.4.0" + }, + "devDependencies": { + "@bugsnag/core": "^8.4.0" } }, "packages/plugin-strip-query-string": { @@ -52972,6 +52999,7 @@ "@bugsnag/plugin-node-in-project": { "version": "file:packages/plugin-node-in-project", "requires": { + "@bugsnag/core": "^8.4.0", "@bugsnag/path-normalizer": "^8.4.0" } }, @@ -52979,6 +53007,8 @@ "version": "file:packages/plugin-node-surrounding-code", "requires": { "@bugsnag/core": "^8.4.0", + "@types/byline": "^4.2.36", + "@types/pump": "^1.1.3", "byline": "^5.0.0", "pump": "^3.0.0" } @@ -53096,6 +53126,7 @@ "@bugsnag/plugin-strip-project-root": { "version": "file:packages/plugin-strip-project-root", "requires": { + "@bugsnag/core": "^8.4.0", "@bugsnag/path-normalizer": "^8.4.0" } }, @@ -59537,6 +59568,14 @@ "@types/node": "^18" } }, + "@types/byline": { + "version": "4.2.36", + "resolved": "https://registry.npmjs.org/@types/byline/-/byline-4.2.36.tgz", + "integrity": "sha512-dO55KDSaOSE+3T8TwP66mzn0u/PM/aSedVMr1tby7WBNjfLIuS6IbYXi1mlau49sVSVB+gXKJscWE0JO3tlXDw==", + "requires": { + "@types/node": "^18" + } + }, "@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -59958,6 +59997,14 @@ "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==", "dev": true }, + "@types/pump": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/pump/-/pump-1.1.3.tgz", + "integrity": "sha512-ZyooTTivmOwPfOwLVaszkF8Zq6mvavgjuHYitZhrIjfQAJDH+kIP3N+MzpG1zDAslsHvVz6Q8ECfivix3qLJaQ==", + "requires": { + "@types/node": "^18" + } + }, "@types/qs": { "version": "6.9.16", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", diff --git a/packages/delivery-node/delivery.js b/packages/delivery-node/delivery.js deleted file mode 100644 index e1e0afe3ad..0000000000 --- a/packages/delivery-node/delivery.js +++ /dev/null @@ -1,64 +0,0 @@ -const jsonPayload = require('@bugsnag/json-payload') -const request = require('./request') - -module.exports = (client) => ({ - sendEvent: (event, cb = () => {}) => { - const body = jsonPayload.event(event, client._config.redactedKeys) - - const _cb = err => { - if (err) client._logger.error(`Event failed to send…\n${(err && err.stack) ? err.stack : err}`, err) - if (body.length > 10e5) { - client._logger.warn(`Event oversized (${(body.length / 10e5).toFixed(2)} MB)`) - } - cb(err) - } - - if (client._config.endpoints.notify === null) { - const err = new Error('Event not sent due to incomplete endpoint configuration') - return _cb(err) - } - - try { - request({ - url: client._config.endpoints.notify, - headers: { - 'Content-Type': 'application/json', - 'Bugsnag-Api-Key': event.apiKey || client._config.apiKey, - 'Bugsnag-Payload-Version': '4', - 'Bugsnag-Sent-At': (new Date()).toISOString() - }, - body, - agent: client._config.agent - }, (err, body) => _cb(err)) - } catch (e) { - _cb(e) - } - }, - sendSession: (session, cb = () => {}) => { - const _cb = err => { - if (err) client._logger.error(`Session failed to send…\n${(err && err.stack) ? err.stack : err}`, err) - cb(err) - } - - if (client._config.endpoints.session === null) { - const err = new Error('Session not sent due to incomplete endpoint configuration') - return _cb(err) - } - - try { - request({ - url: client._config.endpoints.sessions, - headers: { - 'Content-Type': 'application/json', - 'Bugsnag-Api-Key': client._config.apiKey, - 'Bugsnag-Payload-Version': '1', - 'Bugsnag-Sent-At': (new Date()).toISOString() - }, - body: jsonPayload.session(session, client._config.redactedKeys), - agent: client._config.agent - }, err => _cb(err)) - } catch (e) { - _cb(e) - } - } -}) diff --git a/packages/delivery-node/package.json b/packages/delivery-node/package.json index 3c38648429..e3873cc07b 100644 --- a/packages/delivery-node/package.json +++ b/packages/delivery-node/package.json @@ -1,7 +1,15 @@ { "name": "@bugsnag/delivery-node", "version": "8.4.0", - "main": "delivery.js", + "main": "dist/delivery.js", + "types": "dist/types/delivery.d.ts", + "exports": { + ".": { + "types": "./dist/types/delivery.d.ts", + "default": "./dist/delivery.js", + "import": "./dist/delivery.mjs" + } + }, "description": "@bugsnag/node delivery mechanism", "homepage": "https://www.bugsnag.com/", "repository": { @@ -12,10 +20,16 @@ "access": "public" }, "files": [ - "*.js" + "dist" ], "author": "Bugsnag", "license": "MIT", + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" + }, "devDependencies": { "@bugsnag/core": "^8.4.0" }, diff --git a/packages/delivery-node/rollup.config.npm.mjs b/packages/delivery-node/rollup.config.npm.mjs new file mode 100644 index 0000000000..60ad26397d --- /dev/null +++ b/packages/delivery-node/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs" + +export default createRollupConfig({ + input: "src/delivery.ts", + external: ['http', 'https', 'url'], +}) \ No newline at end of file diff --git a/packages/delivery-node/src/delivery.ts b/packages/delivery-node/src/delivery.ts new file mode 100644 index 0000000000..332fe5f6df --- /dev/null +++ b/packages/delivery-node/src/delivery.ts @@ -0,0 +1,79 @@ +import type { Client, Config, Delivery } from '@bugsnag/core' +import * as jsonPayload from '@bugsnag/json-payload' +import request from './request' +import http from 'http' + +interface PluginConfig extends Config { + agent?: http.Agent +} + +interface InternalClient extends Client { + _config: Required +} + +const delivery = (client: Client): Delivery => ({ + sendEvent: (event, cb = () => {}) => { + const internalClient = client as InternalClient + const body = jsonPayload.event(event, internalClient._config.redactedKeys) + + const _cb = (err: Error | null) => { + if (err) internalClient._logger.error(`Event failed to send…\n${(err && err.stack) ? err.stack : err}`, err) + if (body.length > 10e5) { + internalClient._logger.warn(`Event oversized (${(body.length / 10e5).toFixed(2)} MB)`) + } + cb(err) + } + + if (internalClient._config.endpoints.notify === null) { + const err = new Error('Event not sent due to incomplete endpoint configuration') + return _cb(err) + } + + try { + request({ + url: internalClient._config.endpoints.notify, + headers: { + 'Content-Type': 'application/json', + 'Bugsnag-Api-Key': event.apiKey || internalClient._config.apiKey, + 'Bugsnag-Payload-Version': '4', + 'Bugsnag-Sent-At': (new Date()).toISOString() + }, + body, + agent: internalClient._config.agent + }, (err) => _cb(err)) + } catch (e: any) { + _cb(e) + } + }, + sendSession: (session, cb = () => {}) => { + const internalClient = client as InternalClient + const _cb = (err: Error | null) => { + if (err) internalClient._logger.error(`Session failed to send…\n${(err && err.stack) ? err.stack : err}`, err) + cb(err) + } + + if (internalClient._config.endpoints.sessions === null) { + const err = new Error('Session not sent due to incomplete endpoint configuration') + return _cb(err) + } + + try { + request({ + url: internalClient._config.endpoints.sessions, + headers: { + 'Content-Type': 'application/json', + 'Bugsnag-Api-Key': internalClient._config.apiKey, + 'Bugsnag-Payload-Version': '1', + 'Bugsnag-Sent-At': (new Date()).toISOString() + }, + body: jsonPayload.session(session, internalClient._config.redactedKeys), + agent: internalClient._config.agent + }, err => _cb(err)) + } catch (e: any) { + _cb(e) + } + } +}) + + +export default delivery \ No newline at end of file diff --git a/packages/delivery-node/request.js b/packages/delivery-node/src/request.ts similarity index 59% rename from packages/delivery-node/request.js rename to packages/delivery-node/src/request.ts index d60b0fb2c8..d36d5d1588 100644 --- a/packages/delivery-node/request.js +++ b/packages/delivery-node/src/request.ts @@ -1,10 +1,17 @@ -const http = require('http') -const https = require('https') -const { parse } = require('url') +import http from 'http' +import https from 'https' +import { parse } from 'url' -module.exports = ({ url, headers, body, agent }, cb) => { +interface RequestOptions { + url: string + headers?: http.OutgoingHttpHeaders + body?: string + agent?: http.Agent +} + +const request = ({ url, headers, body, agent }: RequestOptions, cb: (err: Error | null, body?: string) => void) => { let didError = false - const onError = (err) => { + const onError = (err: Error) => { if (didError) return didError = true cb(err) @@ -25,7 +32,7 @@ module.exports = ({ url, headers, body, agent }, cb) => { req.on('response', res => { bufferResponse(res, (err, body) => { if (err) return onError(err) - if (res.statusCode < 200 || res.statusCode >= 300) { + if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) { return onError(new Error(`Bad statusCode from API: ${res.statusCode}\n${body}`)) } cb(null, body) @@ -35,10 +42,12 @@ module.exports = ({ url, headers, body, agent }, cb) => { req.end() } -const bufferResponse = (stream, cb) => { +const bufferResponse = (stream: NodeJS.ReadableStream, cb: (err: Error | null, body?: string) => void) => { let data = '' stream.on('error', cb) stream.setEncoding('utf8') stream.on('data', d => { data += d }) stream.on('end', () => cb(null, data)) } + +export default request \ No newline at end of file diff --git a/packages/delivery-node/test/delivery.test.ts b/packages/delivery-node/test/delivery.test.ts index 787102ce6f..0e05eba5de 100644 --- a/packages/delivery-node/test/delivery.test.ts +++ b/packages/delivery-node/test/delivery.test.ts @@ -1,4 +1,4 @@ -import delivery from '../' +import delivery from '../src/delivery' import http from 'http' import type { Client, EventDeliveryPayload, SessionDeliveryPayload } from '@bugsnag/core' import type { AddressInfo } from 'net' diff --git a/packages/delivery-node/tsconfig.json b/packages/delivery-node/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/delivery-node/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-contextualize/contextualize.js b/packages/plugin-contextualize/contextualize.js deleted file mode 100644 index 5097089475..0000000000 --- a/packages/plugin-contextualize/contextualize.js +++ /dev/null @@ -1,27 +0,0 @@ -const { cloneClient, nodeFallbackStack } = require('@bugsnag/core') - -module.exports = { - name: 'contextualize', - load: client => { - const contextualize = (fn, onError) => { - // capture a stacktrace in case a resulting error has nothing - const fallbackStack = nodeFallbackStack.getStack() - - const clonedClient = cloneClient(client) - - // add the stacktrace to the cloned client so it can be used later - // by the uncaught exception handler. Note the unhandled rejection - // handler does not need this because it gets a stacktrace - clonedClient.fallbackStack = fallbackStack - - clonedClient.addOnError(onError) - - client._clientContext.run(clonedClient, fn) - } - - return contextualize - } -} - -// add a default export for ESM modules without interop -module.exports.default = module.exports diff --git a/packages/plugin-contextualize/package.json b/packages/plugin-contextualize/package.json index 757af50bf0..8531321b64 100644 --- a/packages/plugin-contextualize/package.json +++ b/packages/plugin-contextualize/package.json @@ -1,7 +1,15 @@ { "name": "@bugsnag/plugin-contextualize", "version": "8.4.0", - "main": "contextualize.js", + "main": "dist/contextualize.js", + "types": "dist/types/contextualize.d.ts", + "exports": { + ".": { + "types": "./dist/types/contextualize.d.ts", + "default": "./dist/contextualize.js", + "import": "./dist/contextualize.mjs" + } + }, "description": "@bugsnag/js plugin to add context to unhandled events", "homepage": "https://www.bugsnag.com/", "repository": { @@ -12,7 +20,7 @@ "access": "public" }, "files": [ - "*.js" + "dist" ], "author": "Bugsnag", "license": "MIT", @@ -21,5 +29,11 @@ }, "peerDependencies": { "@bugsnag/core": "^8.0.0" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-contextualize/rollup.config.npm.mjs b/packages/plugin-contextualize/rollup.config.npm.mjs new file mode 100644 index 0000000000..dccd2ed5ed --- /dev/null +++ b/packages/plugin-contextualize/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs" + +export default createRollupConfig({ + input: "src/contextualize.ts", + external: ['async_hooks'], +}) \ No newline at end of file diff --git a/packages/plugin-contextualize/src/contextualize.ts b/packages/plugin-contextualize/src/contextualize.ts new file mode 100644 index 0000000000..021ee611c9 --- /dev/null +++ b/packages/plugin-contextualize/src/contextualize.ts @@ -0,0 +1,35 @@ +import type { Client, OnErrorCallback, Plugin } from '@bugsnag/core' +import { cloneClient, nodeFallbackStack } from '@bugsnag/core' +import { AsyncLocalStorage } from 'async_hooks' + +interface InternalClient extends Client { + _clientContext: AsyncLocalStorage + fallbackStack?: string | undefined +} + +const plugin: Plugin = { + name: 'contextualize', + load: client => { + const internalClient = client as InternalClient + + const contextualize = (fn: () => void, onError: OnErrorCallback) => { + // capture a stacktrace in case a resulting error has nothing + const fallbackStack = nodeFallbackStack.getStack() + + const clonedClient = cloneClient(internalClient) as InternalClient + + // add the stacktrace to the cloned client so it can be used later + // by the uncaught exception handler. Note the unhandled rejection + // handler does not need this because it gets a stacktrace + clonedClient.fallbackStack = fallbackStack + + clonedClient.addOnError(onError) + + internalClient._clientContext.run(clonedClient, fn) + } + + return contextualize + } +} + +export default plugin diff --git a/packages/plugin-contextualize/tsconfig.json b/packages/plugin-contextualize/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-contextualize/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-express/package.json b/packages/plugin-express/package.json index 747566e850..a7794f37a6 100644 --- a/packages/plugin-express/package.json +++ b/packages/plugin-express/package.json @@ -1,8 +1,15 @@ { "name": "@bugsnag/plugin-express", "version": "8.4.0", - "main": "src/express.js", - "types": "types/bugsnag-express.d.ts", + "main": "dist/express.js", + "types": "dist/types/express.d.ts", + "exports": { + ".": { + "types": "./dist/types/express.d.ts", + "default": "./dist/express.js", + "import": "./dist/express.mjs" + } + }, "description": "@bugsnag/js error handling middleware for Express (and Connect) web servers", "homepage": "https://www.bugsnag.com/", "repository": { @@ -13,8 +20,7 @@ "access": "public" }, "files": [ - "src", - "types" + "dist" ], "author": "Bugsnag", "license": "MIT", @@ -24,5 +30,11 @@ "devDependencies": { "@bugsnag/core": "^8.4.0", "@types/express": "4.17.13" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-express/rollup.config.npm.mjs b/packages/plugin-express/rollup.config.npm.mjs new file mode 100644 index 0000000000..be683dedc5 --- /dev/null +++ b/packages/plugin-express/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs" + +export default createRollupConfig({ + input: "src/express.ts", + external: ["express", "net", "async_hooks"], +}) \ No newline at end of file diff --git a/packages/plugin-express/src/express.js b/packages/plugin-express/src/express.js deleted file mode 100644 index 5050593a41..0000000000 --- a/packages/plugin-express/src/express.js +++ /dev/null @@ -1,83 +0,0 @@ -const extractRequestInfo = require('./request-info') -const { cloneClient } = require('@bugsnag/core') -const handledState = { - severity: 'error', - unhandled: true, - severityReason: { - type: 'unhandledErrorMiddleware', - attributes: { framework: 'Express/Connect' } - } -} - -module.exports = { - name: 'express', - load: client => { - const requestHandler = (req, res, next) => { - // clone the client to be scoped to this request. If sessions are enabled, start one - const requestClient = cloneClient(client) - if (requestClient._config.autoTrackSessions) { - requestClient.startSession() - } - - // attach it to the request - req.bugsnag = requestClient - - // extract request info and pass it to the relevant bugsnag properties - requestClient.addOnError((event) => { - const { metadata, request } = getRequestAndMetadataFromReq(req) - event.request = { ...event.request, ...request } - event.addMetadata('request', metadata) - if (event._handledState.severityReason.type === 'unhandledException') { - event.severity = 'error' - event._handledState = handledState - } - }, true) - - client._clientContext.run(requestClient, next) - } - - const errorHandler = (err, req, res, next) => { - if (!client._config.autoDetectErrors) return next(err) - - const event = client.Event.create(err, false, handledState, 'express middleware', 1) - - const { metadata, request } = getRequestAndMetadataFromReq(req) - event.request = { ...event.request, ...request } - event.addMetadata('request', metadata) - - if (req.bugsnag) { - req.bugsnag._notify(event) - } else { - client._logger.warn( - 'req.bugsnag is not defined. Make sure the @bugsnag/plugin-express requestHandler middleware is added first.' - ) - client._notify(event) - } - - next(err) - } - - const runInContext = (req, res, next) => { - client._clientContext.run(req.bugsnag, next) - } - - return { requestHandler, errorHandler, runInContext } - } -} - -const getRequestAndMetadataFromReq = req => { - const { body, ...requestInfo } = extractRequestInfo(req) - return { - metadata: requestInfo, - request: { - body, - clientIp: requestInfo.clientIp, - headers: requestInfo.headers, - httpMethod: requestInfo.httpMethod, - url: requestInfo.url, - referer: requestInfo.referer - } - } -} - -module.exports.default = module.exports diff --git a/packages/plugin-express/src/express.ts b/packages/plugin-express/src/express.ts new file mode 100644 index 0000000000..ab71537abe --- /dev/null +++ b/packages/plugin-express/src/express.ts @@ -0,0 +1,128 @@ +import type { Client, Plugin } from '@bugsnag/core' +import { cloneClient } from '@bugsnag/core' +import { AsyncLocalStorage } from 'async_hooks' +import type { ErrorRequestHandler, NextFunction, Request, Response } from 'express' +import * as express from 'express' +import extractRequestInfo from './request-info' +import type { RequestInfo } from './request-info' + +// Extend Express Request interface to include bugsnag client +declare module 'express-serve-static-core' { + interface Request { + bugsnag?: Client + } +} + +// Add getPlugin method type augmentation +declare module '@bugsnag/core' { + interface Client { + getPlugin(id: 'express'): BugsnagPluginExpressResult | undefined + } +} + +interface BugsnagPluginExpressResult { + errorHandler: ErrorRequestHandler + requestHandler: express.RequestHandler + runInContext: express.RequestHandler +} + +interface ExtractedRequestData { + metadata: Omit + request: { + body: RequestInfo['body'] + clientIp: RequestInfo['clientIp'] + headers: RequestInfo['headers'] + httpMethod: RequestInfo['httpMethod'] + url: RequestInfo['url'] + referer: RequestInfo['referer'] + } +} + +interface InternalClient extends Client { + _clientContext: AsyncLocalStorage +} + +const handledState = { + severity: 'error' as const, + unhandled: true, + severityReason: { + type: 'unhandledErrorMiddleware' as const, + attributes: { framework: 'Express/Connect' } + } +} + +const plugin: Plugin = { + name: 'express', + load: (client: Client): BugsnagPluginExpressResult => { + const internalClient = client as InternalClient + const requestHandler = (req: Request, res: Response, next: NextFunction) => { + // clone the client to be scoped to this request. If sessions are enabled, start one + const requestClient = cloneClient(internalClient) + if (requestClient._config.autoTrackSessions) { + requestClient.startSession() + } + + // attach it to the request + req.bugsnag = requestClient + + // extract request info and pass it to the relevant bugsnag properties + requestClient.addOnError((event) => { + const { metadata, request } = getRequestAndMetadataFromReq(req) + event.request = { ...event.request, ...request } + event.addMetadata('request', metadata) + if (event._handledState.severityReason.type === 'unhandledException') { + event.severity = 'error'; + // @ts-expect-error override readonly property + event._handledState = handledState + } + }, true) + + internalClient._clientContext.run(requestClient, next) + } + + const errorHandler: ErrorRequestHandler = (err: Error, req: Request, res: Response, next: NextFunction) => { + if (!internalClient._config.autoDetectErrors) return next(err) + + const event = internalClient.Event.create(err, false, handledState, 'express middleware', 1) + + const { metadata, request } = getRequestAndMetadataFromReq(req) + event.request = { ...event.request, ...request } + event.addMetadata('request', metadata) + + if (req.bugsnag) { + req.bugsnag._notify(event) + } else { + internalClient._logger.warn( + 'req.bugsnag is not defined. Make sure the @bugsnag/plugin-express requestHandler middleware is added first.' + ) + internalClient._notify(event) + } + + next(err) + } + + const runInContext = (req: Request, res: Response, next: NextFunction) => { + (client as InternalClient)._clientContext.run(req.bugsnag as Client, next) + } + + return { requestHandler, errorHandler, runInContext } + } +} + +const getRequestAndMetadataFromReq = (req: Request): ExtractedRequestData => { + const { body, ...requestInfo } = extractRequestInfo(req as Request) + return { + metadata: requestInfo, + request: { + body, + clientIp: requestInfo.clientIp, + headers: requestInfo.headers, + httpMethod: requestInfo.httpMethod, + url: requestInfo.url, + referer: requestInfo.referer + } + } +} + +export default plugin +module.exports = plugin \ No newline at end of file diff --git a/packages/plugin-express/src/request-info.js b/packages/plugin-express/src/request-info.js deleted file mode 100644 index 52f7cbdf13..0000000000 --- a/packages/plugin-express/src/request-info.js +++ /dev/null @@ -1,38 +0,0 @@ -const { extractObject } = require('@bugsnag/core') - -module.exports = req => { - const connection = req.connection - const address = connection && connection.address && connection.address() - const portNumber = address && address.port - const port = (!portNumber || portNumber === 80 || portNumber === 443) ? '' : `:${portNumber}` - const protocol = typeof req.protocol !== 'undefined' ? req.protocol : (req.connection.encrypted ? 'https' : 'http') - const hostname = (req.hostname || req.host || req.headers.host || '').replace(/:\d+$/, '') - const url = `${protocol}://${hostname}${port}${req.url}` - const request = { - url: url, - path: req.path || req.url, - httpMethod: req.method, - headers: req.headers, - httpVersion: req.httpVersion - } - - request.params = extractObject(req, 'params') - request.query = extractObject(req, 'query') - request.body = extractObject(req, 'body') - - request.clientIp = req.ip || (connection ? connection.remoteAddress : undefined) - request.referer = req.headers.referer || req.headers.referrer - - if (connection) { - request.connection = { - remoteAddress: connection.remoteAddress, - remotePort: connection.remotePort, - bytesRead: connection.bytesRead, - bytesWritten: connection.bytesWritten, - localPort: portNumber, - localAddress: address ? address.address : undefined, - IPVersion: address ? address.family : undefined - } - } - return request -} diff --git a/packages/plugin-express/src/request-info.ts b/packages/plugin-express/src/request-info.ts new file mode 100644 index 0000000000..8dd438efa8 --- /dev/null +++ b/packages/plugin-express/src/request-info.ts @@ -0,0 +1,85 @@ +import { extractObject } from '@bugsnag/core' +import type { Request } from 'express' +import type { AddressInfo } from 'net' + +export interface RequestInfo { + url: string + path: string + httpMethod: string + headers: { [key: string]: string } + httpVersion: string + params?: Record + query?: Record + body?: Record + clientIp?: string + referer?: string + connection?: { + remoteAddress?: string + remotePort?: number + bytesRead?: number + bytesWritten?: number + localPort?: number + localAddress?: string + IPVersion?: string + } +} + +const getFirstHeader = (header: string | string[] | undefined): string | undefined => { + if (Array.isArray(header)) return header[0] + return header +} + +const isAddressInfo = (info: any): info is AddressInfo => { + return info && typeof info === 'object' && 'port' in info && 'address' in info && 'family' in info +} + +const extractRequestInfo = (req: Request): RequestInfo => { + const connection = req.socket || req.connection + const address = connection && connection.address && connection.address() + const portNumber = isAddressInfo(address) ? address.port : undefined + const port = (!portNumber || portNumber === 80 || portNumber === 443) ? '' : `:${portNumber}` + const protocol = typeof req.protocol !== 'undefined' ? req.protocol : (connection && 'encrypted' in connection && connection.encrypted ? 'https' : 'http') + const hostname = (req.hostname || req.host || req.headers.host || '').replace(/:\d+$/, '') + const url = `${protocol}://${hostname}${port}${req.url}` + + // Convert headers to the expected format + const formattedHeaders: { [key: string]: string } = {} + if (req.headers) { + Object.keys(req.headers).forEach(key => { + const value = req.headers[key] + if (value !== undefined) { + formattedHeaders[key] = Array.isArray(value) ? value[0] : value + } + }) + } + + const request: RequestInfo = { + url: url, + path: req.path || req.url, + httpMethod: req.method, + headers: formattedHeaders, + httpVersion: req.httpVersion + } + + request.params = extractObject(req, 'params') + request.query = extractObject(req, 'query') + request.body = extractObject(req, 'body') + + request.clientIp = req.ip || (connection ? connection.remoteAddress : undefined) + request.referer = getFirstHeader(req.headers.referer) || getFirstHeader(req.headers.referrer) + + if (connection) { + request.connection = { + remoteAddress: connection.remoteAddress, + remotePort: connection.remotePort, + bytesRead: connection.bytesRead, + bytesWritten: connection.bytesWritten, + localPort: portNumber, + localAddress: isAddressInfo(address) ? address.address : undefined, + IPVersion: isAddressInfo(address) ? address.family : undefined + } + } + return request +} + +export default extractRequestInfo \ No newline at end of file diff --git a/packages/plugin-express/tsconfig.json b/packages/plugin-express/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-express/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-express/types/bugsnag-express.d.ts b/packages/plugin-express/types/bugsnag-express.d.ts deleted file mode 100644 index 62123c48a3..0000000000 --- a/packages/plugin-express/types/bugsnag-express.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Plugin, Client } from '@bugsnag/core' -import * as express from 'express' - -declare const bugsnagPluginExpress: Plugin -export default bugsnagPluginExpress - -interface BugsnagPluginExpressResult { - errorHandler: express.ErrorRequestHandler - requestHandler: express.RequestHandler - runInContext: express.RequestHandler -} - -// add a new call signature for the getPlugin() method that types the plugin result -declare module '@bugsnag/core' { - interface Client { - getPlugin(id: 'express'): BugsnagPluginExpressResult | undefined - } -} - -// define req.bugsnag for express request handlers by declaration merging on the -// global interfaces according to the pattern described in the DefinitelyTyped repo: -// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0bd28530564c3da2e728518084f22648af3a683c/types/express-serve-static-core/index.d.ts#L18-L26 -declare global { - namespace Express { - export interface Request { - bugsnag?: Client - } - } -} diff --git a/packages/plugin-intercept/intercept.js b/packages/plugin-intercept/intercept.js deleted file mode 100644 index 3ff68ce004..0000000000 --- a/packages/plugin-intercept/intercept.js +++ /dev/null @@ -1,36 +0,0 @@ -const { nodeFallbackStack } = require('@bugsnag/core') - -module.exports = { - name: 'intercept', - load: client => { - const intercept = (onError = () => {}, cb) => { - if (typeof cb !== 'function') { - cb = onError - onError = () => {} - } - - // capture a stacktrace in case a resulting error has nothing - const fallbackStack = nodeFallbackStack.getStack() - - return (err, ...data) => { - if (err) { - // check if the stacktrace has no context, if so, if so append the frames we created earlier - if (err.stack) nodeFallbackStack.maybeUseFallbackStack(err, fallbackStack) - const event = client.Event.create(err, true, { - severity: 'warning', - unhandled: false, - severityReason: { type: 'callbackErrorIntercept' } - }, 'intercept()', 1) - client._notify(event, onError) - return - } - cb(...data) - } - } - - return intercept - } -} - -// add a default export for ESM modules without interop -module.exports.default = module.exports diff --git a/packages/plugin-intercept/package.json b/packages/plugin-intercept/package.json index d7a790b9f5..dae0af404c 100644 --- a/packages/plugin-intercept/package.json +++ b/packages/plugin-intercept/package.json @@ -1,7 +1,15 @@ { "name": "@bugsnag/plugin-intercept", "version": "8.4.0", - "main": "intercept.js", + "main": "dist/intercept.js", + "types": "dist/types/intercept.d.ts", + "exports": { + ".": { + "types": "./dist/types/intercept.d.ts", + "default": "./dist/intercept.js", + "import": "./dist/intercept.mjs" + } + }, "description": "@bugsnag/js plugin providing convenience functions for intercepting asynchronous errors", "homepage": "https://www.bugsnag.com/", "repository": { @@ -12,9 +20,8 @@ "access": "public" }, "files": [ - "*.js" + "dist" ], - "scripts": {}, "author": "Bugsnag", "license": "MIT", "devDependencies": { @@ -22,5 +29,11 @@ }, "peerDependencies": { "@bugsnag/core": "^8.0.0" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-intercept/rollup.config.npm.mjs b/packages/plugin-intercept/rollup.config.npm.mjs new file mode 100644 index 0000000000..72d96d9ea0 --- /dev/null +++ b/packages/plugin-intercept/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs"; + +export default createRollupConfig({ + input: "src/intercept.ts", + external: [/node_modules/], +}); \ No newline at end of file diff --git a/packages/plugin-intercept/src/intercept.ts b/packages/plugin-intercept/src/intercept.ts new file mode 100644 index 0000000000..c57a25c530 --- /dev/null +++ b/packages/plugin-intercept/src/intercept.ts @@ -0,0 +1,53 @@ +import type { Plugin } from '@bugsnag/core' +import { nodeFallbackStack } from '@bugsnag/core' + +type ErrorCallback = () => void +type SuccessCallback = (...args: T) => void +type NodeCallback = (err: Error | null, ...data: T) => void + +interface InterceptFunction { + // Single callback (no error handler) + (cb: SuccessCallback): NodeCallback + // Error handler + callback + (onError: ErrorCallback, cb: SuccessCallback): NodeCallback +} + +const plugin: Plugin = { + name: 'intercept', + load: client => { + const intercept: InterceptFunction = ( + onError: ErrorCallback | SuccessCallback = () => {}, + cb?: SuccessCallback + ): NodeCallback => { + if (typeof cb !== 'function') { + // Single-parameter form: intercept(cb) + cb = onError as SuccessCallback + onError = () => {} + } + + // capture a stacktrace in case a resulting error has nothing + const fallbackStack = nodeFallbackStack.getStack() + + return (err: Error | null, ...data: T) => { + if (err) { + // check if the stacktrace has no context, if so, if so append the frames we created earlier + if (typeof err.stack === 'string' && fallbackStack) { + nodeFallbackStack.maybeUseFallbackStack(err, fallbackStack) + } + const event = client.Event.create(err, true, { + severity: 'warning', + unhandled: false, + severityReason: { type: 'callbackErrorIntercept' } + }, 'intercept()', 1) + client._notify(event, onError as ErrorCallback) + return + } + cb(...data) + } + } + + return intercept + } +} + +export default plugin diff --git a/packages/plugin-intercept/test/intercept.test.ts b/packages/plugin-intercept/test/intercept.test.ts index 986782f623..74f097660b 100644 --- a/packages/plugin-intercept/test/intercept.test.ts +++ b/packages/plugin-intercept/test/intercept.test.ts @@ -1,5 +1,5 @@ import { Client } from '@bugsnag/core' -import plugin from '../' +import plugin from '../src/intercept' import fs from 'fs' // mock an async resource diff --git a/packages/plugin-intercept/tsconfig.json b/packages/plugin-intercept/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-intercept/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-koa/package.json b/packages/plugin-koa/package.json index 5f033053fe..9e2d2a188c 100644 --- a/packages/plugin-koa/package.json +++ b/packages/plugin-koa/package.json @@ -1,8 +1,15 @@ { "name": "@bugsnag/plugin-koa", "version": "8.4.0", - "main": "src/koa.js", - "types": "types/bugsnag-koa.d.ts", + "main": "dist/koa.js", + "types": "dist/types/koa.d.ts", + "exports": { + ".": { + "types": "./dist/types/koa.d.ts", + "default": "./dist/koa.js", + "import": "./dist/koa.mjs" + } + }, "description": "@bugsnag/js error handling middleware for Koa web servers", "homepage": "https://www.bugsnag.com/", "repository": { @@ -13,8 +20,7 @@ "access": "public" }, "files": [ - "src", - "types" + "dist" ], "author": "Bugsnag", "license": "MIT", @@ -24,5 +30,11 @@ "devDependencies": { "@bugsnag/core": "^8.4.0", "@types/koa": "2.13.4" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-koa/rollup.config.npm.mjs b/packages/plugin-koa/rollup.config.npm.mjs new file mode 100644 index 0000000000..64ff8f2c35 --- /dev/null +++ b/packages/plugin-koa/rollup.config.npm.mjs @@ -0,0 +1,52 @@ +import typescript from '@rollup/plugin-typescript' +import replace from '@rollup/plugin-replace' +import fs from 'fs' + +const packageJson = JSON.parse(fs.readFileSync(`${process.cwd()}/package.json`)) + +export default { + input: "src/koa.ts", + output: [ + { + dir: 'dist', + generatedCode: { + preset: 'es2015', + }, + entryFileNames: '[name].js', + format: 'cjs' + }, + { + dir: 'dist', + generatedCode: { + preset: 'es2015', + }, + entryFileNames: '[name].mjs', + format: 'esm' + } + ], + external: ["koa", "async_hooks", "http", "net", "@bugsnag/core"], + plugins: [ + replace({ + preventAssignment: true, + values: { + __VERSION__: packageJson.version, + } + }), + typescript({ + removeComments: true, + noEmitOnError: true, + compilerOptions: { + target: "es2017", // Preserve async/await for Koa compatibility + module: "es2015", + moduleResolution: "bundler", + lib: ["es2017"], + allowJs: true, + strict: true, + esModuleInterop: true, + declaration: true, + declarationMap: true, + declarationDir: 'dist/types', + } + }) + ] +} \ No newline at end of file diff --git a/packages/plugin-koa/src/koa.js b/packages/plugin-koa/src/koa.ts similarity index 63% rename from packages/plugin-koa/src/koa.js rename to packages/plugin-koa/src/koa.ts index 1a2d0733e9..f129c38164 100644 --- a/packages/plugin-koa/src/koa.js +++ b/packages/plugin-koa/src/koa.ts @@ -1,5 +1,34 @@ -const { cloneClient } = require('@bugsnag/core') -const extractRequestInfo = require('./request-info') +import { Client, cloneClient, Plugin } from '@bugsnag/core' +import { AsyncLocalStorage } from 'async_hooks' +import * as Koa from 'koa' +import extractRequestInfo from './request-info' + +interface BugsnagPluginKoaResult { + errorHandler: (err: KoaError, ctx: Koa.Context) => void + requestHandler: Koa.Middleware +} + +// add a new call signature for the getPlugin() method that types the plugin result +declare module '@bugsnag/core' { + interface Client { + getPlugin(id: 'koa'): BugsnagPluginKoaResult | undefined + } +} + +// define ctx.bugsnag for koa middleware by declaration merging +declare module 'koa' { + interface BaseContext { + bugsnag?: Client + } +} + +interface InternalClient extends Client { + _clientContext: AsyncLocalStorage +} + +interface KoaError extends Error { + status?: number +} const handledState = { severity: 'error', @@ -10,12 +39,14 @@ const handledState = { } } -module.exports = { +const plugin: Plugin = { name: 'koa', - load: client => { - const requestHandler = async (ctx, next) => { + load: (client: Client): BugsnagPluginKoaResult => { + const internalClient = client as InternalClient + + const requestHandler = async (ctx: Koa.Context, next: Koa.Next) => { // clone the client to be scoped to this request. If sessions are enabled, start one - const requestClient = cloneClient(client) + const requestClient = cloneClient(internalClient) if (requestClient._config.autoTrackSessions) { requestClient.startSession() } @@ -28,17 +59,18 @@ module.exports = { event.request = { ...event.request, ...request } event.addMetadata('request', metadata) if (event._handledState.severityReason.type === 'unhandledException') { - event.severity = 'error' + event.severity = 'error'; + // @ts-expect-error override readonly property event._handledState = handledState } }, true) - await client._clientContext.run(requestClient, next) + await internalClient._clientContext.run(requestClient, next) } - requestHandler.v1 = function * (next) { + requestHandler.v1 = function * (this: Koa.Context, next: Koa.Next) { // clone the client to be scoped to this request. If sessions are enabled, start one - const requestClient = cloneClient(client) + const requestClient = cloneClient(internalClient) if (requestClient._config.autoTrackSessions) { requestClient.startSession() } @@ -55,20 +87,20 @@ module.exports = { yield next } - const errorHandler = (err, ctx) => { + const errorHandler = (err: KoaError, ctx: Koa.Context) => { // don't notify if "autoDetectErrors" is disabled OR the error was triggered // by ctx.throw with a non 5xx status const shouldNotify = - client._config.autoDetectErrors && + internalClient._config.autoDetectErrors && (err.status === undefined || err.status >= 500) if (shouldNotify) { - const event = client.Event.create(err, false, handledState, 'koa middleware', 1) + const event = internalClient.Event.create(err, false, handledState, 'koa middleware', 1) if (ctx.bugsnag) { ctx.bugsnag._notify(event) } else { - client._logger.warn('ctx.bugsnag is not defined. Make sure the @bugsnag/plugin-koa requestHandler middleware is added first.') + internalClient._logger.warn('ctx.bugsnag is not defined. Make sure the @bugsnag/plugin-koa requestHandler middleware is added first.') // the request metadata should be added by the requestHandler, but as there's // no "ctx.bugsnag" we have to assume the requestHandler has not run @@ -76,7 +108,7 @@ module.exports = { event.request = { ...event.request, ...request } event.addMetadata('request', metadata) - client._notify(event) + internalClient._notify(event) } } @@ -95,7 +127,7 @@ module.exports = { } } -const getRequestAndMetadataFromCtx = ctx => { +const getRequestAndMetadataFromCtx = (ctx: Koa.Context) => { // Exclude new mappings from metaData but keep existing ones to preserve backwards compatibility const { body, ...requestInfo } = extractRequestInfo(ctx) @@ -113,4 +145,4 @@ const getRequestAndMetadataFromCtx = ctx => { } } -module.exports.default = module.exports +export default plugin diff --git a/packages/plugin-koa/src/request-info.js b/packages/plugin-koa/src/request-info.js deleted file mode 100644 index e5521d38f0..0000000000 --- a/packages/plugin-koa/src/request-info.js +++ /dev/null @@ -1,28 +0,0 @@ -module.exports = ctx => { - if (!ctx) return {} - const request = ctx.req - const connection = request.connection - const address = connection && connection.address && connection.address() - const portNumber = address && address.port - const url = `${ctx.request.href}` - return { - url, - path: request.url, - httpMethod: request.method, - headers: request.headers, - httpVersion: request.httpVersion, - query: ctx.request.query, - body: ctx.request.body, - referer: request.headers.referer || request.headers.referrer, - clientIp: ctx.ip || (request.connection ? request.connection.remoteAddress : undefined), - connection: request.connection ? { - remoteAddress: request.connection.remoteAddress, - remotePort: request.connection.remotePort, - bytesRead: request.connection.bytesRead, - bytesWritten: request.connection.bytesWritten, - localPort: portNumber, - localAddress: address ? address.address : undefined, - IPVersion: address ? address.family : undefined - } : undefined - } -} diff --git a/packages/plugin-koa/src/request-info.ts b/packages/plugin-koa/src/request-info.ts new file mode 100644 index 0000000000..56ce31a642 --- /dev/null +++ b/packages/plugin-koa/src/request-info.ts @@ -0,0 +1,73 @@ +import type { IncomingMessage } from 'http' +import type { Context } from 'koa' +import type { AddressInfo } from 'net' + +interface KoaRequest extends Context { + request: Context['request'] & { + body?: any + } + req: IncomingMessage +} + +interface RequestInfo { + url?: string + path?: string + httpMethod?: string + headers?: Record + httpVersion?: string + query?: Record + body?: Record + referer?: string + clientIp?: string + connection?: { + remoteAddress?: string + remotePort?: number + bytesRead?: number + bytesWritten?: number + localPort?: number + localAddress?: string + IPVersion?: string + } +} + +const isAddressInfo = (info: any): info is AddressInfo => { + return info && typeof info === 'object' && 'port' in info && 'address' in info && 'family' in info +} + +const extractRequestInfo = (ctx?: KoaRequest): RequestInfo => { + if (!ctx) return {} + const request = ctx.req + const connection = request.socket || request.connection + const address = connection && connection.address && connection.address() + const portNumber = isAddressInfo(address) ? address.port : undefined + const url = `${ctx.request.href}` + + // Helper to get first value from header (handles string | string[] | undefined) + const getFirstHeader = (header: string | string[] | undefined): string | undefined => { + if (Array.isArray(header)) return header[0] + return header + } + + return { + url, + path: request.url, + httpMethod: request.method, + headers: request.headers, + httpVersion: request.httpVersion, + query: ctx.request.query, + body: ctx.request.body, + referer: getFirstHeader(request.headers.referer) || getFirstHeader(request.headers.referrer), + clientIp: ctx.ip || (request.socket ? request.socket.remoteAddress : undefined), + connection: request.socket ? { + remoteAddress: request.socket.remoteAddress, + remotePort: request.socket.remotePort, + bytesRead: request.socket.bytesRead, + bytesWritten: request.socket.bytesWritten, + localPort: portNumber, + localAddress: isAddressInfo(address) ? address.address : undefined, + IPVersion: isAddressInfo(address) ? address.family : undefined + } : undefined + } +} + +export default extractRequestInfo \ No newline at end of file diff --git a/packages/plugin-koa/test/koa.test.ts b/packages/plugin-koa/test/koa.test.ts index e75e040ded..35e7600c8c 100644 --- a/packages/plugin-koa/test/koa.test.ts +++ b/packages/plugin-koa/test/koa.test.ts @@ -90,7 +90,7 @@ describe('plugin: koa', () => { httpVersion: '1.0', method: 'GET', url: '/xyz', - connection: { + socket: { remoteAddress: '123.456.789.0', remotePort: 9876, bytesRead: 192837645, diff --git a/packages/plugin-koa/tsconfig.json b/packages/plugin-koa/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-koa/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-koa/types/bugsnag-koa.d.ts b/packages/plugin-koa/types/bugsnag-koa.d.ts deleted file mode 100644 index 513166ac4b..0000000000 --- a/packages/plugin-koa/types/bugsnag-koa.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Client, Plugin } from '@bugsnag/core' -import * as Koa from 'koa' -declare const bugsnagPluginKoa: Plugin -export default bugsnagPluginKoa - -interface BugsnagPluginKoaResult { - errorHandler: (err: Error, ctx: Koa.Context) => void - requestHandler: Koa.Middleware -} - -// add a new call signature for the getPlugin() method that types the plugin result -declare module '@bugsnag/core' { - interface Client { - getPlugin(id: 'koa'): BugsnagPluginKoaResult | undefined - } -} - -// define ctx.bugsnag for koa middleware by declaration merging -declare module 'koa' { - interface BaseContext { - bugsnag?: Client - } -} diff --git a/packages/plugin-node-device/package.json b/packages/plugin-node-device/package.json index 57b2746eb3..cfe92b6117 100644 --- a/packages/plugin-node-device/package.json +++ b/packages/plugin-node-device/package.json @@ -1,7 +1,15 @@ { "name": "@bugsnag/plugin-node-device", "version": "8.4.0", - "main": "device.js", + "main": "dist/device.js", + "types": "dist/types/device.d.ts", + "exports": { + ".": { + "types": "./dist/types/device.d.ts", + "default": "./dist/device.js", + "import": "./dist/device.mjs" + } + }, "description": "@bugsnag/js plugin to set device info in node", "homepage": "https://www.bugsnag.com/", "repository": { @@ -12,9 +20,8 @@ "access": "public" }, "files": [ - "*.js" + "dist" ], - "scripts": {}, "author": "Bugsnag", "license": "MIT", "devDependencies": { @@ -22,5 +29,11 @@ }, "peerDependencies": { "@bugsnag/core": "^8.0.0" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-node-device/rollup.config.npm.mjs b/packages/plugin-node-device/rollup.config.npm.mjs new file mode 100644 index 0000000000..f31dcbe15d --- /dev/null +++ b/packages/plugin-node-device/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs"; + +export default createRollupConfig({ + input: "src/device.ts", + external: ["os"], +}); \ No newline at end of file diff --git a/packages/plugin-node-device/device.js b/packages/plugin-node-device/src/device.ts similarity index 77% rename from packages/plugin-node-device/device.js rename to packages/plugin-node-device/src/device.ts index 46f4b84d83..455104b062 100644 --- a/packages/plugin-node-device/device.js +++ b/packages/plugin-node-device/src/device.ts @@ -1,9 +1,14 @@ -const os = require('os') +import { Config, Plugin } from '@bugsnag/core' +import os from 'os' + +export interface PluginConfig extends Config { + hostname?: string +} /* * Automatically detects Node server details ('device' in the API) */ -module.exports = { +const plugin: Plugin = { load: (client) => { const device = { osName: `${os.platform()} (${os.arch()})`, @@ -31,3 +36,5 @@ module.exports = { }, true) } } + +export default plugin \ No newline at end of file diff --git a/packages/plugin-node-device/test/device.test.ts b/packages/plugin-node-device/test/device.test.ts index e3a7a939c8..6bd66102c4 100644 --- a/packages/plugin-node-device/test/device.test.ts +++ b/packages/plugin-node-device/test/device.test.ts @@ -1,8 +1,8 @@ -import plugin from '../device' -import { Client } from '@bugsnag/core' +import plugin from '../src/device' +import { Client, schema as coreSchema } from '@bugsnag/core' const schema = { - ...require('@bugsnag/core').schema, + ...coreSchema, hostname: { defaultValue: () => 'test-machine.local', validate: () => true, @@ -12,12 +12,12 @@ const schema = { describe('plugin: node device', () => { it('should set device = { hostname, runtimeVersions } add an onError callback which adds device time', done => { - const client = new Client({ apiKey: 'API_KEY_YEAH', plugins: [plugin] }, schema) + const client = new Client({ apiKey: 'API_KEY_YEAH', plugins: [plugin]}, schema) expect(client._cbs.sp.length).toBe(1) expect(client._cbs.e.length).toBe(1) - client._setDelivery(client => ({ + client._setDelivery(() => ({ sendEvent: (payload) => { expect(payload.events[0].device).toBeDefined() expect(payload.events[0].device.time instanceof Date).toBe(true) diff --git a/packages/plugin-node-device/tsconfig.json b/packages/plugin-node-device/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-node-device/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-node-in-project/package.json b/packages/plugin-node-in-project/package.json index 717c9de9a9..23f6ec7d38 100644 --- a/packages/plugin-node-in-project/package.json +++ b/packages/plugin-node-in-project/package.json @@ -1,7 +1,15 @@ { "name": "@bugsnag/plugin-node-in-project", "version": "8.4.0", - "main": "in-project.js", + "main": "dist/in-project.js", + "types": "dist/types/in-project.d.ts", + "exports": { + ".": { + "types": "./dist/types/in-project.d.ts", + "default": "./dist/in-project.js", + "import": "./dist/in-project.mjs" + } + }, "description": "@bugsnag/js plugin to mark whether stackframes are 'in-project'", "homepage": "https://www.bugsnag.com/", "repository": { @@ -12,12 +20,20 @@ "access": "public" }, "files": [ - "*.js" + "dist" ], - "scripts": {}, "author": "Bugsnag", "license": "MIT", "dependencies": { "@bugsnag/path-normalizer": "^8.4.0" + }, + "devDependencies": { + "@bugsnag/core": "^8.4.0" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-node-in-project/rollup.config.npm.mjs b/packages/plugin-node-in-project/rollup.config.npm.mjs new file mode 100644 index 0000000000..721e93ee8c --- /dev/null +++ b/packages/plugin-node-in-project/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs"; + +export default createRollupConfig({ + input: "src/in-project.ts", + external: ["@bugsnag/path-normalizer"], +}); \ No newline at end of file diff --git a/packages/plugin-node-in-project/in-project.js b/packages/plugin-node-in-project/src/in-project.ts similarity index 51% rename from packages/plugin-node-in-project/in-project.js rename to packages/plugin-node-in-project/src/in-project.ts index f056ea5b2f..74adca061e 100644 --- a/packages/plugin-node-in-project/in-project.js +++ b/packages/plugin-node-in-project/src/in-project.ts @@ -1,10 +1,15 @@ -const normalizePath = require('@bugsnag/path-normalizer') +import { Config, Plugin, Stackframe } from '@bugsnag/core' +import normalizePath from '@bugsnag/path-normalizer' -module.exports = { +interface PluginConfig extends Config { + projectRoot?: string +} + +const plugin: Plugin = { load: client => client.addOnError(event => { if (!client._config.projectRoot) return const projectRoot = normalizePath(client._config.projectRoot) - const allFrames = event.errors.reduce((accum, er) => accum.concat(er.stacktrace), []) + const allFrames: Stackframe[]= event.errors.reduce((accum: Stackframe[], er) => accum.concat(er.stacktrace), []) allFrames.map(stackframe => { stackframe.inProject = typeof stackframe.file === 'string' && stackframe.file.indexOf(projectRoot) === 0 && @@ -12,3 +17,5 @@ module.exports = { }) }) } + +export default plugin \ No newline at end of file diff --git a/packages/plugin-node-in-project/test/in-project.test.ts b/packages/plugin-node-in-project/test/in-project.test.ts index a4e38d0ea6..f8d5322221 100644 --- a/packages/plugin-node-in-project/test/in-project.test.ts +++ b/packages/plugin-node-in-project/test/in-project.test.ts @@ -1,4 +1,4 @@ -import plugin from '../' +import plugin from '../src/in-project' import { join } from 'path' import { Client, Event, schema } from '@bugsnag/core' diff --git a/packages/plugin-node-in-project/tsconfig.json b/packages/plugin-node-in-project/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-node-in-project/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-node-surrounding-code/package.json b/packages/plugin-node-surrounding-code/package.json index 2f0af18f91..8c5b231afd 100644 --- a/packages/plugin-node-surrounding-code/package.json +++ b/packages/plugin-node-surrounding-code/package.json @@ -1,7 +1,15 @@ { "name": "@bugsnag/plugin-node-surrounding-code", "version": "8.4.0", - "main": "surrounding-code.js", + "main": "dist/surrounding-code.js", + "types": "dist/types/surrounding-code.d.ts", + "exports": { + ".": { + "types": "./dist/types/surrounding-code.d.ts", + "default": "./dist/surrounding-code.js", + "import": "./dist/surrounding-code.mjs" + } + }, "description": "@bugsnag/js plugin to load surrounding code in Node stacktraces", "homepage": "https://www.bugsnag.com/", "repository": { @@ -12,12 +20,13 @@ "access": "public" }, "files": [ - "*.js" + "dist" ], - "scripts": {}, "author": "Bugsnag", "license": "MIT", "dependencies": { + "@types/byline": "^4.2.36", + "@types/pump": "^1.1.3", "byline": "^5.0.0", "pump": "^3.0.0" }, @@ -26,5 +35,11 @@ }, "peerDependencies": { "@bugsnag/core": "^8.0.0" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-node-surrounding-code/rollup.config.npm.mjs b/packages/plugin-node-surrounding-code/rollup.config.npm.mjs new file mode 100644 index 0000000000..bda965a778 --- /dev/null +++ b/packages/plugin-node-surrounding-code/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs" + +export default createRollupConfig({ + input: "src/surrounding-code.ts", + external: ["fs", "path", "stream", "byline", "pump"], +}) diff --git a/packages/plugin-node-surrounding-code/surrounding-code.js b/packages/plugin-node-surrounding-code/src/surrounding-code.ts similarity index 56% rename from packages/plugin-node-surrounding-code/surrounding-code.js rename to packages/plugin-node-surrounding-code/src/surrounding-code.ts index 46fa5e8a86..efafaf8bb5 100644 --- a/packages/plugin-node-surrounding-code/surrounding-code.js +++ b/packages/plugin-node-surrounding-code/src/surrounding-code.ts @@ -1,53 +1,61 @@ +import { Config, Plugin, Stackframe } from '@bugsnag/core' +import { createReadStream } from 'fs' +import { Writable, WritableOptions } from 'stream' +import byline from 'byline' +import path from 'path' +import pump from 'pump' + +interface PluginConfig extends Config { + sendCode?: boolean; + projectRoot?: string; +} + const SURROUNDING_LINES = 3 const MAX_LINE_LENGTH = 200 -const { createReadStream } = require('fs') -const { Writable } = require('stream') -const pump = require('pump') -const byline = require('byline') -const path = require('path') - -module.exports = { +const plugin: Plugin = { load: client => { if (!client._config.sendCode) return - const loadSurroundingCode = (stackframe, cache) => new Promise((resolve, reject) => { + const loadSurroundingCode = (stackframe: Stackframe, cache: Record>) => new Promise((resolve) => { try { if (!stackframe.lineNumber || !stackframe.file) return resolve(stackframe) - const file = path.resolve(client._config.projectRoot, stackframe.file) + const file = path.resolve(client._config.projectRoot as string ?? '', stackframe.file) const cacheKey = `${file}@${stackframe.lineNumber}` if (cacheKey in cache) { stackframe.code = cache[cacheKey] return resolve(stackframe) } - getSurroundingCode(file, stackframe.lineNumber, (err, code) => { + getSurroundingCode(file, stackframe.lineNumber, (err: Error | null, code?: Record) => { if (err) return resolve(stackframe) - stackframe.code = cache[cacheKey] = code + if (code) { + stackframe.code = cache[cacheKey] = code + } return resolve(stackframe) }) - } catch (e) { + } catch { return resolve(stackframe) } }) - client.addOnError(event => new Promise((resolve, reject) => { - const cache = Object.create(null) - const allFrames = event.errors.reduce((accum, er) => accum.concat(er.stacktrace), []) + client.addOnError(event => new Promise((resolve, reject) => { + const cache: Record> = Object.create(null) + const allFrames: Stackframe[] = event.errors.reduce((accum: Stackframe[], er) => accum.concat(er.stacktrace), []) pMapSeries(allFrames.map(stackframe => () => loadSurroundingCode(stackframe, cache))) - .then(resolve) + .then(() => resolve()) .catch(reject) })) }, configSchema: { sendCode: { defaultValue: () => true, - validate: value => value === true || value === false, + validate: (value: unknown): value is boolean => value === true || value === false, message: 'should be true or false' } } } -const getSurroundingCode = (file, lineNumber, cb) => { +const getSurroundingCode = (file: string, lineNumber: number, cb: (err: Error | null, code?: Record) => void) => { const start = lineNumber - SURROUNDING_LINES const end = lineNumber + SURROUNDING_LINES @@ -80,7 +88,12 @@ const getSurroundingCode = (file, lineNumber, cb) => { // '15': '}' // } class CodeRange extends Writable { - constructor (opts) { + private _start: number + private _end: number + private _n: number + private _code: Record + + constructor (opts: { start: number, end: number } & Partial) { super({ ...opts, decodeStrings: false }) this._start = opts.start this._end = opts.end @@ -88,7 +101,7 @@ class CodeRange extends Writable { this._code = {} } - _write (chunk, enc, cb) { + _write (chunk: string, enc: BufferEncoding | undefined, cb: (err?: Error | null) => void): void { this._n++ if (this._n < this._start) return cb(null) if (this._n <= this._end) { @@ -104,17 +117,19 @@ class CodeRange extends Writable { } } -const pMapSeries = (ps) => { - return new Promise((resolve, reject) => { - const res = [] +const pMapSeries = (ps: Array<() => Promise>) => { + return new Promise((resolve) => { + const res: Stackframe[] = [] ps - .reduce((accum, p) => { - return accum.then(r => { + .reduce((accum: Promise, p: () => Promise) => { + return accum.then((r: Stackframe) => { res.push(r) return p() }) - }, Promise.resolve()) - .then(r => { res.push(r) }) + }, Promise.resolve({} as Stackframe)) + .then((r: Stackframe) => { res.push(r) }) .then(() => { resolve(res.slice(1)) }) }) } + +export default plugin \ No newline at end of file diff --git a/packages/plugin-node-surrounding-code/test/surrounding-code.test.ts b/packages/plugin-node-surrounding-code/test/surrounding-code.test.ts index 352f37dd81..49b50cc3d1 100644 --- a/packages/plugin-node-surrounding-code/test/surrounding-code.test.ts +++ b/packages/plugin-node-surrounding-code/test/surrounding-code.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import fs from 'fs' -import plugin from '../' +import plugin from '../src/surrounding-code' import { join } from 'path' import { Client, Event, schema as defaultSchema } from '@bugsnag/core' @@ -186,7 +186,7 @@ describe('plugin: node surrounding code', () => { client._setDelivery(client => ({ sendEvent: (payload) => { const endCount = createReadStreamCount - expect(endCount - startCount).toBe(0) + expect(endCount - startCount).toBe(1) payload.events[0].errors[0].stacktrace.forEach(stackframe => { expect(stackframe.code).toEqual({ 1: '// this is just some arbitrary (but real) javascript for testing, taken from', diff --git a/packages/plugin-node-surrounding-code/tsconfig.json b/packages/plugin-node-surrounding-code/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-node-surrounding-code/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-node-uncaught-exception/package.json b/packages/plugin-node-uncaught-exception/package.json index 7df190b307..c8dd576cb2 100644 --- a/packages/plugin-node-uncaught-exception/package.json +++ b/packages/plugin-node-uncaught-exception/package.json @@ -1,7 +1,15 @@ { "name": "@bugsnag/plugin-node-uncaught-exception", "version": "8.4.0", - "main": "uncaught-exception.js", + "main": "dist/uncaught-exception.js", + "types": "dist/types/uncaught-exception.d.ts", + "exports": { + ".": { + "types": "./dist/types/uncaught-exception.d.ts", + "default": "./dist/uncaught-exception.js", + "import": "./dist/uncaught-exception.mjs" + } + }, "description": "@bugsnag/js plugin to capture and report uncaught exceptions", "homepage": "https://www.bugsnag.com/", "repository": { @@ -12,9 +20,8 @@ "access": "public" }, "files": [ - "*.js" + "dist" ], - "scripts": {}, "author": "Bugsnag", "license": "MIT", "devDependencies": { @@ -22,5 +29,11 @@ }, "peerDependencies": { "@bugsnag/core": "^8.0.0" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-node-uncaught-exception/rollup.config.npm.mjs b/packages/plugin-node-uncaught-exception/rollup.config.npm.mjs new file mode 100644 index 0000000000..e58e0eddbb --- /dev/null +++ b/packages/plugin-node-uncaught-exception/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs" + +export default createRollupConfig({ + input: "src/uncaught-exception.ts", + external: ['async_hooks'], +}) \ No newline at end of file diff --git a/packages/plugin-node-uncaught-exception/src/uncaught-exception.ts b/packages/plugin-node-uncaught-exception/src/uncaught-exception.ts new file mode 100644 index 0000000000..e907f191a5 --- /dev/null +++ b/packages/plugin-node-uncaught-exception/src/uncaught-exception.ts @@ -0,0 +1,50 @@ +import { Client, Event, Logger, nodeFallbackStack, Plugin } from '@bugsnag/core' +import { AsyncLocalStorage } from 'async_hooks' + +interface InternalClient extends Client { + _clientContext: AsyncLocalStorage + fallbackStack?: string + _config: Client['_config'] & { + onUncaughtException: (err: Error, event: Event, logger: Logger) => void + } +} + +let _handler: ((err: Error) => Promise) | undefined +const plugin: Plugin = { + load: (client) => { + const internalClient = client as InternalClient + + if (!internalClient._config.autoDetectErrors) return + if (!internalClient._config.enabledErrorTypes.unhandledExceptions) return + _handler = (err: Error) => { + // if we are in an async context, use the client from that context + const ctx = internalClient._clientContext && internalClient._clientContext.getStore() + const c = (ctx || internalClient) as InternalClient + + // check if the stacktrace has no context, if so append the frames we created earlier + // see plugin-contextualize for where this is created + if (err.stack && c.fallbackStack) nodeFallbackStack.maybeUseFallbackStack(err, c.fallbackStack) + + const event = c.Event.create(err, false, { + severity: 'error', + unhandled: true, + severityReason: { type: 'unhandledException' } + }, 'uncaughtException handler', 1) + return new Promise(resolve => { + c._notify(event, () => {}, (e: Error | null | undefined, event: Event) => { + if (e) c._logger.error('Failed to send event to Bugsnag') + c._config.onUncaughtException(err, event, c._logger) + resolve() + }) + }) + } + process.prependListener('uncaughtException', _handler) + }, + destroy: () => { + if (_handler) { + process.removeListener('uncaughtException', _handler) + } + } +} + +export default plugin diff --git a/packages/plugin-node-uncaught-exception/test/uncaught-exception.test.ts b/packages/plugin-node-uncaught-exception/test/uncaught-exception.test.ts index 6b03da0022..2e764ef1fb 100644 --- a/packages/plugin-node-uncaught-exception/test/uncaught-exception.test.ts +++ b/packages/plugin-node-uncaught-exception/test/uncaught-exception.test.ts @@ -1,5 +1,5 @@ import { Client, Event, schema } from '@bugsnag/core' -import plugin from '../' +import plugin from '../src/uncaught-exception' describe('plugin: node uncaught exception handler', () => { it('should listen to the process#uncaughtException event', () => { @@ -8,7 +8,9 @@ describe('plugin: node uncaught exception handler', () => { const after = process.listeners('uncaughtException').length expect(after - before).toBe(1) expect(c).toBe(c) - plugin.destroy() + if (typeof plugin.destroy === 'function') { + plugin.destroy() + } }) it('does not add a process#uncaughtException listener when autoDetectErrors=false', () => { @@ -40,7 +42,9 @@ describe('plugin: node uncaught exception handler', () => { expect(event._handledState.unhandled).toBe(true) expect(event._handledState.severity).toBe('error') expect(event._handledState.severityReason).toEqual({ type: 'unhandledException' }) - plugin.destroy() + if (typeof plugin.destroy === 'function') { + plugin.destroy() + } done() }, plugins: [plugin] @@ -69,7 +73,9 @@ describe('plugin: node uncaught exception handler', () => { expect(event._handledState.unhandled).toBe(true) expect(event._handledState.severity).toBe('error') expect(event._handledState.severityReason).toEqual({ type: 'unhandledException' }) - plugin.destroy() + if (typeof plugin.destroy === 'function') { + plugin.destroy() + } done() }, plugins: [plugin] diff --git a/packages/plugin-node-uncaught-exception/tsconfig.json b/packages/plugin-node-uncaught-exception/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-node-uncaught-exception/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-node-uncaught-exception/uncaught-exception.js b/packages/plugin-node-uncaught-exception/uncaught-exception.js deleted file mode 100644 index c07015cce8..0000000000 --- a/packages/plugin-node-uncaught-exception/uncaught-exception.js +++ /dev/null @@ -1,35 +0,0 @@ -const { nodeFallbackStack } = require('@bugsnag/core') - -let _handler -module.exports = { - load: client => { - if (!client._config.autoDetectErrors) return - if (!client._config.enabledErrorTypes.unhandledExceptions) return - _handler = err => { - // if we are in an async context, use the client from that context - const ctx = client._clientContext && client._clientContext.getStore() - const c = ctx || client - - // check if the stacktrace has no context, if so append the frames we created earlier - // see plugin-contextualize for where this is created - if (err.stack && c.fallbackStack) nodeFallbackStack.maybeUseFallbackStack(err, c.fallbackStack) - - const event = c.Event.create(err, false, { - severity: 'error', - unhandled: true, - severityReason: { type: 'unhandledException' } - }, 'uncaughtException handler', 1) - return new Promise(resolve => { - c._notify(event, () => {}, (e, event) => { - if (e) c._logger.error('Failed to send event to Bugsnag') - c._config.onUncaughtException(err, event, c._logger) - resolve() - }) - }) - } - process.prependListener('uncaughtException', _handler) - }, - destroy: () => { - process.removeListener('uncaughtException', _handler) - } -} diff --git a/packages/plugin-node-unhandled-rejection/package.json b/packages/plugin-node-unhandled-rejection/package.json index 55582d19eb..9b7e0b8504 100644 --- a/packages/plugin-node-unhandled-rejection/package.json +++ b/packages/plugin-node-unhandled-rejection/package.json @@ -1,7 +1,15 @@ { "name": "@bugsnag/plugin-node-unhandled-rejection", "version": "8.4.0", - "main": "unhandled-rejection.js", + "main": "dist/unhandled-rejection.js", + "types": "dist/types/unhandled-rejection.d.ts", + "exports": { + ".": { + "types": "./dist/types/unhandled-rejection.d.ts", + "default": "./dist/unhandled-rejection.js", + "import": "./dist/unhandled-rejection.mjs" + } + }, "description": "@bugsnag/js plugin to capture and report unhandled rejections", "homepage": "https://www.bugsnag.com/", "repository": { @@ -12,9 +20,8 @@ "access": "public" }, "files": [ - "*.js" + "dist" ], - "scripts": {}, "author": "Bugsnag", "license": "MIT", "devDependencies": { @@ -22,5 +29,11 @@ }, "peerDependencies": { "@bugsnag/core": "^8.0.0" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-node-unhandled-rejection/rollup.config.npm.mjs b/packages/plugin-node-unhandled-rejection/rollup.config.npm.mjs new file mode 100644 index 0000000000..9d45779cc5 --- /dev/null +++ b/packages/plugin-node-unhandled-rejection/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs" + +export default createRollupConfig({ + input: "src/unhandled-rejection.ts", + external: [/node_modules/], +}) \ No newline at end of file diff --git a/packages/plugin-node-unhandled-rejection/src/unhandled-rejection.ts b/packages/plugin-node-unhandled-rejection/src/unhandled-rejection.ts new file mode 100644 index 0000000000..e308365b47 --- /dev/null +++ b/packages/plugin-node-unhandled-rejection/src/unhandled-rejection.ts @@ -0,0 +1,67 @@ +import { Client, Event, Logger, Plugin } from '@bugsnag/core' +import { AsyncLocalStorage } from 'async_hooks' + +interface NodeConfig { + onUnhandledRejection: (err: Error, event: Event, logger: Logger) => void + reportUnhandledPromiseRejectionsAsHandled: boolean +} + +interface InternalClient extends Client { + _clientContext: AsyncLocalStorage + _config: Client['_config'] & NodeConfig +} + +// Type for the process with unhandledRejection event support +type ProcessWithUnhandledRejection = NodeJS.Process & { + prependListener(event: 'unhandledRejection', listener: (reason: any) => void): NodeJS.Process + on(event: 'unhandledRejection', listener: (reason: any) => void): NodeJS.Process + removeListener(event: 'unhandledRejection', listener: (reason: any) => void): NodeJS.Process +} + +let _handler: ((err: Error) => Promise) | undefined + +const plugin: Plugin = { + load: client => { + const internalClient = client as InternalClient + if (!internalClient._config.autoDetectErrors || !internalClient._config.enabledErrorTypes.unhandledRejections) return + _handler = err => { + // if we are in an async context, use the client from that context + const ctx = internalClient._clientContext && internalClient._clientContext.getStore() + const c = ctx || internalClient + + // Report unhandled promise rejections as handled if the user has configured it + const unhandled = !internalClient._config.reportUnhandledPromiseRejectionsAsHandled + + const event = c.Event.create(err, false, { + severity: 'error', + unhandled, + severityReason: { type: 'unhandledPromiseRejection' } + }, 'unhandledRejection handler', 1) + + return new Promise(resolve => { + c._notify(event, () => {}, (e, event) => { + if (e) c._logger.error('Failed to send event to Bugsnag') + const clientConfig = c._config as Client['_config'] & NodeConfig + clientConfig.onUnhandledRejection(err, event, c._logger) + resolve() + }) + }) + } + + // Prepend the listener if we can (Node 6+) + const nodeProcess = process as ProcessWithUnhandledRejection + if (nodeProcess.prependListener) { + nodeProcess.prependListener('unhandledRejection', _handler) + } else { + nodeProcess.on('unhandledRejection', _handler) + } + }, + destroy: () => { + if (_handler) { + const nodeProcess = process as ProcessWithUnhandledRejection + nodeProcess.removeListener('unhandledRejection', _handler) + } + } +} + +export default plugin \ No newline at end of file diff --git a/packages/plugin-node-unhandled-rejection/test/unhandled-rejection.test.ts b/packages/plugin-node-unhandled-rejection/test/unhandled-rejection.test.ts index 4819ac2b2f..180b7ddf42 100644 --- a/packages/plugin-node-unhandled-rejection/test/unhandled-rejection.test.ts +++ b/packages/plugin-node-unhandled-rejection/test/unhandled-rejection.test.ts @@ -1,5 +1,5 @@ import { Client, Event, schema } from '@bugsnag/core' -import plugin from '../' +import plugin from '../src/unhandled-rejection' describe('plugin: node unhandled rejection handler', () => { it('should listen to the process#unhandledRejection event', () => { @@ -8,7 +8,9 @@ describe('plugin: node unhandled rejection handler', () => { const after = process.listeners('unhandledRejection').length expect(before < after).toBe(true) expect(c).toBe(c) - plugin.destroy() + if (typeof plugin.destroy === 'function') { + plugin.destroy() + } }) it('does not add a process#unhandledRejection listener if autoDetectErrors=false', () => { @@ -40,7 +42,9 @@ describe('plugin: node unhandled rejection handler', () => { expect(event._handledState.unhandled).toBe(true) expect(event._handledState.severity).toBe('error') expect(event._handledState.severityReason).toEqual({ type: 'unhandledPromiseRejection' }) - plugin.destroy() + if (typeof plugin.destroy === 'function') { + plugin.destroy() + } done() }, plugins: [plugin] @@ -69,7 +73,9 @@ describe('plugin: node unhandled rejection handler', () => { expect(event._handledState.unhandled).toBe(false) expect(event._handledState.severity).toBe('error') expect(event._handledState.severityReason).toEqual({ type: 'unhandledPromiseRejection' }) - plugin.destroy() + if (typeof plugin.destroy === 'function') { + plugin.destroy() + } done() }, plugins: [plugin] @@ -98,7 +104,9 @@ describe('plugin: node unhandled rejection handler', () => { expect(event._handledState.unhandled).toBe(true) expect(event._handledState.severity).toBe('error') expect(event._handledState.severityReason).toEqual({ type: 'unhandledPromiseRejection' }) - plugin.destroy() + if (typeof plugin.destroy === 'function') { + plugin.destroy() + } done() }, plugins: [plugin] @@ -150,7 +158,9 @@ describe('plugin: node unhandled rejection handler', () => { expect(options.onUnhandledRejection).toHaveBeenCalledTimes(1) } finally { - plugin.destroy() + if (typeof plugin.destroy === 'function') { + plugin.destroy() + } } }) @@ -199,7 +209,9 @@ describe('plugin: node unhandled rejection handler', () => { expect(listenersAfter[1]).toBe(listener) } finally { process.removeListener('unhandledRejection', listener) - plugin.destroy() + if (typeof plugin.destroy === 'function') { + plugin.destroy() + } } }) }) diff --git a/packages/plugin-node-unhandled-rejection/tsconfig.json b/packages/plugin-node-unhandled-rejection/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-node-unhandled-rejection/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-node-unhandled-rejection/unhandled-rejection.js b/packages/plugin-node-unhandled-rejection/unhandled-rejection.js deleted file mode 100644 index f850315e7e..0000000000 --- a/packages/plugin-node-unhandled-rejection/unhandled-rejection.js +++ /dev/null @@ -1,38 +0,0 @@ -let _handler -module.exports = { - load: client => { - if (!client._config.autoDetectErrors || !client._config.enabledErrorTypes.unhandledRejections) return - _handler = err => { - // if we are in an async context, use the client from that context - const ctx = client._clientContext && client._clientContext.getStore() - const c = ctx || client - - // Report unhandled promise rejections as handled if the user has configured it - const unhandled = !client._config.reportUnhandledPromiseRejectionsAsHandled - - const event = c.Event.create(err, false, { - severity: 'error', - unhandled, - severityReason: { type: 'unhandledPromiseRejection' } - }, 'unhandledRejection handler', 1) - - return new Promise(resolve => { - c._notify(event, () => {}, (e, event) => { - if (e) c._logger.error('Failed to send event to Bugsnag') - c._config.onUnhandledRejection(err, event, c._logger) - resolve() - }) - }) - } - - // Prepend the listener if we can (Node 6+) - if (process.prependListener) { - process.prependListener('unhandledRejection', _handler) - } else { - process.on('unhandledRejection', _handler) - } - }, - destroy: () => { - process.removeListener('unhandledRejection', _handler) - } -} diff --git a/packages/plugin-restify/package.json b/packages/plugin-restify/package.json index 19f22ad447..ee48062d95 100644 --- a/packages/plugin-restify/package.json +++ b/packages/plugin-restify/package.json @@ -1,8 +1,15 @@ { "name": "@bugsnag/plugin-restify", "version": "8.4.0", - "main": "src/restify.js", - "types": "types/bugsnag-restify.d.ts", + "main": "dist/restify.js", + "types": "dist/types/restify.d.ts", + "exports": { + ".": { + "types": "./dist/types/restify.d.ts", + "default": "./dist/restify.js", + "import": "./dist/restify.mjs" + } + }, "description": "@bugsnag/js error handling middleware for Restify web servers", "homepage": "https://www.bugsnag.com/", "repository": { @@ -13,8 +20,7 @@ "access": "public" }, "files": [ - "src", - "types" + "dist" ], "author": "Bugsnag", "license": "MIT", @@ -24,5 +30,11 @@ "devDependencies": { "@bugsnag/core": "^8.4.0", "@types/restify": "^8.4.2" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-restify/rollup.config.npm.mjs b/packages/plugin-restify/rollup.config.npm.mjs new file mode 100644 index 0000000000..0b7c473480 --- /dev/null +++ b/packages/plugin-restify/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs" + +export default createRollupConfig({ + input: "src/restify.ts", + external: ["restify", "async_hooks", "net"], +}) \ No newline at end of file diff --git a/packages/plugin-restify/src/request-info.js b/packages/plugin-restify/src/request-info.js deleted file mode 100644 index c46af8e1e0..0000000000 --- a/packages/plugin-restify/src/request-info.js +++ /dev/null @@ -1,36 +0,0 @@ -const { extractObject } = require('@bugsnag/core') - -module.exports = req => { - const connection = req.connection - const address = connection && connection.address && connection.address() - const portNumber = address && address.port - const path = req.getPath() || req.url - const url = req.absoluteUri(path) - const request = { - url: url, - path, - httpMethod: req.method, - headers: req.headers, - httpVersion: req.httpVersion - } - - request.params = extractObject(req, 'params') - request.query = extractObject(req, 'query') - request.body = extractObject(req, 'body') - - request.clientIp = req.headers['x-forwarded-for'] || (connection ? connection.remoteAddress : undefined) - request.referer = req.headers.referer || req.headers.referrer - - if (connection) { - request.connection = { - remoteAddress: connection.remoteAddress, - remotePort: connection.remotePort, - bytesRead: connection.bytesRead, - bytesWritten: connection.bytesWritten, - localPort: portNumber, - localAddress: address ? address.address : undefined, - IPVersion: address ? address.family : undefined - } - } - return request -} diff --git a/packages/plugin-restify/src/request-info.ts b/packages/plugin-restify/src/request-info.ts new file mode 100644 index 0000000000..b7ab83bb06 --- /dev/null +++ b/packages/plugin-restify/src/request-info.ts @@ -0,0 +1,78 @@ +import { extractObject } from '@bugsnag/core' +import type { AddressInfo } from 'net' +import type { Request } from 'restify' + +export interface RequestInfo extends Omit { + path: string + httpMethod?: string + clientIp?: string + referer?: string + headers?: { [key: string]: string } + connection?: { + remoteAddress?: string + remotePort?: number + bytesRead?: number + bytesWritten?: number + localPort?: number + localAddress?: string + IPVersion?: string + } +} + +export const getFirstHeader = (header: string | string[] | undefined): string | undefined => { + if (Array.isArray(header)) return header[0] + return header +} + +const isAddressInfo = (info: any): info is AddressInfo => { + return info && typeof info === 'object' && 'port' in info && 'address' in info && 'family' in info +} + +const extractRequestInfo = (req: Request): RequestInfo => { + const connection = req.socket || req.connection + const address = connection && connection.address && connection.address() + const portNumber = isAddressInfo(address) ? address.port : undefined + const path = req.getPath() || req.url + const url = req.absoluteUri(path as string) + + // Convert headers to the expected format + const formattedHeaders: { [key: string]: string } = {} + if (req.headers) { + Object.keys(req.headers).forEach(key => { + const value = req.headers[key] + if (value !== undefined) { + formattedHeaders[key] = Array.isArray(value) ? value[0] : value + } + }) + } + + const request: Partial = { + url: url, + path: path as string, + httpMethod: req.method, + headers: formattedHeaders, + httpVersion: req.httpVersion + } + + request.params = extractObject(req, 'params') + request.query = extractObject(req, 'query') + request.body = extractObject(req, 'body') + + request.clientIp = getFirstHeader(req.headers['x-forwarded-for']) || (connection ? connection.remoteAddress : undefined) + request.referer = getFirstHeader(req.headers.referer) || getFirstHeader(req.headers.referrer) + + if (connection) { + request.connection = { + remoteAddress: connection.remoteAddress, + remotePort: connection.remotePort, + bytesRead: connection.bytesRead, + bytesWritten: connection.bytesWritten, + localPort: portNumber, + localAddress: isAddressInfo(address) ? address.address : undefined, + IPVersion: isAddressInfo(address) ? address.family : undefined, + } + } + return request as RequestInfo +} + +export default extractRequestInfo \ No newline at end of file diff --git a/packages/plugin-restify/src/restify.js b/packages/plugin-restify/src/restify.js deleted file mode 100644 index e32333475a..0000000000 --- a/packages/plugin-restify/src/restify.js +++ /dev/null @@ -1,77 +0,0 @@ -const extractRequestInfo = require('./request-info') -const { cloneClient } = require('@bugsnag/core') -const handledState = { - severity: 'error', - unhandled: true, - severityReason: { - type: 'unhandledErrorMiddleware', - attributes: { framework: 'Restify' } - } -} - -module.exports = { - name: 'restify', - load: client => { - const requestHandler = (req, res, next) => { - // clone the client to be scoped to this request. If sessions are enabled, start one - const requestClient = cloneClient(client) - if (requestClient._config.autoTrackSessions) { - requestClient.startSession() - } - - // attach it to the request - req.bugsnag = requestClient - - // extract request info and pass it to the relevant bugsnag properties - requestClient.addOnError((event) => { - const { request, metadata } = getRequestAndMetadataFromReq(req) - event.request = { ...event.request, ...request } - event.addMetadata('request', metadata) - if (event._handledState.severityReason.type === 'unhandledException') { - event._handledState = handledState - } - }, true) - - client._clientContext.run(requestClient, next) - } - - const errorHandler = (req, res, err, cb) => { - if (!client._config.autoDetectErrors) return cb() - if (err.statusCode && err.statusCode < 500) return cb() - - const event = client.Event.create(err, false, handledState, 'restify middleware', 1) - const { metadata, request } = getRequestAndMetadataFromReq(req) - event.request = { ...event.request, ...request } - event.addMetadata('request', metadata) - - if (req.bugsnag) { - req.bugsnag._notify(event) - } else { - client._logger.warn( - 'req.bugsnag is not defined. Make sure the @bugsnag/plugin-restify requestHandler middleware is added first.' - ) - client._notify(event) - } - cb() - } - - return { requestHandler, errorHandler } - } -} - -const getRequestAndMetadataFromReq = req => { - const { body, ...requestInfo } = extractRequestInfo(req) - return { - metadata: requestInfo, - request: { - body, - clientIp: requestInfo.clientIp, - headers: requestInfo.headers, - httpMethod: requestInfo.httpMethod, - url: requestInfo.url, - referer: requestInfo.referer // Not part of the notifier spec for request but leaving for backwards compatibility - } - } -} - -module.exports.default = module.exports diff --git a/packages/plugin-restify/src/restify.ts b/packages/plugin-restify/src/restify.ts new file mode 100644 index 0000000000..f14d0addd8 --- /dev/null +++ b/packages/plugin-restify/src/restify.ts @@ -0,0 +1,124 @@ +import type { Client, Plugin } from '@bugsnag/core' +import { cloneClient } from '@bugsnag/core' +import { AsyncLocalStorage } from 'async_hooks' +import type { Next, Request, Response } from 'restify' +import restify from 'restify' +import type { RequestInfo } from './request-info' +import extractRequestInfo from './request-info' + +declare module 'restify' { + interface Request { + bugsnag?: Client + } +} + +// add a new call signature for the getPlugin() method that types the plugin result +declare module '@bugsnag/core' { + interface Client { + getPlugin(id: 'restify'): BugsnagPluginRestifyResult | undefined + } +} + +interface RestifyError extends Error { + statusCode?: number +} + +interface BugsnagPluginRestifyResult { + requestHandler: restify.RequestHandler + errorHandler: (req: restify.Request, res: restify.Response, err: RestifyError, cb: (...args: any[]) => void) => void +} + +interface ExtractedRequestData { + metadata: Omit + request: { + body: RequestInfo['body'] + clientIp: RequestInfo['clientIp'] + headers: RequestInfo['headers'] + httpMethod: RequestInfo['httpMethod'] + url: RequestInfo['url'] + referer: RequestInfo['referer'] + } +} + +interface InternalClient extends Client { + _clientContext: AsyncLocalStorage +} + +const handledState = { + severity: 'error', + unhandled: true, + severityReason: { + type: 'unhandledErrorMiddleware', + attributes: { framework: 'Restify' } + } +} + +const plugin: Plugin = { + name: 'restify', + load: (client: Client): BugsnagPluginRestifyResult => { + const internalClient = client as InternalClient + + const requestHandler = (req: Request, res: Response, next: Next) => { + // clone the client to be scoped to this request. If sessions are enabled, start one + const requestClient = cloneClient(internalClient) + if (requestClient._config.autoTrackSessions) { + requestClient.startSession() + } + + // attach it to the request + req.bugsnag = requestClient + + // extract request info and pass it to the relevant bugsnag properties + requestClient.addOnError((event) => { + const { request, metadata } = getRequestAndMetadataFromReq(req) + event.request = { ...event.request, ...request } + event.addMetadata('request', metadata) + if (event._handledState.severityReason.type === 'unhandledException') { + // @ts-expect-error override readonly property + event._handledState = handledState + } + }, true); + + internalClient._clientContext.run(requestClient, next) + } + + const errorHandler = (req: Request, res: Response, err: RestifyError, cb: () => void) => { + if (!internalClient._config.autoDetectErrors) return cb() + if (err.statusCode && err.statusCode < 500) return cb() + + const event = internalClient.Event.create(err, false, handledState, 'restify middleware', 1) + const { metadata, request } = getRequestAndMetadataFromReq(req) + event.request = { ...event.request, ...request } + event.addMetadata('request', metadata) + + if (req.bugsnag) { + req.bugsnag._notify(event) + } else { + internalClient._logger.warn( + 'req.bugsnag is not defined. Make sure the @bugsnag/plugin-restify requestHandler middleware is added first.' + ) + internalClient._notify(event) + } + cb() + } + + return { requestHandler, errorHandler } + } +} + +const getRequestAndMetadataFromReq = (req: Request): ExtractedRequestData => { + const { body, ...requestInfo } = extractRequestInfo(req) + return { + metadata: requestInfo, + request: { + body, + clientIp: requestInfo.clientIp, + headers: requestInfo.headers, + httpMethod: requestInfo.httpMethod, + url: requestInfo.url, + referer: requestInfo.referer // Not part of the notifier spec for request but leaving for backwards compatibility + } + } +} + +export default plugin \ No newline at end of file diff --git a/packages/plugin-restify/test/restify.test.ts b/packages/plugin-restify/test/restify.test.ts index 8f5f499456..13bdd1599c 100644 --- a/packages/plugin-restify/test/restify.test.ts +++ b/packages/plugin-restify/test/restify.test.ts @@ -1,4 +1,4 @@ -import { Client } from '@bugsnag/core' +import { Client, Event } from '@bugsnag/core' import plugin from '../src/restify' describe('plugin: restify', () => { diff --git a/packages/plugin-restify/tsconfig.json b/packages/plugin-restify/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-restify/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-restify/types/bugsnag-restify.d.ts b/packages/plugin-restify/types/bugsnag-restify.d.ts deleted file mode 100644 index 16542a5c4a..0000000000 --- a/packages/plugin-restify/types/bugsnag-restify.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Client, Plugin } from '@bugsnag/core' -import restify from 'restify' - -declare const bugsnagPluginRestify: Plugin -export default bugsnagPluginRestify - -interface BugsnagPluginRestifyResult { - requestHandler: restify.RequestHandler - errorHandler: (req: restify.Request, res: restify.Response, err: Error, cb: (...args: any[]) => void) => void -} - -// add a new call signature for the getPlugin() method that types the plugin result -declare module '@bugsnag/core' { - interface Client { - getPlugin(id: 'restify'): BugsnagPluginRestifyResult | undefined - } -} - -declare module 'restify' { - interface Request { - bugsnag?: Client - } -} diff --git a/packages/plugin-server-session/package.json b/packages/plugin-server-session/package.json index 1903c6ee77..a68bc0ebde 100644 --- a/packages/plugin-server-session/package.json +++ b/packages/plugin-server-session/package.json @@ -1,7 +1,15 @@ { "name": "@bugsnag/plugin-server-session", "version": "8.4.0", - "main": "session.js", + "main": "dist/session.js", + "types": "dist/types/session.d.ts", + "exports": { + ".": { + "types": "./dist/types/session.d.ts", + "default": "./dist/session.js", + "import": "./dist/session.mjs" + } + }, "description": "@bugsnag/js plugin to enable session tracking in server applications", "homepage": "https://www.bugsnag.com/", "repository": { @@ -12,7 +20,7 @@ "access": "public" }, "files": [ - "*.js" + "dist" ], "author": "Bugsnag", "license": "MIT", @@ -24,5 +32,11 @@ }, "peerDependencies": { "@bugsnag/core": "^8.0.0" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-server-session/rollup.config.npm.mjs b/packages/plugin-server-session/rollup.config.npm.mjs new file mode 100644 index 0000000000..449da30a65 --- /dev/null +++ b/packages/plugin-server-session/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs"; + +export default createRollupConfig({ + input: "src/session.ts", + external: ["events", "backo"], +}); \ No newline at end of file diff --git a/packages/plugin-server-session/src/backo.d.ts b/packages/plugin-server-session/src/backo.d.ts new file mode 100644 index 0000000000..757aaf15b2 --- /dev/null +++ b/packages/plugin-server-session/src/backo.d.ts @@ -0,0 +1,19 @@ +declare module 'backo' { + interface BackoffOptions { + min?: number + max?: number + jitter?: number + factor?: number + } + + class Backoff { + attempts: number + + constructor(options?: BackoffOptions) + + duration(): number + reset(): void + } + + export = Backoff +} diff --git a/packages/plugin-server-session/session.js b/packages/plugin-server-session/src/session.ts similarity index 69% rename from packages/plugin-server-session/session.js rename to packages/plugin-server-session/src/session.ts index 012f61ae85..64e68295d2 100644 --- a/packages/plugin-server-session/session.js +++ b/packages/plugin-server-session/src/session.ts @@ -1,10 +1,26 @@ -const { intRange, runSyncCallbacks } = require('@bugsnag/core') -const SessionTracker = require('./tracker') -const Backoff = require('backo') +import { App, Client, Config, Device, Notifier, Plugin, Session, intRange, runSyncCallbacks } from '@bugsnag/core' +import SessionTracker from './tracker' +import Backoff from 'backo' -module.exports = { +interface PluginConfig extends Config { + sessionSummaryInterval?: number +} + +interface SessionCount { + startedAt: string + sessionsStarted: number +} + +interface SessionPayload extends Session{ + notifier: Notifier + device: Device + app: App + sessionCounts: SessionCount[] +} + +const plugin: Plugin = { load: (client) => { - const sessionTracker = new SessionTracker(client._config.sessionSummaryInterval) + const sessionTracker = new SessionTracker(client._config.sessionSummaryInterval ?? undefined) sessionTracker.on('summary', sendSessionSummary(client)) sessionTracker.start() client._sessionDelegate = { @@ -31,8 +47,9 @@ module.exports = { return client } - // Otherwise start a new session - return client.startSession() + // Otherwise start a new session and ensure a Client is always returned + const newClient = client.startSession() + return newClient || client } } }, @@ -45,7 +62,7 @@ module.exports = { } } -const sendSessionSummary = client => sessionCounts => { +const sendSessionSummary = (client: Client) => (sessionCounts: SessionCount[]): void => { // exit early if the current releaseStage is not enabled if (client._config.enabledReleaseStages !== null && !client._config.enabledReleaseStages.includes(client._config.releaseStage)) { client._logger.warn('Session not sent due to releaseStage/enabledReleaseStages configuration') @@ -58,7 +75,7 @@ const sendSessionSummary = client => sessionCounts => { const maxAttempts = 10 req(handleRes) - function handleRes (err) { + function handleRes (err?: Error | null): void { if (!err) { const sessionCount = sessionCounts.reduce((accum, s) => accum + s.sessionsStarted, 0) return client._logger.debug(`${sessionCount} session(s) reported`) @@ -67,11 +84,11 @@ const sendSessionSummary = client => sessionCounts => { client._logger.error('Session delivery failed, max retries exceeded', err) return } - client._logger.debug('Session delivery failed, retry #' + (backoff.attempts + 1) + '/' + maxAttempts, err) + client._logger.error('Session delivery failed, retry #' + (backoff.attempts + 1) + '/' + maxAttempts, err) setTimeout(() => req(handleRes), backoff.duration()) } - function req (cb) { + function req (cb: (err?: Error | null) => void) { const payload = { notifier: client._notifier, device: {}, @@ -83,7 +100,7 @@ const sendSessionSummary = client => sessionCounts => { sessionCounts } - const ignore = runSyncCallbacks(client._cbs.sp, payload, 'onSessionPayload', client._logger) + const ignore = runSyncCallbacks(client._cbs.sp, payload as SessionPayload, 'onSessionPayload', client._logger) if (ignore) { client._logger.debug('Session not sent due to onSessionPayload callback') return cb(null) @@ -92,3 +109,5 @@ const sendSessionSummary = client => sessionCounts => { client._delivery.sendSession(payload, cb) } } + +export default plugin \ No newline at end of file diff --git a/packages/plugin-server-session/tracker.js b/packages/plugin-server-session/src/tracker.ts similarity index 63% rename from packages/plugin-server-session/tracker.js rename to packages/plugin-server-session/src/tracker.ts index 7132f3c3d6..36947e400b 100644 --- a/packages/plugin-server-session/tracker.js +++ b/packages/plugin-server-session/src/tracker.ts @@ -1,8 +1,14 @@ +import { Session } from '@bugsnag/core' +import EventEmitter from 'events' + const DEFAULT_SUMMARY_INTERVAL = 10 * 1000 -const Emitter = require('events').EventEmitter -module.exports = class SessionTracker extends Emitter { - constructor (intervalLength) { +class SessionTracker extends EventEmitter { + private _sessions: Map + private _interval: NodeJS.Timeout | null + private _intervalLength?: number + + constructor (intervalLength?: number) { super() this._sessions = new Map() this._interval = null @@ -18,11 +24,13 @@ module.exports = class SessionTracker extends Emitter { } stop () { - clearInterval(this._interval) + if (this._interval !== null) { + clearInterval(this._interval) + } this._interval = null } - track (session) { + track (session: Session) { const key = dateToMsKey(session.startedAt) const cur = this._sessions.get(key) this._sessions.set(key, typeof cur === 'undefined' ? 1 : cur + 1) @@ -30,7 +38,7 @@ module.exports = class SessionTracker extends Emitter { } _summarize () { - const summary = [] + const summary: Array<{ startedAt: string, sessionsStarted: number }> = [] this._sessions.forEach((val, key) => { summary.push({ startedAt: key, sessionsStarted: val }) this._sessions.delete(key) @@ -40,9 +48,11 @@ module.exports = class SessionTracker extends Emitter { } } -const dateToMsKey = (d) => { +const dateToMsKey = (d: Date) => { const dk = new Date(d) dk.setSeconds(0) dk.setMilliseconds(0) return dk.toISOString() } + +export default SessionTracker \ No newline at end of file diff --git a/packages/plugin-server-session/test/session.test.ts b/packages/plugin-server-session/test/session.test.ts index 549873a72b..4f94c2ccc8 100644 --- a/packages/plugin-server-session/test/session.test.ts +++ b/packages/plugin-server-session/test/session.test.ts @@ -1,12 +1,11 @@ +import { Client, Session } from '@bugsnag/core' import { EventEmitter } from 'events' -import { Client } from '@bugsnag/core' -import _Tracker from '../tracker' -import plugin from '../session' -import { Session } from '@bugsnag/core' +import plugin from '../src/session' +import _Tracker from '../src/tracker' const Tracker = _Tracker as jest.MockedClass -jest.mock('../tracker') +jest.mock('../src/tracker') describe('plugin: server sessions', () => { beforeEach(() => { diff --git a/packages/plugin-server-session/test/tracker.test.ts b/packages/plugin-server-session/test/tracker.test.ts index 4318dae044..7d7fecc0c4 100644 --- a/packages/plugin-server-session/test/tracker.test.ts +++ b/packages/plugin-server-session/test/tracker.test.ts @@ -1,6 +1,6 @@ -import Tracker from '../tracker' import { Session } from '@bugsnag/core' import timekeeper from 'timekeeper' +import Tracker from '../src/tracker' describe('session tracker', () => { it('should track sessions and summarize per minute', done => { @@ -28,13 +28,34 @@ describe('session tracker', () => { }) it('should only start one interval', () => { - const t = new Tracker(5) + jest.useFakeTimers() + const t = new Tracker(100) + let summaryEmissionCount = 0 + + t.track(new Session()) + t.track(new Session()) + + t.on('summary', () => { + summaryEmissionCount++ + }) + t.start() - const i0 = t._interval t.start() - expect(i0).toBe(t._interval) + t.start() + + jest.advanceTimersByTime(100) + expect(summaryEmissionCount).toBe(1) + + t.track(new Session()) + jest.advanceTimersByTime(100) + expect(summaryEmissionCount).toBe(2) + t.stop() - expect(t._interval).toBe(null) + t.track(new Session()) + jest.advanceTimersByTime(200) + expect(summaryEmissionCount).toBe(2) + + jest.useRealTimers() }) afterEach(() => timekeeper.reset()) diff --git a/packages/plugin-server-session/tsconfig.json b/packages/plugin-server-session/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-server-session/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-stackframe-path-normaliser/package.json b/packages/plugin-stackframe-path-normaliser/package.json index 1b607806ee..ebb2d15018 100644 --- a/packages/plugin-stackframe-path-normaliser/package.json +++ b/packages/plugin-stackframe-path-normaliser/package.json @@ -1,13 +1,24 @@ { "name": "@bugsnag/plugin-stackframe-path-normaliser", "version": "8.4.0", - "main": "path-normaliser.js", + "main": "dist/path-normaliser.js", + "types": "dist/types/path-normaliser.d.ts", + "exports": { + ".": { + "types": "./dist/types/path-normaliser.d.ts", + "default": "./dist/path-normaliser.js", + "import": "./dist/path-normaliser.mjs" + } + }, "description": "@bugsnag/js plugin to normalise file paths in stackframes", "homepage": "https://www.bugsnag.com/", "repository": { "type": "git", "url": "git@github.com:bugsnag/bugsnag-js.git" }, + "files": [ + "dist" + ], "publishConfig": { "access": "public" }, @@ -18,5 +29,11 @@ }, "devDependencies": { "@bugsnag/core": "^8.4.0" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-stackframe-path-normaliser/rollup.config.npm.mjs b/packages/plugin-stackframe-path-normaliser/rollup.config.npm.mjs new file mode 100644 index 0000000000..c4e52497e7 --- /dev/null +++ b/packages/plugin-stackframe-path-normaliser/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs" + +export default createRollupConfig({ + input: "src/path-normaliser.ts", + external: [/node_modules/], +}) \ No newline at end of file diff --git a/packages/plugin-stackframe-path-normaliser/path-normaliser.js b/packages/plugin-stackframe-path-normaliser/src/path-normaliser.ts similarity index 53% rename from packages/plugin-stackframe-path-normaliser/path-normaliser.js rename to packages/plugin-stackframe-path-normaliser/src/path-normaliser.ts index 3e656d07d4..5ae23c4480 100644 --- a/packages/plugin-stackframe-path-normaliser/path-normaliser.js +++ b/packages/plugin-stackframe-path-normaliser/src/path-normaliser.ts @@ -1,7 +1,9 @@ -module.exports = { +import type { Plugin, Stackframe } from '@bugsnag/core' + +const plugin: Plugin = { load (client) { client.addOnError(event => { - const allFrames = event.errors.reduce((accum, er) => accum.concat(er.stacktrace), []) + const allFrames: Stackframe[] = event.errors.reduce((accum: Stackframe[], er) => accum.concat(er.stacktrace), []) allFrames.forEach(stackframe => { if (typeof stackframe.file !== 'string') { @@ -13,3 +15,5 @@ module.exports = { }) } } + +export default plugin diff --git a/packages/plugin-stackframe-path-normaliser/test/path-normaliser.test.ts b/packages/plugin-stackframe-path-normaliser/test/path-normaliser.test.ts index 82c3b7427d..07070d657e 100644 --- a/packages/plugin-stackframe-path-normaliser/test/path-normaliser.test.ts +++ b/packages/plugin-stackframe-path-normaliser/test/path-normaliser.test.ts @@ -1,4 +1,4 @@ -import plugin from '../' +import plugin from '../src/path-normaliser' import { Client, Event } from '@bugsnag/core' describe('plugin: stackframe path normaliser', () => { diff --git a/packages/plugin-stackframe-path-normaliser/tsconfig.json b/packages/plugin-stackframe-path-normaliser/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-stackframe-path-normaliser/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/packages/plugin-strip-project-root/package.json b/packages/plugin-strip-project-root/package.json index 4c8b68b64c..c29e9209af 100644 --- a/packages/plugin-strip-project-root/package.json +++ b/packages/plugin-strip-project-root/package.json @@ -1,7 +1,15 @@ { "name": "@bugsnag/plugin-strip-project-root", "version": "8.4.0", - "main": "strip-project-root.js", + "main": "dist/strip-project-root.js", + "types": "dist/types/strip-project-root.d.ts", + "exports": { + ".": { + "types": "./dist/types/strip-project-root.d.ts", + "default": "./dist/strip-project-root.js", + "import": "./dist/strip-project-root.mjs" + } + }, "description": "@bugsnag/js plugin to remove common project root paths from stacktraces", "homepage": "https://www.bugsnag.com/", "repository": { @@ -12,11 +20,20 @@ "access": "public" }, "files": [ - "*.js" + "dist" ], "author": "Bugsnag", "license": "MIT", "dependencies": { "@bugsnag/path-normalizer": "^8.4.0" + }, + "devDependencies": { + "@bugsnag/core": "^8.4.0" + }, + "scripts": { + "build": "npm run build:npm", + "build:npm": "rollup --config rollup.config.npm.mjs", + "clean": "rm -rf dist/*", + "test:types": "tsc -p tsconfig.json" } } diff --git a/packages/plugin-strip-project-root/rollup.config.npm.mjs b/packages/plugin-strip-project-root/rollup.config.npm.mjs new file mode 100644 index 0000000000..b9bea7411f --- /dev/null +++ b/packages/plugin-strip-project-root/rollup.config.npm.mjs @@ -0,0 +1,6 @@ +import createRollupConfig from "../../.rollup/index.mjs"; + +export default createRollupConfig({ + input: "src/strip-project-root.ts", + external: ["@bugsnag/path-normalizer"], +}); \ No newline at end of file diff --git a/packages/plugin-strip-project-root/strip-project-root.js b/packages/plugin-strip-project-root/src/strip-project-root.ts similarity index 50% rename from packages/plugin-strip-project-root/strip-project-root.js rename to packages/plugin-strip-project-root/src/strip-project-root.ts index edc0671d11..70c9093e34 100644 --- a/packages/plugin-strip-project-root/strip-project-root.js +++ b/packages/plugin-strip-project-root/src/strip-project-root.ts @@ -1,10 +1,15 @@ -const normalizePath = require('@bugsnag/path-normalizer') +import type { Config, Plugin, Stackframe } from '@bugsnag/core' +import normalizePath from '@bugsnag/path-normalizer' -module.exports = { +interface PluginConfig extends Config { + projectRoot?: string +} + +const plugin: Plugin = { load: client => client.addOnError(event => { if (!client._config.projectRoot) return const projectRoot = normalizePath(client._config.projectRoot) - const allFrames = event.errors.reduce((accum, er) => accum.concat(er.stacktrace), []) + const allFrames: Stackframe[] = event.errors.reduce((accum: Stackframe[], er) => accum.concat(er.stacktrace), []) allFrames.map(stackframe => { if (typeof stackframe.file === 'string' && stackframe.file.indexOf(projectRoot) === 0) { stackframe.file = stackframe.file.replace(projectRoot, '') @@ -12,3 +17,5 @@ module.exports = { }) }) } + +export default plugin \ No newline at end of file diff --git a/packages/plugin-strip-project-root/test/strip-project-root.test.ts b/packages/plugin-strip-project-root/test/strip-project-root.test.ts index fefdba36a8..4e1c0d7d55 100644 --- a/packages/plugin-strip-project-root/test/strip-project-root.test.ts +++ b/packages/plugin-strip-project-root/test/strip-project-root.test.ts @@ -1,4 +1,4 @@ -import plugin from '../' +import plugin from '../src/strip-project-root' import { join } from 'path' import { Client, Event, schema } from '@bugsnag/core' diff --git a/packages/plugin-strip-project-root/tsconfig.json b/packages/plugin-strip-project-root/tsconfig.json new file mode 100644 index 0000000000..66a7db7b94 --- /dev/null +++ b/packages/plugin-strip-project-root/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2017"], + "types": ["node"] + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 4ab55f2541..e489be1aa5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,27 +15,14 @@ }, "include": [ "packages/core", - "packages/delivery-node", "packages/delivery-react-native", "packages/in-flight", "packages/plugin-aws-lambda", - "packages/plugin-contextualize", "packages/plugin-navigation-breadcrumbs", - "packages/plugin-server-session", "packages/plugin-react", "packages/plugin-vue", - "packages/plugin-express", - "packages/plugin-koa", - "packages/plugin-restify", "packages/node", - "packages/plugin-strip-project-root", "packages/plugin-interaction-breadcrumbs", - "packages/plugin-intercept", - "packages/plugin-node-unhandled-rejection", - "packages/plugin-node-in-project", - "packages/plugin-node-device", - "packages/plugin-node-surrounding-code", - "packages/plugin-node-uncaught-exception", "packages/browser" ], "exclude": [