maxserver 2.0.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxserver",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Bun server setup",
5
5
  "author": "Max Matinpalo",
6
6
  "type": "module",
@@ -27,7 +27,8 @@
27
27
  "devdocs"
28
28
  ],
29
29
  "scripts": {
30
- "test": "bun test ./test"
30
+ "test": "bun test ./test",
31
+ "bench": "bun test/bench/run.js"
31
32
  },
32
33
  "dependencies": {
33
34
  "ajv": "^8",
package/src/cors.js CHANGED
@@ -21,20 +21,31 @@ export function setupCors(cors = "*", production) {
21
21
  * CORS headers for a request, as in v1 (@fastify/cors with credentials).
22
22
  */
23
23
  export function corsHeaders(req, cors) {
24
- const origin = req.headers.get("origin");
25
- const headers = { "Access-Control-Allow-Credentials": "true" };
24
+ return { ...fixedCorsHeaders(cors), ...originHeaders(req, cors) };
25
+ }
26
26
 
27
- // 1. Allowed origin
28
- if (cors.any && !cors.reflect) headers["Access-Control-Allow-Origin"] = "*";
29
- else if (origin && (cors.any || cors.list.includes(origin))) {
30
- headers["Access-Control-Allow-Origin"] = origin;
31
- headers["Vary"] = "Origin";
32
- }
33
27
 
28
+ /**
29
+ * CORS headers that are the same for every request.
30
+ */
31
+ export function fixedCorsHeaders(cors) {
32
+ const headers = { "Access-Control-Allow-Credentials": "true" };
33
+ if (cors.any && !cors.reflect) headers["Access-Control-Allow-Origin"] = "*";
34
34
  return headers;
35
35
  }
36
36
 
37
37
 
38
+ /**
39
+ * CORS headers for the request's origin (reflected or listed), null if none.
40
+ */
41
+ export function originHeaders(req, cors) {
42
+ if (cors.any && !cors.reflect) return null;
43
+ const origin = req.headers.get("origin");
44
+ if (!origin || !(cors.any || cors.list.includes(origin))) return null;
45
+ return { "Access-Control-Allow-Origin": origin, "Vary": "Origin" };
46
+ }
47
+
48
+
38
49
  /**
39
50
  * Answers OPTIONS preflight requests.
40
51
  */
package/src/docs.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import path from "node:path";
2
2
 
3
- const SECURITY = [{ bearerAuth: [] }, { cookieAuth: [] }];
4
3
  const NOT_SCHEMA = ["$id", "auth", "order", "tags", "summary"];
5
4
 
6
5
 
@@ -54,7 +53,7 @@ function parameters(route, schema) {
54
53
  }
55
54
 
56
55
 
57
- function operation(route) {
56
+ function operation(route, security) {
58
57
  const s = route.schema;
59
58
  const op = {};
60
59
 
@@ -73,7 +72,7 @@ function operation(route) {
73
72
  ? { description: r.description || "Default Response", content: { "application/json": { schema: fixRefs(r) } } }
74
73
  : { description: "Default Response" };
75
74
 
76
- if (s.auth) op.security = SECURITY;
75
+ if (s.auth) op.security = security;
77
76
  return op;
78
77
  }
79
78
 
@@ -95,16 +94,19 @@ function sortRoutes(routes) {
95
94
 
96
95
  /**
97
96
  * OpenAPI 3.1 document from routes with a schema and all models.
97
+ * The built-in JWT accepts a Bearer header or a cookie; an app's authenticate only Bearer.
98
98
  */
99
- export function buildOpenApi(routes, models, info) {
99
+ export function buildOpenApi(routes, models, info, customAuth = false) {
100
+ const securitySchemes = customAuth
101
+ ? { bearerAuth: { type: "http", scheme: "bearer" } }
102
+ : { bearerAuth: { type: "http", scheme: "bearer" }, cookieAuth: { type: "apiKey", in: "cookie", name: "token" } };
103
+ const security = Object.keys(securitySchemes).map(name => ({ [name]: [] }));
104
+
100
105
  const doc = {
101
106
  openapi: "3.1.0",
102
107
  info: info || { title: "API", version: "1.0.0" },
103
108
  components: {
104
- securitySchemes: {
105
- bearerAuth: { type: "http", scheme: "bearer" },
106
- cookieAuth: { type: "apiKey", in: "cookie", name: "token" },
107
- },
109
+ securitySchemes,
108
110
  schemas: {},
109
111
  },
110
112
  paths: {},
@@ -115,7 +117,7 @@ export function buildOpenApi(routes, models, info) {
115
117
  for (const r of sortRoutes(routes.filter(r => r.schema && Object.keys(r.schema).length))) {
116
118
  const p = r.path.replace(/:(\w+)/g, "{$1}");
117
119
  doc.paths[p] ||= {};
118
- doc.paths[p][r.method.toLowerCase()] = operation(r);
120
+ doc.paths[p][r.method.toLowerCase()] = operation(r, security);
119
121
  }
120
122
 
121
123
  return doc;
package/src/errors.js CHANGED
@@ -45,7 +45,7 @@ export function errorResponse(err, production) {
45
45
  function appLocation(err) {
46
46
  const cwd = process.cwd() + "/";
47
47
  for (const line of String(err?.stack || "").split("\n").slice(1)) {
48
- const match = line.match(/(?:file:\/\/)?(\/[^\s()]+):(\d+):\d+/);
48
+ const match = line.match(/(?:file:\/\/)?(\/[^()]+):(\d+):\d+\)?$/);
49
49
  if (match && match[1].startsWith(cwd) && !match[1].includes("/node_modules/"))
50
50
  return `${match[1].slice(cwd.length)}:${match[2]}`;
51
51
  }
package/src/headers.js CHANGED
@@ -1,14 +1,23 @@
1
1
  /**
2
- * Security headers, same as v1 helmet config:
3
- * helmet defaults without CSP and frameguard, CORP cross-origin.
2
+ * Security headers for JSON (handler data, errors): the only helmet headers
3
+ * that matter for JSON. Each header costs Bun time on every response.
4
+ */
5
+ export const API_HEADERS = {
6
+ "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
7
+ "X-Content-Type-Options": "nosniff",
8
+ };
9
+
10
+
11
+ /**
12
+ * Security headers for everything else (static files, /docs, returned Responses),
13
+ * same as v1 helmet config: helmet defaults without CSP and frameguard, CORP cross-origin.
4
14
  */
5
15
  export const SECURITY_HEADERS = {
16
+ ...API_HEADERS,
6
17
  "Cross-Origin-Opener-Policy": "same-origin",
7
18
  "Cross-Origin-Resource-Policy": "cross-origin",
8
19
  "Origin-Agent-Cluster": "?1",
9
20
  "Referrer-Policy": "no-referrer",
10
- "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
11
- "X-Content-Type-Options": "nosniff",
12
21
  "X-DNS-Prefetch-Control": "off",
13
22
  "X-Download-Options": "noopen",
14
23
  "X-Permitted-Cross-Domain-Policies": "none",
package/src/index.js CHANGED
@@ -4,7 +4,7 @@ import { setupCors } from "./cors.js";
4
4
  import { setupStatic } from "./static.js";
5
5
  import { createValidators } from "./validate.js";
6
6
  import { buildOpenApi, docsRoutes } from "./docs.js";
7
- import { buildRoutes, fallback, finish } from "./routes.js";
7
+ import { buildRoutes, defaultHeaders, fallback, finish } from "./routes.js";
8
8
 
9
9
  export { createError } from "./errors.js";
10
10
  export { signJwt, verifyJwt } from "./jwt.js";
@@ -44,6 +44,7 @@ export default async function maxserver(config = {}) {
44
44
  static: staticDir = process.env.STATIC,
45
45
  public: isPublic = process.env.PUBLIC === "true",
46
46
  openapiInfo,
47
+ authenticate,
47
48
  routesDir,
48
49
  ...bunOptions
49
50
  } = config;
@@ -51,8 +52,8 @@ export default async function maxserver(config = {}) {
51
52
  if (routesDir !== undefined)
52
53
  throw new Error("maxserver: routesDir is read by the generator, set ROUTESDIR in .env instead");
53
54
 
54
- if (!secret)
55
- throw new Error("maxserver: secret is required, set secret in maxserver() or SECRET in .env");
55
+ if (!secret && !authenticate)
56
+ throw new Error("maxserver: secret is required, set secret in maxserver() or SECRET in .env (or pass authenticate)");
56
57
  if (!registry.routes)
57
58
  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
 
@@ -64,11 +65,14 @@ export default async function maxserver(config = {}) {
64
65
  // 3. Shared context for all requests
65
66
  const dev = env !== "production";
66
67
  configureJwt(secret);
68
+ const corsConfig = setupCors(cors, !dev);
67
69
 
68
70
  const ctx = {
69
71
  dev,
70
72
  secret,
71
- cors: setupCors(cors, !dev),
73
+ authenticate,
74
+ cors: corsConfig,
75
+ headers: defaultHeaders(corsConfig),
72
76
  static: setupStatic(staticDir),
73
77
  ajvs: createValidators(models),
74
78
  };
@@ -76,7 +80,7 @@ export default async function maxserver(config = {}) {
76
80
  // 4. Routes: user routes + docs
77
81
  const routes = buildRoutes(registry.routes, ctx);
78
82
  if (docs) {
79
- const openapi = buildOpenApi(registry.routes, models, openapiInfo);
83
+ const openapi = buildOpenApi(registry.routes, models, openapiInfo, !!authenticate);
80
84
  for (const [path, handler] of Object.entries(await docsRoutes(openapi, dev)))
81
85
  routes[path] = { GET: req => finish(req, handler(), ctx) };
82
86
  }
package/src/routes.js CHANGED
@@ -1,7 +1,7 @@
1
- import { authenticate } from "./jwt.js";
2
- import { corsHeaders, preflight } from "./cors.js";
1
+ import { authenticate as authenticateJwt } from "./jwt.js";
2
+ import { corsHeaders, fixedCorsHeaders, originHeaders, preflight } from "./cors.js";
3
3
  import { errorResponse, logError } from "./errors.js";
4
- import { SECURITY_HEADERS, setHeaders } from "./headers.js";
4
+ import { API_HEADERS, SECURITY_HEADERS, setHeaders } from "./headers.js";
5
5
  import { compileRoute, validatePart, checkResponse } from "./validate.js";
6
6
  import { serveStatic } from "./static.js";
7
7
 
@@ -9,8 +9,17 @@ import { serveStatic } from "./static.js";
9
9
  /**
10
10
  * Common end of every response: CORS + security headers.
11
11
  */
12
- export function finish(req, response, ctx) {
13
- return setHeaders(response, { ...corsHeaders(req, ctx.cors), ...SECURITY_HEADERS });
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 });
14
23
  }
15
24
 
16
25
 
@@ -19,7 +28,9 @@ export function finish(req, response, ctx) {
19
28
  */
20
29
  function parseQuery(url) {
21
30
  const query = {};
22
- for (const [k, v] of new URL(url).searchParams) {
31
+ const i = url.indexOf("?");
32
+ if (i < 0) return query;
33
+ for (const [k, v] of new URLSearchParams(url.slice(i + 1))) {
23
34
  if (!Object.hasOwn(query, k)) setProp(query, k, v);
24
35
  else if (Array.isArray(query[k])) query[k].push(v);
25
36
  else query[k] = [query[k], v];
@@ -28,6 +39,20 @@ function parseQuery(url) {
28
39
  }
29
40
 
30
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
+
31
56
  function badRequest(message) {
32
57
  return Object.assign(new Error(message), { statusCode: 400 });
33
58
  }
@@ -50,8 +75,6 @@ function safeParse(text) {
50
75
  * Parses the body: JSON and text, else left unread.
51
76
  */
52
77
  async function parseBody(req) {
53
- if (req.method === "GET" || req.method === "HEAD") return undefined;
54
-
55
78
  const type = (req.headers.get("content-type") || "").toLowerCase();
56
79
  if (type.includes("application/json")) {
57
80
  const text = await req.text();
@@ -74,13 +97,15 @@ function setProp(obj, key, value) {
74
97
 
75
98
  /**
76
99
  * Handler response object: collects status and headers.
100
+ * Headers are a copy of the defaults, made only when a handler sets one.
77
101
  */
78
- function createRes() {
102
+ function createRes(ctx) {
79
103
  const res = {
80
104
  statusCode: 200,
81
- headers: new Headers(),
105
+ headers: null,
82
106
  status(code) { res.statusCode = code; return res; },
83
107
  header(name, value) {
108
+ res.headers ||= new Headers(ctx.headers);
84
109
  if (name.toLowerCase() === "set-cookie") res.headers.append(name, value);
85
110
  else res.headers.set(name, value);
86
111
  return res;
@@ -90,15 +115,27 @@ function createRes() {
90
115
  }
91
116
 
92
117
 
93
- function toResponse(data, res) {
94
- if (data instanceof Response) return data;
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
+ }
95
131
 
132
+ // 2. Body
96
133
  if (data === undefined) {
97
134
  const status = res.statusCode === 200 ? 204 : res.statusCode;
98
- return new Response(null, { status, headers: res.headers });
135
+ return new Response(null, { status, headers });
99
136
  }
100
137
 
101
- return Response.json(data, { status: res.statusCode, headers: res.headers });
138
+ return Response.json(data, { status: res.statusCode, headers });
102
139
  }
103
140
 
104
141
 
@@ -111,44 +148,46 @@ function wrap(route, ctx) {
111
148
  const name = `${route.method} ${route.path}`;
112
149
 
113
150
  return async function (req) {
114
- let response;
115
151
  try {
116
152
 
117
- // 1. Auth
118
- if (schema.auth) authenticate(req, ctx.secret);
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);
119
159
 
120
- // 2. Parse
121
- const params = { ...req.params };
122
- const query = parseQuery(req.url);
123
- const body = await parseBody(req);
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);
124
162
 
125
163
  // 3. Validate (may coerce, add defaults, remove extras)
126
- if (v.request.params) validatePart(v.request.params, "params", params);
127
- if (v.request.querystring) validatePart(v.request.querystring, "querystring", query);
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);
128
170
  if (v.request.headers) validatePart(v.request.headers, "headers", Object.fromEntries(req.headers));
129
171
  if (v.request.body) validatePart(v.request.body, "body", body);
130
172
 
131
- setProp(req, "params", params);
132
- setProp(req, "query", query);
133
173
  setProp(req, "body", body);
134
174
 
135
175
  // 4. Handler
136
- const res = createRes();
176
+ const res = createRes(ctx);
137
177
  const data = await route.handler(req, res);
138
- response = toResponse(data, res);
178
+ const response = toResponse(data, res, req, ctx);
139
179
 
140
180
  // 5. Development: check response against schema
141
181
  if (ctx.dev && !(data instanceof Response)) {
142
182
  const problem = checkResponse(v.response, response.status, data);
143
183
  if (problem) console.warn(`⚠️ Response mismatch ${name} ${response.status} (${route.file}): ${problem}`);
144
184
  }
185
+ return response;
145
186
 
146
187
  } catch (err) {
147
188
  logError(err, req, ctx.dev);
148
- response = errorResponse(err, !ctx.dev);
189
+ return finish(req, errorResponse(err, !ctx.dev), ctx, API_HEADERS);
149
190
  }
150
-
151
- return finish(req, response, ctx);
152
191
  };
153
192
  }
154
193
 
@@ -182,6 +221,6 @@ export function fallback(ctx) {
182
221
 
183
222
  const { pathname } = new URL(req.url);
184
223
  const err = Object.assign(new Error(`Route ${req.method}:${pathname} not found`), { statusCode: 404 });
185
- return finish(req, errorResponse(err, !ctx.dev), ctx);
224
+ return finish(req, errorResponse(err, !ctx.dev), ctx, API_HEADERS);
186
225
  };
187
226
  }