From 16fa19cd72ca80f226dd33b36d307ca207c1002a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 23:13:04 -0700 Subject: [PATCH] improve(control-ui): compress and cache bundled assets (#105890) * perf(control-ui): compress bundled assets * chore(control-ui): remove release-owned changelog edit * fix(control-ui): release assets before compression * refactor(control-ui): split static response helpers * perf(control-ui): precompress bundled assets * refactor(control-ui): keep encoding internals private * chore(control-ui): refresh LOC baseline --- package.json | 2 +- .../check-control-ui-precompressed-assets.mjs | 43 +++ scripts/openclaw-prepack.ts | 8 + scripts/ts-max-loc-baseline-v2.json | 2 +- src/gateway/control-ui-static.ts | 352 ++++++++++++++++++ src/gateway/control-ui.http.test.ts | 261 ++++++++++++- src/gateway/control-ui.ts | 215 +++++------ test/openclaw-prepack.test.ts | 18 +- ui/src/app/vite-config.node.test.ts | 17 + ui/vite.config.ts | 56 +++ 10 files changed, 842 insertions(+), 132 deletions(-) create mode 100644 scripts/check-control-ui-precompressed-assets.mjs create mode 100644 src/gateway/control-ui-static.ts diff --git a/package.json b/package.json index 4836630c931a..e44610ac43c6 100644 --- a/package.json +++ b/package.json @@ -1990,7 +1990,7 @@ "tui:pty:test:watch:all": "node --import tsx scripts/dev/tui-pty-test-watch.ts --mode all", "tui:pty:test:watch:fake": "node --import tsx scripts/dev/tui-pty-test-watch.ts --mode fake", "tui:pty:test:watch:local": "node --import tsx scripts/dev/tui-pty-test-watch.ts --mode local", - "ui:build": "node scripts/ui.js build", + "ui:build": "node scripts/ui.js build && node scripts/check-control-ui-precompressed-assets.mjs", "ui:dev": "node scripts/ui.js dev", "ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check", "ui:i18n:report": "node --import tsx scripts/control-ui-i18n-report.ts", diff --git a/scripts/check-control-ui-precompressed-assets.mjs b/scripts/check-control-ui-precompressed-assets.mjs new file mode 100644 index 000000000000..38c9dca2a54b --- /dev/null +++ b/scripts/check-control-ui-precompressed-assets.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// Verifies each generated Control UI sidecar encodes the final emitted asset bytes. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { brotliDecompressSync, gunzipSync } from "node:zlib"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const assetsDir = path.join(repoRoot, "dist", "control-ui", "assets"); +const errors = []; +let checked = 0; + +for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) { + if (!entry.isFile()) { + continue; + } + const suffix = entry.name.endsWith(".br") ? ".br" : entry.name.endsWith(".gz") ? ".gz" : null; + if (!suffix) { + continue; + } + const sidecarPath = path.join(assetsDir, entry.name); + const sourcePath = sidecarPath.slice(0, -suffix.length); + try { + const encoded = fs.readFileSync(sidecarPath); + const decoded = suffix === ".br" ? brotliDecompressSync(encoded) : gunzipSync(encoded); + const source = fs.readFileSync(sourcePath); + if (!decoded.equals(source)) { + errors.push(`${entry.name}: decoded bytes differ from ${path.basename(sourcePath)}`); + } + } catch (error) { + errors.push(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`); + } + checked += 1; +} + +if (checked === 0) { + errors.push("no precompressed Control UI assets found"); +} +if (errors.length > 0) { + throw new Error(`Control UI precompressed asset verification failed:\n${errors.join("\n")}`); +} + +process.stdout.write(`verified ${checked} finalized Control UI sidecars\n`); diff --git a/scripts/openclaw-prepack.ts b/scripts/openclaw-prepack.ts index 290582a3ccb9..963657d4c5d7 100644 --- a/scripts/openclaw-prepack.ts +++ b/scripts/openclaw-prepack.ts @@ -14,6 +14,7 @@ const requiredPreparedPathGroups = [ ["dist/control-ui/index.html"], ]; const requiredControlUiAssetPrefix = "dist/control-ui/assets/"; +const requiredControlUiCompressionSuffixes = [".br", ".gz"] as const; const DEFAULT_PREPACK_COMMAND_TIMEOUT_MS = 30 * 60 * 1000; const ALLOW_UNRELEASED_CHANGELOG_ENV = "OPENCLAW_PREPACK_ALLOW_UNRELEASED_CHANGELOG"; @@ -42,6 +43,13 @@ export function collectPreparedPrepackErrors( } if (!normalizedAssets.values().next().done) { + for (const suffix of requiredControlUiCompressionSuffixes) { + if (!Array.from(normalizedAssets).some((assetPath) => assetPath.endsWith(suffix))) { + errors.push( + `missing prepared Control UI ${suffix} asset under ${requiredControlUiAssetPrefix}`, + ); + } + } return errors; } diff --git a/scripts/ts-max-loc-baseline-v2.json b/scripts/ts-max-loc-baseline-v2.json index 1b6a53354cfb..91f6cf82ebd6 100644 --- a/scripts/ts-max-loc-baseline-v2.json +++ b/scripts/ts-max-loc-baseline-v2.json @@ -878,7 +878,7 @@ "src/gateway/cli-session-history.claude.ts": 602, "src/gateway/config-reload.ts": 1022, "src/gateway/control-ui-session-prs.ts": 648, - "src/gateway/control-ui.ts": 1189, + "src/gateway/control-ui.ts": 1152, "src/gateway/exec-approval-manager.ts": 1141, "src/gateway/gateway-cli-backend.live-helpers.ts": 648, "src/gateway/gateway-cli-backend.live-probe-helpers.ts": 513, diff --git a/src/gateway/control-ui-static.ts b/src/gateway/control-ui-static.ts new file mode 100644 index 000000000000..8d3619917d3f --- /dev/null +++ b/src/gateway/control-ui-static.ts @@ -0,0 +1,352 @@ +// Control UI static-response policy: MIME types, caching, encoding, and pinned-file reads. +import fs from "node:fs"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import path from "node:path"; +import { brotliCompress, constants as zlibConstants, gzip } from "node:zlib"; + +const CONTROL_UI_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"; +const CONTROL_UI_HTML_COMPRESSION_CACHE_MAX_ENTRIES = 4; +const CONTROL_UI_COMPRESSIBLE_EXTENSIONS = new Set([ + ".css", + ".html", + ".js", + ".json", + ".svg", + ".txt", + ".wasm", + ".webmanifest", +]); +const CONTROL_UI_PRECOMPRESSED_ASSET_EXTENSIONS = new Set([".br", ".gz"]); + +/** + * Missing files with these extensions return 404 instead of the SPA index. + * `.html` stays excluded because client-side routes may use that suffix. + */ +const CONTROL_UI_STATIC_ASSET_EXTENSIONS = new Set([ + ".js", + ".css", + ".json", + ".map", + ".svg", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".ico", + ".txt", + ".wasm", + ".webmanifest", +]); + +export function isControlUiStaticAssetExtension(extension: string): boolean { + return CONTROL_UI_STATIC_ASSET_EXTENSIONS.has(extension); +} + +function isControlUiCompressibleExtension(extension: string): boolean { + return CONTROL_UI_COMPRESSIBLE_EXTENSIONS.has(extension); +} + +export function isControlUiPrecompressedAssetExtension(extension: string): boolean { + return CONTROL_UI_PRECOMPRESSED_ASSET_EXTENSIONS.has(extension); +} + +type ControlUiContentEncoding = "br" | "gzip"; +type ControlUiEncodingSelection = ControlUiContentEncoding | "identity" | "not-acceptable"; + +const CONTROL_UI_DYNAMIC_ENCODINGS = new Set(["br", "gzip"]); +const controlUiHtmlCompressionCache = new Map>(); + +function contentTypeForExtension(ext: string): string { + switch (ext) { + case ".html": + return "text/html; charset=utf-8"; + case ".js": + return "application/javascript; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".json": + case ".map": + return "application/json; charset=utf-8"; + case ".svg": + return "image/svg+xml"; + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + case ".ico": + return "image/x-icon"; + case ".txt": + return "text/plain; charset=utf-8"; + case ".wasm": + return "application/wasm"; + case ".webmanifest": + return "application/manifest+json; charset=utf-8"; + default: + return "application/octet-stream"; + } +} + +function normalizedAcceptEncoding(req: IncomingMessage): string { + const value = req.headers?.["accept-encoding"]; + return Array.isArray(value) ? value.join(",") : (value ?? ""); +} + +function resolveControlUiContentEncoding( + req: IncomingMessage, + availableEncodings: ReadonlySet, +): ControlUiEncodingSelection { + const qualities = new Map(); + for (const entry of normalizedAcceptEncoding(req).split(",")) { + const [rawName, ...rawParams] = entry.split(";"); + const name = rawName?.trim().toLowerCase(); + if (!name) { + continue; + } + const qualityParam = rawParams.find((param) => param.trim().toLowerCase().startsWith("q=")); + const parsedQuality = qualityParam ? Number.parseFloat(qualityParam.trim().slice(2)) : 1; + const quality = + Number.isFinite(parsedQuality) && parsedQuality >= 0 && parsedQuality <= 1 + ? parsedQuality + : 0; + qualities.set(name, Math.max(qualities.get(name) ?? 0, quality)); + } + + const hasAcceptEncoding = normalizedAcceptEncoding(req).trim().length > 0; + if (!hasAcceptEncoding) { + return "identity"; + } + + const wildcardQuality = qualities.get("*"); + const qualityFor = (name: ControlUiContentEncoding) => + qualities.has(name) ? (qualities.get(name) ?? 0) : (wildcardQuality ?? 0); + // RFC 9110 keeps identity acceptable unless identity or a rejecting wildcard + // explicitly disables it. This distinction is required to return 406 rather + // than silently violate identity;q=0. + const identityQuality = qualities.has("identity") + ? (qualities.get("identity") ?? 0) + : wildcardQuality === 0 + ? 0 + : 1; + const candidates: Array<{ encoding: ControlUiEncodingSelection; quality: number; rank: number }> = + [{ encoding: "identity", quality: identityQuality, rank: 0 }]; + if (availableEncodings.has("gzip")) { + candidates.push({ encoding: "gzip", quality: qualityFor("gzip"), rank: 1 }); + } + if (availableEncodings.has("br")) { + candidates.push({ encoding: "br", quality: qualityFor("br"), rank: 2 }); + } + const selected = candidates + .filter((candidate) => candidate.quality > 0) + .toSorted((left, right) => right.quality - left.quality || right.rank - left.rank)[0]; + return selected?.encoding ?? "not-acceptable"; +} + +export function resolveControlUiHtmlEncoding(req: IncomingMessage): ControlUiEncodingSelection { + return resolveControlUiContentEncoding(req, CONTROL_UI_DYNAMIC_ENCODINGS); +} + +type OpenedControlUiRepresentation = { + bodyFile: { path: string; fd: number }; + contentPath: string; + encoding?: ControlUiContentEncoding; +}; + +export function resolveOpenedControlUiRepresentation(params: { + req: IncomingMessage; + sourceFile: { path: string; fd: number }; + precompressed: boolean; + openPrecompressedFile: (filePath: string) => { path: string; fd: number } | null; +}): OpenedControlUiRepresentation | null { + const { req, sourceFile, precompressed, openPrecompressedFile } = params; + const extension = path.extname(sourceFile.path).toLowerCase(); + const availableEncodings = + precompressed && isControlUiCompressibleExtension(extension) + ? new Set(CONTROL_UI_DYNAMIC_ENCODINGS) + : new Set(); + for (;;) { + const selected = resolveControlUiContentEncoding(req, availableEncodings); + if (selected === "not-acceptable") { + fs.closeSync(sourceFile.fd); + return null; + } + if (selected === "identity") { + return { bodyFile: sourceFile, contentPath: sourceFile.path }; + } + + const suffix = selected === "br" ? ".br" : ".gz"; + let compressedFile: { path: string; fd: number } | null; + try { + compressedFile = openPrecompressedFile(`${sourceFile.path}${suffix}`); + } catch (error) { + fs.closeSync(sourceFile.fd); + throw error; + } + if (compressedFile) { + fs.closeSync(sourceFile.fd); + return { bodyFile: compressedFile, contentPath: sourceFile.path, encoding: selected }; + } + + // Generated builds have both variants, but a stale or partial local build + // can miss one. Retry the remaining representation before identity/406. + availableEncodings.delete(selected); + } +} + +function setControlUiEncodingHeaders( + res: ServerResponse, + extension: string, + encoding: ControlUiContentEncoding | "identity", +) { + res.setHeader("Vary", "Accept-Encoding"); + if (!CONTROL_UI_COMPRESSIBLE_EXTENSIONS.has(extension)) { + return; + } + if (encoding !== "identity") { + res.setHeader("Content-Encoding", encoding); + } +} + +function setControlUiFileHeaders( + res: ServerResponse, + filePath: string, + options?: { immutable?: boolean; encoding?: ControlUiContentEncoding }, +) { + const extension = path.extname(filePath).toLowerCase(); + res.setHeader("Content-Type", contentTypeForExtension(extension)); + res.setHeader( + "Cache-Control", + options?.immutable ? CONTROL_UI_IMMUTABLE_CACHE_CONTROL : "no-cache", + ); + setControlUiEncodingHeaders(res, extension, options?.encoding ?? "identity"); +} + +export function respondHeadForControlUiFile( + res: ServerResponse, + filePath: string, + options?: { immutable?: boolean; encoding?: ControlUiContentEncoding }, +) { + res.statusCode = 200; + setControlUiFileHeaders(res, filePath, options); + res.end(); +} + +function compressControlUiBody(body: Buffer, encoding: ControlUiContentEncoding): Promise { + return new Promise((resolve, reject) => { + const callback = (error: Error | null, compressed: Buffer) => { + if (error) { + reject(error); + return; + } + resolve(compressed); + }; + if (encoding === "br") { + brotliCompress( + body, + { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: 4, + }, + }, + callback, + ); + return; + } + gzip(body, { level: 6 }, callback); + }); +} + +export async function serveControlUiAsset( + res: ServerResponse, + filePath: string, + body: Buffer, + options?: { immutable?: boolean; encoding?: ControlUiContentEncoding }, +) { + setControlUiFileHeaders(res, filePath, options); + res.end(body); +} + +function cachedCompressedControlUiHtml( + body: string, + encoding: ControlUiContentEncoding, +): Promise { + const key = `${encoding}\0${body}`; + const cached = controlUiHtmlCompressionCache.get(key); + if (cached) { + controlUiHtmlCompressionCache.delete(key); + controlUiHtmlCompressionCache.set(key, cached); + return cached; + } + + // Index HTML is process-stable for a configured root. Keep its few rewritten + // variants single-flight and bounded so unauthenticated requests cannot fan + // out zlib work; large hashed assets use build-time sidecars instead. + const compression = compressControlUiBody(Buffer.from(body), encoding); + controlUiHtmlCompressionCache.set(key, compression); + void compression.catch(() => { + if (controlUiHtmlCompressionCache.get(key) === compression) { + controlUiHtmlCompressionCache.delete(key); + } + }); + while (controlUiHtmlCompressionCache.size > CONTROL_UI_HTML_COMPRESSION_CACHE_MAX_ENTRIES) { + const oldestKey = controlUiHtmlCompressionCache.keys().next().value; + if (oldestKey === undefined) { + break; + } + controlUiHtmlCompressionCache.delete(oldestKey); + } + return compression; +} + +export function respondControlUiNotAcceptable(res: ServerResponse) { + res.statusCode = 406; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Vary", "Accept-Encoding"); + res.end("Not Acceptable"); +} + +export async function sendControlUiHtmlBody( + req: IncomingMessage, + res: ServerResponse, + body: string, +) { + const encoding = resolveControlUiHtmlEncoding(req); + if (encoding === "not-acceptable") { + respondControlUiNotAcceptable(res); + return; + } + setControlUiEncodingHeaders(res, ".html", encoding); + res.end(encoding === "identity" ? body : await cachedCompressedControlUiHtml(body, encoding)); +} + +function readOpenedFile(fd: number): Promise { + return new Promise((resolve, reject) => { + fs.readFile(fd, (error, data) => { + if (error) { + reject(error); + return; + } + resolve(data); + }); + }); +} + +// Compression can wait in zlib's worker queue, so release the pinned file as +// soon as its bytes are loaded instead of retaining descriptors per request. +export async function readAndCloseControlUiFile(fd: number): Promise { + try { + return await readOpenedFile(fd); + } finally { + fs.closeSync(fd); + } +} + +export async function readAndCloseControlUiFileText(fd: number): Promise { + return (await readAndCloseControlUiFile(fd)).toString("utf8"); +} diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index ae972f7d1df3..2b98ef9390b1 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -6,6 +6,7 @@ import fs from "node:fs/promises"; import type { IncomingMessage } from "node:http"; import os from "node:os"; import path from "node:path"; +import { brotliCompressSync, brotliDecompressSync, gzipSync, gunzipSync } from "node:zlib"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { normalizeAssistantIdentity } from "../../ui/src/lib/assistant-identity.ts"; @@ -21,6 +22,7 @@ import { AVATAR_MAX_BYTES, AVATAR_MAX_DATA_URL_CHARS } from "../shared/avatar-po import { withEnvAsync } from "../test-utils/env.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "./control-ui-contract.js"; +import { resolveOpenedControlUiRepresentation } from "./control-ui-static.js"; import { handleControlUiAssistantMediaRequest, handleControlUiAvatarRequest, @@ -121,17 +123,18 @@ describe("handleControlUiHttpRequest", () => { rootPath: string; basePath?: string; rootKind?: "resolved" | "bundled"; + headers?: IncomingMessage["headers"]; }) { - const { res, end } = makeMockHttpResponse(); + const { res, end, setHeader } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: params.url, method: params.method } as IncomingMessage, + { url: params.url, method: params.method, headers: params.headers ?? {} } as IncomingMessage, res, { ...(params.basePath ? { basePath: params.basePath } : {}), root: { kind: params.rootKind ?? "resolved", path: params.rootPath }, }, ); - return { res, end, handled }; + return { res, end, setHeader, handled }; } async function runBootstrapConfigRequest(params: { @@ -1838,6 +1841,258 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("compresses bundled assets and caches them immutably", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const source = "console.log('compressed');\n".repeat(200); + const { filePath } = await writeAssetFile(tmp, "app-AbCd1234.js", source); + await fs.writeFile(`${filePath}.br`, brotliCompressSync(source)); + await fs.writeFile(`${filePath}.gz`, gzipSync(source)); + const closeSync = vi.spyOn(fsSync, "closeSync"); + + try { + const { res, end, setHeader, handled } = await runControlUiRequest({ + url: "/assets/app-AbCd1234.js", + method: "GET", + rootPath: tmp, + rootKind: "bundled", + headers: { "accept-encoding": "gzip;q=0.5, br, identity;q=0.1" }, + }); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(setHeader).toHaveBeenCalledWith( + "Cache-Control", + "public, max-age=31536000, immutable", + ); + expect(setHeader).toHaveBeenCalledWith("Vary", "Accept-Encoding"); + expect(setHeader).toHaveBeenCalledWith("Content-Encoding", "br"); + const compressed = end.mock.calls[0]?.[0]; + expect(Buffer.isBuffer(compressed)).toBe(true); + expect(brotliDecompressSync(compressed as Buffer).toString()).toBe(source); + expect(closeSync.mock.invocationCallOrder.at(-1)).toBeLessThan( + end.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + } finally { + closeSync.mockRestore(); + } + }, + }); + }); + + it("serves build-time gzip variants when they are preferred", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const source = "console.log('gzip');\n".repeat(200); + const { filePath } = await writeAssetFile(tmp, "app-EfGh5678.js", source); + await fs.writeFile(`${filePath}.br`, brotliCompressSync(source)); + await fs.writeFile(`${filePath}.gz`, gzipSync(source)); + + const { end, setHeader } = await runControlUiRequest({ + url: "/assets/app-EfGh5678.js", + method: "GET", + rootPath: tmp, + rootKind: "bundled", + headers: { "accept-encoding": "br;q=0.5, gzip, identity;q=0.1" }, + }); + + expect(setHeader).toHaveBeenCalledWith("Content-Encoding", "gzip"); + expect(gunzipSync(end.mock.calls[0]?.[0] as Buffer).toString()).toBe(source); + }, + }); + }); + + it("falls through to an acceptable sidecar when the preferred variant is missing", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const source = "console.log('partial-build');\n".repeat(200); + const { filePath } = await writeAssetFile(tmp, "app-IjKl9012.js", source); + await fs.writeFile(`${filePath}.gz`, gzipSync(source)); + + const { end, setHeader } = await runControlUiRequest({ + url: "/assets/app-IjKl9012.js", + method: "GET", + rootPath: tmp, + rootKind: "bundled", + headers: { "accept-encoding": "br, gzip;q=0.5, identity;q=0" }, + }); + + expect(setHeader).toHaveBeenCalledWith("Content-Encoding", "gzip"); + expect(gunzipSync(end.mock.calls[0]?.[0] as Buffer).toString()).toBe(source); + }, + }); + }); + + it("closes the source descriptor when opening a sidecar fails", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { filePath } = await writeAssetFile(tmp, "app-MnOp3456.js", "source\n"); + const fd = fsSync.openSync(filePath, "r"); + const openError = Object.assign(new Error("descriptor limit"), { code: "EMFILE" }); + + expect(() => + resolveOpenedControlUiRepresentation({ + req: { + headers: { "accept-encoding": "br, identity;q=0" }, + } as IncomingMessage, + sourceFile: { path: filePath, fd }, + precompressed: true, + openPrecompressedFile: () => { + throw openError; + }, + }), + ).toThrow(openError); + expect(() => fsSync.fstatSync(fd)).toThrow(/bad file descriptor/iu); + }, + }); + }); + + it("keeps configured-root assets identity encoded and revalidated", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const source = "console.log('configured');\n".repeat(100); + await writeAssetFile(tmp, "app-settings.js", source); + + const { end, setHeader } = await runControlUiRequest({ + url: "/assets/app-settings.js", + method: "GET", + rootPath: tmp, + headers: { "accept-encoding": "br;q=0, gzip;q=0.8" }, + }); + + expect(setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); + expect(setHeader).not.toHaveBeenCalledWith("Content-Encoding", expect.anything()); + expect(responseBody(end)).toBe(source); + }, + }); + }); + + it("returns 406 when no available asset representation is acceptable", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + await writeAssetFile(tmp, "app-settings.js", "console.log('configured');\n"); + + const { res, end } = await runControlUiRequest({ + url: "/assets/app-settings.js", + method: "GET", + rootPath: tmp, + headers: { "accept-encoding": "br;q=0, gzip;q=0, identity;q=0" }, + }); + + expect(res.statusCode).toBe(406); + expect(responseBody(end)).toBe("Not Acceptable"); + }, + }); + }); + + it("varies identity-only assets on Accept-Encoding", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + await writeAssetFile(tmp, "logo.png", "png-bytes"); + + const { setHeader } = await runControlUiRequest({ + url: "/assets/logo.png", + method: "GET", + rootPath: tmp, + rootKind: "bundled", + }); + + expect(setHeader).toHaveBeenCalledWith("Vary", "Accept-Encoding"); + }, + }); + }); + + it("does not expose precompressed sidecars as independent assets", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { filePath } = await writeAssetFile(tmp, "app-AbCd1234.js", "source\n"); + await fs.writeFile(`${filePath}.br`, brotliCompressSync("source\n")); + + const { res, end, handled } = await runControlUiRequest({ + url: "/assets/app-AbCd1234.js.br", + method: "GET", + rootPath: tmp, + rootKind: "bundled", + }); + + expectNotFoundResponse({ handled, res, end }); + }, + }); + }); + + it("preserves standalone compressed files in configured roots", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + await writeAssetFile(tmp, "data.gz", "configured-compressed-artifact\n"); + + const { end, handled } = await runControlUiRequest({ + url: "/assets/data.gz", + method: "GET", + rootPath: tmp, + }); + + expect(handled).toBe(true); + expect(responseBody(end)).toBe("configured-compressed-artifact\n"); + }, + }); + }); + + it.each([ + ["index", "/"], + ["SPA fallback", "/chat"], + ])("compresses %s HTML after closing its descriptor", async (_name, url) => { + const html = `${"hello ".repeat(200)}\n`; + await withControlUiRoot({ + indexHtml: html, + fn: async (tmp) => { + const { res, end, setHeader } = makeMockHttpResponse(); + const closeSync = vi.spyOn(fsSync, "closeSync"); + try { + await handleControlUiHttpRequest( + { + url, + method: "GET", + headers: { "accept-encoding": "gzip" }, + } as IncomingMessage, + res, + { root: { kind: "resolved", path: tmp } }, + ); + + expect(setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); + expect(setHeader).toHaveBeenCalledWith("Content-Encoding", "gzip"); + expect(gunzipSync(end.mock.calls[0]?.[0] as Buffer).toString()).toContain( + '', + ); + expect(closeSync.mock.invocationCallOrder.at(-1)).toBeLessThan( + end.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + } finally { + closeSync.mockRestore(); + } + }, + }); + }); + + it("returns 406 when every HTML representation is explicitly rejected", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, end } = makeMockHttpResponse(); + await handleControlUiHttpRequest( + { + url: "/", + method: "GET", + headers: { "accept-encoding": "*;q=0" }, + } as IncomingMessage, + res, + { root: { kind: "resolved", path: tmp } }, + ); + + expect(res.statusCode).toBe(406); + expect(responseBody(end)).toBe("Not Acceptable"); + }, + }); + }); + it("serves HEAD for in-root assets without writing a body", async () => { await withControlUiRoot({ fn: async (tmp) => { diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 55c0af5fc316..fee79d974487 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -63,6 +63,18 @@ import { CONTROL_UI_AVATAR_PREFIX, normalizeControlUiBasePath, } from "./control-ui-shared.js"; +import { + isControlUiPrecompressedAssetExtension, + isControlUiStaticAssetExtension, + readAndCloseControlUiFile, + readAndCloseControlUiFileText, + resolveControlUiHtmlEncoding, + resolveOpenedControlUiRepresentation, + respondControlUiNotAcceptable, + respondHeadForControlUiFile, + sendControlUiHtmlBody, + serveControlUiAsset, +} from "./control-ui-static.js"; import { buildMissingScopeForbiddenBody, sendGatewayAuthFailure } from "./http-common.js"; import { getBearerToken, @@ -113,62 +125,6 @@ export type ControlUiRootState = | { kind: "invalid"; path: string } | { kind: "missing" }; -function contentTypeForExt(ext: string): string { - switch (ext) { - case ".html": - return "text/html; charset=utf-8"; - case ".js": - return "application/javascript; charset=utf-8"; - case ".css": - return "text/css; charset=utf-8"; - case ".json": - case ".map": - return "application/json; charset=utf-8"; - case ".svg": - return "image/svg+xml"; - case ".png": - return "image/png"; - case ".jpg": - case ".jpeg": - return "image/jpeg"; - case ".gif": - return "image/gif"; - case ".webp": - return "image/webp"; - case ".ico": - return "image/x-icon"; - case ".txt": - return "text/plain; charset=utf-8"; - case ".webmanifest": - return "application/manifest+json; charset=utf-8"; - default: - return "application/octet-stream"; - } -} - -/** - * Extensions recognised as static assets. Missing files with these extensions - * return 404 instead of the SPA index.html fallback. `.html` is intentionally - * excluded — actual HTML files on disk are served earlier, and missing `.html` - * paths should fall through to the SPA router (client-side routers may use - * `.html`-suffixed routes). - */ -const STATIC_ASSET_EXTENSIONS = new Set([ - ".js", - ".css", - ".json", - ".map", - ".svg", - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".ico", - ".txt", - ".webmanifest", -]); - const CONTROL_UI_NAMESPACE_PREFIX = "/__openclaw__/"; const CONTROL_UI_ROOT_PUBLIC_ASSETS = new Set([ "apple-touch-icon.png", @@ -257,16 +213,6 @@ function respondControlUiAssetsUnavailable( respondPlainText(res, 503, CONTROL_UI_ASSETS_MISSING_MESSAGE); } -function respondHeadForFile(req: IncomingMessage, res: ServerResponse, filePath: string): boolean { - if (req.method !== "HEAD") { - return false; - } - res.statusCode = 200; - setStaticFileHeaders(res, filePath); - res.end(); - return true; -} - function isValidAgentId(agentId: string): boolean { return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(agentId); } @@ -794,20 +740,8 @@ export async function handleControlUiAvatarRequest( } } -function setStaticFileHeaders(res: ServerResponse, filePath: string) { - const ext = path.extname(filePath).toLowerCase(); - res.setHeader("Content-Type", contentTypeForExt(ext)); - // Static UI should never be cached aggressively while iterating; allow the - // browser to revalidate. - res.setHeader("Cache-Control", "no-cache"); -} - -function serveResolvedFile(res: ServerResponse, filePath: string, body: Buffer) { - setStaticFileHeaders(res, filePath); - res.end(body); -} - -function serveResolvedIndexHtml( +async function serveResolvedIndexHtml( + req: IncomingMessage, res: ServerResponse, body: string, basePath?: string, @@ -833,23 +767,7 @@ function serveResolvedIndexHtml( ); res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); - res.end(prepared); -} - -function readOpenedFile(fd: number): Promise { - return new Promise((resolve, reject) => { - fs.readFile(fd, (error, data) => { - if (error) { - reject(error); - return; - } - resolve(data); - }); - }); -} - -async function readOpenedFileText(fd: number): Promise { - return (await readOpenedFile(fd)).toString("utf8"); + await sendControlUiHtmlBody(req, res, prepared); } function isExpectedSafePathError(error: unknown): boolean { @@ -1116,7 +1034,6 @@ export async function handleControlUiHttpRequest( respondControlUiNotFound(res); return true; } - const filePath = path.resolve(root, fileRel); if (!isWithinDir(root, filePath)) { respondControlUiNotFound(res); @@ -1131,35 +1048,77 @@ export async function handleControlUiHttpRequest( argv1: process.argv[1], cwd: process.cwd(), })); + // Bundled sidecars are implementation artifacts selected through + // Accept-Encoding. Configured roots retain ordinary .br/.gz resources. + if ( + isBundledRoot && + isControlUiPrecompressedAssetExtension(path.extname(fileRel).toLowerCase()) + ) { + respondControlUiNotFound(res); + return true; + } const rejectHardlinks = !isBundledRoot; + // Vite fingerprints every file emitted under the bundled assets directory. + // Configured roots remain revalidated because their naming is not our contract. + const immutableAsset = isBundledRoot && fileRel.startsWith("assets/"); const safeFile = resolveSafeControlUiFile(rootReal, filePath, rejectHardlinks); if (safeFile) { - try { - if (respondHeadForFile(req, res, safeFile.path)) { - return true; + if (path.basename(safeFile.path) === "index.html") { + if (req.method === "HEAD") { + try { + const encoding = resolveControlUiHtmlEncoding(req); + if (encoding === "not-acceptable") { + respondControlUiNotAcceptable(res); + return true; + } + respondHeadForControlUiFile(res, safeFile.path, { + encoding: encoding === "identity" ? undefined : encoding, + }); + return true; + } finally { + fs.closeSync(safeFile.fd); + } } - if (path.basename(safeFile.path) === "index.html") { - serveResolvedIndexHtml( - res, - await readOpenedFileText(safeFile.fd), - basePath, - terminalEnabled, - ); - return true; - } - serveResolvedFile(res, safeFile.path, await readOpenedFile(safeFile.fd)); + const body = await readAndCloseControlUiFileText(safeFile.fd); + await serveResolvedIndexHtml(req, res, body, basePath, terminalEnabled); return true; - } finally { - fs.closeSync(safeFile.fd); } + const representation = resolveOpenedControlUiRepresentation({ + req, + sourceFile: safeFile, + precompressed: immutableAsset, + openPrecompressedFile: (compressedPath) => + resolveSafeControlUiFile(rootReal, compressedPath, false), + }); + if (!representation) { + respondControlUiNotAcceptable(res); + return true; + } + if (req.method === "HEAD") { + try { + respondHeadForControlUiFile(res, representation.contentPath, { + immutable: immutableAsset, + encoding: representation.encoding, + }); + return true; + } finally { + fs.closeSync(representation.bodyFile.fd); + } + } + const body = await readAndCloseControlUiFile(representation.bodyFile.fd); + await serveControlUiAsset(res, representation.contentPath, body, { + immutable: immutableAsset, + encoding: representation.encoding, + }); + return true; } // If the requested path looks like a static asset (known extension), return // 404 rather than falling through to the SPA index.html fallback. We check - // against the same set of extensions that contentTypeForExt() recognises so + // against the same extension set used by the static response helper so // that dotted SPA routes (e.g. /user/jane.doe, /v2.0) still get the // client-side router fallback. - if (STATIC_ASSET_EXTENSIONS.has(path.extname(fileRel).toLowerCase())) { + if (isControlUiStaticAssetExtension(path.extname(fileRel).toLowerCase())) { respondControlUiNotFound(res); return true; } @@ -1168,20 +1127,24 @@ export async function handleControlUiHttpRequest( const indexPath = path.join(root, "index.html"); const safeIndex = resolveSafeControlUiFile(rootReal, indexPath, rejectHardlinks); if (safeIndex) { - try { - if (respondHeadForFile(req, res, safeIndex.path)) { + if (req.method === "HEAD") { + try { + const encoding = resolveControlUiHtmlEncoding(req); + if (encoding === "not-acceptable") { + respondControlUiNotAcceptable(res); + return true; + } + respondHeadForControlUiFile(res, safeIndex.path, { + encoding: encoding === "identity" ? undefined : encoding, + }); return true; + } finally { + fs.closeSync(safeIndex.fd); } - serveResolvedIndexHtml( - res, - await readOpenedFileText(safeIndex.fd), - basePath, - terminalEnabled, - ); - return true; - } finally { - fs.closeSync(safeIndex.fd); } + const body = await readAndCloseControlUiFileText(safeIndex.fd); + await serveResolvedIndexHtml(req, res, body, basePath, terminalEnabled); + return true; } respondControlUiNotFound(res); diff --git a/test/openclaw-prepack.test.ts b/test/openclaw-prepack.test.ts index c24d4cc3577c..ca9c5c321dea 100644 --- a/test/openclaw-prepack.test.ts +++ b/test/openclaw-prepack.test.ts @@ -101,11 +101,27 @@ describe("collectPreparedPrepackErrors", () => { expect( collectPreparedPrepackErrors( ["dist/index.mjs", "dist/control-ui/index.html"], - ["dist/control-ui/assets/index-Bu8rSoJV.js"], + [ + "dist/control-ui/assets/index-Bu8rSoJV.js", + "dist/control-ui/assets/index-Bu8rSoJV.js.br", + "dist/control-ui/assets/index-Bu8rSoJV.js.gz", + ], ), ).toStrictEqual([]); }); + it("rejects a stale Control UI build without precompressed variants", () => { + expect( + collectPreparedPrepackErrors( + ["dist/index.mjs", "dist/control-ui/index.html"], + ["dist/control-ui/assets/index-Bu8rSoJV.js"], + ), + ).toEqual([ + "missing prepared Control UI .br asset under dist/control-ui/assets/", + "missing prepared Control UI .gz asset under dist/control-ui/assets/", + ]); + }); + it("reports missing build and control ui artifacts", () => { expect(collectPreparedPrepackErrors([], [])).toEqual([ "missing required prepared artifact: dist/index.js or dist/index.mjs", diff --git a/ui/src/app/vite-config.node.test.ts b/ui/src/app/vite-config.node.test.ts index 418d40b087d3..4d8b73439817 100644 --- a/ui/src/app/vite-config.node.test.ts +++ b/ui/src/app/vite-config.node.test.ts @@ -1,8 +1,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import { brotliDecompressSync, gunzipSync } from "node:zlib"; import { describe, expect, it, vi } from "vitest"; import { controlUiBrowserOnlySharedModuleAliases, + createControlUiPrecompressedAssetVariants, resolveControlUiBuildInfo, resolveExternalPackageAliasesForVite, resolveSourcePackageAliasesForVite, @@ -22,6 +24,21 @@ function findStringAlias(key: string) { } describe("Control UI Vite config", () => { + it("emits Brotli and gzip variants only for bundled compressible assets", () => { + const source = "console.log('precompressed');\n".repeat(200); + const variants = createControlUiPrecompressedAssetVariants("assets/app-AbCd1234.js", source); + + expect(variants.map((variant) => variant.fileName)).toEqual([ + "assets/app-AbCd1234.js.br", + "assets/app-AbCd1234.js.gz", + ]); + expect(brotliDecompressSync(variants[0]?.source ?? Buffer.alloc(0)).toString()).toBe(source); + expect(gunzipSync(variants[1]?.source ?? Buffer.alloc(0)).toString()).toBe(source); + expect(createControlUiPrecompressedAssetVariants("index.html", source)).toEqual([]); + expect(createControlUiPrecompressedAssetVariants("assets/logo.png", source)).toEqual([]); + expect(createControlUiPrecompressedAssetVariants("assets/app.js.map", source)).toEqual([]); + }); + it("embeds one canonical artifact identity from explicit build inputs", () => { const readGitCommit = vi.fn(() => "f".repeat(40)); expect( diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 02346dffa7e5..d4fd888b19b1 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { brotliCompressSync, constants as zlibConstants, gzipSync } from "node:zlib"; import type { Plugin, UserConfig } from "vite"; import { controlUiManualChunk } from "./config/control-ui-chunking.ts"; import { @@ -41,6 +42,42 @@ const commonJsOptimizeDeps = [ "highlight.js/lib/languages/xml", "highlight.js/lib/languages/yaml", ] as const; +// npm excludes dist/**/*.map; sidecars would bypass that rule and ship source +// maps that the browser never needs during normal runtime. +const controlUiPrecompressedAssetExtensions = new Set([ + ".css", + ".js", + ".json", + ".svg", + ".txt", + ".wasm", + ".webmanifest", +]); + +export function createControlUiPrecompressedAssetVariants( + fileName: string, + source: string | Uint8Array, +): Array<{ fileName: string; source: Buffer }> { + if ( + !fileName.startsWith("assets/") || + !controlUiPrecompressedAssetExtensions.has(path.extname(fileName).toLowerCase()) + ) { + return []; + } + const body = typeof source === "string" ? Buffer.from(source) : Buffer.from(source); + return [ + { + fileName: `${fileName}.br`, + source: brotliCompressSync(body, { + params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 9 }, + }), + }, + { + fileName: `${fileName}.gz`, + source: gzipSync(body, { level: 9 }), + }, + ]; +} function normalizeBase(input: string): string { const trimmed = input.trim(); @@ -337,6 +374,24 @@ function controlUiServiceWorkerBuildIdPlugin(buildId: string): Plugin { }; } +function controlUiPrecompressedAssetsPlugin(): Plugin { + return { + name: "control-ui-precompressed-assets", + apply: "build", + writeBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + // Vite's post-build import analysis rewrites lazy preload markers in a + // later generateBundle hook. Read from disk here so sidecars always + // encode the exact final bytes that the identity response serves. + const source = fs.readFileSync(path.join(outDir, output.fileName)); + for (const variant of createControlUiPrecompressedAssetVariants(output.fileName, source)) { + fs.writeFileSync(path.join(outDir, variant.fileName), variant.source); + } + } + }, + }; +} + export default function controlUiViteConfig(): UserConfig { const envBase = process.env.OPENCLAW_CONTROL_UI_BASE_PATH?.trim(); const base = envBase ? normalizeBase(envBase) : "./"; @@ -384,6 +439,7 @@ export default function controlUiViteConfig(): UserConfig { }, plugins: [ controlUiBrowserOnlySharedModuleAliases(), + controlUiPrecompressedAssetsPlugin(), controlUiServiceWorkerBuildIdPlugin(buildInfo.buildId), { name: "control-ui-dev-stubs",