mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 03:06:04 +00:00
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:
committed by
GitHub
parent
70b3467484
commit
16fa19cd72
@@ -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",
|
||||
|
||||
43
scripts/check-control-ui-precompressed-assets.mjs
Normal file
43
scripts/check-control-ui-precompressed-assets.mjs
Normal 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`);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
352
src/gateway/control-ui-static.ts
Normal file
352
src/gateway/control-ui-static.ts
Normal 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");
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user