maxserver 0.0.2
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/README.md +233 -0
- package/bin/watcher.js +48 -0
- package/package.json +27 -0
- package/src/getAddress.js +29 -0
- package/src/index.js +48 -0
- package/src/routeLoader.js +122 -0
- package/src/setup.js +149 -0
package/README.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/max-matinpalo/maxserver/refs/heads/main/assets/logo.png" alt="Project logo" width="160">
|
|
3
|
+
</p>
|
|
4
|
+
<br>
|
|
5
|
+
|
|
6
|
+
# @max-matinpalo/maxserver
|
|
7
|
+
|
|
8
|
+
-> Node server setup based on **Fastify** with a great new route loader.
|
|
9
|
+
|
|
10
|
+
- **Route loader**: auto-register routes and schemas
|
|
11
|
+
- **JWT auth** (cookie or `Authorization: Bearer ...`)
|
|
12
|
+
- **Autogenerates docs** (`/openapi.json`) + optional UI (`/docs`)
|
|
13
|
+
- **MongoDB** auto-connect + global `db` + `oid()` helper
|
|
14
|
+
- **Dev server** (auto reload on changes)
|
|
15
|
+
- **HTTPS** support (when configured)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx @max-matinpalo/maxserver my-app
|
|
24
|
+
cd my-app
|
|
25
|
+
npm run dev
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
maxserver(options) forwards options to fastify(options).
|
|
32
|
+
It returns the fully configured Fastify server instance.
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import maxserver from "@max-matinpalo/maxserver";
|
|
36
|
+
const server = await maxserver();
|
|
37
|
+
const address = await server.listen({
|
|
38
|
+
port: Number(process.env.PORT || 3000),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
console.log("Server running at", address);
|
|
42
|
+
export default server;
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## ⚙️ Configuration (Environment)
|
|
48
|
+
|
|
49
|
+
| Variable | Default | Description |
|
|
50
|
+
| :--- | :--- | :--- |
|
|
51
|
+
| `PORT` | `3000` | Server port |
|
|
52
|
+
| `JWT_SECRET` | *(optional)* | Enables JWT auth (private-by-default routes) |
|
|
53
|
+
| `MONGODB_URI` | *(optional)* | Enables MongoDB auto-connect + global `db` |
|
|
54
|
+
| `DOCS` | `true` | Set `false` to disable docs UI at `/docs` |
|
|
55
|
+
| `STATIC_DIR` | *(optional)* | Serve static files (example: `./public`) |
|
|
56
|
+
| `CORS_ORIGIN` | `*` | Allowed CORS origins |
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 🗂️ Project Structure
|
|
61
|
+
|
|
62
|
+
**Convention: 1 route = 1 handler file + 1 schema file (siblings).**
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
src/
|
|
68
|
+
users/
|
|
69
|
+
teams/
|
|
70
|
+
forms/
|
|
71
|
+
get.js
|
|
72
|
+
get.schema.js
|
|
73
|
+
...
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## 🛣️ Writing Route Handlers
|
|
79
|
+
|
|
80
|
+
### 1) Define method + path
|
|
81
|
+
Every route file starts with:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
// GET /teams/:id
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
That comment is what the route loader uses to auto-register the route.
|
|
88
|
+
|
|
89
|
+
### 2) Default export handler
|
|
90
|
+
|
|
91
|
+
Keep handlers small, and split logic into numbered steps.
|
|
92
|
+
|
|
93
|
+
If something fails, throw:
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
throw createError(code, "Specific failure reason");
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Example
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
// GET /teams/:id
|
|
103
|
+
|
|
104
|
+
export default async function (req, res) {
|
|
105
|
+
|
|
106
|
+
// 1. Read input
|
|
107
|
+
const id = req.params.id;
|
|
108
|
+
|
|
109
|
+
// 2. Load data
|
|
110
|
+
const team = { id, name: "Team A" };
|
|
111
|
+
|
|
112
|
+
// 3. Respond
|
|
113
|
+
return team;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 🧾 Defining Schemas
|
|
120
|
+
Create a sibling file ending in **`.schema.js`**.
|
|
121
|
+
This file will be auto registered.
|
|
122
|
+
|
|
123
|
+
Schemas:
|
|
124
|
+
- validate inputs
|
|
125
|
+
- generate OpenAPI docs
|
|
126
|
+
- control auth (public/private)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
**`src/teams/get.schema.js`**
|
|
130
|
+
```js
|
|
131
|
+
export default {
|
|
132
|
+
tags: ["Teams"],
|
|
133
|
+
summary: "Get team",
|
|
134
|
+
description: "Returns a single team by identifier.",
|
|
135
|
+
|
|
136
|
+
params: {
|
|
137
|
+
type: "object",
|
|
138
|
+
required: ["id"],
|
|
139
|
+
properties: {
|
|
140
|
+
id: {
|
|
141
|
+
type: "string",
|
|
142
|
+
minLength: 24,
|
|
143
|
+
example: "",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
response: {
|
|
149
|
+
200: {
|
|
150
|
+
type: "object",
|
|
151
|
+
properties: {
|
|
152
|
+
id: {
|
|
153
|
+
type: "string",
|
|
154
|
+
example: "507f1f77bcf86cd799439011",
|
|
155
|
+
},
|
|
156
|
+
name: { type: "string", example: "Team A" },
|
|
157
|
+
},
|
|
158
|
+
required: ["id", "name"],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 🔐 Authentication (JWT)
|
|
167
|
+
|
|
168
|
+
Enable auth by setting:
|
|
169
|
+
|
|
170
|
+
- `JWT_SECRET`
|
|
171
|
+
|
|
172
|
+
Behavior:
|
|
173
|
+
- **Private by default**: routes require JWT (cookie or Bearer header)
|
|
174
|
+
- Authenticated user identifier is available as **`req.userId`**
|
|
175
|
+
- Make a route **public** by setting `config.public: true` in its schema
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
export default {
|
|
179
|
+
config: { public: true },
|
|
180
|
+
// ...
|
|
181
|
+
};
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 🍃 MongoDB
|
|
187
|
+
Define in the env **`MONGODB_URI`** and it will auto-connect at server start and you get:
|
|
188
|
+
|
|
189
|
+
- global **`db`** (connected database handle)
|
|
190
|
+
- global **`oid(string)`** (string → MongoDB `ObjectId`)
|
|
191
|
+
|
|
192
|
+
| Global | What it is | Why it exists |
|
|
193
|
+
| :--- | :--- | :--- |
|
|
194
|
+
| `db` | MongoDB database handle | Use it directly in handlers |
|
|
195
|
+
| `oid(id)` | string → `ObjectId` | Avoid importing `ObjectId` everywhere |
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## 🧰 Error Handling
|
|
201
|
+
|
|
202
|
+
Use `createError(code, message)` to stop immediately with a clean HTTP error.
|
|
203
|
+
|
|
204
|
+
```js
|
|
205
|
+
if (!user) throw createError(404, "User not found");
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Rule of thumb: make the message something you would want to see at 02:00 in logs.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## 📚 API Docs
|
|
213
|
+
|
|
214
|
+
- OpenAPI JSON: **`/openapi.json`**
|
|
215
|
+
- Optional UI: **`/docs`** (controlled via `DOCS=true`)
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## 🛠️ Tips & Tools
|
|
220
|
+
|
|
221
|
+
### 🔌 Scalar API Client (Live Testing)
|
|
222
|
+
- Add Item → Import from OpenAPI
|
|
223
|
+
- Paste: `http://localhost:3000/openapi.json`
|
|
224
|
+
- Enable watch mode for live updates
|
|
225
|
+
|
|
226
|
+
### ⚡ VS Code Auto-Start
|
|
227
|
+
In `.vscode/tasks.json`, enable the task with:
|
|
228
|
+
```json
|
|
229
|
+
"runOptions": { "runOn": "folderOpen" }
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### 🤖 AI Assistants (Code Style)
|
|
233
|
+
Copy **`RULES.md`** into your AI tool as system context, then ask it to generate routes + schemas.
|
package/bin/watcher.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* maxserver-watcher
|
|
8
|
+
*
|
|
9
|
+
* A zero-config minimal development server watcher.
|
|
10
|
+
* - Watches the current project root recursively.
|
|
11
|
+
* - Restarts the node process on .js/.json changes.
|
|
12
|
+
* - Automatically loads .env if present via --env-file.
|
|
13
|
+
* - Ignores node_modules and hidden files.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
// Get entry file from args or default to server.js
|
|
18
|
+
const entry = process.argv[2] || "server.js";
|
|
19
|
+
let child;
|
|
20
|
+
|
|
21
|
+
function start() {
|
|
22
|
+
const args = [];
|
|
23
|
+
if (fs.existsSync(".env")) args.push("--env-file=.env");
|
|
24
|
+
args.push(entry);
|
|
25
|
+
|
|
26
|
+
// Spawn the node process and share the console
|
|
27
|
+
child = spawn("node", args, { stdio: "inherit" });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function restart() {
|
|
31
|
+
console.clear();
|
|
32
|
+
console.log(`devserver restart`);
|
|
33
|
+
if (child) child.kill();
|
|
34
|
+
start();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
fs.watch(".", { recursive: true }, (event, file) => {
|
|
39
|
+
if (!file) return;
|
|
40
|
+
if (file.includes("node_modules")) return;
|
|
41
|
+
if (file.startsWith(".")) return;
|
|
42
|
+
if (!file.endsWith(".js") && !file.endsWith(".json")) return;
|
|
43
|
+
|
|
44
|
+
console.log("\nupdated: ", file);
|
|
45
|
+
restart();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
restart();
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "maxserver",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Node server setup based fastify",
|
|
5
|
+
"author": "Max Matinpalo",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"maxserver": "bin/init.js",
|
|
10
|
+
"maxserver-watcher": "bin/watcher.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.mde"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@fastify/cookie": "^11.0.2",
|
|
18
|
+
"@fastify/cors": "^11.2.0",
|
|
19
|
+
"@fastify/helmet": "^13.0.2",
|
|
20
|
+
"@fastify/jwt": "^10.0.0",
|
|
21
|
+
"@fastify/mongodb": "^9.0.2",
|
|
22
|
+
"@fastify/static": "^9.0.0",
|
|
23
|
+
"@fastify/swagger": "^9.6.1",
|
|
24
|
+
"@scalar/fastify-api-reference": "^1.40.9",
|
|
25
|
+
"fastify": "^5.7.1"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export function setupGetAddress(app) {
|
|
5
|
+
app.decorate("getAddress", function () {
|
|
6
|
+
const addr = this.server.address();
|
|
7
|
+
|
|
8
|
+
if (!addr) return null;
|
|
9
|
+
if (typeof addr === "string") return addr;
|
|
10
|
+
|
|
11
|
+
const protocol = this.initialConfig.https ? "https" : "http";
|
|
12
|
+
const host = getExternalIp() || "localhost";
|
|
13
|
+
|
|
14
|
+
return `${protocol}://${host}:${addr.port}`;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
function getExternalIp() {
|
|
20
|
+
const nets = os.networkInterfaces();
|
|
21
|
+
|
|
22
|
+
for (const name of Object.keys(nets)) {
|
|
23
|
+
for (const net of nets[name]) {
|
|
24
|
+
if (net.family === "IPv4" && !net.internal) return net.address;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return null;
|
|
29
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
setupCors,
|
|
5
|
+
setupHelmet,
|
|
6
|
+
setupJwt,
|
|
7
|
+
setupMongo,
|
|
8
|
+
setupStatic,
|
|
9
|
+
setupRoutes,
|
|
10
|
+
setupDocs,
|
|
11
|
+
setupCookie,
|
|
12
|
+
getHttpsOptions,
|
|
13
|
+
} from "./setup.js";
|
|
14
|
+
|
|
15
|
+
import { setupGetAddress } from "./getAddress.js";
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
export default async function maxserver(options = {}) {
|
|
20
|
+
|
|
21
|
+
const app = Fastify({
|
|
22
|
+
trustProxy: true,
|
|
23
|
+
https: getHttpsOptions() || undefined,
|
|
24
|
+
|
|
25
|
+
// To allow writing example field to schema for doucumentation
|
|
26
|
+
ajv: { customOptions: { strictSchema: false } },
|
|
27
|
+
...options,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
global.createError = function (code, message) {
|
|
31
|
+
const err = new Error(message);
|
|
32
|
+
err.statusCode = code;
|
|
33
|
+
return err;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
setupGetAddress(app);
|
|
37
|
+
await setupCookie(app);
|
|
38
|
+
await setupHelmet(app);
|
|
39
|
+
await setupCors(app);
|
|
40
|
+
await setupJwt(app);
|
|
41
|
+
await setupMongo(app);
|
|
42
|
+
await setupStatic(app);
|
|
43
|
+
await setupDocs(app);
|
|
44
|
+
await setupRoutes(app);
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
return app;
|
|
48
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Scans src/** for files whose first line looks like:
|
|
2
|
+
// POST /teams/create
|
|
3
|
+
// Imports handler + schema and registers them with Fastify.
|
|
4
|
+
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
|
|
8
|
+
const ROUTE_OPTION_KEYS = new Set([
|
|
9
|
+
"config",
|
|
10
|
+
"preHandler",
|
|
11
|
+
"onRequest",
|
|
12
|
+
"preValidation",
|
|
13
|
+
"preSerialization",
|
|
14
|
+
"errorHandler",
|
|
15
|
+
"logLevel",
|
|
16
|
+
"bodyLimit",
|
|
17
|
+
"attachValidation",
|
|
18
|
+
"exposeHeadRoute",
|
|
19
|
+
"constraints",
|
|
20
|
+
"timeout",
|
|
21
|
+
"websocket",
|
|
22
|
+
"prefixTrailingSlash",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function walk(dir, out = []) {
|
|
26
|
+
if (!fs.existsSync(dir)) return out;
|
|
27
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
28
|
+
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (entry.name === "node_modules") continue;
|
|
31
|
+
if (entry.name.startsWith(".")) continue;
|
|
32
|
+
const full = path.join(dir, entry.name);
|
|
33
|
+
|
|
34
|
+
if (entry.isDirectory()) {
|
|
35
|
+
walk(full, out);
|
|
36
|
+
} else if (entry.name.endsWith(".js")) {
|
|
37
|
+
out.push(full);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getFirstLine(file) {
|
|
44
|
+
const text = fs.readFileSync(file, "utf8");
|
|
45
|
+
const lines = text.split("\n");
|
|
46
|
+
const firstContent = lines.find((line) => line.trim().length > 0);
|
|
47
|
+
|
|
48
|
+
return (firstContent || "").trim().replace(/^\uFEFF/, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ROUTE_REGEX = /^\/\/\s*(GET|POST|PUT|PATCH|DELETE)\s+(.+)$/;
|
|
52
|
+
|
|
53
|
+
function parseRouteComment(file) {
|
|
54
|
+
const line = getFirstLine(file);
|
|
55
|
+
const m = line.match(ROUTE_REGEX);
|
|
56
|
+
if (!m) return null;
|
|
57
|
+
|
|
58
|
+
return { method: m[1].toLowerCase(), url: m[2] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function splitSchemaExport(raw) {
|
|
62
|
+
const routeOptions = {};
|
|
63
|
+
const schema = {};
|
|
64
|
+
|
|
65
|
+
for (const [key, value] of Object.entries(raw || {})) {
|
|
66
|
+
if (ROUTE_OPTION_KEYS.has(key)) {
|
|
67
|
+
routeOptions[key] = value;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
schema[key] = value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { routeOptions, schema };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function loadRoutes(fastify) {
|
|
77
|
+
const ROOT = path.join(process.cwd(), "src");
|
|
78
|
+
const files = walk(ROOT);
|
|
79
|
+
const seen = new Map();
|
|
80
|
+
|
|
81
|
+
for (const file of files) {
|
|
82
|
+
if (file.endsWith(".schema.js")) continue;
|
|
83
|
+
|
|
84
|
+
const info = parseRouteComment(file);
|
|
85
|
+
if (!info) continue;
|
|
86
|
+
|
|
87
|
+
const key = info.method + " " + info.url;
|
|
88
|
+
if (seen.has(key)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Duplicate route "${key}" detected:\n` +
|
|
91
|
+
`1. ${seen.get(key)}\n` +
|
|
92
|
+
`2. ${file}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
seen.set(key, file);
|
|
96
|
+
|
|
97
|
+
const handlerMod = await import("file://" + file);
|
|
98
|
+
const handler = handlerMod.default;
|
|
99
|
+
|
|
100
|
+
const schemaFile = file.replace(/\.js$/, ".schema.js");
|
|
101
|
+
let raw = null;
|
|
102
|
+
|
|
103
|
+
if (fs.existsSync(schemaFile)) {
|
|
104
|
+
const schemaMod = await import("file://" + schemaFile);
|
|
105
|
+
raw = schemaMod.default || {};
|
|
106
|
+
} else {
|
|
107
|
+
fastify.log.warn(
|
|
108
|
+
`Route schema missing: ` +
|
|
109
|
+
`${info.method.toUpperCase()} ${info.url}`
|
|
110
|
+
);
|
|
111
|
+
raw = {};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const parts = splitSchemaExport(raw);
|
|
115
|
+
|
|
116
|
+
fastify[info.method](
|
|
117
|
+
info.url,
|
|
118
|
+
{ ...parts.routeOptions, schema: parts.schema },
|
|
119
|
+
handler
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import cors from "@fastify/cors";
|
|
5
|
+
import jwt from "@fastify/jwt";
|
|
6
|
+
import cookie from "@fastify/cookie";
|
|
7
|
+
import mongodb from "@fastify/mongodb";
|
|
8
|
+
import fastifyStatic from "@fastify/static";
|
|
9
|
+
import helmet from "@fastify/helmet";
|
|
10
|
+
import swagger from "@fastify/swagger";
|
|
11
|
+
import apiReference from "@scalar/fastify-api-reference";
|
|
12
|
+
|
|
13
|
+
import { loadRoutes } from "./routeLoader.js";
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
export async function setupCookie(app) {
|
|
17
|
+
// Use COOKIE_SECRET or fall back to JWT_SECRET so you don't need a new env var immediately
|
|
18
|
+
const secret = process.env.COOKIE_SECRET || process.env.JWT_SECRET || "change-me-in-prod";
|
|
19
|
+
|
|
20
|
+
await app.register(cookie, {
|
|
21
|
+
secret,
|
|
22
|
+
hook: "onRequest", // Crucial: Ensures cookies are parsed before your route handlers run
|
|
23
|
+
parseOptions: {}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
export async function setupHelmet(app) {
|
|
30
|
+
await app.register(helmet, {
|
|
31
|
+
contentSecurityPolicy: false,
|
|
32
|
+
crossOriginResourcePolicy: {
|
|
33
|
+
policy: "cross-origin"
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function setupCors(app) {
|
|
39
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
40
|
+
const origin = process.env.CORS_ORIGIN || true;
|
|
41
|
+
|
|
42
|
+
if (isProd && !process.env.CORS_ORIGIN) app.log.warn("CORS_ORIGIN not set, allowing all origins");
|
|
43
|
+
|
|
44
|
+
await app.register(cors, { origin });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
export async function setupRoutes(app) {
|
|
50
|
+
await loadRoutes(app);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getHttpsOptions() {
|
|
54
|
+
const { TLS_KEY, TLS_CERT } = process.env;
|
|
55
|
+
if (!TLS_KEY || !TLS_CERT) return null;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
return {
|
|
59
|
+
key: fs.readFileSync(TLS_KEY),
|
|
60
|
+
cert: fs.readFileSync(TLS_CERT),
|
|
61
|
+
};
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new Error(`TLS read failed: ${err.message || err}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
function isAuthSkippableUrl(url) {
|
|
72
|
+
if (!url) return false;
|
|
73
|
+
if (url.startsWith("/openapi.json")) return true;
|
|
74
|
+
if (url.startsWith("/docs")) return true;
|
|
75
|
+
if (url.startsWith("/static/")) return true;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function setupJwt(app) {
|
|
80
|
+
const secret = process.env.JWT_SECRET;
|
|
81
|
+
if (!secret) return;
|
|
82
|
+
|
|
83
|
+
// because we added own cookie setup function above
|
|
84
|
+
//await app.register(cookie);
|
|
85
|
+
|
|
86
|
+
await app.register(jwt, { secret, cookie: { cookieName: "token" } });
|
|
87
|
+
|
|
88
|
+
app.addHook("onRequest", async function (req) {
|
|
89
|
+
if (req.method === "OPTIONS") return;
|
|
90
|
+
|
|
91
|
+
const url = req.raw?.url || req.url;
|
|
92
|
+
if (isAuthSkippableUrl(url)) return;
|
|
93
|
+
if (req.routeOptions?.config?.public) return;
|
|
94
|
+
|
|
95
|
+
await req.jwtVerify();
|
|
96
|
+
|
|
97
|
+
const u = req.user;
|
|
98
|
+
req.userId = u?.sub || u?.userId || u?.userid || u?.id || null;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function setupMongo(app) {
|
|
103
|
+
|
|
104
|
+
const url = process.env.MONGODB_URI;
|
|
105
|
+
if (!url) return;
|
|
106
|
+
|
|
107
|
+
await app.register(mongodb, { url });
|
|
108
|
+
|
|
109
|
+
// ObjectId is available on app.mongo after registration
|
|
110
|
+
const { ObjectId, db } = app.mongo;
|
|
111
|
+
|
|
112
|
+
global.oid = id => new ObjectId(id ? String(id) : undefined);
|
|
113
|
+
global.db = db;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function setupStatic(app) {
|
|
117
|
+
const dir = process.env.STATIC_DIR;
|
|
118
|
+
if (!dir) return;
|
|
119
|
+
|
|
120
|
+
await app.register(fastifyStatic, {
|
|
121
|
+
root: path.resolve(dir),
|
|
122
|
+
prefix: "/static/",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function setupDocs(app) {
|
|
127
|
+
const defaultSecurity = [{ bearerAuth: [] }, { cookieAuth: [] }];
|
|
128
|
+
|
|
129
|
+
await app.register(swagger, {
|
|
130
|
+
openapi: {
|
|
131
|
+
components: {
|
|
132
|
+
securitySchemes: {
|
|
133
|
+
bearerAuth: { type: "http", scheme: "bearer" },
|
|
134
|
+
cookieAuth: { type: "apiKey", in: "cookie", name: "token" },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
app.get("/openapi.json", { config: { public: true } }, () => app.swagger());
|
|
141
|
+
|
|
142
|
+
await app.register(apiReference, { routePrefix: "/docs", openapi: true });
|
|
143
|
+
|
|
144
|
+
app.addHook("onRoute", (route) => {
|
|
145
|
+
if (route.config?.public) return;
|
|
146
|
+
route.schema ||= {};
|
|
147
|
+
route.schema.security = defaultSecurity;
|
|
148
|
+
});
|
|
149
|
+
}
|