feat: 网关 SSE 推送中心——按会话过滤扇出、订阅授权、Last-Event-ID 短窗回放、心跳

This commit is contained in:
lovefyf
2026-08-13 11:53:06 +08:00
parent 41d58a9bb9
commit d308104c0b
8 changed files with 456 additions and 3 deletions
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it, beforeAll } from 'vitest';
import { SseHub } from '../../src/modules/sse/hub';
import type { Connection, SseEvent } from '../../src/modules/sse/hub';
import { buildApp } from '../../src/app/app';
import { makeToken, setupTestJwks } from '../support/token';
function conn(id: string, staffSub: string, topics: string[] = []): { c: Connection; got: SseEvent[] } {
const got: SseEvent[] = [];
return { c: { id, staffSub, isAdmin: false, topics: new Set(topics), send: (e) => got.push(e) }, got };
}
describe('SseHub 路由', () => {
it('staff 精确投递、system 广播、task 按订阅集、回放窗口', () => {
const hub = new SseHub(8);
const a = conn('a', 'sub-a', ['touch.task.t1']);
const b = conn('b', 'sub-b');
hub.register(a.c);
hub.register(b.c);
hub.deliver('touch.system', '{}');
hub.deliver('touch.staff.sub-a', '{}');
hub.deliver('touch.task.t1', '{}');
expect(a.got.map((e) => e.topic)).toEqual(['touch.system', 'touch.staff.sub-a', 'touch.task.t1']);
expect(b.got.map((e) => e.topic)).toEqual(['touch.system']);
// 断线重连回放:c 以 lastEventId=1 上线且订阅 t1 → 补 seq>1 的可见事件。
// D11:register 只登记、replay 单独一步——路由要把 hello 插在两者之间。
const c = conn('c', 'sub-c', ['touch.task.t1']);
hub.register(c.c);
hub.replay(c.c, 1);
expect(c.got.map((e) => e.topic)).toEqual(['touch.task.t1']);
});
it('subscribe 授权:access 拒 → 不入订阅集', async () => {
const hub = new SseHub();
const a = conn('a', 'sub-a');
hub.register(a.c);
const denyAccess = { allowed: async () => false };
const res = await hub.subscribe('a', 'stream.task.t9', denyAccess);
expect(res.ok).toBe(false);
expect(a.c.topics.has('stream.task.t9')).toBe(false);
});
it('D3:形状校验排在连接查找之前,失败带判别式 reason 供路由分流', async () => {
const hub = new SseHub();
const denyAccess = { allowed: async () => false };
// 连接不存在 + 形状也不对 → 必须报形状(否则 HTTP 面永远回不出 422)
expect(await hub.subscribe('nope', 'touch.staff.hack', denyAccess)).toMatchObject({
ok: false,
reason: 'bad-shape',
});
expect(await hub.subscribe('nope', 'touch.task.t1', denyAccess)).toMatchObject({
ok: false,
reason: 'no-conn',
});
const a = conn('a', 'sub-a');
hub.register(a.c);
expect(await hub.subscribe('a', 'stream.task.t9', denyAccess)).toMatchObject({
ok: false,
reason: 'denied',
});
});
it('D12:shutdown 逐条调 close 把手并摘除连接', () => {
const hub = new SseHub();
let closed = 0;
const a = conn('a', 'sub-a');
hub.register({ ...a.c, close: () => (closed += 1) });
hub.shutdown();
expect(closed).toBe(1);
hub.deliver('touch.system', '{}');
expect(a.got).toEqual([]); // 已摘除,不再收扇出
});
});
describe('SSE HTTP 面', () => {
beforeAll(async () => {
await setupTestJwks();
});
it('subscribe 主题形状校验 422;无 token 401', async () => {
const app = buildApp();
expect((await app.request('/api/stream/subscribe', { method: 'POST' })).status).toBe(401);
const token = await makeToken({ roles: ['user'], aud: 'api-gateway' });
const bad = await app.request('/api/stream/subscribe', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ connectionId: 'nope', add: ['touch.staff.hack'] }),
});
expect(bad.status).toBe(422);
});
it('D11/D12:hello 先于回放;hub.shutdown() 能把在途长连收尾', async () => {
const hub = new SseHub(8);
const app = buildApp(hub);
hub.deliver('touch.system', '{"n":1}'); // seq 1
hub.deliver('touch.staff.test-sub-1', '{"n":2}'); // seq 2
const token = await makeToken({ roles: ['user'], aud: 'api-gateway' });
const res = await app.request(`/api/stream?access_token=${token}`, {
headers: { 'last-event-id': '1' },
});
expect(res.status).toBe(200);
const body = res.body;
if (!body) throw new Error('SSE 响应缺流体');
const reader = body.getReader();
const decoder = new TextDecoder();
let text = '';
while (!text.includes('event: touch.staff.test-sub-1')) {
const { value, done } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
expect(text).toContain('event: hello');
expect(text.indexOf('event: hello')).toBeLessThan(text.indexOf('event: touch.staff.test-sub-1'));
expect(text).not.toContain('event: touch.system'); // seq 1 ≤ lastEventId,不补
// D12:回调里挂住的 promise 必须能被 close 把手 settle——settle 了 streamSSE 才会
// close 流,客户端这边才读得到 done。旧写法 `await new Promise(() => {})` 永不 settle:
// 下面这次 read 会一直吊着(用例超时),线上则是 SIGTERM 关不掉服务。
hub.shutdown();
let ended = false;
for (let i = 0; i < 4 && !ended; i += 1) ended = (await reader.read()).done;
expect(ended).toBe(true);
});
});