index.js 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. const express = require('express');
  2. const http = require('http');
  3. const WebSocket = require('ws');
  4. const chokidar = require('chokidar');
  5. const fs = require('fs');
  6. const path = require('path');
  7. const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
  8. const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
  9. const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
  10. const SCREEN_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
  11. if (!fs.existsSync(SCREEN_DIR)) {
  12. fs.mkdirSync(SCREEN_DIR, { recursive: true });
  13. }
  14. // Load frame template and helper script once at startup
  15. const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
  16. const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
  17. const helperInjection = `<script>\n${helperScript}\n</script>`;
  18. // Detect whether content is a full HTML document or a bare fragment
  19. function isFullDocument(html) {
  20. const trimmed = html.trimStart().toLowerCase();
  21. return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
  22. }
  23. // Wrap a content fragment in the frame template
  24. function wrapInFrame(content) {
  25. return frameTemplate.replace('<!-- CONTENT -->', content);
  26. }
  27. // Find the newest .html file in the directory by mtime
  28. function getNewestScreen() {
  29. const files = fs.readdirSync(SCREEN_DIR)
  30. .filter(f => f.endsWith('.html'))
  31. .map(f => ({
  32. name: f,
  33. path: path.join(SCREEN_DIR, f),
  34. mtime: fs.statSync(path.join(SCREEN_DIR, f)).mtime.getTime()
  35. }))
  36. .sort((a, b) => b.mtime - a.mtime);
  37. return files.length > 0 ? files[0].path : null;
  38. }
  39. const WAITING_PAGE = `<!DOCTYPE html>
  40. <html>
  41. <head>
  42. <title>Brainstorm Companion</title>
  43. <style>
  44. body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
  45. h1 { color: #333; }
  46. p { color: #666; }
  47. </style>
  48. </head>
  49. <body>
  50. <h1>Brainstorm Companion</h1>
  51. <p>Waiting for Claude to push a screen...</p>
  52. </body>
  53. </html>`;
  54. const app = express();
  55. const server = http.createServer(app);
  56. const wss = new WebSocket.Server({ server });
  57. const clients = new Set();
  58. wss.on('connection', (ws) => {
  59. clients.add(ws);
  60. ws.on('close', () => clients.delete(ws));
  61. ws.on('message', (data) => {
  62. const event = JSON.parse(data.toString());
  63. console.log(JSON.stringify({ source: 'user-event', ...event }));
  64. // Write user events to .events file for Claude to read
  65. if (event.choice) {
  66. const eventsFile = path.join(SCREEN_DIR, '.events');
  67. fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
  68. }
  69. });
  70. });
  71. // Serve newest screen with helper.js injected
  72. app.get('/', (req, res) => {
  73. const screenFile = getNewestScreen();
  74. let html;
  75. if (!screenFile) {
  76. html = WAITING_PAGE;
  77. } else {
  78. const raw = fs.readFileSync(screenFile, 'utf-8');
  79. html = isFullDocument(raw) ? raw : wrapInFrame(raw);
  80. }
  81. // Inject helper script
  82. if (html.includes('</body>')) {
  83. html = html.replace('</body>', `${helperInjection}\n</body>`);
  84. } else {
  85. html += helperInjection;
  86. }
  87. res.type('html').send(html);
  88. });
  89. // Watch for new or changed .html files
  90. chokidar.watch(SCREEN_DIR, { ignoreInitial: true })
  91. .on('add', (filePath) => {
  92. if (filePath.endsWith('.html')) {
  93. // Clear events from previous screen
  94. const eventsFile = path.join(SCREEN_DIR, '.events');
  95. if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
  96. console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
  97. clients.forEach(ws => {
  98. if (ws.readyState === WebSocket.OPEN) {
  99. ws.send(JSON.stringify({ type: 'reload' }));
  100. }
  101. });
  102. }
  103. })
  104. .on('change', (filePath) => {
  105. if (filePath.endsWith('.html')) {
  106. console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
  107. clients.forEach(ws => {
  108. if (ws.readyState === WebSocket.OPEN) {
  109. ws.send(JSON.stringify({ type: 'reload' }));
  110. }
  111. });
  112. }
  113. });
  114. server.listen(PORT, HOST, () => {
  115. console.log(JSON.stringify({
  116. type: 'server-started',
  117. port: PORT,
  118. host: HOST,
  119. url_host: URL_HOST,
  120. url: `http://${URL_HOST}:${PORT}`,
  121. screen_dir: SCREEN_DIR
  122. }));
  123. });