From eb973dab54dbd7b89916b20d09072d27f393f7d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:08:00 +0000 Subject: [PATCH 1/7] fix: preserve explicit status codes and improve API ergonomics - res.send(undefined, code) and promises resolving to undefined keep the explicit status code - non-numeric error codes (e.g. 'ENOENT') no longer mask a valid err.statusCode - APM instrumentation names multi-path routes per path - expose runtime Protocol/Method objects matching the TypeScript enums - type variadic use() middlewares; drop the unsupported prefix-less use(router) - add .nvmrc Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2wFkaGAAWQhBw9VtCu7qe --- .nvmrc | 1 + CHANGELOG.md | 13 +++++++ docs/README.md | 13 +++++-- index.d.ts | 11 +++--- index.js | 21 ++++++++++- libs/apm-base.js | 7 ++++ libs/response-extensions.js | 11 ++++-- libs/utils.js | 18 +++++++++ specs/bugfixes.test.js | 75 +++++++++++++++++++++++++++++++++++++ specs/types.test.ts | 16 ++++++++ 10 files changed, 172 insertions(+), 14 deletions(-) create mode 100644 .nvmrc create mode 100644 specs/bugfixes.test.js diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b7b8a..a909a69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +### Added +- Runtime `restana.Protocol` and `restana.Method` objects matching the TypeScript enums. +- TypeScript support for registering multiple middlewares in one `use()` call. +- `.nvmrc` pinning the supported Node.js major version for contributors. + +### Fixed +- `res.send(undefined, code)` and promises resolving to `undefined` no longer drop the explicit status code. +- Non-numeric error codes (e.g. `'ENOENT'`) no longer mask a valid `err.statusCode`. +- APM instrumentation names multi-path routes per path instead of joining them (`GET /a,/b`). +- TypeScript no longer accepts `use(router)` without a prefix, which throws at runtime. + ## 6.1.0 ### Added diff --git a/docs/README.md b/docs/README.md index e969b5a..5b97b0c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -80,7 +80,7 @@ Optionally, learn through examples: - `server`: Allows to optionally override the HTTP server instance to be used. - `prioRequestsProcessing`: If `TRUE`, HTTP requests processing/handling is prioritized using `setImmediate`. Default value: `TRUE` - `defaultRoute`: Optional route handler when no route match occurs. Default value: `((req, res) => res.send(404))` -- `errorHandler`: Optional global error handler function. Default value: `(err, req, res) => { const statusCode = typeof (err.status || err.code || err.statusCode) === 'number' ? (err.status || err.code || err.statusCode) : 500; res.send({ code: statusCode, message: 'Internal Server Error' }, statusCode) }`. The default handler returns a generic error message to prevent leaking sensitive internal details (e.g. database connection strings, file paths, stack traces). The appropriate HTTP status code is still preserved from `err.status`, `err.code`, or `err.statusCode`. +- `errorHandler`: Optional global error handler function. Default value: `(err, req, res) => res.send({ code: statusCode, message: 'Internal Server Error' }, statusCode)`, where `statusCode` is the first valid HTTP status code found in `err.status`, `err.code` or `err.statusCode` (falls back to `500`). The default handler returns a generic error message to prevent leaking sensitive internal details (e.g. database connection strings, file paths, stack traces). The appropriate HTTP status code is still preserved from `err.status`, `err.code`, or `err.statusCode`. - `routerCacheSize`: The router matching cache size, indicates how many request matches will be kept in memory. Default value: `2000` - `enableTrace`: When `TRUE`, the `TRACE` HTTP method handler is available for debugging purposes. Default value: `FALSE`. ⚠️ Not recommended for production deployments. - `securityHeaders`: When `TRUE`, default security headers are set on every response. Set to `FALSE` to disable (e.g. when using Helmet or serving non-browser clients). Default value: `TRUE`. @@ -217,10 +217,11 @@ res.send( ) ``` > Optionally, you can also just send a response code: -> `res.send(401)` +> `res.send(401)` +> An `undefined` body keeps the explicit status code: `res.send(undefined, 204)` ## Global error handling -By default, restana returns a generic `Internal Server Error` message to the client, preventing internal details from being leaked. The HTTP status code is preserved from `err.status`, `err.code`, or `err.statusCode` (defaults to `500`). +By default, restana returns a generic `Internal Server Error` message to the client, preventing internal details from being leaked. The HTTP status code is preserved from the first valid status code found in `err.status`, `err.code`, or `err.statusCode` (defaults to `500`). Non-numeric values such as Node.js system error codes (`'ENOENT'`) are ignored. To customize error responses, provide your own `errorHandler`: ```js @@ -281,6 +282,12 @@ service.use('/admin', (req, res, next) => { ... ``` +Multiple middlewares can be registered in a single call, with or without prefix: +```js +service.use(cors, logger) +service.use('/admin', authenticate, authorize) +``` + ### Route level middlewares Connecting middlewares to specific routes is also supported: ```js diff --git a/index.d.ts b/index.d.ts index 56f3ea1..811f3aa 100644 --- a/index.d.ts +++ b/index.d.ts @@ -71,8 +71,9 @@ declare namespace restana { head: RegisterRoute

options: RegisterRoute

all: RegisterRoute

- use(middleware: RequestHandler

| Router

): Router

- use(prefix: string, middleware: RequestHandler

| Router

): Router

+ use(...middlewares: RequestHandler

[]): Router

+ use(prefix: string, ...middlewares: RequestHandler

[]): Router

+ use(prefix: string, router: Router

): Router

lookup(req: Request

, res: Response

): unknown find(method: string, path: string): unknown } @@ -151,9 +152,9 @@ declare namespace restana { errorHandler: ErrorHandler

, getServer(): Server

, getConfigOptions(): Options

- use(middleware: RequestHandler

): restana.Service

- use(prefix: string, middleware: RequestHandler

): restana.Service

- use(prefix: string, middleware: Router

): restana.Service

+ use(...middlewares: RequestHandler

[]): restana.Service

+ use(prefix: string, ...middlewares: RequestHandler

[]): restana.Service

+ use(prefix: string, router: Router

): restana.Service

handle(req: Request

, res: Response

): void callback(): RequestListener

start(port?: number, host?: string): Promise> diff --git a/index.js b/index.js index 6352b27..f75b090 100644 --- a/index.js +++ b/index.js @@ -8,7 +8,7 @@ const requestRouter = require('./libs/request-router') const applySecurityHeaders = require('./libs/security-headers') -const { deepFreezeObject, toHttpStatusCode } = require('./libs/utils') +const { deepFreezeObject, errorStatusCode } = require('./libs/utils') const exts = { request: {}, response: require('./libs/response-extensions') @@ -20,7 +20,7 @@ module.exports = (options = {}) => { config.errorHandler = config.errorHandler || ((err, req, res) => { - const statusCode = toHttpStatusCode(err.status || err.code || err.statusCode) + const statusCode = errorStatusCode(err) res.send({ code: statusCode, message: 'Internal Server Error' }, statusCode) }) @@ -126,3 +126,20 @@ module.exports = (options = {}) => { return service } + +// Runtime counterparts of the `restana.Protocol` and `restana.Method` TypeScript enums +module.exports.Protocol = Object.freeze({ + HTTP: 'http', + HTTPS: 'https', + HTTP2: 'http2' +}) +module.exports.Method = Object.freeze({ + GET: 'get', + DELETE: 'delete', + PATCH: 'patch', + POST: 'post', + PUT: 'put', + HEAD: 'head', + OPTIONS: 'options', + TRACE: 'trace' +}) diff --git a/libs/apm-base.js b/libs/apm-base.js index a5ea59f..4fc4810 100644 --- a/libs/apm-base.js +++ b/libs/apm-base.js @@ -11,6 +11,13 @@ module.exports = (options) => { const ref = app[method] app[method] = (path, ...args) => { + if (Array.isArray(path)) { + // name each path individually instead of joining them, e.g. 'GET /a,/b' + path.forEach(p => app[method](p, ...args)) + + return app + } + args.unshift((req, res, next) => { agent.setTransactionName(`${req.method} ${path}`) diff --git a/libs/response-extensions.js b/libs/response-extensions.js index a683579..68507af 100644 --- a/libs/response-extensions.js +++ b/libs/response-extensions.js @@ -1,6 +1,6 @@ 'use strict' -const { forEachObject, toHttpStatusCode } = require('./utils') +const { forEachObject, toHttpStatusCode, errorStatusCode } = require('./utils') const CONTENT_TYPE_HEADER = 'content-type' const TYPE_JSON = 'application/json; charset=utf-8' @@ -45,7 +45,7 @@ const beforeEnd = (res, contentType, statusCode, data) => { const isProduction = () => process.env.NODE_ENV === 'production' const parseErr = (options, error) => { - const statusCode = toHttpStatusCode(error.status || error.code || error.statusCode) + const statusCode = errorStatusCode(error) if (!options.debugErrors || isProduction()) { return { @@ -74,7 +74,7 @@ const parseErr = (options, error) => { const MAX_PROMISE_DEPTH = 3 module.exports.send = (options, req, res) => { - const send = (data = res.statusCode, code = res.statusCode, headers = null, cb = NOOP, _promiseDepth = 0) => { + const send = (data, code = res.statusCode, headers = null, cb = NOOP, _promiseDepth = 0) => { let contentType if (data instanceof Error) { @@ -100,7 +100,10 @@ module.exports.send = (options, req, res) => { // NOTE: only retrieve content-type after setting custom headers contentType = res.getHeader(CONTENT_TYPE_HEADER) - if (typeof data === 'number') { + if (data === undefined) { + // keep an explicitly provided status code, e.g. res.send(undefined, 204) + data = res.body + } else if (typeof data === 'number') { code = data data = res.body } diff --git a/libs/utils.js b/libs/utils.js index b844b86..fdb8ffa 100644 --- a/libs/utils.js +++ b/libs/utils.js @@ -13,6 +13,24 @@ module.exports.toHttpStatusCode = (code, fallback = 500) => { return Number.isInteger(code) && code >= 100 && code <= 999 ? code : fallback } +/** + * Resolves the HTTP status code of an error, using the first valid value of + * `err.status`, `err.code` or `err.statusCode`. Non-numeric values such as + * Node.js system error codes (e.g. 'ENOENT') are skipped instead of masking a + * valid status code defined on another property. + * + * @param {*} err + * @returns {number} + */ +module.exports.errorStatusCode = (err) => { + if (err) { + for (const code of [err.status, err.code, err.statusCode]) { + if (Number.isInteger(code) && code >= 100 && code <= 999) return code + } + } + return 500 +} + module.exports.forEachObject = (obj, cb) => { const keys = Object.keys(obj) const length = keys.length diff --git a/specs/bugfixes.test.js b/specs/bugfixes.test.js new file mode 100644 index 0000000..e02eaab --- /dev/null +++ b/specs/bugfixes.test.js @@ -0,0 +1,75 @@ +'use strict' + +/* global describe, it */ +const expect = require('chai').expect +const request = require('supertest') +const restana = require('../index') +const apm = require('../libs/apm-base') + +const systemError = () => { + const err = new Error('missing file') + err.code = 'ENOENT' + err.statusCode = 404 + return err +} + +describe('Bug fixes', () => { + it('keeps the explicit status code when sending an undefined body', async () => { + const service = restana() + service.get('/undefined', (req, res) => res.send(undefined, 201)) + service.get('/promise', (req, res) => res.send(Promise.resolve(), 204)) + service.get('/body', (req, res) => { + res.body = { ok: true } + res.send(undefined, 202) + }) + service.get('/default', (req, res) => { + res.statusCode = 202 + res.send() + }) + + const server = await service.start(0, '127.0.0.1') + await request(server).get('/undefined').expect(201) + await request(server).get('/promise').expect(204) + await request(server).get('/body').expect(202, { ok: true }) + await request(server).get('/default').expect(202) + await service.close() + }) + + it('ignores non-numeric error codes when resolving the status code', async () => { + const service = restana() + service.get('/send', (req, res) => res.send(systemError())) + service.get('/next', (req, res, next) => next(systemError())) + service.get('/string-status', (req, res) => { + const err = new Error('bad') + err.status = '400' + res.send(err) + }) + + const server = await service.start(0, '127.0.0.1') + await request(server).get('/send').expect(404, { code: 404, message: 'Not Found' }) + await request(server).get('/next').expect(404, { code: 404, message: 'Internal Server Error' }) + await request(server).get('/string-status').expect(500) + await service.close() + }) + + it('exposes the Protocol and Method enums at runtime', () => { + expect(restana.Protocol.HTTP2).to.equal('http2') + expect(restana.Method.GET).to.equal('get') + expect(Object.isFrozen(restana.Protocol)).to.equal(true) + }) + + it('names APM transactions per path for multi-path routes', async () => { + const names = [] + const service = restana() + apm({ agent: { setTransactionName: name => names.push(name) } }).patch(service) + expect(service.get(['/a', '/b'], (req, res) => res.send('ok'))).to.equal(service) + + const server = await service.start(0, '127.0.0.1') + await request(server).get('/a').expect(200) + await request(server).get('/b').expect(200) + await service.close() + + expect(names).to.deep.equal(['GET /a', 'GET /b']) + expect(service.routes()).to.deep.equal(['GET/a', 'GET/b']) + }) +}) diff --git a/specs/types.test.ts b/specs/types.test.ts index f8e3dc5..45d4d70 100644 --- a/specs/types.test.ts +++ b/specs/types.test.ts @@ -32,3 +32,19 @@ const configured = restana({ }) configured.start({ port: 3000, host: '127.0.0.1' }) + +// Multiple middlewares can be registered at once +const auth: restana.RequestHandler = (req, res, next) => next() +const log: restana.RequestHandler = (req, res, next) => next() +service.use(auth, log) +service.use('/api', auth, log) +service.use('/nested', service.newRouter()) + +// Enums are available at runtime +const protocol: string = restana.Protocol.HTTP2 +const method: string = restana.Method.GET +String(protocol + method) + +// Nested routers require a prefix +// @ts-expect-error +service.use(service.newRouter()) From 10cfe7121b9913497f78d2fc641991b23d3539ac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:09:36 +0000 Subject: [PATCH 2/7] feat: upgrade 0http to 5.2 and default to the trie router - add router option ('trie' default, 'sequential' opt-out) - add caseSensitive and ignoreTrailingSlash options for the trie router - benchmark both routers in CI and route-scale suites - document intentional trie vs sequential matching differences Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2wFkaGAAWQhBw9VtCu7qe --- CHANGELOG.md | 8 ++++++ docs/README.md | 19 +++++++++++++- index.d.ts | 5 ++++ libs/request-router.js | 35 ++++++++++++++++++++----- package-lock.json | 10 +++---- package.json | 2 +- performance/ci.js | 32 ++++++++++++++--------- performance/route-scale.js | 13 ++++----- specs/release-6.1.test.js | 2 +- specs/router-selection.test.js | 48 ++++++++++++++++++++++++++++++++++ specs/types.test.ts | 6 +++++ 11 files changed, 146 insertions(+), 34 deletions(-) create mode 100644 specs/router-selection.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a909a69..2921f31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,15 @@ ## Unreleased +### Changed +- Upgraded `0http` to 5.2 and made its dependency-free trie router the default. Static routes resolve + in constant time and dynamic routes no longer depend on a match cache. Matching is case-sensitive by + default; set `caseSensitive: false` or `router: 'sequential'` for the previous behavior + ([differences](docs/README.md#routers)). + ### Added +- `router` option to select the `'trie'` (default) or `'sequential'` router. +- `caseSensitive` and `ignoreTrailingSlash` options for the trie router. - Runtime `restana.Protocol` and `restana.Method` objects matching the TypeScript enums. - TypeScript support for registering multiple middlewares in one `use()` call. - `.nvmrc` pinning the supported Node.js major version for contributors. diff --git a/docs/README.md b/docs/README.md index 5b97b0c..dd18f54 100644 --- a/docs/README.md +++ b/docs/README.md @@ -81,12 +81,29 @@ Optionally, learn through examples: - `prioRequestsProcessing`: If `TRUE`, HTTP requests processing/handling is prioritized using `setImmediate`. Default value: `TRUE` - `defaultRoute`: Optional route handler when no route match occurs. Default value: `((req, res) => res.send(404))` - `errorHandler`: Optional global error handler function. Default value: `(err, req, res) => res.send({ code: statusCode, message: 'Internal Server Error' }, statusCode)`, where `statusCode` is the first valid HTTP status code found in `err.status`, `err.code` or `err.statusCode` (falls back to `500`). The default handler returns a generic error message to prevent leaking sensitive internal details (e.g. database connection strings, file paths, stack traces). The appropriate HTTP status code is still preserved from `err.status`, `err.code`, or `err.statusCode`. -- `routerCacheSize`: The router matching cache size, indicates how many request matches will be kept in memory. Default value: `2000` +- `router`: The [0http](https://github.com/BackendStack21/0http) router implementation, `'trie'` or `'sequential'`. Default value: `'trie'`. See [Routers](#routers). +- `caseSensitive`: Trie router only. When `FALSE`, static path segments match case-insensitively (ASCII letters only). Default value: `TRUE` +- `ignoreTrailingSlash`: Trie router only. When `TRUE`, `/users/` and `/users` are the same path. Default value: `TRUE` +- `routerCacheSize`: Sequential router only. The router matching cache size, indicates how many request matches will be kept in memory. Default value: `2000` - `enableTrace`: When `TRUE`, the `TRACE` HTTP method handler is available for debugging purposes. Default value: `FALSE`. ⚠️ Not recommended for production deployments. - `securityHeaders`: When `TRUE`, default security headers are set on every response. Set to `FALSE` to disable (e.g. when using Helmet or serving non-browser clients). Default value: `TRUE`. - `trustProxy`: When `TRUE`, trust the first `X-Forwarded-Proto` value when deciding whether to send HSTS. Only enable this behind a reverse proxy that replaces forwarded headers. Default value: `FALSE`. - `debugErrors`: When `TRUE`, `res.send(error)` includes `error.message` and `error.data` outside production. Use only for local debugging. Default value: `FALSE`. +### Routers +The dependency-free trie router is the default: static routes resolve in constant time and dynamic routes stay fast as the route count grows, without a match cache to size. The previous default, the sequential router, remains available: +```js +const service = restana({ router: 'sequential' }) +``` + +The trie router intentionally differs from the sequential router: +- Paths are case-sensitive by default (`/Admin` does not match `/admin`); use `caseSensitive: false` to restore case-insensitive matching. +- A `*` that is not the last segment, or a `?` that is not the last character of a param segment, throws at registration. +- An absent optional param is not present on `req.params` (instead of `undefined`). +- A trailing `/*` also matches the bare prefix, with `req.params['*']` equal to `''`. +- Empty route segments are ignored (`/a//b` is `/a/b`). +- A request path that does not start with `/` matches nothing. + ### Security defaults (v6.0+) Restana now ships with these security hardening measures enabled by default: - **Header injection protection**: Security-sensitive and hop-by-hop headers are blocked from the `res.send()` headers parameter. diff --git a/index.d.ts b/index.d.ts index 811f3aa..cd0e301 100644 --- a/index.d.ts +++ b/index.d.ts @@ -123,9 +123,14 @@ declare namespace restana { ): Service

} + type RouterType = 'trie' | 'sequential' + interface Options

{ server?: Server

prioRequestsProcessing?: boolean + router?: RouterType + caseSensitive?: boolean + ignoreTrailingSlash?: boolean routerCacheSize?: number defaultRoute?: RequestHandler

errorHandler?: ErrorHandler

diff --git a/libs/request-router.js b/libs/request-router.js index d47c90b..6363995 100644 --- a/libs/request-router.js +++ b/libs/request-router.js @@ -1,8 +1,9 @@ 'use strict' /** - * @see: https://github.com/jkyberneees/0http#0http---sequential-default-router + * @see: https://github.com/BackendStack21/0http#trie-router-dependency-free-fastest */ +const trie = require('0http/lib/router/trie') const sequential = require('0http/lib/router/sequential') const getMethods = require('./methods') const EventEmitter = require('events') @@ -15,17 +16,37 @@ function registerRoute ({ routes, service, router, method, args }) { router[method].apply(router, args) } +function createRouter (options, defaultRoute) { + const type = options.router ?? 'trie' + + if (type === 'trie') { + return trie({ + errorHandler: options.errorHandler, + defaultRoute, + caseSensitive: options.caseSensitive ?? true, + ignoreTrailingSlash: options.ignoreTrailingSlash ?? true + }) + } + if (type === 'sequential') { + return sequential({ + errorHandler: options.errorHandler, + cacheSize: options.routerCacheSize ?? 2000, + defaultRoute + }) + } + + throw new TypeError(`Unsupported router type: ${type}. Expected 'trie' or 'sequential'`) +} + module.exports = (options, service = {}) => { const routes = new Set() - const router = sequential({ - errorHandler: options.errorHandler, - cacheSize: options.routerCacheSize ?? 2000, - defaultRoute: options.defaultRoute || ((req, res) => { - res.send(404) - }) + const defaultRoute = options.defaultRoute || ((req, res) => { + res.send(404) }) + const router = createRouter(options, defaultRoute) + // attach router id service.id = router.id diff --git a/package-lock.json b/package-lock.json index 193c506..5d61469 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "6.1.0", "license": "MIT", "dependencies": { - "0http": "^4.4.0" + "0http": "^5.2.0" }, "devDependencies": { "@types/node": "^26.4.1", @@ -1122,12 +1122,12 @@ "license": "ISC" }, "node_modules/0http": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/0http/-/0http-4.4.0.tgz", - "integrity": "sha512-Zs3pTtQFZL8ishQREXX+9cMo1zRgaFF8g6AtnEL0iPUhzi2thtHf3P+487uhIieJm+wyPMu5QHJi8PCrt2zXJQ==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/0http/-/0http-5.2.0.tgz", + "integrity": "sha512-PcXG5vxB99csVSxJUUE2Sq/lnXxpi4RELFM8U2BNw39zdUsFyGssIyFAU1pV90ZNcZSu1XZFicBSDE8EfkjM4g==", "license": "MIT", "dependencies": { - "lru-cache": "^11.2.1", + "lru-cache": "^11.5.1", "regexparam": "^3.0.0", "trouter": "^4.0.0" }, diff --git a/package.json b/package.json index f111382..a97bd08 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ ], "homepage": "https://github.com/BackendStack21/restana#readme", "dependencies": { - "0http": "^4.4.0" + "0http": "^5.2.0" }, "overrides": { "diff": "9.0.0", diff --git a/performance/ci.js b/performance/ci.js index 520b22f..2495403 100644 --- a/performance/ci.js +++ b/performance/ci.js @@ -2,6 +2,7 @@ const restana = require('../index') const responseExtensions = require('../libs/response-extensions') +const trie = require('0http/lib/router/trie') const sequential = require('0http/lib/router/sequential') function budget (name, maximumMilliseconds, operation) { @@ -62,19 +63,24 @@ function median (operation) { return samples.sort((a, b) => a - b)[2] } -const service = restana({ routerCacheSize: routes }) -const frameworkRouter = buildRouter(service.getRouter()) -const dependencyRouter = buildRouter(sequential({ cacheSize: routes })) +function checkIntegration (name, frameworkRouter, dependencyRouter) { + budget(`${name} routing (50k across 500 routes)`, 500, () => { + lookupBatch(frameworkRouter) + }) -budget('cached routing (50k across 500 routes)', 500, () => { - lookupBatch(frameworkRouter) -}) - -const dependencyMedian = median(() => lookupBatch(dependencyRouter)) -const frameworkMedian = median(() => lookupBatch(frameworkRouter)) -const allowedOverhead = dependencyMedian * 1.25 + const dependencyMedian = median(() => lookupBatch(dependencyRouter)) + const frameworkMedian = median(() => lookupBatch(frameworkRouter)) + const allowedOverhead = dependencyMedian * 1.25 -console.log(`router integration: ${frameworkMedian.toFixed(2)}ms vs dependency ${dependencyMedian.toFixed(2)}ms`) -if (frameworkMedian > allowedOverhead) { - throw new Error(`router integration exceeded 25% overhead (${allowedOverhead.toFixed(2)}ms budget)`) + console.log(`${name} router integration: ${frameworkMedian.toFixed(2)}ms vs dependency ${dependencyMedian.toFixed(2)}ms`) + if (frameworkMedian > allowedOverhead) { + throw new Error(`${name} router integration exceeded 25% overhead (${allowedOverhead.toFixed(2)}ms budget)`) + } } + +checkIntegration('trie', buildRouter(restana().getRouter()), buildRouter(trie())) +checkIntegration( + 'sequential (cached)', + buildRouter(restana({ router: 'sequential', routerCacheSize: routes }).getRouter()), + buildRouter(sequential({ cacheSize: routes })) +) diff --git a/performance/route-scale.js b/performance/route-scale.js index 53585b8..52b59b5 100644 --- a/performance/route-scale.js +++ b/performance/route-scale.js @@ -7,8 +7,8 @@ const LOOKUPS = 10000 const SAMPLES = 5 const paths = Array.from({ length: ROUTES }, (_, index) => `/route/${index}`) -function buildService (routerCacheSize) { - const service = restana({ prioRequestsProcessing: false, routerCacheSize }) +function buildService (options) { + const service = restana({ prioRequestsProcessing: false, ...options }) for (let index = 0; index < ROUTES; index++) { service.get(`/route/${index}`, () => {}) } @@ -33,8 +33,8 @@ function sample (service) { return Number(process.hrtime.bigint() - start) / 1e6 } -function run (name, routerCacheSize) { - const service = buildService(routerCacheSize) +function run (name, options) { + const service = buildService(options) // Warm the JIT and, for the cached case, every path under test. sample(service) @@ -45,5 +45,6 @@ function run (name, routerCacheSize) { console.log(`${name}: median ${median.toFixed(2)}ms (${operationsPerSecond.toLocaleString()} lookups/sec)`) } -run('uncached', 0) -run('warm-cache', ROUTES) +run('trie', { router: 'trie' }) +run('sequential uncached', { router: 'sequential', routerCacheSize: 0 }) +run('sequential warm-cache', { router: 'sequential', routerCacheSize: ROUTES }) diff --git a/specs/release-6.1.test.js b/specs/release-6.1.test.js index a606bf4..387ca85 100644 --- a/specs/release-6.1.test.js +++ b/specs/release-6.1.test.js @@ -134,7 +134,7 @@ describe('Restana 6.1 improvements', () => { }) it('honors routerCacheSize zero for dynamic registration', async () => { - const service = restana({ routerCacheSize: 0 }) + const service = restana({ router: 'sequential', routerCacheSize: 0 }) const server = await service.start(0, '127.0.0.1') await request(server).get('/late').expect(404) diff --git a/specs/router-selection.test.js b/specs/router-selection.test.js new file mode 100644 index 0000000..36f71c8 --- /dev/null +++ b/specs/router-selection.test.js @@ -0,0 +1,48 @@ +'use strict' + +/* global describe, it */ +const expect = require('chai').expect +const request = require('supertest') +const restana = require('../index') + +const build = async (options) => { + const service = restana(options) + service.get('/admin', (req, res) => res.send('admin')) + service.get('/users/:id', (req, res) => res.send(req.params.id)) + const nested = service.newRouter() + nested.get('/items/:id', (req, res) => res.send(`item ${req.params.id}`)) + service.use('/api', nested) + const server = await service.start(0, '127.0.0.1') + return { service, server } +} + +describe('Router selection', () => { + it('uses the case-sensitive trie router by default', async () => { + const { service, server } = await build() + await request(server).get('/admin').expect(200, 'admin') + await request(server).get('/admin/').expect(200, 'admin') + await request(server).get('/ADMIN').expect(404) + await request(server).get('/users/42').expect(200, '42') + await request(server).get('/api/items/7').expect(200, 'item 7') + expect(service.getRouter().find).to.be.a('function') + await service.close() + }) + + it('supports case-insensitive and strict trailing slash matching', async () => { + const { service, server } = await build({ caseSensitive: false, ignoreTrailingSlash: false }) + await request(server).get('/ADMIN').expect(200, 'admin') + await request(server).get('/admin/').expect(404) + await service.close() + }) + + it('keeps the sequential router available', async () => { + const { service, server } = await build({ router: 'sequential' }) + await request(server).get('/ADMIN').expect(200, 'admin') + await request(server).get('/api/items/7').expect(200, 'item 7') + await service.close() + }) + + it('rejects unknown router types', () => { + expect(() => restana({ router: 'radix' })).to.throw(TypeError, /Unsupported router type/) + }) +}) diff --git a/specs/types.test.ts b/specs/types.test.ts index 45d4d70..345472f 100644 --- a/specs/types.test.ts +++ b/specs/types.test.ts @@ -48,3 +48,9 @@ String(protocol + method) // Nested routers require a prefix // @ts-expect-error service.use(service.newRouter()) + +// Router selection +restana({ router: 'trie', caseSensitive: false, ignoreTrailingSlash: false }) +restana({ router: 'sequential', routerCacheSize: 100 }) +// @ts-expect-error +restana({ router: 'radix' }) From 230f4db5bb9b9bfb923d05731e2d1dfa15600f04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:20:05 +0000 Subject: [PATCH 3/7] feat!: restana 7.0 BREAKING CHANGES: - the default errorHandler responds like res.send(err): standard status texts, debugErrors support, safe wrapping of non-Error values - res.send(number) only treats integers from 100 to 999 as status codes; other numbers are serialized as JSON - package exports map restricts imports to the public entry points - TypeScript: chaining returns the receiver, readonly config snapshot and errorHandler, required Router.id - trie router default (from the previous commit) Also adds APM type declarations, a 6.x to 7.0 migration guide, and updates the changelog, docs, README and landing page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2wFkaGAAWQhBw9VtCu7qe --- CHANGELOG.md | 27 +++++-- MIGRATION.md | 152 ++++++++++++++++++++++++++++++++++++ README.md | 17 ++-- docs/README.md | 17 +++- docs/index.html | 43 +++++----- index.d.ts | 40 +++++----- index.js | 11 ++- libs/apm-base.d.ts | 23 ++++++ libs/elastic-apm.d.ts | 3 + libs/newrelic-apm.d.ts | 3 + libs/response-extensions.js | 4 +- package-lock.json | 4 +- package.json | 18 ++++- specs/bugfixes.test.js | 2 +- specs/release-7.0.test.js | 58 ++++++++++++++ specs/security.test.js | 2 +- specs/types.test.ts | 22 ++++++ 17 files changed, 376 insertions(+), 70 deletions(-) create mode 100644 MIGRATION.md create mode 100644 libs/apm-base.d.ts create mode 100644 libs/elastic-apm.d.ts create mode 100644 libs/newrelic-apm.d.ts create mode 100644 specs/release-7.0.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2921f31..75b18ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,25 +1,36 @@ # Changelog -## Unreleased - -### Changed -- Upgraded `0http` to 5.2 and made its dependency-free trie router the default. Static routes resolve - in constant time and dynamic routes no longer depend on a match cache. Matching is case-sensitive by - default; set `caseSensitive: false` or `router: 'sequential'` for the previous behavior - ([differences](docs/README.md#routers)). +## 7.0.0 + +Breaking release. See the [migration guide](MIGRATION.md). + +### Breaking +- Upgraded `0http` to 5.2 and made its dependency-free trie router the default. Matching is + case-sensitive and stricter about route patterns. Set `caseSensitive: false` or + `router: 'sequential'` for the previous behavior. `routerCacheSize` only applies to the sequential router. +- The default `errorHandler` responds the same way as `res.send(err)`. The status text matches the + status code (`404 Not Found` instead of `404 Internal Server Error`), and `debugErrors` now applies + to `next(err)` and thrown errors. Non-`Error` values are wrapped safely. +- `res.send(number)` treats only integers from 100 to 999 as status codes. Other numbers are sent + as JSON bodies instead of being dropped. +- `package.json` `exports` limits imports to `restana`, `restana/libs/elastic-apm`, + `restana/libs/newrelic-apm` and `restana/package.json`. +- TypeScript: route registration and `use()` return the object they were called on. + `getConfigOptions()` returns `Readonly`, `errorHandler` is `readonly`, and + `use(router)` without a prefix is no longer accepted. ### Added - `router` option to select the `'trie'` (default) or `'sequential'` router. - `caseSensitive` and `ignoreTrailingSlash` options for the trie router. - Runtime `restana.Protocol` and `restana.Method` objects matching the TypeScript enums. - TypeScript support for registering multiple middlewares in one `use()` call. +- Type declarations for the Elastic APM and New Relic integrations. - `.nvmrc` pinning the supported Node.js major version for contributors. ### Fixed - `res.send(undefined, code)` and promises resolving to `undefined` no longer drop the explicit status code. - Non-numeric error codes (e.g. `'ENOENT'`) no longer mask a valid `err.statusCode`. - APM instrumentation names multi-path routes per path instead of joining them (`GET /a,/b`). -- TypeScript no longer accepts `use(router)` without a prefix, which throws at runtime. ## 6.1.0 diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..8d90a8c --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,152 @@ +# Migrating from restana 6.x to 7.0 + +Restana 7.0 makes the faster trie router the default, gives errors consistent +responses, and tightens the package's public API. Most applications upgrade by +changing the dependency version and checking the items below. + +Requirements are unchanged: Node.js 24 or newer. + +```bash +npm i restana@^7 +``` + +## Quick checklist + +| If your application… | Then… | +| --- | --- | +| relies on case-insensitive paths (`/Users` matching `/users`) | set `caseSensitive: false` | +| depends on the exact matching rules of the 6.x router | set `router: 'sequential'` | +| sets `routerCacheSize` | set `router: 'sequential'` too, or remove the option | +| asserts `message: 'Internal Server Error'` on non-500 errors | expect the standard status text, e.g. `'Not Found'` | +| calls `res.send(number)` with a number that is not an HTTP status code | expect it to be sent as a JSON body | +| imports internal modules such as `restana/libs/utils` | use only the public entry points listed below | +| uses the TypeScript declarations | see [TypeScript](#typescript) | + +## Trie router is the default + +Restana now uses the dependency-free [0http](https://github.com/BackendStack21/0http) +trie router (0http 5.2). Static routes resolve in constant time and dynamic +routes stay fast as the route count grows, with no route match cache to size. + +The trie router intentionally differs from the 6.x sequential router: + +- **Paths are case-sensitive.** `GET /Admin` no longer matches a route registered as `/admin`. + This agrees with how most reverse proxies, WAFs and auth layers classify paths. +- **Invalid patterns fail fast.** A `*` that is not the last segment, or a `?` that is not the + last character of a param segment, throws when the route is registered. +- **Absent optional params are omitted.** For `/users/:id?`, a request to `/users` leaves + `req.params.id` out instead of setting it to `undefined`. +- **Trailing wildcards match the bare prefix.** `/files/*` also matches `/files`, with + `req.params['*']` equal to `''`. +- **Empty route segments are ignored.** A route registered as `/a//b` is the same as `/a/b`. +- **Request paths must start with `/`.** Other request targets match nothing and get a 404. + +New options control the trie router: + +```js +const service = restana({ + caseSensitive: false, // match '/Users' and '/users' (ASCII letters only); default: true + ignoreTrailingSlash: true // '/users/' is the same path as '/users'; default: true +}) +``` + +To keep the 6.x behavior exactly, select the sequential router: + +```js +const service = restana({ router: 'sequential', routerCacheSize: 2000 }) +``` + +`routerCacheSize` only applies to the sequential router. The trie router ignores it. + +## Error responses + +The default `errorHandler` now responds the same way as `res.send(err)`. The +status text in the response matches the status code, and `debugErrors` applies to +errors passed to `next(err)` or thrown from handlers. + +```js +service.get('/users/:id', async () => { + throw Object.assign(new Error('user 42 not in db'), { status: 404 }) +}) +``` + +| | 6.x | 7.0 | +| --- | --- | --- | +| default | `404 {"code":404,"message":"Internal Server Error"}` | `404 {"code":404,"message":"Not Found"}` | +| `debugErrors: true` (non-production) | `404 {"code":404,"message":"Internal Server Error"}` | `404 {"code":404,"message":"user 42 not in db"}` | +| `NODE_ENV=production` | `404 {"code":404,"message":"Internal Server Error"}` | `404 {"code":404,"message":"Not Found"}` | + +Error details stay masked unless you opt in with `debugErrors: true`. They are +always masked when `NODE_ENV=production`. + +Values passed to `next()` that are not `Error` instances, such as `next('oops')`, +are wrapped in an `Error`. The response is a generic +`500 Internal Server Error`, or uses the value's numeric `status`, `code` or +`statusCode`. With `debugErrors: true`, the original value is shown in `data`. + +Custom `errorHandler` functions are not affected. + +To keep the 6.x response body, provide your own handler: + +```js +const service = restana({ + errorHandler (err, req, res) { + const code = [err.status, err.code, err.statusCode].find(Number.isInteger) || 500 + res.send({ code, message: 'Internal Server Error' }, code) + } +}) +``` + +## `res.send()` with numbers + +A number is treated as a status code only when it is an integer from 100 to 999. +Other numbers are sent as JSON instead of being dropped: + +```js +res.send(404) // 404 with an empty body, unchanged +res.send(42.5) // 6.x: 200 with an empty body; 7.0: 200 with body 42.5 (application/json) +res.send(0, 201) // 6.x: 201 with an empty body; 7.0: 201 with body 0 +``` + +`res.send(undefined, code)` now keeps the status code you pass. Previously it +was overwritten by `res.statusCode`. + +## Package entry points + +`package.json` now has an `exports` map. Only these entry points can be imported: + +- `restana` +- `restana/libs/elastic-apm` +- `restana/libs/newrelic-apm` +- `restana/package.json` + +Deep imports of internal modules, such as `restana/libs/utils` or +`restana/libs/response-extensions`, now fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`. +If you relied on one of them, copy the code you need or open an issue describing +your use case. + +## TypeScript + +- Route registration methods (`get`, `post`, …, `all`, `trace`) and `use()` now return the + object they were called on. Chaining on a router created by `newRouter()` now returns the + router, not a `Service`. +- `use()` accepts several middlewares: `service.use(cors, auth)` and + `service.use('/api', cors, auth)`. +- `use(router)` without a prefix is no longer accepted, because it threw at runtime. Mount + nested routers with a prefix: `service.use('/api', router)`. +- `getConfigOptions()` returns `Readonly`, and `service.errorHandler` is `readonly`. + Both already were at runtime. +- `Router.id` is always defined. +- `RegisterRoute>` takes an optional second type parameter for the return type. +- New options are typed: `router`, `caseSensitive` and `ignoreTrailingSlash`. +- `restana.Protocol` and `restana.Method` now exist at runtime, so `restana.Protocol.HTTP2` + works in value positions too. +- `restana/libs/elastic-apm` and `restana/libs/newrelic-apm` now ship type declarations. + +## Other fixes + +- Error status codes are taken from the first valid value of `err.status`, `err.code` or + `err.statusCode`. A Node.js system error code such as `'ENOENT'` no longer hides a valid + `statusCode`. +- APM integrations name routes registered with several paths, `service.get(['/a', '/b'], …)`, + one path at a time (`GET /a`, `GET /b`) instead of joining them (`GET /a,/b`). diff --git a/README.md b/README.md index 3e4fdfd..9ef645c 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ http.createServer(service).listen(3000, '0.0.0.0') # Security defaults Restana ships with secure defaults out of the box: -- **Error handling**: The default error handler returns a generic `Internal Server Error` message, preventing internal details (stack traces, database errors, file paths) from leaking to clients. Provide a custom `errorHandler` to control what gets exposed. +- **Error handling**: The default error handler only returns the status code and its standard status text (e.g. `Not Found`), preventing internal details (stack traces, database errors, file paths) from leaking to clients. Provide a custom `errorHandler` to control what gets exposed. - **Stream safety**: Stream errors are handled gracefully, preventing connection leaks. - **Immutable config**: `getConfigOptions()` returns a frozen copy, preventing middleware from mutating internal framework options. - **Response headers**: Browser hardening headers are enabled by default, and connection-specific or cookie headers cannot be injected through `res.send()`. @@ -77,16 +77,17 @@ const service = restana({ trustProxy: true }) Error details are hidden by default in every environment. For local debugging only, opt in with `debugErrors: true`; production mode always masks details. -## 6.1 highlights -- Reliable `start()` rejection on port and socket errors. -- Isolated, deeply frozen configuration snapshots. -- Boolean response bodies and array-valued headers. -- Correct `routerCacheSize: 0` behavior. -- Updated TypeScript API, reproducible installs, and route-scaling benchmarks. -- Expanded security and performance regression coverage. +## 7.0 highlights +- Dependency-free trie router by default: constant-time static routes and no match cache to tune. +- Case-sensitive routing that agrees with reverse proxies and WAFs (`caseSensitive: false` to opt out). +- Consistent error responses from `res.send(err)` and the default error handler. +- Stricter `res.send()` status code handling and a typed, explicit public API. + +Upgrading from 6.x? Read the [migration guide](MIGRATION.md). # More - Website and documentation: https://restana.21no.de - [Full API guide](docs/README.md) - [Changelog](CHANGELOG.md) +- [Migration guide (6.x → 7.0)](MIGRATION.md) - [Contributing](CONTRIBUTING.md) diff --git a/docs/README.md b/docs/README.md index dd18f54..35e3ca1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -80,7 +80,7 @@ Optionally, learn through examples: - `server`: Allows to optionally override the HTTP server instance to be used. - `prioRequestsProcessing`: If `TRUE`, HTTP requests processing/handling is prioritized using `setImmediate`. Default value: `TRUE` - `defaultRoute`: Optional route handler when no route match occurs. Default value: `((req, res) => res.send(404))` -- `errorHandler`: Optional global error handler function. Default value: `(err, req, res) => res.send({ code: statusCode, message: 'Internal Server Error' }, statusCode)`, where `statusCode` is the first valid HTTP status code found in `err.status`, `err.code` or `err.statusCode` (falls back to `500`). The default handler returns a generic error message to prevent leaking sensitive internal details (e.g. database connection strings, file paths, stack traces). The appropriate HTTP status code is still preserved from `err.status`, `err.code`, or `err.statusCode`. +- `errorHandler`: Optional global error handler function. Default value: `(err, req, res) => res.send(err)`. Values that are not `Error` instances are wrapped first. The response body is `{ code, message }`, where `code` is the first valid HTTP status code found in `err.status`, `err.code` or `err.statusCode` (falls back to `500`), and `message` is the matching status text (e.g. `Not Found`). Error messages and `err.data` are only exposed with `debugErrors: true` outside production, which prevents leaking internal details such as database connection strings, file paths or stack traces. - `router`: The [0http](https://github.com/BackendStack21/0http) router implementation, `'trie'` or `'sequential'`. Default value: `'trie'`. See [Routers](#routers). - `caseSensitive`: Trie router only. When `FALSE`, static path segments match case-insensitively (ASCII letters only). Default value: `TRUE` - `ignoreTrailingSlash`: Trie router only. When `TRUE`, `/users/` and `/users` are the same path. Default value: `TRUE` @@ -88,7 +88,7 @@ Optionally, learn through examples: - `enableTrace`: When `TRUE`, the `TRACE` HTTP method handler is available for debugging purposes. Default value: `FALSE`. ⚠️ Not recommended for production deployments. - `securityHeaders`: When `TRUE`, default security headers are set on every response. Set to `FALSE` to disable (e.g. when using Helmet or serving non-browser clients). Default value: `TRUE`. - `trustProxy`: When `TRUE`, trust the first `X-Forwarded-Proto` value when deciding whether to send HSTS. Only enable this behind a reverse proxy that replaces forwarded headers. Default value: `FALSE`. -- `debugErrors`: When `TRUE`, `res.send(error)` includes `error.message` and `error.data` outside production. Use only for local debugging. Default value: `FALSE`. +- `debugErrors`: When `TRUE`, `res.send(error)` and the default `errorHandler` include `error.message` and `error.data` outside production. Use only for local debugging. Default value: `FALSE`. ### Routers The dependency-free trie router is the default: static routes resolve in constant time and dynamic routes stay fast as the route count grows, without a match cache to size. The previous default, the sequential router, remains available: @@ -238,7 +238,7 @@ res.send( > An `undefined` body keeps the explicit status code: `res.send(undefined, 204)` ## Global error handling -By default, restana returns a generic `Internal Server Error` message to the client, preventing internal details from being leaked. The HTTP status code is preserved from the first valid status code found in `err.status`, `err.code`, or `err.statusCode` (defaults to `500`). Non-numeric values such as Node.js system error codes (`'ENOENT'`) are ignored. +By default, restana responds with the standard status text (e.g. `{ "code": 404, "message": "Not Found" }`), preventing internal details from being leaked. The HTTP status code is preserved from the first valid status code found in `err.status`, `err.code`, or `err.statusCode` (defaults to `500`). Non-numeric values such as Node.js system error codes (`'ENOENT'`) are ignored. To customize error responses, provide your own `errorHandler`: ```js @@ -513,6 +513,17 @@ service.get('/hello', (req, res) => { https://goo.gl/forms/qlBwrf5raqfQwteH3 # Breaking changes +## 7.0 +> Restana 7.0 makes the trie router the default, gives errors consistent responses, and tightens the public API. Read the full [migration guide](../MIGRATION.md). + +Changed: +- The 0http trie router is the default. Paths are case-sensitive and route patterns are validated when registered. Use `caseSensitive: false` or `router: 'sequential'` for the previous behavior. +- `routerCacheSize` only applies to the sequential router. +- The default `errorHandler` responds the same way as `res.send(err)`: `404 Not Found` instead of `404 Internal Server Error`, and `debugErrors` applies to it. +- `res.send(number)` treats only integers from 100 to 999 as status codes. Other numbers are sent as JSON. +- Only `restana`, `restana/libs/elastic-apm`, `restana/libs/newrelic-apm` and `restana/package.json` can be imported. +- TypeScript: route registration and `use()` return the object they were called on. `getConfigOptions()` is `Readonly`. + ## 6.1 > Restana 6.1 improves lifecycle reliability, secure defaults, performance tooling, and TypeScript support. diff --git a/docs/index.html b/docs/index.html index 258e3da..9cc876d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -234,7 +234,7 @@ restana

7
HTTP methods
-
v6.1
latest
+
v7.0
latest
Node 24+
runtime
3
dependencies
TS
types built-in
@@ -271,29 +271,29 @@

- -

Safer APIs, clearer operations

-

Restana 6.1 strengthens production behavior without trading away routing performance.

+ +

Faster routing, consistent errors

+

Restana 7.0 switches to a dependency-free trie router and tightens the public API. Upgrading from 6.x? Read the migration guide.

-

Reliable lifecycle

-

service.start() now rejects cleanly on socket and port conflicts, making startup failures observable and recoverable.

+

Trie router

+

Static routes resolve in constant time and dynamic routes stay fast as the route count grows, without a match cache to tune.

-

Explicit trust

-

Error details are opt-in for local debugging, while forwarded TLS headers require an explicitly trusted proxy.

+

Case-sensitive matching

+

Paths match the way reverse proxies and WAFs classify them. Opt out with caseSensitive: false, or keep the sequential router.

-

Stronger API contracts

-

Boolean bodies, array-valued headers, cache disabling, and TypeScript declarations now match documented behavior.

+

Consistent errors

+

The default error handler and res.send(err) share one masked response format with standard status texts.

-

Performance guarded

-

Response and routing benchmarks now run with CI budgets, including a direct router-integration overhead check.

+

Explicit public API

+

An exports map, typed APM integrations, and router-returning chaining in the TypeScript declarations.

@@ -345,7 +345,7 @@

Minimal Footprint

Safe Out of the Box

-

restana v6.1 ships with hardened security defaults. No extra packages needed.

+

restana v7.0 ships with hardened security defaults. No extra packages needed.

🔒 @@ -491,7 +491,7 @@

1. HTTP Routing

  • GET, DELETE, PUT, PATCH, POST, HEAD, OPTIONS — plus service.all() for universal handlers
  • TRACE is disabled by default (re-enable with enableTrace: true)
  • -
  • Route matching uses an internal LRU cache — routerCacheSize controls cache depth (default: 2000)
  • +
  • Route matching uses the 0http trie router by default — router: 'sequential' selects the previous router and its routerCacheSize LRU cache
@@ -566,7 +566,10 @@

Configuration Options

prioRequestsProcessingUse setImmediate for priority processingtrue defaultRouteHandler for unmatched routes (404)res.send(404) errorHandlerGlobal error handler functionSafe by default - routerCacheSizeRoute match cache size2000 + routerRouter implementation: 'trie' or 'sequential''trie' + caseSensitiveCase-sensitive path matching (trie router)true + ignoreTrailingSlashTreat /users/ and /users as the same path (trie router)true + routerCacheSizeRoute match cache size (sequential router)2000 enableTraceEnable TRACE HTTP methodfalse securityHeadersSet default security headers on every responsetrue trustProxyTrust forwarded protocol for HSTSfalse @@ -610,12 +613,12 @@

Configuration Options

- disable-cache.js + router-selection.js
const service = restana({
-  routerCacheSize: 0  // disable LRU cache
+  caseSensitive: false  // trie router, '/Users' matches '/users'
 })
-// Or: unlimited cache (useful for many static routes)
-// routerCacheSize: -1
+// Or: the 6.x sequential router with its LRU cache +// restana({ router: 'sequential', routerCacheSize: 2000 })
diff --git a/index.d.ts b/index.d.ts index cd0e301..74e8cc8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -62,24 +62,24 @@ declare namespace restana { } interface Router

{ - readonly id?: string - get: RegisterRoute

- delete: RegisterRoute

- patch: RegisterRoute

- post: RegisterRoute

- put: RegisterRoute

- head: RegisterRoute

- options: RegisterRoute

- all: RegisterRoute

- use(...middlewares: RequestHandler

[]): Router

- use(prefix: string, ...middlewares: RequestHandler

[]): Router

- use(prefix: string, router: Router

): Router

+ readonly id: string + get: RegisterRoute + delete: RegisterRoute + patch: RegisterRoute + post: RegisterRoute + put: RegisterRoute + head: RegisterRoute + options: RegisterRoute + all: RegisterRoute + use(...middlewares: RequestHandler

[]): this + use(prefix: string, ...middlewares: RequestHandler

[]): this + use(prefix: string, router: Router

): this lookup(req: Request

, res: Response

): unknown find(method: string, path: string): unknown } interface TraceRouter

extends Router

{ - trace: RegisterRoute

+ trace: RegisterRoute } type Response

= P extends Protocol.HTTP2 @@ -116,11 +116,11 @@ declare namespace restana { res: Response

, ) => void | Promise - interface RegisterRoute

{ + interface RegisterRoute

> { ( path: string | string[], ...middlewares: RequestHandler

[] - ): Service

+ ): R } type RouterType = 'trie' | 'sequential' @@ -149,17 +149,13 @@ declare namespace restana { } interface Service

extends Router

{ - readonly id: string readonly events: ServiceEvents routes(): string[], getRouter(): Router

, newRouter(): Router

- errorHandler: ErrorHandler

, + readonly errorHandler: ErrorHandler

, getServer(): Server

, - getConfigOptions(): Options

- use(...middlewares: RequestHandler

[]): restana.Service

- use(prefix: string, ...middlewares: RequestHandler

[]): restana.Service

- use(prefix: string, router: Router

): restana.Service

+ getConfigOptions(): Readonly> handle(req: Request

, res: Response

): void callback(): RequestListener

start(port?: number, host?: string): Promise> @@ -169,7 +165,7 @@ declare namespace restana { } interface TraceService

extends Service

{ - trace: RegisterRoute

+ trace: RegisterRoute getRouter(): TraceRouter

newRouter(): TraceRouter

} diff --git a/index.js b/index.js index f75b090..07c9858 100644 --- a/index.js +++ b/index.js @@ -17,11 +17,18 @@ const exts = { module.exports = (options = {}) => { const config = { ...options } + // The default error handler shares the res.send(err) response format: + // details are masked unless `debugErrors` is enabled outside production. config.errorHandler = config.errorHandler || ((err, req, res) => { - const statusCode = errorStatusCode(err) - res.send({ code: statusCode, message: 'Internal Server Error' }, statusCode) + if (!(err instanceof Error)) { + err = Object.assign(new Error('Non-error value thrown'), { + status: errorStatusCode(err), + data: err + }) + } + res.send(err) }) const server = config.server || require('http').createServer() diff --git a/libs/apm-base.d.ts b/libs/apm-base.d.ts new file mode 100644 index 0000000..e17bbe8 --- /dev/null +++ b/libs/apm-base.d.ts @@ -0,0 +1,23 @@ +declare namespace apm { + interface Agent { + setTransactionName(name: string): void + } + + interface Options { + apm?: Agent + agent?: Agent + } + + interface RoutesNaming { + /** + * Patches the route registration methods of a service or router so each + * request transaction is named after its route pattern, e.g. `GET /users/:id`. + * Must be called before registering routes. + */ + patch(app: object): void + } +} + +declare function apm(options: apm.Options): apm.RoutesNaming + +export = apm diff --git a/libs/elastic-apm.d.ts b/libs/elastic-apm.d.ts new file mode 100644 index 0000000..9c1a664 --- /dev/null +++ b/libs/elastic-apm.d.ts @@ -0,0 +1,3 @@ +import apm = require('./apm-base') + +export = apm diff --git a/libs/newrelic-apm.d.ts b/libs/newrelic-apm.d.ts new file mode 100644 index 0000000..9c1a664 --- /dev/null +++ b/libs/newrelic-apm.d.ts @@ -0,0 +1,3 @@ +import apm = require('./apm-base') + +export = apm diff --git a/libs/response-extensions.js b/libs/response-extensions.js index 68507af..94eed66 100644 --- a/libs/response-extensions.js +++ b/libs/response-extensions.js @@ -103,7 +103,7 @@ module.exports.send = (options, req, res) => { if (data === undefined) { // keep an explicitly provided status code, e.g. res.send(undefined, 204) data = res.body - } else if (typeof data === 'number') { + } else if (typeof data === 'number' && toHttpStatusCode(data, 0) !== 0) { code = data data = res.body } @@ -111,7 +111,7 @@ module.exports.send = (options, req, res) => { if (data !== undefined && data !== null) { if (typeof data === 'string') { if (!contentType) contentType = TYPE_PLAIN - } else if (typeof data === 'boolean') { + } else if (typeof data === 'boolean' || typeof data === 'number') { if (!contentType) contentType = TYPE_JSON data = stringify(data) } else if (typeof data === 'object') { diff --git a/package-lock.json b/package-lock.json index 5d61469..ffab106 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "restana", - "version": "6.1.0", + "version": "7.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "restana", - "version": "6.1.0", + "version": "7.0.0", "license": "MIT", "dependencies": { "0http": "^5.2.0" diff --git a/package.json b/package.json index a97bd08..ddaea16 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,24 @@ { "name": "restana", - "version": "6.1.0", + "version": "7.0.0", "description": "Super fast and minimalist web framework for building REST micro-services.", "main": "index.js", "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./libs/elastic-apm": { + "types": "./libs/elastic-apm.d.ts", + "default": "./libs/elastic-apm.js" + }, + "./libs/newrelic-apm": { + "types": "./libs/newrelic-apm.d.ts", + "default": "./libs/newrelic-apm.js" + }, + "./package.json": "./package.json" + }, "scripts": { "lint": "standard", "format": "standard --fix", @@ -36,6 +51,7 @@ "LICENSE", "README.md", "CHANGELOG.md", + "MIGRATION.md", "CONTRIBUTING.md", "docs/README.md", "index.js", diff --git a/specs/bugfixes.test.js b/specs/bugfixes.test.js index e02eaab..7f48df2 100644 --- a/specs/bugfixes.test.js +++ b/specs/bugfixes.test.js @@ -47,7 +47,7 @@ describe('Bug fixes', () => { const server = await service.start(0, '127.0.0.1') await request(server).get('/send').expect(404, { code: 404, message: 'Not Found' }) - await request(server).get('/next').expect(404, { code: 404, message: 'Internal Server Error' }) + await request(server).get('/next').expect(404, { code: 404, message: 'Not Found' }) await request(server).get('/string-status').expect(500) await service.close() }) diff --git a/specs/release-7.0.test.js b/specs/release-7.0.test.js new file mode 100644 index 0000000..5fa4bc7 --- /dev/null +++ b/specs/release-7.0.test.js @@ -0,0 +1,58 @@ +'use strict' + +/* global describe, it */ +const expect = require('chai').expect +const request = require('supertest') +const restana = require('../index') + +describe('Restana 7.0 behavior', () => { + it('formats default error handler responses like res.send(err)', async () => { + const service = restana() + service.get('/forbidden', async () => { + throw Object.assign(new Error('secret'), { status: 403, data: { token: 'x' } }) + }) + service.get('/string', (req, res, next) => next('database password')) + service.get('/object', (req, res, next) => next({ status: 409 })) + + const server = await service.start(0, '127.0.0.1') + await request(server).get('/forbidden').expect(403, { code: 403, message: 'Forbidden' }) + await request(server).get('/string').expect(500, { code: 500, message: 'Internal Server Error' }) + await request(server).get('/object').expect(409, { code: 409, message: 'Conflict' }) + await service.close() + }) + + it('exposes default error handler details only with debugErrors', async () => { + const service = restana({ debugErrors: true }) + service.get('/', (req, res, next) => next(Object.assign(new Error('detail'), { status: 400, data: 1 }))) + service.get('/string', (req, res, next) => next('raw')) + + const server = await service.start(0, '127.0.0.1') + await request(server).get('/').expect(400, { code: 400, message: 'detail', data: 1 }) + await request(server).get('/string').expect(500, { code: 500, message: 'Non-error value thrown', data: 'raw' }) + await service.close() + }) + + it('serializes numbers that are not status codes as JSON', async () => { + const service = restana() + service.get('/float', (req, res) => res.send(42.5)) + service.get('/zero', (req, res) => res.send(0, 201)) + service.get('/status', (req, res) => res.send(204)) + + const server = await service.start(0, '127.0.0.1') + const float = await request(server).get('/float').expect(200) + expect(float.headers['content-type']).to.equal('application/json; charset=utf-8') + expect(float.text).to.equal('42.5') + await request(server).get('/zero').expect(201).then(res => expect(res.text).to.equal('0')) + await request(server).get('/status').expect(204) + await service.close() + }) + + it('exposes only the public entry points', () => { + const exports = require('../package.json').exports + expect(Object.keys(exports)).to.deep.equal(['.', './libs/elastic-apm', './libs/newrelic-apm', './package.json']) + for (const entry of Object.values(exports)) { + const target = typeof entry === 'string' ? entry : entry.default + expect(() => require.resolve(`../${target}`)).to.not.throw() + } + }) +}) diff --git a/specs/security.test.js b/specs/security.test.js index df10dfe..c8b869b 100644 --- a/specs/security.test.js +++ b/specs/security.test.js @@ -79,7 +79,7 @@ describe('Security Fixes', () => { .expect(404) .then((response) => { expect(response.body.code).to.equal(404) - expect(response.body.message).to.equal('Internal Server Error') + expect(response.body.message).to.equal('Not Found') }) }) diff --git a/specs/types.test.ts b/specs/types.test.ts index 345472f..99c42f7 100644 --- a/specs/types.test.ts +++ b/specs/types.test.ts @@ -54,3 +54,25 @@ restana({ router: 'trie', caseSensitive: false, ignoreTrailingSlash: false }) restana({ router: 'sequential', routerCacheSize: 100 }) // @ts-expect-error restana({ router: 'radix' }) + +// Router registration chains on the router itself +const router: restana.Router = service.newRouter() + .get('/a', (req, res) => res.send('a')) + .use(auth) + .post('/b', (req, res) => res.send('b')) +service.use('/r', router) +const chained: restana.Service = service.get('/c', auth).use(log) +String(chained.id) + +// Configuration snapshots are read-only +const snapshot = service.getConfigOptions() +// @ts-expect-error +snapshot.trustProxy = true +// @ts-expect-error +service.errorHandler = () => {} + +// APM integrations are typed public entry points +import elasticApm = require('restana/libs/elastic-apm') +import newrelicApm = require('restana/libs/newrelic-apm') +elasticApm({ agent: { setTransactionName (name: string) { String(name) } } }).patch(service) +newrelicApm({ apm: { setTransactionName () {} } }).patch(router) From ce24f39acc13cf9ad8766c340809dfa238616af7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:33:58 +0000 Subject: [PATCH 4/7] perf: faster startup and response path; feat(types): infer route params - load the sequential router on demand (about 40% faster require, about 0.5MB less heap) - avoid a per-response 'finish' listener in res.send() when no callback is given - resolve security header options once per service - infer req.params from route paths and expose RouteParams - document TypeScript usage and the performance changes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2wFkaGAAWQhBw9VtCu7qe --- CHANGELOG.md | 8 +++++++ MIGRATION.md | 3 +++ README.md | 2 ++ docs/README.md | 20 +++++++++++++++++ index.d.ts | 41 ++++++++++++++++++++++++++--------- index.js | 10 +++++---- libs/request-router.js | 3 ++- libs/response-extensions.js | 4 +++- libs/security-headers.js | 43 +++++++++++++++++++------------------ specs/release-7.0.test.js | 12 +++++++++++ specs/types.test.ts | 32 +++++++++++++++++++++++++++ 11 files changed, 141 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b18ab..2fc94f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,16 @@ Breaking release. See the [migration guide](MIGRATION.md). - Runtime `restana.Protocol` and `restana.Method` objects matching the TypeScript enums. - TypeScript support for registering multiple middlewares in one `use()` call. - Type declarations for the Elastic APM and New Relic integrations. +- TypeScript route parameter inference: `service.get('/users/:id', (req) => req.params.id)` and the + `restana.RouteParams` helper type. - `.nvmrc` pinning the supported Node.js major version for contributors. +### Performance +- The sequential router and its dependencies are loaded only when `router: 'sequential'` is selected, + cutting `require('restana')` time by about 40% and the initial heap by about 0.5 MB. +- `res.send()` no longer registers a `finish` listener per response when no callback is passed. +- Security header options are resolved once per service instead of on every request. + ### Fixed - `res.send(undefined, code)` and promises resolving to `undefined` no longer drop the explicit status code. - Non-numeric error codes (e.g. `'ENOENT'`) no longer mask a valid `err.statusCode`. diff --git a/MIGRATION.md b/MIGRATION.md index 8d90a8c..d40bf9c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -139,6 +139,9 @@ your use case. - `Router.id` is always defined. - `RegisterRoute>` takes an optional second type parameter for the return type. - New options are typed: `router`, `caseSensitive` and `ignoreTrailingSlash`. +- Route params are inferred from the path: in `service.get('/users/:id', handler)`, `req.params.id` + is typed and autocompleted. Any other key is still typed as `string`, so existing code compiles + unchanged. `Request`, `RequestHandler` and `RouteParams` take an optional params type argument. - `restana.Protocol` and `restana.Method` now exist at runtime, so `restana.Protocol.HTTP2` works in value positions too. - `restana/libs/elastic-apm` and `restana/libs/newrelic-apm` now ship type declarations. diff --git a/README.md b/README.md index 9ef645c..f9bf5e1 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ Error details are hidden by default in every environment. For local debugging on - Dependency-free trie router by default: constant-time static routes and no match cache to tune. - Case-sensitive routing that agrees with reverse proxies and WAFs (`caseSensitive: false` to opt out). - Consistent error responses from `res.send(err)` and the default error handler. +- Faster startup: no third-party modules are loaded by default. +- TypeScript route params inferred from the path (`'/users/:id'` → `req.params.id`). - Stricter `res.send()` status code handling and a typed, explicit public API. Upgrading from 6.x? Read the [migration guide](MIGRATION.md). diff --git a/docs/README.md b/docs/README.md index 35e3ca1..76b40de 100644 --- a/docs/README.md +++ b/docs/README.md @@ -393,6 +393,26 @@ Service events are accessible through the `service.events` object, an instance o ### Available events - `service.events.BEFORE_ROUTE_REGISTER`: This event is triggered before registering a route. +## TypeScript +Type declarations are bundled. Route parameters are inferred from the path pattern: +```ts +import restana = require('restana') + +const service = restana() + +service.get('/users/:userId/posts/:postId', (req, res) => { + // req.params.userId and req.params.postId are typed as string + res.send({ user: req.params.userId, post: req.params.postId }) +}) +``` +Optional params (`/:id?`), extensions (`/:name.json`) and wildcards (`/*`, available as `req.params['*']`) are supported. Other keys, such as params inherited from a nested router prefix, remain accessible as `string`. Use `restana.RouteParams<'/users/:id'>` to type handlers declared separately: +```ts +const getUser: restana.RequestHandler> = (req, res) => { + res.send({ id: req.params.id }) +} +service.get('/users/:id', getUser) +``` + ## AWS Serverless Integration `restana` is compatible with the [serverless-http](https://github.com/dougmoscrop/serverless-http) library, so restana based services can also run as AWS lambdas 🚀 ```js diff --git a/index.d.ts b/index.d.ts index 74e8cc8..3cd3389 100644 --- a/index.d.ts +++ b/index.d.ts @@ -41,16 +41,37 @@ declare namespace restana { TRACE = 'trace' } - interface RequestExtensions { - params: Record + type ParamName = + Segment extends `:${infer Name}?` ? StripExtension + : Segment extends `:${infer Name}` ? StripExtension + : Segment extends '*' ? '*' + : never + + type StripExtension = Name extends `${infer Base}.${string}` ? Base : Name + + type PathParamNames = + Path extends `${infer Segment}/${infer Rest}` + ? ParamName | PathParamNames + : ParamName + + /** + * Route parameters inferred from a path pattern, e.g. `'/users/:id'` -> `{ id: string }`. + * Other keys (such as params inherited from a nested router prefix) remain accessible as strings. + */ + type RouteParams = string extends Path + ? Record + : { [Name in PathParamNames]: string } & Record + + interface RequestExtensions> { + params: Params query: Record originalUrl: string, body?: Body } - type Request

= P extends Protocol.HTTP2 - ? Http2ServerRequest & RequestExtensions - : IncomingMessage & RequestExtensions + type Request

> = P extends Protocol.HTTP2 + ? Http2ServerRequest & RequestExtensions + : IncomingMessage & RequestExtensions interface ResponseExtensions { send( @@ -92,8 +113,8 @@ declare namespace restana { ? HttpsServer : HttpServer - type RequestHandler

= ( - req: Request

, + type RequestHandler

> = ( + req: Request, res: Response

, next: (error?: unknown) => void ) => void | Promise @@ -117,9 +138,9 @@ declare namespace restana { ) => void | Promise interface RegisterRoute

> { - ( - path: string | string[], - ...middlewares: RequestHandler

[] + ( + path: Path | Path[], + ...middlewares: RequestHandler>[] ): R } diff --git a/index.js b/index.js index 07c9858..0837b96 100644 --- a/index.js +++ b/index.js @@ -7,7 +7,7 @@ */ const requestRouter = require('./libs/request-router') -const applySecurityHeaders = require('./libs/security-headers') +const securityHeaders = require('./libs/security-headers') const { deepFreezeObject, errorStatusCode } = require('./libs/utils') const exts = { request: {}, @@ -43,10 +43,12 @@ module.exports = (options = {}) => { }) } + // Default security headers (can be overridden by application or disabled via options) + const applySecurityHeaders = config.securityHeaders !== false ? securityHeaders(config) : null + const handle = (req, res) => { - // Default security headers (can be overridden by application or disabled via options) - if (config.securityHeaders !== false) { - applySecurityHeaders(config, req, res) + if (applySecurityHeaders !== null) { + applySecurityHeaders(req, res) } // request object population diff --git a/libs/request-router.js b/libs/request-router.js index 6363995..7d3a641 100644 --- a/libs/request-router.js +++ b/libs/request-router.js @@ -4,7 +4,6 @@ * @see: https://github.com/BackendStack21/0http#trie-router-dependency-free-fastest */ const trie = require('0http/lib/router/trie') -const sequential = require('0http/lib/router/sequential') const getMethods = require('./methods') const EventEmitter = require('events') const BEFORE_ROUTE_REGISTER_EVENT = 'beforeRouteRegister' @@ -28,6 +27,8 @@ function createRouter (options, defaultRoute) { }) } if (type === 'sequential') { + // loaded on demand: its dependencies account for most of the framework's startup time + const sequential = require('0http/lib/router/sequential') return sequential({ errorHandler: options.errorHandler, cacheSize: options.routerCacheSize ?? 2000, diff --git a/libs/response-extensions.js b/libs/response-extensions.js index 94eed66..9a06859 100644 --- a/libs/response-extensions.js +++ b/libs/response-extensions.js @@ -172,7 +172,9 @@ module.exports.send = (options, req, res) => { } beforeEnd(res, contentType, code, data) - res.end(data, cb) + // passing a callback to res.end() registers a 'finish' listener, skip it when unused + if (cb === NOOP) res.end(data) + else res.end(data, cb) } return send diff --git a/libs/security-headers.js b/libs/security-headers.js index 0bd4afb..0aa3fec 100644 --- a/libs/security-headers.js +++ b/libs/security-headers.js @@ -7,35 +7,36 @@ const isForwardedHttps = (value) => { return first.trim().toLowerCase() === 'https' } +const HSTS = 'max-age=15552000; includeSubDomains' + +const setDefault = (res, name, value) => { + if (res.getHeader(name) === undefined) res.setHeader(name, value) +} + /** - * Applies default security headers to the response, if not already set. - * Headers can be overridden by the application via res.setHeader(). + * Creates a function that applies default security headers to the response, + * if not already set. Headers can be overridden by the application via res.setHeader(). * * @param {Object} options - * @param {import('http').IncomingMessage} req - * @param {import('http').ServerResponse} res + * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse) => void} */ -module.exports = (options, req, res) => { - if (!res.getHeader('x-content-type-options')) { - res.setHeader('x-content-type-options', 'nosniff') - } - if (!res.getHeader('x-frame-options')) { - res.setHeader('x-frame-options', 'DENY') - } - if (!res.getHeader('x-xss-protection')) { - res.setHeader('x-xss-protection', '0') - } - +module.exports = (options) => { // Forwarded headers are only trustworthy when the application explicitly // opts in because clients can otherwise spoof them. - const isTLS = req.socket && req.socket.encrypted const trustProxy = options.trustProxy === true - const forwardedProto = trustProxy && req.headers && req.headers['x-forwarded-proto'] - const isForwardedTLS = typeof forwardedProto === 'string' && isForwardedHttps(forwardedProto) - if (isTLS || isForwardedTLS) { - if (!res.getHeader('strict-transport-security')) { - res.setHeader('strict-transport-security', 'max-age=15552000; includeSubDomains') + return (req, res) => { + setDefault(res, 'x-content-type-options', 'nosniff') + setDefault(res, 'x-frame-options', 'DENY') + setDefault(res, 'x-xss-protection', '0') + + if (req.socket?.encrypted || (trustProxy && isForwardedTLS(req))) { + setDefault(res, 'strict-transport-security', HSTS) } } } + +function isForwardedTLS (req) { + const forwardedProto = req.headers && req.headers['x-forwarded-proto'] + return typeof forwardedProto === 'string' && isForwardedHttps(forwardedProto) +} diff --git a/specs/release-7.0.test.js b/specs/release-7.0.test.js index 5fa4bc7..959ee86 100644 --- a/specs/release-7.0.test.js +++ b/specs/release-7.0.test.js @@ -47,6 +47,18 @@ describe('Restana 7.0 behavior', () => { await service.close() }) + it('loads the sequential router only when selected', () => { + const { execFileSync } = require('child_process') + const loaded = (code) => JSON.parse(execFileSync(process.execPath, ['-e', ` + const restana = require(${JSON.stringify(require.resolve('../index'))}) + ${code} + console.log(JSON.stringify(Object.keys(require.cache).some(file => file.includes('sequential')))) + `]).toString()) + + expect(loaded('restana()')).to.equal(false) + expect(loaded("restana({ router: 'sequential' })")).to.equal(true) + }) + it('exposes only the public entry points', () => { const exports = require('../package.json').exports expect(Object.keys(exports)).to.deep.equal(['.', './libs/elastic-apm', './libs/newrelic-apm', './package.json']) diff --git a/specs/types.test.ts b/specs/types.test.ts index 99c42f7..b8f941a 100644 --- a/specs/types.test.ts +++ b/specs/types.test.ts @@ -76,3 +76,35 @@ import elasticApm = require('restana/libs/elastic-apm') import newrelicApm = require('restana/libs/newrelic-apm') elasticApm({ agent: { setTransactionName (name: string) { String(name) } } }).patch(service) newrelicApm({ apm: { setTransactionName () {} } }).patch(router) + +// Route params are inferred from the path pattern +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false +const assertType = () => {} + +assertType, 'id' | 'name'>>() +assertType, never>>() +assertType, 'a' | '*'>>() +// @ts-expect-error +assertType, 'userId'>>() +const params: restana.RouteParams<'/orgs/:org/users/:id?/*'> = { org: 'a', id: 'b', '*': 'c' } +String(params.org + params.id + params['*']) +// @ts-expect-error missing inferred param +const missing: restana.RouteParams<'/users/:id'> = {} +String(missing) + +service.get('/users/:userId/posts/:postId', (req, res) => { + const userId: string = req.params.userId + const postId: string = req.params.postId + // params inherited from nested router prefixes stay accessible + const tenant: string = req.params.tenant + res.send({ userId, postId, tenant }) +}) +service.get(['/a/:x', '/b/:y'], auth, (req, res) => res.send(req.params.x ?? req.params.y)) +const dynamicPath: string = '/runtime' +service.get(dynamicPath, (req, res) => res.send(req.params.anything)) + +// Documented handler typed separately from its route +const getUser: restana.RequestHandler> = (req, res) => { + res.send({ id: req.params.id }) +} +service.get('/users/:id', getUser) From 907595d0bd46445b381c5505a6cd4ff671379b4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:55:59 +0000 Subject: [PATCH 5/7] chore: pin Node.js 26 (latest stable) for contributors Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2wFkaGAAWQhBw9VtCu7qe --- .nvmrc | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.nvmrc b/.nvmrc index a45fd52..6f4247a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24 +26 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc94f9..200b3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ Breaking release. See the [migration guide](MIGRATION.md). - Type declarations for the Elastic APM and New Relic integrations. - TypeScript route parameter inference: `service.get('/users/:id', (req) => req.params.id)` and the `restana.RouteParams` helper type. -- `.nvmrc` pinning the supported Node.js major version for contributors. +- `.nvmrc` pinning Node.js 26 (latest stable) for contributors; Node.js 24 LTS remains the minimum. ### Performance - The sequential router and its dependencies are loaded only when `router: 'sequential'` is selected, From 59c7f7d3f853801a087083dbb30c94a5cb88484a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:50:59 +0000 Subject: [PATCH 6/7] test: reach 100% coverage and enforce a 99% minimum - cover lifecycle failures, stream error paths, promise depth cap, security header edge cases and configuration snapshot cloning - fix res.send() with functions, symbols and bigints: respond with 500 instead of treating a function as the res.end() callback (empty 200) - enforce 99% statements, branches, functions and lines in npm test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2wFkaGAAWQhBw9VtCu7qe --- CHANGELOG.md | 6 + CONTRIBUTING.md | 3 + MIGRATION.md | 2 + libs/response-extensions.js | 4 + package.json | 2 +- specs/edge-cases.test.js | 324 ++++++++++++++++++++++++++++++++++++ 6 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 specs/edge-cases.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 200b3e8..535e752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ Breaking release. See the [migration guide](MIGRATION.md). `restana.RouteParams` helper type. - `.nvmrc` pinning Node.js 26 (latest stable) for contributors; Node.js 24 LTS remains the minimum. +### Testing +- 100% statement, branch, function and line coverage, enforced at a 99% minimum in `npm test`. +- The test suite also passes on Bun. + ### Performance - The sequential router and its dependencies are loaded only when `router: 'sequential'` is selected, cutting `require('restana')` time by about 40% and the initial heap by about 0.5 MB. @@ -39,6 +43,8 @@ Breaking release. See the [migration guide](MIGRATION.md). - `res.send(undefined, code)` and promises resolving to `undefined` no longer drop the explicit status code. - Non-numeric error codes (e.g. `'ENOENT'`) no longer mask a valid `err.statusCode`. - APM instrumentation names multi-path routes per path instead of joining them (`GET /a,/b`). +- `res.send()` rejects function, symbol and bigint payloads with a `500` error. Functions used to be + treated as the `res.end()` callback, silently sending an empty `200` response. ## 6.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 316ef47..0348a84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,9 @@ npm ci npm run check ``` +`npm test` enforces at least 99% statement, branch, function and line coverage. New code +paths need tests, including failure paths. + Tests must listen on an ephemeral loopback port: ```js diff --git a/MIGRATION.md b/MIGRATION.md index d40bf9c..59d49e3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -153,3 +153,5 @@ your use case. `statusCode`. - APM integrations name routes registered with several paths, `service.get(['/a', '/b'], …)`, one path at a time (`GET /a`, `GET /b`) instead of joining them (`GET /a,/b`). +- `res.send()` with a function, symbol or bigint now responds with a `500` error. A function used + to be called as the `res.end()` callback, sending an empty `200` response. diff --git a/libs/response-extensions.js b/libs/response-extensions.js index 9a06859..90b64df 100644 --- a/libs/response-extensions.js +++ b/libs/response-extensions.js @@ -167,6 +167,10 @@ module.exports.send = (options, req, res) => { if (!contentType) contentType = TYPE_JSON data = stringify(data) } + } else { + // functions, symbols and bigints cannot be sent; res.end() would even treat + // a function as its callback and silently send an empty response + return send(new TypeError(`Unsupported res.send() payload type: ${typeof data}`), code, headers, cb, _promiseDepth) } } } diff --git a/package.json b/package.json index ddaea16..e056f05 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "scripts": { "lint": "standard", "format": "standard --fix", - "test": "cross-env NODE_ENV=testing nyc --check-coverage --lines 95 mocha specs/*.test.js", + "test": "cross-env NODE_ENV=testing nyc --check-coverage --lines 99 --branches 99 --functions 99 --statements 99 mocha specs/*.test.js", "test:types": "tsc", "check": "npm run lint && npm run test:types && npm test", "bench": "node performance/suites/res-send.js && node performance/suites/res-send-promise.js && node performance/route-scale.js", diff --git a/specs/edge-cases.test.js b/specs/edge-cases.test.js new file mode 100644 index 0000000..d2e8172 --- /dev/null +++ b/specs/edge-cases.test.js @@ -0,0 +1,324 @@ +'use strict' + +/* global describe, it */ +const expect = require('chai').expect +const http = require('http') +const request = require('supertest') +const stream = require('stream') +const EventEmitter = require('events') +const restana = require('../index') +const getMethods = require('../libs/methods') +const securityHeaders = require('../libs/security-headers') +const { toHttpStatusCode, errorStatusCode, deepFreezeObject } = require('../libs/utils') + +// Minimal server double to exercise start()/close() failure paths deterministically +class FakeServer extends EventEmitter { + constructor ({ listen, close } = {}) { + super() + this.listen = listen || ((...args) => { + this.listenArgs = args + setImmediate(() => this.emit('listening')) + }) + this.close = close || (cb => cb()) + } +} + +// Issues a request and resolves with how the response ended +const fetchOutcome = (server, path) => new Promise(resolve => { + const { port } = server.address() + const req = http.get({ host: '127.0.0.1', port, path }, res => { + res.on('data', () => {}) + res.on('end', () => resolve({ status: res.statusCode, complete: res.complete })) + res.on('aborted', () => resolve({ status: res.statusCode, complete: false })) + res.on('error', () => resolve({ status: res.statusCode, complete: false })) + }) + req.on('error', err => resolve({ error: err.code })) +}) + +describe('Edge cases', () => { + describe('service lifecycle', () => { + it('listens on port 3000 when start() is called without arguments', async () => { + const server = new FakeServer() + const service = restana({ server }) + + expect(await service.start()).to.equal(server) + expect(server.listenArgs).to.deep.equal([3000]) + expect(server.listenerCount('error')).to.equal(0) + expect(server.listenerCount('listening')).to.equal(0) + }) + + it('rejects start() when listen throws synchronously', async () => { + const server = new FakeServer({ + listen () { throw new Error('invalid listen options') } + }) + const service = restana({ server }) + + let error + await service.start({ port: -1 }).catch(err => { error = err }) + expect(error.message).to.equal('invalid listen options') + expect(server.listenerCount('error')).to.equal(0) + expect(server.listenerCount('listening')).to.equal(0) + }) + + it('rejects close() when the server reports an error', async () => { + const service = restana({ + server: new FakeServer({ close: cb => cb(new Error('not running')) }) + }) + + let error + await service.close().catch(err => { error = err }) + expect(error.message).to.equal('not running') + }) + + it('rejects close() when the server throws synchronously', async () => { + const service = restana({ + server: new FakeServer({ close () { throw new Error('close failed') } }) + }) + + let error + await service.close().catch(err => { error = err }) + expect(error.message).to.equal('close failed') + }) + + it('returns the request handler through callback()', async () => { + const service = restana() + service.get('/hi', (req, res) => res.send('hi')) + + expect(service.callback()).to.equal(service.handle) + await request(http.createServer(service.callback())).get('/hi').expect(200, 'hi') + }) + }) + + describe('routers', () => { + it('exposes find() on routers created with newRouter()', () => { + const router = restana().newRouter() + const handler = (req, res) => res.send('ok') + router.get('/users/:id', handler) + + const match = router.find('GET', '/users/1') + expect(match.handlers).to.include(handler) + expect(match.params).to.deep.include({ id: '1' }) + }) + + it('lists the base methods unless TRACE is enabled', () => { + expect(getMethods()).to.not.include('trace') + expect(getMethods({ enableTrace: true })).to.include('trace') + }) + }) + + describe('res.send()', () => { + it('invokes the end callback for regular payloads', async () => { + const service = restana() + let callbackCalled = false + service.get('/', (req, res) => res.send('done', 200, null, () => { callbackCalled = true })) + + const server = await service.start(0, '127.0.0.1') + await request(server).get('/').expect(200, 'done') + await service.close() + expect(callbackCalled).to.equal(true) + }) + + it('fails with 500 on payloads that cannot be sent', async () => { + const service = restana({ debugErrors: true }) + let functionCalled = false + service.get('/symbol', (req, res) => res.send(Symbol('payload'))) + service.get('/function', (req, res) => res.send(() => { functionCalled = true })) + service.get('/bigint', (req, res) => res.send(1n)) + + const server = await service.start(0, '127.0.0.1') + for (const type of ['symbol', 'function', 'bigint']) { + await request(server) + .get(`/${type}`) + .expect('content-type', 'application/json; charset=utf-8') + .expect(500, { code: 500, message: `Unsupported res.send() payload type: ${type}` }) + } + await service.close() + expect(functionCalled).to.equal(false) + }) + + it('falls back to a generic message for unknown status codes', async () => { + const service = restana() + service.get('/', (req, res) => res.send(Object.assign(new Error('custom'), { status: 599 }))) + + const server = await service.start(0, '127.0.0.1') + await request(server).get('/').expect(599, { code: 599, message: 'Internal Server Error' }) + await service.close() + }) + + it('caps promise resolution depth', async () => { + const service = restana() + // rejection reasons are not flattened, so each nesting level adds a resolution step + const handled = (promise) => { promise.catch(() => {}); return promise } + const chain = (depth) => { + let promise = handled(Promise.reject(new Error('innermost'))) + for (let i = 0; i < depth; i++) promise = handled(Promise.reject(promise)) + return promise + } + + service.get('/shallow', (req, res) => res.send(chain(1))) + service.get('/deep', (req, res) => res.send(chain(5))) + + const server = await service.start(0, '127.0.0.1') + await request(server).get('/shallow').expect(500, { code: 500, message: 'Internal Server Error' }) + await request(server).get('/deep').expect(500, { code: 500, message: 'Internal Server Error' }) + await service.close() + }) + }) + + describe('stream failures', () => { + const failingAfterFirstChunk = () => new stream.Readable({ + read () { + if (!this.sent) { + this.sent = true + this.push('partial') + setTimeout(() => this.destroy(new Error('stream broke')), 20) + } + } + }) + + it('destroys the response when a stream fails after headers were sent', async () => { + const service = restana() + let callbackError + service.get('/', (req, res) => { + res.send(failingAfterFirstChunk(), 200, null, err => { callbackError = err }) + }) + + const server = await service.start(0, '127.0.0.1') + const outcome = await fetchOutcome(server, '/') + await service.close() + + expect(outcome.complete).to.equal(false) + expect(callbackError.message).to.equal('stream broke') + }) + + // fails a streamed response before any data is written, with a failing error handler + const failEarly = async (errorHandler) => { + let handlerCalls = 0 + let serverResponse + const service = restana({ + errorHandler (...args) { + handlerCalls++ + return errorHandler(...args) + } + }) + service.get('/', (req, res) => { + serverResponse = res + const failing = new stream.Readable({ read () {} }) + res.send(failing) + setImmediate(() => failing.destroy(new Error('early failure'))) + }) + + const server = await service.start(0, '127.0.0.1') + const outcome = await fetchOutcome(server, '/') + await service.close() + + expect(handlerCalls).to.equal(1) + expect(serverResponse.destroyed).to.equal(true) + // Bun flushes a 200 status once the route handler returns without ending the + // response, so a later destroy cannot be observed as a connection reset there + if (!process.versions.bun) expect(outcome.error).to.equal('ECONNRESET') + } + + it('destroys the response when the error handler throws', async () => { + await failEarly(() => { throw new Error('handler failed') }) + }) + + it('destroys the response when an async error handler rejects', async () => { + await failEarly(async () => { throw new Error('async handler failed') }) + }) + }) + + describe('security headers', () => { + const fakeResponse = (headers = {}) => ({ + headers, + getHeader (name) { return this.headers[name] }, + setHeader (name, value) { this.headers[name] = value } + }) + + it('keeps headers set before restana handles the request', async () => { + const service = restana() + service.get('/', (req, res) => res.send('ok')) + const server = http.createServer((req, res) => { + res.setHeader('x-frame-options', 'SAMEORIGIN') + service(req, res) + }) + + const response = await request(server).get('/').expect(200) + expect(response.headers['x-frame-options']).to.equal('SAMEORIGIN') + expect(response.headers['x-content-type-options']).to.equal('nosniff') + }) + + it('only trusts https as the first forwarded protocol', () => { + const apply = securityHeaders({ trustProxy: true }) + const hsts = (proto) => { + const res = fakeResponse() + apply({ headers: { 'x-forwarded-proto': proto } }, res) + return res.headers['strict-transport-security'] !== undefined + } + + expect(hsts('https')).to.equal(true) + expect(hsts('https, http')).to.equal(true) + expect(hsts(' HTTPS ')).to.equal(true) + expect(hsts(' HTTPS , http')).to.equal(true) + expect(hsts('http, https')).to.equal(false) + expect(hsts('httpsx')).to.equal(false) + }) + + it('ignores forwarded protocols without trustProxy and requests without headers', () => { + const res = fakeResponse() + securityHeaders({})({ headers: { 'x-forwarded-proto': 'https' } }, res) + expect(res.headers['strict-transport-security']).to.equal(undefined) + + const bare = fakeResponse() + securityHeaders({ trustProxy: true })({}, bare) + expect(bare.headers['strict-transport-security']).to.equal(undefined) + expect(bare.headers['x-content-type-options']).to.equal('nosniff') + }) + }) + + describe('utils', () => { + it('coerces status codes with a default fallback', () => { + expect(toHttpStatusCode(404)).to.equal(404) + expect(toHttpStatusCode('404')).to.equal(500) + expect(toHttpStatusCode(42)).to.equal(500) + expect(toHttpStatusCode(42, 200)).to.equal(200) + }) + + it('resolves error status codes from falsy errors', () => { + expect(errorStatusCode(undefined)).to.equal(500) + expect(errorStatusCode(null)).to.equal(500) + expect(errorStatusCode('boom')).to.equal(500) + expect(errorStatusCode({ code: 'ENOENT', statusCode: 404 })).to.equal(404) + }) + + it('clones built-in values in configuration snapshots', () => { + class Store {} + const store = new Store() + const original = { + buffer: Buffer.from('secret'), + date: new Date(0), + pattern: /^\/api/gi, + store + } + + const snapshot = deepFreezeObject(original) + + expect(snapshot.buffer).to.not.equal(original.buffer) + expect(snapshot.buffer.toString()).to.equal('secret') + snapshot.buffer[0] = 0 + expect(original.buffer.toString()).to.equal('secret') + + expect(snapshot.date).to.not.equal(original.date) + expect(snapshot.date.getTime()).to.equal(0) + expect(Object.isFrozen(snapshot.date)).to.equal(true) + + expect(snapshot.pattern).to.not.equal(original.pattern) + expect(snapshot.pattern.source).to.equal('^\\/api') + expect(snapshot.pattern.flags).to.equal('gi') + + // class instances are kept by reference (e.g. stores, clients, loggers) + expect(snapshot.store).to.equal(store) + expect(Object.isFrozen(store)).to.equal(false) + }) + }) +}) From caaf96c52f08db4857f4d5f8190a0ebc5e652e65 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 24 Oct 2026 17:20:52 +0200 Subject: [PATCH 7/7] docs: finish 7.0 landing page refresh Add release cards for typed route params, faster startup and response fixes, and drop stale sequential-router and dependency-count claims. Co-Authored-By: Claude Opus 5.5 --- docs/index.html | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/index.html b/docs/index.html index 9cc876d..6e18bda 100644 --- a/docs/index.html +++ b/docs/index.html @@ -262,7 +262,7 @@

7
HTTP methods
v7.0
latest
Node 24+
runtime
-
3
dependencies
+
1
dependency
TS
types built-in

@@ -295,6 +295,21 @@

Consistent errors

Explicit public API

An exports map, typed APM integrations, and router-returning chaining in the TypeScript declarations.

+
+ +

Typed route params

+

req.params is inferred from the route path: '/users/:id' gives you req.params.id, with no annotations needed.

+
+
+ +

Faster startup

+

The sequential router loads only when selected, so require('restana') is about 40% faster and pulls in only 0http's trie by default.

+
+
+ +

Correct responses

+

Explicit status codes survive empty and promise bodies, string error codes no longer mask err.statusCode, and coverage is held at 100%.

+
@@ -309,7 +324,7 @@

Why restana?

⚡

Blazing Fast

-

Lightweight routing with minimal overhead. The sequential router uses internal caching for repeated lookups — tuned for raw throughput.

+

Lightweight routing with minimal overhead. The default trie router resolves static routes in constant time and needs no match cache — tuned for raw throughput.

🛡 @@ -334,7 +349,7 @@

Serverless Ready

📦

Minimal Footprint

-

Three runtime dependencies. Zero bloat. The res.send() method handles null, strings, buffers, objects, streams, and promises — all in one call.

+

One direct dependency, and the default trie router loads nothing beyond it. Zero bloat. The res.send() method handles null, strings, buffers, objects, streams, and promises — all in one call.