@cloudbase/app 2.26.1 → 2.26.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.js +43 -7
- package/dist/cjs/index.node.d.ts +6 -0
- package/dist/cjs/index.node.js +20 -0
- package/dist/cjs/libs/adapter-node/constants.d.ts +33 -0
- package/dist/cjs/libs/adapter-node/constants.js +38 -0
- package/dist/cjs/libs/adapter-node/context.d.ts +13 -0
- package/dist/cjs/libs/adapter-node/context.js +162 -0
- package/dist/cjs/libs/adapter-node/index.d.ts +23 -0
- package/dist/cjs/libs/adapter-node/index.js +124 -0
- package/dist/cjs/libs/adapter-node/metadata.d.ts +14 -0
- package/dist/cjs/libs/adapter-node/metadata.js +130 -0
- package/dist/cjs/libs/adapter-node/request.d.ts +38 -0
- package/dist/cjs/libs/adapter-node/request.js +441 -0
- package/dist/cjs/libs/adapter-node/tool.d.ts +2 -0
- package/dist/cjs/libs/adapter-node/tool.js +228 -0
- package/dist/cjs/libs/adapter-node/types.d.ts +78 -0
- package/dist/cjs/libs/adapter-node/types.js +3 -0
- package/dist/cjs/libs/adapter-node/utils.d.ts +17 -0
- package/dist/cjs/libs/adapter-node/utils.js +221 -0
- package/dist/cjs/libs/request.d.ts +1 -0
- package/dist/cjs/libs/request.js +34 -19
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +43 -7
- package/dist/esm/index.node.d.ts +6 -0
- package/dist/esm/index.node.js +12 -0
- package/dist/esm/libs/adapter-node/constants.d.ts +33 -0
- package/dist/esm/libs/adapter-node/constants.js +35 -0
- package/dist/esm/libs/adapter-node/context.d.ts +13 -0
- package/dist/esm/libs/adapter-node/context.js +156 -0
- package/dist/esm/libs/adapter-node/index.d.ts +23 -0
- package/dist/esm/libs/adapter-node/index.js +118 -0
- package/dist/esm/libs/adapter-node/metadata.d.ts +14 -0
- package/dist/esm/libs/adapter-node/metadata.js +123 -0
- package/dist/esm/libs/adapter-node/request.d.ts +38 -0
- package/dist/esm/libs/adapter-node/request.js +438 -0
- package/dist/esm/libs/adapter-node/tool.d.ts +2 -0
- package/dist/esm/libs/adapter-node/tool.js +224 -0
- package/dist/esm/libs/adapter-node/types.d.ts +78 -0
- package/dist/esm/libs/adapter-node/types.js +2 -0
- package/dist/esm/libs/adapter-node/utils.d.ts +17 -0
- package/dist/esm/libs/adapter-node/utils.js +205 -0
- package/dist/esm/libs/request.d.ts +1 -0
- package/dist/esm/libs/request.js +34 -19
- package/dist/miniprogram/index.js +1 -1
- package/package.json +32 -4
- package/src/index.node.ts +22 -0
- package/src/index.ts +91 -7
- package/src/libs/adapter-node/constants.ts +42 -0
- package/src/libs/adapter-node/context.ts +238 -0
- package/src/libs/adapter-node/index.ts +166 -0
- package/src/libs/adapter-node/metadata.ts +69 -0
- package/src/libs/adapter-node/request.ts +486 -0
- package/src/libs/adapter-node/tool.ts +223 -0
- package/src/libs/adapter-node/types.ts +116 -0
- package/src/libs/adapter-node/utils.ts +182 -0
- package/src/libs/request.ts +125 -113
- package/webpack/web.prod.js +14 -13
- package/webpack/webpack.miniprogram.js +10 -3
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/naming-convention */
|
|
2
|
+
// 注意:改地址已经不是一定可以访问的了
|
|
3
|
+
export const kMetadataBaseUrl = 'http://metadata.tencentyun.com'
|
|
4
|
+
|
|
5
|
+
export const kAppIdPath = 'meta-data/app-id'
|
|
6
|
+
export const kSecurityCredentialsPath = 'meta-data/cam/security-credentials'
|
|
7
|
+
|
|
8
|
+
export enum kMetadataVersions {
|
|
9
|
+
'v20170919' = '2017-09-19',
|
|
10
|
+
'v1.0' = '1.0',
|
|
11
|
+
'latest' = 'latest',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isAppId(appIdStr: string) {
|
|
15
|
+
return /^[1-9][0-9]{4,64}$/gim.test(appIdStr)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function lookup(path: string, options: { timeout?: number } = {}): Promise<string> {
|
|
19
|
+
const url = `${kMetadataBaseUrl}/${kMetadataVersions.latest}/${path}`
|
|
20
|
+
|
|
21
|
+
const controller = new AbortController()
|
|
22
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
23
|
+
if (options.timeout) {
|
|
24
|
+
timer = setTimeout(() => controller.abort(), options.timeout)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const resp = await fetch(url, { signal: controller.signal })
|
|
29
|
+
if (resp.status === 200) {
|
|
30
|
+
return await resp.text()
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`[ERROR] GET ${url} status: ${resp.status}`)
|
|
33
|
+
} finally {
|
|
34
|
+
if (timer) clearTimeout(timer)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const metadataCache: { appId: string | undefined } = {
|
|
39
|
+
appId: undefined,
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* lookupAppId - 该方法主要用于判断是否在云上环境
|
|
43
|
+
* @returns
|
|
44
|
+
*/
|
|
45
|
+
export async function lookupAppId(): Promise<string> {
|
|
46
|
+
if (metadataCache.appId === undefined) {
|
|
47
|
+
try {
|
|
48
|
+
// 只有首次会请求且要求快速返回,超时时间很短,DNS无法解析将会超时返回
|
|
49
|
+
// 在云环境中,这个时间通常在 10ms 内,部分耗时长(30+ms)的情况是 DNS 解析耗时长(27+ms)
|
|
50
|
+
const appId = await lookup(kAppIdPath, { timeout: 30 })
|
|
51
|
+
if (isAppId(appId)) {
|
|
52
|
+
metadataCache.appId = appId
|
|
53
|
+
} else {
|
|
54
|
+
metadataCache.appId = ''
|
|
55
|
+
}
|
|
56
|
+
} catch (_e) {
|
|
57
|
+
// ignore
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return metadataCache.appId || ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function lookupCredentials(ruleName: string): Promise<string> {
|
|
64
|
+
// `${kMetadataBaseUrl}/meta-data/cam/security-credentials/TCB_QcsRole`
|
|
65
|
+
// 这里设置了一个较短的超时时间,因为这个请求是在云环境中发起的,通常会很快返回
|
|
66
|
+
return await lookup(`${kSecurityCredentialsPath}/${ruleName}`, {
|
|
67
|
+
timeout: 200,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/* eslint-disable no-nested-ternary */
|
|
2
|
+
import {
|
|
3
|
+
IFetchOptions,
|
|
4
|
+
IRequestConfig,
|
|
5
|
+
IRequestMethod,
|
|
6
|
+
IRequestOptions,
|
|
7
|
+
ResponseObject,
|
|
8
|
+
SDKRequestInterface,
|
|
9
|
+
formatUrl,
|
|
10
|
+
} from '@cloudbase/adapter-interface'
|
|
11
|
+
import { ADMIN_PATH, CLIENT_AUTH_PATH } from './constants'
|
|
12
|
+
import { getCloudbaseContext, getSecretInfo, INIT_CONFIG } from './context'
|
|
13
|
+
import type { ICustomReqOpts } from './types'
|
|
14
|
+
import {
|
|
15
|
+
getEnv,
|
|
16
|
+
isFormData,
|
|
17
|
+
toQueryString,
|
|
18
|
+
safeParseJson,
|
|
19
|
+
obj2StrRecord,
|
|
20
|
+
headersInit2Indexable,
|
|
21
|
+
getCurrRunEnvTag,
|
|
22
|
+
} from './utils'
|
|
23
|
+
import { ICloudbaseConfig } from '@cloudbase/types'
|
|
24
|
+
import { getSdkVersion } from '../../constants/common'
|
|
25
|
+
// 延迟加载,避免非 Node 环境打包时引入此包
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
let signFn: ((...args: any[]) => any) | null = null
|
|
28
|
+
async function getSign() {
|
|
29
|
+
if (!signFn) {
|
|
30
|
+
try {
|
|
31
|
+
// @ts-ignore — 该包仅在 Node 运行时存在,开发环境可能未安装
|
|
32
|
+
const mod = await import('@cloudbase/signature-nodejs')
|
|
33
|
+
signFn = mod.sign
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.error('缺少依赖 @cloudbase/signature-nodejs,请执行以下命令安装:\n\n'
|
|
36
|
+
+ ' npm install @cloudbase/signature-nodejs\n\n'
|
|
37
|
+
+ '该依赖用于 Node 环境下的请求签名。')
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return signFn!
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Node.js 环境下的 HTTP 请求实现
|
|
45
|
+
* 基于原生 fetch,支持 GET/POST/PUT/上传/下载/流式请求
|
|
46
|
+
* 自动处理 V3 签名和 Bearer Token 认证
|
|
47
|
+
*/
|
|
48
|
+
export class NodeRequest implements SDKRequestInterface {
|
|
49
|
+
/** 请求配置,包含认证信息 */
|
|
50
|
+
config: IRequestConfig & ICloudbaseConfig
|
|
51
|
+
|
|
52
|
+
/** 请求超时时间(毫秒),默认 15000 */
|
|
53
|
+
private readonly timeout: number
|
|
54
|
+
|
|
55
|
+
/** 受超时控制的请求类型列表 */
|
|
56
|
+
private readonly restrictedMethods: Array<IRequestMethod>
|
|
57
|
+
|
|
58
|
+
constructor(config: IRequestConfig & ICloudbaseConfig) {
|
|
59
|
+
const { timeout, restrictedMethods } = config
|
|
60
|
+
this.timeout = timeout || 15000
|
|
61
|
+
this.restrictedMethods = restrictedMethods || ['get', 'post', 'upload', 'download']
|
|
62
|
+
this.config = config
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 获取客户端凭证 access_token,为管理员权限
|
|
67
|
+
* 用于不支持 V3 签名的接口(如数据模型、关系型数据库、AI)
|
|
68
|
+
*/
|
|
69
|
+
async getClientCredential(opts: { origin: string }): Promise<any> {
|
|
70
|
+
const res = await this.fetch({
|
|
71
|
+
url: `${opts.origin}${CLIENT_AUTH_PATH}`,
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'content-type': 'application/json',
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
grant_type: 'client_credentials',
|
|
78
|
+
}),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
return res.data?.access_token
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 获取实际请求 URL
|
|
86
|
+
* 当请求未携带 Authorization 且未自带 access_token 时,
|
|
87
|
+
* 将 /web 路径替换为 /admin(管理端签名路径)
|
|
88
|
+
*/
|
|
89
|
+
getRealUrl(url: string, headers: Record<string, any>, body: any) {
|
|
90
|
+
if (headers.Authorization?.replace('Bearer', '')?.trim()) {
|
|
91
|
+
return url
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const params = typeof body === 'object' ? body : safeParseJson(body) || ''
|
|
95
|
+
|
|
96
|
+
if (params.access_token) {
|
|
97
|
+
return url
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const urlObj = new URL(url)
|
|
101
|
+
|
|
102
|
+
if (urlObj.pathname === '/web' && this.config.auth?.secretId && this.config.auth?.secretKey) {
|
|
103
|
+
urlObj.pathname = ADMIN_PATH
|
|
104
|
+
}
|
|
105
|
+
return urlObj.toString()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 为请求添加签名或认证信息
|
|
110
|
+
*
|
|
111
|
+
* 处理逻辑优先级:
|
|
112
|
+
* 1. 已有 Authorization header → 直接返回
|
|
113
|
+
* 2. /auth/ 路径 → SDK 认证接口,无需签名
|
|
114
|
+
* 3. 不支持 V3 签名的路径(/v1/model/ 等)→ 用 clientCredential Bearer Token
|
|
115
|
+
* 4. 参数含 access_token → 无需签名
|
|
116
|
+
* 5. 其他 → 使用 V3 签名(TC3-HMAC-SHA256)
|
|
117
|
+
*/
|
|
118
|
+
public getReqOptions = async (
|
|
119
|
+
url: string,
|
|
120
|
+
options: {
|
|
121
|
+
method?: any
|
|
122
|
+
headers: any
|
|
123
|
+
body?: any
|
|
124
|
+
credentials?: string
|
|
125
|
+
signal?: AbortSignal
|
|
126
|
+
url?: any
|
|
127
|
+
},
|
|
128
|
+
) => {
|
|
129
|
+
const urlObj = new URL(url)
|
|
130
|
+
|
|
131
|
+
const { TCB_SOURCE } = getCloudbaseContext()
|
|
132
|
+
// Note: 云函数被调用时可能调用端未传递 SOURCE,TCB_SOURCE 可能为空
|
|
133
|
+
const SOURCE = `${INIT_CONFIG.context?.extendedContext?.source || TCB_SOURCE || ''},${await getCurrRunEnvTag()}`
|
|
134
|
+
|
|
135
|
+
options.headers = {
|
|
136
|
+
...options.headers,
|
|
137
|
+
'User-Agent': `adapter-node/${options.headers?.['X-SDK-Version'] || `@cloudbase/js-sdk/${getSdkVersion()}`}`,
|
|
138
|
+
'X-TCB-Source': SOURCE,
|
|
139
|
+
'X-Client-Timestamp': new Date().valueOf(),
|
|
140
|
+
'X-TCB-Region': INIT_CONFIG.region || getEnv('TENCENTCLOUD_REGION') || '',
|
|
141
|
+
Host: urlObj.host,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 已携带有效 Authorization,直接返回
|
|
145
|
+
if (options.headers.Authorization?.replace('Bearer', '')?.trim()) {
|
|
146
|
+
return options
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// auth 相关接口不需要签名
|
|
150
|
+
if (urlObj.pathname.startsWith('/auth/') && urlObj.pathname !== CLIENT_AUTH_PATH) {
|
|
151
|
+
return options
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const isAdminPath = urlObj.pathname === ADMIN_PATH
|
|
155
|
+
|
|
156
|
+
// 这些路径不支持 V3 签名,改用 clientCredential 获取 Bearer Token
|
|
157
|
+
const UNSUPPORTED_V3_SIGN_PATH = ['/v1/model/', '/v1/rdb/', '/v1/ai/']
|
|
158
|
+
|
|
159
|
+
if (UNSUPPORTED_V3_SIGN_PATH.some(v => urlObj.pathname.startsWith(v))) {
|
|
160
|
+
const token = await this.getClientCredential({ origin: urlObj.origin })
|
|
161
|
+
options.headers.Authorization = `Bearer ${token}`
|
|
162
|
+
return options
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let { secretId, secretKey, sessionToken } = this.config?.auth || {}
|
|
166
|
+
const { secretType } = this.config?.auth || {}
|
|
167
|
+
const { method, body } = options
|
|
168
|
+
const headers = JSON.parse(JSON.stringify(options.headers))
|
|
169
|
+
let params = typeof body === 'object' ? body : safeParseJson(body) || ''
|
|
170
|
+
|
|
171
|
+
// 参数自带 access_token,无需签名
|
|
172
|
+
if (params.access_token) {
|
|
173
|
+
return options
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// GET 请求参数已在 URL 中,签名时 params 置空
|
|
177
|
+
if (method.toLowerCase() === 'get') {
|
|
178
|
+
params = undefined
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// SESSION_SECRET 类型或无密钥时,从环境变量获取临时密钥
|
|
182
|
+
if (!secretId || !secretKey || secretType === 'SESSION_SECRET') {
|
|
183
|
+
const secretInfo = getSecretInfo()
|
|
184
|
+
secretId = secretInfo.secretId
|
|
185
|
+
secretKey = secretInfo.secretKey
|
|
186
|
+
sessionToken = secretInfo.sessionToken
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 仍无密钥则跳过签名
|
|
190
|
+
if (!secretId || !secretKey) {
|
|
191
|
+
return options
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
delete headers.Authorization
|
|
195
|
+
|
|
196
|
+
// content-type 需与服务端签名验证保持大小写一致
|
|
197
|
+
if (headers['content-type']) {
|
|
198
|
+
headers['content-type'] = headers['content-type'].toLowerCase()
|
|
199
|
+
} else if (headers['Content-Type']) {
|
|
200
|
+
headers['Content-Type'] = headers['Content-Type'].toLowerCase()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (isAdminPath && !!sessionToken) {
|
|
204
|
+
params.sessionToken = sessionToken
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const signedParams = {
|
|
208
|
+
secretId,
|
|
209
|
+
secretKey,
|
|
210
|
+
method,
|
|
211
|
+
url,
|
|
212
|
+
params,
|
|
213
|
+
headers,
|
|
214
|
+
withSignedParams: isAdminPath,
|
|
215
|
+
timestamp: Math.floor(new Date().getTime() / 1000) - 1,
|
|
216
|
+
isCloudApi: !isAdminPath,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
const sign = await getSign()
|
|
221
|
+
if (!sign) return options
|
|
222
|
+
|
|
223
|
+
const { authorization, timestamp } = sign(signedParams)
|
|
224
|
+
|
|
225
|
+
if (typeof sessionToken === 'string' && sessionToken !== '') {
|
|
226
|
+
if (isAdminPath) {
|
|
227
|
+
headers.Authorization = `${authorization}`
|
|
228
|
+
headers['X-Timestamp'] = timestamp
|
|
229
|
+
headers['X-Signature-Expires'] = 600
|
|
230
|
+
} else {
|
|
231
|
+
// 临时密钥需要额外附带 Token
|
|
232
|
+
headers.Authorization = `${authorization}, Timestamp=${timestamp}, Token=${sessionToken}`
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
headers.Authorization = `${authorization}, Timestamp=${timestamp}`
|
|
236
|
+
|
|
237
|
+
// admin 路径需要额外的时间戳和过期头
|
|
238
|
+
if (isAdminPath) {
|
|
239
|
+
headers['X-Timestamp'] = timestamp
|
|
240
|
+
headers['X-Signature-Expires'] = 600
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
...options,
|
|
246
|
+
headers,
|
|
247
|
+
body: typeof params === 'object' ? JSON.stringify(params) : params,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
public get = (options: IRequestOptions): Promise<ResponseObject> => this.request(
|
|
252
|
+
{
|
|
253
|
+
...options,
|
|
254
|
+
method: 'get',
|
|
255
|
+
},
|
|
256
|
+
this.restrictedMethods.includes('get'),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
public post = (options: IRequestOptions): Promise<ResponseObject> => this.request(
|
|
260
|
+
{
|
|
261
|
+
...options,
|
|
262
|
+
method: 'post',
|
|
263
|
+
},
|
|
264
|
+
this.restrictedMethods.includes('post'),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
public put = (options: IRequestOptions): Promise<ResponseObject> => this.request({
|
|
268
|
+
...options,
|
|
269
|
+
method: 'put',
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 文件上传
|
|
274
|
+
* POST 方式:构建 FormData 上传
|
|
275
|
+
* PUT 方式:直接将文件作为 body 上传
|
|
276
|
+
*/
|
|
277
|
+
public upload = (options: IRequestOptions): Promise<ResponseObject> => {
|
|
278
|
+
const { data: _data, file, name, method, headers = {} } = options
|
|
279
|
+
if (file === undefined || name == undefined) {
|
|
280
|
+
throw new Error('file and name is required')
|
|
281
|
+
}
|
|
282
|
+
const data = obj2StrRecord(_data ?? {})
|
|
283
|
+
const loweredMethod = method?.toLowerCase()
|
|
284
|
+
const reqMethod = ['post', 'put'].find(m => m === loweredMethod) ?? 'put'
|
|
285
|
+
const formData = new FormData()
|
|
286
|
+
if (reqMethod === 'post') {
|
|
287
|
+
Object.keys(data).forEach((key) => {
|
|
288
|
+
formData.append(key, data[key])
|
|
289
|
+
})
|
|
290
|
+
formData.append('key', name)
|
|
291
|
+
formData.append('file', file)
|
|
292
|
+
return this.request(
|
|
293
|
+
{
|
|
294
|
+
...options,
|
|
295
|
+
data: formData,
|
|
296
|
+
method: reqMethod,
|
|
297
|
+
},
|
|
298
|
+
this.restrictedMethods.includes('upload'),
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return this.request(
|
|
303
|
+
{
|
|
304
|
+
...options,
|
|
305
|
+
method: 'put',
|
|
306
|
+
headers,
|
|
307
|
+
body: file,
|
|
308
|
+
},
|
|
309
|
+
this.restrictedMethods.includes('upload'),
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 文件下载(浏览器环境,通过创建 <a> 标签触发下载) */
|
|
314
|
+
public download = async (options: IRequestOptions): Promise<unknown> => {
|
|
315
|
+
const { data } = await this.get({
|
|
316
|
+
...options,
|
|
317
|
+
headers: {},
|
|
318
|
+
responseType: 'blob',
|
|
319
|
+
})
|
|
320
|
+
const url = window.URL.createObjectURL(new Blob([data]))
|
|
321
|
+
const fileName = decodeURIComponent(new URL(options?.url ?? '').pathname.split('/').pop() || '')
|
|
322
|
+
const link = document.createElement('a')
|
|
323
|
+
|
|
324
|
+
link.href = url
|
|
325
|
+
link.setAttribute('download', fileName)
|
|
326
|
+
link.style.display = 'none'
|
|
327
|
+
|
|
328
|
+
document.body.appendChild(link)
|
|
329
|
+
link.click()
|
|
330
|
+
window.URL.revokeObjectURL(url)
|
|
331
|
+
document.body.removeChild(link)
|
|
332
|
+
return new Promise((resolve) => {
|
|
333
|
+
resolve({
|
|
334
|
+
statusCode: 200,
|
|
335
|
+
tempFilePath: options.url,
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 底层 fetch 请求,支持流式响应
|
|
342
|
+
* 超时通过 AbortController + setTimeout 实现
|
|
343
|
+
*/
|
|
344
|
+
public fetch = async (options: Omit<IFetchOptions, 'signal'> & { signal?: AbortSignal; customReqOpts?: ICustomReqOpts }): Promise<ResponseObject> => {
|
|
345
|
+
const { enableAbort = false, stream = false, signal, customReqOpts } = options
|
|
346
|
+
const url = this.getRealUrl(options.url, options.headers || {}, options.body)
|
|
347
|
+
const abortController = new AbortController()
|
|
348
|
+
const timeout = customReqOpts?.timeout || this.timeout
|
|
349
|
+
|
|
350
|
+
// 桥接外部 signal 到内部 AbortController
|
|
351
|
+
if (signal) {
|
|
352
|
+
if (signal.aborted) {
|
|
353
|
+
abortController.abort()
|
|
354
|
+
} else {
|
|
355
|
+
signal.addEventListener('abort', () => abortController.abort())
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 超时自动中断
|
|
360
|
+
let timer = undefined
|
|
361
|
+
if (enableAbort || timeout) {
|
|
362
|
+
timer = setTimeout(() => {
|
|
363
|
+
const timeoutMsg = `请求在${timeout / 1000}s内未完成,已中断`
|
|
364
|
+
console.warn(timeoutMsg)
|
|
365
|
+
abortController.abort(new Error(timeoutMsg))
|
|
366
|
+
}, timeout)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const headers = options.headers ? headersInit2Indexable(options.headers) : undefined
|
|
370
|
+
|
|
371
|
+
const fetchOptions = await this.getReqOptions(url, {
|
|
372
|
+
...options,
|
|
373
|
+
headers,
|
|
374
|
+
body: options.body as any as NodeJS.ReadableStream,
|
|
375
|
+
signal: abortController.signal,
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
const res = await fetch(url, fetchOptions as RequestInit)
|
|
379
|
+
.then((x) => {
|
|
380
|
+
clearTimeout(timer)
|
|
381
|
+
return x
|
|
382
|
+
})
|
|
383
|
+
.catch((x) => {
|
|
384
|
+
clearTimeout(timer)
|
|
385
|
+
return Promise.reject(x)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
const ret = {
|
|
389
|
+
data:
|
|
390
|
+
+res.status === 204
|
|
391
|
+
? '' // 204 No Content
|
|
392
|
+
: stream
|
|
393
|
+
? res.body
|
|
394
|
+
: await res.json(),
|
|
395
|
+
statusCode: res.status,
|
|
396
|
+
header: res.headers,
|
|
397
|
+
}
|
|
398
|
+
return ret
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 通用请求方法,被 get/post/put/upload 等调用
|
|
403
|
+
* 负责构建 payload、设置超时、解析响应
|
|
404
|
+
*
|
|
405
|
+
* @param options - 请求选项
|
|
406
|
+
* @param enableAbort - 是否启用超时中断(由 restrictedMethods 决定)
|
|
407
|
+
*/
|
|
408
|
+
public request = async (options: IRequestOptions, enableAbort = false): Promise<ResponseObject> => {
|
|
409
|
+
const {
|
|
410
|
+
url,
|
|
411
|
+
headers: _headers = {},
|
|
412
|
+
data,
|
|
413
|
+
responseType,
|
|
414
|
+
withCredentials,
|
|
415
|
+
body,
|
|
416
|
+
method: _method,
|
|
417
|
+
customReqOpts,
|
|
418
|
+
} = options
|
|
419
|
+
const headers = obj2StrRecord(_headers)
|
|
420
|
+
const method = String(_method).toLowerCase() || 'get'
|
|
421
|
+
const abortController = new AbortController()
|
|
422
|
+
const { signal } = abortController
|
|
423
|
+
const timeout = customReqOpts?.timeout || this.timeout
|
|
424
|
+
|
|
425
|
+
// 构建请求 payload:FormData > urlencoded > raw body > JSON
|
|
426
|
+
let payload
|
|
427
|
+
if (isFormData(data)) {
|
|
428
|
+
payload = data
|
|
429
|
+
} else if (headers['content-type'] === 'application/x-www-form-urlencoded') {
|
|
430
|
+
payload = toQueryString(data ?? {})
|
|
431
|
+
} else if (body) {
|
|
432
|
+
payload = body
|
|
433
|
+
} else {
|
|
434
|
+
payload = data ? JSON.stringify(data) : undefined
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const realUrl = this.getRealUrl(
|
|
438
|
+
formatUrl('https', url ?? '', method === 'get' ? data : {}),
|
|
439
|
+
options.headers || {},
|
|
440
|
+
payload,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
// 超时通过 AbortController + setTimeout 实现
|
|
444
|
+
let timer
|
|
445
|
+
|
|
446
|
+
if (enableAbort || timeout) {
|
|
447
|
+
timer = setTimeout(() => {
|
|
448
|
+
const timeoutMsg = `请求在${timeout / 1000}s内未完成,已中断`
|
|
449
|
+
console.warn(timeoutMsg)
|
|
450
|
+
abortController.abort(new Error(timeoutMsg))
|
|
451
|
+
}, timeout)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const requestOptions = await this.getReqOptions(realUrl, {
|
|
455
|
+
method,
|
|
456
|
+
headers,
|
|
457
|
+
body: payload,
|
|
458
|
+
credentials: withCredentials ? 'include' : 'same-origin',
|
|
459
|
+
signal,
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
const response = await fetch(realUrl, requestOptions as RequestInit)
|
|
464
|
+
const result: ResponseObject = {
|
|
465
|
+
header: {},
|
|
466
|
+
statusCode: response.status,
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
result.data = responseType === 'blob' ? await response.blob() : safeParseJson(await response.text())
|
|
471
|
+
} catch (e) {
|
|
472
|
+
// 上传 POST 请求可能返回 XML 等非 JSON 格式,容错处理
|
|
473
|
+
console.log('catch an error', e)
|
|
474
|
+
result.data = responseType === 'blob' ? await response.blob() : await response.text()
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 将响应头统一转为小写 key
|
|
478
|
+
const { headers } = response
|
|
479
|
+
headers.forEach((val, key) => (result.header[key.toLowerCase()] = val))
|
|
480
|
+
|
|
481
|
+
return result
|
|
482
|
+
} finally {
|
|
483
|
+
clearTimeout(timer)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|