mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 12:49:03 +00:00
Merge pull request #32 from AnsoCode/fix/distributed-api-refinement
fix: Distributed API Proxy & Auth Refinement
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user