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/README.md +26 -8
- package/package.json +1 -1
- package/src/application.js +109 -19
- package/src/cli.js +3 -3
- package/src/index.js +5 -0
- package/src/middlewares.js +87 -36
- package/src/node-shim.js +2 -1
- package/src/request.js +95 -37
- package/src/response.js +141 -77
- package/src/route.js +180 -0
- package/src/router.js +503 -124
- package/src/utils.js +41 -3
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
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
|
-
|
|
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
|
|
357
|
-
const
|
|
358
|
-
|
|
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 =
|
|
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
|
-
|
|
400
|
+
res.writeHeader(header, val);
|
|
372
401
|
}
|
|
373
402
|
} else {
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
809
|
-
|
|
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}/${
|
|
816
|
-
offset
|
|
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
|
-
//
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
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 && !
|
|
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 (
|
|
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 ||
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
943
|
-
|
|
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
|
-
|
|
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
|
});
|