Files
UHD_Media_Tool/apps/server/src/http/sse.ts
T
umtandClaude Fable 5.1 b22c440139 chore: scaffold monorepo and migrate uhd_web design system (W0-A)
- pnpm workspace: apps/{web,server}, packages/{contracts,db,domain,adapters,tasks,agent,testkit}
- server: Hono + zod-openapi, API Key auth with scopes, redacting pino logger, SSE event bus with replay, worker heartbeat
- db: Kysely + pg, file migrator, 0001_init (settings/api_key/audit_event/outbox_event)
- web: React Router 7 SPA console shell with 12 workspace placeholders, uhd_web tokens/ui/guard scripts
- docs: ADR-001 (Node over Deno), dev runbook, design system rules

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-27 17:36:26 +08:00

107 lines
3.3 KiB
TypeScript

import { type EventEnvelope, EventType } from "@umt/contracts";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { ApiException } from "./errors";
type Listener = (e: EventEnvelope) => void;
/**
* 进程内事件总线:序号自增,保留最近 capacity 条供断线补读。
* 多进程部署时需换成 PG LISTEN/NOTIFY 或 outbox 投影(W2)。
*/
export class EventBus {
private seq = 0;
private readonly buffer: EventEnvelope[] = [];
private readonly listeners = new Set<Listener>();
constructor(private readonly capacity = 1000) {}
get latestSeq() {
return this.seq;
}
publish<P>(type: string, payload: P): EventEnvelope<P> {
const e: EventEnvelope<P> = { seq: ++this.seq, type, at: new Date().toISOString(), payload };
this.buffer.push(e);
if (this.buffer.length > this.capacity) this.buffer.shift();
for (const l of this.listeners) l(e);
return e;
}
/** 取 seq > after 的缓冲事件;gap=true 表示请求的起点已被淘汰,客户端需重新拉快照 */
since(after: number): { events: EventEnvelope[]; gap: boolean } {
const oldest = this.buffer[0]?.seq ?? this.seq + 1;
const gap = after > 0 && after < oldest - 1;
return { events: this.buffer.filter((e) => e.seq > after), gap };
}
subscribe(l: Listener): () => void {
this.listeners.add(l);
return () => this.listeners.delete(l);
}
}
export interface SseOptions {
bus: EventBus;
keepaliveMs?: number;
}
/** GET /events?after=<seq>(也接受 Last-Event-ID 头) */
export function sseRoutes({ bus, keepaliveMs = 15_000 }: SseOptions) {
const app = new Hono();
app.get("/events", (c) => {
const raw = c.req.query("after") ?? c.req.header("last-event-id") ?? "0";
const after = Number(raw);
if (!Number.isInteger(after) || after < 0) throw new ApiException("bad_request", "after 必须是非负整数");
return streamSSE(c, async (stream) => {
const queue: EventEnvelope[] = [];
let wake: (() => void) | null = null;
// 先订阅再读缓冲,避免两者之间的事件丢失
const unsubscribe = bus.subscribe((e) => {
queue.push(e);
wake?.();
});
let closed = false;
stream.onAbort(() => {
closed = true;
wake?.();
});
const { events, gap } = bus.since(after);
let last = after;
if (gap) {
await stream.writeSSE({ event: EventType.resync, data: JSON.stringify({ latestSeq: bus.latestSeq }) });
}
const write = async (e: EventEnvelope) => {
if (e.seq <= last) return;
last = e.seq;
await stream.writeSSE({ id: String(e.seq), event: e.type, data: JSON.stringify(e) });
};
for (const e of events) await write(e);
const timer = setInterval(() => {
void stream.write(": keepalive\n\n");
}, keepaliveMs);
try {
while (!closed) {
while (queue.length) {
const e = queue.shift();
if (e) await write(e);
}
if (closed) break;
await new Promise<void>((r) => {
if (queue.length || closed) r();
else wake = r;
});
wake = null;
}
} finally {
clearInterval(timer);
unsubscribe();
}
});
});
return app;
}