Compression

app.Use(muzak.Compress(muzak.CompressionOptions{}))

That is the whole installation. The middleware negotiates an encoding from Accept-Encoding, compresses the bodies worth compressing, and records Vary on every response either way.

Install it with App.Use, which puts it inside the built-in chain, so it already has a request identifier and is already covered by panic recovery.

What is negotiated

gzip is preferred over deflate, and an explicit refusal such as gzip;q=0 is honoured. A client that asks for neither gets an uncompressed body.

deflate is answered in the zlib format, which is what RFC 9110 defines the coding to be: zlib around DEFLATE. The bare DEFLATE stream sent before was refused by strict decoders. A client that decodes it as raw DEFLATE, which was only ever a lenient guess, should decode zlib; every conforming client already does.

A quality is read only between 0 and 1. q=NaN, q=Inf and q=1e9 used to parse as valid, so an infinity outranked every honest range and NaN made the ordering meaningless. Now NaN is unreadable (one, as before), anything above one is one, and a negative value is a refusal.

Vary: Accept-Encoding is set on every response whether or not it was compressed, so a cache cannot hand a compressed body to a client that cannot read it.

What is left alone

A response that......is not compressed
is smaller than MinSizea body that already fits in one packet cannot arrive sooner by shrinking
carries a media type that is not text-likean image, a video or an archive is already compressed
the handler encoded itselfa Content-Encoding is already set
carries no bodythere is nothing to compress
is a rangea compressed range is not the range that was asked for
is an event streamholding events in a compressor's window until something forces them out is the one thing a stream cannot survive

muzak.DefaultCompressionMinSize is 1400 bytes, a little under one ethernet MTU. A response that already fits in a single packet cannot be made to arrive sooner by shrinking it, and compressing it spends CPU on both ends to save nothing.

Entity tags and ranges

A compressed response is a different representation from the one the handler described, and its validators are adjusted to say so.

A strong ETag is weakened, "v1" to W/"v1", on a compressed 200 and on a 304 answering a client that negotiated an encoding. A cache can then use the 304 to refresh the response it stored under the weak tag, which a strong tag in the 304 would not let it do. The weak tag still corresponds to a stored response that was not compressed, because tags correspond by weak comparison.

The tag need not be one the handler set. muzak.AutoETag() gives a route a strong tag computed from its body and answers a client that already holds the body with 304. The two compose: Compress weakens the tag of a body it compresses, and AutoETag compares If-None-Match weakly, so the weakened tag a client stored still earns the 304. See Files, Streams and Redirects.

If-Match is compared strongly, so a weak tag in it never matches, and a client that read a compressed response would have every conditional write refused with 412. The W/ is therefore taken off the tags of an incoming If-Match before the handler sees it. A handler that compares as RFC 9110 asks gets the right answer. One that issues weak tags of its own and compares If-Match by string equality, which RFC 9110 does not allow, now sees their strong form, and should compare the opaque tag instead.

A compressed response does not advertise Accept-Ranges, since no range of the bytes it carries can be asked for. A Range request is answered from the uncompressed content, uncompressed. When an encoding was negotiated, a Range whose If-Range carries a date or a weak tag is answered with the whole content instead. A date cannot say whether the client holds the compressed bytes or the uncompressed ones, and a client appending a range of one to the other ends up with neither; a weak tag never matches If-Range at all. A Range with no If-Range, or with a strong tag, is still answered with an uncompressed range. A client that resumes a download through Compress sends a strong ETag in If-Range, or asks for no encoding along with its Range, as Go's client and most download tools already do.

Options

app.Use(muzak.Compress(muzak.CompressionOptions{
    Level:        muzak.CompressionBest,
    MinSize:      2048,
    ContentTypes: []string{"text/", "application/json", "+json", "image/svg+xml"},
}))
FieldDefaultEffect
LevelCompressionDefaultHow hard the compressor works
MinSizeDefaultCompressionMinSizeThe smallest body worth compressing. A response whose length is not known in advance is buffered up to this size before the decision is made
ContentTypesa built-in list of text-like typesWhich media types are compressed
LevelFor
CompressionDefaultBalances speed against size, which is the right choice until a measurement says otherwise
CompressionFastestA service that is CPU bound, or serving large bodies to a fast network
CompressionBestA service whose clients are on slow or metered connections

There are three levels rather than an integer because the underlying range has invalid values in it and there is nothing useful to do with a level of 42 at run time.

An entry in ContentTypes is matched as a prefix, so text/ covers every text type, and an entry beginning with + matches a structured syntax suffix, so +json covers application/problem+json.

What it is worth

On the framework's own example application, compression takes 91% off the OpenAPI document and 68% off the documentation page. A JSON listing of any size behaves much the same. A small response, an image and an event stream are all untouched, which is the point of the exclusions above.

Compression and secrecy

Compression and secrecy interact badly, and it is worth knowing where.

When a response mixes a secret with something the client controls, its compressed length leaks how much the two have in common. That is what the BREACH attack recovers a token from, over many requests. Muzak's own responses do not mix the two, but a handler that reflects a query parameter back alongside a CSRF token does.

Where that is possible, either leave compression off for the route or stop reflecting the input.

Ordering with your own middleware

Middleware installed first is outermost, so a middleware that reports a duration sees the time spent compressing only if it is installed before the compressor.

// ProcessTime is outermost of the two, so the duration it reports includes the
// time spent compressing.
app.Use(core.ProcessTime())
app.Use(muzak.Compress(muzak.CompressionOptions{}))

Anything that wraps the response writer needs to implement Unwrap() http.ResponseWriter, or it will break flushing and hijacking for everything inside it. See Middleware. Compress implements Hijack itself, so a handler beneath it that takes the connection over through http.ResponseController gets the connection with nothing written onto it afterwards: no held header, no flushed bytes, and no compressor closed onto a socket that has stopped speaking HTTP.

The compressor reports a failed flush: it carries FlushError, so http.ResponseController can tell a stream that its client has gone, where a bare Flush() returned nothing whatever became of the bytes underneath. A panic below Compress is answered with a 500 and not an empty 200; its deferred finish used to send the pending header while the panic unwound, so recovery could no longer write its error.

Checking it

curl -s -H 'Accept-Encoding: gzip' -o /dev/null -D - http://localhost:8080/openapi.json
HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: gzip
Vary: Accept-Encoding
func TestOpenAPIIsCompressed(t *testing.T) {
    client := testclient.New(t, buildApp())

    res := client.Get("/openapi.json", testclient.Header("Accept-Encoding", "gzip"))

    res.AssertStatus(http.StatusOK)
    res.AssertHeader("Vary", "Accept-Encoding")
}

Where to go next

Static Files and Frontends covers the assets this makes smaller, and Server-Sent Events covers the one response kind compression skips on purpose.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.