@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.
Files changed (67) hide show
  1. package/README.md +89 -37
  2. package/dist/core/config/config-sources.js +114 -2
  3. package/dist/core/config/config-sources.js.map +1 -1
  4. package/dist/core/config/config-validator.js +187 -5
  5. package/dist/core/config/config-validator.js.map +1 -1
  6. package/dist/core/config/schema.js +31 -3
  7. package/dist/core/config/schema.js.map +1 -1
  8. package/dist/core/framework.d.ts +41 -2
  9. package/dist/core/framework.js +338 -73
  10. package/dist/core/framework.js.map +1 -1
  11. package/dist/core/http/http-server.d.ts +21 -22
  12. package/dist/core/http/http-server.js +268 -226
  13. package/dist/core/http/http-server.js.map +1 -1
  14. package/dist/core/http/http2-server.d.ts +6 -16
  15. package/dist/core/http/http2-server.js +14 -79
  16. package/dist/core/http/http2-server.js.map +1 -1
  17. package/dist/core/http/index.d.ts +1 -0
  18. package/dist/core/http/index.js +3 -0
  19. package/dist/core/http/index.js.map +1 -1
  20. package/dist/core/http/moro-engine-server.d.ts +263 -0
  21. package/dist/core/http/moro-engine-server.js +1939 -0
  22. package/dist/core/http/moro-engine-server.js.map +1 -0
  23. package/dist/core/http/utils/compression.d.ts +47 -0
  24. package/dist/core/http/utils/compression.js +132 -0
  25. package/dist/core/http/utils/compression.js.map +1 -0
  26. package/dist/core/http/utils/lazy-event-emitter.d.ts +11 -0
  27. package/dist/core/http/utils/lazy-event-emitter.js +69 -0
  28. package/dist/core/http/utils/lazy-event-emitter.js.map +1 -0
  29. package/dist/core/http/utils/multipart-parser.d.ts +32 -0
  30. package/dist/core/http/utils/multipart-parser.js +211 -0
  31. package/dist/core/http/utils/multipart-parser.js.map +1 -0
  32. package/dist/core/http/utils/query-parser.d.ts +1 -0
  33. package/dist/core/http/utils/query-parser.js +44 -0
  34. package/dist/core/http/utils/query-parser.js.map +1 -0
  35. package/dist/core/http/utils/size.d.ts +33 -0
  36. package/dist/core/http/utils/size.js +37 -0
  37. package/dist/core/http/utils/size.js.map +1 -0
  38. package/dist/core/http/utils/ssl-config.d.ts +69 -0
  39. package/dist/core/http/utils/ssl-config.js +116 -0
  40. package/dist/core/http/utils/ssl-config.js.map +1 -0
  41. package/dist/core/http/utils/uws-worker-clustering.js +8 -2
  42. package/dist/core/http/utils/uws-worker-clustering.js.map +1 -1
  43. package/dist/core/http/uws-http-server.d.ts +24 -3
  44. package/dist/core/http/uws-http-server.js +386 -65
  45. package/dist/core/http/uws-http-server.js.map +1 -1
  46. package/dist/core/middleware/built-in/compression/core.d.ts +3 -0
  47. package/dist/core/middleware/built-in/compression/core.js +28 -34
  48. package/dist/core/middleware/built-in/compression/core.js.map +1 -1
  49. package/dist/core/networking/adapters/engine-adapter.d.ts +23 -0
  50. package/dist/core/networking/adapters/engine-adapter.js +365 -0
  51. package/dist/core/networking/adapters/engine-adapter.js.map +1 -0
  52. package/dist/core/networking/adapters/index.d.ts +1 -0
  53. package/dist/core/networking/adapters/index.js +1 -0
  54. package/dist/core/networking/adapters/index.js.map +1 -1
  55. package/dist/core/networking/adapters/uws-adapter.js +26 -15
  56. package/dist/core/networking/adapters/uws-adapter.js.map +1 -1
  57. package/dist/core/utilities/package-utils.d.ts +68 -0
  58. package/dist/core/utilities/package-utils.js +198 -1
  59. package/dist/core/utilities/package-utils.js.map +1 -1
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.js +1 -1
  62. package/dist/index.js.map +1 -1
  63. package/dist/moro.d.ts +2 -0
  64. package/dist/moro.js +63 -21
  65. package/dist/moro.js.map +1 -1
  66. package/dist/types/config.d.ts +90 -5
  67. 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
- function parseUwsQueryString(queryString) {
7
- const result = {};
8
- if (!queryString)
9
- return result;
10
- const pairs = queryString.split('&');
11
- for (let i = 0; i < pairs.length; i++) {
12
- const pair = pairs[i];
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
- // Lazy load uWebSockets.js - only when explicitly configured
348
- // This ensures it's an optional dependency with graceful fallback
349
- const uwsModule = await import('uWebSockets.js');
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 uWebSockets.js (optional dependency)\n' +
370
- 'To use uWebSockets, install it with:\n' +
371
- ' npm install --save-dev github:uNetworking/uWebSockets.js#v20.52.0\n' +
372
- 'Or set useUWebSockets: false in your config to use the standard HTTP server.\n' +
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
- init(res, req, logger) {
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 (this.headersSent || this._res.aborted) {
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
- this._res.writeStatus(UWebSocketsHttpServer.getStatusString(this.statusCode));
756
- UWebSocketsHttpServer.writeHeaders(this._res, this.responseHeaders);
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.headersSent = true;
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
- // Build cookie string with array join for better performance
936
- const parts = [name, '=', value];
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
- if (options.maxAge) {
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.headersSent,
1113
- writable: !this.headersSent,
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
- this.logger.error('Failed to parse request body', 'BodyParseError');
1191
- httpReq.body = null;
1192
- finish(resolve);
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
- configurePerformance(_config) {
1254
- // uWebSockets is already highly optimized
1255
- // This method exists for API compatibility
1256
- this.logger.debug('Performance configuration noted (uWebSockets is pre-optimized)', 'Config');
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