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/node-shim.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2026 Nigro Simone
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// A uWS-shaped request and response backed by node's own, so an app can serve what arrived through
|
|
18
|
+
// http.createServer. Nothing on this path is fast, and it is not meant to be: it is what lets
|
|
19
|
+
// supertest and http.createServer(app) work, which is what an app has to be a function for.
|
|
20
|
+
//
|
|
21
|
+
// Request and Response ask uWS for eighteen things and this answers all eighteen. Where the two
|
|
22
|
+
// models disagree node's gives way: cork only runs its callback, and a status is remembered rather
|
|
23
|
+
// than sent, since node writes the head with the first byte of body.
|
|
24
|
+
|
|
25
|
+
const { IncomingMessage } = require("http");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An IP address as the four or sixteen bytes uWS hands over, since that is what req.ip parses.
|
|
29
|
+
* Anything unreadable comes back empty, which req.ip reports as undefined, the same answer it gives
|
|
30
|
+
* for a unix socket.
|
|
31
|
+
*
|
|
32
|
+
* @param {string|undefined} address
|
|
33
|
+
* @returns {ArrayBuffer}
|
|
34
|
+
*/
|
|
35
|
+
function addressToBytes(address) {
|
|
36
|
+
if (!address) {
|
|
37
|
+
return new ArrayBuffer(0);
|
|
38
|
+
}
|
|
39
|
+
// node reports an IPv4 client on a dual stack socket as ::ffff:127.0.0.1, and the address that
|
|
40
|
+
// belongs in req.ip is the v4 one
|
|
41
|
+
const mapped = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
42
|
+
if (mapped.includes(".")) {
|
|
43
|
+
const parts = mapped.split(".");
|
|
44
|
+
if (parts.length !== 4) {
|
|
45
|
+
return new ArrayBuffer(0);
|
|
46
|
+
}
|
|
47
|
+
const bytes = new Uint8Array(4);
|
|
48
|
+
for (let i = 0; i < 4; i++) {
|
|
49
|
+
const value = Number(parts[i]);
|
|
50
|
+
if (!Number.isInteger(value) || value < 0 || value > 255) {
|
|
51
|
+
return new ArrayBuffer(0);
|
|
52
|
+
}
|
|
53
|
+
bytes[i] = value;
|
|
54
|
+
}
|
|
55
|
+
return bytes.buffer;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// IPv6, with the one "::" expanded to however many zero groups are missing
|
|
59
|
+
const [head, tail] = address.split("::");
|
|
60
|
+
const headGroups = head ? head.split(":") : [];
|
|
61
|
+
const tailGroups = tail ? tail.split(":") : [];
|
|
62
|
+
const missing = 8 - headGroups.length - tailGroups.length;
|
|
63
|
+
if (missing < 0 || (address.includes("::") === false && headGroups.length !== 8)) {
|
|
64
|
+
return new ArrayBuffer(0);
|
|
65
|
+
}
|
|
66
|
+
const groups = address.includes("::")
|
|
67
|
+
? [...headGroups, ...new Array(missing).fill("0"), ...tailGroups]
|
|
68
|
+
: headGroups;
|
|
69
|
+
const view = new DataView(new ArrayBuffer(16));
|
|
70
|
+
for (let i = 0; i < 8; i++) {
|
|
71
|
+
const value = parseInt(groups[i] || "0", 16);
|
|
72
|
+
if (Number.isNaN(value)) {
|
|
73
|
+
return new ArrayBuffer(0);
|
|
74
|
+
}
|
|
75
|
+
view.setUint16(i * 2, value);
|
|
76
|
+
}
|
|
77
|
+
return view.buffer;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A chunk as the ArrayBuffer uWS deals in, copied rather than viewed so nothing aliases node's. */
|
|
81
|
+
function toArrayBuffer(chunk) {
|
|
82
|
+
if (chunk instanceof ArrayBuffer) {
|
|
83
|
+
return chunk;
|
|
84
|
+
}
|
|
85
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
86
|
+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What uWS calls an HttpRequest, over node's IncomingMessage.
|
|
91
|
+
*
|
|
92
|
+
* Only valid for as long as the response is, which here is longer than uWS allows: node keeps the
|
|
93
|
+
* headers alive, so nothing has to be copied out in a hurry. Request copies them anyway, since it
|
|
94
|
+
* cannot tell which kind of request it is holding.
|
|
95
|
+
*/
|
|
96
|
+
class NodeHttpRequest {
|
|
97
|
+
/** @param {import("http").IncomingMessage} req */
|
|
98
|
+
constructor(req) {
|
|
99
|
+
this._req = req;
|
|
100
|
+
const url = req.url || "/";
|
|
101
|
+
const question = url.indexOf("?");
|
|
102
|
+
this._path = question === -1 ? url : url.slice(0, question);
|
|
103
|
+
// uWS answers the query without its "?", and an empty string when there is none
|
|
104
|
+
this._query = question === -1 ? "" : url.slice(question + 1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The path, without the query, which is what uWS answers here. */
|
|
108
|
+
getUrl() {
|
|
109
|
+
return this._path;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The query string without its "?", empty when there is none, as uWS reports it. */
|
|
113
|
+
getQuery() {
|
|
114
|
+
return this._query;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The method as it arrived on the wire. */
|
|
118
|
+
getCaseSensitiveMethod() {
|
|
119
|
+
return this._req.method || "GET";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The method lowercased, which is the other spelling uWS offers. */
|
|
123
|
+
getMethod() {
|
|
124
|
+
return (this._req.method || "GET").toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Every header, lowercased, in the order they arrived. rawHeaders and not headers, because the
|
|
129
|
+
* object node builds has already joined the repeated ones together.
|
|
130
|
+
* @param {(key: string, value: string) => void} cb
|
|
131
|
+
*/
|
|
132
|
+
forEach(cb) {
|
|
133
|
+
const raw = this._req.rawHeaders;
|
|
134
|
+
for (let i = 0; i < raw.length; i += 2) {
|
|
135
|
+
cb(raw[i].toLowerCase(), raw[i + 1]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @param {string} name */
|
|
140
|
+
getHeader(name) {
|
|
141
|
+
const value = this._req.headers[name];
|
|
142
|
+
if (value === undefined) {
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
return Array.isArray(value) ? value.join(", ") : value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* There are no natively registered routes on this path, so nothing ever asks. Answering the
|
|
150
|
+
* empty string rather than throwing keeps a stray caller from taking the app down.
|
|
151
|
+
*/
|
|
152
|
+
getParameter() {
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What uWS calls an HttpResponse, over node's ServerResponse.
|
|
159
|
+
*
|
|
160
|
+
* The status and the headers are held until node writes the head on its own, which it does when the
|
|
161
|
+
* first byte of body goes out. That is why writeStatus only remembers: sending it here would send
|
|
162
|
+
* the headers too, before the ones still to come had been set.
|
|
163
|
+
*/
|
|
164
|
+
class NodeHttpResponse {
|
|
165
|
+
/**
|
|
166
|
+
* @param {import("http").IncomingMessage} req
|
|
167
|
+
* @param {import("http").ServerResponse} res
|
|
168
|
+
*/
|
|
169
|
+
constructor(req, res) {
|
|
170
|
+
this._nodeReq = req;
|
|
171
|
+
this._nodeRes = res;
|
|
172
|
+
// how much of the body has gone out, which is what uWS reports through getWriteOffset and
|
|
173
|
+
// hands back to an onWritable callback
|
|
174
|
+
this._offset = 0;
|
|
175
|
+
this._onWritable = null;
|
|
176
|
+
this._aborted = false;
|
|
177
|
+
|
|
178
|
+
res.on("drain", () => {
|
|
179
|
+
const handler = this._onWritable;
|
|
180
|
+
if (handler) {
|
|
181
|
+
this._onWritable = null;
|
|
182
|
+
handler(this._offset);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* uWS batches everything written inside this into one syscall. node has no equivalent that
|
|
189
|
+
* means the same thing, and its own cork would hold the write until the callback returned
|
|
190
|
+
* without changing what is sent, so this only runs it.
|
|
191
|
+
*/
|
|
192
|
+
cork(cb) {
|
|
193
|
+
cb();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @param {string} status "200 OK", as uWS takes it */
|
|
197
|
+
writeStatus(status) {
|
|
198
|
+
const space = status.indexOf(" ");
|
|
199
|
+
this._nodeRes.statusCode = parseInt(space === -1 ? status : status.slice(0, space), 10);
|
|
200
|
+
if (space !== -1) {
|
|
201
|
+
this._nodeRes.statusMessage = status.slice(space + 1);
|
|
202
|
+
}
|
|
203
|
+
return this;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* uWS writes a header line per call and never replaces one, which is what the code above this
|
|
208
|
+
* is written against: two calls for Set-Cookie are two cookies. setHeader would have kept only
|
|
209
|
+
* the last, and did, until supertest started serving applications through node's own server.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} key
|
|
212
|
+
* @param {string|number} value
|
|
213
|
+
*/
|
|
214
|
+
writeHeader(key, value) {
|
|
215
|
+
if (!this._nodeRes.headersSent) {
|
|
216
|
+
this._nodeRes.appendHeader(key, String(value));
|
|
217
|
+
}
|
|
218
|
+
return this;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @param {ArrayBuffer|Buffer|string} chunk @returns {boolean} false when the socket is full */
|
|
222
|
+
write(chunk) {
|
|
223
|
+
const buffer = Buffer.from(toArrayBuffer(chunk));
|
|
224
|
+
this._offset += buffer.length;
|
|
225
|
+
return this._nodeRes.write(buffer);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** @param {ArrayBuffer|Buffer|string} [body] */
|
|
229
|
+
end(body) {
|
|
230
|
+
if (this._aborted) {
|
|
231
|
+
return this;
|
|
232
|
+
}
|
|
233
|
+
if (body === undefined || body === null || body === "") {
|
|
234
|
+
this._nodeRes.end();
|
|
235
|
+
return this;
|
|
236
|
+
}
|
|
237
|
+
const buffer = Buffer.from(toArrayBuffer(body));
|
|
238
|
+
this._offset += buffer.length;
|
|
239
|
+
this._nodeRes.end(buffer);
|
|
240
|
+
return this;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A response with a length but no body of its own: a HEAD, or a status that carries none. uWS
|
|
245
|
+
* takes the length here rather than as a header, so it is written as one on the way through.
|
|
246
|
+
*
|
|
247
|
+
* @param {string|number} [length]
|
|
248
|
+
*/
|
|
249
|
+
endWithoutBody(length) {
|
|
250
|
+
if (this._aborted) {
|
|
251
|
+
return this;
|
|
252
|
+
}
|
|
253
|
+
if (length !== undefined && !this._nodeRes.headersSent) {
|
|
254
|
+
this._nodeRes.setHeader("Content-Length", String(length));
|
|
255
|
+
}
|
|
256
|
+
this._nodeRes.end();
|
|
257
|
+
return this;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Writes a chunk of a response whose total length is known. Answers uWS's pair: whether the
|
|
262
|
+
* write got through, and whether that was the last of it.
|
|
263
|
+
*
|
|
264
|
+
* @param {ArrayBuffer|Buffer} chunk
|
|
265
|
+
* @param {number} totalSize
|
|
266
|
+
* @returns {[boolean, boolean]}
|
|
267
|
+
*/
|
|
268
|
+
tryEnd(chunk, totalSize) {
|
|
269
|
+
if (this._aborted) {
|
|
270
|
+
return [false, true];
|
|
271
|
+
}
|
|
272
|
+
if (!this._nodeRes.headersSent && !this._nodeRes.hasHeader("Content-Length")) {
|
|
273
|
+
this._nodeRes.setHeader("Content-Length", String(totalSize));
|
|
274
|
+
}
|
|
275
|
+
const buffer = Buffer.from(toArrayBuffer(chunk));
|
|
276
|
+
const ok = this._nodeRes.write(buffer);
|
|
277
|
+
this._offset += buffer.length;
|
|
278
|
+
const done = this._offset >= totalSize;
|
|
279
|
+
if (done) {
|
|
280
|
+
this._nodeRes.end();
|
|
281
|
+
}
|
|
282
|
+
return [ok, done];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** How many bytes of the body have gone out. */
|
|
286
|
+
getWriteOffset() {
|
|
287
|
+
return this._offset;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Called when there is room to write again. uWS asks the handler to answer whether it managed
|
|
292
|
+
* to write everything, and calls it again if not; node's drain says nothing, so the handler is
|
|
293
|
+
* kept until the next drain and its answer ignored.
|
|
294
|
+
*/
|
|
295
|
+
onWritable(handler) {
|
|
296
|
+
this._onWritable = handler;
|
|
297
|
+
return this;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Called when the connection goes before the response is finished. */
|
|
301
|
+
onAborted(handler) {
|
|
302
|
+
this._nodeRes.on("close", () => {
|
|
303
|
+
if (!this._nodeRes.writableFinished) {
|
|
304
|
+
this._aborted = true;
|
|
305
|
+
handler();
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
return this;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The body, in the chunks node hands over, as the ArrayBuffer and last-chunk flag uWS delivers.
|
|
313
|
+
* @param {(chunk: ArrayBuffer, isLast: boolean) => void} handler
|
|
314
|
+
*/
|
|
315
|
+
onData(handler) {
|
|
316
|
+
const req = this._nodeReq;
|
|
317
|
+
let pending = null;
|
|
318
|
+
req.on("data", (chunk) => {
|
|
319
|
+
if (pending !== null) {
|
|
320
|
+
handler(pending, false);
|
|
321
|
+
}
|
|
322
|
+
pending = toArrayBuffer(chunk);
|
|
323
|
+
});
|
|
324
|
+
req.on("end", () => {
|
|
325
|
+
// uWS marks the last chunk rather than announcing the end separately, so the one in
|
|
326
|
+
// hand is held back until there is nothing after it
|
|
327
|
+
handler(pending ?? new ArrayBuffer(0), true);
|
|
328
|
+
pending = null;
|
|
329
|
+
});
|
|
330
|
+
return this;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Stops reading the body, which is how backpressure reaches the client. */
|
|
334
|
+
pause() {
|
|
335
|
+
this._nodeReq.pause();
|
|
336
|
+
return this;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Starts reading the body again. */
|
|
340
|
+
resume() {
|
|
341
|
+
this._nodeReq.resume();
|
|
342
|
+
return this;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Drops the connection without finishing a response. */
|
|
346
|
+
close() {
|
|
347
|
+
this._aborted = true;
|
|
348
|
+
this._nodeRes.destroy();
|
|
349
|
+
return this;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The client address as the bytes uWS hands over, which is what req.ip parses. */
|
|
353
|
+
getRemoteAddress() {
|
|
354
|
+
return addressToBytes(this._nodeReq.socket?.remoteAddress);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The client address as text, which uWS also offers. */
|
|
358
|
+
getRemoteAddressAsText() {
|
|
359
|
+
return Buffer.from(this._nodeReq.socket?.remoteAddress || "");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The client port, or 0 when the socket has already gone. */
|
|
363
|
+
getRemotePort() {
|
|
364
|
+
return this._nodeReq.socket?.remotePort ?? 0;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Whether these are node's own request and response rather than this project's.
|
|
370
|
+
* @param {any} req
|
|
371
|
+
*/
|
|
372
|
+
function isNodeRequest(req) {
|
|
373
|
+
return req instanceof IncomingMessage;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Serves a request that arrived through node's HTTP server with the given router or app.
|
|
378
|
+
*
|
|
379
|
+
* @param {any} router
|
|
380
|
+
* @param {import("http").IncomingMessage} nodeReq
|
|
381
|
+
* @param {import("http").ServerResponse} nodeRes
|
|
382
|
+
* @param {(err?: any) => void} [next] called when nothing in the router answered
|
|
383
|
+
*/
|
|
384
|
+
function serveNodeRequest(router, nodeReq, nodeRes, next) {
|
|
385
|
+
const shimRes = new NodeHttpResponse(nodeReq, nodeRes);
|
|
386
|
+
const shimReq = new NodeHttpRequest(nodeReq);
|
|
387
|
+
const { request, response } = router.handleRequest(shimRes, shimReq);
|
|
388
|
+
|
|
389
|
+
return router._routeRequest(request, response).then((matched) => {
|
|
390
|
+
if (matched || response.headersSent || response.aborted) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (next) {
|
|
394
|
+
return next(request._error);
|
|
395
|
+
}
|
|
396
|
+
router._endUnmatched(request, response);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
module.exports = { NodeHttpRequest, NodeHttpResponse, isNodeRequest, serveNodeRequest, addressToBytes };
|