Files
3proxyUI/server/app.ts

180 lines
5.7 KiB
TypeScript

import express, { type Request, type Response } from 'express';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { ControlPlaneState, CreateUserInput } from '../src/shared/contracts';
import {
buildRuntimePaths,
createUserRecord,
deriveDashboardSnapshot,
render3proxyConfig,
validateCreateUserInput,
type RuntimePaths,
} from './lib/config';
import type { RuntimeController } from './lib/runtime';
import { StateStore } from './lib/store';
export interface AppServices {
store: StateStore;
runtime: RuntimeController;
runtimeRootDir: string;
}
export function createApp({ store, runtime, runtimeRootDir }: AppServices) {
const app = express();
const runtimePaths = buildRuntimePaths(runtimeRootDir);
const distDir = path.resolve('dist');
app.use(express.json());
app.use(express.static(distDir));
app.get('/api/health', async (_request, response) => {
const state = await store.read();
const previewConfig = render3proxyConfig(state, runtimePaths);
response.json({
ok: true,
runtime: runtime.getSnapshot(),
users: state.userRecords.length,
configBytes: Buffer.byteLength(previewConfig),
});
});
app.get('/api/state', async (_request, response, next) => {
try {
const payload = await getSnapshot(store, runtime, runtimePaths);
response.json(payload);
} catch (error) {
next(error);
}
});
app.post('/api/runtime/:action', async (request, response, next) => {
try {
const state = await store.read();
const action = request.params.action;
const controller = action === 'restart' ? runtime.restart.bind(runtime) : runtime.start.bind(runtime);
if (!['start', 'restart'].includes(action)) {
response.status(404).json({ error: 'Unknown runtime action.' });
return;
}
const runtimeSnapshot = await controller();
state.service.lastEvent = action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel';
if (runtimeSnapshot.startedAt) {
state.service.startedAt = runtimeSnapshot.startedAt;
}
await writeConfigAndState(store, state, runtimePaths);
response.json(await getSnapshot(store, runtime, runtimePaths));
} catch (error) {
next(error);
}
});
app.post('/api/users', async (request, response, next) => {
try {
const state = await store.read();
const input = validateCreateUserInput(request.body as Partial<CreateUserInput>, state.system.services);
const record = createUserRecord(state, input);
state.userRecords.push(record);
state.service.lastEvent = `User ${record.username} created from panel`;
await persistRuntimeMutation(store, runtime, state, runtimePaths);
response.status(201).json(await getSnapshot(store, runtime, runtimePaths));
} catch (error) {
next(error);
}
});
app.post('/api/users/:id/pause', async (request, response, next) => {
try {
const state = await store.read();
const user = state.userRecords.find((entry) => entry.id === request.params.id);
if (!user) {
response.status(404).json({ error: 'User not found.' });
return;
}
user.paused = !user.paused;
state.service.lastEvent = user.paused
? `User ${user.username} paused from panel`
: `User ${user.username} resumed from panel`;
await persistRuntimeMutation(store, runtime, state, runtimePaths);
response.json(await getSnapshot(store, runtime, runtimePaths));
} catch (error) {
next(error);
}
});
app.delete('/api/users/:id', async (request, response, next) => {
try {
const state = await store.read();
const index = state.userRecords.findIndex((entry) => entry.id === request.params.id);
if (index === -1) {
response.status(404).json({ error: 'User not found.' });
return;
}
const [removed] = state.userRecords.splice(index, 1);
state.service.lastEvent = `User ${removed.username} deleted from panel`;
await persistRuntimeMutation(store, runtime, state, runtimePaths);
response.json(await getSnapshot(store, runtime, runtimePaths));
} catch (error) {
next(error);
}
});
app.use(async (_request, response) => {
const distPath = path.join(distDir, 'index.html');
try {
const html = await fs.readFile(distPath, 'utf8');
response.type('html').send(html);
} catch {
response.status(404).send('Frontend build not found.');
}
});
app.use((error: unknown, _request: Request, response: Response, _next: express.NextFunction) => {
response.status(400).json({
error: error instanceof Error ? error.message : 'Unknown server error.',
});
});
return app;
}
async function getSnapshot(
store: StateStore,
runtime: RuntimeController,
runtimePaths: RuntimePaths,
) {
const state = await store.read();
const previewConfig = render3proxyConfig(state, runtimePaths);
return deriveDashboardSnapshot(state, runtime.getSnapshot(), previewConfig);
}
async function persistRuntimeMutation(
store: StateStore,
runtime: RuntimeController,
state: ControlPlaneState,
runtimePaths: RuntimePaths,
) {
await writeConfigAndState(store, state, runtimePaths);
await runtime.reload();
}
async function writeConfigAndState(
store: StateStore,
state: ControlPlaneState,
runtimePaths: RuntimePaths,
) {
const config = render3proxyConfig(state, runtimePaths);
await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true });
await fs.mkdir(path.dirname(runtimePaths.logPath), { recursive: true });
await fs.mkdir(runtimePaths.reportDir, { recursive: true });
await fs.writeFile(runtimePaths.configPath, config, 'utf8');
await store.write(state);
}