@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 ADDED
@@ -0,0 +1,370 @@
1
+ # @open-xchange/fastify-sdk
2
+
3
+ [![coverage](https://gitlab.com/openxchange/appsuite/web-foundation/fastify-sdk/badges/main/coverage.svg)](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'