Replace polling with websocket live sync
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
} from './lib/config';
|
||||
import { getDashboardSnapshot } from './lib/snapshot';
|
||||
import { AuthService } from './lib/auth';
|
||||
import type { LiveSyncPublisher } from './lib/liveSync';
|
||||
import type { RuntimeController } from './lib/runtime';
|
||||
import { StateStore } from './lib/store';
|
||||
|
||||
@@ -24,9 +25,10 @@ export interface AppServices {
|
||||
runtime: RuntimeController;
|
||||
runtimeRootDir: string;
|
||||
auth: AuthService;
|
||||
liveSync?: LiveSyncPublisher;
|
||||
}
|
||||
|
||||
export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices) {
|
||||
export function createApp({ store, runtime, runtimeRootDir, auth, liveSync }: AppServices) {
|
||||
const app = express();
|
||||
const runtimePaths = buildRuntimePaths(runtimeRootDir);
|
||||
const distDir = path.resolve('dist');
|
||||
@@ -106,6 +108,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
|
||||
}
|
||||
|
||||
await writeConfigAndState(store, state, runtimePaths);
|
||||
liveSync?.notifyPotentialChange();
|
||||
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -120,6 +123,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
|
||||
state.userRecords.push(record);
|
||||
state.service.lastEvent = `User ${record.username} created from panel`;
|
||||
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||
liveSync?.notifyPotentialChange();
|
||||
response.status(201).json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -147,6 +151,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
|
||||
? `System configuration updated from panel and removed ${removedUsers.length} linked users`
|
||||
: 'System configuration updated from panel';
|
||||
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||
liveSync?.notifyPotentialChange();
|
||||
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -169,6 +174,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
|
||||
: `User ${user.username} resumed from panel`;
|
||||
|
||||
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||
liveSync?.notifyPotentialChange();
|
||||
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -188,6 +194,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
|
||||
const [removed] = state.userRecords.splice(index, 1);
|
||||
state.service.lastEvent = `User ${removed.username} deleted from panel`;
|
||||
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||
liveSync?.notifyPotentialChange();
|
||||
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createServer } from 'node:http';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createApp } from './app';
|
||||
import { AuthService } from './lib/auth';
|
||||
import { buildRuntimePaths, render3proxyConfig } from './lib/config';
|
||||
import { LiveSyncServer } from './lib/liveSync';
|
||||
import { ThreeProxyManager } from './lib/runtime';
|
||||
import { StateStore } from './lib/store';
|
||||
|
||||
@@ -34,8 +36,13 @@ async function main() {
|
||||
await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true });
|
||||
await fs.writeFile(runtimePaths.configPath, render3proxyConfig(initialState, runtimePaths), 'utf8');
|
||||
await runtime.initialize();
|
||||
const app = createApp({ store, runtime, runtimeRootDir, auth });
|
||||
app.listen(port, () => {
|
||||
const liveSync = new LiveSyncServer({ store, runtime, runtimePaths, auth });
|
||||
await liveSync.initialize();
|
||||
const app = createApp({ store, runtime, runtimeRootDir, auth, liveSync });
|
||||
const server = createServer(app);
|
||||
liveSync.attach(server);
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`Panel server listening on http://0.0.0.0:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
30
server/lib/liveSync.test.ts
Normal file
30
server/lib/liveSync.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fallbackDashboardSnapshot } from '../../src/data/mockDashboard';
|
||||
import { buildSnapshotPatch } from './liveSync';
|
||||
|
||||
describe('buildSnapshotPatch', () => {
|
||||
it('returns only changed top-level dashboard sections', () => {
|
||||
const nextSnapshot = {
|
||||
...fallbackDashboardSnapshot,
|
||||
traffic: {
|
||||
...fallbackDashboardSnapshot.traffic,
|
||||
totalBytes: fallbackDashboardSnapshot.traffic.totalBytes + 512,
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildSnapshotPatch(fallbackDashboardSnapshot, nextSnapshot)).toEqual({
|
||||
traffic: nextSnapshot.traffic,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a full patch when no previous snapshot exists', () => {
|
||||
expect(buildSnapshotPatch(null, fallbackDashboardSnapshot)).toEqual({
|
||||
service: fallbackDashboardSnapshot.service,
|
||||
traffic: fallbackDashboardSnapshot.traffic,
|
||||
users: fallbackDashboardSnapshot.users,
|
||||
attention: fallbackDashboardSnapshot.attention,
|
||||
userRecords: fallbackDashboardSnapshot.userRecords,
|
||||
system: fallbackDashboardSnapshot.system,
|
||||
});
|
||||
});
|
||||
});
|
||||
206
server/lib/liveSync.ts
Normal file
206
server/lib/liveSync.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { watch, type FSWatcher } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
import path from 'node:path';
|
||||
import type { Socket } from 'node:net';
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
import type { DashboardSnapshot, DashboardSnapshotPatch, DashboardSyncMessage } from '../../src/shared/contracts';
|
||||
import { AuthService } from './auth';
|
||||
import { getDashboardSnapshot } from './snapshot';
|
||||
import type { RuntimePaths } from './config';
|
||||
import type { RuntimeController } from './runtime';
|
||||
import type { StateStore } from './store';
|
||||
|
||||
export interface LiveSyncPublisher {
|
||||
notifyPotentialChange(): void;
|
||||
}
|
||||
|
||||
interface LiveSyncOptions {
|
||||
auth: AuthService;
|
||||
runtime: RuntimeController;
|
||||
runtimePaths: RuntimePaths;
|
||||
store: StateStore;
|
||||
}
|
||||
|
||||
export class LiveSyncServer implements LiveSyncPublisher {
|
||||
private readonly wss = new WebSocketServer({ noServer: true });
|
||||
private readonly watchers: FSWatcher[] = [];
|
||||
private readonly clients = new Set<WebSocket>();
|
||||
private lastSnapshot: DashboardSnapshot | null = null;
|
||||
private refreshTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private readonly options: LiveSyncOptions) {
|
||||
this.wss.on('connection', (socket) => {
|
||||
this.clients.add(socket);
|
||||
socket.on('close', () => {
|
||||
this.clients.delete(socket);
|
||||
});
|
||||
|
||||
void this.sendInit(socket);
|
||||
});
|
||||
}
|
||||
|
||||
attach(server: HttpServer) {
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
|
||||
if (url.pathname !== '/ws') {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = url.searchParams.get('token');
|
||||
const session = token ? this.options.auth.verify(token) : null;
|
||||
|
||||
if (!session) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
this.wss.handleUpgrade(request, socket as Socket, head, (ws) => {
|
||||
this.wss.emit('connection', ws, request);
|
||||
this.scheduleSessionExpiry(ws, session.exp);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
const directories = new Set([
|
||||
this.options.runtimePaths.rootDir,
|
||||
path.dirname(this.options.runtimePaths.configPath),
|
||||
path.dirname(this.options.runtimePaths.logPath),
|
||||
path.dirname(this.options.runtimePaths.counterPath),
|
||||
this.options.runtimePaths.reportDir,
|
||||
]);
|
||||
|
||||
for (const directory of directories) {
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
this.watchers.push(watch(directory, { recursive: false }, () => this.notifyPotentialChange()));
|
||||
}
|
||||
}
|
||||
|
||||
notifyPotentialChange() {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
}
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refreshTimer = null;
|
||||
void this.refreshAndBroadcast();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
async refreshAndBroadcast() {
|
||||
if (this.clients.size === 0) {
|
||||
this.lastSnapshot = await this.readSnapshot();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSnapshot = await this.readSnapshot();
|
||||
const patch = buildSnapshotPatch(this.lastSnapshot, nextSnapshot);
|
||||
this.lastSnapshot = nextSnapshot;
|
||||
|
||||
if (!patch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message: DashboardSyncMessage = { type: 'snapshot.patch', patch };
|
||||
const payload = JSON.stringify(message);
|
||||
|
||||
this.clients.forEach((client) => {
|
||||
if (client.readyState === client.OPEN) {
|
||||
client.send(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
this.watchers.forEach((watcher) => watcher.close());
|
||||
this.watchers.length = 0;
|
||||
this.clients.forEach((client) => client.close());
|
||||
this.clients.clear();
|
||||
this.wss.close();
|
||||
}
|
||||
|
||||
private async sendInit(socket: WebSocket) {
|
||||
const snapshot = await this.readSnapshot();
|
||||
this.lastSnapshot = snapshot;
|
||||
const message: DashboardSyncMessage = {
|
||||
type: 'snapshot.init',
|
||||
snapshot,
|
||||
};
|
||||
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
private readSnapshot() {
|
||||
return getDashboardSnapshot(this.options.store, this.options.runtime, this.options.runtimePaths);
|
||||
}
|
||||
|
||||
private scheduleSessionExpiry(socket: WebSocket, expiresAt: number) {
|
||||
const timeoutMs = Math.max(expiresAt - Date.now(), 0);
|
||||
const timer = setTimeout(() => {
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'session.expired',
|
||||
error: this.options.auth.invalidTokenError().error,
|
||||
} satisfies DashboardSyncMessage),
|
||||
);
|
||||
}
|
||||
|
||||
socket.close();
|
||||
}, timeoutMs);
|
||||
|
||||
socket.on('close', () => clearTimeout(timer));
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSnapshotPatch(
|
||||
previous: DashboardSnapshot | null,
|
||||
next: DashboardSnapshot,
|
||||
): DashboardSnapshotPatch | null {
|
||||
if (!previous) {
|
||||
return {
|
||||
service: next.service,
|
||||
traffic: next.traffic,
|
||||
users: next.users,
|
||||
attention: next.attention,
|
||||
userRecords: next.userRecords,
|
||||
system: next.system,
|
||||
};
|
||||
}
|
||||
|
||||
const patch: DashboardSnapshotPatch = {};
|
||||
|
||||
if (!isEqual(previous.service, next.service)) {
|
||||
patch.service = next.service;
|
||||
}
|
||||
if (!isEqual(previous.traffic, next.traffic)) {
|
||||
patch.traffic = next.traffic;
|
||||
}
|
||||
if (!isEqual(previous.users, next.users)) {
|
||||
patch.users = next.users;
|
||||
}
|
||||
if (!isEqual(previous.attention, next.attention)) {
|
||||
patch.attention = next.attention;
|
||||
}
|
||||
if (!isEqual(previous.userRecords, next.userRecords)) {
|
||||
patch.userRecords = next.userRecords;
|
||||
}
|
||||
if (!isEqual(previous.system, next.system)) {
|
||||
patch.system = next.system;
|
||||
}
|
||||
|
||||
return Object.keys(patch).length > 0 ? patch : null;
|
||||
}
|
||||
|
||||
function isEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
Reference in New Issue
Block a user