mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
fix: bypass repeated vite html transforms in dev
This commit is contained in:
1 parent
2e7db9e202
commit
d330076bb2
5 files changed
+202
-59
No files matched your search
@@ -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<typeof vi.fn>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, Set<(file: string) => 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,
|
||||
'<html><body>v1<script type="module" src="/src/main.tsx"></script></body></html>',
|
||||
"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,
|
||||
'<html><body>v2<script type="module" src="/src/main.tsx"></script></body></html>',
|
||||
"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"),
|
||||
'<html><body><script type="module" src="/@vite/client"></script></body></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);
|
||||
});
|
||||
});
|
||||
+39
-4
@@ -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<typeof createCachedViteHtmlRenderer> | 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();
|
||||
});
|
||||
|
||||
@@ -18,13 +18,6 @@ interface ActorMiddlewareOptions {
|
||||
resolveSession?: (req: Request) => Promise<BetterAuthSessionResult | null>;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<string>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const WATCHER_EVENTS: ViteWatcherEvent[] = ["add", "change", "unlink"];
|
||||
const MAIN_ENTRY_TAG = '<script type="module" src="/src/main.tsx"></script>';
|
||||
const VITE_CLIENT_TAG = '<script type="module" src="/@vite/client"></script>';
|
||||
|
||||
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("</body>", ` ${VITE_CLIENT_TAG}\n </body>`);
|
||||
}
|
||||
|
||||
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<string> {
|
||||
return Promise.resolve(loadHtml());
|
||||
},
|
||||
|
||||
dispose(): void {
|
||||
for (const eventName of WATCHER_EVENTS) {
|
||||
opts.vite.watcher?.off?.(eventName, onWatchEvent);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user