@open-xchange/fastify-sdk 0.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/README.md +370 -0
- package/lib/app.js +324 -0
- package/lib/config/index.js +4 -0
- package/lib/config/read.js +38 -0
- package/lib/config/registry.js +57 -0
- package/lib/config/util.js +28 -0
- package/lib/database/migrations.js +37 -0
- package/lib/database/mysql.js +81 -0
- package/lib/database/postgres.js +34 -0
- package/lib/dotenv.js +6 -0
- package/lib/health.js +36 -0
- package/lib/index.js +28 -0
- package/lib/lint/index.js +5 -0
- package/lib/logger.js +60 -0
- package/lib/metrics-server.js +33 -0
- package/lib/plugins/cors.js +16 -0
- package/lib/plugins/helmet.js +14 -0
- package/lib/plugins/jwt.js +88 -0
- package/lib/plugins/logging.js +15 -0
- package/lib/plugins/metrics.js +8 -0
- package/lib/plugins/swagger.js +31 -0
- package/lib/redis/index.js +58 -0
- package/lib/testing/index.js +32 -0
- package/lib/testing/jwt.js +17 -0
- package/package.json +85 -0
package/README.md
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# @open-xchange/fastify-sdk
|
|
2
|
+
|
|
3
|
+
[](https://gitlab.com/openxchange/appsuite/web-foundation/fastify-sdk/-/graphs/main/charts)
|
|
4
|
+
|
|
5
|
+
Shared foundation package for OX App Suite Node.js services. Extracts common infrastructure — Fastify setup, logging, health checks, plugins, database pools, config loading — so consuming projects consist almost entirely of business logic.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @open-xchange/fastify-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { createApp } from '@open-xchange/fastify-sdk'
|
|
17
|
+
|
|
18
|
+
const app = await createApp({
|
|
19
|
+
dirname: import.meta.dirname,
|
|
20
|
+
plugins: {
|
|
21
|
+
jwt: true,
|
|
22
|
+
swagger: { enabled: process.env.EXPOSE_API_DOCS === 'true' }
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
await app.listen({ host: '0.0.0.0', port: 8080 })
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That single call replaces ~500 lines of boilerplate (logger, CORS, Helmet, metrics, JWT, Swagger, autoload).
|
|
30
|
+
|
|
31
|
+
## Exports
|
|
32
|
+
|
|
33
|
+
| Import path | What it provides |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `@open-xchange/fastify-sdk` | `createApp`, `createMetricsServer`, `createLogger`, `getLogger`, `loadEnv`, `jwtAuthHook`, health check helpers, re-exports of `fastify`, `fp`, `pino`, `promClient`, `createError`, `jose` |
|
|
36
|
+
| `@open-xchange/fastify-sdk/mysql` | `createMySQLPool`, `createMySQLPoolFromEnv`, `getMySQLPools`, `mysqlReadyCheck`, `createUUID` |
|
|
37
|
+
| `@open-xchange/fastify-sdk/postgres` | `createPostgresPool`, `createPostgresPoolFromEnv` |
|
|
38
|
+
| `@open-xchange/fastify-sdk/migrations` | `createMigrationRunner`, `executeMigrations` |
|
|
39
|
+
| `@open-xchange/fastify-sdk/config` | `createConfigRegistry`, `readConfigurationFile`, Joi helpers (`defaultTrue`, `defaultFalse`, `customString`, `customURL`), `Joi` |
|
|
40
|
+
| `@open-xchange/fastify-sdk/redis` | `createRedisClient`, `createRedisClientFromEnv`, `redisReadyCheck` |
|
|
41
|
+
| `@open-xchange/fastify-sdk/testing` | `createTestApp`, `generateTokenForJwks`, `getJwks` |
|
|
42
|
+
| `@open-xchange/fastify-sdk/lint` | ESLint flat config with `@open-xchange/lint` |
|
|
43
|
+
|
|
44
|
+
## API reference
|
|
45
|
+
|
|
46
|
+
### `createApp(options)`
|
|
47
|
+
|
|
48
|
+
Creates a configured Fastify instance with standard OX defaults.
|
|
49
|
+
|
|
50
|
+
**Options:**
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
{
|
|
54
|
+
dirname: import.meta.dirname, // For resolving plugins/routes dirs
|
|
55
|
+
pluginsDir: 'plugins', // Relative to dirname (auto-loaded)
|
|
56
|
+
routesDir: 'routes', // Relative to dirname (auto-loaded)
|
|
57
|
+
routes: { prefix, ...autoloadOpts }, // Extra @fastify/autoload options for routes
|
|
58
|
+
fastify: {}, // Merged into Fastify constructor options
|
|
59
|
+
plugins: {
|
|
60
|
+
cors: true | { origin, methods }, // Default: true
|
|
61
|
+
helmet: true | { options }, // Default: true
|
|
62
|
+
logging: true, // Default: true (request/response hooks)
|
|
63
|
+
metrics: true, // Default: true (fastify-metrics collectors, no endpoint)
|
|
64
|
+
jwt: false | true | { key }, // Default: false (see JWT section below)
|
|
65
|
+
swagger: false | { enabled, openapi }, // Default: false
|
|
66
|
+
static: false | true | { root, preCompressed, ... }, // Default: false
|
|
67
|
+
},
|
|
68
|
+
metricsServer: true, // Default: true (separate Fastify on port 9000)
|
|
69
|
+
database: { mysql: true }, // Auto-manages pool readiness, health checks, shutdown
|
|
70
|
+
config: { // YAML config file watching
|
|
71
|
+
filename: 'config.yaml',
|
|
72
|
+
schema, // Joi schema for validation
|
|
73
|
+
optional: true,
|
|
74
|
+
callback: (data) => { ... }
|
|
75
|
+
},
|
|
76
|
+
onReady: async () => {}, // Called in Fastify onReady hook
|
|
77
|
+
onClose: async () => {}, // Called in Fastify onClose hook
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Defaults applied:**
|
|
82
|
+
- `requestIdLogLabel: 'requestId'`
|
|
83
|
+
- `disableRequestLogging: true`
|
|
84
|
+
- `connectionTimeout: 30000`
|
|
85
|
+
- `genReqId: () => randomUUID()`
|
|
86
|
+
|
|
87
|
+
### `createLogger(options)`
|
|
88
|
+
|
|
89
|
+
Returns a Pino logger with the standard OX configuration:
|
|
90
|
+
- Custom level mapping (trace→8, debug→7, info→6, warn→4, error→3, fatal→0)
|
|
91
|
+
- Redaction of `headers.authorization`, `headers.cookie`, `headers.host`, `key`, `password`, `salt`, `hash`
|
|
92
|
+
- Epoch millisecond timestamps
|
|
93
|
+
- No base (omits pid/hostname)
|
|
94
|
+
|
|
95
|
+
Pass custom options to override defaults (e.g. `createLogger({ level: 'debug' })`).
|
|
96
|
+
|
|
97
|
+
### `loadEnv()`
|
|
98
|
+
|
|
99
|
+
Loads environment variables from `.env.defaults` then `.env` using Node's built-in `process.loadEnvFile()`. No external dependency needed.
|
|
100
|
+
|
|
101
|
+
### Health check helpers
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
import { registerReadinessCheck, registerHealthCheck, mysqlHealthCheck } from '@open-xchange/fastify-sdk'
|
|
105
|
+
|
|
106
|
+
registerReadinessCheck(async () => { await mysqlHealthCheck(pool) })
|
|
107
|
+
registerHealthCheck(async () => { await mysqlHealthCheck(pool) })
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Registered checks are run by the metrics server (`GET /ready` and `GET /live` on port 9000). See [Metrics server](#metrics-server-default-enabled) below.
|
|
111
|
+
|
|
112
|
+
### MySQL
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
import { createMySQLPool, createMySQLPoolFromEnv, mysqlReadyCheck, createUUID } from '@open-xchange/fastify-sdk/mysql'
|
|
116
|
+
|
|
117
|
+
// From explicit options
|
|
118
|
+
const pool = createMySQLPool({ host: 'localhost', database: 'mydb', user: 'root', password: '' })
|
|
119
|
+
|
|
120
|
+
// From env vars (SQL_HOST, SQL_PORT, SQL_DB, SQL_USER, SQL_PASS, SQL_CONNECTIONS)
|
|
121
|
+
const pool = createMySQLPoolFromEnv()
|
|
122
|
+
|
|
123
|
+
// Multi-database from env (DB_<NAME>_HOST, DB_<NAME>_PORT, etc.)
|
|
124
|
+
const pools = createMySQLPoolFromEnv({ names: 'users,analytics' })
|
|
125
|
+
|
|
126
|
+
// Retry-based readiness check
|
|
127
|
+
await mysqlReadyCheck(pool, { retries: 12, delay: 10_000, logger })
|
|
128
|
+
|
|
129
|
+
// MySQL UUID generation
|
|
130
|
+
const uuid = await createUUID(pool)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### PostgreSQL
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
import { createPostgresPool, createPostgresPoolFromEnv } from '@open-xchange/fastify-sdk/postgres'
|
|
137
|
+
|
|
138
|
+
// From env vars (DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD)
|
|
139
|
+
// Supports SSL via DATABASE_SSL, DATABASE_SSL_CA_PATH, etc.
|
|
140
|
+
const pool = createPostgresPoolFromEnv()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Migrations (Umzug + MySQL)
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
import { createMigrationRunner, executeMigrations } from '@open-xchange/fastify-sdk/migrations'
|
|
147
|
+
|
|
148
|
+
const runner = createMigrationRunner({
|
|
149
|
+
pool,
|
|
150
|
+
migrationsGlob: 'src/migrations/*.mjs',
|
|
151
|
+
tableName: 'migrations',
|
|
152
|
+
logger
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
await executeMigrations(runner)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Config (YAML + Joi + hot-reload)
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
import { createConfigRegistry, Joi, defaultTrue } from '@open-xchange/fastify-sdk/config'
|
|
162
|
+
|
|
163
|
+
const { registerConfigurationFile, getCurrent } = createConfigRegistry({ logger })
|
|
164
|
+
|
|
165
|
+
const schema = Joi.object({
|
|
166
|
+
features: Joi.object({ chat: defaultTrue }).default()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
await registerConfigurationFile('config.yaml', { schema, watch: true }, (data) => {
|
|
170
|
+
Object.assign(config, data)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const current = getCurrent('config.yaml')
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Redis
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
import { createRedisClient, redisReadyCheck } from '@open-xchange/fastify-sdk/redis'
|
|
180
|
+
|
|
181
|
+
// Reads REDIS_HOSTS, REDIS_MODE (standalone|sentinel|cluster), REDIS_PASSWORD, etc.
|
|
182
|
+
const client = createRedisClient()
|
|
183
|
+
await redisReadyCheck(client)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Testing
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
import { createTestApp, generateTokenForJwks, getJwks } from '@open-xchange/fastify-sdk/testing'
|
|
190
|
+
|
|
191
|
+
// Creates Fastify with CORS/Helmet/Metrics/Logging disabled, metrics server off
|
|
192
|
+
const app = await createTestApp({
|
|
193
|
+
dirname: import.meta.dirname,
|
|
194
|
+
routesDir: '../src/routes',
|
|
195
|
+
plugins: { jwt: true }
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const token = await generateTokenForJwks({ userId: '1' }, 'kid', 'issuer.com')
|
|
199
|
+
const jwks = await getJwks('kid')
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Lint
|
|
203
|
+
|
|
204
|
+
```js
|
|
205
|
+
// eslint.config.js
|
|
206
|
+
import config from '@open-xchange/fastify-sdk/lint'
|
|
207
|
+
|
|
208
|
+
export default [
|
|
209
|
+
...config
|
|
210
|
+
]
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Logging
|
|
214
|
+
|
|
215
|
+
Foundation configures Pino with syslog-level mapping, redaction, and epoch timestamps. When running in a TTY (e.g. local development), logs are automatically pretty-printed with colors — no `pino-pretty` pipe needed.
|
|
216
|
+
|
|
217
|
+
Override TTY detection with `LOG_PRETTY`:
|
|
218
|
+
- `LOG_PRETTY=true` — force pretty printing (useful in CI or non-TTY environments)
|
|
219
|
+
- `LOG_PRETTY=false` — force JSON output
|
|
220
|
+
|
|
221
|
+
### Re-exports
|
|
222
|
+
|
|
223
|
+
These are re-exported so consuming projects don't need to install them separately:
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
import { fastify, fp, pino, promClient, createError, jose } from '@open-xchange/fastify-sdk'
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Plugins
|
|
230
|
+
|
|
231
|
+
### CORS (default: enabled)
|
|
232
|
+
|
|
233
|
+
Reads `ORIGINS` env var (comma-separated). Defaults: methods `GET, POST`, maxAge `86400`.
|
|
234
|
+
|
|
235
|
+
### Helmet (default: enabled)
|
|
236
|
+
|
|
237
|
+
Standard security headers. `contentSecurityPolicy: false`, `crossOriginEmbedderPolicy: false`, `crossOriginOpenerPolicy: same-origin-allow-popups`.
|
|
238
|
+
|
|
239
|
+
### Logging (default: enabled)
|
|
240
|
+
|
|
241
|
+
- `preHandler`: trace-logs request body
|
|
242
|
+
- `onResponse`: debug-logs URL, status, responseTime (includes headers at trace level)
|
|
243
|
+
|
|
244
|
+
### Metrics server (default: enabled)
|
|
245
|
+
|
|
246
|
+
A separate Fastify instance on port 9000, serving health probes and Prometheus metrics.
|
|
247
|
+
|
|
248
|
+
| Endpoint | Purpose | Response |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| `GET /live` | K8s liveness probe | `200 {"status":"ok"}` or `503 {"status":"error"}` |
|
|
251
|
+
| `GET /ready` | K8s readiness probe | `200 {"status":"ok"}` or `503 {"status":"error"}` |
|
|
252
|
+
| `GET /metrics` | Prometheus scraping | Prometheus text format |
|
|
253
|
+
|
|
254
|
+
`/live` runs checks registered via `registerHealthCheck()`. `/ready` runs checks registered via `registerReadinessCheck()`. With no checks registered, both return 200.
|
|
255
|
+
|
|
256
|
+
The metrics server starts automatically in `createApp()`'s `onReady` hook and closes in `onClose`. Disable with `metricsServer: false` (used by `createTestApp()` to avoid port binding in tests).
|
|
257
|
+
|
|
258
|
+
Port 9000 is hardcoded to match all existing K8s probe and Prometheus configs.
|
|
259
|
+
|
|
260
|
+
### Metrics plugin (default: enabled)
|
|
261
|
+
|
|
262
|
+
Registers `fastify-metrics` collectors on the main app (request duration, etc.) but does **not** serve an endpoint — metrics are read from `prom-client`'s registry by the metrics server on port 9000.
|
|
263
|
+
|
|
264
|
+
### Sensible (always enabled)
|
|
265
|
+
|
|
266
|
+
Registers `@fastify/sensible`, providing `reply.notFound()`, `reply.badRequest()`, `app.httpErrors`, `request.to()`, and other convenience utilities on every app.
|
|
267
|
+
|
|
268
|
+
### JWT (default: disabled)
|
|
269
|
+
|
|
270
|
+
JWKS-based JWT verification using `jose.createRemoteJWKSet`. Verifies tokens against remote JWKS endpoints with OIDC discovery support. Also supports a custom key resolver for project-specific verification (e.g. local X.509 certificates).
|
|
271
|
+
|
|
272
|
+
**Env vars:**
|
|
273
|
+
- `OIDC_ISSUER` — comma-separated allowed issuers (e.g. `auth.example.com, *.example.org`). Supports wildcard subdomains.
|
|
274
|
+
|
|
275
|
+
**Modes:**
|
|
276
|
+
|
|
277
|
+
| `OIDC_ISSUER` | `key` option | Behavior |
|
|
278
|
+
|---|---|---|
|
|
279
|
+
| Set | — | OIDC/JWKS verification via `createRemoteJWKSet` |
|
|
280
|
+
| Not set | Provided | Custom key resolver (e.g. local certificates) |
|
|
281
|
+
| Not set | Not provided | `app.verifyJWT` always returns 401 |
|
|
282
|
+
|
|
283
|
+
**OIDC mode** (`plugins: { jwt: true }` with `OIDC_ISSUER` set):
|
|
284
|
+
1. Extracts the `iss` claim from the JWT and checks it against the allowlist
|
|
285
|
+
2. On first request per issuer, tries OIDC discovery (`.well-known/openid-configuration`) to find `jwks_uri`
|
|
286
|
+
3. Falls back to `.well-known/jwks.json` if discovery is unavailable
|
|
287
|
+
4. Uses `jose.createRemoteJWKSet` for key resolution — jose handles caching and key rotation internally
|
|
288
|
+
|
|
289
|
+
**Custom key mode** (`plugins: { jwt: { key: fn } }` without `OIDC_ISSUER`):
|
|
290
|
+
|
|
291
|
+
For services that verify tokens against project-specific keys (e.g. local X.509 certificates):
|
|
292
|
+
|
|
293
|
+
```js
|
|
294
|
+
import { getLocalPublicKey } from './modules/jwks.js'
|
|
295
|
+
|
|
296
|
+
const app = await createApp({
|
|
297
|
+
plugins: {
|
|
298
|
+
jwt: { key: (request, token) => getLocalPublicKey(token) }
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The `key` function receives `(request, token)` where `token` has `header` (with `kid`, `alg`) and `payload` (with `iss`, `sub`, etc.). It should return the verification key (SPKI public key string or `{}` to reject).
|
|
304
|
+
|
|
305
|
+
**Decorators:**
|
|
306
|
+
- `app.verifyJWT` — async function for use as `onRequest` hook. Returns 401 if token is invalid or JWT is not configured.
|
|
307
|
+
- `request.jwtVerify()` — from `@fastify/jwt`, available when `OIDC_ISSUER` is set or `key` is provided.
|
|
308
|
+
|
|
309
|
+
**`jwtAuthHook`** — ready-made autohook that requires a valid JWT on every request in the encapsulated scope. Use as the default export of an `autohooks.js` file to protect all routes in that directory:
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
// src/routes/api/autohooks.js
|
|
313
|
+
export { jwtAuthHook as default } from '@open-xchange/fastify-sdk'
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
This is equivalent to:
|
|
317
|
+
|
|
318
|
+
```js
|
|
319
|
+
export default async function (app) {
|
|
320
|
+
app.addHook('onRequest', app.verifyJWT)
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Static files (default: disabled)
|
|
325
|
+
|
|
326
|
+
Serves static files using `@fastify/static`. Defaults to `public/` directory with pre-compressed file support.
|
|
327
|
+
|
|
328
|
+
```js
|
|
329
|
+
plugins: {
|
|
330
|
+
static: true, // Serve from public/ with preCompressed: true
|
|
331
|
+
static: { root: 'dist', prefix: '/assets' } // Customize
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Swagger (default: disabled)
|
|
336
|
+
|
|
337
|
+
Conditional on `enabled` option. Serves Swagger UI at `/api-docs`.
|
|
338
|
+
|
|
339
|
+
## Running standalone
|
|
340
|
+
|
|
341
|
+
Foundation runs Fastify standalone. Health endpoints, metrics, and logging are all built in:
|
|
342
|
+
|
|
343
|
+
```js
|
|
344
|
+
import { createApp } from '@open-xchange/fastify-sdk'
|
|
345
|
+
|
|
346
|
+
const app = await createApp({ dirname: import.meta.dirname })
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
await app.listen({ host: process.env.BIND_ADDR, port: Number(process.env.PORT) })
|
|
350
|
+
} catch (err) {
|
|
351
|
+
app.log.error(err)
|
|
352
|
+
process.exit(1)
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
This starts:
|
|
357
|
+
- **Port 8080** (app) — your routes (auto-loaded from `routes/`), with CORS, Helmet, logging, JWT
|
|
358
|
+
- **Port 9000** (metrics server) — `GET /live`, `GET /ready`, `GET /metrics`
|
|
359
|
+
|
|
360
|
+
## Development
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
pnpm install
|
|
364
|
+
pnpm test
|
|
365
|
+
pnpm lint
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
## License
|
|
369
|
+
|
|
370
|
+
AGPL-3.0-or-later
|
package/lib/app.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import fastify from 'fastify'
|
|
5
|
+
import autoLoad from '@fastify/autoload'
|
|
6
|
+
import ajvErrors from 'ajv-errors'
|
|
7
|
+
import { loadEnv } from './dotenv.js'
|
|
8
|
+
import { getLogger } from './logger.js'
|
|
9
|
+
|
|
10
|
+
import sensible from '@fastify/sensible'
|
|
11
|
+
import corsPlugin from './plugins/cors.js'
|
|
12
|
+
import helmetPlugin from './plugins/helmet.js'
|
|
13
|
+
import loggingPlugin from './plugins/logging.js'
|
|
14
|
+
import metricsPlugin from './plugins/metrics.js'
|
|
15
|
+
|
|
16
|
+
/** @typedef {import('fastify').FastifyInstance} FastifyInstance */
|
|
17
|
+
/** @typedef {import('fastify').FastifyServerOptions} FastifyServerOptions */
|
|
18
|
+
/** @typedef {import('@fastify/cors').FastifyCorsOptions} FastifyCorsOptions */
|
|
19
|
+
/** @typedef {import('@fastify/static').FastifyStaticOptions} FastifyStaticOptions */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} PluginsConfig
|
|
23
|
+
* @property {boolean | FastifyCorsOptions} [cors=true] CORS plugin. Pass `true` for defaults (reads ORIGINS env var), an object to customize, or `false` to disable.
|
|
24
|
+
* @property {boolean | import('@fastify/helmet').FastifyHelmetOptions} [helmet=true] Helmet security headers. Pass `true` for defaults, an object to customize, or `false` to disable.
|
|
25
|
+
* @property {boolean} [logging=true] Request/response logging plugin.
|
|
26
|
+
* @property {boolean} [metrics=true] Prometheus metrics plugin (fastify-metrics).
|
|
27
|
+
* @property {boolean | JwtConfig} [jwt=true] JWT verification plugin. Pass `true` for OIDC/JWKS via `OIDC_ISSUER` env var, or an object with `{ key }` for a custom key resolver.
|
|
28
|
+
* @property {boolean | SwaggerConfig} [swagger=true] Swagger/OpenAPI documentation plugin. Enabled when `EXPOSE_API_DOCS=true`.
|
|
29
|
+
* @property {boolean | StaticConfig} [static=false] Static file serving plugin (@fastify/static). Pass `true` to serve from `public/` with `preCompressed: true`, or an object to customize.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {object} SwaggerConfig
|
|
34
|
+
* @property {boolean} [enabled] Whether to enable Swagger UI. Defaults to `EXPOSE_API_DOCS === 'true'` env var.
|
|
35
|
+
* @property {object} [openapi] OpenAPI specification object passed to @fastify/swagger.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} StaticConfig
|
|
40
|
+
* @property {string} [root='public'] Directory to serve files from. Relative paths are resolved against `dirname`.
|
|
41
|
+
* @property {boolean} [preCompressed=true] Whether to serve pre-compressed `.gz`/`.br` files when available.
|
|
42
|
+
* @property {string} [prefix] URL prefix for static files (e.g. `'/assets'`).
|
|
43
|
+
* @property {string} [logLevel] Fastify log level for static file requests. Defaults to `'warn'` when `LOG_LEVEL` is `debug` or `info` (to reduce noise), otherwise uses `LOG_LEVEL`.
|
|
44
|
+
* @property {(res: import('http').ServerResponse, path: string, stat: import('fs').Stats) => void} [setHeaders] Function to set custom headers on static file responses.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} JwtConfig
|
|
49
|
+
* @property {(request: import('fastify').FastifyRequest, token: object) => Promise<string|object>} [key] Custom key resolver function. Called with `(request, token)` where `token` has `header` and `payload`. Return the verification key (e.g. SPKI public key string). Used as fallback when `OIDC_ISSUER` is not set.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {object} ConfigFileRegistration
|
|
54
|
+
* @property {string} filename Config file name relative to CONFIG_PATH (e.g. `'config.yaml'`).
|
|
55
|
+
* @property {import('joi').Schema} [schema] Joi schema to validate the parsed YAML against.
|
|
56
|
+
* @property {boolean} [optional=false] If `true`, skip silently when the file doesn't exist.
|
|
57
|
+
* @property {boolean} [watch=true] Watch the file for changes and re-read/validate on change.
|
|
58
|
+
* @property {(data: object) => void} [callback] Called with the validated config data on initial read and on every change.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {object} CreateAppOptions
|
|
63
|
+
* @property {string} [dirname] Absolute path to the application root. Defaults to `dirname(process.argv[1])`.
|
|
64
|
+
* @property {string | null} [pluginsDir='plugins'] Directory for auto-loaded Fastify plugins (relative to `dirname`). Set to `null` to disable.
|
|
65
|
+
* @property {string | null} [routesDir='routes'] Directory for auto-loaded route files (relative to `dirname`). Set to `null` to disable.
|
|
66
|
+
* @property {object} [routes] Options passed to @fastify/autoload for routes.
|
|
67
|
+
* @property {string} [routes.prefix] URL prefix for all routes (e.g. `'/api'`).
|
|
68
|
+
* @property {boolean} [routes.routeParams] Enable route parameters from directory names.
|
|
69
|
+
* @property {FastifyServerOptions} [fastify] Options passed directly to the Fastify constructor.
|
|
70
|
+
* @property {PluginsConfig} [plugins] Plugin configuration.
|
|
71
|
+
* @property {boolean} [metricsServer=true] Start a metrics/health server on port 9000 (`/live`, `/ready`, `/metrics`).
|
|
72
|
+
* @property {{ mysql?: true | Record<string, import('mysql2/promise').Pool> }} [database] Database pools to manage. Pass `{ mysql: true }` to create pools from the `DATABASES` env var, or pass pre-created pools. Foundation waits for readiness, registers health checks, and closes pools on shutdown.
|
|
73
|
+
* @property {ConfigFileRegistration | ConfigFileRegistration[]} [config] YAML configuration files to watch. Foundation creates the config registry, registers files on ready, and closes the watcher on shutdown.
|
|
74
|
+
* @property {() => void | Promise<void>} [onReady] Hook called when the Fastify instance is ready.
|
|
75
|
+
* @property {() => void | Promise<void>} [onClose] Hook called when the Fastify instance is closing (before shutdown).
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {FastifyInstance & { start: () => Promise<void> }} FoundationApp
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Creates a pre-configured Fastify application with standard OX infrastructure:
|
|
84
|
+
* env loading, logging, CORS, helmet, metrics, Swagger, static files, auto-loaded
|
|
85
|
+
* plugins/routes, a metrics server on port 9000, and graceful shutdown handlers.
|
|
86
|
+
*
|
|
87
|
+
* @param {CreateAppOptions} options
|
|
88
|
+
* @returns {Promise<FoundationApp>} A Fastify instance with an added `start()` method.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* const app = await createApp({
|
|
92
|
+
* plugins: { cors: { methods: ['GET', 'POST'] }, swagger: { openapi: { info: { title: 'My API', version: '1.0.0' } } } },
|
|
93
|
+
* routes: { prefix: '/api' }
|
|
94
|
+
* })
|
|
95
|
+
* await app.start()
|
|
96
|
+
*/
|
|
97
|
+
export async function createApp (options = {}) {
|
|
98
|
+
const {
|
|
99
|
+
dirname: dir = dirname(process.argv[1]),
|
|
100
|
+
pluginsDir = 'plugins',
|
|
101
|
+
routesDir = 'routes',
|
|
102
|
+
routes: routeOptions = {},
|
|
103
|
+
fastify: fastifyOptions = {},
|
|
104
|
+
plugins = {},
|
|
105
|
+
metricsServer = true,
|
|
106
|
+
database: databaseOptions,
|
|
107
|
+
config: configOptions,
|
|
108
|
+
onReady,
|
|
109
|
+
onClose
|
|
110
|
+
} = options
|
|
111
|
+
|
|
112
|
+
// Load env vars
|
|
113
|
+
loadEnv()
|
|
114
|
+
|
|
115
|
+
const app = fastify({
|
|
116
|
+
requestIdLogLabel: 'requestId',
|
|
117
|
+
disableRequestLogging: true,
|
|
118
|
+
loggerInstance: getLogger(),
|
|
119
|
+
connectionTimeout: 30000,
|
|
120
|
+
genReqId: () => randomUUID(),
|
|
121
|
+
ajv: {
|
|
122
|
+
customOptions: { allErrors: true },
|
|
123
|
+
plugins: [ajvErrors]
|
|
124
|
+
},
|
|
125
|
+
...fastifyOptions
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Register universal plugins based on config (defaults: cors, helmet, logging, metrics ON)
|
|
129
|
+
const {
|
|
130
|
+
cors = true,
|
|
131
|
+
helmet = true,
|
|
132
|
+
logging = true,
|
|
133
|
+
metrics = true,
|
|
134
|
+
jwt: jwtConfig = true,
|
|
135
|
+
swagger: swaggerConfig = true,
|
|
136
|
+
static: staticConfig = false
|
|
137
|
+
} = plugins
|
|
138
|
+
|
|
139
|
+
await app.register(sensible)
|
|
140
|
+
|
|
141
|
+
if (cors) {
|
|
142
|
+
await app.register(corsPlugin, cors === true ? {} : cors)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (helmet) {
|
|
146
|
+
await app.register(helmetPlugin, helmet === true ? {} : helmet)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (logging) {
|
|
150
|
+
await app.register(loggingPlugin)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (metrics) {
|
|
154
|
+
await app.register(metricsPlugin)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Opt-in plugins
|
|
158
|
+
if (jwtConfig) {
|
|
159
|
+
const { default: jwtPlugin } = await import('./plugins/jwt.js')
|
|
160
|
+
await app.register(jwtPlugin, jwtConfig === true ? {} : jwtConfig)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (swaggerConfig) {
|
|
164
|
+
const enabled = swaggerConfig.enabled !== undefined ? swaggerConfig.enabled : process.env.EXPOSE_API_DOCS === 'true'
|
|
165
|
+
if (enabled) {
|
|
166
|
+
const { default: swaggerPlugin } = await import('./plugins/swagger.js')
|
|
167
|
+
await app.register(swaggerPlugin, swaggerConfig === true ? {} : swaggerConfig)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (staticConfig) {
|
|
172
|
+
const { default: fastifyStatic } = await import('@fastify/static')
|
|
173
|
+
const logLevelEnv = process.env.LOG_LEVEL
|
|
174
|
+
const defaultLogLevel = logLevelEnv === 'debug' || logLevelEnv === 'info' ? 'warn' : logLevelEnv
|
|
175
|
+
const { root = 'public', preCompressed = true, logLevel = defaultLogLevel, ...restStaticConfig } = staticConfig === true ? {} : staticConfig
|
|
176
|
+
const isAbsolute = root.startsWith('/')
|
|
177
|
+
await app.register(fastifyStatic, {
|
|
178
|
+
root: isAbsolute ? root : join(dir, root),
|
|
179
|
+
preCompressed,
|
|
180
|
+
logLevel,
|
|
181
|
+
...restStaticConfig
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Auto-load project-specific plugins
|
|
186
|
+
if (dir && pluginsDir) {
|
|
187
|
+
const pluginsDirPath = join(dir, pluginsDir)
|
|
188
|
+
if (existsSync(pluginsDirPath)) {
|
|
189
|
+
app.register(autoLoad, { dir: pluginsDirPath })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Auto-load routes
|
|
194
|
+
if (dir && routesDir) {
|
|
195
|
+
const routesDirPath = join(dir, routesDir)
|
|
196
|
+
if (existsSync(routesDirPath)) {
|
|
197
|
+
const { prefix, ...restRouteOptions } = routeOptions
|
|
198
|
+
const autoLoadOptions = {
|
|
199
|
+
dir: routesDirPath,
|
|
200
|
+
autoHooks: true,
|
|
201
|
+
cascadeHooks: true,
|
|
202
|
+
...restRouteOptions
|
|
203
|
+
}
|
|
204
|
+
if (prefix) {
|
|
205
|
+
app.register(async (scoped) => {
|
|
206
|
+
scoped.register(autoLoad, autoLoadOptions)
|
|
207
|
+
}, { prefix })
|
|
208
|
+
} else {
|
|
209
|
+
app.register(autoLoad, autoLoadOptions)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Database lifecycle: readiness checks, health probes, graceful shutdown
|
|
215
|
+
if (databaseOptions?.mysql) {
|
|
216
|
+
const { mysqlReadyCheck, getMySQLPools } = await import('./database/mysql.js')
|
|
217
|
+
const { registerReadinessCheck, mysqlHealthCheck } = await import('./health.js')
|
|
218
|
+
const pools = databaseOptions.mysql === true ? getMySQLPools() : databaseOptions.mysql
|
|
219
|
+
const entries = Object.entries(pools)
|
|
220
|
+
|
|
221
|
+
// Register health checks for each pool
|
|
222
|
+
for (const [, pool] of entries) {
|
|
223
|
+
registerReadinessCheck(() => mysqlHealthCheck(pool))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Wait for all pools to be ready
|
|
227
|
+
for (const [name, pool] of entries) {
|
|
228
|
+
await mysqlReadyCheck(pool, { logger: app.log })
|
|
229
|
+
app.log.info(`Database "${name}" is ready`)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Close all pools on shutdown
|
|
233
|
+
app.addHook('onClose', async () => {
|
|
234
|
+
await Promise.all(entries.map(([, pool]) => pool.end()))
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Start metrics server immediately so K8s probes work during initialization
|
|
239
|
+
if (metricsServer !== false) {
|
|
240
|
+
const { createMetricsServer } = await import('./metrics-server.js')
|
|
241
|
+
const server = createMetricsServer()
|
|
242
|
+
await server.listen({ port: 9000, host: process.env.BIND_ADDR })
|
|
243
|
+
app.log.info('Metrics server listening on port 9000')
|
|
244
|
+
|
|
245
|
+
app.addHook('onClose', async () => {
|
|
246
|
+
await server.close()
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Configuration file watching
|
|
251
|
+
if (configOptions) {
|
|
252
|
+
const { createConfigRegistry } = await import('./config/registry.js')
|
|
253
|
+
const registry = createConfigRegistry({ logger: app.log })
|
|
254
|
+
const files = Array.isArray(configOptions) ? configOptions : [configOptions]
|
|
255
|
+
|
|
256
|
+
app.addHook('onReady', async () => {
|
|
257
|
+
for (const { filename, schema, optional, watch, callback } of files) {
|
|
258
|
+
await registry.registerConfigurationFile(filename, { schema, optional, watch }, callback)
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
app.addHook('onClose', async () => {
|
|
263
|
+
await registry.close()
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (onReady) {
|
|
268
|
+
app.addHook('onReady', onReady)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (onClose) {
|
|
272
|
+
app.addHook('onClose', onClose)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Register process signal handlers for graceful shutdown
|
|
276
|
+
async function shutdown () {
|
|
277
|
+
await app.close()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function onUncaughtException (err) {
|
|
281
|
+
app.log.fatal({ err }, `Uncaught Exception: ${err.message}`)
|
|
282
|
+
await shutdown()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function onUnhandledRejection (err) {
|
|
286
|
+
app.log.fatal({ err }, `Unhandled Rejection: ${err.message}`)
|
|
287
|
+
await shutdown()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function onSigint () {
|
|
291
|
+
app.log.info('SIGINT received, initiating graceful shutdown...')
|
|
292
|
+
await shutdown()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
process.on('uncaughtException', onUncaughtException)
|
|
296
|
+
process.on('unhandledRejection', onUnhandledRejection)
|
|
297
|
+
process.on('SIGINT', onSigint)
|
|
298
|
+
|
|
299
|
+
app.addHook('onClose', () => {
|
|
300
|
+
process.removeListener('uncaughtException', onUncaughtException)
|
|
301
|
+
process.removeListener('unhandledRejection', onUnhandledRejection)
|
|
302
|
+
process.removeListener('SIGINT', onSigint)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Start the app server. Reads `BIND_ADDR` and `PORT` (default 8080) from
|
|
307
|
+
* environment variables, logs the listen address, and begins accepting connections.
|
|
308
|
+
* On failure, logs the error and closes the app.
|
|
309
|
+
* @returns {Promise<void>}
|
|
310
|
+
*/
|
|
311
|
+
app.start = async function () {
|
|
312
|
+
try {
|
|
313
|
+
const host = process.env.BIND_ADDR
|
|
314
|
+
const port = Number(process.env.PORT) || 8080
|
|
315
|
+
app.log.info(`Starting on ${host || '0.0.0.0'}:${port}`)
|
|
316
|
+
await app.listen({ host, port })
|
|
317
|
+
} catch (err) {
|
|
318
|
+
app.log.error({ err }, `Failed to start: ${err.message}`)
|
|
319
|
+
await app.close()
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return app
|
|
324
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createConfigRegistry } from './registry.js'
|
|
2
|
+
export { readConfigurationFile, validateAndUpdateConfiguration, fileExists, getConfigPath } from './read.js'
|
|
3
|
+
export { defaultTrue, defaultFalse, customString, optionalCustomString, customURL } from './util.js'
|
|
4
|
+
export { default as Joi } from 'joi'
|