History

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

Julius Marminge committed 8 months ago

Commit ffbf5411 parent 709f9615 files · +291 −156Browse files

Unified · Split

5 files
  1. apps/server/src/projectFaviconRoute.test.ts +118 −0
  2. apps/server/src/projectFaviconRoute.ts +160 −0
  3. apps/server/src/wsServer.ts +2 −153
  4. apps/web/src/session-logic.test.ts +9 −0
  5. apps/web/src/session-logic.ts +2 −3
apps/server/src/projectFaviconRoute.test.ts+118−0
@@ -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+160−0
@@ -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"; · 41 unmodified lines
4242 import { Open } from "./open";
4343 import { ServerConfig } from "./config";
4444 import { GitCore } from "./git/Services/GitCore.ts";
45+import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
4546
4647 const 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< · 182 unmodified lines
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−0
@@ -169,6 +169,15 @@ describe("isLatestTurnSettled", () => { · 168 unmodified lines
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( · 82 unmodified lines
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
9190 export function derivePendingApprovals(