@server/next 0.15.1 → 0.17.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/package.json +2 -1
- package/readme.md +33 -9
- package/src/RequestLogger.js +67 -21
- package/src/ServerUrl.js +53 -17
- package/src/ServerUrl.test.js +18 -0
- package/src/bucket.js +28 -0
- package/src/color.js +6 -2
- package/src/getMime.js +48 -0
- package/src/index.js +142 -64
- package/src/logger.js +21 -0
- package/src/parseBody.js +31 -34
- package/src/parseBody.test.js +10 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@server/next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "An experimental reimplementation of server.js focused on the DX",
|
|
5
5
|
"homepage": "https://node-server.com/",
|
|
6
6
|
"repository": "https://github.com/franciscop/server-next.git",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"author": "Francisco Presencia <public@francisco.io> (https://francisco.io/)",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
11
|
"scripts": {
|
|
12
|
+
"demo": "nodemon ./demo/app.js",
|
|
12
13
|
"start": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
|
|
13
14
|
"size": "echo \"$(gzip -c index.js | wc -c) bytes\" # Only for Unix",
|
|
14
15
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
|
package/readme.md
CHANGED
|
@@ -7,29 +7,29 @@
|
|
|
7
7
|
A fully-fledged web server for Node.js, with all the basics covered for you:
|
|
8
8
|
|
|
9
9
|
```js
|
|
10
|
-
import server, { get, post, put, use } from
|
|
10
|
+
import server, { get, post, put, use } from "server";
|
|
11
11
|
|
|
12
12
|
// Create a running instance of the server
|
|
13
13
|
const app = server(config, [pluginA, pluginB]);
|
|
14
14
|
|
|
15
15
|
// Attach handlers to the instance
|
|
16
16
|
app([
|
|
17
|
-
get(
|
|
18
|
-
post(
|
|
19
|
-
put(
|
|
20
|
-
use(
|
|
17
|
+
get("/users", getUsers),
|
|
18
|
+
post("/users", createUser),
|
|
19
|
+
put("/users/:id", editUser),
|
|
20
|
+
use("/admin/*", dashboard),
|
|
21
21
|
]);
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
It includes all the things you would expect from a modern Server framework, like routing, static file serving, body+file parsing, gzip+brotli
|
|
24
|
+
It includes all the things you would expect from a modern Server framework, like routing, static file serving, options\*, body+file parsing, gzip+brotli, streaming, server-timing, plugins\*, etc.
|
|
25
25
|
|
|
26
26
|
> \* not yet available
|
|
27
27
|
|
|
28
|
-
##
|
|
28
|
+
## Upgrading server
|
|
29
29
|
|
|
30
30
|
Why? The ecosystem is moving out of server-rendered websites so we are as well. Now instead we treat APIs as first-class citizens. Desired improvements (WIP!):
|
|
31
31
|
|
|
32
|
-
- Tiny footprint with no dependencies
|
|
32
|
+
- Tiny footprint with no dependencies\*, all bundled in a single file. Installing and using the full library takes under 10kb (target limit).
|
|
33
33
|
- Faster! Reimplemented from scratch for speed. With raw ES6+ and a tiny code footprint, your server will fly.
|
|
34
34
|
- Modern ES6+ESM syntax for both the library and examples.
|
|
35
35
|
- Error handling improved greatly.
|
|
@@ -37,6 +37,13 @@ Why? The ecosystem is moving out of server-rendered websites so we are as well.
|
|
|
37
37
|
- Changed the reply logic greatly, including the removal of `render()`. This is the main reason express was removed. Many servers don't need render() at all.
|
|
38
38
|
- **[security]** Removed mandatory CSRF token, since this is only useful for server-rendered pages and not for SPA. You can activate it with a single option as before.
|
|
39
39
|
|
|
40
|
+
Major changes:
|
|
41
|
+
|
|
42
|
+
- New fully fledged `ctx.url` that extends [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) object inside `ctx` (also note: `ctx.url` is no longer a string):
|
|
43
|
+
- `ctx.params` is now `ctx.url.params`, e.g. `ctx.url.params.id`.
|
|
44
|
+
- `ctx.query` is now `ctx.url.query`, e.g. `ctx.url.params.search`.
|
|
45
|
+
- `ctx.path` is now `ctx.url.path` (or `ctx.url.pathname`).
|
|
46
|
+
- All URL properties are available, like `ctx.url.port`, `ctx.url.searchParams`, etc.
|
|
40
47
|
|
|
41
48
|
## Progress
|
|
42
49
|
|
|
@@ -48,7 +55,7 @@ Why? The ecosystem is moving out of server-rendered websites so we are as well.
|
|
|
48
55
|
- A string and it'll be sent as plain text or html (if it starts with "<")
|
|
49
56
|
- A readStream and it'll be piped to the response
|
|
50
57
|
- An object with `status`, `body` and `headers` and it'll be set raw.
|
|
51
|
-
|
|
58
|
+
- Response compression works
|
|
52
59
|
|
|
53
60
|
## Some plugins
|
|
54
61
|
|
|
@@ -85,3 +92,20 @@ app([
|
|
|
85
92
|
})
|
|
86
93
|
]);
|
|
87
94
|
```
|
|
95
|
+
|
|
96
|
+
## Examples
|
|
97
|
+
|
|
98
|
+
### Streams
|
|
99
|
+
|
|
100
|
+
Creating a 100x100px thumbnail on the fly with Sharp:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
// createThumbnail.js
|
|
104
|
+
import { get } from "@server/next";
|
|
105
|
+
import sharp from "sharp";
|
|
106
|
+
|
|
107
|
+
export default function createThumbnail(ctx) {
|
|
108
|
+
// Return a pipe, which will be streamed to the output
|
|
109
|
+
return sharp(ctx.url.params.name).resize(100, 100, { fit: "cover" }).png();
|
|
110
|
+
}
|
|
111
|
+
```
|
package/src/RequestLogger.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import Spinnies from "spinnies";
|
|
2
3
|
import color from "./color.js";
|
|
3
4
|
|
|
@@ -17,11 +18,13 @@ const format = (n, [below, above], limit = Infinity) => {
|
|
|
17
18
|
}
|
|
18
19
|
};
|
|
19
20
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
const range = (char, num) => {
|
|
22
|
+
const cols = process.stdout.columns || 80;
|
|
23
|
+
let str = "";
|
|
24
|
+
for (let i = 0; i < cols; i += char.length) {
|
|
25
|
+
str += char;
|
|
26
|
+
}
|
|
27
|
+
return str;
|
|
25
28
|
};
|
|
26
29
|
|
|
27
30
|
function simpleType(type) {
|
|
@@ -31,6 +34,7 @@ function simpleType(type) {
|
|
|
31
34
|
"image/svg+xml": "svg",
|
|
32
35
|
"image/png": "png",
|
|
33
36
|
"text/css": "css",
|
|
37
|
+
"text/javascript": "js",
|
|
34
38
|
"application/javascript": "js",
|
|
35
39
|
"application/json": "json",
|
|
36
40
|
"text/markdown": "md",
|
|
@@ -38,6 +42,8 @@ function simpleType(type) {
|
|
|
38
42
|
return simpler[type] || type;
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
const cwd = path.resolve("./");
|
|
46
|
+
|
|
41
47
|
let spinnies;
|
|
42
48
|
export default function RequestLogger(ctx) {
|
|
43
49
|
this.id = Math.round(Math.random() * 100000);
|
|
@@ -52,26 +58,66 @@ export default function RequestLogger(ctx) {
|
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
this.end = function (ctx) {
|
|
55
|
-
if (
|
|
56
|
-
|
|
61
|
+
if (isProduction) return;
|
|
62
|
+
const status = ctx.res.status;
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const resTime = format(t, ["ms", "s"], 1000);
|
|
64
|
+
const paddedPath = `${ctx.url?.path || ctx.url} {dim}`.padEnd(30, "─");
|
|
65
|
+
const statColor = status < 300 ? "green" : status < 500 ? "yellow" : "red";
|
|
66
|
+
const statusBlock = `{${statColor}}[${ctx.res.status}]{/}`;
|
|
67
|
+
const resSize = format(ctx.res.size || 0, ["b", "kb"], 100000);
|
|
68
|
+
const t = Math.round(ctx.time._total - ctx.time._init);
|
|
69
|
+
const resTime = format(t, ["ms", "s"], 1000);
|
|
65
70
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
const type = simpleType(
|
|
72
|
+
ctx.res.type || ctx.res.headers["content-type"] || "----"
|
|
73
|
+
);
|
|
74
|
+
const method = ("[" + ctx.method + "]").padEnd(6, " ");
|
|
75
|
+
const reqText = `{dim}${method}{/} ${paddedPath}`;
|
|
76
|
+
const resText = `${statusBlock} ${resSize} ${resTime} ${type}`;
|
|
77
|
+
const text = color(`${reqText}─›{/} ${resText}`);
|
|
73
78
|
|
|
79
|
+
this.text = text;
|
|
80
|
+
if (statColor !== "red") {
|
|
74
81
|
spinnies.succeed(`spinner-${this.id}`, { text });
|
|
82
|
+
} else {
|
|
83
|
+
spinnies.fail(`spinner-${this.id}`, { text });
|
|
75
84
|
}
|
|
76
85
|
};
|
|
86
|
+
|
|
87
|
+
this.error = function (err) {
|
|
88
|
+
const cols = process.stdout.columns || 80;
|
|
89
|
+
console.log(color("{red} ┌───────┬" + range("─").slice(12) + "┐{/}"));
|
|
90
|
+
console.log(
|
|
91
|
+
color(
|
|
92
|
+
`{red} │ {bright}{red}Error{/} {red}│{/} ${err.message.padEnd(
|
|
93
|
+
cols - 14,
|
|
94
|
+
" "
|
|
95
|
+
)} {red}│{/}`
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
console.log(color("{red} ├───────┼" + range("─").slice(12) + "┤{/}"));
|
|
99
|
+
console.log(
|
|
100
|
+
color(
|
|
101
|
+
err.stack
|
|
102
|
+
.split("\n")
|
|
103
|
+
.slice(1)
|
|
104
|
+
.map((line, i) => {
|
|
105
|
+
line = line.replace(/\s*at\s/, "");
|
|
106
|
+
line = line.replace("file://" + cwd, "{dim}$PWD{/}");
|
|
107
|
+
return (
|
|
108
|
+
`{red} │{/} ${i === 0 ? "Trace" : " "} {red}│{/} ` +
|
|
109
|
+
line.padEnd(cols - 6, " ") +
|
|
110
|
+
" {red}│{/}"
|
|
111
|
+
);
|
|
112
|
+
})
|
|
113
|
+
.join("\n")
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
console.log(color("{red} └───────┴" + range("─").slice(12) + "┘{/}"));
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
this.reprint = function () {
|
|
120
|
+
if (isProduction) return;
|
|
121
|
+
console.log(color(`{red}✖{/} `) + this.text);
|
|
122
|
+
};
|
|
77
123
|
}
|
package/src/ServerUrl.js
CHANGED
|
@@ -1,21 +1,57 @@
|
|
|
1
|
-
|
|
1
|
+
import { inspect } from "util";
|
|
2
|
+
|
|
3
|
+
const colors = {
|
|
4
|
+
string: process.env.NO_COLOR ? "" : "\x1b[32m",
|
|
5
|
+
number: process.env.NO_COLOR ? "" : "\x1b[33m",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const properties = [
|
|
9
|
+
"hash",
|
|
10
|
+
"host",
|
|
11
|
+
"hostname",
|
|
12
|
+
"href",
|
|
13
|
+
"origin",
|
|
14
|
+
"params",
|
|
15
|
+
"password",
|
|
16
|
+
"path",
|
|
17
|
+
"pathname",
|
|
18
|
+
"port",
|
|
19
|
+
"protocol",
|
|
20
|
+
"query",
|
|
21
|
+
"search",
|
|
22
|
+
"searchParams",
|
|
23
|
+
"username",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export default class ServerUrl extends URL {
|
|
2
27
|
constructor(urlString) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
super(urlString);
|
|
29
|
+
|
|
30
|
+
const custom = {
|
|
31
|
+
port: +this.port || null,
|
|
32
|
+
path: this.pathname,
|
|
33
|
+
params: {},
|
|
34
|
+
query: this.getQuery(this.searchParams.entries()),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
for (let key of properties) {
|
|
38
|
+
const value = key in custom ? custom[key] : this[key];
|
|
39
|
+
Object.defineProperty(this, key, {
|
|
40
|
+
value,
|
|
41
|
+
enumerable: true,
|
|
42
|
+
writable: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
[inspect.custom]() {
|
|
48
|
+
const props = Object.keys(this)
|
|
49
|
+
.map((key) => {
|
|
50
|
+
const color = colors[typeof this[key]] || "";
|
|
51
|
+
return ` ${key}: ${color}${inspect(this[key])}\x1b[0m`;
|
|
52
|
+
})
|
|
53
|
+
.join("\n");
|
|
54
|
+
return `ServerUrl {\n${props}\n}`;
|
|
19
55
|
}
|
|
20
56
|
|
|
21
57
|
getQuery(entries) {
|
package/src/ServerUrl.test.js
CHANGED
|
@@ -17,6 +17,24 @@ describe("getUrl()", () => {
|
|
|
17
17
|
expect(url.host).toBe("example.com");
|
|
18
18
|
expect(url.hostname).toBe("example.com");
|
|
19
19
|
expect(url.port).toBe(null);
|
|
20
|
+
|
|
21
|
+
expect(Object.keys(url)).toEqual([
|
|
22
|
+
"hash",
|
|
23
|
+
"host",
|
|
24
|
+
"hostname",
|
|
25
|
+
"href",
|
|
26
|
+
"origin",
|
|
27
|
+
"params",
|
|
28
|
+
"password",
|
|
29
|
+
"path",
|
|
30
|
+
"pathname",
|
|
31
|
+
"port",
|
|
32
|
+
"protocol",
|
|
33
|
+
"query",
|
|
34
|
+
"search",
|
|
35
|
+
"searchParams",
|
|
36
|
+
"username",
|
|
37
|
+
]);
|
|
20
38
|
});
|
|
21
39
|
|
|
22
40
|
it("can parse localhost", () => {
|
package/src/bucket.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import fsp from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
// A fake tiny implementation of a generic bucket, it needs
|
|
6
|
+
// at the very least a read(id) and write(id, value), both returning
|
|
7
|
+
// promises. If possible both are also pipeable/streamable.
|
|
8
|
+
export default function (root) {
|
|
9
|
+
const absolute = (name) => {
|
|
10
|
+
if (!name) throw new Error(`File name is required`);
|
|
11
|
+
return path.resolve(path.join(root, name));
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
read: (name, type = "utf8") => {
|
|
16
|
+
const fullPath = absolute(name);
|
|
17
|
+
return fsp.readFile(fullPath, type);
|
|
18
|
+
},
|
|
19
|
+
write: (name, value, type = "utf8") => {
|
|
20
|
+
const fullPath = absolute(name);
|
|
21
|
+
if (value) {
|
|
22
|
+
return fsp.writeFile(fullPath, value, type).then(() => fullPath);
|
|
23
|
+
} else {
|
|
24
|
+
return fs.createWriteStream(fullPath);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
package/src/color.js
CHANGED
|
@@ -12,7 +12,11 @@ const map = {
|
|
|
12
12
|
bgblue: 44, bgmagenta: 45, bgcyan: 46, bgwhite: 47,
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
const replace = (k) =>
|
|
15
|
+
const replace = (k) => {
|
|
16
|
+
if (process.env.NO_COLOR) return "";
|
|
17
|
+
if (!(k in map)) throw new Error(`"{${k}}" is not a valid color`);
|
|
18
|
+
return `\x1b[${map[k]}m`;
|
|
19
|
+
};
|
|
16
20
|
|
|
17
21
|
export default function color(str, ...vals) {
|
|
18
22
|
if (typeof str === "string") {
|
|
@@ -21,6 +25,6 @@ export default function color(str, ...vals) {
|
|
|
21
25
|
.replaceAll(/\{\/\w*\}/g, replace("reset"));
|
|
22
26
|
}
|
|
23
27
|
|
|
24
|
-
//
|
|
28
|
+
// Template literals, put them together first and then color them
|
|
25
29
|
return color(str[0] + vals.map((v, i) => v + str[i + 1]).join(""));
|
|
26
30
|
}
|
package/src/getMime.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
// These are some mime types that are not properly produced by
|
|
6
|
+
// the file --mime-type command, and can be inferred by their
|
|
7
|
+
// filename extension easily
|
|
8
|
+
// Note: text/javascript is the recommended now https://stackoverflow.com/a/876805/938236
|
|
9
|
+
const extensions = {
|
|
10
|
+
css: "text/css",
|
|
11
|
+
js: "text/javascript",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const exists = (file) => {
|
|
15
|
+
return fsp.stat(file, fs.constants.F_OK).then(
|
|
16
|
+
(stat) => stat.isFile(),
|
|
17
|
+
() => false
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function cmd(...args) {
|
|
22
|
+
const { stdout: stream } = spawn(...args);
|
|
23
|
+
const chunks = [];
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
stream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
26
|
+
stream.on("error", (err) => reject(err));
|
|
27
|
+
stream.on("end", () =>
|
|
28
|
+
resolve(Buffer.concat(chunks).toString("utf8").trim())
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default async function (file) {
|
|
34
|
+
const cwd = await fsp.realpath("./");
|
|
35
|
+
file = await fsp.realpath(file);
|
|
36
|
+
|
|
37
|
+
const ext = file.split(".").pop();
|
|
38
|
+
if (extensions[ext]) return extensions[ext];
|
|
39
|
+
|
|
40
|
+
// It _is_ a file
|
|
41
|
+
if (!(await exists(file))) return false;
|
|
42
|
+
// Inside the CWD directory
|
|
43
|
+
if (!file.startsWith(cwd)) return false;
|
|
44
|
+
|
|
45
|
+
// The two conditions above give us enough confidence to run
|
|
46
|
+
// this, but even then we use spawn() to avoid some nasty injections
|
|
47
|
+
return cmd("file", ["--mime-type", "-b", file]);
|
|
48
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
|
|
1
3
|
import http from "node:http";
|
|
2
4
|
import fs from "node:fs";
|
|
3
5
|
import fsp from "node:fs/promises";
|
|
4
6
|
import path from "node:path";
|
|
7
|
+
import zlib from "node:zlib";
|
|
5
8
|
import { pipeline } from "node:stream/promises";
|
|
6
|
-
|
|
7
|
-
import "dotenv/config";
|
|
9
|
+
import { Readable, PassThrough } from "node:stream";
|
|
8
10
|
|
|
9
11
|
import ServerUrl from "./ServerUrl.js";
|
|
10
12
|
import RequestLogger from "./RequestLogger.js";
|
|
11
13
|
|
|
14
|
+
import logger from "./logger.js";
|
|
15
|
+
import getMime from "./getMime.js";
|
|
12
16
|
import pathPattern from "./pathPattern.js";
|
|
13
17
|
import parseBody from "./parseBody.js";
|
|
14
|
-
import color from "./color.js";
|
|
15
18
|
|
|
16
19
|
const isProduction = process.env.NODE_ENV === "production";
|
|
17
20
|
|
|
@@ -22,11 +25,33 @@ const getIp = (req) =>
|
|
|
22
25
|
req.socket.remoteAddress ||
|
|
23
26
|
req.connection.socket.remoteAddress;
|
|
24
27
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
const measure = (ctx) => {
|
|
29
|
+
const sizeUp = new PassThrough();
|
|
30
|
+
ctx.res.size = 0;
|
|
31
|
+
sizeUp.on("data", (chunk) => {
|
|
32
|
+
ctx.res.size += chunk.length;
|
|
33
|
+
});
|
|
34
|
+
return sizeUp;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const findEncoding = (acceptEncoding) => {
|
|
38
|
+
let encoding;
|
|
39
|
+
if (/\bbr\b/.test(acceptEncoding)) {
|
|
40
|
+
encoding = "br";
|
|
41
|
+
} else if (/\bgzip\b/.test(acceptEncoding)) {
|
|
42
|
+
encoding = "gzip";
|
|
43
|
+
} else if (/\bdeflate\b/.test(acceptEncoding)) {
|
|
44
|
+
encoding = "deflate";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const methods = {
|
|
48
|
+
deflate: () => zlib.createDeflate(),
|
|
49
|
+
gzip: () => zlib.createGzip(),
|
|
50
|
+
br: () => zlib.createBrotliCompress(),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const compress = methods[encoding];
|
|
54
|
+
return [encoding, compress];
|
|
30
55
|
};
|
|
31
56
|
|
|
32
57
|
const api = {
|
|
@@ -52,19 +77,32 @@ const getCtx = (req) => ({
|
|
|
52
77
|
const createApp = (server) => {
|
|
53
78
|
const app = function (...mid) {
|
|
54
79
|
app.middleware.push(...mid.flat());
|
|
80
|
+
|
|
81
|
+
// Final error handling
|
|
82
|
+
app.middleware.push({
|
|
83
|
+
handle: (error) => {
|
|
84
|
+
// console.clear();
|
|
85
|
+
// console.log("ERROR!", error);
|
|
86
|
+
return {
|
|
87
|
+
status: 500,
|
|
88
|
+
body: JSON.stringify({ error: error.message }),
|
|
89
|
+
type: "application/json",
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
});
|
|
55
93
|
};
|
|
56
94
|
|
|
95
|
+
// Static middleware
|
|
57
96
|
app.middleware = [
|
|
58
97
|
async function (ctx) {
|
|
59
|
-
if (ctx.method
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
98
|
+
if (ctx.method !== "get") return;
|
|
99
|
+
const file = path.join(process.cwd(), ctx.options.public, ctx.url.path);
|
|
100
|
+
const size = await fsp.stat(file).then(
|
|
101
|
+
(stat) => stat.isFile() && stat.size,
|
|
102
|
+
() => false
|
|
103
|
+
);
|
|
104
|
+
if (size) {
|
|
105
|
+
return fs.createReadStream(file);
|
|
68
106
|
}
|
|
69
107
|
},
|
|
70
108
|
];
|
|
@@ -80,12 +118,49 @@ const createApp = (server) => {
|
|
|
80
118
|
return app;
|
|
81
119
|
};
|
|
82
120
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
121
|
+
let ttyWarn = null;
|
|
122
|
+
// if (!process.stdin.isTTY) {
|
|
123
|
+
// ttyWarn = "Invalid terminal; cannot accept user input";
|
|
124
|
+
// }
|
|
125
|
+
// if (ttyWarn && process.env._.endsWith("nodemon")) {
|
|
126
|
+
// ttyWarn = "when running with nodemon, please pass the flag --no-stdin";
|
|
127
|
+
// }
|
|
128
|
+
|
|
129
|
+
const parseOutput = async (res, data) => {
|
|
130
|
+
// Plain number
|
|
131
|
+
if (typeof data === "number") {
|
|
132
|
+
res.status = data;
|
|
133
|
+
res.body = "";
|
|
134
|
+
res.type = "text/plain";
|
|
135
|
+
return res;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Plain string, which can only be text or html
|
|
139
|
+
if (typeof data === "string") {
|
|
140
|
+
res.status = 200;
|
|
141
|
+
res.body = data;
|
|
142
|
+
const isHtml = data.trim().startsWith("<");
|
|
143
|
+
res.type = isHtml ? "text/html" : "text/plain";
|
|
144
|
+
return res;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// When piping the output
|
|
148
|
+
if (data.pipe) {
|
|
149
|
+
res.body = data;
|
|
150
|
+
res.status = 200;
|
|
151
|
+
// It's a file; find its mimetype
|
|
152
|
+
if (res.body.path) {
|
|
153
|
+
res.type = await getMime(res.body.path);
|
|
154
|
+
}
|
|
155
|
+
return res;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Plain object, merge it with the existing one
|
|
159
|
+
if (data.type) res.type = data.type;
|
|
160
|
+
res.headers = { ...res.headers, ...(data.headers || {}) };
|
|
161
|
+
res.body = data.body || ""; // This could also be a pipe and that's okay
|
|
162
|
+
res.status = data.status || 200;
|
|
163
|
+
return res;
|
|
89
164
|
};
|
|
90
165
|
|
|
91
166
|
export default function (options = {}, plugins) {
|
|
@@ -95,48 +170,43 @@ export default function (options = {}, plugins) {
|
|
|
95
170
|
|
|
96
171
|
const server = http.createServer(async (req, res) => {
|
|
97
172
|
const logger = new RequestLogger(req);
|
|
98
|
-
const ctx = { ...getCtx(req), options };
|
|
173
|
+
const ctx = { ...getCtx(req), bucket: options.bucket, options };
|
|
99
174
|
|
|
100
|
-
const parsed = await parseBody(
|
|
175
|
+
const parsed = await parseBody(
|
|
176
|
+
ctx.req,
|
|
177
|
+
ctx.headers["content-type"],
|
|
178
|
+
ctx.bucket
|
|
179
|
+
);
|
|
101
180
|
if (parsed) {
|
|
102
181
|
ctx.body = parsed.body;
|
|
103
182
|
ctx.files = parsed.files;
|
|
104
183
|
}
|
|
105
184
|
|
|
106
185
|
let out;
|
|
186
|
+
let err;
|
|
107
187
|
ctx.res = { headers: {} };
|
|
108
188
|
for (let cb of app.middleware) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
ctx.res.status = out;
|
|
114
|
-
ctx.res.body = "";
|
|
115
|
-
ctx.res.headers["content-type"] = "text/plain";
|
|
116
|
-
} else if (typeof out === "string") {
|
|
117
|
-
// Plain string
|
|
118
|
-
ctx.res.status = 200;
|
|
119
|
-
ctx.res.body = out;
|
|
120
|
-
const isHtml = out.trim().startsWith("<");
|
|
121
|
-
ctx.res.headers["content-type"] = isHtml ? "text/html" : "text/plain";
|
|
122
|
-
ctx.res.headers["content-length"] = Buffer.byteLength(out);
|
|
123
|
-
} else {
|
|
124
|
-
if (out.pipe) {
|
|
125
|
-
ctx.res.body = out;
|
|
126
|
-
ctx.res.status = 200;
|
|
127
|
-
} else {
|
|
128
|
-
// Plain object
|
|
129
|
-
if (out.type) ctx.res.headers["content-type"] = out.type;
|
|
130
|
-
if (out.length) ctx.res.headers["content-length"] = out.length;
|
|
131
|
-
ctx.res.headers = { ...ctx.res.headers, ...(out.headers || {}) };
|
|
132
|
-
ctx.res.body = out.body || "";
|
|
133
|
-
ctx.res.status = out.status || 200;
|
|
189
|
+
try {
|
|
190
|
+
if (err) {
|
|
191
|
+
if (cb.handle) {
|
|
192
|
+
out = await cb.handle(err);
|
|
134
193
|
}
|
|
194
|
+
} else {
|
|
195
|
+
out = await cb(ctx);
|
|
196
|
+
}
|
|
197
|
+
if (out) {
|
|
198
|
+
ctx.res = await parseOutput(ctx.res, out);
|
|
199
|
+
break;
|
|
135
200
|
}
|
|
136
|
-
|
|
201
|
+
} catch (error) {
|
|
202
|
+
err = error;
|
|
137
203
|
}
|
|
138
204
|
}
|
|
139
205
|
|
|
206
|
+
if (ctx.res.type) {
|
|
207
|
+
ctx.res.headers["content-type"] = ctx.res.type;
|
|
208
|
+
}
|
|
209
|
+
|
|
140
210
|
ctx.time._total = performance.now();
|
|
141
211
|
ctx.res.headers["server-timing"] = ctx.res.headers["server-timing"] || "";
|
|
142
212
|
Object.entries(ctx.time).forEach(([name, value], i, times) => {
|
|
@@ -147,21 +217,26 @@ export default function (options = {}, plugins) {
|
|
|
147
217
|
ctx.res.headers["server-timing"] += ", ";
|
|
148
218
|
}
|
|
149
219
|
});
|
|
220
|
+
|
|
221
|
+
const [encoding, compress] = findEncoding(ctx.headers["accept-encoding"]);
|
|
222
|
+
if (encoding) {
|
|
223
|
+
ctx.res.headers["content-encoding"] = encoding;
|
|
224
|
+
}
|
|
225
|
+
|
|
150
226
|
res.writeHead(ctx.res.status, ctx.res.headers);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
await pipeline(ctx.res.body, res);
|
|
227
|
+
|
|
228
|
+
// If it's not a pipe, e.g. a String, make it a pipe
|
|
229
|
+
if (!ctx.res.body.pipe) {
|
|
230
|
+
ctx.res.body = Readable.from([ctx.res.body]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (compress) {
|
|
234
|
+
await pipeline(ctx.res.body, compress(), measure(ctx), res);
|
|
162
235
|
} else {
|
|
163
|
-
|
|
236
|
+
await pipeline(ctx.res.body, measure(ctx), res);
|
|
164
237
|
}
|
|
238
|
+
res.end();
|
|
239
|
+
|
|
165
240
|
// The actual sent headers, as seen by the response
|
|
166
241
|
ctx.res.headers = Object.fromEntries(
|
|
167
242
|
res._header
|
|
@@ -175,6 +250,9 @@ export default function (options = {}, plugins) {
|
|
|
175
250
|
);
|
|
176
251
|
|
|
177
252
|
logger.end(ctx);
|
|
253
|
+
if (err) {
|
|
254
|
+
logger.error(err);
|
|
255
|
+
}
|
|
178
256
|
});
|
|
179
257
|
|
|
180
258
|
const app = createApp(server);
|
|
@@ -188,7 +266,7 @@ export default function (options = {}, plugins) {
|
|
|
188
266
|
}
|
|
189
267
|
} else {
|
|
190
268
|
app.events.ready.forEach((cb) => cb({ options }));
|
|
191
|
-
|
|
269
|
+
logger.header({ api, options });
|
|
192
270
|
}
|
|
193
271
|
});
|
|
194
272
|
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import color from "./color.js";
|
|
2
|
+
|
|
3
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
4
|
+
|
|
5
|
+
const header = function ({ api, options }) {
|
|
6
|
+
if (isProduction) return;
|
|
7
|
+
const routes = Object.values(api).flat().length;
|
|
8
|
+
console.clear();
|
|
9
|
+
console.log(
|
|
10
|
+
color(
|
|
11
|
+
`{green}Started server successfully{/} (${routes} routes). See app in the browser:
|
|
12
|
+
|
|
13
|
+
🔗 {under}http://localhost:${options.port}/{/}
|
|
14
|
+
|
|
15
|
+
{dim}Press [ctrl+c] to exit the server.{/}
|
|
16
|
+
`
|
|
17
|
+
)
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default { header };
|
package/src/parseBody.js
CHANGED
|
@@ -23,17 +23,32 @@ function getMatching(string, regex) {
|
|
|
23
23
|
|
|
24
24
|
const getBody = async (req) => {
|
|
25
25
|
return await new Promise((done) => {
|
|
26
|
-
|
|
26
|
+
const buffers = [];
|
|
27
27
|
req.on("data", (chunk) => {
|
|
28
|
-
|
|
28
|
+
buffers.push(chunk);
|
|
29
29
|
});
|
|
30
30
|
req.on("end", () => {
|
|
31
|
-
done(
|
|
31
|
+
done(Buffer.concat(buffers).toString("binary"));
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
const nanoid = (size = 12) => {
|
|
37
|
+
let str = "";
|
|
38
|
+
while (str.length < size + 2) {
|
|
39
|
+
str += Math.round(Math.random() * 1000000).toString(16);
|
|
40
|
+
}
|
|
41
|
+
return str.slice(0, size);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const saveFile = async (name, value, bucket) => {
|
|
45
|
+
const ext = name.split(".").pop();
|
|
46
|
+
const id = `file-${nanoid(12)}.${ext}`;
|
|
47
|
+
await bucket.write(id, value, "binary");
|
|
48
|
+
return id;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export default async function Parse(req, contentType, bucket) {
|
|
37
52
|
const rawData = await (typeof req === "string" ? req : getBody(req));
|
|
38
53
|
if (!rawData) return null;
|
|
39
54
|
|
|
@@ -44,10 +59,7 @@ export default async function Parse(req, contentType) {
|
|
|
44
59
|
const boundary = getBoundary(contentType);
|
|
45
60
|
if (!boundary) return null;
|
|
46
61
|
|
|
47
|
-
let result = {};
|
|
48
|
-
|
|
49
62
|
const body = {};
|
|
50
|
-
const files = {};
|
|
51
63
|
|
|
52
64
|
const rawDataArray = rawData.split(boundary);
|
|
53
65
|
for (let item of rawDataArray) {
|
|
@@ -56,41 +68,26 @@ export default async function Parse(req, contentType) {
|
|
|
56
68
|
.trim()
|
|
57
69
|
.replace(/\[\]$/, "");
|
|
58
70
|
if (!name) continue;
|
|
59
|
-
|
|
71
|
+
|
|
72
|
+
let value = getMatching(item, /(?:\r\n\r\n)([\S\s]*)(?:\r\n--$)/);
|
|
60
73
|
if (!value) continue;
|
|
61
74
|
|
|
75
|
+
// Check whether we have a filename. If we do, assign it to the value
|
|
62
76
|
const filename = getMatching(item, /(?:filename=")(.*?)(?:")/).trim();
|
|
63
|
-
// It's a file!
|
|
64
77
|
if (filename) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (type) {
|
|
68
|
-
file.type = type;
|
|
69
|
-
}
|
|
70
|
-
file.value = value;
|
|
78
|
+
value = await saveFile(filename, value, bucket);
|
|
79
|
+
}
|
|
71
80
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
files[name].push(file);
|
|
78
|
-
} else {
|
|
79
|
-
files[name] = file;
|
|
81
|
+
// Save the key-value, accounting for possibly repeated keys
|
|
82
|
+
if (body[name]) {
|
|
83
|
+
if (!Array.isArray(body[name])) {
|
|
84
|
+
body[name] = [body[name]];
|
|
80
85
|
}
|
|
81
|
-
|
|
82
|
-
// It's a body
|
|
86
|
+
body[name].push(value);
|
|
83
87
|
} else {
|
|
84
|
-
|
|
85
|
-
if (!Array.isArray(body[name])) {
|
|
86
|
-
body[name] = [body[name]];
|
|
87
|
-
}
|
|
88
|
-
body[name].push(value);
|
|
89
|
-
} else {
|
|
90
|
-
body[name] = value;
|
|
91
|
-
}
|
|
88
|
+
body[name] = value;
|
|
92
89
|
}
|
|
93
90
|
}
|
|
94
91
|
|
|
95
|
-
return
|
|
92
|
+
return body;
|
|
96
93
|
}
|
package/src/parseBody.test.js
CHANGED
|
@@ -36,34 +36,23 @@ const getBody = () => {
|
|
|
36
36
|
return body;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
const matchMd = expect.stringMatching(/^file-\w{12}.md$/);
|
|
40
|
+
const matchTxt = expect.stringMatching(/^file-\w{12}.txt$/);
|
|
41
|
+
|
|
39
42
|
describe("parseBody", () => {
|
|
40
43
|
it("can parse the example body", async () => {
|
|
41
|
-
const
|
|
44
|
+
const body = await parseBody(
|
|
42
45
|
getBody(),
|
|
43
|
-
"multipart/form-data; boundary=----WebKitFormBoundaryvef1fLxmoUdYZWXp"
|
|
46
|
+
"multipart/form-data; boundary=----WebKitFormBoundaryvef1fLxmoUdYZWXp",
|
|
47
|
+
{ write: (id) => id }
|
|
44
48
|
);
|
|
45
|
-
expect(
|
|
49
|
+
expect(body).toMatchObject({
|
|
46
50
|
hello: "world",
|
|
47
51
|
test: ["test message 123456", "test message number two"],
|
|
48
52
|
});
|
|
49
|
-
expect(
|
|
50
|
-
profile:
|
|
51
|
-
|
|
52
|
-
type: "text/plain",
|
|
53
|
-
value: "@11X111Y\r\n111Z\rCCCC\nCCCC\r\nCCCCC@\r\n",
|
|
54
|
-
},
|
|
55
|
-
gallery: [
|
|
56
|
-
{
|
|
57
|
-
name: "A.txt",
|
|
58
|
-
type: "text/plain",
|
|
59
|
-
value: "@11X111Y\r\n111Z\rCCCC\nCCCC\r\nCCCCC@\r\n",
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
name: "C.txt",
|
|
63
|
-
type: "text/plain",
|
|
64
|
-
value: "@CCCCCCY\r\nCCCZ\rCCCW\nCCC0\r\n666@",
|
|
65
|
-
},
|
|
66
|
-
],
|
|
53
|
+
expect(body).toMatchObject({
|
|
54
|
+
profile: matchMd,
|
|
55
|
+
gallery: [matchTxt, matchTxt],
|
|
67
56
|
});
|
|
68
57
|
});
|
|
69
58
|
});
|