@server/next 0.24.1 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.24.1",
3
+ "version": "0.25.1",
4
4
  "description": "A fully-fledged web server with routing, file uploads, sessions, static files, schema validation, websockets, testing, etc.",
5
5
  "homepage": "https://server-js.com/",
6
6
  "repository": "https://github.com/franciscop/server-next.git",
package/src/auth/index.js CHANGED
@@ -4,6 +4,53 @@ import providers from "./providers/index.js";
4
4
  import session from "./session.js";
5
5
  import user from "./user.js";
6
6
 
7
+ const parseOptions = (auth, all) => {
8
+ if (!auth) return null;
9
+
10
+ if (typeof auth === "string") {
11
+ const [type, provider] = auth.split(":");
12
+ auth = { type, provider };
13
+ }
14
+ // if (typeof auth.type === "string") {
15
+ // auth.type = auth.type.split("|").filter(Boolean);
16
+ // }
17
+ if (typeof auth.provider === "string") {
18
+ auth.provider = auth.provider.split("|").filter(Boolean);
19
+ }
20
+ if (!auth.type) {
21
+ throw new Error("Auth options needs a type");
22
+ }
23
+ // if (!auth.type.length) {
24
+ // throw new Error("Auth options needs a type");
25
+ // }
26
+ if (!auth.provider || !auth.provider.length) {
27
+ throw new Error("Auth options needs a provider");
28
+ }
29
+ const providerNotFound = auth.provider.find((p) => !providers[p]);
30
+ if (providerNotFound) {
31
+ throw new Error(
32
+ `Provider "${providerNotFound}" not found, available ones are "${Object.keys(providers).join('", "')}"`,
33
+ );
34
+ }
35
+
36
+ if (!auth.session && all.store) {
37
+ auth.session = all.store.prefix("auth:");
38
+ }
39
+ if (!auth.store && all.store) {
40
+ auth.store = all.store.prefix("user:");
41
+ }
42
+ if (!auth.cleanUser) {
43
+ auth.cleanUser = (fullUser) => {
44
+ const { password, token, ...user } = fullUser;
45
+ return user;
46
+ };
47
+ }
48
+ if (!auth.redirect) {
49
+ auth.redirect = "/user";
50
+ }
51
+ return auth;
52
+ };
53
+
7
54
  const load = async (ctx) => {
8
55
  ctx.session = await session(ctx);
9
56
  ctx.auth = await auth(ctx);
@@ -54,4 +101,4 @@ const middle = async (ctx) => {
54
101
  }
55
102
  };
56
103
 
57
- export default { load, middle };
104
+ export default { load, parseOptions, middle };
@@ -7,11 +7,19 @@ import server from "../index.js";
7
7
  const ID = "REqA2l022l8Q0tuI";
8
8
 
9
9
  describe("auth", () => {
10
- it("requires a provider", () => {
11
- const store = kv(new Map());
12
- const api = server({ store, auth: "token:email" })
13
- .get("/", (ctx) => ctx.auth)
14
- .test();
10
+ it("requires a provider", async () => {
11
+ expect(() => server({ auth: "token" })).toThrow(
12
+ "Auth options needs a provider",
13
+ );
14
+ expect(() => server({ auth: "token:" })).toThrow(
15
+ "Auth options needs a provider",
16
+ );
17
+ });
18
+
19
+ it("requires a valid provider", async () => {
20
+ expect(() => server({ auth: "token:nonexisting" })).toThrow(
21
+ /Provider \"nonexisting\" not found, available ones are/,
22
+ );
15
23
  });
16
24
 
17
25
  it("provider must belong", async () => {
@@ -3,24 +3,18 @@ import { createId } from "../helpers/index.js";
3
3
  function getBoundary(header) {
4
4
  if (!header) return null;
5
5
  const items = header.split(";");
6
- if (items)
7
- for (let j = 0; j < items.length; j++) {
8
- const item = new String(items[j]).trim();
9
- if (item.indexOf("boundary") >= 0) {
10
- const k = item.split("=");
11
- return new String(k[1]).trim();
12
- }
6
+ for (const item of items) {
7
+ const trimmedItem = item.trim();
8
+ if (trimmedItem.startsWith("boundary=")) {
9
+ return trimmedItem.split("=")[1].trim();
13
10
  }
11
+ }
14
12
  return null;
15
13
  }
16
14
 
17
15
  function getMatching(string, regex) {
18
- // Helper function when using non-matching groups
19
16
  const matches = string.match(regex);
20
- if (!matches || matches.length < 2) {
21
- return "";
22
- }
23
- return matches[1];
17
+ return matches?.[1] ? matches[1] : "";
24
18
  }
25
19
 
26
20
  const saveFile = async (name, value, bucket) => {
@@ -30,48 +24,76 @@ const saveFile = async (name, value, bucket) => {
30
24
  return id;
31
25
  };
32
26
 
27
+ // Utility function to split a buffer
28
+ function splitBuffer(buffer, delimiter) {
29
+ const result = [];
30
+ let start = 0;
31
+ let index = buffer.indexOf(delimiter, start);
32
+
33
+ while (index !== -1) {
34
+ result.push(buffer.slice(start, index));
35
+ start = index + delimiter.length;
36
+ index = buffer.indexOf(delimiter, start);
37
+ }
38
+
39
+ result.push(buffer.slice(start));
40
+ return result;
41
+ }
42
+
43
+ const BREAK = "\r\n\r\n";
44
+
33
45
  export default async function parseBody(raw, contentType, bucket) {
34
- const rawData = typeof raw === "string" ? raw : await raw.clone().text();
35
- if (!rawData) return {};
46
+ const rawBuffer =
47
+ typeof raw === "string"
48
+ ? Buffer.from(raw)
49
+ : Buffer.from(await raw.arrayBuffer());
50
+ if (!rawBuffer) return {};
36
51
 
37
52
  if (!contentType || /text\/plain/.test(contentType)) {
38
- return rawData;
53
+ return rawBuffer.toString(); // Return as plain text
39
54
  }
40
55
 
41
56
  if (/application\/json/.test(contentType)) {
42
- return JSON.parse(rawData);
57
+ return JSON.parse(rawBuffer.toString()); // Parse JSON
43
58
  }
44
59
 
45
60
  const boundary = getBoundary(contentType);
46
61
  if (!boundary) return null;
47
62
 
48
63
  const body = {};
64
+ const boundaryBuffer = Buffer.from(`--${boundary}`);
65
+ const parts = splitBuffer(rawBuffer, boundaryBuffer);
66
+
67
+ for (const part of parts) {
68
+ if (part.length === 0 || part.equals(Buffer.from("--\r\n"))) continue;
49
69
 
50
- const rawDataArray = rawData.split(boundary);
51
- for (const item of rawDataArray) {
52
- // Use non-matching groups to exclude part of the result
53
- const name = getMatching(item, /(?:name=")(.+?)(?:")/)
70
+ const partString = part.toString();
71
+ const name = getMatching(partString, /(?:name=")(.+?)(?:")/)
54
72
  .trim()
55
73
  .replace(/\[\]$/, "");
56
74
  if (!name) continue;
57
75
 
58
- let value = getMatching(item, /(?:\r\n\r\n)([\S\s]*)(?:\r\n--$)/);
59
- if (!value) continue;
76
+ const filename = getMatching(partString, /(?:filename=")(.*?)(?:")/).trim();
60
77
 
61
- // Check whether we have a filename. If we do, assign it to the value
62
- const filename = getMatching(item, /(?:filename=")(.*?)(?:")/).trim();
63
- if (filename) {
64
- value = await saveFile(filename, value, bucket);
65
- }
78
+ if (!part.includes(BREAK)) continue;
66
79
 
67
- // Save the key-value, accounting for possibly repeated keys
68
- if (body[name]) {
69
- if (!Array.isArray(body[name])) {
70
- body[name] = [body[name]];
71
- }
72
- body[name].push(value);
80
+ // Content starts after headers and "\r\n\r\n", remove trailing CRLF
81
+ const content = part.slice(part.indexOf(BREAK) + 4, part.length - 2);
82
+
83
+ if (filename) {
84
+ // Save binary content as a file
85
+ body[name] = await saveFile(filename, content, bucket);
73
86
  } else {
74
- body[name] = value;
87
+ // Treat content as text
88
+ const value = content.toString().trim();
89
+ if (body[name]) {
90
+ if (!Array.isArray(body[name])) {
91
+ body[name] = [body[name]];
92
+ }
93
+ body[name].push(value);
94
+ } else {
95
+ body[name] = value;
96
+ }
75
97
  }
76
98
  }
77
99
 
@@ -51,8 +51,10 @@ describe("parseBody", () => {
51
51
  const matchMd = expect.stringMatching(/^\w{16}.md$/);
52
52
  const matchTxt = expect.stringMatching(/^\w{16}.txt$/);
53
53
  expect(body).toMatchObject({
54
+ hello: "world",
54
55
  profile: matchMd,
55
- gallery: [matchTxt, matchTxt],
56
+ gallery: matchTxt,
57
+ test: ["test message 123456", "test message number two"],
56
58
  });
57
59
  });
58
60
  });
@@ -2,27 +2,31 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import fsp from "node:fs/promises";
4
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
- // Already a bucket, no need to do anything with it, just return it:
10
- if (typeof root !== "string") {
11
- return root;
12
- }
13
-
5
+ function thinLocalBucket(root) {
14
6
  const absolute = (name) => {
15
7
  if (!name) throw new Error("File name is required");
16
8
  return path.resolve(path.join(root, name));
17
9
  };
18
10
 
19
11
  return {
20
- path: root,
21
- read: (name, type = "utf8") => {
12
+ read: async (name) => {
22
13
  const fullPath = absolute(name);
23
- return fsp.readFile(fullPath, type);
14
+ const stats = await fsp.stat(fullPath).catch(() => null);
15
+ if (!stats || !stats.isFile()) return null;
16
+
17
+ const nodeStream = fs.createReadStream(fullPath);
18
+ return new ReadableStream({
19
+ start(controller) {
20
+ nodeStream.on("data", (chunk) => controller.enqueue(chunk));
21
+ nodeStream.on("end", () => controller.close());
22
+ nodeStream.on("error", (err) => controller.error(err));
23
+ },
24
+ cancel() {
25
+ nodeStream.destroy();
26
+ },
27
+ });
24
28
  },
25
- write: (name, value, type = "utf8") => {
29
+ write: (name, value, type) => {
26
30
  const fullPath = absolute(name);
27
31
  if (value) {
28
32
  return fsp.writeFile(fullPath, value, type).then(() => fullPath);
@@ -31,3 +35,41 @@ export default function (root) {
31
35
  },
32
36
  };
33
37
  }
38
+
39
+ function thinBunBucket(s3) {
40
+ return {
41
+ read: async (name) => {
42
+ const file = s3.file(name);
43
+ if (!(await file.exists())) return null;
44
+ return await file.stream();
45
+ },
46
+ write: async (name, value) => {
47
+ const file = s3.file(name);
48
+ if (value) {
49
+ await file.write(value);
50
+ return name;
51
+ }
52
+ return s3.presign(name, { expiresIn: 3600, acl: "public-read-write" });
53
+ },
54
+ };
55
+ }
56
+
57
+ // A fake tiny implementation of a generic bucket, it needs
58
+ // at the very least a read(id) and write(id, value), both returning
59
+ // promises. If possible both are also pipeable/streamable.
60
+ export default function (root) {
61
+ if (!root) return null;
62
+
63
+ // Already a bucket, no need to do anything with it, just return it:
64
+ if (typeof root === "string") {
65
+ return thinLocalBucket(root);
66
+ }
67
+
68
+ // Bun's S3
69
+ if (root.file && root.write) {
70
+ return thinBunBucket(root);
71
+ }
72
+
73
+ // Assuming the base is already implementing our API
74
+ return root;
75
+ }
@@ -1,3 +1,4 @@
1
+ import auth from "../auth/index.js";
1
2
  import Bucket from "./bucket.js";
2
3
  import createId from "./createId.js";
3
4
 
@@ -50,37 +51,7 @@ export default function config(options = {}) {
50
51
  }
51
52
 
52
53
  // AUTH
53
- options.auth = options.auth || env.AUTH || null;
54
- if (options.auth) {
55
- if (typeof options.auth !== "object") {
56
- const [type, provider] = options.auth.split(":");
57
- options.auth = { type, provider };
58
- }
59
- if (typeof options.auth.provider === "string") {
60
- options.auth.provider === options.auth.provider.split("|");
61
- }
62
- if (!options.auth.type) {
63
- throw new Error("Auth options needs a type");
64
- }
65
- if (!options.auth.provider) {
66
- throw new Error("Auth options needs a provider");
67
- }
68
- if (!options.auth.session && options.store) {
69
- options.auth.session = options.store.prefix("auth:");
70
- }
71
- if (!options.auth.store && options.store) {
72
- options.auth.store = options.store.prefix("user:");
73
- }
74
- if (!options.auth.cleanUser) {
75
- options.auth.cleanUser = (fullUser) => {
76
- const { password, token, ...user } = fullUser;
77
- return user;
78
- };
79
- }
80
- if (!options.auth.redirect) {
81
- options.auth.redirect = "/user";
82
- }
83
- }
54
+ options.auth = auth.parseOptions(options.auth || env.AUTH || null, options);
84
55
 
85
56
  // OpenAPI
86
57
  if (options.openapi) {
@@ -3,9 +3,11 @@ import { type } from "../reply.js";
3
3
  export default async function assets(ctx) {
4
4
  if (!ctx.options.public) return;
5
5
  if (ctx.method !== "get") return;
6
+ // The homepage _cannot_ be a file by definition. We could consider sending
7
+ // `index.html`, but that's easy with `.get('/', () => file('index.html'))
8
+ if (ctx.url.pathname === "/") return;
6
9
  try {
7
10
  // TODO: streaming
8
- // Read it as buffer (null)
9
11
  const asset = await ctx.options.public.read(ctx.url.pathname, null);
10
12
  if (!asset) return;
11
13
  return type(ctx.url.pathname.split(".").pop()).send(asset);
@@ -3,8 +3,8 @@ import server from "../";
3
3
  describe("static assets", () => {
4
4
  it("can serve a simple file", async () => {
5
5
  const app = server({ public: "./src/middle/" }).test();
6
- const { body, headers } = await app.get("/assets.test.js");
7
- expect(body).toInclude("describe");
8
- expect(headers["content-type"]).toBe("text/javascript");
6
+ const res = await app.get("/assets.test.js");
7
+ expect(res.body).toInclude("describe");
8
+ expect(res.headers["content-type"]).toBe("text/javascript");
9
9
  });
10
10
  });
@@ -25,7 +25,7 @@ const getConfig = (routes) => {
25
25
  return config;
26
26
  };
27
27
 
28
- const pkg = await fsp
28
+ const pkgProm = fsp
29
29
  .readFile("package.json", "utf-8")
30
30
  .then((data) => JSON.parse(data))
31
31
  .catch(() => ({}));
@@ -113,6 +113,7 @@ const generateOpenApiPaths = (handlers) => {
113
113
  };
114
114
 
115
115
  export default async (ctx) => {
116
+ const pkg = await pkgProm;
116
117
  const domain = pkg.homepage || ctx.url.origin;
117
118
  const openApi = {
118
119
  openapi: "3.0.0",
@@ -10,6 +10,10 @@ export default async function parseResponse(out, ctx) {
10
10
  out = await out(ctx);
11
11
  }
12
12
 
13
+ if (out instanceof Blob) {
14
+ out = new Response(out, { headers: { "Content-Type": blob.type } });
15
+ }
16
+
13
17
  // A plain number is a status code
14
18
  if (typeof out === "number") {
15
19
  out = new Response(undefined, { status: out });
@@ -26,11 +30,6 @@ export default async function parseResponse(out, ctx) {
26
30
  out = json(out);
27
31
  }
28
32
 
29
- // if (out instanceof Readable) {
30
- // if (out?.prototype?.name === 'Readable') {
31
- // out = new Response(Readable.toWeb(out));
32
- // }
33
-
34
33
  if (!(out instanceof Response)) {
35
34
  throw new Error(`Invalid response type ${out}`);
36
35
  }
package/src/reply.js CHANGED
@@ -122,6 +122,7 @@ Reply.prototype.send = function (body = "") {
122
122
 
123
123
  // WebStream already, just pass it through
124
124
  if (name === "ReadableStream") {
125
+ const headers = this.generateHeaders();
125
126
  return new Response(body, { status, headers });
126
127
  }
127
128