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
32 changes: 28 additions & 4 deletions src/server/gui-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@ const MIME_TYPES: Record<string, string> = {
".ico": "image/x-icon",
};

interface StaticAssetSnapshot {
fingerprint: string;
blob: Blob;
}

const staticAssetSnapshots = new Map<string, StaticAssetSnapshot>();

function staticAssetSnapshot(path: string, contentType: string): Blob | null {
try {
const stat = statSync(path);
if (!stat.isFile()) return null;
const fingerprint = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
const cached = staticAssetSnapshots.get(path);
if (cached?.fingerprint === fingerprint) return cached.blob;

// Blob owns one immutable copy that every response can share. This keeps a response
// stable across package replacement without allocating the asset again per request.
const blob = new Blob([readFileSync(path)], { type: contentType });
staticAssetSnapshots.set(path, { fingerprint, blob });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Evict snapshots for obsolete asset paths

When a running source checkout rebuilds the dashboard—or a live package update replaces it—Vite gives changed JS/CSS bundles new content-hashed paths. Each subsequently requested bundle is retained by this process-wide map, while entries for the now-deleted paths are never removed and their full Blob contents remain reachable. Repeated dashboard rebuilds or updates therefore accumulate roughly one GUI bundle per version for the lifetime of the proxy, undermining the memory-amplification fix; bound the cache or purge entries that no longer belong to the current gui/dist snapshot.

Useful? React with 👍 / 👎.

return blob;
} catch {
return null;
}
}

function findGuiDist(): string | null {
const candidates = [
join(import.meta.dir, "..", "..", "gui", "dist"),
Expand Down Expand Up @@ -127,10 +152,9 @@ export function serveGuiFile(
const ext = extname(filePath);
const contentType = MIME_TYPES[ext] || "application/octet-stream";
if (ext === ".html") return htmlResponse(filePath, session);
// 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), {
const snapshot = staticAssetSnapshot(filePath, contentType);
if (!snapshot) return null;
return new Response(snapshot, {
headers: { "Content-Type": contentType, ...browserSecurityHeaders() },
});
}
Expand Down
6 changes: 5 additions & 1 deletion tests/gui-static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ afterEach(() => {
}
});

test("#2792 snapshots a static asset before server framing can outlive the file", async () => {
test("#2792 shares a stable static asset snapshot and refreshes it after replacement", async () => {
const guiDist = mkdtempSync(join(tmpdir(), "ocx-gui-static-"));
temporaryDirectories.push(guiDist);
writeFileSync(join(guiDist, "index.html"), "<!doctype html>");
Expand All @@ -27,4 +27,8 @@ test("#2792 snapshots a static asset before server framing can outlive the file"
// body must retain the same byte snapshot the HTTP server uses for Content-Length.
writeFileSync(assetPath, "truncated");
expect(await response!.text()).toBe(originalAsset);

const replacementResponse = serveGuiFile("/index.js", guiDist);
expect(replacementResponse).not.toBeNull();
expect(await replacementResponse!.text()).toBe("truncated");
});
Loading