diff --git a/package-lock.json b/package-lock.json index 08e4e0c..e0206fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codebygarv/express-lens", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codebygarv/express-lens", - "version": "2.2.0", + "version": "2.3.0", "license": "MIT", "devDependencies": { "@types/node": "^26.1.2", diff --git a/package.json b/package.json index a54f81e..fde991d 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,29 @@ "module": "./dist/index.mjs", "exports": { ".": { - "types": "./index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./express": { + "types": "./dist/adapters/express.d.ts", + "import": "./dist/adapters/express.js", + "require": "./dist/adapters/express.cjs" + }, + "./fastify": { + "types": "./dist/adapters/fastify.d.ts", + "import": "./dist/adapters/fastify.js", + "require": "./dist/adapters/fastify.cjs" + }, + "./hono": { + "types": "./dist/adapters/hono.d.ts", + "import": "./dist/adapters/hono.js", + "require": "./dist/adapters/hono.cjs" + }, + "./next": { + "types": "./dist/next.d.ts", + "import": "./dist/next.js", + "require": "./dist/next.cjs" } }, "types": "index.d.ts", @@ -23,7 +43,7 @@ "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", - "test": "node --import tsx --test test/*.test.js" + "test": "node --import tsx --test test/*.test.js test/adapters/*.test.js" }, "repository": { "type": "git", diff --git a/src/adapters/express.ts b/src/adapters/express.ts new file mode 100644 index 0000000..96b70b6 --- /dev/null +++ b/src/adapters/express.ts @@ -0,0 +1,25 @@ +import monitor, { + getMetrics, + resetMetrics, + metricsHandler, + prometheusHandler, + dashboardHandler, + exportHAR, + replayRequest, + generateCurl, +} from '../../index.ts'; + +export { + monitor as expressLens, + monitor, + getMetrics, + resetMetrics, + metricsHandler, + prometheusHandler, + dashboardHandler, + exportHAR, + replayRequest, + generateCurl, +}; + +export default monitor; diff --git a/src/adapters/fastify.ts b/src/adapters/fastify.ts new file mode 100644 index 0000000..9182988 --- /dev/null +++ b/src/adapters/fastify.ts @@ -0,0 +1,105 @@ +import store from '../store.ts'; +import { redactHeaders, generateCurl } from '../utils.ts'; +import { getDashboardHTML } from '../dashboard.ts'; +import type { ExpressLensOptions } from '../middleware.ts'; + +/** + * Fastify plugin adapter for Express Lens HTTP monitoring and debugging. + */ +export function fastifyExpressLens(options: ExpressLensOptions = {}) { + const slowThresholdMs = options.slowThresholdMs != null ? options.slowThresholdMs : 500; + const customRedactList = Array.isArray(options.redactHeaders) ? options.redactHeaders : []; + + return async function expressLensPlugin(fastify: any) { + const requestTimes = new WeakMap(); + + // 1. Hook into onRequest to record start time + fastify.addHook('onRequest', (request: any, reply: any, done: () => void) => { + requestTimes.set(request, Date.now()); + + const url = request.raw?.url || request.url || ''; + store.totalRequests++; + const method = request.method || 'GET'; + if (store.methods[method] !== undefined) { + store.methods[method]++; + } else { + store.methods[method] = 1; + } + + done(); + }); + + // 2. Hook into onResponse to record metrics + fastify.addHook('onResponse', (request: any, reply: any, done: () => void) => { + const startTime = requestTimes.get(request) || Date.now(); + const timeMs = Date.now() - startTime; + const durationMs = Number(timeMs.toFixed(2)); + + store.totalDuration += timeMs; + store.recordLatency(timeMs); + + const status = reply.statusCode || 200; + const url = request.raw?.url || request.url || ''; + const method = request.method || 'GET'; + const requestId = `fastify_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`; + const sanitizedHeaders = redactHeaders(request.headers || {}, customRedactList); + + const requestEntry = { + id: requestId, + timestamp: new Date().toISOString(), + method, + url, + status, + durationMs, + headers: sanitizedHeaders, + ip: request.ip || request.raw?.socket?.remoteAddress || '127.0.0.1', + curl: generateCurl({ method, url, headers: sanitizedHeaders, body: request.body }), + }; + + store.recordRequest(requestEntry); + + if (status >= 400) { + store.totalErrors++; + store.recordError({ + id: requestId, + timestamp: new Date().toISOString(), + method, + url, + status, + durationMs, + headers: sanitizedHeaders, + }); + } + + if (store.statusCodes[status] !== undefined) { + store.statusCodes[status]++; + } else { + store.statusCodes[status] = 1; + } + + const routeKey = `${method} ${request.routerPath || url}`; + store.recordRoute(routeKey, timeMs); + + if (slowThresholdMs > 0 && timeMs >= slowThresholdMs) { + store.recordSlowRequest({ + ...requestEntry, + slowThresholdMs, + }); + } + + store.checkAlerts(options); + done(); + }); + + // 3. Register Embedded Web Dashboard endpoint on Fastify + fastify.get('/express-lens', async (request: any, reply: any) => { + reply.type('text/html').send(getDashboardHTML()); + }); + + fastify.get('/express-lens/metrics-json', async (request: any, reply: any) => { + reply.type('application/json').send(store.getMetrics()); + }); + }; +} + +export default fastifyExpressLens; diff --git a/src/adapters/hono.ts b/src/adapters/hono.ts new file mode 100644 index 0000000..70fb268 --- /dev/null +++ b/src/adapters/hono.ts @@ -0,0 +1,102 @@ +import store from '../store.ts'; +import { redactHeaders, generateCurl } from '../utils.ts'; +import { getDashboardHTML } from '../dashboard.ts'; +import type { ExpressLensOptions } from '../middleware.ts'; + +/** + * Hono & Edge framework adapter for Express Lens HTTP monitoring and debugging. + * Compatible with Hono, Cloudflare Workers, Deno, Bun, and Vercel Edge. + */ +export function honoExpressLens(options: ExpressLensOptions = {}) { + const slowThresholdMs = options.slowThresholdMs != null ? options.slowThresholdMs : 500; + const customRedactList = Array.isArray(options.redactHeaders) ? options.redactHeaders : []; + + return async function middleware(c: any, next: () => Promise) { + const url = c.req.url || c.req.path || '/'; + + // 1. Intercept /express-lens dashboard endpoints directly inside Hono + if (url.includes('/express-lens')) { + if (url.includes('/metrics-json')) { + return c.json(store.getMetrics()); + } + return c.html(getDashboardHTML()); + } + + const startTime = globalThis.performance ? globalThis.performance.now() : Date.now(); + + store.totalRequests++; + const method = c.req.method || 'GET'; + if (store.methods[method] !== undefined) { + store.methods[method]++; + } else { + store.methods[method] = 1; + } + + await next(); + + const endTime = globalThis.performance ? globalThis.performance.now() : Date.now(); + const timeMs = endTime - startTime; + const durationMs = Number(timeMs.toFixed(2)); + + store.totalDuration += timeMs; + store.recordLatency(timeMs); + + const status = c.res.status || 200; + const requestId = `hono_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`; + const headersObj: Record = {}; + if (c.req.raw && c.req.raw.headers) { + c.req.raw.headers.forEach((v: string, k: string) => { + headersObj[k] = v; + }); + } + + const sanitizedHeaders = redactHeaders(headersObj, customRedactList); + + const requestEntry = { + id: requestId, + timestamp: new Date().toISOString(), + method, + url, + status, + durationMs, + headers: sanitizedHeaders, + ip: c.req.header('x-forwarded-for') || '127.0.0.1', + curl: generateCurl({ method, url, headers: sanitizedHeaders }), + }; + + store.recordRequest(requestEntry); + + if (status >= 400) { + store.totalErrors++; + store.recordError({ + id: requestId, + timestamp: new Date().toISOString(), + method, + url, + status, + durationMs, + headers: sanitizedHeaders, + }); + } + + if (store.statusCodes[status] !== undefined) { + store.statusCodes[status]++; + } else { + store.statusCodes[status] = 1; + } + + const routeKey = `${method} ${c.req.path || url}`; + store.recordRoute(routeKey, timeMs); + + if (slowThresholdMs > 0 && timeMs >= slowThresholdMs) { + store.recordSlowRequest({ + ...requestEntry, + slowThresholdMs, + }); + } + + store.checkAlerts(options); + }; +} + +export default honoExpressLens; diff --git a/src/next.ts b/src/next.ts new file mode 100644 index 0000000..411ece2 --- /dev/null +++ b/src/next.ts @@ -0,0 +1,115 @@ +import store from './store.ts'; +import { redactHeaders, generateCurl } from './utils.ts'; +import { getDashboardHTML } from './dashboard.ts'; +import type { ExpressLensOptions } from './middleware.ts'; + +/** + * Higher-Order Function to wrap Next.js App Router route handlers with HTTP monitoring. + */ +export function withExpressLens(handler: Function, options: ExpressLensOptions = {}) { + const slowThresholdMs = options.slowThresholdMs != null ? options.slowThresholdMs : 500; + const customRedactList = Array.isArray(options.redactHeaders) ? options.redactHeaders : []; + + return async function expressLensNextHandler(req: Request, context?: any) { + const startTime = globalThis.performance ? globalThis.performance.now() : Date.now(); + const url = req.url || '/'; + const method = req.method || 'GET'; + + store.totalRequests++; + if (store.methods[method] !== undefined) { + store.methods[method]++; + } else { + store.methods[method] = 1; + } + + let response: Response; + try { + response = await handler(req, context); + } catch (error) { + store.totalErrors++; + throw error; + } finally { + const endTime = globalThis.performance ? globalThis.performance.now() : Date.now(); + const timeMs = endTime - startTime; + const durationMs = Number(timeMs.toFixed(2)); + + store.totalDuration += timeMs; + store.recordLatency(timeMs); + + const status = response! ? response.status : 500; + const requestId = `next_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`; + + const headersObj: Record = {}; + if (req.headers) { + req.headers.forEach((v, k) => { + headersObj[k] = v; + }); + } + + const sanitizedHeaders = redactHeaders(headersObj, customRedactList); + + const requestEntry = { + id: requestId, + timestamp: new Date().toISOString(), + method, + url, + status, + durationMs, + headers: sanitizedHeaders, + ip: req.headers.get('x-forwarded-for') || '127.0.0.1', + curl: generateCurl({ method, url, headers: sanitizedHeaders }), + }; + + store.recordRequest(requestEntry); + + if (status >= 400) { + store.totalErrors++; + store.recordError({ + id: requestId, + timestamp: new Date().toISOString(), + method, + url, + status, + durationMs, + headers: sanitizedHeaders, + }); + } + + if (store.statusCodes[status] !== undefined) { + store.statusCodes[status]++; + } else { + store.statusCodes[status] = 1; + } + + const routeKey = `${method} ${url}`; + store.recordRoute(routeKey, timeMs); + + if (slowThresholdMs > 0 && timeMs >= slowThresholdMs) { + store.recordSlowRequest({ + ...requestEntry, + slowThresholdMs, + }); + } + + store.checkAlerts(options); + } + + return response; + }; +} + +/** + * Route handler for exposing the Express Lens dashboard in Next.js catch-all route. + * Usage: export const GET = dashboardRoute(); inside app/api/express-lens/[[...route]]/route.ts + */ +export function dashboardRoute() { + return async function handleNextDashboard(req: Request) { + const url = req.url || ''; + if (url.includes('/metrics-json')) { + return Response.json(store.getMetrics()); + } + return new Response(getDashboardHTML(), { + headers: { 'Content-Type': 'text/html' }, + }); + }; +} diff --git a/test/adapters/fastify.test.js b/test/adapters/fastify.test.js new file mode 100644 index 0000000..ee497a0 --- /dev/null +++ b/test/adapters/fastify.test.js @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { fastifyExpressLens } from '../../src/adapters/fastify.ts'; +import store from '../../src/store.ts'; + +test('Fastify Adapter Suite', async (t) => { + t.afterEach(() => { + store.reset(); + }); + + await t.test('tracks requests via Fastify hooks', async () => { + const plugin = fastifyExpressLens({ slowThresholdMs: 100 }); + const hooks = {}; + const routes = {}; + + const mockFastify = { + addHook: (event, fn) => { + hooks[event] = fn; + }, + get: (path, fn) => { + routes[path] = fn; + } + }; + + await plugin(mockFastify); + + assert.ok(typeof hooks['onRequest'] === 'function'); + assert.ok(typeof hooks['onResponse'] === 'function'); + + const req = { method: 'GET', url: '/fastify-test', headers: {} }; + const res = { statusCode: 200 }; + + hooks['onRequest'](req, res, () => {}); + hooks['onResponse'](req, res, () => {}); + + const metrics = store.getMetrics(); + assert.strictEqual(metrics.totalRequests, 1); + assert.strictEqual(metrics.methods.GET, 1); + assert.strictEqual(metrics.statusCodes[200], 1); + }); +}); diff --git a/test/adapters/hono.test.js b/test/adapters/hono.test.js new file mode 100644 index 0000000..ae8bf70 --- /dev/null +++ b/test/adapters/hono.test.js @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { honoExpressLens } from '../../src/adapters/hono.ts'; +import store from '../../src/store.ts'; + +test('Hono Adapter Suite', async (t) => { + t.afterEach(() => { + store.reset(); + }); + + await t.test('tracks requests via Hono middleware', async () => { + const middleware = honoExpressLens({ slowThresholdMs: 100 }); + const mockContext = { + req: { + method: 'POST', + url: 'http://localhost/api/data', + path: '/api/data', + header: () => '127.0.0.1', + raw: { headers: new Map([['content-type', 'application/json']]) } + }, + res: { status: 201 } + }; + + await middleware(mockContext, async () => {}); + + const metrics = store.getMetrics(); + assert.strictEqual(metrics.totalRequests, 1); + assert.strictEqual(metrics.methods.POST, 1); + assert.strictEqual(metrics.statusCodes[201], 1); + }); +}); diff --git a/test/adapters/next.test.js b/test/adapters/next.test.js new file mode 100644 index 0000000..0bb5aa7 --- /dev/null +++ b/test/adapters/next.test.js @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { withExpressLens, dashboardRoute } from '../../src/next.ts'; +import store from '../../src/store.ts'; + +test('Next.js App Router Adapter Suite', async (t) => { + t.afterEach(() => { + store.reset(); + }); + + await t.test('wraps Next.js route handler with withExpressLens', async () => { + const mockHandler = async (req) => { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }; + + const wrapped = withExpressLens(mockHandler); + const mockReq = new Request('http://localhost/api/users', { + method: 'GET', + headers: { 'user-agent': 'next-test' } + }); + + const res = await wrapped(mockReq); + assert.strictEqual(res.status, 200); + + const metrics = store.getMetrics(); + assert.strictEqual(metrics.totalRequests, 1); + assert.strictEqual(metrics.methods.GET, 1); + }); + + await t.test('dashboardRoute serves HTML dashboard in Next.js', async () => { + const handler = dashboardRoute(); + const mockReq = new Request('http://localhost/api/express-lens'); + const res = await handler(mockReq); + + assert.strictEqual(res.headers.get('Content-Type'), 'text/html'); + const body = await res.text(); + assert.ok(body.includes('Express Lens')); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 4afadca..14183f4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,14 +3,14 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "types": ["node"], "esModuleInterop": true, "strict": true, "skipLibCheck": true, "declaration": true, - "emitDeclarationOnly": false, "noEmit": true, "allowImportingTsExtensions": true, - "types": ["node"] + "ignoreDeprecations": "6.0" }, "include": ["src/**/*", "index.ts"] } diff --git a/tsup.config.ts b/tsup.config.ts index ea39eff..e047344 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,13 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['index.ts'], + entry: { + index: 'index.ts', + 'adapters/express': 'src/adapters/express.ts', + 'adapters/fastify': 'src/adapters/fastify.ts', + 'adapters/hono': 'src/adapters/hono.ts', + next: 'src/next.ts', + }, format: ['cjs', 'esm'], dts: true, clean: true,