From ac799821ee4b465dbc83959dc5afcf1b623df403 Mon Sep 17 00:00:00 2001 From: chilung Date: Wed, 9 Sep 2026 09:08:59 +0800 Subject: [PATCH] perf(gui): cache immutable assets and static files Add Cache-Control headers when serving static files from the GUI distribution: - Content-hashed bundles under 'assets/' receive 'public, max-age=31536000, immutable' so browsers can cache bundles without redundant re-downloads over slow networks and SSH tunnels. - Unhashed files under 'assets/' (e.g. runtime-config.js) and other static files receive 'no-cache' so updates are picked up promptly. - 'index.html' continues to be served with 'no-store' to guarantee immediate bootstrap and script hash updates. --- src/server/gui-static.ts | 38 ++++++++++++++++++- tests/gui/gui-static.test.ts | 71 +++++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/server/gui-static.ts b/src/server/gui-static.ts index 75f3369be0..d128361c02 100644 --- a/src/server/gui-static.ts +++ b/src/server/gui-static.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, statSync } from "node:fs"; -import { extname, isAbsolute, join, relative, resolve } from "node:path"; +import { basename, extname, isAbsolute, join, relative, resolve } from "node:path"; import { browserSecurityHeaders } from "./auth-cors"; import type { GuiSessionBootstrap } from "./gui-session"; @@ -18,6 +18,12 @@ const MIME_TYPES: Record = { ".ico": "image/x-icon", }; +/** + * Matches Vite's content-hashed bundle filenames (e.g. `index-B5r7LNHN.js` or `style-D5SiRo8X.css`). + * Unhashed static assets (e.g. `runtime-config.js` or unversioned icons) must not be cached immutably. + */ +const HASHED_ASSET_PATTERN = /-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9]+$/; + function findGuiDist(): string | null { const candidates = [ join(import.meta.dir, "..", "..", "gui", "dist"), @@ -146,6 +152,14 @@ export function serveSessionBootstrap(session: GuiSessionBootstrap): Response { return htmlDocumentResponse(html); } +/** + * Serves a GUI static file from the distribution directory. + * Returns a Response with MIME type and appropriate Cache-Control headers: + * - HTML files receive `no-store` to guarantee immediate bootstrap updates. + * - Content-hashed bundles under `assets/` receive 1-year `immutable` caching. + * - Non-hashed static files (e.g. favicon, unhashed assets) receive `no-cache` for prompt revalidation. + * Returns null if the file cannot be found or resolved. + */ export function serveGuiFile( pathname: string, guiDist = findGuiDist(), @@ -170,11 +184,31 @@ export function serveGuiFile( const ext = extname(filePath); const contentType = MIME_TYPES[ext] || "application/octet-stream"; if (ext === ".html") return htmlResponse(filePath, session, runtimeRole, managementAuthRequired); + + const root = resolve(guiDist); + // "assets/" matches Vite's default assetsDir (gui/vite.config.ts). If that value is + // ever changed, update this prefix to match. filePath is already resolve()-normalized + // by resolveGuiFilePath, so rel cannot contain ".." fragments. + // + // Immutable 1-year caching is only applied when the file is under assets/ AND its basename + // matches Vite's content-hash pattern (e.g. index-B5r7LNHN.js). Any unhashed asset under + // assets/ (e.g. runtime-config.js) or elsewhere falls back to no-cache so clients revalidate, + // whereas index.html uses no-store above to avoid storing any bootstrap state. + const rel = relative(root, filePath).replace(/\\/g, "/"); + const isHashedAsset = rel.startsWith("assets/") && HASHED_ASSET_PATTERN.test(basename(filePath)); + const cacheControl = isHashedAsset + ? "public, max-age=31536000, immutable" + : "no-cache"; + // Snapshot bytes before returning the response. Bun.file is lazy: if gui/dist is replaced // after Bun frames the response but before the stream finishes, its Content-Length can // describe the old file while the body comes from the new one (#2792). return new Response(readFileSync(filePath), { - headers: { "Content-Type": contentType, ...browserSecurityHeaders() }, + headers: { + "Content-Type": contentType, + "Cache-Control": cacheControl, + ...browserSecurityHeaders(), + }, }); } diff --git a/tests/gui/gui-static.test.ts b/tests/gui/gui-static.test.ts index e8536fac52..43c8c1887e 100644 --- a/tests/gui/gui-static.test.ts +++ b/tests/gui/gui-static.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { serveGuiFile } from "../../src/server/gui-static"; @@ -29,3 +29,72 @@ test("#2792 snapshots a static asset before server framing can outlive the file" writeFileSync(assetPath, "truncated"); expect(await response!.text()).toBe(originalAsset); }); + +test("serves immutable cache header for assets and no-cache for non-hashed static files", async () => { + const guiDist = mkdtempSync(join(tmpdir(), "ocx-gui-static-cache-")); + temporaryDirectories.push(guiDist); + writeFileSync(join(guiDist, "index.html"), ""); + + mkdirSync(join(guiDist, "assets", "chunks"), { recursive: true }); + mkdirSync(join(guiDist, "provider-icons"), { recursive: true }); + + const hashedAssetPath = join(guiDist, "assets", "index-B5r7LNHN.js"); + writeFileSync(hashedAssetPath, "console.log('hashed asset');"); + const nestedAssetPath = join(guiDist, "assets", "chunks", "vendor-D7A_7j3g.js"); + writeFileSync(nestedAssetPath, "console.log('nested asset');"); + + const faviconPath = join(guiDist, "favicon.png"); + writeFileSync(faviconPath, "fake-png-bytes"); + const iconPath = join(guiDist, "provider-icons", "openai.svg"); + writeFileSync(iconPath, ""); + const unhashedAssetPath = join(guiDist, "assets", "runtime-config.js"); + writeFileSync(unhashedAssetPath, "window.__CONFIG__ = {};"); + const shortSuffixAssetPath = join(guiDist, "assets", "logo-small.png"); + writeFileSync(shortSuffixAssetPath, "fake-png-bytes"); + + // Hashed bundle asset under /assets/ should be cached immutably for 1 year + const assetResponse = serveGuiFile("/assets/index-B5r7LNHN.js", guiDist); + expect(assetResponse).not.toBeNull(); + expect(assetResponse!.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable"); + + // Nested asset under /assets/chunks/ should also be cached immutably + const nestedResponse = serveGuiFile("/assets/chunks/vendor-D7A_7j3g.js", guiDist); + expect(nestedResponse).not.toBeNull(); + expect(nestedResponse!.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable"); + + // Unhashed asset under /assets/ must NOT be cached immutably (regression check for CodeRabbit finding) + const unhashedResponse = serveGuiFile("/assets/runtime-config.js", guiDist); + expect(unhashedResponse).not.toBeNull(); + expect(unhashedResponse!.headers.get("Cache-Control")).toBe("no-cache"); + + // Asset with short suffix that doesn't match content-hash pattern must fall back to no-cache + const shortSuffixResponse = serveGuiFile("/assets/logo-small.png", guiDist); + expect(shortSuffixResponse).not.toBeNull(); + expect(shortSuffixResponse!.headers.get("Cache-Control")).toBe("no-cache"); + + // Non-hashed root asset should revalidate + const faviconResponse = serveGuiFile("/favicon.png", guiDist); + expect(faviconResponse).not.toBeNull(); + expect(faviconResponse!.headers.get("Cache-Control")).toBe("no-cache"); + + // Non-hashed subdirectory asset should revalidate + const iconResponse = serveGuiFile("/provider-icons/openai.svg", guiDist); + expect(iconResponse).not.toBeNull(); + expect(iconResponse!.headers.get("Cache-Control")).toBe("no-cache"); + + // HTML must remain no-store with Pragma: no-cache + const htmlResponse = serveGuiFile("/index.html", guiDist); + expect(htmlResponse).not.toBeNull(); + expect(htmlResponse!.headers.get("Cache-Control")).toBe("no-store"); + expect(htmlResponse!.headers.get("Pragma")).toBe("no-cache"); + + // SPA virtual route fallback (e.g. /models) must return index.html with no-store + const spaResponse = serveGuiFile("/models", guiDist); + expect(spaResponse).not.toBeNull(); + expect(spaResponse!.headers.get("Cache-Control")).toBe("no-store"); + + // Directory traversal attempt out of /assets/ must not be treated as immutable + const traversalResponse = serveGuiFile("/assets/../favicon.png", guiDist); + expect(traversalResponse).not.toBeNull(); + expect(traversalResponse!.headers.get("Cache-Control")).toBe("no-cache"); +});