mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 12:07:09 +02:00
## 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>
242 lines
8.9 KiB
TypeScript
242 lines
8.9 KiB
TypeScript
import express from "express";
|
|
import { createServer } from "node:http";
|
|
import { request as httpRequest } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import { deflateSync, gunzipSync, inflateSync } from "node:zlib";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { apiCompression } from "../middleware/api-compression.js";
|
|
|
|
type RawResponse = {
|
|
statusCode: number;
|
|
headers: Record<string, string | string[] | undefined>;
|
|
body: Buffer;
|
|
};
|
|
|
|
const openServers: Array<ReturnType<typeof createServer>> = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(openServers.splice(0).map((server) => new Promise<void>((resolve, reject) => {
|
|
server.close((err) => err ? reject(err) : resolve());
|
|
})));
|
|
});
|
|
|
|
function issueListFixture(count: number) {
|
|
return Array.from({ length: count }, (_, index) => ({
|
|
id: `issue-${index}`,
|
|
companyId: "company-1",
|
|
identifier: `PAP-${index + 1}`,
|
|
title: `Synthetic issue list row ${index}`,
|
|
description: "repeatable payload used to prove API compression on the hot issue-list response",
|
|
status: index % 2 === 0 ? "in_progress" : "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: index % 3 === 0 ? "agent-1" : null,
|
|
assigneeUserId: null,
|
|
parentId: null,
|
|
projectId: "project-1",
|
|
goalId: "goal-1",
|
|
createdAt: "2026-07-03T00:00:00.000Z",
|
|
updatedAt: "2026-07-03T00:00:00.000Z",
|
|
successfulRunHandoff: null,
|
|
activeRecoveryAction: null,
|
|
}));
|
|
}
|
|
|
|
async function requestRaw(app: express.Express, path: string, headers: Record<string, string> = {}): Promise<RawResponse> {
|
|
const server = createServer(app);
|
|
openServers.push(server);
|
|
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const address = server.address() as AddressInfo;
|
|
|
|
return await new Promise<RawResponse>((resolve, reject) => {
|
|
const req = httpRequest({
|
|
host: "127.0.0.1",
|
|
port: address.port,
|
|
path,
|
|
headers,
|
|
}, (res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on("data", (chunk) => {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
});
|
|
res.on("end", () => {
|
|
resolve({
|
|
statusCode: res.statusCode ?? 0,
|
|
headers: res.headers,
|
|
body: Buffer.concat(chunks),
|
|
});
|
|
});
|
|
});
|
|
req.on("error", reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use("/api", apiCompression());
|
|
app.get("/api/companies/:companyId/issues", (_req, res) => {
|
|
res.json(issueListFixture(500));
|
|
});
|
|
app.get("/api/small", (_req, res) => {
|
|
res.json({ ok: true });
|
|
});
|
|
app.get("/api/encoded", (_req, res) => {
|
|
const body = Buffer.from(JSON.stringify({ already: "encoded" }));
|
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
res.setHeader("Content-Encoding", "deflate");
|
|
res.end(deflateSync(body));
|
|
});
|
|
app.get("/api/etag", (_req, res) => {
|
|
res.setHeader("ETag", "\"fixture-etag\"");
|
|
res.json(issueListFixture(500));
|
|
});
|
|
app.get("/api/download", (_req, res) => {
|
|
res.setHeader("Content-Type", "application/octet-stream");
|
|
res.write("chunk-one:");
|
|
res.end("chunk-two");
|
|
});
|
|
app.get("/api/json-download", (_req, res) => {
|
|
const chunk = JSON.stringify(issueListFixture(500));
|
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
res.setHeader("Content-Disposition", "attachment; filename=\"issues.json\"");
|
|
res.write(chunk.slice(0, chunk.length / 2));
|
|
res.end(chunk.slice(chunk.length / 2));
|
|
});
|
|
// Mirrors better-call's setResponse (Better Auth sign-in/sign-up): headers
|
|
// are committed with writeHead() first, then the web-stream body arrives as
|
|
// Uint8Array chunks.
|
|
app.get("/api/auth-bridge", (req, res) => {
|
|
const body = JSON.stringify({
|
|
token: "t".repeat(Number(req.query.pad ?? 0)),
|
|
user: { name: "Dotta", email: "dotta@example.test" },
|
|
});
|
|
res.setHeader("content-type", "application/json");
|
|
res.setHeader("set-cookie", "workspace.session_token=abc; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax");
|
|
res.writeHead(200);
|
|
const bytes = new TextEncoder().encode(body);
|
|
res.write(bytes.subarray(0, 16));
|
|
res.write(bytes.subarray(16));
|
|
res.end();
|
|
});
|
|
app.get("/api/uint8-json", (req, res) => {
|
|
const body = JSON.stringify(issueListFixture(Number(req.query.count ?? 1)));
|
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
res.end(new TextEncoder().encode(body));
|
|
});
|
|
return app;
|
|
}
|
|
|
|
describe("API compression middleware", () => {
|
|
it("compresses the hot 500-item issue-list response with gzip when the client supports it", async () => {
|
|
const uncompressed = await requestRaw(buildApp(), "/api/companies/company-1/issues?limit=500");
|
|
const compressed = await requestRaw(buildApp(), "/api/companies/company-1/issues?limit=500", {
|
|
"accept-encoding": "gzip",
|
|
});
|
|
|
|
expect(uncompressed.statusCode).toBe(200);
|
|
expect(compressed.statusCode).toBe(200);
|
|
expect(compressed.headers["content-encoding"]).toBe("gzip");
|
|
expect(compressed.headers["vary"]).toContain("Accept-Encoding");
|
|
expect(JSON.parse(gunzipSync(compressed.body).toString("utf8"))).toHaveLength(500);
|
|
expect(compressed.body.byteLength).toBeLessThan(uncompressed.body.byteLength / 5);
|
|
});
|
|
|
|
it("uses deflate when that is the supported content encoding", async () => {
|
|
const res = await requestRaw(buildApp(), "/api/companies/company-1/issues?limit=500", {
|
|
"accept-encoding": "deflate",
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-encoding"]).toBe("deflate");
|
|
expect(JSON.parse(inflateSync(res.body).toString("utf8"))).toHaveLength(500);
|
|
});
|
|
|
|
it("keeps small JSON responses uncompressed for compatibility", async () => {
|
|
const res = await requestRaw(buildApp(), "/api/small", {
|
|
"accept-encoding": "gzip, deflate",
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-encoding"]).toBeUndefined();
|
|
expect(JSON.parse(res.body.toString("utf8"))).toEqual({ ok: true });
|
|
});
|
|
|
|
it("does not double-compress responses that already set Content-Encoding", async () => {
|
|
const res = await requestRaw(buildApp(), "/api/encoded", {
|
|
"accept-encoding": "gzip, deflate",
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-encoding"]).toBe("deflate");
|
|
expect(JSON.parse(inflateSync(res.body).toString("utf8"))).toEqual({ already: "encoded" });
|
|
});
|
|
|
|
it("weakens strong ETag validators on compressed JSON responses", async () => {
|
|
const res = await requestRaw(buildApp(), "/api/etag", {
|
|
"accept-encoding": "gzip",
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-encoding"]).toBe("gzip");
|
|
expect(res.headers.etag).toBe("W/\"fixture-etag\"");
|
|
expect(JSON.parse(gunzipSync(res.body).toString("utf8"))).toHaveLength(500);
|
|
});
|
|
|
|
it("passes streamed non-JSON responses through without compression", async () => {
|
|
const res = await requestRaw(buildApp(), "/api/download", {
|
|
"accept-encoding": "gzip, deflate",
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-encoding"]).toBeUndefined();
|
|
expect(res.body.toString("utf8")).toBe("chunk-one:chunk-two");
|
|
});
|
|
|
|
it("passes streamed JSON attachment downloads through without compression", async () => {
|
|
const res = await requestRaw(buildApp(), "/api/json-download", {
|
|
"accept-encoding": "gzip, deflate",
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-disposition"]).toBe("attachment; filename=\"issues.json\"");
|
|
expect(res.headers["content-encoding"]).toBeUndefined();
|
|
expect(JSON.parse(res.body.toString("utf8"))).toHaveLength(500);
|
|
});
|
|
|
|
it("delivers small writeHead+Uint8Array auth responses byte-for-byte", async () => {
|
|
const res = await requestRaw(buildApp(), "/api/auth-bridge", {
|
|
"accept-encoding": "gzip, deflate",
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["set-cookie"]).toBeDefined();
|
|
expect(res.headers["content-encoding"]).toBeUndefined();
|
|
expect(JSON.parse(res.body.toString("utf8")).user.email).toBe("dotta@example.test");
|
|
});
|
|
|
|
it("does not drop the connection for large writeHead+Uint8Array auth responses", async () => {
|
|
const res = await requestRaw(buildApp(), "/api/auth-bridge?pad=2000", {
|
|
"accept-encoding": "gzip, deflate",
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-encoding"]).toBeUndefined();
|
|
const parsed = JSON.parse(res.body.toString("utf8"));
|
|
expect(parsed.token).toHaveLength(2000);
|
|
expect(parsed.user.email).toBe("dotta@example.test");
|
|
});
|
|
|
|
it("compresses large Uint8Array bodies without corrupting them", async () => {
|
|
const res = await requestRaw(buildApp(), "/api/uint8-json?count=500", {
|
|
"accept-encoding": "gzip",
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-encoding"]).toBe("gzip");
|
|
expect(JSON.parse(gunzipSync(res.body).toString("utf8"))).toHaveLength(500);
|
|
});
|
|
});
|