From eac4ede21e2b5fdc342247a29a89de7b5a117d98 Mon Sep 17 00:00:00 2001 From: Krystie Date: Sat, 28 Mar 2026 19:25:06 -0700 Subject: [PATCH] refactor(routes): Phase 3.3 - standardize health.js with explicit dependencies - Replaced god object ctx with explicit dependency injection - Added JSDoc documenting required dependencies (8 deps vs 50+) - Updated response calls to use response-helpers (success/error) - Self-documenting: you can see exactly what this route needs - Health checks, pylon relay, CA cert validation all preserved --- dashcaddy-api/routes/health.js | 119 +++++++++++++++++++-------------- dashcaddy-api/server.js | 11 ++- 2 files changed, 80 insertions(+), 50 deletions(-) diff --git a/dashcaddy-api/routes/health.js b/dashcaddy-api/routes/health.js index 9f34fe2..106f20d 100644 --- a/dashcaddy-api/routes/health.js +++ b/dashcaddy-api/routes/health.js @@ -7,8 +7,31 @@ const { exists } = require('../fs-helpers'); const { paginate, parsePaginationParams } = require('../pagination'); const platformPaths = require('../platform-paths'); const { resolveServiceUrl } = require('../url-resolver'); +const { success, error: errorResponse } = require('../response-helpers'); -module.exports = function(ctx) { +/** + * Health routes factory + * @param {Object} deps - Explicit dependencies + * @param {Function} deps.fetchT - Fetch wrapper with timeout + * @param {string} deps.SERVICES_FILE - Path to services.json + * @param {Object} deps.servicesStateManager - State manager for services.json + * @param {Object} deps.siteConfig - Site configuration + * @param {Function} deps.buildServiceUrl - URL builder function + * @param {Function} deps.asyncHandler - Async route handler wrapper + * @param {Function} deps.logError - Error logging function + * @param {Object} deps.healthChecker - Health check manager instance + * @returns {express.Router} + */ +module.exports = function({ + fetchT, + SERVICES_FILE, + servicesStateManager, + siteConfig, + buildServiceUrl, + asyncHandler, + logError, + healthChecker +}) { const router = express.Router(); // In-memory cache for health results (local to this router) @@ -23,7 +46,7 @@ module.exports = function(ctx) { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); - const response = await ctx.fetchT(url, { method: 'HEAD', signal: controller.signal, redirect: 'follow' }); + const response = await fetchT(url, { method: 'HEAD', signal: controller.signal, redirect: 'follow' }); clearTimeout(timeout); return { status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', @@ -37,7 +60,7 @@ module.exports = function(ctx) { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); - const response = await ctx.fetchT(url, { method: 'GET', signal: controller.signal, redirect: 'follow' }); + const response = await fetchT(url, { method: 'GET', signal: controller.signal, redirect: 'follow' }); clearTimeout(timeout); return { status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', @@ -61,7 +84,7 @@ module.exports = function(ctx) { if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 12000); - const response = await ctx.fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers }); + const response = await fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers }); clearTimeout(timeout); if (!response.ok) return null; const data = await response.json(); @@ -82,15 +105,15 @@ module.exports = function(ctx) { // ===== HEALTH / SERVICES ===== // Check health of all services (performs live checks) - router.get('/health/services', ctx.asyncHandler(async (req, res) => { - if (!await exists(ctx.SERVICES_FILE)) { - return res.json({ success: true, health: {} }); + router.get('/health/services', asyncHandler(async (req, res) => { + if (!await exists(SERVICES_FILE)) { + return success(res, { health: {} }); } - const servicesData = await ctx.servicesStateManager.read(); + const servicesData = await servicesStateManager.read(); const services = Array.isArray(servicesData) ? servicesData : servicesData.services || []; const health = {}; - const pylonConfig = ctx.siteConfig?.pylon; + const pylonConfig = siteConfig?.pylon; // Check each service await Promise.all(services.map(async (service) => { @@ -98,7 +121,7 @@ module.exports = function(ctx) { if (!serviceId) return; try { - const url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl); + const url = resolveServiceUrl(serviceId, service, siteConfig, buildServiceUrl); if (!url) { health[serviceId] = { status: 'unknown', reason: 'No URL configured' }; return; @@ -144,8 +167,7 @@ module.exports = function(ctx) { const healthEntries = Object.entries(health); const result = paginate(healthEntries, paginationParams); const paginatedHealth = Object.fromEntries(result.data); - res.json({ - success: true, + success(res, { health: paginatedHealth, checkedAt: lastHealthCheck, ...(result.pagination && { pagination: result.pagination }) @@ -153,9 +175,8 @@ module.exports = function(ctx) { }, 'health-services')); // Get cached health status (fast, no re-check) - router.get('/health/cached', ctx.asyncHandler(async (req, res) => { - res.json({ - success: true, + router.get('/health/cached', asyncHandler(async (req, res) => { + success(res, { health: serviceHealthCache, lastCheck: lastHealthCheck, cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null @@ -163,16 +184,16 @@ module.exports = function(ctx) { }, 'health-cached')); // Check health of single service - router.get('/health/service/:id', ctx.asyncHandler(async (req, res) => { + router.get('/health/service/:id', asyncHandler(async (req, res) => { const serviceId = req.params.id; // Load service config - if (!await exists(ctx.SERVICES_FILE)) { + if (!await exists(SERVICES_FILE)) { const { NotFoundError } = require('../errors'); throw new NotFoundError('Services file'); } - const servicesData = await ctx.servicesStateManager.read(); + const servicesData = await servicesStateManager.read(); const services = Array.isArray(servicesData) ? servicesData : servicesData.services || []; const service = services.find(s => (s.id || s.name?.toLowerCase()) === serviceId); @@ -182,8 +203,8 @@ module.exports = function(ctx) { } // Determine URL - const url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl); - const pylonConfig = ctx.siteConfig?.pylon; + const url = resolveServiceUrl(serviceId, service, siteConfig, buildServiceUrl); + const pylonConfig = siteConfig?.pylon; // Try direct, then pylon relay let result = await checkDirect(url); @@ -199,16 +220,16 @@ module.exports = function(ctx) { }; } - res.json({ success: true, serviceId, health: result }); + success(res, { serviceId, health: result }); }, 'health-service')); // ===== HEALTH / PROBE (Pylon-compatible) ===== // Probe endpoint — lets this DashCaddy act as a pylon for other instances - router.get('/health/probe', ctx.asyncHandler(async (req, res) => { + router.get('/health/probe', asyncHandler(async (req, res) => { const targetUrl = req.query.url; if (!targetUrl) { - return ctx.errorResponse(res, 400, 'Missing ?url= parameter'); + return errorResponse(res, 'Missing ?url= parameter', 400); } const result = await checkDirect(targetUrl); res.json(result || { @@ -220,29 +241,29 @@ module.exports = function(ctx) { }, 'health-probe')); // Pylon status — check if the configured pylon is reachable - router.get('/health/pylon', ctx.asyncHandler(async (req, res) => { - const pylonConfig = ctx.siteConfig?.pylon; + router.get('/health/pylon', asyncHandler(async (req, res) => { + const pylonConfig = siteConfig?.pylon; if (!pylonConfig?.url) { - return res.json({ success: true, configured: false }); + return success(res, { configured: false }); } try { const headers = {}; if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key; - const response = await ctx.fetchT(`${pylonConfig.url}/health`, { + const response = await fetchT(`${pylonConfig.url}/health`, { method: 'GET', headers }, 5000); const data = await response.json(); - res.json({ success: true, configured: true, reachable: true, pylon: data }); + success(res, { configured: true, reachable: true, pylon: data }); } catch (e) { - res.json({ success: true, configured: true, reachable: false, error: e.message }); + success(res, { configured: true, reachable: false, error: e.message }); } }, 'health-pylon')); // ===== HEALTH / CA ===== // Get CA certificate health status - router.get('/health/ca', ctx.asyncHandler(async (req, res) => { + router.get('/health/ca', asyncHandler(async (req, res) => { // Try deployed location first, then Caddy PKI location const deployedCertPath = path.join(platformPaths.caCertDir, 'root.crt'); const pkiCertPath = platformPaths.pkiRootCert; @@ -288,7 +309,7 @@ module.exports = function(ctx) { expiresAt: notAfter }); } catch (error) { - await ctx.logError('GET /api/health/ca', error); + await logError('GET /api/health/ca', error); res.json({ status: 'error', message: error.message, @@ -300,50 +321,50 @@ module.exports = function(ctx) { // ===== HEALTH CHECK (health-checker module) ===== // Get current status for all services - router.get('/health-checks/status', ctx.asyncHandler(async (req, res) => { - const status = ctx.healthChecker.getCurrentStatus(); - res.json({ success: true, status }); + router.get('/health-checks/status', asyncHandler(async (req, res) => { + const status = healthChecker.getCurrentStatus(); + success(res, { status }); }, 'health-check-status')); // Get service statistics - router.get('/health-checks/:serviceId/stats', ctx.asyncHandler(async (req, res) => { + router.get('/health-checks/:serviceId/stats', asyncHandler(async (req, res) => { const hours = parseInt(req.query.hours) || 24; - const stats = ctx.healthChecker.getServiceStats(req.params.serviceId, hours); + const stats = healthChecker.getServiceStats(req.params.serviceId, hours); if (!stats) { const { NotFoundError } = require('../errors'); throw new NotFoundError('Service'); } - res.json({ success: true, stats }); + success(res, { stats }); }, 'health-check-stats')); // Configure health check - router.post('/health-checks/:serviceId/configure', ctx.asyncHandler(async (req, res) => { - ctx.healthChecker.configureService(req.params.serviceId, req.body); - res.json({ success: true, message: 'Health check configured' }); + router.post('/health-checks/:serviceId/configure', asyncHandler(async (req, res) => { + healthChecker.configureService(req.params.serviceId, req.body); + success(res, { message: 'Health check configured' }); }, 'health-check-configure')); // Remove health check configuration - router.delete('/health-checks/:serviceId/configure', ctx.asyncHandler(async (req, res) => { - ctx.healthChecker.removeService(req.params.serviceId); - res.json({ success: true, message: 'Health check removed' }); + router.delete('/health-checks/:serviceId/configure', asyncHandler(async (req, res) => { + healthChecker.removeService(req.params.serviceId); + success(res, { message: 'Health check removed' }); }, 'health-check-remove')); // Get open incidents - router.get('/health-checks/incidents', ctx.asyncHandler(async (req, res) => { - const incidents = ctx.healthChecker.getOpenIncidents(); + router.get('/health-checks/incidents', asyncHandler(async (req, res) => { + const incidents = healthChecker.getOpenIncidents(); const paginationParams = parsePaginationParams(req.query); const result = paginate(incidents, paginationParams); - res.json({ success: true, incidents: result.data, ...(result.pagination && { pagination: result.pagination }) }); + success(res, { incidents: result.data, ...(result.pagination && { pagination: result.pagination }) }); }, 'health-check-incidents')); // Get incident history - router.get('/health-checks/incidents/history', ctx.asyncHandler(async (req, res) => { + router.get('/health-checks/incidents/history', asyncHandler(async (req, res) => { const paginationParams = parsePaginationParams(req.query); // When paginating, fetch all history so pagination can slice correctly const fetchLimit = paginationParams ? Number.MAX_SAFE_INTEGER : (parseInt(req.query.limit) || 50); - const history = ctx.healthChecker.getIncidentHistory(fetchLimit); + const history = healthChecker.getIncidentHistory(fetchLimit); const result = paginate(history, paginationParams); - res.json({ success: true, history: result.data, ...(result.pagination && { pagination: result.pagination }) }); + success(res, { history: result.data, ...(result.pagination && { pagination: result.pagination }) }); }, 'health-check-incidents-history')); return router; diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index 67bc93b..17fc33d 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -1207,7 +1207,16 @@ apiRouter.use(serviceRoutes({ caddy: ctx.caddy, dns: ctx.dns })); -apiRouter.use(healthRoutes(ctx)); +apiRouter.use(healthRoutes({ + fetchT: ctx.fetchT, + SERVICES_FILE: ctx.SERVICES_FILE, + servicesStateManager: ctx.servicesStateManager, + siteConfig: ctx.siteConfig, + buildServiceUrl: ctx.buildServiceUrl, + asyncHandler: ctx.asyncHandler, + logError: ctx.logError, + healthChecker: ctx.healthChecker +})); apiRouter.use(monitoringRoutes(ctx)); apiRouter.use(updatesRoutes(ctx)); apiRouter.use('/tailscale', tailscaleRoutes(ctx));