@server/next 0.24.0 → 0.25.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 +1 -1
- package/src/auth/index.js +48 -1
- package/src/auth/index.test.js +13 -5
- package/src/context/parseBody.js +56 -34
- package/src/context/parseBody.test.js +3 -1
- package/src/helpers/bucket.js +55 -13
- package/src/helpers/config.js +2 -31
- package/src/index.d.ts +1 -1
- package/src/middle/assets.js +3 -1
- package/src/middle/assets.test.js +3 -3
- package/src/parseResponse.js +4 -5
- package/src/reply.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@server/next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
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 };
|
package/src/auth/index.test.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 () => {
|
package/src/context/parseBody.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
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
|
|
53
|
+
return rawBuffer.toString(); // Return as plain text
|
|
39
54
|
}
|
|
40
55
|
|
|
41
56
|
if (/application\/json/.test(contentType)) {
|
|
42
|
-
return JSON.parse(
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
59
|
-
if (!value) continue;
|
|
76
|
+
const filename = getMatching(partString, /(?:filename=")(.*?)(?:")/).trim();
|
|
60
77
|
|
|
61
|
-
|
|
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
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
body[name]
|
|
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
|
-
|
|
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:
|
|
56
|
+
gallery: matchTxt,
|
|
57
|
+
test: ["test message 123456", "test message number two"],
|
|
56
58
|
});
|
|
57
59
|
});
|
|
58
60
|
});
|
package/src/helpers/bucket.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
21
|
-
read: (name, type = "utf8") => {
|
|
12
|
+
read: async (name) => {
|
|
22
13
|
const fullPath = absolute(name);
|
|
23
|
-
|
|
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
|
|
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
|
+
}
|
package/src/helpers/config.js
CHANGED
|
@@ -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) {
|
package/src/index.d.ts
CHANGED
package/src/middle/assets.js
CHANGED
|
@@ -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
|
|
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
|
});
|
package/src/parseResponse.js
CHANGED
|
@@ -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