@server/next 0.23.1 → 0.24.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 +2 -2
- package/src/auth/index.js +32 -8
- package/src/auth/providers/email.js +10 -5
- package/src/auth/providers/github.js +1 -1
- package/src/helpers/config.js +7 -0
- package/src/index.d.ts +41 -9
- package/src/index.js +5 -1
- package/src/middle/index.js +2 -1
- package/src/middle/openapi.js +146 -0
- package/src/pathPattern.js +14 -1
- package/src/pathPattern.test.js +27 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@server/next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"start": "bun test --watch",
|
|
22
|
-
"lint": "npx @biomejs/biome lint ./src --skip=lint/style/noParameterAssign",
|
|
22
|
+
"lint": "npx @biomejs/biome lint ./src --skip=lint/style/noParameterAssign --skip=lint/suspicious/noExplicitAny --skip=lint/suspicious/noConfusingVoidType",
|
|
23
23
|
"test": "bun test && check-dts ./src/**",
|
|
24
24
|
"test:jest": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
|
|
25
25
|
},
|
package/src/auth/index.js
CHANGED
|
@@ -15,17 +15,41 @@ const middle = async (ctx) => {
|
|
|
15
15
|
if (ctx.options.auth.provider.includes("github")) {
|
|
16
16
|
if (!env.GITHUB_ID) throw new Error("GITHUB_ID not defined");
|
|
17
17
|
if (!env.GITHUB_SECRET) throw new Error("GITHUB_SECRET not defined");
|
|
18
|
-
ctx.app.get(
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
ctx.app.get(
|
|
19
|
+
"/auth/logout",
|
|
20
|
+
{ tags: "Auth", title: "Github logout" },
|
|
21
|
+
logout,
|
|
22
|
+
);
|
|
23
|
+
ctx.app.get(
|
|
24
|
+
"/auth/login/github",
|
|
25
|
+
{ tags: "Auth" },
|
|
26
|
+
providers.github.login,
|
|
27
|
+
);
|
|
28
|
+
ctx.app.get(
|
|
29
|
+
"/auth/callback/github",
|
|
30
|
+
{ tags: "Auth", title: "Github callback" },
|
|
31
|
+
providers.github.callback,
|
|
32
|
+
);
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
if (ctx.options.auth.provider.includes("email")) {
|
|
24
|
-
ctx.app.post("/auth/logout", logout);
|
|
25
|
-
ctx.app.post(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
ctx.app.post("/auth/logout", { tags: "Auth" }, logout);
|
|
37
|
+
ctx.app.post(
|
|
38
|
+
"/auth/register/email",
|
|
39
|
+
{ tags: "Auth" },
|
|
40
|
+
providers.email.register,
|
|
41
|
+
);
|
|
42
|
+
ctx.app.post(
|
|
43
|
+
"/auth/login/email",
|
|
44
|
+
{ tags: "Auth" },
|
|
45
|
+
providers.email.login,
|
|
46
|
+
);
|
|
47
|
+
ctx.app.put(
|
|
48
|
+
"/auth/password/email",
|
|
49
|
+
{ tags: "Auth" },
|
|
50
|
+
providers.email.password,
|
|
51
|
+
);
|
|
52
|
+
ctx.app.put("/auth/reset/email", { tags: "Auth" }, providers.email.reset);
|
|
29
53
|
}
|
|
30
54
|
}
|
|
31
55
|
};
|
|
@@ -61,7 +61,7 @@ const createSession = async (user, ctx) => {
|
|
|
61
61
|
throw new Error("Unknown auth type");
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
async function
|
|
64
|
+
async function emailLogin(ctx) {
|
|
65
65
|
const { email, password } = ctx.body;
|
|
66
66
|
if (!email) throw ServerError.LOGIN_NO_EMAIL();
|
|
67
67
|
if (!/@/.test(email)) throw ServerError.LOGIN_INVALID_EMAIL();
|
|
@@ -78,7 +78,7 @@ async function login(ctx) {
|
|
|
78
78
|
return createSession(user, ctx);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
async function
|
|
81
|
+
async function emailRegister(ctx) {
|
|
82
82
|
const { email, password, ...data } = ctx.body;
|
|
83
83
|
if (!email) throw ServerError.REGISTER_NO_EMAIL();
|
|
84
84
|
if (!/@/.test(email)) throw ServerError.REGISTER_INVALID_EMAIL();
|
|
@@ -101,7 +101,7 @@ async function register(ctx) {
|
|
|
101
101
|
return createSession(user, ctx);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
async function
|
|
104
|
+
async function emailResetPassword(ctx) {
|
|
105
105
|
// const reset = ctx.options.store.prefix("reset:");
|
|
106
106
|
// // Already resetting
|
|
107
107
|
// if (ctx.body.token) {
|
|
@@ -123,7 +123,7 @@ async function reset(ctx) {
|
|
|
123
123
|
// }
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
async function
|
|
126
|
+
async function emailUpdatePassword(ctx) {
|
|
127
127
|
const { previous, updated } = ctx.body;
|
|
128
128
|
|
|
129
129
|
const fullUser = await ctx.options.auth.store.get(ctx.auth.user);
|
|
@@ -137,4 +137,9 @@ async function password(ctx) {
|
|
|
137
137
|
return 200;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
export default {
|
|
140
|
+
export default {
|
|
141
|
+
login: emailLogin,
|
|
142
|
+
register: emailRegister,
|
|
143
|
+
reset: emailResetPassword,
|
|
144
|
+
password: emailUpdatePassword,
|
|
145
|
+
};
|
package/src/helpers/config.js
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -45,16 +45,48 @@ type ServerOptions = {
|
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
type ExtractPathParams<Path extends string> =
|
|
48
|
-
Path extends `${string}:${infer Param}
|
|
49
|
-
? Param | ExtractPathParams<`/${Rest}`>
|
|
50
|
-
: Path extends `${string}:${infer Param}
|
|
51
|
-
? Param
|
|
52
|
-
:
|
|
48
|
+
Path extends `${string}:${infer Param}(${infer Type})?/${infer Rest}`
|
|
49
|
+
? `${Param}:${Type}?` | ExtractPathParams<`/${Rest}`>
|
|
50
|
+
: Path extends `${string}:${infer Param}(${infer Type})?`
|
|
51
|
+
? `${Param}:${Type}?`
|
|
52
|
+
: Path extends `${string}:${infer Param}(${infer Type})/${infer Rest}`
|
|
53
|
+
? `${Param}:${Type}` | ExtractPathParams<`/${Rest}`>
|
|
54
|
+
: Path extends `${string}:${infer Param}(${infer Type})`
|
|
55
|
+
? `${Param}:${Type}`
|
|
56
|
+
: Path extends `${string}:${infer Param}?/${infer Rest}`
|
|
57
|
+
? `${Param}?` | ExtractPathParams<`/${Rest}`>
|
|
58
|
+
: Path extends `${string}:${infer Param}?`
|
|
59
|
+
? `${Param}?`
|
|
60
|
+
: Path extends `${string}:${infer Param}/${infer Rest}`
|
|
61
|
+
? Param | ExtractPathParams<`/${Rest}`>
|
|
62
|
+
: Path extends `${string}:${infer Param}`
|
|
63
|
+
? Param
|
|
64
|
+
: never;
|
|
65
|
+
|
|
66
|
+
type ParamTypeMap = {
|
|
67
|
+
string: string;
|
|
68
|
+
number: number;
|
|
69
|
+
date: Date;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type InferParamType<T extends string> = T extends keyof ParamTypeMap
|
|
73
|
+
? ParamTypeMap[T]
|
|
74
|
+
: string;
|
|
53
75
|
|
|
54
76
|
type ParamsToObject<Params extends string> = {
|
|
55
|
-
[K in Params as K extends `${infer Key}?`
|
|
77
|
+
[K in Params as K extends `${infer Key}:${infer Type}?`
|
|
56
78
|
? Key
|
|
57
|
-
: K
|
|
79
|
+
: K extends `${infer Key}:${infer Type}`
|
|
80
|
+
? Key
|
|
81
|
+
: K extends `${infer Key}?`
|
|
82
|
+
? Key
|
|
83
|
+
: K]: K extends `${infer Key}:${infer Type}?`
|
|
84
|
+
? InferParamType<Type> | undefined
|
|
85
|
+
: K extends `${infer Key}:${infer Type}`
|
|
86
|
+
? InferParamType<Type>
|
|
87
|
+
: K extends `${infer Key}?`
|
|
88
|
+
? string | undefined
|
|
89
|
+
: string;
|
|
58
90
|
};
|
|
59
91
|
|
|
60
92
|
type PathToParams<Path extends string> = ParamsToObject<
|
|
@@ -96,11 +128,11 @@ type InlineReply =
|
|
|
96
128
|
| { body: Body; headers?: Headers }
|
|
97
129
|
| string
|
|
98
130
|
| number
|
|
99
|
-
|
|
|
131
|
+
| undefined;
|
|
100
132
|
|
|
101
133
|
type Middleware<Path extends string = string> = (
|
|
102
134
|
ctx: Context<Path>,
|
|
103
|
-
) => InlineReply;
|
|
135
|
+
) => InlineReply | void;
|
|
104
136
|
|
|
105
137
|
declare interface Router {
|
|
106
138
|
get<Path extends string>(path: Path, ...middle: Middleware<Path>[]): this;
|
package/src/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
parseHeaders,
|
|
12
12
|
} from "./helpers/index.js";
|
|
13
13
|
|
|
14
|
-
import { assets, auth, timer } from "./middle/index.js";
|
|
14
|
+
import { assets, auth, timer, openapi } from "./middle/index.js";
|
|
15
15
|
|
|
16
16
|
// Export the reply helpers
|
|
17
17
|
export * from "./reply.js";
|
|
@@ -75,6 +75,10 @@ export default function server(options = {}) {
|
|
|
75
75
|
|
|
76
76
|
this.use(timer);
|
|
77
77
|
this.use(assets);
|
|
78
|
+
if (this.opts.openapi) {
|
|
79
|
+
const path = this.opts.openapi.path || "/docs";
|
|
80
|
+
this.get(path, openapi);
|
|
81
|
+
}
|
|
78
82
|
if (this.opts.auth) {
|
|
79
83
|
this.use(auth({ options: this.opts, app: this }));
|
|
80
84
|
}
|
package/src/middle/index.js
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
const entities = { "&": "&", "<": "<", ">": ">", '"': """ };
|
|
4
|
+
const encode = (str = "") => {
|
|
5
|
+
if (typeof str === "number") str = String(str);
|
|
6
|
+
if (typeof str !== "string") return ""; // nullify not-strings
|
|
7
|
+
return str.replace(/[&<>"]/g, (tag) => entities[tag]);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const getConfig = (routes) => {
|
|
11
|
+
const config = routes.find(
|
|
12
|
+
(r) =>
|
|
13
|
+
typeof r !== "string" && typeof r !== "function" && typeof r === "object",
|
|
14
|
+
);
|
|
15
|
+
if (!config) return {};
|
|
16
|
+
if (config.tags) {
|
|
17
|
+
if (typeof config.tags === "string") {
|
|
18
|
+
config.tags = config.tags.split(/\s*\,\s*/g);
|
|
19
|
+
}
|
|
20
|
+
if (!Array.isArray(config.tags)) {
|
|
21
|
+
throw new Error("invalid tags", config.tags);
|
|
22
|
+
}
|
|
23
|
+
config.tags = config.tags.map((t) => t.trim());
|
|
24
|
+
}
|
|
25
|
+
return config;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const pkg = await fsp
|
|
29
|
+
.readFile("package.json", "utf-8")
|
|
30
|
+
.then((data) => JSON.parse(data))
|
|
31
|
+
.catch(() => ({}));
|
|
32
|
+
|
|
33
|
+
const getTag = (name, fn) => {
|
|
34
|
+
const found = fn
|
|
35
|
+
.toString()
|
|
36
|
+
.split("\n")
|
|
37
|
+
.filter((l) => /\s+\/\/\s/.test(l))
|
|
38
|
+
.map((l) => l.trim().replace("// ", ""))
|
|
39
|
+
.find((l) => l.startsWith(name));
|
|
40
|
+
if (!found) return "";
|
|
41
|
+
return encode(found.replace(name, "").trim());
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const getDescription = (fn) => getTag("@description", fn) || "";
|
|
45
|
+
const getReturn = (fn) => getTag("@returns", fn) || "200 OK";
|
|
46
|
+
|
|
47
|
+
const generateOpenApiPaths = (handlers) => {
|
|
48
|
+
const paths = {};
|
|
49
|
+
|
|
50
|
+
for (const [method, routes] of Object.entries(handlers)) {
|
|
51
|
+
for (const route of routes) {
|
|
52
|
+
const [_, path, fn, meta] = [
|
|
53
|
+
route[0],
|
|
54
|
+
route[1],
|
|
55
|
+
route.find((p) => typeof p === "function"),
|
|
56
|
+
route.find((p) => typeof p === "object"),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const config = getConfig(route);
|
|
60
|
+
|
|
61
|
+
if (typeof path !== "string" || path === "*" || !fn) continue;
|
|
62
|
+
|
|
63
|
+
// Normalize path (convert ":id" to "{id}" for OpenAPI)
|
|
64
|
+
const normalizedPath = path.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
|
|
65
|
+
|
|
66
|
+
if (!paths[normalizedPath]) {
|
|
67
|
+
paths[normalizedPath] = {};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const getTitle = (fn) => {
|
|
71
|
+
if (!fn.name) return null;
|
|
72
|
+
// Well, we shouldn't really rely on these, e.g. automatic names from export default
|
|
73
|
+
const wrongNames = ["default"];
|
|
74
|
+
if (wrongNames.includes(fn.name)) return null;
|
|
75
|
+
if (fn.name.length <= 3) return null;
|
|
76
|
+
if (fn.name.includes("_")) return fn.name.replaceAll("_", " ");
|
|
77
|
+
const name = fn.name
|
|
78
|
+
.split(/(?=[A-Z])/)
|
|
79
|
+
.join(" ")
|
|
80
|
+
.toLowerCase();
|
|
81
|
+
return name[0].toUpperCase() + name.slice(1);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
paths[normalizedPath][method] = {
|
|
85
|
+
tags: config.tags,
|
|
86
|
+
summary:
|
|
87
|
+
config.title ||
|
|
88
|
+
getTitle(fn) ||
|
|
89
|
+
getTag("@title", fn) ||
|
|
90
|
+
`${method.toUpperCase()} ${path}`,
|
|
91
|
+
description: getDescription(fn),
|
|
92
|
+
responses: {
|
|
93
|
+
200: {
|
|
94
|
+
description: getReturn(fn),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
...(meta
|
|
98
|
+
? {
|
|
99
|
+
parameters: Object.entries(meta).map(([key, value]) => ({
|
|
100
|
+
name: key,
|
|
101
|
+
in: "query",
|
|
102
|
+
required: false,
|
|
103
|
+
schema: { type: typeof value },
|
|
104
|
+
example: value,
|
|
105
|
+
})),
|
|
106
|
+
}
|
|
107
|
+
: {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return paths;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export default async (ctx) => {
|
|
116
|
+
const domain = pkg.homepage || ctx.url.origin;
|
|
117
|
+
const openApi = {
|
|
118
|
+
openapi: "3.0.0",
|
|
119
|
+
info: {
|
|
120
|
+
title: pkg.name || "API Documentation",
|
|
121
|
+
version: pkg.version || "1.0.0",
|
|
122
|
+
description: pkg.description || "",
|
|
123
|
+
},
|
|
124
|
+
servers: domain ? [{ url: domain }] : [],
|
|
125
|
+
paths: generateOpenApiPaths(ctx.app.handlers),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const configuration = ctx.options.openapi.scalar || {};
|
|
129
|
+
|
|
130
|
+
return `
|
|
131
|
+
<!doctype html>
|
|
132
|
+
<html>
|
|
133
|
+
<head>
|
|
134
|
+
<title>API Reference</title>
|
|
135
|
+
<meta charset="utf-8" />
|
|
136
|
+
<meta
|
|
137
|
+
name="viewport"
|
|
138
|
+
content="width=device-width, initial-scale=1" />
|
|
139
|
+
<style>.open-api-client-button {display: none!important;}</style>
|
|
140
|
+
</head>
|
|
141
|
+
<body>
|
|
142
|
+
<script id="api-reference" type="application/json" data-configuration="${encode(JSON.stringify(configuration))}">${JSON.stringify(openApi, null, 2)}</script>
|
|
143
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
|
144
|
+
</body>
|
|
145
|
+
</html> `;
|
|
146
|
+
};
|
package/src/pathPattern.js
CHANGED
|
@@ -15,11 +15,24 @@ export default function pathPattern(pattern, path) {
|
|
|
15
15
|
const patt = pattParts[i] || "";
|
|
16
16
|
const part = pathParts[i] || "";
|
|
17
17
|
const last = pattParts[pattParts.length - 1];
|
|
18
|
-
const key = patt
|
|
18
|
+
const key = patt
|
|
19
|
+
.replace(/^:/, "")
|
|
20
|
+
.replace(/\?$/, "")
|
|
21
|
+
.replace(/\(\w*\)/, "");
|
|
19
22
|
if (patt === part) continue;
|
|
20
23
|
if (patt.endsWith("?") && !part) continue;
|
|
21
24
|
if (patt.startsWith(":")) {
|
|
22
25
|
params[key] = part;
|
|
26
|
+
if (/\(\w*\)/.test(patt)) {
|
|
27
|
+
if (patt.includes("(number)")) {
|
|
28
|
+
const value = Number(part);
|
|
29
|
+
params[key] = Number.isNaN(value) ? undefined : value;
|
|
30
|
+
}
|
|
31
|
+
if (patt.includes("(date)")) {
|
|
32
|
+
const value = new Date(part);
|
|
33
|
+
params[key] = Number.isNaN(value) ? undefined : value;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
23
36
|
continue;
|
|
24
37
|
}
|
|
25
38
|
if ((!patt && last === "*" && part) || (patt === "*" && part)) {
|
package/src/pathPattern.test.js
CHANGED
|
@@ -25,20 +25,45 @@ describe("pathPattern.js", () => {
|
|
|
25
25
|
expect(pathPattern("/hello/", "/hello/John")).toEqual(null);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
it("can capture simple
|
|
28
|
+
it("can capture simple params", () => {
|
|
29
29
|
expect(pathPattern("/:hello", "/john")).toEqual({ hello: "john" });
|
|
30
30
|
expect(pathPattern("/hello/:there", "/hello/john")).toEqual({
|
|
31
31
|
there: "john",
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
it("will parse the params as numbers", () => {
|
|
36
|
+
expect(pathPattern("/:id(number)", "/25")).toEqual({ id: 25 });
|
|
37
|
+
expect(pathPattern("/:id(number)", "/25.5")).toEqual({ id: 25.5 });
|
|
38
|
+
expect(pathPattern("/users/:id(number)", "/users/25")).toEqual({
|
|
39
|
+
id: 25,
|
|
40
|
+
});
|
|
41
|
+
expect(pathPattern("/:id(date)", "/2015-01-01")).toEqual({
|
|
42
|
+
id: new Date("2015-01-01"),
|
|
43
|
+
});
|
|
44
|
+
expect(pathPattern("/report/:id(date)", "/report/2015-01-01")).toEqual({
|
|
45
|
+
id: new Date("2015-01-01"),
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("will still match, but not parse it if it's an invalid number", () => {
|
|
50
|
+
expect(pathPattern("/:id(number)", "/hi")).toEqual({});
|
|
51
|
+
expect(pathPattern("/users/:id(number)", "/users/hi")).toEqual({});
|
|
52
|
+
});
|
|
53
|
+
|
|
35
54
|
it("requires a part for the asterisk", () => {
|
|
36
55
|
expect(pathPattern("/hello/:there/*", "/hello/John")).toEqual(null);
|
|
37
56
|
});
|
|
38
57
|
|
|
39
58
|
it("can make a part optional", () => {
|
|
40
|
-
|
|
59
|
+
expect(pathPattern("/:name?", "/")).toEqual({});
|
|
41
60
|
expect(pathPattern("/hello/:name?", "/hello/")).toEqual({});
|
|
61
|
+
expect(pathPattern("/:id(number)?", "/")).toEqual({});
|
|
62
|
+
expect(pathPattern("/:id(date)?", "/")).toEqual({});
|
|
63
|
+
expect(pathPattern("/:id(number)?", "/25")).toEqual({ id: 25 });
|
|
64
|
+
expect(pathPattern("/:id(date)?", "/2015-01-01")).toEqual({
|
|
65
|
+
id: new Date("2015-01-01"),
|
|
66
|
+
});
|
|
42
67
|
expect(pathPattern("/:name?", "/john")).toEqual({ name: "john" });
|
|
43
68
|
expect(pathPattern("/:name/*?", "/john")).toEqual({ name: "john" });
|
|
44
69
|
});
|