diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 86a30ec5c900a..c6b4f005cbfcd 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -675,7 +675,7 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) { fs := http.FileServer(cfg.StaticFS) - fs = makeGzipHandler(fs) + fs = makeBrotliHandler(fs, cfg.StaticFS) fs = makeCacheHandler(fs, etag) http.StripPrefix("/web", fs).ServeHTTP(w, r) diff --git a/lib/web/brotlihandler.go b/lib/web/brotlihandler.go new file mode 100644 index 0000000000000..67b0ecd8f53cd --- /dev/null +++ b/lib/web/brotlihandler.go @@ -0,0 +1,69 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package web + +import ( + "io" + "mime" + "net/http" + "path" + "slices" + "strings" +) + +var compressedFileExtensions = []string{ + ".js", + ".svg", + ".wasm", +} + +// makeBrotliHandler serves pre-compressed .br files for supported file types. +func makeBrotliHandler(handler http.Handler, fs http.FileSystem) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ext := path.Ext(r.URL.Path) + isRequestForCompressedFile := slices.Contains(compressedFileExtensions, ext) + clientAcceptsBrotli := strings.Contains(r.Header.Get("Accept-Encoding"), "br") + if !isRequestForCompressedFile || !clientAcceptsBrotli { + handler.ServeHTTP(w, r) + return + } + + brPath := r.URL.Path + ".br" + brFile, err := fs.Open(brPath) + if err != nil { + handler.ServeHTTP(w, r) + return + } + defer brFile.Close() + + contentType := mime.TypeByExtension(ext) + if contentType == "" { + contentType = "application/octet-stream" // same default as http.DetectContentType + } + + w.Header().Set("Content-Encoding", "br") + w.Header().Set("Content-Type", contentType) + + if r.Method == http.MethodHead { + return + } + + io.Copy(w, brFile) + }) +} diff --git a/lib/web/gziphandler.go b/lib/web/gziphandler.go deleted file mode 100644 index d550a3392f66e..0000000000000 --- a/lib/web/gziphandler.go +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package web - -import ( - "compress/gzip" - "io" - "net/http" - "strings" - "sync" -) - -// writerPool is a sync.Pool for shared gzip writers. -// each gzip writer allocates a lot of memory -// so it makes sense to reset the writer and reuse the -// internal buffers to avoid too many objects on the heap -var writerPool = sync.Pool{ - New: func() any { - gz := gzip.NewWriter(io.Discard) - return gz - }, -} - -func newGzipResponseWriter(w http.ResponseWriter) gzipResponseWriter { - gz := writerPool.Get().(*gzip.Writer) - gz.Reset(w) - return gzipResponseWriter{gz: gz, ResponseWriter: w} -} - -type gzipResponseWriter struct { - gz *gzip.Writer - http.ResponseWriter -} - -// Write uses the Writer part of gzipResponseWriter to write the output. -func (w gzipResponseWriter) Write(b []byte) (int, error) { - _, haveType := w.Header()["Content-Type"] - // Explicitly set Content-Type if it has not been set previously - if !haveType { - // If no content type, apply sniffing algorithm to un-gzipped body. - w.Header().Set("Content-Type", http.DetectContentType(b)) - } - return w.gz.Write(b) -} - -// makeGzipHandler adds support for gzip compression for given handler. -func makeGzipHandler(handler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check if the client can accept the gzip encoding and that this is not an image asset. - if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") || isCompressedImageRequest(r) { - handler.ServeHTTP(w, r) - return - } - - // Set the HTTP header indicating encoding. - w.Header().Set("Content-Encoding", "gzip") - gzw := newGzipResponseWriter(w) - defer gzw.gz.Close() - handler.ServeHTTP(gzw, r) - }) -} - -// isCompressedImageRequest checks whether a request is for a png or jpg/jpeg -func isCompressedImageRequest(r *http.Request) bool { - return strings.HasSuffix(r.URL.Path, ".png") || strings.HasSuffix(r.URL.Path, ".jpg") || strings.HasSuffix(r.URL.Path, ".jpeg") -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5aba509bebae..dc9e5ed5c7895 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,6 +274,9 @@ importers: typescript-eslint: specifier: ^8.35.1 version: 8.35.1(eslint@9.30.0)(typescript@5.8.3) + vite-plugin-compression: + specifier: ^0.5.1 + version: 0.5.1(vite@6.3.5(@types/node@22.15.34)(yaml@2.8.0)) vite-plugin-wasm: specifier: ^3.4.1 version: 3.4.1(vite@6.3.5(@types/node@22.15.34)(yaml@2.8.0)) @@ -6539,6 +6542,11 @@ packages: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} + vite-plugin-compression@0.5.1: + resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==} + peerDependencies: + vite: '>=2.0.0' + vite-plugin-wasm@3.4.1: resolution: {integrity: sha512-ja3nSo2UCkVeitltJGkS3pfQHAanHv/DqGatdI39ja6McgABlpsZ5hVgl6wuR8Qx5etY3T5qgDQhOWzc5RReZA==} peerDependencies: @@ -13983,6 +13991,15 @@ snapshots: extsprintf: 1.4.1 optional: true + vite-plugin-compression@0.5.1(vite@6.3.5(@types/node@22.15.34)(yaml@2.8.0)): + dependencies: + chalk: 4.1.2 + debug: 4.4.1 + fs-extra: 10.1.0 + vite: 6.3.5(@types/node@22.15.34)(yaml@2.8.0) + transitivePeerDependencies: + - supports-color + vite-plugin-wasm@3.4.1(vite@6.3.5(@types/node@22.15.34)(yaml@2.8.0)): dependencies: vite: 6.3.5(@types/node@22.15.34)(yaml@2.8.0) diff --git a/web/README.md b/web/README.md index e21b75e107c23..05cef0df8f558 100644 --- a/web/README.md +++ b/web/README.md @@ -39,7 +39,12 @@ To build the Teleport open source version pnpm build-ui-oss ``` -The resulting output will be in the `webassets` folder. +The resulting output will be in the `webassets` folder. By default, the webassets are compressed with Brotli. If you +want to disable this for faster local builds, set the environment variable `VITE_DISABLE_COMPRESSION` to any value: + +``` +VITE_DISABLE_COMPRESSION=1 pnpm build-ui-oss +``` ### Docker Build diff --git a/web/packages/build/package.json b/web/packages/build/package.json index 249cfbe5926ec..c0b735448e296 100644 --- a/web/packages/build/package.json +++ b/web/packages/build/package.json @@ -34,6 +34,7 @@ "jsdom": "^26.1.0", "rollup-plugin-visualizer": "^6.0.3", "typescript-eslint": "^8.35.1", + "vite-plugin-compression": "^0.5.1", "vite-plugin-wasm": "^3.4.1", "vite-tsconfig-paths": "^5.1.4" } diff --git a/web/packages/build/vite/config.ts b/web/packages/build/vite/config.ts index dec8a64bb7581..77d2f962d1e1f 100644 --- a/web/packages/build/vite/config.ts +++ b/web/packages/build/vite/config.ts @@ -21,6 +21,7 @@ import { resolve } from 'path'; import { visualizer } from 'rollup-plugin-visualizer'; import { defineConfig, type UserConfig } from 'vite'; +import compression from 'vite-plugin-compression'; import wasm from 'vite-plugin-wasm'; import { generateAppHashFile } from './apphash'; @@ -98,6 +99,18 @@ export function createViteConfig( if (mode === 'production') { config.base = '/web'; + + if (!process.env.VITE_DISABLE_COMPRESSION) { + config.plugins.push( + compression({ + algorithm: 'brotliCompress', + deleteOriginFile: true, + filter: /\.(js|svg|wasm)$/, + threshold: 1024 * 10, // 10KB + verbose: false, + }) + ); + } } else { config.plugins.push(htmlPlugin(target)); // siteName matches everything between the slashes.