@cloudbase/app
Version:
cloudbase javascript sdk core
492 lines (431 loc) • 15 kB
text/typescript
/* eslint-disable no-nested-ternary */
import {
IFetchOptions,
IRequestConfig,
IRequestMethod,
IRequestOptions,
ResponseObject,
SDKRequestInterface,
formatUrl,
} from '@cloudbase/adapter-interface'
import { ADMIN_PATH, CLIENT_AUTH_PATH } from './constants'
import { getCloudbaseContext, getSecretInfo, INIT_CONFIG } from './context'
import type { ICustomReqOpts } from './types'
import {
getEnv,
isFormData,
toQueryString,
safeParseJson,
obj2StrRecord,
headersInit2Indexable,
getCurrRunEnvTag,
} from './utils'
import { ICloudbaseConfig } from '@cloudbase/types'
import { getSdkVersion } from '../../constants/common'
// 延迟加载,避免非 Node 环境打包时引入此包
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let signFn: ((...args: any[]) => any) | null = null
async function getSign() {
if (!signFn) {
try {
// @ts-ignore — 该包仅在 Node 运行时存在,开发环境可能未安装
const mod = await import('@cloudbase/signature-nodejs')
signFn = mod.sign
} catch (e) {
console.error('缺少依赖 @cloudbase/signature-nodejs,请执行以下命令安装:\n\n'
+ ' npm install @cloudbase/signature-nodejs\n\n'
+ '该依赖用于 Node 环境下的请求签名。')
}
}
return signFn!
}
/**
* Node.js 环境下的 HTTP 请求实现
* 基于原生 fetch,支持 GET/POST/PUT/上传/下载/流式请求
* 自动处理 V3 签名和 Bearer Token 认证
*/
export class NodeRequest implements SDKRequestInterface {
/** 请求配置,包含认证信息 */
config: IRequestConfig & ICloudbaseConfig
/** 请求超时时间(毫秒),默认 15000 */
private readonly timeout: number
/** 受超时控制的请求类型列表 */
private readonly restrictedMethods: Array<IRequestMethod>
constructor(config: IRequestConfig & ICloudbaseConfig) {
const { timeout, restrictedMethods } = config
this.timeout = timeout || 15000
this.restrictedMethods = restrictedMethods || ['get', 'post', 'upload', 'download']
this.config = config
}
/**
* 获取客户端凭证 access_token,为管理员权限
* 用于不支持 V3 签名的接口(如数据模型、关系型数据库、AI)
*/
async getClientCredential(opts: { origin: string }): Promise<any> {
const res = await this.fetch({
url: `${opts.origin}${CLIENT_AUTH_PATH}`,
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
grant_type: 'client_credentials',
}),
})
return res.data?.access_token
}
/**
* 获取实际请求 URL
* 当请求未携带 Authorization 且未自带 access_token 时,
* 将 /web 路径替换为 /admin(管理端签名路径)
*/
getRealUrl(url: string, headers: Record<string, any>, body: any) {
if (headers.Authorization?.replace('Bearer', '')?.trim()) {
return url
}
const params = typeof body === 'object' ? body : safeParseJson(body) || ''
if (params.access_token) {
return url
}
const urlObj = new URL(url)
if (urlObj.pathname === '/web' && this.config.auth?.secretId && this.config.auth?.secretKey) {
urlObj.pathname = ADMIN_PATH
}
return urlObj.toString()
}
/**
* 为请求添加签名或认证信息
*
* 处理逻辑优先级:
* 1. 已有 Authorization header → 直接返回
* 2. /auth/ 路径 → SDK 认证接口,无需签名
* 3. 不支持 V3 签名的路径(/v1/model/ 等)→ 用 clientCredential Bearer Token
* 4. 参数含 access_token → 无需签名
* 5. 其他 → 使用 V3 签名(TC3-HMAC-SHA256)
*/
public getReqOptions = async (
url: string,
options: {
method?: any
headers: any
body?: any
credentials?: string
signal?: AbortSignal
url?: any
},
) => {
const urlObj = new URL(url)
const { TCB_SOURCE } = getCloudbaseContext()
// Note: 云函数被调用时可能调用端未传递 SOURCE,TCB_SOURCE 可能为空
const SOURCE = `${INIT_CONFIG.context?.extendedContext?.source || TCB_SOURCE || ''},${await getCurrRunEnvTag()}`
options.headers = {
...options.headers,
'User-Agent': `adapter-node/${options.headers?.['X-SDK-Version'] || `@cloudbase/js-sdk/${getSdkVersion()}`}`,
'X-TCB-Source': SOURCE,
'X-Client-Timestamp': new Date().valueOf(),
'X-TCB-Region': INIT_CONFIG.region || getEnv('TENCENTCLOUD_REGION') || '',
Host: urlObj.host,
}
// 已携带有效 Authorization,直接返回
if (options.headers.Authorization?.replace('Bearer', '')?.trim()) {
return options
}
// auth 相关接口不需要签名
if (urlObj.pathname.startsWith('/auth/') && urlObj.pathname !== CLIENT_AUTH_PATH) {
return options
}
const isAdminPath = urlObj.pathname === ADMIN_PATH
// 这些路径不支持 V3 签名,改用 clientCredential 获取 Bearer Token
const UNSUPPORTED_V3_SIGN_PATH = ['/v1/model/', '/v1/rdb/', '/v1/ai/']
if (UNSUPPORTED_V3_SIGN_PATH.some(v => urlObj.pathname.startsWith(v))) {
const token = await this.getClientCredential({ origin: urlObj.origin })
options.headers.Authorization = `Bearer ${token}`
return options
}
let { secretId, secretKey, sessionToken } = this.config?.auth || {}
const { secretType } = this.config?.auth || {}
const { method, body } = options
const headers = JSON.parse(JSON.stringify(options.headers))
let params = typeof body === 'object' ? body : safeParseJson(body) || {}
params.envName = params?.env || this.config.env || getSecretInfo()?.env || ''
// 参数自带 access_token,无需签名
if (params.access_token) {
return options
}
// GET 请求参数已在 URL 中,签名时 params 置空
if (method.toLowerCase() === 'get') {
params = undefined
}
// SESSION_SECRET 类型或无密钥时,从环境变量获取临时密钥
if (!secretId || !secretKey || secretType === 'SESSION_SECRET') {
const secretInfo = getSecretInfo()
secretId = secretInfo.secretId
secretKey = secretInfo.secretKey
sessionToken = secretInfo.sessionToken
}
// 仍无密钥则跳过签名
if (!secretId || !secretKey) {
return options
}
delete headers.Authorization
// content-type 需与服务端签名验证保持大小写一致
if (headers['content-type']) {
headers['content-type'] = headers['content-type'].toLowerCase()
} else if (headers['Content-Type']) {
headers['Content-Type'] = headers['Content-Type'].toLowerCase()
}
if (isAdminPath && !!sessionToken) {
params.sessionToken = sessionToken
}
const signedParams = {
secretId,
secretKey,
method,
url,
params,
headers,
withSignedParams: isAdminPath,
timestamp: Math.floor(new Date().getTime() / 1000) - 1,
isCloudApi: !isAdminPath,
}
if (!headers['content-type'] && !headers['Content-Type']) {
return options
}
const sign = await getSign()
if (!sign) return options
const { authorization, timestamp } = sign(signedParams)
if (typeof sessionToken === 'string' && sessionToken !== '') {
if (isAdminPath) {
headers.Authorization = `${authorization}`
headers['X-Timestamp'] = timestamp
headers['X-Signature-Expires'] = 600
} else {
// 临时密钥需要额外附带 Token
headers.Authorization = `${authorization}, Timestamp=${timestamp}, Token=${sessionToken}`
}
} else {
headers.Authorization = `${authorization}, Timestamp=${timestamp}`
// admin 路径需要额外的时间戳和过期头
if (isAdminPath) {
headers['X-Timestamp'] = timestamp
headers['X-Signature-Expires'] = 600
}
}
return {
...options,
headers,
body: typeof params === 'object' ? JSON.stringify(params) : params,
}
}
public get = (options: IRequestOptions): Promise<ResponseObject> => this.request(
{
...options,
method: 'get',
},
this.restrictedMethods.includes('get'),
)
public post = (options: IRequestOptions): Promise<ResponseObject> => this.request(
{
...options,
method: 'post',
},
this.restrictedMethods.includes('post'),
)
public put = (options: IRequestOptions): Promise<ResponseObject> => this.request({
...options,
method: 'put',
})
/**
* 文件上传
* POST 方式:构建 FormData 上传
* PUT 方式:直接将文件作为 body 上传
*/
public upload = (options: IRequestOptions): Promise<ResponseObject> => {
const { data: _data, file, name, method, headers = {} } = options
if (file === undefined || name == undefined) {
throw new Error('file and name is required')
}
const data = obj2StrRecord(_data ?? {})
const loweredMethod = method?.toLowerCase()
const reqMethod = ['post', 'put'].find(m => m === loweredMethod) ?? 'put'
const formData = new FormData()
if (reqMethod === 'post') {
Object.keys(data).forEach((key) => {
formData.append(key, data[key])
})
formData.append('key', name)
formData.append('file', file)
return this.request(
{
...options,
data: formData,
method: reqMethod,
},
this.restrictedMethods.includes('upload'),
)
}
return this.request(
{
...options,
method: 'put',
headers,
body: file,
},
this.restrictedMethods.includes('upload'),
)
}
/** 文件下载(浏览器环境,通过创建 <a> 标签触发下载) */
public download = async (options: IRequestOptions): Promise<unknown> => {
const { data } = await this.get({
...options,
headers: {},
responseType: 'blob',
})
const url = window.URL.createObjectURL(new Blob([data]))
const fileName = decodeURIComponent(new URL(options?.url ?? '').pathname.split('/').pop() || '')
const link = document.createElement('a')
link.href = url
link.setAttribute('download', fileName)
link.style.display = 'none'
document.body.appendChild(link)
link.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(link)
return new Promise((resolve) => {
resolve({
statusCode: 200,
tempFilePath: options.url,
})
})
}
/**
* 底层 fetch 请求,支持流式响应
* 超时通过 AbortController + setTimeout 实现
*/
public fetch = async (options: Omit<IFetchOptions, 'signal'> & { signal?: AbortSignal; customReqOpts?: ICustomReqOpts }): Promise<ResponseObject> => {
const { enableAbort = false, stream = false, signal, customReqOpts } = options
const url = this.getRealUrl(options.url, options.headers || {}, options.body)
const abortController = new AbortController()
const timeout = customReqOpts?.timeout || this.timeout
// 桥接外部 signal 到内部 AbortController
if (signal) {
if (signal.aborted) {
abortController.abort()
} else {
signal.addEventListener('abort', () => abortController.abort())
}
}
// 超时自动中断
let timer = undefined
if (enableAbort || timeout) {
timer = setTimeout(() => {
const timeoutMsg = `请求在${timeout / 1000}s内未完成,已中断`
console.warn(timeoutMsg)
abortController.abort(new Error(timeoutMsg))
}, timeout)
}
const headers = options.headers ? headersInit2Indexable(options.headers) : undefined
const fetchOptions = await this.getReqOptions(url, {
...options,
headers,
body: options.body as any as NodeJS.ReadableStream,
signal: abortController.signal,
})
const res = await fetch(url, fetchOptions as RequestInit)
.then((x) => {
clearTimeout(timer)
return x
})
.catch((x) => {
clearTimeout(timer)
return Promise.reject(x)
})
const ret = {
data:
+res.status === 204
? '' // 204 No Content
: stream
? res.body
: await res.json(),
statusCode: res.status,
header: res.headers,
}
return ret
}
/**
* 通用请求方法,被 get/post/put/upload 等调用
* 负责构建 payload、设置超时、解析响应
*
* @param options - 请求选项
* @param enableAbort - 是否启用超时中断(由 restrictedMethods 决定)
*/
public request = async (options: IRequestOptions, enableAbort = false): Promise<ResponseObject> => {
const {
url,
headers: _headers = {},
data,
responseType,
withCredentials,
body,
method: _method,
customReqOpts,
} = options
const headers = obj2StrRecord(_headers)
const method = String(_method).toLowerCase() || 'get'
const abortController = new AbortController()
const { signal } = abortController
const timeout = customReqOpts?.timeout || this.timeout
// 构建请求 payload:FormData > urlencoded > raw body > JSON
let payload
if (isFormData(data)) {
payload = data
} else if (headers['content-type'] === 'application/x-www-form-urlencoded') {
payload = toQueryString(data ?? {})
} else if (body) {
payload = body
} else {
payload = data ? JSON.stringify(data) : undefined
}
const realUrl = this.getRealUrl(
formatUrl('https', url ?? '', method === 'get' ? data : {}),
options.headers || {},
payload,
)
// 超时通过 AbortController + setTimeout 实现
let timer
if (enableAbort || timeout) {
timer = setTimeout(() => {
const timeoutMsg = `请求在${timeout / 1000}s内未完成,已中断`
console.warn(timeoutMsg)
abortController.abort(new Error(timeoutMsg))
}, timeout)
}
const requestOptions = await this.getReqOptions(realUrl, {
method,
headers,
body: payload,
credentials: withCredentials ? 'include' : 'same-origin',
signal,
})
try {
const response = await fetch(realUrl, requestOptions as RequestInit)
const result: ResponseObject = {
header: {},
statusCode: response.status,
}
try {
result.data = responseType === 'blob' ? await response.blob() : safeParseJson(await response.text())
} catch (e) {
// 上传 POST 请求可能返回 XML 等非 JSON 格式,容错处理
console.log('catch an error', e)
result.data = responseType === 'blob' ? await response.blob() : await response.text()
}
// 将响应头统一转为小写 key
const { headers } = response
headers.forEach((val, key) => (result.header[key.toLowerCase()] = val))
return result
} finally {
clearTimeout(timer)
}
}
}