Files
tailscale/tsweb/compserve/zstd.go
T
James Tucker 6608b9a387 tsweb/compserve, client/web: add zstd, remove brotli for precompressed assets
Adds tsweb/compserve: content negotiation for precompressed static
variants, with a transcode-to-identity fallback for clients that do not
accept an encoding (including when the raw file is absent), and
CompressWriter, which live-compresses dynamic responses with zstd in its
fastest mode, streamed incrementally with no buffering. Negotiation is
q-value and wildcard aware (gzip;q=0 previously matched gzip).

client/web serves its prebuilt embedded assets through compserve,
replacing brotli with zstd; the embedded FS is wrapped in
tsweb/vcstime for conditional-request mod times. tsweb/compserve/gzip.go
keeps transitional serving of gzip variants from pre-zstd file systems
(such as the currently published web-client-prebuilt module):
passthrough to gzip-accepting clients, transcoded to identity otherwise;
it becomes inert once a zstd-only module is published.

util/zstdframe gains pooled GetDecoder and GetStreamingEncoder
(concurrency=1). util/precompress is now a build-time tool, generating
zstd variants only. cmd/tsconnect and cmd/build-webclient consume the
new precompress/compserve split. tsweb.AcceptsEncoding and
tsweb/tswebutil are removed; negotiation lives in compserve and the
deprecated shim had no callers. go.mod bumps web-client-prebuilt.

Also fixes a transcoding bug where http.ServeContent's size probe via
the promoted zstd.Decoder.WriteTo could report a zero length, serving
empty bodies.

Updates tailscale/corp#20099

Signed-off-by: James Tucker <james@tailscale.com>
2026-09-17 15:23:49 -07:00

82 lines
2.1 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package compserve
import (
"io"
"io/fs"
"github.com/klauspost/compress/zstd"
"tailscale.com/util/zstdframe"
)
// Zstd serves zstd-precompressed variants ("*.zst", as generated by
// util/precompress). [Encoding.Decompress] lets ServeFile transcode the
// variant for clients that do not accept zstd.
var Zstd = Encoding{
Token: "zstd",
Ext: ".zst",
Decompress: decompressZstd,
Compress: compressZstdLive,
}
// compressZstdLive wraps w in a streaming zstd compressor using the fastest
// compression level and a small window suitable for low-latency live
// compression. Close on the returned writer finishes the zstd frame and
// returns the encoder to the shared pool.
func compressZstdLive(w io.Writer) (io.WriteCloser, error) {
enc, put := zstdframe.GetStreamingEncoder(
zstdframe.FastestCompression,
zstdframe.MaxWindowSize(64<<10),
)
// Encoder.Reset (streaming) cannot fail; only Decoder.Reset can.
enc.Reset(w)
return &pooledZstdWriter{Encoder: enc, put: put}, nil
}
// pooledZstdWriter is a zstd.Encoder writing to the underlying writer;
// Close finishes the frame and returns the encoder to the shared pool.
type pooledZstdWriter struct {
put func()
closed bool
*zstd.Encoder
}
func (p *pooledZstdWriter) Close() error {
if p.closed {
return nil
}
p.closed = true
err := p.Encoder.Close()
p.put()
return err
}
// decompressZstd wraps f in a streaming zstd decompressor backed by the
// shared decoder pool in util/zstdframe (concurrency=1, so streaming decode
// neither spawns goroutines nor retains unbounded memory). It takes
// ownership of f.
func decompressZstd(f fs.File) (io.ReadCloser, error) {
dec, put := zstdframe.GetDecoder()
if err := dec.Reset(f); err != nil {
put()
f.Close()
return nil, err
}
return &pooledZstdReader{f: f, put: put, Decoder: dec}, nil
}
// pooledZstdReader is a zstd.Decoder reading from f; Close returns the
// decoder to the shared pool and closes f.
type pooledZstdReader struct {
f fs.File
put func()
*zstd.Decoder
}
func (p *pooledZstdReader) Close() error {
p.put()
return p.f.Close()
}