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,561 @@
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
+ // H3App, DeclarativeResponse and _cfg all exist at runtime but are missing from the
19
+ // declaration file the package ships, so the module is read through a loose alias
20
+ const uWS = require("uWebSockets.js");
21
+ const uWSAny = /** @type {any} */ (uWS);
22
+ const Router = require("./router.js");
23
+ const {
24
+ removeDuplicateSlashes,
25
+ defaultSettings,
26
+ compileTrust,
27
+ createETagGenerator,
28
+ fastQueryParse,
29
+ NullObject
30
+ } = require("./utils.js");
31
+ const querystring = require("fast-querystring");
32
+ const ViewClass = require("./view.js");
33
+ const path = require("path");
34
+ const os = require("os");
35
+ const { Worker } = require("worker_threads");
36
+ const cluster = require("cluster");
37
+
38
+ const cpuCount = os.cpus().length;
39
+
40
+ const workers = [];
41
+ let taskKey = 0;
42
+ const workerTasks = new NullObject();
43
+
44
+ class FSWorker {
45
+ /**
46
+ * A worker thread that does nothing but read files, so a read does not sit on the event loop.
47
+ * It is unref'd, so an idle one does not keep the process alive, and it is shared between every
48
+ * app in the process rather than started per app.
49
+ */
50
+ constructor() {
51
+ this.busy = false;
52
+ this.worker = new Worker(path.join(__dirname, "worker.js"));
53
+
54
+ this.worker.on("message", (message) => {
55
+ this.busy = false;
56
+ if (message.err) {
57
+ workerTasks[message.key].reject(new Error(message.err));
58
+ } else {
59
+ // worker transfers file contents as an ArrayBuffer; wrap it in a Buffer (zero-copy) so
60
+ // consumers get the same type as fs.readFile. A bare ArrayBuffer is rejected by wrapped
61
+ // res.end() implementations (e.g. express-session calls Buffer.byteLength on the chunk).
62
+ workerTasks[message.key].resolve(
63
+ message.data instanceof ArrayBuffer ? Buffer.from(message.data) : message.data
64
+ );
65
+ }
66
+ delete workerTasks[message.key];
67
+ });
68
+ this.worker.unref();
69
+
70
+ workers.push(this);
71
+ }
72
+ }
73
+
74
+ class Application extends Router {
75
+ /**
76
+ * @param {object} [settings] the options express() takes. uwsOptions goes to uWS and decides
77
+ * between an HTTP, an HTTPS and an HTTP/3 server; threads sizes the file-reading pool, and 0
78
+ * turns it off; uwsApp adopts an existing uWS app instead of making one. Everything else is
79
+ * an application setting and lands next to the defaults.
80
+ */
81
+ constructor(settings = new NullObject()) {
82
+ super(settings);
83
+ if (!settings?.uwsOptions) {
84
+ settings.uwsOptions = {};
85
+ }
86
+ if (typeof settings.threads !== "number") {
87
+ settings.threads = cpuCount > 1 ? 1 : 0;
88
+ }
89
+ if (settings.uwsApp) {
90
+ this.uwsApp = settings.uwsApp;
91
+ } else if (settings.http3) {
92
+ if (!settings.uwsOptions.key_file_name || !settings.uwsOptions.cert_file_name) {
93
+ throw new Error("uwsOptions.key_file_name and uwsOptions.cert_file_name are required for HTTP/3");
94
+ }
95
+ this.uwsApp = uWSAny.H3App(settings.uwsOptions);
96
+ } else if (settings.uwsOptions.key_file_name && settings.uwsOptions.cert_file_name) {
97
+ this.uwsApp = uWS.SSLApp(settings.uwsOptions);
98
+ } else {
99
+ this.uwsApp = uWS.App(settings.uwsOptions);
100
+ }
101
+ this.ssl = settings.uwsOptions.key_file_name && settings.uwsOptions.cert_file_name;
102
+ this.cache = new NullObject();
103
+ this.engines = { __proto__: null };
104
+ this.locals = {
105
+ settings: this.settings
106
+ };
107
+ this.listenCalled = false;
108
+ this.workers = [];
109
+ for (let i = 0; i < settings.threads; i++) {
110
+ if (workers[i]) {
111
+ this.workers[i] = workers[i];
112
+ } else {
113
+ this.workers[i] = new FSWorker();
114
+ }
115
+ }
116
+ this.port = undefined;
117
+ this.listening = false;
118
+ // the host handed to listen(), which is all address() has to go on
119
+ this._listenHost = undefined;
120
+ for (const key in defaultSettings) {
121
+ if (typeof this.settings[key] === "undefined") {
122
+ if (typeof defaultSettings[key] === "function") {
123
+ this.settings[key] = defaultSettings[key](this);
124
+ } else {
125
+ this.settings[key] = defaultSettings[key];
126
+ }
127
+ }
128
+ }
129
+ this.set("view", ViewClass);
130
+ this.set("views", path.resolve("views"));
131
+ }
132
+
133
+ /**
134
+ * Parks a promise's settle functions under a key the worker can send back, since a worker
135
+ * message carries data and not closures. The counter wraps rather than growing without bound,
136
+ * a million tasks being far more than can be outstanding at once.
137
+ *
138
+ * @param {(value: any) => void} resolve
139
+ * @param {(err: any) => void} reject
140
+ * @returns {number} the key to send to the worker
141
+ */
142
+ createWorkerTask(resolve, reject) {
143
+ const key = taskKey++;
144
+ workerTasks[key] = { resolve, reject };
145
+ if (key > 1000000) {
146
+ taskKey = 0;
147
+ }
148
+ return key;
149
+ }
150
+
151
+ /**
152
+ * Reads a file on one of the file threads, picked at random, rather than on the event loop.
153
+ * Only worth it below the size where the copy back costs more than the read, which is why
154
+ * res.sendFile uses it for small files and streams the rest.
155
+ *
156
+ * @param {string} path absolute path to read
157
+ * @returns {Promise<Buffer>}
158
+ */
159
+ readFileWithWorker(path) {
160
+ return new Promise((resolve, reject) => {
161
+ const worker = this.workers[Math.floor(Math.random() * this.workers.length)];
162
+ const key = this.createWorkerTask(resolve, reject);
163
+ worker.busy = true;
164
+ worker.worker.postMessage({ key, type: "readFile", path });
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Reads or writes an application setting. One argument is the getter, and the check is on
170
+ * `arguments.length`, so `set(key, undefined)` still writes. Some keys have a side effect:
171
+ * `trust proxy`, `query parser` and `etag` compile the value into a function kept beside it,
172
+ * `views` becomes an absolute path, and `env` set to "production" turns the view cache on.
173
+ *
174
+ * @param {string} key setting name
175
+ * @param {*} [value] value to store; omit to read instead
176
+ * @returns {*} the app, for chaining, or the value when reading
177
+ */
178
+ set(key, value) {
179
+ if (arguments.length === 1) {
180
+ return this.get(key);
181
+ }
182
+ if (key === "trust proxy") {
183
+ if (!value) {
184
+ delete this.settings["trust proxy fn"];
185
+ } else {
186
+ this.settings["trust proxy fn"] = compileTrust(value);
187
+ }
188
+ } else if (key === "query parser") {
189
+ if (value === "extended") {
190
+ this.settings["query parser fn"] = fastQueryParse;
191
+ } else if (value === "simple") {
192
+ this.settings["query parser fn"] = querystring.parse;
193
+ } else if (typeof value === "function") {
194
+ this.settings["query parser fn"] = value;
195
+ } else {
196
+ this.settings["query parser fn"] = undefined;
197
+ }
198
+ } else if (key === "env") {
199
+ if (value === "production") {
200
+ this.settings["view cache"] = true;
201
+ } else {
202
+ this.settings["view cache"] = undefined;
203
+ }
204
+ } else if (key === "views") {
205
+ this.settings[key] = path.resolve(value);
206
+ return this;
207
+ } else if (key === "etag") {
208
+ if (typeof value === "function") {
209
+ this.settings["etag fn"] = value;
210
+ } else {
211
+ switch (value) {
212
+ case true:
213
+ case "weak":
214
+ this.settings["etag fn"] = createETagGenerator({ weak: true });
215
+ break;
216
+ case "strong":
217
+ this.settings["etag fn"] = createETagGenerator({ weak: false });
218
+ break;
219
+ case false:
220
+ delete this.settings["etag fn"];
221
+ break;
222
+ default:
223
+ throw new Error(`Invalid etag mode: ${value}`);
224
+ }
225
+ }
226
+ }
227
+
228
+ this.settings[key] = value;
229
+ return this;
230
+ }
231
+
232
+ /**
233
+ * Sets a setting to true, side effects and all.
234
+ * @param {string} key setting name
235
+ * @returns {this} the app, for chaining
236
+ */
237
+ enable(key) {
238
+ this.set(key, true);
239
+ return this;
240
+ }
241
+
242
+ /**
243
+ * Sets a setting to false, side effects and all.
244
+ * @param {string} key setting name
245
+ * @returns {this} the app, for chaining
246
+ */
247
+ disable(key) {
248
+ this.set(key, false);
249
+ return this;
250
+ }
251
+
252
+ /**
253
+ * Whether a setting is truthy. Reads this app's own settings, without falling back to a
254
+ * parent app the way get() does.
255
+ * @param {string} key setting name
256
+ * @returns {boolean}
257
+ */
258
+ enabled(key) {
259
+ return !!this.settings[key];
260
+ }
261
+
262
+ /**
263
+ * Whether a setting is falsy.
264
+ * @param {string} key setting name
265
+ * @returns {boolean}
266
+ */
267
+ disabled(key) {
268
+ return !this.settings[key];
269
+ }
270
+
271
+ /**
272
+ * Registers the catch-all uWS handler, which is what serves every request that no optimized
273
+ * route took natively. It walks this app's own chain and, when nothing in it answered, decides
274
+ * between an error, the automatic OPTIONS reply and a 404.
275
+ */
276
+ _createRequestHandler() {
277
+ this.uwsApp.any("/*", async (res, req) => {
278
+ const { request, response } = this.handleRequest(res, req);
279
+
280
+ const matchedRoute = await this._routeRequest(request, response);
281
+ if (!matchedRoute && !response.headersSent && !response.aborted) {
282
+ this._endUnmatched(request, response);
283
+ }
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Binds the server and starts accepting requests.
289
+ *
290
+ * Returns the app and not an `http.Server`, since there is no node server underneath. The app
291
+ * carries `address()`, `close()`, `listening` and the 'listening' and 'close' events; anything
292
+ * needing a real server, socket.io being the usual case, wants `app.uwsApp`. The callback runs
293
+ * on the next tick with the bind error, if there was one. A path instead of a port is a unix
294
+ * socket.
295
+ *
296
+ * @param {number|string} [port] port, or a unix socket path; 0 picks a free port
297
+ * @param {string} [host] interface to bind; every interface when omitted
298
+ * @param {(err?: Error) => void} [callback] called once bound, or with the bind error
299
+ * @returns {this} the app, which doubles as the server handle
300
+ */
301
+ listen(port, host, callback) {
302
+ this._compileOptimizedRoutes();
303
+ this._createRequestHandler();
304
+ // support listen(callback)
305
+ if (!callback && typeof port === "function") {
306
+ callback = port;
307
+ port = 0;
308
+ }
309
+ // support listen(port, callback)
310
+ if (typeof host === "function") {
311
+ callback = host;
312
+ host = undefined;
313
+ }
314
+ // uWS runs this handler from inside its own listen(), so everything it hands back to the
315
+ // caller is deferred a tick. Express binds synchronously too but reports through events,
316
+ // and node emits both 'listening' and 'error' from a process.nextTick.
317
+ const onListen = (socket) => {
318
+ if (!socket) {
319
+ /** @type {NodeJS.ErrnoException} */
320
+ const err = new Error("listen EADDRINUSE: address already in use :::" + port);
321
+ err.code = "EADDRINUSE";
322
+ // Express 5 registers the listen callback on 'error' as well as on 'listening',
323
+ // so a failed bind arrives at the callback rather than being thrown past it
324
+ if (callback) {
325
+ return process.nextTick(() => callback.call(this, err));
326
+ }
327
+ // no callback means no 'error' listener either, and an EventEmitter carrying an
328
+ // unhandled error rethrows it from the tick that emitted it, not from listen()
329
+ return process.nextTick(() => {
330
+ throw err;
331
+ });
332
+ }
333
+ // the port is known synchronously, as it is in Express, so address() works as soon as
334
+ // listen() returns. The callback is not: running it here would run it before listen()
335
+ // had returned, and `const server = app.listen(p, () => server.address())` - the form
336
+ // the Express docs use - would die on the temporal dead zone.
337
+ this.port = uWS.us_socket_local_port(socket);
338
+ this.listening = true;
339
+ this._listenHost = host;
340
+ process.nextTick(() => {
341
+ // `this` is the app, which is what listen() returns here. Express binds it to the
342
+ // http.Server, which is what listen() returns there, so
343
+ // `function () { this.address() }` reads the same on both.
344
+ // The callback goes first: in Express it is registered as a 'listening' listener
345
+ // before the caller can add any of their own.
346
+ if (callback) callback.call(this);
347
+ this.emit("listening");
348
+ });
349
+ };
350
+ let fn = "listen";
351
+ const args = [];
352
+ // 1 = exclusive port, 0 = shared port
353
+ const uwsOptions = cluster.isPrimary ? 1 : 0;
354
+ if (typeof port !== "number") {
355
+ if (!isNaN(Number(port))) {
356
+ port = Number(port);
357
+ args.push(port, uwsOptions, onListen);
358
+ if (host) {
359
+ args.unshift(host);
360
+ }
361
+ } else {
362
+ fn = "listen_unix";
363
+ args.push(onListen, port);
364
+ }
365
+ } else {
366
+ args.push(port, uwsOptions, onListen);
367
+ if (host) {
368
+ args.unshift(host);
369
+ }
370
+ }
371
+ this.listenCalled = true;
372
+ this.uwsApp[fn](...args);
373
+ return this;
374
+ }
375
+
376
+ /**
377
+ * The bound address, or null when not listening.
378
+ * @returns {{address: string, family: string, port: number}|null}
379
+ */
380
+ address() {
381
+ if (!this.listening || !this.port) {
382
+ return null;
383
+ }
384
+ // uWS hands back the port and nothing else, so the address reported is the one we asked
385
+ // it to bind. No host means every interface, which node reports as "::". A hostname is
386
+ // reported as written, since what it resolved to is not readable back from here: node
387
+ // would say "::1" where this says "localhost".
388
+ const host = this._listenHost;
389
+ if (!host) {
390
+ return { address: "::", family: "IPv6", port: this.port };
391
+ }
392
+ return { address: host, family: host.includes(":") ? "IPv6" : "IPv4", port: this.port };
393
+ }
394
+
395
+ /**
396
+ * The full mount path of this app, walking up through every parent it is mounted on.
397
+ * A top level app returns the empty string rather than "/".
398
+ * @returns {string}
399
+ */
400
+ path() {
401
+ const paths = [this.mountpath];
402
+ let parent = this.parent;
403
+ while (parent) {
404
+ paths.unshift(parent.mountpath);
405
+ parent = parent.parent;
406
+ }
407
+ const path = removeDuplicateSlashes(paths.join(""));
408
+ return path === "/" ? "" : path;
409
+ }
410
+
411
+ /**
412
+ * Registers a template engine for a file extension.
413
+ *
414
+ * The leading dot is optional: "pug" and ".pug" register the same thing.
415
+ *
416
+ * @param {string} ext file extension the engine handles
417
+ * @param {(path: string, options: object, callback: (err: Error|null, rendered?: string) => void) => void} fn
418
+ * the engine, in the callback style consolidate-style engines use
419
+ * @returns {this} the app, for chaining
420
+ * @throws {Error} if fn is not a function
421
+ */
422
+ engine(ext, fn) {
423
+ if (typeof fn !== "function") {
424
+ throw new Error("callback function required");
425
+ }
426
+ const extension = ext[0] !== "." ? "." + ext : ext;
427
+ this.engines[extension] = fn;
428
+ return this;
429
+ }
430
+
431
+ /**
432
+ * Renders a view and hands the result to the callback, without sending anything.
433
+ * `res.render()` is the one that responds.
434
+ *
435
+ * `app.locals` and `options._locals` are merged into the options, in that order, so a
436
+ * per-request local wins over an application-wide one. Caching follows the "view cache"
437
+ * setting unless `options.cache` says otherwise.
438
+ *
439
+ * A function in the options position is taken as the callback.
440
+ *
441
+ * @param {string} name view name, resolved against the "views" setting
442
+ * @param {Record<string, any>} [options] locals for the view
443
+ * @param {(err: Error|null, html?: string) => void} [callback] receives the rendered view. It
444
+ * is what render is for, so leaving it out throws, as it does in Express
445
+ */
446
+ render(name, options, callback) {
447
+ if (typeof options === "function") {
448
+ callback = /** @type {any} */ (options);
449
+ options = new NullObject();
450
+ }
451
+ // render exists to hand the result somewhere, so there is always a callback by this point:
452
+ // either the third argument or the second one, shuffled above
453
+ const done = /** @type {(err: Error|null, html?: string) => void} */ (callback);
454
+ if (!options) {
455
+ options = new NullObject();
456
+ } else {
457
+ options = Object.assign({}, options);
458
+ }
459
+ for (const key in this.locals) {
460
+ options[key] = this.locals[key];
461
+ }
462
+
463
+ if (options._locals) {
464
+ for (const key in options._locals) {
465
+ options[key] = options._locals[key];
466
+ }
467
+ }
468
+
469
+ if (options.cache == null) {
470
+ options.cache = this.enabled("view cache");
471
+ }
472
+
473
+ let view;
474
+ if (options.cache) {
475
+ view = this.cache[name];
476
+ }
477
+
478
+ if (!view) {
479
+ const View = this.get("view");
480
+ view = new View(name, {
481
+ defaultEngine: this.get("view engine"),
482
+ root: this.get("views"),
483
+ engines: { ...this.engines }
484
+ });
485
+ if (!view.path) {
486
+ const dirs =
487
+ Array.isArray(view.root) && view.root.length > 1
488
+ ? 'directories "' +
489
+ view.root.slice(0, -1).join('", "') +
490
+ '" or "' +
491
+ view.root[view.root.length - 1] +
492
+ '"'
493
+ : 'directory "' + view.root + '"';
494
+
495
+ /** @type {Error & { view?: unknown }} */
496
+ const err = new Error(`Failed to lookup view "${name}" in views ${dirs}`);
497
+ err.view = view;
498
+ return done(err);
499
+ }
500
+
501
+ if (options.cache) {
502
+ this.cache[name] = view;
503
+ }
504
+ }
505
+
506
+ try {
507
+ view.render(options, done);
508
+ } catch (err) {
509
+ done(/** @type {Error} */ (err));
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Stops listening and emits 'close'.
515
+ *
516
+ * The callback is the first 'close' listener, so it runs before any added afterwards. Closing
517
+ * a server that was not listening still calls back, with an ERR_SERVER_NOT_RUNNING error, the
518
+ * way node does.
519
+ *
520
+ * @param {(err?: Error) => void} [callback] called once closed
521
+ * @returns {this} the app, for chaining
522
+ */
523
+ close(callback) {
524
+ const wasListening = this.listening;
525
+ if (this.listenCalled && wasListening) {
526
+ this.uwsApp.close();
527
+ }
528
+ this.listening = false;
529
+ // in Express the close callback is nothing more than the first 'close' listener, and a
530
+ // server that was not running still gets called back, with an error
531
+ if (callback) {
532
+ this.once("close", () => {
533
+ if (wasListening) {
534
+ return callback();
535
+ }
536
+ /** @type {NodeJS.ErrnoException} */
537
+ const err = new Error("Server is not running.");
538
+ err.code = "ERR_SERVER_NOT_RUNNING";
539
+ callback(err);
540
+ });
541
+ }
542
+ process.nextTick(() => this.emit("close"));
543
+ return this;
544
+ }
545
+ }
546
+
547
+ // An app is a function, as it is in Express, and not the Application instance whose properties it
548
+ // carries. Middleware that takes a whole app and calls it, vhost being the one everybody meets, was
549
+ // given something it could not call.
550
+ //
551
+ // This was tried once before and reverted the same day, because a callable app broke supertest:
552
+ // `request(app)` reads `typeof app === "function"` and wraps whatever it finds in
553
+ // http.createServer, and there was nothing underneath that could serve node's IncomingMessage, so
554
+ // every call timed out. src/node-shim.js is what closes that hole, and it is why this is safe now.
555
+ module.exports = function (options) {
556
+ return new Application(options)._asCallable();
557
+ };
558
+
559
+ // the class itself, so index.js can expose its prototype as express.application does. Adding a
560
+ // method to that prototype adds it to every app, which is what the property is for.
561
+ module.exports.Application = Application;