const http = require('http') const fs = require('fs') const path = require('path') const WebSocket = require('ws') const { WebSocketServer } = WebSocket const HOST = '0.0.0.0' const PORT = 17865 const GPS_WS_PATH = '/mock-gps' const HEART_RATE_WS_PATH = '/mock-hr' const DEBUG_LOG_WS_PATH = '/debug-log' const PROXY_PATH = '/proxy' const BRIDGE_STATUS_PATH = '/bridge-status' const BRIDGE_CONFIG_PATH = '/bridge-config' const PUBLIC_DIR = path.join(__dirname, 'public') const DEFAULT_GATEWAY_BRIDGE_URL = 'ws://127.0.0.1:18080/ws' const INITIAL_BRIDGE_CONFIG = { enabled: process.env.MOCK_SIM_GATEWAY_ENABLED === '1', url: process.env.MOCK_SIM_GATEWAY_URL || DEFAULT_GATEWAY_BRIDGE_URL, token: process.env.MOCK_SIM_GATEWAY_TOKEN || 'dev-producer-token', channelId: process.env.MOCK_SIM_GATEWAY_CHANNEL_ID || '', deviceId: process.env.MOCK_SIM_GATEWAY_DEVICE_ID || 'child-001', groupId: process.env.MOCK_SIM_GATEWAY_GROUP_ID || '', sourceId: process.env.MOCK_SIM_GATEWAY_SOURCE_ID || 'mock-gps-sim', sourceMode: process.env.MOCK_SIM_GATEWAY_SOURCE_MODE || 'mock', reconnectMs: Math.max(1000, Number(process.env.MOCK_SIM_GATEWAY_RECONNECT_MS) || 3000), } function getContentType(filePath) { const ext = path.extname(filePath).toLowerCase() if (ext === '.html') { return 'text/html; charset=utf-8' } if (ext === '.css') { return 'text/css; charset=utf-8' } if (ext === '.js') { return 'application/javascript; charset=utf-8' } if (ext === '.json') { return 'application/json; charset=utf-8' } if (ext === '.svg') { return 'image/svg+xml' } return 'text/plain; charset=utf-8' } function respondJson(response, statusCode, payload) { response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', }) response.end(JSON.stringify(payload)) } function serveStatic(requestPath, response) { let safePath = requestPath === '/' ? '/index.html' : requestPath if (safePath.endsWith('/')) { safePath = `${safePath}index.html` } else if (!path.extname(safePath)) { safePath = `${safePath}/index.html` } const resolvedPath = path.normalize(path.join(PUBLIC_DIR, safePath)) if (!resolvedPath.startsWith(PUBLIC_DIR)) { response.writeHead(403) response.end('Forbidden') return } fs.readFile(resolvedPath, (error, content) => { if (error) { response.writeHead(404) response.end('Not Found') return } response.writeHead(200, { 'Content-Type': getContentType(resolvedPath), 'Cache-Control': 'no-store', }) response.end(content) }) } function isMockGpsPayload(payload) { return payload && payload.type === 'mock_gps' && Number.isFinite(payload.lat) && Number.isFinite(payload.lon) } function isMockHeartRatePayload(payload) { return payload && payload.type === 'mock_heart_rate' && Number.isFinite(payload.bpm) } function isDebugLogPayload(payload) { return payload && payload.type === 'debug-log' && typeof payload.scope === 'string' && typeof payload.level === 'string' && typeof payload.message === 'string' } function normalizeChannelId(value) { const trimmed = String(value || '').trim() return trimmed || 'default' } async function handleProxyRequest(request, response) { const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`) const targetUrl = requestUrl.searchParams.get('url') if (!targetUrl) { response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8', 'Access-Control-Allow-Origin': '*', }) response.end('Missing url') return } try { const upstream = await fetch(targetUrl) const body = Buffer.from(await upstream.arrayBuffer()) response.writeHead(upstream.status, { 'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', }) response.end(body) } catch (error) { response.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8', 'Access-Control-Allow-Origin': '*', }) response.end(error && error.message ? error.message : 'Proxy request failed') } } async function readJsonBody(request) { return new Promise((resolve, reject) => { const chunks = [] request.on('data', (chunk) => { chunks.push(chunk) }) request.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8').trim() if (!raw) { resolve({}) return } try { resolve(JSON.parse(raw)) } catch (error) { reject(error) } }) request.on('error', reject) }) } function normalizeBridgeConfig(input, currentConfig) { const source = input || {} const fallback = currentConfig || INITIAL_BRIDGE_CONFIG return { enabled: typeof source.enabled === 'boolean' ? source.enabled : fallback.enabled, url: typeof source.url === 'string' && source.url.trim() ? source.url.trim() : fallback.url, token: typeof source.token === 'string' ? source.token.trim() : fallback.token, channelId: typeof source.channelId === 'string' ? source.channelId.trim() : fallback.channelId, deviceId: typeof source.deviceId === 'string' && source.deviceId.trim() ? source.deviceId.trim() : fallback.deviceId, groupId: typeof source.groupId === 'string' ? source.groupId.trim() : fallback.groupId, sourceId: typeof source.sourceId === 'string' && source.sourceId.trim() ? source.sourceId.trim() : fallback.sourceId, sourceMode: typeof source.sourceMode === 'string' && source.sourceMode.trim() ? source.sourceMode.trim() : fallback.sourceMode, reconnectMs: Math.max(1000, Number(source.reconnectMs) || fallback.reconnectMs), } } function createGatewayBridge() { const bridgeState = { config: { ...INITIAL_BRIDGE_CONFIG }, socket: null, connecting: false, connected: false, authenticated: false, reconnectTimer: 0, lastError: '', lastSentAt: 0, lastSentTopic: '', sentCount: 0, droppedCount: 0, } function logBridge(message) { console.log(`[gateway-bridge] ${message}`) } function clearReconnectTimer() { if (!bridgeState.reconnectTimer) { return } clearTimeout(bridgeState.reconnectTimer) bridgeState.reconnectTimer = 0 } function scheduleReconnect() { if (!bridgeState.config.enabled || bridgeState.reconnectTimer) { return } bridgeState.reconnectTimer = setTimeout(() => { bridgeState.reconnectTimer = 0 connect() }, bridgeState.config.reconnectMs) } function resetSocketState() { bridgeState.socket = null bridgeState.connecting = false bridgeState.connected = false bridgeState.authenticated = false } function handleGatewayMessage(rawMessage) { let parsed try { parsed = JSON.parse(String(rawMessage)) } catch (_error) { return } if (parsed.type === 'welcome') { if (!bridgeState.socket || bridgeState.socket.readyState !== WebSocket.OPEN) { return } if (bridgeState.config.channelId) { bridgeState.socket.send(JSON.stringify({ type: 'join_channel', role: 'producer', channelId: bridgeState.config.channelId, token: bridgeState.config.token, })) } else { bridgeState.socket.send(JSON.stringify({ type: 'authenticate', role: 'producer', token: bridgeState.config.token, })) } return } if (parsed.type === 'authenticated' || parsed.type === 'joined_channel') { bridgeState.authenticated = true bridgeState.lastError = '' if (bridgeState.config.channelId) { logBridge(`joined channel=${bridgeState.config.channelId}, device=${bridgeState.config.deviceId}, source=${bridgeState.config.sourceId}`) } else { logBridge(`authenticated, device=${bridgeState.config.deviceId}, source=${bridgeState.config.sourceId}`) } return } if (parsed.type === 'error') { bridgeState.lastError = parsed.error || 'gateway error' logBridge(`error: ${bridgeState.lastError}`) } } function closeSocket() { if (!bridgeState.socket) { return } try { bridgeState.socket.close() } catch (_error) { // noop } resetSocketState() } function connect() { if (!bridgeState.config.enabled || bridgeState.connecting) { return } if (bridgeState.socket && (bridgeState.socket.readyState === WebSocket.OPEN || bridgeState.socket.readyState === WebSocket.CONNECTING)) { return } clearReconnectTimer() bridgeState.connecting = true bridgeState.lastError = '' logBridge(`connecting to ${bridgeState.config.url}`) const socket = new WebSocket(bridgeState.config.url) bridgeState.socket = socket socket.on('open', () => { bridgeState.connecting = false bridgeState.connected = true logBridge('connected') }) socket.on('message', handleGatewayMessage) socket.on('close', () => { const wasConnected = bridgeState.connected || bridgeState.authenticated resetSocketState() if (wasConnected) { logBridge('disconnected') } scheduleReconnect() }) socket.on('error', (error) => { bridgeState.lastError = error && error.message ? error.message : 'gateway socket error' logBridge(`socket error: ${bridgeState.lastError}`) }) } function toGatewayEnvelope(payload) { if (isMockGpsPayload(payload)) { return { schemaVersion: 1, messageId: `gps-${payload.timestamp}`, timestamp: payload.timestamp, topic: 'telemetry.location', source: { kind: 'producer', id: bridgeState.config.sourceId, mode: bridgeState.config.sourceMode, }, target: { channelId: bridgeState.config.channelId, deviceId: bridgeState.config.deviceId, groupId: bridgeState.config.groupId, }, payload: { lat: Number(payload.lat), lng: Number(payload.lon), speed: Number(payload.speedMps) || 0, bearing: Number(payload.headingDeg) || 0, accuracy: Number(payload.accuracyMeters) || 6, coordSystem: 'GCJ02', }, } } if (isMockHeartRatePayload(payload)) { return { schemaVersion: 1, messageId: `hr-${payload.timestamp}`, timestamp: payload.timestamp, topic: 'telemetry.heart_rate', source: { kind: 'producer', id: bridgeState.config.sourceId, mode: bridgeState.config.sourceMode, }, target: { channelId: bridgeState.config.channelId, deviceId: bridgeState.config.deviceId, groupId: bridgeState.config.groupId, }, payload: { bpm: Math.max(1, Math.round(Number(payload.bpm))), }, } } return null } function publish(payload) { if (!bridgeState.config.enabled) { return } if (!bridgeState.socket || bridgeState.socket.readyState !== WebSocket.OPEN || !bridgeState.authenticated) { bridgeState.droppedCount += 1 connect() return } const envelope = toGatewayEnvelope(payload) if (!envelope) { return } bridgeState.socket.send(JSON.stringify({ type: 'publish', envelope, })) bridgeState.lastSentAt = Date.now() bridgeState.lastSentTopic = envelope.topic bridgeState.sentCount += 1 } function updateConfig(nextConfigInput) { const nextConfig = normalizeBridgeConfig(nextConfigInput, bridgeState.config) const changed = JSON.stringify(nextConfig) !== JSON.stringify(bridgeState.config) bridgeState.config = nextConfig if (!changed) { return getStatus() } bridgeState.lastError = '' if (!bridgeState.config.enabled) { clearReconnectTimer() closeSocket() logBridge('disabled') return getStatus() } clearReconnectTimer() closeSocket() connect() return getStatus() } function getConfig() { return { ...bridgeState.config } } function getStatus() { return { enabled: bridgeState.config.enabled, url: bridgeState.config.url, connected: bridgeState.connected, authenticated: bridgeState.authenticated, channelId: bridgeState.config.channelId, deviceId: bridgeState.config.deviceId, groupId: bridgeState.config.groupId, sourceId: bridgeState.config.sourceId, sourceMode: bridgeState.config.sourceMode, reconnectMs: bridgeState.config.reconnectMs, hasToken: Boolean(bridgeState.config.token), sentCount: bridgeState.sentCount, droppedCount: bridgeState.droppedCount, lastSentAt: bridgeState.lastSentAt, lastSentTopic: bridgeState.lastSentTopic, lastError: bridgeState.lastError, } } if (bridgeState.config.enabled) { connect() } return { publish, updateConfig, getConfig, getStatus, } } const gatewayBridge = createGatewayBridge() const server = http.createServer((request, response) => { if (request.method === 'OPTIONS') { response.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }) response.end() return } if ((request.url || '').startsWith(PROXY_PATH)) { handleProxyRequest(request, response) return } if ((request.url || '').startsWith(BRIDGE_CONFIG_PATH)) { if (request.method === 'GET') { respondJson(response, 200, { config: gatewayBridge.getConfig(), status: gatewayBridge.getStatus(), }) return } if (request.method === 'POST') { readJsonBody(request) .then((payload) => { const status = gatewayBridge.updateConfig(payload) respondJson(response, 200, { config: gatewayBridge.getConfig(), status, }) }) .catch((error) => { respondJson(response, 400, { error: error && error.message ? error.message : 'Invalid JSON body', }) }) return } respondJson(response, 405, { error: 'Method Not Allowed', }) return } if ((request.url || '').startsWith(BRIDGE_STATUS_PATH)) { respondJson(response, 200, gatewayBridge.getStatus()) return } serveStatic(request.url || '/', response) }) const gpsWss = new WebSocketServer({ noServer: true }) const heartRateWss = new WebSocketServer({ noServer: true }) const debugLogWss = new WebSocketServer({ noServer: true }) gpsWss.on('connection', (socket) => { socket.on('message', (rawMessage) => { const text = String(rawMessage) let parsed try { parsed = JSON.parse(text) } catch (_error) { return } if (!isMockGpsPayload(parsed)) { return } const outgoing = JSON.stringify({ type: 'mock_gps', timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(), channelId: normalizeChannelId(parsed.channelId), lat: Number(parsed.lat), lon: Number(parsed.lon), accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6, speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0, headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0, }) gatewayBridge.publish(JSON.parse(outgoing)) gpsWss.clients.forEach((client) => { if (client.readyState === client.OPEN) { client.send(outgoing) } }) }) }) heartRateWss.on('connection', (socket) => { socket.on('message', (rawMessage) => { const text = String(rawMessage) let parsed try { parsed = JSON.parse(text) } catch (_error) { return } if (!isMockHeartRatePayload(parsed)) { return } const outgoing = JSON.stringify({ type: 'mock_heart_rate', timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(), channelId: normalizeChannelId(parsed.channelId), bpm: Math.max(1, Math.round(Number(parsed.bpm))), }) gatewayBridge.publish(JSON.parse(outgoing)) heartRateWss.clients.forEach((client) => { if (client.readyState === client.OPEN) { client.send(outgoing) } }) }) }) debugLogWss.on('connection', (socket) => { socket.on('message', (rawMessage) => { const text = String(rawMessage) let parsed try { parsed = JSON.parse(text) } catch (_error) { return } if (!isDebugLogPayload(parsed)) { return } const outgoing = JSON.stringify({ type: 'debug-log', timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(), channelId: normalizeChannelId(parsed.channelId), scope: String(parsed.scope || 'app').slice(0, 64), level: parsed.level === 'warn' || parsed.level === 'error' ? parsed.level : 'info', message: String(parsed.message || '').slice(0, 400), ...(parsed.payload && typeof parsed.payload === 'object' ? { payload: parsed.payload } : {}), }) debugLogWss.clients.forEach((client) => { if (client.readyState === client.OPEN) { client.send(outgoing) } }) }) }) server.on('upgrade', (request, socket, head) => { const requestUrl = request.url || '' if (requestUrl.startsWith(GPS_WS_PATH)) { gpsWss.handleUpgrade(request, socket, head, (ws) => { gpsWss.emit('connection', ws, request) }) return } if (requestUrl.startsWith(HEART_RATE_WS_PATH)) { heartRateWss.handleUpgrade(request, socket, head, (ws) => { heartRateWss.emit('connection', ws, request) }) return } if (requestUrl.startsWith(DEBUG_LOG_WS_PATH)) { debugLogWss.handleUpgrade(request, socket, head, (ws) => { debugLogWss.emit('connection', ws, request) }) return } if (!requestUrl) { socket.destroy() } socket.destroy() }) server.listen(PORT, HOST, () => { console.log(`Mock GPS simulator running:`) console.log(` UI: http://127.0.0.1:${PORT}/`) console.log(` GPS WS: ws://127.0.0.1:${PORT}${GPS_WS_PATH}`) console.log(` HR WS: ws://127.0.0.1:${PORT}${HEART_RATE_WS_PATH}`) console.log(` Logger WS: ws://127.0.0.1:${PORT}${DEBUG_LOG_WS_PATH}`) console.log(` Proxy: http://127.0.0.1:${PORT}${PROXY_PATH}?url=`) console.log(` Bridge status: http://127.0.0.1:${PORT}${BRIDGE_STATUS_PATH}`) console.log(` Bridge config: http://127.0.0.1:${PORT}${BRIDGE_CONFIG_PATH}`) if (INITIAL_BRIDGE_CONFIG.enabled) { console.log(` Gateway bridge: enabled -> ${INITIAL_BRIDGE_CONFIG.url}`) console.log(` Gateway target device: ${INITIAL_BRIDGE_CONFIG.deviceId}`) } else { console.log(` Gateway bridge: disabled`) } })