Skip to content

Commit c55311b

Browse files
badjerclaude
andauthored
fix: defer writeHead to prevent Content-Length truncation in payment rewriter (#161)
* fix: defer writeHead in payment response rewriter to prevent Content-Length truncation @hono/node-server's responseViaCache sets Content-Length from the original body size and calls writeHead before res.end. When the payment response rewriter intercepts res.end and swaps in a larger rewritten body (with full x402/mpp challenge data), the Content-Length already on the wire reflects the original smaller body. The client reads only that many bytes, truncating the JSON and causing a parse error. Fix: defer writeHead until res.end fires, then update Content-Length to match the (potentially rewritten) body before flushing both. For SSE (res.write before res.end), the deferred writeHead flushes at the first write call — no Content-Length conflict since SSE doesn't use the responseViaCache path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add writeHead deferral tests for JSON and SSE response paths Tests installPaymentResponseRewriter by simulating @hono/node-server's exact call patterns: - JSON path: writeHead(status, {Content-Length}) then end(body) - SSE path: writeHead(status, headers) then write(chunk)... then end() Coverage: - Content-Length updated to match rewritten body size - Content-Length preserved when no rewrite occurs (no challenge, non-matching body) - writeHead(status, statusMessage, headers) three-arg form - end() without writeHead (implicit headers) - Buffer body handling - Hook restoration after end() - SSE: writeHead flushed on first write, not duplicated - SSE: payment error chunks rewritten, normal chunks unchanged - SSE: only error chunk rewritten in multi-chunk stream - Content-Length === actual body byte length invariant Also exports installPaymentResponseRewriter for direct unit testing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ab410f0 commit c55311b

2 files changed

Lines changed: 426 additions & 1 deletion

File tree

packages/atxp-express/src/atxpExpress.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,11 @@ export function atxpExpress(args: ATXPArgs): Router {
165165
* Old clients: see JSON-RPC error with code -30402 → Branch 1 matches
166166
* New clients: see JSON-RPC error with code -30402 + full error.data → x402/mpp works
167167
*/
168-
function installPaymentResponseRewriter(res: Response, logger: import("@atxp/common").Logger): void {
168+
/** @internal Exported for testing only. */
169+
export function installPaymentResponseRewriter(res: Response, logger: import("@atxp/common").Logger): void {
169170
const origEnd = res.end;
170171
const origWrite = res.write;
172+
const origWriteHead = res.writeHead;
171173

172174
// Rewrite helper shared by both res.write and res.end hooks.
173175
// tryRewritePaymentResponse handles both SSE (data: lines) and plain JSON.
@@ -183,9 +185,30 @@ function installPaymentResponseRewriter(res: Response, logger: import("@atxp/com
183185
return tryRewritePaymentResponse(body, challenge, logger) ?? chunk;
184186
}
185187

188+
// Defer writeHead until res.end so we can update Content-Length after
189+
// rewriting the body. @hono/node-server's responseViaCache sets
190+
// Content-Length from the original (pre-rewrite) body size, then calls
191+
// writeHead before end. Without deferring, the client receives the
192+
// original Content-Length but the rewritten (larger) body, causing
193+
// JSON truncation.
194+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
195+
let deferredWriteHead: any[] | null = null;
196+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
197+
res.writeHead = function writeHeadDeferred(this: Response, ...args: any[]): any {
198+
deferredWriteHead = args;
199+
return this;
200+
} as any;
201+
202+
function flushWriteHead(self: Response): void {
203+
if (!deferredWriteHead) return;
204+
(origWriteHead as any).apply(self, deferredWriteHead);
205+
deferredWriteHead = null;
206+
}
207+
186208
// Hook res.write for SSE streaming responses.
187209
// eslint-disable-next-line @typescript-eslint/no-explicit-any
188210
res.write = function writeWithPaymentRewrite(this: Response, ...args: any[]): any {
211+
flushWriteHead(this);
189212
args[0] = rewriteChunk(args[0]);
190213
return (origWrite as any).apply(this, args);
191214
} as any;
@@ -195,7 +218,31 @@ function installPaymentResponseRewriter(res: Response, logger: import("@atxp/com
195218
res.end = function endWithPaymentRewrite(this: Response, ...args: any[]): any {
196219
res.end = origEnd;
197220
res.write = origWrite;
221+
res.writeHead = origWriteHead;
198222
args[0] = rewriteChunk(args[0]);
223+
224+
// Update Content-Length in deferred writeHead to match the rewritten body.
225+
if (deferredWriteHead) {
226+
const newBody = args[0];
227+
if (newBody != null) {
228+
const newLength = typeof newBody === 'string'
229+
? Buffer.byteLength(newBody)
230+
: Buffer.isBuffer(newBody)
231+
? newBody.length
232+
: undefined;
233+
if (newLength !== undefined) {
234+
// writeHead(statusCode, headers) or writeHead(statusCode, statusMessage, headers)
235+
const headersIdx = typeof deferredWriteHead[1] === 'string' ? 2 : 1;
236+
const headers = deferredWriteHead[headersIdx];
237+
if (headers && typeof headers === 'object') {
238+
headers['Content-Length'] = newLength;
239+
}
240+
}
241+
}
242+
(origWriteHead as any).apply(this, deferredWriteHead);
243+
deferredWriteHead = null;
244+
}
245+
199246
return (origEnd as any).apply(this, args);
200247
} as any;
201248
}

0 commit comments

Comments
 (0)