关于 iOS 夸克浏览器劫持 fetch 带来问题的记录
我的某个项目中有 AI 对话的功能,最近发现在 iOS 夸克浏览器上使用时流式输出失效,所有内容缓冲完毕后一次性返回,排查到最后发现是夸克浏览器劫持了 fetch 。
起因
我的某个项目由于有 AI 会话功能,且后端接口需要使用 POST 方法提交相关数据,不能使用原生的 EventSource,因此引入了微软的 @microsoft/fetch-event-source ,它的底层是用 fetch + ReadableStream 实现的。
起初发现 iOS 端夸克浏览器出现这个问题时,我先测试了其他几个 iOS浏览器:Safari / Chrome / Firefox,甚至微信内置浏览器,均不存在这个问题。
我以为是夸克浏览器有“云加速”相关的功能,关掉即可,毕竟国产浏览器都喜欢玩这套东西。 我在设置中找到了“智能预加载”,发现是默认关闭的。
这下有点棘手了,和 Claude 讨论一番后,它认为最大的可能是夸克浏览器拦截了底层的 fetch,但只实现了一次性的 finishWithData,没有实现流式分块通知 didReceiveData ,因此导致 JS 中 response.body.getReader().read() 一直读取不到新的 chunk,直到连接断开时,iOS 原生层才将所有结果一起返回,在 UI 层面就表现为流式输出失效,等待很久后一次性返回。
做点测试
我让 Claude 使用 Node 帮我写了一个小小的测试服务器,包含一个简单的网页和后端测试接口,网页可以通过 XHR、EventSource、fetch 三种不同的底层协议来和后端接口建立流式输出连接。
建立连接后,后端会每隔两秒发送一次数据,一共发送五次。前端会记录每次接收到数据的时间并实时进行输出,这是测试得到的最终结果:
Note
输出时间从建立连接开始计算。
| 底层协议 | 首帧输出时间 | 末帧输出时间 |
|---|---|---|
| XHR | +2s | +10s |
| EventSource | +2s | +10s |
| fetch | +10s | +10s |
由此可见,夸克浏览器对 fetch 的拦截魔改确实存在问题,而原生的 EventSource 不支持 POST 和自定义 Header,因此我们只能使用 XHR 来实现流式传输,替换掉底层的 @microsoft/fetch-event-source。
XHR 实现的流式传输
/** SSE 允许 \r\n / \n / \r 三种换行,空行即一帧结束 */
const LINE_SEPARATOR = /\r\n|\n|\r/;
/**
* 取出一帧里所有 data 字段,按规范用 \n 连接。
* 注释行(`:` 开头,通常是心跳)与 event / id / retry 字段一律忽略。
*/
const readFrameData = (frame: string): string | undefined => {
const values: string[] = [];
for (const line of frame.split(LINE_SEPARATOR)) {
if (line === "" || line.startsWith(":")) continue;
const colon = line.indexOf(":");
if ((colon < 0 ? line : line.slice(0, colon)) !== "data") continue;
// 规范规定冒号后紧跟的单个空格属于分隔符,不算内容
const value = colon < 0 ? "" : line.slice(colon + 1);
values.push(value.startsWith(" ") ? value.slice(1) : value);
}
return values.length > 0 ? values.join("\n") : undefined;
};
export interface SseStreamRequest {
method: string;
headers: Record<string, string>;
body: string;
signal: AbortSignal;
/** 收到响应头时调用;抛错即中断请求并转 onerror */
onopen: (response: { status: number; contentType: string }) => void;
/** 一帧里 data 字段的拼接结果;抛错同样中断并转 onerror */
onmessage: (data: string) => void;
onerror: (error: unknown) => void;
/** 服务端正常结束。中断与失败都不会走到这里 */
onclose: () => void;
}
export const openSseStream = (url: string, request: SseStreamRequest) => {
const xhr = new XMLHttpRequest();
let settled = false;
// responseText 是累积的,记住上一帧的结束位置,每次只扫新增部分
let offset = 0;
const fail = (error: unknown) => {
if (settled) return;
settled = true;
xhr.abort();
request.onerror(error);
};
const drain = () => {
const text = xhr.responseText;
// 每次新建:正则带 g 时 lastIndex 是实例状态,模块级共享会被并发的流互相踩
const separator = /\r\n\r\n|\n\n|\r\r/g;
separator.lastIndex = offset;
for (let match = separator.exec(text); match; match = separator.exec(text)) {
const data = readFrameData(text.slice(offset, match.index));
offset = match.index + match[0].length;
separator.lastIndex = offset;
if (data !== undefined) request.onmessage(data);
// onmessage 里可能已经中断(协议错误帧、调用方停止生成)
if (settled) return;
}
};
const onAbort = () => {
if (settled) return;
settled = true;
xhr.abort();
};
xhr.open(request.method, url, true);
for (const [name, value] of Object.entries(request.headers)) xhr.setRequestHeader(name, value);
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.HEADERS_RECEIVED || settled) return;
try {
request.onopen({ status: xhr.status, contentType: xhr.getResponseHeader("content-type") ?? "" });
} catch (error) {
fail(error);
}
};
xhr.onprogress = () => {
if (settled) return;
try {
drain();
} catch (error) {
fail(error);
}
};
xhr.onload = () => {
if (settled) return;
// 最后一批数据未必触发过 progress,收尾再扫一次
try {
drain();
} catch (error) {
return fail(error);
}
if (settled) return;
settled = true;
request.onclose();
};
xhr.onerror = () => fail(new Error("连接失败"));
xhr.ontimeout = () => fail(new Error("连接超时"));
// 长会话里 signal 会被反复复用,请求收尾时摘掉监听,避免挂一串死回调
xhr.onloadend = () => request.signal.removeEventListener("abort", onAbort);
if (request.signal.aborted) return;
request.signal.addEventListener("abort", onAbort, { once: true });
xhr.send(request.body);
};