You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Non-blocking mechanism to transform request/response body streams #192
Hi @avarayr, I saw you left a comment on a PR (#145, comment now removed?) relating to this, so I thought I'd file a proper issue to track the suggestion.
I can definitely see use cases for transforming streams of content, and I'm open to ideas. This is indeed not supported by the current APIs, which instead block for the body before running the callbacks every time. The PR you commented on isn't really related to this as such, but it's part of a general improvement in support for streaming overall.
Do you have any thoughts on how you'd like this to work? If you can share more information about the kind of transform you're interested in that would be useful.
A relatively simple option would be to add a new kind of RequestTransform/ResponseTransform option, for declarative transformations instead of a callback, but whether that's possible depends on the kinds of transform you'd be doing.
Alternatively, there are certainly ways to modify the existing callback approach to support this, it's just a question of tradeoffs. I'm currently in the process of preparing some big changes for Mockttp v4 though, so if there are breaking changes required for this then now is the time to make them.
Thank you for this @pimterry and thank you for this amazing well-tested library.
I ended up forking and hacking up a solution that fit my usecase, here's how I did it.
request-handlers.ts
// definitionsexportinterfacePassThroughResponseStreamextendsOmit<PassThroughResponse,"body"|"rawHeaders">{body: Readable;}exportinterfaceCompletedRequestextendsRequest{body: CompletedBody;rawTrailers: RawTrailers;trailers: Trailers;}publicreadonly onStreamingResponse?: (res: PassThroughResponseStream,req: CompletedRequest,)=>MaybePromise<CallbackResponseStreamResult|void>|void;// request-handlers.ts/** * Check if we need to transform the response */if(this.onStreamingResponse){letresTransformStream: CallbackResponseStreamResult|void;letserverHeaders=rawHeadersToObject(serverRawHeaders);letreqHeader=rawHeadersToObjectPreservingCase(rawHeaders);resTransformStream=awaitthis.onStreamingResponse({id: clientReq.id,statusCode: serverStatusCode,statusMessage: serverRes.statusMessage,headers: serverHeaders,body: decodeBodyStream(serverRes,serverHeaders),// decode the body for convenience},{id: clientReq.id,protocol: protocol?.replace(":","")??"",method: method,httpVersion: serverRes.httpVersion,url: clientReq.url,// original, unmodified URLmodifiedReqUrl: clientReq.url!==reqUrl ? reqUrl : undefined,// modified URLdestination: {hostname: hostname||"localhost",port: effectivePort,},path: path??"",headers: reqHeader,rawHeaders: rawHeaders,timingEvents: clientReq.timingEvents,tags: clientReq.tags,body: buildBodyReader(reqBodyOverride ? Buffer.from(reqBodyOverride.buffer) : awaitclientReq.body.asDecodedBuffer(),reqHeader,),rawTrailers: clientReq.rawTrailers??[],trailers: rawHeadersToObject(clientReq.rawTrailers??[]),},);if(!resTransformStream){// what are we supposed to do here?serverRes.pipe(clientRes);resolve();return;}if(resTransformStream==="close"||resTransformStream==="reset"){// If you kill the connection, we need to fire an upstream event separately here, since// this means the body won't be delivered in normal response events.if(options.emitEventCallback){options.emitEventCallback!("passthrough-response-body",{overridden: true,rawBody: undefined,});}if(resTransformStream==="close"){(clientReqasany).socket.end();}elseif(resTransformStream==="reset"){requireSocketResetSupport();resetOrDestroy(clientReq);}thrownewAbortError(`Connection ${resTransformStream==="close" ? "closed" : "reset"} intentionally by rule`,`E_RULE_BRES_${resTransformStream.toUpperCase()}`,);}validateCustomHeaders(serverHeaders,resTransformStream?.headers);serverStatusCode=resTransformStream?.statusCode||serverStatusCode;serverStatusMessage=resTransformStream?.statusMessage||serverStatusMessage;serverHeaders=resTransformStream?.headers||serverHeaders;// delete the content-encoding to avoid needless CPU usage// there's no need to encode again if the downstream client is perfectly happy with raw bytesdeleteserverHeaders["content-encoding"];/** * Check if the .body is a buffer * If it's a buffer, we have the entire response ready to go, we can calculate the content-length * And pass it down immediately */if(Buffer.isBuffer(resTransformStream.body)){serverHeaders["content-length"]=getContentLengthAfterModification(resTransformStream.body,serverRes.headers,resTransformStream?.headers,method==="HEAD",// HEAD responses are allowed mismatched content-length);// rebuild raw headers after content length modificationserverRawHeaders=objectHeadersToRaw(serverHeaders);writeHead(clientRes,serverStatusCode,serverStatusMessage,serverRawHeaders.filter(([key,value])=>{if(key===":status")returnfalse;if(!validateHeader(key,value)){console.warn(`Not forwarding invalid header: "${key}: ${value}"`);// Nothing else we can do in this case regardless - setHeaders will// throw within Node if we try to set this value.returnfalse;}returntrue;}),);// Return the override data to the client:clientRes.end(resTransformStream.body);// Dump the real response data, in case that body wasn't read yet:serverRes.resume();resolve();return;}else{/** * Body is a stream | undefined * Write the modified headers and pipe the stream to client * * Because we're potentially transforming the body, * we're unsure of the content-length, so we don't set it */deleteserverHeaders["content-length"];// get the raw headersserverRawHeaders=objectHeadersToRaw(serverHeaders);// send the headers firstwriteHead(clientRes,serverStatusCode,serverStatusMessage,serverRawHeaders.filter(([key,value])=>{if(key===":status")returnfalse;if(!validateHeader(key,value)){console.warn(`Not forwarding invalid header: "${key}: ${value}"`);// Nothing else we can do in this case regardless - setHeaders will// throw within Node if we try to set this value.returnfalse;}returntrue;}),);resTransformStream.body.pipe(clientRes);resTransformStream.body.once("end",resolve);return;}}
Couple important notes to consider:
This comes before calling beforeResponse and transformResponse, most of the body of the code is essentially copy-pasted from beforeResponse, with several modifications to avoid buffering response
Since we're dealing with transform streams, I'm not sure how we can reliably calculate the resulting Content-Length... I think we can't. Therefore I'm just deleting the Content-Length (since the HTTP RFC seems to allow omitting it, and clients will just read the response until connection closes.
I'm deleting the Content-Encoding, thus the downstream client will receive decoded content. This might be a questionable decision, but in my use case, I need to achieve low latency, and re-encoding seems to be unnecessary.
I also had to write a custom decodeBodyStream function because I couldn't find support for it in the @httptoolkit/http-encoding
request-utils.ts
exportfunctiondecodeBodyStream(stream: stream.Readable,headers: Headers|RawHeaders){constcontentEncoding=getHeaderValue(headers,"content-encoding");if(!contentEncoding)returnstream;letencodingLayers: string[]=[];if(contentEncoding){constheaderValue=Array.isArray(contentEncoding) ? contentEncoding.join(",") : String(contentEncoding);encodingLayers=headerValue.split(",").map((e)=>e.trim().toLowerCase());}if(encodingLayers.length>0){// Apply decompression layers in orderfor(constencofencodingLayers){if(enc==="gzip"||enc==="x-gzip"){stream=stream.pipe(zlib.createGunzip());}elseif(enc==="deflate"){stream=stream.pipe(zlib.createInflate());}elseif(enc==="br"||enc==="brotli"){stream=stream.pipe(zlib.createBrotliDecompress());}elseif(enc==="zstd"){if(typeofzlib.createZstdDecompress==="function"){stream=stream.pipe(zlib.createZstdDecompress());}else{// i had a weird edge case where zstd was not available in a particular electron version. not sure what happened thereconsole.warn("Skipping Zstd streaming decompression: zlib.createZstdDecompress not available");}}}}returnstream;}exportfunctionencodeBodyStream(stream: stream.Readable,headers: Headers|RawHeaders){constcontentEncoding=getHeaderValue(headers,"content-encoding");if(!contentEncoding)returnstream;letencodingLayers: string[]=[];if(contentEncoding){constheaderValue=Array.isArray(contentEncoding) ? contentEncoding.join(",") : String(contentEncoding);encodingLayers=headerValue.split(",").map((e)=>e.trim().toLowerCase());}if(encodingLayers.length>0){// Apply decompression layers in orderfor(constencofencodingLayers){if(enc==="gzip"||enc==="x-gzip"){stream=stream.pipe(zlib.createGzip());}elseif(enc==="deflate"){stream=stream.pipe(zlib.createDeflate());}elseif(enc==="br"||enc==="brotli"){stream=stream.pipe(zlib.createBrotliCompress());}elseif(enc==="zstd"){if(typeofzlib.createZstdCompress==="function"){stream=stream.pipe(zlib.createZstdCompress());}else{console.warn("Skipping Zstd streaming compression: zlib.createZstdCompress not available");}}}}returnstream;}
Activity
avarayr commentedon May 13, 2025
Thank you for this @pimterry and thank you for this amazing well-tested library.
I ended up forking and hacking up a solution that fit my usecase, here's how I did it.
request-handlers.ts
Couple important notes to consider:
This comes before calling
beforeResponseandtransformResponse, most of the body of the code is essentially copy-pasted frombeforeResponse, with several modifications to avoid buffering responseSince we're dealing with transform streams, I'm not sure how we can reliably calculate the resulting
Content-Length... I think we can't. Therefore I'm just deleting theContent-Length(since the HTTP RFC seems to allow omitting it, and clients will just read the response until connection closes.I'm deleting the
Content-Encoding, thus the downstream client will receive decoded content. This might be a questionable decision, but in my use case, I need to achieve low latency, and re-encoding seems to be unnecessary.avarayr commentedon May 13, 2025
I also had to write a custom
decodeBodyStreamfunction because I couldn't find support for it in the@httptoolkit/http-encodingrequest-utils.ts