maxserver 1.0.1 → 2.1.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/routes.js ADDED
@@ -0,0 +1,226 @@
1
+ import { authenticate as authenticateJwt } from "./jwt.js";
2
+ import { corsHeaders, fixedCorsHeaders, originHeaders, preflight } from "./cors.js";
3
+ import { errorResponse, logError } from "./errors.js";
4
+ import { API_HEADERS, SECURITY_HEADERS, setHeaders } from "./headers.js";
5
+ import { compileRoute, validatePart, checkResponse } from "./validate.js";
6
+ import { serveStatic } from "./static.js";
7
+
8
+
9
+ /**
10
+ * Common end of every response: CORS + security headers.
11
+ */
12
+ export function finish(req, response, ctx, security = SECURITY_HEADERS) {
13
+ return setHeaders(response, { ...corsHeaders(req, ctx.cors), ...security });
14
+ }
15
+
16
+
17
+ /**
18
+ * Headers of every handler response, built once: Bun copies a Headers
19
+ * object much faster than it sets headers one by one.
20
+ */
21
+ export function defaultHeaders(cors) {
22
+ return new Headers({ ...fixedCorsHeaders(cors), ...API_HEADERS });
23
+ }
24
+
25
+
26
+ /**
27
+ * Query string as object, repeated keys become arrays (like Fastify).
28
+ */
29
+ function parseQuery(url) {
30
+ const query = {};
31
+ const i = url.indexOf("?");
32
+ if (i < 0) return query;
33
+ for (const [k, v] of new URLSearchParams(url.slice(i + 1))) {
34
+ if (!Object.hasOwn(query, k)) setProp(query, k, v);
35
+ else if (Array.isArray(query[k])) query[k].push(v);
36
+ else query[k] = [query[k], v];
37
+ }
38
+ return query;
39
+ }
40
+
41
+
42
+ /**
43
+ * req.query parsed on first use, because reading req.url is slow in Bun.
44
+ */
45
+ function lazyQuery(req) {
46
+ let query;
47
+ Object.defineProperty(req, "query", {
48
+ get: () => query ??= parseQuery(req.url),
49
+ set: value => { query = value; },
50
+ configurable: true,
51
+ enumerable: true,
52
+ });
53
+ }
54
+
55
+
56
+ function badRequest(message) {
57
+ return Object.assign(new Error(message), { statusCode: 400 });
58
+ }
59
+
60
+
61
+ /**
62
+ * Blocks prototype poisoning like Fastify: __proto__ and constructor.prototype keys -> 400.
63
+ */
64
+ function safeParse(text) {
65
+ const risky = text.includes("__proto__") || text.includes("constructor");
66
+ return JSON.parse(text, risky ? (key, value) => {
67
+ if (key === "__proto__" || (key === "constructor" && value && typeof value === "object" && "prototype" in value))
68
+ throw badRequest("Object contains forbidden prototype property");
69
+ return value;
70
+ } : undefined);
71
+ }
72
+
73
+
74
+ /**
75
+ * Parses the body: JSON and text, else left unread.
76
+ */
77
+ async function parseBody(req) {
78
+ const type = (req.headers.get("content-type") || "").toLowerCase();
79
+ if (type.includes("application/json")) {
80
+ const text = await req.text();
81
+ if (!text) throw badRequest("Body cannot be empty when content-type is set to 'application/json'");
82
+ try {
83
+ return safeParse(text);
84
+ } catch (err) {
85
+ throw err.statusCode ? err : badRequest("Body is not valid JSON");
86
+ }
87
+ }
88
+ if (type.startsWith("text/")) return await req.text();
89
+ return undefined;
90
+ }
91
+
92
+
93
+ function setProp(obj, key, value) {
94
+ Object.defineProperty(obj, key, { value, writable: true, configurable: true, enumerable: true });
95
+ }
96
+
97
+
98
+ /**
99
+ * Handler response object: collects status and headers.
100
+ * Headers are a copy of the defaults, made only when a handler sets one.
101
+ */
102
+ function createRes(ctx) {
103
+ const res = {
104
+ statusCode: 200,
105
+ headers: null,
106
+ status(code) { res.statusCode = code; return res; },
107
+ header(name, value) {
108
+ res.headers ||= new Headers(ctx.headers);
109
+ if (name.toLowerCase() === "set-cookie") res.headers.append(name, value);
110
+ else res.headers.set(name, value);
111
+ return res;
112
+ },
113
+ };
114
+ return res;
115
+ }
116
+
117
+
118
+ /**
119
+ * Handler result -> Response with default, handler and CORS origin headers.
120
+ */
121
+ function toResponse(data, res, req, ctx) {
122
+ if (data instanceof Response) return finish(req, data, ctx);
123
+
124
+ // 1. Headers: the prebuilt defaults unless something is added
125
+ let headers = res.headers || ctx.headers;
126
+ const origin = originHeaders(req, ctx.cors);
127
+ if (origin) {
128
+ if (headers === ctx.headers) headers = new Headers(headers);
129
+ for (const [k, v] of Object.entries(origin)) headers.set(k, v);
130
+ }
131
+
132
+ // 2. Body
133
+ if (data === undefined) {
134
+ const status = res.statusCode === 200 ? 204 : res.statusCode;
135
+ return new Response(null, { status, headers });
136
+ }
137
+
138
+ return Response.json(data, { status: res.statusCode, headers });
139
+ }
140
+
141
+
142
+ /**
143
+ * Wraps a route handler: auth, parsing, validation, handler, response.
144
+ */
145
+ function wrap(route, ctx) {
146
+ const schema = route.schema || {};
147
+ const v = compileRoute(schema, ctx.ajvs, ctx.dev);
148
+ const name = `${route.method} ${route.path}`;
149
+
150
+ return async function (req) {
151
+ try {
152
+
153
+ // 1. Auth: the app's authenticate (its result is req.auth), else the built-in JWT
154
+ if (schema.auth && ctx.authenticate) {
155
+ const auth = await ctx.authenticate(req);
156
+ if (!auth) throw Object.assign(new Error("Unauthorized"), { statusCode: 401 });
157
+ setProp(req, "auth", auth);
158
+ } else if (schema.auth) authenticateJwt(req, ctx.secret);
159
+
160
+ // 2. Parse (Bun's req.params is kept and validated in place)
161
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : await parseBody(req);
162
+
163
+ // 3. Validate (may coerce, add defaults, remove extras)
164
+ if (v.request.params) validatePart(v.request.params, "params", req.params);
165
+ if (v.request.querystring) {
166
+ const query = parseQuery(req.url);
167
+ validatePart(v.request.querystring, "querystring", query);
168
+ setProp(req, "query", query);
169
+ } else lazyQuery(req);
170
+ if (v.request.headers) validatePart(v.request.headers, "headers", Object.fromEntries(req.headers));
171
+ if (v.request.body) validatePart(v.request.body, "body", body);
172
+
173
+ setProp(req, "body", body);
174
+
175
+ // 4. Handler
176
+ const res = createRes(ctx);
177
+ const data = await route.handler(req, res);
178
+ const response = toResponse(data, res, req, ctx);
179
+
180
+ // 5. Development: check response against schema
181
+ if (ctx.dev && !(data instanceof Response)) {
182
+ const problem = checkResponse(v.response, response.status, data);
183
+ if (problem) console.warn(`⚠️ Response mismatch ${name} ${response.status} (${route.file}): ${problem}`);
184
+ }
185
+ return response;
186
+
187
+ } catch (err) {
188
+ logError(err, req, ctx.dev);
189
+ return finish(req, errorResponse(err, !ctx.dev), ctx, API_HEADERS);
190
+ }
191
+ };
192
+ }
193
+
194
+
195
+ /**
196
+ * Registered routes -> Bun.serve routes object.
197
+ */
198
+ export function buildRoutes(routes, ctx) {
199
+ const out = {};
200
+ for (const r of routes) {
201
+ out[r.path] ||= {};
202
+ try {
203
+ out[r.path][r.method] = wrap(r, ctx);
204
+ } catch (err) {
205
+ throw new Error(`Invalid schema for ${r.method} ${r.path} (${r.file}): ${err.message}`);
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+
211
+
212
+ /**
213
+ * Everything not matched by a route: preflight, static files, 404.
214
+ */
215
+ export function fallback(ctx) {
216
+ return async function (req) {
217
+ if (req.method === "OPTIONS") return finish(req, preflight(req, ctx.cors), ctx);
218
+
219
+ const file = await serveStatic(req, ctx.static);
220
+ if (file) return finish(req, file, ctx);
221
+
222
+ const { pathname } = new URL(req.url);
223
+ const err = Object.assign(new Error(`Route ${req.method}:${pathname} not found`), { statusCode: 404 });
224
+ return finish(req, errorResponse(err, !ctx.dev), ctx, API_HEADERS);
225
+ };
226
+ }
package/src/static.js ADDED
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+
5
+ /**
6
+ * Resolves the static directory, null if not set or missing (as in v1).
7
+ */
8
+ export function setupStatic(dir) {
9
+ if (!dir) return null;
10
+
11
+ if (typeof dir !== "string") {
12
+ console.error("❌ maxserver.static must be a string path");
13
+ return null;
14
+ }
15
+
16
+ const root = path.resolve(dir);
17
+ if (!fs.existsSync(root)) {
18
+ console.error(`❌ maxserver.static not found: ${root}`);
19
+ return null;
20
+ }
21
+
22
+ return root;
23
+ }
24
+
25
+
26
+ /**
27
+ * Serves a file from root, null if not found.
28
+ * Blocks paths outside root and dotfiles (.env, .git).
29
+ */
30
+ export async function serveStatic(req, root) {
31
+ if (!root || (req.method !== "GET" && req.method !== "HEAD")) return null;
32
+
33
+ // 1. Decode path
34
+ let pathname;
35
+ try {
36
+ pathname = decodeURIComponent(new URL(req.url).pathname);
37
+ } catch {
38
+ return null;
39
+ }
40
+ if (pathname.includes("\0")) return null;
41
+
42
+ // 2. Stay inside root, no dotfiles
43
+ let file = path.resolve(root, "." + pathname);
44
+ const rel = path.relative(root, file);
45
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
46
+ if (rel.split(path.sep).some(p => p.startsWith("."))) return null;
47
+
48
+ // 3. Directory -> index.html
49
+ let stat;
50
+ try {
51
+ stat = fs.statSync(file, { throwIfNoEntry: false });
52
+ } catch {
53
+ return null;
54
+ }
55
+ if (stat?.isDirectory()) file = path.join(file, "index.html");
56
+
57
+ const bunFile = Bun.file(file);
58
+ if (!(await bunFile.exists())) return null;
59
+ return new Response(bunFile);
60
+ }
@@ -0,0 +1,90 @@
1
+ import Ajv from "ajv";
2
+ import addFormats from "ajv-formats";
3
+ import { createError } from "./errors.js";
4
+
5
+ const PARTS = ["params", "querystring", "headers", "body"];
6
+
7
+
8
+ /**
9
+ * Two ajv instances:
10
+ * - request: same options as Fastify defaults, changes data (coerce, defaults, remove)
11
+ * - response: never changes data, only reports
12
+ */
13
+ export function createValidators(models = []) {
14
+ const request = new Ajv({ coerceTypes: "array", useDefaults: true, removeAdditional: true, strict: false });
15
+ const response = new Ajv({ allErrors: true, strict: false });
16
+
17
+ for (const ajv of [request, response]) {
18
+ addFormats(ajv);
19
+ for (const model of models) ajv.addSchema(model);
20
+ }
21
+
22
+ return { request, response };
23
+ }
24
+
25
+
26
+ /**
27
+ * Fastify shorthand: params / querystring / headers may list properties directly.
28
+ */
29
+ function normalize(part, schema) {
30
+ if (part !== "body" && !schema.type && !schema.properties && !schema.$ref)
31
+ schema = { type: "object", properties: schema };
32
+ return part === "headers" ? lowercaseHeaders(schema) : schema;
33
+ }
34
+
35
+
36
+ /**
37
+ * Request header names are lowercase, so schema names must be too (as in Fastify).
38
+ */
39
+ function lowercaseHeaders(schema) {
40
+ const out = { ...schema };
41
+ if (schema.properties)
42
+ out.properties = Object.fromEntries(Object.entries(schema.properties).map(([k, v]) => [k.toLowerCase(), v]));
43
+ if (schema.required) out.required = schema.required.map(k => k.toLowerCase());
44
+ return out;
45
+ }
46
+
47
+
48
+ /**
49
+ * Compiles all validators of one route once at startup.
50
+ */
51
+ export function compileRoute(schema, ajvs, dev) {
52
+ const out = { request: {}, response: {} };
53
+
54
+ // 1. Request parts
55
+ for (const part of PARTS) {
56
+ const s = part === "querystring" ? (schema.querystring || schema.query) : schema[part];
57
+ if (s) out.request[part] = ajvs.request.compile(normalize(part, s));
58
+ }
59
+
60
+ // 2. Response schemas, only needed in development
61
+ if (dev && schema.response)
62
+ for (const [status, s] of Object.entries(schema.response))
63
+ out.response[status] = ajvs.response.compile(s);
64
+
65
+ return out;
66
+ }
67
+
68
+
69
+ /**
70
+ * Validates one request part, throws 400 like Fastify: "body/name must be string".
71
+ */
72
+ export function validatePart(validate, part, data) {
73
+ if (validate(data)) return;
74
+ const e = validate.errors[0];
75
+ throw createError(400, `${part}${e.instancePath} ${e.message}`);
76
+ }
77
+
78
+
79
+ /**
80
+ * Development: checks the response against its schema, returns error text or null.
81
+ */
82
+ export function checkResponse(validators, status, data) {
83
+ const validate = validators[status] || validators[`${String(status)[0]}xx`] || validators.default;
84
+ if (!validate) return null;
85
+
86
+ const json = data === undefined ? undefined : JSON.parse(JSON.stringify(data));
87
+ if (validate(json)) return null;
88
+
89
+ return validate.errors.map(e => `response${e.instancePath} ${e.message}`).join(", ");
90
+ }
@@ -4,5 +4,4 @@ NODE_ENV = development
4
4
  # PORT
5
5
  # CORS
6
6
  # SECRET
7
- # MONGODB
8
7
  # STATIC
@@ -0,0 +1,3 @@
1
+ node_modules
2
+ setup.js
3
+ dist
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "__NAME__",
3
+ "version": "0.1.0",
4
+ "description": "",
5
+ "main": "setup.js",
6
+ "scripts": {
7
+ "dev": "maxserver dev",
8
+ "build": "maxserver build",
9
+ "start": "bun dist/bundle.js"
10
+ },
11
+ "type": "module",
12
+ "private": true,
13
+ "dependencies": {}
14
+ }
@@ -1,6 +1,6 @@
1
1
  // POST /hello
2
2
 
3
- export default async function handler(req, rep) {
3
+ export default async function handler(req, res) {
4
4
 
5
5
  console.log("POST /hello");
6
6
 
@@ -1,10 +1,10 @@
1
1
  // GET /welcome
2
2
 
3
- export default async function handler(req, rep) {
3
+ export default async function handler(req, res) {
4
4
 
5
5
  console.log("GET /welcome");
6
6
  return {
7
- message: "Weclome to maxserver 😉 - Updated",
7
+ message: "Welcome to maxserver 😉",
8
8
  };
9
9
 
10
10
  }
@@ -8,7 +8,7 @@ export default {
8
8
  properties: {
9
9
  message: {
10
10
  type: "string",
11
- example: "Weclome to maxserver 😉 - Updated"
11
+ example: "Welcome to maxserver 😉"
12
12
  }
13
13
  },
14
14
  required: ["message"]
package/.github/README.md DELETED
@@ -1,263 +0,0 @@
1
- # maxserver
2
- Node server setup based on **Fastify** to speed up backend development.
3
-
4
- - **Auto Routes**: auto imports and registers routes and schemas
5
- - **Auto Docs**: auto generates docs based on schemas
6
- - **Preconfigures essentials**: jwt auth, cors, helmet
7
- - **Auto Connect MongoDB** (optional)
8
-
9
- <br><br>
10
-
11
- ## Install
12
- ```js
13
- npm install maxserver
14
- ```
15
- <br>
16
-
17
- ## Setup
18
- ```js
19
- import maxserver from "maxserver";
20
-
21
- const server = await maxserver({
22
- port: 3000,
23
- secret: "your_secret"
24
- });
25
-
26
- await server.start();
27
- export default server;
28
- ```
29
- <br>
30
-
31
- ## ⚙️ Configure
32
- Configs can be passed to the init call to **maxserver()** or set in your .env file.
33
- If you define options in env, use all upper case letters.
34
- Any fastify options can be passed to maxserver() too.
35
-
36
-
37
- | Variable | Default | Description |
38
- | :--- | :--- | :--- |
39
- | `port` | `3000` | Server port |
40
- | `secret` | *-* | Secret used for jwt and cookies |
41
- | `cors` | `*` | Default all allowed |
42
- | `docs` | `true` | Set `false` to disable auto generated docs |
43
- | `mongodb` | *-* | MongoDB URI, if set auto-connects db |
44
- | `public` | `false` | Set `true` to expose the server publicly (binds to `0.0.0.0`) |
45
- | `static` | *-* | If set, serves this directory statically |
46
- | `routesDir` | *src* | Directory to auto collect routes |
47
- ---
48
-
49
- <br>
50
-
51
-
52
- ## 🤖 Auto Routing
53
- Routes are auto registered based on one small comment per file:
54
-
55
- ```
56
- // METHOD /path
57
- ```
58
- <br>
59
-
60
-
61
- ```js
62
- // GET /hello
63
-
64
- export default async function handler(req, rep) {
65
-
66
- console.log("GET /hello");
67
- return {
68
- message: "Hello world",
69
- };
70
-
71
- }
72
- ```
73
- <br>
74
-
75
- Doesn't matter how nested your path is or how many params,
76
- It always works this simple and you are free to position your files the way you like.
77
- If you don't want to autoregister some files, then simply don't add that magic comment 😃
78
- <br>
79
- <br>
80
- ```
81
- // GET /teams/:teamid
82
- // PATCH /forms/:formId/questions/:questionId
83
- ...
84
- ```
85
- <br/>
86
-
87
- ### 3 RULES
88
- 1. Add magic comment
89
- 2. Default export handler
90
- 3. One handler per file
91
-
92
- <br>
93
-
94
-
95
- ## 🧾 Schemas
96
- Files ending with **`.schema.js`** will be auto registered.
97
- For example: **hello.js** and **hello.schema.js**
98
-
99
- Schemas are optional.
100
- Besides the basic validation fields we can set fields like `tags`, `summary` and description,
101
- which will appear in the docs. Only if a schema exists the route will be added to the documentation.
102
-
103
-
104
- ```js
105
- export default {
106
-
107
- tags: ["Test"],
108
- summary: "Post hello",
109
- description: "Accepts a name and returns a greeting.",
110
-
111
- body: {
112
- type: "object",
113
- required: ["name"],
114
- properties: {
115
- name: {
116
- type: "string",
117
- },
118
- },
119
- },
120
-
121
- ...
122
- };
123
- ```
124
-
125
-
126
- ### MODELS
127
- You can also auto register **models** (schemas which are shared between multiple routes).
128
- For example `User.schema.js`. It "magically" understand the difference betwen route specific
129
- schema or generic model, by looking if a sibling file exist or not 😉
130
-
131
- <br>
132
-
133
- **‼️ Important use export default**
134
- Some examples in the template folder.
135
-
136
-
137
-
138
- ## 📚 API Docs
139
- Open in your browser **`localhost:3000/docs`**
140
- You should find all your routes well documented.
141
- And you can also easily test any route.
142
-
143
- <br>
144
-
145
-
146
-
147
- ## Global Named Exports
148
-
149
- Every named export across your JavaScript files is automatically assigned to the Node.js `global` object on startup. This makes your utility functions, constants, or services instantly accessible anywhere in the application without manual `import` statements. The system safely ignores `default` exports and lifecycle hooks, and it will immediately halt with a clear console error if it detects duplicate variable names across different files.
150
-
151
-
152
-
153
-
154
-
155
-
156
-
157
-
158
-
159
-
160
-
161
-
162
-
163
- ## 🔐 Authentication
164
- JWT header and cookie based auth is preconfigured.
165
- To enable auth for a route set in it's schema **auth = true**
166
- The authenticated user is available as **`req.userId`**
167
-
168
- ```js
169
- // Inside schema
170
-
171
- export default {
172
- auth: true
173
- };
174
- ```
175
-
176
- ## 🛠️ Route Options
177
- Though we don't mostly register routes manually, we don't set route options on the register call.
178
- If needed, you can wether register that route manually or just set them on the schema.
179
-
180
- ```js
181
- // Inside schema
182
-
183
- export default {
184
- routeOptions: {
185
- config: {
186
- preHandler: ...
187
- },
188
- },
189
- ...
190
- ```
191
-
192
- <br>
193
- <br>
194
-
195
- ## 🍃 MongoDB
196
- Set option **`MONGODB`** your mongodbURI and it will auto-connect at server start and you get:
197
-
198
- - global **`db`** (connected database handle)
199
- - global **`oid(string)`** (string → MongoDB `ObjectId`)
200
-
201
- | Global | What it is | Why it exists |
202
- | :--- | :--- | :--- |
203
- | `db` | MongoDB database handle | Use it directly in handlers |
204
- | `oid(id)` | string → `ObjectId` | Saves you from importing everywhere `ObjectId` |
205
-
206
- ### Example
207
-
208
- ```js
209
- // Inside route handlers
210
-
211
- export default async function (req, res) {
212
-
213
- await db.feedback.insert(...)
214
- }
215
- ```
216
-
217
-
218
- <br>
219
-
220
- ## 🧰 Error Handling
221
-
222
- Use `createError(code, message)` to stop immediately with a clean HTTP error.
223
-
224
- ```js
225
- if (!user) throw createError(404, "User not found");
226
- ```
227
-
228
- Rule of thumb: make the message something you would want to see at 03:00 in logs.
229
-
230
- <br>
231
-
232
-
233
- ## Autoregister Hooks
234
-
235
- Exported functions starting with `autoregister_` automatically execute on startup and receive the Fastify `app` instance. This allows files to self-inject custom hooks, plugins, or configurations locally.
236
-
237
- ### Example
238
- ```javascript
239
- // In any standard .js file
240
- export async function autoregister_custom_auth(app) {
241
- app.addHook("onRequest", async (req, reply) => {
242
- // Local hook logic here
243
- });
244
- }
245
- ```
246
-
247
-
248
-
249
-
250
- ## About
251
- - Dependencies: original fastify packages + scalar/fastify-api-reference
252
- - The source is simple. Everyone can read, understand and modify if needed.
253
-
254
-
255
- ## Todo
256
- - document how to add fastify hooks
257
- - document how to pass scalar options
258
- - more example and best practises
259
- - document project setup with npx
260
- - npx option, atm devserver macos only
261
- - unit tests
262
-
263
-