Skip to content

Commit 0215e76

Browse files
authored
feat: Allow to disable internal frontend UI serving for standalone serving (#27851)
1 parent 833605b commit 0215e76

File tree

5 files changed

+104
-84
lines changed

5 files changed

+104
-84
lines changed

lib/extension/frontend.ts

Lines changed: 84 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import type {Socket} from "node:net";
77
import {posix} from "node:path";
88
import {parse} from "node:url";
99
import bind from "bind-decorator";
10-
import type {RequestHandler} from "express-static-gzip";
1110
import expressStaticGzip from "express-static-gzip";
1211
import finalhandler from "finalhandler";
1312
import stringify from "json-stable-stringify-without-jsonify";
@@ -24,9 +23,7 @@ import Extension from "./extension";
2423
*/
2524
export class Frontend extends Extension {
2625
private mqttBaseTopic: string;
27-
private server!: Server;
28-
private fileServer!: RequestHandler;
29-
private deviceIconsFileServer!: RequestHandler;
26+
private server: Server | undefined;
3027
private wss!: WebSocket.Server;
3128
private baseUrl: string;
3229

@@ -49,60 +46,97 @@ export class Frontend extends Extension {
4946
}
5047

5148
override async start(): Promise<void> {
52-
const hasSSL = (val: string | undefined, key: string): val is string => {
53-
if (val) {
54-
if (!existsSync(val)) {
49+
if (settings.get().frontend.disable_ui_serving) {
50+
const {host, port} = settings.get().frontend;
51+
this.wss = new WebSocket.Server({port, host, path: posix.join(this.baseUrl, "api")});
52+
53+
logger.info(
54+
/* v8 ignore next */
55+
`Frontend UI serving is disabled. WebSocket at: ${this.wss.options.host ?? "0.0.0.0"}:${this.wss.options.port}${this.wss.options.path}`,
56+
);
57+
} else {
58+
const {host, port, ssl_key: sslKey, ssl_cert: sslCert} = settings.get().frontend;
59+
const hasSSL = (val: string | undefined, key: string): val is string => {
60+
if (val) {
61+
if (existsSync(val)) {
62+
return true;
63+
}
64+
5565
logger.error(`Defined ${key} '${val}' file path does not exists, server won't be secured.`);
56-
return false;
5766
}
58-
return true;
59-
}
60-
return false;
61-
};
62-
const {host, port, ssl_key: sslKey, ssl_cert: sslCert} = settings.get().frontend;
63-
const options = {
64-
enableBrotli: true,
65-
// TODO: https://github.com/Koenkk/zigbee2mqtt/issues/24654 - enable compressed index serving when express-static-gzip is fixed.
66-
index: false,
67-
serveStatic: {
68-
index: "index.html",
69-
/* v8 ignore start */
70-
setHeaders: (res: ServerResponse, path: string): void => {
71-
if (path.endsWith("index.html")) {
72-
res.setHeader("Cache-Control", "no-store");
73-
}
67+
68+
return false;
69+
};
70+
const options: expressStaticGzip.ExpressStaticGzipOptions = {
71+
enableBrotli: true,
72+
serveStatic: {
73+
/* v8 ignore start */
74+
setHeaders: (res: ServerResponse, path: string): void => {
75+
if (path.endsWith("index.html")) {
76+
res.setHeader("Cache-Control", "no-store");
77+
}
78+
},
79+
/* v8 ignore stop */
7480
},
75-
/* v8 ignore stop */
76-
},
77-
};
78-
const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend");
79-
this.fileServer = expressStaticGzip(frontend.default.getPath(), options);
80-
this.deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options);
81-
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, "api")});
81+
};
82+
const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend");
83+
const fileServer = expressStaticGzip(frontend.default.getPath(), options);
84+
const deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options);
85+
const onRequest = (request: IncomingMessage, response: ServerResponse): void => {
86+
const next = finalhandler(request, response);
87+
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
88+
const newUrl = posix.relative(this.baseUrl, request.url!);
89+
90+
// The request url is not within the frontend base url, so the relative path starts with '..'
91+
if (newUrl.startsWith(".")) {
92+
next();
93+
94+
return;
95+
}
8296

83-
this.wss.on("connection", this.onWebSocketConnection);
97+
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
98+
// This is necessary for the browser to resolve relative assets paths correctly.
99+
request.originalUrl = request.url;
100+
request.url = `/${newUrl}`;
101+
request.path = request.url;
84102

85-
if (hasSSL(sslKey, "ssl_key") && hasSSL(sslCert, "ssl_cert")) {
86-
const serverOptions = {key: readFileSync(sslKey), cert: readFileSync(sslCert)};
87-
this.server = createSecureServer(serverOptions, this.onRequest);
88-
} else {
89-
this.server = createServer(this.onRequest);
103+
if (newUrl.startsWith("device_icons/")) {
104+
request.path = request.path.replace("device_icons/", "");
105+
request.url = request.url.replace("/device_icons", "");
106+
107+
deviceIconsFileServer(request, response, next);
108+
} else {
109+
fileServer(request, response, next);
110+
}
111+
};
112+
113+
if (hasSSL(sslKey, "ssl_key") && hasSSL(sslCert, "ssl_cert")) {
114+
const serverOptions = {key: readFileSync(sslKey), cert: readFileSync(sslCert)};
115+
this.server = createSecureServer(serverOptions, onRequest);
116+
} else {
117+
this.server = createServer(onRequest);
118+
}
119+
120+
this.server.on("upgrade", this.onUpgrade);
121+
122+
if (!host) {
123+
this.server.listen(port);
124+
logger.info(`Started frontend on port ${port}`);
125+
} else if (host.startsWith("/")) {
126+
this.server.listen(host);
127+
logger.info(`Started frontend on socket ${host}`);
128+
} else {
129+
this.server.listen(port, host);
130+
logger.info(`Started frontend on port ${host}:${port}`);
131+
}
132+
133+
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, "api")});
90134
}
91135

92-
this.server.on("upgrade", this.onUpgrade);
136+
this.wss.on("connection", this.onWebSocketConnection);
137+
93138
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessageOrEntityState);
94139
this.eventBus.onPublishEntityState(this, this.onMQTTPublishMessageOrEntityState);
95-
96-
if (!host) {
97-
this.server.listen(port);
98-
logger.info(`Started frontend on port ${port}`);
99-
} else if (host.startsWith("/")) {
100-
this.server.listen(host);
101-
logger.info(`Started frontend on socket ${host}`);
102-
} else {
103-
this.server.listen(port, host);
104-
logger.info(`Started frontend on port ${host}:${port}`);
105-
}
106140
}
107141

108142
override async stop(): Promise<void> {
@@ -117,34 +151,7 @@ export class Frontend extends Extension {
117151
this.wss.close();
118152
}
119153

120-
await new Promise((resolve) => this.server?.close(resolve));
121-
}
122-
123-
@bind private onRequest(request: IncomingMessage, response: ServerResponse): void {
124-
const fin = finalhandler(request, response);
125-
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
126-
const newUrl = posix.relative(this.baseUrl, request.url!);
127-
128-
// The request url is not within the frontend base url, so the relative path starts with '..'
129-
if (newUrl.startsWith(".")) {
130-
fin();
131-
132-
return;
133-
}
134-
135-
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
136-
// This is necessary for the browser to resolve relative assets paths correctly.
137-
request.originalUrl = request.url;
138-
request.url = `/${newUrl}`;
139-
request.path = request.url;
140-
141-
if (newUrl.startsWith("device_icons/")) {
142-
request.path = request.path.replace("device_icons/", "");
143-
request.url = request.url.replace("/device_icons", "");
144-
this.deviceIconsFileServer(request, response, fin);
145-
} else {
146-
this.fileServer(request, response, fin);
147-
}
154+
await new Promise((resolve) => (this.server ? this.server.close(resolve) : resolve(undefined)));
148155
}
149156

150157
@bind private onUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void {

lib/types/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export interface Zigbee2MQTTSettings {
151151
ssl_cert?: string;
152152
ssl_key?: string;
153153
notification_filter?: string[];
154+
disable_ui_serving?: boolean;
154155
};
155156
devices: {[s: string]: Zigbee2MQTTDeviceOptions};
156157
groups: {[s: string]: Omit<Zigbee2MQTTGroupOptions, "ID">};

lib/types/zigbee2mqtt-frontend.d.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,3 @@ declare module "http" {
1212
path?: string;
1313
}
1414
}
15-
16-
declare module "express-static-gzip" {
17-
import type {IncomingMessage, ServerResponse} from "node:http";
18-
19-
export type RequestHandler = (req: IncomingMessage, res: ServerResponse, finalhandler: (err: unknown) => void) => void;
20-
export default function expressStaticGzip(root: string, options?: Record<string, unknown>): RequestHandler;
21-
}

lib/util/settings.schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,12 @@
446446
"items": {
447447
"type": "string"
448448
}
449+
},
450+
"disable_ui_serving": {
451+
"type": ["boolean", "null"],
452+
"title": "Disable UI serving",
453+
"description": "If true, the frontend UI is not served, only the WebSocket is maintained by Zigbee2MQTT (you are required to serve a standalone UI yourself as needed).",
454+
"requiresRestart": true
449455
}
450456
},
451457
"required": []

test/extensions/frontend.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,4 +486,17 @@ describe("Extension: Frontend", () => {
486486

487487
await vi.waitFor(() => controller.getExtension("Frontend") === undefined);
488488
});
489+
490+
it("disables serving", async () => {
491+
settings.set(["frontend", "disable_ui_serving"], true);
492+
controller = new Controller(vi.fn(), vi.fn());
493+
await controller.start();
494+
495+
expect(mockHTTP.listen).toHaveBeenCalledTimes(0);
496+
mockWS.clients.push(mockWSClient);
497+
await controller.stop();
498+
expect(mockWSClient.terminate).toHaveBeenCalledTimes(1);
499+
expect(mockHTTP.close).toHaveBeenCalledTimes(0);
500+
expect(mockWS.close).toHaveBeenCalledTimes(1);
501+
});
489502
});

0 commit comments

Comments
 (0)