@server/next 0.16.0 → 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 +69 -16
- package/src/bucket.js +28 -0
- package/src/color.js +6 -2
- package/src/getMime.js +48 -0
- package/src/index.js +96 -56
- 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, streaming, server-timing, plugins\*, etc.
|
|
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,6 +18,15 @@ const format = (n, [below, above], limit = Infinity) => {
|
|
|
17
18
|
}
|
|
18
19
|
};
|
|
19
20
|
|
|
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;
|
|
28
|
+
};
|
|
29
|
+
|
|
20
30
|
function simpleType(type) {
|
|
21
31
|
const simpler = {
|
|
22
32
|
"text/html": "html",
|
|
@@ -24,6 +34,7 @@ function simpleType(type) {
|
|
|
24
34
|
"image/svg+xml": "svg",
|
|
25
35
|
"image/png": "png",
|
|
26
36
|
"text/css": "css",
|
|
37
|
+
"text/javascript": "js",
|
|
27
38
|
"application/javascript": "js",
|
|
28
39
|
"application/json": "json",
|
|
29
40
|
"text/markdown": "md",
|
|
@@ -31,6 +42,8 @@ function simpleType(type) {
|
|
|
31
42
|
return simpler[type] || type;
|
|
32
43
|
}
|
|
33
44
|
|
|
45
|
+
const cwd = path.resolve("./");
|
|
46
|
+
|
|
34
47
|
let spinnies;
|
|
35
48
|
export default function RequestLogger(ctx) {
|
|
36
49
|
this.id = Math.round(Math.random() * 100000);
|
|
@@ -45,26 +58,66 @@ export default function RequestLogger(ctx) {
|
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
this.end = function (ctx) {
|
|
48
|
-
if (
|
|
49
|
-
|
|
61
|
+
if (isProduction) return;
|
|
62
|
+
const status = ctx.res.status;
|
|
50
63
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
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);
|
|
58
70
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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}`);
|
|
66
78
|
|
|
79
|
+
this.text = text;
|
|
80
|
+
if (statColor !== "red") {
|
|
67
81
|
spinnies.succeed(`spinner-${this.id}`, { text });
|
|
82
|
+
} else {
|
|
83
|
+
spinnies.fail(`spinner-${this.id}`, { text });
|
|
68
84
|
}
|
|
69
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
|
+
};
|
|
70
123
|
}
|
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,3 +1,5 @@
|
|
|
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";
|
|
@@ -6,14 +8,13 @@ import zlib from "node:zlib";
|
|
|
6
8
|
import { pipeline } from "node:stream/promises";
|
|
7
9
|
import { Readable, PassThrough } from "node:stream";
|
|
8
10
|
|
|
9
|
-
import "dotenv/config";
|
|
10
|
-
|
|
11
11
|
import ServerUrl from "./ServerUrl.js";
|
|
12
12
|
import RequestLogger from "./RequestLogger.js";
|
|
13
13
|
|
|
14
|
+
import logger from "./logger.js";
|
|
15
|
+
import getMime from "./getMime.js";
|
|
14
16
|
import pathPattern from "./pathPattern.js";
|
|
15
17
|
import parseBody from "./parseBody.js";
|
|
16
|
-
import color from "./color.js";
|
|
17
18
|
|
|
18
19
|
const isProduction = process.env.NODE_ENV === "production";
|
|
19
20
|
|
|
@@ -24,13 +25,6 @@ const getIp = (req) =>
|
|
|
24
25
|
req.socket.remoteAddress ||
|
|
25
26
|
req.connection.socket.remoteAddress;
|
|
26
27
|
|
|
27
|
-
const exists = (file) => {
|
|
28
|
-
return fsp.stat(file, fs.constants.F_OK).then(
|
|
29
|
-
(stat) => stat.isFile(),
|
|
30
|
-
() => false
|
|
31
|
-
);
|
|
32
|
-
};
|
|
33
|
-
|
|
34
28
|
const measure = (ctx) => {
|
|
35
29
|
const sizeUp = new PassThrough();
|
|
36
30
|
ctx.res.size = 0;
|
|
@@ -83,19 +77,32 @@ const getCtx = (req) => ({
|
|
|
83
77
|
const createApp = (server) => {
|
|
84
78
|
const app = function (...mid) {
|
|
85
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
|
+
});
|
|
86
93
|
};
|
|
87
94
|
|
|
95
|
+
// Static middleware
|
|
88
96
|
app.middleware = [
|
|
89
97
|
async function (ctx) {
|
|
90
|
-
if (ctx.method
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
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);
|
|
99
106
|
}
|
|
100
107
|
},
|
|
101
108
|
];
|
|
@@ -111,12 +118,49 @@ const createApp = (server) => {
|
|
|
111
118
|
return app;
|
|
112
119
|
};
|
|
113
120
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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;
|
|
120
164
|
};
|
|
121
165
|
|
|
122
166
|
export default function (options = {}, plugins) {
|
|
@@ -126,46 +170,43 @@ export default function (options = {}, plugins) {
|
|
|
126
170
|
|
|
127
171
|
const server = http.createServer(async (req, res) => {
|
|
128
172
|
const logger = new RequestLogger(req);
|
|
129
|
-
const ctx = { ...getCtx(req), options };
|
|
173
|
+
const ctx = { ...getCtx(req), bucket: options.bucket, options };
|
|
130
174
|
|
|
131
|
-
const parsed = await parseBody(
|
|
175
|
+
const parsed = await parseBody(
|
|
176
|
+
ctx.req,
|
|
177
|
+
ctx.headers["content-type"],
|
|
178
|
+
ctx.bucket
|
|
179
|
+
);
|
|
132
180
|
if (parsed) {
|
|
133
181
|
ctx.body = parsed.body;
|
|
134
182
|
ctx.files = parsed.files;
|
|
135
183
|
}
|
|
136
184
|
|
|
137
185
|
let out;
|
|
186
|
+
let err;
|
|
138
187
|
ctx.res = { headers: {} };
|
|
139
188
|
for (let cb of app.middleware) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
ctx.res.status = out;
|
|
145
|
-
ctx.res.body = "";
|
|
146
|
-
ctx.res.headers["content-type"] = "text/plain";
|
|
147
|
-
} else if (typeof out === "string") {
|
|
148
|
-
// Plain string
|
|
149
|
-
ctx.res.status = 200;
|
|
150
|
-
ctx.res.body = out;
|
|
151
|
-
const isHtml = out.trim().startsWith("<");
|
|
152
|
-
ctx.res.headers["content-type"] = isHtml ? "text/html" : "text/plain";
|
|
153
|
-
} else {
|
|
154
|
-
if (out.pipe) {
|
|
155
|
-
ctx.res.body = out;
|
|
156
|
-
ctx.res.status = 200;
|
|
157
|
-
} else {
|
|
158
|
-
// Plain object
|
|
159
|
-
if (out.type) ctx.res.headers["content-type"] = out.type;
|
|
160
|
-
ctx.res.headers = { ...ctx.res.headers, ...(out.headers || {}) };
|
|
161
|
-
ctx.res.body = out.body || "";
|
|
162
|
-
ctx.res.status = out.status || 200;
|
|
189
|
+
try {
|
|
190
|
+
if (err) {
|
|
191
|
+
if (cb.handle) {
|
|
192
|
+
out = await cb.handle(err);
|
|
163
193
|
}
|
|
194
|
+
} else {
|
|
195
|
+
out = await cb(ctx);
|
|
164
196
|
}
|
|
165
|
-
|
|
197
|
+
if (out) {
|
|
198
|
+
ctx.res = await parseOutput(ctx.res, out);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
err = error;
|
|
166
203
|
}
|
|
167
204
|
}
|
|
168
205
|
|
|
206
|
+
if (ctx.res.type) {
|
|
207
|
+
ctx.res.headers["content-type"] = ctx.res.type;
|
|
208
|
+
}
|
|
209
|
+
|
|
169
210
|
ctx.time._total = performance.now();
|
|
170
211
|
ctx.res.headers["server-timing"] = ctx.res.headers["server-timing"] || "";
|
|
171
212
|
Object.entries(ctx.time).forEach(([name, value], i, times) => {
|
|
@@ -189,10 +230,6 @@ export default function (options = {}, plugins) {
|
|
|
189
230
|
ctx.res.body = Readable.from([ctx.res.body]);
|
|
190
231
|
}
|
|
191
232
|
|
|
192
|
-
if (ctx.res.body.path) {
|
|
193
|
-
ctx.res.type = ctx.res.body.path.split(".").pop();
|
|
194
|
-
}
|
|
195
|
-
|
|
196
233
|
if (compress) {
|
|
197
234
|
await pipeline(ctx.res.body, compress(), measure(ctx), res);
|
|
198
235
|
} else {
|
|
@@ -213,6 +250,9 @@ export default function (options = {}, plugins) {
|
|
|
213
250
|
);
|
|
214
251
|
|
|
215
252
|
logger.end(ctx);
|
|
253
|
+
if (err) {
|
|
254
|
+
logger.error(err);
|
|
255
|
+
}
|
|
216
256
|
});
|
|
217
257
|
|
|
218
258
|
const app = createApp(server);
|
|
@@ -226,7 +266,7 @@ export default function (options = {}, plugins) {
|
|
|
226
266
|
}
|
|
227
267
|
} else {
|
|
228
268
|
app.events.ready.forEach((cb) => cb({ options }));
|
|
229
|
-
|
|
269
|
+
logger.header({ api, options });
|
|
230
270
|
}
|
|
231
271
|
});
|
|
232
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
|
});
|