@server/next 0.17.2 → 0.18.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/src/index.js CHANGED
@@ -1,368 +1,143 @@
1
- import "dotenv/config";
1
+ import "./polyfill.js";
2
2
 
3
- import http from "node:http";
4
- import fs from "node:fs";
5
- import fsp from "node:fs/promises";
6
- import path from "node:path";
7
- import zlib from "node:zlib";
8
- import { pipeline } from "node:stream/promises";
9
- import { Readable, PassThrough } from "node:stream";
3
+ import Bucket from "./bucket.js";
4
+ import createNodeContext from "./context/node.js";
5
+ import createWinterContext from "./context/winter.js";
6
+ import { getMachine, handleRequest, iterate } from "./helpers/index.js";
10
7
 
11
- import ServerUrl from "./ServerUrl.js";
12
- import RequestLogger from "./RequestLogger.js";
8
+ // Export the reply helpers
9
+ export * from "./reply.js";
13
10
 
14
- import logger from "./logger.js";
15
- import getMime from "./getMime.js";
16
- import pathPattern from "./pathPattern.js";
17
- import parseBody from "./parseBody.js";
11
+ // Allow to create a sub-router
12
+ export { default as router } from "./router.js";
18
13
 
19
- const isProduction = process.env.NODE_ENV === "production";
20
-
21
- // https://stackoverflow.com/a/19524949/938236
22
- const getIp = (req) =>
23
- (req.headers["x-forwarded-for"] || "").split(",").pop() ||
24
- req.connection.remoteAddress ||
25
- req.socket.remoteAddress ||
26
- req.connection.socket.remoteAddress;
27
-
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";
14
+ // Export the main server()
15
+ export default function server(options = {}) {
16
+ if (!(this instanceof server)) {
17
+ return new server(options);
45
18
  }
46
19
 
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];
55
- };
56
-
57
- const api = {
58
- get: [],
59
- post: [],
60
- put: [],
61
- patch: [],
62
- del: [],
63
- options: [],
64
- head: [],
65
- };
20
+ this.platform = getMachine();
66
21
 
67
- const getCtx = (req) => ({
68
- url: new ServerUrl(req.protocol + "://" + req.headers["host"] + req.url),
69
- method: req.method.toLowerCase(),
70
- headers: req.headers,
71
- ip: getIp(req),
72
- time: { _init: performance.now() },
73
- api,
74
- req,
75
- });
22
+ this.handlers = {};
76
23
 
77
- const createApp = (server) => {
78
- const app = function (...mid) {
79
- app.middleware.push(...mid.flat());
24
+ options.views = options.views ? Bucket(options.views) : null;
25
+ options.public = options.public ? Bucket(options.public) : null;
26
+ options.uploads = options.uploads ? Bucket(options.uploads) : null;
80
27
 
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
- });
93
- };
28
+ this.options = options;
94
29
 
95
- // Static middleware
96
- app.middleware = [
97
- async function (ctx) {
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);
106
- }
30
+ // WEBSOCKETS stuff
31
+ this.sockets = [];
32
+ this.websocket = {
33
+ message: async (ws, body) => {
34
+ this.handlers.socket
35
+ ?.filter((s) => s[0] === "message")
36
+ ?.map((s) => s[1]({ socket: ws, sockets: this.sockets, body }));
107
37
  },
108
- ];
109
-
110
- app.events = {
111
- error: [],
112
- ready: [],
38
+ open: (ws) => this.sockets.push(ws),
39
+ close: (ws) => this.sockets.splice(this.sockets.indexOf(ws), 1),
113
40
  };
114
41
 
115
- app.on = (name, cb) => app.events[name].push(cb);
116
- app.close = () => server.close();
117
-
118
- return app;
119
- };
120
-
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;
164
- };
165
-
166
- export default function (options = {}, plugins) {
167
- if (!options.public) {
168
- options.public = "public";
169
- }
170
-
171
- const server = http.createServer(async (req, res) => {
172
- const logger = new RequestLogger(req);
173
- const ctx = { ...getCtx(req), bucket: options.bucket, options };
174
-
175
- const parsed = await parseBody(
176
- ctx.req,
177
- ctx.headers["content-type"],
178
- ctx.bucket
179
- );
180
- if (parsed) {
181
- ctx.body = parsed.body;
182
- ctx.files = parsed.files;
183
- }
184
-
185
- let out;
186
- let err;
187
- // Will (hopefully) be overwritten later on!
188
- ctx.res = { status: 500, headers: {} };
189
- for (let cb of app.middleware) {
190
- try {
191
- if (err) {
192
- if (cb.handle) {
193
- out = await cb.handle(err);
42
+ if (this.platform.runtime === "node") {
43
+ (async () => {
44
+ const http = await import("http");
45
+ http
46
+ .createServer(async (request, response) => {
47
+ const ctx = await createNodeContext(request, options);
48
+ ctx.platform = this.platform;
49
+
50
+ const out = await handleRequest(this.handlers, ctx);
51
+
52
+ response.writeHead(out.status || 200, { header: out.headers });
53
+ if (out.body instanceof ReadableStream) {
54
+ await iterate(out.body, (chunk) => response.write(chunk));
55
+ } else {
56
+ response.write(out.body || "");
194
57
  }
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;
201
- }
202
- } catch (error) {
203
- err = error;
204
- }
205
- }
206
-
207
- if (ctx.res.type) {
208
- ctx.res.headers["content-type"] = ctx.res.type;
209
- }
210
-
211
- ctx.time._total = performance.now();
212
- ctx.res.headers["server-timing"] = ctx.res.headers["server-timing"] || "";
213
- Object.entries(ctx.time).forEach(([name, value], i, times) => {
214
- if (name === "_init") return; // Index = 0
215
- ctx.res.headers["server-timing"] +=
216
- name + ";dur=" + Math.round(value - times[i - 1][1]);
217
- if (i !== times.length - 1) {
218
- ctx.res.headers["server-timing"] += ", ";
219
- }
220
- });
221
-
222
- const [encoding, compress] = findEncoding(ctx.headers["accept-encoding"]);
223
- if (encoding) {
224
- ctx.res.headers["content-encoding"] = encoding;
225
- }
226
-
227
- res.writeHead(ctx.res.status, ctx.res.headers);
228
-
229
- // If it's not a pipe, e.g. a String, make it a pipe
230
- if (!ctx.res.body.pipe) {
231
- ctx.res.body = Readable.from([ctx.res.body]);
232
- }
233
-
234
- if (compress) {
235
- await pipeline(ctx.res.body, compress(), measure(ctx), res);
236
- } else {
237
- await pipeline(ctx.res.body, measure(ctx), res);
238
- }
239
- res.end();
240
-
241
- // The actual sent headers, as seen by the response
242
- ctx.res.headers = Object.fromEntries(
243
- res._header
244
- .split("\r\n")
245
- .filter(Boolean)
246
- .slice(1)
247
- .map((line) => {
248
- const [key, ...vals] = line.split(":");
249
- return [key.toLowerCase(), vals.join(":").trim()];
58
+ response.end();
250
59
  })
251
- );
252
-
253
- logger.end(ctx);
254
- if (err) {
255
- logger.error(err);
256
- }
257
- });
60
+ .listen(options.port || 3000);
61
+ })();
62
+ }
258
63
 
259
- const app = createApp(server);
64
+ this.fetch = async (request, env, fetchCtx) => {
65
+ if (env?.upgrade(request)) return;
260
66
 
261
- server.listen(options.port, (error) => {
262
- options.port = server.address().port;
263
- if (error) {
264
- app.events.error.forEach((cb) => cb(error));
265
- if (!isProduction) {
266
- console.error("Error:", error);
267
- }
268
- } else {
269
- app.events.ready.forEach((cb) => cb({ options }));
270
- logger.header({ api, options });
271
- }
272
- });
67
+ const ctx = await createWinterContext(request, options);
68
+ ctx.platform = this.platform;
273
69
 
274
- return app;
70
+ return await handleRequest(this.handlers, ctx);
71
+ };
275
72
  }
276
73
 
277
- export const get = (pattern, callback) => {
278
- api.get.push([pattern, callback]);
279
-
280
- return (ctx) => {
281
- if (ctx.method !== "get") return;
282
- const match = pathPattern(pattern, ctx.url.path);
283
- if (!match) return null;
284
- ctx.url.params = match;
285
- return callback(ctx);
286
- };
74
+ // INTERNAL
75
+ server.prototype.handle = function (name, ...middleware) {
76
+ if (!this.handlers[name]) {
77
+ this.handlers[name] = [];
78
+ }
79
+ this.handlers[name].push(...middleware);
80
+ return this;
287
81
  };
288
82
 
289
- export const post = (pattern, callback) => {
290
- api.post.push([pattern, callback]);
291
-
292
- return (ctx) => {
293
- if (ctx.method !== "post") return;
294
- const match = pathPattern(pattern, ctx.url.path);
295
- if (!match) return null;
296
- ctx.url.params = match;
297
- return callback(ctx);
298
- };
83
+ server.prototype.socket = function (path, ...middleware) {
84
+ return this.handle("socket", [path, ...middleware]);
299
85
  };
300
86
 
301
- export const put = (pattern, callback) => {
302
- api.put.push([pattern, callback]);
303
-
304
- return (ctx) => {
305
- if (ctx.method !== "put") return;
306
- const match = pathPattern(pattern, ctx.url.path);
307
- if (!match) return null;
308
- ctx.url.params = match;
309
- return callback(ctx);
310
- };
87
+ server.prototype.get = function (path, ...middleware) {
88
+ return this.handle("get", [path, ...middleware]);
311
89
  };
312
90
 
313
- export const patch = (pattern, callback) => {
314
- api.patch.push([pattern, callback]);
91
+ server.prototype.head = function (path, ...middleware) {
92
+ return this.handle("head", [path, ...middleware]);
93
+ };
315
94
 
316
- return (ctx) => {
317
- if (ctx.method !== "patch") return;
318
- const match = pathPattern(pattern, ctx.url.path);
319
- if (!match) return null;
320
- ctx.url.params = match;
321
- return callback(ctx);
322
- };
95
+ server.prototype.post = function (path, ...middleware) {
96
+ return this.handle("post", [path, ...middleware]);
323
97
  };
324
98
 
325
- export const del = (pattern, callback) => {
326
- api.del.push([pattern, callback]);
99
+ server.prototype.put = function (path, ...middleware) {
100
+ return this.handle("put", [path, ...middleware]);
101
+ };
327
102
 
328
- return (ctx) => {
329
- if (ctx.method !== "delete") return;
330
- const match = pathPattern(pattern, ctx.url.path);
331
- if (!match) return null;
332
- ctx.url.params = match;
333
- return callback(ctx);
334
- };
103
+ server.prototype.patch = function (path, ...middleware) {
104
+ return this.handle("patch", [path, ...middleware]);
335
105
  };
336
106
 
337
- export const options = (pattern, callback) => {
338
- api.options.push([pattern, callback]);
107
+ server.prototype.del = function (path, ...middleware) {
108
+ return this.handle("del", [path, ...middleware]);
109
+ };
339
110
 
340
- return (ctx) => {
341
- if (ctx.method !== "options") return;
342
- const match = pathPattern(pattern, ctx.url.path);
343
- if (!match) return null;
344
- ctx.url.params = match;
345
- return callback(ctx);
346
- };
111
+ server.prototype.options = function (path, ...middleware) {
112
+ return this.handle("options", [path, ...middleware]);
347
113
  };
348
114
 
349
- export const head = (pattern, callback) => {
350
- api.head.push([pattern, callback]);
115
+ server.prototype.use = function (...middleware) {
116
+ let path = "*";
117
+ if (typeof middleware[0] === "string" || middleware[0] instanceof RegExp) {
118
+ path = middleware.shift();
119
+ }
351
120
 
352
- return (ctx) => {
353
- if (ctx.method !== "head") return;
354
- const match = pathPattern(pattern, ctx.url.path);
355
- if (!match) return null;
356
- ctx.url.params = match;
357
- return callback(ctx);
358
- };
121
+ this.handle("socket", [path, ...middleware]);
122
+ this.handle("get", [path, ...middleware]);
123
+ this.handle("head", [path, ...middleware]);
124
+ this.handle("post", [path, ...middleware]);
125
+ this.handle("put", [path, ...middleware]);
126
+ this.handle("patch", [path, ...middleware]);
127
+ this.handle("del", [path, ...middleware]);
128
+ this.handle("options", [path, ...middleware]);
129
+
130
+ return this;
359
131
  };
360
132
 
361
- export const use = (pattern, callback) => {
362
- return (ctx) => {
363
- const match = pathPattern(pattern, ctx.url.path);
364
- if (!match) return null;
365
- ctx.url.params = match;
366
- return callback(ctx);
367
- };
133
+ server.prototype.router = function (basePath, router) {
134
+ basePath = "/" + basePath.replace(/^\//, "").replace(/\/$/, "") + "/";
135
+ for (const method in router.handlers) {
136
+ const handlers = router.handlers[method].map(([path, ...callbacks]) => [
137
+ basePath + path.replace(/^\//, ""),
138
+ ...callbacks,
139
+ ]);
140
+ this.handle(method, ...handlers);
141
+ }
142
+ return this;
368
143
  };
@@ -0,0 +1,45 @@
1
+ import server, { status } from "./index.js";
2
+
3
+ describe("server", () => {
4
+ const app = server()
5
+ .get("/", () => "Hello world")
6
+ .get("/text", () => "Hello world")
7
+ .get("/array", () => ["Hello world"])
8
+ .get("/object", () => ({ hello: "world" }))
9
+ .get("/status", () => 201)
10
+ .post("/", (ctx) => status(201).send(ctx.body));
11
+
12
+ it("can render hello world", async () => {
13
+ const res = await app.fetch(new Request("http://localhost:3000/"));
14
+
15
+ expect(res.status).toBe(200);
16
+ expect(await res.text()).toBe("Hello world");
17
+ });
18
+
19
+ it("can post new data", async () => {
20
+ const res = await app.fetch(
21
+ new Request("http://localhost:3000/", {
22
+ method: "POST",
23
+ body: "New Data",
24
+ })
25
+ );
26
+
27
+ expect(res.status).toBe(201);
28
+ expect(await res.text()).toBe("New Data");
29
+ });
30
+
31
+ it("will return JSON", async () => {
32
+ const res = await app.fetch(
33
+ new Request("http://localhost:3000/", {
34
+ method: "POST",
35
+ body: JSON.stringify({ hello: "world" }),
36
+ headers: { "content-type": "application/json" },
37
+ })
38
+ );
39
+
40
+ expect(res.status).toBe(201);
41
+ expect(await res.json()).toEqual({ hello: "world" });
42
+ });
43
+ });
44
+
45
+ describe("all the replies", () => {});
@@ -0,0 +1,48 @@
1
+ // import { Readable } from "node:stream";
2
+
3
+ import { json } from "./reply.js";
4
+
5
+ export default async function parseResponse(handler, ctx) {
6
+ let out = await handler(ctx);
7
+
8
+ // undefined || null || 0 || false || ~""~ -> empty string is still 200
9
+ if (!out && typeof out !== "string") return null;
10
+
11
+ if (typeof out === "function") {
12
+ out = out(ctx);
13
+ }
14
+
15
+ // A plain number is a status code
16
+ if (typeof out === "number") {
17
+ out = new Response(undefined, { status: out });
18
+ }
19
+
20
+ // A plain string will be converted to either html or plain
21
+ if (typeof out === "string") {
22
+ const type = /\s*\</.test(out) ? "text/html" : "text/plain";
23
+ out = new Response(out, { headers: { "content-type": type } });
24
+ }
25
+
26
+ // https://stackoverflow.com/a/69745650/938236
27
+ if (out?.constructor === Object || Array.isArray(out)) {
28
+ out = json(out);
29
+ }
30
+
31
+ // if (out instanceof Readable) {
32
+ // if (out?.prototype?.name === 'Readable') {
33
+ // out = new Response(Readable.toWeb(out));
34
+ // }
35
+
36
+ if (!(out instanceof Response)) {
37
+ throw new Error(`Invalid response type ${out}`);
38
+ }
39
+
40
+ // Here it should be a Response
41
+ if (ctx?.res?.headers) {
42
+ for (let key in ctx.res.headers) {
43
+ out.headers[key] = ctx.res.headers[key];
44
+ }
45
+ }
46
+
47
+ return out;
48
+ }
@@ -1,24 +1,31 @@
1
- import { URLPattern } from "urlpattern-polyfill";
2
-
3
1
  export default function pathPattern(pattern, path) {
4
2
  pattern = pattern.replace(/\/$/, "") || "/";
5
3
  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;
4
+
5
+ if (pattern === path) return {};
6
+
7
+ const params = {};
8
+ const pathParts = path.split("/").slice(1);
9
+ const pattParts = pattern.split("/").slice(1);
10
+ let allSame = true;
11
+ for (let i = 0; i < Math.max(pathParts.length, pattParts.length); i++) {
12
+ const patt = pattParts[i] || "";
13
+ const part = pathParts[i] || "";
14
+ const last = pattParts[pattParts.length - 1];
15
+ const key = patt.replace(/^:/, "").replace(/\?$/, "");
16
+ if (patt === part) continue;
17
+ if (patt.endsWith("?") && !part) continue;
18
+ if (patt.startsWith(":")) {
19
+ params[key] = part;
20
+ continue;
21
+ }
22
+ if ((!patt && last === "*" && part) || (patt === "*" && part)) {
23
+ params["*"] = params["*"] || [];
24
+ params["*"].push(part);
25
+ continue;
26
+ }
27
+ allSame = false;
28
+ }
29
+ if (allSame) return params;
30
+ return false;
24
31
  }
@@ -0,0 +1,6 @@
1
+ // out = new Response(out, { headers: { "content-type": type } });
2
+ if (typeof Response === "undefined") {
3
+ global.Response = function Response(body, other = {}) {
4
+ return { body, ...other };
5
+ };
6
+ }