Files
MP-Server/macropad-relay/src/handlers/apiProxy.ts
T
shadowdao 2bbbc5e283 perf(relay): cache macro images instead of re-fetching every render
The relay web client re-fetched every macro image through the relay->desktop
proxy on each render (tab switch, WS update) and revoked the object URL on
load, so nothing was reused and fetches were serial — slow to display.

- Client: cache image_path -> Promise<objectURL> and reuse across renders
  (macro images are content-addressed by uuid, so immutable); fetch in
  parallel and de-duplicate concurrent requests.
- Proxy: send Cache-Control: private, max-age=31536000, immutable on image
  responses so the browser also disk-caches them across reloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 23:22:04 -07:00

89 lines
3.2 KiB
TypeScript

// API Proxy Handler - proxies REST requests to desktop apps
import { Request, Response, NextFunction } from 'express';
import { ConnectionManager } from '../services/ConnectionManager';
import { SessionManager } from '../services/SessionManager';
import { logger } from '../utils/logger';
export function createApiProxy(
connectionManager: ConnectionManager,
sessionManager: SessionManager
) {
return async (req: Request, res: Response, next: NextFunction) => {
const sessionId = req.params.sessionId;
// Credentials must be supplied via the header (never the URL/query,
// which leaks into logs, history and referrers).
const password = req.headers['x-macropad-password'] as string;
// Uniform auth failure. A missing session, a missing password, and a
// wrong password all return the SAME status + generic message so a
// caller cannot infer whether a given session id exists (no
// enumeration oracle). validatePassword() safely returns false for an
// unknown session id.
if (!password || !(await sessionManager.validatePassword(sessionId, password))) {
return res.status(401).json({ error: 'Authentication failed' });
}
// Only AFTER successful auth do we reveal desktop connectivity, so the
// 503 "not connected" signal cannot itself be used to probe which
// session ids exist.
const desktop = connectionManager.getDesktopBySessionId(sessionId);
if (!desktop) {
return res.status(503).json({ error: 'Desktop not connected' });
}
// Extract the API path (remove the session ID prefix)
const apiPath = req.path.replace(`/${sessionId}`, '');
try {
const response = await connectionManager.forwardApiRequest(
sessionId,
req.method,
apiPath,
req.body,
filterHeaders(req.headers)
);
// Handle binary responses (images)
if (response.body?.base64 && response.body?.contentType) {
const buffer = Buffer.from(response.body.base64, 'base64');
res.set('Content-Type', response.body.contentType);
// Macro images are content-addressed (uuid filenames) and therefore
// immutable, so let the browser cache them aggressively. 'private'
// keeps them out of shared proxy caches since they sit behind auth.
res.set('Cache-Control', 'private, max-age=31536000, immutable');
res.send(buffer);
} else {
res.status(response.status).json(response.body);
}
} catch (error: any) {
logger.error(`API proxy error for ${sessionId}:`, error);
if (error.message === 'Request timeout') {
return res.status(504).json({ error: 'Desktop request timeout' });
}
if (error.message === 'Desktop not connected' || error.message === 'Desktop disconnected') {
return res.status(503).json({ error: 'Desktop not connected' });
}
res.status(500).json({ error: 'Proxy error' });
}
};
}
function filterHeaders(headers: any): Record<string, string> {
// Only forward relevant headers
const allowed = ['content-type', 'accept', 'accept-language'];
const filtered: Record<string, string> = {};
for (const key of allowed) {
if (headers[key]) {
filtered[key] = headers[key];
}
}
return filtered;
}