Refactor frontend to TypeScript.

This commit is contained in:
Koen Kanters 2021-09-22 20:01:46 +02:00
parent 57b4d678d5
commit 6888394600
6 changed files with 36037 additions and 1282 deletions

View File

@ -1,130 +0,0 @@
const http = require('http');
const serveStatic = require('serve-static');
const finalhandler = require('finalhandler');
const Extension = require('./extension');
const logger = require('../util/logger');
const frontend = require('zigbee2mqtt-frontend');
const WebSocket = require('ws');
const url = require('url');
const settings = require('../util/settings');
const utils = require('../util/utils');
const stringify = require('json-stable-stringify-without-jsonify');
/**
* This extension servers the frontend
*/
class Frontend extends Extension {
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
super(zigbee, mqtt, state, publishEntityState, eventBus);
this.onRequest = this.onRequest.bind(this);
this.onUpgrade = this.onUpgrade.bind(this);
this.mqttBaseTopic = settings.get().mqtt.base_topic;
this.onMQTTPublishMessage = this.onMQTTPublishMessage.bind(this);
this.eventBus.onMQTTMessagePublished(this.constructor.name, this.onMQTTPublishMessage);
this.onWebSocketConnection = this.onWebSocketConnection.bind(this);
this.server = http.createServer(this.onRequest);
this.server.on('upgrade', this.onUpgrade);
this.host = settings.get().frontend.host || '0.0.0.0';
this.port = settings.get().frontend.port || 8080;
this.authToken = settings.get().frontend.auth_token || false;
this.retainedMessages = new Map();
/* istanbul ignore next */
const options = {setHeaders: (res, path) => {
if (path.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-store');
}
}};
this.fileServer = serveStatic(frontend.getPath(), options);
this.wss = new WebSocket.Server({noServer: true});
this.wss.on('connection', this.onWebSocketConnection);
}
onZigbeeStarted() {
this.server.listen(this.port, this.host);
logger.info(`Started frontend on port ${this.host}:${this.port}`);
}
async stop() {
for (const client of this.wss.clients) {
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
client.terminate();
}
this.wss.close();
return new Promise((resolve) => {
this.server.close(resolve);
});
}
onRequest(request, response) {
this.fileServer(request, response, finalhandler(request, response));
}
authenticate(request, cb) {
const {query} = url.parse(request.url, true);
cb(!this.authToken || this.authToken === query.token);
}
onUpgrade(request, socket, head) {
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.authenticate(request, (isAuthentificated) => {
if (isAuthentificated) {
this.wss.emit('connection', ws, request);
} else {
ws.close(4401, 'Unauthorized');
}
});
});
}
onWebSocketConnection(ws) {
ws.on('message', (data, isBinary) => {
if (!isBinary && data) {
const message = data.toString();
const {topic, payload} = utils.parseJSON(message, message);
this.mqtt.onMessage(`${this.mqttBaseTopic}/${topic}`, stringify(payload));
}
});
for (const [key, value] of this.retainedMessages) {
ws.send(stringify({topic: key, payload: value}));
}
for (const device of this.zigbee.getClientsLegacy()) {
let payload = {};
const resolvedEntity = this.zigbee.resolveEntityLegacy(device);
if (this.state.exists(device.ieeeAddr)) {
payload = {...payload, ...this.state.get(device.ieeeAddr)};
}
const lastSeen = settings.get().advanced.last_seen;
/* istanbul ignore if */
if (lastSeen !== 'disable') {
payload.last_seen = utils.formatDate(resolvedEntity.device.lastSeen, lastSeen);
}
if (resolvedEntity.device.linkquality !== undefined) {
payload.linkquality = resolvedEntity.device.linkquality;
}
ws.send(stringify({topic: resolvedEntity.name, payload}));
}
}
onMQTTPublishMessage(data) {
let {topic, payload, options} = data;
if (topic.startsWith(`${this.mqttBaseTopic}/`)) {
// Send topic without base_topic
topic = topic.substring(this.mqttBaseTopic.length + 1);
payload = utils.parseJSON(payload, payload);
if (options.retain) {
this.retainedMessages.set(topic, payload);
}
for (const client of this.wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(stringify({topic, payload}));
}
}
}
}
}
module.exports = Frontend;

143
lib/extension/frontend.ts Normal file
View File

@ -0,0 +1,143 @@
import http from 'http';
import serveStatic from 'serve-static';
import finalhandler from 'finalhandler';
import logger from '../util/logger';
// @ts-ignore
import frontend from 'zigbee2mqtt-frontend';
import WebSocket from 'ws';
import net from 'net';
import url from 'url';
import * as settings from '../util/settings';
import * as utils from '../util/utils';
// @ts-ignore
import stringify from 'json-stable-stringify-without-jsonify';
import ExtensionTS from './extensionts';
import bind from 'bind-decorator';
/**
* This extension servers the frontend
*/
class Frontend extends ExtensionTS {
private mqttBaseTopic = settings.get().mqtt.base_topic;
private host = settings.get().frontend.host || '0.0.0.0';
private port = settings.get().frontend.port || 8080;
private authToken = settings.get().frontend.auth_token || false;
private retainedMessages = new Map();
private server: http.Server;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private fileServer: serveStatic.RequestHandler<any>;
private wss: WebSocket.Server = null;
constructor(zigbee: Zigbee, mqtt: MQTT, state: TempState, publishEntityState: PublishEntityState,
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => void, addExtension: (extension: ExternalConverterClass) => void) {
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
}
override async start(): Promise<void> {
this.server = http.createServer(this.onRequest);
this.server.on('upgrade', this.onUpgrade);
/* istanbul ignore next */ // eslint-disable-next-line @typescript-eslint/no-explicit-any
const options = {setHeaders: (res: any, path: any): void => {
if (path.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-store');
}
}};
this.fileServer = serveStatic(frontend.getPath(), options);
this.wss = new WebSocket.Server({noServer: true});
this.wss.on('connection', this.onWebSocketConnection);
this.server.listen(this.port, this.host);
logger.info(`Started frontend on port ${this.host}:${this.port}`);
}
override async stop(): Promise<void> {
super.stop();
for (const client of this.wss.clients) {
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
client.terminate();
}
this.wss.close();
return new Promise((cb: () => void) => this.server.close(cb));
}
@bind private onRequest(request: http.IncomingMessage, response: http.ServerResponse): void {
// @ts-ignore
this.fileServer(request, response, finalhandler(request, response));
}
private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void {
const {query} = url.parse(request.url, true);
cb(!this.authToken || this.authToken === query.token);
}
@bind private onUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void {
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.authenticate(request, (isAuthentificated) => {
if (isAuthentificated) {
this.wss.emit('connection', ws, request);
} else {
ws.close(4401, 'Unauthorized');
}
});
});
}
@bind private onWebSocketConnection(ws: WebSocket): void {
ws.on('message', (data: Buffer, isBinary: boolean) => {
if (!isBinary && data) {
const message = data.toString();
const {topic, payload} = JSON.parse(message);
this.mqtt.onMessage(`${this.mqttBaseTopic}/${topic}`, stringify(payload));
}
});
for (const [key, value] of this.retainedMessages) {
ws.send(stringify({topic: key, payload: value}));
}
for (const device of this.zigbee.getClients()) {
let payload: KeyValue = {};
if (this.state.exists(device.ieeeAddr)) {
payload = {...payload, ...this.state.get(device.ieeeAddr)};
}
const lastSeen = settings.get().advanced.last_seen;
/* istanbul ignore if */
if (lastSeen !== 'disable') {
payload.last_seen = utils.formatDate(device.lastSeen, lastSeen);
}
if (device.zhDevice.linkquality !== undefined) {
payload.linkquality = device.zhDevice.linkquality;
}
ws.send(stringify({topic: device.name, payload}));
}
}
@bind private onMQTTPublishMessage(data: EventMQTTMessagePublished): void {
if (data.topic.startsWith(`${this.mqttBaseTopic}/`)) {
// Send topic without base_topic
const topic = data.topic.substring(this.mqttBaseTopic.length + 1);
const payload = utils.parseJSON(data.payload, data.payload);
if (data.options.retain) {
this.retainedMessages.set(topic, payload);
}
if (this.wss) {
for (const client of this.wss.clients) {
/* istanbul ignore else */
if (client.readyState === WebSocket.OPEN) {
client.send(stringify({topic, payload}));
}
}
}
}
}
}
module.exports = Frontend;

View File

@ -106,7 +106,7 @@ export default class MQTT {
this.client.subscribe(topic);
}
private onMessage(topic: string, message: string): void {
public onMessage(topic: string, message: string): void {
// Since we subscribe to zigbee2mqtt/# we also receive the message we send ourselves, skip these.
if (!this.publishedTopics.has(topic)) {
this.eventBus.emitMQTTMessage({topic, message: message + ''});

View File

@ -71,6 +71,8 @@ declare global {
permit_join: boolean,
frontend?: {
auth_token?: string,
host?: string,
port?: number,
},
mqtt: {
base_topic: string,
@ -262,7 +264,7 @@ declare global {
interface TempState {
get: (ID: string | number) => KeyValue | null;
remove: (ID: string | number) => void;
removeKey: (ID: string, keys: string[]) => void;
removeKey: (ID: string, keys: string[]) => void;
exists: (ID: string) => boolean;
}

37038
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff

View File

@ -66,10 +66,12 @@
"@babel/preset-env": "^7.15.6",
"@babel/preset-typescript": "^7.15.0",
"@types/debounce": "^1.2.0",
"@types/finalhandler": "^1.1.1",
"@types/humanize-duration": "^3.25.1",
"@types/jest": "^27.0.1",
"@types/js-yaml": "^4.0.3",
"@types/object-assign-deep": "^0.4.0",
"@types/serve-static": "^1.13.10",
"@types/ws": "^7.4.7",
"@typescript-eslint/eslint-plugin": "^4.31.1",
"@typescript-eslint/parser": "^4.31.1",