diff --git a/server/package-lock.json b/server/package-lock.json
index 2995d4d..9ee9df8 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -8,6 +8,7 @@
"name": "mcp-context-forge-bff",
"version": "0.1.0",
"dependencies": {
+ "@fastify/compress": "^9.2.0",
"@fastify/cookie": "^11.1.2",
"@fastify/csrf-protection": "^8.0.1",
"@fastify/redis": "^8.0.0",
@@ -23,6 +24,9 @@
"tsx": "^4.23.7",
"typescript": "^5.9.3",
"vitest": "^4.1.10"
+ },
+ "engines": {
+ "node": ">=18.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -498,6 +502,29 @@
"fast-uri": "^3.0.0"
}
},
+ "node_modules/@fastify/compress": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/@fastify/compress/-/compress-9.2.0.tgz",
+ "integrity": "sha512-35VL33PIEt/UbD/ZMlRNaICd2BQz0IaG7en74Dv7tI+MHIQkedibS1+9JfekWH4I1g+Y8RbbZD4Zsk9h4XTItg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/accept-negotiator": "^2.0.0",
+ "fastify-plugin": "^6.0.0",
+ "mime-db": "^1.52.0",
+ "minipass": "^7.0.4",
+ "readable-stream": "^4.5.2"
+ }
+ },
"node_modules/@fastify/cookie": {
"version": "11.1.2",
"funding": [
@@ -1153,6 +1180,18 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "license": "MIT",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
"node_modules/abstract-logging": {
"version": "2.0.1",
"license": "MIT"
@@ -1226,6 +1265,26 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/brace-expansion": {
"version": "5.0.9",
"license": "MIT",
@@ -1236,6 +1295,30 @@
"node": "20 || >=22"
}
},
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
"node_modules/chai": {
"version": "6.2.2",
"dev": true,
@@ -1386,6 +1469,24 @@
"@types/estree": "^1.0.0"
}
},
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
"node_modules/expect-type": {
"version": "1.4.0",
"dev": true,
@@ -1598,6 +1699,26 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/inherits": {
"version": "2.0.4",
"license": "ISC"
@@ -1988,6 +2109,15 @@
"node": ">=10.0.0"
}
},
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.6",
"license": "BlueOak-1.0.0",
@@ -2148,6 +2278,15 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/process-warning": {
"version": "5.1.0",
"funding": [
@@ -2166,6 +2305,22 @@
"version": "4.0.4",
"license": "MIT"
},
+ "node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "license": "MIT",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
"node_modules/real-require": {
"version": "0.2.0",
"license": "MIT",
@@ -2247,6 +2402,26 @@
"@rolldown/binding-win32-x64-msvc": "1.2.2"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/safe-regex2": {
"version": "5.1.1",
"funding": [
@@ -2354,6 +2529,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/thread-stream": {
"version": "4.2.0",
"license": "MIT",
diff --git a/server/package.json b/server/package.json
index f778290..20120f6 100644
--- a/server/package.json
+++ b/server/package.json
@@ -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",
diff --git a/server/src/index.ts b/server/src/index.ts
index 2294db8..0e44dc7 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -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";
@@ -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 }));
diff --git a/server/src/plugins/compress.ts b/server/src/plugins/compress.ts
new file mode 100644
index 0000000..b5558e6
--- /dev/null
+++ b/server/src/plugins/compress.ts
@@ -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" },
+);
diff --git a/server/src/plugins/static.ts b/server/src/plugins/static.ts
index 0ee491a..b43d9a1 100644
--- a/server/src/plugins/static.ts
+++ b/server/src/plugins/static.ts
@@ -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");
+ }
+ },
});
fastify.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
@@ -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" ||
diff --git a/server/test/app.test.ts b/server/test/app.test.ts
index 2d63dd1..4e8037d 100644
--- a/server/test/app.test.ts
+++ b/server/test/app.test.ts
@@ -8,7 +8,7 @@
// process.env.PUBLIC_DIR set in beforeAll, with modules under test
// dynamic-imported afterwards.
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -24,6 +24,9 @@ beforeAll(async () => {
path.join(publicDir, "index.html"),
"
spa-shell-marker",
);
+ await writeFile(path.join(publicDir, "favicon.ico"), "favicon-bytes");
+ await mkdir(path.join(publicDir, "assets"), { recursive: true });
+ await writeFile(path.join(publicDir, "assets", "index-abc123.js"), "console.log('hashed')");
process.env.PUBLIC_DIR = publicDir;
});
@@ -120,3 +123,29 @@ describe("SPA fallback (404 handler)", () => {
expect(response.body).not.toContain("spa-shell-marker");
});
});
+
+describe("Cache-Control headers", () => {
+ it("long-caches hashed /assets/* files", async () => {
+ app = await buildApp();
+ const response = await app.fastify.inject({ method: "GET", url: "/assets/index-abc123.js" });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
+ });
+
+ it("does not long-cache favicon.ico served directly (not content-hashed)", async () => {
+ app = await buildApp();
+ const response = await app.fastify.inject({ method: "GET", url: "/favicon.ico" });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["cache-control"]).not.toContain("immutable");
+ });
+
+ it("does not long-cache index.html when served directly (not via the SPA fallback)", async () => {
+ app = await buildApp();
+ const response = await app.fastify.inject({ method: "GET", url: "/index.html" });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["cache-control"]).not.toContain("immutable");
+ });
+});
diff --git a/server/test/compress.test.ts b/server/test/compress.test.ts
new file mode 100644
index 0000000..888aca0
--- /dev/null
+++ b/server/test/compress.test.ts
@@ -0,0 +1,68 @@
+// Location: ./client/server/test/compress.test.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Covers both halves of compressPlugin: responses get compressed (the
+// stated intent), and request bodies do NOT get auto-decompressed (the
+// globalDecompression: false fix — @fastify/compress defaults that to true
+// under plain { global: true }, which would silently gunzip any request
+// body with a Content-Encoding header fleet-wide).
+
+import { gunzipSync, gzipSync } from "node:zlib";
+
+import Fastify, { type FastifyInstance } from "fastify";
+import { afterEach, describe, expect, it } from "vitest";
+
+import compressPlugin from "../src/plugins/compress.js";
+
+let app: FastifyInstance;
+
+afterEach(async () => {
+ await app?.close();
+});
+
+describe("compressPlugin", () => {
+ it("compresses a response when the client accepts gzip", async () => {
+ app = Fastify();
+ await app.register(compressPlugin);
+ const payload = "x".repeat(2048); // above @fastify/compress's default threshold
+ app.get("/big", async () => payload);
+
+ const response = await app.inject({
+ method: "GET",
+ url: "/big",
+ headers: { "accept-encoding": "gzip" },
+ });
+
+ expect(response.headers["content-encoding"]).toBe("gzip");
+ expect(gunzipSync(response.rawPayload).toString()).toBe(payload);
+ });
+
+ it("does NOT auto-decompress a request body with Content-Encoding: gzip", async () => {
+ app = Fastify();
+ await app.register(compressPlugin);
+ // Bypass the default JSON parser (which would happily gunzip-then-parse
+ // if globalDecompression were on) so the route sees the raw bytes
+ // exactly as they arrived off the wire.
+ app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
+ done(null, body);
+ });
+ app.post("/echo-raw", async (request) => {
+ const buf = request.body as Buffer;
+ return { firstTwoBytes: [...buf.subarray(0, 2)] };
+ });
+
+ const compressedBody = gzipSync(JSON.stringify({ hello: "world" }));
+ const response = await app.inject({
+ method: "POST",
+ url: "/echo-raw",
+ headers: { "content-type": "application/json", "content-encoding": "gzip" },
+ payload: compressedBody,
+ });
+
+ expect(response.statusCode).toBe(200);
+ // Gzip magic number (0x1f8b): still present, i.e. still compressed —
+ // proves the plugin left the request body untouched.
+ expect(response.json()).toEqual({ firstTwoBytes: [0x1f, 0x8b] });
+ });
+});
diff --git a/server/test/static-cache-scope.test.ts b/server/test/static-cache-scope.test.ts
new file mode 100644
index 0000000..0d05a1c
--- /dev/null
+++ b/server/test/static-cache-scope.test.ts
@@ -0,0 +1,66 @@
+// Location: ./client/server/test/static-cache-scope.test.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Regression test for the cache-header scoping bug: PUBLIC_DIR is deploy-
+// configurable (PUBLIC_DIR env var), so a naive `pathName.includes("/assets/")`
+// check false-positives on index.html whenever the deploy path itself has an
+// "assets" *ancestor* directory — not just PUBLIC_DIR/assets/*. Uses its own
+// file (rather than app.test.ts) because PUBLIC_DIR is read once at module
+// evaluation and vitest isolates modules per test file.
+
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+
+import Fastify, { type FastifyInstance } from "fastify";
+import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+
+let publicDir: string;
+let assetsAncestorRoot: string;
+
+beforeAll(async () => {
+ // .../assets//server/public — "assets" is an ancestor of
+ // PUBLIC_DIR, not PUBLIC_DIR's own child.
+ assetsAncestorRoot = await mkdtemp(path.join(tmpdir(), "assets-"));
+ publicDir = path.join(assetsAncestorRoot, "server", "public");
+ await mkdir(path.join(publicDir, "assets"), { recursive: true });
+ await writeFile(
+ path.join(publicDir, "index.html"),
+ "spa-shell-marker",
+ );
+ await writeFile(path.join(publicDir, "assets", "index-abc123.js"), "console.log('hashed')");
+ process.env.PUBLIC_DIR = publicDir;
+});
+
+afterAll(() => rm(assetsAncestorRoot, { recursive: true, force: true }));
+
+let app: FastifyInstance;
+
+afterEach(async () => {
+ await app?.close();
+});
+
+describe("static plugin cache-header scoping", () => {
+ it("does not long-cache index.html when PUBLIC_DIR has an 'assets' ancestor directory", async () => {
+ const staticPlugin = (await import("../src/plugins/static.js")).default;
+ app = Fastify();
+ await app.register(staticPlugin);
+
+ const response = await app.inject({ method: "GET", url: "/index.html" });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["cache-control"]).not.toContain("immutable");
+ });
+
+ it("still long-caches files actually under PUBLIC_DIR/assets/*", async () => {
+ const staticPlugin = (await import("../src/plugins/static.js")).default;
+ app = Fastify();
+ await app.register(staticPlugin);
+
+ const response = await app.inject({ method: "GET", url: "/assets/index-abc123.js" });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
+ });
+});
diff --git a/src/App.test.tsx b/src/App.test.tsx
index fdc4011..85e0240 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -16,6 +16,35 @@ vi.mock("@/hooks/useQuery", () => ({
}));
describe("App", () => {
+ it("shows the Suspense fallback while a route chunk is loading, and only clears the chunk-reload guard once real content mounts", async () => {
+ // Must be the first test in this file to exercise Login's *unresolved*
+ // lazy import — later tests hit its now-cached module instead, so the
+ // fallback never actually shows for them.
+ localStorage.clear();
+ sessionStorage.clear();
+ // Regression coverage: the guard used to live in an effect on App
+ // itself, an ancestor of Suspense — so it fired on the fallback's own
+ // commit, before the lazy chunk had a chance to succeed or fail. A
+ // route stuck permanently broken would then auto-reload forever
+ // instead of once. Pre-set the flag and assert it survives the
+ // fallback phase, only clearing once the chunk actually mounts.
+ sessionStorage.setItem("cf:error-boundary-reloaded", "1");
+ window.history.pushState({}, "", "/app/login");
+
+ renderWithProviders();
+
+ // First mount of the Login chunk: its Suspense fallback shows before
+ // the lazy import resolves (later tests hit the cached module instead).
+ expect(screen.getByRole("status", { name: /loading/i })).toBeInTheDocument();
+ expect(sessionStorage.getItem("cf:error-boundary-reloaded")).toBe("1");
+
+ await waitFor(() =>
+ expect(screen.queryByRole("status", { name: /loading/i })).not.toBeInTheDocument(),
+ );
+ expect(screen.getByRole("heading", { name: /sign in/i })).toBeInTheDocument();
+ expect(sessionStorage.getItem("cf:error-boundary-reloaded")).toBeNull();
+ });
+
it("logs in and navigates to Gateways page via sidebar", async () => {
const user = userEvent.setup();
@@ -62,4 +91,59 @@ describe("App", () => {
// The bare /app path renders , which normalizes the URL.
await waitFor(() => expect(window.location.pathname).toBe("/app/"));
});
+
+ it("navigates between separately-chunked public routes (login -> forgot password)", async () => {
+ const user = userEvent.setup();
+ localStorage.clear();
+ sessionStorage.clear();
+ window.history.pushState({}, "", "/app/login");
+
+ renderWithProviders();
+ await screen.findByRole("heading", { name: /sign in/i });
+
+ await user.click(screen.getByText(/forgot password/i));
+
+ // Login's chunk unmounts and ForgotPassword's chunk loads in its place.
+ await screen.findByRole("heading", { name: /forgot password/i });
+ expect(screen.queryByRole("heading", { name: /sign in/i })).not.toBeInTheDocument();
+ });
+
+ it("mounts every stub private route's lazy chunk at least once", async () => {
+ const user = userEvent.setup();
+ localStorage.clear();
+ sessionStorage.clear();
+ window.history.pushState({}, "", "/app/login");
+
+ renderWithProviders();
+ await screen.findByRole("heading", { name: /sign in/i });
+ await user.type(screen.getByLabelText(/email address/i), "test@example.com");
+ await user.type(screen.getByLabelText(/password/i), "password123");
+ await user.click(screen.getByRole("button", { name: /sign in/i }));
+ await screen.findByRole("heading", { name: /connect a source/i });
+
+ // These routes are plain placeholder pages (no network calls), so it's
+ // safe to fly through all of them just to exercise their App.tsx lazy()
+ // wrapper — the heavier pages already get their own dedicated tests.
+ const stubRoutesAndHeadings: [string, RegExp][] = [
+ ["/app/change-password", /change password/i],
+ ["/app/agents", /agents/i],
+ ["/app/rest-api", /rest api/i],
+ ["/app/grpc", /grpc/i],
+ ["/app/llm/providers", /llm providers/i],
+ ["/app/llm/models", /llm models/i],
+ ["/app/metrics", /metrics/i],
+ ["/app/observability", /observability/i],
+ ["/app/plugins", /plugins/i],
+ ["/app/performance", /performance/i],
+ ["/app/maintenance", /maintenance/i],
+ ["/app/server-catalog", /server catalog/i],
+ ["/app/not-found", /page not found\./i],
+ ];
+
+ for (const [route, heading] of stubRoutesAndHeadings) {
+ window.history.pushState({}, "", route);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ await screen.findByText(heading);
+ }
+ });
});
diff --git a/src/App.tsx b/src/App.tsx
index ec28ebd..cd16980 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,32 +1,68 @@
+import { lazy, Suspense, useEffect, type ComponentType } from "react";
import { AuthProvider } from "./auth/AuthContext";
+import { ForgotPassword } from "./pages/ForgotPassword";
+import { ResetPassword } from "./pages/ResetPassword";
+import { ChangePassword } from "./pages/ChangePassword";
import { ThemeProvider } from "./hooks/useTheme";
import { Toaster } from "@/components/ui/sonner";
import { RouterProvider, Route, Redirect, AuthGuard, useRouter } from "./router";
import { AppShell } from "./components/layout/AppShell";
-import { Login } from "./pages/Login";
-import { ForgotPassword } from "./pages/ForgotPassword";
-import { ResetPassword } from "./pages/ResetPassword";
-import { ChangePassword } from "./pages/ChangePassword";
-import { Dashboard } from "./pages/Dashboard";
-import { Gateways } from "./pages/Gateways";
-import { CreateServer } from "./pages/CreateServer";
-import { Servers } from "./pages/Servers";
-import { Tools } from "./pages/Tools";
-import { Resources } from "./pages/Resources";
-import { ServerCatalog } from "./pages/ServerCatalog";
-import { Prompts } from "./pages/Prompts";
-import { Agents } from "./pages/Agents";
-import { RestApi } from "./pages/RestApi";
-import { Grpc } from "./pages/Grpc";
-import { LLMProviders } from "./pages/LLMProviders";
-import { LLMModels } from "./pages/LLMModels";
-import { Metrics } from "./pages/Metrics";
-import { Observability } from "./pages/Observability";
-import { Plugins } from "./pages/Plugins";
-import { Performance } from "./pages/Performance";
-import { Maintenance } from "./pages/Maintenance";
-import { Settings } from "./pages/Settings";
-import { NotFound } from "./pages/NotFound";
+import { Loading } from "@/components/ui/loading";
+import { ErrorBoundary, clearChunkReloadGuard } from "@/components/ErrorBoundary";
+
+// Route-level code splitting: each page becomes its own chunk, fetched only
+// when its route is visited, instead of all ~25 pages riding in the initial
+// bundle. `lazyNamed` is the shared `.then(m => ({ default: m[key] }))`
+// adapter since React.lazy only accepts a default export.
+function lazyNamed, K extends keyof M & string>(
+ factory: () => Promise,
+ key: K,
+) {
+ return lazy(() => factory().then((m) => ({ default: m[key] })));
+}
+
+// ForgotPassword/ResetPassword/ChangePassword are a few lines of static
+// placeholder JSX each ("Not yet implemented") — splitting them into their
+// own chunk costs a request for less code than the import() wiring saves.
+const Login = lazyNamed(() => import("./pages/Login"), "Login");
+const Dashboard = lazyNamed(() => import("./pages/Dashboard"), "Dashboard");
+// Dashboard is the default post-login landing page (see the /app/ route
+// below), so warm its chunk in parallel with the auth check instead of
+// waiting for AuthGuard to resolve first — otherwise its own useQuery data
+// fetch only starts after chunk-fetch -> parse -> mount, a JS-then-data
+// waterfall on the single most common navigation in the app.
+void import("./pages/Dashboard");
+const Gateways = lazyNamed(() => import("./pages/Gateways"), "Gateways");
+const CreateServer = lazyNamed(() => import("./pages/CreateServer"), "CreateServer");
+const Servers = lazyNamed(() => import("./pages/Servers"), "Servers");
+const Tools = lazyNamed(() => import("./pages/Tools"), "Tools");
+const Resources = lazyNamed(() => import("./pages/Resources"), "Resources");
+const ServerCatalog = lazyNamed(() => import("./pages/ServerCatalog"), "ServerCatalog");
+const Prompts = lazyNamed(() => import("./pages/Prompts"), "Prompts");
+const Agents = lazyNamed(() => import("./pages/Agents"), "Agents");
+const RestApi = lazyNamed(() => import("./pages/RestApi"), "RestApi");
+const Grpc = lazyNamed(() => import("./pages/Grpc"), "Grpc");
+const LLMProviders = lazyNamed(() => import("./pages/LLMProviders"), "LLMProviders");
+const LLMModels = lazyNamed(() => import("./pages/LLMModels"), "LLMModels");
+const Metrics = lazyNamed(() => import("./pages/Metrics"), "Metrics");
+const Observability = lazyNamed(() => import("./pages/Observability"), "Observability");
+const Plugins = lazyNamed(() => import("./pages/Plugins"), "Plugins");
+const Performance = lazyNamed(() => import("./pages/Performance"), "Performance");
+const Maintenance = lazyNamed(() => import("./pages/Maintenance"), "Maintenance");
+const Settings = lazyNamed(() => import("./pages/Settings"), "Settings");
+const NotFound = lazyNamed(() => import("./pages/NotFound"), "NotFound");
+
+// Rendered as a Suspense *child*, not an ancestor — so its effect only
+// fires once the lazy route chunk has actually resolved and mounted, never
+// while the fallback is still showing. Clearing the reload guard here (not
+// in App itself) is what makes "reload once, then give up" actually hold
+// for a chunk that's permanently broken, not just stale.
+function ClearReloadGuardOnMount() {
+ useEffect(() => {
+ clearChunkReloadGuard();
+ }, []);
+ return null;
+}
function SettingsTabRedirect({ to }: { to: string }) {
const { path } = useRouter();
@@ -42,11 +78,12 @@ const TokensRedirect = () => ;
// ---------------------------------------------------------------------------
function PublicRoutes() {
return (
- <>
+ }>
+
- >
+
);
}
@@ -57,31 +94,36 @@ function PrivateRoutes() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {/* Suspense sits inside AppShell so sidebar/header render immediately
+ and only the route body shows the fallback while its chunk loads. */}
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
@@ -95,7 +137,9 @@ export function App() {
-
+
+
+
diff --git a/src/components/ErrorBoundary.test.tsx b/src/components/ErrorBoundary.test.tsx
new file mode 100644
index 0000000..846985c
--- /dev/null
+++ b/src/components/ErrorBoundary.test.tsx
@@ -0,0 +1,109 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { ErrorBoundary, clearChunkReloadGuard } from "./ErrorBoundary";
+
+vi.mock("react-intl", () => ({
+ useIntl: () => ({
+ formatMessage: ({ id }: { id: string }) => id,
+ }),
+}));
+
+function Bomb({ message }: { message: string }): never {
+ throw new Error(message);
+}
+
+function NonErrorBomb({ value }: { value: unknown }): never {
+ throw value; // deliberately non-Error — see test below
+}
+
+// Silence React's error-boundary console.error noise for these tests.
+const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+
+describe("ErrorBoundary", () => {
+ const originalReload = window.location.reload;
+
+ beforeEach(() => {
+ sessionStorage.clear();
+ Object.defineProperty(window, "location", {
+ value: { ...window.location, reload: vi.fn() },
+ writable: true,
+ });
+ });
+
+ afterEach(() => {
+ window.location.reload = originalReload;
+ consoleErrorSpy.mockClear();
+ });
+
+ it("renders children when nothing throws", () => {
+ render(
+
+ content
+ ,
+ );
+ expect(screen.getByText("content")).toBeTruthy();
+ });
+
+ it("shows generic fallback and logs for a non-chunk error", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("alert")).toBeTruthy();
+ expect(screen.getByText("common.errorBoundary.genericMessage")).toBeTruthy();
+ expect(window.location.reload).not.toHaveBeenCalled();
+ });
+
+ it("auto-reloads once on a chunk-load error", () => {
+ render(
+
+
+ ,
+ );
+ expect(window.location.reload).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not auto-reload a chunk error twice in the same session", () => {
+ sessionStorage.setItem("cf:error-boundary-reloaded", "1");
+ render(
+
+
+ ,
+ );
+ expect(window.location.reload).not.toHaveBeenCalled();
+ expect(screen.getByText("common.errorBoundary.chunkLoadMessage")).toBeTruthy();
+ });
+
+ it("reload button clears the guard and reloads", () => {
+ sessionStorage.setItem("cf:error-boundary-reloaded", "1");
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText("common.errorBoundary.reload"));
+ expect(sessionStorage.getItem("cf:error-boundary-reloaded")).toBeNull();
+ expect(window.location.reload).toHaveBeenCalled();
+ });
+
+ it.each([null, undefined, "plain string", 42])(
+ "shows the generic fallback instead of crashing when %p is thrown",
+ (value) => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("alert")).toBeTruthy();
+ expect(screen.getByText("common.errorBoundary.genericMessage")).toBeTruthy();
+ expect(window.location.reload).not.toHaveBeenCalled();
+ },
+ );
+
+ it("clearChunkReloadGuard removes the session flag", () => {
+ sessionStorage.setItem("cf:error-boundary-reloaded", "1");
+ clearChunkReloadGuard();
+ expect(sessionStorage.getItem("cf:error-boundary-reloaded")).toBeNull();
+ });
+});
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..0532da4
--- /dev/null
+++ b/src/components/ErrorBoundary.tsx
@@ -0,0 +1,94 @@
+import { Component, type ErrorInfo, type ReactNode } from "react";
+import { useIntl } from "react-intl";
+import { Button } from "@/components/ui/button";
+
+// Vite/webpack throw distinct messages when a lazy chunk 404s (stale build
+// still referencing a chunk name the latest deploy no longer serves).
+const CHUNK_LOAD_ERROR_PATTERN =
+ /Failed to fetch dynamically imported module|error loading dynamically imported module|ChunkLoadError|Importing a module script failed/i;
+
+// `error` is typed `Error` by React but isn't guaranteed to be one at
+// runtime — `throw "x"` or an empty `Promise.reject()` land here unchanged.
+function isChunkLoadError(error: unknown): boolean {
+ if (!(error instanceof Error)) return false;
+ return error.name === "ChunkLoadError" || CHUNK_LOAD_ERROR_PATTERN.test(error.message);
+}
+
+// One-time auto-reload guard: a stale-chunk error is fixed by a single
+// reload (it fetches the new build's manifest); without the guard a page
+// that still fails after reloading would reload forever.
+const RELOAD_FLAG_KEY = "cf:error-boundary-reloaded";
+
+// Called once the app has rendered successfully, so a *later* chunk-load
+// error (e.g. after the next deploy) still gets its one auto-reload.
+export function clearChunkReloadGuard() {
+ sessionStorage.removeItem(RELOAD_FLAG_KEY);
+}
+
+function Fallback({ isChunkError, onReload }: { isChunkError: boolean; onReload: () => void }) {
+ const intl = useIntl();
+ return (
+
+
+ {intl.formatMessage({ id: "common.errorBoundary.title" })}
+
+
+ {intl.formatMessage({
+ id: isChunkError
+ ? "common.errorBoundary.chunkLoadMessage"
+ : "common.errorBoundary.genericMessage",
+ })}
+
+
+
+ );
+}
+
+interface Props {
+ children: ReactNode;
+}
+
+interface State {
+ error: unknown;
+ hasError: boolean;
+}
+
+// Class component: componentDidCatch has no hook equivalent.
+export class ErrorBoundary extends Component {
+ state: State = { error: null, hasError: false };
+
+ static getDerivedStateFromError(error: unknown): State {
+ return { error, hasError: true };
+ }
+
+ componentDidCatch(error: unknown, info: ErrorInfo) {
+ if (isChunkLoadError(error) && !sessionStorage.getItem(RELOAD_FLAG_KEY)) {
+ sessionStorage.setItem(RELOAD_FLAG_KEY, "1");
+ window.location.reload();
+ return;
+ }
+ console.error("Uncaught error in route tree:", error, info.componentStack);
+ }
+
+ handleReload = () => {
+ sessionStorage.removeItem(RELOAD_FLAG_KEY);
+ window.location.reload();
+ };
+
+ render() {
+ const { error, hasError } = this.state;
+ if (hasError) {
+ // Chunk-load errors auto-reload once (above); this fallback only
+ // renders if that reload didn't fix it, or for non-chunk errors —
+ // `error` itself may be null/undefined (e.g. a bare `throw`), so
+ // `hasError` — not truthiness of `error` — is what gates this.
+ return ;
+ }
+ return this.props.children;
+ }
+}
diff --git a/src/i18n/locales/en-US/common.json b/src/i18n/locales/en-US/common.json
index d168c8b..16f797f 100644
--- a/src/i18n/locales/en-US/common.json
+++ b/src/i18n/locales/en-US/common.json
@@ -43,5 +43,9 @@
"common.theme.dark": "Dark mode",
"common.theme.system": "System theme",
"common.theme.toggle": "Toggle theme",
- "common.button.remove": "Remove"
+ "common.button.remove": "Remove",
+ "common.errorBoundary.title": "Something went wrong",
+ "common.errorBoundary.chunkLoadMessage": "This page was updated since you last loaded it. Reload to get the latest version.",
+ "common.errorBoundary.genericMessage": "An unexpected error occurred. Try reloading the page.",
+ "common.errorBoundary.reload": "Reload page"
}
diff --git a/src/i18n/locales/es-ES/common.json b/src/i18n/locales/es-ES/common.json
index 1256aa5..be90dbb 100644
--- a/src/i18n/locales/es-ES/common.json
+++ b/src/i18n/locales/es-ES/common.json
@@ -43,5 +43,9 @@
"common.theme.dark": "Modo oscuro",
"common.theme.system": "Tema del sistema",
"common.theme.toggle": "Cambiar tema",
- "common.button.remove": "Eliminar"
+ "common.button.remove": "Eliminar",
+ "common.errorBoundary.title": "Algo salió mal",
+ "common.errorBoundary.chunkLoadMessage": "Esta página se actualizó desde la última vez que la cargaste. Recárgala para obtener la última versión.",
+ "common.errorBoundary.genericMessage": "Ocurrió un error inesperado. Intenta recargar la página.",
+ "common.errorBoundary.reload": "Recargar página"
}
diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json
index 4dfe550..cf0cacb 100644
--- a/src/i18n/locales/pt-BR/common.json
+++ b/src/i18n/locales/pt-BR/common.json
@@ -43,5 +43,9 @@
"common.theme.dark": "Modo escuro",
"common.theme.system": "Tema do sistema",
"common.theme.toggle": "Alternar tema",
- "common.button.remove": "Remover"
+ "common.button.remove": "Remover",
+ "common.errorBoundary.title": "Algo deu errado",
+ "common.errorBoundary.chunkLoadMessage": "Esta página foi atualizada desde o último carregamento. Recarregue para obter a versão mais recente.",
+ "common.errorBoundary.genericMessage": "Ocorreu um erro inesperado. Tente recarregar a página.",
+ "common.errorBoundary.reload": "Recarregar página"
}