From a95fd0ee826bbf113e075d48b7b2437071ed4de5 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:24:34 +0800 Subject: [PATCH] perf(webui): reduce cold-start payload (#5262) --- nanobot/webui/http_utils.py | 4 +- nanobot/webui/ws_http.py | 42 +++++++++++++---- tests/webui/test_static_assets.py | 72 +++++++++++++++++++++++++++++ webui/src/tests/vite-config.test.ts | 58 ++++++++++++++++++++++- webui/vite.config.ts | 69 ++++++++++++++++++++++++++- 5 files changed, 231 insertions(+), 14 deletions(-) create mode 100644 tests/webui/test_static_assets.py diff --git a/nanobot/webui/http_utils.py b/nanobot/webui/http_utils.py index c77033954..ed17c60ee 100644 --- a/nanobot/webui/http_utils.py +++ b/nanobot/webui/http_utils.py @@ -75,7 +75,7 @@ def host_for_url(host: str, port: int) -> str: return f"{host}:{port}" -def _accepts_gzip(value: str) -> bool: +def accepts_gzip(value: str) -> bool: wildcard_quality: float | None = None for item in value.split(","): name, *params = (part.strip() for part in item.split(";")) @@ -109,7 +109,7 @@ def http_json_response( ] if accept_encoding is not None: headers.append(("Vary", "Accept-Encoding")) - if len(body) >= _JSON_GZIP_MIN_BYTES and _accepts_gzip(accept_encoding): + if len(body) >= _JSON_GZIP_MIN_BYTES and accepts_gzip(accept_encoding): body = gzip.compress(body, compresslevel=_JSON_GZIP_LEVEL, mtime=0) headers.append(("Content-Encoding", "gzip")) headers.append(("Content-Length", str(len(body)))) diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 56f3a93dc..04dfed49d 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -36,6 +36,9 @@ from nanobot.webui.file_preview import ( file_preview_payload, ) from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload +from nanobot.webui.http_utils import ( + accepts_gzip as _accepts_gzip, +) from nanobot.webui.http_utils import ( case_insensitive_header as _case_insensitive_header, ) @@ -336,7 +339,10 @@ class GatewayHTTPHandler: # Static SPA serving if self.static_dist_path is not None: - response = self._serve_static(got) + response = self._serve_static( + got, + accept_encoding=_combined_list_header(request.headers, "Accept-Encoding"), + ) if response is not None: return response @@ -1143,7 +1149,12 @@ class GatewayHTTPHandler: # -- Static file serving ------------------------------------------------ - def _serve_static(self, request_path: str) -> Response | None: + def _serve_static( + self, + request_path: str, + *, + accept_encoding: str = "", + ) -> Response | None: assert self.static_dist_path is not None rel = request_path.lstrip("/") if not rel: @@ -1161,15 +1172,28 @@ class GatewayHTTPHandler: candidate = index else: return None - try: - body = candidate.read_bytes() - except OSError as e: - self._log.warning("static: failed to read {}: {}", candidate, e) - return _http_error(500, "Internal Server Error") ctype, _ = mimetypes.guess_type(candidate.name) if ctype is None: ctype = "application/octet-stream" - if ctype.startswith("text/") or ctype in {"application/javascript", "application/json"}: + utf8_text = ctype.startswith("text/") or ctype in { + "application/javascript", + "application/json", + } + compressible = utf8_text or ctype == "image/svg+xml" + response_path = candidate + extra_headers: list[tuple[str, str]] = [] + if compressible: + extra_headers.append(("Vary", "Accept-Encoding")) + gzip_candidate = candidate.with_name(f"{candidate.name}.gz") + if _accepts_gzip(accept_encoding) and gzip_candidate.is_file(): + response_path = gzip_candidate + extra_headers.append(("Content-Encoding", "gzip")) + try: + body = response_path.read_bytes() + except OSError as e: + self._log.warning("static: failed to read {}: {}", response_path, e) + return _http_error(500, "Internal Server Error") + if utf8_text: ctype = f"{ctype}; charset=utf-8" if candidate.name == "index.html": cache = "no-cache" @@ -1179,7 +1203,7 @@ class GatewayHTTPHandler: body, status=200, content_type=ctype, - extra_headers=[("Cache-Control", cache)], + extra_headers=[("Cache-Control", cache), *extra_headers], ) diff --git a/tests/webui/test_static_assets.py b/tests/webui/test_static_assets.py new file mode 100644 index 000000000..581e3b37e --- /dev/null +++ b/tests/webui/test_static_assets.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import gzip +from pathlib import Path +from unittest.mock import MagicMock + +from nanobot.webui.ws_http import GatewayHTTPHandler + + +def _handler(static_dist_path: Path) -> GatewayHTTPHandler: + handler = object.__new__(GatewayHTTPHandler) + handler.static_dist_path = static_dist_path + handler._log = MagicMock() + return handler + + +def test_static_asset_serves_precompressed_gzip_variant(tmp_path) -> None: + source = b"const message = 'hello';\n" * 200 + asset = tmp_path / "assets" / "app-abc123.js" + asset.parent.mkdir() + asset.write_bytes(source) + compressed = gzip.compress(source, mtime=0) + asset.with_name(f"{asset.name}.gz").write_bytes(compressed) + + response = _handler(tmp_path)._serve_static( + "/assets/app-abc123.js", + accept_encoding="br, gzip; q=0.8", + ) + + assert response is not None + assert response.headers["Content-Encoding"] == "gzip" + assert response.headers["Vary"] == "Accept-Encoding" + assert response.headers["Cache-Control"] == "public, max-age=31536000, immutable" + assert response.headers["Content-Type"] == "application/javascript; charset=utf-8" + assert int(response.headers["Content-Length"]) == len(compressed) + assert gzip.decompress(response.body) == source + + +def test_static_asset_preserves_identity_when_gzip_is_rejected(tmp_path) -> None: + source = b"body { color: black; }\n" * 200 + asset = tmp_path / "assets" / "app-abc123.css" + asset.parent.mkdir() + asset.write_bytes(source) + asset.with_name(f"{asset.name}.gz").write_bytes(gzip.compress(source, mtime=0)) + + response = _handler(tmp_path)._serve_static( + "/assets/app-abc123.css", + accept_encoding="gzip;q=0, br", + ) + + assert response is not None + assert "Content-Encoding" not in response.headers + assert response.headers["Vary"] == "Accept-Encoding" + assert response.body == source + + +def test_spa_fallback_uses_precompressed_index_without_long_term_cache(tmp_path) -> None: + source = b"
" * 100 + index = tmp_path / "index.html" + index.write_bytes(source) + compressed = gzip.compress(source, mtime=0) + index.with_name("index.html.gz").write_bytes(compressed) + + response = _handler(tmp_path)._serve_static( + "/chat/example", + accept_encoding="gzip", + ) + + assert response is not None + assert response.headers["Content-Encoding"] == "gzip" + assert response.headers["Cache-Control"] == "no-cache" + assert gzip.decompress(response.body) == source diff --git a/webui/src/tests/vite-config.test.ts b/webui/src/tests/vite-config.test.ts index 74ab15fbe..813d963a3 100644 --- a/webui/src/tests/vite-config.test.ts +++ b/webui/src/tests/vite-config.test.ts @@ -1,8 +1,64 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { gunzipSync } from "node:zlib"; import { describe, expect, it } from "vitest"; -import { webuiManualChunk } from "../../vite.config"; +import { + entryLazyFeatureImports, + gzipWebuiAssets, + webuiManualChunk, + writeCompressedWebuiAssets, +} from "../../vite.config"; + +describe("gzipWebuiAssets", () => { + it("compresses finalized files after Rollup writes the bundle", () => { + const plugin = gzipWebuiAssets(); + expect(plugin.generateBundle).toBeUndefined(); + expect(plugin.writeBundle).toBeTypeOf("function"); + + const outputDir = mkdtempSync(path.join(tmpdir(), "nanobot-vite-gzip-")); + try { + const assetPath = path.join(outputDir, "assets", "index-final.js"); + mkdirSync(path.dirname(assetPath), { recursive: true }); + const finalized = Buffer.from( + "const __vite__mapDeps = ['assets/lazy-feature.js'];\n".repeat(200), + ); + writeFileSync(assetPath, finalized); + + writeCompressedWebuiAssets(outputDir, ["assets/index-final.js"]); + + expect(gunzipSync(readFileSync(`${assetPath}.gz`))).toEqual(finalized); + } finally { + rmSync(outputDir, { recursive: true, force: true }); + } + }); +}); + +describe("entryLazyFeatureImports", () => { + it("allows the core runtime but rejects heavy lazy feature chunks", () => { + expect(entryLazyFeatureImports(["assets/react-vendor-abc.js"])).toEqual([]); + expect(entryLazyFeatureImports([ + "assets/markdown-vendor-abc.js", + "assets/syntax-highlight-def.js", + "assets/katex-ghi.js", + ])).toEqual([ + "assets/markdown-vendor-abc.js", + "assets/syntax-highlight-def.js", + "assets/katex-ghi.js", + ]); + }); +}); describe("webuiManualChunk", () => { + it("keeps the React runtime outside lazy feature chunks", () => { + expect(webuiManualChunk("/repo/node_modules/react/index.js")).toBe("react-vendor"); + expect(webuiManualChunk("/repo/node_modules/react-dom/client.js")).toBe("react-vendor"); + expect(webuiManualChunk("/repo/node_modules/scheduler/index.js")).toBe("react-vendor"); + expect(webuiManualChunk("/repo/node_modules/clsx/dist/clsx.mjs")).toBe("react-vendor"); + expect(webuiManualChunk("\0vite/preload-helper.js")).toBe("react-vendor"); + }); + it("keeps Refractor's selector parser in the syntax highlighting chunk", () => { expect( webuiManualChunk("/repo/node_modules/hast-util-parse-selector/index.js"), diff --git a/webui/vite.config.ts b/webui/vite.config.ts index b9b500efb..8a0202447 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -1,8 +1,73 @@ -import { defineConfig, loadEnv } from "vite"; +import { defineConfig, loadEnv, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; +import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { gzipSync } from "node:zlib"; + +const GZIP_MIN_BYTES = 4 * 1024; +const GZIP_ASSET_PATTERN = /\.(?:css|html|js|json|mjs|svg)$/i; +const LAZY_FEATURE_CHUNK_PREFIXES = ["markdown-vendor-", "syntax-highlight-", "katex-"]; + +export function entryLazyFeatureImports(imports: string[]): string[] { + return imports.filter((fileName) => + LAZY_FEATURE_CHUNK_PREFIXES.some((prefix) => path.basename(fileName).startsWith(prefix)), + ); +} + +function guardWebuiEntryChunk(): Plugin { + return { + name: "nanobot-guard-webui-entry-chunk", + apply: "build", + generateBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== "chunk" || !output.isEntry) continue; + const unexpected = entryLazyFeatureImports(output.imports); + if (unexpected.length > 0) { + throw new Error( + `WebUI entry chunk statically imports lazy features: ${unexpected.join(", ")}`, + ); + } + } + }, + }; +} + +export function writeCompressedWebuiAssets(outputDir: string, fileNames: string[]): void { + for (const fileName of fileNames) { + if (!GZIP_ASSET_PATTERN.test(fileName)) continue; + const outputPath = path.resolve(outputDir, fileName); + const bytes = readFileSync(outputPath); + if (bytes.byteLength < GZIP_MIN_BYTES) continue; + const compressed = gzipSync(bytes, { level: 9 }); + if (compressed.byteLength >= bytes.byteLength) continue; + writeFileSync(`${outputPath}.gz`, compressed); + } +} + +export function gzipWebuiAssets(): Plugin { + return { + name: "nanobot-gzip-webui-assets", + apply: "build", + writeBundle(options, bundle) { + const outputDir = options.dir ?? (options.file ? path.dirname(options.file) : undefined); + if (!outputDir) { + throw new Error("WebUI gzip build requires a Rollup output directory"); + } + writeCompressedWebuiAssets(outputDir, Object.keys(bundle)); + }, + }; +} export function webuiManualChunk(id: string): string | undefined { + if ( + id.includes("node_modules/react/") + || id.includes("node_modules/react-dom/") + || id.includes("node_modules/scheduler/") + || id.includes("node_modules/clsx/") + || id.includes("vite/preload-helper") + ) { + return "react-vendor"; + } if (id.includes("node_modules/refractor/lang/")) { return; } @@ -47,7 +112,7 @@ export default defineConfig(({ mode }) => { const hmrPath = "/__nanobot_vite_hmr"; return { - plugins: [react()], + plugins: [react(), guardWebuiEntryChunk(), gzipWebuiAssets()], resolve: { alias: { "@": path.resolve(__dirname, "./src"),