server.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. const http = require('http')
  2. const fs = require('fs')
  3. const path = require('path')
  4. const WebSocket = require('ws')
  5. const { WebSocketServer } = WebSocket
  6. const HOST = '0.0.0.0'
  7. const PORT = 17865
  8. const GPS_WS_PATH = '/mock-gps'
  9. const HEART_RATE_WS_PATH = '/mock-hr'
  10. const DEBUG_LOG_WS_PATH = '/debug-log'
  11. const PROXY_PATH = '/proxy'
  12. const BRIDGE_STATUS_PATH = '/bridge-status'
  13. const BRIDGE_CONFIG_PATH = '/bridge-config'
  14. const PUBLIC_DIR = path.join(__dirname, 'public')
  15. const DEFAULT_GATEWAY_BRIDGE_URL = 'ws://127.0.0.1:18080/ws'
  16. const INITIAL_BRIDGE_CONFIG = {
  17. enabled: process.env.MOCK_SIM_GATEWAY_ENABLED === '1',
  18. url: process.env.MOCK_SIM_GATEWAY_URL || DEFAULT_GATEWAY_BRIDGE_URL,
  19. token: process.env.MOCK_SIM_GATEWAY_TOKEN || 'dev-producer-token',
  20. channelId: process.env.MOCK_SIM_GATEWAY_CHANNEL_ID || '',
  21. deviceId: process.env.MOCK_SIM_GATEWAY_DEVICE_ID || 'child-001',
  22. groupId: process.env.MOCK_SIM_GATEWAY_GROUP_ID || '',
  23. sourceId: process.env.MOCK_SIM_GATEWAY_SOURCE_ID || 'mock-gps-sim',
  24. sourceMode: process.env.MOCK_SIM_GATEWAY_SOURCE_MODE || 'mock',
  25. reconnectMs: Math.max(1000, Number(process.env.MOCK_SIM_GATEWAY_RECONNECT_MS) || 3000),
  26. }
  27. function getContentType(filePath) {
  28. const ext = path.extname(filePath).toLowerCase()
  29. if (ext === '.html') {
  30. return 'text/html; charset=utf-8'
  31. }
  32. if (ext === '.css') {
  33. return 'text/css; charset=utf-8'
  34. }
  35. if (ext === '.js') {
  36. return 'application/javascript; charset=utf-8'
  37. }
  38. if (ext === '.json') {
  39. return 'application/json; charset=utf-8'
  40. }
  41. if (ext === '.svg') {
  42. return 'image/svg+xml'
  43. }
  44. return 'text/plain; charset=utf-8'
  45. }
  46. function respondJson(response, statusCode, payload) {
  47. response.writeHead(statusCode, {
  48. 'Content-Type': 'application/json; charset=utf-8',
  49. 'Cache-Control': 'no-store',
  50. 'Access-Control-Allow-Origin': '*',
  51. })
  52. response.end(JSON.stringify(payload))
  53. }
  54. function serveStatic(requestPath, response) {
  55. const safePath = requestPath === '/' ? '/index.html' : requestPath
  56. const resolvedPath = path.normalize(path.join(PUBLIC_DIR, safePath))
  57. if (!resolvedPath.startsWith(PUBLIC_DIR)) {
  58. response.writeHead(403)
  59. response.end('Forbidden')
  60. return
  61. }
  62. fs.readFile(resolvedPath, (error, content) => {
  63. if (error) {
  64. response.writeHead(404)
  65. response.end('Not Found')
  66. return
  67. }
  68. response.writeHead(200, {
  69. 'Content-Type': getContentType(resolvedPath),
  70. 'Cache-Control': 'no-store',
  71. })
  72. response.end(content)
  73. })
  74. }
  75. function isMockGpsPayload(payload) {
  76. return payload
  77. && payload.type === 'mock_gps'
  78. && Number.isFinite(payload.lat)
  79. && Number.isFinite(payload.lon)
  80. }
  81. function isMockHeartRatePayload(payload) {
  82. return payload
  83. && payload.type === 'mock_heart_rate'
  84. && Number.isFinite(payload.bpm)
  85. }
  86. function isDebugLogPayload(payload) {
  87. return payload
  88. && payload.type === 'debug-log'
  89. && typeof payload.scope === 'string'
  90. && typeof payload.level === 'string'
  91. && typeof payload.message === 'string'
  92. }
  93. async function handleProxyRequest(request, response) {
  94. const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`)
  95. const targetUrl = requestUrl.searchParams.get('url')
  96. if (!targetUrl) {
  97. response.writeHead(400, {
  98. 'Content-Type': 'text/plain; charset=utf-8',
  99. 'Access-Control-Allow-Origin': '*',
  100. })
  101. response.end('Missing url')
  102. return
  103. }
  104. try {
  105. const upstream = await fetch(targetUrl)
  106. const body = Buffer.from(await upstream.arrayBuffer())
  107. response.writeHead(upstream.status, {
  108. 'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream',
  109. 'Cache-Control': 'no-store',
  110. 'Access-Control-Allow-Origin': '*',
  111. })
  112. response.end(body)
  113. } catch (error) {
  114. response.writeHead(502, {
  115. 'Content-Type': 'text/plain; charset=utf-8',
  116. 'Access-Control-Allow-Origin': '*',
  117. })
  118. response.end(error && error.message ? error.message : 'Proxy request failed')
  119. }
  120. }
  121. async function readJsonBody(request) {
  122. return new Promise((resolve, reject) => {
  123. const chunks = []
  124. request.on('data', (chunk) => {
  125. chunks.push(chunk)
  126. })
  127. request.on('end', () => {
  128. const raw = Buffer.concat(chunks).toString('utf8').trim()
  129. if (!raw) {
  130. resolve({})
  131. return
  132. }
  133. try {
  134. resolve(JSON.parse(raw))
  135. } catch (error) {
  136. reject(error)
  137. }
  138. })
  139. request.on('error', reject)
  140. })
  141. }
  142. function normalizeBridgeConfig(input, currentConfig) {
  143. const source = input || {}
  144. const fallback = currentConfig || INITIAL_BRIDGE_CONFIG
  145. return {
  146. enabled: typeof source.enabled === 'boolean' ? source.enabled : fallback.enabled,
  147. url: typeof source.url === 'string' && source.url.trim() ? source.url.trim() : fallback.url,
  148. token: typeof source.token === 'string' ? source.token.trim() : fallback.token,
  149. channelId: typeof source.channelId === 'string' ? source.channelId.trim() : fallback.channelId,
  150. deviceId: typeof source.deviceId === 'string' && source.deviceId.trim() ? source.deviceId.trim() : fallback.deviceId,
  151. groupId: typeof source.groupId === 'string' ? source.groupId.trim() : fallback.groupId,
  152. sourceId: typeof source.sourceId === 'string' && source.sourceId.trim() ? source.sourceId.trim() : fallback.sourceId,
  153. sourceMode: typeof source.sourceMode === 'string' && source.sourceMode.trim() ? source.sourceMode.trim() : fallback.sourceMode,
  154. reconnectMs: Math.max(1000, Number(source.reconnectMs) || fallback.reconnectMs),
  155. }
  156. }
  157. function createGatewayBridge() {
  158. const bridgeState = {
  159. config: { ...INITIAL_BRIDGE_CONFIG },
  160. socket: null,
  161. connecting: false,
  162. connected: false,
  163. authenticated: false,
  164. reconnectTimer: 0,
  165. lastError: '',
  166. lastSentAt: 0,
  167. lastSentTopic: '',
  168. sentCount: 0,
  169. droppedCount: 0,
  170. }
  171. function logBridge(message) {
  172. console.log(`[gateway-bridge] ${message}`)
  173. }
  174. function clearReconnectTimer() {
  175. if (!bridgeState.reconnectTimer) {
  176. return
  177. }
  178. clearTimeout(bridgeState.reconnectTimer)
  179. bridgeState.reconnectTimer = 0
  180. }
  181. function scheduleReconnect() {
  182. if (!bridgeState.config.enabled || bridgeState.reconnectTimer) {
  183. return
  184. }
  185. bridgeState.reconnectTimer = setTimeout(() => {
  186. bridgeState.reconnectTimer = 0
  187. connect()
  188. }, bridgeState.config.reconnectMs)
  189. }
  190. function resetSocketState() {
  191. bridgeState.socket = null
  192. bridgeState.connecting = false
  193. bridgeState.connected = false
  194. bridgeState.authenticated = false
  195. }
  196. function handleGatewayMessage(rawMessage) {
  197. let parsed
  198. try {
  199. parsed = JSON.parse(String(rawMessage))
  200. } catch (_error) {
  201. return
  202. }
  203. if (parsed.type === 'welcome') {
  204. if (!bridgeState.socket || bridgeState.socket.readyState !== WebSocket.OPEN) {
  205. return
  206. }
  207. if (bridgeState.config.channelId) {
  208. bridgeState.socket.send(JSON.stringify({
  209. type: 'join_channel',
  210. role: 'producer',
  211. channelId: bridgeState.config.channelId,
  212. token: bridgeState.config.token,
  213. }))
  214. } else {
  215. bridgeState.socket.send(JSON.stringify({
  216. type: 'authenticate',
  217. role: 'producer',
  218. token: bridgeState.config.token,
  219. }))
  220. }
  221. return
  222. }
  223. if (parsed.type === 'authenticated' || parsed.type === 'joined_channel') {
  224. bridgeState.authenticated = true
  225. bridgeState.lastError = ''
  226. if (bridgeState.config.channelId) {
  227. logBridge(`joined channel=${bridgeState.config.channelId}, device=${bridgeState.config.deviceId}, source=${bridgeState.config.sourceId}`)
  228. } else {
  229. logBridge(`authenticated, device=${bridgeState.config.deviceId}, source=${bridgeState.config.sourceId}`)
  230. }
  231. return
  232. }
  233. if (parsed.type === 'error') {
  234. bridgeState.lastError = parsed.error || 'gateway error'
  235. logBridge(`error: ${bridgeState.lastError}`)
  236. }
  237. }
  238. function closeSocket() {
  239. if (!bridgeState.socket) {
  240. return
  241. }
  242. try {
  243. bridgeState.socket.close()
  244. } catch (_error) {
  245. // noop
  246. }
  247. resetSocketState()
  248. }
  249. function connect() {
  250. if (!bridgeState.config.enabled || bridgeState.connecting) {
  251. return
  252. }
  253. if (bridgeState.socket && (bridgeState.socket.readyState === WebSocket.OPEN || bridgeState.socket.readyState === WebSocket.CONNECTING)) {
  254. return
  255. }
  256. clearReconnectTimer()
  257. bridgeState.connecting = true
  258. bridgeState.lastError = ''
  259. logBridge(`connecting to ${bridgeState.config.url}`)
  260. const socket = new WebSocket(bridgeState.config.url)
  261. bridgeState.socket = socket
  262. socket.on('open', () => {
  263. bridgeState.connecting = false
  264. bridgeState.connected = true
  265. logBridge('connected')
  266. })
  267. socket.on('message', handleGatewayMessage)
  268. socket.on('close', () => {
  269. const wasConnected = bridgeState.connected || bridgeState.authenticated
  270. resetSocketState()
  271. if (wasConnected) {
  272. logBridge('disconnected')
  273. }
  274. scheduleReconnect()
  275. })
  276. socket.on('error', (error) => {
  277. bridgeState.lastError = error && error.message ? error.message : 'gateway socket error'
  278. logBridge(`socket error: ${bridgeState.lastError}`)
  279. })
  280. }
  281. function toGatewayEnvelope(payload) {
  282. if (isMockGpsPayload(payload)) {
  283. return {
  284. schemaVersion: 1,
  285. messageId: `gps-${payload.timestamp}`,
  286. timestamp: payload.timestamp,
  287. topic: 'telemetry.location',
  288. source: {
  289. kind: 'producer',
  290. id: bridgeState.config.sourceId,
  291. mode: bridgeState.config.sourceMode,
  292. },
  293. target: {
  294. channelId: bridgeState.config.channelId,
  295. deviceId: bridgeState.config.deviceId,
  296. groupId: bridgeState.config.groupId,
  297. },
  298. payload: {
  299. lat: Number(payload.lat),
  300. lng: Number(payload.lon),
  301. speed: Number(payload.speedMps) || 0,
  302. bearing: Number(payload.headingDeg) || 0,
  303. accuracy: Number(payload.accuracyMeters) || 6,
  304. coordSystem: 'GCJ02',
  305. },
  306. }
  307. }
  308. if (isMockHeartRatePayload(payload)) {
  309. return {
  310. schemaVersion: 1,
  311. messageId: `hr-${payload.timestamp}`,
  312. timestamp: payload.timestamp,
  313. topic: 'telemetry.heart_rate',
  314. source: {
  315. kind: 'producer',
  316. id: bridgeState.config.sourceId,
  317. mode: bridgeState.config.sourceMode,
  318. },
  319. target: {
  320. channelId: bridgeState.config.channelId,
  321. deviceId: bridgeState.config.deviceId,
  322. groupId: bridgeState.config.groupId,
  323. },
  324. payload: {
  325. bpm: Math.max(1, Math.round(Number(payload.bpm))),
  326. },
  327. }
  328. }
  329. return null
  330. }
  331. function publish(payload) {
  332. if (!bridgeState.config.enabled) {
  333. return
  334. }
  335. if (!bridgeState.socket || bridgeState.socket.readyState !== WebSocket.OPEN || !bridgeState.authenticated) {
  336. bridgeState.droppedCount += 1
  337. connect()
  338. return
  339. }
  340. const envelope = toGatewayEnvelope(payload)
  341. if (!envelope) {
  342. return
  343. }
  344. bridgeState.socket.send(JSON.stringify({
  345. type: 'publish',
  346. envelope,
  347. }))
  348. bridgeState.lastSentAt = Date.now()
  349. bridgeState.lastSentTopic = envelope.topic
  350. bridgeState.sentCount += 1
  351. }
  352. function updateConfig(nextConfigInput) {
  353. const nextConfig = normalizeBridgeConfig(nextConfigInput, bridgeState.config)
  354. const changed = JSON.stringify(nextConfig) !== JSON.stringify(bridgeState.config)
  355. bridgeState.config = nextConfig
  356. if (!changed) {
  357. return getStatus()
  358. }
  359. bridgeState.lastError = ''
  360. if (!bridgeState.config.enabled) {
  361. clearReconnectTimer()
  362. closeSocket()
  363. logBridge('disabled')
  364. return getStatus()
  365. }
  366. clearReconnectTimer()
  367. closeSocket()
  368. connect()
  369. return getStatus()
  370. }
  371. function getConfig() {
  372. return { ...bridgeState.config }
  373. }
  374. function getStatus() {
  375. return {
  376. enabled: bridgeState.config.enabled,
  377. url: bridgeState.config.url,
  378. connected: bridgeState.connected,
  379. authenticated: bridgeState.authenticated,
  380. channelId: bridgeState.config.channelId,
  381. deviceId: bridgeState.config.deviceId,
  382. groupId: bridgeState.config.groupId,
  383. sourceId: bridgeState.config.sourceId,
  384. sourceMode: bridgeState.config.sourceMode,
  385. reconnectMs: bridgeState.config.reconnectMs,
  386. hasToken: Boolean(bridgeState.config.token),
  387. sentCount: bridgeState.sentCount,
  388. droppedCount: bridgeState.droppedCount,
  389. lastSentAt: bridgeState.lastSentAt,
  390. lastSentTopic: bridgeState.lastSentTopic,
  391. lastError: bridgeState.lastError,
  392. }
  393. }
  394. if (bridgeState.config.enabled) {
  395. connect()
  396. }
  397. return {
  398. publish,
  399. updateConfig,
  400. getConfig,
  401. getStatus,
  402. }
  403. }
  404. const gatewayBridge = createGatewayBridge()
  405. const server = http.createServer((request, response) => {
  406. if (request.method === 'OPTIONS') {
  407. response.writeHead(204, {
  408. 'Access-Control-Allow-Origin': '*',
  409. 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
  410. 'Access-Control-Allow-Headers': 'Content-Type',
  411. })
  412. response.end()
  413. return
  414. }
  415. if ((request.url || '').startsWith(PROXY_PATH)) {
  416. handleProxyRequest(request, response)
  417. return
  418. }
  419. if ((request.url || '').startsWith(BRIDGE_CONFIG_PATH)) {
  420. if (request.method === 'GET') {
  421. respondJson(response, 200, {
  422. config: gatewayBridge.getConfig(),
  423. status: gatewayBridge.getStatus(),
  424. })
  425. return
  426. }
  427. if (request.method === 'POST') {
  428. readJsonBody(request)
  429. .then((payload) => {
  430. const status = gatewayBridge.updateConfig(payload)
  431. respondJson(response, 200, {
  432. config: gatewayBridge.getConfig(),
  433. status,
  434. })
  435. })
  436. .catch((error) => {
  437. respondJson(response, 400, {
  438. error: error && error.message ? error.message : 'Invalid JSON body',
  439. })
  440. })
  441. return
  442. }
  443. respondJson(response, 405, {
  444. error: 'Method Not Allowed',
  445. })
  446. return
  447. }
  448. if ((request.url || '').startsWith(BRIDGE_STATUS_PATH)) {
  449. respondJson(response, 200, gatewayBridge.getStatus())
  450. return
  451. }
  452. serveStatic(request.url || '/', response)
  453. })
  454. const gpsWss = new WebSocketServer({ noServer: true })
  455. const heartRateWss = new WebSocketServer({ noServer: true })
  456. const debugLogWss = new WebSocketServer({ noServer: true })
  457. gpsWss.on('connection', (socket) => {
  458. socket.on('message', (rawMessage) => {
  459. const text = String(rawMessage)
  460. let parsed
  461. try {
  462. parsed = JSON.parse(text)
  463. } catch (_error) {
  464. return
  465. }
  466. if (!isMockGpsPayload(parsed)) {
  467. return
  468. }
  469. const outgoing = JSON.stringify({
  470. type: 'mock_gps',
  471. timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
  472. lat: Number(parsed.lat),
  473. lon: Number(parsed.lon),
  474. accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6,
  475. speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0,
  476. headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0,
  477. })
  478. gatewayBridge.publish(JSON.parse(outgoing))
  479. gpsWss.clients.forEach((client) => {
  480. if (client.readyState === client.OPEN) {
  481. client.send(outgoing)
  482. }
  483. })
  484. })
  485. })
  486. heartRateWss.on('connection', (socket) => {
  487. socket.on('message', (rawMessage) => {
  488. const text = String(rawMessage)
  489. let parsed
  490. try {
  491. parsed = JSON.parse(text)
  492. } catch (_error) {
  493. return
  494. }
  495. if (!isMockHeartRatePayload(parsed)) {
  496. return
  497. }
  498. const outgoing = JSON.stringify({
  499. type: 'mock_heart_rate',
  500. timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
  501. bpm: Math.max(1, Math.round(Number(parsed.bpm))),
  502. })
  503. gatewayBridge.publish(JSON.parse(outgoing))
  504. heartRateWss.clients.forEach((client) => {
  505. if (client.readyState === client.OPEN) {
  506. client.send(outgoing)
  507. }
  508. })
  509. })
  510. })
  511. debugLogWss.on('connection', (socket) => {
  512. socket.on('message', (rawMessage) => {
  513. const text = String(rawMessage)
  514. let parsed
  515. try {
  516. parsed = JSON.parse(text)
  517. } catch (_error) {
  518. return
  519. }
  520. if (!isDebugLogPayload(parsed)) {
  521. return
  522. }
  523. const outgoing = JSON.stringify({
  524. type: 'debug-log',
  525. timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
  526. scope: String(parsed.scope || 'app').slice(0, 64),
  527. level: parsed.level === 'warn' || parsed.level === 'error' ? parsed.level : 'info',
  528. message: String(parsed.message || '').slice(0, 400),
  529. ...(parsed.payload && typeof parsed.payload === 'object'
  530. ? { payload: parsed.payload }
  531. : {}),
  532. })
  533. debugLogWss.clients.forEach((client) => {
  534. if (client.readyState === client.OPEN) {
  535. client.send(outgoing)
  536. }
  537. })
  538. })
  539. })
  540. server.on('upgrade', (request, socket, head) => {
  541. const requestUrl = request.url || ''
  542. if (requestUrl.startsWith(GPS_WS_PATH)) {
  543. gpsWss.handleUpgrade(request, socket, head, (ws) => {
  544. gpsWss.emit('connection', ws, request)
  545. })
  546. return
  547. }
  548. if (requestUrl.startsWith(HEART_RATE_WS_PATH)) {
  549. heartRateWss.handleUpgrade(request, socket, head, (ws) => {
  550. heartRateWss.emit('connection', ws, request)
  551. })
  552. return
  553. }
  554. if (requestUrl.startsWith(DEBUG_LOG_WS_PATH)) {
  555. debugLogWss.handleUpgrade(request, socket, head, (ws) => {
  556. debugLogWss.emit('connection', ws, request)
  557. })
  558. return
  559. }
  560. if (!requestUrl) {
  561. socket.destroy()
  562. }
  563. socket.destroy()
  564. })
  565. server.listen(PORT, HOST, () => {
  566. console.log(`Mock GPS simulator running:`)
  567. console.log(` UI: http://127.0.0.1:${PORT}/`)
  568. console.log(` GPS WS: ws://127.0.0.1:${PORT}${GPS_WS_PATH}`)
  569. console.log(` HR WS: ws://127.0.0.1:${PORT}${HEART_RATE_WS_PATH}`)
  570. console.log(` Logger WS: ws://127.0.0.1:${PORT}${DEBUG_LOG_WS_PATH}`)
  571. console.log(` Proxy: http://127.0.0.1:${PORT}${PROXY_PATH}?url=<remote-url>`)
  572. console.log(` Bridge status: http://127.0.0.1:${PORT}${BRIDGE_STATUS_PATH}`)
  573. console.log(` Bridge config: http://127.0.0.1:${PORT}${BRIDGE_CONFIG_PATH}`)
  574. if (INITIAL_BRIDGE_CONFIG.enabled) {
  575. console.log(` Gateway bridge: enabled -> ${INITIAL_BRIDGE_CONFIG.url}`)
  576. console.log(` Gateway target device: ${INITIAL_BRIDGE_CONFIG.deviceId}`)
  577. } else {
  578. console.log(` Gateway bridge: disabled`)
  579. }
  580. })