From a705dfa418fbaa0a27772bbe85f216e16ad9d812 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Wed, 12 Aug 2026 12:18:12 +0100 Subject: [PATCH 1/4] feature: Performance improvements Lazy-load all page components in App.tsx (565KB main chunk -> 200KB, per-page chunks fetched on nav). Register @fastify/compress globally on the BFF (gzip/brotli, SSE exempt via reply.hijack()). Cache hashed /assets/* for 1y immutable; index.html stays no-cache. Targets FCP regression vs the HTMX app (1.4s vs 0.6s). Signed-off-by: Gabriel Costa --- server/package-lock.json | 184 +++++++++++++++++++++++++++++++++ server/package.json | 3 +- server/src/index.ts | 2 + server/src/plugins/compress.ts | 20 ++++ server/src/plugins/static.ts | 7 +- src/App.tsx | 131 ++++++++++++++--------- 6 files changed, 294 insertions(+), 53 deletions(-) create mode 100644 server/src/plugins/compress.ts 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..38e2d40 --- /dev/null +++ b/server/src/plugins/compress.ts @@ -0,0 +1,20 @@ +// 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. + +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 }); + }, + { name: "compressPlugin" }, +); diff --git a/server/src/plugins/static.ts b/server/src/plugins/static.ts index 0ee491a..306c282 100644 --- a/server/src/plugins/static.ts +++ b/server/src/plugins/static.ts @@ -32,6 +32,11 @@ export default fp( root: PUBLIC_DIR, prefix: "/", index: false, // '/' is handled explicitly by routes/app.ts, for the auth check + // Vite content-hashes every /assets/* filename, so those are safe to + // cache for a year. index.html itself overrides this below — it must + // always revalidate, since it's what points at the current hashes. + maxAge: "1y", + immutable: true, }); fastify.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { @@ -58,7 +63,7 @@ export default fp( statusCode: 404, }); } - return reply.sendFile("index.html"); + return reply.sendFile("index.html", { maxAge: 0, immutable: false }); }); }, { name: "staticPlugin" }, diff --git a/src/App.tsx b/src/App.tsx index ec28ebd..25057e7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,32 +1,57 @@ +import { lazy, Suspense } from "react"; import { AuthProvider } from "./auth/AuthContext"; 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"; + +// 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. Named exports need the `.then(m => ({ default: m.X }))` adapter +// since React.lazy only accepts a default export. +const Login = lazy(() => import("./pages/Login").then((m) => ({ default: m.Login }))); +const ForgotPassword = lazy(() => + import("./pages/ForgotPassword").then((m) => ({ default: m.ForgotPassword })), +); +const ResetPassword = lazy(() => + import("./pages/ResetPassword").then((m) => ({ default: m.ResetPassword })), +); +const ChangePassword = lazy(() => + import("./pages/ChangePassword").then((m) => ({ default: m.ChangePassword })), +); +const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard }))); +const Gateways = lazy(() => import("./pages/Gateways").then((m) => ({ default: m.Gateways }))); +const CreateServer = lazy(() => + import("./pages/CreateServer").then((m) => ({ default: m.CreateServer })), +); +const Servers = lazy(() => import("./pages/Servers").then((m) => ({ default: m.Servers }))); +const Tools = lazy(() => import("./pages/Tools").then((m) => ({ default: m.Tools }))); +const Resources = lazy(() => import("./pages/Resources").then((m) => ({ default: m.Resources }))); +const ServerCatalog = lazy(() => + import("./pages/ServerCatalog").then((m) => ({ default: m.ServerCatalog })), +); +const Prompts = lazy(() => import("./pages/Prompts").then((m) => ({ default: m.Prompts }))); +const Agents = lazy(() => import("./pages/Agents").then((m) => ({ default: m.Agents }))); +const RestApi = lazy(() => import("./pages/RestApi").then((m) => ({ default: m.RestApi }))); +const Grpc = lazy(() => import("./pages/Grpc").then((m) => ({ default: m.Grpc }))); +const LLMProviders = lazy(() => + import("./pages/LLMProviders").then((m) => ({ default: m.LLMProviders })), +); +const LLMModels = lazy(() => import("./pages/LLMModels").then((m) => ({ default: m.LLMModels }))); +const Metrics = lazy(() => import("./pages/Metrics").then((m) => ({ default: m.Metrics }))); +const Observability = lazy(() => + import("./pages/Observability").then((m) => ({ default: m.Observability })), +); +const Plugins = lazy(() => import("./pages/Plugins").then((m) => ({ default: m.Plugins }))); +const Performance = lazy(() => + import("./pages/Performance").then((m) => ({ default: m.Performance })), +); +const Maintenance = lazy(() => + import("./pages/Maintenance").then((m) => ({ default: m.Maintenance })), +); +const Settings = lazy(() => import("./pages/Settings").then((m) => ({ default: m.Settings }))); +const NotFound = lazy(() => import("./pages/NotFound").then((m) => ({ default: m.NotFound }))); function SettingsTabRedirect({ to }: { to: string }) { const { path } = useRouter(); @@ -42,11 +67,11 @@ const TokensRedirect = () => ; // --------------------------------------------------------------------------- function PublicRoutes() { return ( - <> + }> - + ); } @@ -57,31 +82,35 @@ function PrivateRoutes() { return ( - - - - - - - - - - - - - - - - - - - - - - - - - + {/* Suspense sits inside AppShell so sidebar/header render immediately + and only the route body shows the fallback while its chunk loads. */} + }> + + + + + + + + + + + + + + + + + + + + + + + + + + ); From 112a17055fae05d12b5f937b4e1111a72747a4a9 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Thu, 13 Aug 2026 09:57:23 +0100 Subject: [PATCH 2/4] Fix caching issues Signed-off-by: Gabriel Costa --- server/src/plugins/static.ts | 13 ++++--- server/test/app.test.ts | 31 +++++++++++++++- src/App.test.tsx | 71 ++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 7 deletions(-) diff --git a/server/src/plugins/static.ts b/server/src/plugins/static.ts index 306c282..a6becf1 100644 --- a/server/src/plugins/static.ts +++ b/server/src/plugins/static.ts @@ -32,11 +32,12 @@ export default fp( root: PUBLIC_DIR, prefix: "/", index: false, // '/' is handled explicitly by routes/app.ts, for the auth check - // Vite content-hashes every /assets/* filename, so those are safe to - // cache for a year. index.html itself overrides this below — it must - // always revalidate, since it's what points at the current hashes. - maxAge: "1y", - immutable: true, + // Only /assets/* is content-hashed by Vite, so only it gets long-cached. + setHeaders(reply, pathName) { + if (pathName.includes(`${path.sep}assets${path.sep}`)) { + reply.header("Cache-Control", "public, max-age=31536000, immutable"); + } + }, }); fastify.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { @@ -63,7 +64,7 @@ export default fp( statusCode: 404, }); } - return reply.sendFile("index.html", { maxAge: 0, immutable: false }); + return reply.sendFile("index.html"); }); }, { name: "staticPlugin" }, 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/src/App.test.tsx b/src/App.test.tsx index fdc4011..65879db 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -16,6 +16,22 @@ vi.mock("@/hooks/useQuery", () => ({ })); describe("App", () => { + it("shows the Suspense fallback while a route chunk is loading", async () => { + localStorage.clear(); + sessionStorage.clear(); + 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(); + await waitFor(() => + expect(screen.queryByRole("status", { name: /loading/i })).not.toBeInTheDocument(), + ); + expect(screen.getByRole("heading", { name: /sign in/i })).toBeInTheDocument(); + }); + it("logs in and navigates to Gateways page via sidebar", async () => { const user = userEvent.setup(); @@ -62,4 +78,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); + } + }); }); From 3ce67c2fbfffec5e154606e6b697cd2869647164 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Thu, 13 Aug 2026 12:02:54 +0100 Subject: [PATCH 3/4] fix: harden route chunk loading and disable auto request decompression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ErrorBoundary around the route tree so a stale-build chunk 404 auto-reloads once instead of leaving a blank crashed page; factor the 23 duplicated lazy-import adapters into one lazyNamed() helper. Set globalDecompression: false on @fastify/compress — { global: true } alone also auto-decompressed request bodies fleet-wide, which was undocumented and untested. Signed-off-by: Gabriel Costa --- server/src/plugins/compress.ts | 5 +- src/App.tsx | 90 +++++++++++++------------- src/components/ErrorBoundary.test.tsx | 91 +++++++++++++++++++++++++++ src/components/ErrorBoundary.tsx | 88 ++++++++++++++++++++++++++ src/i18n/locales/en-US/common.json | 6 +- src/i18n/locales/es-ES/common.json | 6 +- src/i18n/locales/pt-BR/common.json | 6 +- 7 files changed, 242 insertions(+), 50 deletions(-) create mode 100644 src/components/ErrorBoundary.test.tsx create mode 100644 src/components/ErrorBoundary.tsx diff --git a/server/src/plugins/compress.ts b/server/src/plugins/compress.ts index 38e2d40..b5558e6 100644 --- a/server/src/plugins/compress.ts +++ b/server/src/plugins/compress.ts @@ -7,6 +7,9 @@ // 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"; @@ -14,7 +17,7 @@ import fp from "fastify-plugin"; export default fp( async function compressPlugin(fastify: FastifyInstance) { - await fastify.register(fastifyCompress, { global: true }); + await fastify.register(fastifyCompress, { global: true, globalDecompression: false }); }, { name: "compressPlugin" }, ); diff --git a/src/App.tsx b/src/App.tsx index 25057e7..1ba188e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,57 +1,47 @@ -import { lazy, Suspense } from "react"; +import { lazy, Suspense, useEffect, type ComponentType } from "react"; import { AuthProvider } from "./auth/AuthContext"; 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 { 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. Named exports need the `.then(m => ({ default: m.X }))` adapter -// since React.lazy only accepts a default export. -const Login = lazy(() => import("./pages/Login").then((m) => ({ default: m.Login }))); -const ForgotPassword = lazy(() => - import("./pages/ForgotPassword").then((m) => ({ default: m.ForgotPassword })), -); -const ResetPassword = lazy(() => - import("./pages/ResetPassword").then((m) => ({ default: m.ResetPassword })), -); -const ChangePassword = lazy(() => - import("./pages/ChangePassword").then((m) => ({ default: m.ChangePassword })), -); -const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard }))); -const Gateways = lazy(() => import("./pages/Gateways").then((m) => ({ default: m.Gateways }))); -const CreateServer = lazy(() => - import("./pages/CreateServer").then((m) => ({ default: m.CreateServer })), -); -const Servers = lazy(() => import("./pages/Servers").then((m) => ({ default: m.Servers }))); -const Tools = lazy(() => import("./pages/Tools").then((m) => ({ default: m.Tools }))); -const Resources = lazy(() => import("./pages/Resources").then((m) => ({ default: m.Resources }))); -const ServerCatalog = lazy(() => - import("./pages/ServerCatalog").then((m) => ({ default: m.ServerCatalog })), -); -const Prompts = lazy(() => import("./pages/Prompts").then((m) => ({ default: m.Prompts }))); -const Agents = lazy(() => import("./pages/Agents").then((m) => ({ default: m.Agents }))); -const RestApi = lazy(() => import("./pages/RestApi").then((m) => ({ default: m.RestApi }))); -const Grpc = lazy(() => import("./pages/Grpc").then((m) => ({ default: m.Grpc }))); -const LLMProviders = lazy(() => - import("./pages/LLMProviders").then((m) => ({ default: m.LLMProviders })), -); -const LLMModels = lazy(() => import("./pages/LLMModels").then((m) => ({ default: m.LLMModels }))); -const Metrics = lazy(() => import("./pages/Metrics").then((m) => ({ default: m.Metrics }))); -const Observability = lazy(() => - import("./pages/Observability").then((m) => ({ default: m.Observability })), -); -const Plugins = lazy(() => import("./pages/Plugins").then((m) => ({ default: m.Plugins }))); -const Performance = lazy(() => - import("./pages/Performance").then((m) => ({ default: m.Performance })), -); -const Maintenance = lazy(() => - import("./pages/Maintenance").then((m) => ({ default: m.Maintenance })), -); -const Settings = lazy(() => import("./pages/Settings").then((m) => ({ default: m.Settings }))); -const NotFound = lazy(() => import("./pages/NotFound").then((m) => ({ default: m.NotFound }))); +// 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] }))); +} + +const Login = lazyNamed(() => import("./pages/Login"), "Login"); +const ForgotPassword = lazyNamed(() => import("./pages/ForgotPassword"), "ForgotPassword"); +const ResetPassword = lazyNamed(() => import("./pages/ResetPassword"), "ResetPassword"); +const ChangePassword = lazyNamed(() => import("./pages/ChangePassword"), "ChangePassword"); +const Dashboard = lazyNamed(() => import("./pages/Dashboard"), "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"); function SettingsTabRedirect({ to }: { to: string }) { const { path } = useRouter(); @@ -120,11 +110,19 @@ function PrivateRoutes() { // Root // --------------------------------------------------------------------------- export function App() { + // Mounted without throwing: any stale-chunk auto-reload already did its + // job, so re-arm it for whatever the *next* deploy breaks. + useEffect(() => { + clearChunkReloadGuard(); + }, []); + return ( - + + + diff --git a/src/components/ErrorBoundary.test.tsx b/src/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..19a2f75 --- /dev/null +++ b/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,91 @@ +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); +} + +// 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("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..ac02a88 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,88 @@ +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; + +function isChunkLoadError(error: Error): boolean { + 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: Error | null; +} + +// Class component: componentDidCatch has no hook equivalent. +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, 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 } = this.state; + if (error) { + // Chunk-load errors auto-reload once (above); this fallback only + // renders if that reload didn't fix it, or for non-chunk errors. + 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" } From 5761b101d7f0ad2532a11ce0b5707ae484205f30 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Thu, 13 Aug 2026 14:32:05 +0100 Subject: [PATCH 4/4] fix: harden error boundary, cache scoping, and route chunk loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ErrorBoundary: guard against non-Error throws (null/undefined/literal) crashing componentDidCatch/render instead of showing the fallback. - App: clear the chunk-reload guard from inside Suspense (post-mount), not App's own effect — was firing on the fallback commit, causing an infinite reload loop for a permanently-broken chunk. - static.ts: scope the immutable-cache header to PUBLIC_DIR/assets/ via prefix match instead of an absolute-path substring, which false- positived on index.html whenever PUBLIC_DIR had an "assets" ancestor dir; unify it with the 404 handler's asset check. - App: preload the Dashboard chunk in parallel with the auth check (default post-login route) and stop code-splitting the three placeholder auth pages, which cost more in request overhead than they save. - Add test coverage: compress.ts request/response behavior, cache-scope regression, and the two ErrorBoundary/App fixes above. Signed-off-by: Gabriel Costa --- server/src/plugins/static.ts | 20 ++++++-- server/test/compress.test.ts | 68 ++++++++++++++++++++++++++ server/test/static-cache-scope.test.ts | 66 +++++++++++++++++++++++++ src/App.test.tsx | 15 +++++- src/App.tsx | 35 +++++++++---- src/components/ErrorBoundary.test.tsx | 18 +++++++ src/components/ErrorBoundary.tsx | 24 +++++---- 7 files changed, 223 insertions(+), 23 deletions(-) create mode 100644 server/test/compress.test.ts create mode 100644 server/test/static-cache-scope.test.ts diff --git a/server/src/plugins/static.ts b/server/src/plugins/static.ts index a6becf1..b43d9a1 100644 --- a/server/src/plugins/static.ts +++ b/server/src/plugins/static.ts @@ -26,6 +26,19 @@ 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, { @@ -34,7 +47,7 @@ export default fp( 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.includes(`${path.sep}assets${path.sep}`)) { + if (pathName.startsWith(ASSETS_FS_PREFIX)) { reply.header("Cache-Control", "public, max-age=31536000, immutable"); } }, @@ -48,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/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 65879db..85e0240 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -16,9 +16,19 @@ vi.mock("@/hooks/useQuery", () => ({ })); describe("App", () => { - it("shows the Suspense fallback while a route chunk is loading", async () => { + 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(); @@ -26,10 +36,13 @@ describe("App", () => { // 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 () => { diff --git a/src/App.tsx b/src/App.tsx index 1ba188e..cd16980 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,8 @@ 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"; @@ -18,11 +21,17 @@ function lazyNamed, K extends keyof M & 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 ForgotPassword = lazyNamed(() => import("./pages/ForgotPassword"), "ForgotPassword"); -const ResetPassword = lazyNamed(() => import("./pages/ResetPassword"), "ResetPassword"); -const ChangePassword = lazyNamed(() => import("./pages/ChangePassword"), "ChangePassword"); 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"); @@ -43,6 +52,18 @@ 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(); const query = path.split("?")[1]; @@ -58,6 +79,7 @@ const TokensRedirect = () => ; function PublicRoutes() { return ( }> + @@ -75,6 +97,7 @@ function PrivateRoutes() { {/* Suspense sits inside AppShell so sidebar/header render immediately and only the route body shows the fallback while its chunk loads. */} }> + @@ -110,12 +133,6 @@ function PrivateRoutes() { // Root // --------------------------------------------------------------------------- export function App() { - // Mounted without throwing: any stale-chunk auto-reload already did its - // job, so re-arm it for whatever the *next* deploy breaks. - useEffect(() => { - clearChunkReloadGuard(); - }, []); - return ( diff --git a/src/components/ErrorBoundary.test.tsx b/src/components/ErrorBoundary.test.tsx index 19a2f75..846985c 100644 --- a/src/components/ErrorBoundary.test.tsx +++ b/src/components/ErrorBoundary.test.tsx @@ -12,6 +12,10 @@ 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(() => {}); @@ -83,6 +87,20 @@ describe("ErrorBoundary", () => { 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(); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index ac02a88..0532da4 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -7,7 +7,10 @@ import { Button } from "@/components/ui/button"; const CHUNK_LOAD_ERROR_PATTERN = /Failed to fetch dynamically imported module|error loading dynamically imported module|ChunkLoadError|Importing a module script failed/i; -function isChunkLoadError(error: Error): boolean { +// `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); } @@ -51,18 +54,19 @@ interface Props { } interface State { - error: Error | null; + error: unknown; + hasError: boolean; } // Class component: componentDidCatch has no hook equivalent. export class ErrorBoundary extends Component { - state: State = { error: null }; + state: State = { error: null, hasError: false }; - static getDerivedStateFromError(error: Error): State { - return { error }; + static getDerivedStateFromError(error: unknown): State { + return { error, hasError: true }; } - componentDidCatch(error: Error, info: ErrorInfo) { + componentDidCatch(error: unknown, info: ErrorInfo) { if (isChunkLoadError(error) && !sessionStorage.getItem(RELOAD_FLAG_KEY)) { sessionStorage.setItem(RELOAD_FLAG_KEY, "1"); window.location.reload(); @@ -77,10 +81,12 @@ export class ErrorBoundary extends Component { }; render() { - const { error } = this.state; - if (error) { + 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. + // 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;