From ffbf541ff88c2c8398e69612fb65d8705ab2ad60 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Feb 2026 14:58:17 -0800 Subject: [PATCH] Extract project favicon route and tighten turn-settled logic - move `/api/project-favicon` handling into a dedicated server route with coverage for root and source-linked icons - wire `wsServer` to use the new route helper - treat any running session as unsettled in `isLatestTurnSettled` to prevent stale latest-turn banners, with test coverage --- apps/server/src/projectFaviconRoute.test.ts | 118 +++++++++++++++ apps/server/src/projectFaviconRoute.ts | 160 ++++++++++++++++++++ apps/server/src/wsServer.ts | 155 +------------------ apps/web/src/session-logic.test.ts | 9 ++ apps/web/src/session-logic.ts | 5 +- 5 files changed, 291 insertions(+), 156 deletions(-) create mode 100644 apps/server/src/projectFaviconRoute.test.ts create mode 100644 apps/server/src/projectFaviconRoute.ts diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0f0273cfbaf9e8d681b9ae9e955bd85df095753 --- /dev/null +++ b/apps/server/src/projectFaviconRoute.test.ts @@ -0,0 +1,118 @@ +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; +import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; + +interface HttpResponse { + statusCode: number; + contentType: string | null; + body: string; +} + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function withRouteServer( + run: (baseUrl: string) => Promise, +): Promise { + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (tryHandleProjectFaviconRequest(url, res)) { + return; + } + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + }); + + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", (error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + const address = server.address(); + if (typeof address !== "object" || address === null) { + throw new Error("Expected server address to be an object"); + } + const baseUrl = `http://127.0.0.1:${address.port}`; + + try { + await run(baseUrl); + } finally { + await new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +async function request(baseUrl: string, pathname: string): Promise { + const response = await fetch(`${baseUrl}${pathname}`); + return { + statusCode: response.status, + contentType: response.headers.get("content-type"), + body: await response.text(), + }; +} + +describe("tryHandleProjectFaviconRequest", () => { + afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns 400 when cwd is missing", async () => { + await withRouteServer(async (baseUrl) => { + const response = await request(baseUrl, "/api/project-favicon"); + expect(response.statusCode).toBe(400); + expect(response.body).toBe("Missing cwd parameter"); + }); + }); + + it("serves a well-known favicon file from the project root", async () => { + const projectDir = makeTempDir("t3code-favicon-route-root-"); + fs.writeFileSync(path.join(projectDir, "favicon.svg"), "favicon", "utf8"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/svg+xml"); + expect(response.body).toBe("favicon"); + }); + }); + + it("resolves icon href from source files when no well-known favicon exists", async () => { + const projectDir = makeTempDir("t3code-favicon-route-source-"); + const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); + fs.writeFileSync(path.join(projectDir, "index.html"), ''); + fs.writeFileSync(iconPath, "brand", "utf8"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/svg+xml"); + expect(response.body).toBe("brand"); + }); + }); +}); diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts new file mode 100644 index 0000000000000000000000000000000000000000..2db12fdeb5adfd3c1d9b6c60bf8e2b61a17b9d15 --- /dev/null +++ b/apps/server/src/projectFaviconRoute.ts @@ -0,0 +1,160 @@ +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; + +const FAVICON_MIME_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", +}; + +// Well-known favicon paths checked in order. +const FAVICON_CANDIDATES = [ + "favicon.svg", + "favicon.ico", + "favicon.png", + "public/favicon.svg", + "public/favicon.ico", + "public/favicon.png", + "app/favicon.ico", + "app/favicon.png", + "app/icon.svg", + "app/icon.png", + "app/icon.ico", + "src/favicon.ico", + "src/favicon.svg", + "src/app/favicon.ico", + "src/app/icon.svg", + "src/app/icon.png", +]; + +// Files that may contain a or icon metadata declaration. +const ICON_SOURCE_FILES = [ + "index.html", + "public/index.html", + "app/routes/__root.tsx", + "src/routes/__root.tsx", + "app/root.tsx", + "src/root.tsx", + "src/index.html", +]; + +// Matches or { rel: "icon", href: "..." } patterns. +const LINK_ICON_HTML_RE = /]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i; +const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i; + +function extractIconHref(source: string): string | null { + const htmlMatch = source.match(LINK_ICON_HTML_RE); + if (htmlMatch?.[1]) return htmlMatch[1]; + const objMatch = source.match(LINK_ICON_OBJ_RE); + if (objMatch?.[1]) return objMatch[1]; + return null; +} + +function resolveIconHref(projectCwd: string, href: string): string[] { + const clean = href.replace(/^\//, ""); + return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; +} + +function isPathWithinProject(projectCwd: string, candidatePath: string): boolean { + const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function serveFaviconFile(filePath: string, res: http.ServerResponse): void { + const ext = path.extname(filePath).toLowerCase(); + const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; + fs.readFile(filePath, (readErr, data) => { + if (readErr) { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Read error"); + return; + } + res.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": "public, max-age=3600", + }); + res.end(data); + }); +} + +export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean { + if (url.pathname !== "/api/project-favicon") { + return false; + } + + const projectCwd = url.searchParams.get("cwd"); + if (!projectCwd) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("Missing cwd parameter"); + return true; + } + + const tryResolvedPaths = ( + paths: string[], + index: number, + onExhausted: () => void, + ): void => { + if (index >= paths.length) { + onExhausted(); + return; + } + const candidate = paths[index]!; + if (!isPathWithinProject(projectCwd, candidate)) { + tryResolvedPaths(paths, index + 1, onExhausted); + return; + } + fs.stat(candidate, (err, stats) => { + if (err || !stats?.isFile()) { + tryResolvedPaths(paths, index + 1, onExhausted); + return; + } + serveFaviconFile(candidate, res); + }); + }; + + const trySourceFiles = (index: number): void => { + if (index >= ICON_SOURCE_FILES.length) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("No favicon found"); + return; + } + const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); + fs.readFile(sourceFile, "utf8", (err, content) => { + if (err) { + trySourceFiles(index + 1); + return; + } + const href = extractIconHref(content); + if (!href) { + trySourceFiles(index + 1); + return; + } + const candidates = resolveIconHref(projectCwd, href); + tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); + }); + }; + + const tryCandidates = (index: number): void => { + if (index >= FAVICON_CANDIDATES.length) { + trySourceFiles(0); + return; + } + const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); + if (!isPathWithinProject(projectCwd, candidate)) { + tryCandidates(index + 1); + return; + } + fs.stat(candidate, (err, stats) => { + if (err || !stats?.isFile()) { + tryCandidates(index + 1); + return; + } + serveFaviconFile(candidate, res); + }); + }; + + tryCandidates(0); + return true; +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 9918627e922c410cc90052634e7afc2b8f4f23de..ceb370ab6814557070598d14f1ba626b7d569e14 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -42,6 +42,7 @@ import { clamp } from "effect/Number"; import { Open } from "./open"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; +import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -230,163 +231,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); }); - // Well-known favicon paths checked in order. - const FAVICON_CANDIDATES = [ - "favicon.svg", - "favicon.ico", - "favicon.png", - "public/favicon.svg", - "public/favicon.ico", - "public/favicon.png", - "app/favicon.ico", - "app/favicon.png", - "app/icon.svg", - "app/icon.png", - "app/icon.ico", - "src/favicon.ico", - "src/favicon.svg", - "src/app/favicon.ico", - "src/app/icon.svg", - "src/app/icon.png", - ]; - - // Files that may contain a or icon metadata declaration. - const ICON_SOURCE_FILES = [ - "index.html", - "public/index.html", - "app/routes/__root.tsx", - "src/routes/__root.tsx", - "app/root.tsx", - "src/root.tsx", - "src/index.html", - ]; - - // Extract icon href from HTML/TSX source content. - // Matches or { rel: "icon", href: "…" } patterns. - const LINK_ICON_HTML_RE = /]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i; - const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i; - function extractIconHref(source: string): string | null { - const htmlMatch = source.match(LINK_ICON_HTML_RE); - if (htmlMatch?.[1]) return htmlMatch[1]; - const objMatch = source.match(LINK_ICON_OBJ_RE); - if (objMatch?.[1]) return objMatch[1]; - return null; - } - - /** - * Resolve an icon href (e.g. "/grass-logo.svg") to a file on disk. - * For href starting with "/" we look in public/ first, then project root. - */ - function resolveIconHref(projectCwd: string, href: string): string[] { - const clean = href.replace(/^\//, ""); - return [ - path.join(projectCwd, "public", clean), - path.join(projectCwd, clean), - ]; - } - - function serveFaviconFile( - filePath: string, - res: http.ServerResponse, - ): void { - const ext = path.extname(filePath); - const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; - fs.readFile(filePath, (readErr, data) => { - if (readErr) { - res.writeHead(500, { "Content-Type": "text/plain" }); - res.end("Read error"); - return; - } - res.writeHead(200, { - "Content-Type": contentType, - "Cache-Control": "public, max-age=3600", - }); - res.end(data); - }); - } - // HTTP server — serves static files or redirects to Vite dev server const httpServer = http.createServer((req, res) => { const url = new URL(req.url ?? "/", `http://localhost:${port}`); - // Serve project favicons from their working directories. - // Handled before the dev redirect so it works in both modes. - if (url.pathname === "/api/project-favicon") { - const projectCwd = url.searchParams.get("cwd"); - if (!projectCwd) { - res.writeHead(400, { "Content-Type": "text/plain" }); - res.end("Missing cwd parameter"); - return; - } - - // Phase 1: Check well-known favicon paths. - const tryCandidates = (index: number) => { - if (index >= FAVICON_CANDIDATES.length) { - // Phase 2: Parse source files for icon link declarations. - trySourceFiles(0); - return; - } - const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); - if (!candidate.startsWith(projectCwd + path.sep)) { - tryCandidates(index + 1); - return; - } - fs.stat(candidate, (err, stats) => { - if (err || !stats?.isFile()) { - tryCandidates(index + 1); - return; - } - serveFaviconFile(candidate, res); - }); - }; - - // Phase 2: Read source/HTML files and extract icon href references. - const trySourceFiles = (index: number) => { - if (index >= ICON_SOURCE_FILES.length) { - res.writeHead(404, { "Content-Type": "text/plain" }); - res.end("No favicon found"); - return; - } - const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); - fs.readFile(sourceFile, "utf8", (err, content) => { - if (err) { - trySourceFiles(index + 1); - return; - } - const href = extractIconHref(content); - if (!href) { - trySourceFiles(index + 1); - return; - } - const candidates = resolveIconHref(projectCwd, href); - tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); - }); - }; - - const tryResolvedPaths = ( - paths: string[], - index: number, - onExhausted: () => void, - ) => { - if (index >= paths.length) { - onExhausted(); - return; - } - const candidate = paths[index]!; - if (!candidate.startsWith(projectCwd + path.sep)) { - tryResolvedPaths(paths, index + 1, onExhausted); - return; - } - fs.stat(candidate, (err, stats) => { - if (err || !stats?.isFile()) { - tryResolvedPaths(paths, index + 1, onExhausted); - return; - } - serveFaviconFile(candidate, res); - }); - }; - - tryCandidates(0); + if (tryHandleProjectFaviconRequest(url, res)) { return; } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index dc74adc67f7dbd7b98ac51dd81753b6c8ff6f2bd..26ff6221a2702498ab5a104b6c9e93b05f6d07b6 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -169,6 +169,15 @@ describe("isLatestTurnSettled", () => { ).toBe(false); }); + it("returns false while any turn is running to avoid stale latest-turn banners", () => { + expect( + isLatestTurnSettled(latestTurn, { + orchestrationStatus: "running", + activeTurnId: TurnId.makeUnsafe("turn-2"), + }), + ).toBe(false); + }); + it("returns true once the session is no longer running that turn", () => { expect( isLatestTurnSettled(latestTurn, { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 27d75ef6d0674ffcab8c06bcdddb19ab054d54b4..a44c12e79ccf8fe53a26b8d9af67a595f0e5e199 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -83,9 +83,8 @@ export function isLatestTurnSettled( if (!latestTurn?.startedAt) return false; if (!latestTurn.completedAt) return false; if (!session) return true; - return !( - session.orchestrationStatus === "running" && session.activeTurnId === latestTurn.turnId - ); + if (session.orchestrationStatus === "running") return false; + return true; } export function derivePendingApprovals( -- 2.54.0