fix: 旧版 Node 与 iOS 环境下请求降级至 node-fetch v3 修复响应头丢失导致个别源弹幕获取失败 (#431)

* 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 及静态判定缓存动机,去除冗余推导表述。
This commit is contained in:
Elegy233 authored and GitHub committed 2026-08-11 00:19:24 +08:00
1 parent 17b3f4d723
commit 89fd166424
2 files changed
+64 -14

No files matched your search

+5
View File
@@ -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);
+59 -14
View File
@@ -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}`);