- 移除 bd/mg 两平台及磁盘残留产物;清理 api-test 死脚本、musicSdk/message+options 死模块、infSign vendor - musicSdk/ 改名 sdk/;src/common、src/auth、src/apis 整体移入 sdk/,依赖方向定为 src → sdk 严格单向 - src/ 手写 JS 全量转 .ts,删除 JS 类型垫片 modules.d.ts;源码 import 统一 .ts 后缀 - Node 原生类型剥离直接运行 .ts(engines >=22.18),取消 tsc 发射产物,tsc 仅类型检查(pnpm typecheck) - 删除 kwdes 备用实现与 src/index.ts 转发垫片;测试移至 test/smoke-test.ts - common/request.ts 补显式导出类型;修复 set-cookie 单值形态、ApiError 类字段等隐患 验证:tsc 零错误,smoke-test 41/41
205 lines
6.0 KiB
TypeScript
205 lines
6.0 KiB
TypeScript
// 网络请求模块
|
||
// 接口与原版保持一致:
|
||
// httpFetch(url, { method, headers, body, form, timeout, lookup, family })
|
||
// → { promise, cancelHttp }
|
||
// promise resolve → { statusCode, body, headers, raw }
|
||
// - body:JSON 已解析对象,或原始字符串(HTML/文本)
|
||
// - raw:响应体 Buffer
|
||
// promise reject → 错误(timeout 请求超时 / canceled 主动取消)
|
||
// 注意:promise 与 cancelHttp 均为普通可赋值属性(SDK 多处会重写 promise)
|
||
|
||
import http from 'node:http'
|
||
import https from 'node:https'
|
||
import zlib from 'node:zlib'
|
||
import { URL } from 'node:url'
|
||
import { requestMsg } from './message.ts'
|
||
|
||
const MAX_BUFFER_SIZE = 64 * 1024 * 1024 // 64MB
|
||
|
||
export interface HttpOptions {
|
||
method?: string
|
||
headers?: Record<string, any>
|
||
body?: any
|
||
form?: Record<string, any>
|
||
timeout?: number
|
||
lookup?: any
|
||
family?: number
|
||
[key: string]: any
|
||
}
|
||
|
||
export interface HttpFetchResponse {
|
||
statusCode: number
|
||
statusMessage?: string
|
||
headers: Record<string, any>
|
||
body: any
|
||
raw: Buffer
|
||
}
|
||
|
||
export interface HttpRequestObject {
|
||
promise: Promise<HttpFetchResponse>
|
||
cancelHttp(): void
|
||
isCancelled: boolean
|
||
requestObj?: any
|
||
cancelFn?: ((err: any) => void) | null
|
||
[key: string]: any
|
||
}
|
||
|
||
const defaultHeaders = {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36',
|
||
}
|
||
|
||
const parseBody = (raw: Buffer): any => {
|
||
if (!raw || !raw.length) return null
|
||
const text = raw.toString('utf8')
|
||
// 与原版一致:无论 content-type 都尝试 JSON 解析,失败则返回原始文本
|
||
try {
|
||
return JSON.parse(text)
|
||
} catch {
|
||
return text
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发起请求(返回带 promise 与 cancelHttp 的对象)
|
||
*/
|
||
const buildHttpPromise = (url: string, options: HttpOptions = {}): HttpRequestObject => {
|
||
const method = (options.method || 'GET').toUpperCase()
|
||
const obj: HttpRequestObject = {
|
||
promise: null as any,
|
||
isCancelled: false,
|
||
cancelHttp: () => {
|
||
if (!obj.requestObj) {
|
||
obj.isCancelled = true
|
||
return
|
||
}
|
||
obj.requestObj.destroy(new Error('Canceled'))
|
||
obj.requestObj = null
|
||
// 取消后置空是运行时事实,但公开类型保持非空(消费方总是先取 promise 再取消)
|
||
;(obj as any).promise = (obj as any).cancelHttp = null
|
||
obj.cancelFn?.(new Error('Canceled'))
|
||
obj.cancelFn = null
|
||
},
|
||
}
|
||
obj.promise = new Promise((resolve, reject) => {
|
||
obj.cancelFn = reject
|
||
|
||
let parsedUrl: URL
|
||
try {
|
||
parsedUrl = new URL(url)
|
||
} catch (err) {
|
||
reject(err)
|
||
return
|
||
}
|
||
|
||
const isHttps = parsedUrl.protocol === 'https:'
|
||
const client = isHttps ? https : http
|
||
|
||
// 数据体构造:body(对象/字符串/Buffer)与 form(对象 → urlencoded)
|
||
let body: string | any = null
|
||
let isForm = false
|
||
if (options.form) {
|
||
const formBody = new URLSearchParams()
|
||
for (const key of Object.keys(options.form)) {
|
||
const value = options.form[key]
|
||
if (value == null) continue
|
||
formBody.append(key, typeof value === 'object' ? JSON.stringify(value) : value)
|
||
}
|
||
body = formBody.toString()
|
||
isForm = true
|
||
} else if (options.body) {
|
||
body = typeof options.body === 'object' && !Buffer.isBuffer(options.body) ? JSON.stringify(options.body) : options.body
|
||
}
|
||
|
||
const headers: Record<string, any> = { ...defaultHeaders, ...(options.headers || {}) }
|
||
if (body && !headers['content-type'] && !headers['Content-Type']) {
|
||
headers['content-type'] = isForm ? 'application/x-www-form-urlencoded' : 'application/json'
|
||
}
|
||
if (body) headers['content-length'] = Buffer.byteLength(body)
|
||
|
||
const requestOptions: Record<string, any> = {
|
||
method,
|
||
headers,
|
||
family: options.family || undefined,
|
||
}
|
||
if (options.lookup) requestOptions.lookup = options.lookup
|
||
|
||
const req = client.request(parsedUrl, requestOptions, res => {
|
||
obj.requestObj = null
|
||
obj.cancelFn = null
|
||
const statusCode = res.statusCode ?? 0
|
||
|
||
let stream: any = res
|
||
switch (res.headers['content-encoding']) {
|
||
case 'gzip': stream = res.pipe(zlib.createGunzip()); break
|
||
case 'deflate': stream = res.pipe(zlib.createInflate()); break
|
||
case 'br': stream = res.pipe(zlib.createBrotliDecompress()); break
|
||
}
|
||
|
||
const chunks: Buffer[] = []
|
||
let size = 0
|
||
stream.on('data', chunk => {
|
||
size += chunk.length
|
||
if (size > MAX_BUFFER_SIZE) {
|
||
stream.destroy(new Error('Response too large'))
|
||
return
|
||
}
|
||
chunks.push(chunk)
|
||
})
|
||
stream.on('error', err => reject(err))
|
||
stream.on('end', () => {
|
||
const raw = Buffer.concat(chunks)
|
||
resolve({
|
||
statusCode,
|
||
headers: res.headers,
|
||
body: parseBody(raw),
|
||
raw,
|
||
})
|
||
})
|
||
})
|
||
|
||
obj.requestObj = req
|
||
|
||
req.on('error', err => {
|
||
const e = err as any
|
||
if (obj.isCancelled || e.canceled) e.canceled = true
|
||
reject(e)
|
||
})
|
||
|
||
if (options.timeout) {
|
||
req.setTimeout(options.timeout, () => {
|
||
const err = new Error('Request timeout') as any
|
||
err.timeout = true
|
||
req.destroy(err)
|
||
})
|
||
}
|
||
|
||
if (body) req.write(body)
|
||
req.end()
|
||
})
|
||
return obj
|
||
}
|
||
|
||
/**
|
||
* 请求超时/断网错误统一处理(与原版行为一致)
|
||
*/
|
||
export const httpFetch = (url: string, options: HttpOptions = { method: 'get' }): HttpRequestObject => {
|
||
const requestObj = buildHttpPromise(url, options)
|
||
requestObj.promise = requestObj.promise.catch((err: any) => {
|
||
if (err.canceled) return Promise.reject(err)
|
||
switch (err.code) {
|
||
case 'ETIMEDOUT':
|
||
case 'ESOCKETTIMEDOUT':
|
||
return Promise.reject(new Error(requestMsg.timeout))
|
||
case 'ENOTFOUND':
|
||
return Promise.reject(new Error(requestMsg.notConnectNetwork))
|
||
case 'ECONNRESET':
|
||
return Promise.reject(new Error(requestMsg.fail))
|
||
default:
|
||
return Promise.reject(err)
|
||
}
|
||
})
|
||
return requestObj
|
||
}
|
||
|
||
export default { httpFetch }
|