fulmine.js 5.19.2 → 5.19.3
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/package.json +2 -1
- package/src/adopt.js +20 -26
- package/src/application.js +46 -45
- package/src/cli.js +28 -34
- package/src/cluster.js +16 -25
- package/src/compression.js +39 -51
- package/src/declarative.js +586 -538
- package/src/hot-settings.js +80 -0
- package/src/index.js +11 -17
- package/src/lazy-readable.js +129 -0
- package/src/lazy-writable.js +97 -0
- package/src/middlewares.js +61 -77
- package/src/nest.js +19 -34
- package/src/node-shim.js +11 -13
- package/src/optimizer.js +598 -0
- package/src/parse-query.js +3 -3
- package/src/request-utils.js +306 -0
- package/src/request.js +101 -513
- package/src/response-utils.js +88 -0
- package/src/response.js +100 -443
- package/src/route.js +4 -5
- package/src/router-utils.js +950 -0
- package/src/router.js +126 -2148
- package/src/server-shape.js +26 -41
- package/src/server-timing.js +16 -29
- package/src/socket.js +208 -0
- package/src/testing.js +39 -42
- package/src/usage.js +16 -21
- package/src/utils.js +49 -59
- package/src/verify.js +18 -28
- package/src/view.js +5 -7
- package/src/walk.js +580 -0
- package/src/websocket.js +19 -20
- package/src/work.js +21 -27
package/src/declarative.js
CHANGED
|
@@ -19,8 +19,8 @@ limitations under the License.
|
|
|
19
19
|
|
|
20
20
|
const acorn = require("acorn");
|
|
21
21
|
const { stringify, withDefaultCharset, withUtf8Charset, contentTypeFor } = require("./utils.js");
|
|
22
|
-
// H3App, DeclarativeResponse and _cfg
|
|
23
|
-
//
|
|
22
|
+
// H3App, DeclarativeResponse and _cfg exist at runtime but are missing from the .d.ts the
|
|
23
|
+
// package ships, so the module is read through a loose alias
|
|
24
24
|
const uWS = require("uWebSockets.js");
|
|
25
25
|
const uWSAny = /** @type {any} */ (uWS);
|
|
26
26
|
const statuses = require("statuses");
|
|
@@ -43,31 +43,27 @@ const allowedResMethods = [
|
|
|
43
43
|
|
|
44
44
|
const allowedIdentifiers = ["query", "params", ...allowedResMethods];
|
|
45
45
|
|
|
46
|
-
/** What res.type(x) sets the content type to
|
|
46
|
+
/** What res.type(x) sets the content type to. A lookup on a literal. */
|
|
47
47
|
const typeValueOf = (type) => (type.indexOf("/") === -1 ? contentTypeFor(type) : type);
|
|
48
48
|
|
|
49
49
|
// what one instruction of a declarative response can carry, since uWS writes its length as a u16
|
|
50
50
|
const MAX_INSTRUCTION_LENGTH = 65535;
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// so it cannot honour one, and a handler that sets one has to stay on the ordinary path.
|
|
52
|
+
// Headers used to answer a conditional request. A compiled response cannot read the request, so a
|
|
53
|
+
// handler that sets one has to stay on the ordinary path.
|
|
55
54
|
const VALIDATOR_HEADERS = new Set(["etag", "last-modified"]);
|
|
56
55
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// Content-Length of zero, so all three come out of the ordinary path with no body at all.
|
|
56
|
+
// Statuses that carry no body. Express strips it for 204 and 304, node answers a 205 with
|
|
57
|
+
// Content-Length: 0. All three come out of the ordinary path with no body.
|
|
60
58
|
const BODILESS_STATUSES = new Set([204, 205, 304]);
|
|
61
59
|
|
|
60
|
+
// the three that write a body, only one of them may appear
|
|
62
61
|
const bodyMethods = new Set(["send", "json", "end"]);
|
|
63
|
-
//
|
|
62
|
+
// the four that finish the response, nothing a handler does after them is observable
|
|
64
63
|
const terminalMethods = new Set(["send", "json", "end", "sendStatus"]);
|
|
65
64
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// saying so: a sequence expression hid its calls completely, and `res.append("x", "1"), res.send("k")`
|
|
69
|
-
// compiled into a 200 with no body and no headers at all. Anything unfamiliar falls back to
|
|
70
|
-
// ordinary routing, which is always correct if slower.
|
|
65
|
+
// Node types filterNodes can walk. A missing one is refused because the walk would skip it in
|
|
66
|
+
// silence: `res.append("x", "1"), res.send("k")` compiled to a 200 with no body and no headers.
|
|
71
67
|
const understoodNodeTypes = new Set([
|
|
72
68
|
"ArrowFunctionExpression",
|
|
73
69
|
"FunctionDeclaration",
|
|
@@ -90,10 +86,10 @@ const understoodNodeTypes = new Set([
|
|
|
90
86
|
]);
|
|
91
87
|
|
|
92
88
|
/**
|
|
93
|
-
* Every node type in the tree
|
|
94
|
-
*
|
|
89
|
+
* Every node type in the tree. Walks all the keys instead of named edges, so the answer does not
|
|
90
|
+
* depend on the walk being complete.
|
|
95
91
|
*
|
|
96
|
-
* @param {any} node
|
|
92
|
+
* @param {any} node an acorn AST node. acorn ships no useful node types, and every shape here is checked by hand
|
|
97
93
|
* @param {Set<string>} types
|
|
98
94
|
*/
|
|
99
95
|
function collectNodeTypes(node, types) {
|
|
@@ -114,10 +110,9 @@ function collectNodeTypes(node, types) {
|
|
|
114
110
|
}
|
|
115
111
|
|
|
116
112
|
/**
|
|
117
|
-
* The key a property writes
|
|
118
|
-
* computed and never a getter or a spread.
|
|
113
|
+
* The key a property writes. Only a plain name or a literal, never computed, a getter or a spread.
|
|
119
114
|
*
|
|
120
|
-
* @param {any} property
|
|
115
|
+
* @param {any} property an acorn Property node
|
|
121
116
|
* @returns {string|null} null when the shape is not one of those
|
|
122
117
|
*/
|
|
123
118
|
function literalKeyOf(property) {
|
|
@@ -131,15 +126,11 @@ function literalKeyOf(property) {
|
|
|
131
126
|
}
|
|
132
127
|
|
|
133
128
|
/**
|
|
134
|
-
* The value a literal expression
|
|
135
|
-
*
|
|
136
|
-
* routing: a response written once cannot contain something only a request could produce.
|
|
129
|
+
* The value of a literal expression, for the shapes known at registration time. Anything else
|
|
130
|
+
* throws, and the catch around the compiler turns it into ordinary routing.
|
|
137
131
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* @param {any} node
|
|
142
|
-
* @returns {any}
|
|
132
|
+
* @param {any} node an acorn AST node. acorn ships no useful node types, and every shape here is checked by hand
|
|
133
|
+
* @returns {any} whatever the literal denotes
|
|
143
134
|
*/
|
|
144
135
|
function literalValue(node) {
|
|
145
136
|
switch (node.type) {
|
|
@@ -192,533 +183,601 @@ function literalValue(node) {
|
|
|
192
183
|
}
|
|
193
184
|
|
|
194
185
|
// generates a declarative response from a callback
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// it is why the tree is walked loosely: acorn types every shape JavaScript can take, and
|
|
214
|
-
// enumerating them here would be a second, worse copy of that catch.
|
|
215
|
-
//
|
|
216
|
-
// The list below looks like the thing to widen, and it is not. Counted over the 1113
|
|
217
|
-
// handlers in this repository's tests, demo and benchmark scenarios: 42.6% call something
|
|
218
|
-
// that is not res, which no syntax admitted here can reach, and admitting `const` would
|
|
219
|
-
// unlock 7 handlers, 0.6%, one conditional a further 0.1%. On the demo and the benchmark
|
|
220
|
-
// alone, where the handlers look more like an application's, the share that calls something
|
|
221
|
-
// rises to 59% and the two would unlock nothing at all. What keeps a route off this path is
|
|
222
|
-
// that its answer is not knowable until the request arrives, and a variable is only another
|
|
223
|
-
// way to spell an answer that already was.
|
|
224
|
-
/** @type {any[]} */
|
|
225
|
-
const tokens = [...acorn.tokenizer(code, { ecmaVersion: "latest" })];
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
tokens.some((token) =>
|
|
229
|
-
[
|
|
230
|
-
"throw",
|
|
231
|
-
"new",
|
|
232
|
-
"await",
|
|
233
|
-
"try",
|
|
234
|
-
"catch",
|
|
235
|
-
"finally",
|
|
236
|
-
"if",
|
|
237
|
-
"else",
|
|
238
|
-
"switch",
|
|
239
|
-
"case",
|
|
240
|
-
"default",
|
|
241
|
-
"for",
|
|
242
|
-
"while",
|
|
243
|
-
"do",
|
|
244
|
-
"var",
|
|
245
|
-
"let",
|
|
246
|
-
"const"
|
|
247
|
-
].includes(token.value)
|
|
248
|
-
)
|
|
249
|
-
) {
|
|
250
|
-
return false;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** @type {any[]} */
|
|
254
|
-
const parsed = parser.parse(code, { ecmaVersion: "latest" }).body;
|
|
255
|
-
let fn = parsed[0];
|
|
256
|
-
|
|
257
|
-
if (fn.type === "ExpressionStatement") {
|
|
258
|
-
fn = fn.expression;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// check if it is a function
|
|
262
|
-
if (fn.type !== "FunctionDeclaration" && fn.type !== "ArrowFunctionExpression") {
|
|
263
|
-
return false;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// before anything is read out of the tree, since reading it is only meaningful for the
|
|
267
|
-
// shapes the walk can see through
|
|
268
|
-
const nodeTypes = new Set();
|
|
269
|
-
collectNodeTypes(fn, nodeTypes);
|
|
270
|
-
for (const type of nodeTypes) {
|
|
271
|
-
if (!understoodNodeTypes.has(type)) {
|
|
272
|
-
return false;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const args = fn.params.map((param) => param.name);
|
|
277
|
-
|
|
278
|
-
if (args.length < 2) {
|
|
279
|
-
// invalid function? doesn't have (req, res) args
|
|
280
|
-
return false;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// `return res.send(...)` describes the same response as `res.send(...)` and is written far
|
|
284
|
-
// more often, so it is worth reading. It only describes the same response when nothing
|
|
285
|
-
// follows it: every call in the body is read, whether or not it can be reached, so a return
|
|
286
|
-
// in the middle would compile statements that never run. A return inside a nested function
|
|
287
|
-
// fails the same test, not being the last statement of this one.
|
|
288
|
-
const returns = filterNodes(fn, (node) => node.type === "ReturnStatement");
|
|
289
|
-
if (returns.length) {
|
|
290
|
-
const statements = fn.body.type === "BlockStatement" ? fn.body.body : null;
|
|
291
|
-
if (!statements || returns.length > 1 || returns[0] !== statements[statements.length - 1]) {
|
|
292
|
-
return false;
|
|
186
|
+
/**
|
|
187
|
+
* The status and the headers the calls set, in the order they were first written. null when one
|
|
188
|
+
* of them is not a literal this can read.
|
|
189
|
+
*
|
|
190
|
+
* @param {any[]} callExprs the res calls, in run order
|
|
191
|
+
* @param {any[]} headers written to, so the caller keeps the array the body reader also uses
|
|
192
|
+
* @returns {{statusCode: number, sendStatusUsed: boolean}|null}
|
|
193
|
+
*/
|
|
194
|
+
function readStatusAndHeaders(callExprs, headers) {
|
|
195
|
+
let statusCode = 200;
|
|
196
|
+
// sendStatus and a bare send() both leave the body empty, but sendStatus sends the status
|
|
197
|
+
// message and send() sends nothing
|
|
198
|
+
let sendStatusUsed = false;
|
|
199
|
+
// get statusCode
|
|
200
|
+
for (const call of callExprs) {
|
|
201
|
+
if (call.obj.propertyName === "status") {
|
|
202
|
+
if (call.arguments[0].type !== "Literal") {
|
|
203
|
+
return null;
|
|
293
204
|
}
|
|
205
|
+
statusCode = call.arguments[0].value;
|
|
294
206
|
}
|
|
207
|
+
}
|
|
295
208
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
queries.push(prop.value.name);
|
|
209
|
+
// get headers
|
|
210
|
+
for (const call of callExprs) {
|
|
211
|
+
const isType = call.obj.propertyName === "type" || call.obj.propertyName === "contentType";
|
|
212
|
+
if (
|
|
213
|
+
call.obj.propertyName === "header" ||
|
|
214
|
+
call.obj.propertyName === "setHeader" ||
|
|
215
|
+
call.obj.propertyName === "set" ||
|
|
216
|
+
isType
|
|
217
|
+
) {
|
|
218
|
+
// type() is set("content-type", ...) after a media type lookup. set() also takes a
|
|
219
|
+
// whole object, one pair per set(). setHeader is node's and takes only strings.
|
|
220
|
+
let pairs;
|
|
221
|
+
if (isType) {
|
|
222
|
+
if (call.arguments[0].type !== "Literal") {
|
|
223
|
+
return null;
|
|
313
224
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (
|
|
323
|
-
return
|
|
225
|
+
pairs = [["content-type", typeValueOf(String(call.arguments[0].value))]];
|
|
226
|
+
} else if (call.arguments.length === 1 && call.obj.propertyName !== "setHeader") {
|
|
227
|
+
if (call.arguments[0].type !== "ObjectExpression") {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
pairs = [];
|
|
231
|
+
for (const property of call.arguments[0].properties) {
|
|
232
|
+
const key = literalKeyOf(property);
|
|
233
|
+
if (key === null || property.value.type !== "Literal") {
|
|
234
|
+
return null;
|
|
324
235
|
}
|
|
325
|
-
|
|
236
|
+
pairs.push([key, String(property.value.value)]);
|
|
326
237
|
}
|
|
327
238
|
} else {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const resCalls = [];
|
|
335
|
-
for (const expr of callExprs) {
|
|
336
|
-
let calleeName, propertyName;
|
|
337
|
-
|
|
338
|
-
// get propertyName
|
|
339
|
-
if (expr.type === "MemberExpression") {
|
|
340
|
-
propertyName = expr.property.name;
|
|
341
|
-
} else if (expr.type === "CallExpression") {
|
|
342
|
-
propertyName = expr.callee?.property?.name ?? expr.callee?.name;
|
|
239
|
+
if (call.arguments[0].type !== "Literal" || call.arguments[1]?.type !== "Literal") {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
// String() here: a numeric literal would reach uWS writeHeader as a number,
|
|
243
|
+
// and uWS refuses anything that is not a string
|
|
244
|
+
pairs = [[call.arguments[0].value, String(call.arguments[1].value)]];
|
|
343
245
|
}
|
|
344
246
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
247
|
+
for (let [header, value] of pairs) {
|
|
248
|
+
const name = String(header).toLowerCase();
|
|
249
|
+
// res.set adds a charset to a content-type, res.setHeader does not: setHeader
|
|
250
|
+
// is node's and node does not know what a media type is
|
|
251
|
+
if (call.obj.propertyName !== "setHeader" && name === "content-type") {
|
|
252
|
+
value = withDefaultCharset(value);
|
|
253
|
+
}
|
|
254
|
+
const index = headers.findIndex((entry) => String(entry[0]).toLowerCase() === name);
|
|
255
|
+
if (index === -1) {
|
|
256
|
+
headers.push([header, value]);
|
|
257
|
+
} else {
|
|
258
|
+
// in place, so the header keeps the position it was first given
|
|
259
|
+
headers[index][1] = value;
|
|
260
|
+
// set replaces the header, so values appended after it go too. Replacing
|
|
261
|
+
// only the first left the response carrying both.
|
|
262
|
+
for (let i = headers.length - 1; i > index; i--) {
|
|
263
|
+
if (String(headers[i][0]).toLowerCase() === name) {
|
|
264
|
+
headers.splice(i, 1);
|
|
361
265
|
}
|
|
362
|
-
calleeName = callee.object.name;
|
|
363
266
|
}
|
|
364
|
-
|
|
365
|
-
default:
|
|
366
|
-
return false;
|
|
267
|
+
}
|
|
367
268
|
}
|
|
368
|
-
|
|
369
|
-
if (
|
|
370
|
-
return
|
|
269
|
+
} else if (call.obj.propertyName === "append") {
|
|
270
|
+
if (call.arguments[0].type !== "Literal" || call.arguments[1].type !== "Literal") {
|
|
271
|
+
return null;
|
|
371
272
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// check if res property being called are
|
|
379
|
-
// - set, header, setHeader
|
|
380
|
-
// - status
|
|
381
|
-
// - send
|
|
382
|
-
// - end
|
|
383
|
-
for (const call of resCalls) {
|
|
384
|
-
if (!allowedResMethods.includes(call.propertyName)) {
|
|
385
|
-
return false;
|
|
273
|
+
headers.push([call.arguments[0].value, String(call.arguments[1].value)]);
|
|
274
|
+
} else if (call.obj.propertyName === "sendStatus") {
|
|
275
|
+
if (call.arguments[0].type !== "Literal") {
|
|
276
|
+
return null;
|
|
386
277
|
}
|
|
278
|
+
statusCode = call.arguments[0].value;
|
|
279
|
+
sendStatusUsed = true;
|
|
387
280
|
}
|
|
281
|
+
}
|
|
282
|
+
return { statusCode, sendStatusUsed };
|
|
283
|
+
}
|
|
388
284
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
285
|
+
/**
|
|
286
|
+
* The body parts the calls write, pushed into `body`, with the content-type decisions they imply
|
|
287
|
+
* pushed into `headers`. null when one of the calls writes something this cannot read.
|
|
288
|
+
*
|
|
289
|
+
* @param {any[]} callExprs the res calls, in run order
|
|
290
|
+
* @param {any[]} headers the headers read so far, written to
|
|
291
|
+
* @param {any[]} body the body parts, written to
|
|
292
|
+
* @param {any} app the application, for the json settings
|
|
293
|
+
* @param {string[]} queries names bound by a destructured req.query
|
|
294
|
+
* @param {string[]} params names bound by a destructured req.params
|
|
295
|
+
* @returns {{sendUsed: boolean, bodyFromSend: boolean}|null}
|
|
296
|
+
*/
|
|
297
|
+
function readBody(callExprs, headers, body, app, queries, params) {
|
|
298
|
+
// get body
|
|
299
|
+
let sendUsed = false;
|
|
300
|
+
// only send() gets an ETag. end() is node's and never computes one, and the ordinary path
|
|
301
|
+
// does the same.
|
|
302
|
+
let bodyFromSend = false;
|
|
303
|
+
for (const call of callExprs) {
|
|
304
|
+
if (bodyMethods.has(call.obj.propertyName)) {
|
|
305
|
+
if (sendUsed) {
|
|
306
|
+
return null;
|
|
405
307
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
.
|
|
412
|
-
.map((id) => id.name);
|
|
413
|
-
if (identifiers[identifiers.length - 1] === "__cb") {
|
|
414
|
-
identifiers.pop();
|
|
415
|
-
}
|
|
416
|
-
if (
|
|
417
|
-
!identifiers.every(
|
|
418
|
-
(id, i) =>
|
|
419
|
-
allowedIdentifiers.includes(id) ||
|
|
420
|
-
id === req ||
|
|
421
|
-
id === res ||
|
|
422
|
-
(identifiers[i - 2] === req && identifiers[i - 1] === "params") ||
|
|
423
|
-
(identifiers[i - 2] === req && identifiers[i - 1] === "query") ||
|
|
424
|
-
id === queryName ||
|
|
425
|
-
id === paramsName ||
|
|
426
|
-
queries.includes(id) ||
|
|
427
|
-
params.includes(id)
|
|
428
|
-
)
|
|
429
|
-
) {
|
|
430
|
-
return false;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
let statusCode = 200;
|
|
434
|
-
// sendStatus and a bare send() both leave the body empty, and they mean different things:
|
|
435
|
-
// one sends the status message, the other sends nothing
|
|
436
|
-
let sendStatusUsed = false;
|
|
437
|
-
const headers = [];
|
|
438
|
-
const body = [];
|
|
308
|
+
// send() with no argument gets no content-type, same as Express and as the ordinary
|
|
309
|
+
// path. It was given one here anyway, so the two paths disagreed on `res.send()`.
|
|
310
|
+
if (call.obj.propertyName !== "end") {
|
|
311
|
+
bodyFromSend = true;
|
|
312
|
+
}
|
|
313
|
+
const arg = call.arguments[0];
|
|
439
314
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if (
|
|
444
|
-
return
|
|
315
|
+
if (call.obj.propertyName === "json") {
|
|
316
|
+
// res.json() with no argument sends no body and no length, a shape left to the
|
|
317
|
+
// ordinary path
|
|
318
|
+
if (!arg) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
// a replacer runs per response on the ordinary path, so a body computed once
|
|
322
|
+
// could not honour it
|
|
323
|
+
const replacer = app.get("json replacer");
|
|
324
|
+
if (typeof replacer !== "undefined" && typeof replacer !== "string") {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
// json sets a type only when none was chosen, then hands a string to send,
|
|
328
|
+
// which adds the charset to whatever type is there
|
|
329
|
+
const existing = headers.find((header) => header[0].toLowerCase() === "content-type");
|
|
330
|
+
if (existing) {
|
|
331
|
+
existing[1] = withUtf8Charset(String(existing[1]));
|
|
332
|
+
} else {
|
|
333
|
+
headers.push(["content-type", "application/json; charset=utf-8"]);
|
|
445
334
|
}
|
|
446
|
-
|
|
335
|
+
body.push({
|
|
336
|
+
type: "text",
|
|
337
|
+
value: stringify(literalValue(arg), replacer, app.get("json spaces"), app.get("json escape"))
|
|
338
|
+
});
|
|
339
|
+
sendUsed = true;
|
|
340
|
+
continue;
|
|
447
341
|
}
|
|
448
|
-
}
|
|
449
342
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
return false;
|
|
343
|
+
if (call.obj.propertyName === "send" && arg) {
|
|
344
|
+
// The body decides the content-type, so this runs before the body is read.
|
|
345
|
+
// Doing it after made res.set("content-type", "text/plain") + res.send({})
|
|
346
|
+
// answer application/json, where Express answers text/plain.
|
|
347
|
+
const isJsonBody =
|
|
348
|
+
arg.type === "ObjectExpression" || (arg.type === "Literal" && typeof arg.value === "boolean");
|
|
349
|
+
const isNullBody = arg.type === "Literal" && arg.value === null;
|
|
350
|
+
const existing = headers.find((header) => header[0].toLowerCase() === "content-type");
|
|
351
|
+
if (!existing) {
|
|
352
|
+
if (isJsonBody) {
|
|
353
|
+
headers.push(["content-type", "application/json; charset=utf-8"]);
|
|
354
|
+
} else if (!isNullBody) {
|
|
355
|
+
// send(null) sends an empty string and chooses no type, the same as
|
|
356
|
+
// the ordinary path
|
|
357
|
+
headers.push(["content-type", "text/html; charset=utf-8"]);
|
|
466
358
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
359
|
+
} else {
|
|
360
|
+
existing[1] = withUtf8Charset(String(existing[1]));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (arg) {
|
|
364
|
+
if (arg.type === "Literal") {
|
|
365
|
+
if (typeof arg.value === "number") {
|
|
366
|
+
// status code
|
|
367
|
+
return null;
|
|
471
368
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
369
|
+
// the content-type was decided above, from what this argument is
|
|
370
|
+
const val = arg.value === null ? "" : arg.value;
|
|
371
|
+
body.push({ type: "text", value: val });
|
|
372
|
+
} else if (arg.type === "TemplateLiteral") {
|
|
373
|
+
const exprs = [...arg.quasis, ...arg.expressions].sort((a, b) => a.start - b.start);
|
|
374
|
+
for (const expr of exprs) {
|
|
375
|
+
if (expr.type === "TemplateElement") {
|
|
376
|
+
body.push({ type: "text", value: expr.value.cooked });
|
|
377
|
+
} else if (expr.type === "MemberExpression") {
|
|
378
|
+
const obj = expr.object;
|
|
379
|
+
let type;
|
|
380
|
+
if (obj.type === "MemberExpression") {
|
|
381
|
+
if (obj.property.type !== "Identifier") {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
type = obj.property.name;
|
|
385
|
+
} else if (obj.type === "Identifier") {
|
|
386
|
+
type = obj.name;
|
|
387
|
+
} else {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
if (type !== "params" && type !== "query") {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
body.push({ type, value: expr.property.name });
|
|
394
|
+
} else if (expr.type === "Identifier") {
|
|
395
|
+
if (queries.includes(expr.name)) {
|
|
396
|
+
body.push({ type: "query", value: expr.name });
|
|
397
|
+
} else if (params.includes(expr.name)) {
|
|
398
|
+
body.push({ type: "params", value: expr.name });
|
|
399
|
+
} else {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
return null;
|
|
477
404
|
}
|
|
478
|
-
pairs.push([key, String(property.value.value)]);
|
|
479
405
|
}
|
|
480
|
-
} else {
|
|
481
|
-
if (
|
|
482
|
-
return
|
|
406
|
+
} else if (arg.type === "MemberExpression") {
|
|
407
|
+
if (!arg.object.property) {
|
|
408
|
+
return null;
|
|
483
409
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
for (let [header, value] of pairs) {
|
|
490
|
-
const name = String(header).toLowerCase();
|
|
491
|
-
// res.set charsets a content-type and res.setHeader does not, since the second
|
|
492
|
-
// is node's and node does not know what a media type is
|
|
493
|
-
if (call.obj.propertyName !== "setHeader" && name === "content-type") {
|
|
494
|
-
value = withDefaultCharset(value);
|
|
410
|
+
if (
|
|
411
|
+
arg.object.property.type !== "Identifier" ||
|
|
412
|
+
(arg.object.property.name !== "query" && arg.object.property.name !== "params")
|
|
413
|
+
) {
|
|
414
|
+
return null;
|
|
495
415
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
416
|
+
body.push({ type: arg.object.property.name, value: arg.property.name });
|
|
417
|
+
} else if (arg.type === "BinaryExpression") {
|
|
418
|
+
const stuff = [];
|
|
419
|
+
/**
|
|
420
|
+
* Reads a chain of string concatenations right to left. Each side must be a literal or a
|
|
421
|
+
* param or query value, anything else makes the whole handler fall back.
|
|
422
|
+
*
|
|
423
|
+
* @param {any} node a BinaryExpression
|
|
424
|
+
* @returns {boolean}
|
|
425
|
+
*/
|
|
426
|
+
function check(node) {
|
|
427
|
+
// only "+" concatenates, any other operator computes a value the parts
|
|
428
|
+
// cannot hold, so the handler falls back
|
|
429
|
+
if (node.operator !== "+") {
|
|
430
|
+
return false;
|
|
508
431
|
}
|
|
432
|
+
if (node.right.type === "Literal") {
|
|
433
|
+
stuff.push({ type: "text", value: node.right.value });
|
|
434
|
+
} else if (node.right.type === "MemberExpression") {
|
|
435
|
+
stuff.push({ type: node.right.object.property.name, value: node.right.property.name });
|
|
436
|
+
} else return false;
|
|
437
|
+
if (node.left.type === "Literal") {
|
|
438
|
+
stuff.push({ type: "text", value: node.left.value });
|
|
439
|
+
} else if (node.left.type === "MemberExpression") {
|
|
440
|
+
stuff.push({ type: node.left.object.property.name, value: node.left.property.name });
|
|
441
|
+
} else if (node.left.type === "BinaryExpression") {
|
|
442
|
+
return check(node.left);
|
|
443
|
+
} else return false;
|
|
444
|
+
|
|
445
|
+
return true;
|
|
509
446
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (call.arguments[0].type !== "Literal") {
|
|
518
|
-
return false;
|
|
519
|
-
}
|
|
520
|
-
statusCode = call.arguments[0].value;
|
|
521
|
-
sendStatusUsed = true;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// get body
|
|
526
|
-
let sendUsed = false;
|
|
527
|
-
// only send() earns an ETag. end() is node's, which never computes one, and the ordinary
|
|
528
|
-
// path here follows that too, so the compiled path has to as well.
|
|
529
|
-
let bodyFromSend = false;
|
|
530
|
-
for (const call of callExprs) {
|
|
531
|
-
if (bodyMethods.has(call.obj.propertyName)) {
|
|
532
|
-
if (sendUsed) {
|
|
533
|
-
return false;
|
|
534
|
-
}
|
|
535
|
-
// send() with nothing to send gets no content-type, the same as on the ordinary
|
|
536
|
-
// path and the same as Express. It was being given one here regardless, so the
|
|
537
|
-
// two paths disagreed about a route as simple as `res.send()`.
|
|
538
|
-
if (call.obj.propertyName !== "end") {
|
|
539
|
-
bodyFromSend = true;
|
|
540
|
-
}
|
|
541
|
-
const arg = call.arguments[0];
|
|
542
|
-
|
|
543
|
-
if (call.obj.propertyName === "json") {
|
|
544
|
-
// res.json() with nothing to serialise sends no body and no length, which is a
|
|
545
|
-
// shape worth leaving to the ordinary path rather than reproducing here
|
|
546
|
-
if (!arg) {
|
|
547
|
-
return false;
|
|
447
|
+
if (!check(arg)) {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
body.push(...stuff.reverse());
|
|
451
|
+
} else if (arg.type === "ObjectExpression") {
|
|
452
|
+
if (call.obj.propertyName === "end") {
|
|
453
|
+
return null;
|
|
548
454
|
}
|
|
549
|
-
// a replacer runs per response on the ordinary path, so a body computed
|
|
550
|
-
// could not honour it
|
|
455
|
+
// a replacer runs per response on the ordinary path, so a body computed
|
|
456
|
+
// once could not honour it
|
|
551
457
|
const replacer = app.get("json replacer");
|
|
552
458
|
if (typeof replacer !== "undefined" && typeof replacer !== "string") {
|
|
553
|
-
return
|
|
554
|
-
}
|
|
555
|
-
// json reaches for a type only when none was chosen, then hands a string to
|
|
556
|
-
// send, which is what charsets whatever type is there
|
|
557
|
-
const existing = headers.find((header) => header[0].toLowerCase() === "content-type");
|
|
558
|
-
if (existing) {
|
|
559
|
-
existing[1] = withUtf8Charset(String(existing[1]));
|
|
560
|
-
} else {
|
|
561
|
-
headers.push(["content-type", "application/json; charset=utf-8"]);
|
|
459
|
+
return null;
|
|
562
460
|
}
|
|
461
|
+
|
|
462
|
+
// the content-type was decided above, from what this argument is
|
|
563
463
|
body.push({
|
|
564
464
|
type: "text",
|
|
565
465
|
value: stringify(literalValue(arg), replacer, app.get("json spaces"), app.get("json escape"))
|
|
566
466
|
});
|
|
567
|
-
|
|
568
|
-
|
|
467
|
+
} else {
|
|
468
|
+
return null;
|
|
569
469
|
}
|
|
470
|
+
}
|
|
471
|
+
sendUsed = true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return { sendUsed, bodyFromSend };
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* The handler's AST and its parameter names, when it is a shape this compiler can read at all.
|
|
478
|
+
* null for anything it cannot: a keyword it does not admit, a node type the walk cannot see
|
|
479
|
+
* through, too few parameters, or a return that is not the last statement.
|
|
480
|
+
*
|
|
481
|
+
* @param {Function} cb
|
|
482
|
+
* @returns {{fn: any, args: string[]}|null}
|
|
483
|
+
*/
|
|
484
|
+
function readHandler(cb) {
|
|
485
|
+
let code = cb.toString();
|
|
486
|
+
// convert anonymous functions to named ones to make it valid code
|
|
487
|
+
if (code.startsWith("function") || code.startsWith("async function")) {
|
|
488
|
+
code = code.replace(/function *\(/, "function __cb(");
|
|
489
|
+
}
|
|
570
490
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
491
|
+
// Anything not understood returns false and falls back to ordinary routing. Widening the
|
|
492
|
+
// list below is not worth it: over the 1113 handlers in tests, demo and benchmark, 42.6%
|
|
493
|
+
// call something that is not res, `const` would unlock 7 (0.6%), a conditional 0.1% more.
|
|
494
|
+
/** @type {any[]} */
|
|
495
|
+
const tokens = [...acorn.tokenizer(code, { ecmaVersion: "latest" })];
|
|
496
|
+
|
|
497
|
+
if (
|
|
498
|
+
tokens.some((token) =>
|
|
499
|
+
[
|
|
500
|
+
"throw",
|
|
501
|
+
"new",
|
|
502
|
+
"await",
|
|
503
|
+
"try",
|
|
504
|
+
"catch",
|
|
505
|
+
"finally",
|
|
506
|
+
"if",
|
|
507
|
+
"else",
|
|
508
|
+
"switch",
|
|
509
|
+
"case",
|
|
510
|
+
"default",
|
|
511
|
+
"for",
|
|
512
|
+
"while",
|
|
513
|
+
"do",
|
|
514
|
+
"var",
|
|
515
|
+
"let",
|
|
516
|
+
"const"
|
|
517
|
+
].includes(token.value)
|
|
518
|
+
)
|
|
519
|
+
) {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** @type {any[]} */
|
|
524
|
+
const parsed = parser.parse(code, { ecmaVersion: "latest" }).body;
|
|
525
|
+
let fn = parsed[0];
|
|
526
|
+
|
|
527
|
+
if (fn.type === "ExpressionStatement") {
|
|
528
|
+
fn = fn.expression;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// check if it is a function
|
|
532
|
+
if (fn.type !== "FunctionDeclaration" && fn.type !== "ArrowFunctionExpression") {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// before reading the tree, because reading is only valid for the shapes the walk can see
|
|
537
|
+
// through
|
|
538
|
+
const nodeTypes = new Set();
|
|
539
|
+
collectNodeTypes(fn, nodeTypes);
|
|
540
|
+
for (const type of nodeTypes) {
|
|
541
|
+
if (!understoodNodeTypes.has(type)) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const args = fn.params.map((param) => param.name);
|
|
547
|
+
|
|
548
|
+
if (args.length < 2) {
|
|
549
|
+
// invalid function? doesn't have (req, res) args
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// `return res.send(...)` is the same response as `res.send(...)`, but only as the last
|
|
554
|
+
// statement: every call is read, so a return in the middle would compile dead ones.
|
|
555
|
+
const returns = filterNodes(fn, (node) => node.type === "ReturnStatement");
|
|
556
|
+
if (returns.length) {
|
|
557
|
+
const statements = fn.body.type === "BlockStatement" ? fn.body.body : null;
|
|
558
|
+
if (!statements || returns.length > 1 || returns[0] !== statements[statements.length - 1]) {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return { fn, args };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The names a destructured `req` binds for query and params, so the body reader can tell one of
|
|
568
|
+
* them from an identifier it must refuse. null when the pattern is one this cannot read.
|
|
569
|
+
*
|
|
570
|
+
* @param {any} fn the handler's AST
|
|
571
|
+
* @param {string[]} args its parameter names
|
|
572
|
+
* @returns {{req: string, res: string, queryName: string|undefined, paramsName: string|undefined,
|
|
573
|
+
* queries: string[], params: string[]}|null}
|
|
574
|
+
*/
|
|
575
|
+
function readParamNames(fn, args) {
|
|
576
|
+
const [req, res] = args;
|
|
577
|
+
let queryName, paramsName;
|
|
578
|
+
const queries = [],
|
|
579
|
+
params = [];
|
|
580
|
+
|
|
581
|
+
if (fn.params[0].type === "ObjectPattern") {
|
|
582
|
+
const query = fn.params[0].properties.find((prop) => prop.key.name === "query");
|
|
583
|
+
const param = fn.params[0].properties.find((prop) => prop.key.name === "params");
|
|
584
|
+
|
|
585
|
+
if (query?.value?.type === "Identifier") {
|
|
586
|
+
queryName = query.value.name;
|
|
587
|
+
} else if (query?.value?.type === "ObjectPattern") {
|
|
588
|
+
for (const prop of query.value.properties) {
|
|
589
|
+
if (prop.value.type !== "Identifier") {
|
|
590
|
+
return null;
|
|
592
591
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
}
|
|
599
|
-
// the content-type was decided above, from what this argument is
|
|
600
|
-
const val = arg.value === null ? "" : arg.value;
|
|
601
|
-
body.push({ type: "text", value: val });
|
|
602
|
-
} else if (arg.type === "TemplateLiteral") {
|
|
603
|
-
const exprs = [...arg.quasis, ...arg.expressions].sort((a, b) => a.start - b.start);
|
|
604
|
-
for (const expr of exprs) {
|
|
605
|
-
if (expr.type === "TemplateElement") {
|
|
606
|
-
body.push({ type: "text", value: expr.value.cooked });
|
|
607
|
-
} else if (expr.type === "MemberExpression") {
|
|
608
|
-
const obj = expr.object;
|
|
609
|
-
let type;
|
|
610
|
-
if (obj.type === "MemberExpression") {
|
|
611
|
-
if (obj.property.type !== "Identifier") {
|
|
612
|
-
return false;
|
|
613
|
-
}
|
|
614
|
-
type = obj.property.name;
|
|
615
|
-
} else if (obj.type === "Identifier") {
|
|
616
|
-
type = obj.name;
|
|
617
|
-
} else {
|
|
618
|
-
return false;
|
|
619
|
-
}
|
|
620
|
-
if (type !== "params" && type !== "query") {
|
|
621
|
-
return false;
|
|
622
|
-
}
|
|
623
|
-
body.push({ type, value: expr.property.name });
|
|
624
|
-
} else if (expr.type === "Identifier") {
|
|
625
|
-
if (queries.includes(expr.name)) {
|
|
626
|
-
body.push({ type: "query", value: expr.name });
|
|
627
|
-
} else if (params.includes(expr.name)) {
|
|
628
|
-
body.push({ type: "params", value: expr.name });
|
|
629
|
-
} else {
|
|
630
|
-
return false;
|
|
631
|
-
}
|
|
632
|
-
} else {
|
|
633
|
-
return false;
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
} else if (arg.type === "MemberExpression") {
|
|
637
|
-
if (!arg.object.property) {
|
|
638
|
-
return false;
|
|
639
|
-
}
|
|
640
|
-
if (
|
|
641
|
-
arg.object.property.type !== "Identifier" ||
|
|
642
|
-
(arg.object.property.name !== "query" && arg.object.property.name !== "params")
|
|
643
|
-
) {
|
|
644
|
-
return false;
|
|
645
|
-
}
|
|
646
|
-
body.push({ type: arg.object.property.name, value: arg.property.name });
|
|
647
|
-
} else if (arg.type === "BinaryExpression") {
|
|
648
|
-
const stuff = [];
|
|
649
|
-
/**
|
|
650
|
-
* Reads a chain of string concatenations right to left, collecting each side as either a
|
|
651
|
-
* literal or a param or query value. Returns false for anything else, which is what makes
|
|
652
|
-
* the whole handler fall back.
|
|
653
|
-
*
|
|
654
|
-
* @param {any} node a BinaryExpression
|
|
655
|
-
* @returns {boolean}
|
|
656
|
-
*/
|
|
657
|
-
function check(node) {
|
|
658
|
-
// only "+" concatenates; any other operator computes a value the
|
|
659
|
-
// parts cannot represent, so the handler falls back
|
|
660
|
-
if (node.operator !== "+") {
|
|
661
|
-
return false;
|
|
662
|
-
}
|
|
663
|
-
if (node.right.type === "Literal") {
|
|
664
|
-
stuff.push({ type: "text", value: node.right.value });
|
|
665
|
-
} else if (node.right.type === "MemberExpression") {
|
|
666
|
-
stuff.push({ type: node.right.object.property.name, value: node.right.property.name });
|
|
667
|
-
} else return false;
|
|
668
|
-
if (node.left.type === "Literal") {
|
|
669
|
-
stuff.push({ type: "text", value: node.left.value });
|
|
670
|
-
} else if (node.left.type === "MemberExpression") {
|
|
671
|
-
stuff.push({ type: node.left.object.property.name, value: node.left.property.name });
|
|
672
|
-
} else if (node.left.type === "BinaryExpression") {
|
|
673
|
-
return check(node.left);
|
|
674
|
-
} else return false;
|
|
675
|
-
|
|
676
|
-
return true;
|
|
677
|
-
}
|
|
678
|
-
if (!check(arg)) {
|
|
679
|
-
return false;
|
|
680
|
-
}
|
|
681
|
-
body.push(...stuff.reverse());
|
|
682
|
-
} else if (arg.type === "ObjectExpression") {
|
|
683
|
-
if (call.obj.propertyName === "end") {
|
|
684
|
-
return false;
|
|
685
|
-
}
|
|
686
|
-
// a replacer runs per response on the ordinary path, so a body computed
|
|
687
|
-
// once could not honour it
|
|
688
|
-
const replacer = app.get("json replacer");
|
|
689
|
-
if (typeof replacer !== "undefined" && typeof replacer !== "string") {
|
|
690
|
-
return false;
|
|
691
|
-
}
|
|
592
|
+
queries.push(prop.value.name);
|
|
593
|
+
}
|
|
594
|
+
} else {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
692
597
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
598
|
+
if (param?.value?.type === "Identifier") {
|
|
599
|
+
paramsName = param.value.name;
|
|
600
|
+
} else if (param?.value?.type === "ObjectPattern") {
|
|
601
|
+
for (const prop of param.value.properties) {
|
|
602
|
+
if (prop.value.type !== "Identifier") {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
params.push(prop.value.name);
|
|
606
|
+
}
|
|
607
|
+
} else {
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return { req, res, queryName, paramsName, queries, params };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Every call the handler makes, in the order they run, cut after the one that writes the body.
|
|
616
|
+
* null when it calls anything but `res`, or a method a compiled response cannot stand for.
|
|
617
|
+
*
|
|
618
|
+
* @param {any} fn the handler's AST
|
|
619
|
+
* @param {string} res the name its second parameter was given
|
|
620
|
+
* @returns {any[]|null}
|
|
621
|
+
*/
|
|
622
|
+
function readResCalls(fn, res) {
|
|
623
|
+
// check if it calls any other function other than the one in `res`
|
|
624
|
+
const callExprs = filterNodes(fn, (node) => node.type === "CallExpression");
|
|
625
|
+
const resCalls = [];
|
|
626
|
+
for (const expr of callExprs) {
|
|
627
|
+
let calleeName, propertyName;
|
|
628
|
+
|
|
629
|
+
// get propertyName
|
|
630
|
+
if (expr.type === "MemberExpression") {
|
|
631
|
+
propertyName = expr.property.name;
|
|
632
|
+
} else if (expr.type === "CallExpression") {
|
|
633
|
+
propertyName = expr.callee?.property?.name ?? expr.callee?.name;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// get calleeName
|
|
637
|
+
switch (expr.callee.type) {
|
|
638
|
+
case "Identifier":
|
|
639
|
+
calleeName = expr.callee.name;
|
|
640
|
+
break;
|
|
641
|
+
case "MemberExpression":
|
|
642
|
+
if (expr.callee.object.type === "Identifier") {
|
|
643
|
+
calleeName = expr.callee.object.name;
|
|
644
|
+
} else if (expr.callee.object.type === "CallExpression") {
|
|
645
|
+
// function call chaining
|
|
646
|
+
let callee = expr.callee;
|
|
647
|
+
while (callee.object.callee) {
|
|
648
|
+
callee = callee.object.callee;
|
|
705
649
|
}
|
|
650
|
+
if (callee.object.type !== "Identifier") {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
calleeName = callee.object.name;
|
|
706
654
|
}
|
|
707
|
-
|
|
655
|
+
break;
|
|
656
|
+
default:
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
// check if calleeName is res
|
|
660
|
+
if (calleeName !== res) {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const obj = { calleeName, propertyName };
|
|
665
|
+
expr.obj = obj;
|
|
666
|
+
resCalls.push(obj);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// check if res property being called are
|
|
670
|
+
// - set, header, setHeader
|
|
671
|
+
// - status
|
|
672
|
+
// - send
|
|
673
|
+
// - end
|
|
674
|
+
for (const call of resCalls) {
|
|
675
|
+
if (!allowedResMethods.includes(call.propertyName)) {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Sorted in run order. In a chain the walk reaches the outer call first, so
|
|
681
|
+
// res.status(201).status(202) was read backwards. End position orders both cases.
|
|
682
|
+
callExprs.sort((a, b) => a.end - b.end);
|
|
683
|
+
|
|
684
|
+
// Nothing after the body call has any effect: on Express res.send("k") then
|
|
685
|
+
// res.status(201) is still a 200. Two calls that both write a body fall back.
|
|
686
|
+
const terminalIndex = callExprs.findIndex((call) => terminalMethods.has(call.obj.propertyName));
|
|
687
|
+
if (terminalIndex !== -1) {
|
|
688
|
+
for (let i = terminalIndex + 1; i < callExprs.length; i++) {
|
|
689
|
+
if (terminalMethods.has(callExprs[i].obj.propertyName)) {
|
|
690
|
+
return null;
|
|
708
691
|
}
|
|
709
692
|
}
|
|
693
|
+
callExprs.length = terminalIndex + 1;
|
|
694
|
+
}
|
|
695
|
+
return callExprs;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Whether every identifier in the handler is one a compiled response can stand for.
|
|
700
|
+
*
|
|
701
|
+
* @param {any} fn the handler's AST
|
|
702
|
+
* @param {string[]} args its parameter names
|
|
703
|
+
* @param {any} names what a destructured req bound, from readParamNames
|
|
704
|
+
* @returns {boolean}
|
|
705
|
+
*/
|
|
706
|
+
function identifiersAllowed(fn, args, names) {
|
|
707
|
+
const { req, res, queryName, paramsName, queries, params } = names;
|
|
708
|
+
const identifiers = filterNodes(fn, (node) => node.type === "Identifier")
|
|
709
|
+
.slice(args.length)
|
|
710
|
+
.map((id) => id.name);
|
|
711
|
+
if (identifiers[identifiers.length - 1] === "__cb") {
|
|
712
|
+
identifiers.pop();
|
|
713
|
+
}
|
|
714
|
+
return identifiers.every(
|
|
715
|
+
(id, i) =>
|
|
716
|
+
allowedIdentifiers.includes(id) ||
|
|
717
|
+
id === req ||
|
|
718
|
+
id === res ||
|
|
719
|
+
(identifiers[i - 2] === req && identifiers[i - 1] === "params") ||
|
|
720
|
+
(identifiers[i - 2] === req && identifiers[i - 1] === "query") ||
|
|
721
|
+
id === queryName ||
|
|
722
|
+
id === paramsName ||
|
|
723
|
+
queries.includes(id) ||
|
|
724
|
+
params.includes(id)
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
// uWS allows creating such responses and they are extremely fast
|
|
728
|
+
// since you don't even have to call into Node.js at all
|
|
729
|
+
// declarative response will only be created if callback is 'simple enough'
|
|
730
|
+
// simple enough means:
|
|
731
|
+
// - doesnt call external functions
|
|
732
|
+
// - doesnt create variables
|
|
733
|
+
// - only uses req.query and req.params
|
|
734
|
+
// basically, its only simple, static responses
|
|
735
|
+
module.exports = function compileDeclarative(cb, app) {
|
|
736
|
+
try {
|
|
737
|
+
const handler = readHandler(cb);
|
|
738
|
+
if (handler === null) {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
const { fn, args } = handler;
|
|
742
|
+
|
|
743
|
+
const names = readParamNames(fn, args);
|
|
744
|
+
if (names === null) {
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
const { res, queries, params } = names;
|
|
748
|
+
|
|
749
|
+
const callExprs = readResCalls(fn, res);
|
|
750
|
+
if (callExprs === null) {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (!identifiersAllowed(fn, args, names)) {
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const headers = [];
|
|
759
|
+
const body = [];
|
|
760
|
+
|
|
761
|
+
const status = readStatusAndHeaders(callExprs, headers);
|
|
762
|
+
if (status === null) {
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
const { statusCode, sendStatusUsed } = status;
|
|
766
|
+
|
|
767
|
+
const read = readBody(callExprs, headers, body, app, queries, params);
|
|
768
|
+
if (read === null) {
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
const { sendUsed, bodyFromSend } = read;
|
|
710
772
|
|
|
711
|
-
// a handler that never sends is not a response: Express leaves the request waiting, so
|
|
712
|
-
//
|
|
713
|
-
// nothing at all. It has to fall back instead.
|
|
773
|
+
// a handler that never sends is not a response: Express leaves the request waiting, so this
|
|
774
|
+
// has to fall back instead of answering a bare 200
|
|
714
775
|
if (!sendUsed && !sendStatusUsed) {
|
|
715
776
|
return false;
|
|
716
777
|
}
|
|
717
778
|
|
|
718
|
-
// A status that carries no content. Compiled, the body went out
|
|
719
|
-
//
|
|
720
|
-
// of the next answer on the connection. The ordinary path already writes all three the way
|
|
721
|
-
// express does.
|
|
779
|
+
// A status that carries no content. Compiled, the body went out anyway, and a client frames
|
|
780
|
+
// these as bodiless, so those bytes were read as the start of the next answer.
|
|
722
781
|
if (BODILESS_STATUSES.has(statusCode) || statusCode < 200) {
|
|
723
782
|
return false;
|
|
724
783
|
}
|
|
@@ -729,22 +788,21 @@ module.exports = function compileDeclarative(cb, app) {
|
|
|
729
788
|
const statusMessage = statuses.message[statusCode] ?? "unknown";
|
|
730
789
|
decRes = decRes.writeStatus(`${statusCode} ${statusMessage}`);
|
|
731
790
|
}
|
|
732
|
-
// only sendStatus types its body
|
|
733
|
-
//
|
|
791
|
+
// only sendStatus types its body, through res.type("txt"). status(n).end() sends no
|
|
792
|
+
// Content-Type at all, in Express and here
|
|
734
793
|
if (sendStatusUsed && !headers.some((header) => header[0].toLowerCase() === "content-type")) {
|
|
735
794
|
decRes = decRes.writeHeader("content-type", "text/plain; charset=utf-8");
|
|
736
795
|
}
|
|
737
796
|
|
|
738
797
|
// the same two the ordinary path seeds every response with. Without them a route answered
|
|
739
|
-
// different headers
|
|
740
|
-
// than either choice on its own, and a client had no idle timeout to go on.
|
|
798
|
+
// different headers only because it was compilable, and a client had no idle timeout.
|
|
741
799
|
const advertise = app.get("connection headers") !== false;
|
|
742
800
|
const connection = headers.find((header) => header[0].toLowerCase() === "connection");
|
|
743
801
|
if (!connection && advertise) {
|
|
744
802
|
decRes = decRes.writeHeader("connection", "keep-alive");
|
|
745
803
|
}
|
|
746
|
-
// not
|
|
747
|
-
//
|
|
804
|
+
// not when the handler is closing: Keep-Alive describes a connection that stays open, and
|
|
805
|
+
// the ordinary path leaves it out for the same reason
|
|
748
806
|
const closing = typeof connection?.[1] === "string" && connection[1].toLowerCase() === "close";
|
|
749
807
|
if (advertise && !closing && !headers.some((header) => header[0].toLowerCase() === "keep-alive")) {
|
|
750
808
|
decRes = decRes.writeHeader("keep-alive", "timeout=10");
|
|
@@ -755,42 +813,35 @@ module.exports = function compileDeclarative(cb, app) {
|
|
|
755
813
|
if (name === "content-length") {
|
|
756
814
|
return false;
|
|
757
815
|
}
|
|
758
|
-
// lowercased
|
|
759
|
-
//
|
|
816
|
+
// lowercased like the ordinary path stores them, so both paths answer the same bytes
|
|
817
|
+
// whatever casing the handler wrote, see issue #7
|
|
760
818
|
decRes = decRes.writeHeader(name, header[1]);
|
|
761
819
|
}
|
|
762
820
|
|
|
763
|
-
// sendStatus sends the status message as
|
|
764
|
-
//
|
|
765
|
-
// sendStatus response the same one, so a cache could not tell a 404 from a 500 and a
|
|
766
|
-
// conditional request could be answered 304 with the wrong body entirely.
|
|
821
|
+
// sendStatus sends the status message as body, and it has to join `body` before the ETag:
|
|
822
|
+
// over an empty body every sendStatus response got the same ETag.
|
|
767
823
|
if (sendStatusUsed && !body.length) {
|
|
768
824
|
body.push({ type: "text", value: statuses.message[statusCode] || String(statusCode) });
|
|
769
825
|
}
|
|
770
826
|
|
|
771
|
-
// A response
|
|
772
|
-
//
|
|
773
|
-
// invites. Dropping the ETag instead would leave the simplest routes of an application
|
|
774
|
-
// without one, so `etag` false is how a route stays compiled.
|
|
827
|
+
// A response carrying a validator is not compiled: uWS answers without reading the request,
|
|
828
|
+
// so it could never turn a conditional GET into a 304. Use `etag` false to stay compiled.
|
|
775
829
|
if (headers.some((header) => VALIDATOR_HEADERS.has(header[0].toLowerCase()))) {
|
|
776
830
|
return false;
|
|
777
831
|
}
|
|
778
|
-
// an empty body gets no ETag
|
|
779
|
-
// to lose by being compiled
|
|
832
|
+
// an empty body gets no ETag in Express either, so it loses nothing by being compiled
|
|
780
833
|
if (body.length && (bodyFromSend || sendStatusUsed) && app.get("etag")) {
|
|
781
834
|
return false;
|
|
782
835
|
}
|
|
783
836
|
|
|
784
|
-
// No Content-Length
|
|
785
|
-
//
|
|
837
|
+
// No Content-Length here: uWS writes the framing itself and a response with both is
|
|
838
|
+
// invalid. Which framing it writes is decided at the end of this function.
|
|
786
839
|
if (app.get("x-powered-by")) {
|
|
787
840
|
decRes = decRes.writeHeader("x-powered-by", "Fulmine");
|
|
788
841
|
}
|
|
789
842
|
|
|
790
|
-
// A
|
|
791
|
-
//
|
|
792
|
-
// request has no length until the request arrives, so those stay a write each and uWS
|
|
793
|
-
// chunks them.
|
|
843
|
+
// A fully literal body goes out as one end(), so uWS frames it with a Content-Length like
|
|
844
|
+
// Express. A part taken from the request has no length yet, so those are chunked writes.
|
|
794
845
|
const literal = body.every((part) => part.type === "text")
|
|
795
846
|
? body.map((part) => String(part.value)).join("")
|
|
796
847
|
: null;
|
|
@@ -815,12 +866,10 @@ module.exports = function compileDeclarative(cb, app) {
|
|
|
815
866
|
};
|
|
816
867
|
|
|
817
868
|
/**
|
|
818
|
-
* Every node
|
|
819
|
-
*
|
|
820
|
-
* any node type that is not on the understood list: a shape this walk cannot see through would
|
|
821
|
-
* otherwise be compiled as though it were empty.
|
|
869
|
+
* Every node matching the predicate, in the order of the named edges below. The edges are written
|
|
870
|
+
* by hand, which is why compileDeclarative first refuses any node type not on the understood list.
|
|
822
871
|
*
|
|
823
|
-
* @param {any} node
|
|
872
|
+
* @param {any} node an acorn AST node. acorn ships no useful node types, and every shape here is checked by hand
|
|
824
873
|
* @param {(node: any) => boolean} fn
|
|
825
874
|
* @returns {any[]}
|
|
826
875
|
*/
|
|
@@ -887,9 +936,8 @@ function filterNodes(node, fn) {
|
|
|
887
936
|
}
|
|
888
937
|
}
|
|
889
938
|
|
|
890
|
-
// singular,
|
|
891
|
-
//
|
|
892
|
-
// it at all, which would have compiled into an empty response.
|
|
939
|
+
// singular, not the list above: what a return statement returns and what a unary operator
|
|
940
|
+
// applies to. Without it `return res.send("x")` looked like a body with no calls in it.
|
|
893
941
|
if (node.argument) {
|
|
894
942
|
filtered.push(...filterNodes(node.argument, fn));
|
|
895
943
|
}
|