mirror of
https://github.com/langgenius/dify.git
synced 2026-07-16 01:48:40 +08:00
271 lines
8.4 KiB
TypeScript
271 lines
8.4 KiB
TypeScript
import type { AfterResponseHook, BeforeRequestHook, Hooks } from 'ky'
|
|
import type { IOtherOptions } from './base'
|
|
import { toast } from '@langgenius/dify-ui/toast'
|
|
import Cookies from 'js-cookie'
|
|
import ky, { HTTPError } from 'ky'
|
|
import {
|
|
API_PREFIX,
|
|
APP_VERSION,
|
|
CSRF_COOKIE_NAME,
|
|
CSRF_HEADER_NAME,
|
|
IS_MARKETPLACE,
|
|
MARKETPLACE_API_PREFIX,
|
|
PASSPORT_HEADER_NAME,
|
|
PUBLIC_API_PREFIX,
|
|
WEB_APP_SHARE_CODE_HEADER_NAME,
|
|
} from '@/config'
|
|
import { getWebAppAccessToken, getWebAppPassport } from './webapp-auth'
|
|
|
|
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
|
|
downloadZip: 'application/zip', // 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 ({ response }) => {
|
|
if (response.status === 204) {
|
|
return new Response(JSON.stringify({ result: 'success' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': ContentType.json },
|
|
})
|
|
}
|
|
}
|
|
|
|
export type ResponseError = {
|
|
code: string
|
|
message: string
|
|
error?: string
|
|
status: number
|
|
}
|
|
|
|
const createResponseFromHTTPError = (error: HTTPError): Response => {
|
|
const headers = new Headers(error.response.headers)
|
|
headers.delete('content-length')
|
|
|
|
let body: BodyInit | null = null
|
|
if (typeof error.data === 'string') body = error.data
|
|
else if (error.data !== undefined) body = JSON.stringify(error.data)
|
|
|
|
if (body !== null && !headers.has('content-type')) headers.set('content-type', ContentType.json)
|
|
|
|
return new Response(body, {
|
|
status: error.response.status,
|
|
statusText: error.response.statusText,
|
|
headers,
|
|
})
|
|
}
|
|
|
|
const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook => {
|
|
return async ({ response }) => {
|
|
if (!/^[23]\d{2}$/.test(String(response.status))) {
|
|
let errorData: ResponseError | null = null
|
|
try {
|
|
const data: unknown = await response.clone().json()
|
|
errorData = data as ResponseError
|
|
} catch {}
|
|
const shouldNotifyError = response.status !== 401 && errorData && !otherOptions.silent
|
|
|
|
const errorMessage = errorData?.message || errorData?.error
|
|
if (shouldNotifyError && errorMessage) toast.error(errorMessage)
|
|
|
|
if (response.status === 403 && errorData?.code === 'already_setup')
|
|
globalThis.location.href = `${globalThis.location.origin}/signin`
|
|
}
|
|
}
|
|
}
|
|
|
|
const SHARE_ROUTE_DENY_LIST = new Set(['webapp-signin', 'check-code', 'login'])
|
|
|
|
const resolveShareCode = () => {
|
|
const pathnameSegments = globalThis.location.pathname.split('/').filter(Boolean)
|
|
const lastSegment = pathnameSegments.at(-1) || ''
|
|
if (lastSegment && !SHARE_ROUTE_DENY_LIST.has(lastSegment)) return lastSegment
|
|
|
|
const redirectParam = new URLSearchParams(globalThis.location.search).get('redirect_url')
|
|
if (!redirectParam) return ''
|
|
try {
|
|
const redirectUrl = new URL(decodeURIComponent(redirectParam), globalThis.location.origin)
|
|
const redirectSegments = redirectUrl.pathname.split('/').filter(Boolean)
|
|
const redirectSegment = redirectSegments.at(-1) || ''
|
|
return SHARE_ROUTE_DENY_LIST.has(redirectSegment) ? '' : redirectSegment
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
const beforeRequestPublicWithCode: BeforeRequestHook = ({ request }) => {
|
|
if (!request.headers.has('Authorization')) {
|
|
const accessToken = getWebAppAccessToken()
|
|
if (accessToken) request.headers.set('Authorization', `Bearer ${accessToken}`)
|
|
else request.headers.delete('Authorization')
|
|
}
|
|
const shareCode = resolveShareCode()
|
|
if (!shareCode) return
|
|
request.headers.set(WEB_APP_SHARE_CODE_HEADER_NAME, shareCode)
|
|
request.headers.set(PASSPORT_HEADER_NAME, getWebAppPassport(shareCode))
|
|
}
|
|
|
|
const baseHooks: Hooks = {
|
|
afterResponse: [afterResponse204],
|
|
}
|
|
|
|
const baseClient = ky.create({
|
|
hooks: baseHooks,
|
|
timeout: TIME_OUT,
|
|
})
|
|
|
|
export const getBaseOptions = (): 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> {
|
|
// In fetchCompat mode, skip baseOptions to avoid overriding Request object's method, headers,
|
|
const baseOptions = otherOptions.fetchCompat
|
|
? ({
|
|
mode: 'cors',
|
|
credentials: 'include', // always send cookies、HTTP Basic authentication.
|
|
redirect: 'follow',
|
|
} as const)
|
|
: ({
|
|
mode: 'cors',
|
|
credentials: 'include', // always send cookies、HTTP Basic authentication.
|
|
headers: new Headers({
|
|
'Content-Type': ContentType.json,
|
|
}),
|
|
method: 'GET',
|
|
redirect: 'follow',
|
|
} as const)
|
|
const { params, body, headers: headersFromProps, ...init } = { ...baseOptions, ...options }
|
|
|
|
const {
|
|
isPublicAPI = false,
|
|
isMarketplaceAPI = false,
|
|
bodyStringify = true,
|
|
needAllResponseContent,
|
|
deleteContentType,
|
|
getAbortController,
|
|
fetchCompat = false,
|
|
request,
|
|
} = otherOptions
|
|
|
|
const headers = new Headers(headersFromProps || {})
|
|
|
|
let base: string
|
|
if (isMarketplaceAPI) base = MARKETPLACE_API_PREFIX
|
|
else if (isPublicAPI) base = PUBLIC_API_PREFIX
|
|
else base = API_PREFIX
|
|
|
|
if (getAbortController) {
|
|
const abortController = new AbortController()
|
|
getAbortController(abortController)
|
|
options.signal = abortController.signal
|
|
}
|
|
|
|
const fetchPathname = base + (url.startsWith('/') ? url : `/${url}`)
|
|
if (!isMarketplaceAPI) headers.set(CSRF_HEADER_NAME, Cookies.get(CSRF_COOKIE_NAME()) || '')
|
|
|
|
if (deleteContentType) headers.delete('Content-Type')
|
|
|
|
// ! For Marketplace API, help to filter tags added in new version
|
|
if (isMarketplaceAPI) headers.set('X-Dify-Version', !IS_MARKETPLACE ? APP_VERSION : '999.0.0')
|
|
|
|
const client = baseClient.extend({
|
|
hooks: {
|
|
...baseHooks,
|
|
beforeRequest: [
|
|
...(baseHooks.beforeRequest || []),
|
|
isPublicAPI && beforeRequestPublicWithCode,
|
|
].filter((h): h is BeforeRequestHook => Boolean(h)),
|
|
afterResponse: [...(baseHooks.afterResponse || []), afterResponseErrorCode(otherOptions)],
|
|
},
|
|
})
|
|
|
|
let res: Response
|
|
try {
|
|
res = await client(request || fetchPathname, {
|
|
...init,
|
|
headers,
|
|
credentials: isMarketplaceAPI ? 'omit' : options.credentials || 'include',
|
|
retry: {
|
|
methods: [],
|
|
},
|
|
...(bodyStringify && !fetchCompat ? { json: body } : { body: body as BodyInit }),
|
|
searchParams: !fetchCompat ? params : undefined,
|
|
fetch(resource: RequestInfo | URL, options?: RequestInit) {
|
|
if (resource instanceof Request && options) {
|
|
const mergedHeaders = new Headers(options.headers || {})
|
|
resource.headers.forEach((value, key) => {
|
|
mergedHeaders.append(key, value)
|
|
})
|
|
options.headers = mergedHeaders
|
|
}
|
|
return globalThis.fetch(resource, options)
|
|
},
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof HTTPError) throw createResponseFromHTTPError(error)
|
|
throw error
|
|
}
|
|
|
|
if (needAllResponseContent || fetchCompat) return res as T
|
|
const contentType = res.headers.get('content-type')
|
|
if (
|
|
contentType &&
|
|
[ContentType.download, ContentType.audio, ContentType.downloadZip].includes(contentType)
|
|
) {
|
|
return (await res.blob()) as T
|
|
}
|
|
|
|
return (await res.json()) as T
|
|
}
|
|
|
|
/**
|
|
* Fire-and-forget POST with `keepalive: true` for use during page unload.
|
|
* Includes credentials, Authorization (if available), and CSRF header
|
|
* so the request is authenticated, matching the headers sent by the
|
|
* standard `base()` fetch wrapper.
|
|
*/
|
|
export function postWithKeepalive(url: string, body: Record<string, unknown>): void {
|
|
const headers = new Headers({
|
|
'Content-Type': ContentType.json,
|
|
[CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '',
|
|
})
|
|
|
|
// Add Authorization header if an access token is available
|
|
const accessToken = getWebAppAccessToken()
|
|
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
|
|
|
|
globalThis
|
|
.fetch(url, {
|
|
method: 'POST',
|
|
keepalive: true,
|
|
credentials: 'include',
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
|
|
export { base }
|