147 lines
5.8 KiB
TypeScript
147 lines
5.8 KiB
TypeScript
import type { NatsConnection, Subscription } from 'nats';
|
|
|
|
export interface SseEvent { seq: number; topic: string; data: string }
|
|
export interface Connection {
|
|
id: string;
|
|
staffSub: string;
|
|
isAdmin: boolean;
|
|
topics: Set<string>;
|
|
send(e: SseEvent): void;
|
|
/**
|
|
* D12:从 streamSSE 回调之外关掉这条流的把手。流的生命周期挂在回调里的一个
|
|
* 挂起 promise 上,只有 resolve 它才能收尾——所以 hub 得拿到一个由路由实现的
|
|
* 闭包(清心跳+resolve)。单测手搓的连接不需要,故可选。
|
|
*/
|
|
close?(): void;
|
|
}
|
|
export interface TaskAccessPort { allowed(taskId: string, staffSub: string): Promise<boolean> }
|
|
|
|
/**
|
|
* 订阅失败带判别式:路由要按失败**种类**分流 HTTP 码(形状错=422 整单驳回,
|
|
* 连接不存在/被拒=200 里逐条报)。靠 message.includes('不可订阅') 猜种类,
|
|
* 改一个字文案就会把 422 悄悄变成 200。
|
|
*/
|
|
export type SubscribeResult =
|
|
| { ok: true }
|
|
| { ok: false; reason: 'bad-shape' | 'no-conn' | 'denied'; message: string };
|
|
|
|
const SUBSCRIBABLE = /^(touch\.task\.|touch\.workflow\.|stream\.task\.)[^.]+$/;
|
|
|
|
export class SseHub {
|
|
private conns = new Map<string, Connection>();
|
|
private ring: SseEvent[] = [];
|
|
private seq = 0;
|
|
private streamSubs = new Map<string, Subscription>();
|
|
private nc: NatsConnection | null = null;
|
|
|
|
constructor(private replayWindow = 512) {}
|
|
|
|
attachNats(nc: NatsConnection): void {
|
|
this.nc = nc;
|
|
const sub = nc.subscribe('touch.>', {
|
|
callback: (_err, msg) => this.deliver(msg.subject, new TextDecoder().decode(msg.data)),
|
|
});
|
|
this.streamSubs.set('touch.>', sub);
|
|
}
|
|
|
|
/**
|
|
* D11:只登记,**不**回放。回放必须排在 hello 帧之后——客户端拿到 connectionId
|
|
* 之前收到的事件无从对账(它还不知道该拿哪个 id 去 /subscribe)。
|
|
*/
|
|
register(conn: Connection): void {
|
|
this.conns.set(conn.id, conn);
|
|
}
|
|
|
|
/**
|
|
* 断线重连补发:按全局 seq 补窗口内错过的、且本连接可见的事件。
|
|
*
|
|
* 诚实的局限(D11):EventSource 重连不带订阅状态,路由只能以空 topics 重新登记,
|
|
* 于是这里实际只补得到 `touch.system` 与 `touch.staff.<sub>`。任务域的补发要求客户端
|
|
* 收到 hello 后先重新 /subscribe——但那时 lastEventId 这一跳已经过去了。真正的
|
|
* 任务域断点续传得等 N3 把订阅集做成服务端持久态(按 staffSub 记住上次订阅)。
|
|
*/
|
|
replay(conn: Connection, lastEventId?: number): void {
|
|
if (lastEventId === undefined) return;
|
|
for (const e of this.ring) {
|
|
if (e.seq > lastEventId && this.visible(conn, e.topic)) conn.send(e);
|
|
}
|
|
}
|
|
|
|
unregister(connId: string): void {
|
|
const conn = this.conns.get(connId);
|
|
this.conns.delete(connId);
|
|
if (conn) for (const t of conn.topics) this.maybeDropStreamSub(t);
|
|
}
|
|
|
|
async subscribe(connId: string, topic: string, access: TaskAccessPort): Promise<SubscribeResult> {
|
|
// D3:形状校验排在连接查找**之前**。请求体里写了个根本不可订阅的主题,是这个请求
|
|
// 自身不合法(422),跟连接在不在无关;反过来先查连接的话,一个失效 connectionId
|
|
// 会把形状错整个遮掉,HTTP 面只能回 200。
|
|
if (!SUBSCRIBABLE.test(topic)) {
|
|
return {
|
|
ok: false,
|
|
reason: 'bad-shape',
|
|
message: `主题 ${topic} 不可订阅——只接受 touch.task/workflow.* 与 stream.task.*`,
|
|
};
|
|
}
|
|
const conn = this.conns.get(connId);
|
|
if (!conn) return { ok: false, reason: 'no-conn', message: `连接 ${connId} 不存在——先建立 /api/stream` };
|
|
const taskId = topic.split('.').pop() ?? '';
|
|
if (!conn.isAdmin && (topic.startsWith('stream.task.') || topic.startsWith('touch.task.'))) {
|
|
if (!(await access.allowed(taskId, conn.staffSub))) {
|
|
return { ok: false, reason: 'denied', message: `无权订阅任务 ${taskId}——需要负责人/审批人/admin` };
|
|
}
|
|
}
|
|
conn.topics.add(topic);
|
|
if (topic.startsWith('stream.task.') && this.nc && !this.streamSubs.has(topic)) {
|
|
const sub = this.nc.subscribe(topic, {
|
|
callback: (_err, msg) => this.deliver(msg.subject, new TextDecoder().decode(msg.data)),
|
|
});
|
|
this.streamSubs.set(topic, sub);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
unsubscribe(connId: string, topic: string): void {
|
|
this.conns.get(connId)?.topics.delete(topic);
|
|
this.maybeDropStreamSub(topic);
|
|
}
|
|
|
|
deliver(topic: string, data: string): void {
|
|
const e: SseEvent = { seq: ++this.seq, topic, data };
|
|
this.ring.push(e);
|
|
if (this.ring.length > this.replayWindow) this.ring.shift();
|
|
for (const conn of this.conns.values()) {
|
|
if (this.visible(conn, topic)) conn.send(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* D12:优雅退出。网关此前没有 SIGTERM 处理(N1 靠默认行为 exit 143),现在挂着
|
|
* 长连 SSE——不主动关,server.close() 会一直等这些永不结束的响应。
|
|
* 先快照再遍历:close() 可能同步触发 unregister,边删边迭代会漏。
|
|
*/
|
|
shutdown(): void {
|
|
for (const conn of [...this.conns.values()]) conn.close?.();
|
|
this.conns.clear();
|
|
for (const sub of this.streamSubs.values()) sub.unsubscribe();
|
|
this.streamSubs.clear();
|
|
this.nc = null;
|
|
}
|
|
|
|
private visible(conn: Connection, topic: string): boolean {
|
|
if (topic === 'touch.system') return true;
|
|
if (topic.startsWith('touch.staff.')) return topic === `touch.staff.${conn.staffSub}`;
|
|
return conn.topics.has(topic);
|
|
}
|
|
|
|
private maybeDropStreamSub(topic: string): void {
|
|
if (!topic.startsWith('stream.task.')) return;
|
|
const stillUsed = [...this.conns.values()].some((c) => c.topics.has(topic));
|
|
if (!stillUsed) {
|
|
this.streamSubs.get(topic)?.unsubscribe();
|
|
this.streamSubs.delete(topic);
|
|
}
|
|
}
|
|
}
|