Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/middlewares/deprecation.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
return {
headers,
setHeader(name: string, value: string) {
headers[name] = value;
},
} as unknown as Response & { headers: Record<string, string> };
}

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'], '</api/v2/creators>; 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();
69 changes: 69 additions & 0 deletions src/middlewares/deprecation.middleware.ts
Original file line number Diff line number Diff line change
@@ -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();
};
}
13 changes: 5 additions & 8 deletions src/middlewares/response-timing.middleware.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -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;
Expand All @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions src/utils/bigint-serializer.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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();
60 changes: 60 additions & 0 deletions src/utils/bigint-serializer.utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).map(([k, v]) => [
k,
sanitizeBigInts(v),
])
);
}
return value;
}
30 changes: 30 additions & 0 deletions src/utils/monotonic-clock.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
44 changes: 44 additions & 0 deletions src/utils/monotonic-clock.utils.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
Loading
Loading