fix: decode zstd responses while restoring claude encoding
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user