feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
299
frontend/packages/arch/fetch-stream/src/fetch-stream.ts
Normal file
299
frontend/packages/arch/fetch-stream/src/fetch-stream.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
31
frontend/packages/arch/fetch-stream/src/index.ts
Normal file
31
frontend/packages/arch/fetch-stream/src/index.ts
Normal 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,
|
||||
};
|
||||
145
frontend/packages/arch/fetch-stream/src/type.ts
Normal file
145
frontend/packages/arch/fetch-stream/src/type.ts
Normal 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;
|
||||
}
|
||||
102
frontend/packages/arch/fetch-stream/src/utils.ts
Normal file
102
frontend/packages/arch/fetch-stream/src/utils.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user