fulmine.js 5.0.0 → 5.1.1

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/src/response.js CHANGED
@@ -103,6 +103,22 @@ class Socket extends EventEmitter {
103
103
  }
104
104
  }
105
105
 
106
+ // One status line per code, built on first use: the default path, with no custom reason phrase,
107
+ // paid a template string and a trim per request for a line that never changes. Bounded to real
108
+ // HTTP codes so a wild writeHead value cannot grow the array or flip it into dictionary mode.
109
+ const STATUS_LINES = [];
110
+ /**
111
+ * @param {number} code
112
+ * @param {string|undefined} text an explicit reason phrase, which bypasses the cache
113
+ * @returns {string} the uWS status line, e.g. "200 OK"
114
+ */
115
+ function statusLine(code, text) {
116
+ if (text === undefined && Number.isInteger(code) && code >= 100 && code <= 999) {
117
+ return STATUS_LINES[code] ?? (STATUS_LINES[code] = code + " " + (statuses.message[code] ?? "unknown"));
118
+ }
119
+ return `${code} ${text ?? statuses.message[code] ?? "unknown"}`.trim();
120
+ }
121
+
106
122
  module.exports = class Response extends Writable {
107
123
  /** @type {Socket|null} */
108
124
  #socket = null;
@@ -117,9 +133,6 @@ module.exports = class Response extends Writable {
117
133
 
118
134
  req;
119
135
 
120
- /** @type {Set<any>|undefined} the app's live-response set, see Application#handleRequest */
121
- _pendingIn;
122
-
123
136
  /**
124
137
  * Built for every request, right after the Request it belongs to. The headers start with the
125
138
  * two that describe the connection, since every response carries them, and x-powered-by only
@@ -162,23 +175,38 @@ module.exports = class Response extends Writable {
162
175
  }
163
176
 
164
177
  this.body = undefined;
165
- this.on("error", (err) => {
166
- if (this.finished) {
167
- return;
168
- }
169
- this._res.cork(() => {
170
- this._res.close();
171
- this.finished = true;
172
- this.#socket?.emit("close");
173
- });
174
- });
175
- this.once("close", () => {
176
- this.#ended = true;
177
- // the application's graceful close() waits on this set, see its handleRequest
178
- this._pendingIn?.delete(this);
178
+ // false while the uWS route handler is still in its synchronous window, where uWS holds
179
+ // the socket corked itself; the two uWS entry points flip it once that window closes
180
+ this._corkNeeded = false;
181
+ // shared methods, not arrows: two closures and a once() wrapper here were four
182
+ // allocations per request. EventEmitter calls listeners with this = the emitter.
183
+ this.on("error", this._onAbortError);
184
+ this.on("close", this._onCloseCleanup);
185
+ }
186
+
187
+ /** @param {Error} err */
188
+ _onAbortError(err) {
189
+ if (this.finished) {
190
+ return;
191
+ }
192
+ this._res.cork(() => {
193
+ this._res.close();
194
+ this.finished = true;
195
+ this.#socket?.emit("close");
179
196
  });
180
197
  }
181
198
 
199
+ /**
200
+ * on(), not once(), so this must stay idempotent: end() emits 'close' by hand and a later
201
+ * destroy() makes Writable emit it again.
202
+ */
203
+ _onCloseCleanup() {
204
+ this.#ended = true;
205
+ // the application's graceful close() waits on this set, which lives on the per-app
206
+ // response prototype layer, see the Application constructor
207
+ /** @type {any} */ (this)._pendingIn?.delete(this);
208
+ }
209
+
182
210
  /**
183
211
  * Where node keeps the outgoing headers of an OutgoingMessage. Only code going through node's
184
212
  * own header path ever looks, cookie-session being the one in this project's tests, so the
@@ -247,9 +275,11 @@ module.exports = class Response extends Writable {
247
275
  if (!this.headersSent) {
248
276
  this.writeHead(this.statusCode);
249
277
  // "unknown" and not the bare number: node writes that reason phrase for a code it
250
- // has no message for, so the raw status lines match
251
- const statusMessage = this.statusText ?? statuses.message[this.statusCode] ?? "unknown";
252
- this._res.writeStatus(`${this.statusCode} ${statusMessage}`.trim());
278
+ // has no message for, so the raw status lines match. The default 200 with no
279
+ // phrase is uWS's own head, byte for byte, so it is not written at all
280
+ if (this.statusCode !== 200 || this.statusText !== undefined) {
281
+ this._res.writeStatus(statusLine(this.statusCode, this.statusText));
282
+ }
253
283
  this.writeHeaders(typeof chunk === "string");
254
284
  }
255
285
 
@@ -353,13 +383,18 @@ module.exports = class Response extends Writable {
353
383
  // the connection is closing. That happens both when the client asked and when something
354
384
  // else set the header on the way out, which is what a proxy passing an upstream response
355
385
  // through does.
356
- const connection = this.headers["connection"];
357
- const closing = typeof connection === "string" && connection.toLowerCase() === "close";
358
- for (const header in this.headers) {
386
+ const headers = this.headers;
387
+ const res = this._res;
388
+ const connection = headers["connection"];
389
+ // length before lowercasing: no string of another length can lowercase to "close", and
390
+ // the value here is nearly always the 10-char "keep-alive", which paid a scan per response
391
+ const closing =
392
+ typeof connection === "string" && connection.length === 5 && connection.toLowerCase() === "close";
393
+ for (const header in headers) {
359
394
  if (closing && header === "keep-alive") {
360
395
  continue;
361
396
  }
362
- const value = this.headers[header];
397
+ const value = headers[header];
363
398
  if (header === "content-length") {
364
399
  // if content-length is set, disable chunked transfer encoding, since size is known
365
400
  this.chunkedTransfer = false;
@@ -368,10 +403,10 @@ module.exports = class Response extends Writable {
368
403
  }
369
404
  if (Array.isArray(value)) {
370
405
  for (const val of value) {
371
- this._res.writeHeader(header, val);
406
+ res.writeHeader(header, val);
372
407
  }
373
408
  } else {
374
- this._res.writeHeader(header, value);
409
+ res.writeHeader(header, value);
375
410
  }
376
411
  }
377
412
  this.headersSent = true;
@@ -446,48 +481,67 @@ module.exports = class Response extends Writable {
446
481
  return this;
447
482
  }
448
483
  this.writeHead(this.statusCode);
449
- this._res.cork(() => {
450
- if (!this.headersSent) {
451
- // freshness is not decided here. node's end() knows nothing about conditional
452
- // requests, and Express answers 304 from send() and from sendFile(), each of
453
- // which strips the entity headers first. Deciding it here meant res.end("body")
454
- // answered 304 and dropped the body that the caller had just written.
455
- // "unknown" for a code without a message, as node's status line has it
456
- const statusMessage = this.statusText ?? statuses.message[this.statusCode] ?? "unknown";
457
- this._res.writeStatus(`${this.statusCode} ${statusMessage}`.trim());
458
- this.writeHeaders(true);
484
+ // uWS holds the socket corked for the synchronous window of its route handler, and
485
+ // cork inside cork is a passthrough: the wrapper and its closure are only paid once the
486
+ // answer has outlived that window, which is what _corkNeeded records
487
+ if (this._corkNeeded) {
488
+ this._res.cork(() => this._finish(data, cb));
489
+ } else {
490
+ this._finish(data, cb);
491
+ }
492
+ return this;
493
+ }
494
+
495
+ /**
496
+ * The corked tail of end(): status, headers, body and the finish events. Split out so a
497
+ * synchronous answer calls it straight, already inside uWS's own cork.
498
+ *
499
+ * @param {any} data
500
+ * @param {any} cb
501
+ */
502
+ _finish(data, cb) {
503
+ if (!this.headersSent) {
504
+ // freshness is not decided here. node's end() knows nothing about conditional
505
+ // requests, and Express answers 304 from send() and from sendFile(), each of
506
+ // which strips the entity headers first. Deciding it here meant res.end("body")
507
+ // answered 304 and dropped the body that the caller had just written.
508
+ // "unknown" for a code without a message, as node's status line has it.
509
+ // The default 200 with no phrase is not written at all: uWS emits the identical
510
+ // "HTTP/1.1 200 OK" head on its own, and the crossing costs more than it says
511
+ if (this.statusCode !== 200 || this.statusText !== undefined) {
512
+ this._res.writeStatus(statusLine(this.statusCode, this.statusText));
459
513
  }
460
- const contentLength = this.headers["content-length"];
461
- if (STATUSES_WITHOUT_BODY.has(this.statusCode) || this.statusCode < 200) {
462
- // no body and no length describing one, whatever the caller passed. node decides
463
- // this the same way, from the status alone, so res.status(304).end("x") sends the
464
- // status and nothing else on either.
465
- this._res.endWithoutBody();
466
- } else if (!data && contentLength) {
467
- this._res.endWithoutBody(contentLength.toString());
514
+ this.writeHeaders(true);
515
+ }
516
+ const contentLength = this.headers["content-length"];
517
+ if (STATUSES_WITHOUT_BODY.has(this.statusCode) || this.statusCode < 200) {
518
+ // no body and no length describing one, whatever the caller passed. node decides
519
+ // this the same way, from the status alone, so res.status(304).end("x") sends the
520
+ // status and nothing else on either.
521
+ this._res.endWithoutBody();
522
+ } else if (!data && contentLength) {
523
+ this._res.endWithoutBody(contentLength.toString());
524
+ } else {
525
+ if (data instanceof Buffer) {
526
+ data = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
527
+ }
528
+ if (this.req.method === "HEAD") {
529
+ const length = Buffer.byteLength(data ?? "");
530
+ this._res.endWithoutBody(length.toString());
468
531
  } else {
469
- if (data instanceof Buffer) {
470
- data = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
471
- }
472
- if (this.req.method === "HEAD") {
473
- const length = Buffer.byteLength(data ?? "");
474
- this._res.endWithoutBody(length.toString());
475
- } else {
476
- this._res.end(data);
477
- }
532
+ this._res.end(data);
478
533
  }
534
+ }
479
535
 
480
- this.finished = true;
481
- this.#socket?.emit("close");
482
- this.emit("finish");
483
- this.emit("close");
484
- cb &&
485
- queueMicrotask(() => {
486
- this.#ended = true;
487
- cb();
488
- });
489
- });
490
- return this;
536
+ this.finished = true;
537
+ this.#socket?.emit("close");
538
+ this.emit("finish");
539
+ this.emit("close");
540
+ cb &&
541
+ queueMicrotask(() => {
542
+ this.#ended = true;
543
+ cb();
544
+ });
491
545
  }
492
546
 
493
547
  /**
@@ -616,7 +670,17 @@ module.exports = class Response extends Writable {
616
670
  if (!options) options = new NullObject();
617
671
  // the callback is optional: without one, errors go to next(). The router assigns req.next
618
672
  // before any handler can run, so by the time sendFile is reachable it is always there.
619
- const done = /** @type {(err?: Error) => void} */ (callback ?? this.req.next);
673
+ // Express's completion handler, exactly: a callback hears everything and the response is
674
+ // left alone, so it can still answer 200 after a 404 error. Without one, a directory
675
+ // falls through as a plain next(), and aborts and write errors go nowhere.
676
+ const next = this.req.next;
677
+ const done = /** @type {(err?: any) => void} */ (
678
+ (err) => {
679
+ if (callback) return callback(err);
680
+ if (err && err.code === "EISDIR") return next();
681
+ if (err && err.code !== "ECONNABORTED" && err.syscall !== "write") next(err);
682
+ }
683
+ );
620
684
  // default options
621
685
  // Normalised the way send does, and it is not fussiness: max-age takes a non-negative
622
686
  // integer count of seconds, so 0.5, -1 and Infinity are all invalid, and a client that
@@ -661,12 +725,10 @@ module.exports = class Response extends Writable {
661
725
  // it can go back into path
662
726
  const decoded = decode(path);
663
727
  if (decoded === -1) {
664
- this.status(400);
665
728
  return done(httpError(400));
666
729
  }
667
730
  path = decoded;
668
731
  if (~path.indexOf("\0")) {
669
- this.status(400);
670
732
  return done(httpError(400));
671
733
  }
672
734
  // send's two branches: with a root the path is normalized first, so an in-root ".." like
@@ -676,18 +738,15 @@ module.exports = class Response extends Writable {
676
738
  if (options.root) {
677
739
  path = Path.normalize("." + Path.sep + path);
678
740
  if (UP_PATH_REGEXP.test(path)) {
679
- this.status(403);
680
741
  return done(httpError(403));
681
742
  }
682
743
  parts = path.split(Path.sep);
683
744
  fullpath = Path.resolve(Path.join(options.root, path));
684
745
  if (!fullpath.startsWith(Path.resolve(options.root))) {
685
- this.status(403);
686
746
  return done(httpError(403));
687
747
  }
688
748
  } else {
689
749
  if (UP_PATH_REGEXP.test(path)) {
690
- this.status(403);
691
750
  return done(httpError(403));
692
751
  }
693
752
  parts = Path.normalize(path).split(Path.sep);
@@ -700,21 +759,18 @@ module.exports = class Response extends Writable {
700
759
  case "allow":
701
760
  break;
702
761
  case "deny":
703
- this.status(403);
704
762
  return done(httpError(403));
705
763
  case "ignore_files": {
706
764
  // the file segment alone: with a root the normalized parts no longer carry a
707
765
  // leading empty segment, so a bare dotfile can be the only part
708
766
  const len = parts.length;
709
767
  if (parts[len - 1].startsWith(".")) {
710
- this.status(404);
711
768
  return done(httpError(404));
712
769
  }
713
770
  break;
714
771
  }
715
772
  case "ignore":
716
773
  default:
717
- this.status(404);
718
774
  return done(httpError(404));
719
775
  }
720
776
  }
@@ -732,8 +788,7 @@ module.exports = class Response extends Writable {
732
788
  // Express reports a directory as an EISDIR with no status, because send tells it
733
789
  // apart from an error: it emits "directory", and res.sendFile has no listener for
734
790
  // one. So this is not a 404, and an error handler reading err.code sees the code
735
- // it expects.
736
- this.status(404);
791
+ // it expects. Without a callback, done() turns it into a plain next().
737
792
  const err = /** @type {any} */ (new Error("EISDIR, read"));
738
793
  err.code = "EISDIR";
739
794
  return done(err);
@@ -785,17 +840,38 @@ module.exports = class Response extends Writable {
785
840
 
786
841
  // conditional requests
787
842
  if (isPreconditionFailure(this.req, this)) {
788
- this.status(412);
789
843
  return done(httpError(412));
790
844
  }
791
845
 
846
+ // if-modified-since, if-none-match. Before range handling, as send orders it: a fresh
847
+ // request with an unsatisfiable Range gets the 304, never the 416.
848
+ if (this.req.fresh) {
849
+ // the same fields send removes: everything describing a body that is not being sent.
850
+ delete this.headers["content-type"];
851
+ delete this.headers["content-encoding"];
852
+ delete this.headers["content-language"];
853
+ delete this.headers["content-length"];
854
+ this.status(304);
855
+ this.end();
856
+ // the response is complete, so a callback hears about it. Never done(): on success
857
+ // that would be next(), and the request would fall through to the next handler.
858
+ if (callback) callback();
859
+ return;
860
+ }
861
+
862
+ // the start and end options, before the Range header: send serves ranges relative to the
863
+ // window they select, so both the parse and the Content-Range total use the windowed len
864
+ let offset = options.start || 0;
865
+ let len = Math.max(0, stat.size - offset);
866
+ if (options.end !== undefined) {
867
+ const bytes = options.end - offset + 1;
868
+ if (len > bytes) len = bytes;
869
+ }
870
+
792
871
  // range requests
793
- let offset = 0,
794
- len = stat.size,
795
- ranged = false;
796
872
  if (options.acceptRanges) {
797
873
  if (this.req.headers.range) {
798
- let ranges = this.req.range(stat.size, {
874
+ let ranges = this.req.range(len, {
799
875
  combine: true
800
876
  });
801
877
 
@@ -805,44 +881,36 @@ module.exports = class Response extends Writable {
805
881
  }
806
882
 
807
883
  if (ranges === -1) {
808
- this.status(416);
809
- this.headers["content-range"] = `bytes */${stat.size}`;
884
+ // the header goes on the response itself, as send writes it before raising
885
+ // the error, and the status stays on the error for the handler to apply
886
+ this.headers["content-range"] = `bytes */${len}`;
810
887
  return done(httpError(416));
811
888
  }
812
889
  if (ranges !== -2 && ranges.length === 1) {
813
890
  this.status(206);
814
891
  const range = ranges[0];
815
- this.headers["content-range"] = `bytes ${range.start}-${range.end}/${stat.size}`;
816
- offset = range.start;
892
+ this.headers["content-range"] = `bytes ${range.start}-${range.end}/${len}`;
893
+ offset += range.start;
817
894
  len = range.end - range.start + 1;
818
- ranged = true;
819
895
  }
820
896
  }
821
897
  }
822
898
 
823
- // if-modified-since, if-none-match
824
- if (this.req.fresh) {
825
- // the same fields send removes: everything describing a body that is not being sent.
826
- // Content-Range goes too, since a 304 answers the whole conditional request and not
827
- // the range that was asked for.
828
- delete this.headers["content-type"];
829
- delete this.headers["content-encoding"];
830
- delete this.headers["content-language"];
831
- delete this.headers["content-length"];
832
- delete this.headers["content-range"];
833
- this.status(304);
834
- return this.end();
835
- }
899
+ // anything but the whole file, whether from a Range header or the start/end options,
900
+ // has to go through the read stream with explicit bounds
901
+ const partial = offset > 0 || len < stat.size;
836
902
 
837
903
  if (this.req.method === "HEAD") {
838
904
  // len, not stat.size: a ranged HEAD answers with the length of the selected part,
839
905
  // as send sets it before ending
840
- this.set("Content-Length", len);
841
- return this.end();
906
+ this.set("Content-Length", String(len));
907
+ this.end();
908
+ if (callback) callback();
909
+ return;
842
910
  }
843
911
 
844
912
  // serve smaller files using workers
845
- if (this.app.workers.length && stat.size < 768 * 1024 && !ranged) {
913
+ if (this.app.workers.length && stat.size < 768 * 1024 && !partial) {
846
914
  this.app
847
915
  .readFileWithWorker(fullpath)
848
916
  .then((data) => {
@@ -872,12 +940,12 @@ module.exports = class Response extends Writable {
872
940
  const opts = {
873
941
  highWaterMark: HIGH_WATERMARK
874
942
  };
875
- if (ranged) {
943
+ if (partial) {
876
944
  opts.start = offset;
877
945
  opts.end = Math.max(offset, offset + len - 1);
878
946
  }
879
947
  const file = fs.createReadStream(fullpath, opts);
880
- this.set("Content-Length", len);
948
+ this.set("Content-Length", String(len));
881
949
  // pipe() forwards nothing from the source, so a read error after the stat, the file
882
950
  // gone or unreadable, must reach next()/the callback instead of crashing the process
883
951
  file.on("error", (err) => {
@@ -896,6 +964,11 @@ module.exports = class Response extends Writable {
896
964
  this.removeListener("close", cleanup);
897
965
  socket?.removeListener("close", cleanup);
898
966
  });
967
+ // "end" only fires on a full read, and never together with "error", so the callback
968
+ // hears about completion exactly once, as it does on the worker path
969
+ if (callback) {
970
+ file.once("end", () => callback());
971
+ }
899
972
  file.pipe(this);
900
973
  }
901
974
  }
@@ -915,16 +988,16 @@ module.exports = class Response extends Writable {
915
988
  let done = callback;
916
989
  /** @type {string|null|undefined} */
917
990
  let name = filename;
918
- let opts = options || new NullObject();
991
+ let opts = options || null;
919
992
 
920
993
  // support function as second or third arg
921
994
  if (typeof filename === "function") {
922
995
  done = /** @type {any} */ (filename);
923
996
  name = null;
924
- opts = {};
997
+ opts = null;
925
998
  } else if (typeof options === "function") {
926
999
  done = /** @type {any} */ (options);
927
- opts = {};
1000
+ opts = null;
928
1001
  }
929
1002
 
930
1003
  // support optional filename, where options may be in it's place
@@ -932,15 +1005,31 @@ module.exports = class Response extends Writable {
932
1005
  name = null;
933
1006
  opts = filename;
934
1007
  }
935
- if (!name) {
936
- name = Path.basename(path);
937
- }
938
- if (!opts.root && !isAbsolute(path)) {
939
- opts.root = process.cwd();
1008
+
1009
+ // Handed to sendFile as a header option rather than set here, as Express does: headers
1010
+ // from the options only go out once the stat has succeeded, so a 404 or a 403 carries no
1011
+ // Content-Disposition. The Content-Type still comes from the file's own extension.
1012
+ const headers = {
1013
+ "Content-Disposition": contentDisposition(name || path)
1014
+ };
1015
+
1016
+ // merge user-provided headers, which never get to override the disposition
1017
+ if (opts && opts.headers) {
1018
+ for (const key of Object.keys(opts.headers)) {
1019
+ if (key.toLowerCase() !== "content-disposition") {
1020
+ headers[key] = opts.headers[key];
1021
+ }
1022
+ }
940
1023
  }
941
1024
 
942
- this.attachment(name);
943
- this.sendFile(path, opts, done);
1025
+ // merge user-provided options
1026
+ const merged = Object.create(opts ?? null);
1027
+ merged.headers = headers;
1028
+
1029
+ // resolved here so a relative path works against cwd, exactly as Express resolves it
1030
+ const fullPath = !merged.root ? Path.resolve(path) : path;
1031
+
1032
+ return this.sendFile(fullPath, merged, done);
944
1033
  }
945
1034
 
946
1035
  /**
@@ -1362,7 +1451,8 @@ module.exports = class Response extends Writable {
1362
1451
  body = `<p>${statuses.message[status]}. Redirecting to ${escapeHtml(address)}</p>`;
1363
1452
  },
1364
1453
  default: () => {
1365
- this.set("Content-Type", "text/plain; charset=utf-8");
1454
+ // no Content-Type on purpose: Express leaves the header off entirely when
1455
+ // the client accepts neither text nor html
1366
1456
  body = "";
1367
1457
  }
1368
1458
  });