From b0fab33b14238cb45c790448953da697d3ab16a8 Mon Sep 17 00:00:00 2001 From: HRS Date: Thu, 4 Jun 2026 22:33:26 +0200 Subject: [PATCH 1/3] feat: add parallel HTTPS server support When SSL_KEY and SSL_CERT env vars point to valid certificate files, an HTTPS server starts alongside the always-on HTTP server on HTTPS_PORT (default 1338). Socket.IO attaches to both servers via ServerServiceProvider. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 3 +++ src/index.ts | 24 ++++++++++++++++---- src/serviceProvider/serverServiceProvider.ts | 14 ++++++++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index a66a947a..d47b3ac6 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,6 @@ PORT=1337 LOG_LEVEL=debug ALLOWED_ORIGINS= APP_VERSION=dev +# SSL_KEY=/path/to/server.key +# SSL_CERT=/path/to/server.crt +# HTTPS_PORT=1338 diff --git a/src/index.ts b/src/index.ts index 9e8827d2..6af66611 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,8 @@ import DeviceServiceProvider from './serviceProvider/deviceServiceProvider.js'; import SettingsServiceProvider from './serviceProvider/settingsServiceProvider.js'; import SchemaValidationServiceProvider from './serviceProvider/schemaValidationServiceProvider.js'; import http from 'http' +import https from 'https' +import fs from 'fs' import SocketServiceProvider from './serviceProvider/socketServiceProvider.js'; import { DeviceUpdateData } from './socket/types.js'; import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js'; @@ -30,21 +32,28 @@ import { logError } from './util/error.js'; import { setIntervalAsync } from './util/async.js'; import HealthServiceProvider from './serviceProvider/healthServiceProvider.js'; -const APP_PORT = process.env.PORT ?? '1337'; +const APP_HTTP_PORT = process.env.PORT ?? '1337'; +const APP_HTTPS_PORT = process.env.HTTPS_PORT ?? '1338'; const ALLOWED_ORIGINS = undefined !== process.env.ALLOWED_ORIGINS && null !== process.env.ALLOWED_ORIGINS.length ? process.env.ALLOWED_ORIGINS.split(',') .map(origin => origin.trim()) .filter(origin => origin.length > 0) : []; +const SSL_KEY_FILE = process.env.SSL_KEY; +const SSL_CERT_FILE = process.env.SSL_CERT; + const container = new Pimple(); const app = express(); const httpServer = http.createServer(app); +const httpsServer = SSL_KEY_FILE !== undefined && SSL_CERT_FILE !== undefined + ? https.createServer({ key: fs.readFileSync(SSL_KEY_FILE), cert: fs.readFileSync(SSL_CERT_FILE) }, app) + : undefined; container .register(new LoggerServiceProvider()) .register(new HealthServiceProvider()) - .register(new ServerServiceProvider(httpServer)) + .register(new ServerServiceProvider(httpServer, httpsServer)) .register(new SettingsServiceProvider()) .register(new DeviceServiceProvider()) .register(new ControllerServiceProvider()) @@ -58,6 +67,7 @@ container const logger = container.get('logger.default'); const io = container.get('server.websocket'); + const deviceManager = container.get('device.manager'); const serialPortObserver = container.get('device.observer.serial'); const settingsManager = container.get('settings.manager'); @@ -164,11 +174,17 @@ setIntervalAsync(async () => { onError: (err) => logError(logger, 'Health metrics broadcast failed', err), }); -httpServer.listen(APP_PORT, () => { +httpServer.listen(APP_HTTP_PORT, () => { logger.info(`Node version: ${process.version}`); - logger.info(`SlvCtrl+ server listening on port ${APP_PORT}!`); + logger.info(`SlvCtrl+ server listening on http://localhost:${APP_HTTP_PORT} (http)`); }); +if (httpsServer !== undefined) { + httpsServer.listen(APP_HTTPS_PORT, () => { + logger.info(`SlvCtrl+ server listening on https://localhost:${APP_HTTPS_PORT} (https)`); + }); +} + process.on('uncaughtException', (error: Error) => { logger.error('Asynchronous error caught', error); }); diff --git a/src/serviceProvider/serverServiceProvider.ts b/src/serviceProvider/serverServiceProvider.ts index 97061133..48616941 100644 --- a/src/serviceProvider/serverServiceProvider.ts +++ b/src/serviceProvider/serverServiceProvider.ts @@ -6,19 +6,29 @@ import ServiceMap from '../serviceMap.js'; export default class ServerServiceProvider implements ServiceProvider { private readonly httpServer: http.Server; + private readonly httpsServer?: http.Server; - public constructor(server: http.Server) { + public constructor(server: http.Server, httpsServer?: http.Server) { this.httpServer = server; + this.httpsServer = httpsServer; } public register(container: Pimple): void { container.set('server.websocket', () => { - return new Server(this.httpServer, { + const socketIoServer = new Server(undefined, { cors: { origin: '*', methods: ['GET', 'POST', 'PATCH'] } }); + + socketIoServer.attach(this.httpServer); + + if (this.httpsServer) { + socketIoServer.attach(this.httpsServer); + } + + return socketIoServer; }); } } From 47c4376f9958eb5857299fd6c088881c41c1b286 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 6 Jun 2026 08:23:08 +0200 Subject: [PATCH 2/3] Log interval timeouts only as warnings --- src/index.ts | 5 ++--- src/util/async.ts | 12 ++++++++++-- src/util/error.ts | 6 ++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6af66611..377d82d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,7 +67,6 @@ container const logger = container.get('logger.default'); const io = container.get('server.websocket'); - const deviceManager = container.get('device.manager'); const serialPortObserver = container.get('device.observer.serial'); const settingsManager = container.get('settings.manager'); @@ -176,12 +175,12 @@ setIntervalAsync(async () => { httpServer.listen(APP_HTTP_PORT, () => { logger.info(`Node version: ${process.version}`); - logger.info(`SlvCtrl+ server listening on http://localhost:${APP_HTTP_PORT} (http)`); + logger.info(`SlvCtrl+ server listening on http://localhost:${APP_HTTP_PORT}`); }); if (httpsServer !== undefined) { httpsServer.listen(APP_HTTPS_PORT, () => { - logger.info(`SlvCtrl+ server listening on https://localhost:${APP_HTTPS_PORT} (https)`); + logger.info(`SlvCtrl+ server listening on https://localhost:${APP_HTTPS_PORT} (ssl)`); }); } diff --git a/src/util/async.ts b/src/util/async.ts index 7d8b9449..030f80e5 100644 --- a/src/util/async.ts +++ b/src/util/async.ts @@ -1,5 +1,12 @@ export const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)); +export class IntervalTimeoutError extends Error { + public constructor(timeoutMs: number) { + super(`Interval function timed out (>${timeoutMs}ms)`); + this.name = 'IntervalTimeoutError'; + } +} + export const setImmediateInterval = ( callback: (...args: TArgs) => void, delay?: number, @@ -41,10 +48,11 @@ export const setIntervalAsync = ( let timeoutHandle: ReturnType | undefined; if (undefined !== options.timeoutMs) { + const timeoutMs = options.timeoutMs; promises.push(new Promise((_, reject) => timeoutHandle = setTimeout(() => { - reject(new Error(`Interval function timed out (>${options.timeoutMs}ms)`)); - }, options.timeoutMs)) + reject(new IntervalTimeoutError(timeoutMs)); + }, timeoutMs)) ); } diff --git a/src/util/error.ts b/src/util/error.ts index c00bd27e..3867aadb 100644 --- a/src/util/error.ts +++ b/src/util/error.ts @@ -1,7 +1,13 @@ import BaseError from 'modern-errors'; import Logger from '../logging/Logger.js'; +import { IntervalTimeoutError } from './async.js'; export const logError = (logger: Logger, message: string, error: unknown): void => { + if (error instanceof IntervalTimeoutError) { + logger.warn(`${message}: ${error.message}`); + return; + } + const baseError = error instanceof Error ? error : BaseError.normalize(error); logger.error(`${message}: ${baseError.message}`, baseError); }; From 668451759601f8af4cf041c3be3904804ef88923 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 6 Jun 2026 09:16:56 +0200 Subject: [PATCH 3/3] Rearrange ServerServiceProvider --- src/index.ts | 42 ++++++------- src/serviceMap.ts | 4 ++ src/serviceProvider/serverServiceProvider.ts | 63 ++++++++++++++------ 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/src/index.ts b/src/index.ts index 377d82d8..0b86f7f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import 'dotenv/config'; import 'reflect-metadata'; -import cors from 'cors'; +import cors, { CorsOptions } from 'cors'; import contentTypeMiddleware from './middleware/contentTypeMiddleware.js'; import express from 'express'; import { Pimple } from '@timesplinter/pimple'; @@ -11,15 +11,12 @@ import FactoryServiceProvider from './serviceProvider/factoryServiceProvider.js' import DeviceServiceProvider from './serviceProvider/deviceServiceProvider.js'; import SettingsServiceProvider from './serviceProvider/settingsServiceProvider.js'; import SchemaValidationServiceProvider from './serviceProvider/schemaValidationServiceProvider.js'; -import http from 'http' -import https from 'https' -import fs from 'fs' import SocketServiceProvider from './serviceProvider/socketServiceProvider.js'; import { DeviceUpdateData } from './socket/types.js'; import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js'; import Device from './device/device.js'; import WebSocketEvent from './device/webSocketEvent.js'; -import ServerServiceProvider from './serviceProvider/serverServiceProvider.js'; +import ServerServiceProvider, { SslConfig } from './serviceProvider/serverServiceProvider.js'; import AutomationEventType from './automation/automationEventType.js'; import LoggerServiceProvider from './serviceProvider/loggerServiceProvider.js'; import DeviceDiscriminator from './serialization/discriminator/deviceDiscriminator.js'; @@ -43,17 +40,27 @@ const ALLOWED_ORIGINS = undefined !== process.env.ALLOWED_ORIGINS && null !== pr const SSL_KEY_FILE = process.env.SSL_KEY; const SSL_CERT_FILE = process.env.SSL_CERT; -const container = new Pimple(); -const app = express(); -const httpServer = http.createServer(app); -const httpsServer = SSL_KEY_FILE !== undefined && SSL_CERT_FILE !== undefined - ? https.createServer({ key: fs.readFileSync(SSL_KEY_FILE), cert: fs.readFileSync(SSL_CERT_FILE) }, app) +const sslConfig: SslConfig | undefined = SSL_KEY_FILE !== undefined && SSL_CERT_FILE !== undefined + ? { keyFile: SSL_KEY_FILE, certFile: SSL_CERT_FILE } : undefined; +const corsOptions: CorsOptions = { + origin: (origin, callback) => { + if (undefined === origin || ALLOWED_ORIGINS.length === 0) { + return callback(null, true); + } + + return callback(null, ALLOWED_ORIGINS.includes(origin)); + }, +}; + +const app = express(); +const container = new Pimple(); + container .register(new LoggerServiceProvider()) .register(new HealthServiceProvider()) - .register(new ServerServiceProvider(httpServer, httpsServer)) + .register(new ServerServiceProvider(app, corsOptions, sslConfig)) .register(new SettingsServiceProvider()) .register(new DeviceServiceProvider()) .register(new ControllerServiceProvider()) @@ -86,15 +93,7 @@ app next(); }) - .use(cors({ - origin: (origin, callback) => { - if (undefined === origin || ALLOWED_ORIGINS.length === 0) { - return callback(null, true); - } - - return callback(null, ALLOWED_ORIGINS.includes(origin)); - }, - })) + .use(cors(corsOptions)) .use(contentTypeMiddleware) .use(express.json()) .use(express.text()) @@ -173,6 +172,9 @@ setIntervalAsync(async () => { onError: (err) => logError(logger, 'Health metrics broadcast failed', err), }); +const httpServer = container.get('server.http'); +const httpsServer = container.get('server.https'); + httpServer.listen(APP_HTTP_PORT, () => { logger.info(`Node version: ${process.version}`); logger.info(`SlvCtrl+ server listening on http://localhost:${APP_HTTP_PORT}`); diff --git a/src/serviceMap.ts b/src/serviceMap.ts index 803c84d2..b1e38a2a 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -1,4 +1,6 @@ import { Ajv } from 'ajv'; +import type http from 'http'; +import type https from 'https'; import { Server } from 'socket.io'; import ClassToPlainSerializer from './serialization/classToPlainSerializer.js'; import PlainToClassSerializer from './serialization/plainToClassSerializer.js'; @@ -56,6 +58,8 @@ type ServiceMap = { 'logger.default': Logger, /* serverServiceProvider */ + 'server.http': http.Server, + 'server.https': https.Server | undefined, 'server.websocket': Server, /* deviceServiceProvider */ diff --git a/src/serviceProvider/serverServiceProvider.ts b/src/serviceProvider/serverServiceProvider.ts index 48616941..33275e29 100644 --- a/src/serviceProvider/serverServiceProvider.ts +++ b/src/serviceProvider/serverServiceProvider.ts @@ -1,34 +1,61 @@ +import BaseError from 'modern-errors'; import { Pimple, ServiceProvider } from '@timesplinter/pimple'; import http from 'http' +import https from 'https' +import fs from 'fs' import { Server } from 'socket.io'; import ServiceMap from '../serviceMap.js'; +import { CorsOptions } from 'cors'; +import express from 'express'; + +export type SslConfig = { keyFile: string, certFile: string }; export default class ServerServiceProvider implements ServiceProvider { - private readonly httpServer: http.Server; - private readonly httpsServer?: http.Server; + private readonly app: express.Application; + private readonly corsOptions: CorsOptions; + private readonly sslConfig?: SslConfig; - public constructor(server: http.Server, httpsServer?: http.Server) { - this.httpServer = server; - this.httpsServer = httpsServer; + public constructor(app: express.Application, corsOptions: CorsOptions, sslConfig?: SslConfig) { + this.app = app; + this.corsOptions = corsOptions; + this.sslConfig = sslConfig; } public register(container: Pimple): void { - container.set('server.websocket', () => { - const socketIoServer = new Server(undefined, { - cors: { - origin: '*', - methods: ['GET', 'POST', 'PATCH'] - } - }); - - socketIoServer.attach(this.httpServer); - - if (this.httpsServer) { - socketIoServer.attach(this.httpsServer); + container.set('server.websocket', () => new Server(undefined, { + cors: this.corsOptions + })); + + container.set('server.http', () => { + const server = http.createServer(this.app); + + container.get('server.websocket').attach(server); + + return server; + }); + + container.set('server.https', () => { + if (this.sslConfig === undefined) { + return undefined; } - return socketIoServer; + const logger = container.get('logger.default'); + + try { + const key = fs.readFileSync(this.sslConfig.keyFile); + const cert = fs.readFileSync(this.sslConfig.certFile); + const server = https.createServer({ key, cert }, this.app); + + container.get('server.websocket').attach(server); + + return server; + } catch (err) { + const baseError = BaseError.normalize(err); + logger.error(`Failed to load SSL certificates: ${baseError.message}`); + logger.warn('HTTPS server will not be started'); + return undefined; + } }); } }