diff --git a/server/src/__tests__/static-index-html.test.ts b/server/src/__tests__/static-index-html.test.ts new file mode 100644 index 00000000000..1ad3c40501b --- /dev/null +++ b/server/src/__tests__/static-index-html.test.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterEach, describe, expect, it } from "vitest"; +import { readBrandedStaticIndexHtml } from "../static-index-html.js"; + +describe("static SPA fallback HTML", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("serves the current index.html instead of reusing stale asset hashes", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-static-index-")); + tempDirs.push(tempDir); + const indexPath = path.join(tempDir, "index.html"); + const app = express(); + app.get(/.*/, (_req, res) => { + res + .status(200) + .set("Content-Type", "text/html") + .set("Cache-Control", "no-cache") + .end(readBrandedStaticIndexHtml(tempDir)); + }); + + fs.writeFileSync( + indexPath, + '', + "utf8", + ); + await expect(request(app).get("/PAP/issues/PAP-9939")).resolves.toMatchObject({ + text: expect.stringContaining("/assets/index-old.js"), + }); + + fs.writeFileSync( + indexPath, + '', + "utf8", + ); + const res = await request(app).get("/PAP/issues/PAP-9939"); + expect(res.text).toContain("/assets/index-new.js"); + expect(res.text).not.toContain("/assets/index-old.js"); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index a7c9a4ed630..b2348714f12 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -41,6 +41,7 @@ import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; import { adapterRoutes } from "./routes/adapters.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; +import { readBrandedStaticIndexHtml } from "./static-index-html.js"; import { applyUiBranding } from "./ui-branding.js"; import { logger } from "./middleware/logger.js"; import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js"; @@ -328,7 +329,6 @@ export async function createApp( ]; const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { - const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); // Hashed asset files (Vite emits them under /assets/..) // never change once built, so they can be cached aggressively. app.use( @@ -368,7 +368,7 @@ export async function createApp( .status(200) .set("Content-Type", "text/html") .set("Cache-Control", "no-cache") - .end(indexHtml); + .end(readBrandedStaticIndexHtml(uiDist)); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); diff --git a/server/src/static-index-html.ts b/server/src/static-index-html.ts new file mode 100644 index 00000000000..13fa592c09a --- /dev/null +++ b/server/src/static-index-html.ts @@ -0,0 +1,7 @@ +import fs from "node:fs"; +import path from "node:path"; +import { applyUiBranding } from "./ui-branding.js"; + +export function readBrandedStaticIndexHtml(uiDist: string): string { + return applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); +}