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/cli.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
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
|
+
// npx fulmine migrate [dir]
|
|
19
|
+
//
|
|
20
|
+
// Rewrites the module specifier and nothing else. An Express 5 app is a Fulmine app already, so
|
|
21
|
+
// there is no code to translate: what there is instead is a short list of things that behave
|
|
22
|
+
// differently, printed at the end, because no rewrite can find those for you.
|
|
23
|
+
|
|
24
|
+
const fs = require("fs");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
const acorn = require("acorn");
|
|
27
|
+
|
|
28
|
+
const FROM = "express";
|
|
29
|
+
const TO = "fulmine.js";
|
|
30
|
+
|
|
31
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".nyc_output", ".next"]);
|
|
32
|
+
const EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".tsx"]);
|
|
33
|
+
const TYPESCRIPT_EXTENSIONS = new Set([".ts", ".mts", ".cts", ".tsx"]);
|
|
34
|
+
|
|
35
|
+
// Printed after a migration, and by `npx fulmine differences` on its own. Each one is something
|
|
36
|
+
// a working Express 5 app can depend on and that Fulmine answers differently.
|
|
37
|
+
const DIFFERENCES = [
|
|
38
|
+
[
|
|
39
|
+
"app.listen() returns the app, not an http.Server",
|
|
40
|
+
"There is no node http server underneath, so anything doing `const server = app.listen(...)` and then\n" +
|
|
41
|
+
"reaching for server.close(), server.address() or attaching a websocket library to it needs a look.\n" +
|
|
42
|
+
"app.close(), app.address() and app.listening exist and do what you would expect."
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
"an HTTPS server is configured through express(), not https.createServer()",
|
|
46
|
+
"Pass uwsOptions to the constructor: express({ uwsOptions: { key_file_name, cert_file_name } }).\n" +
|
|
47
|
+
"The same goes for plain HTTP: do not create a server yourself, call app.listen()."
|
|
48
|
+
],
|
|
49
|
+
[
|
|
50
|
+
"the request body is only read for POST, PUT, PATCH and QUERY",
|
|
51
|
+
'A body sent with GET or DELETE is not read unless you add the method: app.set("body methods", [...]).'
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
"case sensitive routing is on by default",
|
|
55
|
+
'/Users and /users are two different routes unless you set app.set("case sensitive routing", false).\n' +
|
|
56
|
+
"It is on because it is what makes a route eligible for the native router."
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
"x-powered-by is off by default",
|
|
60
|
+
'Express sends X-Powered-By: Express unless told not to. Set app.set("x-powered-by", true) to send it.'
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
"a compiled route is framed differently and never answers 304",
|
|
64
|
+
"A handler simple enough to be read at registration time is answered natively, which means chunked\n" +
|
|
65
|
+
"framing with no Content-Length, and a conditional request gets the whole body rather than a 304.\n" +
|
|
66
|
+
'app.set("declarative responses", false) turns that off.'
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
"headers are capped at 4096 bytes by default",
|
|
70
|
+
"Node allows 16384. Set the UWS_HTTP_MAX_HEADERS_SIZE environment variable if you need more."
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
"a request body arriving slower than 16KB/s is dropped",
|
|
74
|
+
"Node waits as long as the client needs. Uploads over very slow connections can fail here and\n" +
|
|
75
|
+
"succeed on Express."
|
|
76
|
+
]
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every .js, .mjs and .cjs file under dir, skipping the directories nobody wants rewritten.
|
|
81
|
+
* @param {string} dir
|
|
82
|
+
* @returns {string[]}
|
|
83
|
+
*/
|
|
84
|
+
function collectFiles(dir) {
|
|
85
|
+
const found = [];
|
|
86
|
+
/** @type {string[]} */
|
|
87
|
+
const stack = [dir];
|
|
88
|
+
while (stack.length) {
|
|
89
|
+
const current = /** @type {string} */ (stack.pop());
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
93
|
+
} catch {
|
|
94
|
+
continue; // unreadable directory, nothing to migrate in it
|
|
95
|
+
}
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const full = path.join(current, entry.name);
|
|
98
|
+
if (entry.isDirectory()) {
|
|
99
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
|
|
100
|
+
stack.push(full);
|
|
101
|
+
}
|
|
102
|
+
} else if (EXTENSIONS.has(path.extname(entry.name))) {
|
|
103
|
+
found.push(full);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return found.sort();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The TypeScript compiler belonging to the project being migrated, or null when it has none.
|
|
112
|
+
*
|
|
113
|
+
* acorn cannot read TypeScript, and shipping a parser that can would put megabytes into this
|
|
114
|
+
* package for a command most people run once. A TypeScript project already has the compiler, so
|
|
115
|
+
* it is resolved from there. A project without one is told its .ts files were left alone rather
|
|
116
|
+
* than having them quietly skipped, which is what happened before they were looked at at all.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} target directory being migrated
|
|
119
|
+
* @returns {any|null}
|
|
120
|
+
*/
|
|
121
|
+
function loadTypeScript(target) {
|
|
122
|
+
try {
|
|
123
|
+
return require(require.resolve("typescript", { paths: [target, process.cwd()] }));
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The same specifiers, out of a TypeScript file. A separate walk because the compiler's tree is
|
|
131
|
+
* not ESTree: the node kinds are different and children are visited through forEachChild.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} source
|
|
134
|
+
* @param {string} fileName decides whether JSX is allowed, so a .tsx angle bracket is not a cast
|
|
135
|
+
* @param {any} ts the compiler
|
|
136
|
+
* @returns {{start: number, end: number}[]}
|
|
137
|
+
*/
|
|
138
|
+
function findSpecifiersTypeScript(source, fileName, ts) {
|
|
139
|
+
const sourceFile = ts.createSourceFile(
|
|
140
|
+
fileName,
|
|
141
|
+
source,
|
|
142
|
+
ts.ScriptTarget.Latest,
|
|
143
|
+
true,
|
|
144
|
+
fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
/** @type {{start: number, end: number}[]} */
|
|
148
|
+
const found = [];
|
|
149
|
+
const take = (node) => found.push({ start: node.getStart(sourceFile), end: node.getEnd() });
|
|
150
|
+
|
|
151
|
+
const visit = (node) => {
|
|
152
|
+
// import express from "express", import type { Request } from "express", export * from it.
|
|
153
|
+
// A type-only import is rewritten too: the types come from the new package as well.
|
|
154
|
+
if (
|
|
155
|
+
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
|
156
|
+
node.moduleSpecifier &&
|
|
157
|
+
ts.isStringLiteral(node.moduleSpecifier) &&
|
|
158
|
+
node.moduleSpecifier.text === FROM
|
|
159
|
+
) {
|
|
160
|
+
take(node.moduleSpecifier);
|
|
161
|
+
} else if (
|
|
162
|
+
// import express = require("express"), which is TypeScript's own spelling
|
|
163
|
+
ts.isImportEqualsDeclaration(node) &&
|
|
164
|
+
ts.isExternalModuleReference(node.moduleReference) &&
|
|
165
|
+
ts.isStringLiteral(node.moduleReference.expression) &&
|
|
166
|
+
node.moduleReference.expression.text === FROM
|
|
167
|
+
) {
|
|
168
|
+
take(node.moduleReference.expression);
|
|
169
|
+
} else if (ts.isCallExpression(node)) {
|
|
170
|
+
const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require";
|
|
171
|
+
const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
|
|
172
|
+
const arg = node.arguments[0];
|
|
173
|
+
if ((isRequire || isDynamicImport) && arg && ts.isStringLiteral(arg) && arg.text === FROM) {
|
|
174
|
+
take(arg);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
ts.forEachChild(node, visit);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
visit(sourceFile);
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The string literals naming the module, found through the parser rather than by searching the
|
|
186
|
+
* text. "express" appears inside express-session, inside comments and inside strings that are not
|
|
187
|
+
* imports at all, and none of those may be rewritten.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} source
|
|
190
|
+
* @returns {{start: number, end: number}[]|null} null when the file does not parse
|
|
191
|
+
*/
|
|
192
|
+
function findSpecifiers(source) {
|
|
193
|
+
/** @type {any} */
|
|
194
|
+
let tree;
|
|
195
|
+
// A file is either a module or a script and the parser has to be told which. Try module first,
|
|
196
|
+
// since it also accepts everything a script can contain except a bare `return`.
|
|
197
|
+
for (const sourceType of ["module", "script"]) {
|
|
198
|
+
try {
|
|
199
|
+
tree = acorn.parse(source, {
|
|
200
|
+
ecmaVersion: "latest",
|
|
201
|
+
sourceType: /** @type {any} */ (sourceType),
|
|
202
|
+
allowReturnOutsideFunction: true,
|
|
203
|
+
allowAwaitOutsideFunction: true,
|
|
204
|
+
allowHashBang: true
|
|
205
|
+
});
|
|
206
|
+
break;
|
|
207
|
+
} catch {
|
|
208
|
+
tree = null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!tree) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @type {{start: number, end: number}[]} */
|
|
216
|
+
const found = [];
|
|
217
|
+
walk(tree, (node) => {
|
|
218
|
+
// import express from "express", export * from "express"
|
|
219
|
+
if (
|
|
220
|
+
(node.type === "ImportDeclaration" ||
|
|
221
|
+
node.type === "ExportNamedDeclaration" ||
|
|
222
|
+
node.type === "ExportAllDeclaration") &&
|
|
223
|
+
node.source?.value === FROM
|
|
224
|
+
) {
|
|
225
|
+
found.push({ start: node.source.start, end: node.source.end });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// require("express") and import("express"), the second being a node of its own
|
|
229
|
+
const isRequire =
|
|
230
|
+
node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require";
|
|
231
|
+
const isDynamicImport = node.type === "ImportExpression";
|
|
232
|
+
if (isRequire || isDynamicImport) {
|
|
233
|
+
const arg = isDynamicImport ? node.source : node.arguments?.[0];
|
|
234
|
+
if (arg?.type === "Literal" && arg.value === FROM) {
|
|
235
|
+
found.push({ start: arg.start, end: arg.end });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
return found;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Visits every node. acorn produces plain objects, so the shape is walked rather than dispatched
|
|
244
|
+
* on: a table of node types would have to be kept in step with the parser, and being out of step
|
|
245
|
+
* would mean silently skipping an import.
|
|
246
|
+
* @param {any} node
|
|
247
|
+
* @param {(node: any) => void} visit
|
|
248
|
+
*/
|
|
249
|
+
function walk(node, visit) {
|
|
250
|
+
if (!node || typeof node !== "object") return;
|
|
251
|
+
if (Array.isArray(node)) {
|
|
252
|
+
for (const child of node) walk(child, visit);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (typeof node.type === "string") visit(node);
|
|
256
|
+
for (const key in node) {
|
|
257
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
258
|
+
walk(node[key], visit);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {string[]} argv
|
|
264
|
+
*/
|
|
265
|
+
function main(argv) {
|
|
266
|
+
const command = argv[0];
|
|
267
|
+
if (command === "differences") {
|
|
268
|
+
printDifferences();
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
if (command !== "migrate") {
|
|
272
|
+
console.log(`Usage:
|
|
273
|
+
npx ${TO} migrate [dir] rewrite require("${FROM}") and import from "${FROM}" to "${TO}"
|
|
274
|
+
npx ${TO} differences print what behaves differently, without changing anything
|
|
275
|
+
|
|
276
|
+
Options:
|
|
277
|
+
--dry-run say what would change and change nothing`);
|
|
278
|
+
return command ? 1 : 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const dryRun = argv.includes("--dry-run");
|
|
282
|
+
const target = path.resolve(argv.slice(1).find((arg) => !arg.startsWith("--")) ?? ".");
|
|
283
|
+
if (!fs.existsSync(target)) {
|
|
284
|
+
console.error(`${target} does not exist`);
|
|
285
|
+
return 1;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const files = fs.statSync(target).isDirectory() ? collectFiles(target) : [target];
|
|
289
|
+
let changedFiles = 0;
|
|
290
|
+
let changedImports = 0;
|
|
291
|
+
/** @type {string[]} */
|
|
292
|
+
const unparsed = [];
|
|
293
|
+
/** @type {string[]} */
|
|
294
|
+
const needTypeScript = [];
|
|
295
|
+
|
|
296
|
+
// resolved once, and only if there is anything to use it on
|
|
297
|
+
const hasTypeScriptFiles = files.some((file) => TYPESCRIPT_EXTENSIONS.has(path.extname(file)));
|
|
298
|
+
const ts = hasTypeScriptFiles ? loadTypeScript(target) : null;
|
|
299
|
+
|
|
300
|
+
for (const file of files) {
|
|
301
|
+
const source = fs.readFileSync(file, "utf8");
|
|
302
|
+
// reading every file's AST to find nothing is the common case, so skip the ones that
|
|
303
|
+
// cannot contain the specifier at all
|
|
304
|
+
if (!source.includes(FROM)) continue;
|
|
305
|
+
|
|
306
|
+
const isTypeScript = TYPESCRIPT_EXTENSIONS.has(path.extname(file));
|
|
307
|
+
if (isTypeScript && !ts) {
|
|
308
|
+
needTypeScript.push(path.relative(target, file));
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const specifiers = isTypeScript ? findSpecifiersTypeScript(source, file, ts) : findSpecifiers(source);
|
|
313
|
+
if (specifiers === null) {
|
|
314
|
+
unparsed.push(path.relative(target, file));
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!specifiers.length) continue;
|
|
318
|
+
|
|
319
|
+
// right to left, so an earlier replacement does not move the offsets of a later one
|
|
320
|
+
let rewritten = source;
|
|
321
|
+
for (const { start, end } of specifiers.sort((a, b) => b.start - a.start)) {
|
|
322
|
+
const quote = source[start];
|
|
323
|
+
rewritten = rewritten.slice(0, start) + quote + TO + quote + rewritten.slice(end);
|
|
324
|
+
}
|
|
325
|
+
changedFiles++;
|
|
326
|
+
changedImports += specifiers.length;
|
|
327
|
+
console.log(`${dryRun ? "would rewrite" : "rewrote"} ${path.relative(target, file)} (${specifiers.length})`);
|
|
328
|
+
if (!dryRun) fs.writeFileSync(file, rewritten);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (unparsed.length) {
|
|
332
|
+
console.log(`\n${unparsed.length} file(s) could not be parsed and were left alone:`);
|
|
333
|
+
for (const file of unparsed) console.log(` ${file}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (needTypeScript.length) {
|
|
337
|
+
console.log(
|
|
338
|
+
`\n${needTypeScript.length} TypeScript file(s) were left alone: reading them needs the` +
|
|
339
|
+
` typescript package, and it is not installed here.\nInstall it and run this again,` +
|
|
340
|
+
` or rewrite these by hand:`
|
|
341
|
+
);
|
|
342
|
+
for (const file of needTypeScript) console.log(` ${file}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
console.log(
|
|
346
|
+
`\n${dryRun ? "would rewrite" : "rewrote"} ${changedImports} import(s) in ${changedFiles} file(s) of ${files.length} scanned`
|
|
347
|
+
);
|
|
348
|
+
if (changedFiles) {
|
|
349
|
+
console.log(`Remember to install it: npm install ${TO}`);
|
|
350
|
+
printDifferences();
|
|
351
|
+
}
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Prints the list above, which is what migrate ends with and what the differences command prints alone. */
|
|
356
|
+
function printDifferences() {
|
|
357
|
+
console.log(`\nWhat to check by hand, since no rewrite can find these for you:\n`);
|
|
358
|
+
for (const [title, detail] of DIFFERENCES) {
|
|
359
|
+
console.log(` ${title}`);
|
|
360
|
+
for (const line of detail.split("\n")) console.log(` ${line}`);
|
|
361
|
+
console.log("");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (require.main === module) {
|
|
366
|
+
process.exitCode = main(process.argv.slice(2));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
module.exports = { main, findSpecifiers, collectFiles, DIFFERENCES };
|