| author | |
| committer | |
| log | ffbf541ff88c2c8398e69612fb65d8705ab2ad60 |
| tree | 43bd2e13e293fdb6f9033ba164384f74540cac28 |
| parent | 709f961e1c9261efd43673a0e802c6cd0bfca8f1 |
- 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 coverage5 files changed, 291 insertions(+), 156 deletions(-)
apps/server/src/projectFaviconRoute.test.ts created+118| ... | ... | @@ -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 | }); |
apps/server/src/projectFaviconRoute.ts created+160| ... | ... | @@ -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 | } |
apps/server/src/wsServer.ts+2-153| ... | ... | @@ -42,6 +42,7 @@ import { clamp } from "effect/Number"; |
| 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< |
| 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 |
apps/web/src/session-logic.test.ts+9| ... | ... | @@ -169,6 +169,15 @@ describe("isLatestTurnSettled", () => { |
| 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, { |
apps/web/src/session-logic.ts+2-3| ... | ... | @@ -83,9 +83,8 @@ export function isLatestTurnSettled( |
| 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( |