This commit is contained in:
jeanotx32
2026-08-11 00:26:56 +02:00
commit 6f11b72cbb
43 changed files with 7175 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"serverUrl": "ws://127.0.0.1:8080/ws/agent",
"token": "colle-ici-le-jeton-d-enrolement-ou-le-jeton-de-l-agent",
"name": "vm-rec-01",
"obs": {
"host": "127.0.0.1",
"port": 4455,
"password": "mot-de-passe-obs-websocket"
},
"insecureTls": false
}

View File

@@ -0,0 +1,25 @@
{
"name": "@stream-control/agent",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"bin": {
"stream-control-agent": "./dist/index.js"
},
"scripts": {
"build": "tsc -b",
"dev": "npm run build -w @stream-control/shared && node --experimental-strip-types --disable-warning=ExperimentalWarning --watch src/index.ts",
"start": "node dist/index.js"
},
"dependencies": {
"@stream-control/shared": "*",
"obs-websocket-js": "^5.0.6",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"@types/ws": "^8.5.13",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,79 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { ObsSettings } from '@stream-control/shared';
import { DEFAULT_OBS_SETTINGS } from '@stream-control/shared';
export interface AgentConfig {
/** URL WebSocket du serveur de contrôle, ex. wss://control.exemple.com/ws/agent */
serverUrl: string;
/** Jeton d'enrôlement au premier démarrage, puis jeton propre à l'agent. */
token: string;
/** Attribué par le serveur lors de l'enrôlement. */
agentId?: string;
/** Nom affiché dans le dashboard. */
name: string;
/** Repli local si le serveur n'a pas encore poussé de configuration OBS. */
obs: ObsSettings;
/** Ignorer les erreurs de certificat TLS (utile en auto-signé). */
insecureTls?: boolean;
}
const CONFIG_PATH = path.resolve(
process.env.AGENT_CONFIG ?? path.join(process.cwd(), 'agent.config.json'),
);
function readFile(): Partial<AgentConfig> {
if (!fs.existsSync(CONFIG_PATH)) return {};
try {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) as Partial<AgentConfig>;
} catch (err) {
console.error(`Configuration illisible (${CONFIG_PATH}) :`, err);
return {};
}
}
/** Le fichier fournit les valeurs par défaut, l'environnement a priorité. */
export function loadConfig(): AgentConfig {
const file = readFile();
const config: AgentConfig = {
serverUrl: process.env.SERVER_URL ?? file.serverUrl ?? 'ws://127.0.0.1:8080/ws/agent',
token: process.env.AGENT_TOKEN ?? file.token ?? '',
agentId: process.env.AGENT_ID ?? file.agentId,
name: process.env.AGENT_NAME ?? file.name ?? os.hostname(),
obs: {
host: process.env.OBS_HOST ?? file.obs?.host ?? DEFAULT_OBS_SETTINGS.host,
port: Number(process.env.OBS_PORT ?? file.obs?.port ?? DEFAULT_OBS_SETTINGS.port),
password: process.env.OBS_PASSWORD ?? file.obs?.password ?? DEFAULT_OBS_SETTINGS.password,
},
insecureTls: process.env.INSECURE_TLS === '1' || file.insecureTls === true,
};
if (!config.token) {
throw new Error(
`Aucun jeton. Renseigne "token" dans ${CONFIG_PATH} (jeton d'enrôlement ou jeton d'agent) ou la variable AGENT_TOKEN.`,
);
}
return config;
}
/**
* Persiste l'identité reçue du serveur pour que les redémarrages suivants
* réutilisent le jeton permanent au lieu du jeton d'enrôlement.
*/
export function persistIdentity(agentId: string, token: string): void {
const current = readFile();
const next = { ...current, agentId, token };
try {
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
console.log(`Identité d'agent enregistrée dans ${CONFIG_PATH}`);
} catch (err) {
console.error(
`Impossible d'écrire ${CONFIG_PATH} — l'agent devra se ré-enrôler au prochain démarrage :`,
err,
);
}
}
export { CONFIG_PATH };

215
packages/agent/src/index.ts Normal file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env node
import os from 'node:os';
import { WebSocket } from 'ws';
import type {
AgentStatus,
AgentToServer,
LogLevel,
ServerToAgent,
} from '@stream-control/shared';
import { PROTOCOL_VERSION, detectPlatform, emptyStatus, safeJsonParse } from '@stream-control/shared';
import { loadConfig, persistIdentity, type AgentConfig } from './config.ts';
import { ObsController } from './obs.ts';
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
const AGENT_VERSION = '0.1.0';
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
let config: AgentConfig;
try {
config = loadConfig();
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
const obs = new ObsController(config.obs);
let socket: WebSocket | null = null;
let statusTimer: NodeJS.Timeout | null = null;
let reconnectDelay = RECONNECT_MIN_MS;
let statusIntervalMs = 2000;
let shuttingDown = false;
// --- Transport vers le serveur de contrôle ----------------------------------
function send(message: AgentToServer): void {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}
}
function report(level: LogLevel, message: string): void {
const prefix = level === 'error' ? '✖' : level === 'warn' ? '!' : '·';
console.log(`${prefix} ${message}`);
send({ type: 'log', level, message, ts: Date.now() });
}
obs.on('log', (level: LogLevel, message: string) => report(level, message));
function connect(): void {
if (shuttingDown) return;
const url = new URL(config.serverUrl);
console.log(`Connexion au serveur ${url.origin}${url.pathname}`);
socket = new WebSocket(url, {
headers: { authorization: `Bearer ${config.token}` },
rejectUnauthorized: !config.insecureTls,
handshakeTimeout: 10_000,
});
socket.on('open', () => {
reconnectDelay = RECONNECT_MIN_MS;
send({
type: 'hello',
protocol: PROTOCOL_VERSION,
agentId: config.agentId,
name: config.name,
hostname: os.hostname(),
platform: detectPlatform(process.platform),
agentVersion: AGENT_VERSION,
});
});
socket.on('message', (raw) => {
const message = safeJsonParse<ServerToAgent>(raw.toString());
if (message) void handleServerMessage(message);
});
socket.on('close', (code, reason) => {
stopStatusLoop();
socket = null;
if (shuttingDown) return;
const why = reason.toString() || `code ${code}`;
console.warn(`Session serveur fermée (${why}), nouvelle tentative dans ${reconnectDelay / 1000}s`);
scheduleReconnect();
});
socket.on('error', (err: Error) => {
console.error(`Erreur de connexion : ${err.message}`);
});
}
function scheduleReconnect(): void {
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
setTimeout(connect, delay).unref?.();
}
// --- Traitement des messages serveur ----------------------------------------
async function handleServerMessage(message: ServerToAgent): Promise<void> {
switch (message.type) {
case 'welcome': {
statusIntervalMs = message.statusIntervalMs || statusIntervalMs;
if (message.token && message.token !== config.token) {
config.token = message.token;
config.agentId = message.agentId;
persistIdentity(message.agentId, message.token);
} else if (!config.agentId) {
config.agentId = message.agentId;
}
obs.applySettings(message.obs);
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
if (message.autoConnectObs && !obs.isConnected) {
obs.connect().catch((err: Error) => report('warn', err.message));
}
startStatusLoop();
break;
}
case 'config': {
obs.applySettings(message.obs);
if (message.autoConnectObs && !obs.isConnected) {
obs.connect().catch((err: Error) => report('warn', err.message));
}
break;
}
case 'command': {
try {
const data = await obs.execute(message.action, message.params ?? {});
send({ type: 'result', requestId: message.requestId, ok: true, data });
void pushStatus(); // état rafraîchi immédiatement après l'action
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
send({ type: 'result', requestId: message.requestId, ok: false, error: text });
report('error', `Échec de « ${message.action} » : ${text}`);
}
break;
}
case 'ping': {
send({ type: 'pong', ts: Date.now() });
break;
}
}
}
// --- Boucle de statut --------------------------------------------------------
async function buildStatus(): Promise<AgentStatus> {
const snapshot = await obs.snapshot();
const memory = memoryUsage();
const disk = await diskUsage(snapshot.recordDirectory);
return {
...emptyStatus(),
...snapshot,
lastRecordingPath: obs.recordingPath,
systemCpu: cpuUsagePercent(),
systemMemoryUsed: memory.used,
systemMemoryTotal: memory.total,
diskFreeBytes: disk?.freeBytes,
diskTotalBytes: disk?.totalBytes,
updatedAt: Date.now(),
};
}
async function pushStatus(): Promise<void> {
if (socket?.readyState !== WebSocket.OPEN) return;
try {
send({ type: 'status', status: await buildStatus() });
} catch (err) {
console.error('Collecte de statut en échec :', err);
}
}
function startStatusLoop(): void {
stopStatusLoop();
void pushStatus();
statusTimer = setInterval(() => void pushStatus(), statusIntervalMs);
statusTimer.unref?.();
}
function stopStatusLoop(): void {
if (statusTimer) clearInterval(statusTimer);
statusTimer = null;
}
// --- Cycle de vie ------------------------------------------------------------
async function shutdown(signal: string): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
console.log(`\n${signal} reçu, arrêt de l'agent…`);
stopStatusLoop();
// L'enregistrement OBS en cours n'est volontairement pas interrompu.
await obs.disconnect().catch(() => undefined);
socket?.close(1000, 'Arrêt de l\'agent');
setTimeout(() => process.exit(0), 500).unref();
}
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('unhandledRejection', (reason) => {
console.error('Rejet non géré :', reason);
});
console.log(`stream-control agent v${AGENT_VERSION}${config.name} (${process.platform})`);
connect();

313
packages/agent/src/obs.ts Normal file
View File

@@ -0,0 +1,313 @@
import { EventEmitter } from 'node:events';
import OBSWebSocket from 'obs-websocket-js';
import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared';
type ObsSnapshot = Pick<
AgentStatus,
| 'obsConnected'
| 'obsVersion'
| 'obsError'
| 'recording'
| 'recordPaused'
| 'recordTimecode'
| 'recordBytes'
| 'recordDirectory'
| 'streaming'
| 'streamTimecode'
| 'currentScene'
| 'scenes'
| 'currentProfile'
| 'profiles'
| 'currentCollection'
| 'collections'
| 'cpuUsage'
| 'fps'
| 'droppedFrames'
| 'renderSkippedFrames'
>;
const RECONNECT_DELAY_MS = 5000;
/**
* Enveloppe obs-websocket : maintient la session, expose les actions du
* protocole et produit un instantané d'état à chaque cycle de statut.
*/
export class ObsController extends EventEmitter {
private readonly obs = new OBSWebSocket();
private settings: ObsSettings;
private connected = false;
private connecting: Promise<void> | null = null;
private autoReconnect = false;
private reconnectTimer: NodeJS.Timeout | null = null;
private lastError: string | undefined;
private lastRecordingPath: string | undefined;
private cachedVersion: string | undefined;
constructor(settings: ObsSettings) {
super();
this.settings = settings;
this.obs.on('ConnectionClosed', (err: unknown) => {
const wasConnected = this.connected;
this.connected = false;
this.cachedVersion = undefined;
if (wasConnected) {
this.lastError = err instanceof Error ? err.message : undefined;
this.emit('log', 'warn', `Session OBS fermée${this.lastError ? ` : ${this.lastError}` : ''}`);
}
this.scheduleReconnect();
});
this.obs.on('RecordStateChanged', (event) => {
if (typeof event.outputPath === 'string' && event.outputPath) {
this.lastRecordingPath = event.outputPath;
}
this.emit('log', 'info', `Enregistrement : ${String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '')}`);
});
this.obs.on('StreamStateChanged', (event) => {
this.emit('log', 'info', `Diffusion : ${String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '')}`);
});
}
get isConnected(): boolean {
return this.connected;
}
applySettings(settings: ObsSettings): void {
const changed =
settings.host !== this.settings.host ||
settings.port !== this.settings.port ||
settings.password !== this.settings.password;
this.settings = settings;
if (changed && this.connected) {
this.emit('log', 'info', 'Paramètres OBS modifiés, reconnexion');
void this.reconnect();
}
}
async connect(): Promise<void> {
if (this.connected) return;
if (this.connecting) return this.connecting;
this.autoReconnect = true;
const url = `ws://${this.settings.host}:${this.settings.port}`;
this.connecting = (async () => {
try {
const info = await this.obs.connect(url, this.settings.password || undefined, {
rpcVersion: 1,
});
this.connected = true;
this.lastError = undefined;
this.cachedVersion = info.obsWebSocketVersion;
this.emit('log', 'info', `Connecté à OBS ${url} (obs-websocket ${info.obsWebSocketVersion})`);
} catch (err) {
this.connected = false;
this.lastError = err instanceof Error ? err.message : String(err);
throw new Error(`Connexion à OBS impossible (${url}) : ${this.lastError}`);
} finally {
this.connecting = null;
}
})();
return this.connecting;
}
async disconnect(): Promise<void> {
this.autoReconnect = false;
this.clearReconnect();
if (this.connected) await this.obs.disconnect();
this.connected = false;
this.emit('log', 'info', 'Déconnecté d\'OBS');
}
private async reconnect(): Promise<void> {
try {
await this.obs.disconnect();
} catch {
/* ignore */
}
this.connected = false;
await this.connect().catch((err: Error) => this.emit('log', 'warn', err.message));
}
private scheduleReconnect(): void {
if (!this.autoReconnect || this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.autoReconnect || this.connected) return;
this.connect().catch(() => {
/* la prochaine fermeture reprogrammera un essai */
});
}, RECONNECT_DELAY_MS);
this.reconnectTimer.unref?.();
}
private clearReconnect(): void {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// --- Exécution des actions du protocole ---------------------------------
async execute(action: AgentAction, params: Record<string, unknown> = {}): Promise<unknown> {
switch (action) {
case 'obs.connect':
await this.connect();
return { connected: true };
case 'obs.disconnect':
await this.disconnect();
return { connected: false };
case 'obs.refresh':
this.requireConnection();
return this.snapshot();
case 'record.start': {
this.requireConnection();
await this.obs.call('StartRecord');
return { recording: true };
}
case 'record.stop': {
this.requireConnection();
const result = await this.obs.call('StopRecord');
this.lastRecordingPath = result.outputPath;
return { recording: false, outputPath: result.outputPath };
}
case 'record.pause':
this.requireConnection();
await this.obs.call('PauseRecord');
return { paused: true };
case 'record.resume':
this.requireConnection();
await this.obs.call('ResumeRecord');
return { paused: false };
case 'record.split':
this.requireConnection();
await this.obs.call('SplitRecordFile');
return { split: true };
case 'stream.start':
this.requireConnection();
await this.obs.call('StartStream');
return { streaming: true };
case 'stream.stop':
this.requireConnection();
await this.obs.call('StopStream');
return { streaming: false };
case 'scene.set': {
this.requireConnection();
const sceneName = requireString(params.scene, 'scene');
await this.obs.call('SetCurrentProgramScene', { sceneName });
return { scene: sceneName };
}
case 'profile.set': {
this.requireConnection();
const profileName = requireString(params.profile, 'profile');
await this.obs.call('SetCurrentProfile', { profileName });
return { profile: profileName };
}
case 'collection.set': {
this.requireConnection();
const sceneCollectionName = requireString(params.collection, 'collection');
await this.obs.call('SetCurrentSceneCollection', { sceneCollectionName });
return { collection: sceneCollectionName };
}
case 'recordDirectory.set': {
this.requireConnection();
const recordDirectory = requireString(params.directory, 'directory');
await this.obs.call('SetRecordDirectory', { recordDirectory });
return { recordDirectory };
}
case 'agent.ping':
return { pong: Date.now() };
default: {
const exhaustive: never = action;
throw new Error(`Action non gérée : ${String(exhaustive)}`);
}
}
}
/** Interroge OBS pour construire l'état courant ; ne lève jamais. */
async snapshot(): Promise<ObsSnapshot> {
const base: ObsSnapshot = {
obsConnected: this.connected,
obsVersion: this.cachedVersion,
obsError: this.connected ? undefined : this.lastError,
recording: false,
recordPaused: false,
streaming: false,
scenes: [],
profiles: [],
collections: [],
};
if (!this.connected) return base;
try {
const [record, stream, sceneList, profileList, collectionList, stats, directory] =
await Promise.all([
this.obs.call('GetRecordStatus'),
this.obs.call('GetStreamStatus'),
this.obs.call('GetSceneList'),
this.obs.call('GetProfileList'),
this.obs.call('GetSceneCollectionList'),
this.obs.call('GetStats'),
this.obs.call('GetRecordDirectory').catch(() => null),
]);
return {
...base,
recording: record.outputActive,
recordPaused: record.outputPaused,
recordTimecode: record.outputTimecode,
recordBytes: record.outputBytes,
recordDirectory: directory?.recordDirectory,
streaming: stream.outputActive,
streamTimecode: stream.outputTimecode,
currentScene: sceneList.currentProgramSceneName,
scenes: (sceneList.scenes as Array<{ sceneName?: string }>)
.map((scene) => scene.sceneName)
.filter((name): name is string => typeof name === 'string'),
currentProfile: profileList.currentProfileName,
profiles: profileList.profiles,
currentCollection: collectionList.currentSceneCollectionName,
collections: collectionList.sceneCollections,
cpuUsage: stats.cpuUsage,
fps: stats.activeFps,
droppedFrames: stats.outputSkippedFrames,
renderSkippedFrames: stats.renderSkippedFrames,
};
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err);
return { ...base, obsError: this.lastError };
}
}
get recordingPath(): string | undefined {
return this.lastRecordingPath;
}
private requireConnection(): void {
if (!this.connected) throw new Error('OBS n\'est pas connecté sur cet agent');
}
}
function requireString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`Paramètre « ${field} » manquant`);
}
return value;
}

View File

@@ -0,0 +1,56 @@
import fs from 'node:fs/promises';
import os from 'node:os';
interface CpuSample {
idle: number;
total: number;
}
function sampleCpu(): CpuSample {
let idle = 0;
let total = 0;
for (const cpu of os.cpus()) {
for (const [kind, value] of Object.entries(cpu.times)) {
total += value;
if (kind === 'idle') idle += value;
}
}
return { idle, total };
}
let previous = sampleCpu();
/** Charge CPU moyenne (0-100) depuis le dernier appel. */
export function cpuUsagePercent(): number {
const current = sampleCpu();
const idleDelta = current.idle - previous.idle;
const totalDelta = current.total - previous.total;
previous = current;
if (totalDelta <= 0) return 0;
return Math.round((1 - idleDelta / totalDelta) * 1000) / 10;
}
export interface DiskUsage {
freeBytes: number;
totalBytes: number;
}
/** Espace disque du volume contenant `directory` (répertoire d'enregistrement). */
export async function diskUsage(directory: string | undefined): Promise<DiskUsage | null> {
const target = directory || os.homedir();
try {
const stats = await fs.statfs(target);
const blockSize = Number(stats.bsize);
return {
freeBytes: Number(stats.bavail) * blockSize,
totalBytes: Number(stats.blocks) * blockSize,
};
} catch {
return null;
}
}
export function memoryUsage(): { used: number; total: number } {
const total = os.totalmem();
return { used: total - os.freemem(), total };
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"],
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../shared" }]
}