@server/next 0.20.6 → 0.20.8
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 +7 -4
- package/src/ServerError.js +24 -0
- package/src/auth.test.js +78 -0
- package/src/context/findAuth.js +32 -0
- package/src/context/findSession.js +34 -0
- package/src/context/node.js +9 -2
- package/src/context/parseBody.js +3 -9
- package/src/context/parseBody.test.js +2 -2
- package/src/context/winter.js +10 -3
- package/src/errors/index.js +18 -0
- package/src/helpers/createCookies.js +13 -0
- package/src/helpers/createId.js +17 -0
- package/src/helpers/handleRequest.js +5 -9
- package/src/helpers/index.js +2 -0
- package/src/helpers/validate.js +0 -1
- package/src/index.js +85 -32
- package/src/middle/index.js +71 -0
- package/src/parseResponse.js +30 -0
- package/src/reply.js +10 -9
- package/src/router.test.js +12 -1
- package/src/session.test.js +65 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@server/next",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.8",
|
|
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",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"author": "Francisco Presencia <public@francisco.io> (https://francisco.io/)",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
11
|
"scripts": {
|
|
12
|
-
"demo": "nodemon ./demo/
|
|
12
|
+
"demo": "nodemon ./demo/src/index.js",
|
|
13
13
|
"start": "bun test --watch",
|
|
14
14
|
"test": "bun test",
|
|
15
15
|
"test:jest": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
|
|
@@ -30,12 +30,15 @@
|
|
|
30
30
|
"node": ">=20.0.0"
|
|
31
31
|
},
|
|
32
32
|
"engineStrict": true,
|
|
33
|
-
"dependencies": {},
|
|
34
33
|
"devDependencies": {
|
|
35
|
-
"jest": "^29.7.0"
|
|
34
|
+
"jest": "^29.7.0",
|
|
35
|
+
"polystore": "^0.8.0"
|
|
36
36
|
},
|
|
37
37
|
"jest": {
|
|
38
38
|
"testEnvironment": "jest-environment-node",
|
|
39
39
|
"transform": {}
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"argon2": "^0.40.3"
|
|
40
43
|
}
|
|
41
44
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export default class ServerError extends Error {
|
|
2
|
+
constructor(code, status, message, vars = {}) {
|
|
3
|
+
if (typeof message === "function") {
|
|
4
|
+
message = message(vars);
|
|
5
|
+
}
|
|
6
|
+
for (let key in vars) {
|
|
7
|
+
message = message.replaceAll(`{${key}}`, vars[key]);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.message = message;
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
static extend(errors) {
|
|
16
|
+
for (let code in errors) {
|
|
17
|
+
const message = errors[code]?.message || errors[code];
|
|
18
|
+
const status = errors[code]?.status;
|
|
19
|
+
ServerError[code] = (vars) =>
|
|
20
|
+
new ServerError(code, status, message, vars);
|
|
21
|
+
}
|
|
22
|
+
return errors;
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/auth.test.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import kv from "polystore";
|
|
2
|
+
|
|
3
|
+
import server from "./index.js";
|
|
4
|
+
|
|
5
|
+
const url = (token) =>
|
|
6
|
+
new Request("http://localhost:3000/", {
|
|
7
|
+
headers: { authorization: token },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("auth", () => {
|
|
11
|
+
const store = kv(new Map());
|
|
12
|
+
const app = server({ store }).get("/", (ctx) => {
|
|
13
|
+
return ctx.headers.authorization;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should be Bearer", async () => {
|
|
17
|
+
const res = await app.fetch(url("Basic REqA2l022l8Q0tuIRtqLOPUy"));
|
|
18
|
+
expect(await res.text()).toBe("Invalid Authorization type, 'Basic'");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should have the proper token", async () => {
|
|
22
|
+
const res = await app.fetch(url("Bearer hola"));
|
|
23
|
+
expect(await res.text()).toBe("Invalid Authorization token");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("can get the nested get", async () => {
|
|
27
|
+
const res = await app.fetch(url("Bearer REqA2l022l8Q0tuIRtqLOPUy"));
|
|
28
|
+
expect(await res.text()).toBe("Bearer REqA2l022l8Q0tuIRtqLOPUy");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("user creation flow", () => {
|
|
32
|
+
// These are obviously mock data
|
|
33
|
+
const EMAIL = "abc@test.com";
|
|
34
|
+
const PASS = "11111111";
|
|
35
|
+
|
|
36
|
+
const url = (path, body = {}, headers = {}) =>
|
|
37
|
+
new Request("http://localhost:3000" + path, {
|
|
38
|
+
headers: {
|
|
39
|
+
cookie: "session=REqA2l022l8Q0tuIRtqLOPUy",
|
|
40
|
+
"content-type": "application/json",
|
|
41
|
+
...headers,
|
|
42
|
+
},
|
|
43
|
+
method: "POST",
|
|
44
|
+
body: JSON.stringify(body),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const store = kv(new Map());
|
|
48
|
+
const app = server({ auth: { type: "token", providers: "email" }, store });
|
|
49
|
+
|
|
50
|
+
it("can create a new user", async () => {
|
|
51
|
+
const regReq = await app.fetch(
|
|
52
|
+
url("/auth/register/email", { email: EMAIL, password: PASS })
|
|
53
|
+
);
|
|
54
|
+
const register = await regReq.json();
|
|
55
|
+
expect(regReq.status).toBe(201);
|
|
56
|
+
expect(await store.keys()).toEqual([
|
|
57
|
+
"auth:abc@test.com",
|
|
58
|
+
"session:" + register.token,
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const logoutReq = await app.fetch(
|
|
62
|
+
url("/auth/logout", {}, { authorization: "Bearer " + register.token })
|
|
63
|
+
);
|
|
64
|
+
expect(logoutReq.status).toBe(200);
|
|
65
|
+
expect(await store.keys()).toEqual(["auth:abc@test.com"]);
|
|
66
|
+
|
|
67
|
+
const loginReq = await app.fetch(
|
|
68
|
+
url("/auth/login/email", { email: EMAIL, password: PASS })
|
|
69
|
+
);
|
|
70
|
+
const login = await loginReq.json();
|
|
71
|
+
expect(loginReq.status).toBe(200);
|
|
72
|
+
expect(await store.keys()).toEqual([
|
|
73
|
+
"auth:abc@test.com",
|
|
74
|
+
"session:" + login.token,
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ServerError } from "../index.js";
|
|
2
|
+
|
|
3
|
+
export default async function findAuth(ctx) {
|
|
4
|
+
// NO AUTH AT ALL
|
|
5
|
+
// If there's no even auth option, nothing to do
|
|
6
|
+
const store = ctx.options.auth?.store;
|
|
7
|
+
if (!store) return;
|
|
8
|
+
|
|
9
|
+
// AUTHENTICATION IS AVAILABLE
|
|
10
|
+
ctx.auth = {
|
|
11
|
+
type: ctx.options.auth.type,
|
|
12
|
+
providers: ctx.options.auth.providers,
|
|
13
|
+
store,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// If the user is not authenticated, there's no auth to retrieve
|
|
17
|
+
if (!ctx.headers.authorization) return;
|
|
18
|
+
|
|
19
|
+
// AUTHENTICATED REQUEST
|
|
20
|
+
// Check the authentication header
|
|
21
|
+
const [type, id] = ctx.headers.authorization.trim().split(" ");
|
|
22
|
+
if (type.toLowerCase() !== "bearer") {
|
|
23
|
+
throw ServerError.AUTH_INVALID_TYPE({ type });
|
|
24
|
+
}
|
|
25
|
+
if (id.length !== 24) {
|
|
26
|
+
throw ServerError.AUTH_INVALID_TOKEN();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Extend the basics
|
|
30
|
+
ctx.auth.id = id;
|
|
31
|
+
ctx.user = await store.get(id);
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ServerError } from "../index.js";
|
|
2
|
+
|
|
3
|
+
class NoSession {}
|
|
4
|
+
|
|
5
|
+
export default async function findSession(ctx) {
|
|
6
|
+
const store = ctx.options.session?.store;
|
|
7
|
+
|
|
8
|
+
// If there's no store at all, we don't have session available;
|
|
9
|
+
// but that's okay, since it's only a problem if you try to use it
|
|
10
|
+
if (!store) {
|
|
11
|
+
return new Proxy(new NoSession(), {
|
|
12
|
+
get(target, key) {
|
|
13
|
+
if (target[key]) return target[key];
|
|
14
|
+
if (key === "then") return target[key];
|
|
15
|
+
throw ServerError.NO_STORE_READ({ key });
|
|
16
|
+
},
|
|
17
|
+
set(target, key, value) {
|
|
18
|
+
if (target[key] || key === "then") {
|
|
19
|
+
target[key] = value;
|
|
20
|
+
} else {
|
|
21
|
+
throw ServerError.NO_STORE_WRITE({ key });
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// There's a session cookie; use it as the key to get the data
|
|
28
|
+
// from the store
|
|
29
|
+
if (ctx.cookies.session) {
|
|
30
|
+
return (await store.get(ctx.cookies.session)) || {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {};
|
|
34
|
+
}
|
package/src/context/node.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { define } from "../helpers/index.js";
|
|
2
|
+
import findAuth from "./findAuth.js";
|
|
3
|
+
import findSession from "./findSession.js";
|
|
2
4
|
import parseBody from "./parseBody.js";
|
|
3
5
|
import parseCookies from "./parseCookies.js";
|
|
4
6
|
|
|
5
|
-
export default async (request, options = {}) => {
|
|
7
|
+
export default async (request, options = {}, app) => {
|
|
6
8
|
const ctx = {};
|
|
7
9
|
ctx.options = options;
|
|
8
10
|
ctx.req = request;
|
|
@@ -10,7 +12,9 @@ export default async (request, options = {}) => {
|
|
|
10
12
|
ctx.method = request.method.toLowerCase();
|
|
11
13
|
|
|
12
14
|
ctx.headers = request.headers;
|
|
13
|
-
|
|
15
|
+
ctx.cookies = parseCookies(ctx.headers.cookie);
|
|
16
|
+
ctx.session = await findSession(ctx);
|
|
17
|
+
await findAuth(ctx);
|
|
14
18
|
|
|
15
19
|
const https = request.connection.encrypted ? "https" : "http";
|
|
16
20
|
const host = ctx.headers.host || "localhost" + options.port;
|
|
@@ -38,5 +42,8 @@ export default async (request, options = {}) => {
|
|
|
38
42
|
.on("error", reject);
|
|
39
43
|
});
|
|
40
44
|
|
|
45
|
+
ctx.app = app;
|
|
46
|
+
ctx.platform = app.platform;
|
|
47
|
+
|
|
41
48
|
return ctx;
|
|
42
49
|
};
|
package/src/context/parseBody.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createId } from "../helpers/index.js";
|
|
2
|
+
|
|
1
3
|
function getBoundary(header) {
|
|
2
4
|
if (!header) return null;
|
|
3
5
|
var items = header.split(";");
|
|
@@ -21,17 +23,9 @@ function getMatching(string, regex) {
|
|
|
21
23
|
return matches[1];
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
const nanoid = (size = 12) => {
|
|
25
|
-
let str = "";
|
|
26
|
-
while (str.length < size + 2) {
|
|
27
|
-
str += Math.round(Math.random() * 1000000).toString(16);
|
|
28
|
-
}
|
|
29
|
-
return str.slice(0, size);
|
|
30
|
-
};
|
|
31
|
-
|
|
32
26
|
const saveFile = async (name, value, bucket) => {
|
|
33
27
|
const ext = name.split(".").pop();
|
|
34
|
-
const id =
|
|
28
|
+
const id = `${createId()}.${ext}`;
|
|
35
29
|
await bucket.write(id, value, "binary");
|
|
36
30
|
return id;
|
|
37
31
|
};
|
|
@@ -48,8 +48,8 @@ describe("parseBody", () => {
|
|
|
48
48
|
test: ["test message 123456", "test message number two"],
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
const matchMd = expect.stringMatching(
|
|
52
|
-
const matchTxt = expect.stringMatching(
|
|
51
|
+
const matchMd = expect.stringMatching(/^\w{24}.md$/);
|
|
52
|
+
const matchTxt = expect.stringMatching(/^\w{24}.txt$/);
|
|
53
53
|
expect(body).toMatchObject({
|
|
54
54
|
profile: matchMd,
|
|
55
55
|
gallery: [matchTxt, matchTxt],
|
package/src/context/winter.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { define } from "../helpers/index.js";
|
|
2
|
+
import findAuth from "./findAuth.js";
|
|
3
|
+
import findSession from "./findSession.js";
|
|
2
4
|
import parseBody from "./parseBody.js";
|
|
3
5
|
import parseCookies from "./parseCookies.js";
|
|
4
6
|
|
|
5
|
-
export default async (request, options = {}) => {
|
|
7
|
+
export default async (request, options = {}, app) => {
|
|
6
8
|
const ctx = {};
|
|
7
9
|
ctx.options = options;
|
|
8
10
|
ctx.req = request;
|
|
9
11
|
ctx.res = { status: null, headers: {}, cookies: {} };
|
|
10
12
|
ctx.method = request.method.toLowerCase();
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
ctx.headers = Object.fromEntries(request.headers.entries());
|
|
15
|
+
ctx.cookies = parseCookies(ctx.headers.cookie);
|
|
16
|
+
ctx.session = await findSession(ctx);
|
|
17
|
+
await findAuth(ctx);
|
|
14
18
|
|
|
15
19
|
ctx.url = new URL(request.url.replace(/\/$/, ""));
|
|
16
20
|
define(ctx.url, "query", (url) =>
|
|
@@ -22,5 +26,8 @@ export default async (request, options = {}) => {
|
|
|
22
26
|
ctx.body = await parseBody(request, type, options.uploads);
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
ctx.app = app;
|
|
30
|
+
ctx.platform = app.platform;
|
|
31
|
+
|
|
25
32
|
return ctx;
|
|
26
33
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import ServerError from "../ServerError.js";
|
|
2
|
+
|
|
3
|
+
ServerError.extend({
|
|
4
|
+
NO_STORE: `You need a 'store' to write 'ctx.session'`,
|
|
5
|
+
NO_STORE_WRITE: `You need a 'store' to write 'ctx.session.{key}'`,
|
|
6
|
+
NO_STORE_READ: `You need a 'store' to read 'ctx.session.{key}'`,
|
|
7
|
+
AUTH_INVALID_TYPE: `Invalid Authorization type, '{type}'`,
|
|
8
|
+
AUTH_INVALID_TOKEN: `Invalid Authorization token`,
|
|
9
|
+
|
|
10
|
+
LOGIN_NO_EMAIL: "The email is required to log in",
|
|
11
|
+
LOGIN_INVALID_EMAIL: "The email you wrote is not correct",
|
|
12
|
+
LOGIN_NO_PASSWORD: "The email is required to log in",
|
|
13
|
+
LOGIN_INVALID_PASSWORD: "The email you wrote is not correct",
|
|
14
|
+
LOGIN_WRONG_ACCOUNT: `That email does not correspond to any account`,
|
|
15
|
+
LOGIN_WRONG_PASSWORD: `That is not the valid password`,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export default ServerError;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Takes an object and returns a string with the proper cookie values
|
|
2
|
+
export default function createCookies(cookies) {
|
|
3
|
+
if (!cookies || !Object.keys(cookies).length) return "";
|
|
4
|
+
return Object.entries(cookies)
|
|
5
|
+
.map(([key, val]) => {
|
|
6
|
+
if (typeof val === "string") {
|
|
7
|
+
val = { value: val, path: "/" };
|
|
8
|
+
}
|
|
9
|
+
const { value, path } = val;
|
|
10
|
+
return `${key}=${value};Path=${path}`;
|
|
11
|
+
})
|
|
12
|
+
.join(";");
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const urlAlphabet =
|
|
2
|
+
"useandom26T198340PX75pxJACKVERYMINDBUSHWOLFGQZbfghjklqvwyzrict";
|
|
3
|
+
|
|
4
|
+
export let random = (bytes) => crypto.getRandomValues(new Uint8Array(bytes));
|
|
5
|
+
|
|
6
|
+
export default function createId() {
|
|
7
|
+
let size = 24;
|
|
8
|
+
let id = "";
|
|
9
|
+
let bytes = crypto.getRandomValues(new Uint8Array(size));
|
|
10
|
+
while (size--) {
|
|
11
|
+
// Using the bitwise AND operator to "cap" the value of
|
|
12
|
+
// the random byte from 255 to 63, in that way we can make sure
|
|
13
|
+
// that the value will be a valid index for the "chars" string.
|
|
14
|
+
id += urlAlphabet[bytes[size] & 61];
|
|
15
|
+
}
|
|
16
|
+
return id;
|
|
17
|
+
}
|
|
@@ -12,15 +12,11 @@ export default async function handleRequest(handlers, ctx) {
|
|
|
12
12
|
define(ctx.url, "params", () => match);
|
|
13
13
|
|
|
14
14
|
for (let cb of cbs) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (out) return out;
|
|
21
|
-
}
|
|
22
|
-
} catch (error) {
|
|
23
|
-
return new Response(error.message, { status: error.status || 500 });
|
|
15
|
+
validate(ctx, cb);
|
|
16
|
+
if (typeof cb === "function") {
|
|
17
|
+
const res = await cb(ctx);
|
|
18
|
+
const out = await parseResponse(res, ctx);
|
|
19
|
+
if (out) return out;
|
|
24
20
|
}
|
|
25
21
|
}
|
|
26
22
|
|
package/src/helpers/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { default as createId } from "./createId.js";
|
|
2
|
+
export { default as createCookies } from "./createCookies.js";
|
|
1
3
|
export { default as define } from "./define.js";
|
|
2
4
|
export { default as getMachine } from "./getMachine.js";
|
|
3
5
|
export { default as handleRequest } from "./handleRequest.js";
|
package/src/helpers/validate.js
CHANGED
package/src/index.js
CHANGED
|
@@ -1,16 +1,85 @@
|
|
|
1
1
|
import "./polyfill.js";
|
|
2
|
+
// Define the errors for ServerError
|
|
3
|
+
import "./errors/index.js";
|
|
2
4
|
|
|
3
5
|
import Bucket from "./bucket.js";
|
|
4
6
|
import createNodeContext from "./context/node.js";
|
|
5
7
|
import createWinterContext from "./context/winter.js";
|
|
6
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
createId,
|
|
10
|
+
getMachine,
|
|
11
|
+
handleRequest,
|
|
12
|
+
iterate,
|
|
13
|
+
} from "./helpers/index.js";
|
|
14
|
+
import middle from "./middle/index.js";
|
|
7
15
|
|
|
8
16
|
// Export the reply helpers
|
|
9
17
|
export * from "./reply.js";
|
|
10
18
|
|
|
19
|
+
export { default as ServerError } from "./ServerError.js";
|
|
20
|
+
|
|
11
21
|
// Allow to create a sub-router
|
|
12
22
|
export { default as router } from "./router.js";
|
|
13
23
|
|
|
24
|
+
const createNodeServer = async (app, options) => {
|
|
25
|
+
const http = await import("http");
|
|
26
|
+
http
|
|
27
|
+
.createServer(async (request, response) => {
|
|
28
|
+
try {
|
|
29
|
+
const ctx = await createNodeContext(request, options, app);
|
|
30
|
+
extendWithDefaults(ctx);
|
|
31
|
+
const out = await handleRequest(app.handlers, ctx);
|
|
32
|
+
|
|
33
|
+
response.writeHead(out.status || 200, out.headers);
|
|
34
|
+
if (out.body instanceof ReadableStream) {
|
|
35
|
+
await iterate(out.body, (chunk) => response.write(chunk));
|
|
36
|
+
} else {
|
|
37
|
+
response.write(out.body || "");
|
|
38
|
+
}
|
|
39
|
+
response.end();
|
|
40
|
+
} catch (error) {
|
|
41
|
+
response.writeHead(error.status || 500);
|
|
42
|
+
response.write(error.message || "");
|
|
43
|
+
response.end();
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
.listen(options.port);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const validateOptions = (options, env = {}) => {
|
|
50
|
+
options.port = options.port || env.PORT || 3000;
|
|
51
|
+
options.secret = options.secret || env.SECRET || "unsafe-" + createId();
|
|
52
|
+
|
|
53
|
+
options.views = options.views ? Bucket(options.views) : null;
|
|
54
|
+
options.public = options.public ? Bucket(options.public) : null;
|
|
55
|
+
options.uploads = options.uploads ? Bucket(options.uploads) : null;
|
|
56
|
+
|
|
57
|
+
options.store = options.store ?? null;
|
|
58
|
+
options.cookies = options.cookies ?? {};
|
|
59
|
+
if (options.store && options.cookies) {
|
|
60
|
+
options.session = { store: options.store.prefix("session:") };
|
|
61
|
+
}
|
|
62
|
+
options.auth = options.auth || {};
|
|
63
|
+
if (options.auth) {
|
|
64
|
+
if (typeof options.auth !== "object") {
|
|
65
|
+
options.auth = { type: options.auth };
|
|
66
|
+
}
|
|
67
|
+
if (!options.auth.store && options.store) {
|
|
68
|
+
options.auth.store = options.store.prefix("auth:");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return options;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const extendWithDefaults = (ctx) => {
|
|
76
|
+
// Only want to execute it once; it needs to happen on a per-request
|
|
77
|
+
// basis since we only have full access to the options there
|
|
78
|
+
if (ctx.app.extended) return;
|
|
79
|
+
middle(ctx);
|
|
80
|
+
ctx.app.extended = true;
|
|
81
|
+
};
|
|
82
|
+
|
|
14
83
|
// Export the main server()
|
|
15
84
|
export default function server(options = {}) {
|
|
16
85
|
if (!(this instanceof server)) {
|
|
@@ -29,12 +98,9 @@ export default function server(options = {}) {
|
|
|
29
98
|
options: [],
|
|
30
99
|
};
|
|
31
100
|
|
|
32
|
-
|
|
33
|
-
options.port = options.port || process.env.PORT || 3000;
|
|
101
|
+
this.extended = false;
|
|
34
102
|
|
|
35
|
-
|
|
36
|
-
options.public = options.public ? Bucket(options.public) : null;
|
|
37
|
-
options.uploads = options.uploads ? Bucket(options.uploads) : null;
|
|
103
|
+
this.platform = getMachine();
|
|
38
104
|
|
|
39
105
|
// WEBSOCKETS stuff
|
|
40
106
|
const sockets = [];
|
|
@@ -48,37 +114,24 @@ export default function server(options = {}) {
|
|
|
48
114
|
close: (ws) => sockets.splice(sockets.indexOf(ws), 1),
|
|
49
115
|
};
|
|
50
116
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const ctx = await createNodeContext(request, options);
|
|
57
|
-
ctx.app = this;
|
|
58
|
-
ctx.platform = platform;
|
|
59
|
-
|
|
60
|
-
const out = await handleRequest(this.handlers, ctx);
|
|
61
|
-
|
|
62
|
-
response.writeHead(out.status || 200, { header: out.headers });
|
|
63
|
-
if (out.body instanceof ReadableStream) {
|
|
64
|
-
await iterate(out.body, (chunk) => response.write(chunk));
|
|
65
|
-
} else {
|
|
66
|
-
response.write(out.body || "");
|
|
67
|
-
}
|
|
68
|
-
response.end();
|
|
69
|
-
})
|
|
70
|
-
.listen(options.port);
|
|
71
|
-
})();
|
|
117
|
+
// Starting stuff
|
|
118
|
+
if (this.platform.runtime === "node") {
|
|
119
|
+
options = validateOptions(options, process.env);
|
|
120
|
+
|
|
121
|
+
createNodeServer(this, options);
|
|
72
122
|
}
|
|
73
123
|
|
|
74
124
|
this.fetch = async (request, env, fetchCtx) => {
|
|
75
125
|
if (env?.upgrade(request)) return;
|
|
76
126
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
127
|
+
try {
|
|
128
|
+
options = validateOptions(options, env);
|
|
129
|
+
const ctx = await createWinterContext(request, options, this);
|
|
130
|
+
extendWithDefaults(ctx);
|
|
131
|
+
return await handleRequest(this.handlers, ctx);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return new Response(error.message, { status: error.status || 500 });
|
|
134
|
+
}
|
|
82
135
|
};
|
|
83
136
|
}
|
|
84
137
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import argon2 from "argon2";
|
|
2
|
+
|
|
3
|
+
import { createId } from "../helpers/index.js";
|
|
4
|
+
import { status, type } from "../reply.js";
|
|
5
|
+
import ServerError from "../ServerError.js";
|
|
6
|
+
|
|
7
|
+
export default function middle(ctx) {
|
|
8
|
+
if (ctx.options.public) {
|
|
9
|
+
ctx.app.handlers.get.unshift([
|
|
10
|
+
"*",
|
|
11
|
+
"*",
|
|
12
|
+
async function publicFolder(ctx) {
|
|
13
|
+
try {
|
|
14
|
+
const asset = await ctx.options.public.read(ctx.url.pathname);
|
|
15
|
+
if (asset) {
|
|
16
|
+
return type(ctx.url.pathname.split(".").pop()).send(asset);
|
|
17
|
+
}
|
|
18
|
+
} catch (error) {}
|
|
19
|
+
},
|
|
20
|
+
]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!ctx.auth) return;
|
|
24
|
+
if (ctx.auth?.providers?.includes("email")) {
|
|
25
|
+
ctx.app.post("/auth/register/email", async (ctx) => {
|
|
26
|
+
const { email, password, ...data } = ctx.body;
|
|
27
|
+
if (!email || !/@/.test(email)) throw new Error("Email needed");
|
|
28
|
+
if (!password || password.length < 8) throw new Error("Password needed");
|
|
29
|
+
if (await ctx.auth.store.has(email)) {
|
|
30
|
+
throw new Error("Email is already registered");
|
|
31
|
+
}
|
|
32
|
+
const id = createId();
|
|
33
|
+
const time = new Date().toISOString();
|
|
34
|
+
const pass = await argon2.hash(password);
|
|
35
|
+
await ctx.auth.store.set(email, { id, email, password: pass, ...data });
|
|
36
|
+
|
|
37
|
+
// TYPE GOES HERE
|
|
38
|
+
const token = await ctx.options.session.store.add({ id, email, time });
|
|
39
|
+
return status(201).json({ id, token, email, ...data });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
ctx.app.post("/auth/logout", async (ctx) => {
|
|
43
|
+
if (ctx.auth.id) {
|
|
44
|
+
await ctx.options.session.store.del(ctx.auth.id);
|
|
45
|
+
}
|
|
46
|
+
return status(200).send();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
ctx.app.post("/auth/login/email", async (ctx) => {
|
|
50
|
+
const { email, password } = ctx.body;
|
|
51
|
+
if (!email) throw ServerError.LOGIN_NO_EMAIL();
|
|
52
|
+
if (!/@/.test(email)) throw ServerError.LOGIN_INVALID_EMAIL();
|
|
53
|
+
if (!password) throw ServerError.LOGIN_NO_PASSWORD();
|
|
54
|
+
if (password.length < 8) throw ServerError.LOGIN_INVALID_PASSWORD();
|
|
55
|
+
|
|
56
|
+
const time = new Date().toISOString();
|
|
57
|
+
const user = await ctx.auth.store.get(email);
|
|
58
|
+
if (!user) throw ServerError.LOGIN_WRONG_EMAIL();
|
|
59
|
+
const isValid = await argon2.verify(user.password, password);
|
|
60
|
+
if (!isValid) throw ServerError.LOGIN_WRONG_PASSWORD();
|
|
61
|
+
|
|
62
|
+
// TYPE GOES HERE
|
|
63
|
+
const token = await ctx.options.session.store.add({
|
|
64
|
+
id: user.id,
|
|
65
|
+
email,
|
|
66
|
+
time,
|
|
67
|
+
});
|
|
68
|
+
return { id: user.id, token, email };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/parseResponse.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// import { Readable } from "node:stream";
|
|
2
2
|
|
|
3
|
+
import { createCookies, createId } from "./helpers/index.js";
|
|
3
4
|
import { json } from "./reply.js";
|
|
5
|
+
import ServerError from "./ServerError.js";
|
|
4
6
|
|
|
5
7
|
export default async function parseResponse(out, ctx) {
|
|
6
8
|
// undefined || null || 0 || false || ~""~ -> empty string is still 200
|
|
@@ -36,6 +38,34 @@ export default async function parseResponse(out, ctx) {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
// Here it should be a Response
|
|
41
|
+
|
|
42
|
+
// If we have a session, we need to persist it into a cookie
|
|
43
|
+
if (Object.keys(ctx.session || {}).length) {
|
|
44
|
+
if (!ctx.options.session?.store) {
|
|
45
|
+
throw ServerError.NO_STORE({});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Persistence is based on the Token
|
|
49
|
+
// Persistence is based on the Cookies
|
|
50
|
+
// No session cookies, generate a _persistent_ cookie
|
|
51
|
+
if (!ctx.cookies.session) {
|
|
52
|
+
ctx.res.cookies.session = createId();
|
|
53
|
+
}
|
|
54
|
+
const id = ctx.cookies.session;
|
|
55
|
+
|
|
56
|
+
// Saves the session in the session store
|
|
57
|
+
// Note that this is async but we are totally fine deferring it
|
|
58
|
+
ctx.options.session.store.set(id, ctx.session);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Cookies to headers
|
|
62
|
+
if (ctx.options.cookies) {
|
|
63
|
+
if (Object.keys(ctx.res.cookies).length) {
|
|
64
|
+
ctx.res.headers["set-cookie"] = createCookies(ctx.res.cookies);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Add the headers that are neeeded
|
|
39
69
|
if (ctx?.res?.headers) {
|
|
40
70
|
for (let key in ctx.res.headers) {
|
|
41
71
|
out.headers[key] = ctx.res.headers[key];
|
package/src/reply.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
|
|
3
|
-
import { types } from "./helpers/index.js";
|
|
3
|
+
import { createCookies, types } from "./helpers/index.js";
|
|
4
4
|
|
|
5
|
-
function Reply() {
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
function Reply() {
|
|
6
|
+
this.res = {
|
|
7
|
+
headers: {},
|
|
8
|
+
cookies: {},
|
|
9
|
+
};
|
|
10
|
+
}
|
|
8
11
|
|
|
9
12
|
// INTERNAL
|
|
10
13
|
Reply.prototype.generateHeaders = function () {
|
|
11
|
-
const cookies =
|
|
12
|
-
.map(([k, { value, path = "/" }]) => `${k}=${value};Path=${path}`)
|
|
13
|
-
.join(";");
|
|
14
|
+
const cookies = createCookies(this.res.cookies);
|
|
14
15
|
return { ...this.res.headers, "set-cookie": cookies };
|
|
15
16
|
};
|
|
16
17
|
|
|
@@ -51,7 +52,7 @@ Reply.prototype.cookies = function (cookies) {
|
|
|
51
52
|
|
|
52
53
|
// FINAL
|
|
53
54
|
Reply.prototype.json = function (body) {
|
|
54
|
-
return headers({ "content-type": "application/json" }).send(
|
|
55
|
+
return this.headers({ "content-type": "application/json" }).send(
|
|
55
56
|
JSON.stringify(body)
|
|
56
57
|
);
|
|
57
58
|
};
|
|
@@ -76,7 +77,7 @@ Reply.prototype.view = async function (path) {
|
|
|
76
77
|
}
|
|
77
78
|
const data = await ctx.options.views.read(path);
|
|
78
79
|
if (data) return this.type(path.split(".").pop()).send(data);
|
|
79
|
-
return status(404).send();
|
|
80
|
+
return this.status(404).send();
|
|
80
81
|
};
|
|
81
82
|
};
|
|
82
83
|
|
package/src/router.test.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import server, { router } from "./index.js";
|
|
1
|
+
import server, { router, status } from "./index.js";
|
|
2
2
|
|
|
3
3
|
const url = (path, options = {}) =>
|
|
4
4
|
new Request("http://localhost:3000" + path, options);
|
|
@@ -44,4 +44,15 @@ describe("can route properly", () => {
|
|
|
44
44
|
const res = await app.fetch(url("/api/hello", { method: "POST" }));
|
|
45
45
|
expect(await res.text()).toBe("Hello /api/hello");
|
|
46
46
|
});
|
|
47
|
+
|
|
48
|
+
it("no status reuse", async () => {
|
|
49
|
+
const app = server()
|
|
50
|
+
.get("/a", () => status(201).send("hello"))
|
|
51
|
+
.get("/b", () => ({ hello: "bye" }))
|
|
52
|
+
.get("/", () => "Fallback");
|
|
53
|
+
const resA = await app.fetch(url("/a"));
|
|
54
|
+
expect(resA.status).toBe(201);
|
|
55
|
+
const resB = await app.fetch(url("/b"));
|
|
56
|
+
expect(resB.status).toBe(200);
|
|
57
|
+
});
|
|
47
58
|
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import kv from "polystore";
|
|
2
|
+
|
|
3
|
+
import server from "./index.js";
|
|
4
|
+
|
|
5
|
+
const url = (path, options = {}) =>
|
|
6
|
+
new Request("http://localhost:3000" + path, {
|
|
7
|
+
headers: { cookie: "session=REqA2l022l8Q0tuIRtqLOPUy" },
|
|
8
|
+
...options,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("session", () => {
|
|
12
|
+
const store = kv(new Map());
|
|
13
|
+
const app = server({ store })
|
|
14
|
+
.get("/hello", (ctx) => "Hello " + ctx.session.a)
|
|
15
|
+
.post("/hello", (ctx) => {
|
|
16
|
+
if (!ctx.session.a) ctx.session.a = 0;
|
|
17
|
+
ctx.session.a += 1;
|
|
18
|
+
return "Bye " + ctx.session.a;
|
|
19
|
+
})
|
|
20
|
+
.get("/", () => "Fallback");
|
|
21
|
+
|
|
22
|
+
it("can get the nested get", async () => {
|
|
23
|
+
await store.set("session:REqA2l022l8Q0tuIRtqLOPUy", { a: 0 });
|
|
24
|
+
|
|
25
|
+
const res = await app.fetch(url("/hello"));
|
|
26
|
+
expect(await res.text()).toBe("Hello 0");
|
|
27
|
+
|
|
28
|
+
const res2 = await app.fetch(url("/hello", { method: "POST" }));
|
|
29
|
+
expect(await res2.text()).toBe("Bye 1");
|
|
30
|
+
const res3 = await app.fetch(url("/hello"));
|
|
31
|
+
expect(await res3.text()).toBe("Hello 1");
|
|
32
|
+
expect(await store.get("session:REqA2l022l8Q0tuIRtqLOPUy")).toEqual({
|
|
33
|
+
a: 1,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const res4 = await app.fetch(url("/hello", { method: "POST" }));
|
|
37
|
+
expect(await res4.text()).toBe("Bye 2");
|
|
38
|
+
const res5 = await app.fetch(url("/hello"));
|
|
39
|
+
expect(await res5.text()).toBe("Hello 2");
|
|
40
|
+
expect(await store.get("session:REqA2l022l8Q0tuIRtqLOPUy")).toEqual({
|
|
41
|
+
a: 2,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const missingStore = server({ store: null })
|
|
46
|
+
.get("/read", (ctx) => "Bye " + ctx.session.a)
|
|
47
|
+
.get("/write", (ctx) => {
|
|
48
|
+
ctx.session.a = "hello";
|
|
49
|
+
return "All good";
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("cannot read a session without a store", async () => {
|
|
53
|
+
const res = await missingStore.fetch(url("/read"));
|
|
54
|
+
const body = await res.text();
|
|
55
|
+
expect(res.status).toBe(500);
|
|
56
|
+
expect(body).toBe("You need a 'store' to read 'ctx.session.a'");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("cannot write a session without a store", async () => {
|
|
60
|
+
const res = await missingStore.fetch(url("/write"));
|
|
61
|
+
const body = await res.text();
|
|
62
|
+
expect(res.status).toBe(500);
|
|
63
|
+
expect(body).toBe("You need a 'store' to write 'ctx.session.a'");
|
|
64
|
+
});
|
|
65
|
+
});
|