fulmine.js 5.0.0-rc.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/EXPRESS_LICENSE +26 -0
- package/LICENSE +202 -0
- package/NOTICE +38 -0
- package/README.md +469 -0
- package/package.json +165 -0
- package/src/application.js +561 -0
- package/src/cli.js +369 -0
- package/src/declarative.js +768 -0
- package/src/index.js +71 -0
- package/src/middlewares.js +636 -0
- package/src/node-shim.js +400 -0
- package/src/request.js +807 -0
- package/src/response.js +1360 -0
- package/src/router.js +1240 -0
- package/src/types.d.ts +62 -0
- package/src/utils.js +993 -0
- package/src/view.js +172 -0
- package/src/worker.js +38 -0
package/src/response.js
ADDED
|
@@ -0,0 +1,1360 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2024 dimden.dev
|
|
3
|
+
Copyright 2026 Nigro Simone
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const cookie = require("cookie");
|
|
19
|
+
const mime = require("mime-types");
|
|
20
|
+
const vary = require("vary");
|
|
21
|
+
const encodeUrl = require("encodeurl");
|
|
22
|
+
const contentDisposition = require("content-disposition");
|
|
23
|
+
const {
|
|
24
|
+
normalizeType,
|
|
25
|
+
stringify,
|
|
26
|
+
UP_PATH_REGEXP,
|
|
27
|
+
decode,
|
|
28
|
+
containsDotFile,
|
|
29
|
+
isPreconditionFailure,
|
|
30
|
+
isRangeFresh,
|
|
31
|
+
escapeHtml,
|
|
32
|
+
withDefaultCharset,
|
|
33
|
+
withUtf8Charset,
|
|
34
|
+
asStatError,
|
|
35
|
+
httpError,
|
|
36
|
+
contentTypeFor,
|
|
37
|
+
statTag,
|
|
38
|
+
NullObject
|
|
39
|
+
} = require("./utils.js");
|
|
40
|
+
const { Writable } = require("stream");
|
|
41
|
+
const { isAbsolute } = require("path");
|
|
42
|
+
const fs = require("fs");
|
|
43
|
+
const Path = require("path");
|
|
44
|
+
const statuses = require("statuses");
|
|
45
|
+
const { sign } = require("cookie-signature");
|
|
46
|
+
// events is faster at init, tseep is faster at sending events
|
|
47
|
+
// since we create a ton of objects and dont send a ton of events, its better to use events here
|
|
48
|
+
const { EventEmitter } = require("events");
|
|
49
|
+
const http = require("http");
|
|
50
|
+
const ms = require("ms");
|
|
51
|
+
|
|
52
|
+
const outgoingMessage = new http.OutgoingMessage();
|
|
53
|
+
const symbols = Object.getOwnPropertySymbols(outgoingMessage);
|
|
54
|
+
// if a future node renames it, fall back to a private symbol rather than writing a property
|
|
55
|
+
// literally named "undefined", which is what indexing with undefined would do
|
|
56
|
+
const kOutHeaders = symbols.find((s) => s.toString() === "Symbol(kOutHeaders)") ?? Symbol("kOutHeaders");
|
|
57
|
+
const HIGH_WATERMARK = 128 * 1024;
|
|
58
|
+
// Statuses whose message carries no body, so no Content-Length may describe one either. 1xx is
|
|
59
|
+
// the third case and is checked by range rather than listed.
|
|
60
|
+
const STATUSES_WITHOUT_BODY = new Set([204, 304]);
|
|
61
|
+
// send's ceiling for maxAge, one year in milliseconds. Anything larger is clamped to it rather
|
|
62
|
+
// than written out, since a year is already longer than any cache will honour.
|
|
63
|
+
const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000;
|
|
64
|
+
|
|
65
|
+
class Socket extends EventEmitter {
|
|
66
|
+
/**
|
|
67
|
+
* Enough of a node socket for the middleware that reaches for one. There is no socket object
|
|
68
|
+
* in uWS to hand over, so this stands in and forwards what it can to the response.
|
|
69
|
+
*
|
|
70
|
+
* @param {any} response
|
|
71
|
+
*/
|
|
72
|
+
constructor(response) {
|
|
73
|
+
super();
|
|
74
|
+
this.response = response;
|
|
75
|
+
|
|
76
|
+
this.on("error", (err) => {
|
|
77
|
+
this.emit("close");
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether anything more can be written, which stops being true once the response is done. */
|
|
82
|
+
get writable() {
|
|
83
|
+
return !this.response.finished;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Finishes the response through the socket, which is how the middleware that only knows
|
|
88
|
+
* about sockets ends one.
|
|
89
|
+
* @param {any} [body]
|
|
90
|
+
*/
|
|
91
|
+
end(body) {
|
|
92
|
+
this.response.end(body);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Closes the connection outright, without finishing a response first. */
|
|
96
|
+
close() {
|
|
97
|
+
if (this.response.finished) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
this.response.finished = true;
|
|
101
|
+
this.emit("close");
|
|
102
|
+
this.response._res.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = class Response extends Writable {
|
|
107
|
+
/** @type {Socket|null} */
|
|
108
|
+
#socket = null;
|
|
109
|
+
|
|
110
|
+
#ended = false;
|
|
111
|
+
|
|
112
|
+
/** @type {((err?: Error|null) => void)|null} */
|
|
113
|
+
#pendingCallback = null;
|
|
114
|
+
|
|
115
|
+
/** @type {any} */
|
|
116
|
+
#outHeaders = null;
|
|
117
|
+
|
|
118
|
+
req;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Built for every request, right after the Request it belongs to. The headers start with the
|
|
122
|
+
* two that describe the connection, since every response carries them, and x-powered-by only
|
|
123
|
+
* when the setting asks for it.
|
|
124
|
+
*
|
|
125
|
+
* @param {any} res the uWS response
|
|
126
|
+
* @param {any} req the Request, already built, which is where the connection header is read from
|
|
127
|
+
* @param {any} app the application or router this request arrived at
|
|
128
|
+
*/
|
|
129
|
+
constructor(res, req, app) {
|
|
130
|
+
super();
|
|
131
|
+
this._req = req;
|
|
132
|
+
this._res = res;
|
|
133
|
+
this.headersSent = false;
|
|
134
|
+
this.app = app;
|
|
135
|
+
this.locals = new NullObject();
|
|
136
|
+
this.finished = false;
|
|
137
|
+
this.aborted = false;
|
|
138
|
+
this.statusCode = 200;
|
|
139
|
+
this.statusText = undefined;
|
|
140
|
+
this.chunkedTransfer = true;
|
|
141
|
+
this.totalSize = 0;
|
|
142
|
+
this.writingChunk = false;
|
|
143
|
+
this.headers = {
|
|
144
|
+
connection: "keep-alive",
|
|
145
|
+
"keep-alive": "timeout=10"
|
|
146
|
+
};
|
|
147
|
+
// the client asked for the connection to be closed, and uWS closes it, so saying otherwise
|
|
148
|
+
// would be telling the client something the transport contradicts. A declarative response
|
|
149
|
+
// cannot do this, being written once and not per request.
|
|
150
|
+
if (req._connectionClose) {
|
|
151
|
+
this.headers.connection = "close";
|
|
152
|
+
}
|
|
153
|
+
if (this.app.get("x-powered-by")) {
|
|
154
|
+
this.headers["x-powered-by"] = "Fulmine";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.body = undefined;
|
|
158
|
+
this.on("error", (err) => {
|
|
159
|
+
if (this.finished) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
this._res.cork(() => {
|
|
163
|
+
this._res.close();
|
|
164
|
+
this.finished = true;
|
|
165
|
+
this.#socket?.emit("close");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
this.once("close", () => {
|
|
169
|
+
this.#ended = true;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Where node keeps the outgoing headers of an OutgoingMessage. Only code going through node's
|
|
175
|
+
* own header path ever looks, cookie-session being the one in this project's tests, so the
|
|
176
|
+
* proxy standing in for it is built on the first look rather than on every response: it was a
|
|
177
|
+
* proxy, a handler object and two closures each time, for something almost nothing reads.
|
|
178
|
+
*
|
|
179
|
+
* A setter as well, because node assigns to this slot when it resets the headers, and a getter
|
|
180
|
+
* on its own would make that throw.
|
|
181
|
+
*/
|
|
182
|
+
get [kOutHeaders]() {
|
|
183
|
+
if (!this.#outHeaders) {
|
|
184
|
+
this.#outHeaders = new Proxy(this.headers, {
|
|
185
|
+
set: (obj, prop, value) => {
|
|
186
|
+
this.set(prop, value[1]);
|
|
187
|
+
return true;
|
|
188
|
+
},
|
|
189
|
+
get: (obj, prop) => {
|
|
190
|
+
return obj[prop];
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return this.#outHeaders;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
set [kOutHeaders](value) {
|
|
198
|
+
this.#outHeaders = value;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A socket-shaped object for middleware that reaches for one, built on first ask and kept
|
|
203
|
+
* from then on. null once the response is over, as node reports it.
|
|
204
|
+
* @returns {Socket|null}
|
|
205
|
+
*/
|
|
206
|
+
get socket() {
|
|
207
|
+
if (this.#ended) return null;
|
|
208
|
+
if (!this.#socket) {
|
|
209
|
+
this.#socket = new Socket(this);
|
|
210
|
+
}
|
|
211
|
+
return this.#socket;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Writable's sink. Sends the headers if they have not gone yet, then hands the chunk to uWS,
|
|
216
|
+
* either as a chunk of a chunked response or through tryEnd when a Content-Length said how
|
|
217
|
+
* much there would be. Backpressure comes back as onWritable, which is what defers the
|
|
218
|
+
* callback rather than dropping the chunk.
|
|
219
|
+
*
|
|
220
|
+
* @param {any} chunk
|
|
221
|
+
* @param {BufferEncoding} encoding
|
|
222
|
+
* @param {(err?: Error|null) => void} callback
|
|
223
|
+
*/
|
|
224
|
+
_write(chunk, encoding, callback) {
|
|
225
|
+
if (this.aborted) {
|
|
226
|
+
/** @type {NodeJS.ErrnoException} */
|
|
227
|
+
const err = new Error("Request aborted");
|
|
228
|
+
err.code = "ECONNABORTED";
|
|
229
|
+
return this.destroy(err);
|
|
230
|
+
}
|
|
231
|
+
if (this.finished) {
|
|
232
|
+
const err = new Error("Response already finished");
|
|
233
|
+
return this.destroy(err);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.writingChunk = true;
|
|
237
|
+
this._res.cork(() => {
|
|
238
|
+
if (!this.headersSent) {
|
|
239
|
+
this.writeHead(this.statusCode);
|
|
240
|
+
const statusMessage = this.statusText ?? statuses.message[this.statusCode] ?? "";
|
|
241
|
+
this._res.writeStatus(`${this.statusCode} ${statusMessage}`.trim());
|
|
242
|
+
this.writeHeaders(typeof chunk === "string");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!Buffer.isBuffer(chunk) && !(chunk instanceof ArrayBuffer)) {
|
|
246
|
+
chunk = Buffer.from(chunk);
|
|
247
|
+
chunk = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (this.chunkedTransfer) {
|
|
251
|
+
const ok = this._res.write(chunk);
|
|
252
|
+
if (ok) {
|
|
253
|
+
this.writingChunk = false;
|
|
254
|
+
callback(null);
|
|
255
|
+
} else {
|
|
256
|
+
this.#pendingCallback = callback;
|
|
257
|
+
this._res.onWritable(() => {
|
|
258
|
+
if (this.aborted || this.finished) return true;
|
|
259
|
+
const cb = this.#pendingCallback;
|
|
260
|
+
this.#pendingCallback = null;
|
|
261
|
+
this.writingChunk = false;
|
|
262
|
+
if (cb) cb(null);
|
|
263
|
+
return true;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
const lastOffset = this._res.getWriteOffset();
|
|
268
|
+
const [ok, done] = this._res.tryEnd(chunk, this.totalSize);
|
|
269
|
+
if (done) {
|
|
270
|
+
super.end();
|
|
271
|
+
this.finished = true;
|
|
272
|
+
this.writingChunk = false;
|
|
273
|
+
this.#socket?.emit("close");
|
|
274
|
+
callback(null);
|
|
275
|
+
} else if (!ok) {
|
|
276
|
+
this._res.ab = chunk;
|
|
277
|
+
this._res.abOffset = lastOffset;
|
|
278
|
+
let handlerUsed = false;
|
|
279
|
+
this._res.onWritable((offset) => {
|
|
280
|
+
if (this.finished || handlerUsed) return true;
|
|
281
|
+
const [ok, done] = this._res.tryEnd(
|
|
282
|
+
this._res.ab.slice(offset - this._res.abOffset),
|
|
283
|
+
this.totalSize
|
|
284
|
+
);
|
|
285
|
+
if (done) {
|
|
286
|
+
this.finished = true;
|
|
287
|
+
this.#socket?.emit("close");
|
|
288
|
+
}
|
|
289
|
+
if (ok) {
|
|
290
|
+
this.writingChunk = false;
|
|
291
|
+
handlerUsed = true;
|
|
292
|
+
callback(null);
|
|
293
|
+
}
|
|
294
|
+
return ok;
|
|
295
|
+
});
|
|
296
|
+
} else {
|
|
297
|
+
this.writingChunk = false;
|
|
298
|
+
callback(null);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Sets the status and, optionally, a batch of headers, the way node does. The second argument
|
|
306
|
+
* is either the status message or the headers, since node allows both shapes.
|
|
307
|
+
*
|
|
308
|
+
* Nothing is written here despite the name: the headers go out when the body does.
|
|
309
|
+
*
|
|
310
|
+
* @param {number} statusCode
|
|
311
|
+
* @param {string|Record<string, any>} [statusMessage] the reason phrase, or the headers
|
|
312
|
+
* @param {Record<string, any>} [headers]
|
|
313
|
+
* @returns {this}
|
|
314
|
+
*/
|
|
315
|
+
writeHead(statusCode, statusMessage, headers) {
|
|
316
|
+
this.statusCode = statusCode;
|
|
317
|
+
if (typeof statusMessage === "string") {
|
|
318
|
+
this.statusText = statusMessage;
|
|
319
|
+
}
|
|
320
|
+
if (!headers) {
|
|
321
|
+
if (!statusMessage) return this;
|
|
322
|
+
// the two-argument shape, where what looked like a reason phrase is the headers. A
|
|
323
|
+
// string reaching here was already taken as the phrase above and simply has no keys.
|
|
324
|
+
headers = /** @type {Record<string, any>} */ (statusMessage);
|
|
325
|
+
}
|
|
326
|
+
for (const header in headers) {
|
|
327
|
+
this.set(header, headers[header]);
|
|
328
|
+
}
|
|
329
|
+
return this;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Writes every header set so far to uWS, which is the point of no return. Content-Length is
|
|
334
|
+
* not one of them: uWS wants the length through tryEnd or endWithoutBody, so it is taken out
|
|
335
|
+
* here and kept on totalSize, where it also turns chunked framing off.
|
|
336
|
+
*
|
|
337
|
+
* @param {boolean} utf8 unused, kept because node's equivalent takes it and the two callers
|
|
338
|
+
* differ on what they know about the body
|
|
339
|
+
*/
|
|
340
|
+
writeHeaders(utf8) {
|
|
341
|
+
// Keep-Alive describes a connection that is being kept alive, so node leaves it out once
|
|
342
|
+
// the connection is closing. That happens both when the client asked and when something
|
|
343
|
+
// else set the header on the way out, which is what a proxy passing an upstream response
|
|
344
|
+
// through does.
|
|
345
|
+
const connection = this.headers["connection"];
|
|
346
|
+
const closing = typeof connection === "string" && connection.toLowerCase() === "close";
|
|
347
|
+
for (const header in this.headers) {
|
|
348
|
+
if (closing && header === "keep-alive") {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const value = this.headers[header];
|
|
352
|
+
if (header === "content-length") {
|
|
353
|
+
// if content-length is set, disable chunked transfer encoding, since size is known
|
|
354
|
+
this.chunkedTransfer = false;
|
|
355
|
+
this.totalSize = parseInt(value);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (Array.isArray(value)) {
|
|
359
|
+
for (const val of value) {
|
|
360
|
+
this._res.writeHeader(header, val);
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
this._res.writeHeader(header, value);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
this.headersSent = true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* What node calls before writing a body when the caller never called writeHead. Here there is
|
|
371
|
+
* nothing to flush, since the headers are written with the body, so this only fixes the status.
|
|
372
|
+
*/
|
|
373
|
+
_implicitHeader() {
|
|
374
|
+
// compatibility function
|
|
375
|
+
// usually should send headers but this is useless for us
|
|
376
|
+
this.writeHead(this.statusCode);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Sets the status code.
|
|
381
|
+
* @param {number} code an integer from 100 to 999
|
|
382
|
+
* @returns {this} the response, for chaining
|
|
383
|
+
* @throws {TypeError} if the code is not an integer, "200" included
|
|
384
|
+
* @throws {RangeError} if it is an integer outside the range
|
|
385
|
+
*/
|
|
386
|
+
status(code) {
|
|
387
|
+
// Express 5 rejects anything that is not a plausible status code, instead of writing NaN or
|
|
388
|
+
// a nonsense number into the response line, and it tells the two ways of being wrong apart:
|
|
389
|
+
// the wrong type is a TypeError and the wrong number is a RangeError. Both messages are
|
|
390
|
+
// Express's own, since they are what reaches whoever catches them
|
|
391
|
+
if (!Number.isInteger(code)) {
|
|
392
|
+
throw new TypeError(`Invalid status code: ${JSON.stringify(code)}. Status code must be an integer.`);
|
|
393
|
+
}
|
|
394
|
+
if (code < 100 || code > 999) {
|
|
395
|
+
throw new RangeError(
|
|
396
|
+
`Invalid status code: ${JSON.stringify(code)}. Status code must be greater than 99 and less than 1000.`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
this.statusCode = code;
|
|
400
|
+
return this;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Sets the status and sends its standard message as the body, so 404 answers "Not Found".
|
|
405
|
+
* @param {number} code
|
|
406
|
+
* @returns {this}
|
|
407
|
+
*/
|
|
408
|
+
sendStatus(code) {
|
|
409
|
+
return this.status(code)
|
|
410
|
+
.type("txt")
|
|
411
|
+
.send(statuses.message[code] || String(code));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @param {any} [data]
|
|
416
|
+
* @param {any} [cb]
|
|
417
|
+
* @returns {this}
|
|
418
|
+
*/
|
|
419
|
+
end(data, cb) {
|
|
420
|
+
if (typeof data === "function") {
|
|
421
|
+
cb = data;
|
|
422
|
+
data = undefined;
|
|
423
|
+
}
|
|
424
|
+
if (typeof cb !== "function") {
|
|
425
|
+
cb = undefined; // silence the error?
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (this.writingChunk) {
|
|
429
|
+
this.once("drain", () => {
|
|
430
|
+
this.end(data, cb);
|
|
431
|
+
});
|
|
432
|
+
return this;
|
|
433
|
+
}
|
|
434
|
+
if (this.finished) {
|
|
435
|
+
return this;
|
|
436
|
+
}
|
|
437
|
+
this.writeHead(this.statusCode);
|
|
438
|
+
this._res.cork(() => {
|
|
439
|
+
if (!this.headersSent) {
|
|
440
|
+
// freshness is not decided here. node's end() knows nothing about conditional
|
|
441
|
+
// requests, and Express answers 304 from send() and from sendFile(), each of
|
|
442
|
+
// which strips the entity headers first. Deciding it here meant res.end("body")
|
|
443
|
+
// answered 304 and dropped the body that the caller had just written.
|
|
444
|
+
const statusMessage = this.statusText ?? statuses.message[this.statusCode] ?? "";
|
|
445
|
+
this._res.writeStatus(`${this.statusCode} ${statusMessage}`.trim());
|
|
446
|
+
this.writeHeaders(true);
|
|
447
|
+
}
|
|
448
|
+
const contentLength = this.headers["content-length"];
|
|
449
|
+
if (STATUSES_WITHOUT_BODY.has(this.statusCode) || this.statusCode < 200) {
|
|
450
|
+
// no body and no length describing one, whatever the caller passed. node decides
|
|
451
|
+
// this the same way, from the status alone, so res.status(304).end("x") sends the
|
|
452
|
+
// status and nothing else on either.
|
|
453
|
+
this._res.endWithoutBody();
|
|
454
|
+
} else if (!data && contentLength) {
|
|
455
|
+
this._res.endWithoutBody(contentLength.toString());
|
|
456
|
+
} else {
|
|
457
|
+
if (data instanceof Buffer) {
|
|
458
|
+
data = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
459
|
+
}
|
|
460
|
+
if (this.req.method === "HEAD") {
|
|
461
|
+
const length = Buffer.byteLength(data ?? "");
|
|
462
|
+
this._res.endWithoutBody(length.toString());
|
|
463
|
+
} else {
|
|
464
|
+
this._res.end(data);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
this.finished = true;
|
|
469
|
+
this.#socket?.emit("close");
|
|
470
|
+
this.emit("finish");
|
|
471
|
+
this.emit("close");
|
|
472
|
+
cb &&
|
|
473
|
+
queueMicrotask(() => {
|
|
474
|
+
this.#ended = true;
|
|
475
|
+
cb();
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
return this;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Sends the body, picking a Content-Type when none was set and adding an ETag when the
|
|
483
|
+
* "etag" setting asks for one.
|
|
484
|
+
*
|
|
485
|
+
* A number is a value to serialise, the same as a boolean or an object, and never a status
|
|
486
|
+
* code: use `sendStatus()` for that.
|
|
487
|
+
*
|
|
488
|
+
* @param {string|number|boolean|object|Buffer|null} [body]
|
|
489
|
+
* @returns {this}
|
|
490
|
+
*/
|
|
491
|
+
send(body) {
|
|
492
|
+
if (this.headersSent) {
|
|
493
|
+
throw new Error("Can't write body: Response was already sent");
|
|
494
|
+
}
|
|
495
|
+
// a typed array is bytes to send, not an object to serialise: res.send(new Uint8Array([104,
|
|
496
|
+
// 101, 121])) is "hey" and not {"0":104,"1":101,"2":121}. Uint8Array and not every view
|
|
497
|
+
// over an ArrayBuffer, because that is what node's own write accepts, and Express hands the
|
|
498
|
+
// view straight to it: a DataView reaches node there and comes out as an empty body
|
|
499
|
+
if (body instanceof Uint8Array && !Buffer.isBuffer(body)) {
|
|
500
|
+
body = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
|
501
|
+
}
|
|
502
|
+
const isBuffer = Buffer.isBuffer(body);
|
|
503
|
+
// undefined means nothing was passed, and Express treats that differently from a value
|
|
504
|
+
// that happens to be empty: no content-type and no ETag for send(), both for send(null)
|
|
505
|
+
// and send("").
|
|
506
|
+
if (body === undefined) {
|
|
507
|
+
return this.end("");
|
|
508
|
+
}
|
|
509
|
+
// null is an object as far as Express's switch is concerned, so it becomes the empty
|
|
510
|
+
// string without ever reaching the branch that gives a string its content-type. It still
|
|
511
|
+
// earns an ETag. send("") takes the string branch and does get one.
|
|
512
|
+
let skipContentType = false;
|
|
513
|
+
if (body === null) {
|
|
514
|
+
body = "";
|
|
515
|
+
skipContentType = true;
|
|
516
|
+
} else if (typeof body === "object" && !isBuffer) {
|
|
517
|
+
return this.json(body);
|
|
518
|
+
} else if (typeof body === "number") {
|
|
519
|
+
// a number is a value to serialise, the same as a boolean, and never a status code.
|
|
520
|
+
// res.sendStatus() is what sets a status.
|
|
521
|
+
return this.json(body);
|
|
522
|
+
} else if (typeof body === "boolean") {
|
|
523
|
+
return this.json(body);
|
|
524
|
+
} else if (!isBuffer) {
|
|
525
|
+
body = String(body);
|
|
526
|
+
}
|
|
527
|
+
if (typeof body === "string" && !isBuffer) {
|
|
528
|
+
const contentType = this.headers["content-type"];
|
|
529
|
+
if (!contentType) {
|
|
530
|
+
// send(null) sends an empty string without choosing a type. Only a string argument
|
|
531
|
+
// reaches for text/html, which is the branch Express's switch takes for it.
|
|
532
|
+
if (!skipContentType) {
|
|
533
|
+
this.headers["content-type"] = "text/html; charset=utf-8";
|
|
534
|
+
}
|
|
535
|
+
} else if (typeof contentType === "string") {
|
|
536
|
+
// replaced, not only added: the body goes out as utf-8, so a content-type saying
|
|
537
|
+
// iso-8859-1 would be describing bytes that are not there.
|
|
538
|
+
this.headers["content-type"] = withUtf8Charset(contentType);
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
if (!this.headers["content-type"]) {
|
|
542
|
+
this.headers["content-type"] = "application/octet-stream";
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
// the ETag belongs here rather than in end(): node's end() does not produce one, so
|
|
546
|
+
// res.end() and res.redirect() must not either. It has to be set before end() reads
|
|
547
|
+
// req.fresh, which compares If-None-Match against it.
|
|
548
|
+
// body is defined by the time it gets here, so an empty one still earns an ETag. Testing
|
|
549
|
+
// its truthiness instead meant send("") and send(null) came back without one.
|
|
550
|
+
const etagFn = this.app.get("etag fn");
|
|
551
|
+
if (etagFn && !this.headers["etag"] && !this.req.noEtag) {
|
|
552
|
+
const etag = etagFn(body);
|
|
553
|
+
// an application's own etag function is allowed to decline: returning nothing means no
|
|
554
|
+
// header, rather than a header saying "undefined"
|
|
555
|
+
if (etag) {
|
|
556
|
+
this.headers["etag"] = etag;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// after the ETag, never before: freshness compares If-None-Match against the one that is
|
|
560
|
+
// about to be sent, so a generated ETag has to exist by now.
|
|
561
|
+
if (this.req.fresh) {
|
|
562
|
+
this.status(304);
|
|
563
|
+
}
|
|
564
|
+
// A 204 and a 304 carry no body, so the headers describing one have no meaning and are
|
|
565
|
+
// dropped. A 205 carries no body either but has to say so with an explicit length.
|
|
566
|
+
if (this.statusCode === 204 || this.statusCode === 304) {
|
|
567
|
+
delete this.headers["content-type"];
|
|
568
|
+
delete this.headers["content-length"];
|
|
569
|
+
delete this.headers["transfer-encoding"];
|
|
570
|
+
body = "";
|
|
571
|
+
} else if (this.statusCode === 205) {
|
|
572
|
+
this.headers["content-length"] = "0";
|
|
573
|
+
delete this.headers["transfer-encoding"];
|
|
574
|
+
body = "";
|
|
575
|
+
}
|
|
576
|
+
return this.end(body);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Streams a file, setting Content-Type from the extension and answering conditional and range
|
|
581
|
+
* requests. The path must be absolute unless `options.root` is given, and a function in the
|
|
582
|
+
* options position is the callback.
|
|
583
|
+
*
|
|
584
|
+
* Options: `root`, `maxAge`, `lastModified`, `headers`, `dotfiles` ("allow", "deny" or
|
|
585
|
+
* "ignore"), `acceptRanges`, `cacheControl`, `immutable`, `etag` and `setHeaders`.
|
|
586
|
+
*
|
|
587
|
+
* @param {string} path
|
|
588
|
+
* @param {Record<string, any>} [options]
|
|
589
|
+
* @param {(err?: Error) => void} [callback] called once sent, or with the error
|
|
590
|
+
*/
|
|
591
|
+
sendFile(path, options = new NullObject(), callback) {
|
|
592
|
+
if (!path) {
|
|
593
|
+
throw new TypeError("path argument is required to res.sendFile");
|
|
594
|
+
}
|
|
595
|
+
// a separate message from the one above, as Express has: "required" is wrong for an
|
|
596
|
+
// argument that was passed and was a number
|
|
597
|
+
if (typeof path !== "string") {
|
|
598
|
+
throw new TypeError("path must be a string to res.sendFile");
|
|
599
|
+
}
|
|
600
|
+
if (typeof options === "function") {
|
|
601
|
+
callback = /** @type {any} */ (options);
|
|
602
|
+
options = new NullObject();
|
|
603
|
+
}
|
|
604
|
+
if (!options) options = new NullObject();
|
|
605
|
+
// the callback is optional: without one, errors go to next(). The router assigns req.next
|
|
606
|
+
// before any handler can run, so by the time sendFile is reachable it is always there.
|
|
607
|
+
const done = /** @type {(err?: Error) => void} */ (callback ?? this.req.next);
|
|
608
|
+
// default options
|
|
609
|
+
// Normalised the way send does, and it is not fussiness: max-age takes a non-negative
|
|
610
|
+
// integer count of seconds, so 0.5, -1 and Infinity are all invalid, and a client that
|
|
611
|
+
// cannot read the directive may throw away the whole Cache-Control header with it. A
|
|
612
|
+
// fractional maxAge came out as "max-age=0.5" here, a negative one as "max-age=-1" and
|
|
613
|
+
// Infinity as "max-age=Infinity".
|
|
614
|
+
// Number() around the lot, and not only around the branch that is already a number: ms()
|
|
615
|
+
// answers undefined for a duration it cannot read, and Number.isNaN(undefined) is false,
|
|
616
|
+
// so an unreadable string reached the header as "max-age=NaN".
|
|
617
|
+
const maxAge = Number(
|
|
618
|
+
typeof options.maxAge === "string" ? ms(/** @type {any} */ (options.maxAge)) : options.maxAge
|
|
619
|
+
);
|
|
620
|
+
options.maxAge = Number.isNaN(maxAge) ? 0 : Math.min(Math.max(0, maxAge), MAX_MAXAGE);
|
|
621
|
+
if (typeof options.lastModified === "undefined") {
|
|
622
|
+
options.lastModified = true;
|
|
623
|
+
}
|
|
624
|
+
if (typeof options.cacheControl === "undefined") {
|
|
625
|
+
options.cacheControl = true;
|
|
626
|
+
}
|
|
627
|
+
if (typeof options.acceptRanges === "undefined") {
|
|
628
|
+
options.acceptRanges = true;
|
|
629
|
+
}
|
|
630
|
+
// Express wires the app's setting straight into send here and drops whatever the caller
|
|
631
|
+
// passed, so res.sendFile(p, { etag: false }) still sends one while the app has ETags on.
|
|
632
|
+
// express.static is the opposite: serve-static never asks the app, so a static file keeps
|
|
633
|
+
// its ETag even under app.set("etag", false). It says so with _ownEtag.
|
|
634
|
+
if (!options._ownEtag) {
|
|
635
|
+
options.etag = this.app.get("etag") !== false;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// path checks
|
|
639
|
+
if (!options.root && !isAbsolute(path)) {
|
|
640
|
+
// thrown rather than reported to the callback, as Express throws it. A relative path
|
|
641
|
+
// with no root is the calling code being wrong, not the request, and there is nothing
|
|
642
|
+
// the caller's error branch could usefully do with it.
|
|
643
|
+
throw new TypeError("path must be absolute or specify root to res.sendFile");
|
|
644
|
+
}
|
|
645
|
+
if (!options.skipEncodePath) {
|
|
646
|
+
path = encodeURI(path);
|
|
647
|
+
}
|
|
648
|
+
// decode reports failure with -1 rather than throwing, so it needs its own binding before
|
|
649
|
+
// it can go back into path
|
|
650
|
+
const decoded = decode(path);
|
|
651
|
+
if (decoded === -1) {
|
|
652
|
+
this.status(400);
|
|
653
|
+
return done(httpError(400));
|
|
654
|
+
}
|
|
655
|
+
path = decoded;
|
|
656
|
+
if (~path.indexOf("\0")) {
|
|
657
|
+
this.status(400);
|
|
658
|
+
return done(httpError(400));
|
|
659
|
+
}
|
|
660
|
+
if (UP_PATH_REGEXP.test(path)) {
|
|
661
|
+
this.status(403);
|
|
662
|
+
return done(httpError(403));
|
|
663
|
+
}
|
|
664
|
+
const parts = Path.normalize(path).split(Path.sep);
|
|
665
|
+
const fullpath = options.root ? Path.resolve(Path.join(options.root, path)) : path;
|
|
666
|
+
if (options.root && !fullpath.startsWith(Path.resolve(options.root))) {
|
|
667
|
+
this.status(403);
|
|
668
|
+
return done(httpError(403));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// dotfile checks
|
|
672
|
+
if (containsDotFile(parts)) {
|
|
673
|
+
switch (options.dotfiles) {
|
|
674
|
+
case "allow":
|
|
675
|
+
break;
|
|
676
|
+
case "deny":
|
|
677
|
+
this.status(403);
|
|
678
|
+
return done(httpError(403));
|
|
679
|
+
case "ignore_files": {
|
|
680
|
+
const len = parts.length;
|
|
681
|
+
if (len > 1 && parts[len - 1].startsWith(".")) {
|
|
682
|
+
this.status(404);
|
|
683
|
+
return done(httpError(404));
|
|
684
|
+
}
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
case "ignore":
|
|
688
|
+
default:
|
|
689
|
+
this.status(404);
|
|
690
|
+
return done(httpError(404));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
let stat = options._stat;
|
|
695
|
+
if (!stat) {
|
|
696
|
+
try {
|
|
697
|
+
stat = fs.statSync(fullpath);
|
|
698
|
+
} catch (err) {
|
|
699
|
+
// the fs error itself, carrying its errno and path, with send's status written on
|
|
700
|
+
// it: a missing file is the request's 404, an unreadable one is the server's 500
|
|
701
|
+
return done(asStatError(/** @type {any} */ (err)));
|
|
702
|
+
}
|
|
703
|
+
if (stat.isDirectory()) {
|
|
704
|
+
// Express reports a directory as an EISDIR with no status, because send tells it
|
|
705
|
+
// apart from an error: it emits "directory", and res.sendFile has no listener for
|
|
706
|
+
// one. So this is not a 404, and an error handler reading err.code sees the code
|
|
707
|
+
// it expects.
|
|
708
|
+
this.status(404);
|
|
709
|
+
const err = /** @type {any} */ (new Error("EISDIR, read"));
|
|
710
|
+
err.code = "EISDIR";
|
|
711
|
+
return done(err);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// headers
|
|
716
|
+
if (!this.headers["content-type"]) {
|
|
717
|
+
const m = mime.lookup(fullpath);
|
|
718
|
+
if (m) this.type(m);
|
|
719
|
+
else this.type("application/octet-stream");
|
|
720
|
+
}
|
|
721
|
+
if (options.cacheControl) {
|
|
722
|
+
this.headers["cache-control"] =
|
|
723
|
+
`public, max-age=${Math.floor(options.maxAge / 1000)}` + (options.immutable ? ", immutable" : "");
|
|
724
|
+
}
|
|
725
|
+
if (options.lastModified) {
|
|
726
|
+
this.headers["last-modified"] = stat.mtime.toUTCString();
|
|
727
|
+
}
|
|
728
|
+
if (options.headers) {
|
|
729
|
+
for (const header in options.headers) {
|
|
730
|
+
// setHeader, not set: Express hands these to send, which writes them through node's
|
|
731
|
+
// setHeader, so a Content-Type given here is written exactly as given. res.set would
|
|
732
|
+
// append a charset and turn "text/x-custom" into "text/x-custom; charset=utf-8",
|
|
733
|
+
// which is a different media type from the one the caller asked for.
|
|
734
|
+
this.setHeader(header, options.headers[header]);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (options.setHeaders) {
|
|
738
|
+
options.setHeaders(/** @type {any} */ (this), fullpath, stat);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// etag, from the stat and never from the app's "etag fn". send computes this itself with
|
|
742
|
+
// the etag package, so neither a custom fn nor app.set("etag", "strong") reaches a file's
|
|
743
|
+
// ETag on Express either.
|
|
744
|
+
if (options.etag && !this.headers["etag"]) {
|
|
745
|
+
this.headers["etag"] = statTag(stat, true);
|
|
746
|
+
}
|
|
747
|
+
if (!options.etag) {
|
|
748
|
+
this.req.noEtag = true;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// announced before the conditional checks, because those can return early and the header
|
|
752
|
+
// still belongs on the response. send does it in the same order, so a 412 or a 416 still
|
|
753
|
+
// tells the client that ranges are available.
|
|
754
|
+
if (options.acceptRanges) {
|
|
755
|
+
this.headers["accept-ranges"] = "bytes";
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// conditional requests
|
|
759
|
+
if (isPreconditionFailure(this.req, this)) {
|
|
760
|
+
this.status(412);
|
|
761
|
+
return done(httpError(412));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// range requests
|
|
765
|
+
let offset = 0,
|
|
766
|
+
len = stat.size,
|
|
767
|
+
ranged = false;
|
|
768
|
+
if (options.acceptRanges) {
|
|
769
|
+
if (this.req.headers.range) {
|
|
770
|
+
let ranges = this.req.range(stat.size, {
|
|
771
|
+
combine: true
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// if-range
|
|
775
|
+
if (!isRangeFresh(this.req, this)) {
|
|
776
|
+
ranges = -2;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (ranges === -1) {
|
|
780
|
+
this.status(416);
|
|
781
|
+
this.headers["content-range"] = `bytes */${stat.size}`;
|
|
782
|
+
return done(httpError(416));
|
|
783
|
+
}
|
|
784
|
+
if (ranges !== -2 && ranges.length === 1) {
|
|
785
|
+
this.status(206);
|
|
786
|
+
const range = ranges[0];
|
|
787
|
+
this.headers["content-range"] = `bytes ${range.start}-${range.end}/${stat.size}`;
|
|
788
|
+
offset = range.start;
|
|
789
|
+
len = range.end - range.start + 1;
|
|
790
|
+
ranged = true;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// if-modified-since, if-none-match
|
|
796
|
+
if (this.req.fresh) {
|
|
797
|
+
// the same fields send removes: everything describing a body that is not being sent.
|
|
798
|
+
// Content-Range goes too, since a 304 answers the whole conditional request and not
|
|
799
|
+
// the range that was asked for.
|
|
800
|
+
delete this.headers["content-type"];
|
|
801
|
+
delete this.headers["content-encoding"];
|
|
802
|
+
delete this.headers["content-language"];
|
|
803
|
+
delete this.headers["content-length"];
|
|
804
|
+
delete this.headers["content-range"];
|
|
805
|
+
this.status(304);
|
|
806
|
+
return this.end();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (this.req.method === "HEAD") {
|
|
810
|
+
this.set("Content-Length", stat.size);
|
|
811
|
+
return this.end();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// serve smaller files using workers
|
|
815
|
+
if (this.app.workers.length && stat.size < 768 * 1024 && !ranged) {
|
|
816
|
+
this.app
|
|
817
|
+
.readFileWithWorker(fullpath)
|
|
818
|
+
.then((data) => {
|
|
819
|
+
if (this._res.finished) {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
this.end(data);
|
|
823
|
+
if (callback) callback();
|
|
824
|
+
})
|
|
825
|
+
.catch((err) => {
|
|
826
|
+
if (callback) callback(err);
|
|
827
|
+
});
|
|
828
|
+
} else {
|
|
829
|
+
// larger files or range requests are piped over response
|
|
830
|
+
const opts = {
|
|
831
|
+
highWaterMark: HIGH_WATERMARK
|
|
832
|
+
};
|
|
833
|
+
if (ranged) {
|
|
834
|
+
opts.start = offset;
|
|
835
|
+
opts.end = Math.max(offset, offset + len - 1);
|
|
836
|
+
}
|
|
837
|
+
const file = fs.createReadStream(fullpath, opts);
|
|
838
|
+
this.set("Content-Length", len);
|
|
839
|
+
file.pipe(this);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Sends a file as an attachment, so the browser saves it instead of displaying it.
|
|
845
|
+
*
|
|
846
|
+
* `filename` and `options` can both be left out, and a function in either position is taken
|
|
847
|
+
* as the callback.
|
|
848
|
+
*
|
|
849
|
+
* @param {string} path
|
|
850
|
+
* @param {string} [filename] name offered to the user, defaults to the basename of the path
|
|
851
|
+
* @param {Record<string, any>} [options] passed through to sendFile
|
|
852
|
+
* @param {(err?: Error) => void} [callback]
|
|
853
|
+
*/
|
|
854
|
+
download(path, filename, options, callback) {
|
|
855
|
+
let done = callback;
|
|
856
|
+
/** @type {string|null|undefined} */
|
|
857
|
+
let name = filename;
|
|
858
|
+
let opts = options || new NullObject();
|
|
859
|
+
|
|
860
|
+
// support function as second or third arg
|
|
861
|
+
if (typeof filename === "function") {
|
|
862
|
+
done = /** @type {any} */ (filename);
|
|
863
|
+
name = null;
|
|
864
|
+
opts = {};
|
|
865
|
+
} else if (typeof options === "function") {
|
|
866
|
+
done = /** @type {any} */ (options);
|
|
867
|
+
opts = {};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// support optional filename, where options may be in it's place
|
|
871
|
+
if (typeof filename === "object" && (typeof options === "function" || options === undefined)) {
|
|
872
|
+
name = null;
|
|
873
|
+
opts = filename;
|
|
874
|
+
}
|
|
875
|
+
if (!name) {
|
|
876
|
+
name = Path.basename(path);
|
|
877
|
+
}
|
|
878
|
+
if (!opts.root && !isAbsolute(path)) {
|
|
879
|
+
opts.root = process.cwd();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
this.attachment(name);
|
|
883
|
+
this.sendFile(path, opts, done);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Sets a header, node's way: no charset is added to a content-type, since node does not know
|
|
888
|
+
* what a media type is. res.set does that, and is what Express code should use.
|
|
889
|
+
*
|
|
890
|
+
* @param {string} field
|
|
891
|
+
* @param {any} value an array sends the header once per entry
|
|
892
|
+
* @returns {this}
|
|
893
|
+
* @throws {Error} once the headers have gone out
|
|
894
|
+
* @throws {TypeError} if the name is not a string
|
|
895
|
+
*/
|
|
896
|
+
setHeader(field, value) {
|
|
897
|
+
if (this.headersSent) {
|
|
898
|
+
throw new Error("Cannot set headers after they are sent to the client");
|
|
899
|
+
}
|
|
900
|
+
if (typeof field !== "string") {
|
|
901
|
+
throw new TypeError("Header name must be a valid HTTP token");
|
|
902
|
+
} else {
|
|
903
|
+
field = field.toLowerCase();
|
|
904
|
+
if (Array.isArray(value)) {
|
|
905
|
+
this.headers[field] = value;
|
|
906
|
+
return this;
|
|
907
|
+
}
|
|
908
|
+
this.headers[field] = String(value);
|
|
909
|
+
}
|
|
910
|
+
return this;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Node asks this before validating a header value, and answering true keeps it permissive.
|
|
915
|
+
* Only reached through code that goes down node's own header path.
|
|
916
|
+
*/
|
|
917
|
+
_isLenientHeaderValidation() {
|
|
918
|
+
// Node.js internal function for lenient header validation
|
|
919
|
+
// Returns true to allow more permissive header value validation
|
|
920
|
+
return true;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* The Express name for set(), including the charset it adds to a content-type.
|
|
925
|
+
* @param {any} field a header name, or an object of them
|
|
926
|
+
* @param {any} [value]
|
|
927
|
+
* @returns {this}
|
|
928
|
+
*/
|
|
929
|
+
header(field, value) {
|
|
930
|
+
return this.set(field, value);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Sets one header, or several from an object. Also available as `header()`.
|
|
935
|
+
* @param {string|object} field header name, or an object of them
|
|
936
|
+
* @param {string|string[]} [value]
|
|
937
|
+
* @returns {this}
|
|
938
|
+
*/
|
|
939
|
+
set(field, value) {
|
|
940
|
+
if (typeof field === "object") {
|
|
941
|
+
for (const header in field) {
|
|
942
|
+
// through set() and not straight to setHeader, so that a whole object of headers
|
|
943
|
+
// gets the same coercion and the same Content-Type handling as one set at a time
|
|
944
|
+
this.set(header, field[header]);
|
|
945
|
+
}
|
|
946
|
+
} else {
|
|
947
|
+
field = field.toLowerCase();
|
|
948
|
+
// a header is text on the wire whatever it was here, and Express coerces at this point,
|
|
949
|
+
// so res.get answers what was sent rather than the number or object it was given
|
|
950
|
+
let out = Array.isArray(value) ? value.map(String) : String(value);
|
|
951
|
+
if (field === "content-type") {
|
|
952
|
+
if (Array.isArray(out)) {
|
|
953
|
+
throw new TypeError("Content-Type cannot be set to an Array");
|
|
954
|
+
}
|
|
955
|
+
// every type the mime database gives a charset, not a list of three. The list was
|
|
956
|
+
// missing application/manifest+json among others, which Express does charset.
|
|
957
|
+
out = withDefaultCharset(out);
|
|
958
|
+
}
|
|
959
|
+
this.setHeader(field, out);
|
|
960
|
+
}
|
|
961
|
+
return this;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Reads a response header that has been set, case insensitively.
|
|
966
|
+
* @param {string} field
|
|
967
|
+
* @returns {string|string[]|undefined}
|
|
968
|
+
*/
|
|
969
|
+
get(field) {
|
|
970
|
+
return this.headers[field.toLowerCase()];
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Reads a header that has been set, case insensitively. node's name for get().
|
|
975
|
+
* @param {string} field
|
|
976
|
+
* @returns {string|string[]|undefined}
|
|
977
|
+
*/
|
|
978
|
+
getHeader(field) {
|
|
979
|
+
return this.get(field);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Every header set so far, as the object they are kept in rather than a copy, so writing to
|
|
984
|
+
* it writes to the response.
|
|
985
|
+
* @returns {Record<string, any>}
|
|
986
|
+
*/
|
|
987
|
+
getHeaders() {
|
|
988
|
+
return this.headers;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Removes a header that has not been flushed yet.
|
|
993
|
+
*
|
|
994
|
+
* Returns nothing, the way node's OutgoingMessage does. Returning the response would let
|
|
995
|
+
* chains be written here that break the moment the same code runs on Express.
|
|
996
|
+
*
|
|
997
|
+
* @param {string} field
|
|
998
|
+
*/
|
|
999
|
+
removeHeader(field) {
|
|
1000
|
+
delete this.headers[field.toLowerCase()];
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Adds a header without replacing what is already there, which is what Set-Cookie and Vary
|
|
1005
|
+
* need.
|
|
1006
|
+
* @param {string} field
|
|
1007
|
+
* @param {string|string[]} value
|
|
1008
|
+
* @returns {this}
|
|
1009
|
+
*/
|
|
1010
|
+
append(field, value) {
|
|
1011
|
+
field = field.toLowerCase();
|
|
1012
|
+
const old = this.headers[field];
|
|
1013
|
+
if (old) {
|
|
1014
|
+
const newVal = [];
|
|
1015
|
+
if (Array.isArray(old)) {
|
|
1016
|
+
newVal.push(...old);
|
|
1017
|
+
} else {
|
|
1018
|
+
newVal.push(old);
|
|
1019
|
+
}
|
|
1020
|
+
if (Array.isArray(value)) {
|
|
1021
|
+
newVal.push(...value);
|
|
1022
|
+
} else {
|
|
1023
|
+
newVal.push(value);
|
|
1024
|
+
}
|
|
1025
|
+
this.headers[field] = newVal;
|
|
1026
|
+
} else {
|
|
1027
|
+
this.headers[field] = value;
|
|
1028
|
+
}
|
|
1029
|
+
return this;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Renders a view and sends it. With a callback the result goes to the callback instead, and
|
|
1034
|
+
* nothing is sent. A function in the options position is taken as the callback.
|
|
1035
|
+
* @param {string} view view name
|
|
1036
|
+
* @param {Record<string, any>} [options] locals for the view
|
|
1037
|
+
* @param {(err: Error|null, html?: string) => void} [callback]
|
|
1038
|
+
*/
|
|
1039
|
+
render(view, options, callback) {
|
|
1040
|
+
if (typeof options === "function") {
|
|
1041
|
+
callback = /** @type {any} */ (options);
|
|
1042
|
+
options = {};
|
|
1043
|
+
}
|
|
1044
|
+
if (!options) {
|
|
1045
|
+
options = {};
|
|
1046
|
+
} else {
|
|
1047
|
+
options = Object.assign({}, options);
|
|
1048
|
+
}
|
|
1049
|
+
options._locals = this.locals;
|
|
1050
|
+
const done =
|
|
1051
|
+
callback ||
|
|
1052
|
+
((err, str) => {
|
|
1053
|
+
if (err) return this.req.next(err);
|
|
1054
|
+
this.send(str);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
// use req.app like express does, so mounted sub-apps resolve views with their own settings
|
|
1058
|
+
this.req.app.render(view, options, done);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Appends a Set-Cookie header. An object value is serialised as JSON. With `signed` the
|
|
1063
|
+
* cookie is signed using the secret given to cookie-parser.
|
|
1064
|
+
* @param {string} name
|
|
1065
|
+
* @param {string|object} value
|
|
1066
|
+
* @param {{maxAge?: number, expires?: Date, path?: string, domain?: string, secure?: boolean,
|
|
1067
|
+
* httpOnly?: boolean, sameSite?: boolean|"lax"|"strict"|"none", signed?: boolean,
|
|
1068
|
+
* priority?: "low"|"medium"|"high", partitioned?: boolean}} [options]
|
|
1069
|
+
* @returns {this}
|
|
1070
|
+
*/
|
|
1071
|
+
cookie(name, value, options) {
|
|
1072
|
+
const opt = { ...(options ?? {}) }; // create a new ref because we change original object (https://github.com/dimdenGD/ultimate-express/issues/68)
|
|
1073
|
+
if (opt.signed && !this.req.secret) {
|
|
1074
|
+
// the message has to read like this: it is the one Express throws, and it names the
|
|
1075
|
+
// thing that is actually missing rather than the library that noticed
|
|
1076
|
+
throw new Error('cookieParser("secret") required for signed cookies');
|
|
1077
|
+
}
|
|
1078
|
+
let val = typeof value === "object" ? "j:" + JSON.stringify(value) : String(value);
|
|
1079
|
+
if (opt.maxAge != null) {
|
|
1080
|
+
const maxAge = opt.maxAge - 0;
|
|
1081
|
+
if (!isNaN(maxAge)) {
|
|
1082
|
+
opt.expires = new Date(Date.now() + maxAge);
|
|
1083
|
+
opt.maxAge = Math.floor(maxAge / 1000);
|
|
1084
|
+
}
|
|
1085
|
+
} else {
|
|
1086
|
+
// Express carries a null maxAge through to a cookie package that ignores it. Ours
|
|
1087
|
+
// refuses it, so it is dropped here instead: no Max-Age is what both end up sending
|
|
1088
|
+
delete opt.maxAge;
|
|
1089
|
+
}
|
|
1090
|
+
if (opt.signed) {
|
|
1091
|
+
val = "s:" + sign(val, this.req.secret);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (opt.path == null) {
|
|
1095
|
+
opt.path = "/";
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
this.append("Set-Cookie", cookie.serialize(name, val, opt));
|
|
1099
|
+
return this;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Clears a cookie. The browser only matches it if `path` and `domain` are the ones it was
|
|
1104
|
+
* set with. Any `maxAge` or `expires` passed here is ignored, since clearing is defined as
|
|
1105
|
+
* expiring it immediately.
|
|
1106
|
+
* @param {string} name
|
|
1107
|
+
* @param {Record<string, any>} [options]
|
|
1108
|
+
* @returns {this}
|
|
1109
|
+
*/
|
|
1110
|
+
clearCookie(name, options) {
|
|
1111
|
+
// clearing is defined as expiring now, so any maxAge passed in is dropped rather than honoured
|
|
1112
|
+
/** @type {Record<string, any>} */
|
|
1113
|
+
const opts = { path: "/", ...options, expires: new Date(1) };
|
|
1114
|
+
delete opts.maxAge;
|
|
1115
|
+
return this.cookie(name, "", opts);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Sets Content-Disposition to attachment, and Content-Type from the extension when a
|
|
1120
|
+
* filename is given.
|
|
1121
|
+
* @param {string} [filename]
|
|
1122
|
+
* @returns {this}
|
|
1123
|
+
*/
|
|
1124
|
+
attachment(filename) {
|
|
1125
|
+
if (filename) {
|
|
1126
|
+
this.type(Path.extname(filename));
|
|
1127
|
+
}
|
|
1128
|
+
this.set("Content-Disposition", contentDisposition(filename));
|
|
1129
|
+
return this;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Answers according to the Accept header, calling the handler whose key matches best. A
|
|
1134
|
+
* `default` key catches everything else; without one an unmatched request gets 406.
|
|
1135
|
+
* Sets Vary: Accept.
|
|
1136
|
+
* @param {Record<string, any>} object handlers keyed by extension or mime type
|
|
1137
|
+
* @returns {this}
|
|
1138
|
+
*/
|
|
1139
|
+
format(object) {
|
|
1140
|
+
const keys = Object.keys(object).filter((v) => v !== "default");
|
|
1141
|
+
const key = keys.length > 0 ? this.req.accepts(keys) : false;
|
|
1142
|
+
|
|
1143
|
+
this.vary("Accept");
|
|
1144
|
+
|
|
1145
|
+
if (key) {
|
|
1146
|
+
this.set("Content-Type", normalizeType(key).value);
|
|
1147
|
+
object[key](this.req, this, this.req.next);
|
|
1148
|
+
} else if (object.default) {
|
|
1149
|
+
object.default(this.req, this, this.req.next);
|
|
1150
|
+
} else {
|
|
1151
|
+
this.status(406).send(this.app._generateErrorPage("Not Acceptable", this.statusCode, false));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return this;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Sends JSON, honouring the "json replacer", "json spaces" and "json escape" settings.
|
|
1159
|
+
* @param {*} body
|
|
1160
|
+
* @returns {this}
|
|
1161
|
+
*/
|
|
1162
|
+
json(body) {
|
|
1163
|
+
if (!this.headers["content-type"]) {
|
|
1164
|
+
this.headers["content-type"] = "application/json; charset=utf-8";
|
|
1165
|
+
}
|
|
1166
|
+
const escape = this.app.get("json escape");
|
|
1167
|
+
const replacer = this.app.get("json replacer");
|
|
1168
|
+
const spaces = this.app.get("json spaces");
|
|
1169
|
+
return this.send(stringify(body, replacer, spaces, escape));
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Sends JSON wrapped in a callback when the query names one, under the setting
|
|
1174
|
+
* "jsonp callback name", which defaults to "callback". Without it this is plain JSON.
|
|
1175
|
+
* @param {*} object
|
|
1176
|
+
* @returns {this}
|
|
1177
|
+
*/
|
|
1178
|
+
jsonp(object) {
|
|
1179
|
+
let callback = this.req.query[this.app.get("jsonp callback name")];
|
|
1180
|
+
let body = stringify(
|
|
1181
|
+
object,
|
|
1182
|
+
this.app.get("json replacer"),
|
|
1183
|
+
this.app.get("json spaces"),
|
|
1184
|
+
this.app.get("json escape")
|
|
1185
|
+
);
|
|
1186
|
+
let js = false;
|
|
1187
|
+
|
|
1188
|
+
if (Array.isArray(callback)) {
|
|
1189
|
+
callback = callback[0];
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
if (typeof callback === "string" && callback.length !== 0) {
|
|
1193
|
+
callback = callback.replace(/[^[\]\w$.]/g, "");
|
|
1194
|
+
|
|
1195
|
+
if (body === undefined) {
|
|
1196
|
+
body = "";
|
|
1197
|
+
} else if (typeof body === "string") {
|
|
1198
|
+
// replace chars not allowed in JavaScript that are in JSON
|
|
1199
|
+
body = body.replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
1200
|
+
}
|
|
1201
|
+
body = "/**/ typeof " + callback + " === 'function' && " + callback + "(" + body + ");";
|
|
1202
|
+
js = true;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (!this.headers["content-type"]) {
|
|
1206
|
+
this.headers["x-content-type-options"] = "nosniff";
|
|
1207
|
+
this.headers["content-type"] = "application/json; charset=utf-8";
|
|
1208
|
+
}
|
|
1209
|
+
if (js) {
|
|
1210
|
+
// with a callback the body is script whatever type was asked for before, so this
|
|
1211
|
+
// overrides rather than filling in, and the nosniff goes with it
|
|
1212
|
+
this.headers["x-content-type-options"] = "nosniff";
|
|
1213
|
+
this.headers["content-type"] = "text/javascript; charset=utf-8";
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
return this.send(body);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Adds to the Link header, one entry per key, the key being the rel.
|
|
1221
|
+
* @param {Record<string, any>} links rel to url
|
|
1222
|
+
* @returns {this}
|
|
1223
|
+
*/
|
|
1224
|
+
links(links) {
|
|
1225
|
+
let link = this.get("Link") || "";
|
|
1226
|
+
if (link) link += ", ";
|
|
1227
|
+
return this.set(
|
|
1228
|
+
"Link",
|
|
1229
|
+
link +
|
|
1230
|
+
Object.keys(links)
|
|
1231
|
+
.map(function (rel) {
|
|
1232
|
+
const target = links[rel];
|
|
1233
|
+
// an array is several links that share a rel, one entry each, and not one
|
|
1234
|
+
// entry holding a comma separated list inside its angle brackets
|
|
1235
|
+
if (Array.isArray(target)) {
|
|
1236
|
+
return target.map((one) => "<" + one + '>; rel="' + rel + '"').join(", ");
|
|
1237
|
+
}
|
|
1238
|
+
return "<" + target + '>; rel="' + rel + '"';
|
|
1239
|
+
})
|
|
1240
|
+
.join(", ")
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Sets the Location header, URL-encoding the value.
|
|
1246
|
+
*
|
|
1247
|
+
* "back" is a literal location here, not the Referrer: that shortcut is gone in Express 5.
|
|
1248
|
+
*
|
|
1249
|
+
* @param {string} path
|
|
1250
|
+
* @returns {this}
|
|
1251
|
+
*/
|
|
1252
|
+
location(path) {
|
|
1253
|
+
// Express 5 dropped the magic where 'back' meant the Referrer header. It is now just a
|
|
1254
|
+
// relative URL like any other, which is what res.redirect('back') also does here.
|
|
1255
|
+
this.headers["location"] = encodeUrl(path);
|
|
1256
|
+
return this;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Redirects, defaulting to 302. The status may be given first, as `redirect(301, url)`.
|
|
1261
|
+
* The body is a short note in whichever format the client accepts.
|
|
1262
|
+
* @param {number|string} status status code, or the url when the status is left out
|
|
1263
|
+
* @param {string} [url]
|
|
1264
|
+
* @param {boolean} [forceHtml] answer with an HTML body whatever the client accepts
|
|
1265
|
+
*/
|
|
1266
|
+
redirect(status, url, forceHtml = false) {
|
|
1267
|
+
if (typeof status !== "number" && !url) {
|
|
1268
|
+
url = status;
|
|
1269
|
+
status = 302;
|
|
1270
|
+
}
|
|
1271
|
+
this.location(/** @type {string} */ (url));
|
|
1272
|
+
this.status(/** @type {number} */ (status));
|
|
1273
|
+
|
|
1274
|
+
// a string, because location() has just set it to one. get() has to allow the array form for
|
|
1275
|
+
// the headers that can repeat, and escapeHtml quite reasonably only takes a string
|
|
1276
|
+
const address = /** @type {string} */ (this.get("Location"));
|
|
1277
|
+
let body;
|
|
1278
|
+
// Support text/{plain,html} by default
|
|
1279
|
+
if (forceHtml) {
|
|
1280
|
+
// uppercase on purpose: this branch stands in for the redirect that send and
|
|
1281
|
+
// serve-static emit, and both of those write "charset=UTF-8". res.redirect() below
|
|
1282
|
+
// goes through format(), which takes the lowercase form from the mime lookup.
|
|
1283
|
+
this.set("Content-Type", "text/html; charset=UTF-8");
|
|
1284
|
+
body =
|
|
1285
|
+
"<!DOCTYPE html>\n" +
|
|
1286
|
+
'<html lang="en">\n' +
|
|
1287
|
+
"<head>\n" +
|
|
1288
|
+
'<meta charset="utf-8">\n' +
|
|
1289
|
+
"<title>Redirecting</title>\n" +
|
|
1290
|
+
"</head>\n" +
|
|
1291
|
+
"<body>\n" +
|
|
1292
|
+
`<pre>Redirecting to ${escapeHtml(address)}</pre>\n` +
|
|
1293
|
+
"</body>\n" +
|
|
1294
|
+
"</html>\n";
|
|
1295
|
+
} else {
|
|
1296
|
+
this.format({
|
|
1297
|
+
text: () => {
|
|
1298
|
+
this.set("Content-Type", "text/plain; charset=utf-8");
|
|
1299
|
+
body = `${statuses.message[status]}. Redirecting to ${address}`;
|
|
1300
|
+
},
|
|
1301
|
+
html: () => {
|
|
1302
|
+
this.set("Content-Type", "text/html; charset=utf-8");
|
|
1303
|
+
body = `<p>${statuses.message[status]}. Redirecting to ${escapeHtml(address)}</p>`;
|
|
1304
|
+
},
|
|
1305
|
+
default: () => {
|
|
1306
|
+
this.set("Content-Type", "text/plain; charset=utf-8");
|
|
1307
|
+
body = "";
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
if (this.req.method === "HEAD") {
|
|
1312
|
+
this.end();
|
|
1313
|
+
} else {
|
|
1314
|
+
this.end(body);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* Sets Content-Type. An extension is looked up as a mime type and gets a charset; anything
|
|
1320
|
+
* containing a slash is used as written. Also available as `contentType()`.
|
|
1321
|
+
* @param {string} type
|
|
1322
|
+
* @returns {this}
|
|
1323
|
+
*/
|
|
1324
|
+
type(type) {
|
|
1325
|
+
const ct = type.indexOf("/") === -1 ? contentTypeFor(type) : type;
|
|
1326
|
+
|
|
1327
|
+
return this.set("content-type", ct);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
contentType = this.type;
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Adds a field to Vary, without repeating one already there.
|
|
1334
|
+
* @param {string|string[]} field
|
|
1335
|
+
* @returns {this}
|
|
1336
|
+
* @throws {TypeError} if no field is given, since a Vary with nothing in it is a mistake
|
|
1337
|
+
*/
|
|
1338
|
+
vary(field) {
|
|
1339
|
+
// the vary package decides: it throws when there is no field at all, and does nothing at
|
|
1340
|
+
// all for an empty list, which is not the same thing and used to be refused here as well
|
|
1341
|
+
vary(/** @type {any} */ (this), field);
|
|
1342
|
+
return this;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** The same object as socket, which node carries under both names. */
|
|
1346
|
+
get connection() {
|
|
1347
|
+
return this.socket;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Whether the response has been fully written. Writable declares this as a plain property and
|
|
1352
|
+
* the machinery that would maintain it is bypassed here, so a getter over our own flag replaces
|
|
1353
|
+
* it. The directive below has to sit outside this block, or it reads as a JSDoc tag.
|
|
1354
|
+
*/
|
|
1355
|
+
// @ts-expect-error TS2611, the accessor replacing the base property is deliberate. Expect
|
|
1356
|
+
// rather than ignore, so it fails loudly if it ever stops applying.
|
|
1357
|
+
get writableFinished() {
|
|
1358
|
+
return this.finished;
|
|
1359
|
+
}
|
|
1360
|
+
};
|