Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions src/server/gui-static.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -18,6 +18,12 @@ const MIME_TYPES: Record<string, string> = {
".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"),
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
},
});
}

Expand Down
71 changes: 70 additions & 1 deletion tests/gui/gui-static.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"), "<!doctype 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, "<svg></svg>");
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");
});
Loading