server.js 16 KB

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