feat: native HTTP response compression#121
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
uWestJS Benchmark Results
|
062e5b4 to
8d85ed8
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/http/core/response.ts (2)
852-878:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win
stream()flushes pending chunks before deciding to compress — produces a mixed-encoding response.
this.flushChunks()runs unconditionally inside the leadingatomic(...)(Lines 858-864), which writes any bytes previously buffered bywrite()calls directly to uWS (and sends headers on the first chunk viawriteHead()). Only after that do we evaluatecreateCompressionStream(...). If a caller didres.write(...); res.stream(readable)with compression enabled, the early bytes go on the wire as plaintext, the compression stream then takes over for the rest, and the client receives a body it cannot decode (andcontent-encodingeither can no longer be set or is set on a partially-plaintext response).Either decide on compression before flushing (and route any pre-buffered chunks through the compression stream), or document/enforce that
write()is not supported before a compressedstream().🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/http/core/response.ts` around lines 852 - 878, The stream() method currently flushes buffered write() chunks via flushChunks() before determining compressibility, causing mixed-encoding responses; change the order so you call compressionHandler.createCompressionStream(this.req, this) before invoking atomic()/flushChunks(), and if a compression stream is returned route any buffered chunks (from flushChunks()/internal buffer) through the compression stream and into _streamCompressed(readable, compressStream) rather than writing them directly to uWS; if no compression stream is returned keep the existing atomic()/flushChunks() path and call _streamFromReadable(readable, totalSize).
1224-1272:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
send()compression error path can leak corrupt headers and crash the fallback.Three concrete problems in the compression branch:
- The
.catch(() => { ... })swallows the original error with no logging, which makes diagnosing real-world compression failures (corrupt stream, brotli not available, etc.) very hard. At minimum log via the configured logger.- By the time
compressBuffer()rejects,compressionHandlermay already have mutated the response (e.g., setcontent-encoding: gziporvary: accept-encoding). The fallback then writes the literal plaintext'Internal Server Error'whilecontent-encoding: gzipis still set, so the client sees a corrupt body. Reset/strip compression-related headers (content-encoding, and any pre-setcontent-length) before sending the 500.this.status(500)throws if headers have already been sent in the meantime (e.g., concurrent write or the compression handler partially writing). Guard with!this._headersSentlike other error paths in this file do.🛠️ Suggested adjustment
- this.compressionHandler - .compressBuffer(this.req, this, bodyBuffer) - .then((compressed) => this._sendInternal(compressed)) - .catch(() => { - this.sending = false; - if (!this.finished && !this.aborted) { - this.status(500); - // Bypass compression for error response to avoid recursion - this._sendInternal('Internal Server Error'); - } - }); + this.compressionHandler + .compressBuffer(this.req, this, bodyBuffer) + .then((compressed) => this._sendInternal(compressed)) + .catch((err) => { + // Log so failures aren't silently masked + // (use the framework logger if you have one wired in here) + // eslint-disable-next-line no-console + console.error('Response compression failed; falling back to 500', err); + this.sending = false; + if (this.finished || this.aborted || this._headersSent) return; + // Drop any headers the compression handler may have set so the + // plaintext fallback isn't tagged as gzip/br/etc. + this.removeHeader('content-encoding'); + this.removeHeader('content-length'); + this.status(500); + this._sendInternal('Internal Server Error'); + });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/http/core/response.ts` around lines 1224 - 1272, The compression error path in send() (where compressionHandler.compressBuffer(...) .catch(...)) swallows the error, can leave compression headers set and calls this.status(500) unsafely; update the catch to log the original error via the configured logger, clear any compression-related headers (at least 'content-encoding' and 'content-length' and optionally 'vary') before sending the fallback body, guard the this.status(500) call with if (!this._headersSent) to avoid throwing, and ensure this.sending is reset appropriately before calling _sendInternal('Internal Server Error') so state remains consistent (refer to methods/properties: send, compressionHandler.compressBuffer, _sendInternal, this.status, this._headersSent, this.sending).
🧹 Nitpick comments (1)
src/http/platform/uws-platform.adapter.ts (1)
54-54: 💤 Low valuePrefer a top‑level
import typeforCompressionOptions(matches the rest of this file).The inline
import('../../http/handlers/compression/compression-handler').CompressionOptionsworks, but every other type used here (e.g.,PlatformOptions,CorsOptions,RouteMetadata,StaticFileOptions) is brought in via top-levelimport type { ... } from '...'. The relative path is also unusually long given the file lives atsrc/http/platform/...; the canonical path is../handlers/compression/compression-handler(already used elsewhere in this PR).♻️ Suggested change
import type { StaticFileOptions } from '../handlers/static/static-file-handler'; +import type { CompressionOptions } from '../handlers/compression/compression-handler'; @@ - compress?: import('../../http/handlers/compression/compression-handler').CompressionOptions; + compress?: CompressionOptions;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/http/platform/uws-platform.adapter.ts` at line 54, Replace the inline type reference for compress with a top-level type import: add import type { CompressionOptions } from '../handlers/compression/compression-handler' (note the canonical relative path) and change the property declaration to use CompressionOptions directly; this matches the rest of the file's style and removes the long inline import expression for CompressionOptions in uws-platform.adapter.ts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/http/core/response.ts`:
- Around line 883-919: The _streamCompressed method sets this.activeStream =
readable but never registers the compressStream for abort cleanup, so when the
client aborts the compressStream (zlib) resources remain alive; update the code
to track and teardown compressStream on abort too — either set this.activeStream
to compressStream (or add a new this.activeCompressStream property) when
starting the stream in _streamCompressed and ensure the uwsRes.onAborted handler
(the existing abort cleanup that currently destroys this.activeStream) also
destroys the compressStream, and clear the tracked compressStream in the
onFinish/onError cleanup in _streamCompressed (references: _streamCompressed,
activeStream, compressStream, uwsRes.onAborted).
- Around line 1237-1253: The bug is that this.sending is set before
potentially-throwing serialization (JSON.stringify) in send(), which can leave
the Response locked; to fix, perform serialization of body (compute finalBody
via JSON.stringify or other branches) before setting this.sending, or wrap the
serialization in a try/catch that on any throw resets this.sending (and
rethrows) so the flag is not left true; locate the serialization block that
assigns finalBody and the this.sending assignment and either move the
this.sending = true below the serialization or add a try/finally around
serialization to clear sending on failure.
In `@src/http/interfaces/http-options.interface.ts`:
- Around line 147-164: The interface currently defines compress?:
CompressionOptions which mismatches the public docs that use compression; rename
the property to compression?: CompressionOptions in
src/http/interfaces/http-options.interface.ts and update any references/usages
(constructor args, destructuring, tests, consumers) that access the old compress
symbol so they read/write the new compression property, preserving the same
CompressionOptions type and JSDoc text/examples.
---
Outside diff comments:
In `@src/http/core/response.ts`:
- Around line 852-878: The stream() method currently flushes buffered write()
chunks via flushChunks() before determining compressibility, causing
mixed-encoding responses; change the order so you call
compressionHandler.createCompressionStream(this.req, this) before invoking
atomic()/flushChunks(), and if a compression stream is returned route any
buffered chunks (from flushChunks()/internal buffer) through the compression
stream and into _streamCompressed(readable, compressStream) rather than writing
them directly to uWS; if no compression stream is returned keep the existing
atomic()/flushChunks() path and call _streamFromReadable(readable, totalSize).
- Around line 1224-1272: The compression error path in send() (where
compressionHandler.compressBuffer(...) .catch(...)) swallows the error, can
leave compression headers set and calls this.status(500) unsafely; update the
catch to log the original error via the configured logger, clear any
compression-related headers (at least 'content-encoding' and 'content-length'
and optionally 'vary') before sending the fallback body, guard the
this.status(500) call with if (!this._headersSent) to avoid throwing, and ensure
this.sending is reset appropriately before calling _sendInternal('Internal
Server Error') so state remains consistent (refer to methods/properties: send,
compressionHandler.compressBuffer, _sendInternal, this.status,
this._headersSent, this.sending).
---
Nitpick comments:
In `@src/http/platform/uws-platform.adapter.ts`:
- Line 54: Replace the inline type reference for compress with a top-level type
import: add import type { CompressionOptions } from
'../handlers/compression/compression-handler' (note the canonical relative path)
and change the property declaration to use CompressionOptions directly; this
matches the rest of the file's style and removes the long inline import
expression for CompressionOptions in uws-platform.adapter.ts.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: e685faff-d3af-4a2e-9ec2-109d1edb7b3f
📒 Files selected for processing (4)
src/http/core/response.tssrc/http/interfaces/http-options.interface.tssrc/http/platform/uws-platform.adapter.tssrc/http/routing/route-registry.ts
8d85ed8 to
3799445
Compare
3799445 to
f614dab
Compare
Wire
CompressionHandlerintoUwsResponsesosend()andstream()automatically compress when the client accepts gzip/deflate/br and the response is above threshold.Scope
send(): async compress-then-write insideatomic()stream():readable.pipe(compressTransform).pipe(this)when applicableConfig
Resolves #119
Summary by CodeRabbit
New Features
Refactor