ultimate-express 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,7 +57,7 @@ For full table with other runtimes, check [here](https://github.com/dimdenGD/bun
57
57
  | express | 10,411.313 | 11,245.57 | 10,598.74 | 9,389.63 |
58
58
 
59
59
  Other benchmarks:
60
- - [TechEmpower / FrameworkBenchmarks](https://www.techempower.com/benchmarks/#hw=ph&test=composite&section=data-r23&l=zik0sf-cn3)
60
+ - [TechEmpower / FrameworkBenchmarks](https://www.techempower.com/benchmarks/#section=data-r23&test=plaintext&l=zik0sf-pa7)
61
61
  - [the-benchmarker / web-frameworks](https://web-frameworks-benchmark.netlify.app/result?l=javascript)
62
62
 
63
63
  ### Performance on real-world application
@@ -189,6 +189,7 @@ In general, basically all features and options are supported. Use [Express 4.x d
189
189
  - ✅ app.engines
190
190
  - ✅ app.on("mount")
191
191
  - ✅ HEAD method
192
+ - ✅ OPTIONS method
192
193
 
193
194
  ### Application settings
194
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-express",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "The Ultimate Express. Fastest http server with full Express compatibility, based on uWebSockets.",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -191,6 +191,12 @@ class Application extends Router {
191
191
  if(request._error) {
192
192
  return this._handleError(request._error, null, request, response);
193
193
  }
194
+ if(request._isOptions && request._matchedMethods.size > 0) {
195
+ const allowedMethods = Array.from(request._matchedMethods).join(',');
196
+ response.setHeader('Allow', allowedMethods);
197
+ response.send(allowedMethods);
198
+ return;
199
+ }
194
200
  response.status(404);
195
201
  this._sendErrorPage(request, response, `Cannot ${request.method} ${request.path}`, false);
196
202
  }
@@ -86,8 +86,9 @@ function static(root, options) {
86
86
 
87
87
  if(stat.isDirectory()) {
88
88
  if(!req.endsWithSlash) {
89
- if(options.redirect) return res.redirect(301, req._originalPath + '/');
90
- else {
89
+ if(options.redirect) {
90
+ return res.redirect(301, req._originalPath + '/', true);
91
+ } else {
91
92
  if(!options.fallthrough) {
92
93
  res.status(404);
93
94
  return next(new Error('Not found'));
package/src/request.js CHANGED
@@ -69,8 +69,11 @@ module.exports = class Request extends Readable {
69
69
  this._opPath = this._opPath.slice(0, -1);
70
70
  }
71
71
  this.method = req.getCaseSensitiveMethod().toUpperCase();
72
+ this._isOptions = this.method === 'OPTIONS';
73
+ this._isHead = this.method === 'HEAD';
72
74
  this.params = {};
73
-
75
+
76
+ this._matchedMethods = new Set();
74
77
  this._gotParams = new Set();
75
78
  this._stack = [];
76
79
  this._paramStack = [];
@@ -135,6 +138,10 @@ module.exports = class Request extends Readable {
135
138
  return match ? match[0] : '';
136
139
  }
137
140
 
141
+ set baseUrl(x) {
142
+ return this._originalPath = x;
143
+ }
144
+
138
145
  get #host() {
139
146
  const trust = this.app.get('trust proxy fn');
140
147
  if(!trust) {
@@ -248,7 +255,7 @@ module.exports = class Request extends Readable {
248
255
  if(this.#cachedParsedIp !== null) {
249
256
  return this.#cachedParsedIp;
250
257
  }
251
- const finished = !this.res.socket.writable;
258
+ const finished = this.res.finished;
252
259
  if(finished) {
253
260
  // mark app as one that needs ip after response
254
261
  this.app.needsIpAfterResponse = true;
package/src/response.js CHANGED
@@ -69,6 +69,7 @@ class Socket extends EventEmitter {
69
69
 
70
70
  module.exports = class Response extends Writable {
71
71
  #socket = null;
72
+ #ended = false;
72
73
  #pendingChunks = [];
73
74
  #lastWriteChunkTime = 0;
74
75
  #writeTimeout = null;
@@ -112,13 +113,16 @@ module.exports = class Response extends Writable {
112
113
  this._res.cork(() => {
113
114
  this._res.close();
114
115
  this.finished = true;
115
- if(this.socketExists) this.socket.emit('close');
116
+ this.#socket?.emit('close');
116
117
  });
117
118
  });
119
+ this.once('close', () => {
120
+ this.#ended = true
121
+ })
118
122
  }
119
123
 
120
124
  get socket() {
121
- this.socketExists = true;
125
+ if(this.#ended) return null;
122
126
  if(!this.#socket) {
123
127
  this.#socket = new Socket(this);
124
128
  }
@@ -153,7 +157,7 @@ module.exports = class Response extends Writable {
153
157
  if (this.chunkedTransfer) {
154
158
  this.#pendingChunks.push(chunk);
155
159
  const size = this.#pendingChunks.reduce((acc, chunk) => acc + chunk.byteLength, 0);
156
- const now = Date.now();
160
+ const now = performance.now();
157
161
  // the first chunk is sent immediately (!this.#lastWriteChunkTime)
158
162
  // the other chunks are sent when watermark is reached (size >= HIGH_WATERMARK)
159
163
  // or if elapsed 50ms of last send (now - this.#lastWriteChunkTime > 50)
@@ -173,7 +177,7 @@ module.exports = class Response extends Writable {
173
177
  const size = this.#pendingChunks.reduce((acc, chunk) => acc + chunk.byteLength, 0);
174
178
  this._res.write(Buffer.concat(this.#pendingChunks, size));
175
179
  this.#pendingChunks = [];
176
- this.#lastWriteChunkTime = now;
180
+ this.#lastWriteChunkTime = performance.now();
177
181
  }
178
182
  });
179
183
  }, 50);
@@ -188,7 +192,7 @@ module.exports = class Response extends Writable {
188
192
  super.end();
189
193
  this.finished = true;
190
194
  this.writingChunk = false;
191
- if (this.socketExists) this.socket.emit('close');
195
+ this.#socket?.emit('close');
192
196
  callback(null);
193
197
  } else if (!ok) {
194
198
  this._res.ab = chunk;
@@ -198,7 +202,7 @@ module.exports = class Response extends Writable {
198
202
  const [ok, done] = this._res.tryEnd(this._res.ab.slice(offset - this._res.abOffset), this.totalSize);
199
203
  if (done) {
200
204
  this.finished = true;
201
- if (this.socketExists) this.socket.emit('close');
205
+ this.#socket?.emit('close');
202
206
  }
203
207
  if (ok) {
204
208
  this.writingChunk = false;
@@ -258,10 +262,18 @@ module.exports = class Response extends Writable {
258
262
  sendStatus(code) {
259
263
  return this.status(code).send(statuses.message[+code] ?? code.toString());
260
264
  }
261
- end(data) {
265
+ end(data, cb) {
266
+ if(typeof data === 'function') {
267
+ cb = data;
268
+ data = undefined;
269
+ }
270
+ if(typeof cb !== 'function') {
271
+ cb = undefined; // silence the error?
272
+ }
273
+
262
274
  if(this.writingChunk) {
263
275
  this.once('drain', () => {
264
- this.end(data);
276
+ this.end(data, cb);
265
277
  });
266
278
  return;
267
279
  }
@@ -282,9 +294,13 @@ module.exports = class Response extends Writable {
282
294
  if(fresh) {
283
295
  this._res.end();
284
296
  this.finished = true;
285
- if(this.socketExists) this.socket.emit('close');
297
+ this.#socket?.emit('close');
286
298
  this.emit('finish');
287
299
  this.emit('close');
300
+ cb && queueMicrotask(() => {
301
+ this.#ended = true;
302
+ cb();
303
+ });
288
304
  return;
289
305
  }
290
306
  }
@@ -309,9 +325,13 @@ module.exports = class Response extends Writable {
309
325
  }
310
326
 
311
327
  this.finished = true;
312
- if(this.socketExists) this.socket.emit('close');
328
+ this.#socket?.emit('close');
313
329
  this.emit('finish');
314
330
  this.emit('close');
331
+ cb && queueMicrotask(() => {
332
+ this.#ended = true;
333
+ cb();
334
+ });
315
335
  });
316
336
  return this;
317
337
  }
@@ -775,28 +795,44 @@ module.exports = class Response extends Writable {
775
795
  }
776
796
  return this.headers['location'] = encodeUrl(path);
777
797
  }
778
- redirect(status, url) {
798
+ redirect(status, url, forceHtml = false) {
779
799
  if(typeof status !== 'number' && !url) {
780
800
  url = status;
781
801
  status = 302;
782
802
  }
783
803
  this.location(url);
784
804
  this.status(status);
785
- this.headers['content-type'] = 'text/plain; charset=utf-8';
786
805
  let body;
787
806
  // Support text/{plain,html} by default
788
- this.format({
789
- text: function() {
790
- body = statuses.message[status] + '. Redirecting to ' + url
791
- },
792
- html: function() {
793
- let u = escapeHtml(url);
794
- body = '<p>' + statuses.message[status] + '. Redirecting to ' + u + '</p>'
795
- },
796
- default: function() {
797
- body = '';
798
- }
799
- });
807
+ if(forceHtml) {
808
+ this.set('Content-Type', 'text/html; charset=UTF-8');
809
+ body =
810
+ '<!DOCTYPE html>\n' +
811
+ '<html lang="en">\n' +
812
+ '<head>\n' +
813
+ '<meta charset="utf-8">\n' +
814
+ '<title>Redirecting</title>\n' +
815
+ '</head>\n' +
816
+ '<body>\n' +
817
+ `<pre>Redirecting to ${url.replaceAll("<", "&lt;").replaceAll(">", "&gt;")}</pre>\n` +
818
+ '</body>\n' +
819
+ '</html>\n';
820
+ } else {
821
+ this.format({
822
+ text: () => {
823
+ this.set('Content-Type', 'text/plain; charset=UTF-8');
824
+ body = statuses.message[status] + '. Redirecting to ' + url
825
+ },
826
+ html: () => {
827
+ this.set('Content-Type', 'text/html; charset=UTF-8');
828
+ body = `<p>${statuses.message[status]}. Redirecting to ${url.replaceAll("<", "&lt;").replaceAll(">", "&gt;")}</p>`;
829
+ },
830
+ default: () => {
831
+ this.set('Content-Type', 'text/plain; charset=UTF-8');
832
+ body = '';
833
+ }
834
+ });
835
+ }
800
836
  if (this.req.method === 'HEAD') {
801
837
  this.end();
802
838
  } else {
package/src/router.js CHANGED
@@ -152,6 +152,9 @@ module.exports = class Router extends EventEmitter {
152
152
  all: method === 'ALL' || method === 'USE',
153
153
  gettable: method === 'GET' || method === 'HEAD',
154
154
  };
155
+ if(typeof route.path === 'string' && (route.path.includes(':') || route.path.includes('*') || (route.path.includes('(') && route.path.includes(')'))) && route.pattern instanceof RegExp) {
156
+ route.complex = true;
157
+ }
155
158
  routes.push(route);
156
159
  // normal routes optimization
157
160
  if(canBeOptimized(route.path) && route.pattern !== '/*' && !this.parent && this.get('case sensitive routing') && this.uwsApp) {
@@ -296,6 +299,12 @@ module.exports = class Router extends EventEmitter {
296
299
  if(request._error) {
297
300
  return this._handleError(request._error, null, request, response);
298
301
  }
302
+ if(request._isOptions && request._matchedMethods.size > 0) {
303
+ const allowedMethods = Array.from(request._matchedMethods).join(',');
304
+ response.setHeader('Allow', allowedMethods);
305
+ response.send(allowedMethods);
306
+ return;
307
+ }
299
308
  response.status(404);
300
309
  request.noEtag = true;
301
310
  this._sendErrorPage(request, response, `Cannot ${request.method} ${request._originalPath}`, false);
@@ -369,7 +378,7 @@ module.exports = class Router extends EventEmitter {
369
378
  req.route = route;
370
379
  if(route.optimizedParams) {
371
380
  req.params = {...req.optimizedParams};
372
- } else if(typeof route.path === 'string' && (route.path.includes(':') || route.path.includes('*') || (route.path.includes('(') && route.path.includes(')'))) && route.pattern instanceof RegExp) {
381
+ } else if(route.complex) {
373
382
  let path = req._originalPath;
374
383
  if(req._stack.length > 0) {
375
384
  path = path.replace(this.getFullMountpath(req), '');
@@ -445,7 +454,7 @@ module.exports = class Router extends EventEmitter {
445
454
  }
446
455
 
447
456
  async _routeRequest(req, res, startIndex = 0, routes = this._routes, skipCheck = false, skipUntil) {
448
- let routeIndex = skipCheck ? startIndex : findIndexStartingFrom(routes, r => (r.all || r.method === req.method || (r.gettable && req.method === 'HEAD')) && this._pathMatches(r, req), startIndex);
457
+ let routeIndex = skipCheck ? startIndex : findIndexStartingFrom(routes, r => (r.all || r.method === req.method || req._isOptions || (r.gettable && req._isHead)) && this._pathMatches(r, req), startIndex);
449
458
  const route = routes[routeIndex];
450
459
  if(!route) {
451
460
  if(!skipCheck) {
@@ -487,6 +496,9 @@ module.exports = class Router extends EventEmitter {
487
496
  if(thingamabob) {
488
497
  if(thingamabob === 'route' || thingamabob === 'skipPop') {
489
498
  if(route.use && thingamabob !== 'skipPop') {
499
+ if(req._isOptions) {
500
+ return resolve(false);
501
+ }
490
502
  req._stack.pop();
491
503
 
492
504
  req._opPath = req._stack.length > 0 ? req._originalPath.replace(this.getFullMountpath(req), '') : req._originalPath;
@@ -546,6 +558,15 @@ module.exports = class Router extends EventEmitter {
546
558
  }
547
559
 
548
560
  try {
561
+ // handling OPTIONS method
562
+ if(req._isOptions && !route.all && route.method !== 'OPTIONS') {
563
+ req._matchedMethods.add(route.method);
564
+ if(route.gettable) {
565
+ req._matchedMethods.add('HEAD');
566
+ }
567
+ return next();
568
+ }
569
+
549
570
  // skipping routes we already went through via optimized path
550
571
  if(!skipCheck && skipUntil && skipUntil.routeKey >= route.routeKey) {
551
572
  return next();