

























@@ -94,6 +94,114 @@ export type ReadResponseTextResult = {
9494bytesRead: number;
9595};
969697+const RESPONSE_CHARSET_SCAN_BYTES = 4096;
98+const latin1Decoder = new TextDecoder("latin1");
99+const utf8Decoder = new TextDecoder("utf-8");
100+101+function normalizeCharset(value: string | undefined): string | undefined {
102+const normalized = value?.trim().replace(/^["']|["']$/g, "") ?? "";
103+return normalized && normalized.length <= 64 && /^[A-Za-z0-9._:-]+$/.test(normalized)
104+ ? normalized
105+ : undefined;
106+}
107+108+function readCharsetParam(value: string | null | undefined): string | undefined {
109+const match = /(?:^|;)\s*charset\s*=\s*(?:"([^"]+)"|'([^']+)'|([^;\s]+))/i.exec(value ?? "");
110+return normalizeCharset(match?.[1] ?? match?.[2] ?? match?.[3]);
111+}
112+113+function readAttribute(tag: string, name: string): string | undefined {
114+const target = name.toLowerCase();
115+for (const match of tag.matchAll(
116+/([A-Za-z0-9:_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g,
117+)) {
118+if (match[1]?.toLowerCase() === target) {
119+return match[2] ?? match[3] ?? match[4] ?? "";
120+}
121+}
122+return undefined;
123+}
124+125+function shouldSniffDocumentCharset(contentType: string | null): boolean {
126+const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase();
127+if (!mediaType) {
128+return true;
129+}
130+return (
131+mediaType === "text/html" ||
132+mediaType === "application/xhtml+xml" ||
133+mediaType === "text/xml" ||
134+mediaType === "application/xml" ||
135+mediaType.endsWith("+xml")
136+);
137+}
138+139+function sniffCharset(contentType: string | null, bytes: Uint8Array): string | undefined {
140+if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
141+return "utf-8";
142+}
143+if (bytes[0] === 0xff && bytes[1] === 0xfe) {
144+return "utf-16le";
145+}
146+if (bytes[0] === 0xfe && bytes[1] === 0xff) {
147+return "utf-16be";
148+}
149+if (!shouldSniffDocumentCharset(contentType)) {
150+return undefined;
151+}
152+153+const head = latin1Decoder.decode(
154+bytes.subarray(0, Math.min(bytes.byteLength, RESPONSE_CHARSET_SCAN_BYTES)),
155+);
156+const xmlEncoding = /<\?xml\s+[^>]*\bencoding\s*=\s*(?:"([^"]+)"|'([^']+)')/i.exec(head);
157+if (xmlEncoding) {
158+return normalizeCharset(xmlEncoding[1] ?? xmlEncoding[2]);
159+}
160+161+for (const match of head.matchAll(/<meta\b[^>]*>/gi)) {
162+const tag = match[0];
163+const charset = normalizeCharset(readAttribute(tag, "charset"));
164+if (charset) {
165+return charset;
166+}
167+if (/^content-type$/i.test(readAttribute(tag, "http-equiv") ?? "")) {
168+const contentCharset = readCharsetParam(readAttribute(tag, "content"));
169+if (contentCharset) {
170+return contentCharset;
171+}
172+}
173+}
174+return undefined;
175+}
176+177+function concatBytes(parts: Uint8Array[], totalBytes: number): Uint8Array {
178+if (parts.length === 1 && parts[0]?.byteLength === totalBytes) {
179+return parts[0];
180+}
181+const bytes = new Uint8Array(totalBytes);
182+let offset = 0;
183+for (const part of parts) {
184+bytes.set(part, offset);
185+offset += part.byteLength;
186+}
187+return bytes;
188+}
189+190+function responseContentType(res: Response): string | null {
191+const headers = (res as { headers?: { get?: (name: string) => string | null } }).headers;
192+return typeof headers?.get === "function" ? headers.get("content-type") : null;
193+}
194+195+function decodeResponseBytes(res: Response, bytes: Uint8Array): string {
196+const contentType = responseContentType(res);
197+const charset = readCharsetParam(contentType) ?? sniffCharset(contentType, bytes);
198+try {
199+return new TextDecoder(charset ?? "utf-8").decode(bytes);
200+} catch {
201+return utf8Decoder.decode(bytes);
202+}
203+}
204+97205export async function readResponseText(
98206res: Response,
99207options?: { maxBytes?: number },
@@ -113,10 +221,9 @@ export async function readResponseText(
113221typeof (body as { getReader: () => unknown }).getReader === "function"
114222) {
115223const reader = (body as ReadableStream<Uint8Array>).getReader();
116-const decoder = new TextDecoder();
117224let bytesRead = 0;
118225let truncated = false;
119-const parts: string[] = [];
226+const parts: Uint8Array[] = [];
120227121228try {
122229while (true) {
@@ -140,15 +247,15 @@ export async function readResponseText(
140247}
141248142249bytesRead += chunk.byteLength;
143-parts.push(decoder.decode(chunk, { stream: true }));
250+parts.push(chunk);
144251145252if (truncated || bytesRead >= maxBytes) {
146253truncated = true;
147254break;
148255}
149256}
150257} catch {
151-// Best-effort: return whatever we decoded so far.
258+// Best-effort: return whatever we read so far.
152259} finally {
153260if (truncated) {
154261// Some mocked or non-compliant streams never settle cancel(); do not
@@ -157,8 +264,22 @@ export async function readResponseText(
157264}
158265}
159266160-parts.push(decoder.decode());
161-return { text: parts.join(""), truncated, bytesRead };
267+const bytes = concatBytes(parts, bytesRead);
268+return { text: decodeResponseBytes(res, bytes), truncated, bytesRead };
269+}
270+271+const readBytes = (res as { arrayBuffer?: () => Promise<ArrayBuffer> }).arrayBuffer;
272+if (typeof readBytes === "function") {
273+try {
274+const bytes = new Uint8Array(await readBytes.call(res));
275+return {
276+text: decodeResponseBytes(res, bytes),
277+truncated: false,
278+bytesRead: bytes.byteLength,
279+};
280+} catch {
281+// Fall back to text() for lightweight Response-like mocks that do not expose bytes.
282+}
162283}
163284164285try {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。