Add relay server for remote HTTPS access

Node.js/TypeScript relay server that enables remote access to MacroPad:
- WebSocket-based communication between desktop and relay
- Password authentication with bcrypt hashing
- Session management with consistent IDs
- REST API proxying to desktop app
- Web client WebSocket relay
- Login page and PWA-ready app page
- Designed for cloud-node-container deployment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-05 19:46:33 -08:00
parent 8e4c32fea4
commit 1d7f18018d
16 changed files with 1947 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
// 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;
// Check session exists
const session = sessionManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Check password in header or query
const password = req.headers['x-macropad-password'] as string ||
req.query.password as string;
if (!password) {
return res.status(401).json({ error: 'Password required' });
}
const valid = await sessionManager.validatePassword(sessionId, password);
if (!valid) {
return res.status(401).json({ error: 'Invalid password' });
}
// Check desktop is connected
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);
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;
}