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
|
@@ -0,0 +1,636 @@
|
|
|
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 fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const bytes = require("bytes");
|
|
21
|
+
const zlib = require("fast-zlib");
|
|
22
|
+
const typeis = require("type-is");
|
|
23
|
+
const querystring = require("fast-querystring");
|
|
24
|
+
const { AsyncResource } = require("async_hooks");
|
|
25
|
+
const { fastQueryParse, NullObject, asStatError, httpError, memoizeByString } = require("./utils.js");
|
|
26
|
+
|
|
27
|
+
// largest content-length we will allocate a body buffer for up front. above this the body is
|
|
28
|
+
// collected chunk by chunk instead, so a declared-but-unsent body cannot pin more memory than a
|
|
29
|
+
// real one of the same size would
|
|
30
|
+
const MAX_PREALLOCATED_BODY = 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
// The failures express.static answers by moving on to the next handler rather than by reporting
|
|
33
|
+
// them, when fallthrough is on. They all mean the same thing: the request is not a file here.
|
|
34
|
+
//
|
|
35
|
+
// serve-static decides this by remembering whether send got as far as settling on a file, and
|
|
36
|
+
// forwards everything after that point. The list is the same thing said from the other side, since
|
|
37
|
+
// by the time this hands over, the file has been found and stat'ed already: what is left to fail
|
|
38
|
+
// is a dotfile rule or a path that will not decode.
|
|
39
|
+
//
|
|
40
|
+
// A 412 and a 416 are not on it, and that is the point of the list. Both are about a file that
|
|
41
|
+
// exists and about conditions the client itself set, and falling through swallowed them: a Range
|
|
42
|
+
// Not Satisfiable came back as a 404, which tells the client its file is gone when it is not.
|
|
43
|
+
const FALLTHROUGH_STATUSES = new Set([400, 403, 404]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A path with any run of leading slashes reduced to one.
|
|
47
|
+
*
|
|
48
|
+
* This is not tidiness. A Location header beginning with "//" is a protocol-relative URL, so a
|
|
49
|
+
* browser given "//assets/" goes to the host called "assets" rather than to a path on this server.
|
|
50
|
+
* serve-static collapses them for exactly that reason, and a redirect that leaves the server is
|
|
51
|
+
* not a redirect the server meant to issue.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} path
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function collapseLeadingSlashes(path) {
|
|
57
|
+
let i = 0;
|
|
58
|
+
while (i < path.length && path.charCodeAt(i) === 0x2f) {
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
return i > 1 ? "/" + path.slice(i) : path;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The text without a leading byte order mark, which is what a decoder would have handed over.
|
|
66
|
+
* body-parser decodes through iconv and iconv removes it, so nothing downstream of it ever sees
|
|
67
|
+
* one; reading the buffer directly, as here, means removing it explicitly.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} text
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
function stripBom(text) {
|
|
73
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The message a strict violation gets, which is the one V8 would have produced had the body been
|
|
78
|
+
* invalid JSON rather than merely not an object.
|
|
79
|
+
*
|
|
80
|
+
* body-parser goes to some trouble over this: it builds a string that is the body up to the
|
|
81
|
+
* offending character followed by placeholder characters, asks JSON.parse to fail on that, and
|
|
82
|
+
* then puts the real characters back into whatever V8 said. The point is that an application
|
|
83
|
+
* showing err.message reads the same sentence either way, naming the character and its position.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} text the body as sent
|
|
86
|
+
* @param {string|undefined} char the first character that is neither whitespace nor { nor [
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
function strictSyntaxMessage(text, char) {
|
|
90
|
+
const index = char === undefined ? -1 : text.indexOf(char);
|
|
91
|
+
if (index === -1) {
|
|
92
|
+
return "Unexpected end of JSON input";
|
|
93
|
+
}
|
|
94
|
+
const partial = text.substring(0, index) + "#".repeat(text.length - index);
|
|
95
|
+
try {
|
|
96
|
+
JSON.parse(partial);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
// put the real characters back where the placeholders were named
|
|
99
|
+
return /** @type {any} */ (e).message.replace(/#+/g, (/** @type {string} */ placeholder) =>
|
|
100
|
+
text.substring(index, index + placeholder.length)
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return "strict violation";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The error a body parser hands to next(), shaped as body-parser shapes it: with a status, since
|
|
108
|
+
* `res.status(err.status || 500)` would otherwise answer 500 to a request that was merely too
|
|
109
|
+
* large, and with `type`, which applications branch on.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} message
|
|
112
|
+
* @param {number} status
|
|
113
|
+
* @param {string} type body-parser's own name for the kind of failure
|
|
114
|
+
* @param {object} [extra] anything else body-parser puts on that particular error
|
|
115
|
+
* @returns {Error}
|
|
116
|
+
*/
|
|
117
|
+
function bodyError(message, status, type, extra) {
|
|
118
|
+
const err = /** @type {any} */ (new Error(message));
|
|
119
|
+
// 4xx is the client's to see; a 5xx here would be the server's own problem and stays hidden
|
|
120
|
+
err.expose = status < 500;
|
|
121
|
+
err.statusCode = status;
|
|
122
|
+
err.status = status;
|
|
123
|
+
err.type = type;
|
|
124
|
+
return Object.assign(err, extra);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* express.static, which is a thin front for res.sendFile: it resolves the path, refuses anything
|
|
129
|
+
* that climbs out of the root, applies the dotfiles and index rules, and hands the rest over.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} root directory to serve from
|
|
132
|
+
* @param {object} [options] index, redirect, fallthrough, dotfiles, extensions, setHeaders, etag
|
|
133
|
+
* @returns {(req: any, res: any, next: (err?: any) => void) => any}
|
|
134
|
+
*/
|
|
135
|
+
function serveStatic(root, options) {
|
|
136
|
+
if (!options) options = new NullObject();
|
|
137
|
+
if (typeof options.index === "undefined") options.index = "index.html";
|
|
138
|
+
if (typeof options.redirect === "undefined") options.redirect = true;
|
|
139
|
+
if (typeof options.fallthrough === "undefined") options.fallthrough = true;
|
|
140
|
+
if (typeof options.dotfiles === "undefined") options.dotfiles = "ignore";
|
|
141
|
+
if (options.extensions) {
|
|
142
|
+
if (typeof options.extensions !== "string" && !Array.isArray(options.extensions)) {
|
|
143
|
+
throw new Error("extensions must be a string or an array");
|
|
144
|
+
}
|
|
145
|
+
if (!Array.isArray(options.extensions)) {
|
|
146
|
+
options.extensions = [options.extensions];
|
|
147
|
+
}
|
|
148
|
+
options.extensions = options.extensions.map((ext) => (ext.startsWith(".") ? ext.slice(1) : ext));
|
|
149
|
+
}
|
|
150
|
+
options.root = root;
|
|
151
|
+
// serve-static decides this for itself and never asks the app, so a static file keeps its
|
|
152
|
+
// ETag under app.set("etag", false) and only { etag: false } here turns it off. res.sendFile
|
|
153
|
+
// takes the app's setting instead, which is why this has to be said out loud.
|
|
154
|
+
options.etag = options.etag !== false;
|
|
155
|
+
options._ownEtag = true;
|
|
156
|
+
|
|
157
|
+
return (req, res, next) => {
|
|
158
|
+
const iq = req.url.indexOf("?");
|
|
159
|
+
let url;
|
|
160
|
+
|
|
161
|
+
next = AsyncResource.bind(next);
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
url = decodeURIComponent(iq !== -1 ? req.url.substring(0, iq) : req.url);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
// 400 and not 404: send answers a path it cannot decode with a Bad Request, since
|
|
167
|
+
// nothing was asked for that could be missing
|
|
168
|
+
if (!options.fallthrough) {
|
|
169
|
+
res.status(400);
|
|
170
|
+
return next(httpError(400));
|
|
171
|
+
} else return next();
|
|
172
|
+
}
|
|
173
|
+
let _path = url;
|
|
174
|
+
const fullpath = path.resolve(path.join(options.root, url));
|
|
175
|
+
if (options.root && !fullpath.startsWith(path.resolve(options.root))) {
|
|
176
|
+
if (!options.fallthrough) {
|
|
177
|
+
res.status(403);
|
|
178
|
+
return next(httpError(403));
|
|
179
|
+
} else return next();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let stat;
|
|
183
|
+
try {
|
|
184
|
+
stat = fs.statSync(fullpath);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const ext = path.extname(fullpath);
|
|
187
|
+
let i = 0;
|
|
188
|
+
if (ext === "" && options.extensions) {
|
|
189
|
+
while (i < options.extensions.length) {
|
|
190
|
+
try {
|
|
191
|
+
stat = fs.statSync(fullpath + "." + options.extensions[i]);
|
|
192
|
+
_path = url + "." + options.extensions[i];
|
|
193
|
+
break;
|
|
194
|
+
} catch (err) {
|
|
195
|
+
i++;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!stat) {
|
|
200
|
+
if (!options.fallthrough) {
|
|
201
|
+
res.status(404);
|
|
202
|
+
// the error itself, not its message: serve-static hands the fs error to the
|
|
203
|
+
// error handler with its errno, code, syscall and path still on it, and an
|
|
204
|
+
// error handler doing res.send(err) sends those as JSON. Passing the string
|
|
205
|
+
// sent an HTML page instead.
|
|
206
|
+
return next(asStatError(err));
|
|
207
|
+
} else return next();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (stat.isDirectory()) {
|
|
212
|
+
if (!req.endsWithSlash) {
|
|
213
|
+
if (options.redirect) {
|
|
214
|
+
// The query goes along, and the leading slashes are collapsed. Both were
|
|
215
|
+
// wrong: "/docs?page=3" redirected to "/docs/" and lost the page, and a
|
|
216
|
+
// request for "//assets" answered "Location: //assets/", which a browser
|
|
217
|
+
// reads as a protocol-relative URL and follows to the host "assets". A
|
|
218
|
+
// redirect that leaves this server is not a redirect this server meant.
|
|
219
|
+
return res.redirect(301, collapseLeadingSlashes(req._originalPath + "/") + req.urlQuery, true);
|
|
220
|
+
} else {
|
|
221
|
+
if (!options.fallthrough) {
|
|
222
|
+
res.status(404);
|
|
223
|
+
return next(httpError(404));
|
|
224
|
+
} else return next();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (options.index) {
|
|
228
|
+
try {
|
|
229
|
+
stat = fs.statSync(path.join(fullpath, options.index));
|
|
230
|
+
_path = path.join(url, options.index);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (!options.fallthrough) {
|
|
233
|
+
res.status(404);
|
|
234
|
+
// the fs error, as above: the index file is missing and the error handler
|
|
235
|
+
// is told which one and where
|
|
236
|
+
return next(asStatError(err));
|
|
237
|
+
} else return next();
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
// a directory with no index to serve is a Not Found, and saying so is the whole
|
|
241
|
+
// point of fallthrough: false. This moved on to the next handler instead, so the
|
|
242
|
+
// application's own 404 answered where serve-static's error handler should have.
|
|
243
|
+
if (!options.fallthrough) {
|
|
244
|
+
res.status(404);
|
|
245
|
+
return next(httpError(404));
|
|
246
|
+
}
|
|
247
|
+
return next();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
options._stat = stat;
|
|
252
|
+
|
|
253
|
+
return res.sendFile(_path, options, (e) => {
|
|
254
|
+
if (e) {
|
|
255
|
+
next(options.fallthrough && FALLTHROUGH_STATUSES.has(e.status) ? undefined : e);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The decompressor for a Content-Encoding, or undefined when the body is not compressed. An
|
|
263
|
+
* encoding nobody knows throws, since decoding it wrong is worse than refusing.
|
|
264
|
+
*
|
|
265
|
+
* @param {string|undefined} contentEncoding
|
|
266
|
+
* @returns {any|undefined}
|
|
267
|
+
*/
|
|
268
|
+
function createInflate(contentEncoding) {
|
|
269
|
+
const encoding = (contentEncoding || "identity").toLowerCase();
|
|
270
|
+
switch (encoding) {
|
|
271
|
+
case "identity":
|
|
272
|
+
return;
|
|
273
|
+
case "deflate":
|
|
274
|
+
return new zlib.Inflate();
|
|
275
|
+
case "gzip":
|
|
276
|
+
return new zlib.Gunzip();
|
|
277
|
+
case "br":
|
|
278
|
+
return new zlib.BrotliDecompress();
|
|
279
|
+
default:
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Builds one of the body parsers. All four share the same work, which is deciding whether this
|
|
286
|
+
* request has a body worth reading, collecting it within the size limit, decompressing it and
|
|
287
|
+
* handing the bytes over; they differ only in the content type they claim by default and in what
|
|
288
|
+
* they turn the bytes into.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} defaultType the type matched when the caller names none
|
|
291
|
+
* @param {(...args: any[]) => any} beforeReturn turns the collected bytes into req.body. Called
|
|
292
|
+
* with the body, the request, the response, next and the options
|
|
293
|
+
* @returns {(options?: object) => Function} the middleware factory
|
|
294
|
+
*/
|
|
295
|
+
function createBodyParser(defaultType, beforeReturn) {
|
|
296
|
+
return function (options) {
|
|
297
|
+
if (typeof options !== "object") {
|
|
298
|
+
options = new NullObject();
|
|
299
|
+
}
|
|
300
|
+
if (typeof options.limit === "undefined") options.limit = bytes("100kb");
|
|
301
|
+
else options.limit = bytes(options.limit);
|
|
302
|
+
|
|
303
|
+
if (typeof options.inflate === "undefined") options.inflate = true;
|
|
304
|
+
if (typeof options.type === "undefined") options.type = defaultType;
|
|
305
|
+
if (typeof options.type === "string") {
|
|
306
|
+
if (!options.type.includes("*")) {
|
|
307
|
+
options.simpleType = options.type;
|
|
308
|
+
}
|
|
309
|
+
options.type = [options.type];
|
|
310
|
+
} else if (typeof options.type !== "function" && !Array.isArray(options.type)) {
|
|
311
|
+
throw new Error("type must be a string, function or an array");
|
|
312
|
+
}
|
|
313
|
+
if (typeof options.defaultCharset === "undefined") options.defaultCharset = "utf-8";
|
|
314
|
+
|
|
315
|
+
// Whether a content-type is one this parser claims, remembered per parser.
|
|
316
|
+
//
|
|
317
|
+
// Only reached when the caller asked for a wildcard or a list, since a plain type takes the
|
|
318
|
+
// simpleType shortcut above and never calls type-is at all. For those callers type-is was
|
|
319
|
+
// 513 ns to reach the same answer about the same string on every request, against 4 ns for
|
|
320
|
+
// an answer already worked out. The header is the client's, so the memo needs its ceiling.
|
|
321
|
+
//
|
|
322
|
+
// typeis.is and not typeis(req, ...): the request form first checks that there is a body,
|
|
323
|
+
// and the caller below has established that already.
|
|
324
|
+
const claimsType = memoizeByString((contentType) => !!typeis.is(contentType, options.type));
|
|
325
|
+
|
|
326
|
+
let additionalMethods;
|
|
327
|
+
|
|
328
|
+
return (req, res, next) => {
|
|
329
|
+
next = AsyncResource.bind(next);
|
|
330
|
+
|
|
331
|
+
// skip reading body twice
|
|
332
|
+
if (req.bodyRead) {
|
|
333
|
+
return next();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const type = req.headers["content-type"];
|
|
337
|
+
|
|
338
|
+
// req.body is deliberately left undefined until a parser claims the request. That is
|
|
339
|
+
// what lets a handler tell "nothing parsed this" apart from "the body was empty",
|
|
340
|
+
// so it must not be seeded with an empty object first.
|
|
341
|
+
|
|
342
|
+
// skip reading body for no content type
|
|
343
|
+
if (!type) {
|
|
344
|
+
return next();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const length = req.headers["content-length"];
|
|
348
|
+
|
|
349
|
+
// No content-length and no transfer-encoding means the request carries no body at all,
|
|
350
|
+
// and a body parser must leave it alone rather than parse nothing into an empty value.
|
|
351
|
+
// type-is applies this before matching the type, but the simpleType shortcut below
|
|
352
|
+
// compares strings directly and would otherwise skip the check.
|
|
353
|
+
if (req.headers["transfer-encoding"] === undefined && isNaN(length)) {
|
|
354
|
+
return next();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (options.simpleType) {
|
|
358
|
+
const semicolonIndex = type.indexOf(";");
|
|
359
|
+
const clearType = semicolonIndex !== -1 ? type.substring(0, semicolonIndex) : type;
|
|
360
|
+
if (clearType !== options.simpleType) {
|
|
361
|
+
return next();
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
if (typeof options.type === "function") {
|
|
365
|
+
if (!options.type(req)) {
|
|
366
|
+
return next();
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
if (!claimsType(type)) {
|
|
370
|
+
return next();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// an empty body still has to produce this parser's empty value the way express does -
|
|
376
|
+
// {} for json and urlencoded, '' for text, an empty Buffer for raw - rather than leaving
|
|
377
|
+
// req.body as the placeholder object. there is nothing to read, so run the tail directly
|
|
378
|
+
if (length == "0") {
|
|
379
|
+
return beforeReturn(req, res, next, options, Buffer.alloc(0));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// skip reading too large body
|
|
383
|
+
if (length && +length > options.limit) {
|
|
384
|
+
return next(
|
|
385
|
+
bodyError("request entity too large", 413, "entity.too.large", {
|
|
386
|
+
expected: +length,
|
|
387
|
+
length: +length,
|
|
388
|
+
limit: options.limit
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// skip reading body for non-POST requests
|
|
394
|
+
// this makes it +10k req/sec faster
|
|
395
|
+
if (additionalMethods === undefined) additionalMethods = req.app.get("body methods") ?? null;
|
|
396
|
+
if (
|
|
397
|
+
req.method !== "POST" &&
|
|
398
|
+
req.method !== "PUT" &&
|
|
399
|
+
req.method !== "PATCH" &&
|
|
400
|
+
req.method !== "QUERY" &&
|
|
401
|
+
(!additionalMethods || !additionalMethods.includes(req.method))
|
|
402
|
+
) {
|
|
403
|
+
return next();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const abs = [];
|
|
407
|
+
let inflate;
|
|
408
|
+
let totalSize = 0;
|
|
409
|
+
if (options.inflate) {
|
|
410
|
+
inflate = createInflate(req.headers["content-encoding"]);
|
|
411
|
+
if (inflate === false) {
|
|
412
|
+
return next(
|
|
413
|
+
bodyError(
|
|
414
|
+
'unsupported content encoding "' + req.headers["content-encoding"] + '"',
|
|
415
|
+
415,
|
|
416
|
+
"encoding.unsupported",
|
|
417
|
+
{ encoding: req.headers["content-encoding"] }
|
|
418
|
+
)
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// uWS neuters its ArrayBuffer after the callback, so every chunk has to be copied out of
|
|
424
|
+
// it - and then Buffer.concat copied the whole body a second time. when content-length is
|
|
425
|
+
// known and we aren't inflating, the final size is known up front, so chunks can go
|
|
426
|
+
// straight into one buffer and the body is copied once.
|
|
427
|
+
// the cap means a client that declares a body and never sends it costs no more than one
|
|
428
|
+
// that actually sends a body that size, and content-length above options.limit was
|
|
429
|
+
// already rejected above
|
|
430
|
+
const declaredLength = inflate ? -1 : Number(length);
|
|
431
|
+
let target =
|
|
432
|
+
declaredLength > 0 && declaredLength <= MAX_PREALLOCATED_BODY
|
|
433
|
+
? Buffer.allocUnsafe(declaredLength)
|
|
434
|
+
: null;
|
|
435
|
+
let targetOffset = 0;
|
|
436
|
+
|
|
437
|
+
req.bodyRead = true;
|
|
438
|
+
|
|
439
|
+
// uWS keeps delivering chunks after we reject an oversized body, and the
|
|
440
|
+
// stream path still emits 'end', so without this every further chunk would
|
|
441
|
+
// call next() again and the second response would throw
|
|
442
|
+
let finished = false;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* One chunk from uWS. Decompresses it, counts it against the limit and keeps it. The
|
|
446
|
+
* finished flag matters: uWS goes on delivering chunks after an oversized body has
|
|
447
|
+
* been refused, and without it every further chunk would answer the request again.
|
|
448
|
+
*
|
|
449
|
+
* @param {any} buf a Buffer, or an ArrayBuffer straight from uWS
|
|
450
|
+
*/
|
|
451
|
+
function onData(buf) {
|
|
452
|
+
if (finished) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (!Buffer.isBuffer(buf)) {
|
|
456
|
+
buf = Buffer.from(buf);
|
|
457
|
+
}
|
|
458
|
+
if (inflate) {
|
|
459
|
+
buf = inflate.process(buf);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
totalSize += buf.length;
|
|
463
|
+
if (totalSize > options.limit) {
|
|
464
|
+
finished = true;
|
|
465
|
+
abs.length = 0;
|
|
466
|
+
target = null;
|
|
467
|
+
return next(
|
|
468
|
+
bodyError("request entity too large", 413, "entity.too.large", {
|
|
469
|
+
limit: options.limit,
|
|
470
|
+
received: totalSize
|
|
471
|
+
})
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (target) {
|
|
476
|
+
if (targetOffset + buf.length <= target.length) {
|
|
477
|
+
buf.copy(target, targetOffset);
|
|
478
|
+
targetOffset += buf.length;
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
// more body than content-length promised: keep what we have and fall back
|
|
482
|
+
abs.push(Buffer.from(target.subarray(0, targetOffset)));
|
|
483
|
+
target = null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// shallow copy, to avoid shared references for large bodies.
|
|
487
|
+
abs.push(Buffer.from(buf));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The body is complete: assemble it, hand it to the parser and continue routing. */
|
|
491
|
+
function onEnd() {
|
|
492
|
+
if (finished) {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
finished = true;
|
|
496
|
+
// target holds the whole body already; otherwise a single chunk is the body, and
|
|
497
|
+
// only a genuinely chunked body needs the concat
|
|
498
|
+
const buf = target
|
|
499
|
+
? targetOffset === target.length
|
|
500
|
+
? target
|
|
501
|
+
: target.subarray(0, targetOffset)
|
|
502
|
+
: abs.length === 1
|
|
503
|
+
? abs[0]
|
|
504
|
+
: Buffer.concat(abs);
|
|
505
|
+
if (options.verify) {
|
|
506
|
+
try {
|
|
507
|
+
options.verify(req, res, buf);
|
|
508
|
+
} catch (e) {
|
|
509
|
+
const err = /** @type {any} */ (e);
|
|
510
|
+
return next(
|
|
511
|
+
bodyError(
|
|
512
|
+
err.message,
|
|
513
|
+
err.status ?? err.statusCode ?? 403,
|
|
514
|
+
err.type ?? "entity.verify.failed",
|
|
515
|
+
{
|
|
516
|
+
body: buf,
|
|
517
|
+
stack: err.stack
|
|
518
|
+
}
|
|
519
|
+
)
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
beforeReturn(req, res, next, options, buf);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// reading data directly from uWS is faster than from a stream
|
|
527
|
+
// if we are fast enough (not async), we can do it
|
|
528
|
+
// otherwise we need to use a stream since it already started streaming it
|
|
529
|
+
if (!req.receivedData) {
|
|
530
|
+
req._res.onData((ab, isLast) => {
|
|
531
|
+
onData(ab);
|
|
532
|
+
if (isLast) {
|
|
533
|
+
onEnd();
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
} else {
|
|
537
|
+
req.on("data", onData);
|
|
538
|
+
req.on("end", onEnd);
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const json = createBodyParser("application/json", function (req, res, next, options, buf) {
|
|
545
|
+
if (buf.length === 0) {
|
|
546
|
+
req.body = {};
|
|
547
|
+
return next();
|
|
548
|
+
}
|
|
549
|
+
// A leading byte order mark is removed rather than parsed. body-parser never sees one either:
|
|
550
|
+
// it decodes through iconv, which strips it, so by the time the first character is looked at
|
|
551
|
+
// the mark is gone. JSON.parse would refuse it, so without this a body saved by an editor that
|
|
552
|
+
// writes a BOM is answered 400 here and 200 by Express.
|
|
553
|
+
const text = stripBom(buf.toString());
|
|
554
|
+
|
|
555
|
+
// "strict" means only an object or an array is a body, which is body-parser's default and was
|
|
556
|
+
// not honoured here at all: the check read req.body before this function had parsed anything,
|
|
557
|
+
// so it looked at the previous request's body and passed. `express.json()` accepted a bare
|
|
558
|
+
// string or number where Express answers 400.
|
|
559
|
+
if (options.strict !== false) {
|
|
560
|
+
// Exactly the four characters body-parser skips, and no more. A BOM or a non-breaking
|
|
561
|
+
// space is not whitespace to it, so a body starting with one is a violation rather than
|
|
562
|
+
// something to skip past, and a wider class here would accept bodies Express refuses.
|
|
563
|
+
// eslint-disable-next-line no-control-regex
|
|
564
|
+
const first = text.match(/^[\x20\x09\x0a\x0d]*([^\x20\x09\x0a\x0d])/)?.[1];
|
|
565
|
+
if (first !== "{" && first !== "[") {
|
|
566
|
+
return next(bodyError(strictSyntaxMessage(text, first), 400, "entity.parse.failed", { body: text }));
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
req.body = JSON.parse(text, options.reviver);
|
|
572
|
+
} catch (e) {
|
|
573
|
+
// the JSON error's own message, which is what body-parser keeps, so an application showing
|
|
574
|
+
// err.message still says where the parse gave up
|
|
575
|
+
const err = /** @type {any} */ (e);
|
|
576
|
+
return next(bodyError(err.message, 400, "entity.parse.failed", { body: text }));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
next();
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const raw = createBodyParser("application/octet-stream", function (req, res, next, options, buf) {
|
|
583
|
+
req.body = buf;
|
|
584
|
+
next();
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
const text = createBodyParser("text/plain", function (req, res, next, options, buf) {
|
|
588
|
+
const contentType = req.headers["content-type"];
|
|
589
|
+
const charsetIndex = contentType.indexOf("charset=");
|
|
590
|
+
let encoding = options.defaultCharset;
|
|
591
|
+
if (charsetIndex !== -1) {
|
|
592
|
+
encoding = contentType.substring(charsetIndex + 8);
|
|
593
|
+
const semicolonIndex = encoding.indexOf(";");
|
|
594
|
+
if (semicolonIndex !== -1) {
|
|
595
|
+
encoding = encoding.substring(0, semicolonIndex);
|
|
596
|
+
}
|
|
597
|
+
encoding = encoding.trim().toLowerCase();
|
|
598
|
+
}
|
|
599
|
+
if (encoding !== "utf-8" && encoding !== "utf-16le" && encoding !== "latin1") {
|
|
600
|
+
return next(
|
|
601
|
+
bodyError('unsupported charset "' + encoding.toUpperCase() + '"', 415, "charset.unsupported", {
|
|
602
|
+
charset: encoding.toLowerCase()
|
|
603
|
+
})
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
req.body = buf.toString(encoding);
|
|
608
|
+
} catch (e) {
|
|
609
|
+
return next(e);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
next();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const urlencoded = createBodyParser("application/x-www-form-urlencoded", function (req, res, next, options, buf) {
|
|
616
|
+
try {
|
|
617
|
+
// Express 5 defaults extended to false, so nested keys need opting in
|
|
618
|
+
const extended = typeof options.extended !== "undefined" ? options.extended : false;
|
|
619
|
+
if (extended) {
|
|
620
|
+
req.body = fastQueryParse(buf.toString(), options);
|
|
621
|
+
} else {
|
|
622
|
+
req.body = querystring.parse(buf.toString());
|
|
623
|
+
}
|
|
624
|
+
} catch (e) {
|
|
625
|
+
return next(e);
|
|
626
|
+
}
|
|
627
|
+
next();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
module.exports = {
|
|
631
|
+
static: serveStatic,
|
|
632
|
+
json,
|
|
633
|
+
raw,
|
|
634
|
+
text,
|
|
635
|
+
urlencoded
|
|
636
|
+
};
|