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 +3 -2
- package/src/cors.js +19 -8
- package/src/docs.js +11 -9
- package/src/errors.js +1 -1
- package/src/headers.js +13 -4
- package/src/index.js +9 -5
- package/src/routes.js +70 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maxserver",
|
|
3
|
-
"version": "2.
|
|
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
|
-
|
|
25
|
-
|
|
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 =
|
|
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:\/\/)?(\/[
|
|
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,
|
|
3
|
-
*
|
|
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
|
-
|
|
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), ...
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
135
|
+
return new Response(null, { status, headers });
|
|
99
136
|
}
|
|
100
137
|
|
|
101
|
-
return Response.json(data, { status: res.statusCode, 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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
}
|