feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
72
frontend/packages/arch/fetch-stream/README.md
Normal file
72
frontend/packages/arch/fetch-stream/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# @coze-arch/fetch-stream
|
||||
|
||||
fetch stream vanilla js
|
||||
|
||||
## Overview
|
||||
|
||||
This package is part of the Coze Studio monorepo and provides api & networking functionality. It serves as a core component in the Coze ecosystem.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Add this package to your `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@coze-arch/fetch-stream": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
rush update
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
import { /* exported functions/components */ } from '@coze-arch/fetch-stream';
|
||||
|
||||
// Example usage
|
||||
// TODO: Add specific usage examples
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Core functionality for Coze Studio
|
||||
- TypeScript support
|
||||
- Modern ES modules
|
||||
|
||||
## API Reference
|
||||
|
||||
### Exports
|
||||
|
||||
- `isFetchStreamErrorInfo,
|
||||
fetchStream,
|
||||
FetchStreamErrorCode,
|
||||
type FetchSteamConfig,
|
||||
type FetchStreamErrorInfo,`
|
||||
|
||||
|
||||
For detailed API documentation, please refer to the TypeScript definitions.
|
||||
|
||||
## Development
|
||||
|
||||
This package is built with:
|
||||
|
||||
- TypeScript
|
||||
- Modern JavaScript
|
||||
- Vitest for testing
|
||||
- ESLint for code quality
|
||||
|
||||
## Contributing
|
||||
|
||||
This package is part of the Coze Studio monorepo. Please follow the monorepo contribution guidelines.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
44
frontend/packages/arch/fetch-stream/__tests__/type.test.ts
Normal file
44
frontend/packages/arch/fetch-stream/__tests__/type.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 { describe, it, expect } from 'vitest';
|
||||
|
||||
import { FetchStreamErrorCode, type ValidateResult } from '../src/type';
|
||||
|
||||
describe('type definitions', () => {
|
||||
describe('FetchStreamErrorCode', () => {
|
||||
it('应该包含正确的错误码', () => {
|
||||
expect(FetchStreamErrorCode.FetchException).toBe(10001);
|
||||
expect(FetchStreamErrorCode.HttpChunkStreamingException).toBe(10002);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ValidateResult', () => {
|
||||
it('应该支持成功状态', () => {
|
||||
const successResult: ValidateResult = { status: 'success' };
|
||||
expect(successResult.status).toBe('success');
|
||||
});
|
||||
|
||||
it('应该支持错误状态', () => {
|
||||
const errorResult: ValidateResult = {
|
||||
status: 'error',
|
||||
error: new Error('Test error'),
|
||||
};
|
||||
expect(errorResult.status).toBe('error');
|
||||
expect(errorResult.error).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
227
frontend/packages/arch/fetch-stream/__tests__/utils.test.ts
Normal file
227
frontend/packages/arch/fetch-stream/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* 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 { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
onStart,
|
||||
validateChunk,
|
||||
isFetchStreamErrorInfo,
|
||||
getStreamingErrorInfo,
|
||||
getFetchErrorInfo,
|
||||
isAbortError,
|
||||
} from '../src/utils';
|
||||
import { FetchStreamErrorCode } from '../src/type';
|
||||
|
||||
describe('utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('onStart', () => {
|
||||
it('应该调用 inputOnStart 函数', async () => {
|
||||
const inputOnStart = vi.fn().mockResolvedValue(undefined);
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
body: new ReadableStream(),
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
await onStart(mockResponse, inputOnStart);
|
||||
|
||||
expect(inputOnStart).toHaveBeenCalledWith(mockResponse);
|
||||
});
|
||||
|
||||
it('当 response.ok 为 false 时应该抛出错误', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
body: new ReadableStream(),
|
||||
status: 500,
|
||||
} as Response;
|
||||
|
||||
await expect(onStart(mockResponse, undefined)).rejects.toThrow(
|
||||
'Invalid Response, ResponseStatus: 500',
|
||||
);
|
||||
});
|
||||
|
||||
it('当 response.body 为 null 时应该抛出错误', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
body: null,
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
await expect(onStart(mockResponse, undefined)).rejects.toThrow(
|
||||
'Invalid Response, ResponseStatus: 200',
|
||||
);
|
||||
});
|
||||
|
||||
it('当 inputOnStart 抛出错误时应该传播错误', async () => {
|
||||
const inputOnStart = vi.fn().mockRejectedValue(new Error('Custom error'));
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
body: new ReadableStream(),
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
await expect(onStart(mockResponse, inputOnStart)).rejects.toThrow(
|
||||
'Custom error',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateChunk', () => {
|
||||
it('应该成功验证正常的文本块', () => {
|
||||
expect(() => validateChunk('normal text')).not.toThrow();
|
||||
});
|
||||
|
||||
it('应该成功验证包含 code: 0 的 JSON', () => {
|
||||
const chunk = JSON.stringify({ code: 0, msg: 'success' });
|
||||
expect(() => validateChunk(chunk)).not.toThrow();
|
||||
});
|
||||
|
||||
it('应该抛出包含非零 code 的 JSON 对象', () => {
|
||||
const errorObj = { code: 400, msg: 'Bad Request' };
|
||||
const chunk = JSON.stringify(errorObj);
|
||||
|
||||
expect(() => validateChunk(chunk)).toThrow();
|
||||
});
|
||||
|
||||
it('应该成功处理无效的 JSON', () => {
|
||||
expect(() => validateChunk('invalid json {')).not.toThrow();
|
||||
});
|
||||
|
||||
it('应该成功处理非对象的 JSON', () => {
|
||||
expect(() => validateChunk('"string"')).not.toThrow();
|
||||
expect(() => validateChunk('123')).not.toThrow();
|
||||
expect(() => validateChunk('true')).not.toThrow();
|
||||
});
|
||||
|
||||
it('应该成功处理没有 code 字段的对象', () => {
|
||||
const chunk = JSON.stringify({ msg: 'no code field' });
|
||||
expect(() => validateChunk(chunk)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFetchStreamErrorInfo', () => {
|
||||
it('应该识别有效的 FetchStreamErrorInfo', () => {
|
||||
const errorInfo = { code: 400, msg: 'Error message' };
|
||||
expect(isFetchStreamErrorInfo(errorInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝缺少 code 字段的对象', () => {
|
||||
const obj = { msg: 'Error message' };
|
||||
expect(isFetchStreamErrorInfo(obj)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝缺少 msg 字段的对象', () => {
|
||||
const obj = { code: 400 };
|
||||
expect(isFetchStreamErrorInfo(obj)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝非对象值', () => {
|
||||
expect(isFetchStreamErrorInfo(null)).toBe(false);
|
||||
expect(isFetchStreamErrorInfo(undefined)).toBe(false);
|
||||
expect(isFetchStreamErrorInfo('string')).toBe(false);
|
||||
expect(isFetchStreamErrorInfo(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStreamingErrorInfo', () => {
|
||||
it('应该从 Error 对象中提取消息', () => {
|
||||
const error = new Error('Test error message');
|
||||
const result = getStreamingErrorInfo(error);
|
||||
|
||||
expect(result).toEqual({
|
||||
msg: 'Test error message',
|
||||
code: FetchStreamErrorCode.HttpChunkStreamingException,
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该从 FetchStreamErrorInfo 对象中提取信息', () => {
|
||||
const errorInfo = { code: 400, msg: 'Custom error' };
|
||||
const result = getStreamingErrorInfo(errorInfo);
|
||||
|
||||
expect(result).toEqual({
|
||||
msg: 'Custom error',
|
||||
code: 400,
|
||||
error: errorInfo,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理未知错误类型', () => {
|
||||
const error = 'string error';
|
||||
const result = getStreamingErrorInfo(error);
|
||||
|
||||
expect(result).toEqual({
|
||||
msg: 'An exception occurred during the process of dealing with HTTP chunked streaming response.',
|
||||
code: FetchStreamErrorCode.HttpChunkStreamingException,
|
||||
error,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFetchErrorInfo', () => {
|
||||
it('应该从 Error 对象中提取消息', () => {
|
||||
const error = new Error('Fetch failed');
|
||||
const result = getFetchErrorInfo(error);
|
||||
|
||||
expect(result).toEqual({
|
||||
msg: 'Fetch failed',
|
||||
code: FetchStreamErrorCode.FetchException,
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理非 Error 对象', () => {
|
||||
const error = 'fetch error';
|
||||
const result = getFetchErrorInfo(error);
|
||||
|
||||
expect(result).toEqual({
|
||||
msg: 'An exception occurred during the fetch',
|
||||
code: FetchStreamErrorCode.FetchException,
|
||||
error,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAbortError', () => {
|
||||
it('应该识别 AbortError', () => {
|
||||
const abortError = new DOMException(
|
||||
'The operation was aborted',
|
||||
'AbortError',
|
||||
);
|
||||
expect(isAbortError(abortError)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝其他 DOMException', () => {
|
||||
const otherError = new DOMException('Other error', 'OtherError');
|
||||
expect(isAbortError(otherError)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝普通 Error', () => {
|
||||
const error = new Error('Normal error');
|
||||
expect(isAbortError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝非错误对象', () => {
|
||||
expect(isAbortError('string')).toBe(false);
|
||||
expect(isAbortError(null)).toBe(false);
|
||||
expect(isAbortError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
12
frontend/packages/arch/fetch-stream/config/rush-project.json
Normal file
12
frontend/packages/arch/fetch-stream/config/rush-project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"operationSettings": [
|
||||
{
|
||||
"operationName": "test:cov",
|
||||
"outputFolderNames": ["coverage"]
|
||||
},
|
||||
{
|
||||
"operationName": "ts-check",
|
||||
"outputFolderNames": ["./dist"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"codecov": {
|
||||
"coverage": 0,
|
||||
"incrementCoverage": 0
|
||||
}
|
||||
}
|
||||
9
frontend/packages/arch/fetch-stream/eslint.config.js
Normal file
9
frontend/packages/arch/fetch-stream/eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const { defineConfig } = require('@coze-arch/eslint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
packageRoot: __dirname,
|
||||
preset: 'node',
|
||||
rules: {
|
||||
'no-console': 'error',
|
||||
},
|
||||
});
|
||||
30
frontend/packages/arch/fetch-stream/package.json
Normal file
30
frontend/packages/arch/fetch-stream/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@coze-arch/fetch-stream",
|
||||
"version": "0.0.1",
|
||||
"description": "fetch stream vanilla js",
|
||||
"license": "Apache-2.0",
|
||||
"author": "gaoyuanhan.duty@bytedance.com",
|
||||
"maintainers": [],
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "exit 0",
|
||||
"lint": "eslint ./ --cache",
|
||||
"test": "vitest --run --passWithNoTests",
|
||||
"test:cov": "npm run test -- --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mattiasbuelens/web-streams-adapter": "~0.1.0",
|
||||
"eventsource-parser": "^1.0.0",
|
||||
"web-streams-polyfill": "~3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@coze-arch/eslint-config": "workspace:*",
|
||||
"@coze-arch/ts-config": "workspace:*",
|
||||
"@coze-arch/vitest-config": "workspace:*",
|
||||
"@types/node": "^18",
|
||||
"@vitest/coverage-v8": "~3.0.5",
|
||||
"sucrase": "^3.32.0",
|
||||
"vitest": "~3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
27
frontend/packages/arch/fetch-stream/tsconfig.build.json
Normal file
27
frontend/packages/arch/fetch-stream/tsconfig.build.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@coze-arch/ts-config/tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"types": [],
|
||||
"moduleResolution": "Node10",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../config/eslint-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/ts-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/vitest-config/tsconfig.build.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
frontend/packages/arch/fetch-stream/tsconfig.json
Normal file
15
frontend/packages/arch/fetch-stream/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.misc.json"
|
||||
}
|
||||
],
|
||||
"exclude": ["**/*"]
|
||||
}
|
||||
21
frontend/packages/arch/fetch-stream/tsconfig.misc.json
Normal file
21
frontend/packages/arch/fetch-stream/tsconfig.misc.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@coze-arch/ts-config/tsconfig.node.json",
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"include": ["__tests__", "__tests__/**/*.json", "vitest.config.ts"],
|
||||
"exclude": ["./dist"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.build.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rootDir": "./",
|
||||
"outDir": "./dist",
|
||||
"types": ["vitest/globals"],
|
||||
"moduleResolution": "Node10",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true
|
||||
}
|
||||
}
|
||||
22
frontend/packages/arch/fetch-stream/vitest.config.ts
Normal file
22
frontend/packages/arch/fetch-stream/vitest.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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 { defineConfig } from '@coze-arch/vitest-config';
|
||||
|
||||
export default defineConfig({
|
||||
dirname: __dirname,
|
||||
preset: 'node',
|
||||
});
|
||||
Reference in New Issue
Block a user