Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 35 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,13 +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 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';
Expand All @@ -30,21 +29,38 @@ 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 container = new Pimple<ServiceMap>();
const SSL_KEY_FILE = process.env.SSL_KEY;
const SSL_CERT_FILE = process.env.SSL_CERT;

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 httpServer = http.createServer(app);
const container = new Pimple<ServiceMap>();

container
.register(new LoggerServiceProvider())
.register(new HealthServiceProvider())
.register(new ServerServiceProvider(httpServer))
.register(new ServerServiceProvider(app, corsOptions, sslConfig))
.register(new SettingsServiceProvider())
.register(new DeviceServiceProvider())
.register(new ControllerServiceProvider())
Expand Down Expand Up @@ -77,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())
Expand Down Expand Up @@ -164,11 +172,20 @@ setIntervalAsync(async () => {
onError: (err) => logError(logger, 'Health metrics broadcast failed', err),
});

httpServer.listen(APP_PORT, () => {
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 port ${APP_PORT}!`);
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} (ssl)`);
});
}

process.on('uncaughtException', (error: Error) => {
logger.error('Asynchronous error caught', error);
});
4 changes: 4 additions & 0 deletions src/serviceMap.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -56,6 +58,8 @@ type ServiceMap = {
'logger.default': Logger,

/* serverServiceProvider */
'server.http': http.Server,
'server.https': https.Server | undefined,
'server.websocket': Server,

/* deviceServiceProvider */
Expand Down
57 changes: 47 additions & 10 deletions src/serviceProvider/serverServiceProvider.ts
Original file line number Diff line number Diff line change
@@ -1,24 +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<ServiceMap>
{
private readonly httpServer: http.Server;
private readonly app: express.Application;
private readonly corsOptions: CorsOptions;
private readonly sslConfig?: SslConfig;

public constructor(server: http.Server) {
this.httpServer = server;
public constructor(app: express.Application, corsOptions: CorsOptions, sslConfig?: SslConfig) {
this.app = app;
this.corsOptions = corsOptions;
this.sslConfig = sslConfig;
}

public register(container: Pimple<ServiceMap>): void {
container.set('server.websocket', () => {
return new Server(this.httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST', 'PATCH']
}
});
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;
}

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;
}
});
}
}
12 changes: 10 additions & 2 deletions src/util/async.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
export const sleep = (ms: number): Promise<void> => new Promise<void>(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 = <TArgs extends any[]>(
callback: (...args: TArgs) => void,
delay?: number,
Expand Down Expand Up @@ -41,10 +48,11 @@ export const setIntervalAsync = <TArgs extends any[]>(
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;

if (undefined !== options.timeoutMs) {
const timeoutMs = options.timeoutMs;
promises.push(new Promise<void>((_, reject) =>
timeoutHandle = setTimeout(() => {
reject(new Error(`Interval function timed out (>${options.timeoutMs}ms)`));
}, options.timeoutMs))
reject(new IntervalTimeoutError(timeoutMs));
}, timeoutMs))
);
}

Expand Down
6 changes: 6 additions & 0 deletions src/util/error.ts
Original file line number Diff line number Diff line change
@@ -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);
};
Loading