/** * SSE 文本帧与断点续推解析(A6-BE-03 / 技术架构 §6.4) * * 帧格式(与 EventSource 规范一致): * id: * event: item | progress | done | error * data: <单行 JSON> * <空行> * 心跳用注释帧 `: ping`,不污染事件流。`Last-Event-ID` 请求头优先于 `last_event_id` query。 */ import { TASK_EVENT_TYPES, type TaskDoneEventPayload, type TaskEventType } from '@lingxi/shared'; /** DB 字符串 → SSE 事件类型(未知视为脏数据,由调用方兜底为 error / 跳过) */ export function parseTaskEventType(value: string): TaskEventType { const found = TASK_EVENT_TYPES.find((event) => event === value); if (found === undefined) throw new Error(`任务事件类型非法:${value}`); return found; } export type SseResume = { ok: true; seq: bigint } | { ok: false }; /** 解析续推起点:header 优先;缺省 / 空串 = 从 0 开始;非纯数字视为非法(40001) */ export function resolveResumeSeq( header: string | string[] | undefined, query: string | undefined, ): SseResume { const headerValue = Array.isArray(header) ? header[0] : header; const raw = headerValue === undefined || headerValue.trim().length === 0 ? query : headerValue; if (raw === undefined || raw.trim().length === 0) { return { ok: true, seq: 0n }; } const trimmed = raw.trim(); if (!/^\d{1,19}$/.test(trimmed)) { return { ok: false }; } return { ok: true, seq: BigInt(trimmed) }; } /** 事件帧:id 用 task_events.seq(BigInt 转字符串),data 单行 JSON */ export function formatSseEvent(id: string, event: TaskEventType, data: unknown): string { return `id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`; } /** 心跳注释帧 */ export function formatSseComment(text: string): string { return `: ${text}\n\n`; } /** 终态补发 done:id 用最后消费到的 seq,保证重连语义不倒退 */ export function formatSseDone(seq: bigint, payload: TaskDoneEventPayload): string { return formatSseEvent(seq.toString(), 'done', payload); }