diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e91560..8f592f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Fixed:** Backend memory leak caused by improper proxy middleware instantiation — `createProxyMiddleware` was called inside the request handler on every API call, spawning a new `http-proxy` instance (and registering new server listeners) per request. Refactored to a single globally-instantiated proxy using the `router` option for dynamic per-request target resolution. +- **Fixed:** `[DEP0060] DeprecationWarning: util._extend` deprecation eliminated as a side-effect of the above fix (deprecation was triggered on every new `http-proxy` initialisation). +- **Fixed:** Remote node authentication failures — `authMiddleware` and WebSocket upgrade handler both accept `Authorization: Bearer` tokens (Sencho-to-Sencho proxy auth). +- **Fixed:** Node connection testing logic updated to perform authenticated HTTP pings to `/api/auth/check` on the remote instance. +- **Fixed:** Node switcher dropdown failing to trigger data refreshes — `EditorLayout` now reacts to `activeNode` changes, re-fetching stacks and clearing stale editor/container state when the user switches nodes. +- **Fixed:** API Token copy button failing silently on HTTP / non-localhost deployments where `navigator.clipboard` is unavailable — added `try/catch` with `document.execCommand('copy')` fallback. - **Fixed:** Remote node authentication failures by updating middleware to support Bearer tokens in WebSocket upgrade handler (node-to-node WS proxy now authenticates correctly on the receiving instance). - **Fixed:** Node connection testing logic updated to normalize `api_url` trailing slashes before constructing the authenticated HTTP ping URL. - **Fixed:** Memory leak in `GlobalObservabilityView` SSE mode — log array now capped at 10,000 entries (`.slice(-10000)`) to prevent unbounded accumulation across long sessions. diff --git a/backend/src/index.ts b/backend/src/index.ts index 8b48e903..94b11e77 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -305,7 +305,40 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => { authMiddleware(req, res, next); }); -// Remote Node HTTP Proxy +// Remote Node HTTP Proxy — single global instance. +// Previously, createProxyMiddleware was called inside the request handler on every API +// call, spawning a new proxy instance (and http-proxy server) each time. This caused: +// - MaxListenersExceededWarning: repeated 'close' listeners added to [Server] +// - DEP0060: util._extend called on every http-proxy initialisation +// Fix: create ONE instance at startup; use the router option to resolve the +// target URL dynamically per request without constructing new listeners. +const remoteNodeProxy = createProxyMiddleware({ + target: 'http://localhost:0', // placeholder — overridden per-request by router + changeOrigin: true, + router: (req) => { + const node = NodeRegistry.getInstance().getNode(req.nodeId); + return node?.api_url?.replace(/\/$/, ''); + }, + on: { + proxyReq: (proxyReq, req) => { + const node = NodeRegistry.getInstance().getNode(req.nodeId); + // Remote Sencho sees itself as local — strip node context and inject bearer auth + proxyReq.removeHeader('x-node-id'); + if (node?.api_token) { + proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`); + } + }, + error: (err, _req, proxyRes) => { + console.error('[Proxy] Remote node error:', (err as Error).message); + if (!(proxyRes as Response).headersSent) { + (proxyRes as Response).status(502).json({ + error: 'Remote node is unreachable. Check the API URL and ensure Sencho is running on that host.' + }); + } + }, + }, +}); + // Intercepts all /api/ requests for remote Distributed API nodes and forwards them // to the target Sencho instance. Node management and auth routes always execute locally. app.use('/api/', (req: Request, res: Response, next: NextFunction): void => { @@ -327,24 +360,7 @@ app.use('/api/', (req: Request, res: Response, next: NextFunction): void => { return; } - createProxyMiddleware({ - target: node.api_url.replace(/\/$/, ''), - changeOrigin: true, - on: { - proxyReq: (proxyReq) => { - // Remote Sencho is always "local" to itself — strip node context - proxyReq.removeHeader('x-node-id'); - proxyReq.setHeader('Authorization', `Bearer ${node.api_token!}`); - }, - error: (_err, _req, proxyRes) => { - if (!(proxyRes as Response).headersSent) { - (proxyRes as Response).status(502).json({ - error: `Remote node "${node.name}" is unreachable. Check the API URL and ensure Sencho is running on that host.` - }); - } - }, - }, - })(req, res, next); + remoteNodeProxy(req, res, next); }); // Create HTTP server for WebSocket upgrade handling diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 09d0e0b4..dad04e78 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -147,12 +147,27 @@ export default function EditorLayout() { } }; + // Notification polling — independent of active node, runs once on mount useEffect(() => { - refreshStacks(); fetchNotifications(); const notificationInterval = setInterval(fetchNotifications, 5000); return () => clearInterval(notificationInterval); - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Re-fetch stacks whenever the active node changes (or becomes available on mount). + // Also clears any stale editor/container state that belonged to the previous node. + useEffect(() => { + if (!activeNode) return; + setSelectedFile(null); + setContent(''); + setOriginalContent(''); + setEnvContent(''); + setOriginalEnvContent(''); + setContainers([]); + setIsEditing(false); + setActiveView('dashboard'); + refreshStacks(); + }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps const fetchNotifications = async () => { try { diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 02a3585b..75b5fabb 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -177,10 +177,31 @@ export function NodeManager() { const copyToken = async () => { if (!generatedToken) return; - await navigator.clipboard.writeText(generatedToken); - setTokenCopied(true); - toast.success('Token copied to clipboard'); - setTimeout(() => setTokenCopied(false), 2000); + try { + // Clipboard API requires a secure context (HTTPS or localhost) + await navigator.clipboard.writeText(generatedToken); + setTokenCopied(true); + toast.success('Token copied to clipboard'); + setTimeout(() => setTokenCopied(false), 2000); + } catch { + // Fallback for HTTP / non-localhost deployments where Clipboard API is unavailable + try { + const ta = document.createElement('textarea'); + ta.value = generatedToken; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + setTokenCopied(true); + toast.success('Token copied to clipboard'); + setTimeout(() => setTokenCopied(false), 2000); + } catch { + toast.error('Could not copy automatically — please select and copy the token manually.'); + } + } }; const getStatusBadge = (status: string) => {