diff --git a/packages/fastify-core/package.json b/packages/fastify-core/package.json index 185e2eb3..8c65a571 100644 --- a/packages/fastify-core/package.json +++ b/packages/fastify-core/package.json @@ -21,7 +21,21 @@ "@bytelyst/errors": "workspace:*" }, "peerDependencies": { + "@fastify/cors": ">=10.0.0", + "@fastify/swagger": ">=9.0.0", "fastify": ">=5.0.0", - "@fastify/cors": ">=10.0.0" + "fastify-metrics": ">=10.0.0" + }, + "peerDependenciesMeta": { + "@fastify/swagger": { + "optional": true + }, + "fastify-metrics": { + "optional": true + } + }, + "devDependencies": { + "@fastify/swagger": "^9.7.0", + "fastify-metrics": "^10.6.0" } } diff --git a/packages/fastify-core/src/create-app.ts b/packages/fastify-core/src/create-app.ts index 4b462125..9c947446 100644 --- a/packages/fastify-core/src/create-app.ts +++ b/packages/fastify-core/src/create-app.ts @@ -26,7 +26,7 @@ import type { ServiceAppOptions, FastifyApp } from './types.js'; * ``` */ export async function createServiceApp(options: ServiceAppOptions): Promise { - const { name, version, description, corsOrigin, logger = true } = options; + const { name, version, description, corsOrigin, logger = true, swagger, metrics } = options; const app = Fastify({ logger }); @@ -34,6 +34,28 @@ export async function createServiceApp(options: ServiceAppOptions): Promise o.trim()) : true; await app.register(cors, { origin }); + // OpenAPI spec (optional — consumer must have @fastify/swagger installed) + if (swagger) { + const swaggerPlugin = (await import('@fastify/swagger')).default; + await app.register(swaggerPlugin, { + openapi: { + info: { + title: swagger.title, + version, + ...(swagger.description && { description: swagger.description }), + }, + ...(swagger.port && { servers: [{ url: `http://localhost:${swagger.port}` }] }), + }, + }); + } + + // Prometheus metrics (optional — consumer must have fastify-metrics installed) + if (metrics) { + const metricsMod = await import('fastify-metrics'); + const plugin = metricsMod.default as unknown as Parameters[0]; + await app.register(plugin, { endpoint: '/metrics' }); + } + // x-request-id propagation app.addHook('onRequest', async (req, reply) => { const requestId = (req.headers['x-request-id'] as string) || randomUUID(); diff --git a/packages/fastify-core/src/types.ts b/packages/fastify-core/src/types.ts index fd15bf93..bcef1b9a 100644 --- a/packages/fastify-core/src/types.ts +++ b/packages/fastify-core/src/types.ts @@ -1,11 +1,19 @@ import type { FastifyInstance } from 'fastify'; +export interface SwaggerOptions { + title: string; + description?: string; + port?: number; +} + export interface ServiceAppOptions { name: string; version: string; description?: string; corsOrigin?: string; logger?: boolean; + swagger?: SwaggerOptions; + metrics?: boolean; } export interface StartOptions { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49307c27..f6440e99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,13 @@ importers: fastify: specifier: '>=5.0.0' version: 5.7.4 + devDependencies: + '@fastify/swagger': + specifier: ^9.7.0 + version: 9.7.0 + fastify-metrics: + specifier: ^10.6.0 + version: 10.6.0(fastify@5.7.4) packages/logger: {} @@ -131,6 +138,9 @@ importers: '@bytelyst/errors': specifier: workspace:* version: link:../../packages/errors + '@bytelyst/fastify-core': + specifier: workspace:* + version: link:../../packages/fastify-core '@fastify/cors': specifier: ^10.0.2 version: 10.1.0 @@ -177,6 +187,9 @@ importers: '@bytelyst/errors': specifier: workspace:* version: link:../../packages/errors + '@bytelyst/fastify-core': + specifier: workspace:* + version: link:../../packages/fastify-core '@fastify/cors': specifier: ^10.0.2 version: 10.1.0 @@ -238,6 +251,9 @@ importers: '@bytelyst/errors': specifier: workspace:* version: link:../../packages/errors + '@bytelyst/fastify-core': + specifier: workspace:* + version: link:../../packages/fastify-core '@fastify/cors': specifier: ^10.0.2 version: 10.1.0 @@ -290,6 +306,9 @@ importers: '@bytelyst/errors': specifier: workspace:* version: link:../../packages/errors + '@bytelyst/fastify-core': + specifier: workspace:* + version: link:../../packages/fastify-core '@fastify/cors': specifier: ^10.0.2 version: 10.1.0 diff --git a/services/billing-service/package.json b/services/billing-service/package.json index 1bfe9d17..409fb71f 100644 --- a/services/billing-service/package.json +++ b/services/billing-service/package.json @@ -16,6 +16,7 @@ "@bytelyst/config": "workspace:*", "@bytelyst/cosmos": "workspace:*", "@bytelyst/errors": "workspace:*", + "@bytelyst/fastify-core": "workspace:*", "@azure/cosmos": "^4.2.0", "fastify": "^5.2.1", "@fastify/cors": "^10.0.2", diff --git a/services/billing-service/src/server.ts b/services/billing-service/src/server.ts index f0a637c5..b94f4c6a 100644 --- a/services/billing-service/src/server.ts +++ b/services/billing-service/src/server.ts @@ -5,12 +5,7 @@ * Port: 4002 (configurable via PORT env var). */ -import { randomUUID } from 'node:crypto'; -import Fastify from 'fastify'; -import cors from '@fastify/cors'; -import swagger from '@fastify/swagger'; -import metricsPlugin from 'fastify-metrics'; -import { ServiceError } from './lib/errors.js'; +import { createServiceApp, startService } from '@bytelyst/fastify-core'; import { subscriptionRoutes } from './modules/subscriptions/routes.js'; import { usageRoutes } from './modules/usage/routes.js'; import { planRoutes } from './modules/plans/routes.js'; @@ -18,50 +13,20 @@ import { licenseRoutes } from './modules/licenses/routes.js'; import { stripeRoutes } from './modules/stripe/routes.js'; import { config } from './lib/config.js'; -const PORT = config.PORT; -const HOST = config.HOST; - -const app = Fastify({ logger: true }); - -// CORS — restrict to specific origins in production via CORS_ORIGIN (comma-separated) -const corsOrigin = config.CORS_ORIGIN; -await app.register(cors, { - origin: corsOrigin ? corsOrigin.split(',').map(o => o.trim()) : true, -}); - -// OpenAPI spec auto-generation (GET /api/docs/json) -await app.register(swagger, { - openapi: { - info: { - title: 'Billing & Entitlement Service', - version: '0.1.0', - description: 'Subscriptions, payments, usage, licenses, plans, Stripe', - }, - servers: [{ url: `http://localhost:${PORT}` }], - }, -}); - -// Prometheus metrics -await app.register(metricsPlugin, { endpoint: '/metrics' }); - -// x-request-id: propagate incoming header or generate a new one -app.addHook('onRequest', async (req, reply) => { - const requestId = (req.headers['x-request-id'] as string) || randomUUID(); - req.headers['x-request-id'] = requestId; - reply.header('x-request-id', requestId); - req.log = req.log.child({ requestId }); -}); - -// Health check -app.get('/health', async req => ({ - status: 'ok', - service: 'billing-service', +const app = await createServiceApp({ + name: 'billing-service', version: '0.1.0', - timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'], -})); + description: 'Subscriptions, payments, usage, licenses, plans, Stripe', + corsOrigin: config.CORS_ORIGIN, + swagger: { + title: 'Billing & Entitlement Service', + description: 'Subscriptions, payments, usage, licenses, plans, Stripe', + port: config.PORT, + }, + metrics: true, +}); -// Internal API key auth (skip health, webhook, and when key not configured) +// Internal API key auth (service-specific — skip health, webhook, and when key not configured) const INTERNAL_KEY = config.BILLING_INTERNAL_KEY; if (INTERNAL_KEY) { app.addHook('onRequest', async (req, reply) => { @@ -75,18 +40,6 @@ if (INTERNAL_KEY) { }); } -// Custom error handler -app.setErrorHandler((error, _req, reply) => { - if (error instanceof ServiceError) { - const body: Record = { error: error.message }; - if ('details' in error && error.details) body.details = error.details; - reply.code(error.statusCode).send(body); - return; - } - app.log.error(error); - reply.code(500).send({ error: 'Internal server error' }); -}); - // Register route modules await app.register(subscriptionRoutes, { prefix: '/api' }); await app.register(usageRoutes, { prefix: '/api' }); @@ -94,11 +47,4 @@ await app.register(planRoutes, { prefix: '/api' }); await app.register(licenseRoutes, { prefix: '/api' }); await app.register(stripeRoutes, { prefix: '/api' }); -// Start -try { - await app.listen({ port: PORT, host: HOST }); - app.log.info(`Billing Service listening on ${HOST}:${PORT}`); -} catch (err) { - app.log.error(err); - process.exit(1); -} +await startService(app, { port: config.PORT, host: config.HOST }); diff --git a/services/growth-service/package.json b/services/growth-service/package.json index fd71994d..5327e276 100644 --- a/services/growth-service/package.json +++ b/services/growth-service/package.json @@ -16,6 +16,7 @@ "@bytelyst/config": "workspace:*", "@bytelyst/cosmos": "workspace:*", "@bytelyst/errors": "workspace:*", + "@bytelyst/fastify-core": "workspace:*", "@azure/cosmos": "^4.2.0", "fastify": "^5.2.1", "@fastify/cors": "^10.0.2", diff --git a/services/growth-service/src/server.ts b/services/growth-service/src/server.ts index 82331c8f..d28bf62e 100644 --- a/services/growth-service/src/server.ts +++ b/services/growth-service/src/server.ts @@ -5,66 +5,23 @@ * Port: 4001 (configurable via PORT env var). */ -import { randomUUID } from 'node:crypto'; -import Fastify from 'fastify'; -import cors from '@fastify/cors'; -import swagger from '@fastify/swagger'; -import metricsPlugin from 'fastify-metrics'; -import { ServiceError } from './lib/errors.js'; +import { createServiceApp, startService } from '@bytelyst/fastify-core'; import { invitationRoutes } from './modules/invitations/routes.js'; import { referralRoutes } from './modules/referrals/routes.js'; import { promoRoutes } from './modules/promos/routes.js'; import { config } from './lib/config.js'; -const PORT = config.PORT; -const HOST = config.HOST; - -const app = Fastify({ logger: true }); - -// CORS — restrict to specific origins in production via CORS_ORIGIN (comma-separated) -const corsOrigin = config.CORS_ORIGIN; -await app.register(cors, { - origin: corsOrigin ? corsOrigin.split(',').map(o => o.trim()) : true, -}); - -// OpenAPI spec auto-generation (GET /api/docs/json) -await app.register(swagger, { - openapi: { - info: { - title: 'Growth Service', - version: '0.1.0', - description: 'Invitations, referrals, promo codes', - }, - servers: [{ url: `http://localhost:${PORT}` }], - }, -}); -// Prometheus metrics -await app.register(metricsPlugin, { endpoint: '/metrics' }); -// x-request-id: propagate incoming header or generate a new one -app.addHook('onRequest', async (req, reply) => { - const requestId = (req.headers['x-request-id'] as string) || randomUUID(); - req.headers['x-request-id'] = requestId; - reply.header('x-request-id', requestId); - req.log = req.log.child({ requestId }); -}); - -// Health check -app.get('/health', async req => ({ - status: 'ok', - service: 'growth-service', +const app = await createServiceApp({ + name: 'growth-service', version: '0.1.0', - timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'], -})); - -// Custom error handler for ServiceError -app.setErrorHandler((error, _req, reply) => { - if (error instanceof ServiceError) { - reply.code(error.statusCode).send({ error: error.message }); - return; - } - app.log.error(error); - reply.code(500).send({ error: 'Internal server error' }); + description: 'Invitations, referrals, promo codes', + corsOrigin: config.CORS_ORIGIN, + swagger: { + title: 'Growth Service', + description: 'Invitations, referrals, promo codes', + port: config.PORT, + }, + metrics: true, }); // Register route modules @@ -72,11 +29,4 @@ await app.register(invitationRoutes, { prefix: '/api' }); await app.register(referralRoutes, { prefix: '/api' }); await app.register(promoRoutes, { prefix: '/api' }); -// Start -try { - await app.listen({ port: PORT, host: HOST }); - app.log.info(`Growth Service listening on ${HOST}:${PORT}`); -} catch (err) { - app.log.error(err); - process.exit(1); -} +await startService(app, { port: config.PORT, host: config.HOST }); diff --git a/services/platform-service/package.json b/services/platform-service/package.json index cba4c8bc..4111699f 100644 --- a/services/platform-service/package.json +++ b/services/platform-service/package.json @@ -16,6 +16,7 @@ "@bytelyst/config": "workspace:*", "@bytelyst/cosmos": "workspace:*", "@bytelyst/errors": "workspace:*", + "@bytelyst/fastify-core": "workspace:*", "@azure/cosmos": "^4.2.0", "@azure/storage-blob": "^12.31.0", "@fastify/cors": "^10.0.2", diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 50dcdc4c..49a7355f 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -5,12 +5,7 @@ * Port: 4003 (configurable via PORT env var). */ -import { randomUUID } from 'node:crypto'; -import Fastify from 'fastify'; -import cors from '@fastify/cors'; -import swagger from '@fastify/swagger'; -import metricsPlugin from 'fastify-metrics'; -import { ServiceError } from './lib/errors.js'; +import { createServiceApp, startService } from '@bytelyst/fastify-core'; import { authRoutes } from './modules/auth/routes.js'; import { auditRoutes } from './modules/audit/routes.js'; import { notificationRoutes } from './modules/notifications/routes.js'; @@ -19,57 +14,17 @@ import { rateLimitRoutes } from './modules/ratelimit/routes.js'; import { blobRoutes } from './modules/blob/routes.js'; import { config } from './lib/config.js'; -const PORT = config.PORT; -const HOST = config.HOST; - -const app = Fastify({ logger: true }); - -// CORS — restrict to specific origins in production via CORS_ORIGIN (comma-separated) -const corsOrigin = config.CORS_ORIGIN; -await app.register(cors, { - origin: corsOrigin ? corsOrigin.split(',').map(o => o.trim()) : true, -}); - -// OpenAPI spec auto-generation (GET /api/docs/json) -await app.register(swagger, { - openapi: { - info: { - title: 'Platform Service', - version: '0.1.0', - description: 'Auth, audit, notifications, feature flags, rate limiting', - }, - servers: [{ url: `http://localhost:${PORT}` }], - }, -}); - -// Prometheus metrics -await app.register(metricsPlugin, { endpoint: '/metrics' }); - -// x-request-id: propagate incoming header or generate a new one -app.addHook('onRequest', async (req, reply) => { - const requestId = (req.headers['x-request-id'] as string) || randomUUID(); - req.headers['x-request-id'] = requestId; - reply.header('x-request-id', requestId); - req.log = req.log.child({ requestId }); -}); - -// Health check -app.get('/health', async req => ({ - status: 'ok', - service: 'platform-service', +const app = await createServiceApp({ + name: 'platform-service', version: '0.1.0', - timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'], -})); - -// Custom error handler -app.setErrorHandler((error, _req, reply) => { - if (error instanceof ServiceError) { - reply.code(error.statusCode).send({ error: error.message }); - return; - } - app.log.error(error); - reply.code(500).send({ error: 'Internal server error' }); + description: 'Auth, audit, notifications, feature flags, rate limiting', + corsOrigin: config.CORS_ORIGIN, + swagger: { + title: 'Platform Service', + description: 'Auth, audit, notifications, feature flags, rate limiting', + port: config.PORT, + }, + metrics: true, }); // Register route modules @@ -80,11 +35,4 @@ await app.register(flagRoutes, { prefix: '/api' }); await app.register(rateLimitRoutes, { prefix: '/api' }); await app.register(blobRoutes, { prefix: '/api' }); -// Start -try { - await app.listen({ port: PORT, host: HOST }); - app.log.info(`Platform Service listening on ${HOST}:${PORT}`); -} catch (err) { - app.log.error(err); - process.exit(1); -} +await startService(app, { port: config.PORT, host: config.HOST }); diff --git a/services/tracker-service/package.json b/services/tracker-service/package.json index 8aa6da3d..e10e79f2 100644 --- a/services/tracker-service/package.json +++ b/services/tracker-service/package.json @@ -16,6 +16,7 @@ "@bytelyst/config": "workspace:*", "@bytelyst/cosmos": "workspace:*", "@bytelyst/errors": "workspace:*", + "@bytelyst/fastify-core": "workspace:*", "@azure/cosmos": "^4.2.0", "@fastify/cors": "^10.0.2", "@fastify/rate-limit": "^10.3.0", diff --git a/services/tracker-service/src/server.ts b/services/tracker-service/src/server.ts index 9ff46c44..c39b2139 100644 --- a/services/tracker-service/src/server.ts +++ b/services/tracker-service/src/server.ts @@ -6,69 +6,24 @@ * Product-agnostic: all data scoped by productId. */ -import { randomUUID } from 'node:crypto'; -import Fastify from 'fastify'; -import cors from '@fastify/cors'; -import swagger from '@fastify/swagger'; -import metricsPlugin from 'fastify-metrics'; -import { ServiceError } from './lib/errors.js'; +import { createServiceApp, startService } from '@bytelyst/fastify-core'; import { itemRoutes } from './modules/items/routes.js'; import { commentRoutes } from './modules/comments/routes.js'; import { voteRoutes } from './modules/votes/routes.js'; import { publicRoutes } from './modules/public/routes.js'; import { config } from './lib/config.js'; -const PORT = config.PORT; -const HOST = config.HOST; - -const app = Fastify({ logger: true }); - -// CORS — restrict to specific origins in production via CORS_ORIGIN (comma-separated) -const corsOrigin = config.CORS_ORIGIN; -await app.register(cors, { - origin: corsOrigin ? corsOrigin.split(',').map(o => o.trim()) : true, -}); - -// OpenAPI spec auto-generation (GET /api/docs/json) -await app.register(swagger, { - openapi: { - info: { - title: 'Tracker Service', - version: '0.1.0', - description: 'Feature requests, bugs, tasks — product-agnostic', - }, - servers: [{ url: `http://localhost:${PORT}` }], - }, -}); - -// Prometheus metrics -await app.register(metricsPlugin, { endpoint: '/metrics' }); - -// x-request-id: propagate incoming header or generate a new one -app.addHook('onRequest', async (req, reply) => { - const requestId = (req.headers['x-request-id'] as string) || randomUUID(); - req.headers['x-request-id'] = requestId; - reply.header('x-request-id', requestId); - req.log = req.log.child({ requestId }); -}); - -// Health check -app.get('/health', async req => ({ - status: 'ok', - service: 'tracker-service', +const app = await createServiceApp({ + name: 'tracker-service', version: '0.1.0', - timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'], -})); - -// Custom error handler -app.setErrorHandler((error, _req, reply) => { - if (error instanceof ServiceError) { - reply.code(error.statusCode).send({ error: error.message }); - return; - } - app.log.error(error); - reply.code(500).send({ error: 'Internal server error' }); + description: 'Feature requests, bugs, tasks — product-agnostic', + corsOrigin: config.CORS_ORIGIN, + swagger: { + title: 'Tracker Service', + description: 'Feature requests, bugs, tasks — product-agnostic', + port: config.PORT, + }, + metrics: true, }); // Register route modules @@ -77,11 +32,4 @@ await app.register(commentRoutes, { prefix: '/api' }); await app.register(voteRoutes, { prefix: '/api' }); await app.register(publicRoutes, { prefix: '/api' }); -// Start -try { - await app.listen({ port: PORT, host: HOST }); - app.log.info(`Tracker Service listening on ${HOST}:${PORT}`); -} catch (err) { - app.log.error(err); - process.exit(1); -} +await startService(app, { port: config.PORT, host: config.HOST });