Files
Endoleap/src/lib/canvas-response.ts
T

73 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 画布响应的流转发与超时分层:响应头截止、整个body时限、单次等待下一块的空闲时限分别管理。
* 不把大body一次读入内存或缓存,返回保留状态/头的新Response;外部断开与内部超时通过组合signal中止上游。
*/
/** Separate the response-header deadline from a large snapshot's transfer.
* A client disconnect still aborts the upstream; bodies are never cached. */
/** 单次传输诊断:头耗时、头后首块等待、body/总耗时与字节/块数;记录结果不是协议ACK。 */
export type CanvasTransferTrace = { started_at: string; finished_at: string; headers_ms: number; first_chunk_ms?: number; transfer_ms: number; total_ms: number; bytes: number; chunks: number; outcome: "complete" | "cancelled" | "error"; error?: string };
/** 默认头15秒、body90秒、单次read30秒;request/report可注入。
* report由调用方提供且应避免抛错;此处没有把报告异常完全隔离成独立旁路。 */
export async function fetchCanvasResponse(url: string, options: RequestInit, limits = { headersMs: 15000, bodyMs: 90000, idleMs: 30000 }, request = fetch, report?: (trace: CanvasTransferTrace) => void) {
const started = performance.now();
const startedAt = new Date().toISOString();
const abort = new AbortController();
// 外部请求取消与内部超时任一生效都中止同一上游fetch,避免客户端已离开却继续传输。
const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal;
/** 三种时限共用内部取消源;只发送取消信号,实际唤醒等待依赖 request/reader 遵守取消契约。 */
const timeout = () => abort.abort(new DOMException("Canvas transfer timed out", "TimeoutError"));
// 初始计时器只约束拿响应头,取得Response后清掉,再给body设置独立总时限。
let timer = setTimeout(timeout, limits.headersMs);
let upstream: Response;
try { upstream = await request(url, { ...options, signal }); }
catch (error) {
clearTimeout(timer);
const elapsed = Math.round((performance.now() - started) * 10) / 10;
report?.({ started_at: startedAt, finished_at: new Date().toISOString(), headers_ms: elapsed, transfer_ms: 0, total_ms: elapsed, bytes: 0, chunks: 0, outcome: "error", ...(error instanceof Error ? { error: error.name } : {}) });
throw error;
}
clearTimeout(timer);
// 无body的响应直接透传,不建立reader,也不产生body完成轨迹。
if (!upstream.body) return upstream;
const headersAt = performance.now();
const reader = upstream.body.getReader();
let bytes = 0, chunks = 0, firstChunk: number | undefined;
/** 从性能时钟计算耗时,四舍五入到0.1ms;首块耗时相对headersAt,不相对最初发起时刻。 */
const record = (outcome: CanvasTransferTrace["outcome"], error?: unknown) => {
const now = performance.now(), rounded = (value: number) => Math.round(value * 10) / 10;
report?.({ started_at: startedAt, finished_at: new Date().toISOString(), headers_ms: rounded(headersAt - started), ...(firstChunk === undefined ? {} : { first_chunk_ms: rounded(firstChunk - headersAt) }), transfer_ms: rounded(now - headersAt), total_ms: rounded(now - started), bytes, chunks, outcome, ...(error instanceof Error ? { error: error.name } : {}) });
};
let idle: ReturnType<typeof setTimeout> | undefined;
let finished = false;
/** 标记结束并清除总/空闲计时与组合signal监听,后续迟到read不能再次enqueue或重复结束。
* 这里只清理调度资源,不 releaseLock;reader 的取消在中断/下游 cancel 路径另行执行。 */
const cleanup = () => { finished = true; clearTimeout(timer); clearTimeout(idle); signal.removeEventListener("abort", interrupted); };
let interrupt: (reason: unknown) => void;
/** 将组合signal的取消原因交给流start中安装的中断处理器。 */
const interrupted = () => interrupt(signal.reason);
const body = new ReadableStream<Uint8Array>({
/** 流开始即启动body总时限,消费者背压暂停不暂停该总时限;中断使下游报错并尽力取消reader。 */
start(controller) {
interrupt = (reason) => { if (finished) return; cleanup(); record("error", reason); controller.error(reason); void reader.cancel(reason).catch(() => {}); };
signal.addEventListener("abort", interrupted, { once: true });
timer = setTimeout(timeout, limits.bodyMs);
if (signal.aborted) interrupted();
},
/** 按下游需求读下一块,idle只覆盖本次reader.read等待;consumer不pull时不运行idle计时。 */
async pull(controller) {
if (finished) return;
idle = setTimeout(timeout, limits.idleMs);
try {
const next = await reader.read(); clearTimeout(idle);
// read等待期间可能已取消/超时;结果回来后先查finished,不向已关闭controller追加字节。
if (finished) return;
if (next.done) { cleanup(); record("complete"); controller.close(); } else { firstChunk ??= performance.now(); bytes += next.value.byteLength; chunks++; controller.enqueue(next.value); }
} catch (error) { if (!finished) { cleanup(); record("error", error); controller.error(error); } }
},
/** 下游主动取消记录cancelled,并中止fetch和reader;已结束状态不重复记录轨迹。 */
async cancel(reason) { if (!finished) { cleanup(); record("cancelled", reason); } abort.abort(reason); await reader.cancel(reason).catch(() => {}); },
});
// 透传HTTP状态和头,业务JSON/NDJSON校验由调用方执行,不在代理流中重新编码内容。
return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers: upstream.headers });
}