mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 19:27:41 +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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [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:** 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:** 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.
|
- **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);
|
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
|
// 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.
|
// to the target Sencho instance. Node management and auth routes always execute locally.
|
||||||
app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
createProxyMiddleware<Request, Response>({
|
remoteNodeProxy(req, res, next);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create HTTP server for WebSocket upgrade handling
|
// 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(() => {
|
useEffect(() => {
|
||||||
refreshStacks();
|
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
const notificationInterval = setInterval(fetchNotifications, 5000);
|
const notificationInterval = setInterval(fetchNotifications, 5000);
|
||||||
return () => clearInterval(notificationInterval);
|
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 () => {
|
const fetchNotifications = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -177,10 +177,31 @@ export function NodeManager() {
|
|||||||
|
|
||||||
const copyToken = async () => {
|
const copyToken = async () => {
|
||||||
if (!generatedToken) return;
|
if (!generatedToken) return;
|
||||||
await navigator.clipboard.writeText(generatedToken);
|
try {
|
||||||
setTokenCopied(true);
|
// Clipboard API requires a secure context (HTTPS or localhost)
|
||||||
toast.success('Token copied to clipboard');
|
await navigator.clipboard.writeText(generatedToken);
|
||||||
setTimeout(() => setTokenCopied(false), 2000);
|
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) => {
|
const getStatusBadge = (status: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user