refactor: 收口前端 SSE 传输层

This commit is contained in:
2026-06-03 14:57:02 +08:00
parent 545ffa4b2c
commit 1eeb14c50f
10 changed files with 442 additions and 579 deletions

View File

@@ -2,6 +2,7 @@ import type {
VisualNovelRunSnapshot,
VisualNovelRuntimeStreamEvent,
} from '../../../packages/shared/src/contracts/visualNovel';
import { readSseJsonStream } from '../sseStream';
type VisualNovelRuntimeSseOptions = {
fallbackMessage: string;
@@ -9,65 +10,6 @@ type VisualNovelRuntimeSseOptions = {
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
};
function findSseEventBoundary(buffer: string) {
const lfBoundary = buffer.indexOf('\n\n');
const crlfBoundary = buffer.indexOf('\r\n\r\n');
if (lfBoundary === -1 && crlfBoundary === -1) {
return null;
}
if (lfBoundary === -1) {
return {
index: crlfBoundary,
length: 4,
};
}
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
return {
index: lfBoundary,
length: 2,
};
}
return {
index: crlfBoundary,
length: 4,
};
}
function parseSseEventBlock(eventBlock: string) {
let eventName = 'message';
const dataLines: string[] = [];
for (const rawLine of eventBlock.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line.startsWith('event:')) {
eventName = line.slice(6).trim() || 'message';
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
}
return {
eventName,
data: dataLines.join('\n'),
};
}
function parseJsonObject(data: string) {
try {
return JSON.parse(data) as Record<string, unknown>;
} catch {
return null;
}
}
function normalizeVisualNovelRuntimeEvent(
eventName: string,
parsed: Record<string, unknown>,
@@ -115,59 +57,19 @@ export async function readVisualNovelRuntimeRunFromSse(
response: Response,
options: VisualNovelRuntimeSseOptions,
) {
const streamBody = response.body;
if (!streamBody) {
throw new Error('streaming response body is unavailable');
}
const reader = streamBody.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finalRun: VisualNovelRunSnapshot | null = null;
const consumeBuffer = () => {
for (;;) {
const boundary = findSseEventBoundary(buffer);
if (!boundary) {
break;
}
const eventBlock = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary.length);
const { eventName, data } = parseSseEventBlock(eventBlock);
if (!data) {
continue;
}
const parsed = parseJsonObject(data);
if (!parsed) {
continue;
}
const event = normalizeVisualNovelRuntimeEvent(eventName, parsed);
if (!event) {
continue;
}
const nextRun = handleVisualNovelRuntimeEvent(event, options);
if (nextRun) {
finalRun = nextRun;
}
}
};
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
await readSseJsonStream(response, ({ eventName, parsed }) => {
const event = normalizeVisualNovelRuntimeEvent(eventName, parsed);
if (!event) {
return;
}
buffer += decoder.decode(value, { stream: true });
consumeBuffer();
}
buffer += decoder.decode();
consumeBuffer();
const nextRun = handleVisualNovelRuntimeEvent(event, options);
if (nextRun) {
finalRun = nextRun;
}
});
if (!finalRun) {
throw new Error(options.incompleteMessage);