feat: Allow serving frontend under subpath (#24244)

* Allow app running in the server subdirectory

* Use path.posix to manage urls

* Add base_utl to config schema
This commit is contained in:
Vladimir Kotikov
2024-10-10 14:22:48 +02:00
committed by GitHub
parent 3bb4af253a
commit 1fdf0a0a47
4 changed files with 76 additions and 24 deletions
+20 -2
View File
@@ -3,6 +3,7 @@ import fs from 'fs';
import http from 'http';
import https from 'https';
import net from 'net';
import path from 'path';
import url from 'url';
import bind from 'bind-decorator';
@@ -31,6 +32,7 @@ export default class Frontend extends Extension {
private server: http.Server | undefined;
private fileServer: RequestHandler | undefined;
private wss: WebSocket.Server | undefined;
private frontendBaseUrl: string;
constructor(
zigbee: Zigbee,
@@ -52,6 +54,10 @@ export default class Frontend extends Extension {
this.sslKey = frontendSettings.ssl_key;
this.authToken = frontendSettings.auth_token;
this.mqttBaseTopic = settings.get().mqtt.base_topic;
this.frontendBaseUrl = settings.get().frontend?.base_url ?? '/';
if (!this.frontendBaseUrl.startsWith('/')) {
this.frontendBaseUrl = '/' + this.frontendBaseUrl;
}
}
private isHttpsConfigured(): boolean {
@@ -87,7 +93,7 @@ export default class Frontend extends Extension {
},
};
this.fileServer = gzipStatic(frontend.getPath(), options);
this.wss = new WebSocket.Server({noServer: true});
this.wss = new WebSocket.Server({noServer: true, path: path.posix.join(this.frontendBaseUrl, 'api')});
this.wss.on('connection', this.onWebSocketConnection);
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
@@ -118,7 +124,19 @@ export default class Frontend extends Extension {
}
@bind private onRequest(request: http.IncomingMessage, response: http.ServerResponse): void {
this.fileServer?.(request, response, finalhandler(request, response));
const fin = finalhandler(request, response);
const newUrl = path.posix.relative(this.frontendBaseUrl, request.url!);
// The request url is not within the frontend base url, so the relative path starts with '..'
if (newUrl.startsWith('.')) {
return fin();
}
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the
// root directory. This is necessary for the browser to resolve relative assets paths correctly.
request.originalUrl = request.url;
request.url = '/' + newUrl;
this.fileServer?.(request, response, fin);
}
private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void {
+7
View File
@@ -179,6 +179,7 @@ declare global {
auth_token?: string;
host?: string;
port: number;
base_url?: string;
url?: string;
ssl_cert?: string;
ssl_key?: string;
@@ -263,3 +264,9 @@ declare global {
qos?: 0 | 1 | 2;
}
}
declare module 'http' {
interface IncomingMessage {
originalUrl?: string;
}
}
+6
View File
@@ -400,6 +400,12 @@
"title": "key file path",
"description": "SSL key file path for exposing HTTPS. The sibling property 'ssl_cert' must be set for HTTPS to be activated.",
"requiresRestart": true
},
"base_url": {
"type": ["string", "null"],
"title": "Base URL",
"description": "Base URL for the frontend if the frontend is hosted under subpath. E.g. if your frontend is available at 'http://localhost/z2m', set this to '/z2m'",
"requiresRestart": true
}
}
}
+43 -22
View File
@@ -8,8 +8,14 @@ const stringify = require('json-stable-stringify-without-jsonify');
const flushPromises = require('./lib/flushPromises');
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
const path = require('path');
const finalhandler = require('finalhandler');
const ws = require('ws');
jest.spyOn(process, 'exit').mockImplementation(() => {});
afterEach(() => {
jest.clearAllMocks();
});
const mockHTTP = {
implementation: {
listen: jest.fn(),
@@ -60,6 +66,10 @@ const mockNodeStatic = {
events: {},
};
const mockFinalHandler = {
implementation: jest.fn(),
};
jest.mock('http', () => ({
createServer: jest.fn().mockImplementation((onRequest) => {
mockHTTP.variables.onRequest = onRequest;
@@ -94,6 +104,12 @@ jest.mock('ws', () => ({
}),
}));
jest.mock('finalhandler', () =>
jest.fn().mockImplementation(() => {
return mockFinalHandler.implementation;
}),
);
describe('Frontend', () => {
let controller;
@@ -136,10 +152,6 @@ describe('Frontend', () => {
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
mockWS.implementation.close.mockClear();
mockHTTP.implementation.close.mockClear();
mockHTTP.implementation.listen.mockClear();
mockHTTPS.implementation.listen.mockClear();
});
it('Start/stop without host', async () => {
@@ -160,10 +172,6 @@ describe('Frontend', () => {
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
mockWS.implementation.close.mockClear();
mockHTTP.implementation.close.mockClear();
mockHTTP.implementation.listen.mockClear();
mockHTTPS.implementation.listen.mockClear();
});
it('Start/stop unix socket', async () => {
@@ -184,10 +192,6 @@ describe('Frontend', () => {
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
mockWS.implementation.close.mockClear();
mockHTTP.implementation.close.mockClear();
mockHTTP.implementation.listen.mockClear();
mockHTTPS.implementation.listen.mockClear();
});
it('Start/stop HTTPS valid', async () => {
@@ -198,8 +202,6 @@ describe('Frontend', () => {
expect(mockHTTP.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTPS.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
await controller.stop();
mockHTTP.implementation.listen.mockClear();
mockHTTPS.implementation.listen.mockClear();
});
it('Start/stop HTTPS invalid : missing config', async () => {
@@ -209,8 +211,6 @@ describe('Frontend', () => {
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
await controller.stop();
mockHTTP.implementation.listen.mockClear();
mockHTTPS.implementation.listen.mockClear();
});
it('Start/stop HTTPS invalid : missing file', async () => {
@@ -221,8 +221,6 @@ describe('Frontend', () => {
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
await controller.stop();
mockHTTP.implementation.listen.mockClear();
mockHTTPS.implementation.listen.mockClear();
});
it('Websocket interaction', async () => {
@@ -314,7 +312,6 @@ describe('Frontend', () => {
await controller.start();
const mockSocket = {destroy: jest.fn()};
mockWS.implementation.handleUpgrade.mockClear();
mockHTTP.events.upgrade({url: 'http://localhost:8080/api'}, mockSocket, 3);
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
@@ -322,9 +319,9 @@ describe('Frontend', () => {
mockWS.implementation.handleUpgrade.mock.calls[0][3](99);
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', 99, {url: 'http://localhost:8080/api'});
mockHTTP.variables.onRequest(1, 2);
mockHTTP.variables.onRequest({url: '/file.txt'}, 2);
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
expect(mockNodeStatic.implementation).toHaveBeenCalledWith(1, 2, expect.any(Function));
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/file.txt', url: '/file.txt'}, 2, expect.any(Function));
});
it('Static server', async () => {
@@ -341,7 +338,6 @@ describe('Frontend', () => {
await controller.start();
const mockSocket = {destroy: jest.fn()};
mockWS.implementation.handleUpgrade.mockClear();
mockHTTP.events.upgrade({url: '/api'}, mockSocket, mockWSocket);
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
@@ -361,4 +357,29 @@ describe('Frontend', () => {
mockWS.implementation.handleUpgrade.mock.calls[0][3](mockWSocket);
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', mockWSocket, {url});
});
it.each(['z2m', 'z2m/', '/z2m'])('Works with non-default base url %s', async (baseUrl) => {
settings.set(['frontend'], {base_url: baseUrl});
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m/api'});
mockHTTP.variables.onRequest({url: '/z2m'}, 2);
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/z2m', url: '/'}, 2, expect.any(Function));
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
mockNodeStatic.implementation.mockReset();
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
mockHTTP.variables.onRequest({url: '/z2m/file.txt'}, 2);
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/z2m/file.txt', url: '/file.txt'}, 2, expect.any(Function));
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
mockNodeStatic.implementation.mockReset();
mockHTTP.variables.onRequest({url: '/z/file.txt'}, 2);
expect(mockNodeStatic.implementation).not.toHaveBeenCalled();
expect(mockFinalHandler.implementation).toHaveBeenCalled();
});
});