forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import events from 'node:events'
2import type http from 'node:http'
3
4import express from 'express'
5import promBundle from 'express-prom-bundle'
6import {createHttpTerminator, type HttpTerminator} from 'http-terminator'
7import {register} from 'prom-client'
8
9import {type Config} from './config.js'
10import {AppContext} from './context.js'
11import {default as routes, errorHandler} from './routes/index.js'
12
13export * from './config.js'
14export * from './logger.js'
15
16export class CardService {
17 public server?: http.Server
18 public metricsServer?: http.Server
19 private terminator?: HttpTerminator
20 private metricsTerminator?: HttpTerminator
21
22 constructor(
23 public app: express.Application,
24 public ctx: AppContext,
25 ) {}
26
27 static async create(cfg: Config): Promise<CardService> {
28 let app = express()
29
30 const ctx = await AppContext.fromConfig(cfg)
31
32 // Add Prometheus middleware for automatic HTTP instrumentation
33 const metricsMiddleware = promBundle({
34 includeMethod: true,
35 includePath: true,
36 includeStatusCode: true,
37 includeUp: true,
38 promClient: {
39 collectDefaultMetrics: {},
40 },
41
42 autoregister: false,
43 normalizePath: req => {
44 // If we have a matched route, use its path (with :params) instead of the full URL path
45 if (req.route) {
46 return req.route.path
47 }
48
49 // Group all unmatched paths together to reduce cardinality
50 return '<unmatched>'
51 },
52 })
53 app.use(metricsMiddleware)
54
55 app = routes(ctx, app)
56 app.use(errorHandler)
57
58 return new CardService(app, ctx)
59 }
60
61 async start() {
62 // Start main application server
63 this.server = this.app.listen(this.ctx.cfg.service.port)
64 this.server.keepAliveTimeout = 90000
65 this.terminator = createHttpTerminator({
66 server: this.server,
67 gracefulTerminationTimeout: 15000, // 15s timeout for in-flight requests
68 })
69 await events.once(this.server, 'listening')
70
71 // Start separate metrics server
72 const metricsApp = express()
73 metricsApp.get('/metrics', async (_req, res) => {
74 res.set('Content-Type', register.contentType)
75 res.end(await register.metrics())
76 })
77
78 this.metricsServer = metricsApp.listen(this.ctx.cfg.service.metricsPort)
79 this.metricsTerminator = createHttpTerminator({
80 server: this.metricsServer,
81 gracefulTerminationTimeout: 2000, // 2s timeout for metrics server
82 })
83 await events.once(this.metricsServer, 'listening')
84 }
85
86 async destroy() {
87 const startTime = Date.now()
88
89 this.ctx.abortController.abort()
90
91 const shutdownPromises = []
92
93 if (this.terminator) {
94 shutdownPromises.push(this.terminator.terminate())
95 }
96
97 if (this.metricsTerminator) {
98 shutdownPromises.push(this.metricsTerminator.terminate())
99 }
100
101 await Promise.all(shutdownPromises)
102
103 const elapsed = Date.now() - startTime
104 const {httpLogger} = await import('./logger.js')
105 httpLogger.info(`Graceful shutdown completed in ${elapsed}ms`)
106 }
107}