server.js 18 KB

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