@open-xchange/fastify-sdk
v0.4.0
Published
Shared foundation package for OX App Suite Node.js services
Downloads
1,004
Maintainers
Keywords
Readme
@open-xchange/fastify-sdk
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.
Install
pnpm add @open-xchange/fastify-sdkQuick start
import { createApp } from '@open-xchange/fastify-sdk'
const app = await createApp({
dirname: import.meta.dirname,
plugins: {
jwt: true,
swagger: { enabled: process.env.EXPOSE_API_DOCS === 'true' }
}
})
await app.listen({ host: '0.0.0.0', port: 8080 })That single call replaces ~500 lines of boilerplate (logger, CORS, Helmet, metrics, JWT, Swagger, autoload).
Exports
| Import path | What it provides |
|---|---|
| @open-xchange/fastify-sdk | createApp, createMetricsServer, createLogger, logger, loadEnv, requireEnv, jwtAuthHook, health check helpers, re-exports of fastify, fp, pino, promClient, createError, jose |
| @open-xchange/fastify-sdk/mariadb | createMariaDBPool, createMariaDBPoolFromEnv, getMariaDBPools, mariadbReadyCheck, createUUID |
| @open-xchange/fastify-sdk/postgres | createPostgresPool, createPostgresPoolFromEnv |
| @open-xchange/fastify-sdk/migrations | createMigrationRunner, executeMigrations |
| @open-xchange/fastify-sdk/config | createConfigRegistry, readConfigurationFile, Joi helpers (defaultTrue, defaultFalse, customString, customURL), Joi |
| @open-xchange/fastify-sdk/redis | createRedisClient, createRedisClientFromEnv, redisReadyCheck |
| @open-xchange/fastify-sdk/testing | createTestApp, generateTokenForJwks, getJwks |
API reference
createApp(options)
Creates a configured Fastify instance with standard OX defaults.
Options:
{
dirname: import.meta.dirname, // For resolving plugins/routes dirs
pluginsDir: 'plugins', // Relative to dirname (auto-loaded)
routesDir: 'routes', // Relative to dirname (auto-loaded)
routes: { prefix, ...autoloadOpts }, // Extra @fastify/autoload options for routes
fastify: {}, // Merged into Fastify constructor options
plugins: {
cors: true | { origin, methods }, // Default: true
helmet: true | { options }, // Default: true
logging: true, // Default: true (request/response hooks)
metrics: true, // Default: true (fastify-metrics collectors, no endpoint)
jwt: false | true | { key }, // Default: false (see JWT section below)
swagger: false | { enabled, openapi }, // Default: false
static: false | true | { root, preCompressed, ... }, // Default: false
},
metricsServer: true, // Default: true (separate Fastify on port 9090)
database: { mariadb: true }, // Auto-manages pool readiness, health checks, shutdown
config: { // YAML config file watching
filename: 'config.yaml',
schema, // Joi schema for validation
optional: true,
callback: (data) => { ... }
},
onReady: async () => {}, // Called in Fastify onReady hook
onClose: async () => {}, // Called in Fastify onClose hook
processHandlers: true, // Default: true (SIGINT/SIGTERM/SIGHUP, uncaughtException, unhandledRejection)
shutdownDelay: 5000, // Default: 5000 (ms to wait before closing connections after signal)
}Defaults applied:
requestIdLogLabel: 'requestId'disableRequestLogging: trueconnectionTimeout: 30000genReqId: () => randomUUID()
createLogger(options)
Returns a Pino logger with the standard OX configuration:
- Custom level mapping (trace→8, debug→7, info→6, warn→4, error→3, fatal→0)
- Redaction of
headers.authorization,headers.cookie,headers.host,key,password,salt,hash - Epoch millisecond timestamps
- No base (omits pid/hostname)
Pass custom options to override defaults (e.g. createLogger({ level: 'debug' })).
loadEnv()
Loads environment variables from .env.defaults then .env using Node's built-in process.loadEnvFile(). No external dependency needed.
requireEnv(keys)
Validates that the given environment variables are set and non-empty. Prints a clear error message and exits the process if any are missing. Call after loadEnv() and before createApp().
import { loadEnv, requireEnv } from '@open-xchange/fastify-sdk'
loadEnv()
requireEnv(['PORT', 'ORIGINS', 'REDIS_HOSTS'])
if (process.env.SQL_ENABLED === 'true') {
requireEnv(['SQL_HOST', 'SQL_USER', 'SQL_PASS', 'SQL_DB'])
}Health check helpers
import { registerReadinessCheck, registerHealthCheck, mariadbHealthCheck } from '@open-xchange/fastify-sdk'
registerReadinessCheck(async () => { await mariadbHealthCheck(pool) })
registerHealthCheck(async () => { await mariadbHealthCheck(pool) })Registered checks are run by the metrics server (GET /ready and GET /live on port 9090). See Metrics server below.
MariaDB
import { createMariaDBPool, createMariaDBPoolFromEnv, mariadbReadyCheck, createUUID } from '@open-xchange/fastify-sdk/mariadb'
// From explicit options
const pool = createMariaDBPool({ host: 'localhost', database: 'mydb', user: 'root', password: '' })
// From env vars (SQL_HOST, SQL_PORT, SQL_DB, SQL_USER, SQL_PASS, SQL_CONNECTIONS)
const pool = createMariaDBPoolFromEnv()
// Multi-database from env (DB_<NAME>_HOST, DB_<NAME>_PORT, etc.)
const pools = createMariaDBPoolFromEnv({ names: 'users,analytics' })
// Retry-based readiness check
await mariadbReadyCheck(pool, { retries: 12, delay: 10_000, logger })
// MariaDB UUID generation
const uuid = await createUUID(pool)PostgreSQL
import { createPostgresPool, createPostgresPoolFromEnv } from '@open-xchange/fastify-sdk/postgres'
// From env vars (DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD)
// Supports SSL via DATABASE_SSL, DATABASE_SSL_CA_PATH, etc.
const pool = createPostgresPoolFromEnv()Migrations (Umzug + MariaDB)
import { createMigrationRunner, executeMigrations } from '@open-xchange/fastify-sdk/migrations'
const runner = createMigrationRunner({
pool,
migrationsGlob: 'src/migrations/*.mjs',
tableName: 'migrations',
logger
})
await executeMigrations(runner)Config (YAML + Joi + hot-reload)
import { createConfigRegistry, Joi, defaultTrue } from '@open-xchange/fastify-sdk/config'
const { registerConfigurationFile, getCurrent } = createConfigRegistry({ logger })
const schema = Joi.object({
features: Joi.object({ chat: defaultTrue }).default()
})
await registerConfigurationFile('config.yaml', { schema, watch: true }, (data) => {
Object.assign(config, data)
})
const current = getCurrent('config.yaml')Redis
import { createRedisClient, redisReadyCheck } from '@open-xchange/fastify-sdk/redis'
// Reads REDIS_HOSTS, REDIS_MODE (standalone|sentinel|cluster), REDIS_PASSWORD, etc.
const client = createRedisClient()
await redisReadyCheck(client)Testing
import { createTestApp, generateTokenForJwks, getJwks } from '@open-xchange/fastify-sdk/testing'
// Creates Fastify with CORS/Helmet/Metrics/Logging disabled, metrics server off
const app = await createTestApp({
dirname: import.meta.dirname,
routesDir: '../src/routes',
plugins: { jwt: true }
})
const token = await generateTokenForJwks({ userId: '1' }, 'kid', 'issuer.com')
const jwks = await getJwks('kid')Lint
Use @open-xchange/lint directly as a devDependency:
// eslint.config.js
import config from '@open-xchange/lint'
export default [
...config
]Logging
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.
Override TTY detection with LOG_PRETTY:
LOG_PRETTY=true— force pretty printing (useful in CI or non-TTY environments)LOG_PRETTY=false— force JSON output
Re-exports
These are re-exported so consuming projects don't need to install them separately:
import { fastify, fp, pino, promClient, createError, jose } from '@open-xchange/fastify-sdk'Plugins
CORS (default: enabled)
Reads ORIGINS env var (comma-separated). Defaults: methods GET, POST, maxAge 86400.
Helmet (default: enabled)
Standard security headers. contentSecurityPolicy: false, crossOriginEmbedderPolicy: false, crossOriginOpenerPolicy: same-origin-allow-popups.
Logging (default: enabled)
preHandler: trace-logs request bodyonResponse: debug-logs URL, status, responseTime (includes headers at trace level)
Metrics server (default: enabled)
A separate Fastify instance on port 9090, serving health probes and Prometheus metrics.
| Endpoint | Purpose | Response |
|---|---|---|
| GET /live | K8s liveness probe | 200 {"status":"ok"} or 503 {"status":"error"} |
| GET /ready | K8s readiness probe | 200 {"status":"ok"} or 503 {"status":"error"} |
| GET /metrics | Prometheus scraping | Prometheus text format |
/live runs checks registered via registerHealthCheck(). /ready runs checks registered via registerReadinessCheck(). With no checks registered, /live returns 200.
Startup lifecycle (matches lightship behavior): The metrics server starts at the very beginning of createApp(), before any plugins, database readiness checks, or other initialization. This ensures K8s liveness probes work immediately while the app initializes. /ready returns 503 until app.start() completes — a built-in readiness check gates it automatically. This means consuming projects can safely perform long-running initialization (Redis, S3, DB, config) between createApp() and app.start() without K8s killing the pod.
| Phase | /live | /ready |
|---|---|---|
| During createApp() (plugins, DB readiness, etc.) | 200 | 503 |
| Between createApp() and app.start() (custom init) | 200 | 503 |
| After app.start() | 200 | 200 |
Graceful shutdown (default: enabled via processHandlers: true): The SDK registers handlers for SIGINT, SIGTERM, and SIGHUP (plus uncaughtException and unhandledRejection). When a signal is received:
/readyflips to 503 immediately — K8s stops routing new traffic to this pod- The process waits
shutdownDelayms (default 5000) — gives K8s time to remove the pod from service endpoints so in-flight requests complete app.close()runs — triggers allonClosehooks (database pool cleanup, config watcher teardown, metrics server shutdown, etc.)
| Phase | /live | /ready |
|---|---|---|
| Signal received | 200 | 503 |
| During shutdown delay | 200 | 503 |
| After app.close() | — | — |
This matches lightship's shutdownDelay: 5000 behavior. Set shutdownDelay: 0 in tests that trigger signal-based shutdown to avoid slow teardown.
All signal listeners are named functions and are removed in the onClose hook to prevent MaxListenersExceededWarning.
Disable with processHandlers: false (used by createTestApp() to skip signal handlers in tests).
Disable with metricsServer: false (used by createTestApp() to avoid port binding in tests).
Port 9090 is hardcoded to match all existing K8s probe and Prometheus configs.
Metrics plugin (default: enabled)
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 9090.
Sensible (always enabled)
Registers @fastify/sensible, providing reply.notFound(), reply.badRequest(), app.httpErrors, request.to(), and other convenience utilities on every app.
JWT (default: disabled)
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).
Env vars:
OIDC_ISSUER— comma-separated allowed issuers (e.g.auth.example.com, *.example.org). Supports wildcard subdomains.
Modes:
| OIDC_ISSUER | key option | Behavior |
|---|---|---|
| Set | — | OIDC/JWKS verification via createRemoteJWKSet |
| Not set | Provided | Custom key resolver (e.g. local certificates) |
| Not set | Not provided | app.verifyJWT always returns 401 |
OIDC mode (plugins: { jwt: true } with OIDC_ISSUER set):
- Extracts the
issclaim from the JWT and checks it against the allowlist - On first request per issuer, tries OIDC discovery (
.well-known/openid-configuration) to findjwks_uri - Falls back to
.well-known/jwks.jsonif discovery is unavailable - Uses
jose.createRemoteJWKSetfor key resolution — jose handles caching and key rotation internally
Custom key mode (plugins: { jwt: { key: fn } } without OIDC_ISSUER):
For services that verify tokens against project-specific keys (e.g. local X.509 certificates):
import { getLocalPublicKey } from './modules/jwks.js'
const app = await createApp({
plugins: {
jwt: { key: (request, token) => getLocalPublicKey(token) }
}
})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).
Decorators:
app.verifyJWT— async function for use asonRequesthook. Returns 401 if token is invalid or JWT is not configured.request.jwtVerify()— from@fastify/jwt, available whenOIDC_ISSUERis set orkeyis provided.
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:
// src/routes/api/autohooks.js
export { jwtAuthHook as default } from '@open-xchange/fastify-sdk'This is equivalent to:
export default async function (app) {
app.addHook('onRequest', app.verifyJWT)
}Static files (default: disabled)
Serves static files using @fastify/static. Defaults to public/ directory with pre-compressed file support.
plugins: {
static: true, // Serve from public/ with preCompressed: true
static: { root: 'dist', prefix: '/assets' } // Customize
}Swagger (default: disabled)
Conditional on enabled option. Serves Swagger UI at /api-docs.
Running standalone
Foundation runs Fastify standalone. Health endpoints, metrics, and logging are all built in:
import { createApp } from '@open-xchange/fastify-sdk'
// Metrics server starts here — K8s /live probe works immediately.
// /ready returns 503 until app.start() completes.
const app = await createApp({ dirname: import.meta.dirname })
// Safe to perform long-running initialization here.
// K8s sees /live → 200 (pod is alive) and /ready → 503 (not accepting traffic yet).
// await connectRedis()
// await loadConfig()
// Start listening. /ready flips to 200 after this completes.
await app.start()This starts:
- Port 9090 (metrics server) — starts first in
createApp():GET /live(200),GET /ready(503 untilapp.start()),GET /metrics - Port 8080 (app) — your routes (auto-loaded from
routes/), with CORS, Helmet, logging, JWT — starts inapp.start()
Development
pnpm install
pnpm test
pnpm lintLicense
AGPL-3.0-or-later
