From 97d18c6beb5621e6d86d64b3f46bf26cef49ee89 Mon Sep 17 00:00:00 2001 From: simosmik Date: Tue, 17 Mar 2026 10:10:33 +0000 Subject: [PATCH] feat: add WebSocket proxy for plugin backends Adds /plugin-ws/:name route that proxies authenticated WebSocket connections to plugin server subprocesses, enabling real-time bidirectional communication for plugins like web-terminal. --- package-lock.json | 2 -- server/index.js | 48 +++++++++++++++++++++++++++++++++++++++- server/routes/plugins.js | 6 ++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 979d333..6299f83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1910,7 +1910,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1927,7 +1926,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ diff --git a/server/index.js b/server/index.js index d7e4d52..4a6f8f3 100755 --- a/server/index.js +++ b/server/index.js @@ -65,7 +65,7 @@ import userRoutes from './routes/user.js'; import codexRoutes from './routes/codex.js'; import geminiRoutes from './routes/gemini.js'; import pluginsRoutes from './routes/plugins.js'; -import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js'; +import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js'; import { configureWebPush } from './services/vapid-keys.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; @@ -1396,6 +1396,50 @@ const uploadFilesHandler = async (req, res) => { app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler); +/** + * Proxy an authenticated client WebSocket to a plugin's internal WS server. + * Auth is enforced by verifyClient before this function is reached. + */ +function handlePluginWsProxy(clientWs, pathname) { + const pluginName = pathname.replace('/plugin-ws/', ''); + if (!pluginName || /[^a-zA-Z0-9_-]/.test(pluginName)) { + clientWs.close(4400, 'Invalid plugin name'); + return; + } + + const port = getPluginPort(pluginName); + if (!port) { + clientWs.close(4404, 'Plugin not running'); + return; + } + + const upstream = new WebSocket(`ws://127.0.0.1:${port}/ws`); + + upstream.on('open', () => { + console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`); + }); + + // Relay messages bidirectionally + upstream.on('message', (data) => { + if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data); + }); + clientWs.on('message', (data) => { + if (upstream.readyState === WebSocket.OPEN) upstream.send(data); + }); + + // Propagate close in both directions + upstream.on('close', () => { if (clientWs.readyState === WebSocket.OPEN) clientWs.close(); }); + clientWs.on('close', () => { if (upstream.readyState === WebSocket.OPEN) upstream.close(); }); + + upstream.on('error', (err) => { + console.error(`[Plugins] WS proxy error for "${pluginName}":`, err.message); + if (clientWs.readyState === WebSocket.OPEN) clientWs.close(4502, 'Upstream error'); + }); + clientWs.on('error', () => { + if (upstream.readyState === WebSocket.OPEN) upstream.close(); + }); +} + // WebSocket connection handler that routes based on URL path wss.on('connection', (ws, request) => { const url = request.url; @@ -1409,6 +1453,8 @@ wss.on('connection', (ws, request) => { handleShellConnection(ws); } else if (pathname === '/ws') { handleChatConnection(ws, request); + } else if (pathname.startsWith('/plugin-ws/')) { + handlePluginWsProxy(ws, pathname); } else { console.log('[WARN] Unknown WebSocket path:', pathname); ws.close(); diff --git a/server/routes/plugins.js b/server/routes/plugins.js index ef490c4..1099c1b 100644 --- a/server/routes/plugins.js +++ b/server/routes/plugins.js @@ -81,6 +81,10 @@ router.get('/:name/assets/*', (req, res) => { const contentType = mime.lookup(resolvedPath) || 'application/octet-stream'; res.setHeader('Content-Type', contentType); + // Prevent CDN/proxy caching of plugin assets so updates take effect immediately + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); const stream = fs.createReadStream(resolvedPath); stream.on('error', () => { if (!res.headersSent) { @@ -236,7 +240,7 @@ router.all('/:name/rpc/*', async (req, res) => { 'content-type': req.headers['content-type'] || 'application/json', }; - // Add per-plugin secrets as X-Plugin-Secret-* headers + // Add per-plugin user-configured secrets as X-Plugin-Secret-* headers for (const [key, value] of Object.entries(secrets)) { headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value); }