From 6f11b72cbb38d1aeca2aa2c3b4956bd162101cfa Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Tue, 11 Aug 2026 00:26:56 +0200 Subject: [PATCH] FP --- .env.example | 23 + .gitignore | 11 + README.md | 161 + deploy/nginx.conf.example | 33 + deploy/stream-control-agent.service | 25 + deploy/stream-control-server.service | 26 + deploy/windows-agent-task.ps1 | 41 + package-lock.json | 3279 +++++++++++++++++ package.json | 30 + packages/agent/agent.config.example.json | 11 + packages/agent/package.json | 25 + packages/agent/src/config.ts | 79 + packages/agent/src/index.ts | 215 ++ packages/agent/src/obs.ts | 313 ++ packages/agent/src/system.ts | 56 + packages/agent/tsconfig.json | 13 + packages/server/package.json | 24 + packages/server/src/agentGateway.ts | 154 + packages/server/src/api.ts | 209 ++ packages/server/src/auth.ts | 63 + packages/server/src/config.ts | 55 + packages/server/src/db.ts | 255 ++ packages/server/src/hub.ts | 267 ++ packages/server/src/index.ts | 111 + packages/server/tsconfig.json | 13 + packages/shared/package.json | 22 + packages/shared/src/index.ts | 257 ++ packages/shared/tsconfig.json | 8 + packages/web/index.html | 13 + packages/web/package.json | 23 + packages/web/src/App.tsx | 189 + packages/web/src/api.ts | 106 + packages/web/src/components/AgentCard.tsx | 165 + packages/web/src/components/AgentSettings.tsx | 161 + packages/web/src/components/LogPanel.tsx | 28 + packages/web/src/components/Login.tsx | 42 + packages/web/src/format.ts | 38 + packages/web/src/main.tsx | 13 + packages/web/src/styles.css | 474 +++ packages/web/src/useRealtime.ts | 88 + packages/web/tsconfig.json | 19 + packages/web/vite.config.ts | 19 + tsconfig.base.json | 18 + 43 files changed, 7175 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deploy/nginx.conf.example create mode 100644 deploy/stream-control-agent.service create mode 100644 deploy/stream-control-server.service create mode 100644 deploy/windows-agent-task.ps1 create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/agent/agent.config.example.json create mode 100644 packages/agent/package.json create mode 100644 packages/agent/src/config.ts create mode 100644 packages/agent/src/index.ts create mode 100644 packages/agent/src/obs.ts create mode 100644 packages/agent/src/system.ts create mode 100644 packages/agent/tsconfig.json create mode 100644 packages/server/package.json create mode 100644 packages/server/src/agentGateway.ts create mode 100644 packages/server/src/api.ts create mode 100644 packages/server/src/auth.ts create mode 100644 packages/server/src/config.ts create mode 100644 packages/server/src/db.ts create mode 100644 packages/server/src/hub.ts create mode 100644 packages/server/src/index.ts create mode 100644 packages/server/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/web/index.html create mode 100644 packages/web/package.json create mode 100644 packages/web/src/App.tsx create mode 100644 packages/web/src/api.ts create mode 100644 packages/web/src/components/AgentCard.tsx create mode 100644 packages/web/src/components/AgentSettings.tsx create mode 100644 packages/web/src/components/LogPanel.tsx create mode 100644 packages/web/src/components/Login.tsx create mode 100644 packages/web/src/format.ts create mode 100644 packages/web/src/main.tsx create mode 100644 packages/web/src/styles.css create mode 100644 packages/web/src/useRealtime.ts create mode 100644 packages/web/tsconfig.json create mode 100644 packages/web/vite.config.ts create mode 100644 tsconfig.base.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2ac59ed --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# --- Serveur de contrôle --- +PORT=8080 +HOST=0.0.0.0 + +# Mot de passe d'accès au dashboard web +ADMIN_PASSWORD=change-me + +# Secret utilisé pour signer les sessions du dashboard (32+ caractères aléatoires) +# Générer : node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +SESSION_SECRET=change-me-please-with-a-long-random-string + +# Jeton d'enrôlement : un agent qui se connecte avec ce jeton s'auto-enregistre +# et reçoit son propre jeton permanent. Mettre à vide pour désactiver l'enrôlement. +ENROLLMENT_TOKEN=change-me-enrollment-token + +# Base SQLite +DB_PATH=./data/stream-control.sqlite + +# Intervalle de remontée de statut demandé aux agents (ms) +STATUS_INTERVAL_MS=2000 + +# Délai avant de considérer un agent comme hors-ligne (ms) +AGENT_TIMEOUT_MS=15000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f2d27b --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +*.tsbuildinfo +.env +.env.local +data/ +*.sqlite +*.sqlite-journal +*.log +packages/agent/agent.config.json +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..d74ac9b --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# Stream Control + +Plan de contrôle web pour piloter des enregistrements OBS répartis sur plusieurs VM/VPS +(Windows et Ubuntu) depuis une seule interface. + +``` +┌────────────┐ HTTPS + WS ┌──────────────────┐ WS sortant ┌──────────────────┐ +│ Navigateur │ ───────────────►│ Serveur de │◄────────────────│ Agent (VM) │ +│ dashboard │◄─── temps réel ─│ contrôle │──── commandes ─►│ └► OBS (4455) │ +└────────────┘ │ Express + SQLite│ └──────────────────┘ + └──────────────────┘ ┌──────────────────┐ + ◄────────────────│ Agent (VM) … │ + └──────────────────┘ +``` + +**Le sens des connexions est le point clé** : ce sont les agents qui se connectent au +serveur, pas l'inverse. Aucun port entrant à ouvrir sur les VM d'enregistrement, aucune +IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`. + +## Fonctionnalités + +- Découverte automatique des agents par jeton d'enrôlement, ou provisionnement manuel. +- État temps réel par VM : OBS connecté, enregistrement en cours, durée, taille du + fichier, scène active, FPS, frames perdues, CPU, espace disque restant. +- Commandes unitaires ou groupées : démarrer/arrêter/mettre en pause/découper un + enregistrement, démarrer/arrêter un stream, changer de scène, de profil, de collection, + changer le dossier d'enregistrement. +- Journal d'évènements horodaté, persistant et diffusé en direct. +- Reconnexion automatique de bout en bout (agent → serveur, agent → OBS, dashboard → serveur). + +## Prérequis + +- Node.js **22+** sur le serveur et sur chaque VM (le serveur utilise `node:sqlite`). +- OBS **28+** sur chaque VM, avec *Outils → Paramètres du serveur WebSocket* activé. + Note le port (4455 par défaut) et le mot de passe. + +## Démarrage rapide (développement) + +```bash +npm install +cp .env.example .env # renseigne ADMIN_PASSWORD, SESSION_SECRET, ENROLLMENT_TOKEN +npm run dev # serveur :8080 + dashboard Vite :5173 +``` + +Puis, dans un autre terminal, un agent local : + +```bash +cd packages/agent +cp agent.config.example.json agent.config.json # colle le ENROLLMENT_TOKEN dans "token" +npm run dev +``` + +L'agent s'enrôle, reçoit son jeton permanent, le réécrit dans `agent.config.json`, et +apparaît dans le dashboard sur . + +## Déploiement + +### Serveur de contrôle + +```bash +npm ci && npm run build +npm start # sert l'API, le WebSocket et le dashboard compilé sur $PORT +``` + +Mets-le derrière un reverse proxy TLS ([exemple nginx](deploy/nginx.conf.example)) et +installe l'unité systemd [`deploy/stream-control-server.service`](deploy/stream-control-server.service). +`ADMIN_PASSWORD` et `SESSION_SECRET` sont obligatoires hors développement. + +### Agent Ubuntu + +Copie `packages/agent/dist`, `packages/agent/node_modules` et `agent.config.json` dans +`/opt/stream-control-agent`, puis installe +[`deploy/stream-control-agent.service`](deploy/stream-control-agent.service). + +### Agent Windows + +Un service Windows classique tourne en session 0 et ne verrait pas OBS. Utilise la tâche +planifiée « à l'ouverture de session » fournie : + +```powershell +.\deploy\windows-agent-task.ps1 -AgentDir 'C:\stream-control-agent' -RunAsUser 'VM01\obs' +``` + +## Configuration + +### Serveur — `.env` (voir [.env.example](.env.example)) + +| Variable | Rôle | +| --- | --- | +| `PORT`, `HOST` | Écoute HTTP/WebSocket | +| `ADMIN_PASSWORD` | Mot de passe du dashboard | +| `SESSION_SECRET` | Clé HMAC des sessions (32+ octets aléatoires) | +| `ENROLLMENT_TOKEN` | Jeton d'auto-enregistrement ; vide = désactivé | +| `DB_PATH` | Fichier SQLite | +| `STATUS_INTERVAL_MS` | Fréquence de remontée d'état des agents | +| `AGENT_TIMEOUT_MS` | Délai avant de déclarer un agent hors-ligne | + +### Agent — `agent.config.json` (ou variables d'environnement) + +| Clé | Variable | Rôle | +| --- | --- | --- | +| `serverUrl` | `SERVER_URL` | `wss://control.exemple.com/ws/agent` | +| `token` | `AGENT_TOKEN` | Jeton d'enrôlement puis jeton propre à l'agent | +| `name` | `AGENT_NAME` | Nom affiché (défaut : hostname) | +| `obs.host/port/password` | `OBS_HOST` / `OBS_PORT` / `OBS_PASSWORD` | Repli local ; le serveur pousse la config de référence | +| `insecureTls` | `INSECURE_TLS=1` | Accepter un certificat auto-signé | + +Les paramètres OBS édités dans le dashboard sont poussés à chaud vers l'agent : pas +besoin de se connecter à la VM pour changer un mot de passe obs-websocket. + +## API HTTP + +Toutes les routes hors `/api/login` exigent `Authorization: Bearer `. + +| Méthode | Route | Rôle | +| --- | --- | --- | +| `POST` | `/api/login` | Ouvre une session (`{ password }`) | +| `GET` | `/api/agents` | Liste des agents et de leur état | +| `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** | +| `PATCH` | `/api/agents/:id` | Nom, notes, paramètres OBS, auto-connexion | +| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) | +| `DELETE` | `/api/agents/:id` | Supprime l'agent | +| `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent | +| `POST` | `/api/commands/bulk` | Même action sur plusieurs agents, résultat par agent | +| `GET` | `/api/logs?limit=` | Journal récent | +| `GET` | `/healthz` | Sonde de vie (non authentifiée) | + +Actions disponibles : `obs.connect`, `obs.disconnect`, `obs.refresh`, `record.start`, +`record.stop`, `record.pause`, `record.resume`, `record.split`, `stream.start`, +`stream.stop`, `scene.set`, `profile.set`, `collection.set`, `recordDirectory.set`, +`agent.ping`. + +## Structure + +| Paquet | Rôle | +| --- | --- | +| [packages/shared/](packages/shared/) | Types du protocole, partagés par les trois autres | +| [packages/server/](packages/server/) | API REST, passerelle agents, diffusion dashboard, SQLite | +| [packages/agent/](packages/agent/) | Binaire à déployer sur chaque VM, pilote OBS | +| [packages/web/](packages/web/) | Dashboard React/Vite | + +Points d'entrée utiles : [packages/shared/src/index.ts](packages/shared/src/index.ts) +(le protocole), [packages/server/src/hub.ts](packages/server/src/hub.ts) (état central et +dispatch), [packages/agent/src/obs.ts](packages/agent/src/obs.ts) (traduction +action → obs-websocket). + +## Sécurité + +- Les jetons d'agent ne sont stockés qu'en SHA-256 ; le clair n'est affiché qu'à la création. +- Le mot de passe obs-websocket n'est jamais renvoyé au navigateur (masqué en `********`). +- Les sessions dashboard sont des jetons HMAC à durée limitée, sans état serveur. +- **Sers l'application en HTTPS/WSS** : jetons d'agent et de session circulent dans les + en-têtes et les URL de WebSocket. +- Après un `POST /api/agents/:id/token`, l'agent est déconnecté jusqu'à ce que son + `agent.config.json` soit mis à jour. + +## Pistes d'évolution + +Enregistrements programmés (cron par agent), rapatriement automatique des fichiers +(rclone/S3 déclenché à `record.stop`), alertes sur seuil d'espace disque ou de frames +perdues, comptes utilisateurs multiples, groupes d'agents. diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100644 index 0000000..db28c86 --- /dev/null +++ b/deploy/nginx.conf.example @@ -0,0 +1,33 @@ +# Reverse proxy TLS devant le serveur de contrôle. +# Indispensable en production : les jetons d'agent et de session transitent en clair sinon. + +server { + listen 443 ssl http2; + server_name control.exemple.com; + + ssl_certificate /etc/letsencrypt/live/control.exemple.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/control.exemple.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Upgrade WebSocket (/ws/agent et /ws/dashboard) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Les sessions agent sont longues : pas de coupure prématurée. + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} + +server { + listen 80; + server_name control.exemple.com; + return 301 https://$host$request_uri; +} diff --git a/deploy/stream-control-agent.service b/deploy/stream-control-agent.service new file mode 100644 index 0000000..14ec6e3 --- /dev/null +++ b/deploy/stream-control-agent.service @@ -0,0 +1,25 @@ +# Agent Ubuntu — /etc/systemd/system/stream-control-agent.service +# +# L'agent doit tourner dans la MÊME session que OBS s'il pilote un OBS graphique. +# Sur une VM headless, OBS tourne généralement sous Xvfb : garder le même User. +# +# systemctl daemon-reload && systemctl enable --now stream-control-agent + +[Unit] +Description=Stream Control — agent OBS +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=obs +WorkingDirectory=/opt/stream-control-agent +Environment=NODE_ENV=production +# Chemin du fichier d'identité (jeton + agentId) — doit être inscriptible. +Environment=AGENT_CONFIG=/opt/stream-control-agent/agent.config.json +ExecStart=/usr/bin/node /opt/stream-control-agent/dist/index.js +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/stream-control-server.service b/deploy/stream-control-server.service new file mode 100644 index 0000000..ab180fe --- /dev/null +++ b/deploy/stream-control-server.service @@ -0,0 +1,26 @@ +# Serveur de contrôle — /etc/systemd/system/stream-control-server.service +# systemctl daemon-reload && systemctl enable --now stream-control-server + +[Unit] +Description=Stream Control — serveur de contrôle +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=stream +WorkingDirectory=/opt/stream-control +Environment=NODE_ENV=production +ExecStart=/usr/bin/node --disable-warning=ExperimentalWarning packages/server/dist/index.js +Restart=always +RestartSec=5 + +# Durcissement +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/stream-control/data + +[Install] +WantedBy=multi-user.target diff --git a/deploy/windows-agent-task.ps1 b/deploy/windows-agent-task.ps1 new file mode 100644 index 0000000..f6cea54 --- /dev/null +++ b/deploy/windows-agent-task.ps1 @@ -0,0 +1,41 @@ +# Installe l'agent Stream Control comme tâche planifiée Windows. +# +# L'agent doit s'exécuter dans la session interactive de l'utilisateur qui lance +# OBS : un service Windows classique tourne en session 0 et ne verrait pas OBS. +# On utilise donc une tâche « à l'ouverture de session » avec redémarrage auto. +# +# Usage (PowerShell administrateur) : +# .\windows-agent-task.ps1 -AgentDir 'C:\stream-control-agent' -RunAsUser 'VM01\obs' + +param( + [string]$AgentDir = 'C:\stream-control-agent', + [string]$RunAsUser = "$env:USERDOMAIN\$env:USERNAME", + [string]$TaskName = 'StreamControlAgent' +) + +$ErrorActionPreference = 'Stop' + +$node = (Get-Command node).Source +$entry = Join-Path $AgentDir 'dist\index.js' + +if (-not (Test-Path $entry)) { + throw "Agent introuvable : $entry — copie d'abord packages/agent (dist + node_modules) dans $AgentDir" +} +if (-not (Test-Path (Join-Path $AgentDir 'agent.config.json'))) { + Write-Warning "agent.config.json absent de $AgentDir : l'agent refusera de démarrer sans jeton." +} + +$action = New-ScheduledTaskAction -Execute $node -Argument "`"$entry`"" -WorkingDirectory $AgentDir +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $RunAsUser +$principal = New-ScheduledTaskPrincipal -UserId $RunAsUser -LogonType Interactive -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ` + -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew + +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` + -Principal $principal -Settings $settings -Force | Out-Null + +Start-ScheduledTask -TaskName $TaskName +Write-Host "Tâche « $TaskName » installée et démarrée pour $RunAsUser." +Write-Host "Journal : Get-ScheduledTaskInfo -TaskName $TaskName" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..33b8b4d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3279 @@ +{ + "name": "stream-control", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stream-control", + "version": "0.1.0", + "workspaces": [ + "packages/shared", + "packages/server", + "packages/agent", + "packages/web" + ], + "devDependencies": { + "concurrently": "^9.1.2", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@msgpack/msgpack": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz", + "integrity": "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@stream-control/agent": { + "resolved": "packages/agent", + "link": true + }, + "node_modules/@stream-control/server": { + "resolved": "packages/server", + "link": true + }, + "node_modules/@stream-control/shared": { + "resolved": "packages/shared", + "link": true + }, + "node_modules/@stream-control/web": { + "resolved": "packages/web", + "link": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.403", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.403.tgz", + "integrity": "sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obs-websocket-js": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/obs-websocket-js/-/obs-websocket-js-5.0.8.tgz", + "integrity": "sha512-QDnQJMr5wuCoYugK02ggZ1/cvESs4KJDEK+UhGg0Ry35jnY8tK1Xr3KoPjKyoD6sd8G+WdtIdzuY+yUyPtogQQ==", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^2.7.1", + "crypto-js": "^4.1.1", + "debug": "^4.3.2", + "eventemitter3": "^5.0.1", + "isomorphic-ws": "^5.0.0", + "type-fest": "^3.11.0", + "ws": "^8.13.0" + }, + "engines": { + "node": ">16.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "packages/agent": { + "name": "@stream-control/agent", + "version": "0.1.0", + "dependencies": { + "@stream-control/shared": "*", + "obs-websocket-js": "^5.0.6", + "ws": "^8.18.0" + }, + "bin": { + "stream-control-agent": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/ws": "^8.5.13", + "typescript": "^5.7.3" + } + }, + "packages/server": { + "name": "@stream-control/server", + "version": "0.1.0", + "dependencies": { + "@stream-control/shared": "*", + "dotenv": "^16.4.7", + "express": "^5.0.1", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.10.5", + "@types/ws": "^8.5.13", + "typescript": "^5.7.3" + } + }, + "packages/shared": { + "name": "@stream-control/shared", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.7.3" + } + }, + "packages/web": { + "name": "@stream-control/web", + "version": "0.1.0", + "dependencies": { + "@stream-control/shared": "*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e00d8cd --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "stream-control", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Plan de contrôle web pour piloter des agents OBS sur plusieurs VM/VPS", + "engines": { + "node": ">=22" + }, + "workspaces": [ + "packages/shared", + "packages/server", + "packages/agent", + "packages/web" + ], + "scripts": { + "build": "npm run build -w @stream-control/shared && npm run build -w @stream-control/server && npm run build -w @stream-control/agent && npm run build -w @stream-control/web", + "build:shared": "npm run build -w @stream-control/shared", + "dev": "npm run build:shared && concurrently -n server,web -c cyan,magenta \"npm:dev:server\" \"npm:dev:web\"", + "dev:server": "npm run dev -w @stream-control/server", + "dev:web": "npm run dev -w @stream-control/web", + "dev:agent": "npm run dev -w @stream-control/agent", + "start": "npm run start -w @stream-control/server", + "typecheck": "tsc -b packages/shared packages/server packages/agent --pretty" + }, + "devDependencies": { + "concurrently": "^9.1.2", + "typescript": "^5.7.3" + } +} diff --git a/packages/agent/agent.config.example.json b/packages/agent/agent.config.example.json new file mode 100644 index 0000000..85a8429 --- /dev/null +++ b/packages/agent/agent.config.example.json @@ -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 +} diff --git a/packages/agent/package.json b/packages/agent/package.json new file mode 100644 index 0000000..0354c78 --- /dev/null +++ b/packages/agent/package.json @@ -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" + } +} diff --git a/packages/agent/src/config.ts b/packages/agent/src/config.ts new file mode 100644 index 0000000..46a5f70 --- /dev/null +++ b/packages/agent/src/config.ts @@ -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 { + if (!fs.existsSync(CONFIG_PATH)) return {}; + try { + return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) as Partial; + } 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 }; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts new file mode 100644 index 0000000..5184ff0 --- /dev/null +++ b/packages/agent/src/index.ts @@ -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(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 { + 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 { + 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 { + 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 { + 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(); diff --git a/packages/agent/src/obs.ts b/packages/agent/src/obs.ts new file mode 100644 index 0000000..1fddb3a --- /dev/null +++ b/packages/agent/src/obs.ts @@ -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 | 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 { + 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 { + 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 { + 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 = {}): Promise { + 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 { + 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; +} diff --git a/packages/agent/src/system.ts b/packages/agent/src/system.ts new file mode 100644 index 0000000..90a6eb6 --- /dev/null +++ b/packages/agent/src/system.ts @@ -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 { + 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 }; +} diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json new file mode 100644 index 0000000..40d2ce4 --- /dev/null +++ b/packages/agent/tsconfig.json @@ -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" }] +} diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..c6d2d96 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,24 @@ +{ + "name": "@stream-control/server", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "scripts": { + "build": "tsc -b", + "dev": "node --experimental-strip-types --disable-warning=ExperimentalWarning --watch src/index.ts", + "start": "node --disable-warning=ExperimentalWarning dist/index.js" + }, + "dependencies": { + "@stream-control/shared": "*", + "dotenv": "^16.4.7", + "express": "^5.0.1", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.10.5", + "@types/ws": "^8.5.13", + "typescript": "^5.7.3" + } +} diff --git a/packages/server/src/agentGateway.ts b/packages/server/src/agentGateway.ts new file mode 100644 index 0000000..62ba350 --- /dev/null +++ b/packages/server/src/agentGateway.ts @@ -0,0 +1,154 @@ +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage } from 'node:http'; +import type { RawData, WebSocket } from 'ws'; +import type { AgentToServer, ServerToAgent } from '@stream-control/shared'; +import { PROTOCOL_VERSION, detectPlatform, safeJsonParse } from '@stream-control/shared'; +import { config } from './config.ts'; +import { extractBearer, generateToken, hashToken, safeEqual } from './auth.ts'; +import { agentsRepo, type AgentRecord } from './db.ts'; +import { hub } from './hub.ts'; + +const HELLO_TIMEOUT_MS = 10_000; + +export type AgentAuth = + | { mode: 'known'; record: AgentRecord } + | { mode: 'enroll' }; + +/** + * Authentifie une tentative de connexion agent à partir du jeton porté par la + * requête d'upgrade. Renvoie null si le jeton est inconnu. + */ +export function authenticateAgent(req: IncomingMessage): AgentAuth | null { + const url = new URL(req.url ?? '/', 'http://localhost'); + const token = + extractBearer(req.headers.authorization) ?? + url.searchParams.get('token') ?? + (typeof req.headers['x-agent-token'] === 'string' ? req.headers['x-agent-token'] : null); + + if (!token) return null; + + const record = agentsRepo.findByTokenHash(hashToken(token)); + if (record) return { mode: 'known', record }; + + if (config.enrollmentToken && safeEqual(token, config.enrollmentToken)) { + return { mode: 'enroll' }; + } + return null; +} + +function send(socket: WebSocket, message: ServerToAgent): void { + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message)); +} + +export function handleAgentConnection( + socket: WebSocket, + auth: AgentAuth, + remoteAddress: string, +): void { + let agentId: string | null = null; + + const helloTimer = setTimeout(() => { + if (!agentId) socket.close(4002, 'Message hello absent'); + }, HELLO_TIMEOUT_MS); + + socket.on('message', (raw: RawData) => { + const message = safeJsonParse(raw.toString()); + if (!message || typeof message.type !== 'string') { + hub.log(agentId, 'warn', 'Message agent illisible, ignoré'); + return; + } + + // Tant que l'agent ne s'est pas présenté, seul `hello` est accepté. + if (!agentId && message.type !== 'hello') return; + + switch (message.type) { + case 'hello': { + if (agentId) return; // hello dupliqué + clearTimeout(helloTimer); + + if (message.protocol !== PROTOCOL_VERSION) { + hub.log( + null, + 'warn', + `Agent « ${message.name} » en protocole v${message.protocol}, serveur en v${PROTOCOL_VERSION}`, + ); + } + + const platform = detectPlatform(message.platform); + let record: AgentRecord; + let issuedToken: string | undefined; + + if (auth.mode === 'enroll') { + issuedToken = generateToken(); + record = agentsRepo.create({ + id: message.agentId?.trim() || randomUUID(), + name: message.name || message.hostname || 'agent', + tokenHash: hashToken(issuedToken), + hostname: message.hostname, + platform, + agentVersion: message.agentVersion, + }); + hub.log(record.id, 'info', `Nouvel agent enrôlé depuis ${remoteAddress}`); + } else { + record = auth.record; + agentsRepo.updateIdentity(record.id, { + hostname: message.hostname, + platform, + agentVersion: message.agentVersion, + }); + record = agentsRepo.get(record.id) ?? record; + } + + agentId = record.id; + hub.attachAgent(record.id, socket); + hub.log(record.id, 'info', `Agent connecté (${platform}, ${remoteAddress})`); + + send(socket, { + type: 'welcome', + agentId: record.id, + token: issuedToken, + obs: record.obs, + statusIntervalMs: config.statusIntervalMs, + autoConnectObs: record.autoConnectObs, + }); + break; + } + + case 'status': { + if (!agentId) return; + hub.updateStatus(agentId, { ...message.status, updatedAt: Date.now() }); + agentsRepo.touch(agentId); + break; + } + + case 'result': { + if (!agentId) return; + hub.markSeen(agentId); + hub.resolveCommand(agentId, message.requestId, message.ok, message.data, message.error); + break; + } + + case 'log': { + hub.log(agentId, message.level, message.message, message.ts || Date.now()); + break; + } + + case 'pong': { + if (agentId) hub.markSeen(agentId); + break; + } + } + }); + + socket.on('close', () => { + clearTimeout(helloTimer); + if (agentId) { + hub.detachAgent(agentId, socket); + hub.log(agentId, 'info', 'Agent déconnecté'); + } + }); + + socket.on('error', (err: Error) => { + hub.log(agentId, 'error', `Erreur socket agent : ${err.message}`); + }); +} diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts new file mode 100644 index 0000000..e4bf74f --- /dev/null +++ b/packages/server/src/api.ts @@ -0,0 +1,209 @@ +import { randomUUID } from 'node:crypto'; +import { Router } from 'express'; +import type { ObsSettings } from '@stream-control/shared'; +import { AGENT_ACTIONS, DEFAULT_OBS_SETTINGS, isAgentAction } from '@stream-control/shared'; +import { config } from './config.ts'; +import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts'; +import { agentsRepo, logsRepo } from './db.ts'; +import { hub } from './hub.ts'; + +export const api: Router = Router(); + +// --- Authentification ------------------------------------------------------- + +api.post('/login', (req, res) => { + const password = typeof req.body?.password === 'string' ? req.body.password : ''; + if (!safeEqual(password, config.adminPassword)) { + hub.log(null, 'warn', `Échec de connexion au dashboard depuis ${req.ip}`); + res.status(401).json({ error: 'Mot de passe invalide' }); + return; + } + res.json(issueSession()); +}); + +api.use(requireSession); + +api.get('/session', (_req, res) => { + res.json({ ok: true, actions: AGENT_ACTIONS }); +}); + +// --- Agents ----------------------------------------------------------------- + +api.get('/agents', (_req, res) => { + res.json({ agents: hub.views() }); +}); + +api.get('/agents/:id', (req, res) => { + const record = agentsRepo.get(req.params.id); + if (!record) { + res.status(404).json({ error: 'Agent introuvable' }); + return; + } + res.json({ agent: hub.view(record) }); +}); + +/** Provisionnement manuel : renvoie le jeton en clair une seule fois. */ +api.post('/agents', (req, res) => { + const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + if (!name) { + res.status(400).json({ error: 'Le nom est obligatoire' }); + return; + } + + const token = generateToken(); + const record = agentsRepo.create({ + id: randomUUID(), + name, + tokenHash: hashToken(token), + obs: parseObs(req.body?.obs), + autoConnectObs: req.body?.autoConnectObs !== false, + notes: typeof req.body?.notes === 'string' ? req.body.notes : null, + }); + + hub.log(record.id, 'info', `Agent « ${name} » créé depuis le dashboard`); + hub.publishAgent(record.id); + res.status(201).json({ agent: hub.view(record), token }); +}); + +api.patch('/agents/:id', (req, res) => { + const record = agentsRepo.get(req.params.id); + if (!record) { + res.status(404).json({ error: 'Agent introuvable' }); + return; + } + + const obs = parseObs(req.body?.obs, record.obs); + // Le dashboard reçoit le mot de passe masqué : on ne l'écrase pas s'il revient tel quel. + if (obs.password === '********') obs.password = record.obs.password; + + agentsRepo.updateSettings(record.id, { + name: typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : record.name, + obs, + autoConnectObs: + typeof req.body?.autoConnectObs === 'boolean' ? req.body.autoConnectObs : record.autoConnectObs, + notes: typeof req.body?.notes === 'string' ? req.body.notes : record.notes, + }); + + const updated = agentsRepo.get(record.id); + if (updated) { + hub.pushConfig(updated); + hub.publishAgent(updated.id); + res.json({ agent: hub.view(updated) }); + } else { + res.status(500).json({ error: 'Mise à jour impossible' }); + } +}); + +/** Régénère le jeton d'un agent : l'ancienne session est coupée. */ +api.post('/agents/:id/token', (req, res) => { + const record = agentsRepo.get(req.params.id); + if (!record) { + res.status(404).json({ error: 'Agent introuvable' }); + return; + } + const token = generateToken(); + agentsRepo.rotateToken(record.id, hashToken(token)); + hub.disconnectAgent(record.id, 'Jeton régénéré'); + hub.log(record.id, 'warn', 'Jeton régénéré : reconfigure l\'agent'); + res.json({ token }); +}); + +api.delete('/agents/:id', (req, res) => { + const record = agentsRepo.get(req.params.id); + if (!record) { + res.status(404).json({ error: 'Agent introuvable' }); + return; + } + hub.disconnectAgent(record.id, 'Agent supprimé'); + agentsRepo.remove(record.id); + hub.publishRemoval(record.id); + hub.log(null, 'warn', `Agent « ${record.name} » supprimé`); + res.json({ ok: true }); +}); + +// --- Commandes -------------------------------------------------------------- + +api.post('/agents/:id/command', async (req, res) => { + const action = req.body?.action; + if (!isAgentAction(action)) { + res.status(400).json({ error: `Action inconnue : ${String(action)}` }); + return; + } + const record = agentsRepo.get(req.params.id); + if (!record) { + res.status(404).json({ error: 'Agent introuvable' }); + return; + } + + try { + const data = await hub.sendCommand(record.id, action, req.body?.params); + hub.log(record.id, 'info', `Commande « ${action} » exécutée`); + res.json({ ok: true, data }); + } catch (err) { + const messageText = err instanceof Error ? err.message : String(err); + hub.log(record.id, 'error', `Commande « ${action} » en échec : ${messageText}`); + res.status(502).json({ ok: false, error: messageText }); + } +}); + +/** Commande groupée : même action sur plusieurs agents, résultats par agent. */ +api.post('/commands/bulk', async (req, res) => { + const action = req.body?.action; + if (!isAgentAction(action)) { + res.status(400).json({ error: `Action inconnue : ${String(action)}` }); + return; + } + + const requested: string[] = Array.isArray(req.body?.agentIds) + ? req.body.agentIds.filter((id: unknown): id is string => typeof id === 'string') + : agentsRepo.list().map((record) => record.id); + + const results = await Promise.all( + requested.map(async (agentId) => { + try { + const data = await hub.sendCommand(agentId, action, req.body?.params); + return { agentId, ok: true as const, data }; + } catch (err) { + return { + agentId, + ok: false as const, + error: err instanceof Error ? err.message : String(err), + }; + } + }), + ); + + const failed = results.filter((result) => !result.ok).length; + hub.log( + null, + failed ? 'warn' : 'info', + `Commande groupée « ${action} » : ${results.length - failed}/${results.length} OK`, + ); + res.json({ results }); +}); + +// --- Divers ----------------------------------------------------------------- + +api.get('/logs', (req, res) => { + const limit = Math.min(Number.parseInt(String(req.query.limit ?? '200'), 10) || 200, 1000); + res.json({ logs: logsRepo.recent(limit) }); +}); + +/** Infos nécessaires pour configurer un nouvel agent. */ +api.get('/enrollment', (req, res) => { + res.json({ + enabled: Boolean(config.enrollmentToken), + token: config.enrollmentToken || null, + serverUrl: `${req.protocol === 'https' ? 'wss' : 'ws'}://${req.get('host')}/ws/agent`, + }); +}); + +function parseObs(raw: unknown, fallback: ObsSettings = DEFAULT_OBS_SETTINGS): ObsSettings { + const input = (raw ?? {}) as Partial; + const port = Number(input.port); + return { + host: typeof input.host === 'string' && input.host.trim() ? input.host.trim() : fallback.host, + port: Number.isFinite(port) && port > 0 && port < 65536 ? port : fallback.port, + password: typeof input.password === 'string' ? input.password : fallback.password, + }; +} diff --git a/packages/server/src/auth.ts b/packages/server/src/auth.ts new file mode 100644 index 0000000..07f1200 --- /dev/null +++ b/packages/server/src/auth.ts @@ -0,0 +1,63 @@ +import { createHmac, createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import type { NextFunction, Request, Response } from 'express'; +import { config } from './config.ts'; + +/** Comparaison à temps constant, tolérante aux longueurs différentes. */ +export function safeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a, 'utf8'); + const bufB = Buffer.from(b, 'utf8'); + if (bufA.length !== bufB.length) { + // On compare quand même pour ne pas court-circuiter sur la longueur. + timingSafeEqual(bufA, bufA); + return false; + } + return timingSafeEqual(bufA, bufB); +} + +export function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export function generateToken(): string { + return randomBytes(32).toString('base64url'); +} + +// --- Sessions du dashboard : jeton `.` sans stockage serveur --- + +function sign(payload: string): string { + return createHmac('sha256', config.sessionSecret).update(payload).digest('base64url'); +} + +export function issueSession(): { token: string; expiresAt: number } { + const expiresAt = Date.now() + config.sessionTtlMs; + const payload = String(expiresAt); + return { token: `${payload}.${sign(payload)}`, expiresAt }; +} + +export function verifySession(token: string | undefined | null): boolean { + if (!token) return false; + const dot = token.lastIndexOf('.'); + if (dot <= 0) return false; + const payload = token.slice(0, dot); + const signature = token.slice(dot + 1); + if (!safeEqual(signature, sign(payload))) return false; + const expiresAt = Number.parseInt(payload, 10); + return Number.isFinite(expiresAt) && expiresAt > Date.now(); +} + +export function extractBearer(header: string | undefined): string | null { + if (!header) return null; + const [scheme, value] = header.split(' '); + if (!value || scheme?.toLowerCase() !== 'bearer') return null; + return value.trim(); +} + +/** Middleware Express protégeant les routes /api (hors login). */ +export function requireSession(req: Request, res: Response, next: NextFunction): void { + const token = extractBearer(req.headers.authorization) ?? (req.query.token as string | undefined); + if (!verifySession(token)) { + res.status(401).json({ error: 'Non authentifié' }); + return; + } + next(); +} diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts new file mode 100644 index 0000000..9e716f1 --- /dev/null +++ b/packages/server/src/config.ts @@ -0,0 +1,55 @@ +import { randomBytes } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '../../..'); + +dotenv.config({ path: path.join(repoRoot, '.env'), quiet: true }); + +function required(name: string, fallback?: string): string { + const value = process.env[name] ?? fallback; + if (!value) { + throw new Error( + `Variable d'environnement manquante : ${name}. Copie .env.example vers .env et complète-la.`, + ); + } + return value; +} + +function int(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +const isDev = process.env.NODE_ENV !== 'production'; + +export const config = { + port: int('PORT', 8080), + host: process.env.HOST ?? '0.0.0.0', + isDev, + + adminPassword: required('ADMIN_PASSWORD', isDev ? 'admin' : undefined), + sessionSecret: required( + 'SESSION_SECRET', + isDev ? randomBytes(32).toString('hex') : undefined, + ), + sessionTtlMs: int('SESSION_TTL_MS', 12 * 60 * 60 * 1000), + + /** Vide = enrôlement automatique désactivé. */ + enrollmentToken: process.env.ENROLLMENT_TOKEN ?? '', + + dbPath: path.resolve(repoRoot, process.env.DB_PATH ?? './data/stream-control.sqlite'), + + statusIntervalMs: int('STATUS_INTERVAL_MS', 2000), + agentTimeoutMs: int('AGENT_TIMEOUT_MS', 15_000), + commandTimeoutMs: int('COMMAND_TIMEOUT_MS', 15_000), + + /** Nombre d'entrées de journal conservées en base. */ + logRetention: int('LOG_RETENTION', 2000), + + webDist: path.resolve(repoRoot, 'packages/web/dist'), +} as const; diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts new file mode 100644 index 0000000..bc232a0 --- /dev/null +++ b/packages/server/src/db.ts @@ -0,0 +1,255 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import type { LogEntry, LogLevel, ObsSettings, Platform } from '@stream-control/shared'; +import { DEFAULT_OBS_SETTINGS } from '@stream-control/shared'; +import { config } from './config.ts'; + +fs.mkdirSync(path.dirname(config.dbPath), { recursive: true }); + +export const db = new DatabaseSync(config.dbPath); + +db.exec('PRAGMA journal_mode = WAL'); +db.exec('PRAGMA foreign_keys = ON'); + +db.exec(` + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + hostname TEXT, + platform TEXT NOT NULL DEFAULT 'unknown', + agent_version TEXT, + token_hash TEXT NOT NULL, + obs_host TEXT NOT NULL DEFAULT '127.0.0.1', + obs_port INTEGER NOT NULL DEFAULT 4455, + obs_password TEXT NOT NULL DEFAULT '', + auto_connect INTEGER NOT NULL DEFAULT 1, + notes TEXT, + created_at INTEGER NOT NULL, + last_seen_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT, + level TEXT NOT NULL, + message TEXT NOT NULL, + ts INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs (ts DESC); +`); + +export interface AgentRow { + id: string; + name: string; + hostname: string | null; + platform: string; + agent_version: string | null; + token_hash: string; + obs_host: string; + obs_port: number; + obs_password: string; + auto_connect: number; + notes: string | null; + created_at: number; + last_seen_at: number | null; +} + +export interface AgentRecord { + id: string; + name: string; + hostname: string | null; + platform: Platform; + agentVersion: string | null; + tokenHash: string; + obs: ObsSettings; + autoConnectObs: boolean; + notes: string | null; + createdAt: number; + lastSeenAt: number | null; +} + +function toRecord(row: AgentRow): AgentRecord { + return { + id: row.id, + name: row.name, + hostname: row.hostname, + platform: (row.platform as Platform) ?? 'unknown', + agentVersion: row.agent_version, + tokenHash: row.token_hash, + obs: { + host: row.obs_host || DEFAULT_OBS_SETTINGS.host, + port: Number(row.obs_port) || DEFAULT_OBS_SETTINGS.port, + password: row.obs_password ?? '', + }, + autoConnectObs: Number(row.auto_connect) === 1, + notes: row.notes, + createdAt: Number(row.created_at), + lastSeenAt: row.last_seen_at === null ? null : Number(row.last_seen_at), + }; +} + +const stmts = { + listAgents: db.prepare('SELECT * FROM agents ORDER BY name COLLATE NOCASE'), + getAgent: db.prepare('SELECT * FROM agents WHERE id = ?'), + getAgentByTokenHash: db.prepare('SELECT * FROM agents WHERE token_hash = ?'), + insertAgent: db.prepare(` + INSERT INTO agents (id, name, hostname, platform, agent_version, token_hash, + obs_host, obs_port, obs_password, auto_connect, notes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + updateIdentity: db.prepare(` + UPDATE agents SET hostname = ?, platform = ?, agent_version = ?, last_seen_at = ? + WHERE id = ? + `), + updateSettings: db.prepare(` + UPDATE agents SET name = ?, obs_host = ?, obs_port = ?, obs_password = ?, + auto_connect = ?, notes = ? + WHERE id = ? + `), + touchAgent: db.prepare('UPDATE agents SET last_seen_at = ? WHERE id = ?'), + rotateToken: db.prepare('UPDATE agents SET token_hash = ? WHERE id = ?'), + deleteAgent: db.prepare('DELETE FROM agents WHERE id = ?'), + + insertLog: db.prepare('INSERT INTO logs (agent_id, level, message, ts) VALUES (?, ?, ?, ?)'), + recentLogs: db.prepare(` + SELECT l.id, l.agent_id, l.level, l.message, l.ts, a.name AS agent_name + FROM logs l LEFT JOIN agents a ON a.id = l.agent_id + ORDER BY l.id DESC LIMIT ? + `), + pruneLogs: db.prepare(` + DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT ?) + `), +}; + +export const agentsRepo = { + list(): AgentRecord[] { + return (stmts.listAgents.all() as unknown as AgentRow[]).map(toRecord); + }, + + get(id: string): AgentRecord | null { + const row = stmts.getAgent.get(id) as unknown as AgentRow | undefined; + return row ? toRecord(row) : null; + }, + + findByTokenHash(tokenHash: string): AgentRecord | null { + const row = stmts.getAgentByTokenHash.get(tokenHash) as unknown as AgentRow | undefined; + return row ? toRecord(row) : null; + }, + + create(input: { + id: string; + name: string; + tokenHash: string; + hostname?: string | null; + platform?: Platform; + agentVersion?: string | null; + obs?: Partial; + autoConnectObs?: boolean; + notes?: string | null; + }): AgentRecord { + const obs = { ...DEFAULT_OBS_SETTINGS, ...input.obs }; + stmts.insertAgent.run( + input.id, + input.name, + input.hostname ?? null, + input.platform ?? 'unknown', + input.agentVersion ?? null, + input.tokenHash, + obs.host, + obs.port, + obs.password, + input.autoConnectObs === false ? 0 : 1, + input.notes ?? null, + Date.now(), + ); + const created = agentsRepo.get(input.id); + if (!created) throw new Error(`Échec de création de l'agent ${input.id}`); + return created; + }, + + updateIdentity( + id: string, + identity: { hostname: string | null; platform: Platform; agentVersion: string | null }, + ): void { + stmts.updateIdentity.run( + identity.hostname, + identity.platform, + identity.agentVersion, + Date.now(), + id, + ); + }, + + updateSettings( + id: string, + settings: { + name: string; + obs: ObsSettings; + autoConnectObs: boolean; + notes: string | null; + }, + ): void { + stmts.updateSettings.run( + settings.name, + settings.obs.host, + settings.obs.port, + settings.obs.password, + settings.autoConnectObs ? 1 : 0, + settings.notes, + id, + ); + }, + + touch(id: string): void { + stmts.touchAgent.run(Date.now(), id); + }, + + rotateToken(id: string, tokenHash: string): void { + stmts.rotateToken.run(tokenHash, id); + }, + + remove(id: string): void { + stmts.deleteAgent.run(id); + }, +}; + +interface LogRow { + id: number; + agent_id: string | null; + agent_name: string | null; + level: string; + message: string; + ts: number; +} + +export const logsRepo = { + append(agentId: string | null, level: LogLevel, message: string, ts = Date.now()): LogEntry { + const info = stmts.insertLog.run(agentId, level, message, ts); + if (Math.random() < 0.02) stmts.pruneLogs.run(config.logRetention); + const agent = agentId ? agentsRepo.get(agentId) : null; + return { + id: Number(info.lastInsertRowid), + agentId, + agentName: agent?.name ?? null, + level, + message, + ts, + }; + }, + + recent(limit = 200): LogEntry[] { + const rows = stmts.recentLogs.all(limit) as unknown as LogRow[]; + return rows + .map((row) => ({ + id: Number(row.id), + agentId: row.agent_id, + agentName: row.agent_name, + level: row.level as LogLevel, + message: row.message, + ts: Number(row.ts), + })) + .reverse(); + }, +}; diff --git a/packages/server/src/hub.ts b/packages/server/src/hub.ts new file mode 100644 index 0000000..ad06fc9 --- /dev/null +++ b/packages/server/src/hub.ts @@ -0,0 +1,267 @@ +import { randomUUID } from 'node:crypto'; +import type { WebSocket } from 'ws'; +import type { + AgentAction, + AgentStatus, + AgentView, + LogEntry, + LogLevel, + ServerToAgent, + ServerToDashboard, +} from '@stream-control/shared'; +import { emptyStatus } from '@stream-control/shared'; +import { config } from './config.ts'; +import { agentsRepo, logsRepo, type AgentRecord } from './db.ts'; + +interface PendingCommand { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + timer: NodeJS.Timeout; +} + +interface AgentConnection { + socket: WebSocket; + lastSeenAt: number; + pending: Map; +} + +/** + * Point central : garde en mémoire les connexions agents + le dernier statut + * connu, dispatche les commandes et diffuse l'état aux dashboards ouverts. + */ +class Hub { + private readonly connections = new Map(); + private readonly statuses = new Map(); + private readonly dashboards = new Set(); + + // --- Agents ------------------------------------------------------------- + + attachAgent(agentId: string, socket: WebSocket): void { + // Une seule session par agent : la nouvelle connexion évince l'ancienne. + const existing = this.connections.get(agentId); + if (existing && existing.socket !== socket) { + this.failPending(existing, new Error('Connexion agent remplacée')); + try { + existing.socket.close(4000, 'Remplacé par une nouvelle session'); + } catch { + /* ignore */ + } + } + + this.connections.set(agentId, { socket, lastSeenAt: Date.now(), pending: new Map() }); + this.statuses.set(agentId, this.statuses.get(agentId) ?? emptyStatus()); + this.publishAgent(agentId); + } + + detachAgent(agentId: string, socket: WebSocket): void { + const connection = this.connections.get(agentId); + if (!connection || connection.socket !== socket) return; + this.failPending(connection, new Error('Agent déconnecté')); + this.connections.delete(agentId); + + // On garde le dernier statut connu mais on marque OBS comme injoignable. + const status = this.statuses.get(agentId); + if (status) { + this.statuses.set(agentId, { + ...status, + obsConnected: false, + recording: false, + streaming: false, + updatedAt: Date.now(), + }); + } + this.publishAgent(agentId); + } + + isOnline(agentId: string): boolean { + return this.connections.has(agentId); + } + + markSeen(agentId: string): void { + const connection = this.connections.get(agentId); + if (connection) connection.lastSeenAt = Date.now(); + } + + updateStatus(agentId: string, status: AgentStatus): void { + this.statuses.set(agentId, status); + this.markSeen(agentId); + this.publishAgent(agentId); + } + + resolveCommand(agentId: string, requestId: string, ok: boolean, data: unknown, error?: string): void { + const pending = this.connections.get(agentId)?.pending.get(requestId); + if (!pending) return; + this.connections.get(agentId)?.pending.delete(requestId); + clearTimeout(pending.timer); + if (ok) pending.resolve(data); + else pending.reject(new Error(error ?? 'La commande a échoué')); + } + + /** Envoie une commande à un agent et attend son accusé de résultat. */ + async sendCommand( + agentId: string, + action: AgentAction, + params?: Record, + ): Promise { + const connection = this.connections.get(agentId); + if (!connection) throw new Error('Agent hors-ligne'); + + const requestId = randomUUID(); + const message: ServerToAgent = { type: 'command', requestId, action, params }; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + connection.pending.delete(requestId); + reject(new Error(`Timeout : l'agent n'a pas répondu à « ${action} »`)); + }, config.commandTimeoutMs); + + connection.pending.set(requestId, { resolve, reject, timer }); + + try { + connection.socket.send(JSON.stringify(message)); + } catch (err) { + connection.pending.delete(requestId); + clearTimeout(timer); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + } + + /** Pousse la configuration OBS à un agent connecté (sans attendre de réponse). */ + pushConfig(record: AgentRecord): void { + const connection = this.connections.get(record.id); + if (!connection) return; + const message: ServerToAgent = { + type: 'config', + obs: record.obs, + autoConnectObs: record.autoConnectObs, + }; + connection.socket.send(JSON.stringify(message)); + } + + disconnectAgent(agentId: string, reason: string): void { + const connection = this.connections.get(agentId); + if (!connection) return; + try { + connection.socket.close(4001, reason); + } catch { + /* ignore */ + } + } + + // --- Vues --------------------------------------------------------------- + + statusOf(agentId: string): AgentStatus { + return this.statuses.get(agentId) ?? emptyStatus(); + } + + view(record: AgentRecord): AgentView { + const online = this.isOnline(record.id); + return { + id: record.id, + name: record.name, + hostname: record.hostname, + platform: record.platform, + agentVersion: record.agentVersion, + online, + lastSeenAt: online + ? (this.connections.get(record.id)?.lastSeenAt ?? record.lastSeenAt) + : record.lastSeenAt, + createdAt: record.createdAt, + // Le mot de passe OBS n'est jamais renvoyé au navigateur. + obs: { ...record.obs, password: record.obs.password ? '********' : '' }, + autoConnectObs: record.autoConnectObs, + notes: record.notes, + status: this.statusOf(record.id), + }; + } + + views(): AgentView[] { + return agentsRepo.list().map((record) => this.view(record)); + } + + // --- Dashboards --------------------------------------------------------- + + attachDashboard(socket: WebSocket): void { + this.dashboards.add(socket); + this.sendTo(socket, { + type: 'snapshot', + agents: this.views(), + logs: logsRepo.recent(200), + }); + } + + detachDashboard(socket: WebSocket): void { + this.dashboards.delete(socket); + } + + publishAgent(agentId: string): void { + const record = agentsRepo.get(agentId); + if (!record) { + this.broadcast({ type: 'agent.removed', agentId }); + return; + } + this.broadcast({ type: 'agent', agent: this.view(record) }); + } + + publishRemoval(agentId: string): void { + this.statuses.delete(agentId); + this.broadcast({ type: 'agent.removed', agentId }); + } + + /** Journalise un évènement : persistance + diffusion temps réel. */ + log(agentId: string | null, level: LogLevel, message: string, ts = Date.now()): LogEntry { + const entry = logsRepo.append(agentId, level, message, ts); + this.broadcast({ type: 'log', entry }); + if (level === 'error' || level === 'warn') { + console.warn(`[${level}] ${entry.agentName ?? 'serveur'} — ${message}`); + } + return entry; + } + + private broadcast(message: ServerToDashboard): void { + const payload = JSON.stringify(message); + for (const socket of this.dashboards) { + if (socket.readyState === socket.OPEN) socket.send(payload); + } + } + + private sendTo(socket: WebSocket, message: ServerToDashboard): void { + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message)); + } + + private failPending(connection: AgentConnection, error: Error): void { + for (const pending of connection.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + connection.pending.clear(); + } + + /** Coupe les agents silencieux : le heartbeat n'arrive plus. */ + reapStale(): void { + const deadline = Date.now() - config.agentTimeoutMs; + for (const [agentId, connection] of this.connections) { + if (connection.lastSeenAt < deadline) { + this.log(agentId, 'warn', 'Agent silencieux, fermeture de la session'); + try { + connection.socket.terminate(); + } catch { + /* ignore */ + } + this.detachAgent(agentId, connection.socket); + } + } + } + + pingAll(): void { + const payload = JSON.stringify({ type: 'ping', ts: Date.now() } satisfies ServerToAgent); + for (const connection of this.connections.values()) { + if (connection.socket.readyState === connection.socket.OPEN) { + connection.socket.send(payload); + } + } + } +} + +export const hub = new Hub(); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..8140478 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import express from 'express'; +import { WebSocketServer } from 'ws'; +import { config } from './config.ts'; +import { verifySession } from './auth.ts'; +import { api } from './api.ts'; +import { hub } from './hub.ts'; +import { authenticateAgent, handleAgentConnection } from './agentGateway.ts'; + +const app = express(); +app.disable('x-powered-by'); +app.set('trust proxy', true); +app.use(express.json({ limit: '256kb' })); + +app.get('/healthz', (_req, res) => { + res.json({ ok: true, agents: hub.views().filter((agent) => agent.online).length }); +}); + +app.use('/api', api); + +// Le dashboard compilé, s'il a été construit (npm run build -w @stream-control/web). +if (fs.existsSync(config.webDist)) { + app.use(express.static(config.webDist)); + app.use((req, res, next) => { + if (req.method !== 'GET' || req.path.startsWith('/api')) return next(); + res.sendFile(path.join(config.webDist, 'index.html')); + }); +} else { + app.get('/', (_req, res) => { + res + .status(200) + .type('text/plain') + .send( + 'Dashboard non compilé.\n' + + 'Développement : npm run dev (Vite sur http://localhost:5173)\n' + + 'Production : npm run build puis npm start', + ); + }); +} + +const server = http.createServer(app); + +// --- WebSockets : deux points d'entrée, authentifiés à l'upgrade ------------- + +const agentWss = new WebSocketServer({ noServer: true }); +const dashboardWss = new WebSocketServer({ noServer: true }); + +server.on('upgrade', (req, socket, head) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + + if (url.pathname === '/ws/agent') { + const auth = authenticateAgent(req); + if (!auth) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + agentWss.handleUpgrade(req, socket, head, (ws) => { + const remote = String( + req.headers['x-forwarded-for'] ?? req.socket.remoteAddress ?? 'inconnu', + ).split(',')[0]!.trim(); + handleAgentConnection(ws, auth, remote); + }); + return; + } + + if (url.pathname === '/ws/dashboard') { + if (!verifySession(url.searchParams.get('token'))) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + dashboardWss.handleUpgrade(req, socket, head, (ws) => { + hub.attachDashboard(ws); + ws.on('close', () => hub.detachDashboard(ws)); + ws.on('error', () => hub.detachDashboard(ws)); + }); + return; + } + + socket.destroy(); +}); + +// --- Boucles de maintenance ------------------------------------------------- + +const heartbeat = setInterval(() => { + hub.pingAll(); + hub.reapStale(); +}, 5000); +heartbeat.unref(); + +server.listen(config.port, config.host, () => { + console.log(`stream-control · http://${config.host}:${config.port}`); + console.log(` agents → ws://${config.host}:${config.port}/ws/agent`); + console.log(` enrôlement → ${config.enrollmentToken ? 'activé' : 'désactivé'}`); + if (config.isDev && !process.env.ADMIN_PASSWORD) { + console.warn(' ⚠ ADMIN_PASSWORD non défini, mot de passe de développement : « admin »'); + } +}); + +function shutdown(signal: string): void { + console.log(`\n${signal} reçu, arrêt…`); + clearInterval(heartbeat); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(1), 5000).unref(); +} + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..40d2ce4 --- /dev/null +++ b/packages/server/tsconfig.json @@ -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" }] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..3bf9607 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,22 @@ +{ + "name": "@stream-control/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsc -b", + "watch": "tsc -b --watch" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..4ba99ba --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,257 @@ +/** + * Protocole partagé entre le serveur de contrôle, les agents et le dashboard. + * + * Transport : WebSocket, messages JSON, un champ `type` discriminant. + * - agent <-> serveur : /ws/agent (l'agent initie la connexion sortante) + * - browser <- serveur : /ws/dashboard (flux de statut temps réel) + */ + +export const PROTOCOL_VERSION = 1; + +export type Platform = 'windows' | 'linux' | 'darwin' | 'unknown'; + +/** Paramètres de connexion à obs-websocket (plugin intégré à OBS >= 28). */ +export interface ObsSettings { + host: string; + port: number; + /** Mot de passe obs-websocket ; chaîne vide si l'authentification est désactivée. */ + password: string; +} + +export const DEFAULT_OBS_SETTINGS: ObsSettings = { + host: '127.0.0.1', + port: 4455, + password: '', +}; + +// --------------------------------------------------------------------------- +// Actions pilotables sur un agent +// --------------------------------------------------------------------------- + +export const AGENT_ACTIONS = [ + 'obs.connect', + 'obs.disconnect', + 'obs.refresh', + 'record.start', + 'record.stop', + 'record.pause', + 'record.resume', + 'record.split', + 'stream.start', + 'stream.stop', + 'scene.set', + 'profile.set', + 'collection.set', + 'recordDirectory.set', + 'agent.ping', +] as const; + +export type AgentAction = (typeof AGENT_ACTIONS)[number]; + +export function isAgentAction(value: unknown): value is AgentAction { + return typeof value === 'string' && (AGENT_ACTIONS as readonly string[]).includes(value); +} + +/** Paramètres attendus par action (les autres actions n'en prennent aucun). */ +export interface AgentActionParams { + 'scene.set': { scene: string }; + 'profile.set': { profile: string }; + 'collection.set': { collection: string }; + 'recordDirectory.set': { directory: string }; +} + +// --------------------------------------------------------------------------- +// Statut remonté par un agent +// --------------------------------------------------------------------------- + +export interface AgentStatus { + /** L'agent a-t-il une session obs-websocket établie ? */ + obsConnected: boolean; + obsVersion?: string; + obsError?: string; + + recording: boolean; + recordPaused: boolean; + /** Durée d'enregistrement au format HH:MM:SS.mmm renvoyé par OBS. */ + recordTimecode?: string; + recordBytes?: number; + /** Chemin du dernier fichier écrit (renseigné à l'arrêt de l'enregistrement). */ + lastRecordingPath?: string; + recordDirectory?: string; + + streaming: boolean; + streamTimecode?: string; + + currentScene?: string; + scenes: string[]; + currentProfile?: string; + profiles: string[]; + currentCollection?: string; + collections: string[]; + + /** Statistiques OBS. */ + cpuUsage?: number; + fps?: number; + droppedFrames?: number; + renderSkippedFrames?: number; + + /** Statistiques machine (collectées par l'agent, pas par OBS). */ + systemCpu?: number; + systemMemoryUsed?: number; + systemMemoryTotal?: number; + diskFreeBytes?: number; + diskTotalBytes?: number; + + updatedAt: number; +} + +export function emptyStatus(): AgentStatus { + return { + obsConnected: false, + recording: false, + recordPaused: false, + streaming: false, + scenes: [], + profiles: [], + collections: [], + updatedAt: 0, + }; +} + +// --------------------------------------------------------------------------- +// Messages agent -> serveur +// --------------------------------------------------------------------------- + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface HelloMessage { + type: 'hello'; + protocol: number; + /** Absent lors du tout premier enrôlement : le serveur en attribue un. */ + agentId?: string; + name: string; + hostname: string; + platform: Platform; + agentVersion: string; +} + +export interface StatusMessage { + type: 'status'; + status: AgentStatus; +} + +export interface ResultMessage { + type: 'result'; + requestId: string; + ok: boolean; + data?: unknown; + error?: string; +} + +export interface LogMessage { + type: 'log'; + level: LogLevel; + message: string; + ts: number; +} + +export interface PongMessage { + type: 'pong'; + ts: number; +} + +export type AgentToServer = + | HelloMessage + | StatusMessage + | ResultMessage + | LogMessage + | PongMessage; + +// --------------------------------------------------------------------------- +// Messages serveur -> agent +// --------------------------------------------------------------------------- + +export interface WelcomeMessage { + type: 'welcome'; + agentId: string; + /** Fourni uniquement lors de l'enrôlement : l'agent doit le persister. */ + token?: string; + obs: ObsSettings; + /** Fréquence de remontée de statut demandée. */ + statusIntervalMs: number; + /** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */ + autoConnectObs: boolean; +} + +export interface CommandMessage { + type: 'command'; + requestId: string; + action: AgentAction; + params?: Record; +} + +export interface ConfigMessage { + type: 'config'; + obs: ObsSettings; + autoConnectObs: boolean; +} + +export interface PingMessage { + type: 'ping'; + ts: number; +} + +export type ServerToAgent = WelcomeMessage | CommandMessage | ConfigMessage | PingMessage; + +// --------------------------------------------------------------------------- +// Vue agrégée exposée au dashboard +// --------------------------------------------------------------------------- + +export interface AgentView { + id: string; + name: string; + hostname: string | null; + platform: Platform; + agentVersion: string | null; + online: boolean; + lastSeenAt: number | null; + createdAt: number; + obs: ObsSettings; + autoConnectObs: boolean; + notes: string | null; + status: AgentStatus; +} + +export interface LogEntry { + id: number; + agentId: string | null; + agentName: string | null; + level: LogLevel; + message: string; + ts: number; +} + +export type ServerToDashboard = + | { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[] } + | { type: 'agent'; agent: AgentView } + | { type: 'agent.removed'; agentId: string } + | { type: 'log'; entry: LogEntry }; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function detectPlatform(raw: string): Platform { + if (raw === 'win32') return 'windows'; + if (raw === 'linux') return 'linux'; + if (raw === 'darwin') return 'darwin'; + return 'unknown'; +} + +export function safeJsonParse(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..9a6b5f0 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..1224933 --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Stream Control + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..a984416 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "@stream-control/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b --force && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@stream-control/shared": "*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx new file mode 100644 index 0000000..a020937 --- /dev/null +++ b/packages/web/src/App.tsx @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { AgentAction, AgentView } from '@stream-control/shared'; +import { api, getToken } from './api'; +import { useRealtime } from './useRealtime'; +import { Login } from './components/Login'; +import { AgentCard } from './components/AgentCard'; +import { AgentSettings } from './components/AgentSettings'; +import { LogPanel } from './components/LogPanel'; + +interface Toast { + message: string; + tone: 'info' | 'error'; +} + +export function App() { + const [authenticated, setAuthenticated] = useState(() => Boolean(getToken())); + const [selection, setSelection] = useState>(new Set()); + const [settingsFor, setSettingsFor] = useState(null); + const [toast, setToast] = useState(null); + const [newAgentToken, setNewAgentToken] = useState<{ name: string; token: string } | null>(null); + + const onUnauthorized = useCallback(() => setAuthenticated(false), []); + const { agents, logs, connected } = useRealtime(authenticated, onUnauthorized); + + const notify = useCallback((message: string, tone: 'info' | 'error' = 'info') => { + setToast({ message, tone }); + }, []); + + useEffect(() => { + if (!toast) return; + const timer = window.setTimeout(() => setToast(null), 4000); + return () => window.clearTimeout(timer); + }, [toast]); + + // La fiche ouverte doit refléter les mises à jour temps réel. + const openAgent = useMemo( + () => (settingsFor ? (agents.find((agent) => agent.id === settingsFor.id) ?? null) : null), + [agents, settingsFor], + ); + + const online = agents.filter((agent) => agent.online); + const recording = agents.filter((agent) => agent.status.recording); + + const runCommand = useCallback( + async (id: string, action: AgentAction, params?: Record) => { + try { + await api.command(id, action, params); + } catch (err) { + notify(err instanceof Error ? err.message : 'Commande en échec', 'error'); + } + }, + [notify], + ); + + const runBulk = useCallback( + async (action: AgentAction) => { + const targets = selection.size > 0 ? [...selection] : online.map((agent) => agent.id); + if (targets.length === 0) { + notify('Aucun agent sélectionné', 'error'); + return; + } + try { + const { results } = await api.bulk(targets, action); + const failures = results.filter((result) => !result.ok); + if (failures.length === 0) notify(`${results.length} agent(s) : commande envoyée`); + else + notify( + `${results.length - failures.length}/${results.length} OK — ${failures[0]?.error ?? ''}`, + 'error', + ); + } catch (err) { + notify(err instanceof Error ? err.message : 'Commande groupée en échec', 'error'); + } + }, + [notify, online, selection], + ); + + function toggleSelect(id: string) { + setSelection((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + async function addAgent() { + const name = prompt("Nom du nouvel agent (ex. vm-rec-01)")?.trim(); + if (!name) return; + try { + const result = await api.createAgent({ name }); + setNewAgentToken({ name, token: result.token }); + } catch (err) { + notify(err instanceof Error ? err.message : 'Création impossible', 'error'); + } + } + + function logout() { + api.logout(); + setAuthenticated(false); + } + + if (!authenticated) { + return setAuthenticated(true)} />; + } + + return ( +
+
+
+ +

Stream Control

+ + {online.length}/{agents.length} en ligne · {recording.length} en enregistrement + +
+ +
+ + + + + +
+
+ + {!connected && ( +
Flux temps réel interrompu — reconnexion en cours…
+ )} + + {newAgentToken && ( +
+
+ Agent « {newAgentToken.name} » créé. Jeton (affiché une seule fois) :{' '} + {newAgentToken.token} +
+ +
+ )} + +
+ {agents.length === 0 && ( +
+

Aucun agent enregistré

+

+ Crée un agent ici pour obtenir un jeton, ou démarre un agent avec le jeton + d'enrôlement : il apparaîtra automatiquement. +

+
+ )} + {agents.map((agent) => ( + + ))} +
+ + + + {openAgent && ( + setSettingsFor(null)} + onCommand={runCommand} + notify={notify} + /> + )} + + {toast &&
{toast.message}
} +
+ ); +} diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts new file mode 100644 index 0000000..d7b97e2 --- /dev/null +++ b/packages/web/src/api.ts @@ -0,0 +1,106 @@ +import type { AgentAction, AgentView, LogEntry } from '@stream-control/shared'; + +const TOKEN_KEY = 'stream-control.session'; + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string | null): void { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); +} + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +async function request(path: string, init: RequestInit = {}): Promise { + const token = getToken(); + const response = await fetch(`/api${path}`, { + ...init, + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...init.headers, + }, + }); + + if (response.status === 401) { + setToken(null); + throw new ApiError('Session expirée', 401); + } + + const payload = (await response.json().catch(() => ({}))) as Record & T; + if (!response.ok) { + throw new ApiError(String(payload.error ?? response.statusText), response.status); + } + return payload; +} + +export interface BulkResult { + agentId: string; + ok: boolean; + error?: string; +} + +export const api = { + async login(password: string): Promise { + const { token } = await request<{ token: string }>('/login', { + method: 'POST', + body: JSON.stringify({ password }), + }); + setToken(token); + }, + + logout(): void { + setToken(null); + }, + + agents: () => request<{ agents: AgentView[] }>('/agents'), + + logs: (limit = 200) => request<{ logs: LogEntry[] }>(`/logs?limit=${limit}`), + + createAgent: (body: { name: string; notes?: string }) => + request<{ agent: AgentView; token: string }>('/agents', { + method: 'POST', + body: JSON.stringify(body), + }), + + updateAgent: (id: string, body: Partial) => + request<{ agent: AgentView }>(`/agents/${id}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + + rotateToken: (id: string) => + request<{ token: string }>(`/agents/${id}/token`, { method: 'POST' }), + + deleteAgent: (id: string) => request<{ ok: true }>(`/agents/${id}`, { method: 'DELETE' }), + + command: (id: string, action: AgentAction, params?: Record) => + request<{ ok: boolean; data?: unknown }>(`/agents/${id}/command`, { + method: 'POST', + body: JSON.stringify({ action, params }), + }), + + bulk: (agentIds: string[], action: AgentAction, params?: Record) => + request<{ results: BulkResult[] }>('/commands/bulk', { + method: 'POST', + body: JSON.stringify({ agentIds, action, params }), + }), + + enrollment: () => + request<{ enabled: boolean; token: string | null; serverUrl: string }>('/enrollment'), +}; + +/** URL du flux temps réel, jeton en query (les WS ne portent pas d'en-tête). */ +export function dashboardSocketUrl(): string { + const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; + return `${protocol}://${location.host}/ws/dashboard?token=${encodeURIComponent(getToken() ?? '')}`; +} diff --git a/packages/web/src/components/AgentCard.tsx b/packages/web/src/components/AgentCard.tsx new file mode 100644 index 0000000..932c4dc --- /dev/null +++ b/packages/web/src/components/AgentCard.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react'; +import type { AgentAction, AgentView } from '@stream-control/shared'; +import { formatBytes, formatPercent, formatRelative, formatTimecode } from '../format'; + +interface Props { + agent: AgentView; + selected: boolean; + onToggleSelect: (id: string) => void; + onCommand: (id: string, action: AgentAction, params?: Record) => Promise; + onOpenSettings: (agent: AgentView) => void; +} + +export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSettings }: Props) { + const [pending, setPending] = useState(null); + const { status } = agent; + + async function run(action: AgentAction, params?: Record) { + setPending(action); + try { + await onCommand(agent.id, action, params); + } finally { + setPending(null); + } + } + + const busy = pending !== null; + const obsReady = agent.online && status.obsConnected; + + const state = !agent.online + ? { label: 'Hors-ligne', tone: 'offline' as const } + : !status.obsConnected + ? { label: 'OBS déconnecté', tone: 'warn' as const } + : status.recording + ? { + label: status.recordPaused ? 'En pause' : 'Enregistre', + tone: status.recordPaused ? ('warn' as const) : ('rec' as const), + } + : { label: 'Prêt', tone: 'ok' as const }; + + return ( +
+
+ +
+

{agent.name}

+ + {agent.hostname ?? '—'} · {agent.platform} + {agent.agentVersion ? ` · v${agent.agentVersion}` : ''} + +
+ {state.label} + +
+ + {status.obsError && !status.obsConnected && ( +

{status.obsError}

+ )} + +
+ + + + + + +
+ +
+ +
+ +
+ {status.recording ? ( + <> + + {status.recordPaused ? ( + + ) : ( + + )} + + + ) : ( + + )} + + {status.streaming ? ( + + ) : ( + + )} + + {status.obsConnected ? ( + + ) : ( + + )} +
+ +
+ + {status.lastRecordingPath + ? `Dernier fichier : ${basename(status.lastRecordingPath)}` + : (status.recordDirectory ?? 'Dossier inconnu')} + + vu {formatRelative(agent.lastSeenAt)} +
+
+ ); +} + +function Metric({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +function basename(filePath: string): string { + const parts = filePath.split(/[\\/]/); + return parts[parts.length - 1] ?? filePath; +} diff --git a/packages/web/src/components/AgentSettings.tsx b/packages/web/src/components/AgentSettings.tsx new file mode 100644 index 0000000..c26cddf --- /dev/null +++ b/packages/web/src/components/AgentSettings.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import type { AgentAction, AgentView } from '@stream-control/shared'; +import { api } from '../api'; + +interface Props { + agent: AgentView; + onClose: () => void; + onCommand: (id: string, action: AgentAction, params?: Record) => Promise; + notify: (message: string, tone?: 'info' | 'error') => void; +} + +export function AgentSettings({ agent, onClose, onCommand, notify }: Props) { + const [name, setName] = useState(agent.name); + const [host, setHost] = useState(agent.obs.host); + const [port, setPort] = useState(String(agent.obs.port)); + const [password, setPassword] = useState(agent.obs.password); + const [autoConnect, setAutoConnect] = useState(agent.autoConnectObs); + const [notes, setNotes] = useState(agent.notes ?? ''); + const [directory, setDirectory] = useState(agent.status.recordDirectory ?? ''); + const [token, setToken] = useState(null); + const [busy, setBusy] = useState(false); + + async function save() { + setBusy(true); + try { + await api.updateAgent(agent.id, { + name, + notes, + autoConnectObs: autoConnect, + obs: { host, port: Number(port), password }, + } as Partial); + notify('Configuration enregistrée'); + onClose(); + } catch (err) { + notify(err instanceof Error ? err.message : 'Enregistrement impossible', 'error'); + } finally { + setBusy(false); + } + } + + async function applyDirectory() { + if (!directory.trim()) return; + await onCommand(agent.id, 'recordDirectory.set', { directory: directory.trim() }); + } + + async function rotate() { + if (!confirm("Régénérer le jeton ? L'agent sera déconnecté jusqu'à sa reconfiguration.")) return; + try { + const result = await api.rotateToken(agent.id); + setToken(result.token); + } catch (err) { + notify(err instanceof Error ? err.message : 'Rotation impossible', 'error'); + } + } + + async function remove() { + if (!confirm(`Supprimer définitivement l'agent « ${agent.name} » ?`)) return; + try { + await api.deleteAgent(agent.id); + notify('Agent supprimé'); + onClose(); + } catch (err) { + notify(err instanceof Error ? err.message : 'Suppression impossible', 'error'); + } + } + + return ( +
+
event.stopPropagation()}> +
+

Configuration · {agent.name}

+ +
+ +
+ + +
+ + +
+ + + + + +