ProxyForwardClient.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. /**
  2. * 通过 RunForge-ProxyServer 的 /Proxy/Forward 发起出站 HTTP 请求。
  3. */
  4. const axios = require('axios')
  5. const { getRuntimeConfig } = require('./RuntimeConfig')
  6. async function getServerConfig() {
  7. let cfg = null
  8. try {
  9. cfg = await getRuntimeConfig('proxyForwardServer', { required: false, defaultValue: null })
  10. } catch (_) {
  11. return { url: '', enabled: false }
  12. }
  13. if (!cfg || typeof cfg !== 'object') return { url: '', enabled: false }
  14. return {
  15. url: String(cfg.url || '').trim().replace(/\/+$/, ''),
  16. enabled: cfg.enabled !== false,
  17. defaultTimeout: Number(cfg.timeout) || 120000
  18. }
  19. }
  20. async function isProxyForwardEnabled() {
  21. const { url, enabled } = await getServerConfig()
  22. return enabled && url.length > 0
  23. }
  24. function debugProxyEnabled() {
  25. return String(process.env.LEPAO_DEBUG_PROXY || '').trim() === '1'
  26. }
  27. function debugProxyAxiosFragment() {
  28. const host = process.env.LEPAO_DEBUG_PROXY_HOST || '127.0.0.1'
  29. const port = Number(process.env.LEPAO_DEBUG_PROXY_PORT || 9000)
  30. return {
  31. proxy: {
  32. host,
  33. port,
  34. protocol: 'http'
  35. }
  36. }
  37. }
  38. function briefUrlPath(fullUrl) {
  39. try {
  40. const u = new URL(fullUrl)
  41. return `${u.pathname}${u.search}`
  42. } catch {
  43. return fullUrl
  44. }
  45. }
  46. /** 转为可 JSON 序列化的纯对象,避免 AxiosHeaders 等类型丢失字段 */
  47. function normalizeForwardHeaders(headers) {
  48. if (headers == null) return {}
  49. let source = headers
  50. if (typeof headers === 'string') {
  51. try {
  52. source = JSON.parse(headers)
  53. } catch {
  54. return {}
  55. }
  56. }
  57. if (typeof source !== 'object' || Array.isArray(source)) return {}
  58. if (typeof source.toJSON === 'function') source = source.toJSON()
  59. const out = {}
  60. for (const [key, value] of Object.entries(source)) {
  61. if (value == null) continue
  62. out[String(key)] = Array.isArray(value) ? value.join(', ') : String(value)
  63. }
  64. return out
  65. }
  66. /**
  67. * URLSearchParams / Buffer 等无法被 JSON.stringify 正确序列化,需先转换再发给代理服务。
  68. */
  69. function serializeForwardBody(data) {
  70. if (data == null || data === '') return null
  71. if (typeof data === 'string') return data
  72. if (Buffer.isBuffer(data)) {
  73. return { __encoding: 'base64', data: data.toString('base64') }
  74. }
  75. if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) {
  76. return data.toString()
  77. }
  78. if (typeof data === 'object' && !Array.isArray(data)) return data
  79. return String(data)
  80. }
  81. function buildDirectAxiosConfig(extra = {}) {
  82. if (debugProxyEnabled()) {
  83. const { HttpsProxyAgent } = require('https-proxy-agent')
  84. const dbg = debugProxyAxiosFragment()
  85. const { host, port } = dbg.proxy
  86. const agent = new HttpsProxyAgent(`http://${host}:${port}`)
  87. return { proxy: false, httpAgent: agent, httpsAgent: agent, ...extra }
  88. }
  89. return { proxy: false, ...extra }
  90. }
  91. async function execDirect({ method, url, data, headers, timeout, validateStatus, responseType, transformResponse }) {
  92. const m = String(method || 'get').toLowerCase()
  93. const merged = {
  94. headers: headers || {},
  95. timeout,
  96. ...buildDirectAxiosConfig(),
  97. ...(validateStatus ? { validateStatus } : {}),
  98. ...(responseType ? { responseType } : {}),
  99. ...(transformResponse ? { transformResponse } : {})
  100. }
  101. return axios.request({ ...merged, method: m, url, data })
  102. }
  103. function assertProxyModeOk(outboundMode, meta) {
  104. if (outboundMode !== 'proxy') return
  105. if (meta?.fallback_direct) {
  106. const err = new Error('代理模式发生直连回退,任务中止')
  107. err.code = 'PROXY_REQUIRED_FALLBACK_DIRECT'
  108. err.retryable = true
  109. throw err
  110. }
  111. if (!meta?.used_proxy) {
  112. const err = new Error('代理模式未使用代理节点')
  113. err.code = 'PROXY_REQUIRED_NO_PROXY_USED'
  114. err.retryable = true
  115. throw err
  116. }
  117. }
  118. function toAxiosResponse(forwardData, responseType) {
  119. if (!forwardData || typeof forwardData !== 'object') return null
  120. const status = forwardData.status ?? forwardData.statusCode
  121. if (status == null) return null
  122. let data = forwardData.body
  123. if (data && typeof data === 'object' && data.__encoding === 'base64' && data.data) {
  124. data = Buffer.from(String(data.data), 'base64')
  125. }
  126. return {
  127. status,
  128. statusText: String(status),
  129. headers: forwardData.headers || {},
  130. data,
  131. config: {}
  132. }
  133. }
  134. function isValidForwardPayload(json) {
  135. return json && json.code === 0 && json.data != null && (json.data.status ?? json.data.statusCode) != null
  136. }
  137. /**
  138. * @param {{
  139. * method?: string
  140. * url: string
  141. * data?: any
  142. * headers?: object
  143. * timeout?: number
  144. * outboundMode?: 'auto'|'direct'|'proxy'
  145. * validateStatus?: Function
  146. * responseType?: string
  147. * transformResponse?: Function[]
  148. * logger?: object
  149. * logPrefix?: string
  150. * scene?: string
  151. * }} opts
  152. */
  153. async function forwardRequest(opts) {
  154. const {
  155. method = 'get',
  156. url,
  157. data,
  158. headers = {},
  159. timeout = 15000,
  160. outboundMode = 'auto',
  161. validateStatus,
  162. responseType,
  163. transformResponse,
  164. logger,
  165. logPrefix = '[ProxyForwardClient]',
  166. scene = 'outbound'
  167. } = opts
  168. const m = String(method).toLowerCase()
  169. const path = briefUrlPath(url)
  170. if (outboundMode === 'direct') {
  171. logger?.info?.(`${logPrefix} (${scene}) 直连 ${m.toUpperCase()} ${path}`)
  172. return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
  173. }
  174. if (!await isProxyForwardEnabled()) {
  175. logger?.info?.(`${logPrefix} (${scene}) 未配置代理服务,直连 ${path}`)
  176. if (outboundMode === 'proxy') {
  177. const err = new Error('未配置 proxyForwardServer.url')
  178. err.code = 'PROXY_REQUIRED_NOT_CONFIGURED'
  179. err.retryable = false
  180. throw err
  181. }
  182. return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
  183. }
  184. const { url: serverUrl, defaultTimeout } = await getServerConfig()
  185. logger?.info?.(`${logPrefix} (${scene}) 经代理服务 ${m.toUpperCase()} ${path}`)
  186. const reqHeaders = normalizeForwardHeaders(headers)
  187. const reqBody = serializeForwardBody(data)
  188. let forwardResp
  189. try {
  190. forwardResp = await axios.post(
  191. `${serverUrl}/Proxy/Forward`,
  192. {
  193. url,
  194. method: m.toUpperCase(),
  195. headers: reqHeaders,
  196. body: reqBody,
  197. timeout,
  198. ...(responseType ? { responseType } : {})
  199. },
  200. {
  201. timeout: Math.min(defaultTimeout, timeout + 10000),
  202. validateStatus: () => true,
  203. headers: { 'Content-Type': 'application/json' }
  204. }
  205. )
  206. } catch (e) {
  207. if (outboundMode === 'proxy') {
  208. const err = new Error(`代理服务不可达: ${e.message || e}`)
  209. err.code = 'PROXY_SERVER_UNREACHABLE'
  210. err.retryable = true
  211. throw err
  212. }
  213. logger?.warn?.(`${logPrefix} (${scene}) 代理服务请求失败,改直连: ${e.message || e}`)
  214. return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
  215. }
  216. const json = forwardResp.data || {}
  217. if (!isValidForwardPayload(json)) {
  218. const reason = json.code !== 0 ? (json.msg || 'unknown') : '代理响应缺少 status'
  219. if (outboundMode === 'proxy') {
  220. const err = new Error(json.msg || reason || '代理转发失败')
  221. err.code = 'PROXY_FORWARD_FAILED'
  222. err.retryable = true
  223. throw err
  224. }
  225. logger?.warn?.(`${logPrefix} (${scene}) 代理响应无效,改直连: ${reason}`)
  226. return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
  227. }
  228. const meta = json.data?.meta || {}
  229. assertProxyModeOk(outboundMode, meta)
  230. logger?.info?.(
  231. `${logPrefix} (${scene}) 完成 status=${json.data?.status ?? json.data?.statusCode} used_proxy=${meta.used_proxy} fallback=${meta.fallback_direct} ${meta.duration_ms}ms`
  232. )
  233. const axiosResp = toAxiosResponse(json.data, responseType)
  234. if (!axiosResp) {
  235. if (outboundMode === 'proxy') {
  236. const err = new Error('代理响应缺少 HTTP status')
  237. err.code = 'PROXY_FORWARD_BAD_RESPONSE'
  238. err.retryable = true
  239. throw err
  240. }
  241. logger?.warn?.(`${logPrefix} (${scene}) 代理响应缺少 status,改直连`)
  242. return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
  243. }
  244. if (validateStatus && !validateStatus(axiosResp.status)) {
  245. const err = new Error(`Request failed with status code ${axiosResp.status}`)
  246. err.response = axiosResp
  247. err.isAxiosError = true
  248. throw err
  249. }
  250. return axiosResp
  251. }
  252. module.exports = {
  253. forwardRequest,
  254. isProxyForwardEnabled,
  255. debugProxyEnabled,
  256. debugProxyAxiosFragment,
  257. briefUrlPath,
  258. buildDirectAxiosConfig,
  259. execDirect,
  260. getServerConfig,
  261. normalizeForwardHeaders,
  262. serializeForwardBody
  263. }