ultimate-express 2.0.17 → 2.1.0

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
@@ -24,7 +24,7 @@ Similar projects based on uWebSockets:
24
24
 
25
25
  ## Performance
26
26
 
27
- ### Test results
27
+ ### Performance against Express
28
28
 
29
29
  Tested using [wrk](https://github.com/wg/wrk) (`-d 60 -t 1 -c 200`). Tested on Ubuntu 22.04, Node.js 20.17.0, AMD Ryzen 5 3600, 64GB RAM.
30
30
 
@@ -40,25 +40,9 @@ Tested using [wrk](https://github.com/wg/wrk) (`-d 60 -t 1 -c 200`). Tested on U
40
40
  | middlewares/compression-file (/small-file) | 4.81k | 14.92k | 386 MB/sec | 1.17 GB/sec | **3.10X** |
41
41
 
42
42
  ### Performance against other frameworks
43
-
44
- Tested using [bun-http-framework-benchmark](https://github.com/dimdenGD/bun-http-framework-benchmark). This table only includes Node.js results.
45
- For full table with other runtimes, check [here](https://github.com/dimdenGD/bun-http-framework-benchmark?tab=readme-ov-file#results).
46
-
47
- | Framework | Average | Ping | Query | Body |
48
- | -------------------- | -------------- | ------------- | ------------- | ------------- |
49
- | uws | 95,531.277 | 109,960.35 | 105,601.47 | 71,032.01 |
50
- | **ultimate-express (declarative)** | **86,794.997** | **108,546.44** | **105,869.75** | **45,968.8** |
51
- | hyper-express | 68,959.92 | 82,547.21 | 71,685.51 | 52,647.04 |
52
- | **ultimate-express** | **60,839.75** | **68,938.53** | **66,173.86** | **47,406.86** |
53
- | h3 | 35,423.263 | 41,243.68 | 34,429.26 | 30,596.85 |
54
- | fastify | 33,094.62 | 40,147.67 | 40,076.35 | 19,059.84 |
55
- | hono | 26,576.02 | 36,215.35 | 34,656.12 | 8,856.59 |
56
- | koa | 24,045.08 | 28,202.12 | 24,590.84 | 19,342.28 |
57
- | express | 10,411.313 | 11,245.57 | 10,598.74 | 9,389.63 |
58
-
59
- Other benchmarks:
60
43
  - [TechEmpower / FrameworkBenchmarks](https://www.techempower.com/benchmarks/#section=data-r23&test=plaintext&l=zik0sf-pa7)
61
44
  - [the-benchmarker / web-frameworks](https://web-frameworks-benchmark.netlify.app/result?l=javascript)
45
+ - [HttpArena](https://www.http-arena.com/leaderboard/)
62
46
 
63
47
  ### Performance on real-world application
64
48
 
@@ -237,7 +221,7 @@ In general, basically all features and options are supported. Use [Express 4.x d
237
221
  - ✅ req.subdomains
238
222
  - ✅ req.xhr
239
223
  - 🚧 req.route (route implementation is different from Express)
240
- - 🚧 req.connection, req.socket (only `end()`, `encrypted`, `remoteAddress` and `localPort` are supported)
224
+ - 🚧 req.connection, req.socket (only `end()`, `encrypted`, `remoteAddress`, `remotePort` and `localPort` are supported)
241
225
  - ✅ req.accepts()
242
226
  - ✅ req.acceptsCharsets()
243
227
  - ✅ req.acceptsEncodings()
@@ -333,6 +317,7 @@ Almost all middlewares that are compatible with Express are compatible with µEx
333
317
  - ✅ [swagger-ui-express](https://www.npmjs.com/package/swagger-ui-express)
334
318
  - ✅ [graphql-http](https://www.npmjs.com/package/graphql-http)
335
319
  - ✅ [better-sse](https://www.npmjs.com/package/better-sse)
320
+ - ✅ [supertest](https://www.npmjs.com/package/supertest)
336
321
 
337
322
  Middlewares and modules that are confirmed to not work:
338
323
 
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "ultimate-express",
3
- "version": "2.0.17",
3
+ "version": "2.1.0",
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": {
7
7
  "test": "node tests/index.js",
8
- "dev": "node --inspect=9229 demo/index.js",
8
+ "test:types": "tsd --files tests/types/*.test-d.ts",
9
+ "benchmark:compare": "node benchmark/run.js",
9
10
  "cover": "npm run cover:unit && npm run cover:report",
10
11
  "cover:unit": "nyc --silent npm run test",
11
12
  "cover:report": "nyc report --reporter=html"
@@ -43,10 +44,11 @@
43
44
  },
44
45
  "homepage": "https://github.com/dimdenGD/ultimate-express#readme",
45
46
  "dependencies": {
46
- "@types/express": "^4.17.21",
47
+ "@types/express": "^4.17.25",
47
48
  "accepts": "^1.3.8",
48
- "acorn": "^8.15.0",
49
+ "acorn": "^8.16.0",
49
50
  "bytes": "^3.1.2",
51
+ "content-disposition": "^1.1.0",
50
52
  "cookie": "^1.1.1",
51
53
  "cookie-signature": "^1.2.2",
52
54
  "encodeurl": "^2.0.0",
@@ -57,22 +59,23 @@
57
59
  "mime-types": "^2.1.35",
58
60
  "ms": "^2.1.3",
59
61
  "proxy-addr": "^2.0.7",
60
- "qs": "^6.14.1",
62
+ "qs": "^6.15.1",
61
63
  "range-parser": "^1.2.1",
62
64
  "statuses": "^2.0.2",
63
65
  "tseep": "^1.3.1",
64
66
  "type-is": "^2.0.1",
65
- "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.56.0",
67
+ "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.64.0",
66
68
  "vary": "^1.1.2"
67
69
  },
68
70
  "devDependencies": {
69
71
  "@codechecks/client": "^0.1.12",
72
+ "@types/node": "^25.5.2",
70
73
  "better-sse": "^0.16.1",
71
- "body-parser": "^2.2.1",
74
+ "body-parser": "^2.2.2",
72
75
  "compression": "^1.8.1",
73
76
  "cookie-parser": "^1.4.7",
74
77
  "cookie-session": "^2.1.1",
75
- "cors": "^2.8.5",
78
+ "cors": "^2.8.6",
76
79
  "ejs": "^3.1.10",
77
80
  "errorhandler": "^1.5.2",
78
81
  "eventsource": "^4.1.0",
@@ -82,30 +85,32 @@
82
85
  "express-async-errors": "^3.1.1",
83
86
  "express-dot-engine": "^1.0.8",
84
87
  "express-fileupload": "^1.5.2",
85
- "express-handlebars": "^8.0.4",
88
+ "express-handlebars": "^8.0.7",
86
89
  "express-http-proxy": "^2.1.2",
87
90
  "express-mongo-sanitize": "^2.2.0",
88
- "express-rate-limit": "^8.2.1",
89
- "express-session": "^1.18.2",
91
+ "express-rate-limit": "^8.3.2",
92
+ "express-session": "^1.19.0",
90
93
  "express-subdomain": "^1.0.6",
91
94
  "graphql-http": "^1.22.4",
92
95
  "helmet": "^8.1.0",
93
96
  "http-proxy-middleware": "^3.0.5",
94
97
  "method-override": "^3.0.0",
95
98
  "morgan": "^1.10.1",
96
- "multer": "^2.0.2",
99
+ "multer": "^2.1.1",
97
100
  "mustache-express": "^1.3.2",
98
101
  "nyc": "^17.1.0",
99
102
  "pako": "^2.1.0",
100
103
  "passport": "^0.7.0",
101
104
  "passport-local": "^1.0.0",
102
- "pkg-pr-new": "^0.0.62",
103
- "pug": "^3.0.3",
105
+ "pkg-pr-new": "^0.0.66",
106
+ "pug": "^3.0.4",
104
107
  "response-time": "^2.3.4",
105
- "serve-index": "^1.9.1",
108
+ "serve-index": "^1.9.2",
106
109
  "serve-static": "^2.2.1",
110
+ "supertest": "^7.2.2",
107
111
  "swagger-ui-express": "^5.0.1",
108
112
  "swig": "^1.4.2",
113
+ "tsd": "^0.33.0",
109
114
  "vhost": "^3.0.2"
110
115
  }
111
116
  }
@@ -22,6 +22,7 @@ const ViewClass = require("./view.js");
22
22
  const path = require("path");
23
23
  const os = require("os");
24
24
  const { Worker } = require("worker_threads");
25
+ const cluster = require('cluster');
25
26
 
26
27
  const cpuCount = os.cpus().length;
27
28
 
@@ -222,18 +223,21 @@ class Application extends Router {
222
223
  }
223
224
  const onListen = socket => {
224
225
  if(!socket) {
225
- let err = new Error('Failed to listen on port ' + port + '. No permission or address already in use.');
226
+ let err = new Error('listen EADDRINUSE: address already in use :::' + port);
227
+ err.code = 'EADDRINUSE';
226
228
  throw err;
227
229
  }
228
230
  this.port = uWS.us_socket_local_port(socket);
229
- if(callback) callback(this.port);
231
+ if(callback) callback();
230
232
  };
231
233
  let fn = 'listen';
232
234
  let args = [];
235
+ // 1 = exclusive port, 0 = shared port
236
+ let uwsOptions = cluster.isPrimary ? 1 : 0;
233
237
  if(typeof port !== 'number') {
234
238
  if(!isNaN(Number(port))) {
235
239
  port = Number(port);
236
- args.push(port, onListen);
240
+ args.push(port, uwsOptions, onListen);
237
241
  if(host) {
238
242
  args.unshift(host);
239
243
  }
@@ -242,18 +246,18 @@ class Application extends Router {
242
246
  args.push(onListen, port);
243
247
  }
244
248
  } else {
245
- args.push(port, onListen);
249
+ args.push(port, uwsOptions, onListen);
246
250
  if(host) {
247
251
  args.unshift(host);
248
252
  }
249
253
  }
250
254
  this.listenCalled = true;
251
255
  this.uwsApp[fn](...args);
252
- return this.uwsApp;
256
+ return this;
253
257
  }
254
258
 
255
259
  address() {
256
- return { port: this.port };
260
+ return this.port ? { port: this.port } : null;
257
261
  }
258
262
 
259
263
  path() {
@@ -335,6 +339,13 @@ class Application extends Router {
335
339
  callback(err);
336
340
  }
337
341
  }
342
+
343
+ close(callback) {
344
+ if(this.listenCalled) {
345
+ this.uwsApp.close();
346
+ }
347
+ if(callback) callback();
348
+ }
338
349
  }
339
350
 
340
351
  module.exports = function(options) {
@@ -1,10 +1,11 @@
1
1
  const acorn = require("acorn");
2
2
  const { stringify } = require("./utils.js");
3
3
  const uWS = require("uWebSockets.js");
4
+ const statuses = require("statuses");
4
5
 
5
6
  const parser = acorn.Parser;
6
7
 
7
- const allowedResMethods = ['set', 'header', 'setHeader', 'status', 'send', 'end', 'append'];
8
+ const allowedResMethods = ['set', 'header', 'setHeader', 'sendStatus', 'status', 'send', 'end', 'append'];
8
9
  const allowedIdentifiers = ['query', 'params', ...allowedResMethods];
9
10
  const objKeyRegex = /[\s{\n]([A-Za-z-0-9_]+)(\s|\n)*?:/g;
10
11
 
@@ -203,6 +204,11 @@ module.exports = function compileDeclarative(cb, app) {
203
204
  return false;
204
205
  }
205
206
  headers.push([call.arguments[0].value, call.arguments[1].value]);
207
+ } else if(call.obj.propertyName === 'sendStatus'){
208
+ if(call.arguments[0].type !== 'Literal') {
209
+ return false;
210
+ }
211
+ statusCode = call.arguments[0].value;
206
212
  }
207
213
  }
208
214
 
@@ -354,13 +360,16 @@ module.exports = function compileDeclarative(cb, app) {
354
360
  }
355
361
  }
356
362
 
357
- // uws doesnt support status codes other than 200 currently
363
+ let decRes = new uWS.DeclarativeResponse();
364
+
358
365
  if(statusCode != 200) {
359
- return false;
366
+ const statusMessage = statuses.message[statusCode] ?? '';
367
+ decRes = decRes.writeStatus(`${statusCode} ${statusMessage}`.trim());
368
+ if(!headers.some(header => header[0].toLowerCase() === 'content-type')) {
369
+ decRes = decRes.writeHeader('content-type','text/plain; charset=utf-8');
370
+ }
360
371
  }
361
372
 
362
- let decRes = new uWS.DeclarativeResponse();
363
-
364
373
  for(let header of headers) {
365
374
  if(header[0].toLowerCase() === 'content-length') {
366
375
  return false;
@@ -390,6 +399,10 @@ module.exports = function compileDeclarative(cb, app) {
390
399
  }
391
400
  }
392
401
 
402
+ if(!body.length) {
403
+ decRes = decRes.write(statuses.message[statusCode] || String(statusCode));
404
+ }
405
+
393
406
  return decRes.end();
394
407
  } catch(e) {
395
408
  return false;
package/src/request.js CHANGED
@@ -19,15 +19,16 @@ const accepts = require("accepts");
19
19
  const typeis = require("type-is");
20
20
  const parseRange = require("range-parser");
21
21
  const proxyaddr = require("proxy-addr");
22
+ const { isIP } = require("node:net");
22
23
  const fresh = require("fresh");
23
24
  const { Readable } = require("stream");
24
25
 
25
- const discardedDuplicates = [
26
+ const discardedDuplicates = new Set([
26
27
  "age", "authorization", "content-length", "content-type", "etag", "expires",
27
28
  "from", "host", "if-modified-since", "if-unmodified-since", "last-modified",
28
29
  "location", "max-forwards", "proxy-authorization", "referer", "retry-after",
29
30
  "server", "user-agent"
30
- ];
31
+ ]);
31
32
 
32
33
  let key = 0;
33
34
 
@@ -153,19 +154,28 @@ module.exports = class Request extends Readable {
153
154
 
154
155
  get #host() {
155
156
  const trust = this.app.get('trust proxy fn');
156
- if(!trust) {
157
- return this.get('host');
158
- }
159
- let val = this.headers['x-forwarded-host'];
160
- if (!val || !trust(this.connection.remoteAddress, 0)) {
161
- val = this.headers['host'];
162
- } else if (val.indexOf(',') !== -1) {
163
- // Note: X-Forwarded-Host is normally only ever a
164
- // single value, but this is to be safe.
165
- val = val.substring(0, val.indexOf(',')).trimRight()
157
+ const isTrusted = !!(trust && trust(this.connection.remoteAddress, 0));
158
+ const rawHeader = (isTrusted && this.headers['x-forwarded-host']) || this.headers['host'];
159
+ let host = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
160
+
161
+ if (typeof host !== 'string' || !host) return;
162
+ host = host.trim();
163
+
164
+ if (isTrusted) {
165
+ const commaIndex = host.indexOf(',');
166
+ if (commaIndex !== -1) {
167
+ // Note: X-Forwarded-Host is normally only ever a
168
+ // single value, but this is to be safe.
169
+ host = host.substring(0, commaIndex).trimEnd();
170
+ }
166
171
  }
167
-
168
- return val ? val.split(':')[0] : undefined;
172
+
173
+ if (!host) return;
174
+
175
+ const offset = host[0] === '[' ? host.indexOf(']') + 1 : 0;
176
+ const portIndex = host.indexOf(':', offset);
177
+
178
+ return portIndex !== -1 ? host.substring(0, portIndex) : host;
169
179
  }
170
180
 
171
181
  get host() {
@@ -174,11 +184,7 @@ module.exports = class Request extends Readable {
174
184
  }
175
185
 
176
186
  get hostname() {
177
- const host = this.#host;
178
- if(!host) return this.headers['host'].split(':')[0];
179
- const offset = host[0] === '[' ? host.indexOf(']') + 1 : 0;
180
- const index = host.indexOf(':', offset);
181
- return index !== -1 ? host.slice(0, index) : host;
187
+ return this.#host;
182
188
  }
183
189
 
184
190
  get httpVersion() {
@@ -246,18 +252,28 @@ module.exports = class Request extends Readable {
246
252
  return this.protocol === 'https';
247
253
  }
248
254
 
255
+ #cachedSubdomains = null;
256
+
249
257
  get subdomains() {
250
- let host = this.hostname;
251
- let subdomains = host.split('.');
252
- const so = this.app.get('subdomain offset');
253
- if(so === 0) {
254
- return subdomains.reverse();
258
+ if(this.#cachedSubdomains !== null) {
259
+ return this.#cachedSubdomains;
260
+ }
261
+
262
+ const hostname = this.hostname;
263
+ if(!hostname || isIP(hostname)) {
264
+ return this.#cachedSubdomains = [];
255
265
  }
256
- return subdomains.slice(0, -so).reverse();
266
+
267
+ const offset = this.app.get('subdomain offset');
268
+ const parts = hostname.split('.');
269
+ const subdomains = parts.reverse().slice(offset);
270
+
271
+ return this.#cachedSubdomains = subdomains;
257
272
  }
258
273
 
259
274
  get xhr() {
260
- return this.headers['x-requested-with'] === 'XMLHttpRequest';
275
+ const val = this.headers?.['x-requested-with'];
276
+ return typeof val === 'string' && val.toLowerCase() === 'xmlhttprequest';
261
277
  }
262
278
 
263
279
  get parsedIp() {
@@ -299,6 +315,7 @@ module.exports = class Request extends Readable {
299
315
  get connection() {
300
316
  return {
301
317
  remoteAddress: this.parsedIp,
318
+ remotePort: this._res.getRemotePort(),
302
319
  localPort: this.app.port,
303
320
  encrypted: this.app.ssl,
304
321
  end: (body) => this.res.end(body)
@@ -327,6 +344,12 @@ module.exports = class Request extends Readable {
327
344
  }
328
345
 
329
346
  get(field) {
347
+ if(!field) {
348
+ throw new TypeError('name argument is required to req.get');
349
+ }
350
+ if(typeof field !== 'string') {
351
+ throw new TypeError('name must be a string to req.get');
352
+ }
330
353
  field = field.toLowerCase();
331
354
  if(field === 'referrer' || field === 'referer') {
332
355
  const res = this.headers['referrer'];
@@ -355,19 +378,54 @@ module.exports = class Request extends Readable {
355
378
  return accepts(this).languages(...languages);
356
379
  }
357
380
 
358
- is(type) {
359
- return typeis(this, type);
381
+ acceptsEncoding(...args) {
382
+ deprecated('req.acceptsEncoding', 'req.acceptsEncodings');
383
+ return this.acceptsEncodings(...args);
384
+ }
385
+
386
+ acceptsCharset(...args) {
387
+ deprecated('req.acceptsCharset', 'req.acceptsCharsets');
388
+ return this.acceptsCharsets(...args);
389
+ }
390
+
391
+ acceptsLanguage(...args) {
392
+ deprecated('req.acceptsLanguage', 'req.acceptsLanguages');
393
+ return this.acceptsLanguages(...args);
394
+ }
395
+
396
+ is(types) {
397
+ if(Array.isArray(types)) {
398
+ return typeis(this, types);
399
+ }
400
+
401
+ if(arguments.length === 1) {
402
+ return typeis(this, [types]);
403
+ }
404
+
405
+ return typeis(this, [...arguments]);
360
406
  }
361
407
 
362
408
  param(name, defaultValue) {
363
409
  deprecated('req.param(name)', 'req.params, req.body, or req.query');
364
- if(this.params[name]) {
365
- return this.params[name];
410
+
411
+ if(name == null) return defaultValue;
412
+
413
+ if(this.params && Object.prototype.hasOwnProperty.call(this.params, name)) {
414
+ const value = this.params[name];
415
+ if(value != null) return value;
366
416
  }
367
- if(this.body && this.body[name]) {
368
- return this.body[name];
417
+
418
+ if(this.body && Object.prototype.hasOwnProperty.call(this.body, name)) {
419
+ const value = this.body[name];
420
+ if(value != null) return value;
421
+ }
422
+
423
+ if(this.query && Object.prototype.hasOwnProperty.call(this.query, name)) {
424
+ const value = this.query[name];
425
+ if(value != null) return value;
369
426
  }
370
- return this.query[name] ?? defaultValue;
427
+
428
+ return defaultValue;
371
429
  }
372
430
 
373
431
  range(size, options) {
@@ -389,7 +447,7 @@ module.exports = class Request extends Readable {
389
447
  let [key, value] = this.#rawHeadersEntries[index];
390
448
  key = key.toLowerCase();
391
449
  if(this.#cachedHeaders[key]) {
392
- if(discardedDuplicates.includes(key)) {
450
+ if(discardedDuplicates.has(key)) {
393
451
  continue;
394
452
  }
395
453
  if(key === 'cookie') {
package/src/response.js CHANGED
@@ -18,9 +18,10 @@ const cookie = require("cookie");
18
18
  const mime = require("mime-types");
19
19
  const vary = require("vary");
20
20
  const encodeUrl = require("encodeurl");
21
- const {
21
+ const contentDisposition = require("content-disposition");
22
+ const {
22
23
  normalizeType, stringify, deprecated, UP_PATH_REGEXP, decode,
23
- containsDotFile, isPreconditionFailure, isRangeFresh, NullObject
24
+ containsDotFile, isPreconditionFailure, isRangeFresh, escapeHtml, NullObject
24
25
  } = require("./utils.js");
25
26
  const { Writable } = require("stream");
26
27
  const { isAbsolute } = require("path");
@@ -70,9 +71,7 @@ class Socket extends EventEmitter {
70
71
  module.exports = class Response extends Writable {
71
72
  #socket = null;
72
73
  #ended = false;
73
- #pendingChunks = [];
74
- #lastWriteChunkTime = 0;
75
- #writeTimeout = null;
74
+ #pendingCallback = null;
76
75
  req;
77
76
  constructor(res, req, app) {
78
77
  super();
@@ -156,36 +155,21 @@ module.exports = class Response extends Writable {
156
155
  }
157
156
 
158
157
  if (this.chunkedTransfer) {
159
- this.#pendingChunks.push(chunk);
160
- const size = this.#pendingChunks.reduce((acc, chunk) => acc + chunk.byteLength, 0);
161
- const now = performance.now();
162
- // the first chunk is sent immediately (!this.#lastWriteChunkTime)
163
- // the other chunks are sent when watermark is reached (size >= HIGH_WATERMARK)
164
- // or if elapsed 50ms of last send (now - this.#lastWriteChunkTime > 50)
165
- if (!this.#lastWriteChunkTime || size >= HIGH_WATERMARK || now - this.#lastWriteChunkTime > 50) {
166
- this._res.write(Buffer.concat(this.#pendingChunks, size));
167
- this.#pendingChunks = [];
168
- this.#lastWriteChunkTime = now;
169
- if(this.#writeTimeout) {
170
- clearTimeout(this.#writeTimeout);
171
- this.#writeTimeout = null;
172
- }
173
- } else if(!this.#writeTimeout) {
174
- this.#writeTimeout = setTimeout(() => {
175
- this.#writeTimeout = null;
176
- if(!this.finished && !this.aborted) this._res.cork(() => {
177
- if(this.#pendingChunks.length) {
178
- const size = this.#pendingChunks.reduce((acc, chunk) => acc + chunk.byteLength, 0);
179
- this._res.write(Buffer.concat(this.#pendingChunks, size));
180
- this.#pendingChunks = [];
181
- this.#lastWriteChunkTime = performance.now();
182
- }
183
- });
184
- }, 50);
185
- this.#writeTimeout.unref();
158
+ const ok = this._res.write(chunk);
159
+ if (ok) {
160
+ this.writingChunk = false;
161
+ callback(null);
162
+ } else {
163
+ this.#pendingCallback = callback;
164
+ this._res.onWritable(() => {
165
+ if (this.aborted || this.finished) return true;
166
+ const cb = this.#pendingCallback;
167
+ this.#pendingCallback = null;
168
+ this.writingChunk = false;
169
+ if (cb) cb(null);
170
+ return true;
171
+ });
186
172
  }
187
- this.writingChunk = false;
188
- callback(null);
189
173
  } else {
190
174
  const lastOffset = this._res.getWriteOffset();
191
175
  const [ok, done] = this._res.tryEnd(chunk, this.totalSize);
@@ -263,7 +247,7 @@ module.exports = class Response extends Writable {
263
247
  return this;
264
248
  }
265
249
  sendStatus(code) {
266
- return this.status(code).send(statuses.message[+code] ?? code.toString());
250
+ return this.status(code).type('txt').send(statuses.message[code] || String(code));
267
251
  }
268
252
  end(data, cb) {
269
253
  if(typeof data === 'function') {
@@ -311,11 +295,6 @@ module.exports = class Response extends Writable {
311
295
  if(!data && contentLength) {
312
296
  this._res.endWithoutBody(contentLength.toString());
313
297
  } else {
314
- if(this.#pendingChunks.length) {
315
- this._res.write(Buffer.concat(this.#pendingChunks));
316
- this.#pendingChunks = [];
317
- this.lastWriteChunkTime = 0;
318
- }
319
298
  if(data instanceof Buffer) {
320
299
  data = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
321
300
  }
@@ -724,8 +703,10 @@ module.exports = class Response extends Writable {
724
703
  return this.cookie(name, '', opts);
725
704
  }
726
705
  attachment(filename) {
727
- this.headers['Content-Disposition'] = `attachment; filename="${filename}"`;
728
- this.type(filename.split('.').pop());
706
+ if(filename) {
707
+ this.type(Path.extname(filename));
708
+ }
709
+ this.set('Content-Disposition', contentDisposition(filename));
729
710
  return this;
730
711
  }
731
712
  format(object) {
@@ -740,7 +721,7 @@ module.exports = class Response extends Writable {
740
721
  } else if(object.default) {
741
722
  object.default(this.req, this, this.req.next);
742
723
  } else {
743
- this.status(406).send(this.app._generateErrorPage('Not Acceptable'));
724
+ this.status(406).send(this.app._generateErrorPage('Not Acceptable', this.statusCode, false));
744
725
  }
745
726
 
746
727
  return this;
@@ -787,8 +768,13 @@ module.exports = class Response extends Writable {
787
768
  return this.send(body);
788
769
  }
789
770
  links(links) {
790
- this.headers['link'] = Object.entries(links).map(([rel, url]) => `<${url}>; rel="${rel}"`).join(', ');
791
- return this;
771
+ // this.headers['link'] = Object.entries(links).map(([rel, url]) => `<${url}>; rel="${rel}"`).join(', ');
772
+ // return this;
773
+ let link = this.get('Link') || '';
774
+ if(link) link += ', ';
775
+ return this.set('Link', link + Object.keys(links).map(function(rel){
776
+ return '<' + links[rel] + '>; rel="' + rel + '"';
777
+ }).join(', '));
792
778
  }
793
779
  location(path) {
794
780
  if(path === 'back') {
@@ -796,7 +782,8 @@ module.exports = class Response extends Writable {
796
782
  if(!path) path = this.req.get('Referer');
797
783
  if(!path) path = '/';
798
784
  }
799
- return this.headers['location'] = encodeUrl(path);
785
+ this.headers['location'] = encodeUrl(path);
786
+ return this;
800
787
  }
801
788
  redirect(status, url, forceHtml = false) {
802
789
  if(typeof status !== 'number' && !url) {
@@ -805,6 +792,8 @@ module.exports = class Response extends Writable {
805
792
  }
806
793
  this.location(url);
807
794
  this.status(status);
795
+
796
+ const address = this.get('Location');
808
797
  let body;
809
798
  // Support text/{plain,html} by default
810
799
  if(forceHtml) {
@@ -817,18 +806,18 @@ module.exports = class Response extends Writable {
817
806
  '<title>Redirecting</title>\n' +
818
807
  '</head>\n' +
819
808
  '<body>\n' +
820
- `<pre>Redirecting to ${url.replaceAll("<", "&lt;").replaceAll(">", "&gt;")}</pre>\n` +
809
+ `<pre>Redirecting to ${escapeHtml(address)}</pre>\n` +
821
810
  '</body>\n' +
822
811
  '</html>\n';
823
812
  } else {
824
813
  this.format({
825
814
  text: () => {
826
815
  this.set('Content-Type', 'text/plain; charset=UTF-8');
827
- body = statuses.message[status] + '. Redirecting to ' + url
816
+ body = `${statuses.message[status]}. Redirecting to ${address}`;
828
817
  },
829
818
  html: () => {
830
819
  this.set('Content-Type', 'text/html; charset=UTF-8');
831
- body = `<p>${statuses.message[status]}. Redirecting to ${url.replaceAll("<", "&lt;").replaceAll(">", "&gt;")}</p>`;
820
+ body = `<p>${statuses.message[status]}. Redirecting to ${escapeHtml(address)}</p>`;
832
821
  },
833
822
  default: () => {
834
823
  this.set('Content-Type', 'text/plain; charset=UTF-8');
package/src/router.js CHANGED
@@ -35,10 +35,23 @@ const methods = [
35
35
  'search', 'subscribe', 'unsubscribe', 'report', 'mkactivity', 'mkcalendar',
36
36
  'checkout', 'merge', 'm-search', 'notify', 'subscribe', 'unsubscribe', 'search'
37
37
  ];
38
- const supportedUwsMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'CONNECT', 'TRACE'];
38
+ const supportedUwsMethods = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'CONNECT', 'TRACE']);
39
39
 
40
40
  const regExParam = /:(\w+)/g;
41
41
 
42
+ function generateErrorPageHtml(err) {
43
+ return `<!DOCTYPE html>\n` +
44
+ `<html lang="en">\n` +
45
+ `<head>\n` +
46
+ `<meta charset="utf-8">\n` +
47
+ `<title>Error</title>\n` +
48
+ `</head>\n` +
49
+ `<body>\n` +
50
+ `<pre>${err?.stack ?? err}</pre>\n` +
51
+ `</body>\n` +
52
+ `</html>\n`;
53
+ }
54
+
42
55
  module.exports = class Router extends EventEmitter {
43
56
  parent;
44
57
  listenCalled;
@@ -162,7 +175,7 @@ module.exports = class Router extends EventEmitter {
162
175
  routes.push(route);
163
176
  // normal routes optimization
164
177
  if(canBeOptimized(route.path) && route.pattern !== '/*' && !this.parent && this.get('case sensitive routing') && this.uwsApp) {
165
- if(supportedUwsMethods.includes(method)) {
178
+ if(supportedUwsMethods.has(method)) {
166
179
  const optimizedPath = this._optimizeRoute(route, this._routes);
167
180
  if(optimizedPath) {
168
181
  this._registerUwsRoute(route, optimizedPath);
@@ -192,7 +205,7 @@ module.exports = class Router extends EventEmitter {
192
205
  return; // can only optimize router whos parent is listening
193
206
  }
194
207
  for(let cbroute of callback._routes) {
195
- if(!needsConversionToRegex(cbroute.path) && cbroute.path !== '/*' && supportedUwsMethods.includes(cbroute.method)) {
208
+ if(!needsConversionToRegex(cbroute.path) && cbroute.path !== '/*' && supportedUwsMethods.has(cbroute.method)) {
196
209
  let optimizedRouterPath = this._optimizeRoute(cbroute, callback._routes);
197
210
  if(optimizedRouterPath) {
198
211
  optimizedRouterPath = optimizedRouterPath.slice(0, -1);
@@ -363,6 +376,13 @@ module.exports = class Router extends EventEmitter {
363
376
  this._sendErrorPage(request, response, err, true);
364
377
  }
365
378
 
379
+ _generateErrorPage(err, statusCode, checkEnv = false) {
380
+ if(checkEnv && this.get('env') === 'production') {
381
+ err = statusCode >= 400 ? (statuses.message[statusCode] ?? 'Internal Server Error') : 'Internal Server Error';
382
+ }
383
+ return generateErrorPageHtml(err);
384
+ }
385
+
366
386
  _extractParams(pattern, path) {
367
387
  if(path.endsWith('/')) {
368
388
  path = path.slice(0, -1);
@@ -647,22 +667,11 @@ module.exports = class Router extends EventEmitter {
647
667
  }
648
668
 
649
669
  _sendErrorPage(request, response, err, checkEnv = false) {
650
- if(checkEnv && this.get('env') === 'production') {
651
- err = response.statusCode >= 400 ? (statuses.message[response.statusCode] ?? 'Internal Server Error') : 'Internal Server Error';
652
- }
670
+ err = this._generateErrorPage(err, response.statusCode, checkEnv);
653
671
  request.noEtag = true;
654
672
  response.setHeader('Content-Type', 'text/html; charset=utf-8');
655
673
  response.setHeader('X-Content-Type-Options', 'nosniff');
656
674
  response.setHeader('Content-Security-Policy', "default-src 'none'");
657
- response.send(`<!DOCTYPE html>\n` +
658
- `<html lang="en">\n` +
659
- `<head>\n` +
660
- `<meta charset="utf-8">\n` +
661
- `<title>Error</title>\n` +
662
- `</head>\n` +
663
- `<body>\n` +
664
- `<pre>${err?.stack ?? err}</pre>\n` +
665
- `</body>\n` +
666
- `</html>\n`);
675
+ response.send(err);
667
676
  }
668
677
  }
package/src/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  declare module "ultimate-express" {
2
- import e from "@types/express";
2
+ import e from "express";
3
3
  import uWS from "uWebSockets.js";
4
4
 
5
5
  type Settings = {
@@ -19,7 +19,7 @@ declare module "ultimate-express" {
19
19
  export import response = e.response;
20
20
 
21
21
  export import static = e.static;
22
- // export import query = e.query;
22
+ // export import query = e.query;
23
23
 
24
24
  export import urlencoded = e.urlencoded;
25
25
 
@@ -45,11 +45,15 @@ declare module "ultimate-express" {
45
45
  export import Send = e.Send;
46
46
  }
47
47
 
48
+ type UltimateExpressListen = ReturnType<e.Express['listen']> & {
49
+ uwsApp: uWS.TemplatedApp;
50
+ };
51
+
48
52
  type UltimateExpress = Omit<e.Express, 'listen'> & {
49
53
  readonly uwsApp: uWS.TemplatedApp;
50
- listen(port: number, callback?: (token: any) => void): uWS.TemplatedApp;
51
- listen(port: number, host: string, callback?: (token: any) => void): uWS.TemplatedApp;
52
- listen(callback: (token: any) => void): uWS.TemplatedApp;
54
+ listen(port: number, callback?: (token: any) => void): UltimateExpressListen;
55
+ listen(port: number, host: string, callback?: (token: any) => void): UltimateExpressListen;
56
+ listen(callback: (token: any) => void): UltimateExpressListen;
53
57
  };
54
58
 
55
59
  function express(settings?: Settings): UltimateExpress;
package/src/utils.js CHANGED
@@ -340,6 +340,52 @@ function isRangeFresh(req, res) {
340
340
  return parseHttpDate(lastModified) <= parseHttpDate(ifRange);
341
341
  }
342
342
 
343
+ function escapeHtml(str) {
344
+ const s = String(str);
345
+ const len = s.length;
346
+ let i = 0;
347
+
348
+ // Fast scan: find first char that needs escaping
349
+ for(; i < len; i++) {
350
+ const ch = s.charCodeAt(i);
351
+ if(ch === 0x26 || ch === 0x3C || ch === 0x3E || ch === 0x22 || ch === 0x27) {
352
+ break;
353
+ }
354
+ }
355
+
356
+ // No escaping needed
357
+ if(i === len) return s;
358
+
359
+ // Build escaped string from the first match onward
360
+ let escaped = s.substring(0, i);
361
+
362
+ for(; i < len; i++) {
363
+ const ch = s.charCodeAt(i);
364
+ switch(ch) {
365
+ case 0x26: // &
366
+ escaped += '&amp;';
367
+ break;
368
+ case 0x3C: // <
369
+ escaped += '&lt;';
370
+ break;
371
+ case 0x3E: // >
372
+ escaped += '&gt;';
373
+ break;
374
+ case 0x22: // "
375
+ escaped += '&quot;';
376
+ break;
377
+ case 0x27: // '
378
+ escaped += '&#39;';
379
+ break;
380
+ default:
381
+ escaped += s.charAt(i);
382
+ break;
383
+ }
384
+ }
385
+
386
+ return escaped;
387
+ }
388
+
343
389
  // fast null object
344
390
  const NullObject = function() {};
345
391
  NullObject.prototype = Object.create(null);
@@ -366,5 +412,6 @@ module.exports = {
366
412
  findIndexStartingFrom,
367
413
  fastQueryParse,
368
414
  canBeOptimized,
415
+ escapeHtml,
369
416
  EMPTY_REGEX
370
417
  };