diff --git a/.eslintrc.js b/.eslintrc.js index e460c04..860f848 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,6 +7,7 @@ module.exports = { 'prettier/prettier': 'warn', '@typescript-eslint/explicit-module-boundary-types': 'error', 'prefer-arrow-callback': 'error', + 'sort-imports': 'warn', }, overrides: [ { diff --git a/TODOs b/TODOs index 1dd31cb..a41a68a 100644 --- a/TODOs +++ b/TODOs @@ -1 +1 @@ -- Extract HttpServer +- Prefix via configuration \ No newline at end of file diff --git a/src/discovery/api.ts b/src/adapters/discovery/api.ts similarity index 77% rename from src/discovery/api.ts rename to src/adapters/discovery/api.ts index bfc41ef..e49e25d 100644 --- a/src/discovery/api.ts +++ b/src/adapters/discovery/api.ts @@ -1,9 +1,9 @@ -import type { Device } from '../boundaries' +import type { Device } from '../../boundaries' import { Logger } from 'homebridge' type Pin = string -export type HapConfig = { +export interface HapConfig { pin: Pin refreshInterval: number discoveryTimeout: number diff --git a/src/discovery/hap_node_js_client.ts b/src/adapters/discovery/hap_node_js_client.ts similarity index 96% rename from src/discovery/hap_node_js_client.ts rename to src/adapters/discovery/hap_node_js_client.ts index f211955..2ba7232 100644 --- a/src/discovery/hap_node_js_client.ts +++ b/src/adapters/discovery/hap_node_js_client.ts @@ -1,13 +1,13 @@ import type { HapDiscover } from './api' import { HAPNodeJSClient } from 'hap-node-client' -import { Device, DeviceBoundary } from '../boundaries' +import { Device, DeviceBoundary } from '../../boundaries' import { Array, Unknown } from 'runtypes' import { Logger } from 'homebridge' const MaybeDevices = Array(Unknown) -type HapConfig = { +interface HapConfig { debug: boolean refresh: number timeout: number diff --git a/src/adapters/http/api.ts b/src/adapters/http/api.ts new file mode 100644 index 0000000..9bdb2fe --- /dev/null +++ b/src/adapters/http/api.ts @@ -0,0 +1,13 @@ +import { HttpServer } from '../../http' + +export interface HttpResponse { + statusCode?: number + body?: string + headers?: Record +} + +export interface HttpServerController { + shutdown(): void +} + +export type HttpAdapter = (config: HttpServer) => Promise diff --git a/src/adapters/http/fastify.ts b/src/adapters/http/fastify.ts new file mode 100644 index 0000000..725a3e4 --- /dev/null +++ b/src/adapters/http/fastify.ts @@ -0,0 +1,52 @@ +import Fastify, { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from 'fastify' +import { HttpAdapter, HttpResponse } from './api' +import { HttpServer } from '../../http' + +function adaptResponseToReply(response: HttpResponse, reply: FastifyReply): void { + if (response.statusCode) { + void reply.code(response.statusCode) + } + if (response.body) { + void reply.send(response.body) + } + + if (response.headers) { + void reply.headers(response.headers) + } +} + +export const serve: HttpAdapter = async (server: HttpServer) => { + const fastify = Fastify({ + logger: server.debug, + }) + + fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply, next: HookHandlerDoneFunction) => { + const response = server.onRequest() + + if (response) { + adaptResponseToReply(response, reply) + } + + next() + }) + + fastify.setErrorHandler(async (error, request: FastifyRequest, reply: FastifyReply) => { + adaptResponseToReply(server.onError(error), reply) + }) + + fastify.setNotFoundHandler(async (request: FastifyRequest, reply: FastifyReply) => { + adaptResponseToReply(server.onNotFound(), reply) + }) + + fastify.get('/metrics', async (request: FastifyRequest, reply: FastifyReply) => { + adaptResponseToReply(server.onMetrics(), reply) + }) + + await fastify.listen({ port: server.port, host: '::' }) + + return { + shutdown() { + void fastify.close() + }, + } +} diff --git a/src/boundaries.ts b/src/boundaries.ts index 9b8f2f8..6f7668a 100644 --- a/src/boundaries.ts +++ b/src/boundaries.ts @@ -1,4 +1,4 @@ -import { Number, String, Literal, Array, Record, Union, Static, Optional, Intersect } from 'runtypes' +import { Array, Intersect, Literal, Number, Optional, Record, Static, String, Union } from 'runtypes' export const NUMBER_TYPES = ['float', 'int', 'uint8', 'uint16', 'uint32', 'uint64'] as const const NumberTypesLiterals = NUMBER_TYPES.map(Literal) diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..46dfd06 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,14 @@ +import { HttpResponse } from './adapters/http/api' +import { Metric } from './metrics' +import { Logger } from 'homebridge' + +export interface HttpServer { + port: number + debug: boolean + log: Logger + onRequest(): HttpResponse | undefined + onMetrics(): HttpResponse + onNotFound(): HttpResponse + onError(error: unknown): HttpResponse + updateMetrics(metrics: Metric[]): void +} diff --git a/src/http/api.ts b/src/http/api.ts deleted file mode 100644 index ea7efe9..0000000 --- a/src/http/api.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Logger } from 'homebridge' - -export type HttpResponse = { - statusCode: number - body: string - headers: Record -} - -export type HttpServerConfig = { - port: number - requestInterceptor: () => HttpResponse | undefined - metricsController: () => HttpResponse - notFoundController: () => HttpResponse - errorController: (error: unknown) => HttpResponse - logger: Logger -} - -export type HttpServerController = { - shutdown(): void -} - -export type HttpServer = (config: HttpServerConfig) => Promise diff --git a/src/http/fastify.ts b/src/http/fastify.ts deleted file mode 100644 index 76bd5bc..0000000 --- a/src/http/fastify.ts +++ /dev/null @@ -1,82 +0,0 @@ -import Fastify, { FastifyBaseLogger, FastifyReply, FastifyRequest, HookHandlerDoneFunction } from 'fastify' -import { HttpResponse, HttpServer } from './api' -import { Logger } from 'homebridge' -import { Bindings, ChildLoggerOptions } from 'fastify/types/logger' -import { LogFn } from 'pino' - -/*class PinoLoggerBridge implements FastifyBaseLogger { - - constructor(private readonly logger: Logger) { - } - - child(bindings: Bindings, options: ChildLoggerOptions | undefined): FastifyBaseLogger { - return this - } - - debug(m: string|unknown, ...args: unknown[]) { - this.logger.debug(typeof m === 'string' ? m : JSON.stringify(m), ...args) - } - - error: LogFn; - fatal: pino.LogFn; - info: pino.LogFn; - level: pino.LevelWithSilent | string; - silent: pino.LogFn; - trace: pino.LogFn; - warn: pino.LogFn; -}*/ - -function adaptResponseToReply(response: HttpResponse, reply: FastifyReply): void { - if (response.statusCode) { - reply.code(response.statusCode) - } - if (response.body) { - reply.send(response.body) - } - - if (response.headers) { - reply.headers(response.headers) - } -} - -export const serve: HttpServer = async ({ - port, - requestInterceptor, - metricsController, - notFoundController, - errorController, -}) => { - const fastify = Fastify({ - // logger - }) - - fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply, next: HookHandlerDoneFunction) => { - const response = requestInterceptor() - - if (response) { - adaptResponseToReply(response, reply) - } - - next() - }) - - fastify.setErrorHandler(async (error, request: FastifyRequest, reply: FastifyReply) => { - adaptResponseToReply(errorController(error), reply) - }) - - fastify.setNotFoundHandler(async (request: FastifyRequest, reply: FastifyReply) => { - adaptResponseToReply(notFoundController(), reply) - }) - - fastify.get('/metrics', async (request: FastifyRequest, reply: FastifyReply) => { - adaptResponseToReply(metricsController(), reply) - }) - - await fastify.listen({ port, host: '::' }) - - return { - shutdown() { - fastify.close() - }, - } -} diff --git a/src/metrics.ts b/src/metrics.ts index 43a5b9e..661d208 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -1,4 +1,4 @@ -import type { Device, Accessory, NumberTypes, Service } from './boundaries' +import type { Accessory, Device, Service } from './boundaries' import { isType } from './std' import { NUMBER_TYPES } from './boundaries' diff --git a/src/platform.ts b/src/platform.ts index dde27e1..707848b 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -1,15 +1,14 @@ -import { API, Logger, PlatformConfig, IndependentPlatformPlugin } from 'homebridge' +import { API, IndependentPlatformPlugin, Logger, PlatformConfig } from 'homebridge' -import { Metric, aggregate } from './metrics' -import { discover } from './discovery/hap_node_js_client' -import { serve } from './http/fastify' -import { HttpServerController } from './http/api' -import { MetricsRenderer } from './prometheus' +import { aggregate } from './metrics' +import { discover } from './adapters/discovery/hap_node_js_client' +import { serve } from './adapters/http/fastify' +import { HttpServerController } from './adapters/http/api' +import { PrometheusServer } from './prometheus' export class PrometheusExporterPlatform implements IndependentPlatformPlugin { - private metrics: Metric[] = [] - private metricsDiscovered = false - private http: HttpServerController | undefined = undefined + private readonly httpServer: PrometheusServer + private httpServerController: HttpServerController | undefined = undefined constructor(public readonly log: Logger, public readonly config: PlatformConfig, public readonly api: API) { this.log.debug('Initializing platform %s', this.config.platform) @@ -18,12 +17,22 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin { this.api.on('shutdown', () => { this.log.debug('Shutting down %s', this.config.platform) - if (this.http) { - this.http.shutdown() + if (this.httpServerController) { + this.httpServerController.shutdown() } }) - this.startHttpServer() + this.log.debug('Starting probe HTTP server on port %d', this.config.probe_port) + + this.httpServer = new PrometheusServer(this.config.probe_port, this.log, this.config.debug) + serve(this.httpServer) + .then((httpServerController) => { + this.log.debug('HTTP server started on port %d', this.config.probe_port) + this.httpServerController = httpServerController + }) + .catch((e) => { + this.log.error('Failed to start probe HTTP server on port %d: %o', this.config.probe_port, e) + }) this.api.on('didFinishLaunching', () => { this.log.debug('Finished launching %s', this.config.platform) @@ -45,55 +54,6 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin { this.log.debug('Configuration materialized: %o', this.config) } - private startHttpServer(): void { - this.log.debug('Starting probe HTTP server on port %d', this.config.probe_port) - - const contentTypeHeader = { 'Content-Type': 'text/plain; charset=UTF-8' } - serve({ - port: this.config.probe_port, - logger: this.log, - requestInterceptor: () => { - if (!this.metricsDiscovered) { - return { - statusCode: 503, - headers: { ...contentTypeHeader, 'Retry-After': '10' }, - body: 'Discovery pending', - } - } - }, - metricsController: () => { - const renderer = new MetricsRenderer('homebridge') - const metrics = this.metrics.map((metric) => renderer.render(metric)).join('\n') - - return { - statusCode: 200, - headers: contentTypeHeader, - body: metrics, - } - }, - notFoundController: () => ({ - statusCode: 404, - headers: contentTypeHeader, - body: 'Not found. Try /metrics', - }), - errorController: (e) => { - this.log.error('HTTP request error: %o', e) - return { - statusCode: 500, - headers: contentTypeHeader, - body: 'Server error', - } - }, - }) - .then((http) => { - this.log.debug('HTTP server started on port %d', this.config.probe_port) - this.http = http - }) - .catch((e) => { - this.log.error('Failed to start probe HTTP server on port %d: %o', this.config.probe_port, e) - }) - } - private startHapDiscovery(): void { this.log.debug('Starting HAP discovery') discover({ @@ -105,9 +65,9 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin { debug: this.config.debug, }) .then((devices) => { - this.metrics = aggregate(devices, new Date()) - this.metricsDiscovered = true - this.log.debug('HAP discovery completed, %d metrics discovered', this.metrics.length) + const metrics = aggregate(devices, new Date()) + this.httpServer.updateMetrics(metrics) + this.log.debug('HAP discovery completed, %d metrics discovered', metrics.length) this.startHapDiscovery() }) .catch((e) => { diff --git a/src/prometheus.ts b/src/prometheus.ts index 5ecf63c..d9e618a 100644 --- a/src/prometheus.ts +++ b/src/prometheus.ts @@ -1,4 +1,7 @@ import { Metric } from './metrics' +import { Logger } from 'homebridge' +import { HttpResponse } from './adapters/http/api' +import { HttpServer } from './http' function escapeString(str: string) { return str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n') @@ -68,3 +71,55 @@ export class MetricsRenderer { return sanitizePrometheusMetricName(this.prefix.replace(/_+$/, '') + '_' + name) } } + +const contentTypeHeader = { 'Content-Type': 'text/plain; charset=UTF-8' } + +export class PrometheusServer implements HttpServer { + private metricsInitialized = false + private metrics: Metric[] = [] + + constructor(public readonly port: number, public readonly log: Logger, public readonly debug: boolean) {} + + onRequest(): HttpResponse | undefined { + if (!this.metricsInitialized) { + return { + statusCode: 503, + headers: { ...contentTypeHeader, 'Retry-After': '10' }, + body: 'Metrics discovery pending', + } + } + } + + onMetrics(): HttpResponse { + const renderer = new MetricsRenderer('homebridge') + const metrics = this.metrics.map((metric) => renderer.render(metric)).join('\n') + + return { + statusCode: 200, + headers: contentTypeHeader, + body: metrics, + } + } + + onNotFound(): HttpResponse { + return { + statusCode: 404, + headers: contentTypeHeader, + body: 'Not found. Try /metrics', + } + } + + onError(error: unknown): HttpResponse { + this.log.error('HTTP request error: %o', error) + return { + statusCode: 500, + headers: contentTypeHeader, + body: 'Server error', + } + } + + updateMetrics(metrics: Metric[]): void { + this.metrics = metrics + this.metricsInitialized = true + } +} diff --git a/src/std.ts b/src/std.ts index 334acb1..6162dd7 100644 --- a/src/std.ts +++ b/src/std.ts @@ -1,5 +1,5 @@ type Types = 'string' | 'number' | 'boolean' | 'object' -type TypeMap = { +interface TypeMap { string: string number: number boolean: boolean diff --git a/tsconfig.json b/tsconfig.json index 951ee88..7b9892e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "resolveJsonModule": true }, "include": ["src/"], - "exclude": ["**/*.test.ts", "node_modules/hap-nodejs/**/**"] + "exclude": [] }