Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions apps/server/src/projectFaviconRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,15 +153,53 @@ describe("tryHandleProjectFaviconRequest", () => {
});
});

it("serves a fallback favicon when no icon exists", async () => {
const projectDir = makeTempDir("t3code-favicon-route-fallback-");
it("resolves a favicon from a web app inside apps/", async () => {
const projectDir = makeTempDir("t3code-favicon-route-apps-root-");
const appDir = path.join(projectDir, "apps", "web");
const iconPath = path.join(appDir, "public", "favicon.ico");
fs.mkdirSync(path.dirname(iconPath), { recursive: true });
fs.writeFileSync(iconPath, "web-app-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/x-icon");
expect(response.body).toBe("web-app-favicon");
});
});

it("searches apps/ recursively for nested web apps", async () => {
const projectDir = makeTempDir("t3code-favicon-route-apps-recursive-");
const appDir = path.join(projectDir, "apps", "client", "site");
const iconPath = path.join(appDir, "public", "brand", "logo.svg");
fs.mkdirSync(path.dirname(iconPath), { recursive: true });
fs.mkdirSync(path.join(appDir, "src"), { recursive: true });
fs.writeFileSync(
path.join(appDir, "index.html"),
'<link rel="icon" href="/brand/logo.svg">',
"utf8",
);
fs.writeFileSync(iconPath, "<svg>nested-brand</svg>", "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).toContain('data-fallback="project-favicon"');
expect(response.body).toBe("<svg>nested-brand</svg>");
});
});

it("returns 204 when no favicon exists", async () => {
const projectDir = makeTempDir("t3code-favicon-route-fallback-");

await withRouteServer(async (baseUrl) => {
const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
const response = await request(baseUrl, pathname);
expect(response.statusCode).toBe(204);
expect(response.contentType).toBeNull();
expect(response.body).toBe("");
});
});
});
232 changes: 167 additions & 65 deletions apps/server/src/projectFaviconRoute.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from "node:fs";
import fsPromises from "node:fs/promises";
import http from "node:http";
import path from "node:path";

Expand All @@ -9,16 +10,18 @@ const FAVICON_MIME_TYPES: Record<string, string> = {
".ico": "image/x-icon",
};

const FALLBACK_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;

// Well-known favicon paths checked in order.
const FAVICON_CANDIDATES = [
"favicon.svg",
"favicon.ico",
"favicon.png",
"favicon-32x32.png",
"favicon-16x16.png",
"public/favicon.svg",
"public/favicon.ico",
"public/favicon.png",
"public/favicon-32x32.png",
"public/favicon-16x16.png",
"app/favicon.ico",
"app/favicon.png",
"app/icon.svg",
Expand Down Expand Up @@ -52,6 +55,28 @@ const LINK_ICON_HTML_RE =
const LINK_ICON_OBJ_RE =
/(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;

const IGNORED_APP_SEARCH_DIRECTORIES = new Set([
".git",
".next",
".turbo",
"build",
"dist",
"node_modules",
"out",
]);
const APP_ROOT_MARKER_DIRECTORIES = new Set(["app", "public", "src"]);
const APP_ROOT_MARKER_FILES = new Set(["index.html", "package.json"]);
const APP_ROOT_MARKER_FILE_PREFIXES = [
"angular.",
"astro.config.",
"next.config.",
"nuxt.config.",
"remix.config.",
"svelte.config.",
"vite.config.",
];
const APPS_SEARCH_MAX_DEPTH = 4;

function extractIconHref(source: string): string | null {
const htmlMatch = source.match(LINK_ICON_HTML_RE);
if (htmlMatch?.[1]) return htmlMatch[1];
Expand All @@ -70,6 +95,134 @@ function isPathWithinProject(projectCwd: string, candidatePath: string): boolean
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}

async function isFile(filePath: string): Promise<boolean> {
try {
const stats = await fsPromises.stat(filePath);
return stats.isFile();
} catch {
return false;
}
}

async function resolveFaviconFromProjectRoot(projectCwd: string): Promise<string | null> {
for (const relativeCandidate of FAVICON_CANDIDATES) {
const candidatePath = path.join(projectCwd, relativeCandidate);
if (!isPathWithinProject(projectCwd, candidatePath)) {
continue;
}
if (await isFile(candidatePath)) {
return candidatePath;
}
}

for (const relativeSourceFile of ICON_SOURCE_FILES) {
const sourceFilePath = path.join(projectCwd, relativeSourceFile);
let content: string;
try {
content = await fsPromises.readFile(sourceFilePath, "utf8");
} catch {
continue;
}

const href = extractIconHref(content);
if (!href) {
continue;
}

for (const resolvedPath of resolveIconHref(projectCwd, href)) {
if (!isPathWithinProject(projectCwd, resolvedPath)) {
continue;
}
if (await isFile(resolvedPath)) {
return resolvedPath;
}
}
}

return null;
}

function looksLikeWebAppRoot(entries: readonly fs.Dirent[]): boolean {
for (const entry of entries) {
if (entry.isDirectory() && APP_ROOT_MARKER_DIRECTORIES.has(entry.name)) {
return true;
}
if (!entry.isFile()) {
continue;
}
if (APP_ROOT_MARKER_FILES.has(entry.name)) {
return true;
}
if (APP_ROOT_MARKER_FILE_PREFIXES.some((prefix) => entry.name.startsWith(prefix))) {
return true;
}
}
return false;
}

async function collectNestedAppRoots(
directoryPath: string,
depth: number,
roots: string[],
): Promise<void> {
let entries: fs.Dirent[];
try {
entries = await fsPromises.readdir(directoryPath, { withFileTypes: true });
} catch {
return;
}

const sortedEntries = entries.toSorted((left, right) => left.name.localeCompare(right.name));
if (depth > 0 && looksLikeWebAppRoot(sortedEntries)) {
roots.push(directoryPath);
return;
}

if (depth >= APPS_SEARCH_MAX_DEPTH) {
return;
}

for (const entry of sortedEntries) {
if (
!entry.isDirectory() ||
IGNORED_APP_SEARCH_DIRECTORIES.has(entry.name) ||
entry.name.startsWith(".")
) {
continue;
}
await collectNestedAppRoots(path.join(directoryPath, entry.name), depth + 1, roots);
}
}

async function resolveProjectFaviconPath(projectCwd: string): Promise<string | null> {
const faviconFromRoot = await resolveFaviconFromProjectRoot(projectCwd);
if (faviconFromRoot) {
return faviconFromRoot;
}

const appsDirectoryPath = path.join(projectCwd, "apps");
let appsDirectoryStats: fs.Stats;
try {
appsDirectoryStats = await fsPromises.stat(appsDirectoryPath);
} catch {
return null;
}
if (!appsDirectoryStats.isDirectory()) {
return null;
}

const nestedAppRoots: string[] = [];
await collectNestedAppRoots(appsDirectoryPath, 0, nestedAppRoots);
for (const appRoot of nestedAppRoots) {
const faviconFromApp = await resolveFaviconFromProjectRoot(appRoot);
if (faviconFromApp) {
return faviconFromApp;
}
}

return null;
}

function serveFaviconFile(filePath: string, res: http.ServerResponse): void {
const ext = path.extname(filePath).toLowerCase();
const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream";
Expand All @@ -87,12 +240,9 @@ function serveFaviconFile(filePath: string, res: http.ServerResponse): void {
});
}

function serveFallbackFavicon(res: http.ServerResponse): void {
res.writeHead(200, {
"Content-Type": "image/svg+xml",
"Cache-Control": "public, max-age=3600",
});
res.end(FALLBACK_FAVICON_SVG);
function serveMissingFavicon(res: http.ServerResponse): void {
res.writeHead(204, { "Cache-Control": "no-store" });
res.end();
}

export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean {
Expand All @@ -107,65 +257,17 @@ export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerRespons
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);
void resolveProjectFaviconPath(projectCwd)
.then((faviconPath) => {
if (!faviconPath) {
serveMissingFavicon(res);
return;
}
serveFaviconFile(candidate, res);
});
};

const trySourceFiles = (index: number): void => {
if (index >= ICON_SOURCE_FILES.length) {
serveFallbackFavicon(res);
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);
serveFaviconFile(faviconPath, res);
})
.catch(() => {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Failed to resolve favicon");
});
};

tryCandidates(0);
return true;
}
17 changes: 13 additions & 4 deletions apps/web/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FolderIcon } from "lucide-react";
import { useState } from "react";
import { FolderIcon, type LucideIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "../lib/utils";

function getServerHttpOrigin(): string {
Expand All @@ -25,13 +25,19 @@ export function ProjectFavicon(props: {
cwd: string;
className?: string;
fallbackClassName?: string;
fallbackIcon?: LucideIcon;
}) {
const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(props.cwd)}`;
const FallbackIcon = props.fallbackIcon ?? FolderIcon;

useEffect(() => {
setStatus("loading");
}, [src]);

if (status === "error") {
return (
<FolderIcon
<FallbackIcon
className={cn("size-3.5 shrink-0 text-muted-foreground/50", props.fallbackClassName)}
/>
);
Expand All @@ -46,7 +52,10 @@ export function ProjectFavicon(props: {
status === "loading" && "hidden",
props.className,
)}
onLoad={() => setStatus("loaded")}
onLoad={(event) => {
const image = event.currentTarget;
setStatus(image.naturalWidth > 0 || image.naturalHeight > 0 ? "loaded" : "error");
}}
onError={() => setStatus("error")}
/>
);
Expand Down
Loading
Loading