@server/next 0.23.1 → 0.24.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.23.1",
3
+ "version": "0.24.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",
@@ -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("/auth/logout", logout);
19
- ctx.app.get("/auth/login/github", providers.github.login);
20
- ctx.app.get("/auth/callback/github", providers.github.callback);
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("/auth/register/email", providers.email.register);
26
- ctx.app.post("/auth/login/email", providers.email.login);
27
- ctx.app.put("/auth/password/email", providers.email.password);
28
- ctx.app.put("/auth/reset/email", providers.email.password);
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 login(ctx) {
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 register(ctx) {
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 reset(ctx) {
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 password(ctx) {
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 { login, register, reset, password };
140
+ export default {
141
+ login: emailLogin,
142
+ register: emailRegister,
143
+ reset: emailResetPassword,
144
+ password: emailUpdatePassword,
145
+ };
@@ -25,7 +25,7 @@ const oauth = async (code) => {
25
25
  };
26
26
  };
27
27
 
28
- const login = (ctx) => {
28
+ const login = function githubLogin(ctx) {
29
29
  return redirect(
30
30
  `https://github.com/login/oauth/authorize?client_id=${env.GITHUB_ID}&scope=user:email`,
31
31
  );
@@ -82,5 +82,12 @@ export default function config(options = {}) {
82
82
  }
83
83
  }
84
84
 
85
+ // OpenAPI
86
+ if (options.openapi) {
87
+ if (options.openapi === true) {
88
+ options.openapi = {};
89
+ }
90
+ }
91
+
85
92
  return options;
86
93
  }
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}/${infer Rest}`
49
- ? Param | ExtractPathParams<`/${Rest}`>
50
- : Path extends `${string}:${infer Param}`
51
- ? Param
52
- : never;
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
+ boolean: boolean;
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]: K extends `${infer Key}?` ? string | undefined : string;
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
- | void;
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
  }
@@ -1,6 +1,7 @@
1
1
  import authMod from "../auth/index.js";
2
2
  import assets from "./assets.js";
3
3
  import timer from "./timer.js";
4
+ import openapi from "./openapi.js";
4
5
 
5
6
  const auth = authMod.middle;
6
- export { assets, auth, timer };
7
+ export { assets, auth, timer, openapi };
@@ -0,0 +1,146 @@
1
+ import fsp from "node:fs/promises";
2
+
3
+ const entities = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
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
+ };
@@ -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.replace(/^:/, "").replace(/\?$/, "");
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)) {
@@ -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 groups", () => {
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
- // expect(pathPattern("/:name?", "/")).toEqual({});
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
  });