From d330076bb2aff94e03c88b8f0e0784fe70682ce4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 13 Apr 2026 09:20:22 -0500 Subject: [PATCH] fix: bypass repeated vite html transforms in dev --- server/src/__tests__/auth-middleware.test.ts | 43 --------- .../src/__tests__/vite-html-renderer.test.ts | 88 +++++++++++++++++++ server/src/app.ts | 43 ++++++++- server/src/middleware/auth.ts | 13 +-- server/src/vite-html-renderer.ts | 74 ++++++++++++++++ 5 files changed, 202 insertions(+), 59 deletions(-) delete mode 100644 server/src/__tests__/auth-middleware.test.ts create mode 100644 server/src/__tests__/vite-html-renderer.test.ts create mode 100644 server/src/vite-html-renderer.ts diff --git a/server/src/__tests__/auth-middleware.test.ts b/server/src/__tests__/auth-middleware.test.ts deleted file mode 100644 index 890d1cb8205..00000000000 --- a/server/src/__tests__/auth-middleware.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import express from "express"; -import request from "supertest"; -import { actorMiddleware } from "../middleware/auth.js"; - -function createApp(resolveSession: ReturnType) { - const app = express(); - app.use( - actorMiddleware({} as never, { - deploymentMode: "authenticated", - resolveSession, - }), - ); - app.get("/", (_req, res) => { - res.status(200).send("ok"); - }); - app.get("/api/ping", (_req, res) => { - res.status(200).json({ ok: true }); - }); - return app; -} - -describe("actorMiddleware", () => { - it("skips session resolution for non-api page requests", async () => { - const resolveSession = vi.fn(async () => null); - const app = createApp(resolveSession); - - const res = await request(app).get("/"); - - expect(res.status).toBe(200); - expect(resolveSession).not.toHaveBeenCalled(); - }); - - it("still resolves sessions for api requests", async () => { - const resolveSession = vi.fn(async () => null); - const app = createApp(resolveSession); - - const res = await request(app).get("/api/ping"); - - expect(res.status).toBe(200); - expect(resolveSession).toHaveBeenCalledOnce(); - }); -}); diff --git a/server/src/__tests__/vite-html-renderer.test.ts b/server/src/__tests__/vite-html-renderer.test.ts new file mode 100644 index 00000000000..2b7d50679c1 --- /dev/null +++ b/server/src/__tests__/vite-html-renderer.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createCachedViteHtmlRenderer, type ViteWatcherHost } from "../vite-html-renderer.js"; + +function createWatcher() { + const listeners = new Map void>>(); + + return { + on(event: string, listener: (file: string) => void) { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event)?.add(listener); + }, + off(event: string, listener: (file: string) => void) { + listeners.get(event)?.delete(listener); + }, + emit(event: string, file: string) { + for (const listener of listeners.get(event) ?? []) { + listener(file); + } + }, + }; +} + +describe("createCachedViteHtmlRenderer", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reuses the injected dev html shell until a watched file changes", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-")); + tempDirs.push(tempDir); + const indexPath = path.join(tempDir, "index.html"); + fs.writeFileSync( + indexPath, + 'v1', + "utf8", + ); + + const watcher = createWatcher(); + const vite: ViteWatcherHost = { + watcher, + }; + + const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir }); + + await expect(renderer.render("/")).resolves.toContain("/@vite/client"); + const first = await renderer.render("/"); + const second = await renderer.render("/issues"); + expect(first).toBe(second); + expect(first.match(/\/@vite\/client/g)?.length).toBe(1); + + fs.writeFileSync( + indexPath, + 'v2', + "utf8", + ); + watcher.emit("change", indexPath); + + await expect(renderer.render("/")).resolves.toContain("v2"); + + renderer.dispose(); + }); + + it("does not duplicate the vite client tag when it already exists", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-")); + tempDirs.push(tempDir); + fs.writeFileSync( + path.join(tempDir, "index.html"), + '', + "utf8", + ); + + const vite: ViteWatcherHost = { + watcher: createWatcher(), + }; + + const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir }); + + const html = await renderer.render("/"); + expect(html.match(/\/@vite\/client/g)?.length).toBe(1); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index dd89cd7f9ed..b03f5668698 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -49,9 +49,27 @@ import { createPluginHostServiceCleanup } from "./services/plugin-host-service-c import { pluginRegistryService } from "./services/plugin-registry.js"; import { createHostClientHandlers } from "@paperclipai/plugin-sdk"; import type { BetterAuthSessionResult } from "./auth/better-auth.js"; +import { createCachedViteHtmlRenderer } from "./vite-html-renderer.js"; type UiMode = "none" | "static" | "vite-dev"; const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000; +const VITE_DEV_ASSET_PREFIXES = [ + "/@fs/", + "/@id/", + "/@react-refresh", + "/@vite/", + "/assets/", + "/node_modules/", + "/src/", +]; +const VITE_DEV_STATIC_PATHS = new Set([ + "/apple-touch-icon.png", + "/favicon-16x16.png", + "/favicon-32x32.png", + "/favicon.ico", + "/favicon.svg", + "/site.webmanifest", +]); export function resolveViteHmrPort(serverPort: number): number { if (serverPort <= 55_535) { @@ -60,6 +78,13 @@ export function resolveViteHmrPort(serverPort: number): number { return Math.max(1_024, serverPort - 10_000); } +function shouldServeViteDevHtml(req: ExpressRequest): boolean { + const pathname = req.path; + if (VITE_DEV_STATIC_PATHS.has(pathname)) return false; + if (VITE_DEV_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false; + return req.accepts(["html"]) === "html"; +} + export async function createApp( db: Db, opts: { @@ -193,6 +218,7 @@ export async function createApp( jobStore, }); const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers); + let viteHtmlRenderer: ReturnType | null = null; const loader = pluginLoader( db, { localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR }, @@ -285,18 +311,26 @@ export async function createApp( allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined, }, }); + viteHtmlRenderer = createCachedViteHtmlRenderer({ + vite, + uiRoot, + brandHtml: applyUiBranding, + }); + const renderViteHtml = viteHtmlRenderer; - app.use(vite.middlewares); app.get(/.*/, async (req, res, next) => { + if (!shouldServeViteDevHtml(req)) { + next(); + return; + } try { - const templatePath = path.resolve(uiRoot, "index.html"); - const template = fs.readFileSync(templatePath, "utf-8"); - const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template)); + const html = await renderViteHtml.render(req.originalUrl); res.status(200).set({ "Content-Type": "text/html" }).end(html); } catch (err) { next(err); } }); + app.use(vite.middlewares); } app.use(errorHandler); @@ -338,6 +372,7 @@ export async function createApp( process.once("exit", () => { if (feedbackExportTimer) clearInterval(feedbackExportTimer); devWatcher?.close(); + viteHtmlRenderer?.dispose(); hostServiceCleanup.disposeAll(); hostServiceCleanup.teardown(); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 5f0cef802d8..6140c87d5b5 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -18,13 +18,6 @@ interface ActorMiddlewareOptions { resolveSession?: (req: Request) => Promise; } -function shouldResolveBoardSession(req: Request): boolean { - // Only API routes consume req.actor. Skipping session resolution for - // document/static requests avoids unnecessary Better Auth work on each - // Vite-served page load in authenticated dev mode. - return req.path.startsWith("/api"); -} - export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler { const boardAuth = boardAuthService(db); return async (req, _res, next) => { @@ -37,11 +30,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa const authHeader = req.header("authorization"); if (!authHeader?.toLowerCase().startsWith("bearer ")) { - if ( - opts.deploymentMode === "authenticated" && - opts.resolveSession && - shouldResolveBoardSession(req) - ) { + if (opts.deploymentMode === "authenticated" && opts.resolveSession) { let session: BetterAuthSessionResult | null = null; try { session = await opts.resolveSession(req); diff --git a/server/src/vite-html-renderer.ts b/server/src/vite-html-renderer.ts new file mode 100644 index 00000000000..3a768c23ed3 --- /dev/null +++ b/server/src/vite-html-renderer.ts @@ -0,0 +1,74 @@ +import fs from "node:fs"; +import path from "node:path"; + +type ViteWatcherEvent = "add" | "change" | "unlink"; + +export interface ViteWatcherHost { + watcher?: { + on?: (event: ViteWatcherEvent, listener: (file: string) => void) => unknown; + off?: (event: ViteWatcherEvent, listener: (file: string) => void) => unknown; + }; +} + +export interface CachedViteHtmlRenderer { + render(_url: string): Promise; + dispose(): void; +} + +const WATCHER_EVENTS: ViteWatcherEvent[] = ["add", "change", "unlink"]; +const MAIN_ENTRY_TAG = ''; +const VITE_CLIENT_TAG = ''; + +function injectViteClient(html: string): string { + if (html.includes(VITE_CLIENT_TAG)) return html; + if (html.includes(MAIN_ENTRY_TAG)) { + return html.replace(MAIN_ENTRY_TAG, `${VITE_CLIENT_TAG}\n ${MAIN_ENTRY_TAG}`); + } + return html.replace("", ` ${VITE_CLIENT_TAG}\n `); +} + +export function createCachedViteHtmlRenderer(opts: { + vite: ViteWatcherHost; + uiRoot: string; + brandHtml?: (html: string) => string; +}): CachedViteHtmlRenderer { + const uiRoot = path.resolve(opts.uiRoot); + const templatePath = path.resolve(uiRoot, "index.html"); + const brandHtml = opts.brandHtml ?? ((html: string) => html); + let cachedHtml: string | null = null; + + function loadHtml(): string { + if (cachedHtml === null) { + const rawTemplate = fs.readFileSync(templatePath, "utf-8"); + cachedHtml = brandHtml(injectViteClient(rawTemplate)); + } + return cachedHtml; + } + + function invalidate(): void { + cachedHtml = null; + } + + function onWatchEvent(filePath: string): void { + const resolvedPath = path.resolve(filePath); + if (resolvedPath === templatePath || resolvedPath.startsWith(`${uiRoot}${path.sep}`)) { + invalidate(); + } + } + + for (const eventName of WATCHER_EVENTS) { + opts.vite.watcher?.on?.(eventName, onWatchEvent); + } + + return { + render(): Promise { + return Promise.resolve(loadHtml()); + }, + + dispose(): void { + for (const eventName of WATCHER_EVENTS) { + opts.vite.watcher?.off?.(eventName, onWatchEvent); + } + }, + }; +}