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.
@@ -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 };