authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 14:58:17-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-27 15:21:31-08:00
logffbf541ff88c2c8398e69612fb65d8705ab2ad60
tree43bd2e13e293fdb6f9033ba164384f74540cac28
parent709f961e1c9261efd43673a0e802c6cd0bfca8f1

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

5 files changed, 291 insertions(+), 156 deletions(-)

apps/server/src/projectFaviconRoute.test.ts created+118
......@@ -0,0 +1,118 @@
1import fs from "node:fs";
2import http from "node:http";
3import os from "node:os";
4import path from "node:path";
5
6import { afterEach, describe, expect, it } from "vitest";
7import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
8
9interface HttpResponse {
10 statusCode: number;
11 contentType: string | null;
12 body: string;
13}
14
15const tempDirs: string[] = [];
16
17function makeTempDir(prefix: string): string {
18 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
19 tempDirs.push(dir);
20 return dir;
21}
22
23async 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
66async 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
75describe("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 @@
1import fs from "node:fs";
2import http from "node:http";
3import path from "node:path";
4
5const 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.
13const 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.
33const 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.
44const LINK_ICON_HTML_RE = /<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i;
45const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i;
46
47function 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
55function resolveIconHref(projectCwd: string, href: string): string[] {
56 const clean = href.replace(/^\//, "");
57 return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];
58}
59
60function 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
65function 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
82export 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";
4242import { Open } from "./open";
4343import { ServerConfig } from "./config";
4444import { GitCore } from "./git/Services/GitCore.ts";
45import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
4546
4647const MIME_TYPES: Record<string, string> = {
4748 ".html": "text/html; charset=utf-8",
......@@ -230,163 +231,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
230231 });
231232 });
232233
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
308234 // HTTP server — serves static files or redirects to Vite dev server
309235 const httpServer = http.createServer((req, res) => {
310236 const url = new URL(req.url ?? "/", `http://localhost:${port}`);
311237
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)) {
390239 return;
391240 }
392241
apps/web/src/session-logic.test.ts+9
......@@ -169,6 +169,15 @@ describe("isLatestTurnSettled", () => {
169169 ).toBe(false);
170170 });
171171
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
172181 it("returns true once the session is no longer running that turn", () => {
173182 expect(
174183 isLatestTurnSettled(latestTurn, {
apps/web/src/session-logic.ts+2-3
......@@ -83,9 +83,8 @@ export function isLatestTurnSettled(
8383 if (!latestTurn?.startedAt) return false;
8484 if (!latestTurn.completedAt) return false;
8585 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;
8988}
9089
9190export function derivePendingApprovals(