a53ffebe0e
1. 新增工具插件(edit_file, python_executor, search_file, shell_executor, write_file) 2. 新增系统事件日志模块和API 3. 新增workflow配置文件和详情API 4. 前端增加SSE、错误边界、设置引导等组件 5. 优化认证加密、速率限制、配置加载等工具模块 6. 删除废弃的cluster和health API 7. 补充单元测试和集成测试 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
3.8 KiB
TypeScript
131 lines
3.8 KiB
TypeScript
// 基于 fetch + ReadableStream 的轻量 SSE 客户端,带指数退避自动重连。
|
||
//
|
||
// 原生 EventSource 无法携带自定义 header,只能把 token 放进 URL query,
|
||
// 而 token 进 URL 会被网关/浏览器历史/Referer 记录,存在泄露风险。
|
||
// 这里用 fetch 手动读取 text/event-stream,token 走标准 Authorization header。
|
||
|
||
export interface SSEHandlers {
|
||
onOpen?: () => void;
|
||
onMessage?: (data: string) => void;
|
||
onError?: (err: unknown) => void;
|
||
// 连接断开、准备重连时回调,附带本次退避延迟(毫秒)
|
||
onReconnect?: (delayMs: number) => void;
|
||
}
|
||
|
||
export interface SSEOptions {
|
||
// 初始重连延迟(毫秒),默认 1000
|
||
baseDelayMs?: number;
|
||
// 最大重连延迟(毫秒),默认 30000
|
||
maxDelayMs?: number;
|
||
// 鉴权失败(401/403)时是否停止重连,默认 true
|
||
stopOnAuthError?: boolean;
|
||
}
|
||
|
||
export interface SSEConnection {
|
||
close: () => void;
|
||
}
|
||
|
||
const AUTH_ERROR_STATUSES = new Set([401, 403]);
|
||
|
||
export function connectSSE(
|
||
url: string,
|
||
token: string,
|
||
handlers: SSEHandlers,
|
||
options: SSEOptions = {},
|
||
): SSEConnection {
|
||
const baseDelay = options.baseDelayMs ?? 1000;
|
||
const maxDelay = options.maxDelayMs ?? 30000;
|
||
const stopOnAuthError = options.stopOnAuthError ?? true;
|
||
|
||
let controller = new AbortController();
|
||
let closed = false;
|
||
let attempt = 0;
|
||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
const scheduleReconnect = () => {
|
||
if (closed) return;
|
||
// 指数退避 + 抖动,封顶 maxDelay
|
||
const backoff = Math.min(baseDelay * 2 ** attempt, maxDelay);
|
||
const delay = backoff / 2 + Math.random() * (backoff / 2);
|
||
attempt += 1;
|
||
handlers.onReconnect?.(delay);
|
||
retryTimer = setTimeout(() => {
|
||
if (closed) return;
|
||
controller = new AbortController();
|
||
void run();
|
||
}, delay);
|
||
};
|
||
|
||
const run = async () => {
|
||
try {
|
||
const resp = await fetch(url, {
|
||
method: 'GET',
|
||
headers: {
|
||
Accept: 'text/event-stream',
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
signal: controller.signal,
|
||
});
|
||
|
||
if (!resp.ok || !resp.body) {
|
||
handlers.onError?.(new Error(`SSE connect failed: ${resp.status}`));
|
||
if (stopOnAuthError && AUTH_ERROR_STATUSES.has(resp.status)) {
|
||
closed = true;
|
||
return;
|
||
}
|
||
scheduleReconnect();
|
||
return;
|
||
}
|
||
|
||
// 连接成功,重置退避计数
|
||
attempt = 0;
|
||
handlers.onOpen?.();
|
||
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
let sep: number;
|
||
while ((sep = buffer.indexOf('\n\n')) !== -1) {
|
||
const rawEvent = buffer.slice(0, sep);
|
||
buffer = buffer.slice(sep + 2);
|
||
const data = parseEventData(rawEvent);
|
||
if (data !== null) handlers.onMessage?.(data);
|
||
}
|
||
}
|
||
|
||
// 流正常结束(服务端关闭),非主动 close 则尝试重连
|
||
if (!closed) scheduleReconnect();
|
||
} catch (err) {
|
||
if (controller.signal.aborted || closed) return;
|
||
handlers.onError?.(err);
|
||
scheduleReconnect();
|
||
}
|
||
};
|
||
|
||
void run();
|
||
|
||
return {
|
||
close: () => {
|
||
closed = true;
|
||
if (retryTimer) clearTimeout(retryTimer);
|
||
controller.abort();
|
||
},
|
||
};
|
||
}
|
||
|
||
function parseEventData(rawEvent: string): string | null {
|
||
// 只关心 data: 行,多行 data 用 \n 拼接,忽略注释(:)与其他字段
|
||
const dataLines = rawEvent
|
||
.split('\n')
|
||
.filter((line) => line.startsWith('data:'))
|
||
.map((line) => line.slice(5).replace(/^ /, ''));
|
||
if (dataLines.length === 0) return null;
|
||
return dataLines.join('\n');
|
||
}
|