- 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
Unified · Split
| @@ -0,0 +1,118 @@ | ||
| 1 | +import fs from "node:fs"; | |
| 2 | +import http from "node:http"; | |
| 3 | +import os from "node:os"; | |
| 4 | +import path from "node:path"; | |
| 5 | + | |
| 6 | +import { afterEach, describe, expect, it } from "vitest"; | |
| 7 | +import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | |
| 8 | + | |
| 9 | +interface HttpResponse { | |
| 10 | + statusCode: number; | |
| 11 | + contentType: string | null; | |
| 12 | + body: string; | |
| 13 | +} | |
| 14 | + | |
| 15 | +const tempDirs: string[] = []; | |
| 16 | + | |
| 17 | +function makeTempDir(prefix: string): string { | |
| 18 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 19 | + tempDirs.push(dir); | |
| 20 | + return dir; | |
| 21 | +} | |
| 22 | + | |
| 23 | +async function withRouteServer( | |
| 24 | + run: (baseUrl: string) => Promise<void>, | |
| 25 | +): Promise<void> { | |
| 26 | + const server = http.createServer((req, res) => { | |
| 27 | + const url = new URL(req.url ?? "/", "http://127.0.0.1"); | |
| 28 | + if (tryHandleProjectFaviconRequest(url, res)) { | |
| 29 | + return; | |
| 30 | + } | |
| 31 | + res.writeHead(404, { "Content-Type": "text/plain" }); | |
| 32 | + res.end("Not Found"); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + await new Promise<void>((resolve, reject) => { | |
| 36 | + server.listen(0, "127.0.0.1", (error?: Error) => { | |
| 37 | + if (error) { | |
| 38 | + reject(error); | |
| 39 | + return; | |
| 40 | + } | |
| 41 | + resolve(); | |
| 42 | + }); | |
| 43 | + }); | |
| 44 | + | |
| 45 | + const address = server.address(); | |
| 46 | + if (typeof address !== "object" || address === null) { | |
| 47 | + throw new Error("Expected server address to be an object"); | |
| 48 | + } | |
| 49 | + const baseUrl = `http://127.0.0.1:${address.port}`; | |
| 50 | + | |
| 51 | + try { | |
| 52 | + await run(baseUrl); | |
| 53 | + } finally { | |
| 54 | + await new Promise<void>((resolve, reject) => { | |
| 55 | + server.close((error?: Error) => { | |
| 56 | + if (error) { | |
| 57 | + reject(error); | |
| 58 | + return; | |
| 59 | + } | |
| 60 | + resolve(); | |
| 61 | + }); | |
| 62 | + }); | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +async function request(baseUrl: string, pathname: string): Promise<HttpResponse> { | |
| 67 | + const response = await fetch(`${baseUrl}${pathname}`); | |
| 68 | + return { | |
| 69 | + statusCode: response.status, | |
| 70 | + contentType: response.headers.get("content-type"), | |
| 71 | + body: await response.text(), | |
| 72 | + }; | |
| 73 | +} | |
| 74 | + | |
| 75 | +describe("tryHandleProjectFaviconRequest", () => { | |
| 76 | + afterEach(() => { | |
| 77 | + for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 78 | + fs.rmSync(dir, { recursive: true, force: true }); | |
| 79 | + } | |
| 80 | + }); | |
| 81 | + | |
| 82 | + it("returns 400 when cwd is missing", async () => { | |
| 83 | + await withRouteServer(async (baseUrl) => { | |
| 84 | + const response = await request(baseUrl, "/api/project-favicon"); | |
| 85 | + expect(response.statusCode).toBe(400); | |
| 86 | + expect(response.body).toBe("Missing cwd parameter"); | |
| 87 | + }); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + it("serves a well-known favicon file from the project root", async () => { | |
| 91 | + const projectDir = makeTempDir("t3code-favicon-route-root-"); | |
| 92 | + fs.writeFileSync(path.join(projectDir, "favicon.svg"), "<svg>favicon</svg>", "utf8"); | |
| 93 | + | |
| 94 | + await withRouteServer(async (baseUrl) => { | |
| 95 | + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | |
| 96 | + const response = await request(baseUrl, pathname); | |
| 97 | + expect(response.statusCode).toBe(200); | |
| 98 | + expect(response.contentType).toContain("image/svg+xml"); | |
| 99 | + expect(response.body).toBe("<svg>favicon</svg>"); | |
| 100 | + }); | |
| 101 | + }); | |
| 102 | + | |
| 103 | + it("resolves icon href from source files when no well-known favicon exists", async () => { | |
| 104 | + const projectDir = makeTempDir("t3code-favicon-route-source-"); | |
| 105 | + const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); | |
| 106 | + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); | |
| 107 | + fs.writeFileSync(path.join(projectDir, "index.html"), '<link rel="icon" href="/brand/logo.svg">'); | |
| 108 | + fs.writeFileSync(iconPath, "<svg>brand</svg>", "utf8"); | |
| 109 | + | |
| 110 | + await withRouteServer(async (baseUrl) => { | |
| 111 | + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | |
| 112 | + const response = await request(baseUrl, pathname); | |
| 113 | + expect(response.statusCode).toBe(200); | |
| 114 | + expect(response.contentType).toContain("image/svg+xml"); | |
| 115 | + expect(response.body).toBe("<svg>brand</svg>"); | |
| 116 | + }); | |
| 117 | + }); | |
| 118 | +}); | |
| @@ -0,0 +1,160 @@ | ||
| 1 | +import fs from "node:fs"; | |
| 2 | +import http from "node:http"; | |
| 3 | +import path from "node:path"; | |
| 4 | + | |
| 5 | +const FAVICON_MIME_TYPES: Record<string, string> = { | |
| 6 | + ".png": "image/png", | |
| 7 | + ".jpg": "image/jpeg", | |
| 8 | + ".svg": "image/svg+xml", | |
| 9 | + ".ico": "image/x-icon", | |
| 10 | +}; | |
| 11 | + | |
| 12 | +// Well-known favicon paths checked in order. | |
| 13 | +const FAVICON_CANDIDATES = [ | |
| 14 | + "favicon.svg", | |
| 15 | + "favicon.ico", | |
| 16 | + "favicon.png", | |
| 17 | + "public/favicon.svg", | |
| 18 | + "public/favicon.ico", | |
| 19 | + "public/favicon.png", | |
| 20 | + "app/favicon.ico", | |
| 21 | + "app/favicon.png", | |
| 22 | + "app/icon.svg", | |
| 23 | + "app/icon.png", | |
| 24 | + "app/icon.ico", | |
| 25 | + "src/favicon.ico", | |
| 26 | + "src/favicon.svg", | |
| 27 | + "src/app/favicon.ico", | |
| 28 | + "src/app/icon.svg", | |
| 29 | + "src/app/icon.png", | |
| 30 | +]; | |
| 31 | + | |
| 32 | +// Files that may contain a <link rel="icon"> or icon metadata declaration. | |
| 33 | +const ICON_SOURCE_FILES = [ | |
| 34 | + "index.html", | |
| 35 | + "public/index.html", | |
| 36 | + "app/routes/__root.tsx", | |
| 37 | + "src/routes/__root.tsx", | |
| 38 | + "app/root.tsx", | |
| 39 | + "src/root.tsx", | |
| 40 | + "src/index.html", | |
| 41 | +]; | |
| 42 | + | |
| 43 | +// Matches <link rel="icon" href="..."> or { rel: "icon", href: "..." } patterns. | |
| 44 | +const LINK_ICON_HTML_RE = /<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i; | |
| 45 | +const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i; | |
| 46 | + | |
| 47 | +function extractIconHref(source: string): string | null { | |
| 48 | + const htmlMatch = source.match(LINK_ICON_HTML_RE); | |
| 49 | + if (htmlMatch?.[1]) return htmlMatch[1]; | |
| 50 | + const objMatch = source.match(LINK_ICON_OBJ_RE); | |
| 51 | + if (objMatch?.[1]) return objMatch[1]; | |
| 52 | + return null; | |
| 53 | +} | |
| 54 | + | |
| 55 | +function resolveIconHref(projectCwd: string, href: string): string[] { | |
| 56 | + const clean = href.replace(/^\//, ""); | |
| 57 | + return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; | |
| 58 | +} | |
| 59 | + | |
| 60 | +function isPathWithinProject(projectCwd: string, candidatePath: string): boolean { | |
| 61 | + const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); | |
| 62 | + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); | |
| 63 | +} | |
| 64 | + | |
| 65 | +function serveFaviconFile(filePath: string, res: http.ServerResponse): void { | |
| 66 | + const ext = path.extname(filePath).toLowerCase(); | |
| 67 | + const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; | |
| 68 | + fs.readFile(filePath, (readErr, data) => { | |
| 69 | + if (readErr) { | |
| 70 | + res.writeHead(500, { "Content-Type": "text/plain" }); | |
| 71 | + res.end("Read error"); | |
| 72 | + return; | |
| 73 | + } | |
| 74 | + res.writeHead(200, { | |
| 75 | + "Content-Type": contentType, | |
| 76 | + "Cache-Control": "public, max-age=3600", | |
| 77 | + }); | |
| 78 | + res.end(data); | |
| 79 | + }); | |
| 80 | +} | |
| 81 | + | |
| 82 | +export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean { | |
| 83 | + if (url.pathname !== "/api/project-favicon") { | |
| 84 | + return false; | |
| 85 | + } | |
| 86 | + | |
| 87 | + const projectCwd = url.searchParams.get("cwd"); | |
| 88 | + if (!projectCwd) { | |
| 89 | + res.writeHead(400, { "Content-Type": "text/plain" }); | |
| 90 | + res.end("Missing cwd parameter"); | |
| 91 | + return true; | |
| 92 | + } | |
| 93 | + | |
| 94 | + const tryResolvedPaths = ( | |
| 95 | + paths: string[], | |
| 96 | + index: number, | |
| 97 | + onExhausted: () => void, | |
| 98 | + ): void => { | |
| 99 | + if (index >= paths.length) { | |
| 100 | + onExhausted(); | |
| 101 | + return; | |
| 102 | + } | |
| 103 | + const candidate = paths[index]!; | |
| 104 | + if (!isPathWithinProject(projectCwd, candidate)) { | |
| 105 | + tryResolvedPaths(paths, index + 1, onExhausted); | |
| 106 | + return; | |
| 107 | + } | |
| 108 | + fs.stat(candidate, (err, stats) => { | |
| 109 | + if (err || !stats?.isFile()) { | |
| 110 | + tryResolvedPaths(paths, index + 1, onExhausted); | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + serveFaviconFile(candidate, res); | |
| 114 | + }); | |
| 115 | + }; | |
| 116 | + | |
| 117 | + const trySourceFiles = (index: number): void => { | |
| 118 | + if (index >= ICON_SOURCE_FILES.length) { | |
| 119 | + res.writeHead(404, { "Content-Type": "text/plain" }); | |
| 120 | + res.end("No favicon found"); | |
| 121 | + return; | |
| 122 | + } | |
| 123 | + const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); | |
| 124 | + fs.readFile(sourceFile, "utf8", (err, content) => { | |
| 125 | + if (err) { | |
| 126 | + trySourceFiles(index + 1); | |
| 127 | + return; | |
| 128 | + } | |
| 129 | + const href = extractIconHref(content); | |
| 130 | + if (!href) { | |
| 131 | + trySourceFiles(index + 1); | |
| 132 | + return; | |
| 133 | + } | |
| 134 | + const candidates = resolveIconHref(projectCwd, href); | |
| 135 | + tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); | |
| 136 | + }); | |
| 137 | + }; | |
| 138 | + | |
| 139 | + const tryCandidates = (index: number): void => { | |
| 140 | + if (index >= FAVICON_CANDIDATES.length) { | |
| 141 | + trySourceFiles(0); | |
| 142 | + return; | |
| 143 | + } | |
| 144 | + const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); | |
| 145 | + if (!isPathWithinProject(projectCwd, candidate)) { | |
| 146 | + tryCandidates(index + 1); | |
| 147 | + return; | |
| 148 | + } | |
| 149 | + fs.stat(candidate, (err, stats) => { | |
| 150 | + if (err || !stats?.isFile()) { | |
| 151 | + tryCandidates(index + 1); | |
| 152 | + return; | |
| 153 | + } | |
| 154 | + serveFaviconFile(candidate, res); | |
| 155 | + }); | |
| 156 | + }; | |
| 157 | + | |
| 158 | + tryCandidates(0); | |
| 159 | + return true; | |
| 160 | +} | |
| @@ -42,6 +42,7 @@ import { clamp } from "effect/Number"; · 41 unmodified lines | ||
| 42 | 42 | import { Open } from "./open"; |
| 43 | 43 | import { ServerConfig } from "./config"; |
| 44 | 44 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 45 | +import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | |
| 45 | 46 | |
| 46 | 47 | const MIME_TYPES: Record<string, string> = { |
| 47 | 48 | ".html": "text/html; charset=utf-8", |
| @@ -230,163 +231,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 182 unmodified lines | ||
| 230 | 231 | }); |
| 231 | 232 | }); |
| 232 | 233 | |
| 233 | − // Well-known favicon paths checked in order. | |
| 234 | − const FAVICON_CANDIDATES = [ | |
| 235 | − "favicon.svg", | |
| 236 | − "favicon.ico", | |
| 237 | − "favicon.png", | |
| 238 | − "public/favicon.svg", | |
| 239 | − "public/favicon.ico", | |
| 240 | − "public/favicon.png", | |
| 241 | − "app/favicon.ico", | |
| 242 | − "app/favicon.png", | |
| 243 | − "app/icon.svg", | |
| 244 | − "app/icon.png", | |
| 245 | − "app/icon.ico", | |
| 246 | − "src/favicon.ico", | |
| 247 | − "src/favicon.svg", | |
| 248 | − "src/app/favicon.ico", | |
| 249 | − "src/app/icon.svg", | |
| 250 | − "src/app/icon.png", | |
| 251 | − ]; | |
| 252 | − | |
| 253 | − // Files that may contain a <link rel="icon"> or icon metadata declaration. | |
| 254 | − const ICON_SOURCE_FILES = [ | |
| 255 | − "index.html", | |
| 256 | − "public/index.html", | |
| 257 | − "app/routes/__root.tsx", | |
| 258 | − "src/routes/__root.tsx", | |
| 259 | − "app/root.tsx", | |
| 260 | − "src/root.tsx", | |
| 261 | − "src/index.html", | |
| 262 | − ]; | |
| 263 | − | |
| 264 | − // Extract icon href from HTML/TSX source content. | |
| 265 | − // Matches <link rel="icon" href="…"> or { rel: "icon", href: "…" } patterns. | |
| 266 | − const LINK_ICON_HTML_RE = /<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i; | |
| 267 | − const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i; | |
| 268 | − function extractIconHref(source: string): string | null { | |
| 269 | − const htmlMatch = source.match(LINK_ICON_HTML_RE); | |
| 270 | − if (htmlMatch?.[1]) return htmlMatch[1]; | |
| 271 | − const objMatch = source.match(LINK_ICON_OBJ_RE); | |
| 272 | − if (objMatch?.[1]) return objMatch[1]; | |
| 273 | − return null; | |
| 274 | − } | |
| 275 | − | |
| 276 | − /** | |
| 277 | − * Resolve an icon href (e.g. "/grass-logo.svg") to a file on disk. | |
| 278 | − * For href starting with "/" we look in public/ first, then project root. | |
| 279 | − */ | |
| 280 | − function resolveIconHref(projectCwd: string, href: string): string[] { | |
| 281 | − const clean = href.replace(/^\//, ""); | |
| 282 | − return [ | |
| 283 | − path.join(projectCwd, "public", clean), | |
| 284 | − path.join(projectCwd, clean), | |
| 285 | − ]; | |
| 286 | − } | |
| 287 | − | |
| 288 | − function serveFaviconFile( | |
| 289 | − filePath: string, | |
| 290 | − res: http.ServerResponse, | |
| 291 | − ): void { | |
| 292 | − const ext = path.extname(filePath); | |
| 293 | − const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; | |
| 294 | − fs.readFile(filePath, (readErr, data) => { | |
| 295 | − if (readErr) { | |
| 296 | − res.writeHead(500, { "Content-Type": "text/plain" }); | |
| 297 | − res.end("Read error"); | |
| 298 | − return; | |
| 299 | − } | |
| 300 | − res.writeHead(200, { | |
| 301 | − "Content-Type": contentType, | |
| 302 | − "Cache-Control": "public, max-age=3600", | |
| 303 | − }); | |
| 304 | − res.end(data); | |
| 305 | − }); | |
| 306 | − } | |
| 307 | − | |
| 308 | 234 | // HTTP server — serves static files or redirects to Vite dev server |
| 309 | 235 | const httpServer = http.createServer((req, res) => { |
| 310 | 236 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); |
| 311 | 237 | |
| 312 | − // Serve project favicons from their working directories. | |
| 313 | − // Handled before the dev redirect so it works in both modes. | |
| 314 | − if (url.pathname === "/api/project-favicon") { | |
| 315 | − const projectCwd = url.searchParams.get("cwd"); | |
| 316 | − if (!projectCwd) { | |
| 317 | − res.writeHead(400, { "Content-Type": "text/plain" }); | |
| 318 | − res.end("Missing cwd parameter"); | |
| 319 | − return; | |
| 320 | − } | |
| 321 | − | |
| 322 | − // Phase 1: Check well-known favicon paths. | |
| 323 | − const tryCandidates = (index: number) => { | |
| 324 | − if (index >= FAVICON_CANDIDATES.length) { | |
| 325 | − // Phase 2: Parse source files for icon link declarations. | |
| 326 | − trySourceFiles(0); | |
| 327 | − return; | |
| 328 | − } | |
| 329 | − const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); | |
| 330 | − if (!candidate.startsWith(projectCwd + path.sep)) { | |
| 331 | − tryCandidates(index + 1); | |
| 332 | − return; | |
| 333 | − } | |
| 334 | − fs.stat(candidate, (err, stats) => { | |
| 335 | − if (err || !stats?.isFile()) { | |
| 336 | − tryCandidates(index + 1); | |
| 337 | − return; | |
| 338 | − } | |
| 339 | − serveFaviconFile(candidate, res); | |
| 340 | − }); | |
| 341 | − }; | |
| 342 | − | |
| 343 | − // Phase 2: Read source/HTML files and extract icon href references. | |
| 344 | − const trySourceFiles = (index: number) => { | |
| 345 | − if (index >= ICON_SOURCE_FILES.length) { | |
| 346 | − res.writeHead(404, { "Content-Type": "text/plain" }); | |
| 347 | − res.end("No favicon found"); | |
| 348 | − return; | |
| 349 | − } | |
| 350 | − const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); | |
| 351 | − fs.readFile(sourceFile, "utf8", (err, content) => { | |
| 352 | − if (err) { | |
| 353 | − trySourceFiles(index + 1); | |
| 354 | − return; | |
| 355 | − } | |
| 356 | − const href = extractIconHref(content); | |
| 357 | − if (!href) { | |
| 358 | − trySourceFiles(index + 1); | |
| 359 | − return; | |
| 360 | − } | |
| 361 | − const candidates = resolveIconHref(projectCwd, href); | |
| 362 | − tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); | |
| 363 | − }); | |
| 364 | − }; | |
| 365 | − | |
| 366 | − const tryResolvedPaths = ( | |
| 367 | − paths: string[], | |
| 368 | − index: number, | |
| 369 | − onExhausted: () => void, | |
| 370 | − ) => { | |
| 371 | − if (index >= paths.length) { | |
| 372 | − onExhausted(); | |
| 373 | − return; | |
| 374 | − } | |
| 375 | − const candidate = paths[index]!; | |
| 376 | − if (!candidate.startsWith(projectCwd + path.sep)) { | |
| 377 | − tryResolvedPaths(paths, index + 1, onExhausted); | |
| 378 | − return; | |
| 379 | − } | |
| 380 | − fs.stat(candidate, (err, stats) => { | |
| 381 | − if (err || !stats?.isFile()) { | |
| 382 | − tryResolvedPaths(paths, index + 1, onExhausted); | |
| 383 | − return; | |
| 384 | − } | |
| 385 | − serveFaviconFile(candidate, res); | |
| 386 | − }); | |
| 387 | − }; | |
| 388 | − | |
| 389 | − tryCandidates(0); | |
| 238 | + if (tryHandleProjectFaviconRequest(url, res)) { | |
| 390 | 239 | return; |
| 391 | 240 | } |
| 392 | 241 | |
| @@ -169,6 +169,15 @@ describe("isLatestTurnSettled", () => { · 168 unmodified lines | ||
| 169 | 169 | ).toBe(false); |
| 170 | 170 | }); |
| 171 | 171 | |
| 172 | + it("returns false while any turn is running to avoid stale latest-turn banners", () => { | |
| 173 | + expect( | |
| 174 | + isLatestTurnSettled(latestTurn, { | |
| 175 | + orchestrationStatus: "running", | |
| 176 | + activeTurnId: TurnId.makeUnsafe("turn-2"), | |
| 177 | + }), | |
| 178 | + ).toBe(false); | |
| 179 | + }); | |
| 180 | + | |
| 172 | 181 | it("returns true once the session is no longer running that turn", () => { |
| 173 | 182 | expect( |
| 174 | 183 | isLatestTurnSettled(latestTurn, { |
| @@ -83,9 +83,8 @@ export function isLatestTurnSettled( · 82 unmodified lines | ||
| 83 | 83 | if (!latestTurn?.startedAt) return false; |
| 84 | 84 | if (!latestTurn.completedAt) return false; |
| 85 | 85 | if (!session) return true; |
| 86 | − return !( | |
| 87 | − session.orchestrationStatus === "running" && session.activeTurnId === latestTurn.turnId | |
| 88 | − ); | |
| 86 | + if (session.orchestrationStatus === "running") return false; | |
| 87 | + return true; | |
| 89 | 88 | } |
| 90 | 89 | |
| 91 | 90 | export function derivePendingApprovals( |