sse-broker.js
4.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
/**
* 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,
};