From fddd85562432e28de2ac879b804a5111eb802c96 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Thu, 19 Mar 2026 15:34:04 -0400 Subject: [PATCH] fix: Distributed API proxy memory leak, node switcher refresh, and copy button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy memory leak (MaxListenersExceededWarning + DEP0060): createProxyMiddleware was instantiated inside the request handler on every single API call. Each new instance registered fresh 'close' listeners on the HTTP server and re-ran the http-proxy util._extend deprecated path. After ~10 requests the MaxListeners threshold was breached. Fix: declare ONE global remoteNodeProxy at startup using the router option to dynamically resolve the target URL per request. Listeners are registered once. ECONNREFUSED errors are caught in the on.error handler and returned as structured 502 JSON. Node switcher "nothing happens": EditorLayout had a single useEffect([], []) that called refreshStacks() once on mount. Changing the active node updated NodeContext state and localStorage but nothing re-triggered the stack list fetch. Fix: split into two effects — notifications polling (no dependency) and a stack-refresh effect keyed on activeNode?.id. When the node changes, stale editor/container/file state is cleared and the stacks for the new node are fetched. Copy button silently failing: navigator.clipboard.writeText() throws DOMException in non-HTTPS / non-localhost contexts (e.g. http://192.168.x.x). The uncaught async exception silently swallowed the success toast and state update. Fix: wrapped in try/catch with an execCommand('copy') textarea fallback and a final error toast if both fail. --- CHANGELOG.md | 6 +++ backend/src/index.ts | 54 +++++++++++++++--------- frontend/src/components/EditorLayout.tsx | 19 ++++++++- frontend/src/components/NodeManager.tsx | 29 +++++++++++-- 4 files changed, 83 insertions(+), 25 deletions(-) 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) => {