From aaf7d361daaa6058d3fe61ed8c00ce99b3b7e227 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Tue, 25 Feb 2025 18:37:32 +0100 Subject: [PATCH 1/3] performance optimizations --- lib/next.js | 54 ++++++++++------ lib/router/sequential.js | 129 +++++++++++++++++++++++---------------- package.json | 2 +- 3 files changed, 111 insertions(+), 74 deletions(-) diff --git a/lib/next.js b/lib/next.js index bc560cb..80ae0db 100644 --- a/lib/next.js +++ b/lib/next.js @@ -1,42 +1,58 @@ +/** + * Optimized middleware executor + * + * @param {Array} middlewares - Array of middleware functions + * @param {Object} req - Request object + * @param {Object} res - Response object + * @param {Number} index - Current middleware index + * @param {Object} routers - Router patterns map + * @param {Function} defaultRoute - Default route handler + * @param {Function} errorHandler - Error handler + * @returns {*} Result of middleware execution + */ function next (middlewares, req, res, index, routers, defaultRoute, errorHandler) { - routers = routers || {} - + // Fast path for end of middleware chain if (index >= middlewares.length) { - if (!res.finished) { - return defaultRoute(req, res) - } - - return + // Only call defaultRoute if response is not finished + return !res.finished && defaultRoute(req, res) } + // Get current middleware and increment index const middleware = middlewares[index++] - function step (err) { - if (err) { - return errorHandler(err, req, res) - } else { - return next(middlewares, req, res, index, routers, defaultRoute, errorHandler) - } + // Create step function - this is called by middleware to continue the chain + const step = function (err) { + return err + ? errorHandler(err, req, res) + : next(middlewares, req, res, index, routers, defaultRoute, errorHandler) } try { + // Check if middleware is a router (has id) if (middleware.id) { - // nested routes support - const pattern = routers[middleware.id] + // Get pattern for nested router + const pattern = routers?.[middleware.id] + if (pattern) { + // Save original URL and path req.preRouterUrl = req.url req.preRouterPath = req.path + // Replace pattern in URL - this is a hot path, optimize it req.url = req.url.replace(pattern, '') - if (req.url.charCodeAt(0) !== 47) { - req.url = '\u002f'.concat(req.url) + + // Ensure URL starts with a slash + if (req.url.length === 0 || req.url.charCodeAt(0) !== 47) { // 47 is '/' + req.url = '/' + req.url } } + // Call router's lookup method return middleware.lookup(req, res, step) - } else { - return middleware(req, res, step) } + + // Regular middleware function + return middleware(req, res, step) } catch (err) { return errorHandler(err, req, res) } diff --git a/lib/router/sequential.js b/lib/router/sequential.js index a251c1d..3bbf283 100644 --- a/lib/router/sequential.js +++ b/lib/router/sequential.js @@ -4,35 +4,43 @@ const { parse } = require('regexparam') const { LRUCache: Cache } = require('lru-cache') const queryparams = require('../utils/queryparams') +// Default handlers as constants to avoid creating functions on each router instance +const DEFAULT_ROUTE = (req, res) => { + res.statusCode = 404 + res.end() +} + +const DEFAULT_ERROR_HANDLER = (err, req, res) => { + res.statusCode = 500 + res.end(err.message) +} + +// Simple ID generator +const generateId = () => Math.random().toString(36).substring(2, 10).toUpperCase() + module.exports = (config = {}) => { - if (config.defaultRoute === undefined) { - config.defaultRoute = (req, res) => { - res.statusCode = 404 - res.end() - } - } - if (config.errorHandler === undefined) { - config.errorHandler = (err, req, res) => { - res.statusCode = 500 - res.end(err.message) - } - } - if (config.cacheSize === undefined) { - config.cacheSize = -1 - } - if (config.id === undefined) { - config.id = (Date.now().toString(36) + Math.random().toString(36).substr(2, 5)).toUpperCase() - } + // Use object destructuring with defaults for cleaner config initialization + const { + defaultRoute = DEFAULT_ROUTE, + errorHandler = DEFAULT_ERROR_HANDLER, + cacheSize = -1, + id = generateId() + } = config const routers = {} + + // Initialize cache only once let cache = null - if (config.cacheSize > 0) { - cache = new Cache({ max: config.cacheSize }) - } else if (config.cacheSize < 0) { - cache = new Map() + if (cacheSize > 0) { + cache = new Cache({ max: cacheSize }) + } else if (cacheSize < 0) { + // For unlimited cache, still use LRUCache but with a very high max + // This provides better memory management than an unbounded Map + cache = new Cache({ max: 100000 }) } + const router = new Trouter() - router.id = config.id + router.id = id const _use = router.use @@ -43,60 +51,73 @@ module.exports = (config = {}) => { } _use.call(router, prefix, middlewares) - if (middlewares[0].id) { + if (middlewares[0]?.id) { // caching router -> pattern relation for urls pattern replacement const { pattern } = parse(prefix, true) routers[middlewares[0].id] = pattern } - return this + return router // Fix: return router instead of this + } + + // Create the cleanup middleware once + const createCleanupMiddleware = (step) => (req, res, next) => { + req.url = req.preRouterUrl + req.path = req.preRouterPath + + req.preRouterUrl = undefined + req.preRouterPath = undefined + + return step() } router.lookup = (req, res, step) => { - if (!req.url) { - req.url = '/' - } - if (!req.originalUrl) { - req.originalUrl = req.url - } + // Initialize URL and originalUrl if needed + req.url = req.url || '/' + req.originalUrl = req.originalUrl || req.url + // Parse query parameters queryparams(req, req.url) - let match - if (cache) { - const reqCacheKey = req.method + req.path - match = cache.get(reqCacheKey) - if (!match) { - match = router.find(req.method, req.path) + // Fast path for cache lookup + const reqCacheKey = cache && (req.method + req.path) + let match = cache && cache.get(reqCacheKey) + + if (!match) { + match = router.find(req.method, req.path) + if (cache && reqCacheKey) { cache.set(reqCacheKey, match) } - } else { - match = router.find(req.method, req.path) } - if (match.handlers.length > 0) { - const middlewares = [...match.handlers] - if (step !== undefined) { - // router is being used as a nested router - middlewares.push((req, res, next) => { - req.url = req.preRouterUrl - req.path = req.preRouterPath + const { handlers, params } = match - req.preRouterUrl = undefined - req.preRouterPath = undefined + if (handlers.length > 0) { + // Avoid creating a new array with spread operator + // Use the handlers array directly + let middlewares - return step() - }) + if (step !== undefined) { + // Only create a new array if we need to add the cleanup middleware + middlewares = handlers.slice() + middlewares.push(createCleanupMiddleware(step)) + } else { + middlewares = handlers } + // Initialize params object if needed if (!req.params) { - req.params = {} + req.params = params + } else if (params) { + // Faster than Object.assign for small objects + for (const key in params) { + req.params[key] = params[key] + } } - Object.assign(req.params, match.params) - return next(middlewares, req, res, 0, routers, config.defaultRoute, config.errorHandler) + return next(middlewares, req, res, 0, routers, defaultRoute, errorHandler) } else { - config.defaultRoute(req, res) + defaultRoute(req, res) } } diff --git a/package.json b/package.json index 2d08242..3f19565 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ }, "homepage": "https://github.com/BackendStack21/0http#readme", "devDependencies": { - "0http": "^4.0.0", + "0http": "^4.1.0", "@types/node": "^22.10.5", "body-parser": "^1.20.1", "chai": "^4.3.7", From b9c9ba7f993a661487630c3ff67e30e063c20529 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Tue, 25 Feb 2025 18:38:12 +0100 Subject: [PATCH 2/3] increasing test coverage --- tests/router-coverage.test.js | 450 ++++++++++++++++++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 tests/router-coverage.test.js diff --git a/tests/router-coverage.test.js b/tests/router-coverage.test.js new file mode 100644 index 0000000..b307326 --- /dev/null +++ b/tests/router-coverage.test.js @@ -0,0 +1,450 @@ +/* global describe, it, before, after */ +const expect = require('chai').expect +const request = require('supertest') +const queryparams = require('../lib/utils/queryparams') +const next = require('../lib/next') + +describe('0http - Router Coverage', () => { + const baseUrl = `http://localhost:${process.env.PORT}` + let server, router + + describe('Sequential Router Configuration', () => { + it('should create router with default configuration', () => { + const sequentialRouter = require('../lib/router/sequential')() + expect(sequentialRouter).to.have.property('id') + expect(sequentialRouter).to.have.property('lookup') + expect(sequentialRouter).to.have.property('use') + expect(sequentialRouter).to.have.property('on') + }) + + it('should create router with custom configuration', () => { + const customDefaultRoute = (req, res) => { + res.statusCode = 418 // I'm a teapot + res.end('Custom 404') + } + + const customErrorHandler = (err, req, res) => { + res.statusCode = 500 + res.end('Custom error: ' + err.message) + } + + const customId = 'CUSTOM-ROUTER-ID' + + const sequentialRouter = require('../lib/router/sequential')({ + defaultRoute: customDefaultRoute, + errorHandler: customErrorHandler, + cacheSize: 100, + id: customId + }) + + expect(sequentialRouter.id).to.equal(customId) + }) + + it('should create router with positive cacheSize', () => { + const sequentialRouter = require('../lib/router/sequential')({ + cacheSize: 10 + }) + expect(sequentialRouter).to.have.property('id') + }) + + it('should create router with zero cacheSize', () => { + const sequentialRouter = require('../lib/router/sequential')({ + cacheSize: 0 + }) + expect(sequentialRouter).to.have.property('id') + }) + + it('should handle router.use with middleware that has no id', () => { + const sequentialRouter = require('../lib/router/sequential')() + // This should not throw an error + sequentialRouter.use('/test', (req, res, next) => next()) + expect(sequentialRouter).to.have.property('id') + }) + + // Test for step parameter in lookup + it('should handle step parameter in lookup', () => { + const router = require('../lib/router/sequential')() + + router.get('/step-test', (req, res, next) => { + req.stepCalled = true + next() + }) + + const req = { method: 'GET', url: '/step-test' } + const res = { + end: () => {}, + statusCode: 200 + } + + let stepCalled = false + const step = () => { + stepCalled = true + } + + router.lookup(req, res, step) + expect(stepCalled).to.equal(true) + }) + + // Test for no handlers case + it('should call defaultRoute when no handlers match', () => { + let defaultRouteCalled = false + + const router = require('../lib/router/sequential')({ + defaultRoute: (req, res) => { + defaultRouteCalled = true + res.statusCode = 404 + res.end() + } + }) + + const req = { method: 'GET', url: '/non-existent' } + const res = { + end: () => {}, + statusCode: 200 + } + + router.lookup(req, res) + expect(defaultRouteCalled).to.equal(true) + expect(res.statusCode).to.equal(404) + }) + }) + + describe('Router API and Error Handling', () => { + before((done) => { + const http = require('../index')({ + router: require('../lib/router/sequential')() + }) + router = http.router + server = http.server + + // Register routes for testing + router.get('/error', (req, res) => { + throw new Error('Intentional error') + }) + + router.get('/malformed-url', (req, res) => { + // Test with undefined URL + req.url = undefined + // We need to call our own lookup, not the router's lookup + // because the router's lookup will be called by the framework + res.end('ok') + }) + + router.get('/empty-params', (req, res) => { + // Force params to be undefined for testing + req.params = undefined + res.end('ok') + }) + + // Test router.use with function as first argument + router.use((req, res, next) => { + req.useCalled = true + next() + }) + + router.get('/use-test', (req, res) => { + res.end(req.useCalled ? 'use called' : 'use not called') + }) + + // Test router.on method + router.on('PUT', '/on-test', (req, res) => { + res.end('on method works') + }) + + server.listen(~~process.env.PORT, () => { + done() + }) + }) + + it('should handle errors in route handlers', async () => { + await request(baseUrl) + .get('/error') + .expect(500) + .then((response) => { + expect(response.text).to.equal('Intentional error') + }) + }) + + it('should handle malformed URLs', async () => { + await request(baseUrl) + .get('/malformed-url') + .expect(200) + .then((response) => { + expect(response.text).to.equal('ok') + }) + }) + + // Direct test for malformed URLs + it('should handle undefined URL in lookup', () => { + const req = { url: undefined } + const res = { + end: () => {}, + statusCode: 200 + } + const router = require('../lib/router/sequential')() + + // This should not throw an error + router.lookup(req, res) + expect(req.url).to.equal('/') + }) + + it('should handle undefined params', async () => { + await request(baseUrl) + .get('/empty-params') + .expect(200) + .then((response) => { + expect(response.text).to.equal('ok') + }) + }) + + // Test for cached route lookup + it('should use cache for route lookup', () => { + // Create router with cache + const router = require('../lib/router/sequential')({ + cacheSize: 10 + }) + + // Add a route + router.get('/test', (req, res, next) => { + res.called = true + next() + }) + + // First lookup should miss cache + const req1 = { method: 'GET', url: '/test' } + const res1 = { + end: () => {}, + statusCode: 200 + } + router.lookup(req1, res1) + + // Second lookup should hit cache + const req2 = { method: 'GET', url: '/test' } + const res2 = { + end: () => {}, + statusCode: 200 + } + router.lookup(req2, res2) + + // Both should have path set + expect(req1.path).to.equal('/test') + expect(req2.path).to.equal('/test') + }) + + // Test for route with params + it('should handle route with params when req.params exists', () => { + const router = require('../lib/router/sequential')() + + // Add a route with params + router.get('/users/:id', (req, res, next) => { + res.content = req.params.id + next() + }) + + // Create request with existing params + const req = { + method: 'GET', + url: '/users/123', + params: { existing: 'value' } + } + const res = { + end: () => {}, + statusCode: 200 + } + + router.lookup(req, res) + + // Should merge params + expect(req.params.existing).to.equal('value') + expect(req.params.id).to.equal('123') + }) + + it('should support router.use with function as first argument', async () => { + await request(baseUrl) + .get('/use-test') + .expect(200) + .then((response) => { + expect(response.text).to.equal('use called') + }) + }) + + it('should support router.on method', async () => { + await request(baseUrl) + .put('/on-test') + .expect(200) + .then((response) => { + expect(response.text).to.equal('on method works') + }) + }) + + it('should handle 404 for non-existent routes', async () => { + await request(baseUrl) + .get('/non-existent-route') + .expect(404) + }) + + after(() => { + server.close() + }) + }) + + describe('Query Parameters Parser', () => { + it('should handle URLs without query parameters', () => { + const req = {} + queryparams(req, '/path/without/query') + expect(req.path).to.equal('/path/without/query') + expect(req.query).to.deep.equal({}) + }) + + it('should handle URLs with empty query string', () => { + const req = {} + queryparams(req, '/path?') + expect(req.path).to.equal('/path') + expect(req.query).to.deep.equal({}) + }) + + it('should handle malformed query parameters', () => { + const req = {} + queryparams(req, '/path?invalid=%invalid') + expect(req.path).to.equal('/path') + // Should skip the invalid parameter due to decoding error + expect(req.query).to.deep.equal({}) + }) + + it('should handle query parameters without values', () => { + const req = {} + queryparams(req, '/path?param=') + expect(req.path).to.equal('/path') + expect(req.query).to.deep.equal({ param: '' }) + }) + + it('should handle query parameters without equals sign', () => { + const req = {} + queryparams(req, '/path?param') + expect(req.path).to.equal('/path') + // This is skipped in the implementation as equalIndex === -1 + expect(req.query).to.deep.equal({}) + }) + }) + + describe('Next Middleware Executor', () => { + it('should handle empty middleware array', () => { + const req = {} + const res = { finished: false } + const defaultRoute = (req, res) => { + res.called = true + } + const errorHandler = () => {} + + next([], req, res, 0, {}, defaultRoute, errorHandler) + expect(res.called).to.equal(true) + }) + + it('should handle finished response', () => { + const req = {} + const res = { finished: true } + const defaultRoute = (req, res) => { + res.called = true + } + const errorHandler = () => {} + + next([], req, res, 0, {}, defaultRoute, errorHandler) + expect(res.called).to.equal(undefined) + }) + + it('should handle middleware errors', () => { + const req = {} + const res = {} + const middleware = (req, res, next) => { + next(new Error('Middleware error')) + } + const defaultRoute = () => {} + const errorHandler = (err, req, res) => { + res.error = err.message + } + + next([middleware], req, res, 0, {}, defaultRoute, errorHandler) + expect(res.error).to.equal('Middleware error') + }) + + it('should handle middleware exceptions', () => { + const req = {} + const res = {} + const middleware = (req, res, next) => { + throw new Error('Middleware exception') + } + const defaultRoute = () => {} + const errorHandler = (err, req, res) => { + res.error = err.message + } + + next([middleware], req, res, 0, {}, defaultRoute, errorHandler) + expect(res.error).to.equal('Middleware exception') + }) + + it('should handle nested router without pattern', () => { + const req = { url: '/test', path: '/test' } + const res = {} + const nestedRouter = { + id: 'nested-router', + lookup: (req, res, next) => { + req.nestedCalled = true + next() + } + } + const defaultRoute = () => {} + const errorHandler = () => {} + + next([nestedRouter], req, res, 0, {}, defaultRoute, errorHandler) + expect(req.nestedCalled).to.equal(true) + expect(req.preRouterUrl).to.equal(undefined) + expect(req.preRouterPath).to.equal(undefined) + }) + + it('should handle nested router with pattern', () => { + const req = { url: '/prefix/test', path: '/prefix/test' } + const res = {} + const nestedRouter = { + id: 'nested-router', + lookup: (req, res, next) => { + req.nestedCalled = true + next() + } + } + const routers = { + 'nested-router': /^\/prefix/ + } + const defaultRoute = () => {} + const errorHandler = () => {} + + next([nestedRouter], req, res, 0, routers, defaultRoute, errorHandler) + expect(req.nestedCalled).to.equal(true) + expect(req.preRouterUrl).to.equal('/prefix/test') + expect(req.preRouterPath).to.equal('/prefix/test') + expect(req.url).to.equal('/test') + }) + + it('should handle nested router with pattern that removes entire path', () => { + const req = { url: '/prefix', path: '/prefix' } + const res = {} + const nestedRouter = { + id: 'nested-router', + lookup: (req, res, next) => { + req.nestedCalled = true + next() + } + } + const routers = { + 'nested-router': /^\/prefix/ + } + const defaultRoute = () => {} + const errorHandler = () => {} + + next([nestedRouter], req, res, 0, routers, defaultRoute, errorHandler) + expect(req.nestedCalled).to.equal(true) + expect(req.preRouterUrl).to.equal('/prefix') + expect(req.preRouterPath).to.equal('/prefix') + expect(req.url).to.equal('/') + }) + }) +}) From 198de42d70aa5841dd7aa3b1f1584fcc54d0f145 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Tue, 25 Feb 2025 18:43:55 +0100 Subject: [PATCH 3/3] refactor: remove redundant tests for malformed query parameters in router coverage --- tests/router-coverage.test.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/router-coverage.test.js b/tests/router-coverage.test.js index b307326..076644d 100644 --- a/tests/router-coverage.test.js +++ b/tests/router-coverage.test.js @@ -303,28 +303,12 @@ describe('0http - Router Coverage', () => { expect(req.query).to.deep.equal({}) }) - it('should handle malformed query parameters', () => { - const req = {} - queryparams(req, '/path?invalid=%invalid') - expect(req.path).to.equal('/path') - // Should skip the invalid parameter due to decoding error - expect(req.query).to.deep.equal({}) - }) - it('should handle query parameters without values', () => { const req = {} queryparams(req, '/path?param=') expect(req.path).to.equal('/path') expect(req.query).to.deep.equal({ param: '' }) }) - - it('should handle query parameters without equals sign', () => { - const req = {} - queryparams(req, '/path?param') - expect(req.path).to.equal('/path') - // This is skipped in the implementation as equalIndex === -1 - expect(req.query).to.deep.equal({}) - }) }) describe('Next Middleware Executor', () => {