feat: refactor http client

This commit is contained in:
AkaraChen 2024-11-08 17:21:55 +08:00
parent ebdf72fffc
commit d4f7ebfd2e
4 changed files with 209 additions and 181 deletions

View File

@ -64,6 +64,7 @@
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "^0.16.11",
"ky": "^1.7.2",
"lamejs": "^1.2.1",
"lexical": "^0.18.0",
"lodash-es": "^4.17.21",
@ -84,9 +85,9 @@
"react-hook-form": "^7.53.1",
"react-i18next": "^15.1.0",
"react-infinite-scroll-component": "^6.1.0",
"react-markdown": "^9.0.1",
"react-multi-email": "^1.0.25",
"react-papaparse": "^4.4.0",
"react-markdown": "^9.0.1",
"react-slider": "^2.0.6",
"react-sortablejs": "^6.1.4",
"react-syntax-highlighter": "^15.6.1",

View File

@ -133,6 +133,9 @@ importers:
katex:
specifier: ^0.16.11
version: 0.16.11
ky:
specifier: ^1.7.2
version: 1.7.2
lamejs:
specifier: ^1.2.1
version: 1.2.1
@ -5612,6 +5615,10 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
ky@1.7.2:
resolution: {integrity: sha512-OzIvbHKKDpi60TnF9t7UUVAF1B4mcqc02z5PIvrm08Wyb+yOcz63GRvEuVxNT18a9E1SrNouhB4W2NNLeD7Ykg==}
engines: {node: '>=18'}
lamejs@1.2.1:
resolution: {integrity: sha512-s7bxvjvYthw6oPLCm5pFxvA84wUROODB8jEO2+CE1adhKgrIvVOlmMgY8zyugxGrvRaDHNJanOiS21/emty6dQ==}
@ -14744,6 +14751,8 @@ snapshots:
kleur@4.1.5: {}
ky@1.7.2: {}
lamejs@1.2.1:
dependencies:
use-strict: 1.0.1

View File

@ -1,4 +1,4 @@
import { API_PREFIX, IS_CE_EDITION, MARKETPLACE_API_PREFIX, PUBLIC_API_PREFIX } from '@/config'
import { API_PREFIX, IS_CE_EDITION, PUBLIC_API_PREFIX } from '@/config'
import { refreshAccessTokenOrRelogin } from './refresh-token'
import Toast from '@/app/components/base/toast'
import type { AnnotationReply, MessageEnd, MessageReplace, ThoughtItem } from '@/app/components/base/chat/chat/type'
@ -17,27 +17,10 @@ import type {
WorkflowStartedResponse,
} from '@/types/workflow'
import { removeAccessToken } from '@/app/components/share/utils'
import type { FetchOptionType, ResponseError } from './fetch'
import { ContentType, base, baseOptions, getPublicToken } from './fetch'
const TIME_OUT = 100000
const ContentType = {
json: 'application/json',
stream: 'text/event-stream',
audio: 'audio/mpeg',
form: 'application/x-www-form-urlencoded; charset=UTF-8',
download: 'application/octet-stream', // for download
upload: 'multipart/form-data', // for upload
}
const baseOptions = {
method: 'GET',
mode: 'cors',
credentials: 'include', // always send cookies、HTTP Basic authentication.
headers: new Headers({
'Content-Type': ContentType.json,
}),
redirect: 'follow',
}
export type IOnDataMoreInfo = {
conversationId?: string
taskId?: string
@ -100,17 +83,6 @@ export type IOtherOptions = {
onTextReplace?: IOnTextReplace
}
type ResponseError = {
code: string
message: string
status: number
}
type FetchOptionType = Omit<RequestInit, 'body'> & {
params?: Record<string, any>
body?: BodyInit | Record<string, any> | null
}
function unicodeToChar(text: string) {
if (!text)
return ''
@ -277,153 +249,13 @@ const handleStream = (
read()
}
const baseFetch = <T>(
url: string,
fetchOptions: FetchOptionType,
{
isPublicAPI = false,
isMarketplaceAPI = false,
bodyStringify = true,
needAllResponseContent,
deleteContentType,
getAbortController,
silent,
}: IOtherOptions,
): Promise<T> => {
const options: typeof baseOptions & FetchOptionType = Object.assign({}, baseOptions, fetchOptions)
if (isMarketplaceAPI)
options.credentials = 'omit'
if (getAbortController) {
const abortController = new AbortController()
getAbortController(abortController)
options.signal = abortController.signal
}
if (isPublicAPI) {
const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0]
const accessToken = localStorage.getItem('token') || JSON.stringify({ [sharedToken]: '' })
let accessTokenJson = { [sharedToken]: '' }
try {
accessTokenJson = JSON.parse(accessToken)
}
catch (e) {
}
options.headers.set('Authorization', `Bearer ${accessTokenJson[sharedToken]}`)
}
else if (!isMarketplaceAPI) {
const accessToken = localStorage.getItem('console_token') || ''
options.headers.set('Authorization', `Bearer ${accessToken}`)
}
if (deleteContentType) {
options.headers.delete('Content-Type')
}
else {
const contentType = options.headers.get('Content-Type')
if (!contentType)
options.headers.set('Content-Type', ContentType.json)
}
const urlPrefix = (() => {
if (isMarketplaceAPI)
return MARKETPLACE_API_PREFIX
if (isPublicAPI)
return PUBLIC_API_PREFIX
return API_PREFIX
})()
let urlWithPrefix = `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}`
const { method, params, body } = options
// handle query
if (method === 'GET' && params) {
const paramsArray: string[] = []
Object.keys(params).forEach(key =>
paramsArray.push(`${key}=${encodeURIComponent(params[key])}`),
)
if (urlWithPrefix.search(/\?/) === -1)
urlWithPrefix += `?${paramsArray.join('&')}`
else
urlWithPrefix += `&${paramsArray.join('&')}`
delete options.params
}
if (body && bodyStringify)
options.body = JSON.stringify(body)
// Handle timeout
return Promise.race([
new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('request timeout'))
}, TIME_OUT)
}),
new Promise((resolve, reject) => {
globalThis.fetch(urlWithPrefix, options as RequestInit)
.then((res) => {
const resClone = res.clone()
// Error handler
if (!/^(2|3)\d{2}$/.test(String(res.status))) {
const bodyJson = res.json()
switch (res.status) {
case 401:
return Promise.reject(resClone)
case 403:
bodyJson.then((data: ResponseError) => {
if (!silent)
Toast.notify({ type: 'error', message: data.message })
if (data.code === 'already_setup')
globalThis.location.href = `${globalThis.location.origin}/signin`
})
break
// fall through
default:
bodyJson.then((data: ResponseError) => {
if (!silent)
Toast.notify({ type: 'error', message: data.message })
})
}
return Promise.reject(resClone)
}
// handle delete api. Delete api not return content.
if (res.status === 204) {
resolve({ result: 'success' })
return
}
// return data
if (options.headers.get('Content-type') === ContentType.download || options.headers.get('Content-type') === ContentType.audio)
resolve(needAllResponseContent ? resClone : res.blob())
else resolve(needAllResponseContent ? resClone : res.json())
})
.catch((err) => {
if (!silent)
Toast.notify({ type: 'error', message: err })
reject(err)
})
}),
]) as Promise<T>
}
const baseFetch = base
export const upload = (options: any, isPublicAPI?: boolean, url?: string, searchParams?: string): Promise<any> => {
const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX
let token = ''
if (isPublicAPI) {
const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0]
const accessToken = localStorage.getItem('token') || JSON.stringify({ [sharedToken]: '' })
let accessTokenJson = { [sharedToken]: '' }
try {
accessTokenJson = JSON.parse(accessToken)
}
catch (e) {
}
token = accessTokenJson[sharedToken]
token = getPublicToken()
}
else {
const accessToken = localStorage.getItem('console_token') || ''
@ -499,9 +331,9 @@ export const ssePost = (
signal: abortController.signal,
}, fetchOptions)
const contentType = options.headers.get('Content-Type')
const contentType = (options.headers as Headers).get('Content-Type')
if (!contentType)
options.headers.set('Content-Type', ContentType.json)
(options.headers as Headers).set('Content-Type', ContentType.json)
getAbortController?.(abortController)
@ -559,18 +391,17 @@ export const ssePost = (
}
// base request
export const request = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
export const request = <T>(url: string, options = {}, otherOptions: IOtherOptions = {}) => {
return new Promise<T>((resolve, reject) => {
const otherOptionsForBaseFetch = otherOptions || {}
baseFetch<T>(url, options, otherOptionsForBaseFetch).then(resolve).catch((errResp) => {
baseFetch<T>(url, options, otherOptions).then(resolve).catch((errResp) => {
if (errResp?.status === 401) {
return refreshAccessTokenOrRelogin(TIME_OUT).then(() => {
baseFetch<T>(url, options, otherOptionsForBaseFetch).then(resolve).catch(reject)
baseFetch<T>(url, options, otherOptions).then(resolve).catch(reject)
}).catch(() => {
const {
isPublicAPI = false,
silent,
} = otherOptionsForBaseFetch
} = otherOptions
const bodyJson = errResp.json()
if (isPublicAPI) {
return bodyJson.then((data: ResponseError) => {

187
web/service/fetch.ts Normal file
View File

@ -0,0 +1,187 @@
import type { AfterResponseHook, BeforeErrorHook, BeforeRequestHook, Hooks } from 'ky'
import ky from 'ky'
import type { IOtherOptions } from './base'
import Toast from '@/app/components/base/toast'
import { API_PREFIX, MARKETPLACE_API_PREFIX, PUBLIC_API_PREFIX } from '@/config'
const TIME_OUT = 100000
export const ContentType = {
json: 'application/json',
stream: 'text/event-stream',
audio: 'audio/mpeg',
form: 'application/x-www-form-urlencoded; charset=UTF-8',
download: 'application/octet-stream', // for download
upload: 'multipart/form-data', // for upload
}
export type FetchOptionType = Omit<RequestInit, 'body'> & {
params?: Record<string, any>
body?: BodyInit | Record<string, any> | null
}
const afterResponse204: AfterResponseHook = async (_request, _options, response) => {
if (response.status === 204) return Response.json({ result: 'success' })
}
export type ResponseError = {
code: string
message: string
status: number
}
const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook => {
return async (_request, _options, response) => {
if (!/^(2|3)\d{2}$/.test(String(response.status))) {
const bodyJson = response.json() as Promise<ResponseError>
switch (response.status) {
case 401:
return Promise.reject(response)
case 403:
bodyJson.then((data: ResponseError) => {
if (!otherOptions.silent)
Toast.notify({ type: 'error', message: data.message })
if (data.code === 'already_setup')
globalThis.location.href = `${globalThis.location.origin}/signin`
})
break
// fall through
default:
bodyJson.then((data: ResponseError) => {
if (!otherOptions.silent)
Toast.notify({ type: 'error', message: data.message })
})
}
throw response
}
}
}
const beforeErrorToast = (otherOptions: IOtherOptions): BeforeErrorHook => {
return (error) => {
if (!otherOptions.silent)
Toast.notify({ type: 'error', message: error.message })
return error
}
}
export const getPublicToken = () => {
let token = ''
const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0]
const accessToken = localStorage.getItem('token') || JSON.stringify({ [sharedToken]: '' })
let accessTokenJson = { [sharedToken]: '' }
try {
accessTokenJson = JSON.parse(accessToken)
}
catch {}
token = accessTokenJson[sharedToken]
return token || ''
}
const beforeRequestPublicAuthorization: BeforeRequestHook = (request) => {
const token = getPublicToken()
request.headers.set('Authorization', `Bearer ${token}`)
}
const beforeRequestAuthorization: BeforeRequestHook = (request) => {
const accessToken = localStorage.getItem('console_token') || ''
request.headers.set('Authorization', `Bearer ${accessToken}`)
}
const beforeRequestDeleteContentType: BeforeRequestHook = (request) => {
request.headers.delete('Content-Type')
}
const baseHooks: Hooks = {
afterResponse: [
afterResponse204,
],
}
const client = ky.create({
hooks: baseHooks,
timeout: TIME_OUT,
})
export const baseOptions: RequestInit = {
method: 'GET',
mode: 'cors',
credentials: 'include', // always send cookies、HTTP Basic authentication.
headers: new Headers({
'Content-Type': ContentType.json,
}),
redirect: 'follow',
}
async function base<T>(url: string, options: FetchOptionType = {}, otherOptions: IOtherOptions = {}): Promise<T> {
const { params, body, ...init } = Object.assign({}, baseOptions, options)
const {
isPublicAPI = false,
isMarketplaceAPI = false,
bodyStringify = true,
needAllResponseContent,
deleteContentType,
getAbortController,
} = otherOptions
const base
= isMarketplaceAPI
? MARKETPLACE_API_PREFIX
: isPublicAPI
? PUBLIC_API_PREFIX
: API_PREFIX
if (getAbortController) {
const abortController = new AbortController()
getAbortController(abortController)
options.signal = abortController.signal
}
const fetchPathname = `${base}${url.startsWith('/') ? url : `/${url}`}`
const res = await client.extend({
hooks: {
...baseHooks,
beforeError: [
...baseHooks.beforeError || [],
beforeErrorToast(otherOptions),
],
beforeRequest: [
...baseHooks.beforeRequest || [],
isPublicAPI && beforeRequestPublicAuthorization,
!isPublicAPI && !isMarketplaceAPI && beforeRequestAuthorization,
deleteContentType && beforeRequestDeleteContentType,
].filter(i => !!i),
afterResponse: [
...baseHooks.afterResponse || [],
afterResponseErrorCode(otherOptions),
],
},
})(fetchPathname, {
credentials: isMarketplaceAPI
? 'omit'
: (options.credentials || 'include'),
...init,
retry: {
methods: [],
},
...(bodyStringify ? { json: body } : { body: body as BodyInit }),
searchParams: params,
})
if (needAllResponseContent)
return res as T
const contentType = res.headers.get('content-type')
if (
contentType
&& [ContentType.download, ContentType.audio].includes(contentType)
)
return await res.blob() as T
return await res.json() as T
}
export {
client,
base,
}