diff --git a/src/middlewares/deprecation.middleware.test.ts b/src/middlewares/deprecation.middleware.test.ts new file mode 100644 index 0000000..ba99140 --- /dev/null +++ b/src/middlewares/deprecation.middleware.test.ts @@ -0,0 +1,68 @@ +import { strict as assert } from 'assert'; +import { deprecate } from './deprecation.middleware'; +import type { Request, Response, NextFunction } from 'express'; + +// Minimal mock helpers +function mockRes() { + const headers: Record = {}; + return { + headers, + setHeader(name: string, value: string) { + headers[name] = value; + }, + } as unknown as Response & { headers: Record }; +} + +function mockReq() { + return {} as Request; +} + +function run() { + // sets Deprecation header + { + const res = mockRes(); + let called = false; + const next: NextFunction = () => { called = true; }; + deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })(mockReq(), res, next); + assert.equal(res.headers['Deprecation'], '2026-01-01T00:00:00Z'); + assert.ok(called, 'next() should be called'); + } + + // sets Sunset header when provided + { + const res = mockRes(); + deprecate({ + deprecatedSince: '2026-01-01T00:00:00Z', + sunsetDate: '2026-07-01T00:00:00Z', + })(mockReq(), res, () => {}); + assert.equal(res.headers['Sunset'], '2026-07-01T00:00:00Z'); + } + + // omits Sunset header when not provided + { + const res = mockRes(); + deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })(mockReq(), res, () => {}); + assert.ok(!('Sunset' in res.headers), 'Sunset should not be set'); + } + + // sets Link header with successor-version rel when provided + { + const res = mockRes(); + deprecate({ + deprecatedSince: '2026-01-01T00:00:00Z', + link: '/api/v2/creators', + })(mockReq(), res, () => {}); + assert.equal(res.headers['Link'], '; rel="successor-version"'); + } + + // omits Link header when not provided + { + const res = mockRes(); + deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })(mockReq(), res, () => {}); + assert.ok(!('Link' in res.headers), 'Link should not be set'); + } + + console.log('deprecation.middleware tests passed'); +} + +run(); diff --git a/src/middlewares/deprecation.middleware.ts b/src/middlewares/deprecation.middleware.ts new file mode 100644 index 0000000..341efbb --- /dev/null +++ b/src/middlewares/deprecation.middleware.ts @@ -0,0 +1,69 @@ +// src/middlewares/deprecation.middleware.ts +import { Request, Response, NextFunction } from 'express'; + +/** + * Metadata describing a deprecated endpoint. + */ +export interface DeprecationOptions { + /** + * ISO 8601 date string indicating when the endpoint was deprecated. + * Emitted as the `Deprecation` header per draft-ietf-httpapi-deprecation-header. + * + * @example '2026-01-01T00:00:00Z' + */ + deprecatedSince: string; + + /** + * ISO 8601 date string for when the endpoint will be removed. + * Emitted as the `Sunset` header per RFC 8594. + * + * @example '2026-07-01T00:00:00Z' + */ + sunsetDate?: string; + + /** + * URL pointing to migration docs or the replacement endpoint. + * Emitted as a `Link` header with rel="successor-version". + * + * @example 'https://docs.example.com/migration/v2' + */ + link?: string; +} + +/** + * Middleware factory that marks an endpoint as deprecated by injecting + * standard response headers. + * + * Headers set: + * - `Deprecation` – date the endpoint was deprecated (RFC / IETF draft) + * - `Sunset` – planned removal date (RFC 8594), when provided + * - `Link` – migration URL with rel="successor-version", when provided + * + * @example + * router.get( + * '/v1/creators', + * deprecate({ + * deprecatedSince: '2026-01-01T00:00:00Z', + * sunsetDate: '2026-07-01T00:00:00Z', + * link: '/api/v2/creators', + * }), + * listCreators, + * ); + */ +export function deprecate(options: DeprecationOptions) { + const { deprecatedSince, sunsetDate, link } = options; + + return (_req: Request, res: Response, next: NextFunction): void => { + res.setHeader('Deprecation', deprecatedSince); + + if (sunsetDate) { + res.setHeader('Sunset', sunsetDate); + } + + if (link) { + res.setHeader('Link', `<${link}>; rel="successor-version"`); + } + + next(); + }; +} diff --git a/src/middlewares/response-timing.middleware.ts b/src/middlewares/response-timing.middleware.ts index 702bf52..ff4ce34 100644 --- a/src/middlewares/response-timing.middleware.ts +++ b/src/middlewares/response-timing.middleware.ts @@ -1,12 +1,13 @@ // src/middlewares/response-timing.middleware.ts import { Request, Response, NextFunction } from 'express'; import { envConfig } from '../config'; +import { startTimer, elapsedMsFormatted } from '../utils/monotonic-clock.utils'; /** * Middleware that adds an `X-Response-Time` header to the response. * - * It calculates the time elapsed from the beginning of the request - * until the response is sent to the client. + * Uses a monotonic clock (`process.hrtime`) so the measurement is not + * affected by system clock adjustments. * * Can be enabled/disabled via the `ENABLE_RESPONSE_TIMING` environment variable. */ @@ -19,7 +20,7 @@ export const responseTimingMiddleware = ( return next(); } - const start = process.hrtime(); + const timer = startTimer(); // Intercept the response headers being sent const originalWriteHead = res.writeHead; @@ -29,11 +30,7 @@ export const responseTimingMiddleware = ( reasonOrHeaders?: string | any, headers?: any ) { - const diff = process.hrtime(start); - const timeInMs = (diff[0] * 1e3 + diff[1] * 1e-6).toFixed(3); - - // Set the header before the original writeHead is called - res.setHeader('X-Response-Time', `${timeInMs}ms`); + res.setHeader('X-Response-Time', elapsedMsFormatted(timer)); return originalWriteHead.apply(this, [ statusCode, diff --git a/src/utils/bigint-serializer.utils.test.ts b/src/utils/bigint-serializer.utils.test.ts new file mode 100644 index 0000000..109bf9f --- /dev/null +++ b/src/utils/bigint-serializer.utils.test.ts @@ -0,0 +1,42 @@ +import { strict as assert } from 'assert'; +import { bigIntReplacer, safeJsonStringify, sanitizeBigInts } from './bigint-serializer.utils'; + +function run() { + // bigIntReplacer converts BigInt to string + assert.equal(bigIntReplacer('id', 9007199254740993n), '9007199254740993'); + + // bigIntReplacer passes non-BigInt values through unchanged + assert.equal(bigIntReplacer('n', 42), 42); + assert.equal(bigIntReplacer('s', 'hello'), 'hello'); + assert.equal(bigIntReplacer('b', true), true); + assert.equal(bigIntReplacer('n', null), null); + + // safeJsonStringify handles BigInt without throwing + const json = safeJsonStringify({ id: 1000000000000000001n, label: 'test' }); + assert.equal(json, '{"id":"1000000000000000001","label":"test"}'); + + // safeJsonStringify handles nested BigInt + const nested = safeJsonStringify({ a: { b: 2n } }); + assert.equal(nested, '{"a":{"b":"2"}}'); + + // safeJsonStringify with no BigInt behaves like JSON.stringify + assert.equal(safeJsonStringify({ x: 1 }), JSON.stringify({ x: 1 })); + + // sanitizeBigInts – top-level BigInt + assert.equal(sanitizeBigInts(5n), '5'); + + // sanitizeBigInts – nested object + const sanitized = sanitizeBigInts({ id: 1n, nested: { amount: 500n }, label: 'ok' }); + assert.deepEqual(sanitized, { id: '1', nested: { amount: '500' }, label: 'ok' }); + + // sanitizeBigInts – array + assert.deepEqual(sanitizeBigInts([1n, 2n, 3n]), ['1', '2', '3']); + + // sanitizeBigInts – non-BigInt primitives pass through + assert.equal(sanitizeBigInts(42), 42); + assert.equal(sanitizeBigInts('str'), 'str'); + + console.log('bigint-serializer.utils tests passed'); +} + +run(); diff --git a/src/utils/bigint-serializer.utils.ts b/src/utils/bigint-serializer.utils.ts new file mode 100644 index 0000000..c728fbe --- /dev/null +++ b/src/utils/bigint-serializer.utils.ts @@ -0,0 +1,60 @@ +// src/utils/bigint-serializer.utils.ts + +/** + * JSON replacer that converts BigInt values to strings. + * + * Use this wherever `JSON.stringify` may encounter BigInt values to prevent + * a `TypeError: Do not know how to serialize a BigInt` runtime failure. + * + * Numeric formatting is kept explicit: BigInt values become decimal strings + * (e.g. `9007199254740993n` → `"9007199254740993"`). + * + * @example + * JSON.stringify({ id: 9007199254740993n }, bigIntReplacer); + * // → '{"id":"9007199254740993"}' + */ +export function bigIntReplacer(_key: string, value: unknown): unknown { + return typeof value === 'bigint' ? value.toString() : value; +} + +/** + * Safely serializes a value to a JSON string, handling BigInt values. + * + * Combines `bigIntReplacer` with an optional `space` argument for + * pretty-printing. Prefer this over raw `JSON.stringify` when the + * payload may contain BigInt fields (e.g. blockchain IDs, ledger amounts). + * + * @param value - The value to serialize. + * @param space - Optional indentation (passed to `JSON.stringify`). + * + * @example + * safeJsonStringify({ amount: 1000000000000000000n }); + * // → '{"amount":"1000000000000000000"}' + */ +export function safeJsonStringify(value: unknown, space?: number): string { + return JSON.stringify(value, bigIntReplacer, space); +} + +/** + * Recursively replaces BigInt values in an object with their decimal string + * representation. Useful when you need a plain object (not a JSON string) + * with BigInts already coerced — e.g. before handing data to a logger or + * a third-party serializer that doesn't accept a replacer. + * + * @example + * sanitizeBigInts({ id: 1n, nested: { amount: 500n } }); + * // → { id: "1", nested: { amount: "500" } } + */ +export function sanitizeBigInts(value: unknown): unknown { + if (typeof value === 'bigint') return value.toString(); + if (Array.isArray(value)) return value.map(sanitizeBigInts); + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [ + k, + sanitizeBigInts(v), + ]) + ); + } + return value; +} diff --git a/src/utils/monotonic-clock.utils.test.ts b/src/utils/monotonic-clock.utils.test.ts new file mode 100644 index 0000000..48b50e6 --- /dev/null +++ b/src/utils/monotonic-clock.utils.test.ts @@ -0,0 +1,30 @@ +import { strict as assert } from 'assert'; +import { startTimer, elapsedMs, elapsedMsFormatted } from './monotonic-clock.utils'; + +async function run() { + // elapsedMs returns a non-negative number + const t = startTimer(); + const ms = elapsedMs(t); + assert.ok(typeof ms === 'number', 'elapsedMs should return a number'); + assert.ok(ms >= 0, 'elapsed time should be non-negative'); + + // elapsedMs grows over time + const t2 = startTimer(); + await new Promise((r) => setTimeout(r, 20)); + const elapsed = elapsedMs(t2); + assert.ok(elapsed >= 15, `expected >= 15ms, got ${elapsed}ms`); + + // elapsedMsFormatted returns a string ending in "ms" with 3 decimal places + const t3 = startTimer(); + const formatted = elapsedMsFormatted(t3); + assert.ok(typeof formatted === 'string', 'should return a string'); + assert.ok(formatted.endsWith('ms'), 'should end with "ms"'); + assert.ok(/^\d+\.\d{3}ms$/.test(formatted), `unexpected format: ${formatted}`); + + console.log('monotonic-clock.utils tests passed'); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/utils/monotonic-clock.utils.ts b/src/utils/monotonic-clock.utils.ts new file mode 100644 index 0000000..54bb340 --- /dev/null +++ b/src/utils/monotonic-clock.utils.ts @@ -0,0 +1,44 @@ +// src/utils/monotonic-clock.utils.ts + +/** + * Opaque handle returned by `startTimer`. Pass it to `elapsedMs` to get + * the duration. Using a branded type prevents mixing up raw hrtime tuples. + */ +export type TimerHandle = { readonly _hrtime: [number, number] }; + +/** + * Starts a monotonic timer using `process.hrtime`. + * + * Unlike `Date.now()`, `process.hrtime` is not affected by system clock + * adjustments, making it reliable for latency measurements. + * + * @example + * const t = startTimer(); + * await doWork(); + * const ms = elapsedMs(t); // e.g. 42.317 + */ +export function startTimer(): TimerHandle { + return { _hrtime: process.hrtime() }; +} + +/** + * Returns the elapsed time in milliseconds (floating-point) since the + * timer was started with `startTimer`. + * + * @param handle - The handle returned by `startTimer`. + */ +export function elapsedMs(handle: TimerHandle): number { + const diff = process.hrtime(handle._hrtime); + return diff[0] * 1e3 + diff[1] * 1e-6; +} + +/** + * Returns the elapsed time as a formatted string rounded to 3 decimal places. + * Matches the format already used by `X-Response-Time` in this codebase. + * + * @example + * elapsedMsFormatted(t); // "42.317ms" + */ +export function elapsedMsFormatted(handle: TimerHandle): string { + return `${elapsedMs(handle).toFixed(3)}ms`; +} diff --git a/src/utils/rpc-timeout.utils.test.ts b/src/utils/rpc-timeout.utils.test.ts new file mode 100644 index 0000000..12e9442 --- /dev/null +++ b/src/utils/rpc-timeout.utils.test.ts @@ -0,0 +1,46 @@ +import { strict as assert } from 'assert'; +import { withRpcTimeout, RpcTimeoutError, DEFAULT_RPC_TIMEOUT_MS } from './rpc-timeout.utils'; + +async function run() { + // resolves before timeout + const result = await withRpcTimeout('ok', () => Promise.resolve(42), 100); + assert.equal(result, 42, 'should resolve with the wrapped value'); + + // rejects with RpcTimeoutError when the promise is too slow + await assert.rejects( + () => + withRpcTimeout( + 'slow', + () => new Promise((resolve) => setTimeout(resolve, 200)), + 50 + ), + (err: unknown) => { + assert.ok(err instanceof RpcTimeoutError, 'should be RpcTimeoutError'); + assert.equal((err as RpcTimeoutError).operation, 'slow'); + assert.equal((err as RpcTimeoutError).timeoutMs, 50); + assert.ok((err as RpcTimeoutError).message.includes('50ms')); + assert.ok((err as RpcTimeoutError).isTimeout); + return true; + } + ); + + // propagates non-timeout rejections unchanged + await assert.rejects( + () => withRpcTimeout('fail', () => Promise.reject(new Error('boom')), 100), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.equal((err as Error).message, 'boom'); + return true; + } + ); + + // default timeout constant is exported and numeric + assert.equal(typeof DEFAULT_RPC_TIMEOUT_MS, 'number'); + + console.log('rpc-timeout.utils tests passed'); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/utils/rpc-timeout.utils.ts b/src/utils/rpc-timeout.utils.ts new file mode 100644 index 0000000..3359831 --- /dev/null +++ b/src/utils/rpc-timeout.utils.ts @@ -0,0 +1,62 @@ +// src/utils/rpc-timeout.utils.ts +import { ErrorCode } from '../constants/error.constants'; + +/** + * Structured error thrown when an RPC call exceeds its timeout. + */ +export class RpcTimeoutError extends Error { + readonly code = ErrorCode.INTERNAL_ERROR; + readonly isTimeout = true; + + constructor( + public readonly operation: string, + public readonly timeoutMs: number + ) { + super(`RPC call "${operation}" timed out after ${timeoutMs}ms`); + this.name = 'RpcTimeoutError'; + Error.captureStackTrace(this, this.constructor); + } +} + +/** + * Default timeout in milliseconds for outbound RPC calls. + */ +export const DEFAULT_RPC_TIMEOUT_MS = 5000; + +/** + * Wraps an outbound RPC call with a timeout. + * + * If the promise does not resolve within `timeoutMs`, it rejects with + * a `RpcTimeoutError` that carries a consistent error shape. + * + * @param operation - Human-readable name for the call (used in error messages). + * @param fn - Factory that returns the promise to race against the timeout. + * @param timeoutMs - Per-call override. Defaults to `DEFAULT_RPC_TIMEOUT_MS`. + * + * @example + * const data = await withRpcTimeout('fetchUserProfile', () => externalApi.getUser(id)); + * + * @example + * // Per-call override + * const data = await withRpcTimeout('slowQuery', () => db.query(), 10_000); + */ +export async function withRpcTimeout( + operation: string, + fn: () => Promise, + timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS +): Promise { + let timer: ReturnType | undefined; + + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new RpcTimeoutError(operation, timeoutMs)); + }, timeoutMs); + }); + + try { + const result = await Promise.race([fn(), timeout]); + return result; + } finally { + clearTimeout(timer); + } +} diff --git a/tsconfig.json b/tsconfig.json index 0260630..4872010 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { "ignoreDeprecations": "5.0", - "target": "es2017", + "target": "es2020", "module": "commonjs", - "lib": ["es2017", "dom"], + "lib": ["es2020", "dom"], "allowJs": true, "outDir": "dist", "rootDir": "src",