diff --git a/macropad-relay/.gitignore b/macropad-relay/.gitignore
new file mode 100644
index 0000000..5d36149
--- /dev/null
+++ b/macropad-relay/.gitignore
@@ -0,0 +1,26 @@
+# Dependencies
+node_modules/
+
+# Build output
+dist/
+
+# Data
+data/sessions.json
+
+# Environment
+.env
+
+# Logs
+*.log
+error.log
+combined.log
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
diff --git a/macropad-relay/DEPLOY.md b/macropad-relay/DEPLOY.md
new file mode 100644
index 0000000..35a997d
--- /dev/null
+++ b/macropad-relay/DEPLOY.md
@@ -0,0 +1,124 @@
+# MacroPad Relay Server - Deployment Guide
+
+## Cloud Node Container Deployment
+
+For AnHonestHost cloud-node-container deployment:
+
+### 1. Build Locally
+
+```bash
+cd /home/jknapp/code/macropad/macropad-relay
+npm install
+npm run build
+```
+
+### 2. Prepare Deployment Package
+
+The build outputs to `dist/` with public files copied. Upload:
+
+```bash
+# Upload built files to your node container app directory
+rsync -avz --exclude 'node_modules' --exclude 'src' --exclude '.git' \
+ dist/ package.json public/ \
+ user@YOUR_SERVER:/path/to/app/
+```
+
+### 3. On Server
+
+The cloud-node-container will automatically:
+- Install dependencies from package.json
+- Start the app using PM2
+- Configure the process from package.json settings
+
+### 4. Create Data Directory
+
+```bash
+mkdir -p /path/to/app/data
+```
+
+## Directory Structure on Server
+
+```
+app/
+├── index.js # Main entry (compiled)
+├── config.js
+├── server.js
+├── services/
+├── handlers/
+├── utils/
+├── public/
+│ ├── login.html
+│ └── app.html
+├── data/
+│ └── sessions.json # Created automatically
+└── package.json
+```
+
+## Update After Code Changes
+
+```bash
+# On local machine:
+cd /home/jknapp/code/macropad/macropad-relay
+npm run build
+
+rsync -avz --exclude 'node_modules' --exclude 'src' --exclude '.git' --exclude 'data' \
+ dist/ package.json public/ \
+ user@YOUR_SERVER:/path/to/app/
+
+# On server - restart via your container's control panel or:
+pm2 restart macropad-relay
+```
+
+## Environment Variables
+
+Set these in your container configuration:
+
+- `PORT` - Server port (default: 3000)
+- `DATA_DIR` - Data storage path (default: ./data)
+- `NODE_ENV` - production or development
+- `LOG_LEVEL` - info, debug, error
+
+## Test It Works
+
+```bash
+# Test health endpoint
+curl http://localhost:3000/health
+
+# Should return:
+# {"status":"ok","desktopConnections":0,"webClients":0,"sessions":[]}
+```
+
+## Nginx/Reverse Proxy (for HTTPS)
+
+```nginx
+location / {
+ proxy_pass http://localhost:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # WebSocket timeout (24 hours)
+ proxy_read_timeout 86400;
+}
+```
+
+## Troubleshooting
+
+**Check logs:**
+```bash
+pm2 logs macropad-relay
+```
+
+**Check sessions:**
+```bash
+cat /path/to/app/data/sessions.json
+```
+
+**Port in use:**
+```bash
+lsof -i :3000
+```
diff --git a/macropad-relay/package.json b/macropad-relay/package.json
new file mode 100644
index 0000000..3c73796
--- /dev/null
+++ b/macropad-relay/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "macropad-relay",
+ "version": "1.0.0",
+ "description": "Relay server for MacroPad remote access",
+ "main": "index.js",
+ "scripts": {
+ "dev": "nodemon --exec ts-node src/index.ts",
+ "build": "tsc && cp -r public dist/",
+ "start": "node index.js",
+ "clean": "rm -rf dist"
+ },
+ "keywords": ["macropad", "relay", "websocket", "proxy"],
+ "author": "ShadowDao",
+ "license": "MIT",
+ "dependencies": {
+ "bcrypt": "^5.1.1",
+ "cors": "^2.8.5",
+ "dotenv": "^16.3.1",
+ "express": "^4.18.2",
+ "express-rate-limit": "^7.1.5",
+ "helmet": "^7.1.0",
+ "uuid": "^9.0.0",
+ "winston": "^3.11.0",
+ "ws": "^8.14.2"
+ },
+ "devDependencies": {
+ "@types/bcrypt": "^5.0.2",
+ "@types/cors": "^2.8.16",
+ "@types/express": "^4.17.21",
+ "@types/uuid": "^9.0.6",
+ "@types/ws": "^8.5.9",
+ "nodemon": "^3.0.2",
+ "ts-node": "^10.9.2",
+ "typescript": "^5.3.2"
+ }
+}
diff --git a/macropad-relay/public/app.html b/macropad-relay/public/app.html
new file mode 100644
index 0000000..f6bf2d7
--- /dev/null
+++ b/macropad-relay/public/app.html
@@ -0,0 +1,473 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MacroPad
+
+
+
+
+
+ Desktop is offline - waiting for reconnection...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/macropad-relay/public/login.html b/macropad-relay/public/login.html
new file mode 100644
index 0000000..0d0ee95
--- /dev/null
+++ b/macropad-relay/public/login.html
@@ -0,0 +1,248 @@
+
+
+
+
+
+ MacroPad - Login
+
+
+
+
+
MacroPad
+
Enter password to access your macros
+
+
+
+
+
+
Checking connection...
+
+
+
+
+
diff --git a/macropad-relay/src/config.ts b/macropad-relay/src/config.ts
new file mode 100644
index 0000000..15fbe60
--- /dev/null
+++ b/macropad-relay/src/config.ts
@@ -0,0 +1,35 @@
+// Configuration for MacroPad Relay Server
+
+import dotenv from 'dotenv';
+import path from 'path';
+
+dotenv.config();
+
+export const config = {
+ port: parseInt(process.env.PORT || '3000', 10),
+ host: process.env.HOST || '0.0.0.0',
+
+ // Data storage
+ dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'),
+
+ // Session settings
+ sessionIdLength: parseInt(process.env.SESSION_ID_LENGTH || '6', 10),
+
+ // Security
+ bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || '10', 10),
+
+ // Rate limiting
+ rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
+ rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
+
+ // WebSocket
+ pingInterval: parseInt(process.env.PING_INTERVAL || '30000', 10), // 30 seconds
+ requestTimeout: parseInt(process.env.REQUEST_TIMEOUT || '30000', 10), // 30 seconds
+
+ // Logging
+ logLevel: process.env.LOG_LEVEL || 'info',
+
+ // Environment
+ nodeEnv: process.env.NODE_ENV || 'development',
+ isDevelopment: process.env.NODE_ENV !== 'production',
+};
diff --git a/macropad-relay/src/handlers/apiProxy.ts b/macropad-relay/src/handlers/apiProxy.ts
new file mode 100644
index 0000000..5acb9d1
--- /dev/null
+++ b/macropad-relay/src/handlers/apiProxy.ts
@@ -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 {
+ // Only forward relevant headers
+ const allowed = ['content-type', 'accept', 'accept-language'];
+ const filtered: Record = {};
+
+ for (const key of allowed) {
+ if (headers[key]) {
+ filtered[key] = headers[key];
+ }
+ }
+
+ return filtered;
+}
diff --git a/macropad-relay/src/handlers/desktopHandler.ts b/macropad-relay/src/handlers/desktopHandler.ts
new file mode 100644
index 0000000..39f5880
--- /dev/null
+++ b/macropad-relay/src/handlers/desktopHandler.ts
@@ -0,0 +1,168 @@
+// Desktop WebSocket Handler
+
+import WebSocket from 'ws';
+import { ConnectionManager } from '../services/ConnectionManager';
+import { SessionManager } from '../services/SessionManager';
+import { logger } from '../utils/logger';
+
+interface AuthMessage {
+ type: 'auth';
+ sessionId: string | null;
+ password: string;
+}
+
+interface ApiResponseMessage {
+ type: 'api_response';
+ requestId: string;
+ status: number;
+ body: any;
+}
+
+interface WsBroadcastMessage {
+ type: 'ws_broadcast';
+ data: any;
+}
+
+interface PongMessage {
+ type: 'pong';
+}
+
+type DesktopMessage = AuthMessage | ApiResponseMessage | WsBroadcastMessage | PongMessage;
+
+export function handleDesktopConnection(
+ socket: WebSocket,
+ connectionManager: ConnectionManager,
+ sessionManager: SessionManager
+): void {
+ let authenticatedSessionId: string | null = null;
+
+ socket.on('message', async (data) => {
+ try {
+ const message: DesktopMessage = JSON.parse(data.toString());
+
+ switch (message.type) {
+ case 'auth':
+ await handleAuth(socket, message, sessionManager, connectionManager, (sessionId) => {
+ authenticatedSessionId = sessionId;
+ });
+ break;
+
+ case 'api_response':
+ if (authenticatedSessionId) {
+ handleApiResponse(message, authenticatedSessionId, connectionManager);
+ }
+ break;
+
+ case 'ws_broadcast':
+ if (authenticatedSessionId) {
+ handleWsBroadcast(message, authenticatedSessionId, connectionManager);
+ }
+ break;
+
+ case 'pong':
+ if (authenticatedSessionId) {
+ connectionManager.updateDesktopPing(authenticatedSessionId);
+ }
+ break;
+
+ default:
+ logger.warn('Unknown message type from desktop:', (message as any).type);
+ }
+ } catch (error) {
+ logger.error('Error handling desktop message:', error);
+ }
+ });
+
+ socket.on('close', () => {
+ if (authenticatedSessionId) {
+ connectionManager.disconnectDesktop(authenticatedSessionId);
+ }
+ });
+
+ socket.on('error', (error) => {
+ logger.error('Desktop WebSocket error:', error);
+ if (authenticatedSessionId) {
+ connectionManager.disconnectDesktop(authenticatedSessionId);
+ }
+ });
+}
+
+async function handleAuth(
+ socket: WebSocket,
+ message: AuthMessage,
+ sessionManager: SessionManager,
+ connectionManager: ConnectionManager,
+ setSessionId: (id: string) => void
+): Promise {
+ try {
+ let sessionId = message.sessionId;
+ let session;
+
+ if (sessionId) {
+ // Validate existing session
+ const valid = await sessionManager.validatePassword(sessionId, message.password);
+ if (!valid) {
+ socket.send(JSON.stringify({
+ type: 'auth_response',
+ success: false,
+ error: 'Invalid session ID or password'
+ }));
+ socket.close();
+ return;
+ }
+ session = sessionManager.getSession(sessionId);
+ } else {
+ // Create new session
+ session = await sessionManager.createSession(message.password);
+ sessionId = session.id;
+ }
+
+ if (!session || !sessionId) {
+ socket.send(JSON.stringify({
+ type: 'auth_response',
+ success: false,
+ error: 'Failed to create session'
+ }));
+ socket.close();
+ return;
+ }
+
+ // Add to connection manager
+ connectionManager.addDesktopConnection(sessionId, socket);
+ setSessionId(sessionId);
+
+ // Send success response
+ socket.send(JSON.stringify({
+ type: 'auth_response',
+ success: true,
+ sessionId: sessionId
+ }));
+
+ logger.info(`Desktop authenticated: ${sessionId}`);
+ } catch (error) {
+ logger.error('Desktop auth error:', error);
+ socket.send(JSON.stringify({
+ type: 'auth_response',
+ success: false,
+ error: 'Authentication failed'
+ }));
+ socket.close();
+ }
+}
+
+function handleApiResponse(
+ message: ApiResponseMessage,
+ sessionId: string,
+ connectionManager: ConnectionManager
+): void {
+ connectionManager.handleApiResponse(sessionId, message.requestId, message.status, message.body);
+}
+
+function handleWsBroadcast(
+ message: WsBroadcastMessage,
+ sessionId: string,
+ connectionManager: ConnectionManager
+): void {
+ // Forward the broadcast to all web clients for this session
+ connectionManager.broadcastToWebClients(sessionId, message.data);
+}
diff --git a/macropad-relay/src/handlers/webClientHandler.ts b/macropad-relay/src/handlers/webClientHandler.ts
new file mode 100644
index 0000000..e078d32
--- /dev/null
+++ b/macropad-relay/src/handlers/webClientHandler.ts
@@ -0,0 +1,122 @@
+// Web Client WebSocket Handler
+
+import WebSocket from 'ws';
+import { ConnectionManager, WebClientConnection } from '../services/ConnectionManager';
+import { SessionManager } from '../services/SessionManager';
+import { logger } from '../utils/logger';
+
+interface AuthMessage {
+ type: 'auth';
+ password: string;
+}
+
+interface PingMessage {
+ type: 'ping';
+}
+
+type WebClientMessage = AuthMessage | PingMessage | any;
+
+export function handleWebClientConnection(
+ socket: WebSocket,
+ sessionId: string,
+ connectionManager: ConnectionManager,
+ sessionManager: SessionManager
+): void {
+ // Check if session exists
+ const session = sessionManager.getSession(sessionId);
+ if (!session) {
+ socket.send(JSON.stringify({
+ type: 'error',
+ error: 'Session not found'
+ }));
+ socket.close();
+ return;
+ }
+
+ // Add client (not authenticated yet)
+ const client = connectionManager.addWebClient(sessionId, socket, false);
+
+ // Check if desktop is connected
+ const desktop = connectionManager.getDesktopBySessionId(sessionId);
+ socket.send(JSON.stringify({
+ type: 'desktop_status',
+ status: desktop ? 'connected' : 'disconnected'
+ }));
+
+ // Request authentication
+ socket.send(JSON.stringify({
+ type: 'auth_required'
+ }));
+
+ socket.on('message', async (data) => {
+ try {
+ const message: WebClientMessage = JSON.parse(data.toString());
+
+ switch (message.type) {
+ case 'auth':
+ await handleAuth(socket, client, message, sessionId, sessionManager);
+ break;
+
+ case 'ping':
+ socket.send(JSON.stringify({ type: 'pong' }));
+ break;
+
+ default:
+ // Forward other messages to desktop if authenticated
+ if (client.authenticated) {
+ forwardToDesktop(message, sessionId, connectionManager);
+ }
+ }
+ } catch (error) {
+ logger.error('Error handling web client message:', error);
+ }
+ });
+
+ socket.on('close', () => {
+ connectionManager.removeWebClient(sessionId, client);
+ });
+
+ socket.on('error', (error) => {
+ logger.error('Web client WebSocket error:', error);
+ connectionManager.removeWebClient(sessionId, client);
+ });
+}
+
+async function handleAuth(
+ socket: WebSocket,
+ client: WebClientConnection,
+ message: AuthMessage,
+ sessionId: string,
+ sessionManager: SessionManager
+): Promise {
+ const valid = await sessionManager.validatePassword(sessionId, message.password);
+
+ if (valid) {
+ client.authenticated = true;
+ socket.send(JSON.stringify({
+ type: 'auth_response',
+ success: true
+ }));
+ logger.debug(`Web client authenticated for session: ${sessionId}`);
+ } else {
+ socket.send(JSON.stringify({
+ type: 'auth_response',
+ success: false,
+ error: 'Invalid password'
+ }));
+ }
+}
+
+function forwardToDesktop(
+ message: any,
+ sessionId: string,
+ connectionManager: ConnectionManager
+): void {
+ const desktop = connectionManager.getDesktopBySessionId(sessionId);
+ if (desktop && desktop.socket.readyState === WebSocket.OPEN) {
+ desktop.socket.send(JSON.stringify({
+ type: 'ws_message',
+ data: message
+ }));
+ }
+}
diff --git a/macropad-relay/src/index.ts b/macropad-relay/src/index.ts
new file mode 100644
index 0000000..6349841
--- /dev/null
+++ b/macropad-relay/src/index.ts
@@ -0,0 +1,21 @@
+// MacroPad Relay Server Entry Point
+
+import { createServer } from './server';
+import { config } from './config';
+import { logger } from './utils/logger';
+
+async function main() {
+ logger.info('Starting MacroPad Relay Server...');
+
+ const { server } = createServer();
+
+ server.listen(config.port, config.host, () => {
+ logger.info(`Server running on http://${config.host}:${config.port}`);
+ logger.info(`Environment: ${config.nodeEnv}`);
+ });
+}
+
+main().catch((error) => {
+ logger.error('Failed to start server:', error);
+ process.exit(1);
+});
diff --git a/macropad-relay/src/server.ts b/macropad-relay/src/server.ts
new file mode 100644
index 0000000..ae541c6
--- /dev/null
+++ b/macropad-relay/src/server.ts
@@ -0,0 +1,124 @@
+// Express + WebSocket Server Setup
+
+import express from 'express';
+import fs from 'fs';
+import http from 'http';
+import WebSocket, { WebSocketServer } from 'ws';
+import cors from 'cors';
+import helmet from 'helmet';
+import rateLimit from 'express-rate-limit';
+import path from 'path';
+
+import { config } from './config';
+import { logger } from './utils/logger';
+import { SessionManager } from './services/SessionManager';
+import { ConnectionManager } from './services/ConnectionManager';
+import { handleDesktopConnection } from './handlers/desktopHandler';
+import { handleWebClientConnection } from './handlers/webClientHandler';
+import { createApiProxy } from './handlers/apiProxy';
+
+export function createServer() {
+ const app = express();
+ const server = http.createServer(app);
+
+ // Initialize managers
+ const sessionManager = new SessionManager();
+ const connectionManager = new ConnectionManager();
+
+ // Middleware
+ app.use(helmet({
+ contentSecurityPolicy: false // Allow inline scripts for login page
+ }));
+ app.use(cors());
+ app.use(express.json());
+
+ // Rate limiting
+ const limiter = rateLimit({
+ windowMs: config.rateLimitWindowMs,
+ max: config.rateLimitMax,
+ message: { error: 'Too many requests, please try again later' }
+ });
+ app.use(limiter);
+
+ // Static files - check both locations (dev vs production)
+ const publicPath = fs.existsSync(path.join(__dirname, 'public'))
+ ? path.join(__dirname, 'public')
+ : path.join(__dirname, '..', 'public');
+ app.use('/static', express.static(publicPath));
+
+ // Health check
+ app.get('/health', (req, res) => {
+ const stats = connectionManager.getStats();
+ res.json({
+ status: 'ok',
+ ...stats
+ });
+ });
+
+ // Login page for session
+ app.get('/:sessionId', (req, res) => {
+ const session = sessionManager.getSession(req.params.sessionId);
+ if (!session) {
+ return res.status(404).send('Session not found');
+ }
+ res.sendFile(path.join(publicPath, 'login.html'));
+ });
+
+ // PWA app page (after authentication)
+ app.get('/:sessionId/app', (req, res) => {
+ const session = sessionManager.getSession(req.params.sessionId);
+ if (!session) {
+ return res.status(404).send('Session not found');
+ }
+ res.sendFile(path.join(publicPath, 'app.html'));
+ });
+
+ // API proxy routes
+ const apiProxy = createApiProxy(connectionManager, sessionManager);
+ app.all('/:sessionId/api/*', apiProxy);
+
+ // WebSocket server
+ const wss = new WebSocketServer({ noServer: true });
+
+ // Handle HTTP upgrade for WebSocket
+ server.on('upgrade', (request, socket, head) => {
+ const url = new URL(request.url || '', `http://${request.headers.host}`);
+ const pathname = url.pathname;
+
+ // Desktop connection: /desktop
+ if (pathname === '/desktop') {
+ wss.handleUpgrade(request, socket, head, (ws) => {
+ handleDesktopConnection(ws, connectionManager, sessionManager);
+ });
+ return;
+ }
+
+ // Web client connection: /:sessionId/ws
+ const webClientMatch = pathname.match(/^\/([a-zA-Z0-9]+)\/ws$/);
+ if (webClientMatch) {
+ const sessionId = webClientMatch[1];
+ wss.handleUpgrade(request, socket, head, (ws) => {
+ handleWebClientConnection(ws, sessionId, connectionManager, sessionManager);
+ });
+ return;
+ }
+
+ // Invalid path
+ socket.destroy();
+ });
+
+ // Graceful shutdown
+ const shutdown = () => {
+ logger.info('Shutting down...');
+ connectionManager.shutdown();
+ server.close(() => {
+ logger.info('Server closed');
+ process.exit(0);
+ });
+ };
+
+ process.on('SIGTERM', shutdown);
+ process.on('SIGINT', shutdown);
+
+ return { app, server, sessionManager, connectionManager };
+}
diff --git a/macropad-relay/src/services/ConnectionManager.ts b/macropad-relay/src/services/ConnectionManager.ts
new file mode 100644
index 0000000..2d5bb98
--- /dev/null
+++ b/macropad-relay/src/services/ConnectionManager.ts
@@ -0,0 +1,275 @@
+// Connection Manager - manages desktop and web client connections
+
+import WebSocket from 'ws';
+import { logger } from '../utils/logger';
+import { generateRequestId } from '../utils/idGenerator';
+import { config } from '../config';
+
+export interface PendingRequest {
+ resolve: (response: any) => void;
+ reject: (error: Error) => void;
+ timeout: NodeJS.Timeout;
+}
+
+export interface DesktopConnection {
+ sessionId: string;
+ socket: WebSocket;
+ authenticated: boolean;
+ connectedAt: Date;
+ lastPing: Date;
+ pendingRequests: Map;
+}
+
+export interface WebClientConnection {
+ socket: WebSocket;
+ sessionId: string;
+ authenticated: boolean;
+}
+
+export class ConnectionManager {
+ // Desktop connections: sessionId -> connection
+ private desktopConnections: Map = new Map();
+
+ // Web clients: sessionId -> set of connections
+ private webClients: Map> = new Map();
+
+ // Ping interval handle
+ private pingInterval: NodeJS.Timeout | null = null;
+
+ constructor() {
+ this.startPingInterval();
+ }
+
+ private startPingInterval(): void {
+ this.pingInterval = setInterval(() => {
+ this.pingDesktops();
+ }, config.pingInterval);
+ }
+
+ private pingDesktops(): void {
+ const now = Date.now();
+ for (const [sessionId, desktop] of this.desktopConnections) {
+ // Check if desktop hasn't responded in too long
+ if (now - desktop.lastPing.getTime() > config.pingInterval * 2) {
+ logger.warn(`Desktop ${sessionId} not responding, disconnecting`);
+ this.disconnectDesktop(sessionId);
+ continue;
+ }
+
+ // Send ping
+ try {
+ desktop.socket.send(JSON.stringify({ type: 'ping' }));
+ } catch (error) {
+ logger.error(`Failed to ping desktop ${sessionId}:`, error);
+ this.disconnectDesktop(sessionId);
+ }
+ }
+ }
+
+ // Desktop connection methods
+
+ addDesktopConnection(sessionId: string, socket: WebSocket): DesktopConnection {
+ // Close existing connection if any
+ const existing = this.desktopConnections.get(sessionId);
+ if (existing) {
+ try {
+ existing.socket.close();
+ } catch (e) {
+ // Ignore
+ }
+ }
+
+ const connection: DesktopConnection = {
+ sessionId,
+ socket,
+ authenticated: true,
+ connectedAt: new Date(),
+ lastPing: new Date(),
+ pendingRequests: new Map()
+ };
+
+ this.desktopConnections.set(sessionId, connection);
+ logger.info(`Desktop connected: ${sessionId}`);
+
+ // Notify web clients
+ this.broadcastToWebClients(sessionId, {
+ type: 'desktop_status',
+ status: 'connected'
+ });
+
+ return connection;
+ }
+
+ disconnectDesktop(sessionId: string): void {
+ const connection = this.desktopConnections.get(sessionId);
+ if (connection) {
+ // Reject all pending requests
+ for (const [requestId, pending] of connection.pendingRequests) {
+ clearTimeout(pending.timeout);
+ pending.reject(new Error('Desktop disconnected'));
+ }
+
+ try {
+ connection.socket.close();
+ } catch (e) {
+ // Ignore
+ }
+
+ this.desktopConnections.delete(sessionId);
+ logger.info(`Desktop disconnected: ${sessionId}`);
+
+ // Notify web clients
+ this.broadcastToWebClients(sessionId, {
+ type: 'desktop_status',
+ status: 'disconnected'
+ });
+ }
+ }
+
+ getDesktopBySessionId(sessionId: string): DesktopConnection | undefined {
+ return this.desktopConnections.get(sessionId);
+ }
+
+ updateDesktopPing(sessionId: string): void {
+ const connection = this.desktopConnections.get(sessionId);
+ if (connection) {
+ connection.lastPing = new Date();
+ }
+ }
+
+ // Web client connection methods
+
+ addWebClient(sessionId: string, socket: WebSocket, authenticated: boolean = false): WebClientConnection {
+ if (!this.webClients.has(sessionId)) {
+ this.webClients.set(sessionId, new Set());
+ }
+
+ const client: WebClientConnection = {
+ socket,
+ sessionId,
+ authenticated
+ };
+
+ this.webClients.get(sessionId)!.add(client);
+ logger.debug(`Web client connected to session: ${sessionId}`);
+
+ return client;
+ }
+
+ removeWebClient(sessionId: string, client: WebClientConnection): void {
+ const clients = this.webClients.get(sessionId);
+ if (clients) {
+ clients.delete(client);
+ if (clients.size === 0) {
+ this.webClients.delete(sessionId);
+ }
+ }
+ logger.debug(`Web client disconnected from session: ${sessionId}`);
+ }
+
+ getWebClientsBySessionId(sessionId: string): Set {
+ return this.webClients.get(sessionId) || new Set();
+ }
+
+ broadcastToWebClients(sessionId: string, message: object): void {
+ const clients = this.webClients.get(sessionId);
+ if (!clients) return;
+
+ const data = JSON.stringify(message);
+ for (const client of clients) {
+ if (client.authenticated && client.socket.readyState === WebSocket.OPEN) {
+ try {
+ client.socket.send(data);
+ } catch (error) {
+ logger.error('Failed to send to web client:', error);
+ }
+ }
+ }
+ }
+
+ // API request forwarding
+
+ async forwardApiRequest(
+ sessionId: string,
+ method: string,
+ path: string,
+ body?: any,
+ headers?: Record
+ ): Promise<{ status: number; body: any }> {
+ const desktop = this.desktopConnections.get(sessionId);
+ if (!desktop) {
+ throw new Error('Desktop not connected');
+ }
+
+ const requestId = generateRequestId();
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ desktop.pendingRequests.delete(requestId);
+ reject(new Error('Request timeout'));
+ }, config.requestTimeout);
+
+ desktop.pendingRequests.set(requestId, { resolve, reject, timeout });
+
+ try {
+ desktop.socket.send(JSON.stringify({
+ type: 'api_request',
+ requestId,
+ method,
+ path,
+ body,
+ headers
+ }));
+ } catch (error) {
+ desktop.pendingRequests.delete(requestId);
+ clearTimeout(timeout);
+ reject(error);
+ }
+ });
+ }
+
+ handleApiResponse(sessionId: string, requestId: string, status: number, body: any): void {
+ const desktop = this.desktopConnections.get(sessionId);
+ if (!desktop) return;
+
+ const pending = desktop.pendingRequests.get(requestId);
+ if (pending) {
+ clearTimeout(pending.timeout);
+ desktop.pendingRequests.delete(requestId);
+ pending.resolve({ status, body });
+ }
+ }
+
+ // Cleanup
+
+ shutdown(): void {
+ if (this.pingInterval) {
+ clearInterval(this.pingInterval);
+ }
+
+ for (const [sessionId] of this.desktopConnections) {
+ this.disconnectDesktop(sessionId);
+ }
+
+ for (const [sessionId, clients] of this.webClients) {
+ for (const client of clients) {
+ try {
+ client.socket.close();
+ } catch (e) {
+ // Ignore
+ }
+ }
+ }
+ this.webClients.clear();
+ }
+
+ // Stats
+
+ getStats() {
+ return {
+ desktopConnections: this.desktopConnections.size,
+ webClients: Array.from(this.webClients.values()).reduce((sum, set) => sum + set.size, 0),
+ sessions: Array.from(this.desktopConnections.keys())
+ };
+ }
+}
diff --git a/macropad-relay/src/services/SessionManager.ts b/macropad-relay/src/services/SessionManager.ts
new file mode 100644
index 0000000..0e926b8
--- /dev/null
+++ b/macropad-relay/src/services/SessionManager.ts
@@ -0,0 +1,121 @@
+// Session Manager - handles session storage and authentication
+
+import fs from 'fs';
+import path from 'path';
+import bcrypt from 'bcrypt';
+import { config } from '../config';
+import { generateSessionId } from '../utils/idGenerator';
+import { logger } from '../utils/logger';
+
+export interface Session {
+ id: string;
+ passwordHash: string;
+ createdAt: string;
+ lastConnected: string;
+}
+
+interface SessionStore {
+ sessions: Record;
+}
+
+export class SessionManager {
+ private sessionsFile: string;
+ private sessions: Map = new Map();
+
+ constructor() {
+ this.sessionsFile = path.join(config.dataDir, 'sessions.json');
+ this.load();
+ }
+
+ private load(): void {
+ try {
+ if (fs.existsSync(this.sessionsFile)) {
+ const data = JSON.parse(fs.readFileSync(this.sessionsFile, 'utf-8')) as SessionStore;
+ for (const [id, session] of Object.entries(data.sessions || {})) {
+ this.sessions.set(id, session);
+ }
+ logger.info(`Loaded ${this.sessions.size} sessions`);
+ }
+ } catch (error) {
+ logger.error('Failed to load sessions:', error);
+ }
+ }
+
+ private save(): void {
+ try {
+ const store: SessionStore = {
+ sessions: Object.fromEntries(this.sessions)
+ };
+ fs.mkdirSync(path.dirname(this.sessionsFile), { recursive: true });
+ fs.writeFileSync(this.sessionsFile, JSON.stringify(store, null, 2));
+ } catch (error) {
+ logger.error('Failed to save sessions:', error);
+ }
+ }
+
+ async exists(sessionId: string): Promise {
+ return this.sessions.has(sessionId);
+ }
+
+ async createSession(password: string): Promise {
+ // Generate unique session ID
+ let id: string;
+ do {
+ id = generateSessionId(config.sessionIdLength);
+ } while (this.sessions.has(id));
+
+ const passwordHash = await bcrypt.hash(password, config.bcryptRounds);
+ const now = new Date().toISOString();
+
+ const session: Session = {
+ id,
+ passwordHash,
+ createdAt: now,
+ lastConnected: now
+ };
+
+ this.sessions.set(id, session);
+ this.save();
+
+ logger.info(`Created new session: ${id}`);
+ return session;
+ }
+
+ async validatePassword(sessionId: string, password: string): Promise {
+ const session = this.sessions.get(sessionId);
+ if (!session) {
+ return false;
+ }
+
+ const valid = await bcrypt.compare(password, session.passwordHash);
+
+ if (valid) {
+ // Update last connected time
+ session.lastConnected = new Date().toISOString();
+ this.save();
+ }
+
+ return valid;
+ }
+
+ getSession(sessionId: string): Session | undefined {
+ return this.sessions.get(sessionId);
+ }
+
+ updateLastConnected(sessionId: string): void {
+ const session = this.sessions.get(sessionId);
+ if (session) {
+ session.lastConnected = new Date().toISOString();
+ this.save();
+ }
+ }
+
+ deleteSession(sessionId: string): boolean {
+ const deleted = this.sessions.delete(sessionId);
+ if (deleted) {
+ this.save();
+ logger.info(`Deleted session: ${sessionId}`);
+ }
+ return deleted;
+ }
+}
diff --git a/macropad-relay/src/utils/idGenerator.ts b/macropad-relay/src/utils/idGenerator.ts
new file mode 100644
index 0000000..88b11f0
--- /dev/null
+++ b/macropad-relay/src/utils/idGenerator.ts
@@ -0,0 +1,31 @@
+// Unique ID generation utilities
+
+import { randomBytes } from 'crypto';
+
+const BASE62_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+/**
+ * Generate a random base62 string of specified length.
+ * Uses cryptographically secure random bytes.
+ */
+export function generateSessionId(length: number = 6): string {
+ const bytes = randomBytes(length);
+ let result = '';
+
+ for (let i = 0; i < length; i++) {
+ result += BASE62_CHARS[bytes[i] % BASE62_CHARS.length];
+ }
+
+ return result;
+}
+
+/**
+ * Generate a UUID v4 for request IDs.
+ */
+export function generateRequestId(): string {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = Math.random() * 16 | 0;
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+}
diff --git a/macropad-relay/src/utils/logger.ts b/macropad-relay/src/utils/logger.ts
new file mode 100644
index 0000000..26d3e27
--- /dev/null
+++ b/macropad-relay/src/utils/logger.ts
@@ -0,0 +1,36 @@
+// Logger utility using Winston
+
+import winston from 'winston';
+import { config } from '../config';
+
+const logFormat = winston.format.combine(
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+ winston.format.errors({ stack: true }),
+ winston.format.printf(({ level, message, timestamp, stack }) => {
+ return `${timestamp} [${level.toUpperCase()}]: ${stack || message}`;
+ })
+);
+
+export const logger = winston.createLogger({
+ level: config.logLevel,
+ format: logFormat,
+ transports: [
+ new winston.transports.Console({
+ format: winston.format.combine(
+ winston.format.colorize(),
+ logFormat
+ )
+ })
+ ]
+});
+
+// Add file transport in production
+if (!config.isDevelopment) {
+ logger.add(new winston.transports.File({
+ filename: 'error.log',
+ level: 'error'
+ }));
+ logger.add(new winston.transports.File({
+ filename: 'combined.log'
+ }));
+}
diff --git a/macropad-relay/tsconfig.json b/macropad-relay/tsconfig.json
new file mode 100644
index 0000000..50572da
--- /dev/null
+++ b/macropad-relay/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "lib": ["ES2020"],
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}