Skip to content
Open
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
184 changes: 184 additions & 0 deletions server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
"lint": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fastify/compress": "^9.2.0",
"@fastify/cookie": "^11.1.2",
"@fastify/static": "^10.1.2",
"@fastify/csrf-protection": "^8.0.1",
"@fastify/redis": "^8.0.0",
"@fastify/reply-from": "^12.6.4",
"@fastify/static": "^10.1.2",
"fastify": "^5.11.2",
"fastify-plugin": "^6.0.0",
"ioredis": "^5.11.1",
Expand Down
2 changes: 2 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import Fastify from "fastify";

import { config } from "./config.js";
import compressPlugin from "./plugins/compress.js";
import cookiePlugin from "./plugins/cookie.js";
import csrfPlugin from "./plugins/csrf.js";
import redisPlugin from "./plugins/redis.js";
Expand All @@ -30,6 +31,7 @@ await fastify.register(cookiePlugin);
await fastify.register(redisPlugin);
await fastify.register(sessionPlugin);
await fastify.register(csrfPlugin);
await fastify.register(compressPlugin);
await fastify.register(staticPlugin);

fastify.get("/healthz", async () => ({ ok: true }));
Expand Down
23 changes: 23 additions & 0 deletions server/src/plugins/compress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Location: ./client/server/src/plugins/compress.ts
// Copyright contributors to the MCP-CONTEXT-FORGE project
// SPDX-License-Identifier: Apache-2.0
//
// Global response compression (brotli > gzip > deflate, by Accept-Encoding).
// Must be registered before staticPlugin — @fastify/compress's global hook
// only wraps replies from routes registered after it. SSE routes are exempt
// automatically: proxy-sse.ts calls reply.hijack(), which skips the onSend
// chain this plugin hooks into.
//
// globalDecompression: false — { global: true } alone also auto-decompresses
// request bodies fleet-wide (undocumented decompression-bomb surface).

import fastifyCompress from "@fastify/compress";
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";

export default fp(
async function compressPlugin(fastify: FastifyInstance) {
await fastify.register(fastifyCompress, { global: true, globalDecompression: false });
},
{ name: "compressPlugin" },
);
24 changes: 21 additions & 3 deletions server/src/plugins/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,31 @@ import { config } from "../config.js";
const DEFAULT_PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "../../public");
const PUBLIC_DIR = config.publicDir ?? DEFAULT_PUBLIC_DIR;

// The one place both the cache-header check below and the 404 handler's
// asset allowlist agree on what "an asset" is — keep them pointed at the
// same name so they can't drift, and keep it in sync with vite.config.ts's
// outDir contents and the root public/ dir it copies verbatim.
const ASSETS_DIR_NAME = "assets";
const ASSETS_URL_PREFIX = `/${ASSETS_DIR_NAME}/`;
// Prefix, not substring: PUBLIC_DIR is deploy-configurable (PUBLIC_DIR env
// var), so a bare `pathName.includes("/assets/")` would false-positive on
// index.html for any deploy path with an "assets" *ancestor* directory
// (e.g. PUBLIC_DIR=/srv/assets/server/public) — long-caching the SPA shell
// itself instead of only PUBLIC_DIR/assets/*.
const ASSETS_FS_PREFIX = path.join(PUBLIC_DIR, ASSETS_DIR_NAME) + path.sep;

export default fp(
async function staticPlugin(fastify: FastifyInstance) {
await fastify.register(fastifyStatic, {
root: PUBLIC_DIR,
prefix: "/",
index: false, // '/' is handled explicitly by routes/app.ts, for the auth check
// Only /assets/* is content-hashed by Vite, so only it gets long-cached.
setHeaders(reply, pathName) {
if (pathName.startsWith(ASSETS_FS_PREFIX)) {
reply.header("Cache-Control", "public, max-age=31536000, immutable");

@marekdano marekdano Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

server/src/plugins/static.ts:38
The 1-year immutable cache header is applied to the whole static root, not just the hashed /assets/* prefix. Direct requests to index.html (or any non-hashed file copied from public/) get cached immutably for a year, so after the next deploy (which wipes and regenerates hashed chunk names) clients can get stuck on a stale shell referencing chunks that no longer exist.

@gcgoncalves gcgoncalves Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for flagging this. I think it's actually already handled: the header is only applied when the resolved file path contains /assets/:

if (pathName.includes(`${path.sep}assets${path.sep}`)) {
  reply.header("Cache-Control", "public, max-age=31536000, immutable");
}

index.html and anything copied verbatim from public/ don't match that condition, so they fall through with no Cache-Control header set (i.e. @fastify/static's default, effectively no long-term caching). It's not being applied to the whole static root.

That said, you were right about the underlying risk: this exact bug (unconditional maxAge: "1y", immutable: true on the whole root) existed one commit back, before Fix caching issues replaced it with this scoped setHeaders check. Might be worth a re-review/resolve on this thread since it looks like the comment landed on the pre-fix version.

}
},
});

fastify.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
Expand All @@ -42,9 +61,8 @@ export default fp(
// /app/reset-password/:token when the token itself contains a dot.
// Anything under these prefixes that reaches here is a genuinely
// missing build artifact; everything else is a client-router path and
// gets the SPA shell. Keep in sync with vite.config.ts's outDir
// contents and the root public/ dir it copies verbatim.
const isKnownAssetPath = pathname.startsWith("/assets/") || pathname === "/favicon.ico";
// gets the SPA shell.
const isKnownAssetPath = pathname.startsWith(ASSETS_URL_PREFIX) || pathname === "/favicon.ico";

if (
request.method !== "GET" ||
Expand Down
Loading
Loading