/* 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 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 { 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, 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 => this.request( { ...options, method: 'get', }, this.restrictedMethods.includes('get'), ) public post = (options: IRequestOptions): Promise => this.request( { ...options, method: 'post', }, this.restrictedMethods.includes('post'), ) public put = (options: IRequestOptions): Promise => this.request({ ...options, method: 'put', }) /** * 文件上传 * POST 方式:构建 FormData 上传 * PUT 方式:直接将文件作为 body 上传 */ public upload = (options: IRequestOptions): Promise => { 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'), ) } /** 文件下载(浏览器环境,通过创建 标签触发下载) */ public download = async (options: IRequestOptions): Promise => { 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 & { signal?: AbortSignal; customReqOpts?: ICustomReqOpts }): Promise => { 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 => { 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) } } }