fulmine.js 5.0.0 → 5.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/src/request.js CHANGED
@@ -83,6 +83,20 @@ function formatIPv6(groups) {
83
83
  return out;
84
84
  }
85
85
 
86
+ /**
87
+ * Whether node would report an IPv4 peer of this app in mapped form, "::ffff:a.b.c.d". Node maps
88
+ * it whenever the listener is dual stack, which is every listen() not given an IPv4 address to
89
+ * bind. uWS already hands mapped peers over as sixteen bytes; four bytes only reach req.ip from a
90
+ * v4-bound native listener or through the node shim, whose server supertest binds dual stack.
91
+ *
92
+ * @param {any} app the application the request arrived at
93
+ * @returns {boolean}
94
+ */
95
+ function mapsIPv4Peer(app) {
96
+ const host = app._listenHost;
97
+ return !(host && isIP(host) === 4);
98
+ }
99
+
86
100
  const discardedDuplicates = new Set([
87
101
  "age",
88
102
  "authorization",
@@ -109,6 +123,11 @@ let key = 0;
109
123
  // 128 KB of body buffered before uWS is asked to pause
110
124
  const READABLE_OPTIONS = { highWaterMark: 128 * 1024 };
111
125
 
126
+ // Whose headers the shared collector below is filling. uWS's forEach is synchronous and runs no
127
+ // user code, so the handoff cannot interleave; module-level so the callback exists once instead
128
+ // of once per request.
129
+ let currentRequest = null;
130
+
112
131
  module.exports = class Request extends Readable {
113
132
  /** @type {Record<string, any>|null} */
114
133
  #cachedQuery = null;
@@ -133,6 +152,30 @@ module.exports = class Request extends Readable {
133
152
 
134
153
  res;
135
154
 
155
+ // one function for every request, fed through currentRequest: an arrow in the constructor
156
+ // captured `this`, which cost a context and a function allocation per request
157
+ static #collectHeader = (headerKey, value) => {
158
+ const r = currentRequest;
159
+ r.#rawHeadersEntries.push(headerKey, value);
160
+ // spotted in the loop that is running anyway: a client asking for the connection to be
161
+ // closed must not be answered that it is being kept alive. The response is built right
162
+ // after this and reads the flag.
163
+ if (
164
+ headerKey.length === 10 &&
165
+ headerKey === "connection" &&
166
+ value.length === 5 &&
167
+ value.toLowerCase() === "close"
168
+ ) {
169
+ r._connectionClose = true;
170
+ } else if (
171
+ (headerKey.length === 14 && headerKey === "content-length") ||
172
+ (headerKey.length === 17 && headerKey === "transfer-encoding")
173
+ ) {
174
+ // noticed here so the body decision in the constructor does not build the headers object
175
+ r._declaresBody = true;
176
+ }
177
+ };
178
+
136
179
  optimizedParams;
137
180
 
138
181
  _error;
@@ -154,15 +197,9 @@ module.exports = class Request extends Readable {
154
197
  this._res = res;
155
198
  this._req = req;
156
199
  this.readable = true;
157
- this._req.forEach((key, value) => {
158
- this.#rawHeadersEntries.push(key, value);
159
- // spotted in the loop that is running anyway: a client asking for the connection to be
160
- // closed must not be answered that it is being kept alive. The response is built right
161
- // after this and reads the flag.
162
- if (key.length === 10 && key === "connection" && value.length === 5 && value.toLowerCase() === "close") {
163
- this._connectionClose = true;
164
- }
165
- });
200
+ currentRequest = this;
201
+ this._req.forEach(Request.#collectHeader);
202
+ currentRequest = null;
166
203
  this.routeCount = 1;
167
204
  this.key = key++;
168
205
  if (key > 100000) {
@@ -206,11 +243,13 @@ module.exports = class Request extends Readable {
206
243
  // The router builds it the first time it has something to put in it.
207
244
  this._matchedMethods = this._isOptions ? new Set() : null;
208
245
  this._paramCalled = null;
209
- this._stack = [];
246
+ // null for the same reason as the two above: a request that never enters a mount never
247
+ // needs either array, and the push sites materialize them
248
+ this._stack = null;
210
249
  // number of entries in _stack that aren't the empty path. while this is 0 the whole
211
250
  // stack joins to "", so getFullMountpath can skip the join entirely
212
251
  this._stackMounted = 0;
213
- this._paramStack = [];
252
+ this._paramStack = null;
214
253
  this.receivedData = false;
215
254
  // reading ip is very slow in UWS, so its better to not do it unless truly needed
216
255
  if (this.app.needsIpAfterResponse || this.key < 100) {
@@ -227,32 +266,46 @@ module.exports = class Request extends Readable {
227
266
  this.method === "PUT" ||
228
267
  this.method === "PATCH" ||
229
268
  this.method === "QUERY" ||
230
- (additionalMethods && additionalMethods.includes(this.method))
269
+ (additionalMethods && additionalMethods.includes(this.method)) ||
270
+ // any request that declares a body carries one, whatever the verb: a GET with
271
+ // content-length left unread would end this stream empty and poison the keep-alive
272
+ // connection with its unconsumed bytes. uWS itself discards GET bodies, so this is
273
+ // the node shim's path
274
+ /** @type {any} */ (this)._declaresBody
231
275
  ) {
232
- this._res.onData((ab, isLast) => {
233
- this.receivedData = true;
234
- if (this.#responseEnded) {
235
- return;
236
- }
237
- // ab.slice(0) copies the ArrayBuffer; uWS neuters `ab` after this callback,
238
- // so a Buffer.from(ab) view would corrupt data left in the Readable queue.
239
- const chunk = Buffer.from(ab.slice(0));
240
- const accepted = this.push(chunk);
241
- // push() may synchronously end the response via a flowing-mode listener.
242
- if (!accepted && !isLast && !this.#responseEnded) {
243
- this._res.pause();
244
- this.#paused = true;
245
- }
246
- if (isLast) {
247
- this.push(null);
248
- }
249
- });
276
+ this._subscribeBody();
250
277
  } else {
251
278
  this.receivedData = true;
252
279
  this.push(null);
253
280
  }
254
281
  }
255
282
 
283
+ /**
284
+ * Subscribes to the uWS body stream. Out of the constructor so a bodyless request allocates
285
+ * no closure at all there; must still run during the constructor call, since uWS only feeds a
286
+ * handler registered before the route handler returns.
287
+ */
288
+ _subscribeBody() {
289
+ this._res.onData((ab, isLast) => {
290
+ this.receivedData = true;
291
+ if (this.#responseEnded) {
292
+ return;
293
+ }
294
+ // ab.slice(0) copies the ArrayBuffer; uWS neuters `ab` after this callback,
295
+ // so a Buffer.from(ab) view would corrupt data left in the Readable queue.
296
+ const chunk = Buffer.from(ab.slice(0));
297
+ const accepted = this.push(chunk);
298
+ // push() may synchronously end the response via a flowing-mode listener.
299
+ if (!accepted && !isLast && !this.#responseEnded) {
300
+ this._res.pause();
301
+ this.#paused = true;
302
+ }
303
+ if (isLast) {
304
+ this.push(null);
305
+ }
306
+ });
307
+ }
308
+
256
309
  /**
257
310
  * Whether there is any point still reading the body: once the response is finished or the
258
311
  * connection is gone, uWS has nothing left to hand over.
@@ -405,7 +458,10 @@ module.exports = class Request extends Readable {
405
458
  * @returns {string}
406
459
  */
407
460
  get protocol() {
408
- const proto = this.app.ssl ? "https" : "http";
461
+ // express reads socket.encrypted, and middleware does assign to the stand-in; the app's
462
+ // own ssl flag answers when nothing has built the stand-in yet
463
+ const conn = this.#cachedConnection;
464
+ const proto = (conn ? conn.encrypted : this.app.ssl) ? "https" : "http";
409
465
  const trust = this.app.get("trust proxy fn");
410
466
  if (!trust) {
411
467
  return proto;
@@ -481,7 +537,7 @@ module.exports = class Request extends Readable {
481
537
 
482
538
  /**
483
539
  * The hostname's subdomains, furthest from the root first, dropping the last
484
- * "subdomain offset" labels. Empty for an IP address.
540
+ * "subdomain offset" labels. An IP host is one label, never split on its dots.
485
541
  * @returns {string[]}
486
542
  */
487
543
  get subdomains() {
@@ -490,15 +546,14 @@ module.exports = class Request extends Readable {
490
546
  }
491
547
 
492
548
  const hostname = this.hostname;
493
- if (!hostname || isIP(hostname)) {
549
+ if (!hostname) {
494
550
  return (this.#cachedSubdomains = []);
495
551
  }
496
552
 
497
553
  const offset = this.app.get("subdomain offset");
498
- const parts = hostname.split(".");
499
- const subdomains = parts.reverse().slice(offset);
554
+ const parts = isIP(hostname) ? [hostname] : hostname.split(".").reverse();
500
555
 
501
- return (this.#cachedSubdomains = subdomains);
556
+ return (this.#cachedSubdomains = parts.slice(offset));
502
557
  }
503
558
 
504
559
  /**
@@ -531,7 +586,7 @@ module.exports = class Request extends Readable {
531
586
  if (!this.rawIp) {
532
587
  if (finished) {
533
588
  // fallback once
534
- return "127.0.0.1";
589
+ return mapsIPv4Peer(this.app) ? "::ffff:127.0.0.1" : "127.0.0.1";
535
590
  }
536
591
  this.rawIp = this._res.getRemoteAddress();
537
592
  }
@@ -540,6 +595,9 @@ module.exports = class Request extends Readable {
540
595
  if (this.rawIp.byteLength === 4) {
541
596
  // ipv4
542
597
  ip = new Uint8Array(this.rawIp).join(".");
598
+ if (mapsIPv4Peer(this.app)) {
599
+ ip = "::ffff:" + ip;
600
+ }
543
601
  } else if (this.rawIp.byteLength === 16) {
544
602
  // ipv6
545
603
  const dv = new DataView(this.rawIp);
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,35 @@ 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
+ // shared methods, not arrows: two closures and a once() wrapper here were four
179
+ // allocations per request. EventEmitter calls listeners with this = the emitter.
180
+ this.on("error", this._onAbortError);
181
+ this.on("close", this._onCloseCleanup);
182
+ }
183
+
184
+ /** @param {Error} err */
185
+ _onAbortError(err) {
186
+ if (this.finished) {
187
+ return;
188
+ }
189
+ this._res.cork(() => {
190
+ this._res.close();
191
+ this.finished = true;
192
+ this.#socket?.emit("close");
179
193
  });
180
194
  }
181
195
 
196
+ /**
197
+ * on(), not once(), so this must stay idempotent: end() emits 'close' by hand and a later
198
+ * destroy() makes Writable emit it again.
199
+ */
200
+ _onCloseCleanup() {
201
+ this.#ended = true;
202
+ // the application's graceful close() waits on this set, which lives on the per-app
203
+ // response prototype layer, see the Application constructor
204
+ /** @type {any} */ (this)._pendingIn?.delete(this);
205
+ }
206
+
182
207
  /**
183
208
  * Where node keeps the outgoing headers of an OutgoingMessage. Only code going through node's
184
209
  * own header path ever looks, cookie-session being the one in this project's tests, so the
@@ -248,8 +273,7 @@ module.exports = class Response extends Writable {
248
273
  this.writeHead(this.statusCode);
249
274
  // "unknown" and not the bare number: node writes that reason phrase for a code it
250
275
  // 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());
276
+ this._res.writeStatus(statusLine(this.statusCode, this.statusText));
253
277
  this.writeHeaders(typeof chunk === "string");
254
278
  }
255
279
 
@@ -353,13 +377,18 @@ module.exports = class Response extends Writable {
353
377
  // the connection is closing. That happens both when the client asked and when something
354
378
  // else set the header on the way out, which is what a proxy passing an upstream response
355
379
  // through does.
356
- const connection = this.headers["connection"];
357
- const closing = typeof connection === "string" && connection.toLowerCase() === "close";
358
- for (const header in this.headers) {
380
+ const headers = this.headers;
381
+ const res = this._res;
382
+ const connection = headers["connection"];
383
+ // length before lowercasing: no string of another length can lowercase to "close", and
384
+ // the value here is nearly always the 10-char "keep-alive", which paid a scan per response
385
+ const closing =
386
+ typeof connection === "string" && connection.length === 5 && connection.toLowerCase() === "close";
387
+ for (const header in headers) {
359
388
  if (closing && header === "keep-alive") {
360
389
  continue;
361
390
  }
362
- const value = this.headers[header];
391
+ const value = headers[header];
363
392
  if (header === "content-length") {
364
393
  // if content-length is set, disable chunked transfer encoding, since size is known
365
394
  this.chunkedTransfer = false;
@@ -368,10 +397,10 @@ module.exports = class Response extends Writable {
368
397
  }
369
398
  if (Array.isArray(value)) {
370
399
  for (const val of value) {
371
- this._res.writeHeader(header, val);
400
+ res.writeHeader(header, val);
372
401
  }
373
402
  } else {
374
- this._res.writeHeader(header, value);
403
+ res.writeHeader(header, value);
375
404
  }
376
405
  }
377
406
  this.headersSent = true;
@@ -453,8 +482,7 @@ module.exports = class Response extends Writable {
453
482
  // which strips the entity headers first. Deciding it here meant res.end("body")
454
483
  // answered 304 and dropped the body that the caller had just written.
455
484
  // "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());
485
+ this._res.writeStatus(statusLine(this.statusCode, this.statusText));
458
486
  this.writeHeaders(true);
459
487
  }
460
488
  const contentLength = this.headers["content-length"];
@@ -616,7 +644,17 @@ module.exports = class Response extends Writable {
616
644
  if (!options) options = new NullObject();
617
645
  // the callback is optional: without one, errors go to next(). The router assigns req.next
618
646
  // 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);
647
+ // Express's completion handler, exactly: a callback hears everything and the response is
648
+ // left alone, so it can still answer 200 after a 404 error. Without one, a directory
649
+ // falls through as a plain next(), and aborts and write errors go nowhere.
650
+ const next = this.req.next;
651
+ const done = /** @type {(err?: any) => void} */ (
652
+ (err) => {
653
+ if (callback) return callback(err);
654
+ if (err && err.code === "EISDIR") return next();
655
+ if (err && err.code !== "ECONNABORTED" && err.syscall !== "write") next(err);
656
+ }
657
+ );
620
658
  // default options
621
659
  // Normalised the way send does, and it is not fussiness: max-age takes a non-negative
622
660
  // integer count of seconds, so 0.5, -1 and Infinity are all invalid, and a client that
@@ -661,12 +699,10 @@ module.exports = class Response extends Writable {
661
699
  // it can go back into path
662
700
  const decoded = decode(path);
663
701
  if (decoded === -1) {
664
- this.status(400);
665
702
  return done(httpError(400));
666
703
  }
667
704
  path = decoded;
668
705
  if (~path.indexOf("\0")) {
669
- this.status(400);
670
706
  return done(httpError(400));
671
707
  }
672
708
  // send's two branches: with a root the path is normalized first, so an in-root ".." like
@@ -676,18 +712,15 @@ module.exports = class Response extends Writable {
676
712
  if (options.root) {
677
713
  path = Path.normalize("." + Path.sep + path);
678
714
  if (UP_PATH_REGEXP.test(path)) {
679
- this.status(403);
680
715
  return done(httpError(403));
681
716
  }
682
717
  parts = path.split(Path.sep);
683
718
  fullpath = Path.resolve(Path.join(options.root, path));
684
719
  if (!fullpath.startsWith(Path.resolve(options.root))) {
685
- this.status(403);
686
720
  return done(httpError(403));
687
721
  }
688
722
  } else {
689
723
  if (UP_PATH_REGEXP.test(path)) {
690
- this.status(403);
691
724
  return done(httpError(403));
692
725
  }
693
726
  parts = Path.normalize(path).split(Path.sep);
@@ -700,21 +733,18 @@ module.exports = class Response extends Writable {
700
733
  case "allow":
701
734
  break;
702
735
  case "deny":
703
- this.status(403);
704
736
  return done(httpError(403));
705
737
  case "ignore_files": {
706
738
  // the file segment alone: with a root the normalized parts no longer carry a
707
739
  // leading empty segment, so a bare dotfile can be the only part
708
740
  const len = parts.length;
709
741
  if (parts[len - 1].startsWith(".")) {
710
- this.status(404);
711
742
  return done(httpError(404));
712
743
  }
713
744
  break;
714
745
  }
715
746
  case "ignore":
716
747
  default:
717
- this.status(404);
718
748
  return done(httpError(404));
719
749
  }
720
750
  }
@@ -732,8 +762,7 @@ module.exports = class Response extends Writable {
732
762
  // Express reports a directory as an EISDIR with no status, because send tells it
733
763
  // apart from an error: it emits "directory", and res.sendFile has no listener for
734
764
  // one. So this is not a 404, and an error handler reading err.code sees the code
735
- // it expects.
736
- this.status(404);
765
+ // it expects. Without a callback, done() turns it into a plain next().
737
766
  const err = /** @type {any} */ (new Error("EISDIR, read"));
738
767
  err.code = "EISDIR";
739
768
  return done(err);
@@ -785,17 +814,38 @@ module.exports = class Response extends Writable {
785
814
 
786
815
  // conditional requests
787
816
  if (isPreconditionFailure(this.req, this)) {
788
- this.status(412);
789
817
  return done(httpError(412));
790
818
  }
791
819
 
820
+ // if-modified-since, if-none-match. Before range handling, as send orders it: a fresh
821
+ // request with an unsatisfiable Range gets the 304, never the 416.
822
+ if (this.req.fresh) {
823
+ // the same fields send removes: everything describing a body that is not being sent.
824
+ delete this.headers["content-type"];
825
+ delete this.headers["content-encoding"];
826
+ delete this.headers["content-language"];
827
+ delete this.headers["content-length"];
828
+ this.status(304);
829
+ this.end();
830
+ // the response is complete, so a callback hears about it. Never done(): on success
831
+ // that would be next(), and the request would fall through to the next handler.
832
+ if (callback) callback();
833
+ return;
834
+ }
835
+
836
+ // the start and end options, before the Range header: send serves ranges relative to the
837
+ // window they select, so both the parse and the Content-Range total use the windowed len
838
+ let offset = options.start || 0;
839
+ let len = Math.max(0, stat.size - offset);
840
+ if (options.end !== undefined) {
841
+ const bytes = options.end - offset + 1;
842
+ if (len > bytes) len = bytes;
843
+ }
844
+
792
845
  // range requests
793
- let offset = 0,
794
- len = stat.size,
795
- ranged = false;
796
846
  if (options.acceptRanges) {
797
847
  if (this.req.headers.range) {
798
- let ranges = this.req.range(stat.size, {
848
+ let ranges = this.req.range(len, {
799
849
  combine: true
800
850
  });
801
851
 
@@ -805,44 +855,36 @@ module.exports = class Response extends Writable {
805
855
  }
806
856
 
807
857
  if (ranges === -1) {
808
- this.status(416);
809
- this.headers["content-range"] = `bytes */${stat.size}`;
858
+ // the header goes on the response itself, as send writes it before raising
859
+ // the error, and the status stays on the error for the handler to apply
860
+ this.headers["content-range"] = `bytes */${len}`;
810
861
  return done(httpError(416));
811
862
  }
812
863
  if (ranges !== -2 && ranges.length === 1) {
813
864
  this.status(206);
814
865
  const range = ranges[0];
815
- this.headers["content-range"] = `bytes ${range.start}-${range.end}/${stat.size}`;
816
- offset = range.start;
866
+ this.headers["content-range"] = `bytes ${range.start}-${range.end}/${len}`;
867
+ offset += range.start;
817
868
  len = range.end - range.start + 1;
818
- ranged = true;
819
869
  }
820
870
  }
821
871
  }
822
872
 
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
- }
873
+ // anything but the whole file, whether from a Range header or the start/end options,
874
+ // has to go through the read stream with explicit bounds
875
+ const partial = offset > 0 || len < stat.size;
836
876
 
837
877
  if (this.req.method === "HEAD") {
838
878
  // len, not stat.size: a ranged HEAD answers with the length of the selected part,
839
879
  // as send sets it before ending
840
- this.set("Content-Length", len);
841
- return this.end();
880
+ this.set("Content-Length", String(len));
881
+ this.end();
882
+ if (callback) callback();
883
+ return;
842
884
  }
843
885
 
844
886
  // serve smaller files using workers
845
- if (this.app.workers.length && stat.size < 768 * 1024 && !ranged) {
887
+ if (this.app.workers.length && stat.size < 768 * 1024 && !partial) {
846
888
  this.app
847
889
  .readFileWithWorker(fullpath)
848
890
  .then((data) => {
@@ -872,12 +914,12 @@ module.exports = class Response extends Writable {
872
914
  const opts = {
873
915
  highWaterMark: HIGH_WATERMARK
874
916
  };
875
- if (ranged) {
917
+ if (partial) {
876
918
  opts.start = offset;
877
919
  opts.end = Math.max(offset, offset + len - 1);
878
920
  }
879
921
  const file = fs.createReadStream(fullpath, opts);
880
- this.set("Content-Length", len);
922
+ this.set("Content-Length", String(len));
881
923
  // pipe() forwards nothing from the source, so a read error after the stat, the file
882
924
  // gone or unreadable, must reach next()/the callback instead of crashing the process
883
925
  file.on("error", (err) => {
@@ -896,6 +938,11 @@ module.exports = class Response extends Writable {
896
938
  this.removeListener("close", cleanup);
897
939
  socket?.removeListener("close", cleanup);
898
940
  });
941
+ // "end" only fires on a full read, and never together with "error", so the callback
942
+ // hears about completion exactly once, as it does on the worker path
943
+ if (callback) {
944
+ file.once("end", () => callback());
945
+ }
899
946
  file.pipe(this);
900
947
  }
901
948
  }
@@ -915,16 +962,16 @@ module.exports = class Response extends Writable {
915
962
  let done = callback;
916
963
  /** @type {string|null|undefined} */
917
964
  let name = filename;
918
- let opts = options || new NullObject();
965
+ let opts = options || null;
919
966
 
920
967
  // support function as second or third arg
921
968
  if (typeof filename === "function") {
922
969
  done = /** @type {any} */ (filename);
923
970
  name = null;
924
- opts = {};
971
+ opts = null;
925
972
  } else if (typeof options === "function") {
926
973
  done = /** @type {any} */ (options);
927
- opts = {};
974
+ opts = null;
928
975
  }
929
976
 
930
977
  // support optional filename, where options may be in it's place
@@ -932,15 +979,31 @@ module.exports = class Response extends Writable {
932
979
  name = null;
933
980
  opts = filename;
934
981
  }
935
- if (!name) {
936
- name = Path.basename(path);
937
- }
938
- if (!opts.root && !isAbsolute(path)) {
939
- opts.root = process.cwd();
982
+
983
+ // Handed to sendFile as a header option rather than set here, as Express does: headers
984
+ // from the options only go out once the stat has succeeded, so a 404 or a 403 carries no
985
+ // Content-Disposition. The Content-Type still comes from the file's own extension.
986
+ const headers = {
987
+ "Content-Disposition": contentDisposition(name || path)
988
+ };
989
+
990
+ // merge user-provided headers, which never get to override the disposition
991
+ if (opts && opts.headers) {
992
+ for (const key of Object.keys(opts.headers)) {
993
+ if (key.toLowerCase() !== "content-disposition") {
994
+ headers[key] = opts.headers[key];
995
+ }
996
+ }
940
997
  }
941
998
 
942
- this.attachment(name);
943
- this.sendFile(path, opts, done);
999
+ // merge user-provided options
1000
+ const merged = Object.create(opts ?? null);
1001
+ merged.headers = headers;
1002
+
1003
+ // resolved here so a relative path works against cwd, exactly as Express resolves it
1004
+ const fullPath = !merged.root ? Path.resolve(path) : path;
1005
+
1006
+ return this.sendFile(fullPath, merged, done);
944
1007
  }
945
1008
 
946
1009
  /**
@@ -1362,7 +1425,8 @@ module.exports = class Response extends Writable {
1362
1425
  body = `<p>${statuses.message[status]}. Redirecting to ${escapeHtml(address)}</p>`;
1363
1426
  },
1364
1427
  default: () => {
1365
- this.set("Content-Type", "text/plain; charset=utf-8");
1428
+ // no Content-Type on purpose: Express leaves the header off entirely when
1429
+ // the client accepts neither text nor html
1366
1430
  body = "";
1367
1431
  }
1368
1432
  });