perf(server): negotiate permessage-deflate on the websocket#4705

merged3
main
t3code/audit-client-payloads
openedJul 28, 2026byTtheo
View on GitHubTwo-way sync enabledPR last imported 19d ago

Summary

Enables WebSocket compression by patching @effect/platform-node and @effect/platform-bun to accept permessage-deflate from clients that offer it. This is the first item from the WS data-reduction audit (follow-up to #4622).

  • Backwards compatible per-connection: the negotiation is the standard Sec-WebSocket-Extensions handshake. Browsers offer the extension automatically, so web/desktop get compressed frames with no client changes; any client that doesn't offer it (old versions, exotic stacks) silently keeps uncompressed frames on its connection.
  • Context takeover stays enabled (ws default on Node; dedicated compressors on Bun), so the per-connection compression window is shared across frames โ€” that's where the win comes from on streaming traffic of small JSON frames full of repeated keys and UUIDs. Node also sets a 512-byte threshold so tiny frames skip compression.
  • Shipped as pnpm patch entries consistent with the existing patches/ setup (we already patch effect@4.0.0-beta.102). Worth an upstream PR to expose this as a layer option so the patches can eventually be dropped.

Measurements

Replayed 500 real thread.activity-appended frames from a production database over the wire, counting raw TCP bytes:

TrafficPlainDeflateRatio
Pruned (post-#4622) live frames344,330 B85,855 B4.0ร—
Unpruned frames903,884 B243,625 B3.7ร—

Large cold-open snapshot frames compress closer to the ~10ร— measured for gzip on the same content.

Testing

  • New test in apps/server/src/server.test.ts boots the real server stack and asserts: (1) a socket offering permessage-deflate gets it accepted in the 101 response, (2) a socket not offering it still connects and works uncompressed.
  • Full apps/server/src/server.test.ts suite passes (115/115); t3 typecheck clean.

Follow-ups (not in this PR)

  • CPU load-check under a real streaming turn with multiple connected clients.
  • Confirm whether React Native's socket offers the extension on iOS/Android; if not, mobile stays uncompressed (the planned profile: "lite" work is the mobile lever anyway).

๐Ÿค– Generated with Claude Code


Note

Medium Risk Changes the WebSocket transport for every connection (compression CPU and interoperability), though negotiation is standard and opt-in per client; dependency patches add upgrade/maintenance risk until upstream lands.

Overview Enables permessage-deflate on server WebSockets by patching @effect/platform-node and @effect/platform-bun (registered in pnpm-workspace.yaml / lockfile) until Effect 4.0.0-beta.103 exposes the same as layer options. There are no changes in apps/server runtime code beyond tests.

On Node, the patch turns on perMessageDeflate: true on the ws WebSocketServer. On Bun, it sets perMessageDeflate: { compress: "dedicated", decompress: "shared" } to negotiate compression while avoiding uWebSocketsโ€™ dedicated decompressor disconnect issue.

Clients that offer the extension get compressed frames; clients that do not stay on plain frames. A new server.test.ts case boots the real stack and asserts permessage-deflate appears in negotiated extensions only when the client requests it.

Reviewed by Cursor Bugbot for commit df9816cfca2239502bc676387999a241ede45791. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Negotiate permessage-deflate compression on WebSocket connections

  • Patches @effect/platform-bun to enable permessage-deflate with a dedicated compressor and shared decompressor in BunHttpServer.make.
  • Patches @effect/platform-node to pass perMessageDeflate: true to the WebSocketServer constructor in NodeHttpServer.make.
  • Both patches are registered in pnpm-workspace.yaml and applied during installation.
  • A new test in server.test.ts verifies the extension is negotiated when offered by the client and absent when not.
  • Behavioral Change: WebSocket connections that advertise permessage-deflate support will now use per-message compression, increasing CPU usage in exchange for reduced bandwidth.

Macroscope summarized df9816c.

Summary by CodeRabbit

  • New Features

    • Enabled WebSocket per-message compression when supported by the client.
    • Clients without compression support continue to connect normally without compressed frames.
  • Tests

    • Added coverage verifying compression negotiation during WebSocket handshakes.

Added 1 commit

  • perf(server): negotiate permessage-deflate on the websockete70433c
Synced from GitHub
๐Ÿšฅ Pre-merge checks | โœ… 5
โœ… Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverageโœ… PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues checkโœ… PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes checkโœ… PassedCheck skipped because no linked issues were found for this pull request.
Title checkโœ… PassedThe title clearly and concisely describes the main change: negotiating permessage-deflate for WebSocket connections.
Description checkโœ… PassedThe description covers what changed, why, testing, and follow-ups, though it does not follow the template headings exactly.
โœจ Finishing Touches
๐Ÿ“ Generate docstrings
  • Create stacked PR
  • Commit on current branch
๐Ÿงช Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/audit-client-payloads

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.

โค๏ธ Share

Comment @coderabbitai help to get the list of available commands.

patches/@effect__platform-node@4.0.0-beta.102.patchlines 37โ€“39Outdated ยท e70433cOpen thread
โ€ฆโ€ฆ@@ -0,0 +1,44 @@
36++ // shared across frames; threshold skips compression for tiny frames.
37++ perMessageDeflate: {
38++ threshold: 512
39++ }
MmacroscopeappBoton lines 37โ€“39Jul 28, 2026

๐ŸŸก Medium patches/@effect__platform-node@4.0.0-beta.102.patch:37

The threshold: 512 option does not skip compression for small messages โ€” every outgoing message is still compressed. In the ws library, perMessageDeflate.threshold only takes effect when context takeover is disabled, but this config leaves context takeover enabled (the ws default). So small streaming messages still incur zlib work and can grow on the wire, defeating the intended purpose of the threshold. Either set contextTakeover: false so the threshold actually applies, or remove the threshold and document the compression strategy explicitly.

suggestion
1 perMessageDeflate: {
2 contextTakeover: false,
3 threshold: 512
4 }
๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @patches/@effect__platform-node@4.0.0-beta.102.patch around lines 37-39:
2
3The `threshold: 512` option does not skip compression for small messages โ€” every outgoing message is still compressed. In the `ws` library, `perMessageDeflate.threshold` only takes effect when context takeover is disabled, but this config leaves context takeover enabled (the `ws` default). So small streaming messages still incur zlib work and can grow on the wire, defeating the intended purpose of the threshold. Either set `contextTakeover: false` so the threshold actually applies, or remove the `threshold` and document the compression strategy explicitly.

Good catch that threshold is dead with context takeover enabled โ€” confirmed in ws sender.js (rsv1 is only gated on _threshold when server_no_context_takeover was negotiated). Fixed in 6b327f0d0, but in the opposite direction from the suggestion: measured on real frames <512B from production data, contextTakeover: false + threshold gives 1.00x (no win โ€” each small frame compresses standalone or skips), while keeping takeover compresses those same frames 4.42x because they deflate against the shared window. So the fix removes the dead threshold and keeps takeover, with the comment corrected to document why.

Ccursor[bot]reviewed ยท 1 inline threadJul 28, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

โŒ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e70433cfff6192084bc4f51d08835b0b132bf526. Configure here.

patches/@effect__platform-bun@4.0.0-beta.102.patchline 16Outdated ยท e70433cOpen thread
โ€ฆโ€ฆ@@ -0,0 +1,38 @@
13++ perMessageDeflate: {
14++ compress: "dedicated",
15++ decompress: "dedicated"
16++ },
Ccursor[bot]on line 16Jul 28, 2026

Bun deflate can drop sockets

Medium Severity

The Bun patch enables perMessageDeflate with decompress: "dedicated", which matches a known Bun runtime bug: dedicated decompressors abort the TCP connection (close code 1006) when a client sends a valid BFINAL=1 DEFLATE block allowed by RFC 7692. Under Bun, some compressed client frames can therefore drop live websocket sessions after negotiation succeeds.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit e70433cfff6192084bc4f51d08835b0b132bf526. Configure here.

Legit โ€” Bun's WS server inherits uWebSockets' dedicated-decompressor path with the known BFINAL/1006 abort (uNetworking/uWebSockets.js#633). Fixed in 6b327f0d0: decompress switched to "shared" (clientโ†’server traffic is small RPC commands, per-connection windows don't matter there), compress: "dedicated" kept for the serverโ†’client direction where the compression win lives.

Synced from GitHub

Approvability

Verdict: Needs human review

This PR enables WebSocket compression (permessage-deflate) by patching @effect/platform dependencies. While well-documented, this is a significant runtime behavior change affecting all WebSocket connections that warrants human review.

You can customize Macroscope's approvability policy. Learn more.

Added 1 commit

  • fix(server): address deflate review findings from bots6b327f0
CcoderabbitaiBotreviewed ยท 1 inline threadJul 28, 2026
Synced from GitHub

Actionable comments posted: 1

๐Ÿงน Nitpick comments (1)
apps/server/src/server.test.ts (1)

3109-3109: ๐Ÿ“ Maintainability & Code Quality | ๐Ÿ”ต Trivial | ๐Ÿ—๏ธ Heavy lift

Add Bun-backed negotiation coverage.

This test provisions NodeHttpServer.layerTest, so it validates only the Node patch. Add an equivalent Bun integration/package-level check, or document a focused Bun verification path for the new compressor/decompressor modes.

๐Ÿค– Prompt for AI Agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4In `@apps/server/src/server.test.ts` at line 3109, Extend the coverage around the
5test using NodeHttpServer.layerTest to also exercise the equivalent Bun-backed
6server path, verifying negotiation for the new compressor/decompressor modes. If
7an equivalent integration test cannot be added here, document a focused Bun
8verification path that explicitly covers those modes.
๐Ÿค– Prompt for all review comments with AI agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4Inline comments:
5In `@apps/server/src/server.test.ts`:
6- Around line 3089-3100: Update the finalizer of openSocket to await the
7WebSocket close event after calling socket.close(), rather than completing
8immediately via Effect.sync. Add a bounded fallback so teardown still completes
9if the close event never arrives, while preserving the existing socket
10acquisition and error handling.
11
12---
13
14Nitpick comments:
15In `@apps/server/src/server.test.ts`:
16- Line 3109: Extend the coverage around the test using NodeHttpServer.layerTest
17to also exercise the equivalent Bun-backed server path, verifying negotiation
18for the new compressor/decompressor modes. If an equivalent integration test
19cannot be added here, document a focused Bun verification path that explicitly
20covers those modes.
๐Ÿช„ 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 56189bc4-a153-4c80-939f-41a4e366ea59

๐Ÿ“ฅ Commits

Reviewing files that changed from the base of the PR and between dd5ea324885aea4980a49bb42d28f83023c40ea7 and 6b327f0d009880be216cdf3b38fa0a8951532cbd.

โ›” Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
๐Ÿ“’ Files selected for processing (4)
  • apps/server/src/server.test.ts
  • patches/@effect__platform-bun@4.0.0-beta.102.patch
  • patches/@effect__platform-node@4.0.0-beta.102.patch
  • pnpm-workspace.yaml
apps/server/src/server.test.tslines 3089โ€“3100Outdated ยท 6b327f0Open thread
โ€ฆโ€ฆ@@ -3081,6 +3081,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3097+ socket.on("error", (error) => resume(Effect.fail(error)));
3098+ }),
3099+ (socket) => Effect.sync(() => socket.close()),
3100+ );
CcoderabbitaiBoton lines 3089โ€“3100Jul 28, 2026

๐Ÿฉบ Stability & Availability | ๐ŸŸก Minor | โšก Quick win

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

shell
1#!/bin/bash
2set -euo pipefail
3
4echo "Changed files/stat:"
5git diff --stat || true
6
7echo
8echo "Locate test file and relevant lines:"
9wc -l apps/server/src/server.test.ts
10sed -n '3060,3130p' apps/server/src/server.test.ts
11
12echo
13echo "Search for openSocket usages and related tests:"
14rg -n "openSocket|perMessageDeflate|NodeWs|NodeWS|layerTest|NodeServer|close event|Effect\.acquireRelease" apps/server/src/server.test.ts
15
16echo
17echo "Package WebSocket dependencies:"
18if [ -f package.json ]; then jq '.dependencies, .devDependencies' package.json | rg -i 'ws|`@ff-labs/fff-node`|bun|node' || true; fi
19fd -a 'package.json' . | while read -r f; do
20 echo "--- ${f#"$PWD/"}"
21 jq -r 'select(.dependencies or .devDependencies) as $d
22 | {"path": env.SUBJECT_FILE, "deps": ($d.dependencies // {}), "dev": ($d.devDependencies // {})}
23 | with_entries(select(.value|keys[]?|test("^(ws|`@ff-labs/fff-node`)$";"i")?))' "$f" || true
24done

Repository: pingdotgg/t3code

Length of output: 12844


๐Ÿ Script executed:

shell
1#!/bin/bash
2set -euo pipefail
3
4# Behavioral probe: if test workspace has ws installed, demonstrate close() starts handshake and close event arrives after it.
5# Otherwise print availability context without executing repository test code.
6echo "Node version:"
7node --version || true
8echo "Probe: ws availability"
9node - <<'JS'
10try {
11 const wsPkg = require('ws');
12 const server = require('ws');
13 console.log("ws version:", wsPkg.version);
14 const WebSocket = server.WebSocket;
15 const wss = new server.WebSocketServer({ port: 0 }, () => {
16 const url = `ws://localhost:${wss.address().port}`;
17 console.log("server url:", url);
18 const client = new WebSocket(url, { perMessageDeflate: false });
19 let events;
20 client.on("open", () => {
21 events.push(["open"]);
22 client.close();
23 });
24 client.on("close", (code, reason) => {
25 events.push(["close", code, reason && reason.toString()]);
26 console.log(JSON.stringify(events));
27 });
28 client.on("error", (err) => {
29 console.log("error", err.message);
30 });
31 console.log("events immediate after close init:", events);
32 });
33} catch (err) {
34 console.log("ws not available in node_modules:", err && err.message);
35}
36JS

Repository: pingdotgg/t3code

Length of output: 302


๐ŸŒ Web query:

ws WebSocket close event close method asynchronous close handshake documentation

๐Ÿ’ก Result:

The WebSocket closing handshake is a standardized mechanism defined in RFC 6455 to ensure that both peers are aware that the connection is being terminated, allowing for the graceful handling of pending data [1]. In the context of the popular Node.js ws library, the websocket.close([code[, reason]]) method initiates this closing handshake [2][3]. It is important to note that while the closing handshake is an asynchronous process in the WebSocket protocol, the ws library's close method itself is synchronous in terms of function executionโ€”it immediately triggers the protocol's closing sequence [2][3]. It does not return a Promise or take a completion callback to signal when the handshake has finished [2][3]. Instead, the completion of the handshake is signaled by the 'close' event, which is emitted once the connection has been fully closed [2][3][4]. Key distinctions regarding closing methods: 1. websocket.close: This initiates the graceful closing handshake [4]. It allows for pending messages to be sent before the handshake begins and waits for the corresponding close frame from the other peer [5][6]. 2. websocket.terminate: This is an alternative method that immediately destroys the underlying socket connection without performing the closing handshake [4]. It is typically used for error handling or when an immediate shutdown is required regardless of the state of pending data [7][4]. While libraries in other environments (such as.NET with CloseAsync or Boost.Beast with async_close) explicitly expose the asynchronous nature of the handshake via Promises or completion handlers [8][9], the standard Web API close and the Node.js ws library's close method are designed to be invoked as fire-and-forget actions that lead to a later 'close' event [5][3][6].

Citations:


Await the socketsโ€™ close events in the finalizer.

WebSocket.close() initiates an asynchronous closing handshake; wrapping it in Effect.sync lets the scope finish immediately after calling close(), before the close event signals the socket is actually terminated. Await the close event in the finalizer with a bounded termination fallback to avoid leaving unclosed WebSocket handles during teardown. (github.com)

๐Ÿค– Prompt for AI Agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4In `@apps/server/src/server.test.ts` around lines 3089 - 3100, Update the
5finalizer of openSocket to await the WebSocket close event after calling
6socket.close(), rather than completing immediately via Effect.sync. Add a
7bounded fallback so teardown still completes if the close event never arrives,
8while preserving the existing socket acquisition and error handling.

Added 1 commit

  • chore(server): note the upstream Effect PR inside the deflate patchesdf9816c

Update: the options passthrough was upstreamed and merged as Effect-TS/effect#6691; it ships in @effect/platform-node / @effect/platform-bun >= 4.0.0-beta.103 (queued in their open Version Packages PR). Both patch files now carry a comment pointing at the upstream PR and the removal path: on the next effect bump, delete the two patches and pass websocket: { perMessageDeflate: ... } through the layer options in apps/server/src/server.ts instead.