Files
superpowers/docs/superpowers/plans/2026-03-11-zero-dep-brainstorm-server.md
2026-03-11 13:14:42 -07:00

15 KiB

Zero-Dependency Brainstorm Server Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the brainstorm server's vendored node_modules with a single zero-dependency server.js using Node built-ins.

Architecture: Single file with WebSocket protocol (RFC 6455 text frames), HTTP server (http module), and file watching (fs.watch). Exports protocol functions for unit testing when required as a module.

Tech Stack: Node.js built-ins only: http, crypto, fs, path

Spec: docs/superpowers/specs/2026-03-11-zero-dep-brainstorm-server-design.md

Existing tests: tests/brainstorm-server/ws-protocol.test.js (unit), tests/brainstorm-server/server.test.js (integration)


File Map

  • Create: skills/brainstorming/scripts/server.js — the zero-dep replacement
  • Modify: skills/brainstorming/scripts/start-server.sh:94,100 — change index.js to server.js
  • Modify: .gitignore:6 — remove the !skills/brainstorming/scripts/node_modules/ exception
  • Delete: skills/brainstorming/scripts/index.js
  • Delete: skills/brainstorming/scripts/package.json
  • Delete: skills/brainstorming/scripts/package-lock.json
  • Delete: skills/brainstorming/scripts/node_modules/ (714 files)
  • No changes: skills/brainstorming/scripts/helper.js, skills/brainstorming/scripts/frame-template.html, skills/brainstorming/scripts/stop-server.sh

Chunk 1: WebSocket Protocol Layer

Task 1: Implement WebSocket protocol exports

Files:

  • Create: skills/brainstorming/scripts/server.js

  • Test: tests/brainstorm-server/ws-protocol.test.js (already exists)

  • Step 1: Create server.js with OPCODES constant and computeAcceptKey

const crypto = require('crypto');

const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A };
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

function computeAcceptKey(clientKey) {
  return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64');
}
  • Step 2: Implement encodeFrame

Server frames are never masked. Three length encodings:

  • payload < 126: 2-byte header (FIN+opcode, length)
  • 126-65535: 4-byte header (FIN+opcode, 126, 16-bit length)
  • > 65535: 10-byte header (FIN+opcode, 127, 64-bit length)
function encodeFrame(opcode, payload) {
  const fin = 0x80;
  const len = payload.length;
  let header;

  if (len < 126) {
    header = Buffer.alloc(2);
    header[0] = fin | opcode;
    header[1] = len;
  } else if (len < 65536) {
    header = Buffer.alloc(4);
    header[0] = fin | opcode;
    header[1] = 126;
    header.writeUInt16BE(len, 2);
  } else {
    header = Buffer.alloc(10);
    header[0] = fin | opcode;
    header[1] = 127;
    header.writeBigUInt64BE(BigInt(len), 2);
  }

  return Buffer.concat([header, payload]);
}
  • Step 3: Implement decodeFrame

Client frames are always masked. Returns { opcode, payload, bytesConsumed } or null for incomplete. Throws on unmasked frames.

function decodeFrame(buffer) {
  if (buffer.length < 2) return null;

  const firstByte = buffer[0];
  const secondByte = buffer[1];
  const opcode = firstByte & 0x0F;
  const masked = (secondByte & 0x80) !== 0;
  let payloadLen = secondByte & 0x7F;
  let offset = 2;

  if (!masked) throw new Error('Client frames must be masked');

  if (payloadLen === 126) {
    if (buffer.length < 4) return null;
    payloadLen = buffer.readUInt16BE(2);
    offset = 4;
  } else if (payloadLen === 127) {
    if (buffer.length < 10) return null;
    payloadLen = Number(buffer.readBigUInt64BE(2));
    offset = 10;
  }

  const maskOffset = offset;
  const dataOffset = offset + 4;
  const totalLen = dataOffset + payloadLen;
  if (buffer.length < totalLen) return null;

  const mask = buffer.slice(maskOffset, dataOffset);
  const data = Buffer.alloc(payloadLen);
  for (let i = 0; i < payloadLen; i++) {
    data[i] = buffer[dataOffset + i] ^ mask[i % 4];
  }

  return { opcode, payload: data, bytesConsumed: totalLen };
}
  • Step 4: Add module exports at the bottom of the file
module.exports = { computeAcceptKey, encodeFrame, decodeFrame, OPCODES };
  • Step 5: Run unit tests

Run: cd tests/brainstorm-server && node ws-protocol.test.js Expected: All tests pass (handshake, encoding, decoding, boundaries, edge cases)

  • Step 6: Commit
git add skills/brainstorming/scripts/server.js
git commit -m "Add WebSocket protocol layer for zero-dep brainstorm server"

Chunk 2: HTTP Server and Application Logic

Task 2: Add HTTP server, file watching, and WebSocket connection handling

Files:

  • Modify: skills/brainstorming/scripts/server.js

  • Test: tests/brainstorm-server/server.test.js (already exists)

  • Step 1: Add configuration and constants at top of server.js (after requires)

const http = require('http');
const fs = require('fs');
const path = require('path');

const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
const SCREEN_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';

const MIME_TYPES = {
  '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
  '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml'
};
  • Step 2: Add WAITING_PAGE, template loading at module scope, and helper functions

Load frameTemplate and helperInjection at module scope so they're accessible to wrapInFrame and handleRequest. They only read files from __dirname (the scripts directory), which is valid whether the module is required or run directly.

const WAITING_PAGE = `<!DOCTYPE html>
<html>
<head><title>Brainstorm Companion</title>
<style>body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
h1 { color: #333; } p { color: #666; }</style>
</head>
<body><h1>Brainstorm Companion</h1>
<p>Waiting for Claude to push a screen...</p></body></html>`;

const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
const helperInjection = '<script>\n' + helperScript + '\n</script>';

function isFullDocument(html) {
  const trimmed = html.trimStart().toLowerCase();
  return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
}

function wrapInFrame(content) {
  return frameTemplate.replace('<!-- CONTENT -->', content);
}

function getNewestScreen() {
  const files = fs.readdirSync(SCREEN_DIR)
    .filter(f => f.endsWith('.html'))
    .map(f => {
      const fp = path.join(SCREEN_DIR, f);
      return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
    })
    .sort((a, b) => b.mtime - a.mtime);
  return files.length > 0 ? files[0].path : null;
}
  • Step 3: Add HTTP request handler
function handleRequest(req, res) {
  if (req.method === 'GET' && req.url === '/') {
    const screenFile = getNewestScreen();
    let html = screenFile
      ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
      : WAITING_PAGE;

    if (html.includes('</body>')) {
      html = html.replace('</body>', helperInjection + '\n</body>');
    } else {
      html += helperInjection;
    }

    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end(html);
  } else if (req.method === 'GET' && req.url.startsWith('/files/')) {
    const fileName = req.url.slice(7); // strip '/files/'
    const filePath = path.join(SCREEN_DIR, path.basename(fileName));
    if (!fs.existsSync(filePath)) {
      res.writeHead(404);
      res.end('Not found');
      return;
    }
    const ext = path.extname(filePath).toLowerCase();
    const contentType = MIME_TYPES[ext] || 'application/octet-stream';
    res.writeHead(200, { 'Content-Type': contentType });
    res.end(fs.readFileSync(filePath));
  } else {
    res.writeHead(404);
    res.end('Not found');
  }
}
  • Step 4: Add WebSocket connection handling
const clients = new Set();

function handleUpgrade(req, socket) {
  const key = req.headers['sec-websocket-key'];
  if (!key) { socket.destroy(); return; }

  const accept = computeAcceptKey(key);
  socket.write(
    'HTTP/1.1 101 Switching Protocols\r\n' +
    'Upgrade: websocket\r\n' +
    'Connection: Upgrade\r\n' +
    'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'
  );

  let buffer = Buffer.alloc(0);
  clients.add(socket);

  socket.on('data', (chunk) => {
    buffer = Buffer.concat([buffer, chunk]);
    while (buffer.length > 0) {
      let result;
      try {
        result = decodeFrame(buffer);
      } catch (e) {
        socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
        clients.delete(socket);
        return;
      }
      if (!result) break;
      buffer = buffer.slice(result.bytesConsumed);

      switch (result.opcode) {
        case OPCODES.TEXT:
          handleMessage(result.payload.toString());
          break;
        case OPCODES.CLOSE:
          socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
          clients.delete(socket);
          return;
        case OPCODES.PING:
          socket.write(encodeFrame(OPCODES.PONG, result.payload));
          break;
        case OPCODES.PONG:
          break;
        default:
          // Unsupported opcode — close with 1003
          const closeBuf = Buffer.alloc(2);
          closeBuf.writeUInt16BE(1003);
          socket.end(encodeFrame(OPCODES.CLOSE, closeBuf));
          clients.delete(socket);
          return;
      }
    }
  });

  socket.on('close', () => clients.delete(socket));
  socket.on('error', () => clients.delete(socket));
}

function handleMessage(text) {
  let event;
  try {
    event = JSON.parse(text);
  } catch (e) {
    console.error('Failed to parse WebSocket message:', e.message);
    return;
  }
  console.log(JSON.stringify({ source: 'user-event', ...event }));
  if (event.choice) {
    const eventsFile = path.join(SCREEN_DIR, '.events');
    fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
  }
}

function broadcast(msg) {
  const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg)));
  for (const socket of clients) {
    try { socket.write(frame); } catch (e) { clients.delete(socket); }
  }
}
  • Step 5: Add debounce timer map
const debounceTimers = new Map();

File watching logic is inlined in startServer (Step 6) to keep watcher lifecycle together with server lifecycle and include an error handler per spec.

  • Step 6: Add startServer function and conditional main

frameTemplate and helperInjection are already at module scope (Step 2). startServer just creates the screen dir, starts the HTTP server, watcher, and logs startup info.

function startServer() {
  if (!fs.existsSync(SCREEN_DIR)) fs.mkdirSync(SCREEN_DIR, { recursive: true });

  const server = http.createServer(handleRequest);
  server.on('upgrade', handleUpgrade);

  const watcher = fs.watch(SCREEN_DIR, (eventType, filename) => {
    if (!filename || !filename.endsWith('.html')) return;
    if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
    debounceTimers.set(filename, setTimeout(() => {
      debounceTimers.delete(filename);
      const filePath = path.join(SCREEN_DIR, filename);
      if (eventType === 'rename' && fs.existsSync(filePath)) {
        const eventsFile = path.join(SCREEN_DIR, '.events');
        if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
        console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
      } else if (eventType === 'change') {
        console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
      }
      broadcast({ type: 'reload' });
    }, 100));
  });
  watcher.on('error', (err) => console.error('fs.watch error:', err.message));

  server.listen(PORT, HOST, () => {
    const info = JSON.stringify({
      type: 'server-started', port: Number(PORT), host: HOST,
      url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT,
      screen_dir: SCREEN_DIR
    });
    console.log(info);
    fs.writeFileSync(path.join(SCREEN_DIR, '.server-info'), info + '\n');
  });
}

if (require.main === module) {
  startServer();
}
  • Step 7: Run integration tests

The test directory already has a package.json with ws as a dependency. Install it if needed, then run tests.

Run: cd tests/brainstorm-server && npm install && node server.test.js Expected: All tests pass

  • Step 8: Commit
git add skills/brainstorming/scripts/server.js
git commit -m "Add HTTP server, WebSocket handling, and file watching to server.js"

Chunk 3: Swap and Cleanup

Task 3: Update start-server.sh and remove old files

Files:

  • Modify: skills/brainstorming/scripts/start-server.sh:94,100

  • Modify: .gitignore:6

  • Delete: skills/brainstorming/scripts/index.js

  • Delete: skills/brainstorming/scripts/package.json

  • Delete: skills/brainstorming/scripts/package-lock.json

  • Delete: skills/brainstorming/scripts/node_modules/ (entire directory)

  • Step 1: Update start-server.sh — change index.js to server.js

Two lines to change:

Line 94: env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" node server.js

Line 100: nohup env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" node server.js > "$LOG_FILE" 2>&1 &

  • Step 2: Remove the gitignore exception for node_modules

In .gitignore, delete line 6: !skills/brainstorming/scripts/node_modules/

  • Step 3: Delete old files
git rm skills/brainstorming/scripts/index.js
git rm skills/brainstorming/scripts/package.json
git rm skills/brainstorming/scripts/package-lock.json
git rm -r skills/brainstorming/scripts/node_modules/
  • Step 4: Run both test suites

Run: cd tests/brainstorm-server && node ws-protocol.test.js && node server.test.js Expected: All tests pass

  • Step 5: Commit
git add skills/brainstorming/scripts/ .gitignore
git commit -m "Remove vendored node_modules, swap to zero-dep server.js"

Task 4: Manual smoke test

  • Step 1: Start the server manually
cd skills/brainstorming/scripts
BRAINSTORM_DIR=/tmp/brainstorm-smoke BRAINSTORM_PORT=9876 node server.js

Expected: server-started JSON printed with port 9876

Expected: Waiting page with "Waiting for Claude to push a screen..."

  • Step 3: Write an HTML file to the screen directory
echo '<h2>Hello from smoke test</h2>' > /tmp/brainstorm-smoke/test.html

Expected: Browser reloads and shows "Hello from smoke test" wrapped in frame template

  • Step 4: Verify WebSocket works — check browser console

Open browser dev tools. The WebSocket connection should show as connected (no errors in console). The frame template's status indicator should show "Connected".

  • Step 5: Stop server with Ctrl-C, clean up
rm -rf /tmp/brainstorm-smoke