@morojs/moro 1.7.29 → 1.8.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 +89 -37
- package/dist/core/config/config-sources.js +114 -2
- package/dist/core/config/config-sources.js.map +1 -1
- package/dist/core/config/config-validator.js +187 -5
- package/dist/core/config/config-validator.js.map +1 -1
- package/dist/core/config/schema.js +31 -3
- package/dist/core/config/schema.js.map +1 -1
- package/dist/core/framework.d.ts +41 -2
- package/dist/core/framework.js +338 -73
- package/dist/core/framework.js.map +1 -1
- package/dist/core/http/http-server.d.ts +21 -22
- package/dist/core/http/http-server.js +268 -226
- package/dist/core/http/http-server.js.map +1 -1
- package/dist/core/http/http2-server.d.ts +6 -16
- package/dist/core/http/http2-server.js +14 -79
- package/dist/core/http/http2-server.js.map +1 -1
- package/dist/core/http/index.d.ts +1 -0
- package/dist/core/http/index.js +3 -0
- package/dist/core/http/index.js.map +1 -1
- package/dist/core/http/moro-engine-server.d.ts +263 -0
- package/dist/core/http/moro-engine-server.js +1939 -0
- package/dist/core/http/moro-engine-server.js.map +1 -0
- package/dist/core/http/utils/compression.d.ts +47 -0
- package/dist/core/http/utils/compression.js +132 -0
- package/dist/core/http/utils/compression.js.map +1 -0
- package/dist/core/http/utils/lazy-event-emitter.d.ts +11 -0
- package/dist/core/http/utils/lazy-event-emitter.js +69 -0
- package/dist/core/http/utils/lazy-event-emitter.js.map +1 -0
- package/dist/core/http/utils/multipart-parser.d.ts +32 -0
- package/dist/core/http/utils/multipart-parser.js +211 -0
- package/dist/core/http/utils/multipart-parser.js.map +1 -0
- package/dist/core/http/utils/query-parser.d.ts +1 -0
- package/dist/core/http/utils/query-parser.js +44 -0
- package/dist/core/http/utils/query-parser.js.map +1 -0
- package/dist/core/http/utils/size.d.ts +33 -0
- package/dist/core/http/utils/size.js +37 -0
- package/dist/core/http/utils/size.js.map +1 -0
- package/dist/core/http/utils/ssl-config.d.ts +69 -0
- package/dist/core/http/utils/ssl-config.js +116 -0
- package/dist/core/http/utils/ssl-config.js.map +1 -0
- package/dist/core/http/utils/uws-worker-clustering.js +8 -2
- package/dist/core/http/utils/uws-worker-clustering.js.map +1 -1
- package/dist/core/http/uws-http-server.d.ts +24 -3
- package/dist/core/http/uws-http-server.js +386 -65
- package/dist/core/http/uws-http-server.js.map +1 -1
- package/dist/core/middleware/built-in/compression/core.d.ts +3 -0
- package/dist/core/middleware/built-in/compression/core.js +28 -34
- package/dist/core/middleware/built-in/compression/core.js.map +1 -1
- package/dist/core/networking/adapters/engine-adapter.d.ts +23 -0
- package/dist/core/networking/adapters/engine-adapter.js +365 -0
- package/dist/core/networking/adapters/engine-adapter.js.map +1 -0
- package/dist/core/networking/adapters/index.d.ts +1 -0
- package/dist/core/networking/adapters/index.js +1 -0
- package/dist/core/networking/adapters/index.js.map +1 -1
- package/dist/core/networking/adapters/uws-adapter.js +26 -15
- package/dist/core/networking/adapters/uws-adapter.js.map +1 -1
- package/dist/core/utilities/package-utils.d.ts +68 -0
- package/dist/core/utilities/package-utils.js +198 -1
- package/dist/core/utilities/package-utils.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/moro.d.ts +2 -0
- package/dist/moro.js +63 -21
- package/dist/moro.js.map +1 -1
- package/dist/types/config.d.ts +90 -5
- package/package.json +4 -1
|
@@ -3,27 +3,13 @@
|
|
|
3
3
|
import cluster from 'cluster';
|
|
4
4
|
import { randomUUID } from 'crypto';
|
|
5
5
|
import { createFrameworkLogger } from '../logger/index.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const eqIdx = pair.indexOf('=');
|
|
14
|
-
if (eqIdx > 0) {
|
|
15
|
-
const key = pair.substring(0, eqIdx);
|
|
16
|
-
const value = pair.substring(eqIdx + 1);
|
|
17
|
-
try {
|
|
18
|
-
result[decodeURIComponent(key)] = decodeURIComponent(value);
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
result[key] = value;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return result;
|
|
26
|
-
}
|
|
6
|
+
import { loadNativeEngine, getNativeEngineLoadErrors } from '../utilities/package-utils.js';
|
|
7
|
+
import { parseMultipart as parseMultipartBuffer, } from './utils/multipart-parser.js';
|
|
8
|
+
import { LazyEventEmitter } from './utils/lazy-event-emitter.js';
|
|
9
|
+
import { parseRawQueryString } from './utils/query-parser.js';
|
|
10
|
+
import { resolveCompressionSettings, compressBuffer, isCompressible, negotiateEncoding, } from './utils/compression.js';
|
|
11
|
+
// Back-compat alias (this parser is now the transport-neutral one in utils/)
|
|
12
|
+
const parseUwsQueryString = parseRawQueryString;
|
|
27
13
|
/**
|
|
28
14
|
* Prototype-based request object for the uWS adapter.
|
|
29
15
|
*
|
|
@@ -33,7 +19,7 @@ function parseUwsQueryString(queryString) {
|
|
|
33
19
|
* so `materialize()` MUST be called before any `await` - it snapshots the
|
|
34
20
|
* headers and detaches the native reference.
|
|
35
21
|
*/
|
|
36
|
-
export class UwsRequest {
|
|
22
|
+
export class UwsRequest extends LazyEventEmitter {
|
|
37
23
|
method;
|
|
38
24
|
path;
|
|
39
25
|
url;
|
|
@@ -48,7 +34,9 @@ export class UwsRequest {
|
|
|
48
34
|
_cookies = undefined;
|
|
49
35
|
_context = undefined;
|
|
50
36
|
_requestId = undefined;
|
|
37
|
+
_socket = undefined;
|
|
51
38
|
constructor(server, uwsReq, method, url, queryString) {
|
|
39
|
+
super();
|
|
52
40
|
this._server = server;
|
|
53
41
|
this._uwsReq = uwsReq;
|
|
54
42
|
this.method = method;
|
|
@@ -56,6 +44,35 @@ export class UwsRequest {
|
|
|
56
44
|
this.url = url;
|
|
57
45
|
this._queryString = queryString || null;
|
|
58
46
|
}
|
|
47
|
+
// net.Socket-shaped shim so Node-style middleware (CSRF's req.socket.encrypted,
|
|
48
|
+
// rate-limiters' req.connection.remoteAddress) doesn't throw on this transport.
|
|
49
|
+
// uWS terminates plaintext HTTP here (TLS via its own listen options or a
|
|
50
|
+
// proxy); scheme detection should use x-forwarded-proto.
|
|
51
|
+
get socket() {
|
|
52
|
+
let s = this._socket;
|
|
53
|
+
if (s === undefined) {
|
|
54
|
+
const getIp = () => this.ip; // arrow captures this lexically (no this-alias)
|
|
55
|
+
s = {
|
|
56
|
+
get remoteAddress() {
|
|
57
|
+
return getIp() || undefined;
|
|
58
|
+
},
|
|
59
|
+
remotePort: undefined,
|
|
60
|
+
encrypted: false,
|
|
61
|
+
destroyed: false,
|
|
62
|
+
};
|
|
63
|
+
this._socket = s;
|
|
64
|
+
}
|
|
65
|
+
return s;
|
|
66
|
+
}
|
|
67
|
+
set socket(value) {
|
|
68
|
+
this._socket = value;
|
|
69
|
+
}
|
|
70
|
+
get connection() {
|
|
71
|
+
return this.socket;
|
|
72
|
+
}
|
|
73
|
+
set connection(value) {
|
|
74
|
+
this._socket = value;
|
|
75
|
+
}
|
|
59
76
|
/** Snapshot uWS-backed data and detach the native request. Idempotent.
|
|
60
77
|
* Must be called before returning control to the event loop if the
|
|
61
78
|
* request object outlives the synchronous route callback. */
|
|
@@ -270,6 +287,8 @@ export class UwsRequest {
|
|
|
270
287
|
export class UWebSocketsHttpServer {
|
|
271
288
|
app; // uWebSockets app instance
|
|
272
289
|
uws; // uWebSockets module reference (stored to avoid re-importing)
|
|
290
|
+
_limits;
|
|
291
|
+
multipartLimits;
|
|
273
292
|
listenSocket; // uWebSockets listen socket
|
|
274
293
|
globalMiddleware = [];
|
|
275
294
|
logger = createFrameworkLogger('UWSHttpServer');
|
|
@@ -327,6 +346,9 @@ export class UWebSocketsHttpServer {
|
|
|
327
346
|
this.maxBodySize = options.maxBodySize;
|
|
328
347
|
if (options.maxUploadSize)
|
|
329
348
|
this.maxUploadSize = options.maxUploadSize;
|
|
349
|
+
this._limits = options.limits;
|
|
350
|
+
if (options.limits?.multipart)
|
|
351
|
+
this.multipartLimits = options.limits.multipart;
|
|
330
352
|
this.initPromise = this.initialize(options);
|
|
331
353
|
}
|
|
332
354
|
// Direct router dispatch - called after global middleware without the
|
|
@@ -344,14 +366,29 @@ export class UWebSocketsHttpServer {
|
|
|
344
366
|
}
|
|
345
367
|
async initialize(options) {
|
|
346
368
|
try {
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
369
|
+
// The framework preloads the engine synchronously (loadNativeEngine) and
|
|
370
|
+
// injects it; loading here directly covers standalone construction.
|
|
371
|
+
// This server is uWS-shaped only: never probe @morojs/engine, whose
|
|
372
|
+
// Moro-shaped module would pass a generic load but has no App().
|
|
373
|
+
const uwsModule = options.engineModule ?? loadNativeEngine({ candidates: ['uWebSockets.js'] })?.module;
|
|
374
|
+
if (!uwsModule) {
|
|
375
|
+
throw new Error('uWebSockets.js is not available: ' +
|
|
376
|
+
(getNativeEngineLoadErrors().join('; ') || 'not installed'));
|
|
377
|
+
}
|
|
350
378
|
this.uws = uwsModule.default || uwsModule;
|
|
379
|
+
// A TLS config that was given only as inline PEM cannot be honored by
|
|
380
|
+
// uWS (it requires on-disk file paths). Surface it loudly rather than
|
|
381
|
+
// silently booting plain HTTP (the historical footgun).
|
|
382
|
+
if (options.sslInlineOnly) {
|
|
383
|
+
this.logger.error('uWebSockets.js requires TLS key/cert as FILE PATHS (keyFile/certFile ' +
|
|
384
|
+
'or key_file_name/cert_file_name); inline PEM is not supported. ' +
|
|
385
|
+
'Booting WITHOUT TLS - provide file paths or use engine: "moro"/"node".', 'Init');
|
|
386
|
+
}
|
|
351
387
|
if (options.ssl && options.ssl.key_file_name && options.ssl.cert_file_name) {
|
|
352
388
|
this.app = this.uws.SSLApp({
|
|
353
389
|
key_file_name: options.ssl.key_file_name,
|
|
354
390
|
cert_file_name: options.ssl.cert_file_name,
|
|
391
|
+
...(options.ssl.ca_file_name && { ca_file_name: options.ssl.ca_file_name }),
|
|
355
392
|
passphrase: options.ssl.passphrase,
|
|
356
393
|
});
|
|
357
394
|
this.isSsl = true;
|
|
@@ -361,15 +398,32 @@ export class UWebSocketsHttpServer {
|
|
|
361
398
|
this.app = this.uws.App();
|
|
362
399
|
this.logger.info('uWebSockets HTTP server created', 'Init');
|
|
363
400
|
}
|
|
401
|
+
// uWS has no knobs for TCP timeouts/backlog/maxConnections/maxHeaderSize;
|
|
402
|
+
// if the user set any, say so once at startup rather than silently
|
|
403
|
+
// ignoring them (the "no arbitrary caps" contract cuts both ways).
|
|
404
|
+
const ignored = [];
|
|
405
|
+
const t = options.limits?.timeouts ?? {};
|
|
406
|
+
if (t.request || t.idle || t.keepAlive || t.headers)
|
|
407
|
+
ignored.push('timeouts');
|
|
408
|
+
if (options.limits?.maxConnections)
|
|
409
|
+
ignored.push('maxConnections');
|
|
410
|
+
if (options.limits?.backlog)
|
|
411
|
+
ignored.push('backlog');
|
|
412
|
+
if (options.limits?.maxHeaderSize || options.limits?.maxHeaders)
|
|
413
|
+
ignored.push('header limits');
|
|
414
|
+
if (ignored.length) {
|
|
415
|
+
this.logger.info(`uWebSockets.js does not support these server options; they are ignored: ${ignored.join(', ')}`, 'Init');
|
|
416
|
+
}
|
|
364
417
|
// Setup generic route handler for all HTTP methods and paths
|
|
365
418
|
this.setupRouteHandlers();
|
|
366
419
|
}
|
|
367
420
|
catch (error) {
|
|
368
421
|
// Log helpful error message with installation instructions
|
|
369
|
-
this.logger.error('Failed to load
|
|
370
|
-
'
|
|
371
|
-
' npm install
|
|
372
|
-
'
|
|
422
|
+
this.logger.error('Failed to load the native HTTP engine\n' +
|
|
423
|
+
'Install it with:\n' +
|
|
424
|
+
' npm install @morojs/engine\n' +
|
|
425
|
+
'(or the legacy engine: npm install --save-dev github:uNetworking/uWebSockets.js#v20.52.0)\n' +
|
|
426
|
+
"Or set engine: 'node' in your server config to use the standard HTTP server.\n" +
|
|
373
427
|
'Error: ' +
|
|
374
428
|
(error instanceof Error ? error.message : String(error)), 'Init');
|
|
375
429
|
throw error; // Re-throw so framework.ts can catch and fallback
|
|
@@ -433,16 +487,21 @@ export class UWebSocketsHttpServer {
|
|
|
433
487
|
}
|
|
434
488
|
httpReq.params = params;
|
|
435
489
|
}
|
|
436
|
-
const httpRes = new UWebSocketsHttpServer.ResponsePrototype().init(res, req, this.logger);
|
|
490
|
+
const httpRes = new UWebSocketsHttpServer.ResponsePrototype().init(res, req, this.logger, this._compression);
|
|
491
|
+
httpRes._moroReq = httpReq;
|
|
437
492
|
if (needsBody) {
|
|
438
493
|
// Body routes always go async: snapshot headers, arm the abort hook,
|
|
439
494
|
// read the body, then run the handler
|
|
440
495
|
httpReq.materialize();
|
|
496
|
+
res._abortArmed = true;
|
|
441
497
|
res.onAborted(() => {
|
|
442
498
|
res.aborted = true;
|
|
443
499
|
const hook = res._abortHook;
|
|
444
500
|
if (hook)
|
|
445
501
|
hook();
|
|
502
|
+
const moroRes = res._moroRes;
|
|
503
|
+
if (moroRes)
|
|
504
|
+
moroRes._handleAbort();
|
|
446
505
|
});
|
|
447
506
|
const contentLength = req.getHeader('content-length');
|
|
448
507
|
const bodyPromise = contentLength && parseInt(contentLength) > 0
|
|
@@ -471,11 +530,15 @@ export class UWebSocketsHttpServer {
|
|
|
471
530
|
// Async handler: it is suspended at its first await - snapshot
|
|
472
531
|
// uWS-backed data and arm abort tracking before returning to the loop
|
|
473
532
|
httpReq.materialize();
|
|
533
|
+
res._abortArmed = true;
|
|
474
534
|
res.onAborted(() => {
|
|
475
535
|
res.aborted = true;
|
|
476
536
|
const hook = res._abortHook;
|
|
477
537
|
if (hook)
|
|
478
538
|
hook();
|
|
539
|
+
const moroRes = res._moroRes;
|
|
540
|
+
if (moroRes)
|
|
541
|
+
moroRes._handleAbort();
|
|
479
542
|
});
|
|
480
543
|
result.then(r => {
|
|
481
544
|
if (r !== undefined && !httpRes.headersSent && !res.aborted)
|
|
@@ -486,6 +549,23 @@ export class UWebSocketsHttpServer {
|
|
|
486
549
|
// Fully synchronous: single corked write, no header copy, no promise
|
|
487
550
|
httpRes.json(result);
|
|
488
551
|
}
|
|
552
|
+
// A synchronous handler that returned with the response still open
|
|
553
|
+
// (e.g. SSE: writeHead + periodic write) is about to hand an unfinished
|
|
554
|
+
// uWS response back to the event loop - uWS forbids that without an
|
|
555
|
+
// abort handler, and without one client disconnects are never noticed.
|
|
556
|
+
if (!httpRes.finished && !res.aborted && !res._abortArmed) {
|
|
557
|
+
res._abortArmed = true;
|
|
558
|
+
httpReq.materialize();
|
|
559
|
+
res.onAborted(() => {
|
|
560
|
+
res.aborted = true;
|
|
561
|
+
const hook = res._abortHook;
|
|
562
|
+
if (hook)
|
|
563
|
+
hook();
|
|
564
|
+
const moroRes = res._moroRes;
|
|
565
|
+
if (moroRes)
|
|
566
|
+
moroRes._handleAbort();
|
|
567
|
+
});
|
|
568
|
+
}
|
|
489
569
|
}
|
|
490
570
|
catch (err) {
|
|
491
571
|
this.handleNativeRouteError(err, httpReq, httpRes, res);
|
|
@@ -498,6 +578,12 @@ export class UWebSocketsHttpServer {
|
|
|
498
578
|
httpRes.end('{"success":false,"error":"Request entity too large"}');
|
|
499
579
|
return;
|
|
500
580
|
}
|
|
581
|
+
if (err?.statusCode === 400 && !res.aborted && !httpRes.headersSent) {
|
|
582
|
+
httpRes.statusCode = 400;
|
|
583
|
+
httpRes.setHeader('Content-Type', 'application/json');
|
|
584
|
+
httpRes.end('{"success":false,"error":"Invalid request body"}');
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
501
587
|
const sendFallback = () => {
|
|
502
588
|
if (!res.aborted && !httpRes.headersSent) {
|
|
503
589
|
// Same shape as the unified router's fast-path error response
|
|
@@ -527,6 +613,10 @@ export class UWebSocketsHttpServer {
|
|
|
527
613
|
const hook = res._abortHook;
|
|
528
614
|
if (hook)
|
|
529
615
|
hook();
|
|
616
|
+
// Fire 'close'/'aborted' on the moro wrappers (SSE/monitor cleanup)
|
|
617
|
+
const moroRes = res._moroRes;
|
|
618
|
+
if (moroRes)
|
|
619
|
+
moroRes._handleAbort();
|
|
530
620
|
});
|
|
531
621
|
let httpReq;
|
|
532
622
|
let httpRes;
|
|
@@ -534,6 +624,7 @@ export class UWebSocketsHttpServer {
|
|
|
534
624
|
// Create Moro-compatible request/response objects
|
|
535
625
|
httpReq = this.createMoroRequest(req, res);
|
|
536
626
|
httpRes = this.createMoroResponse(req, res);
|
|
627
|
+
httpRes._moroReq = httpReq;
|
|
537
628
|
const method = httpReq.method;
|
|
538
629
|
// Parse body only if there's actually a body (check content-length).
|
|
539
630
|
// Check first char for early exit (all body methods start with 'P')
|
|
@@ -594,6 +685,13 @@ export class UWebSocketsHttpServer {
|
|
|
594
685
|
httpRes.end('{"success":false,"error":"Request entity too large"}');
|
|
595
686
|
return;
|
|
596
687
|
}
|
|
688
|
+
// Malformed body: a client error (400), not a server error (500)
|
|
689
|
+
if (error?.statusCode === 400 && !res.aborted && httpRes && !httpRes.headersSent) {
|
|
690
|
+
httpRes.statusCode = 400;
|
|
691
|
+
httpRes.setHeader('Content-Type', 'application/json');
|
|
692
|
+
httpRes.end('{"success":false,"error":"Invalid request body"}');
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
597
695
|
this.logger.error(`Request handling error: ${error instanceof Error ? error.message : String(error)}`, 'RequestError');
|
|
598
696
|
// Send error response if not already sent
|
|
599
697
|
if (!res.aborted) {
|
|
@@ -633,14 +731,25 @@ export class UWebSocketsHttpServer {
|
|
|
633
731
|
return UWebSocketsHttpServer.STATUS_STRINGS.get(code) || `${code} OK`;
|
|
634
732
|
}
|
|
635
733
|
// Pre-define methods on prototype instead of creating new closures for each request
|
|
636
|
-
static ResponsePrototype = class {
|
|
734
|
+
static ResponsePrototype = class extends LazyEventEmitter {
|
|
637
735
|
headersSent = false;
|
|
638
736
|
statusCode = 200;
|
|
639
737
|
responseHeaders = {};
|
|
640
738
|
_res;
|
|
641
739
|
_req;
|
|
642
740
|
_logger;
|
|
643
|
-
|
|
741
|
+
// Terminal-write latch: headersSent means "head flushed" (true mid-stream
|
|
742
|
+
// after writeHead/write), _ended means the body is complete
|
|
743
|
+
_ended = false;
|
|
744
|
+
_drainArmed = false;
|
|
745
|
+
// Accept-Encoding captured eagerly (uWS req is valid only synchronously)
|
|
746
|
+
// and the server's compression settings, for buffered-response compression.
|
|
747
|
+
_acceptEncoding = undefined;
|
|
748
|
+
_compression = undefined;
|
|
749
|
+
// The UwsRequest wrapper, linked by the server so lifecycle events reach
|
|
750
|
+
// req.on('close') listeners (SSE cleanup, monitors)
|
|
751
|
+
_moroReq = null;
|
|
752
|
+
init(res, req, logger, compression) {
|
|
644
753
|
this.headersSent = false;
|
|
645
754
|
this.statusCode = 200;
|
|
646
755
|
// Fresh object (a delete-loop reset would push the object into V8
|
|
@@ -649,8 +758,122 @@ export class UWebSocketsHttpServer {
|
|
|
649
758
|
this._res = res;
|
|
650
759
|
this._req = req;
|
|
651
760
|
this._logger = logger;
|
|
761
|
+
this._ended = false;
|
|
762
|
+
this._drainArmed = false;
|
|
763
|
+
this._compression = compression;
|
|
764
|
+
// Capture Accept-Encoding now: the native req is valid only during the
|
|
765
|
+
// synchronous handler entry, but compression resolves asynchronously.
|
|
766
|
+
this._acceptEncoding =
|
|
767
|
+
compression?.enabled && req ? req.getHeader('accept-encoding') || undefined : undefined;
|
|
768
|
+
// Route client aborts to the wrapper so 'close' fires (see onAborted)
|
|
769
|
+
res._moroRes = this;
|
|
652
770
|
return this;
|
|
653
771
|
}
|
|
772
|
+
// Buffered-response compression for uWS (parity with the engine/Node
|
|
773
|
+
// paths). Returns true when it took over the write (async), false when the
|
|
774
|
+
// caller should proceed with its normal synchronous cork+end.
|
|
775
|
+
_tryCompressedEnd(body, contentType) {
|
|
776
|
+
const s = this._compression;
|
|
777
|
+
if (!s || !s.enabled)
|
|
778
|
+
return false;
|
|
779
|
+
const bytes = typeof body === 'string' ? Buffer.byteLength(body) : body.length;
|
|
780
|
+
if (bytes < s.threshold)
|
|
781
|
+
return false;
|
|
782
|
+
if ('content-encoding' in this.responseHeaders)
|
|
783
|
+
return false;
|
|
784
|
+
if (this.statusCode === 204 || this.statusCode === 304)
|
|
785
|
+
return false;
|
|
786
|
+
const ctHeader = this.responseHeaders['content-type'];
|
|
787
|
+
const ct = contentType ?? (Array.isArray(ctHeader) ? ctHeader[0] : ctHeader);
|
|
788
|
+
if (!isCompressible(ct))
|
|
789
|
+
return false;
|
|
790
|
+
const encoding = negotiateEncoding(this._acceptEncoding, s.encodings);
|
|
791
|
+
if (!encoding)
|
|
792
|
+
return false;
|
|
793
|
+
void compressBuffer(body, encoding, s.level)
|
|
794
|
+
.then(compressed => {
|
|
795
|
+
if (this._ended || this._res.aborted)
|
|
796
|
+
return;
|
|
797
|
+
this.responseHeaders['content-encoding'] = encoding;
|
|
798
|
+
const vary = this.responseHeaders['vary'];
|
|
799
|
+
this.responseHeaders['vary'] = vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding';
|
|
800
|
+
this._res.cork(() => {
|
|
801
|
+
this._res.writeStatus(UWebSocketsHttpServer.getStatusString(this.statusCode));
|
|
802
|
+
UWebSocketsHttpServer.writeHeaders(this._res, this.responseHeaders);
|
|
803
|
+
this._res.end(compressed);
|
|
804
|
+
});
|
|
805
|
+
this.headersSent = true;
|
|
806
|
+
this._ended = true;
|
|
807
|
+
this._emitDone();
|
|
808
|
+
})
|
|
809
|
+
.catch((err) => {
|
|
810
|
+
this._logger?.error?.(`uWS response compression failed: ${err instanceof Error ? err.message : String(err)}`, 'Response');
|
|
811
|
+
if (this._ended || this._res.aborted)
|
|
812
|
+
return;
|
|
813
|
+
this._res.cork(() => {
|
|
814
|
+
this._res.writeStatus(UWebSocketsHttpServer.getStatusString(this.statusCode));
|
|
815
|
+
UWebSocketsHttpServer.writeHeaders(this._res, this.responseHeaders);
|
|
816
|
+
this._res.end(body);
|
|
817
|
+
});
|
|
818
|
+
this.headersSent = true;
|
|
819
|
+
this._ended = true;
|
|
820
|
+
this._emitDone();
|
|
821
|
+
});
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
824
|
+
get writableEnded() {
|
|
825
|
+
return this._ended;
|
|
826
|
+
}
|
|
827
|
+
get writableFinished() {
|
|
828
|
+
return this._ended;
|
|
829
|
+
}
|
|
830
|
+
get finished() {
|
|
831
|
+
return this._ended;
|
|
832
|
+
}
|
|
833
|
+
// Node ServerResponse lifecycle: 'finish' then 'close' after the terminal
|
|
834
|
+
// write; the request wrapper's 'close' fires too (IncomingMessage parity).
|
|
835
|
+
// No listeners -> two null checks, nothing else.
|
|
836
|
+
_emitDone() {
|
|
837
|
+
this._ended = true;
|
|
838
|
+
if (this._events) {
|
|
839
|
+
this.emit('finish');
|
|
840
|
+
this.emit('close');
|
|
841
|
+
}
|
|
842
|
+
const req = this._moroReq;
|
|
843
|
+
if (req && req._events)
|
|
844
|
+
req.emit('close');
|
|
845
|
+
}
|
|
846
|
+
// Client aborted: 'close' without 'finish' (Node semantics), plus
|
|
847
|
+
// 'aborted'/'close' on the request wrapper
|
|
848
|
+
_handleAbort() {
|
|
849
|
+
if (this._ended)
|
|
850
|
+
return;
|
|
851
|
+
this._ended = true;
|
|
852
|
+
if (this._events)
|
|
853
|
+
this.emit('close');
|
|
854
|
+
const req = this._moroReq;
|
|
855
|
+
if (req && req._events) {
|
|
856
|
+
req.emit('aborted');
|
|
857
|
+
req.emit('close');
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// Backpressure: surface uWS's onWritable as the 'drain' event pipe()
|
|
861
|
+
// and manual streaming loops wait on
|
|
862
|
+
_armDrain() {
|
|
863
|
+
if (this._drainArmed)
|
|
864
|
+
return;
|
|
865
|
+
this._drainArmed = true;
|
|
866
|
+
try {
|
|
867
|
+
this._res.onWritable(() => {
|
|
868
|
+
this._drainArmed = false;
|
|
869
|
+
this.emit('drain');
|
|
870
|
+
return true;
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
catch {
|
|
874
|
+
this._drainArmed = false;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
654
877
|
status(code) {
|
|
655
878
|
this.statusCode = code;
|
|
656
879
|
return this;
|
|
@@ -717,12 +940,17 @@ export class UWebSocketsHttpServer {
|
|
|
717
940
|
this.responseHeaders['content-type'] = 'application/json';
|
|
718
941
|
}
|
|
719
942
|
try {
|
|
943
|
+
// Compression (when enabled + compressible + accepted) takes over the
|
|
944
|
+
// write asynchronously; otherwise the synchronous corked path runs.
|
|
945
|
+
if (this._tryCompressedEnd(body, 'application/json'))
|
|
946
|
+
return;
|
|
720
947
|
this._res.cork(() => {
|
|
721
948
|
this._res.writeStatus(UWebSocketsHttpServer.getStatusString(this.statusCode));
|
|
722
949
|
UWebSocketsHttpServer.writeHeaders(this._res, this.responseHeaders);
|
|
723
950
|
this._res.end(body);
|
|
724
951
|
});
|
|
725
952
|
this.headersSent = true;
|
|
953
|
+
this._emitDone();
|
|
726
954
|
}
|
|
727
955
|
catch {
|
|
728
956
|
this._logger.error('Failed to send JSON response', 'ResponseError');
|
|
@@ -733,30 +961,103 @@ export class UWebSocketsHttpServer {
|
|
|
733
961
|
return;
|
|
734
962
|
const body = typeof data === 'string' ? data : data.toString();
|
|
735
963
|
try {
|
|
964
|
+
if (this._tryCompressedEnd(body, undefined))
|
|
965
|
+
return;
|
|
736
966
|
this._res.cork(() => {
|
|
737
967
|
this._res.writeStatus(UWebSocketsHttpServer.getStatusString(this.statusCode));
|
|
738
968
|
UWebSocketsHttpServer.writeHeaders(this._res, this.responseHeaders);
|
|
739
969
|
this._res.end(body);
|
|
740
970
|
});
|
|
741
971
|
this.headersSent = true;
|
|
972
|
+
this._emitDone();
|
|
742
973
|
}
|
|
743
974
|
catch {
|
|
744
975
|
this._logger.error('Failed to send response', 'ResponseError');
|
|
745
976
|
}
|
|
746
977
|
}
|
|
978
|
+
// Flush status + headers without ending the response (Node streaming
|
|
979
|
+
// entry point - SSE and manual chunked responses start here). Supports
|
|
980
|
+
// both (code, headers) and (code, statusMessage, headers) signatures;
|
|
981
|
+
// uWS derives the reason phrase from the status string, so a custom
|
|
982
|
+
// message is folded into it.
|
|
983
|
+
writeHead(statusCode, reasonOrHeaders, maybeHeaders) {
|
|
984
|
+
if (this.headersSent || this._res.aborted)
|
|
985
|
+
return this;
|
|
986
|
+
this.statusCode = statusCode;
|
|
987
|
+
const headers = reasonOrHeaders && typeof reasonOrHeaders === 'object' ? reasonOrHeaders : maybeHeaders;
|
|
988
|
+
if (headers) {
|
|
989
|
+
for (const key of Object.keys(headers)) {
|
|
990
|
+
this.setHeader(key, headers[key]);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
try {
|
|
994
|
+
this._res.cork(() => {
|
|
995
|
+
const status = typeof reasonOrHeaders === 'string'
|
|
996
|
+
? `${statusCode} ${reasonOrHeaders}`
|
|
997
|
+
: UWebSocketsHttpServer.getStatusString(statusCode);
|
|
998
|
+
this._res.writeStatus(status);
|
|
999
|
+
UWebSocketsHttpServer.writeHeaders(this._res, this.responseHeaders);
|
|
1000
|
+
});
|
|
1001
|
+
this.headersSent = true;
|
|
1002
|
+
}
|
|
1003
|
+
catch {
|
|
1004
|
+
this._logger.error('Failed to write response head', 'ResponseError');
|
|
1005
|
+
}
|
|
1006
|
+
return this;
|
|
1007
|
+
}
|
|
1008
|
+
// Stream a body chunk, flushing the head first if needed. Returns uWS's
|
|
1009
|
+
// backpressure signal like Node's Writable.write; 'drain' fires when the
|
|
1010
|
+
// socket can accept more.
|
|
1011
|
+
write(chunk, encoding, callback) {
|
|
1012
|
+
if (typeof encoding === 'function') {
|
|
1013
|
+
callback = encoding;
|
|
1014
|
+
encoding = undefined;
|
|
1015
|
+
}
|
|
1016
|
+
if (this._ended || this._res.aborted)
|
|
1017
|
+
return false;
|
|
1018
|
+
let ok = true;
|
|
1019
|
+
try {
|
|
1020
|
+
this._res.cork(() => {
|
|
1021
|
+
if (!this.headersSent) {
|
|
1022
|
+
this._res.writeStatus(UWebSocketsHttpServer.getStatusString(this.statusCode));
|
|
1023
|
+
UWebSocketsHttpServer.writeHeaders(this._res, this.responseHeaders);
|
|
1024
|
+
this.headersSent = true;
|
|
1025
|
+
}
|
|
1026
|
+
ok = this._res.write(chunk);
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
this._logger.error('Failed to write response chunk', 'ResponseError');
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
if (!ok)
|
|
1034
|
+
this._armDrain();
|
|
1035
|
+
if (typeof callback === 'function')
|
|
1036
|
+
callback();
|
|
1037
|
+
return ok;
|
|
1038
|
+
}
|
|
747
1039
|
end(data, encoding, callback) {
|
|
748
|
-
if (
|
|
1040
|
+
if (typeof encoding === 'function') {
|
|
1041
|
+
callback = encoding;
|
|
1042
|
+
encoding = undefined;
|
|
1043
|
+
}
|
|
1044
|
+
// Guard on _ended, not headersSent: in streaming mode (writeHead/write)
|
|
1045
|
+
// the head is already flushed and end() must still complete the body
|
|
1046
|
+
if (this._ended || this._res.aborted) {
|
|
749
1047
|
if (typeof callback === 'function')
|
|
750
1048
|
callback();
|
|
751
1049
|
return this;
|
|
752
1050
|
}
|
|
753
1051
|
try {
|
|
754
1052
|
this._res.cork(() => {
|
|
755
|
-
|
|
756
|
-
|
|
1053
|
+
if (!this.headersSent) {
|
|
1054
|
+
this._res.writeStatus(UWebSocketsHttpServer.getStatusString(this.statusCode));
|
|
1055
|
+
UWebSocketsHttpServer.writeHeaders(this._res, this.responseHeaders);
|
|
1056
|
+
this.headersSent = true;
|
|
1057
|
+
}
|
|
757
1058
|
this._res.end(data || '');
|
|
758
1059
|
});
|
|
759
|
-
this.
|
|
1060
|
+
this._emitDone();
|
|
760
1061
|
if (typeof callback === 'function')
|
|
761
1062
|
callback();
|
|
762
1063
|
}
|
|
@@ -780,6 +1081,7 @@ export class UWebSocketsHttpServer {
|
|
|
780
1081
|
this._res.end();
|
|
781
1082
|
});
|
|
782
1083
|
this.headersSent = true;
|
|
1084
|
+
this._emitDone();
|
|
783
1085
|
}
|
|
784
1086
|
catch {
|
|
785
1087
|
this._logger.error('Failed to send redirect', 'ResponseError');
|
|
@@ -918,32 +1220,25 @@ export class UWebSocketsHttpServer {
|
|
|
918
1220
|
},
|
|
919
1221
|
});
|
|
920
1222
|
}
|
|
921
|
-
// EventEmitter stubs for middleware compatibility
|
|
922
|
-
on(_event, _callback) {
|
|
923
|
-
return this;
|
|
924
|
-
}
|
|
925
|
-
once(_event, _callback) {
|
|
926
|
-
return this;
|
|
927
|
-
}
|
|
928
|
-
emit(_event, ..._args) {
|
|
929
|
-
return true;
|
|
930
|
-
}
|
|
931
|
-
removeListener(_event, _callback) {
|
|
932
|
-
return this;
|
|
933
|
-
}
|
|
934
1223
|
cookie(name, value, options) {
|
|
935
|
-
//
|
|
936
|
-
|
|
1224
|
+
// Percent-encode the value (matches the Node path and Express) so a value
|
|
1225
|
+
// containing ';'/',' can't inject cookie attributes and control chars
|
|
1226
|
+
// don't corrupt the Set-Cookie header.
|
|
1227
|
+
const parts = [name, '=', encodeURIComponent(value)];
|
|
937
1228
|
if (options) {
|
|
938
|
-
|
|
1229
|
+
// maxAge: 0 is meaningful (immediate expiry - clearCookie relies on it)
|
|
1230
|
+
if (options.maxAge !== undefined && options.maxAge !== null) {
|
|
939
1231
|
parts.push('; Max-Age=', String(options.maxAge));
|
|
940
1232
|
}
|
|
1233
|
+
if (options.expires) {
|
|
1234
|
+
const expires = options.expires instanceof Date
|
|
1235
|
+
? options.expires.toUTCString()
|
|
1236
|
+
: String(options.expires);
|
|
1237
|
+
parts.push('; Expires=', expires);
|
|
1238
|
+
}
|
|
941
1239
|
if (options.domain) {
|
|
942
1240
|
parts.push('; Domain=', options.domain);
|
|
943
1241
|
}
|
|
944
|
-
if (options.path) {
|
|
945
|
-
parts.push('; Path=', options.path);
|
|
946
|
-
}
|
|
947
1242
|
if (options.secure) {
|
|
948
1243
|
parts.push('; Secure');
|
|
949
1244
|
}
|
|
@@ -954,6 +1249,9 @@ export class UWebSocketsHttpServer {
|
|
|
954
1249
|
parts.push('; SameSite=', options.sameSite);
|
|
955
1250
|
}
|
|
956
1251
|
}
|
|
1252
|
+
// Path defaults to '/' (Express behavior) so clearCookie() from a nested
|
|
1253
|
+
// route can clear a cookie originally set at the site root.
|
|
1254
|
+
parts.push('; Path=', options?.path ?? '/');
|
|
957
1255
|
const cookie = parts.join('');
|
|
958
1256
|
const lowerKey = 'set-cookie';
|
|
959
1257
|
const existing = this.responseHeaders[lowerKey];
|
|
@@ -1109,8 +1407,8 @@ export class UWebSocketsHttpServer {
|
|
|
1109
1407
|
headersSent: this.headersSent,
|
|
1110
1408
|
statusCode: this.statusCode,
|
|
1111
1409
|
headers: this.responseHeaders,
|
|
1112
|
-
finished: this.
|
|
1113
|
-
writable: !this.
|
|
1410
|
+
finished: this._ended,
|
|
1411
|
+
writable: !this._ended,
|
|
1114
1412
|
};
|
|
1115
1413
|
}
|
|
1116
1414
|
};
|
|
@@ -1181,15 +1479,34 @@ export class UWebSocketsHttpServer {
|
|
|
1181
1479
|
});
|
|
1182
1480
|
httpReq.body = body;
|
|
1183
1481
|
}
|
|
1482
|
+
else if (isMultipart) {
|
|
1483
|
+
// Shared parser (same as the Node http server) - stringifying
|
|
1484
|
+
// multipart would corrupt binary uploads and lose fields/files.
|
|
1485
|
+
// parseMultipart retains slices of the payload as file Buffers;
|
|
1486
|
+
// the single-chunk fast path (buffer === view) aliases uWS-owned
|
|
1487
|
+
// memory that is recycled after this callback returns, so copy
|
|
1488
|
+
// it first. Concatenated multi-chunk buffers are already stable.
|
|
1489
|
+
const stable = buffer === view ? Buffer.from(buffer) : buffer;
|
|
1490
|
+
httpReq.body = parseMultipartBuffer(stable, contentType, this.multipartLimits);
|
|
1491
|
+
}
|
|
1184
1492
|
else {
|
|
1185
1493
|
httpReq.body = buffer.toString('utf-8');
|
|
1186
1494
|
}
|
|
1187
1495
|
finish(resolve);
|
|
1188
1496
|
}
|
|
1189
|
-
catch {
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1497
|
+
catch (parseError) {
|
|
1498
|
+
// A limit error (multipart maxParts/maxFiles/maxFileSize) carries its
|
|
1499
|
+
// own 413 - preserve it rather than masking it as a generic 400.
|
|
1500
|
+
if (parseError && typeof parseError.statusCode === 'number') {
|
|
1501
|
+
finish(() => reject(parseError));
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
// A malformed body is a client error: reject with 400 (parity with
|
|
1505
|
+
// the engine and Node transports) rather than silently handing the
|
|
1506
|
+
// handler a null body it may treat as "no input".
|
|
1507
|
+
const error = new Error(`Invalid request body: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
|
|
1508
|
+
error.statusCode = 400;
|
|
1509
|
+
finish(() => reject(error));
|
|
1193
1510
|
}
|
|
1194
1511
|
});
|
|
1195
1512
|
// Chain onto the request-lifetime abort hook (registered in handleRequest
|
|
@@ -1250,10 +1567,14 @@ export class UWebSocketsHttpServer {
|
|
|
1250
1567
|
setHookManager(hookManager) {
|
|
1251
1568
|
this.hookManager = hookManager;
|
|
1252
1569
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1570
|
+
/** @internal buffered-response compression settings (read at init). */
|
|
1571
|
+
_compression = resolveCompressionSettings();
|
|
1572
|
+
configurePerformance(config) {
|
|
1573
|
+
// uWS batches the request hot path natively; response compression is the
|
|
1574
|
+
// one thing it doesn't do itself, so wire that here (parity with Node/
|
|
1575
|
+
// engine). Buffered responses only - streaming stays raw.
|
|
1576
|
+
this._compression = resolveCompressionSettings(config);
|
|
1577
|
+
this.logger.debug(`Performance configured (compression ${this._compression.enabled ? 'on' : 'off'})`, 'Config');
|
|
1257
1578
|
}
|
|
1258
1579
|
listen(port, hostOrCallback, callback) {
|
|
1259
1580
|
// Wrap in async to await init
|