realtime_probe_server.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. // 仅供本地浏览器联调:模拟后台 API,与独立的真实 Reverb 实例通信。
  2. // 不读取项目 .env,不连接任何业务数据库,只监听 127.0.0.1。
  3. import http from 'node:http';
  4. import { createHash, createHmac } from 'node:crypto';
  5. import { readFile } from 'node:fs/promises';
  6. import { fileURLToPath } from 'node:url';
  7. const port = Number(process.env.PROBE_FIXTURE_PORT || 19272);
  8. const reverbPort = Number(process.env.PROBE_REVERB_PORT || 19273);
  9. const key = 'probe-fixture-key';
  10. const secret = 'probe-fixture-secret';
  11. const app = 'probe-fixture';
  12. const token = 'probe-test-token';
  13. const expiry = Math.floor(Date.now() / 1000) + 3600;
  14. const A = 'admin.orders.withdraw';
  15. const B = 'admin.users.online';
  16. const C = 'admin.stats.platform_count';
  17. const D = 'admin.stats.unprocessed';
  18. const CHANNELS = [A, B, C, D];
  19. const history = [];
  20. let mode = 'normal';
  21. const files = new Map([
  22. ['/realtime-test.html', ['../../public/realtime-test.html', 'text/html']],
  23. ['/static/css/realtime-test.css', ['../../public/static/css/realtime-test.css', 'text/css']],
  24. ['/static/js/realtime-test.js', ['../../public/static/js/realtime-test.js', 'application/javascript']],
  25. ['/static/js/realtime-probe.js', ['../../public/static/js/realtime-probe.js', 'application/javascript']],
  26. ['/static/vendor/realtime/pusher-8.4.0.min.js', ['../../public/static/vendor/realtime/pusher-8.4.0.min.js', 'application/javascript']],
  27. ['/static/vendor/realtime/laravel-echo-2.1.6.js', ['../../public/static/vendor/realtime/laravel-echo-2.1.6.js', 'application/javascript']],
  28. ]);
  29. const json = (res, data, status = 200) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(data)); };
  30. const body = async (req) => {
  31. let text = '';
  32. for await (const chunk of req) { text += chunk; if (text.length > 65536) throw new Error('fixture request too large'); }
  33. return text;
  34. };
  35. const server = http.createServer(async (req, res) => {
  36. try {
  37. const url = new URL(req.url, `http://127.0.0.1:${port}`);
  38. if (req.method === 'GET' && files.has(url.pathname)) {
  39. const [path, mime] = files.get(url.pathname);
  40. res.writeHead(200, { 'Content-Type': `${mime}; charset=utf-8`, 'Cache-Control': 'no-store' });
  41. res.end(await readFile(fileURLToPath(new URL(path, import.meta.url)))); return;
  42. }
  43. if (req.method === 'POST' && url.pathname === '/__fixture/mode') {
  44. mode = (JSON.parse(await body(req))).mode;
  45. json(res, { mode }); return;
  46. }
  47. if (req.method === 'POST' && url.pathname === '/__fixture/publish') {
  48. const input = JSON.parse(await body(req));
  49. const channel = input.channel || A;
  50. if (!CHANNELS.includes(channel)) { json(res, { error: 'unknown fixture channel' }, 400); return; }
  51. const statistics = channel === C || channel === D;
  52. const event = { seq: history.length + 1, id: history.length + 1, channel,
  53. event: statistics ? 'stats.updated' : channel === A ? 'order.updated' : 'user.online', action: statistics || channel === A ? 'updated' : 'online',
  54. key: statistics ? { scope: 'all' } : { id: 88 }, data: channel === C
  55. ? { total: 3, apple: 1, android: 1, pc: 1, mobile: 0, wechat: 0, unknown: 0 }
  56. : channel === D ? { usdtRecharge: 1, rgRecharge: 2, rmbRecharge: 0, usdtWithdraw: 1, rgWithdraw: 0, rmbWithdraw: 0, shareClaim: 1 }
  57. : { id: 88, status: 1, note: input.note || 'fixture event', token: 'private-fixture-value' },
  58. source: 'browser-fixture', created_at: new Date().toISOString() };
  59. const payload = JSON.stringify({ name: event.event, channels: [`private-${channel}`], data: JSON.stringify(event) });
  60. const path = `/apps/${app}/events`;
  61. const values = { auth_key: key, auth_timestamp: String(Math.floor(Date.now() / 1000)), auth_version: '1.0', body_md5: createHash('md5').update(payload).digest('hex') };
  62. const query = new URLSearchParams(Object.entries(values).sort(([a], [b]) => a.localeCompare(b)));
  63. query.set('auth_signature', createHmac('sha256', secret).update(`POST\n${path}\n${query}`).digest('hex'));
  64. const reply = await fetch(`http://127.0.0.1:${reverbPort}${path}?${query}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload });
  65. if (!reply.ok) throw new Error(`Reverb publish returned HTTP ${reply.status}: ${await reply.text()}`);
  66. history.push(event); json(res, { seq: event.seq }); return;
  67. }
  68. if (req.headers.authorization !== `Bearer ${token}`) { json(res, { msg: 'fixture token invalid' }, 401); return; }
  69. if (url.pathname === '/admin/realtime/config') {
  70. json(res, { code: 0, data: { key, broadcaster: 'reverb', ws_host: '127.0.0.1', ws_port: reverbPort, force_tls: false,
  71. channels: CHANNELS, last_seq: history.length, session_expires_at: expiry, auth_endpoint: '/admin/broadcasting/auth' } });
  72. } else if (url.pathname === '/admin/broadcasting/auth' && req.method === 'POST') {
  73. const data = new URLSearchParams(await body(req));
  74. const channel = data.get('channel_name');
  75. if (mode === 'expired') { json(res, { msg: 'fixture expired' }, 401); return; }
  76. if (mode === 'denied' || !CHANNELS.map((name) => `private-${name}`).includes(channel)) { json(res, { msg: 'fixture denied' }, 403); return; }
  77. const channel_data = JSON.stringify({ user_id: `1:${expiry}` });
  78. const auth = `${key}:${createHmac('sha256', secret).update(`${data.get('socket_id')}:${channel}:${channel_data}`).digest('hex')}`;
  79. json(res, { auth, channel_data });
  80. } else if (url.pathname === '/admin/realtime/replay') {
  81. const after = Number(url.searchParams.get('after_seq'));
  82. const channels = (url.searchParams.get('channels') || '').split(',');
  83. json(res, { code: 0, data: { resync: mode === 'resync', ...(mode === 'resync' ? { reason: 'too_many_events' } : {}),
  84. last_seq: history.length, channels,
  85. events: mode === 'resync' ? [] : history.filter((event) => event.seq > after && channels.includes(event.channel)) } });
  86. } else json(res, { error: 'fixture route not found' }, 404);
  87. } catch (error) { json(res, { error: error.message }, 500); }
  88. });
  89. server.listen(port, '127.0.0.1', () => console.log(`Probe fixture: http://127.0.0.1:${port}/realtime-test.html`));
  90. process.on('SIGINT', () => server.close(() => process.exit(0)));
  91. process.on('SIGTERM', () => server.close(() => process.exit(0)));