@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.15.1",
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 'server';
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('/users', getUsers),
18
- post('/users', createUser),
19
- put('/users/:id', editUser),
20
- use('/admin/*', dashboard)
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, 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
- ## Upgradingn server
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, all bundled in a single file. Installing and using the full library takes under 10kb (target limit).
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
+ ```
@@ -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 findSize = ({ headers, body, size }, res) => {
21
- if (size) return size;
22
- if (body) return body.length;
23
- if (headers["content-length"]) return +headers["content-length"];
24
- return 0;
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 (!isProduction) {
56
- const status = ctx.res.status;
61
+ if (isProduction) return;
62
+ const status = ctx.res.status;
57
63
 
58
- const paddedPath = `${ctx.url?.path || ctx.url} {dim}`.padEnd(30, "╌");
59
- const statColor =
60
- status < 300 ? "green" : status < 500 ? "yellow" : "red";
61
- const statusBlock = `{${statColor}}[${ctx.res.status}]{/}`;
62
- const resSize = format(findSize(ctx.res), ["b", "kb"], 100000);
63
- const t = Math.round(ctx.time._total - ctx.time._init);
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
- const type = simpleType(
67
- ctx.res.type || ctx.res.headers["content-type"] || "----"
68
- );
69
- const method = ("[" + ctx.method + "]").padEnd(6, " ");
70
- const reqText = `{dim}${method}{/} ${paddedPath}`;
71
- const resText = `${statusBlock} ${resSize} ${resTime} ${type}`;
72
- const text = color(`${reqText}╌›{/} ${resText}`);
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
- export default class ServerUrl {
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
- const url = new URL(urlString);
4
- this.href = url.href;
5
- this.origin = url.origin;
6
- this.protocol = url.protocol;
7
- this.username = url.username;
8
- this.password = url.password;
9
- this.host = url.host;
10
- this.hostname = url.hostname;
11
- this.port = url.port ? +url.port : null; // make it an intege
12
- this.pathname = url.pathname;
13
- this.path = url.pathname; // nicknam
14
- this.params = {}; // The URL parameter
15
- this.search = url.search;
16
- this.searchParams = url.searchParams;
17
- this.query = this.getQuery(url.searchParams.entries()); // As a plain object
18
- this.hash = url.hash;
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) {
@@ -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) => (process.env.NO_COLOR ? "" : `\x1b[${map[k]}m`);
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
- // For template literals, put them together first and then color it
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 exists = (file) => {
26
- return fsp.stat(file, fs.constants.F_OK).then(
27
- (stat) => stat.isFile(),
28
- () => false
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 === "get") {
60
- const file = path.join(process.cwd(), ctx.options.public, ctx.url.path);
61
- const size = await fsp.stat(file).then(
62
- (stat) => stat.isFile() && stat.size,
63
- (err) => false
64
- );
65
- if (size) {
66
- return fs.createReadStream(file);
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
- const logStart = (port) => {
84
- if (isProduction) return;
85
- const routes = Object.values(api).flat().length;
86
- console.log(
87
- color(`Running on {under}http://localhost:${port}/{/} (${routes} routes)`)
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(ctx.req, ctx.headers["content-type"]);
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
- out = await cb(ctx);
110
- if (out) {
111
- if (typeof out === "number") {
112
- // Plain number
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
- break;
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
- if (ctx.res.body.pipe) {
152
- if (ctx.res.body.path) {
153
- ctx.res.type = ctx.res.body.path.split(".").pop();
154
- ctx.res.size = await fsp.stat(ctx.res.body.path).then((s) => s.size);
155
- } else {
156
- ctx.res.size = 0;
157
- ctx.res.body.on("data", function (chunk) {
158
- ctx.res.size += chunk.length;
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
- res.end(ctx.res.body);
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
- logStart(options.port);
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
- let data = "";
26
+ const buffers = [];
27
27
  req.on("data", (chunk) => {
28
- data += chunk;
28
+ buffers.push(chunk);
29
29
  });
30
30
  req.on("end", () => {
31
- done(data);
31
+ done(Buffer.concat(buffers).toString("binary"));
32
32
  });
33
33
  });
34
34
  };
35
35
 
36
- export default async function Parse(req, contentType) {
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
- const value = getMatching(item, /(?:\r\n\r\n)([\S\s]*)(?:\r\n--$)/);
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
- const file = { name: filename };
66
- const type = getMatching(item, /(?:Content-Type:)(.*?)(?:\r\n)/).trim();
67
- if (type) {
68
- file.type = type;
69
- }
70
- file.value = value;
78
+ value = await saveFile(filename, value, bucket);
79
+ }
71
80
 
72
- // Already exists, so (maybe convert to an array) and push the item in it
73
- if (files[name]) {
74
- if (!Array.isArray(files.name)) {
75
- files[name] = [files[name]];
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
- if (body[name]) {
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 { body, files };
92
+ return body;
96
93
  }
@@ -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 parsed = await parseBody(
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(parsed.body).toEqual({
49
+ expect(body).toMatchObject({
46
50
  hello: "world",
47
51
  test: ["test message 123456", "test message number two"],
48
52
  });
49
- expect(parsed.files).toEqual({
50
- profile: {
51
- name: "profile.md",
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
  });