@@ -7,7 +7,6 @@ import type {Socket} from "node:net";
77import { posix } from "node:path" ;
88import { parse } from "node:url" ;
99import bind from "bind-decorator" ;
10- import type { RequestHandler } from "express-static-gzip" ;
1110import expressStaticGzip from "express-static-gzip" ;
1211import finalhandler from "finalhandler" ;
1312import stringify from "json-stable-stringify-without-jsonify" ;
@@ -24,9 +23,7 @@ import Extension from "./extension";
2423 */
2524export 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 {
0 commit comments