@server/next 0.13.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,289 @@
1
+ import http from "node:http";
2
+ import fs from "node:fs";
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { pipeline } from "node:stream/promises";
6
+
7
+ import "dotenv/config";
8
+
9
+ import ServerUrl from "./ServerUrl.js";
10
+ import RequestLogger from "./RequestLogger.js";
11
+
12
+ import pathPattern from "./pathPattern.js";
13
+ import parseBody from "./parseBody.js";
14
+ import color from "./color.js";
15
+
16
+ const isProduction = process.env.NODE_ENV === "production";
17
+
18
+ // https://stackoverflow.com/a/19524949/938236
19
+ const getIp = (req) =>
20
+ (req.headers["x-forwarded-for"] || "").split(",").pop() ||
21
+ req.connection.remoteAddress ||
22
+ req.socket.remoteAddress ||
23
+ req.connection.socket.remoteAddress;
24
+
25
+ const exists = (file) => {
26
+ return fsp.stat(file, fs.constants.F_OK).then(
27
+ (stat) => stat.isFile(),
28
+ () => false
29
+ );
30
+ };
31
+
32
+ const api = {
33
+ get: [],
34
+ post: [],
35
+ put: [],
36
+ patch: [],
37
+ del: [],
38
+ options: [],
39
+ head: [],
40
+ };
41
+
42
+ const getCtx = (req) => ({
43
+ url: new ServerUrl(req.protocol + "://" + req.headers["host"] + req.url),
44
+ method: req.method.toLowerCase(),
45
+ headers: req.headers,
46
+ ip: getIp(req),
47
+ time: { _init: performance.now() },
48
+ api,
49
+ req,
50
+ });
51
+
52
+ const createApp = (server) => {
53
+ const app = function (...mid) {
54
+ app.middleware.push(...mid.flat());
55
+ };
56
+
57
+ app.middleware = [
58
+ 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
+ }
68
+ }
69
+ },
70
+ ];
71
+
72
+ app.events = {
73
+ error: [],
74
+ ready: [],
75
+ };
76
+
77
+ app.on = (name, cb) => app.events[name].push(cb);
78
+ app.close = () => server.close();
79
+
80
+ return app;
81
+ };
82
+
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
+ );
89
+ };
90
+
91
+ export default function (options = {}, plugins) {
92
+ if (!options.public) {
93
+ options.public = "public";
94
+ }
95
+
96
+ const server = http.createServer(async (req, res) => {
97
+ const logger = new RequestLogger(req);
98
+ const ctx = { ...getCtx(req), options };
99
+
100
+ const parsed = await parseBody(ctx.req, ctx.headers["content-type"]);
101
+ if (parsed) {
102
+ ctx.body = parsed.body;
103
+ ctx.files = parsed.files;
104
+ }
105
+
106
+ let out;
107
+ ctx.res = { headers: {} };
108
+ 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;
134
+ }
135
+ }
136
+ break;
137
+ }
138
+ }
139
+
140
+ ctx.time._total = performance.now();
141
+ ctx.res.headers["server-timing"] = ctx.res.headers["server-timing"] || "";
142
+ Object.entries(ctx.time).forEach(([name, value], i, times) => {
143
+ if (name === "_init") return; // Index = 0
144
+ ctx.res.headers["server-timing"] +=
145
+ name + ";dur=" + Math.round(value - times[i - 1][1]);
146
+ if (i !== times.length - 1) {
147
+ ctx.res.headers["server-timing"] += ", ";
148
+ }
149
+ });
150
+ 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);
162
+ } else {
163
+ res.end(ctx.res.body);
164
+ }
165
+ // The actual sent headers, as seen by the response
166
+ ctx.res.headers = Object.fromEntries(
167
+ res._header
168
+ .split("\r\n")
169
+ .filter(Boolean)
170
+ .slice(1)
171
+ .map((line) => {
172
+ const [key, ...vals] = line.split(":");
173
+ return [key.toLowerCase(), vals.join(":").trim()];
174
+ })
175
+ );
176
+
177
+ logger.end(ctx);
178
+ });
179
+
180
+ const app = createApp(server);
181
+
182
+ server.listen(options.port, (error) => {
183
+ options.port = server.address().port;
184
+ if (error) {
185
+ app.events.error.forEach((cb) => cb(error));
186
+ if (!isProduction) {
187
+ console.error("Error:", error);
188
+ }
189
+ } else {
190
+ app.events.ready.forEach((cb) => cb({ options }));
191
+ logStart(options.port);
192
+ }
193
+ });
194
+
195
+ return app;
196
+ }
197
+
198
+ export const get = (pattern, callback) => {
199
+ api.get.push([pattern, callback]);
200
+
201
+ return (ctx) => {
202
+ if (ctx.method !== "get") return;
203
+ const match = pathPattern(pattern, ctx.url.path);
204
+ if (!match) return null;
205
+ ctx.url.params = match;
206
+ return callback(ctx);
207
+ };
208
+ };
209
+
210
+ export const post = (pattern, callback) => {
211
+ api.post.push([pattern, callback]);
212
+
213
+ return (ctx) => {
214
+ if (ctx.method !== "post") return;
215
+ const match = pathPattern(pattern, ctx.url.path);
216
+ if (!match) return null;
217
+ ctx.url.params = match;
218
+ return callback(ctx);
219
+ };
220
+ };
221
+
222
+ export const put = (pattern, callback) => {
223
+ api.put.push([pattern, callback]);
224
+
225
+ return (ctx) => {
226
+ if (ctx.method !== "put") return;
227
+ const match = pathPattern(pattern, ctx.url.path);
228
+ if (!match) return null;
229
+ ctx.url.params = match;
230
+ return callback(ctx);
231
+ };
232
+ };
233
+
234
+ export const patch = (pattern, callback) => {
235
+ api.patch.push([pattern, callback]);
236
+
237
+ return (ctx) => {
238
+ if (ctx.method !== "patch") return;
239
+ const match = pathPattern(pattern, ctx.url.path);
240
+ if (!match) return null;
241
+ ctx.url.params = match;
242
+ return callback(ctx);
243
+ };
244
+ };
245
+
246
+ export const del = (pattern, callback) => {
247
+ api.del.push([pattern, callback]);
248
+
249
+ return (ctx) => {
250
+ if (ctx.method !== "delete") return;
251
+ const match = pathPattern(pattern, ctx.url.path);
252
+ if (!match) return null;
253
+ ctx.url.params = match;
254
+ return callback(ctx);
255
+ };
256
+ };
257
+
258
+ export const options = (pattern, callback) => {
259
+ api.options.push([pattern, callback]);
260
+
261
+ return (ctx) => {
262
+ if (ctx.method !== "options") return;
263
+ const match = pathPattern(pattern, ctx.url.path);
264
+ if (!match) return null;
265
+ ctx.url.params = match;
266
+ return callback(ctx);
267
+ };
268
+ };
269
+
270
+ export const head = (pattern, callback) => {
271
+ api.head.push([pattern, callback]);
272
+
273
+ return (ctx) => {
274
+ if (ctx.method !== "head") return;
275
+ const match = pathPattern(pattern, ctx.url.path);
276
+ if (!match) return null;
277
+ ctx.url.params = match;
278
+ return callback(ctx);
279
+ };
280
+ };
281
+
282
+ export const use = (pattern, callback) => {
283
+ return (ctx) => {
284
+ const match = pathPattern(pattern, ctx.url.path);
285
+ if (!match) return null;
286
+ ctx.url.params = match;
287
+ return callback(ctx);
288
+ };
289
+ };
@@ -0,0 +1,96 @@
1
+ function getBoundary(header) {
2
+ if (!header) return null;
3
+ var items = header.split(";");
4
+ if (items)
5
+ for (var j = 0; j < items.length; j++) {
6
+ var item = new String(items[j]).trim();
7
+ if (item.indexOf("boundary") >= 0) {
8
+ var k = item.split("=");
9
+ return new String(k[1]).trim();
10
+ }
11
+ }
12
+ return null;
13
+ }
14
+
15
+ function getMatching(string, regex) {
16
+ // Helper function when using non-matching groups
17
+ const matches = string.match(regex);
18
+ if (!matches || matches.length < 2) {
19
+ return "";
20
+ }
21
+ return matches[1];
22
+ }
23
+
24
+ const getBody = async (req) => {
25
+ return await new Promise((done) => {
26
+ let data = "";
27
+ req.on("data", (chunk) => {
28
+ data += chunk;
29
+ });
30
+ req.on("end", () => {
31
+ done(data);
32
+ });
33
+ });
34
+ };
35
+
36
+ export default async function Parse(req, contentType) {
37
+ const rawData = await (typeof req === "string" ? req : getBody(req));
38
+ if (!rawData) return null;
39
+
40
+ if (/application\/json/.test(contentType)) {
41
+ return { body: JSON.parse(rawData), files: {} };
42
+ }
43
+
44
+ const boundary = getBoundary(contentType);
45
+ if (!boundary) return null;
46
+
47
+ let result = {};
48
+
49
+ const body = {};
50
+ const files = {};
51
+
52
+ const rawDataArray = rawData.split(boundary);
53
+ for (let item of rawDataArray) {
54
+ // Use non-matching groups to exclude part of the result
55
+ const name = getMatching(item, /(?:name=")(.+?)(?:")/)
56
+ .trim()
57
+ .replace(/\[\]$/, "");
58
+ if (!name) continue;
59
+ const value = getMatching(item, /(?:\r\n\r\n)([\S\s]*)(?:\r\n--$)/);
60
+ if (!value) continue;
61
+
62
+ const filename = getMatching(item, /(?:filename=")(.*?)(?:")/).trim();
63
+ // It's a file!
64
+ 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;
71
+
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;
80
+ }
81
+
82
+ // It's a body
83
+ } 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
+ }
92
+ }
93
+ }
94
+
95
+ return { body, files };
96
+ }
@@ -0,0 +1,69 @@
1
+ import parseBody from "./parseBody.js";
2
+
3
+ const getBody = () => {
4
+ let body = "trash1\r\n";
5
+ body += "------WebKitFormBoundaryvef1fLxmoUdYZWXp\r\n";
6
+ body += 'Content-Disposition: form-data; name="hello";\r\n\r\n';
7
+ body += "world\r\n";
8
+ body += "------WebKitFormBoundaryvef1fLxmoUdYZWXp\r\n";
9
+ body +=
10
+ 'Content-Disposition: form-data; name="profile"; filename="profile.md"\r\n';
11
+ body += "Content-Type: text/plain\r\n\r\n";
12
+ body += "@11X";
13
+ body += "111Y\r\n";
14
+ body += "111Z\rCCCC\nCCCC\r\nCCCCC@\r\n\r\n";
15
+ body += "------WebKitFormBoundaryvef1fLxmoUdYZWXp\r\n";
16
+ body +=
17
+ 'Content-Disposition: form-data; name="gallery[]"; filename="A.txt"\r\n';
18
+ body += "Content-Type: text/plain\r\n\r\n";
19
+ body += "@11X";
20
+ body += "111Y\r\n";
21
+ body += "111Z\rCCCC\nCCCC\r\nCCCCC@\r\n\r\n";
22
+ body += "------WebKitFormBoundaryvef1fLxmoUdYZWXp\r\n";
23
+ body += 'Content-Disposition: form-data; name="test";\r\n\r\n';
24
+ body += "test message 123456\r\n";
25
+ body += "------WebKitFormBoundaryvef1fLxmoUdYZWXp\r\n";
26
+ body += 'Content-Disposition: form-data; name="test";\r\n\r\n';
27
+ body += "test message number two\r\n";
28
+ body += "------WebKitFormBoundaryvef1fLxmoUdYZWXp\r\n";
29
+ body +=
30
+ 'Content-Disposition: form-data; name="gallery[]"; filename="C.txt"\r\n';
31
+ body += "Content-Type: text/plain\r\n\r\n";
32
+ body += "@CCC";
33
+ body += "CCCY\r\n";
34
+ body += "CCCZ\rCCCW\nCCC0\r\n666@\r\n";
35
+ body += "------WebKitFormBoundaryvef1fLxmoUdYZWXp--\r\n";
36
+ return body;
37
+ };
38
+
39
+ describe("parseBody", () => {
40
+ it("can parse the example body", async () => {
41
+ const parsed = await parseBody(
42
+ getBody(),
43
+ "multipart/form-data; boundary=----WebKitFormBoundaryvef1fLxmoUdYZWXp"
44
+ );
45
+ expect(parsed.body).toEqual({
46
+ hello: "world",
47
+ test: ["test message 123456", "test message number two"],
48
+ });
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
+ ],
67
+ });
68
+ });
69
+ });
@@ -0,0 +1,24 @@
1
+ import { URLPattern } from "urlpattern-polyfill";
2
+
3
+ export default function pathPattern(pattern, path) {
4
+ pattern = pattern.replace(/\/$/, "") || "/";
5
+ path = path.replace(/\/$/, "") || "/";
6
+ const origin =
7
+ typeof location === "object" ? location.origin : "https://example.com/";
8
+ const patt = new URLPattern(pattern, origin);
9
+ const match = patt.exec(path, origin);
10
+ // console.log(match, path, pattern);
11
+ if (!match) return false;
12
+ const groups = match.pathname.groups;
13
+ const rest = Object.keys(groups)
14
+ .filter((k) => /^\d+$/.test(k))
15
+ .reduce((all, key) => {
16
+ const value = groups[key];
17
+ delete groups[key];
18
+ if (!value) return all;
19
+ all = all.concat(...value.split("/"));
20
+ return all;
21
+ }, []);
22
+ if (rest.length) groups["*"] = rest;
23
+ return groups;
24
+ }
@@ -0,0 +1,72 @@
1
+ import pathPattern from "./pathPattern.js";
2
+
3
+ describe("pathPattern.js", () => {
4
+ it("matches the same string", () => {
5
+ expect(pathPattern("/hello", "/hello")).toEqual({});
6
+ expect(pathPattern("/hello/world", "/hello/world")).toEqual({});
7
+ });
8
+
9
+ it("is trailing slash insensitive both ways", () => {
10
+ expect(pathPattern("/hello", "/hello")).toEqual({});
11
+ expect(pathPattern("/hello", "/hello/")).toEqual({});
12
+ expect(pathPattern("/hello/", "/hello")).toEqual({});
13
+ expect(pathPattern("/hello/", "/hello/")).toEqual({});
14
+
15
+ expect(pathPattern("/hello/world", "/hello/world")).toEqual({});
16
+ expect(pathPattern("/hello/world", "/hello/world/")).toEqual({});
17
+ expect(pathPattern("/hello/world/", "/hello/world")).toEqual({});
18
+ expect(pathPattern("/hello/world/", "/hello/world/")).toEqual({});
19
+ });
20
+
21
+ it("doesn't do partial matches", () => {
22
+ expect(pathPattern("/hello", "/hello/John")).toEqual(false);
23
+ expect(pathPattern("/hello/", "/hello/John")).toEqual(false);
24
+ });
25
+
26
+ it("can capture simple groups", () => {
27
+ expect(pathPattern("/:hello", "/john")).toEqual({ hello: "john" });
28
+ expect(pathPattern("/hello/:there", "/hello/john")).toEqual({
29
+ there: "john",
30
+ });
31
+ });
32
+
33
+ it("requires a part for the asterisk", () => {
34
+ expect(pathPattern("/hello/:there/*", "/hello/John")).toEqual(false);
35
+ });
36
+
37
+ it("can make a part optional", () => {
38
+ // expect(pathPattern("/:name?", "/")).toEqual({});
39
+ expect(pathPattern("/hello/:name?", "/hello/")).toEqual({});
40
+ expect(pathPattern("/:name?", "/john")).toEqual({ name: "john" });
41
+ expect(pathPattern("/:name/*?", "/john")).toEqual({ name: "john" });
42
+ });
43
+
44
+ it("correctly matches the asterisk as an array of parts", () => {
45
+ expect(pathPattern("/*", "/john")).toEqual({ "*": ["john"] });
46
+ expect(pathPattern("/*", "/john/doe")).toEqual({ "*": ["john", "doe"] });
47
+ expect(pathPattern("/*/*", "/john/doe")).toEqual({ "*": ["john", "doe"] });
48
+
49
+ expect(pathPattern("/hello/*", "/hello/john")).toEqual({ "*": ["john"] });
50
+ expect(pathPattern("/hello/*", "/hello/john/doe")).toEqual({
51
+ "*": ["john", "doe"],
52
+ });
53
+ expect(pathPattern("/hello/*/*", "/hello/john/doe")).toEqual({
54
+ "*": ["john", "doe"],
55
+ });
56
+
57
+ expect(pathPattern("/:name/*", "/john/doe")).toEqual({
58
+ name: "john",
59
+ "*": ["doe"],
60
+ });
61
+
62
+ expect(pathPattern("/:name/*", "/john/doe/derek")).toEqual({
63
+ name: "john",
64
+ "*": ["doe", "derek"],
65
+ });
66
+
67
+ expect(pathPattern("/:name/*/*", "/john/doe/derek")).toEqual({
68
+ name: "john",
69
+ "*": ["doe", "derek"],
70
+ });
71
+ });
72
+ });