fix: decode zstd responses while restoring claude encoding

This commit is contained in:
ciat-777
2026-03-21 15:38:11 +08:00
parent 17a4281117
commit 7a2953ac36
12 changed files with 146 additions and 16 deletions
@@ -0,0 +1,56 @@
import { gzipSync, zstdCompressSync } from 'node:zlib';
import { Response } from 'undici';
import { describe, expect, it } from 'vitest';
import { materializeErrorResponse, readRuntimeResponseText } from './types.js';
describe('readRuntimeResponseText', () => {
it('decompresses zstd responses before reading the body text', async () => {
const payload = JSON.stringify({ ok: true, text: 'hello zstd' });
const response = new Response(zstdCompressSync(Buffer.from(payload)), {
status: 200,
headers: {
'content-encoding': 'zstd',
'content-type': 'application/json; charset=utf-8',
},
});
await expect(readRuntimeResponseText(response)).resolves.toBe(payload);
});
it('decompresses stacked content-encodings in reverse order', async () => {
const payload = JSON.stringify({ ok: true, text: 'stacked' });
const response = new Response(
zstdCompressSync(gzipSync(Buffer.from(payload))),
{
status: 200,
headers: {
'content-encoding': 'gzip, zstd',
'content-type': 'application/json; charset=utf-8',
},
},
);
await expect(readRuntimeResponseText(response)).resolves.toBe(payload);
});
});
describe('materializeErrorResponse', () => {
it('decodes compressed error bodies and strips compression headers', async () => {
const payload = JSON.stringify({ error: { message: 'upstream failed' } });
const response = new Response(zstdCompressSync(Buffer.from(payload)), {
status: 503,
headers: {
'content-encoding': 'zstd',
'content-length': '999',
'content-type': 'application/json; charset=utf-8',
},
});
const materialized = await materializeErrorResponse(response);
await expect(materialized.text()).resolves.toBe(payload);
expect(materialized.headers.get('content-encoding')).toBeNull();
expect(materialized.headers.get('content-length')).toBeNull();
expect(materialized.headers.get('content-type')).toBe('application/json; charset=utf-8');
});
});
+74 -2
View File
@@ -1,3 +1,9 @@
import {
brotliDecompressSync,
gunzipSync,
inflateSync,
zstdDecompressSync,
} from 'node:zlib';
import {
Response,
fetch,
@@ -63,13 +69,79 @@ export async function performFetch(
return fetch(requestUrl, init);
}
function hasZstdContentEncoding(contentEncoding: string | null): boolean {
if (!contentEncoding) return false;
return contentEncoding
.split(',')
.some((encoding) => encoding.trim().toLowerCase() === 'zstd');
}
function looksLikeZstdFrame(buffer: Buffer): boolean {
return buffer.length >= 4
&& buffer[0] === 0x28
&& buffer[1] === 0xb5
&& buffer[2] === 0x2f
&& buffer[3] === 0xfd;
}
function decodeRuntimeResponseBuffer(buffer: Buffer, contentEncoding: string | null): Buffer {
if (!contentEncoding) return buffer;
let decoded = buffer;
const encodings = contentEncoding
.split(',')
.map((encoding) => encoding.trim().toLowerCase())
.filter(Boolean)
.reverse();
for (const encoding of encodings) {
if (encoding === 'zstd') {
decoded = zstdDecompressSync(decoded);
continue;
}
if (encoding === 'br') {
decoded = brotliDecompressSync(decoded);
continue;
}
if (encoding === 'gzip' || encoding === 'x-gzip') {
decoded = gunzipSync(decoded);
continue;
}
if (encoding === 'deflate') {
decoded = inflateSync(decoded);
continue;
}
}
return decoded;
}
export async function readRuntimeResponseText(
response: RuntimeResponse,
): Promise<string> {
const contentEncoding = response.headers.get('content-encoding');
if (!hasZstdContentEncoding(contentEncoding)) {
return response.text().catch(() => '');
}
const rawBuffer = Buffer.from(await response.arrayBuffer());
try {
return decodeRuntimeResponseBuffer(rawBuffer, contentEncoding).toString('utf8');
} catch {
return looksLikeZstdFrame(rawBuffer) ? '' : rawBuffer.toString('utf8');
}
}
export async function materializeErrorResponse(
response: RuntimeResponse,
): Promise<RuntimeResponse> {
if (response.ok) return response;
const text = await response.text().catch(() => '');
const text = await readRuntimeResponseText(response);
const headers = new Headers(response.headers);
headers.delete('content-encoding');
headers.delete('content-length');
return new Response(text, {
status: response.status,
headers: response.headers,
headers,
});
}