fulmine.js 5.19.8 → 5.20.0
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/README.md +115 -985
- package/package.json +16 -4
- package/src/adopt.js +116 -5
- package/src/application.js +8 -4
- package/src/cli.js +26 -3
- package/src/compression.js +1 -0
- package/src/create.js +248 -0
- package/src/declarative.js +15 -3
- package/src/hot-settings.js +7 -0
- package/src/index.js +23 -0
- package/src/middlewares.js +6 -2
- package/src/node-shim.js +34 -10
- package/src/optimizer.js +24 -3
- package/src/request-utils.js +3 -2
- package/src/request.js +8 -15
- package/src/response-utils.js +1 -1
- package/src/response.js +15 -1
- package/src/route.js +12 -6
- package/src/router-utils.js +11 -2
- package/src/router.js +27 -4
- package/src/testing.js +1 -1
- package/src/utils.js +28 -9
- package/src/verify.js +108 -7
- package/src/walk.js +8 -3
- package/src/websocket.js +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fulmine.js",
|
|
3
|
-
"version": "5.
|
|
4
|
-
"description": "Drop-in Express 5 replacement on uWebSockets.js.
|
|
3
|
+
"version": "5.20.0",
|
|
4
|
+
"description": "Drop-in Express 5 replacement on uWebSockets.js, up to 20x faster. Same API, your middleware and framework keep working.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -64,16 +64,28 @@
|
|
|
64
64
|
"keywords": [
|
|
65
65
|
"express",
|
|
66
66
|
"express5",
|
|
67
|
+
"express-alternative",
|
|
68
|
+
"drop-in replacement",
|
|
67
69
|
"fast",
|
|
70
|
+
"fastest",
|
|
71
|
+
"performance",
|
|
72
|
+
"web framework",
|
|
68
73
|
"http",
|
|
69
74
|
"http server",
|
|
70
75
|
"https",
|
|
71
76
|
"https server",
|
|
77
|
+
"router",
|
|
78
|
+
"middleware",
|
|
79
|
+
"rest api",
|
|
72
80
|
"uwebsockets",
|
|
73
81
|
"uws",
|
|
74
82
|
"websocket",
|
|
75
83
|
"websockets",
|
|
76
|
-
"
|
|
84
|
+
"nestjs",
|
|
85
|
+
"angular ssr",
|
|
86
|
+
"nextjs",
|
|
87
|
+
"cluster",
|
|
88
|
+
"typescript"
|
|
77
89
|
],
|
|
78
90
|
"types": "src/types.d.ts",
|
|
79
91
|
"author": "Nigro Simone",
|
|
@@ -81,7 +93,7 @@
|
|
|
81
93
|
"bugs": {
|
|
82
94
|
"url": "https://github.com/nigrosimone/fulmine.js/issues"
|
|
83
95
|
},
|
|
84
|
-
"homepage": "https://
|
|
96
|
+
"homepage": "https://fulmine.sndesign.it",
|
|
85
97
|
"dependencies": {
|
|
86
98
|
"@types/express": "^5.0.6",
|
|
87
99
|
"accepts": "^2.0.0",
|
package/src/adopt.js
CHANGED
|
@@ -14,17 +14,20 @@ See the License for the specific language governing permissions and
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
// The
|
|
18
|
-
// `npx fulmine.js angular`.
|
|
17
|
+
// The commands that edit a config file rather than source: `npx fulmine.js override`,
|
|
18
|
+
// `npx fulmine.js angular` and `npx fulmine.js pnpm`.
|
|
19
19
|
//
|
|
20
|
-
// `migrate` rewrites `require("express")` in your own files. The
|
|
21
|
-
//
|
|
20
|
+
// `migrate` rewrites `require("express")` in your own files. The cases it cannot reach are each
|
|
21
|
+
// a line in a config file:
|
|
22
22
|
//
|
|
23
23
|
// override A framework built on Express requires it in its own code, so there is no specifier
|
|
24
24
|
// to rewrite. Every package manager can answer `express` with this package instead,
|
|
25
25
|
// and each one spells it differently.
|
|
26
26
|
// angular An Angular server bundle is built with esbuild, which inlines every dependency and
|
|
27
27
|
// cannot load uWS's native binary. Two names in `externalDependencies` fix it.
|
|
28
|
+
// pnpm pnpm 10.26 and later refuse a git dependency of a dependency, and µWebSockets.js
|
|
29
|
+
// is one. A direct dependency is allowed, so the project takes it on itself and an
|
|
30
|
+
// override drops the copy this package asks for.
|
|
28
31
|
|
|
29
32
|
"use strict";
|
|
30
33
|
|
|
@@ -37,6 +40,12 @@ const REPLACES = "express";
|
|
|
37
40
|
/** The major this package tracks, which is the range an override should ask for. */
|
|
38
41
|
const MAJOR = require("../package.json").version.split(".")[0];
|
|
39
42
|
|
|
43
|
+
const UWS = "uWebSockets.js";
|
|
44
|
+
/** The git spec this package pins, which is what a project taking uWS on itself has to pin too. */
|
|
45
|
+
const UWS_SPEC = /** @type {string} */ (require("../package.json").dependencies[UWS]);
|
|
46
|
+
/** The pnpm override that drops this package's own copy, so the project's direct one is the only one. */
|
|
47
|
+
const UWS_OVERRIDE = `${SELF}>${UWS}`;
|
|
48
|
+
|
|
40
49
|
/** Where each manager keeps its substitutions, and what to call it when telling someone. */
|
|
41
50
|
const MANAGERS = {
|
|
42
51
|
npm: { keys: ["overrides"], reinstall: "npm install" },
|
|
@@ -315,4 +324,106 @@ function angular(argv) {
|
|
|
315
324
|
return 0;
|
|
316
325
|
}
|
|
317
326
|
|
|
318
|
-
|
|
327
|
+
/**
|
|
328
|
+
* The pnpm-workspace.yaml with the override in it. Written by hand rather than through a YAML
|
|
329
|
+
* library: the file is small, the block is two lines, and what is already there is kept as is.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} source the file as it is, or "" when there is none
|
|
332
|
+
* @returns {string|undefined} the new file, or nothing when the override is already there
|
|
333
|
+
*/
|
|
334
|
+
function withPnpmOverride(source) {
|
|
335
|
+
const line = ` "${UWS_OVERRIDE}": "-"`;
|
|
336
|
+
if (
|
|
337
|
+
new RegExp(
|
|
338
|
+
`^\\s*["']?${SELF.replace(".", "\\.")}>${UWS.replace(".", "\\.")}["']?:\\s*["']?-["']?\\s*$`,
|
|
339
|
+
"m"
|
|
340
|
+
).test(source)
|
|
341
|
+
) {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
const lines = source.length ? source.replace(/\r\n/g, "\n").replace(/\n*$/, "").split("\n") : [];
|
|
345
|
+
const at = lines.findIndex((one) => /^overrides:\s*$/.test(one));
|
|
346
|
+
if (at === -1) {
|
|
347
|
+
return [...lines, ...(lines.length ? [""] : []), "overrides:", line, ""].join("\n");
|
|
348
|
+
}
|
|
349
|
+
lines.splice(at + 1, 0, line);
|
|
350
|
+
return lines.join("\n") + "\n";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* npx fulmine.js pnpm [dir] [--dry-run]
|
|
355
|
+
*
|
|
356
|
+
* Makes a pnpm project install this package, which pnpm 10.26 and later otherwise refuse: the
|
|
357
|
+
* project takes µWebSockets.js as a direct dependency, at the spec this package pins, and an
|
|
358
|
+
* override in pnpm-workspace.yaml drops the copy this package asks for. pnpm 11 reads its
|
|
359
|
+
* settings from that file only, so package.json's `pnpm` field is not where this goes.
|
|
360
|
+
*
|
|
361
|
+
* @param {string[]} argv everything after the command name
|
|
362
|
+
* @returns {number} exit code
|
|
363
|
+
*/
|
|
364
|
+
function pnpm(argv) {
|
|
365
|
+
const dryRun = argv.includes("--dry-run");
|
|
366
|
+
const dir = path.resolve(argv.find((arg) => !arg.startsWith("--")) ?? ".");
|
|
367
|
+
const file = path.join(dir, "package.json");
|
|
368
|
+
const workspaceFile = path.join(dir, "pnpm-workspace.yaml");
|
|
369
|
+
|
|
370
|
+
const read = readJson(file);
|
|
371
|
+
if ("error" in read) {
|
|
372
|
+
console.error(read.code === "ENOENT" ? `no package.json in ${dir}` : read.error);
|
|
373
|
+
return 1;
|
|
374
|
+
}
|
|
375
|
+
const { data: pkg, source } = read;
|
|
376
|
+
|
|
377
|
+
let changed = 0;
|
|
378
|
+
const existing = readPath(pkg, ["dependencies", UWS]);
|
|
379
|
+
if (existing === UWS_SPEC) {
|
|
380
|
+
console.log(`dependencies.${UWS} already says ${UWS_SPEC}`);
|
|
381
|
+
} else {
|
|
382
|
+
if (existing !== undefined) {
|
|
383
|
+
console.log(`dependencies.${UWS} says ${JSON.stringify(existing)}, and this package pins ${UWS_SPEC}:`);
|
|
384
|
+
console.log("the pin is what the code here was tested against, so it is the one written.");
|
|
385
|
+
}
|
|
386
|
+
writePath(pkg, ["dependencies", UWS], UWS_SPEC);
|
|
387
|
+
changed++;
|
|
388
|
+
console.log(`${dryRun ? "would add" : "added"} to package.json: "dependencies": { "${UWS}": "${UWS_SPEC}" }`);
|
|
389
|
+
if (!dryRun) fs.writeFileSync(file, JSON.stringify(pkg, null, indentOf(source)) + "\n");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let workspace = "";
|
|
393
|
+
try {
|
|
394
|
+
workspace = fs.readFileSync(workspaceFile, "utf8");
|
|
395
|
+
} catch {
|
|
396
|
+
// no workspace file yet, it is written below
|
|
397
|
+
}
|
|
398
|
+
const rewritten = withPnpmOverride(workspace);
|
|
399
|
+
if (rewritten === undefined) {
|
|
400
|
+
console.log(`pnpm-workspace.yaml already overrides ${UWS_OVERRIDE}`);
|
|
401
|
+
} else {
|
|
402
|
+
changed++;
|
|
403
|
+
console.log(`${dryRun ? "would add" : "added"} to pnpm-workspace.yaml: overrides: { "${UWS_OVERRIDE}": "-" }`);
|
|
404
|
+
if (!dryRun) fs.writeFileSync(workspaceFile, rewritten);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!changed) {
|
|
408
|
+
console.log("\nNothing to change.");
|
|
409
|
+
return 0;
|
|
410
|
+
}
|
|
411
|
+
console.log(`\nThen \`pnpm install\`. µWebSockets.js is now the project's own dependency, so it is fetched as a`);
|
|
412
|
+
console.log("direct one, which pnpm allows, and the copy this package asks for is dropped from the graph.");
|
|
413
|
+
console.log(
|
|
414
|
+
`When this package moves its pin, run this again: \`npx fulmine.js verify\` says when the two differ.\n`
|
|
415
|
+
);
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
module.exports = {
|
|
420
|
+
override,
|
|
421
|
+
angular,
|
|
422
|
+
pnpm,
|
|
423
|
+
detectManager,
|
|
424
|
+
serverBuilds,
|
|
425
|
+
indentOf,
|
|
426
|
+
withPnpmOverride,
|
|
427
|
+
UWS_SPEC,
|
|
428
|
+
UWS_OVERRIDE
|
|
429
|
+
};
|
package/src/application.js
CHANGED
|
@@ -47,7 +47,7 @@ const cpuCount = os.cpus().length;
|
|
|
47
47
|
// mounted sub-app knows it may inherit the parent's
|
|
48
48
|
const trustProxyDefaultSymbol = "@@symbol:trust_proxy_default";
|
|
49
49
|
|
|
50
|
-
const workers = [];
|
|
50
|
+
const workers = /** @type {FSWorker[]} */ ([]);
|
|
51
51
|
let taskKey = 0;
|
|
52
52
|
const workerTasks = new NullObject();
|
|
53
53
|
|
|
@@ -62,6 +62,9 @@ class FSWorker {
|
|
|
62
62
|
this.worker = new Worker(path.join(__dirname, "worker.js"));
|
|
63
63
|
|
|
64
64
|
this.worker.on("message", (message) => {
|
|
65
|
+
// node speaks on this channel too: under --watch a worker reports the files it loaded
|
|
66
|
+
// as {"watch:import": [...]}, which carries no key of ours
|
|
67
|
+
if (workerTasks[message.key] === undefined) return;
|
|
65
68
|
this.busy = false;
|
|
66
69
|
if (message.err) {
|
|
67
70
|
workerTasks[message.key].reject(new Error(message.err));
|
|
@@ -133,7 +136,7 @@ class Application extends Router {
|
|
|
133
136
|
becomeSupervisor();
|
|
134
137
|
}
|
|
135
138
|
if (settings.uwsApp) {
|
|
136
|
-
this.uwsApp = settings.uwsApp;
|
|
139
|
+
this.uwsApp = /** @type {import("uWebSockets.js").TemplatedApp} */ (settings.uwsApp);
|
|
137
140
|
} else if (settings.http3) {
|
|
138
141
|
// uWS.H3App exists in the pinned build but its QUIC stack does not: the constructor
|
|
139
142
|
// segfaults on Linux and hangs forever on Windows before serving a single request,
|
|
@@ -401,7 +404,7 @@ class Application extends Router {
|
|
|
401
404
|
if (value != null && (!Array.isArray(value) || value.some((m) => typeof m !== "string"))) {
|
|
402
405
|
throw new TypeError('"etag methods" wants an array of method names, or null for all of them');
|
|
403
406
|
}
|
|
404
|
-
value = value == null ? undefined : value.map((m) => m.toUpperCase());
|
|
407
|
+
value = value == null ? undefined : value.map((/** @type {string} */ m) => m.toUpperCase());
|
|
405
408
|
} else if (key === "etag") {
|
|
406
409
|
// The skips are not taken back here. They used to be, because send consults freshness,
|
|
407
410
|
// but that branch reads if-none-match, if-modified-since and cache-control by name
|
|
@@ -594,7 +597,7 @@ class Application extends Router {
|
|
|
594
597
|
// uWS runs this handler from inside its own listen(), so everything it hands back to the
|
|
595
598
|
// caller is deferred a tick. Express binds synchronously too but reports through events,
|
|
596
599
|
// and node emits both 'listening' and 'error' from a process.nextTick.
|
|
597
|
-
const onListen = (socket) => {
|
|
600
|
+
const onListen = (/** @type {import("uWebSockets.js").us_listen_socket|false} */ socket) => {
|
|
598
601
|
if (!socket) {
|
|
599
602
|
/** @type {NodeJS.ErrnoException} */
|
|
600
603
|
const err = new Error("listen EADDRINUSE: address already in use :::" + port);
|
|
@@ -926,6 +929,7 @@ class Application extends Router {
|
|
|
926
929
|
// Tried once before and reverted the same day, because a callable app broke supertest: `request(app)`
|
|
927
930
|
// reads `typeof app === "function"` and wraps what it finds in http.createServer, and there was
|
|
928
931
|
// nothing underneath that could serve node's IncomingMessage. src/node-shim.js closes that hole.
|
|
932
|
+
/** @param {object} [options] the settings express() takes, see the Application constructor */
|
|
929
933
|
module.exports = function (options) {
|
|
930
934
|
return new Application(options)._asCallable();
|
|
931
935
|
};
|
package/src/cli.js
CHANGED
|
@@ -37,6 +37,15 @@ limitations under the License.
|
|
|
37
37
|
// The two things a project needs that are a line in a JSON file rather than a specifier in a
|
|
38
38
|
// source file: the package manager substitution, for a framework that requires express in its own
|
|
39
39
|
// code, and angular.json's externalDependencies. See src/adopt.js.
|
|
40
|
+
//
|
|
41
|
+
// npx fulmine create <dir>
|
|
42
|
+
//
|
|
43
|
+
// A new project for whoever has nothing to migrate: a server, a package.json and the Dockerfile
|
|
44
|
+
// that works. See src/create.js.
|
|
45
|
+
//
|
|
46
|
+
// npx fulmine pnpm [dir]
|
|
47
|
+
//
|
|
48
|
+
// The two lines a pnpm project needs before it will install this at all. See src/adopt.js.
|
|
40
49
|
|
|
41
50
|
const fs = require("fs");
|
|
42
51
|
const path = require("path");
|
|
@@ -44,7 +53,8 @@ const acorn = require("acorn");
|
|
|
44
53
|
// the same walk express.testing asserts on, so the command and the assertions cannot drift
|
|
45
54
|
const { collectRoutes } = require("./testing.js");
|
|
46
55
|
const { verify } = require("./verify.js");
|
|
47
|
-
const { override, angular } = require("./adopt.js");
|
|
56
|
+
const { override, angular, pnpm } = require("./adopt.js");
|
|
57
|
+
const { create } = require("./create.js");
|
|
48
58
|
|
|
49
59
|
/** @typedef {import("./application.js").Application} Application */
|
|
50
60
|
/** @typedef {import("./router-utils.js").RouteEntry} RouteEntry */
|
|
@@ -214,6 +224,7 @@ function findSpecifiersTypeScript(source, fileName, ts, seen) {
|
|
|
214
224
|
}
|
|
215
225
|
};
|
|
216
226
|
|
|
227
|
+
/** @param {import("typescript").Node} node */
|
|
217
228
|
const visit = (node) => {
|
|
218
229
|
// import express from "express", import type { Request } from "express", export * from it.
|
|
219
230
|
// A type-only import is rewritten too: the types come from the new package as well.
|
|
@@ -465,7 +476,7 @@ function findEntry(given) {
|
|
|
465
476
|
* owns listen.
|
|
466
477
|
*
|
|
467
478
|
* @param {string} entry
|
|
468
|
-
* @returns {
|
|
479
|
+
* @returns {Application[]} the prototypes to stub, this command's copy first
|
|
469
480
|
*/
|
|
470
481
|
function listenOwners(entry) {
|
|
471
482
|
const builds = new Set([require("./index.js")]);
|
|
@@ -477,6 +488,7 @@ function listenOwners(entry) {
|
|
|
477
488
|
}
|
|
478
489
|
}
|
|
479
490
|
|
|
491
|
+
/** @type {Application[]} */
|
|
480
492
|
const owners = [];
|
|
481
493
|
for (const build of builds) {
|
|
482
494
|
if (typeof build !== "function") {
|
|
@@ -531,6 +543,7 @@ function loadApps(argv, command) {
|
|
|
531
543
|
return null;
|
|
532
544
|
}
|
|
533
545
|
|
|
546
|
+
/** @type {Application[]} */
|
|
534
547
|
const listened = [];
|
|
535
548
|
const real = owners.map((proto) => proto.listen);
|
|
536
549
|
for (const proto of owners) {
|
|
@@ -907,13 +920,23 @@ function main(argv) {
|
|
|
907
920
|
if (command === "angular") {
|
|
908
921
|
return angular(argv.slice(1));
|
|
909
922
|
}
|
|
923
|
+
if (command === "create") {
|
|
924
|
+
return create(argv.slice(1));
|
|
925
|
+
}
|
|
926
|
+
if (command === "pnpm") {
|
|
927
|
+
return pnpm(argv.slice(1));
|
|
928
|
+
}
|
|
910
929
|
if (command !== "migrate") {
|
|
911
930
|
console.log(`Usage:
|
|
931
|
+
npx ${TO} create <dir> start a new project: a server, a package.json and a Dockerfile that
|
|
932
|
+
works, --ts for TypeScript, --pnpm for the two lines pnpm needs
|
|
912
933
|
npx ${TO} migrate [dir] rewrite require("${FROM}") and import from "${FROM}" to "${TO}"
|
|
913
934
|
npx ${TO} override [dir] answer ${FROM} with this package for the whole dependency tree, for
|
|
914
935
|
when a framework requires ${FROM} in its own code and not in yours
|
|
915
936
|
npx ${TO} angular [dir] declare this package external in angular.json's server build, which
|
|
916
937
|
esbuild otherwise tries to inline a native binary into
|
|
938
|
+
npx ${TO} pnpm [dir] make a pnpm project install this: pnpm 10.26 and later refuse a git
|
|
939
|
+
dependency of a dependency, and µWebSockets.js is one
|
|
917
940
|
npx ${TO} profile [entry] load an application without listening and print what compiling
|
|
918
941
|
its routes decided, route by route
|
|
919
942
|
npx ${TO} explain <route> what happens when a request for that route arrives
|
|
@@ -921,7 +944,7 @@ function main(argv) {
|
|
|
921
944
|
npx ${TO} differences print what behaves differently, without changing anything
|
|
922
945
|
|
|
923
946
|
Options:
|
|
924
|
-
--dry-run migrate, override, angular: say what would change and change nothing`);
|
|
947
|
+
--dry-run migrate, override, angular, pnpm: say what would change and change nothing`);
|
|
925
948
|
return command ? 1 : 0;
|
|
926
949
|
}
|
|
927
950
|
|
package/src/compression.js
CHANGED
package/src/create.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
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
|
+
// npx fulmine.js create <dir> [--ts] [--pnpm]
|
|
18
|
+
//
|
|
19
|
+
// A new project, for whoever has no Express application to migrate. `migrate` and `override` start
|
|
20
|
+
// from somebody's code; this starts from nothing and writes the few files a first run needs: a
|
|
21
|
+
// server, a package.json, and the Dockerfile that works, since the base image is the one thing a
|
|
22
|
+
// Dockerfile written for Express gets wrong here. Nothing is installed, that is the user's call.
|
|
23
|
+
|
|
24
|
+
"use strict";
|
|
25
|
+
|
|
26
|
+
const fs = require("fs");
|
|
27
|
+
const path = require("path");
|
|
28
|
+
const { withPnpmOverride, UWS_SPEC } = require("./adopt.js");
|
|
29
|
+
|
|
30
|
+
const SELF = "fulmine.js";
|
|
31
|
+
|
|
32
|
+
/** The major this package tracks, which is the range a new project should ask for. */
|
|
33
|
+
const MAJOR = require("../package.json").version.split(".")[0];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The node major the Dockerfile names: the one running this when it has a µWS binary, which is the
|
|
37
|
+
* even lines, and the newest supported one otherwise.
|
|
38
|
+
*
|
|
39
|
+
* @returns {number}
|
|
40
|
+
*/
|
|
41
|
+
function nodeMajor() {
|
|
42
|
+
const running = Number(process.versions.node.split(".")[0]);
|
|
43
|
+
return running >= 22 && running % 2 === 0 ? running : 26;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The server, in the flavour asked for. The same three routes either way: a page, a JSON answer
|
|
48
|
+
* with a parameter, and a body read by the built-in parser.
|
|
49
|
+
*
|
|
50
|
+
* @param {boolean} ts
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function serverSource(ts) {
|
|
54
|
+
const types = ts ? 'import type { Request, Response } from "fulmine.js";\n' : "";
|
|
55
|
+
const params = ts ? "(req: Request, res: Response)" : "(req, res)";
|
|
56
|
+
return `import express from "${SELF}";
|
|
57
|
+
${types}
|
|
58
|
+
const app = express();
|
|
59
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
60
|
+
|
|
61
|
+
app.use(express.json());
|
|
62
|
+
app.use(express.static("public"));
|
|
63
|
+
|
|
64
|
+
app.get("/api/hello", ${params} => {
|
|
65
|
+
res.json({ hello: "world" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
app.get("/api/items/:id", ${params} => {
|
|
69
|
+
res.json({ id: req.params.id });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
app.post("/api/items", ${params} => {
|
|
73
|
+
res.status(201).json(req.body);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
app.listen(port, () => {
|
|
77
|
+
console.log(\`listening on http://localhost:\${port}\`);
|
|
78
|
+
});
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} name the package name, from the directory
|
|
84
|
+
* @param {boolean} ts
|
|
85
|
+
* @param {boolean} pnpm the project also owns µWebSockets.js, see `npx fulmine.js pnpm`
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
function packageSource(name, ts, pnpm) {
|
|
89
|
+
const pkg = {
|
|
90
|
+
name,
|
|
91
|
+
version: "0.1.0",
|
|
92
|
+
private: true,
|
|
93
|
+
type: "module",
|
|
94
|
+
scripts: ts
|
|
95
|
+
? {
|
|
96
|
+
build: "tsc",
|
|
97
|
+
start: "node dist/server.js",
|
|
98
|
+
dev: "node --watch --experimental-strip-types src/server.ts"
|
|
99
|
+
}
|
|
100
|
+
: { start: "node server.js", dev: "node --watch server.js" },
|
|
101
|
+
dependencies: { [SELF]: `^${MAJOR}`, ...(pnpm ? { "uWebSockets.js": UWS_SPEC } : {}) },
|
|
102
|
+
...(ts ? { devDependencies: { "@types/node": `^${nodeMajor()}`, typescript: "^5" } } : {}),
|
|
103
|
+
engines: { node: ">=22" }
|
|
104
|
+
};
|
|
105
|
+
return JSON.stringify(pkg, null, 4) + "\n";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const TSCONFIG = `{
|
|
109
|
+
"compilerOptions": {
|
|
110
|
+
"target": "es2022",
|
|
111
|
+
"module": "nodenext",
|
|
112
|
+
"outDir": "dist",
|
|
113
|
+
"rootDir": "src",
|
|
114
|
+
"strict": true,
|
|
115
|
+
"verbatimModuleSyntax": true,
|
|
116
|
+
"skipLibCheck": true,
|
|
117
|
+
"types": ["node"]
|
|
118
|
+
},
|
|
119
|
+
"include": ["src"]
|
|
120
|
+
}
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Install with the full image, which has git for the µWS fetch, run with the slim one. trixie or
|
|
125
|
+
* newer on both, since bookworm's glibc is too old for the binary and Alpine has no build at all.
|
|
126
|
+
*
|
|
127
|
+
* @param {boolean} ts
|
|
128
|
+
* @param {boolean} pnpm install with pnpm, whose lockfile and workspace file come along
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
function dockerfileSource(ts, pnpm) {
|
|
132
|
+
const major = nodeMajor();
|
|
133
|
+
const manifests = pnpm ? "package.json pnpm-lock.yaml pnpm-workspace.yaml" : "package*.json";
|
|
134
|
+
const install = pnpm ? "npm install -g pnpm@11 && pnpm install --frozen-lockfile" : "npm ci";
|
|
135
|
+
const build = ts
|
|
136
|
+
? `COPY ${manifests} tsconfig.json ./
|
|
137
|
+
RUN ${install}
|
|
138
|
+
COPY src ./src
|
|
139
|
+
RUN ${pnpm ? "pnpm run build && pnpm prune --prod" : "npm run build && npm prune --omit=dev"}`
|
|
140
|
+
: `COPY ${manifests} ./
|
|
141
|
+
RUN ${install}${pnpm ? " --prod" : " --omit=dev"}`;
|
|
142
|
+
const run = ts
|
|
143
|
+
? `COPY --from=build /app/node_modules ./node_modules
|
|
144
|
+
COPY --from=build /app/dist ./dist
|
|
145
|
+
COPY package.json ./
|
|
146
|
+
COPY public ./public
|
|
147
|
+
EXPOSE 3000
|
|
148
|
+
CMD ["node", "dist/server.js"]`
|
|
149
|
+
: `COPY --from=build /app/node_modules ./node_modules
|
|
150
|
+
COPY . .
|
|
151
|
+
EXPOSE 3000
|
|
152
|
+
CMD ["node", "server.js"]`;
|
|
153
|
+
return `FROM node:${major}-trixie AS build
|
|
154
|
+
WORKDIR /app
|
|
155
|
+
${build}
|
|
156
|
+
|
|
157
|
+
FROM node:${major}-trixie-slim
|
|
158
|
+
WORKDIR /app
|
|
159
|
+
ENV NODE_ENV=production
|
|
160
|
+
${run}
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const INDEX_HTML = `<!doctype html>
|
|
165
|
+
<meta charset="utf-8">
|
|
166
|
+
<title>fulmine.js</title>
|
|
167
|
+
<p>Served by <code>express.static()</code>. The API answers on <a href="/api/hello">/api/hello</a>.</p>
|
|
168
|
+
`;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The files a new project is made of, by path.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} name
|
|
174
|
+
* @param {boolean} ts
|
|
175
|
+
* @param {boolean} pnpm
|
|
176
|
+
* @returns {Record<string, string>}
|
|
177
|
+
*/
|
|
178
|
+
function projectFiles(name, ts, pnpm) {
|
|
179
|
+
return {
|
|
180
|
+
"package.json": packageSource(name, ts, pnpm),
|
|
181
|
+
[ts ? "src/server.ts" : "server.js"]: serverSource(ts),
|
|
182
|
+
...(ts ? { "tsconfig.json": TSCONFIG } : {}),
|
|
183
|
+
// pnpm refuses a git dependency of a dependency, so the project owns µWebSockets.js itself
|
|
184
|
+
...(pnpm ? { "pnpm-workspace.yaml": /** @type {string} */ (withPnpmOverride("")) } : {}),
|
|
185
|
+
"public/index.html": INDEX_HTML,
|
|
186
|
+
Dockerfile: dockerfileSource(ts, pnpm),
|
|
187
|
+
".dockerignore": "node_modules\n.git\ndist\n",
|
|
188
|
+
".gitignore": "node_modules\ndist\n"
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* `npx fulmine.js create <dir> [--ts] [--pnpm]`
|
|
194
|
+
*
|
|
195
|
+
* pnpm is also read from how this was started: `pnpm dlx fulmine.js create` says so in the user
|
|
196
|
+
* agent npm and pnpm both set, and a project started that way would otherwise fail its first install.
|
|
197
|
+
*
|
|
198
|
+
* @param {string[]} argv everything after the command
|
|
199
|
+
* @returns {number} exit code
|
|
200
|
+
*/
|
|
201
|
+
function create(argv) {
|
|
202
|
+
const ts = argv.includes("--ts");
|
|
203
|
+
const pnpm = argv.includes("--pnpm") || /^pnpm\//.test(process.env.npm_config_user_agent ?? "");
|
|
204
|
+
const given = argv.find((arg) => !arg.startsWith("--"));
|
|
205
|
+
if (!given) {
|
|
206
|
+
console.error(`Usage: npx ${SELF} create <dir> [--ts] [--pnpm]`);
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
const dir = path.resolve(given);
|
|
210
|
+
// a name npm accepts: what the directory is called, lowercased, anything else a dash
|
|
211
|
+
const name =
|
|
212
|
+
path
|
|
213
|
+
.basename(dir)
|
|
214
|
+
.toLowerCase()
|
|
215
|
+
.replace(/[^a-z0-9._-]+/g, "-") || "app";
|
|
216
|
+
|
|
217
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length) {
|
|
218
|
+
console.error(`${dir} is not empty. Nothing was written: this only starts a project, it does not join one.`);
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const files = projectFiles(name, ts, pnpm);
|
|
223
|
+
for (const [file, content] of Object.entries(files)) {
|
|
224
|
+
const full = path.join(dir, file);
|
|
225
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
226
|
+
fs.writeFileSync(full, content);
|
|
227
|
+
console.log(`wrote ${path.join(given, file)}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const pm = pnpm ? "pnpm" : "npm";
|
|
231
|
+
console.log(`\nNext:\n`);
|
|
232
|
+
console.log(` cd ${given}`);
|
|
233
|
+
console.log(` ${pm} install`);
|
|
234
|
+
console.log(` ${pm} run dev${ts ? ` # or ${pm} run build && ${pm} start` : ""}\n`);
|
|
235
|
+
if (pnpm) {
|
|
236
|
+
console.log("pnpm refuses a git dependency of a dependency, and µWebSockets.js is one, so this project owns");
|
|
237
|
+
console.log(
|
|
238
|
+
"it: the pin in package.json and the override in pnpm-workspace.yaml. See docs/deployment.md#pnpm."
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
console.log(
|
|
242
|
+
`The Dockerfile uses node:${nodeMajor()}-trixie, since Alpine and bookworm cannot load µWebSockets.js.`
|
|
243
|
+
);
|
|
244
|
+
console.log(`\`npx ${SELF} verify\` says whether this machine can, before you install anything.\n`);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = { create, projectFiles };
|
package/src/declarative.js
CHANGED
|
@@ -26,6 +26,7 @@ const uWSAny = /** @type {any} */ (uWS);
|
|
|
26
26
|
const statuses = require("statuses");
|
|
27
27
|
|
|
28
28
|
/** @typedef {import("./application.js").Application} Application */
|
|
29
|
+
/** @typedef {import("./router.js")} Router */
|
|
29
30
|
|
|
30
31
|
const parser = acorn.Parser;
|
|
31
32
|
|
|
@@ -45,7 +46,11 @@ const allowedResMethods = [
|
|
|
45
46
|
|
|
46
47
|
const allowedIdentifiers = ["query", "params", ...allowedResMethods];
|
|
47
48
|
|
|
48
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* What res.type(x) sets the content type to. A lookup on a literal.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} type
|
|
53
|
+
*/
|
|
49
54
|
const typeValueOf = (type) => (type.indexOf("/") === -1 ? contentTypeFor(type) : type);
|
|
50
55
|
|
|
51
56
|
// what one instruction of a declarative response can carry, since uWS writes its length as a u16
|
|
@@ -307,7 +312,7 @@ function readStatusAndHeaders(callExprs, headers) {
|
|
|
307
312
|
* @param {any[]} callExprs the res calls, in run order, as readStatusAndHeaders takes them
|
|
308
313
|
* @param {[string, string][]} headers the headers read so far, written to
|
|
309
314
|
* @param {any[]} body the body parts, written to; loose because a literal's value is kept as it is
|
|
310
|
-
* @param {Application} app the application, for the json settings
|
|
315
|
+
* @param {Application|Router} app the application or router the route hangs on, for the json settings
|
|
311
316
|
* @param {string[]} queries names bound by a destructured req.query
|
|
312
317
|
* @param {string[]} params names bound by a destructured req.params
|
|
313
318
|
* @returns {{sendUsed: boolean, bodyFromSend: boolean}|null}
|
|
@@ -438,6 +443,7 @@ function readBody(callExprs, headers, body, app, queries, params) {
|
|
|
438
443
|
}
|
|
439
444
|
body.push({ type: arg.object.property.name, value: arg.property.name });
|
|
440
445
|
} else if (arg.type === "BinaryExpression") {
|
|
446
|
+
/** @type {any[]} the parts, in the same loose shape as body */
|
|
441
447
|
const stuff = [];
|
|
442
448
|
/**
|
|
443
449
|
* Reads a chain of string concatenations right to left. Each side must be a literal or a
|
|
@@ -768,6 +774,10 @@ function identifiersAllowed(fn, args, names) {
|
|
|
768
774
|
// - doesnt create variables
|
|
769
775
|
// - only uses req.query and req.params
|
|
770
776
|
// basically, its only simple, static responses
|
|
777
|
+
/**
|
|
778
|
+
* @param {Function} cb the handler
|
|
779
|
+
* @param {Application|Router} app the application or router the route hangs on, for the json settings
|
|
780
|
+
*/
|
|
771
781
|
module.exports = function compileDeclarative(cb, app) {
|
|
772
782
|
try {
|
|
773
783
|
const handler = readHandler(cb);
|
|
@@ -791,8 +801,8 @@ module.exports = function compileDeclarative(cb, app) {
|
|
|
791
801
|
return false;
|
|
792
802
|
}
|
|
793
803
|
|
|
804
|
+
/** @type {[string, string][]} */
|
|
794
805
|
const headers = [];
|
|
795
|
-
const body = [];
|
|
796
806
|
|
|
797
807
|
const status = readStatusAndHeaders(callExprs, headers);
|
|
798
808
|
if (status === null) {
|
|
@@ -800,6 +810,8 @@ module.exports = function compileDeclarative(cb, app) {
|
|
|
800
810
|
}
|
|
801
811
|
const { statusCode, sendStatusUsed } = status;
|
|
802
812
|
|
|
813
|
+
/** @type {any[]} loose because a literal's value is kept as it is, see readBody */
|
|
814
|
+
const body = [];
|
|
803
815
|
const read = readBody(callExprs, headers, body, app, queries, params);
|
|
804
816
|
if (read === null) {
|
|
805
817
|
return false;
|