maxserver 1.0.1 → 2.0.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/src/docs.js ADDED
@@ -0,0 +1,169 @@
1
+ import path from "node:path";
2
+
3
+ const SECURITY = [{ bearerAuth: [] }, { cookieAuth: [] }];
4
+ const NOT_SCHEMA = ["$id", "auth", "order", "tags", "summary"];
5
+
6
+
7
+ /**
8
+ * Rewrites model refs: "User" -> "#/components/schemas/User",
9
+ * "User#/properties/name" -> "#/components/schemas/User/properties/name"
10
+ */
11
+ function fixRefs(value) {
12
+ if (Array.isArray(value)) return value.map(fixRefs);
13
+ if (!value || typeof value !== "object") return value;
14
+
15
+ const out = {};
16
+ for (const [k, v] of Object.entries(value)) {
17
+ if (k === "$ref" && typeof v === "string" && !v.startsWith("#")) {
18
+ const [name, pointer = ""] = v.split("#");
19
+ out[k] = `#/components/schemas/${name}${pointer}`;
20
+ } else out[k] = fixRefs(v);
21
+ }
22
+ return out;
23
+ }
24
+
25
+
26
+ function clean(schema) {
27
+ const out = { ...schema };
28
+ for (const k of NOT_SCHEMA) delete out[k];
29
+ return fixRefs(out);
30
+ }
31
+
32
+
33
+ /**
34
+ * Params, querystring and headers become OpenAPI parameters.
35
+ * Every path param is listed, typed from schema.params if given.
36
+ */
37
+ function parameters(route, schema) {
38
+ const list = [];
39
+ const add = (where, s = {}, names) => {
40
+ const props = s.properties || (s.type ? {} : s);
41
+ for (const name of names || Object.keys(props))
42
+ list.push({
43
+ name, in: where,
44
+ required: where === "path" || (s.required || []).includes(name),
45
+ schema: fixRefs(props[name] || { type: "string" }),
46
+ ...(props[name]?.description && { description: props[name].description }),
47
+ });
48
+ };
49
+
50
+ add("path", schema.params, [...route.path.matchAll(/:(\w+)/g)].map(m => m[1]));
51
+ if (schema.querystring || schema.query) add("query", schema.querystring || schema.query);
52
+ if (schema.headers) add("header", schema.headers);
53
+ return list;
54
+ }
55
+
56
+
57
+ function operation(route) {
58
+ const s = route.schema;
59
+ const op = {};
60
+
61
+ if (s.tags) op.tags = s.tags;
62
+ if (s.summary) op.summary = s.summary;
63
+ if (s.description) op.description = s.description;
64
+
65
+ const params = parameters(route, s);
66
+ if (params.length) op.parameters = params;
67
+
68
+ if (s.body) op.requestBody = { required: true, content: { "application/json": { schema: fixRefs(s.body) } } };
69
+
70
+ op.responses = {};
71
+ for (const [status, r] of Object.entries(s.response || { 200: null }))
72
+ op.responses[status] = r
73
+ ? { description: r.description || "Default Response", content: { "application/json": { schema: fixRefs(r) } } }
74
+ : { description: "Default Response" };
75
+
76
+ if (s.auth) op.security = SECURITY;
77
+ return op;
78
+ }
79
+
80
+
81
+ /**
82
+ * Routes sorted like v1: folder order kept, inside a folder by schema.order.
83
+ */
84
+ function sortRoutes(routes) {
85
+ const dirs = [];
86
+ for (const r of routes) {
87
+ const dir = path.dirname(r.file || "");
88
+ if (!dirs.includes(dir)) dirs.push(dir);
89
+ }
90
+
91
+ const key = r => dirs.indexOf(path.dirname(r.file || ""));
92
+ return [...routes].sort((a, b) => key(a) - key(b) || (a.schema.order ?? 999) - (b.schema.order ?? 999));
93
+ }
94
+
95
+
96
+ /**
97
+ * OpenAPI 3.1 document from routes with a schema and all models.
98
+ */
99
+ export function buildOpenApi(routes, models, info) {
100
+ const doc = {
101
+ openapi: "3.1.0",
102
+ info: info || { title: "API", version: "1.0.0" },
103
+ components: {
104
+ securitySchemes: {
105
+ bearerAuth: { type: "http", scheme: "bearer" },
106
+ cookieAuth: { type: "apiKey", in: "cookie", name: "token" },
107
+ },
108
+ schemas: {},
109
+ },
110
+ paths: {},
111
+ };
112
+
113
+ for (const m of models) doc.components.schemas[m.$id] = clean(m);
114
+
115
+ for (const r of sortRoutes(routes.filter(r => r.schema && Object.keys(r.schema).length))) {
116
+ const p = r.path.replace(/:(\w+)/g, "{$1}");
117
+ doc.paths[p] ||= {};
118
+ doc.paths[p][r.method.toLowerCase()] = operation(r);
119
+ }
120
+
121
+ return doc;
122
+ }
123
+
124
+
125
+ // Dev GUI files (built in the maxserver-docs repo). Read from disk, never
126
+ // imported, so production bundles stay free of them.
127
+ const UI_DIR = path.resolve(import.meta.dir, "../devdocs");
128
+ const UI_FILES = { "maxserver-docs.js": "text/javascript; charset=utf-8", "maxserver-docs.css": "text/css; charset=utf-8" };
129
+
130
+
131
+ function escapeHtml(text) {
132
+ return String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
133
+ }
134
+
135
+
136
+ /**
137
+ * Bun routes: /docs/openapi.json always; in development also the /docs
138
+ * dev GUI with its two files, when they exist.
139
+ */
140
+ export async function docsRoutes(openapi, dev) {
141
+ const json = JSON.stringify(openapi);
142
+ const routes = {
143
+ "/docs/openapi.json": () => new Response(json, { headers: { "Content-Type": "application/json" } }),
144
+ };
145
+
146
+ // 1. Production, or bundled app without the files: spec only
147
+ if (!dev || !(await Bun.file(path.join(UI_DIR, "maxserver-docs.js")).exists())) return routes;
148
+
149
+ // 2. Development: page + UI files
150
+ const html = `<!doctype html>
151
+ <html lang="en">
152
+ <head>
153
+ <meta charset="utf-8">
154
+ <meta name="viewport" content="width=device-width, initial-scale=1">
155
+ <title>${escapeHtml(openapi.info?.title || "API")}</title>
156
+ <link rel="stylesheet" href="/docs/maxserver-docs.css">
157
+ </head>
158
+ <body>
159
+ <div id="app" data-spec="/docs/openapi.json"></div>
160
+ <script type="module" src="/docs/maxserver-docs.js"></script>
161
+ </body>
162
+ </html>`;
163
+
164
+ routes["/docs"] = () => new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
165
+ for (const [name, type] of Object.entries(UI_FILES))
166
+ routes[`/docs/${name}`] = () => new Response(Bun.file(path.join(UI_DIR, name)), { headers: { "Content-Type": type } });
167
+
168
+ return routes;
169
+ }
package/src/errors.js ADDED
@@ -0,0 +1,70 @@
1
+ import { STATUS_CODES } from "node:http";
2
+
3
+
4
+ /**
5
+ * Creates an Error with an HTTP status code.
6
+ * Throw it from handlers to stop with a clean HTTP error.
7
+ */
8
+ export function createError(code, message) {
9
+ const err = new Error(message);
10
+ err.statusCode = code;
11
+ Error.captureStackTrace?.(err, createError);
12
+ return err;
13
+ }
14
+
15
+
16
+ /**
17
+ * Only valid HTTP error codes, everything else is 500.
18
+ */
19
+ function statusOf(err) {
20
+ const code = Number(err?.statusCode || err?.status);
21
+ return code >= 400 && code <= 599 ? code : 500;
22
+ }
23
+
24
+
25
+ /**
26
+ * Turns any thrown error into a JSON response.
27
+ * Shape as in v1 (Fastify): { statusCode, error, message }
28
+ */
29
+ export function errorResponse(err, production) {
30
+ const code = statusOf(err);
31
+
32
+ let message = err?.message || STATUS_CODES[code];
33
+ if (code >= 500 && production) message = STATUS_CODES[code];
34
+
35
+ return Response.json(
36
+ { statusCode: code, error: STATUS_CODES[code] || "Error", message },
37
+ { status: code }
38
+ );
39
+ }
40
+
41
+
42
+ /**
43
+ * First stack frame in the app (inside cwd, not node_modules), as "src/file.js:4".
44
+ */
45
+ function appLocation(err) {
46
+ const cwd = process.cwd() + "/";
47
+ for (const line of String(err?.stack || "").split("\n").slice(1)) {
48
+ const match = line.match(/(?:file:\/\/)?(\/[^\s()]+):(\d+):\d+/);
49
+ if (match && match[1].startsWith(cwd) && !match[1].includes("/node_modules/"))
50
+ return `${match[1].slice(cwd.length)}:${match[2]}`;
51
+ }
52
+ return null;
53
+ }
54
+
55
+
56
+ /**
57
+ * One log entry per error: status, request, message, app file:line, stack for 5xx.
58
+ * Development logs all errors, production only 5xx.
59
+ */
60
+ export function logError(err, req, dev) {
61
+ const code = statusOf(err);
62
+ if (!dev && code < 500) return;
63
+
64
+ const { pathname } = new URL(req.url);
65
+ const where = appLocation(err);
66
+ console.error(`❌ ${code} ${req.method} ${pathname} ${err?.message}${where ? ` (${where})` : ""}`);
67
+
68
+ const stack = String(err?.stack || "").split("\n").slice(1).join("\n");
69
+ if (code >= 500 && stack) console.error(stack);
70
+ }
package/src/headers.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Security headers, same as v1 helmet config:
3
+ * helmet defaults without CSP and frameguard, CORP cross-origin.
4
+ */
5
+ export const SECURITY_HEADERS = {
6
+ "Cross-Origin-Opener-Policy": "same-origin",
7
+ "Cross-Origin-Resource-Policy": "cross-origin",
8
+ "Origin-Agent-Cluster": "?1",
9
+ "Referrer-Policy": "no-referrer",
10
+ "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
11
+ "X-Content-Type-Options": "nosniff",
12
+ "X-DNS-Prefetch-Control": "off",
13
+ "X-Download-Options": "noopen",
14
+ "X-Permitted-Cross-Domain-Policies": "none",
15
+ "X-XSS-Protection": "0",
16
+ };
17
+
18
+
19
+ /**
20
+ * Sets headers on a response. Responses with immutable headers
21
+ * (e.g. from fetch) are copied first.
22
+ */
23
+ export function setHeaders(response, headers) {
24
+ try {
25
+ for (const [k, v] of Object.entries(headers)) response.headers.set(k, v);
26
+ return response;
27
+ } catch {
28
+ const copy = new Response(response.body, response);
29
+ for (const [k, v] of Object.entries(headers)) copy.headers.set(k, v);
30
+ return copy;
31
+ }
32
+ }
package/src/index.js CHANGED
@@ -1,93 +1,115 @@
1
- import Fastify from "fastify";
1
+ import { createError } from "./errors.js";
2
+ import { configureJwt } from "./jwt.js";
3
+ import { setupCors } from "./cors.js";
4
+ import { setupStatic } from "./static.js";
5
+ import { createValidators } from "./validate.js";
6
+ import { buildOpenApi, docsRoutes } from "./docs.js";
7
+ import { buildRoutes, fallback, finish } from "./routes.js";
2
8
 
3
- import {
4
- setupCors,
5
- setupHelmet,
6
- setupJwt,
7
- setupMongo,
8
- setupStatic,
9
- setupCookie,
10
- setupErrorLogger,
11
- } from "./setup.js";
9
+ export { createError } from "./errors.js";
10
+ export { signJwt, verifyJwt } from "./jwt.js";
12
11
 
13
- import { getAddress } from "./getAddress.js";
14
- import { setupDocs } from "./setupDocs.js";
15
- import { setupRoutes } from "./setupRoutes.js";
16
- import { setupDevSounds } from "./devSounds.js";
17
12
 
18
- import fastifyWebsocket from "@fastify/websocket";
13
+ // Framework globals
14
+ globalThis.createError = createError;
15
+ globalThis.ENV = {
16
+ ...process.env,
17
+ development: process.env.NODE_ENV !== "production",
18
+ production: process.env.NODE_ENV === "production",
19
+ };
20
+
21
+
22
+ // Filled by generated setup.js before server.js runs
23
+ const registry = { routes: null, models: [] };
24
+
25
+
26
+ /**
27
+ * Called by setup.js with all routes and models.
28
+ */
29
+ export function register({ routes = [], models = [] } = {}) {
30
+ registry.routes = routes;
31
+ registry.models = models;
32
+ }
33
+
19
34
 
20
35
  export default async function maxserver(config = {}) {
36
+
37
+ // 1. Config: maxserver() > .env > default
21
38
  const {
22
39
  port = Number(process.env.PORT || 3000),
23
40
  secret = process.env.SECRET,
24
- mongodb = process.env.MONGODB,
25
41
  docs = process.env.DOCS !== "false",
26
42
  cors = process.env.CORS || "*",
27
43
  env = process.env.NODE_ENV || "development",
28
- routesDir = process.env.ROUTESDIR || "src",
29
- scalar = {},
30
- openapiInfo,
31
- sounds = true,
32
- static: isStatic = process.env.STATIC,
44
+ static: staticDir = process.env.STATIC,
33
45
  public: isPublic = process.env.PUBLIC === "true",
34
-
35
- ...fastifyOpts
46
+ openapiInfo,
47
+ routesDir,
48
+ ...bunOptions
36
49
  } = config;
37
50
 
38
- globalThis.ENV = {
39
- ...process.env,
40
- development: process.env.NODE_ENV !== "production",
41
- production: process.env.NODE_ENV === "production"
42
- };
51
+ if (routesDir !== undefined)
52
+ throw new Error("maxserver: routesDir is read by the generator, set ROUTESDIR in .env instead");
53
+
54
+ if (!secret)
55
+ throw new Error("maxserver: secret is required, set secret in maxserver() or SECRET in .env");
56
+ if (!registry.routes)
57
+ throw new Error("maxserver: no routes registered. Start with `maxserver dev`, or `maxserver build` and `bun dist/bundle.js` (running server.js directly skips the generated setup.js)");
58
+
59
+ // 2. Models need an $id to be referenced
60
+ for (const m of registry.models)
61
+ if (!m.schema?.$id) throw new Error(`maxserver: model schema needs an $id (${m.file})`);
62
+ const models = registry.models.map(m => m.schema);
43
63
 
44
- const maxserverConfig = {
45
- port, secret, mongodb, docs, cors, env, openapiInfo, routesDir, scalar, sounds,
46
- static: isStatic,
47
- public: isPublic
64
+ // 3. Shared context for all requests
65
+ const dev = env !== "production";
66
+ configureJwt(secret);
67
+
68
+ const ctx = {
69
+ dev,
70
+ secret,
71
+ cors: setupCors(cors, !dev),
72
+ static: setupStatic(staticDir),
73
+ ajvs: createValidators(models),
48
74
  };
49
75
 
50
- if (!secret) throw new Error("secret is must have");
51
-
52
- let app;
53
- try {
54
- app = Fastify({
55
- trustProxy: true,
56
- ajv: { customOptions: { strictSchema: false } },
57
- ...fastifyOpts
58
- });
59
- } catch (err) {
60
- console.error("❌ Fastify initialization failed:", err);
61
- throw err;
76
+ // 4. Routes: user routes + docs
77
+ const routes = buildRoutes(registry.routes, ctx);
78
+ if (docs) {
79
+ const openapi = buildOpenApi(registry.routes, models, openapiInfo);
80
+ for (const [path, handler] of Object.entries(await docsRoutes(openapi, dev)))
81
+ routes[path] = { GET: req => finish(req, handler(), ctx) };
62
82
  }
63
83
 
64
- app.decorate("maxserver", maxserverConfig);
65
-
66
- app.decorate("start", async function () {
67
- const port = this.maxserver.port ?? 3000;
68
- const host = this.maxserver.public ? "0.0.0.0" : "127.0.0.1";
69
- await this.listen({ port, host });
70
- console.log("🟢 ", getAddress(this));
71
- });
72
-
73
- app.register(fastifyWebsocket);
74
-
75
- await setupDevSounds(app);
76
- await setupErrorLogger(app);
77
- await setupCookie(app);
78
- await setupHelmet(app);
79
- await setupCors(app);
80
- await setupJwt(app);
81
- await setupMongo(app);
82
- await setupStatic(app);
83
- await setupDocs(app);
84
- await setupRoutes(app);
85
-
86
- global.createError = function (code, message) {
87
- const err = new Error(message);
88
- err.statusCode = code;
89
- return err;
84
+ // 5. Server object
85
+ const server = {
86
+ config: { port, docs, cors, env, static: staticDir, public: isPublic, ...bunOptions },
87
+ bun: null,
88
+ url: null,
89
+
90
+ async start() {
91
+ // Defaults first (1 MiB body limit like v1), then any Bun.serve option,
92
+ // then what maxserver must control
93
+ server.bun = Bun.serve({
94
+ maxRequestBodySize: 1048576,
95
+ development: dev,
96
+ ...bunOptions,
97
+ port,
98
+ hostname: isPublic ? "0.0.0.0" : "127.0.0.1",
99
+ routes,
100
+ fetch: fallback(ctx),
101
+ });
102
+
103
+ server.url = server.bun.url.href.replace(/\/$/, "");
104
+ console.log("🟢 ", server.url);
105
+ if (routes["/docs"]) console.log("📚 ", `${server.url}/docs`);
106
+ return server;
107
+ },
108
+
109
+ async stop() {
110
+ await server.bun?.stop(true);
111
+ },
90
112
  };
91
113
 
92
- return app;
93
- }
114
+ return server;
115
+ }
package/src/jwt.js ADDED
@@ -0,0 +1,107 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { createError } from "./errors.js";
3
+
4
+ const HEADER = b64(JSON.stringify({ alg: "HS256", typ: "JWT" }));
5
+ const UNITS = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
6
+
7
+ let configuredSecret;
8
+
9
+
10
+ export function configureJwt(secret) {
11
+ configuredSecret = secret;
12
+ }
13
+
14
+
15
+ function getSecret() {
16
+ const secret = configuredSecret || process.env.SECRET;
17
+ if (!secret) throw new Error("signJwt: no secret, set secret in maxserver() or SECRET in .env");
18
+ return secret;
19
+ }
20
+
21
+
22
+ function b64(str) {
23
+ return Buffer.from(str).toString("base64url");
24
+ }
25
+
26
+
27
+ function sign(data, secret) {
28
+ return createHmac("sha256", secret).update(data).digest("base64url");
29
+ }
30
+
31
+
32
+ /**
33
+ * Parses expiresIn: seconds as number or "60s", "30m", "12h", "7d", "2w".
34
+ */
35
+ function toSeconds(value) {
36
+ if (typeof value === "number") return value;
37
+ const m = String(value).match(/^(\d+)\s*([smhdw])$/);
38
+ if (!m) throw new Error(`signJwt: invalid expiresIn "${value}"`);
39
+ return Number(m[1]) * UNITS[m[2]];
40
+ }
41
+
42
+
43
+ /**
44
+ * Creates a HS256 JWT signed with the server secret.
45
+ */
46
+ export function signJwt(payload, { expiresIn } = {}) {
47
+ const now = Math.floor(Date.now() / 1000);
48
+ const claims = { iat: now, ...payload };
49
+ if (expiresIn !== undefined) claims.exp = now + toSeconds(expiresIn);
50
+
51
+ const data = `${HEADER}.${b64(JSON.stringify(claims))}`;
52
+ return `${data}.${sign(data, getSecret())}`;
53
+ }
54
+
55
+
56
+ /**
57
+ * Verifies a HS256 JWT and returns its payload.
58
+ * Only HS256 is accepted, signature compared in constant time.
59
+ */
60
+ export function verifyJwt(token, secret = getSecret()) {
61
+ const invalid = createError(401, "Authorization token is invalid");
62
+
63
+ // 1. Structure
64
+ const parts = String(token).split(".");
65
+ if (parts.length !== 3) throw invalid;
66
+ const [head, body, signature] = parts;
67
+
68
+ // 2. Algorithm
69
+ let header, payload;
70
+ try {
71
+ header = JSON.parse(Buffer.from(head, "base64url"));
72
+ payload = JSON.parse(Buffer.from(body, "base64url"));
73
+ } catch {
74
+ throw invalid;
75
+ }
76
+ if (header?.alg !== "HS256") throw invalid;
77
+ if (!payload || typeof payload !== "object") throw invalid;
78
+
79
+ // 3. Signature
80
+ const expected = Buffer.from(sign(`${head}.${body}`, secret));
81
+ const given = Buffer.from(signature);
82
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) throw invalid;
83
+
84
+ // 4. Time claims
85
+ const now = Math.floor(Date.now() / 1000);
86
+ if (typeof payload.exp === "number" && now >= payload.exp)
87
+ throw createError(401, "Authorization token expired");
88
+ if (typeof payload.nbf === "number" && now < payload.nbf) throw invalid;
89
+
90
+ return payload;
91
+ }
92
+
93
+
94
+ /**
95
+ * Auth for routes with auth: true.
96
+ * Token from "Authorization: Bearer <token>" header or "token" cookie.
97
+ */
98
+ export function authenticate(req, secret) {
99
+ const auth = req.headers.get("authorization") || "";
100
+ const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : req.cookies?.get("token");
101
+
102
+ if (!token) throw createError(401, "No Authorization was found in request");
103
+
104
+ const user = verifyJwt(token, secret);
105
+ req.user = user;
106
+ req.userId = user.sub || user.userId || user.userid || user.id || null;
107
+ }