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
This commit is contained in:
Peter Steinberger
2026-07-12 23:13:04 -07:00
committed by GitHub
parent 70b3467484
commit 16fa19cd72
10 changed files with 842 additions and 132 deletions

View File

@@ -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",

View File

@@ -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`);

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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<ControlUiContentEncoding>(["br", "gzip"]);
const controlUiHtmlCompressionCache = new Map<string, Promise<Buffer>>();
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<ControlUiContentEncoding>,
): ControlUiEncodingSelection {
const qualities = new Map<string, number>();
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<ControlUiContentEncoding>();
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<Buffer> {
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<Buffer> {
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<Buffer> {
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<Buffer> {
try {
return await readOpenedFile(fd);
} finally {
fs.closeSync(fd);
}
}
export async function readAndCloseControlUiFileText(fd: number): Promise<string> {
return (await readAndCloseControlUiFile(fd)).toString("utf8");
}

View File

@@ -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 = `<html><body>${"hello ".repeat(200)}</body></html>\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(
'<html data-openclaw-terminal-enabled="false">',
);
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) => {

View File

@@ -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<Buffer> {
return new Promise((resolve, reject) => {
fs.readFile(fd, (error, data) => {
if (error) {
reject(error);
return;
}
resolve(data);
});
});
}
async function readOpenedFileText(fd: number): Promise<string> {
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);

View File

@@ -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",

View File

@@ -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(

View File

@@ -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",