Files
PaperClipAI/server/src/middleware/api-compression.ts
T
DottaandClaude Fable 5 23f34491e2 Fix apiCompression corrupting and dropping Better Auth responses for gzip clients (#9381)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Its server fronts every API route — including Better Auth sign-in —
with Express middleware, and #9190 added an `apiCompression` middleware
that gzips JSON responses over 1KB
> - That middleware buffers `res.write()` chunks with `String(chunk)`,
but Better Auth (via better-call) streams `Uint8Array` chunks and
commits headers with `writeHead()` before streaming
> - `String(Uint8Array)` serializes the body to comma-separated decimal
bytes (~3.4x inflation), and once the inflated body crossed the 1KB
threshold, `setHeader()` threw `ERR_HTTP_HEADERS_SENT` and the catch
handler destroyed the socket
> - Every real browser sends `Accept-Encoding: gzip`, so sign-in
returned zero bytes (`net::ERR_EMPTY_RESPONSE` / "Failed to fetch"),
while curl without `Accept-Encoding` worked — making the bug easy to
misdiagnose as a client or network issue
> - This pull request makes the middleware byte-safe for `Uint8Array`
chunks, passes through responses whose headers are already committed,
and falls back to the uncompressed body instead of destroying the
connection when compression fails
> - The benefit is that browser sign-in (and any other streamed
binary-chunk response) works again for gzip-accepting clients, with
regression tests locking in all three behaviors

## Linked Issues or Issue Description

Refs #9190 (introduced the `apiCompression` middleware).

No public GitHub issue exists; bug description:

- **What happened:** Sign-in from any real browser failed with
`net::ERR_EMPTY_RESPONSE` / "Failed to fetch". The server logged
`ERR_HTTP_HEADERS_SENT` from the compression middleware and destroyed
the response socket, so zero bytes reached the client.
- **Expected:** `/api/auth/*` responses are delivered intact regardless
of the client's `Accept-Encoding`.
- **Steps to reproduce:** Run the server with API compression active,
open the web UI in a browser (which sends `Accept-Encoding: gzip`), and
attempt email/password sign-in. The auth response body exceeds ~300
bytes, so after the ~3.4x stringification inflation it crosses the
1024-byte compression threshold and the response is destroyed. `curl`
without `Accept-Encoding` succeeds against the same server.
- **Scope:** Any route that streams `Uint8Array` chunks and/or commits
headers via `writeHead()` before writing — in practice all Better Auth
routes served through better-call.

## What Changed

- `server/src/middleware/api-compression.ts`:
- Buffer `res.write()` chunks with a `toBodyBuffer()` helper that
converts `Uint8Array`/`ArrayBuffer` views via `Buffer.from()` instead of
`String()`, so binary chunks are preserved byte-for-byte.
- Pass responses through untouched once headers are already sent
(`writeHead()`-style streaming), since compression headers can no longer
be set at that point.
- On any compression failure, write the original uncompressed body
instead of calling `res.destroy()`, so clients get a valid (just
uncompressed) response rather than a dropped connection.
- `server/src/__tests__/api-compression.test.ts`: three new regression
tests — small `writeHead`+`Uint8Array` responses are delivered
byte-for-byte, large ones no longer drop the connection, and
`Uint8Array` JSON bodies gzip without corruption (includes
`/api/auth-bridge` and `/api/uint8-json` test routes mirroring
better-call's streaming pattern).

## Verification

- `cd server && pnpm vitest run src/__tests__/api-compression.test.ts` —
10/10 passing (7 pre-existing + 3 new regression tests).
- Manual: with the fix, browser sign-in against a dev instance succeeds
for gzip-accepting clients; before the fix the same request returned
`net::ERR_EMPTY_RESPONSE`.

## Risks

- Low risk. The middleware still compresses large text/JSON responses
exactly as before; the changes only affect paths that previously
produced corrupted or destroyed responses.
- Behavioral shift: responses whose headers were already committed are
now delivered uncompressed instead of being (incorrectly) buffered —
this is strictly less surprising than the previous corrupted output.
- Failure-path shift: a compression error now yields an uncompressed 200
response instead of a dropped connection.

## Model Used

- Claude Fable 5 (`claude-fable-5`, Anthropic), extended thinking
enabled, running via Claude Code / Paperclip agent harness with tool use
(shell, file edit, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:04:29 -05:00

245 lines
8.5 KiB
TypeScript

import type { RequestHandler } from "express";
import { promisify } from "node:util";
import { deflate, gzip } from "node:zlib";
export const API_COMPRESSION_THRESHOLD_BYTES = 1024;
const gzipAsync = promisify(gzip);
const deflateAsync = promisify(deflate);
type SupportedEncoding = "gzip" | "deflate";
type ApiCompressionOptions = {
thresholdBytes?: number;
};
type EncodingPreference = {
encoding: string;
q: number;
};
function parseAcceptEncoding(value: string | string[] | undefined): EncodingPreference[] {
const raw = Array.isArray(value) ? value.join(",") : value;
if (!raw) return [];
return raw
.split(",")
.map((part) => {
const [encodingPart, ...paramParts] = part.trim().split(";");
const encoding = encodingPart?.trim().toLowerCase() ?? "";
const qParam = paramParts
.map((param) => param.trim())
.find((param) => param.toLowerCase().startsWith("q="));
const parsedQ = qParam ? Number(qParam.slice(2)) : 1;
const q = Number.isFinite(parsedQ) ? parsedQ : 0;
return { encoding, q };
})
.filter((entry) => entry.encoding.length > 0);
}
function selectEncoding(value: string | string[] | undefined): SupportedEncoding | null {
const preferences = parseAcceptEncoding(value).filter((entry) => entry.q > 0);
const findQ = (encoding: SupportedEncoding) =>
preferences.find((entry) => entry.encoding === encoding)?.q ??
preferences.find((entry) => entry.encoding === "*")?.q ??
0;
const gzipQ = findQ("gzip");
const deflateQ = findQ("deflate");
if (gzipQ <= 0 && deflateQ <= 0) return null;
return gzipQ >= deflateQ ? "gzip" : "deflate";
}
function isJsonContentType(value: unknown): boolean {
const contentType = String(value ?? "").toLowerCase();
return contentType.includes("application/json") || contentType.includes("+json");
}
function shouldSkipForCacheControl(value: unknown): boolean {
return /\bno-transform\b/i.test(String(value ?? ""));
}
function shouldSkipForStreamedResponse(res: Parameters<RequestHandler>[1]): boolean {
return (
res.hasHeader("Content-Disposition") ||
res.hasHeader("Accept-Ranges") ||
res.hasHeader("Content-Range")
);
}
function statusAllowsBody(statusCode: number): boolean {
return statusCode !== 204 && statusCode !== 304 && statusCode >= 200;
}
function shouldPassthroughWrite(res: Parameters<RequestHandler>[1]): boolean {
const contentType = res.getHeader("Content-Type");
const alreadyEncoded = res.hasHeader("Content-Encoding") && String(res.getHeader("Content-Encoding")).toLowerCase() !== "identity";
return (
// writeHead() may already have committed the response head (better-call
// does this before streaming the body); headers can no longer change, so
// buffering for compression would only risk corrupting the stream.
res.headersSent ||
alreadyEncoded ||
!statusAllowsBody(res.statusCode) ||
shouldSkipForCacheControl(res.getHeader("Cache-Control")) ||
shouldSkipForStreamedResponse(res) ||
contentType === undefined ||
!isJsonContentType(contentType)
);
}
function weakenStrongEtag(res: Parameters<RequestHandler>[1]): void {
const etag = res.getHeader("ETag");
if (etag === undefined) return;
const weaken = (value: string) => /^W\//i.test(value) ? value : `W/${value}`;
if (Array.isArray(etag)) {
res.setHeader("ETag", etag.map((value) => weaken(String(value))));
return;
}
res.setHeader("ETag", weaken(String(etag)));
}
function toBodyBuffer(chunk: unknown, encoding: BufferEncoding | undefined): Buffer {
if (Buffer.isBuffer(chunk)) return chunk;
// Handlers bridged from web Response streams (e.g. Better Auth via
// better-call) write Uint8Array chunks; String(chunk) would serialize them
// as comma-separated byte values and corrupt the body.
if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
return Buffer.from(String(chunk), encoding);
}
function normalizeEndArgs(args: unknown[]): {
chunk: unknown;
encoding: BufferEncoding | undefined;
callback: (() => void) | undefined;
} {
const [chunk, encodingOrCallback, callback] = args;
return {
chunk,
encoding: typeof encodingOrCallback === "string" ? encodingOrCallback as BufferEncoding : undefined,
callback:
typeof encodingOrCallback === "function"
? encodingOrCallback as () => void
: typeof callback === "function"
? callback as () => void
: undefined,
};
}
export function apiCompression(options: ApiCompressionOptions = {}): RequestHandler {
const thresholdBytes = options.thresholdBytes ?? API_COMPRESSION_THRESHOLD_BYTES;
return (req, res, next) => {
const selectedEncoding = selectEncoding(req.headers["accept-encoding"]);
if (!selectedEncoding || req.method === "HEAD") {
next();
return;
}
const chunks: Buffer[] = [];
const writeCallbacks: Array<() => void> = [];
let passthrough = false;
const originalWrite = res.write.bind(res);
const originalEnd = res.end.bind(res);
const originalFlushHeaders = res.flushHeaders?.bind(res);
const restore = () => {
res.write = originalWrite as typeof res.write;
res.end = originalEnd as typeof res.end;
if (originalFlushHeaders) {
res.flushHeaders = originalFlushHeaders as typeof res.flushHeaders;
}
};
const beginPassthrough = () => {
if (passthrough) return;
passthrough = true;
restore();
for (const buffered of chunks.splice(0)) {
originalWrite(buffered);
}
for (const writeCallback of writeCallbacks.splice(0)) writeCallback();
};
res.write = ((chunk: unknown, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void) => {
if (passthrough) {
return originalWrite(chunk as never, encodingOrCallback as never, callback as never);
}
if (shouldPassthroughWrite(res)) {
beginPassthrough();
return originalWrite(chunk as never, encodingOrCallback as never, callback as never);
}
if (chunk !== undefined) {
chunks.push(toBodyBuffer(chunk, typeof encodingOrCallback === "string" ? encodingOrCallback : undefined));
}
const writeCallback = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
if (writeCallback) writeCallbacks.push(() => writeCallback(null));
return true;
}) as typeof res.write;
res.end = ((...args: unknown[]) => {
restore();
const { chunk, encoding, callback } = normalizeEndArgs(args);
if (chunk !== undefined) {
chunks.push(toBodyBuffer(chunk, encoding));
}
const body = Buffer.concat(chunks);
const alreadyEncoded = res.hasHeader("Content-Encoding") && String(res.getHeader("Content-Encoding")).toLowerCase() !== "identity";
const shouldCompress =
!passthrough &&
!res.headersSent &&
!alreadyEncoded &&
statusAllowsBody(res.statusCode) &&
body.length >= thresholdBytes &&
isJsonContentType(res.getHeader("Content-Type")) &&
!shouldSkipForCacheControl(res.getHeader("Cache-Control")) &&
!shouldSkipForStreamedResponse(res);
if (!shouldCompress) {
const result = originalEnd(body, callback);
for (const writeCallback of writeCallbacks) writeCallback();
return result;
}
void (async () => {
try {
const compressed = selectedEncoding === "gzip"
? await gzipAsync(body)
: await deflateAsync(body);
res.vary("Accept-Encoding");
res.setHeader("Content-Encoding", selectedEncoding);
res.setHeader("Content-Length", String(compressed.length));
weakenStrongEtag(res);
res.removeHeader("Content-MD5");
originalEnd(compressed, callback);
for (const writeCallback of writeCallbacks) writeCallback();
} catch (error) {
// Compression is best-effort: never turn a healthy response into a
// dropped connection. Send the original body if the head allows it.
try {
originalEnd(body, callback);
for (const writeCallback of writeCallbacks) writeCallback();
} catch {
res.destroy(error instanceof Error ? error : new Error(String(error)));
}
}
})();
return res;
}) as typeof res.end;
if (originalFlushHeaders) {
res.flushHeaders = (() => {
passthrough = true;
restore();
return originalFlushHeaders();
}) as typeof res.flushHeaders;
}
next();
};
}