feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,299 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable @coze-arch/max-line-per-function */
/* eslint-disable max-lines-per-function */
import {
createParser,
type ParseEvent,
type EventSourceParser,
} from 'eventsource-parser';
import {
getFetchErrorInfo,
getStreamingErrorInfo,
isAbortError,
onStart,
validateChunk,
} from './utils';
import { type FetchSteamConfig } from './type';
/** 发起流式消息拉取的请求 */
export async function fetchStream<Message = ParseEvent, DataClump = unknown>(
requestInfo: RequestInfo,
{
onStart: inputOnStart,
onError,
onAllSuccess,
onFetchStart,
onFetchSuccess,
onStartReadStream,
onMessage,
fetch: inputFetch,
dataClump,
signal,
streamParser,
totalFetchTimeout,
onTotalFetchTimeout,
betweenChunkTimeout,
onBetweenChunkTimeout,
validateMessage,
...rest
}: FetchSteamConfig<Message, DataClump>,
): Promise<void> {
const webStreamsPolyfill = await import(
/*webpackChunkName: "web-streams-polyfill"*/ 'web-streams-polyfill/ponyfill'
);
const { ReadableStream, WritableStream, TransformStream } =
webStreamsPolyfill as {
ReadableStream?: typeof globalThis.ReadableStream;
WritableStream: typeof globalThis.WritableStream;
TransformStream: typeof globalThis.TransformStream;
};
const { createReadableStreamWrapper } = await import(
/*webpackChunkName: "web-streams-polyfill"*/ '@mattiasbuelens/web-streams-adapter'
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const readableStreamWrapper = createReadableStreamWrapper(ReadableStream!);
return new Promise<void>(resolve => {
const decoder = new TextDecoder();
const fetch = inputFetch ?? window.fetch;
let totalFetchTimer: ReturnType<typeof setTimeout> | null = null;
let betweenChunkTimer: ReturnType<typeof setTimeout> | null = null;
/**
* clear 时机
* 所有异常退出
* create 函数 return
* readStream 结束
* abortSignal 触发
*/
const clearTotalFetchTimer = () => {
if (!totalFetchTimer) {
return;
}
clearTimeout(totalFetchTimer);
totalFetchTimer = null;
};
/**
* set 时机
* fetch 之间 set 一次, 只此一次
*/
const setTotalFetchTimer = () => {
if (totalFetchTimeout && onTotalFetchTimeout) {
totalFetchTimer = setTimeout(() => {
onTotalFetchTimeout(dataClump);
clearTotalFetchTimer();
}, totalFetchTimeout);
}
};
/**
* clear 时机
* readStream 异常退出
* readStream 结束
* 收到了新 chunk
* abortSignal 触发
*/
const clearBetweenChunkTimer = () => {
if (!betweenChunkTimer) {
return;
}
clearTimeout(betweenChunkTimer);
betweenChunkTimer = null;
};
/**
* set 时机
* readStream 之前 set 一次
* 每次收到 chunk 并执行了 clearBetweenChunkTimer 时 set 一次
*/
const setBetweenChunkTimer = () => {
if (betweenChunkTimeout && onBetweenChunkTimeout) {
betweenChunkTimer = setTimeout(() => {
onBetweenChunkTimeout(dataClump);
clearBetweenChunkTimer();
}, betweenChunkTimeout);
}
};
signal?.addEventListener('abort', () => {
// 此处 abort 后下方 readableStream 与 writableStream 都会停止
clearTotalFetchTimer();
clearBetweenChunkTimer();
resolve();
});
const fetchAndVerifyResponse = async () => {
try {
setTotalFetchTimer();
onFetchStart?.(dataClump);
const response = await fetch(requestInfo, {
signal,
...rest,
});
await onStart(response, inputOnStart);
onFetchSuccess?.(dataClump);
return response;
} catch (error) {
/**
* 这里会被 catch 的错误
* fetch 服务端返回异常
* js error例如被 onStart 抛出的
* fetch 过程中 signal 被 abort
*/
// 被 abort 不认为是异常,不调用 onError
if (isAbortError(error)) {
return;
}
clearTotalFetchTimer();
onError?.({
fetchStreamError: getFetchErrorInfo(error),
dataClump,
});
}
};
const readStream = async (
responseBody: globalThis.ReadableStream<Uint8Array>,
) => {
setBetweenChunkTimer();
let parser: EventSourceParser;
const streamTransformer = new TransformStream<ArrayBuffer, Message>({
start(controller) {
parser = createParser(parseEvent => {
if (!streamParser) {
controller.enqueue(parseEvent as Message);
return;
}
const terminateFn = controller.terminate;
const onParseErrorFn = controller.error;
const result = streamParser?.(parseEvent, {
terminate: terminateFn.bind(controller),
onParseError: onParseErrorFn.bind(controller),
});
if (result) {
controller.enqueue(result);
}
});
},
transform(chunk, controller) {
clearBetweenChunkTimer();
setBetweenChunkTimer();
const decodedChunk = decoder.decode(chunk, { stream: true });
try {
//
validateChunk(decodedChunk);
// 上方 start 会在 TransformStream 被构建的同时执行,所以此处执行时能取到 parser
parser.feed(decodedChunk);
} catch (chunkError) {
// 处理 validateChunk 抛出的业务错误
// 服务端不会流式返回业务错误,错误结构:{ msg: 'xxx', code: 123456 }
controller.error(chunkError);
}
},
});
const streamWriter = new WritableStream<Message>({
async write(chunk, controller) {
// 写消息异步化 避免回调中的错误 panic 管道流
await Promise.resolve();
const param = { message: chunk, dataClump };
const validateResult = validateMessage?.(param);
if (validateResult && validateResult.status === 'error') {
/**
* 会中断 WritableStream, 即使还有数据也会被中断, 不会再写了
*/
throw validateResult.error;
}
onMessage?.(param);
},
});
try {
onStartReadStream?.(dataClump);
await (
readableStreamWrapper(
responseBody,
) as unknown as ReadableStream<ArrayBuffer>
)
.pipeThrough(streamTransformer)
.pipeTo(streamWriter);
clearTotalFetchTimer();
clearBetweenChunkTimer();
onAllSuccess?.(dataClump);
resolve();
} catch (streamError) {
/**
* 这里会被 catch 的错误
* 流式返回中服务端异常
* js error
* 流式返回过程中被 signal 被 abort
* 上方 onParseErrorFn 被调用
*/
// 被 abort 不认为是异常,不调用 onError
if (isAbortError(streamError)) {
return;
}
clearTotalFetchTimer();
clearBetweenChunkTimer();
onError?.({
fetchStreamError: getStreamingErrorInfo(streamError),
dataClump,
});
}
};
async function create(): Promise<void> {
const response = await fetchAndVerifyResponse();
const body = response?.body;
// response 不合法与没有 body 的错误在上方 onStart 中处理过
if (!body) {
clearTotalFetchTimer();
return;
}
await readStream(body);
}
create();
});
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isFetchStreamErrorInfo } from './utils';
import {
FetchStreamErrorCode,
type FetchSteamConfig,
type FetchStreamErrorInfo,
} from './type';
import { fetchStream } from './fetch-stream';
export {
isFetchStreamErrorInfo,
fetchStream,
FetchStreamErrorCode,
type FetchSteamConfig,
type FetchStreamErrorInfo,
};

View File

@@ -0,0 +1,145 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ParseEvent } from 'eventsource-parser';
export enum FetchStreamErrorCode {
FetchException = 10001,
HttpChunkStreamingException = 10002,
}
export interface FetchStreamErrorInfo {
code: FetchStreamErrorCode | number;
msg: string;
}
export interface FetchStreamError extends FetchStreamErrorInfo {
error: unknown;
}
export type ValidateResult =
| {
status: 'success';
}
| {
status: 'error';
error: Error;
};
/**
* {@link RequestInfo} 与 {@link RequestInit} 是 Fetch 原有参数类型
*/
export interface FetchSteamConfig<Message = ParseEvent, DataClump = unknown>
extends RequestInit {
/**
* 当开始 fetch时调用
*/
onFetchStart?: (params?: DataClump) => void;
/**
* 当 fetch 返回 response 时调用此方法。使用这个方法来验证 Response 是否符合预期,当不符合预期时抛出错误
* 无论是否提供此方法,会自动校验 Response.ok 标志位与 Response.body 是否存在
*/
onStart?: (response: Response) => Promise<void>;
/**
* 当 fetch 成功返回 response 并且 onStart 成功后触发此回调
*/
onFetchSuccess?: (params?: DataClump) => void;
/**
* 开始读取 ReadableStream 时触发此回调。onFetchSuccess 后紧接着会触发这个回调
*/
onStartReadStream?: (params?: DataClump) => void;
/**
* 流式过程中解析服务端返回的 chunk 数据,返回值符合 {@link Message} 类型时,预期将在后续 {@link onMessage} 方法中响应
* 可在解析过程中进行中断或抛出错误,抛出错误同时会中断整个流式解析
* 如果不提供则由 onMessage 直接响应 chunk 数据
*/
streamParser?: (
parseEvent: ParseEvent,
method: {
/**
* 中止当前流式读取行为
*/
terminate: () => void;
/**
* @deprecated
* 抛出错误,同时中止当前流式读取行为,如果流中还有正常数据未被读取,也会被一起终止掉
*/
onParseError: (error: FetchStreamErrorInfo) => void;
},
) => Message | undefined;
/**
* 在 onMessage 回调之前执行。对业务错误的处理和抛出推荐在这个回调处理
*/
validateMessage?: (params: {
message: Message;
dataClump?: DataClump;
}) => ValidateResult;
/**
* 接收到服务端 Chunk 数据并经过 parse如果有如果过程中无异常则调用此方法
*/
onMessage?: (params: { message: Message; dataClump?: DataClump }) => void;
/**
* 当 fetchStream resolve 时调用此方法
*/
onAllSuccess?: (params?: DataClump) => void;
/**
* fetchStream 整个过程中出现任意错误会调用此方法,包括 fetch / 流式处理 chunk / response 非法等
* 不会自动重试
*/
onError?: (params: {
fetchStreamError: FetchStreamError;
dataClump?: DataClump;
}) => void;
/** Fetch 方法,默认为 window.fetch */
fetch?: typeof fetch;
/**
* {@link https://book-refactoring2.ifmicro.com/docs/ch3.html#310-%E6%95%B0%E6%8D%AE%E6%B3%A5%E5%9B%A2%EF%BC%88data-clumps%EF%BC%89}
* 如果你想为每个 fetchStream 维护一些业务数据、状态,推荐在此处传入抽象后的数据实例。它们会在每个回调函数中出现
*/
dataClump?: DataClump;
/**
* fetch stream 整个过程的超时时长, 单位: ms。缺省或者传入 0 代表不开启定时器
*/
totalFetchTimeout?: number;
/**
* 当设置了 totalFetchTimeout, 并且到期时触发此回调。除此外不会有其余副作用例如abort 请求。请调用方根据需要自行处理
*/
onTotalFetchTimeout?: (params?: DataClump) => void;
/**
* chunk 之间超时时长, 处理 stream 过程中, 从收到上一个 chunk 开始计时, 收到下一个 chunk 时清除定时并重新计时
* 缺省或者传入 0 代表不开启定时器, 单位: ms
*/
betweenChunkTimeout?: number;
/**
* 当设置了 chunkTimeout并且定时器到期时触发此回调。除此外不会有其余副作用例如abort 请求。请调用方根据需要自行处理
*/
onBetweenChunkTimeout?: (params?: DataClump) => void;
}

View File

@@ -0,0 +1,102 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type FetchSteamConfig,
FetchStreamErrorCode,
type FetchStreamErrorInfo,
type FetchStreamError,
} from './type';
export async function onStart(
response: Response,
inputOnStart: FetchSteamConfig<unknown, unknown>['onStart'],
): Promise<void> {
await inputOnStart?.(response);
if (!(response.ok && response.body)) {
throw new Error(`Invalid Response, ResponseStatus: ${response.status}`);
}
}
export function validateChunk(decodedChunk: string): void {
let json: unknown;
try {
json = JSON.parse(decodedChunk);
// eslint-disable-next-line @coze-arch/no-empty-catch, @coze-arch/use-error-in-catch -- 设计如此
} catch {
/**
* 此处捕获 JSON.parse 错误不做任何处理
* 正常流式返回 json 解析失败才是正常的
*/
}
if (
typeof json === 'object' &&
json !== null &&
'code' in json &&
json.code !== 0
) {
throw json;
}
}
export function isFetchStreamErrorInfo(
error: unknown,
): error is FetchStreamErrorInfo {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
'msg' in error
);
}
export function getStreamingErrorInfo(error: unknown): FetchStreamError {
let errorMsg =
'An exception occurred during the process of dealing with HTTP chunked streaming response.';
let errorCode = FetchStreamErrorCode.HttpChunkStreamingException;
if (error instanceof Error) {
errorMsg = error.message;
}
if (isFetchStreamErrorInfo(error)) {
errorMsg = error.msg;
errorCode = error.code;
}
return {
msg: errorMsg,
code: errorCode,
error,
};
}
export function getFetchErrorInfo(error: unknown): FetchStreamError {
const errorMsg = 'An exception occurred during the fetch';
const errorCode = FetchStreamErrorCode.FetchException;
return {
msg: error instanceof Error ? error.message : errorMsg,
code: errorCode,
error,
};
}
export function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}