Replace polling with websocket live sync

This commit is contained in:
2026-04-02 02:31:59 +03:00
parent 9a3785deb9
commit c04847b21c
15 changed files with 596 additions and 28 deletions

View File

@@ -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);

View File

@@ -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}`);
});
}

View 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
View 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);
}