From 89fd166424fc32cefb88872233b67fec678b2f4e Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:19:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=97=A7=E7=89=88=20Node=20=E4=B8=8E=20?= =?UTF-8?q?iOS=20=E7=8E=AF=E5=A2=83=E4=B8=8B=E8=AF=B7=E6=B1=82=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E8=87=B3=20node-fetch=20v3=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=A4=B4=E4=B8=A2=E5=A4=B1=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E4=B8=AA=E5=88=AB=E6=BA=90=E5=BC=B9=E5=B9=95=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=20(#431)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 旧版 Node 下改用 node-fetch v3 以修复 Set-Cookie 被丢弃 Node 18 自带的 undici(fetch 实验特性)在解析响应头时会丢弃 Set-Cookie,导致 youku 等依赖该响应头做令牌握手的请求在 Node < 20.19.0 下失败(FAIL_SYS_TOKEN_EMPTY、segmentList is not iterable)。 将请求层(http-util.js)原仅针对 iOS 巨魔环境的 node-fetch v3 降级条件扩展到「Node < 20.19.0」,与 esm-shim 兼容性边界 20.19.0 对齐;并在 server.js 启动处预加载 node-fetch v3,确保 esm-shim 的 require 代理可被同步取用。httpGet / httpPost / httpRequestMethod / httpGetWithStreamCheck 四处 fetch 调用点统一经 shouldUseNodeFetch() 判定,Node >= 20.19.0 仍走原生 fetch,行为与修复前一致。 * 优化: 旧版Node/iOS降级路径启用 keep-alive 连接复用并收敛降级日志为单次 旧版 Node / iOS 环境下统一降级到 node-fetch v3 后,node-fetch 默认走 keepAlive:false 的全局 Agent,并发场景(如跨季并行 mapping)下比原生 undici 连接池更重。本提交在降级分支接入共享 keep-alive Agent(按协议区分 https/http),复用 TCP/TLS 连接,与原生 undici 连接池达到实际等价。 同时将原本 4 个 fetch 调用点「每次请求都输出」的降级日志收敛为模块加载时计算一次、运行时单次启动日志,避免降级环境下日志刷屏。降级判定仅依赖静态环境、进程内恒定,故可安全缓存。 * 文档: 精简 http-util 降级判定的函数注释 将 detectNodeFetchDowngrade 上方注释压缩为单行,保留降级边界(Node<20.19.0 与 esm-shim 一致)、根因(旧 undici 丢弃 Set-Cookie)、iOS 无 WebAssembly 及静态判定缓存动机,去除冗余推导表述。 --- danmu_api/server.js | 5 +++ danmu_api/utils/http-util.js | 73 +++++++++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/danmu_api/server.js b/danmu_api/server.js index 70bf3f2..385f38b 100644 --- a/danmu_api/server.js +++ b/danmu_api/server.js @@ -23,6 +23,11 @@ import { startFavoriteScheduler, stopFavoriteScheduler } from './utils/favorite- // 导入 ES module 兼容层(始终加载,但内部会根据需要启用) import './esm-shim.cjs'; +// 预加载 node-fetch v3:仅在 Node < 20.19.0 等需兼容层的环境实际加载,其他环境为无操作;必须在首个请求前完成,否则 esm-shim 的 require 代理会拒绝同步取用 +if (typeof global.loadNodeFetch === 'function') { + await global.loadNodeFetch(); +} + // 构建 CommonJS 环境下才有的全局变量 const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/danmu_api/utils/http-util.js b/danmu_api/utils/http-util.js index b679bf9..bb294d0 100644 --- a/danmu_api/utils/http-util.js +++ b/danmu_api/utils/http-util.js @@ -1,6 +1,8 @@ import { globals } from '../configs/globals.js'; import { log } from './log-util.js' import { AsyncLocalStorage } from 'node:async_hooks'; +import https from 'node:https'; +import http from 'node:http'; // 跨异步生命周期链路的日志上下文追踪器 export const sourceLogContext = new AsyncLocalStorage(); @@ -59,6 +61,31 @@ function linkSignal(externalSignal, internalController) { }; } +// 旧版 Node(<20.19.0,自带 undici 解析响应头时丢弃 Set-Cookie)与 iOS 巨魔(无 WebAssembly、无原生 fetch)改用 node-fetch v3(其 Headers 正常暴露 Set-Cookie);降级边界与 esm-shim 的 20.19.0 一致,Node >= 20.19.0 仍用原生 fetch。判定仅依赖静态环境、进程内恒定,故模块加载时算一次并缓存。 +function detectNodeFetchDowngrade() { + if (typeof WebAssembly === 'undefined') return true; + const [major, minor] = process.versions.node.split('.').map(Number); + return major < 20 || (major === 20 && minor < 19); +} + +const USE_NODE_FETCH = detectNodeFetchDowngrade(); +if (USE_NODE_FETCH) { + // 模块载入时 logLevel 尚未初始化,用 console.log 保证启动提示必现 + console.log("[system] [http] 检测到旧版Node/iOS环境,已全局切换至 node-fetch v3 作为请求实现"); +} + +// 降级分支共享 keep-alive Agent,复用 TCP/TLS 连接以与原生 undici 连接池达到实际等价(消除重复握手开销);按协议区分 https/http +const nodeFetchHttpsAgent = USE_NODE_FETCH ? new https.Agent({ keepAlive: true, keepAliveMsecs: 1000, maxSockets: 256 }) : null; +const nodeFetchHttpAgent = USE_NODE_FETCH ? new http.Agent({ keepAlive: true, keepAliveMsecs: 1000, maxSockets: 256 }) : null; +function nodeFetchAgent(parsedUrl) { + const protocol = parsedUrl instanceof URL ? parsedUrl.protocol : new URL(parsedUrl).protocol; + return protocol === 'https:' ? nodeFetchHttpsAgent : nodeFetchHttpAgent; +} + +function shouldUseNodeFetch() { + return USE_NODE_FETCH; +} + export async function httpGet(url, options = {}) { // 单次搜索请求内 HTTP 响应复用: 若当前请求上下文已激活复用缓存且本 URL 已缓存, 直接返回克隆结果, 跳过重复网络请求 const requestHttpCache = httpCacheContext.getStore(); @@ -103,17 +130,17 @@ export async function httpGet(url, options = {}) { const cleanupSignal = linkSignal(options.signal, controller); try { - // 兼容iOS巨魔环境:使用node-fetch替代内置fetch + // 兼容iOS巨魔或旧版Node:使用node-fetch替代内置fetch let response; - if (typeof WebAssembly === 'undefined') { - log("info", "[system] [http] iOS环境降级使用node-fetch"); + if (shouldUseNodeFetch()) { const fetch = (await import('node-fetch')).default; response = await fetch(url, { method: 'GET', headers: { ...options.headers, }, - signal: controller.signal + signal: controller.signal, + agent: nodeFetchAgent }); } else { // 现代浏览器环境 @@ -328,12 +355,11 @@ export async function httpPost(url, body, options = {}) { } try { - // 兼容iOS巨魔环境:使用node-fetch替代内置fetch + // 兼容iOS巨魔或旧版Node:使用node-fetch替代内置fetch let response; - if (typeof WebAssembly === 'undefined') { - log("info", "[system] [http] iOS环境降级使用node-fetch"); + if (shouldUseNodeFetch()) { const fetch = (await import('node-fetch')).default; - response = await fetch(url, fetchOptions); + response = await fetch(url, { ...fetchOptions, agent: nodeFetchAgent }); } else { // 现代浏览器环境 response = await fetch(url, fetchOptions); @@ -452,7 +478,14 @@ async function httpRequestMethod(method, url, body, options = {}) { } try { - const response = await fetch(url, fetchOptions); + // 兼容iOS巨魔或旧版Node:使用node-fetch替代内置fetch + let response; + if (shouldUseNodeFetch()) { + const fetch = (await import('node-fetch')).default; + response = await fetch(url, { ...fetchOptions, agent: nodeFetchAgent }); + } else { + response = await fetch(url, fetchOptions); + } const textData = await response.text(); if (!response.ok && !validStatusCodes.includes(response.status)) { @@ -712,11 +745,23 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) { const currentSource = sourceLogContext.getStore() || "system"; log("info", `[${currentSource}] [流式请求] HTTP GET: ${url}`); - const response = await fetch(url, { - method: 'GET', - headers: headers, - signal: controller.signal - }); + // 兼容iOS巨魔或旧版Node:使用node-fetch替代内置fetch + let response; + if (shouldUseNodeFetch()) { + const fetch = (await import('node-fetch')).default; + response = await fetch(url, { + method: 'GET', + headers: headers, + signal: controller.signal, + agent: nodeFetchAgent + }); + } else { + response = await fetch(url, { + method: 'GET', + headers: headers, + signal: controller.signal + }); + } if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`);