sse-broker.js 4.41 KB
/**
 * DevLoop · sseBroker
 *
 * 每项目一个广播通道,多订阅者。
 * - subscribe(pid, res, opts) 把 HTTP res 注册为订阅者
 * - emit(pid, event, data) 写一行到 stream.log + 广播给所有订阅者
 * - 心跳:每 25s 一条 `:keepalive\n\n` 注释
 * - 断线重连:客户端发 Last-Event-ID header,replay 从 stream.log 读 > 该 id 的行
 *
 * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-11
 *      docs/prd/newfunction/_spec/api-sse.md
 *      docs/prd/newfunction/_spec/sdd-process.md · P-5
 */

const fs = require('fs');
const path = require('path');
const pathResolver = require('./path-resolver');

const HEARTBEAT_MS = 25_000;

// pid → Set<{ res, heartbeatTimer }>
const subscriptions = new Map();

// 单调递增的事件 id(跨进程不严格保证;崩溃重启从 stream.log 读最大值续)
let eventCounter = 0;

function _streamLogPath(pid) {
  return pathResolver.resolveStreamLog(pid);
}

function _initCounterFromLog(pid) {
  // 启动时如果 stream.log 已存在,找最大 id 续号
  const p = _streamLogPath(pid);
  if (!fs.existsSync(p)) return;
  const raw = fs.readFileSync(p, 'utf8');
  const matches = raw.match(/^id: (\d+)$/gm) || [];
  for (const m of matches) {
    const n = parseInt(m.slice(4), 10);
    if (n > eventCounter) eventCounter = n;
  }
}

function _ensureCounterInit(pid) {
  // 全局单调即可;不严格 per-pid(避免每次启动都全扫)
  if (eventCounter === 0) _initCounterFromLog(pid);
}

/**
 * @param {string} pid
 * @param {http.ServerResponse} res - 调用方应已 writeHead(200, SSE headers)
 * @param {object} opts - { replayFromOffset?: string | number (Last-Event-ID) }
 */
function subscribe(pid, res, opts = {}) {
  _ensureCounterInit(pid);

  // Replay:从 stream.log 读 > replayFromOffset 的行
  if (opts.replayFromOffset != null) {
    const fromId = parseInt(opts.replayFromOffset, 10) || 0;
    _replayTo(res, pid, fromId);
  }

  // 心跳
  const heartbeat = setInterval(() => {
    try { res.write(':keepalive\n\n'); } catch { /* ignore broken pipe */ }
  }, HEARTBEAT_MS);

  // 加订阅
  if (!subscriptions.has(pid)) subscriptions.set(pid, new Set());
  const entry = { res, heartbeatTimer: heartbeat };
  subscriptions.get(pid).add(entry);

  // 客户端断开
  res.on('close', () => {
    clearInterval(heartbeat);
    subscriptions.get(pid)?.delete(entry);
    if (subscriptions.get(pid)?.size === 0) subscriptions.delete(pid);
  });
}

function _replayTo(res, pid, fromId) {
  const p = _streamLogPath(pid);
  if (!fs.existsSync(p)) return;

  const raw = fs.readFileSync(p, 'utf8');
  // stream.log 格式为 SSE 帧:`id: N\nevent: X\ndata: {...}\n\n`
  const blocks = raw.split('\n\n').filter(Boolean);
  for (const block of blocks) {
    const idMatch = block.match(/^id: (\d+)/m);
    if (!idMatch) continue;
    const id = parseInt(idMatch[1], 10);
    if (id <= fromId) continue;
    try { res.write(block + '\n\n'); } catch { return; }
  }
}

/**
 * 广播一条事件 + 落盘 stream.log。
 * @param {string} pid
 * @param {string} event - 事件名,如 "unit.state" / "bug.opened"
 * @param {any} data
 */
function emit(pid, event, data) {
  _ensureCounterInit(pid);
  const id = ++eventCounter;
  const payload = JSON.stringify(data == null ? {} : data);
  const line = `id: ${id}\nevent: ${event}\ndata: ${payload}\n\n`;

  // 1. 落盘
  const p = _streamLogPath(pid);
  fs.mkdirSync(path.dirname(p), { recursive: true });
  fs.appendFileSync(p, line);

  // 2. 广播
  const subs = subscriptions.get(pid);
  if (subs) {
    for (const entry of subs) {
      try { entry.res.write(line); } catch { /* ignore broken pipe */ }
    }
  }

  return { id, event };
}

/** 项目结束:发 close + 关所有订阅。 */
function close(pid) {
  const subs = subscriptions.get(pid);
  if (!subs) return;
  for (const entry of subs) {
    try { entry.res.write('event: close\ndata: {}\n\n'); } catch { /* ignore */ }
    try { entry.res.end(); } catch { /* ignore */ }
    clearInterval(entry.heartbeatTimer);
  }
  subscriptions.delete(pid);
}

/** 测试用 */
function _resetForTesting() {
  for (const subs of subscriptions.values()) {
    for (const entry of subs) clearInterval(entry.heartbeatTimer);
  }
  subscriptions.clear();
  eventCounter = 0;
}

function _activeSubscribers(pid) {
  return subscriptions.get(pid)?.size || 0;
}

module.exports = {
  subscribe,
  emit,
  close,
  HEARTBEAT_MS,
  _resetForTesting,
  _activeSubscribers,
};