Merge pull request #32 from AnsoCode/fix/distributed-api-refinement

fix: Distributed API Proxy & Auth Refinement
This commit is contained in:
Anso
2026-03-19 15:35:47 -04:00
committed by GitHub
4 changed files with 83 additions and 25 deletions
+6
View File
@@ -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.
+35 -19
View File
@@ -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<Request, Response>({
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<Request, Response>({
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
+17 -2
View File
@@ -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 {
+25 -4
View File
@@ -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) => {