@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.17.2",
3
+ "version": "0.18.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",
@@ -10,9 +10,10 @@
10
10
  "license": "UNLICENSED",
11
11
  "scripts": {
12
12
  "demo": "nodemon ./demo/app.js",
13
- "start": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
14
- "size": "echo \"$(gzip -c index.js | wc -c) bytes\" # Only for Unix",
15
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
13
+ "build": "bun build src/index.js --outdir ./ --entry-naming server.js",
14
+ "start": "bun test --watch",
15
+ "size": "echo \"$(gzip -c server.js | wc -c) bytes\" # Only for Unix",
16
+ "test": "bun test"
16
17
  },
17
18
  "keywords": [
18
19
  "server",
@@ -25,14 +26,10 @@
25
26
  "src/"
26
27
  ],
27
28
  "engines": {
28
- "node": ">=14.0.0"
29
+ "node": ">=20.0.0"
29
30
  },
30
31
  "engineStrict": true,
31
- "dependencies": {
32
- "dotenv": "^16.0.1",
33
- "spinnies": "^0.5.1",
34
- "urlpattern-polyfill": "^5.0.0"
35
- },
32
+ "dependencies": {},
36
33
  "devDependencies": {
37
34
  "jest": "^26.0.1",
38
35
  "prettier": "^2.7.0"
package/readme.md CHANGED
@@ -1,47 +1,53 @@
1
1
  # Server @ Next
2
2
 
3
- > **VERY EARLY WORK IN PROGRESS**
4
- >
5
- > **I don't know what will come out of this, if anything! Treat as the most experimental thing you've ever seen**
3
+ > **EXPERIMENTAL LIBRARY**
6
4
 
7
- A fully-fledged web server for Node.js, with all the basics covered for you:
5
+ A fully-fledged web server for Bun and Node.js, with all the basics covered for you:
8
6
 
9
7
  ```js
10
- import server, { get, post, put, use } from "server";
8
+ import server from "@server/next";
11
9
 
12
10
  // Create a running instance of the server
13
- const app = server(config, [pluginA, pluginB]);
14
-
15
- // Attach handlers to the instance
16
- app([
17
- get("/users", getUsers),
18
- post("/users", createUser),
19
- put("/users/:id", editUser),
20
- use("/admin/*", dashboard),
21
- ]);
11
+ export default server(options)
12
+ .router("/admin/", dashboard)
13
+ .get("/users", getUsers)
14
+ .post("/users", createUser)
15
+ .put("/users/:id", editUser);
22
16
  ```
23
17
 
24
18
  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
19
 
26
20
  > \* not yet available
27
21
 
22
+ For testing it's also easy, since we are exporting our server throught the WinterCG API we can do:
23
+
24
+ ```js
25
+ // index.test.js
26
+ import app from "./";
27
+
28
+ it("can retrieve the homepage", async () => {
29
+ const res = await app.fetch(new Request("http://localhost:3000/hello/"));
30
+ const data = await res.text();
31
+ expect(data).toBe("Hello world");
32
+ });
33
+ ```
34
+
28
35
  ## Upgrading server
29
36
 
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!):
37
+ Why? We live in the era of multi-cloud (Heroku, Workers, Lambda, etc) and multi-runtimes (Node.js, Bun, WinterGC, etc). Desired improvements (WIP!):
31
38
 
32
- - Tiny footprint with no dependencies\*, all bundled in a single file. Installing and using the full library takes under 10kb (target limit).
39
+ - Tiny footprint with few dependencies. Installing and using the full library takes under 10kb (target limit).
33
40
  - Faster! Reimplemented from scratch for speed. With raw ES6+ and a tiny code footprint, your server will fly.
34
41
  - Modern ES6+ESM syntax for both the library and examples.
35
- - Error handling improved greatly.
36
42
  - Not using express underneath anymore. Considering keeping the compatibility layer anyway (since Express itself is a thin layer).
37
43
  - 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
- - **[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.
44
+ - **[security]** Removed mandatory CSRF token, since this is only useful for server-rendered pages and not for SPA. Now we provide an `auth` module instead.
39
45
 
40
46
  Major changes:
41
47
 
42
48
  - 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
49
  - `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`.
50
+ - `ctx.query` is now `ctx.url.query`, e.g. `ctx.url.query.search`.
45
51
  - `ctx.path` is now `ctx.url.path` (or `ctx.url.pathname`).
46
52
  - All URL properties are available, like `ctx.url.port`, `ctx.url.searchParams`, etc.
47
53
 
@@ -101,7 +107,6 @@ Creating a 100x100px thumbnail on the fly with Sharp:
101
107
 
102
108
  ```js
103
109
  // createThumbnail.js
104
- import { get } from "@server/next";
105
110
  import sharp from "sharp";
106
111
 
107
112
  export default function createThumbnail(ctx) {
@@ -109,3 +114,100 @@ export default function createThumbnail(ctx) {
109
114
  return sharp(ctx.url.params.name).resize(100, 100, { fit: "cover" }).png();
110
115
  }
111
116
  ```
117
+
118
+ ### Breaking Changes
119
+
120
+ `import`, `export` and routing are the main changes from your point of view:
121
+
122
+ ```js
123
+ import server, { status, type, ...reply } from 'server';
124
+
125
+ export default server({ ...options })
126
+ .use(mid1)
127
+ .get('/', cb1)
128
+ .get('/b', mid2, cb2)
129
+ .routes({ get: [['/c', mid3, cb3]] });
130
+ ```
131
+
132
+ `status()` now it's always partial:
133
+
134
+ ```js
135
+ // OLD
136
+ return 404;
137
+ return status(404).send("Not here..."); // treated as partial
138
+ return status(404); // treated as final
139
+
140
+ // NEW
141
+ return 404;
142
+ return status(404).send("Not here..."); // GOOD
143
+ return status(404).send(); // GOOD
144
+
145
+ // DON'T DO:
146
+ return status(404); // INVALID
147
+ ```
148
+
149
+ ```js
150
+ import server from "server";
151
+
152
+ export default server()
153
+ .get("/", () => "Hello world")
154
+ .post("/", (ctx) => {
155
+ console.log(ctx.body);
156
+ return 201;
157
+ });
158
+ ```
159
+
160
+ ```js
161
+ import server, { jwtAuth } from "server";
162
+ import BookRouter from "./BookRouter";
163
+ import UserRouter from "./UserRouter";
164
+
165
+ export default server({ port: 3000 })
166
+ .use(jwtAuth)
167
+ .route("/users", UserRouter)
168
+ .route("/books", BookRouter);
169
+ ```
170
+
171
+ ```js
172
+ // UserRouter.js -> A custom router
173
+ import server, { router } from "server";
174
+
175
+ export default router()
176
+ .get("/", getUsers)
177
+ .get("/:id", getUser)
178
+ .post("/", createUser);
179
+ ```
180
+
181
+ MAYBE?
182
+
183
+ ```js
184
+ // BookRouter.js -> a REST API router
185
+ import { RestRouter } from "server";
186
+
187
+ export default class BookRouter extends RestRouter {
188
+ async create(ctx) {
189
+ // POST /
190
+ }
191
+ async list(ctx) {
192
+ // GET /
193
+ }
194
+ async get(ctx) {
195
+ // GET /:id
196
+ }
197
+ async search(ctx) {
198
+ // GET /?...
199
+ }
200
+ async update(ctx) {
201
+ // PUT /:id
202
+ }
203
+ async set(ctx) {
204
+ // PATCH /:id
205
+ }
206
+ async delete(ctx) {
207
+ // DELETE /:id
208
+ }
209
+ async error(ctx) {
210
+ // ctx.error
211
+ }
212
+ }
213
+ ```
package/src/bucket.js CHANGED
@@ -1,6 +1,7 @@
1
- import path from "node:path";
2
- import fs from "node:fs";
3
- import fsp from "node:fs/promises";
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ import fsp from "fs/promises";
4
5
 
5
6
  // A fake tiny implementation of a generic bucket, it needs
6
7
  // at the very least a read(id) and write(id, value), both returning
@@ -12,6 +13,7 @@ export default function (root) {
12
13
  };
13
14
 
14
15
  return {
16
+ path: root,
15
17
  read: (name, type = "utf8") => {
16
18
  const fullPath = absolute(name);
17
19
  return fsp.readFile(fullPath, type);
@@ -0,0 +1,28 @@
1
+ import { define } from "../helpers/index.js";
2
+ import parseBody from "./parseBody.js";
3
+ import parseCookies from "./parseCookies.js";
4
+
5
+ export default async (request, options = {}) => {
6
+ const ctx = {};
7
+ ctx.req = request;
8
+ ctx.res = { status: null, headers: {}, cookies: {} };
9
+ ctx.method = request.method.toLowerCase();
10
+
11
+ ctx.headers = request.headers;
12
+ define(ctx, "cookies", () => parseCookies(request.headers.cookie));
13
+
14
+ const https = request.connection.encrypted ? "https" : "http";
15
+ const host = ctx.headers.host || "localhost" + options.port;
16
+ const path = request.url.replace(/\/$/, "") || "/";
17
+ ctx.url = new URL(path, `${https}://${host}`);
18
+ define(ctx.url, "query", (url) =>
19
+ Object.fromEntries(url.searchParams.entries())
20
+ );
21
+
22
+ if (request.body) {
23
+ const type = ctx.headers["content-type"];
24
+ ctx.body = await parseBody(request, type, options.uploads);
25
+ }
26
+
27
+ return ctx;
28
+ };
@@ -21,18 +21,6 @@ function getMatching(string, regex) {
21
21
  return matches[1];
22
22
  }
23
23
 
24
- const getBody = async (req) => {
25
- return await new Promise((done) => {
26
- const buffers = [];
27
- req.on("data", (chunk) => {
28
- buffers.push(chunk);
29
- });
30
- req.on("end", () => {
31
- done(Buffer.concat(buffers).toString("binary"));
32
- });
33
- });
34
- };
35
-
36
24
  const nanoid = (size = 12) => {
37
25
  let str = "";
38
26
  while (str.length < size + 2) {
@@ -48,12 +36,16 @@ const saveFile = async (name, value, bucket) => {
48
36
  return id;
49
37
  };
50
38
 
51
- export default async function Parse(req, contentType, bucket) {
52
- const rawData = await (typeof req === "string" ? req : getBody(req));
53
- if (!rawData) return null;
39
+ export default async function parseBody(raw, contentType, bucket) {
40
+ const rawData = typeof raw === "string" ? raw : await raw.text();
41
+ if (!rawData) return {};
42
+
43
+ if (!contentType || /text\/plain/.test(contentType)) {
44
+ return rawData;
45
+ }
54
46
 
55
47
  if (/application\/json/.test(contentType)) {
56
- return { body: JSON.parse(rawData), files: {} };
48
+ return JSON.parse(rawData);
57
49
  }
58
50
 
59
51
  const boundary = getBoundary(contentType);
@@ -36,9 +36,6 @@ 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
-
42
39
  describe("parseBody", () => {
43
40
  it("can parse the example body", async () => {
44
41
  const body = await parseBody(
@@ -50,6 +47,9 @@ describe("parseBody", () => {
50
47
  hello: "world",
51
48
  test: ["test message 123456", "test message number two"],
52
49
  });
50
+
51
+ const matchMd = expect.stringMatching(/^file-\w{12}.md$/);
52
+ const matchTxt = expect.stringMatching(/^file-\w{12}.txt$/);
53
53
  expect(body).toMatchObject({
54
54
  profile: matchMd,
55
55
  gallery: [matchTxt, matchTxt],
@@ -0,0 +1,9 @@
1
+ export default function parseCookies(cookies) {
2
+ if (!cookies) return {};
3
+ return Object.fromEntries(
4
+ cookies.split(/;\s*/).map((part) => {
5
+ const [key, ...rest] = part.split("=");
6
+ return [key, decodeURIComponent(rest.join("="))];
7
+ })
8
+ );
9
+ }
@@ -0,0 +1,25 @@
1
+ import { define } from "../helpers/index.js";
2
+ import parseBody from "./parseBody.js";
3
+ import parseCookies from "./parseCookies.js";
4
+
5
+ export default async (request, options = {}) => {
6
+ const ctx = {};
7
+ ctx.req = request;
8
+ ctx.res = { status: null, headers: {}, cookies: {} };
9
+ ctx.method = request.method.toLowerCase();
10
+
11
+ define(ctx, "headers", () => Object.fromEntries(request.headers.entries()));
12
+ define(ctx, "cookies", () => parseCookies(request.headers.get("cookie")));
13
+
14
+ ctx.url = new URL(request.url.replace(/\/$/, ""));
15
+ define(ctx.url, "query", (url) =>
16
+ Object.fromEntries(url.searchParams.entries())
17
+ );
18
+
19
+ if (request.body) {
20
+ const type = ctx.headers["content-type"];
21
+ ctx.body = await parseBody(request, type, options.uploads);
22
+ }
23
+
24
+ return ctx;
25
+ };
@@ -0,0 +1,18 @@
1
+ // An amazing lazy-definition. It will _not_ parse these properties
2
+ // until needed, and once it has parsed them, it'll replace itself
3
+ // with the value at once, hence it's called 0-1 times even if the
4
+ // properties are accessed 0-N times
5
+ export default function define(obj, key, cb) {
6
+ Object.defineProperty(obj, key, {
7
+ configurable: true,
8
+ get() {
9
+ const value = cb(obj);
10
+ Object.defineProperty(obj, key, {
11
+ configurable: false,
12
+ writable: false,
13
+ value,
14
+ });
15
+ return obj[key];
16
+ },
17
+ });
18
+ }
@@ -0,0 +1,13 @@
1
+ function getRuntime() {
2
+ if ("Bun" in globalThis) return "bun";
3
+ if ("Deno" in globalThis) return "deno";
4
+ if (globalThis.process?.versions?.node) return "node";
5
+ return "unknown";
6
+ }
7
+
8
+ export default function getMachine() {
9
+ return {
10
+ runtime: getRuntime(),
11
+ production: process.env.NODE_ENV === "production",
12
+ };
13
+ }
@@ -0,0 +1,20 @@
1
+ import parseResponse from "../parseResponse.js";
2
+ import pathPattern from "../pathPattern.js";
3
+ import define from "./define.js";
4
+
5
+ export default async function handleRequest(handlers, ctx) {
6
+ for (let [matcher, ...cbs] of handlers[ctx.method]) {
7
+ const match = pathPattern(matcher, ctx.url.pathname || "/");
8
+ // Skip this whole middleware if there was no match
9
+ if (!match) continue;
10
+
11
+ define(ctx.url, "params", () => match);
12
+
13
+ for (let cb of cbs) {
14
+ const out = await parseResponse(cb, ctx);
15
+ if (out) return out;
16
+ }
17
+ }
18
+
19
+ return Response("Not Found", { status: 404 });
20
+ }
@@ -0,0 +1,5 @@
1
+ export { default as define } from "./define.js";
2
+ export { default as getMachine } from "./getMachine.js";
3
+ export { default as handleRequest } from "./handleRequest.js";
4
+ export { default as iterate } from "./iterate.js";
5
+ export { default as types } from "./types.js";
@@ -0,0 +1,8 @@
1
+ export default async function iterate(stream, cb) {
2
+ const reader = stream.getReader();
3
+ while (true) {
4
+ const chunk = await reader.read();
5
+ if (chunk.done || !chunk.value) return;
6
+ cb(chunk.value);
7
+ }
8
+ }
@@ -0,0 +1,79 @@
1
+ // From https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
2
+ export default {
3
+ aac: "audio/aac",
4
+ abw: "application/x-abiword",
5
+ arc: "application/x-freearc",
6
+ avif: "image/avif",
7
+ avi: "video/x-msvideo",
8
+ azw: "application/vnd.amazon.ebook",
9
+ bin: "application/octet-stream",
10
+ bmp: "image/bmp",
11
+ bz: "application/x-bzip",
12
+ bz2: "application/x-bzip2",
13
+ cda: "application/x-cdf",
14
+ csh: "application/x-csh",
15
+ css: "text/css",
16
+ csv: "text/csv",
17
+ doc: "application/msword",
18
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
19
+ eot: "application/vnd.ms-fontobject",
20
+ epub: "application/epub+zip",
21
+ gz: "application/gzip",
22
+ gif: "image/gif",
23
+ htm: "text/html",
24
+ html: "text/html",
25
+ ico: "image/vnd.microsoft.icon",
26
+ ics: "text/calendar",
27
+ jar: "application/java-archive",
28
+ jpeg: "image/jpeg",
29
+ jpg: "image/jpeg",
30
+ js: "text/javascript",
31
+ json: "application/json",
32
+ jsonld: "application/ld+json",
33
+ mid: "audio/midi",
34
+ midi: "audio/midi",
35
+ mjs: "text/javascript",
36
+ mp3: "audio/mpeg",
37
+ mp4: "video/mp4",
38
+ mpeg: "video/mpeg",
39
+ mpkg: "application/vnd.apple.installer+xml",
40
+ odp: "application/vnd.oasis.opendocument.presentation",
41
+ ods: "application/vnd.oasis.opendocument.spreadsheet",
42
+ odt: "application/vnd.oasis.opendocument.text",
43
+ oga: "audio/ogg",
44
+ ogv: "video/ogg",
45
+ ogx: "application/ogg",
46
+ opus: "audio/opus",
47
+ otf: "font/otf",
48
+ png: "image/png",
49
+ pdf: "application/pdf",
50
+ php: "application/x-httpd-php",
51
+ ppt: "application/vnd.ms-powerpoint",
52
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
53
+ rar: "application/vnd.rar",
54
+ rtf: "application/rtf",
55
+ sh: "application/x-sh",
56
+ svg: "image/svg+xml",
57
+ tar: "application/x-tar",
58
+ tif: "image/tiff",
59
+ tiff: "image/tiff",
60
+ ts: "video/mp2t",
61
+ ttf: "font/ttf",
62
+ txt: "text/plain",
63
+ vsd: "application/vnd.visio",
64
+ wav: "audio/wav",
65
+ weba: "audio/webm",
66
+ webm: "video/webm",
67
+ webp: "image/webp",
68
+ woff: "font/woff",
69
+ woff2: "font/woff2",
70
+ xhtml: "application/xhtml+xml",
71
+ xls: "application/vnd.ms-excel",
72
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
73
+ xml: "application/xml",
74
+ xul: "application/vnd.mozilla.xul+xml",
75
+ zip: "application/zip",
76
+ "3gp": "video/3gpp",
77
+ "3g2": "video/3gpp2",
78
+ "7z": "application/x-7z-compressed",
79
+ };