@server/next 0.16.0 → 0.17.2
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 +35 -11
- 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 +98 -57
- 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.2",
|
|
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,18 +37,25 @@ 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
|
|
|
43
50
|
- Router has all verbs, as well as URL pattern matches
|
|
44
51
|
- Full URL parsing, including `query` and `params` in ctx.url.
|
|
45
|
-
- Body and Files parsing is working (
|
|
52
|
+
- Body and Files parsing is working (need testing)
|
|
46
53
|
- The middleware can return:
|
|
47
54
|
- A number and it'll be set as the status code
|
|
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
|
|
|
@@ -63,7 +70,7 @@ import Redis from 'redis';
|
|
|
63
70
|
const bucket = Bucket('my-bucket', { id, key });
|
|
64
71
|
const cache = Redis('my-redis', ...);
|
|
65
72
|
|
|
66
|
-
const app = server({ public:
|
|
73
|
+
const app = server({ public: bucket, uploads: bucket, cache });
|
|
67
74
|
|
|
68
75
|
app([
|
|
69
76
|
post('/uploads', ctx => {
|
|
@@ -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.status = 200;
|
|
150
|
+
res.body = data;
|
|
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; // user set > default
|
|
163
|
+
return res;
|
|
120
164
|
};
|
|
121
165
|
|
|
122
166
|
export default function (options = {}, plugins) {
|
|
@@ -126,46 +170,44 @@ 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;
|
|
138
|
-
|
|
186
|
+
let err;
|
|
187
|
+
// Will (hopefully) be overwritten later on!
|
|
188
|
+
ctx.res = { status: 500, headers: {} };
|
|
139
189
|
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;
|
|
190
|
+
try {
|
|
191
|
+
if (err) {
|
|
192
|
+
if (cb.handle) {
|
|
193
|
+
out = await cb.handle(err);
|
|
163
194
|
}
|
|
195
|
+
} else if (typeof cb === "function") {
|
|
196
|
+
out = await cb(ctx);
|
|
197
|
+
}
|
|
198
|
+
if (out) {
|
|
199
|
+
ctx.res = await parseOutput(ctx.res, out);
|
|
200
|
+
break;
|
|
164
201
|
}
|
|
165
|
-
|
|
202
|
+
} catch (error) {
|
|
203
|
+
err = error;
|
|
166
204
|
}
|
|
167
205
|
}
|
|
168
206
|
|
|
207
|
+
if (ctx.res.type) {
|
|
208
|
+
ctx.res.headers["content-type"] = ctx.res.type;
|
|
209
|
+
}
|
|
210
|
+
|
|
169
211
|
ctx.time._total = performance.now();
|
|
170
212
|
ctx.res.headers["server-timing"] = ctx.res.headers["server-timing"] || "";
|
|
171
213
|
Object.entries(ctx.time).forEach(([name, value], i, times) => {
|
|
@@ -189,10 +231,6 @@ export default function (options = {}, plugins) {
|
|
|
189
231
|
ctx.res.body = Readable.from([ctx.res.body]);
|
|
190
232
|
}
|
|
191
233
|
|
|
192
|
-
if (ctx.res.body.path) {
|
|
193
|
-
ctx.res.type = ctx.res.body.path.split(".").pop();
|
|
194
|
-
}
|
|
195
|
-
|
|
196
234
|
if (compress) {
|
|
197
235
|
await pipeline(ctx.res.body, compress(), measure(ctx), res);
|
|
198
236
|
} else {
|
|
@@ -213,6 +251,9 @@ export default function (options = {}, plugins) {
|
|
|
213
251
|
);
|
|
214
252
|
|
|
215
253
|
logger.end(ctx);
|
|
254
|
+
if (err) {
|
|
255
|
+
logger.error(err);
|
|
256
|
+
}
|
|
216
257
|
});
|
|
217
258
|
|
|
218
259
|
const app = createApp(server);
|
|
@@ -226,7 +267,7 @@ export default function (options = {}, plugins) {
|
|
|
226
267
|
}
|
|
227
268
|
} else {
|
|
228
269
|
app.events.ready.forEach((cb) => cb({ options }));
|
|
229
|
-
|
|
270
|
+
logger.header({ api, options });
|
|
230
271
|
}
|
|
231
272
|
});
|
|
232
273
|
|
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
|
});
|