From cac2c9392371a5fd815e230a13f2eaea7e4d3a35 Mon Sep 17 00:00:00 2001 From: francesco Date: Thu, 16 Jul 2026 14:46:34 +0200 Subject: [PATCH 1/7] feat: full support for v5 --- src/application.js | 9 +++ src/middlewares.js | 9 ++- src/request.js | 33 +++++++- src/response.js | 16 +++- src/router.js | 92 +++++++++++++++++++---- src/utils.js | 183 ++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 322 insertions(+), 20 deletions(-) diff --git a/src/application.js b/src/application.js index e0a900a0..b1c83f3f 100644 --- a/src/application.js +++ b/src/application.js @@ -54,6 +54,12 @@ class FSWorker { class Application extends Router { constructor(settings = new NullObject()) { super(settings); + if(typeof settings.version !== 'undefined') { + if(settings.version !== 4 && settings.version !== 5) { + throw new Error('version must be 4 or 5'); + } + this.settings['version'] = settings.version; + } if(!settings?.uwsOptions) { settings.uwsOptions = {}; } @@ -225,6 +231,9 @@ class Application extends Router { if(!socket) { let err = new Error('listen EADDRINUSE: address already in use :::' + port); err.code = 'EADDRINUSE'; + if(this.isV5() && callback) { + return callback(err); + } throw err; } this.port = uWS.us_socket_local_port(socket); diff --git a/src/middlewares.js b/src/middlewares.js index 36ed48e2..60240e07 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -28,7 +28,7 @@ function static(root, options) { if(typeof options.index === 'undefined') options.index = 'index.html'; if(typeof options.redirect === 'undefined') options.redirect = true; if(typeof options.fallthrough === 'undefined') options.fallthrough = true; - if(typeof options.dotfiles === 'undefined') options.dotfiles = 'ignore_files'; + if(typeof options.dotfiles === 'undefined') options.dotfiles = null; if(options.extensions) { if(typeof options.extensions !== 'string' && !Array.isArray(options.extensions)) { throw new Error('extensions must be a string or an array'); @@ -117,6 +117,10 @@ function static(root, options) { options._stat = stat; + if(options.dotfiles === null) { + options.dotfiles = req.app.isV5() ? 'ignore' : 'ignore_files'; + } + return res.sendFile(_path, options, e => { if(e) { next(!options.fallthrough ? e : undefined); @@ -331,7 +335,8 @@ const text = createBodyParser('text/plain', function(req, res, next, options, bu const urlencoded = createBodyParser('application/x-www-form-urlencoded', function(req, res, next, options, buf) { try { - if(options.extended) { + const extended = typeof options.extended !== 'undefined' ? options.extended : !req.app.isV5(); + if(extended) { req.body = fastQueryParse(buf.toString(), options); } else { req.body = querystring.parse(buf.toString()); diff --git a/src/request.js b/src/request.js index 3335367d..bfadccfa 100644 --- a/src/request.js +++ b/src/request.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -const { patternToRegex, deprecated, NullObject } = require("./utils.js"); +const { patternToRegex, patternToRegexV5, deprecated, NullObject } = require("./utils.js"); const accepts = require("accepts"); const typeis = require("type-is"); const parseRange = require("range-parser"); @@ -134,7 +134,8 @@ module.exports = class Request extends Readable { } get baseUrl() { - let match = this._originalPath.match(patternToRegex(this._stack.join(""), true)); + const _toRegex = this.app.isV5() ? patternToRegexV5 : patternToRegex; + let match = this._originalPath.match(_toRegex(this._stack.join(""), true)); return match ? match[0] : ''; } @@ -168,7 +169,29 @@ module.exports = class Request extends Readable { return portIndex !== -1 ? host.substring(0, portIndex) : host; } + get #hostWithPort() { + const trust = this.app.get('trust proxy fn'); + const isTrusted = !!(trust && trust(this.connection.remoteAddress, 0)); + const rawHeader = (isTrusted && this.headers['x-forwarded-host']) || this.headers['host']; + let host = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; + + if (typeof host !== 'string' || !host) return; + host = host.trim(); + + if (isTrusted) { + const commaIndex = host.indexOf(','); + if (commaIndex !== -1) { + host = host.substring(0, commaIndex).trimEnd(); + } + } + + return host || undefined; + } + get host() { + if(this.app.isV5()) { + return this.#hostWithPort; + } deprecated('req.host', 'req.hostname'); return this.hostname; } @@ -223,6 +246,9 @@ module.exports = class Request extends Readable { } set query(query) { + if(this.app.isV5()) { + throw new Error('req.query is read-only in Express 5. Use a custom query parser middleware instead.'); + } this.#cachedQuery = query; } get query() { @@ -396,6 +422,9 @@ module.exports = class Request extends Readable { } param(name, defaultValue) { + if(this.app.isV5()) { + throw new Error('req.param() has been removed. Use req.params, req.body, or req.query instead.'); + } deprecated('req.param(name)', 'req.params, req.body, or req.query'); if(name == null) return defaultValue; diff --git a/src/response.js b/src/response.js index f8798ec9..4174b86a 100644 --- a/src/response.js +++ b/src/response.js @@ -243,7 +243,15 @@ module.exports = class Response extends Writable { this.writeHead(this.statusCode); } status(code) { - this.statusCode = parseInt(code); + if(this.app.isV5()) { + const statusCode = parseInt(code); + if(!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 999) { + throw new RangeError(`Invalid status code: ${code}`); + } + this.statusCode = statusCode; + } else { + this.statusCode = parseInt(code); + } return this; } sendStatus(code) { @@ -783,6 +791,9 @@ module.exports = class Response extends Writable { } location(path) { if(path === 'back') { + if(this.app.isV5()) { + throw new Error('res.location("back") is no longer supported in Express 5. Use req.get("Referrer") || "/" instead.'); + } path = this.req.get('Referrer'); if(!path) path = this.req.get('Referer'); if(!path) path = '/'; @@ -849,6 +860,9 @@ module.exports = class Response extends Writable { vary(field) { // checks for back-compat if (!field || (Array.isArray(field) && !field.length)) { + if(this.app.isV5()) { + throw new Error('field argument is required for res.vary()'); + } deprecated('res.vary(): Provide a field name'); return this; } diff --git a/src/router.js b/src/router.js index 2eb109c2..9fa14bc7 100644 --- a/src/router.js +++ b/src/router.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -const { patternToRegex, needsConversionToRegex, deprecated, findIndexStartingFrom, canBeOptimized, NullObject, EMPTY_REGEX } = require("./utils.js"); +const { patternToRegex, patternToRegexV5, needsConversionToRegex, needsConversionToRegexV5, deprecated, findIndexStartingFrom, canBeOptimized, canBeOptimizedV5, NullObject, EMPTY_REGEX } = require("./utils.js"); const Response = require("./response.js"); const Request = require("./request.js"); const { EventEmitter } = require("tseep"); @@ -103,10 +103,17 @@ module.exports = class Router extends EventEmitter { } del(path, ...callbacks) { + if(this.isV5()) { + throw new Error('app.del() has been removed. Use app.delete() instead.'); + } deprecated('app.del', 'app.delete'); return this.createRoute('DELETE', path, this, ...callbacks); } + isV5() { + return this.get('version') >= 5; + } + getFullMountpath(req) { if(!req._stack.length) { return EMPTY_REGEX; @@ -114,7 +121,8 @@ module.exports = class Router extends EventEmitter { const fullStack = req._stack.join(""); let fullMountpath = this._mountpathCache.get(fullStack); if(!fullMountpath) { - fullMountpath = patternToRegex(fullStack, true); + const _toRegex = this.isV5() ? patternToRegexV5 : patternToRegex; + fullMountpath = _toRegex(fullStack, true); this._mountpathCache.set(fullStack, fullMountpath); } return fullMountpath; @@ -152,29 +160,40 @@ module.exports = class Router extends EventEmitter { callbacks = callbacks.flat(); const paths = Array.isArray(path) ? path : [path]; const routes = []; + const v5 = this.isV5(); + const _needsConversion = v5 ? needsConversionToRegexV5 : needsConversionToRegex; + const _toRegex = v5 ? patternToRegexV5 : patternToRegex; + const _canBeOptimized = v5 ? canBeOptimizedV5 : canBeOptimized; for(let path of paths) { if(!this.get('strict routing') && typeof path === 'string' && path.endsWith('/') && path !== '/') { path = path.slice(0, -1); } if(path === '*') { - path = '/*'; + path = v5 ? '/{*splat}' : '/*'; } const route = { method: method === 'USE' ? 'ALL' : method, path, - pattern: method === 'USE' || needsConversionToRegex(path) ? patternToRegex(path, method === 'USE') : path, + pattern: method === 'USE' || _needsConversion(path) ? _toRegex(path, method === 'USE') : path, callbacks, routeKey: routeKey++, use: method === 'USE', all: method === 'ALL' || method === 'USE', gettable: method === 'GET' || method === 'HEAD', + v5, }; - if(typeof route.path === 'string' && (route.path.includes(':') || route.path.includes('*') || (route.path.includes('(') && route.path.includes(')'))) && route.pattern instanceof RegExp) { - route.complex = true; + if(v5) { + if(typeof route.path === 'string' && (route.path.includes(':') || route.path.includes('*') || route.path.includes('{')) && route.pattern instanceof RegExp) { + route.complex = true; + } + } else { + if(typeof route.path === 'string' && (route.path.includes(':') || route.path.includes('*') || (route.path.includes('(') && route.path.includes(')'))) && route.pattern instanceof RegExp) { + route.complex = true; + } } routes.push(route); // normal routes optimization - if(!route.complex && canBeOptimized(route.path) && !this.parent && this.get('case sensitive routing') && this.uwsApp) { + if(!route.complex && _canBeOptimized(route.path) && !this.parent && this.get('case sensitive routing') && this.uwsApp) { if(supportedUwsMethods.has(method)) { const optimizedPath = this._optimizeRoute(route, this._routes); if(optimizedPath) { @@ -184,7 +203,7 @@ module.exports = class Router extends EventEmitter { } // router optimization if( - route.use && !needsConversionToRegex(path) && path !== '/*' && // must be predictable path + route.use && !_needsConversion(path) && path !== '/*' && // must be predictable path this.get('case sensitive routing') && // uWS only supports case sensitive routing callbacks.filter(c => c instanceof Router).length === 1 && // only 1 router can be optimized per route callbacks[callbacks.length - 1] instanceof Router // the router must be the last callback @@ -391,20 +410,54 @@ module.exports = class Router extends EventEmitter { const obj = new NullObject(); if(match?.groups) { for(let name in match.groups) { + const value = match.groups[name]; + if(value === undefined) { + // In v5, unmatched params are omitted entirely + continue; + } if(name.startsWith('_wc')) { - obj[name.slice(3)] = match.groups[name]; + obj[name.slice(3)] = value; } else { - obj[name] = match.groups[name]; + obj[name] = value; } } } return obj; } + /** + * Express 5 variant: params have null prototype and wildcards are arrays. + */ + _extractParamsV5(pattern, path) { + if(path.endsWith('/')) { + path = path.slice(0, -1); + } + let match = pattern.exec(path); + const obj = new NullObject(); + if(match?.groups) { + for(let name in match.groups) { + const value = match.groups[name]; + if(value === undefined) { + // In v5, unmatched optional params are omitted + continue; + } + obj[name] = value; + } + } + return obj; + } + _preprocessRequest(req, res, route) { req.route = route; + const v5 = route.v5; if(route.optimizedParams) { - req.params = {...req.optimizedParams}; + if(v5) { + const obj = new NullObject(); + Object.assign(obj, req.optimizedParams); + req.params = obj; + } else { + req.params = {...req.optimizedParams}; + } } else if(route.complex) { let path = req._originalPath; if(req._stack.length > 0) { @@ -413,14 +466,22 @@ module.exports = class Router extends EventEmitter { path = path.replace(fullMountpath, ''); } } - req.params = {...this._extractParams(route.pattern, path)}; + if(v5) { + req.params = this._extractParamsV5(route.pattern, path); + } else { + req.params = {...this._extractParams(route.pattern, path)}; + } if(req._paramStack.length > 0) { for(let params of req._paramStack) { req.params = {...params, ...req.params}; } } } else { - req.params = {}; + if(v5) { + req.params = new NullObject(); + } else { + req.params = {}; + } if(req._paramStack.length > 0) { for(let params of req._paramStack) { req.params = {...params, ...req.params}; @@ -463,6 +524,9 @@ module.exports = class Router extends EventEmitter { param(name, fn) { if(typeof name === 'function') { + if(this.isV5()) { + throw new Error('app.param(fn) has been removed. Use app.param(name, fn) instead.'); + } deprecated('app.param(callback)', 'app.param(name, callback)', true); this._paramFunction = name; } else { @@ -605,7 +669,7 @@ module.exports = class Router extends EventEmitter { const out = callback(req, res, next); if(out instanceof Promise) { out.catch(err => { - if(this.get("catch async errors")) { + if(this.isV5() || this.get("catch async errors")) { req._error = err; req._errorKey = route.routeKey; return next(); diff --git a/src/utils.js b/src/utils.js index 4decfa95..f67ca598 100644 --- a/src/utils.js +++ b/src/utils.js @@ -75,6 +75,159 @@ function patternToRegex(pattern, isPrefix = false) { return new RegExp(`^${regexPattern}${isPrefix ? '(?=$|\/)' : '$'}`); } +/** + * Express 5 path-to-regexp v8 style parser. + * Supports: + * - /:param (named parameters) + * - /*splat (named wildcard, captures remaining segments as array-ready) + * - /{*splat} (named wildcard that also matches root /) + * - /:file{.:ext} (optional groups with braces) + * - Escaped special chars with backslash + * Does NOT support: + * - Unnamed wildcards (/* without name) + * - Regex chars in path (+, (), [], ?) + */ +function patternToRegexV5(pattern, isPrefix = false) { + if(pattern instanceof RegExp) { + return pattern; + } + if(isPrefix && pattern === '') { + return EMPTY_REGEX; + } + + let regexPattern = ''; + let i = 0; + const len = pattern.length; + + while(i < len) { + const ch = pattern[i]; + + // Escaped character + if(ch === '\\' && i + 1 < len) { + regexPattern += '\\' + pattern[i + 1]; + i += 2; + continue; + } + + // Named wildcard: /*name or /{*name} + if(ch === '/' && i + 1 < len && pattern[i + 1] === '*') { + // /*splat + i += 2; // skip /* + let name = ''; + while(i < len && /\w/.test(pattern[i])) { + name += pattern[i++]; + } + if(!name) { + throw new Error('Wildcard must have a name in Express 5 path syntax. Use /*splat instead of /*'); + } + // /*splat matches one or more segments after the slash + regexPattern += `/(?<${name}>.+)`; + continue; + } + + if(ch === '{') { + // Check for {*name} (root-matching wildcard) + if(pattern[i + 1] === '*') { + i += 2; // skip {* + let name = ''; + while(i < len && pattern[i] !== '}') { + name += pattern[i++]; + } + i++; // skip } + if(!name) { + throw new Error('Wildcard must have a name in Express 5 path syntax. Use {*splat}'); + } + // {*splat} matches zero or more path segments + // If preceded by /, the slash is already in regexPattern, so match optionally + if(regexPattern.endsWith('/') || regexPattern.endsWith('\\/')) { + // Remove trailing slash from pattern and make the whole /... optional + regexPattern = regexPattern.slice(0, regexPattern.endsWith('\\/') ? -2 : -1); + regexPattern += `(?:/(?<${name}>.+))?/?`; + } else { + regexPattern += `(?<${name}>.*)`; + } + continue; + } + + // Optional group: {.:ext} or {/subpath} + i++; // skip { + let groupContent = ''; + let braceDepth = 1; + while(i < len && braceDepth > 0) { + if(pattern[i] === '{') braceDepth++; + else if(pattern[i] === '}') { + braceDepth--; + if(braceDepth === 0) break; + } + groupContent += pattern[i++]; + } + i++; // skip closing } + + // Parse the optional group content (may contain :param) + let groupRegex = ''; + let gi = 0; + while(gi < groupContent.length) { + if(groupContent[gi] === ':') { + gi++; // skip : + let paramName = ''; + while(gi < groupContent.length && /\w/.test(groupContent[gi])) { + paramName += groupContent[gi++]; + } + groupRegex += `(?<${paramName}>[^/]+)`; + } else if(groupContent[gi] === '.') { + groupRegex += '\\.'; + gi++; + } else if(groupContent[gi] === '/') { + groupRegex += '/'; + gi++; + } else { + groupRegex += groupContent[gi].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + gi++; + } + } + regexPattern += `(?:${groupRegex})?`; + continue; + } + + // Named parameter :param + if(ch === ':') { + i++; // skip : + let name = ''; + while(i < len && /\w/.test(pattern[i])) { + name += pattern[i++]; + } + // Look ahead: if followed by {, adjust the param regex to not consume the group delimiter + if(i < len && pattern[i] === '{') { + // peek at the first char of the group to determine the delimiter + const delimiter = pattern[i + 1]; + if(delimiter === '.') { + regexPattern += `(?<${name}>[^/.]+)`; + } else if(delimiter === '/') { + regexPattern += `(?<${name}>[^/]+)`; + } else { + regexPattern += `(?<${name}>[^/]+?)`; + } + } else { + regexPattern += `(?<${name}>[^/]+)`; + } + continue; + } + + // Literal characters (escape regex special chars) + if('.+?^${}()|[]'.includes(ch)) { + regexPattern += '\\' + ch; + } else if(ch === '*') { + // Bare * without name — error in v5 + throw new Error('Wildcard must have a name in Express 5 path syntax. Use /*splat instead of /*'); + } else { + regexPattern += ch; + } + i++; + } + + return new RegExp(`^${regexPattern}${isPrefix ? '(?=$|/)' : '$'}`); +} + function needsConversionToRegex(pattern) { if(pattern instanceof RegExp) { return false; @@ -92,6 +245,16 @@ function needsConversionToRegex(pattern) { pattern.includes(']'); } +function needsConversionToRegexV5(pattern) { + if(pattern instanceof RegExp) { + return false; + } + + return pattern.includes('*') || + pattern.includes(':') || + pattern.includes('{'); +} + function canBeOptimized(pattern) { if(pattern === '/*') { return false; @@ -115,6 +278,20 @@ function canBeOptimized(pattern) { return true; } +function canBeOptimizedV5(pattern) { + if(pattern instanceof RegExp) { + return false; + } + if( + pattern.includes('*') || + pattern.includes('{') || + pattern.includes(':') + ) { + return false; + } + return true; +} + function acceptParams(str) { const length = str.length; const colonIndex = str.indexOf(';'); @@ -190,7 +367,8 @@ const defaultSettings = { 'view cache': () => process.env.NODE_ENV === 'production', 'x-powered-by': true, 'case sensitive routing': true, - 'declarative responses': true + 'declarative responses': true, + 'version': 4 }; function compileTrust(val) { @@ -404,7 +582,9 @@ NullObject.prototype = Object.create(null); module.exports = { removeDuplicateSlashes, patternToRegex, + patternToRegexV5, needsConversionToRegex, + needsConversionToRegexV5, acceptParams, normalizeType, stringify, @@ -423,6 +603,7 @@ module.exports = { findIndexStartingFrom, fastQueryParse, canBeOptimized, + canBeOptimizedV5, escapeHtml, EMPTY_REGEX }; From a42a4869b7583f6e8aaa9e500cd23207b90f7c6c Mon Sep 17 00:00:00 2001 From: francesco Date: Thu, 16 Jul 2026 15:01:34 +0200 Subject: [PATCH 2/7] docs: update --- README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cde15d39..505a067f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The *Ultimate* Express. Fastest http server with **full** Express compatibility, based on µWebSockets. -This library is a very fast re-implementation of Express.js 4. +This library is a very fast re-implementation of Express.js that supports both **Express 4** and **Express 5** behavior. It is designed to be a drop-in replacement for Express.js, with the same API and functionality, while being much faster. It is not a fork of Express.js. To make sure µExpress matches behavior of Express in all cases, we run all tests with Express first, and then with µExpress and compare results to make sure they match. @@ -92,6 +92,20 @@ app.listen(3000, () => { - This also applies to non-SSL HTTP too. Do not create http server manually, use `app.listen()` instead. - Node.JS max header size is 16384 bytes, while uWebSockets by default is 4096 bytes, so if you need longer headers set the env variable `UWS_HTTP_MAX_HEADERS_SIZE` to max byte count you need. +## Express 5 mode + +µExpress supports both Express 4 and Express 5 behavior in the same package. By default, it behaves like Express 4. To enable Express 5 behavior, pass `version: 5` to the constructor: + +```js +const express = require("ultimate-express"); + +// Express 4 behavior (default) +const app = express(); + +// Express 5 behavior +const app = express({ version: 5 }); +``` + ## Performance tips 1. µExpress tries to optimize routing as much as possible, but it's only possible if: @@ -132,7 +146,7 @@ const app = express({ ## Compatibility -In general, basically all features and options are supported. Use [Express 4.x documentation](https://expressjs.com/en/4x/api.html) for API reference. +In general, basically all features and options are supported. Use [Express 4.x documentation](https://expressjs.com/en/4x/api.html) for API reference (default mode), or [Express 5.x migration guide](https://expressjs.com/en/guide/migrating-5) for v5 mode differences. ✅ - Full support (all features and options are supported) 🚧 - Partial support (some options are not supported) @@ -323,7 +337,7 @@ Almost all middlewares that are compatible with Express are compatible with µEx Middlewares and modules that are confirmed to not work: -- ❌ [express-async-errors](https://npmjs.com/package/express-async-errors) - doesn't work, use `app.set('catch async errors', true)` instead. +- ❌ [express-async-errors](https://npmjs.com/package/express-async-errors) - doesn't work, use `app.set('catch async errors', true)` instead, or use `version: 5` which handles async errors natively. ## Tested view engines From f613e86cd7986b592530e8e102389e3e8d8c5eed Mon Sep 17 00:00:00 2001 From: francesco Date: Thu, 16 Jul 2026 15:15:57 +0200 Subject: [PATCH 3/7] feat(test): add v5 test --- src/router.js | 7 +++- src/utils.js | 21 +++++------- tests/tests/v5/app-listen-error.js | 26 ++++++++++++++ tests/tests/v5/async-errors-native.js | 31 +++++++++++++++++ tests/tests/v5/path-matching-optional.js | 23 +++++++++++++ tests/tests/v5/path-matching-splat.js | 27 +++++++++++++++ tests/tests/v5/req-host-port.js | 26 ++++++++++++++ tests/tests/v5/req-params-prototype.js | 25 ++++++++++++++ tests/tests/v5/res-status-validation.js | 43 ++++++++++++++++++++++++ tests/tests/v5/urlencoded-default.js | 33 ++++++++++++++++++ 10 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 tests/tests/v5/app-listen-error.js create mode 100644 tests/tests/v5/async-errors-native.js create mode 100644 tests/tests/v5/path-matching-optional.js create mode 100644 tests/tests/v5/path-matching-splat.js create mode 100644 tests/tests/v5/req-host-port.js create mode 100644 tests/tests/v5/req-params-prototype.js create mode 100644 tests/tests/v5/res-status-validation.js create mode 100644 tests/tests/v5/urlencoded-default.js diff --git a/src/router.js b/src/router.js index 9fa14bc7..7ab2d03c 100644 --- a/src/router.js +++ b/src/router.js @@ -434,6 +434,7 @@ module.exports = class Router extends EventEmitter { } let match = pattern.exec(path); const obj = new NullObject(); + const wildcardNames = pattern._wildcardNames || []; if(match?.groups) { for(let name in match.groups) { const value = match.groups[name]; @@ -441,7 +442,11 @@ module.exports = class Router extends EventEmitter { // In v5, unmatched optional params are omitted continue; } - obj[name] = value; + if(wildcardNames.includes(name)) { + obj[name] = value.split('/'); + } else { + obj[name] = value; + } } } return obj; diff --git a/src/utils.js b/src/utils.js index f67ca598..47ba2ec5 100644 --- a/src/utils.js +++ b/src/utils.js @@ -98,6 +98,7 @@ function patternToRegexV5(pattern, isPrefix = false) { let regexPattern = ''; let i = 0; const len = pattern.length; + const wildcardNames = []; while(i < len) { const ch = pattern[i]; @@ -120,6 +121,7 @@ function patternToRegexV5(pattern, isPrefix = false) { if(!name) { throw new Error('Wildcard must have a name in Express 5 path syntax. Use /*splat instead of /*'); } + wildcardNames.push(name); // /*splat matches one or more segments after the slash regexPattern += `/(?<${name}>.+)`; continue; @@ -137,6 +139,7 @@ function patternToRegexV5(pattern, isPrefix = false) { if(!name) { throw new Error('Wildcard must have a name in Express 5 path syntax. Use {*splat}'); } + wildcardNames.push(name); // {*splat} matches zero or more path segments // If preceded by /, the slash is already in regexPattern, so match optionally if(regexPattern.endsWith('/') || regexPattern.endsWith('\\/')) { @@ -173,7 +176,7 @@ function patternToRegexV5(pattern, isPrefix = false) { while(gi < groupContent.length && /\w/.test(groupContent[gi])) { paramName += groupContent[gi++]; } - groupRegex += `(?<${paramName}>[^/]+)`; + groupRegex += `(?<${paramName}>[^/.]+)`; } else if(groupContent[gi] === '.') { groupRegex += '\\.'; gi++; @@ -196,17 +199,9 @@ function patternToRegexV5(pattern, isPrefix = false) { while(i < len && /\w/.test(pattern[i])) { name += pattern[i++]; } - // Look ahead: if followed by {, adjust the param regex to not consume the group delimiter + // Look ahead: if followed by {, make param non-greedy so the optional group can match if(i < len && pattern[i] === '{') { - // peek at the first char of the group to determine the delimiter - const delimiter = pattern[i + 1]; - if(delimiter === '.') { - regexPattern += `(?<${name}>[^/.]+)`; - } else if(delimiter === '/') { - regexPattern += `(?<${name}>[^/]+)`; - } else { - regexPattern += `(?<${name}>[^/]+?)`; - } + regexPattern += `(?<${name}>[^/]+?)`; } else { regexPattern += `(?<${name}>[^/]+)`; } @@ -225,7 +220,9 @@ function patternToRegexV5(pattern, isPrefix = false) { i++; } - return new RegExp(`^${regexPattern}${isPrefix ? '(?=$|/)' : '$'}`); + const regex = new RegExp(`^${regexPattern}${isPrefix ? '(?=$|/)' : '$'}`); + regex._wildcardNames = wildcardNames; + return regex; } function needsConversionToRegex(pattern) { diff --git a/tests/tests/v5/app-listen-error.js b/tests/tests/v5/app-listen-error.js new file mode 100644 index 00000000..89b59063 --- /dev/null +++ b/tests/tests/v5/app-listen-error.js @@ -0,0 +1,26 @@ +// v5 app.listen must pass errors to callback instead of throwing +// SKIP_V4: Express 5 changed app.listen error handling + +const express = require("express"); +const net = require("net"); + +// Occupy a port first +const blocker = net.createServer(); +blocker.listen(13334, () => { + const app = express({ version: 5 }); + + app.get('/test', (req, res) => { + res.send('ok'); + }); + + app.listen(13334, (err) => { + if (err) { + console.log('error code: ' + err.code); + } else { + console.log('no error'); + } + blocker.close(() => { + process.exit(0); + }); + }); +}); diff --git a/tests/tests/v5/async-errors-native.js b/tests/tests/v5/async-errors-native.js new file mode 100644 index 00000000..9d56a30a --- /dev/null +++ b/tests/tests/v5/async-errors-native.js @@ -0,0 +1,31 @@ +// v5 must handle async errors natively without express-async-errors +// SKIP_V4: Express 5 handles async errors natively + +const express = require("express"); + +const app = express({ version: 5 }); +app.set('env', 'production'); + +app.get('/test', async (req, res) => { + throw new Error('async error'); +}); + +app.get('/test2', async (req, res) => { + await Promise.reject(new Error('rejected promise')); +}); + +app.use((err, req, res, next) => { + res.status(500).send('caught: ' + err.message); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + const responses = await Promise.all([ + fetch('http://localhost:13333/test').then(res => res.text()), + fetch('http://localhost:13333/test2').then(res => res.text()), + ]); + + console.log(responses); + process.exit(0); +}); diff --git a/tests/tests/v5/path-matching-optional.js b/tests/tests/v5/path-matching-optional.js new file mode 100644 index 00000000..d4cc7b2f --- /dev/null +++ b/tests/tests/v5/path-matching-optional.js @@ -0,0 +1,23 @@ +// v5 must support optional groups with braces {:file{.:ext}} +// SKIP_V4: Express 5 path syntax + +const express = require("express"); + +const app = express({ version: 5 }); + +app.get('/files/:name{.:ext}', (req, res) => { + res.json({ name: req.params.name, ext: req.params.ext || 'none' }); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + const responses = await Promise.all([ + fetch('http://localhost:13333/files/readme.md').then(res => res.text()), + fetch('http://localhost:13333/files/readme').then(res => res.text()), + fetch('http://localhost:13333/files/archive.tar.gz').then(res => res.text()), + ]); + + console.log(responses); + process.exit(0); +}); diff --git a/tests/tests/v5/path-matching-splat.js b/tests/tests/v5/path-matching-splat.js new file mode 100644 index 00000000..fad27b7b --- /dev/null +++ b/tests/tests/v5/path-matching-splat.js @@ -0,0 +1,27 @@ +// v5 must support named wildcard /*splat path matching +// SKIP_V4: Express 5 path syntax + +const express = require("express"); + +const app = express({ version: 5 }); + +app.get('/files/*path', (req, res) => { + res.send('path:' + req.params.path); +}); + +app.get('/*catchall', (req, res) => { + res.send('catchall:' + req.params.catchall); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + const responses = await Promise.all([ + fetch('http://localhost:13333/files/docs/readme.md').then(res => res.text()), + fetch('http://localhost:13333/files/image.png').then(res => res.text()), + fetch('http://localhost:13333/other/page').then(res => res.text()), + ]); + + console.log(responses); + process.exit(0); +}); diff --git a/tests/tests/v5/req-host-port.js b/tests/tests/v5/req-host-port.js new file mode 100644 index 00000000..b2549a12 --- /dev/null +++ b/tests/tests/v5/req-host-port.js @@ -0,0 +1,26 @@ +// v5 req.host must include the port number +// SKIP_V4: Express 5 changed req.host to include port + +const express = require("express"); + +const app = express({ version: 5 }); + +app.get('/test', (req, res) => { + res.send(req.host || 'undefined'); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + const response = await fetch('http://localhost:13333/test', { + headers: { 'Host': 'example.com:8080' } + }).then(res => res.text()); + console.log(response); + + const response2 = await fetch('http://localhost:13333/test', { + headers: { 'Host': 'example.com' } + }).then(res => res.text()); + console.log(response2); + + process.exit(0); +}); diff --git a/tests/tests/v5/req-params-prototype.js b/tests/tests/v5/req-params-prototype.js new file mode 100644 index 00000000..8fda2c42 --- /dev/null +++ b/tests/tests/v5/req-params-prototype.js @@ -0,0 +1,25 @@ +// v5 req.params must have null prototype +// SKIP_V4: Express 5 uses null prototype for params + +const express = require("express"); + +const app = express({ version: 5 }); + +app.get('/users/:id', (req, res) => { + const hasToString = 'toString' in req.params; + const hasHasOwn = 'hasOwnProperty' in req.params; + res.json({ + id: req.params.id, + hasToString, + hasHasOwn + }); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + const response = await fetch('http://localhost:13333/users/42').then(res => res.text()); + console.log(response); + + process.exit(0); +}); diff --git a/tests/tests/v5/res-status-validation.js b/tests/tests/v5/res-status-validation.js new file mode 100644 index 00000000..5d5e1cd7 --- /dev/null +++ b/tests/tests/v5/res-status-validation.js @@ -0,0 +1,43 @@ +// v5 must validate res.status() accepts only integers 100-999 +// SKIP_V4: Express 5 validates status code range + +const express = require("express"); + +const app = express({ version: 5 }); + +app.get('/valid', (req, res) => { + res.status(201).send('ok'); +}); + +app.get('/invalid-low', (req, res) => { + try { + res.status(99); + res.send('should not reach'); + } catch (e) { + res.status(500).send('error: ' + e.constructor.name); + } +}); + +app.get('/invalid-high', (req, res) => { + try { + res.status(1000); + res.send('should not reach'); + } catch (e) { + res.status(500).send('error: ' + e.constructor.name); + } +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + const r1 = await fetch('http://localhost:13333/valid'); + console.log(r1.status, await r1.text()); + + const r2 = await fetch('http://localhost:13333/invalid-low'); + console.log(await r2.text()); + + const r3 = await fetch('http://localhost:13333/invalid-high'); + console.log(await r3.text()); + + process.exit(0); +}); diff --git a/tests/tests/v5/urlencoded-default.js b/tests/tests/v5/urlencoded-default.js new file mode 100644 index 00000000..f00187cd --- /dev/null +++ b/tests/tests/v5/urlencoded-default.js @@ -0,0 +1,33 @@ +// v5 express.urlencoded() must default to extended:false +// SKIP_V4: Express 5 changed urlencoded default extended to false + +const express = require("express"); + +const app = express({ version: 5 }); +app.use(express.urlencoded()); + +app.post('/test', (req, res) => { + res.json(req.body); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + // With extended:false, nested objects/arrays are not parsed + const response = await fetch('http://localhost:13333/test', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=test&value=123' + }).then(res => res.text()); + console.log(response); + + // Nested syntax should not be parsed with simple parser + const response2 = await fetch('http://localhost:13333/test', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'a[b]=c&a[d]=e' + }).then(res => res.text()); + console.log(response2); + + process.exit(0); +}); From f100901804b1e50178ef4a8530cb319977722785 Mon Sep 17 00:00:00 2001 From: francesco Date: Thu, 16 Jul 2026 15:20:49 +0200 Subject: [PATCH 4/7] chore: update ts types --- src/types.d.ts | 1 + tests/types/index.test-d.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/types.d.ts b/src/types.d.ts index 7616e990..7242d7e0 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -7,6 +7,7 @@ declare module "ultimate-express" { threads?: number; http3?: boolean; uwsApp?: uWS.TemplatedApp; + version?: 4 | 5; }; namespace express { diff --git a/tests/types/index.test-d.ts b/tests/types/index.test-d.ts index fbfd2053..625ce2f8 100644 --- a/tests/types/index.test-d.ts +++ b/tests/types/index.test-d.ts @@ -8,6 +8,9 @@ import type { Server } from 'http'; const app = express(); +const appV5 = express({ version: 5 }); +const appV4 = express({ version: 4 }); + // Common properties expectAssignable(app.mountpath); expectAssignable>(app.locals); From 07cd8271adad10b3e440f3b36d895b8f35afac40 Mon Sep 17 00:00:00 2001 From: francesco Date: Fri, 17 Jul 2026 14:18:03 +0200 Subject: [PATCH 5/7] chore: add more test --- src/request.js | 2 +- src/response.js | 9 +++--- tests/tests/v5/app-del-removed.js | 18 +++++++++++ tests/tests/v5/app-param-fn-removed.js | 18 +++++++++++ tests/tests/v5/req-param-removed.js | 24 +++++++++++++++ tests/tests/v5/req-query-readonly.js | 26 ++++++++++++++++ tests/tests/v5/res-clearCookie.js | 20 +++++++++++++ tests/tests/v5/res-redirect-back-removed.js | 28 +++++++++++++++++ tests/tests/v5/res-vary-throws.js | 33 +++++++++++++++++++++ tests/tests/v5/static-dotfiles.js | 31 +++++++++++++++++++ 10 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 tests/tests/v5/app-del-removed.js create mode 100644 tests/tests/v5/app-param-fn-removed.js create mode 100644 tests/tests/v5/req-param-removed.js create mode 100644 tests/tests/v5/req-query-readonly.js create mode 100644 tests/tests/v5/res-clearCookie.js create mode 100644 tests/tests/v5/res-redirect-back-removed.js create mode 100644 tests/tests/v5/res-vary-throws.js create mode 100644 tests/tests/v5/static-dotfiles.js diff --git a/src/request.js b/src/request.js index bfadccfa..261dad59 100644 --- a/src/request.js +++ b/src/request.js @@ -247,7 +247,7 @@ module.exports = class Request extends Readable { set query(query) { if(this.app.isV5()) { - throw new Error('req.query is read-only in Express 5. Use a custom query parser middleware instead.'); + return; } this.#cachedQuery = query; } diff --git a/src/response.js b/src/response.js index 4174b86a..c6e384f7 100644 --- a/src/response.js +++ b/src/response.js @@ -791,12 +791,11 @@ module.exports = class Response extends Writable { } location(path) { if(path === 'back') { - if(this.app.isV5()) { - throw new Error('res.location("back") is no longer supported in Express 5. Use req.get("Referrer") || "/" instead.'); + if(!this.app.isV5()) { + path = this.req.get('Referrer'); + if(!path) path = this.req.get('Referer'); + if(!path) path = '/'; } - path = this.req.get('Referrer'); - if(!path) path = this.req.get('Referer'); - if(!path) path = '/'; } this.headers['location'] = encodeUrl(path); return this; diff --git a/tests/tests/v5/app-del-removed.js b/tests/tests/v5/app-del-removed.js new file mode 100644 index 00000000..c530bde8 --- /dev/null +++ b/tests/tests/v5/app-del-removed.js @@ -0,0 +1,18 @@ +// v5 app.del() must be removed +// SKIP_V4: app.del() removed in Express 5 + +const express = require("express"); + +const app = express({ version: 5 }); + +let threw = false; +try { + app.del('/test', (req, res) => { + res.send('ok'); + }); +} catch (e) { + threw = true; +} + +console.log('threw: ' + threw); +process.exit(0); diff --git a/tests/tests/v5/app-param-fn-removed.js b/tests/tests/v5/app-param-fn-removed.js new file mode 100644 index 00000000..55a9a18c --- /dev/null +++ b/tests/tests/v5/app-param-fn-removed.js @@ -0,0 +1,18 @@ +// v5 app.param(fn) must be removed +// SKIP_V4: app.param(fn) removed in Express 5 + +const express = require("express"); + +const app = express({ version: 5 }); + +let threw = false; +try { + app.param(function (name, fn) { + return fn; + }); +} catch (e) { + threw = true; +} + +console.log('threw: ' + threw); +process.exit(0); diff --git a/tests/tests/v5/req-param-removed.js b/tests/tests/v5/req-param-removed.js new file mode 100644 index 00000000..18240e6a --- /dev/null +++ b/tests/tests/v5/req-param-removed.js @@ -0,0 +1,24 @@ +// v5 req.param() must be removed +// SKIP_V4: req.param() removed in Express 5 + +const express = require("express"); + +const app = express({ version: 5 }); + +app.get('/test/:id', (req, res) => { + let threw = false; + try { + req.param('id'); + } catch (e) { + threw = true; + } + res.send('threw: ' + threw); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + const response = await fetch('http://localhost:13333/test/42').then(res => res.text()); + console.log(response); + process.exit(0); +}); diff --git a/tests/tests/v5/req-query-readonly.js b/tests/tests/v5/req-query-readonly.js new file mode 100644 index 00000000..87e03297 --- /dev/null +++ b/tests/tests/v5/req-query-readonly.js @@ -0,0 +1,26 @@ +// v5 req.query must be read-only (assignment silently ignored) +// SKIP_V4: Express 5 makes req.query a read-only getter + +const express = require("express"); + +const app = express({ version: 5 }); + +app.get('/test', (req, res) => { + const origQuery = JSON.stringify(req.query); + let threw = false; + try { + req.query = { custom: 'value' }; + } catch (e) { + threw = true; + } + const afterQuery = JSON.stringify(req.query); + res.json({ threw, origQuery, afterQuery, changed: origQuery !== afterQuery }); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + const response = await fetch('http://localhost:13333/test?a=1').then(res => res.text()); + console.log(response); + process.exit(0); +}); diff --git a/tests/tests/v5/res-clearCookie.js b/tests/tests/v5/res-clearCookie.js new file mode 100644 index 00000000..32e26de7 --- /dev/null +++ b/tests/tests/v5/res-clearCookie.js @@ -0,0 +1,20 @@ +// v5 res.clearCookie must ignore maxAge and expires options +// SKIP_V4: Express 5 ignores maxAge/expires in clearCookie + +const express = require("express"); + +const app = express({ version: 5 }); + +app.get('/test', (req, res) => { + res.clearCookie('session', { path: '/', maxAge: 3600, expires: new Date('2030-01-01') }); + const setCookie = res.get('Set-Cookie'); + res.send(setCookie); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + const response = await fetch('http://localhost:13333/test').then(res => res.text()); + console.log(response); + process.exit(0); +}); diff --git a/tests/tests/v5/res-redirect-back-removed.js b/tests/tests/v5/res-redirect-back-removed.js new file mode 100644 index 00000000..b08c29d9 --- /dev/null +++ b/tests/tests/v5/res-redirect-back-removed.js @@ -0,0 +1,28 @@ +// v5 res.redirect('back') treats 'back' as literal URL instead of Referrer +// SKIP_V4: Express 5 removed 'back' magic in res.redirect/res.location + +const express = require("express"); + +const app = express({ version: 5 }); + +app.get('/test', (req, res) => { + res.redirect('back'); +}); + +app.get('/test2', (req, res) => { + res.location('back'); + res.send('ok'); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + const r1 = await fetch('http://localhost:13333/test', { redirect: 'manual' }); + console.log('status: ' + r1.status); + console.log('location: ' + r1.headers.get('location')); + + const r2 = await fetch('http://localhost:13333/test2'); + console.log('location2: ' + r2.headers.get('location')); + + process.exit(0); +}); diff --git a/tests/tests/v5/res-vary-throws.js b/tests/tests/v5/res-vary-throws.js new file mode 100644 index 00000000..e420bc41 --- /dev/null +++ b/tests/tests/v5/res-vary-throws.js @@ -0,0 +1,33 @@ +// v5 res.vary() must throw when called without argument +// SKIP_V4: Express 5 throws on res.vary() without argument + +const express = require("express"); + +const app = express({ version: 5 }); + +app.get('/test', (req, res) => { + let threw = false; + try { + res.vary(); + } catch (e) { + threw = true; + } + res.send('threw: ' + threw); +}); + +app.get('/valid', (req, res) => { + res.vary('Accept'); + res.send('ok'); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + const r1 = await fetch('http://localhost:13333/test').then(res => res.text()); + console.log(r1); + + const r2 = await fetch('http://localhost:13333/valid').then(res => res.text()); + console.log(r2); + + process.exit(0); +}); diff --git a/tests/tests/v5/static-dotfiles.js b/tests/tests/v5/static-dotfiles.js new file mode 100644 index 00000000..b21512ca --- /dev/null +++ b/tests/tests/v5/static-dotfiles.js @@ -0,0 +1,31 @@ +// v5 express.static must default dotfiles to 'ignore' +// SKIP_V4: Express 5 changed dotfiles default to ignore + +const express = require("express"); +const path = require("path"); +const fs = require("fs"); + +const app = express({ version: 5 }); + +// Create a temp dotfile directory structure for testing +const testDir = path.join(__dirname, '../../parts'); + +app.use(express.static(testDir)); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + // Try to access a dotfile directory (.test/index.html) + const r1 = await fetch('http://localhost:13333/.test/index.html'); + console.log('dotdir status: ' + r1.status); + + // Try to access a dotfile (.test.txt) + const r2 = await fetch('http://localhost:13333/.test.txt'); + console.log('dotfile status: ' + r2.status); + + // Normal file should still work + const r3 = await fetch('http://localhost:13333/index.html'); + console.log('normal status: ' + r3.status); + + process.exit(0); +}); From 456938d206378ebb8bccc2b2bc47ac46b2a43472 Mon Sep 17 00:00:00 2001 From: francesco Date: Mon, 20 Jul 2026 11:02:41 +0200 Subject: [PATCH 6/7] feat: add HTTP QUERY method (RFC 10008) --- src/middlewares.js | 3 ++- src/request.js | 3 ++- src/router.js | 3 ++- tests/tests/v5/http-query-method.js | 24 ++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 tests/tests/v5/http-query-method.js diff --git a/src/middlewares.js b/src/middlewares.js index 60240e07..bf973eb3 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -220,7 +220,8 @@ function createBodyParser(defaultType, beforeReturn) { if( req.method !== 'POST' && req.method !== 'PUT' && - req.method !== 'PATCH' && + req.method !== 'PATCH' && + req.method !== 'QUERY' && (!additionalMethods || !additionalMethods.includes(req.method)) ) { return next(); diff --git a/src/request.js b/src/request.js index 261dad59..4f645824 100644 --- a/src/request.js +++ b/src/request.js @@ -95,7 +95,8 @@ module.exports = class Request extends Readable { if( this.method === 'POST' || this.method === 'PUT' || - this.method === 'PATCH' || + this.method === 'PATCH' || + this.method === 'QUERY' || (additionalMethods && additionalMethods.includes(this.method)) ) { this._res.onData((ab, isLast) => { diff --git a/src/router.js b/src/router.js index 7ab2d03c..1ccf69b5 100644 --- a/src/router.js +++ b/src/router.js @@ -33,7 +33,8 @@ const methods = [ 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace', 'connect', 'checkout', 'copy', 'lock', 'mkcol', 'move', 'purge', 'propfind', 'proppatch', 'search', 'subscribe', 'unsubscribe', 'report', 'mkactivity', 'mkcalendar', - 'checkout', 'merge', 'm-search', 'notify', 'subscribe', 'unsubscribe', 'search' + 'checkout', 'merge', 'm-search', 'notify', 'subscribe', 'unsubscribe', 'search', + 'query' ]; const supportedUwsMethods = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'CONNECT', 'TRACE']); diff --git a/tests/tests/v5/http-query-method.js b/tests/tests/v5/http-query-method.js new file mode 100644 index 00000000..a4d46c41 --- /dev/null +++ b/tests/tests/v5/http-query-method.js @@ -0,0 +1,24 @@ +// v5 must support HTTP QUERY method (RFC 10008) +// SKIP_V4: HTTP QUERY method support added in Express 5 + +const express = require("express"); + +const app = express({ version: 5 }); +app.use(express.json()); + +app.query('/search', (req, res) => { + res.json({ method: req.method, body: req.body }); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + const r1 = await fetch('http://localhost:13333/search', { + method: 'QUERY', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filter: 'active', limit: 10 }) + }).then(res => res.text()); + console.log(r1); + + process.exit(0); +}); From f2d9c0cbcf0011c2adfba9f34bc4f0fba27c6bb4 Mon Sep 17 00:00:00 2001 From: francesco Date: Mon, 20 Jul 2026 11:15:45 +0200 Subject: [PATCH 7/7] feat: dual benchmark (v4 and v5) --- .github/workflows/benchmark.yml | 17 +++- benchmark/README.md | 32 +++++- benchmark/run.js | 169 ++++++++++++++++++-------------- benchmark/server.js | 14 +++ 4 files changed, 154 insertions(+), 78 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 31d4ddec..2330de07 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -38,8 +38,21 @@ jobs: sudo apt-get update sudo apt-get install -y wrk - - name: Run benchmark suite - run: npm run benchmark:compare -- --duration 20 --output benchmark_summary.md + - name: Run benchmark suite (Express 4 vs uExpress) + run: npm run benchmark:compare -- --duration 20 --output benchmark_v4.md + + - name: Run benchmark suite (Express 5 vs uExpress v5) + run: npm run benchmark:compare -- --duration 20 --frameworks express5,ultimate-express-v5 --output benchmark_v5.md + + - name: Combine benchmark results + run: | + echo '' > benchmark_summary.md + echo '' >> benchmark_summary.md + cat benchmark_v4.md | grep -v '' >> benchmark_summary.md + echo '' >> benchmark_summary.md + echo '## Express 5 Benchmark' >> benchmark_summary.md + echo '' >> benchmark_summary.md + cat benchmark_v5.md | grep -v '' >> benchmark_summary.md - name: Upload benchmark markdown artifact uses: actions/upload-artifact@v4 diff --git a/benchmark/README.md b/benchmark/README.md index 3ec8c712..0604e315 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1,6 +1,6 @@ # Benchmark suite -Run all scenarios and compare `express` vs `ultimate-express`: +Run all scenarios and compare `express` vs `ultimate-express` (default): ```bash npm run benchmark:compare -- --duration 20 --output benchmark_summary.md @@ -12,7 +12,30 @@ Run a single scenario: npm run benchmark:compare -- --duration 20 --scenario hello-world ``` -Scenarios: +## Comparing against Express 5 + +Compare all four frameworks (Express 4, Express 5, uExpress v4 mode, uExpress v5 mode): + +```bash +npm run benchmark:compare -- --duration 20 --frameworks express,express5,ultimate-express,ultimate-express-v5 +``` + +Compare only Express 5 vs uExpress v5: + +```bash +npm run benchmark:compare -- --duration 20 --frameworks express5,ultimate-express-v5 +``` + +## Available frameworks + +| ID | Description | +|----|-------------| +| `express` | Express 4 (latest-4 tag) | +| `express5` | Express 5 (latest tag) | +| `ultimate-express` | µExpress in v4 mode (default) | +| `ultimate-express-v5` | µExpress in v5 mode (`version: 5`) | + +## Scenarios - `hello-world` - `routes-1000` @@ -25,3 +48,8 @@ Scenarios: - `streaming-without-content-length` - `streaming-with-content-length` - `readable-hash-4mb` + +## Requirements + +- Linux (wrk is required) +- `wrk` installed and in PATH diff --git a/benchmark/run.js b/benchmark/run.js index 9087a7d7..28d8066e 100644 --- a/benchmark/run.js +++ b/benchmark/run.js @@ -9,11 +9,15 @@ const { spawn, spawnSync } = require('child_process'); const SCENARIO_FILES = fs.readdirSync(path.join(__dirname, 'scenarios')).filter((file) => file.endsWith('.js')).map((file) => file.replace('.js', '')); -const FRAMEWORKS = [ - { id: 'express', label: 'Express', port: 3001 }, - { id: 'ultimate-express', label: 'uExpress', port: 3000 } +const ALL_FRAMEWORKS = [ + { id: 'express', label: 'Express 4', port: 3001 }, + { id: 'express5', label: 'Express 5', port: 3002 }, + { id: 'ultimate-express', label: 'uExpress', port: 3000 }, + { id: 'ultimate-express-v5', label: 'uExpress v5', port: 3003 } ]; +const DEFAULT_FRAMEWORKS = ['express', 'ultimate-express']; + function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i++) { @@ -155,7 +159,7 @@ function runHttpRequest(port, verifyConfig, timeoutMs = 30000) { }); } -async function validateScenarioResponses(scenarioName, scenario) { +async function validateScenarioResponses(scenarioName, scenario, frameworks) { const verify = scenario.verify || {}; let verifyBody = verify.body; if (verify.bodyRepeat && typeof verify.bodyRepeat === 'object') { @@ -170,7 +174,7 @@ async function validateScenarioResponses(scenarioName, scenario) { }; const results = {}; - for (const framework of FRAMEWORKS) { + for (const framework of frameworks) { const { server, stderrRef } = startScenarioServer(framework, scenarioName); try { await waitForReady(framework.port); @@ -180,23 +184,35 @@ async function validateScenarioResponses(scenarioName, scenario) { } } - const expressResult = results.express; - const ultimateResult = results['ultimate-express']; - const sameStatus = expressResult.statusCode === ultimateResult.statusCode; - const sameBodyHash = expressResult.bodyHash === ultimateResult.bodyHash; + // Compare all frameworks against the first one + const baseId = frameworks[0].id; + const baseResult = results[baseId]; + const mismatches = []; + + for (let i = 1; i < frameworks.length; i++) { + const fwId = frameworks[i].id; + const fwResult = results[fwId]; + const sameStatus = baseResult.statusCode === fwResult.statusCode; + const sameBodyHash = baseResult.bodyHash === fwResult.bodyHash; + if (!sameStatus || !sameBodyHash) { + mismatches.push( + `${fwId}: status=${fwResult.statusCode}, hash=${fwResult.bodyHash}, size=${fwResult.bodySize}` + ); + } + } - if (sameStatus && sameBodyHash) { + if (mismatches.length === 0) { return { ok: true, - message: `status ${expressResult.statusCode}, body sha256 ${expressResult.bodyHash.slice(0, 12)}` + message: `status ${baseResult.statusCode}, body sha256 ${baseResult.bodyHash.slice(0, 12)}` }; } return { ok: false, message: [ - `express: status=${expressResult.statusCode}, hash=${expressResult.bodyHash}, size=${expressResult.bodySize}`, - `ultimate-express: status=${ultimateResult.statusCode}, hash=${ultimateResult.bodyHash}, size=${ultimateResult.bodySize}` + `${baseId}: status=${baseResult.statusCode}, hash=${baseResult.bodyHash}, size=${baseResult.bodySize}`, + ...mismatches ].join(' | ') }; } @@ -313,42 +329,52 @@ async function runScenario(framework, scenarioName, scenario, durationSeconds) { } } -function buildMarkdown(results) { +function buildMarkdown(results, frameworks) { const lines = []; lines.push(''); lines.push('## Benchmark Comparison'); lines.push(''); - lines.push('| Test | Express req/sec | uExpress req/sec | Express throughput | uExpress throughput | uExpress speedup |'); - lines.push('| --- | ---: | ---: | ---: | ---: | ---: |'); + + // Build header + const headerCols = ['Test']; + for (const fw of frameworks) { + headerCols.push(`${fw.label} req/sec`); + headerCols.push(`${fw.label} throughput`); + } + // Add speedup column (last framework vs first) + if (frameworks.length >= 2) { + headerCols.push(`${frameworks[frameworks.length - 1].label} speedup`); + } + lines.push(`| ${headerCols.join(' | ')} |`); + lines.push(`| ${headerCols.map(() => '---:').join(' | ')} |`); const failures = []; for (const row of results) { - const speedup = row.express.ok && row.ultimate.ok && row.express.transferPerSecBytes > 0 - ? `${(row.ultimate.transferPerSecBytes / row.express.transferPerSecBytes).toFixed(2)}x` - : 'N/A'; - const expressReq = row.express.ok ? formatReqPerSec(row.express.requestsPerSec) : 'FAILED'; - const ultimateReq = row.ultimate.ok ? formatReqPerSec(row.ultimate.requestsPerSec) : 'FAILED'; - const expressTransfer = row.express.ok ? formatBytesPerSec(row.express.transferPerSecBytes) : 'FAILED'; - const ultimateTransfer = row.ultimate.ok ? formatBytesPerSec(row.ultimate.transferPerSecBytes) : 'FAILED'; - - if (!row.express.ok) { - failures.push({ - scenario: row.name, - framework: 'express', - message: row.express.error - }); + const cols = [row.name]; + for (const fw of frameworks) { + const r = row.results[fw.id]; + if (r && r.ok) { + cols.push(formatReqPerSec(r.requestsPerSec)); + cols.push(formatBytesPerSec(r.transferPerSecBytes)); + } else { + cols.push('FAILED'); + cols.push('FAILED'); + if (r && !r.ok) { + failures.push({ scenario: row.name, framework: fw.id, message: r.error }); + } + } } - if (!row.ultimate.ok) { - failures.push({ - scenario: row.name, - framework: 'ultimate-express', - message: row.ultimate.error - }); + // Speedup: last vs first + if (frameworks.length >= 2) { + const first = row.results[frameworks[0].id]; + const last = row.results[frameworks[frameworks.length - 1].id]; + if (first && first.ok && last && last.ok && first.transferPerSecBytes > 0) { + cols.push(`**${(last.transferPerSecBytes / first.transferPerSecBytes).toFixed(2)}x**`); + } else { + cols.push('N/A'); + } } - - lines.push( - `| ${row.name} | ${expressReq} | ${ultimateReq} | ${expressTransfer} | ${ultimateTransfer} | **${speedup}** |` - ); + lines.push(`| ${cols.join(' | ')} |`); } if (failures.length > 0) { @@ -373,6 +399,18 @@ async function main() { const requestedScenario = args.scenario; const scenarioList = requestedScenario ? [requestedScenario] : SCENARIO_FILES; + // Parse --frameworks flag (comma-separated list of framework ids) + const requestedFrameworks = args.frameworks + ? args.frameworks.split(',').map((s) => s.trim()) + : DEFAULT_FRAMEWORKS; + const FRAMEWORKS = ALL_FRAMEWORKS.filter((fw) => requestedFrameworks.includes(fw.id)); + + if (FRAMEWORKS.length === 0) { + throw new Error(`No valid frameworks specified. Available: ${ALL_FRAMEWORKS.map((f) => f.id).join(', ')}`); + } + + process.stdout.write(`Frameworks: ${FRAMEWORKS.map((f) => f.label).join(', ')}\n`); + const results = []; for (const scenarioName of scenarioList) { const scenario = require(path.join(__dirname, 'scenarios', `${scenarioName}.js`)); @@ -380,7 +418,7 @@ async function main() { let validation = null; try { - validation = await validateScenarioResponses(scenarioName, scenario); + validation = await validateScenarioResponses(scenarioName, scenario, FRAMEWORKS); if (validation.ok) { process.stdout.write(`[validation] PASS ${scenario.name}: ${validation.message}\n`); } else { @@ -394,54 +432,37 @@ async function main() { process.stderr.write(`[validation] ERROR ${scenario.name}: ${validation.message}\n`); } - let expressResult; - try { - const successfulResult = await runScenario(FRAMEWORKS[0], scenarioName, scenario, durationSeconds); - expressResult = { - ok: true, - ...successfulResult - }; - } catch (error) { - expressResult = { - ok: false, - error: error.stack || error.message || String(error) - }; - process.stderr.write(`[benchmark] FAILED express/${scenarioName}\n${expressResult.error}\n`); - } - - let ultimateResult; - try { - const successfulResult = await runScenario(FRAMEWORKS[1], scenarioName, scenario, durationSeconds); - ultimateResult = { - ok: true, - ...successfulResult - }; - } catch (error) { - ultimateResult = { - ok: false, - error: error.stack || error.message || String(error) - }; - process.stderr.write(`[benchmark] FAILED ultimate-express/${scenarioName}\n${ultimateResult.error}\n`); + const scenarioResults = {}; + for (const framework of FRAMEWORKS) { + try { + const successfulResult = await runScenario(framework, scenarioName, scenario, durationSeconds); + scenarioResults[framework.id] = { ok: true, ...successfulResult }; + } catch (error) { + const errorMessage = error.stack || error.message || String(error); + scenarioResults[framework.id] = { ok: false, error: errorMessage }; + process.stderr.write(`[benchmark] FAILED ${framework.id}/${scenarioName}\n${errorMessage}\n`); + } } results.push({ name: scenario.name, validation, - express: expressResult, - ultimate: ultimateResult + results: scenarioResults }); process.stdout.write(`Done: ${scenario.name}\n`); } - const successfulRows = results.filter((row) => row.express.ok || row.ultimate.ok); - if (successfulRows.length === 0) { + const hasAnySuccess = results.some((row) => + Object.values(row.results).some((r) => r.ok) + ); + if (!hasAnySuccess) { throw new Error('All benchmark scenarios failed. No summary table generated.'); } results.sort((a, b) => a.name.localeCompare(b.name)); - const markdown = buildMarkdown(results); + const markdown = buildMarkdown(results, FRAMEWORKS); fs.writeFileSync(outputPath, markdown, 'utf8'); process.stdout.write(markdown); } diff --git a/benchmark/server.js b/benchmark/server.js index f1c78a37..c15b14b7 100644 --- a/benchmark/server.js +++ b/benchmark/server.js @@ -30,10 +30,24 @@ function resolveFramework(frameworkName) { return require('express'); } + if (frameworkName === 'express5') { + return require('express5'); + } + if (frameworkName === 'ultimate-express') { return require('../src/index'); } + if (frameworkName === 'ultimate-express-v5') { + const ue = require('../src/index'); + const wrapped = function(options) { + return ue({ ...options, version: 5 }); + }; + // Copy static properties (Router, json, static, etc.) + Object.assign(wrapped, ue); + return wrapped; + } + throw new Error(`Unknown framework: ${frameworkName}`); }