Bun.serve: honor perMessageDeflate compress and decompress set to false or "disable"
Problem
Bun.servewebsocketperMessageDeflate: { compress, decompress }ignoresfalseand"disable"for one direction.{ compress: "disable", decompress: "shared" }still sendsws.send(x, true)compressed (RSV1 set, 320 bytes go out as 8).{ compress: "shared", decompress: "disable" }still negotiates the extension and inflates inbound frames. All four mixed configs produce the same handshake astrue.- The cause is
src/runtime/server/WebSocketServerContext.rs:257-301. It ORs both directions into one uWSCompressOptionsbitfield. Off contributes 0, and uWS negotiates both directions whenever the field is nonzero. A second bug in the same parser:DECOMPRESS_TABLEmaps"3KB"to"256KB"to compressor constants, sodecompress: "8KB"sets compressor bits and leaves the inflater shared.
Fix
- Each direction is parsed on its own (
Noneabsent,Some(0)off).compressoff leaves the compressor bits zero.WebSocket::sendinpackages/bun-uws/src/WebSocket.hnow drops the compress hint when the context has no compressor bits, sows.send,ws.publishandserver.publishnever set RSV1. Inbound frames are still inflated. decompressoff turns the extension off. RFC 7692 has no way to accept the extension and refuse inbound compression.compresson withdecompressoff now throws atBun.serve()with a message that names the fix, instead of compressing both ways as before.src/uws/lib.rsgains theDEDICATED_DECOMPRESSOR_*constants fromPerMessageDeflate.h, andDECOMPRESS_TABLEmaps each size to an inflate window. Sizes above 32KB use the 32KB window (the deflate maximum).- Verified:
test/js/bun/websocket/websocket-server-rsv-frames.test.ts(11 new tests, 7 fail on bun 1.4.3). Alsowebsocket-server.test.ts(perMessageDeflate block),bun-serve-args.test.ts,20053.test.ts,29684.test.tsand the bun-types test.
Background
CompressOptionspacks the compressor in the low byte (windowBits << 4 | memLevel) and the decompressor in bits 8 to 11 (windowBits).SHARED_*is 1 in that byte.DISABLEDis 0 for the whole field.- RSV1 is the frame bit that marks a message as compressed.
send(message, compress)is a hint. uWS already drops it when the client did not offer the extension. { compress: "shared" }alone and{}behave as before: shared both ways, and off. An absent direction defaults to shared when the other is on.
Notes
Self-reviewed: 2 concerns raised, 2 addressed. The first draft turned the extension off for compress on with decompress off. The review found one public project with { compress: "dedicated", decompress: "disable" } that would lose compression silently, and found the DECOMPRESS_TABLE hole that let decompress: "8KB" defeat the compressor-bits check. This version throws for the first and fixes the table for the second.
Shapes considered for compress on with decompress off: negotiate and fail inbound RSV1 frames (breaks browsers, which compress whenever the extension is negotiated), negotiate and inflate anyway (keeps the option a no-op), turn the extension off (silent loss of compression), or throw. The throw is loud and the message says what to do.
Repro on bun 1.4.2 and main: a server with perMessageDeflate: JSON.parse(process.argv[2]) and message(ws) { ws.send("x".repeat(320), true) }, a raw client that offers permessage-deflate; client_max_window_bits and sends one deflated RSV1 text frame. For every mixed config the reply frame head is 0xc1 and the frame is 8 bytes, and the 101 carries permessage-deflate; client_no_context_takeover; server_no_context_takeover.
websocket-server.test.ts as a whole has tests that time out in this container on main too (the (benchmark) test starves its neighbors on a debug build). They do not use compression.
#41629 changes HttpResponse.h and WebSocketExtensions.h (dedicated compressor selection and RFC 7692 negotiation). This PR does not touch those files. The client_max_window_bits=13 assertion for decompress: "8KB" relies on the current client_max_window_bits handling, which that PR keeps.