diff --git a/apps/server/src/gitIgnore.ts b/apps/server/src/gitIgnore.ts new file mode 100644 index 000000000..d7415d9f6 --- /dev/null +++ b/apps/server/src/gitIgnore.ts @@ -0,0 +1,101 @@ +import { runProcess } from "./processRunner"; + +const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; + +function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { + const parts = input.split("\0"); + if (parts.length === 0) return []; + if (truncated && parts[parts.length - 1]?.length) { + parts.pop(); + } + return parts.filter((value) => value.length > 0); +} + +export async function isInsideGitWorkTree(cwd: string): Promise { + const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxBufferBytes: 4_096, + }).catch(() => null); + + return Boolean( + insideWorkTree && insideWorkTree.code === 0 && insideWorkTree.stdout.trim() === "true", + ); +} + +export async function filterGitIgnoredPaths( + cwd: string, + relativePaths: readonly string[], +): Promise { + if (relativePaths.length === 0) { + return [...relativePaths]; + } + + const ignoredPaths = new Set(); + let chunk: string[] = []; + let chunkBytes = 0; + + const flushChunk = async (): Promise => { + if (chunk.length === 0) { + return true; + } + + const checkIgnore = await runProcess("git", ["check-ignore", "--no-index", "-z", "--stdin"], { + cwd, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxBufferBytes: 16 * 1024 * 1024, + outputMode: "truncate", + stdin: `${chunk.join("\0")}\0`, + }).catch(() => null); + chunk = []; + chunkBytes = 0; + + if (!checkIgnore) { + return false; + } + + // git-check-ignore exits with 1 when no paths match. + if (checkIgnore.code !== 0 && checkIgnore.code !== 1) { + return false; + } + + const matchedIgnoredPaths = splitNullSeparatedPaths( + checkIgnore.stdout, + Boolean(checkIgnore.stdoutTruncated), + ); + for (const ignoredPath of matchedIgnoredPaths) { + ignoredPaths.add(ignoredPath); + } + return true; + }; + + for (const relativePath of relativePaths) { + const relativePathBytes = Buffer.byteLength(relativePath) + 1; + if ( + chunk.length > 0 && + chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES && + !(await flushChunk()) + ) { + return [...relativePaths]; + } + + chunk.push(relativePath); + chunkBytes += relativePathBytes; + + if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES && !(await flushChunk())) { + return [...relativePaths]; + } + } + + if (!(await flushChunk())) { + return [...relativePaths]; + } + + if (ignoredPaths.size === 0) { + return [...relativePaths]; + } + + return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); +} diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts index a346e513e..4aa97a7a2 100644 --- a/apps/server/src/projectFaviconRoute.test.ts +++ b/apps/server/src/projectFaviconRoute.test.ts @@ -1,8 +1,11 @@ +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import http from "node:http"; import os from "node:os"; import path from "node:path"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect } from "effect"; import { afterEach, describe, expect, it } from "vitest"; import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; @@ -20,14 +23,48 @@ function makeTempDir(prefix: string): string { return dir; } +function writeFile(filePath: string, contents: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, "utf8"); +} + +function makeUnreadable(filePath: string): void { + fs.chmodSync(filePath, 0o000); +} + +function runGit(cwd: string, args: readonly string[]): void { + execFileSync("git", args, { + cwd, + stdio: "ignore", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test User", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test User", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }); +} + async function withRouteServer(run: (baseUrl: string) => Promise): Promise { const server = http.createServer((req, res) => { const url = new URL(req.url ?? "/", "http://127.0.0.1"); - if (tryHandleProjectFaviconRequest(url, res)) { - return; - } - res.writeHead(404, { "Content-Type": "text/plain" }); - res.end("Not Found"); + void Effect.runPromise( + Effect.gen(function* () { + if (yield* tryHandleProjectFaviconRequest(url, res)) { + return; + } + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + }).pipe(Effect.provide(NodeServices.layer)), + ).catch((error) => { + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "text/plain" }); + } + if (!res.writableEnded) { + res.end(error instanceof Error ? error.message : "Unhandled error"); + } + }); }); await new Promise((resolve, reject) => { @@ -70,6 +107,22 @@ async function request(baseUrl: string, pathname: string): Promise }; } +function requestProjectFavicon(baseUrl: string, projectDir: string): Promise { + return request(baseUrl, `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`); +} + +function expectSvgResponse(response: HttpResponse, expectedBody: string): void { + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/svg+xml"); + expect(response.body).toBe(expectedBody); +} + +function expectFallbackSvgResponse(response: HttpResponse): void { + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/svg+xml"); + expect(response.body).toContain('data-fallback="project-favicon"'); +} + describe("tryHandleProjectFaviconRequest", () => { afterEach(() => { for (const dir of tempDirs.splice(0, tempDirs.length)) { @@ -87,85 +140,146 @@ describe("tryHandleProjectFaviconRequest", () => { it("serves a well-known favicon file from the project root", async () => { const projectDir = makeTempDir("t3code-favicon-route-root-"); - fs.writeFileSync(path.join(projectDir, "favicon.svg"), "favicon", "utf8"); + writeFile(path.join(projectDir, "favicon.svg"), "favicon"); 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).toBe("favicon"); + expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "favicon"); }); }); - it("resolves icon href from source files when no well-known favicon exists", async () => { - const projectDir = makeTempDir("t3code-favicon-route-source-"); - const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); - fs.mkdirSync(path.dirname(iconPath), { recursive: true }); - fs.writeFileSync( - path.join(projectDir, "index.html"), + it.each([ + { + name: "resolves icon link when href appears before rel in HTML", + prefix: "t3code-favicon-route-html-order-", + sourcePath: ["index.html"], + sourceContents: '', + iconPath: ["public", "brand", "logo.svg"], + expectedBody: "brand-html-order", + }, + { + name: "resolves object-style icon metadata when href appears before rel", + prefix: "t3code-favicon-route-obj-order-", + sourcePath: ["src", "root.tsx"], + sourceContents: 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];', + iconPath: ["public", "brand", "obj.svg"], + expectedBody: "brand-obj-order", + }, + ])("$name", async ({ prefix, sourcePath, sourceContents, iconPath, expectedBody }) => { + const projectDir = makeTempDir(prefix); + writeFile(path.join(projectDir, ...sourcePath), sourceContents); + writeFile(path.join(projectDir, ...iconPath), expectedBody); + + await withRouteServer(async (baseUrl) => { + expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), expectedBody); + }); + }); + + it("serves a fallback favicon when no icon exists", async () => { + const projectDir = makeTempDir("t3code-favicon-route-fallback-"); + + await withRouteServer(async (baseUrl) => { + expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir)); + }); + }); + + it("treats unreadable favicon probes as misses and continues searching", async () => { + const projectDir = makeTempDir("t3code-favicon-route-unreadable-probes-"); + const unreadableFaviconPath = path.join(projectDir, "favicon.svg"); + writeFile(unreadableFaviconPath, "blocked-root"); + makeUnreadable(unreadableFaviconPath); + const unreadableSourcePath = path.join(projectDir, "index.html"); + writeFile(unreadableSourcePath, ''); + makeUnreadable(unreadableSourcePath); + writeFile( + path.join(projectDir, "src", "root.tsx"), + 'const links = [{ rel: "icon", href: "/brand/readable.svg" }];', + ); + writeFile( + path.join(projectDir, "public", "brand", "readable.svg"), + "readable-from-source", + ); + + await withRouteServer(async (baseUrl) => { + expectSvgResponse( + await requestProjectFavicon(baseUrl, projectDir), + "readable-from-source", + ); + }); + }); + + it("finds a nested app favicon from source metadata when cwd is a monorepo root", async () => { + const projectDir = makeTempDir("t3code-favicon-route-monorepo-source-"); + writeFile( + path.join(projectDir, "apps", "frontend", "index.html"), '', ); - fs.writeFileSync(iconPath, "brand", "utf8"); + writeFile( + path.join(projectDir, "apps", "frontend", "public", "brand", "logo.svg"), + "nested-app", + ); 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).toBe("brand"); + expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "nested-app"); }); }); - it("resolves icon link when href appears before rel in HTML", async () => { - const projectDir = makeTempDir("t3code-favicon-route-html-order-"); - const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); - fs.mkdirSync(path.dirname(iconPath), { recursive: true }); - fs.writeFileSync( - path.join(projectDir, "index.html"), - '', + it("skips nested search roots that workspace entries ignore", async () => { + const projectDir = makeTempDir("t3code-favicon-route-ignored-search-root-"); + writeFile(path.join(projectDir, ".next", "public", "favicon.svg"), "ignored-next"); + + await withRouteServer(async (baseUrl) => { + expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir)); + }); + }); + + it("prefers a root favicon over nested workspace matches", async () => { + const projectDir = makeTempDir("t3code-favicon-route-root-priority-"); + writeFile(path.join(projectDir, "favicon.svg"), "root-first"); + writeFile(path.join(projectDir, "apps", "frontend", "public", "favicon.ico"), "nested-ico"); + + await withRouteServer(async (baseUrl) => { + expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "root-first"); + }); + }); + + it("skips a gitignored nested app directory", async () => { + const projectDir = makeTempDir("t3code-favicon-route-gitignored-app-"); + runGit(projectDir, ["init"]); + writeFile(path.join(projectDir, ".gitignore"), "apps/frontend/\n"); + writeFile( + path.join(projectDir, "apps", "frontend", "public", "favicon.svg"), + "ignored-app", ); - fs.writeFileSync(iconPath, "brand-html-order", "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).toBe("brand-html-order"); + expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir)); }); }); - it("resolves object-style icon metadata when href appears before rel", async () => { - const projectDir = makeTempDir("t3code-favicon-route-obj-order-"); - const iconPath = path.join(projectDir, "public", "brand", "obj.svg"); - fs.mkdirSync(path.dirname(iconPath), { recursive: true }); - fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); - fs.writeFileSync( - path.join(projectDir, "src", "root.tsx"), - 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];', - "utf8", + it("skips a gitignored root favicon and falls through to a nested app", async () => { + const projectDir = makeTempDir("t3code-favicon-route-gitignored-root-"); + runGit(projectDir, ["init"]); + writeFile(path.join(projectDir, ".gitignore"), "/favicon.svg\n"); + writeFile(path.join(projectDir, "favicon.svg"), "ignored-root"); + writeFile( + path.join(projectDir, "apps", "frontend", "public", "favicon.svg"), + "nested-kept", ); - fs.writeFileSync(iconPath, "brand-obj-order", "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).toBe("brand-obj-order"); + expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "nested-kept"); }); }); - it("serves a fallback favicon when no icon exists", async () => { - const projectDir = makeTempDir("t3code-favicon-route-fallback-"); + it("skips a gitignored source file when resolving icon metadata", async () => { + const projectDir = makeTempDir("t3code-favicon-route-gitignored-source-"); + runGit(projectDir, ["init"]); + writeFile(path.join(projectDir, ".gitignore"), "index.html\n"); + writeFile(path.join(projectDir, "index.html"), ''); + writeFile(path.join(projectDir, "public", "brand", "logo.svg"), "ignored-source"); 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"'); + expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir)); }); }); }); diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts index cf234ad89..1192c71dd 100644 --- a/apps/server/src/projectFaviconRoute.ts +++ b/apps/server/src/projectFaviconRoute.ts @@ -1,7 +1,11 @@ -import fs from "node:fs"; import http from "node:http"; import path from "node:path"; +import { Array, Effect, FileSystem, Option, Result } from "effect"; +import * as PlatformError from "effect/PlatformError"; +import { filterGitIgnoredPaths, isInsideGitWorkTree } from "./gitIgnore"; +import { isPathInIgnoredWorkspaceDirectory } from "./workspaceIgnore"; + const FAVICON_MIME_TYPES: Record = { ".png": "image/png", ".jpg": "image/jpeg", @@ -52,120 +56,361 @@ const LINK_ICON_HTML_RE = const LINK_ICON_OBJ_RE = /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; -function extractIconHref(source: string): string | null { - const htmlMatch = source.match(LINK_ICON_HTML_RE); - if (htmlMatch?.[1]) return htmlMatch[1]; - const objMatch = source.match(LINK_ICON_OBJ_RE); - if (objMatch?.[1]) return objMatch[1]; - return null; +type ExistingPathType = "File" | "Directory"; + +interface FaviconLookupServices { + fileSystem: FileSystem.FileSystem; + projectRoot: string; + filterAllowedPaths: (candidatePaths: readonly string[]) => Effect.Effect; +} + +function extractIconHref(source: string): Option.Option { + return Option.firstSomeOf([ + Option.fromNullishOr(source.match(LINK_ICON_HTML_RE)?.[1]), + Option.fromNullishOr(source.match(LINK_ICON_OBJ_RE)?.[1]), + ]); } -function resolveIconHref(projectCwd: string, href: string): string[] { - const clean = href.replace(/^\//, ""); - return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; +function platformErrorToNone( + effect: Effect.Effect, +): Effect.Effect, never, R> { + return effect.pipe( + Effect.map(Option.some), + Effect.catchTag("PlatformError", () => Effect.succeed(Option.none())), + ); } -function isPathWithinProject(projectCwd: string, candidatePath: string): boolean { - const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +function toProjectRelativePath(projectRoot: string, candidatePath: string): Option.Option { + const relativePath = path.relative(projectRoot, candidatePath); + if (relativePath.length === 0 || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return Option.none(); + } + + return Option.some(relativePath.split(path.sep).join("/")); } -function serveFaviconFile(filePath: string, res: http.ServerResponse): void { - const ext = path.extname(filePath).toLowerCase(); - const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; - fs.readFile(filePath, (readErr, data) => { - if (readErr) { - res.writeHead(500, { "Content-Type": "text/plain" }); - res.end("Read error"); - return; +function resolveExistingPath( + lookup: Pick, + candidatePath: string, + expectedType: ExistingPathType, +) { + return Effect.gen(function* () { + const resolvedPathOption = yield* platformErrorToNone( + lookup.fileSystem.realPath(candidatePath), + ); + if (Option.isNone(resolvedPathOption)) { + return Option.none(); } - res.writeHead(200, { - "Content-Type": contentType, - "Cache-Control": "public, max-age=3600", + + const resolvedPath = resolvedPathOption.value; + // Reject symlinks or traversals that escape the requested project root. + const relativePath = path.relative(lookup.projectRoot, resolvedPath); + if (relativePath !== "" && (relativePath.startsWith("..") || path.isAbsolute(relativePath))) { + return Option.none(); + } + + const infoOption = yield* platformErrorToNone(lookup.fileSystem.stat(resolvedPath)); + if (Option.isNone(infoOption) || infoOption.value.type !== expectedType) { + return Option.none(); + } + + return Option.some(resolvedPath); + }); +} + +function readFileIfExists( + lookup: Pick, + candidatePath: string, + read: (resolvedPath: string) => Effect.Effect, +) { + return Effect.gen(function* () { + const resolvedPathOption = yield* resolveExistingPath(lookup, candidatePath, "File"); + if (Option.isNone(resolvedPathOption)) { + return Option.none(); + } + + const contentOption = yield* platformErrorToNone(read(resolvedPathOption.value)); + if (Option.isNone(contentOption)) { + return Option.none(); + } + + return Option.some({ + path: resolvedPathOption.value, + content: contentOption.value, }); - res.end(data); }); } -function serveFallbackFavicon(res: http.ServerResponse): void { - res.writeHead(200, { - "Content-Type": "image/svg+xml", - "Cache-Control": "public, max-age=3600", +function makeAllowedPathFilter(projectRoot: string, shouldFilterWithGitIgnore: boolean) { + const gitIgnorePathCache = new Map(); + + return (candidatePaths: readonly string[]) => + Effect.gen(function* () { + if (!shouldFilterWithGitIgnore || candidatePaths.length === 0) { + return [...candidatePaths]; + } + + const uncachedRelativePaths = Array.dedupe( + candidatePaths.flatMap((candidatePath) => + Option.match(toProjectRelativePath(projectRoot, candidatePath), { + onNone: () => [], + onSome: (relativePath) => (gitIgnorePathCache.has(relativePath) ? [] : [relativePath]), + }), + ), + ); + + if (uncachedRelativePaths.length > 0) { + // Cache git-ignore decisions by normalized relative path so repeated root + // and nested scans only hit `git check-ignore` once per candidate. + const allowedRelativePaths = yield* Effect.promise(() => + filterGitIgnoredPaths(projectRoot, uncachedRelativePaths), + ).pipe(Effect.orElseSucceed(() => uncachedRelativePaths)); + const allowedRelativePathSet = new Set(allowedRelativePaths); + + for (const relativePath of uncachedRelativePaths) { + gitIgnorePathCache.set(relativePath, allowedRelativePathSet.has(relativePath)); + } + } + + return candidatePaths.filter((candidatePath) => + Option.match(toProjectRelativePath(projectRoot, candidatePath), { + onNone: () => true, + onSome: (relativePath) => gitIgnorePathCache.get(relativePath) !== false, + }), + ); + }); +} + +function findFirstReadableFavicon( + lookup: FaviconLookupServices, + candidatePaths: readonly string[], +) { + return Effect.gen(function* () { + const allowedCandidatePaths = yield* lookup.filterAllowedPaths(candidatePaths); + + for (const candidatePath of allowedCandidatePaths) { + const fileOption = yield* readFileIfExists(lookup, candidatePath, (resolvedPath) => + lookup.fileSystem.readFile(resolvedPath), + ); + if (Option.isSome(fileOption)) { + return Option.some({ + body: fileOption.value.content, + contentType: + FAVICON_MIME_TYPES[path.extname(fileOption.value.path).toLowerCase()] ?? + "application/octet-stream", + }); + } + } + + return Option.none(); }); - res.end(FALLBACK_FAVICON_SVG); } -export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean { - if (url.pathname !== "/api/project-favicon") { - return false; - } +function iconHrefCandidatePaths(searchRoot: string, href: string): string[] { + // Treat root-relative hrefs as app-relative because different toolchains place + // runtime-served assets in either `public/` or directly beside the app entrypoint. + const cleanHref = href.replace(/^\//, ""); + return [path.join(searchRoot, "public", cleanHref), path.join(searchRoot, cleanHref)]; +} - const projectCwd = url.searchParams.get("cwd"); - if (!projectCwd) { - res.writeHead(400, { "Content-Type": "text/plain" }); - res.end("Missing cwd parameter"); - return true; - } +function findFaviconFromSourcePath( + lookup: FaviconLookupServices, + searchRoot: string, + sourcePath: string, +) { + return Effect.gen(function* () { + const sourceFileOption = yield* readFileIfExists(lookup, sourcePath, (resolvedPath) => + lookup.fileSystem.readFileString(resolvedPath), + ); + if (Option.isNone(sourceFileOption)) { + return Option.none(); + } - const tryResolvedPaths = (paths: string[], index: number, onExhausted: () => void): void => { - if (index >= paths.length) { - onExhausted(); - return; + const hrefOption = extractIconHref(sourceFileOption.value.content); + if (Option.isNone(hrefOption)) { + return Option.none(); } - const candidate = paths[index]!; - if (!isPathWithinProject(projectCwd, candidate)) { - tryResolvedPaths(paths, index + 1, onExhausted); - return; + + return yield* findFirstReadableFavicon( + lookup, + iconHrefCandidatePaths(searchRoot, hrefOption.value), + ); + }); +} + +function findFaviconFromSourceFiles(lookup: FaviconLookupServices, searchRoot: string) { + return Effect.gen(function* () { + const sourcePaths = yield* lookup.filterAllowedPaths( + ICON_SOURCE_FILES.map((sourceFile) => path.join(searchRoot, sourceFile)), + ); + return yield* Effect.findFirstFilter(sourcePaths, (sourcePath) => + findFaviconFromSourcePath(lookup, searchRoot, sourcePath).pipe( + Effect.map((option) => Result.fromOption(option, () => undefined)), + ), + ); + }); +} + +function findFaviconInSearchRoot(lookup: FaviconLookupServices, searchRoot: string) { + return Effect.gen(function* () { + const faviconOption = yield* findFirstReadableFavicon( + lookup, + FAVICON_CANDIDATES.map((candidate) => path.join(searchRoot, candidate)), + ); + if (Option.isSome(faviconOption)) { + return faviconOption; } - fs.stat(candidate, (err, stats) => { - if (err || !stats?.isFile()) { - tryResolvedPaths(paths, index + 1, onExhausted); - return; - } - serveFaviconFile(candidate, res); - }); - }; - const trySourceFiles = (index: number): void => { - if (index >= ICON_SOURCE_FILES.length) { - serveFallbackFavicon(res); - return; + return yield* findFaviconFromSourceFiles(lookup, searchRoot); + }); +} + +function listChildDirectories(lookup: FaviconLookupServices, rootPath: string) { + return Effect.gen(function* () { + const entriesOption = yield* platformErrorToNone(lookup.fileSystem.readDirectory(rootPath)); + if (Option.isNone(entriesOption)) { + return []; } - const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); - fs.readFile(sourceFile, "utf8", (err, content) => { - if (err) { - trySourceFiles(index + 1); - return; + + const entries = entriesOption.value; + const directories: string[] = []; + + for (const entry of entries.toSorted((left, right) => left.localeCompare(right))) { + if (entry.length === 0 || entry.includes("/") || entry.includes("\\")) { + continue; } - const href = extractIconHref(content); - if (!href) { - trySourceFiles(index + 1); - return; + if (isPathInIgnoredWorkspaceDirectory(entry)) { + continue; } - 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 directoryPathOption = yield* resolveExistingPath( + lookup, + path.join(rootPath, entry), + "Directory", + ); + if (Option.isSome(directoryPathOption)) { + directories.push(directoryPathOption.value); + } } - const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); - if (!isPathWithinProject(projectCwd, candidate)) { - tryCandidates(index + 1); - return; + + return directories; + }); +} + +function listCandidateSearchRoots(lookup: FaviconLookupServices) { + return Effect.gen(function* () { + // Prefer conventional monorepo roots first, then fall back to other top-level children. + const [appRoots, packageRoots, directChildRoots] = yield* Effect.all([ + listChildDirectories(lookup, path.join(lookup.projectRoot, "apps")), + listChildDirectories(lookup, path.join(lookup.projectRoot, "packages")), + listChildDirectories(lookup, lookup.projectRoot), + ]); + + return [ + ...appRoots, + ...packageRoots, + ...directChildRoots.filter((directChildRoot) => { + const baseName = path.basename(directChildRoot).toLowerCase(); + return baseName !== "apps" && baseName !== "packages"; + }), + ]; + }); +} + +function findNestedFavicon(lookup: FaviconLookupServices) { + return Effect.gen(function* () { + const searchRoots = yield* listCandidateSearchRoots(lookup).pipe( + Effect.flatMap((roots) => lookup.filterAllowedPaths(roots)), + ); + return yield* Effect.findFirstFilter(searchRoots, (searchRoot) => + findFaviconInSearchRoot(lookup, searchRoot).pipe( + Effect.map((option) => Result.fromOption(option, () => undefined)), + ), + ); + }); +} + +function respond( + res: http.ServerResponse, + statusCode: number, + contentType: string, + body: Uint8Array | string, + cacheable = true, +) { + return Effect.sync(() => { + const headers: Record = { + "Content-Type": contentType, + }; + if (cacheable) { + headers["Cache-Control"] = "public, max-age=3600"; } - fs.stat(candidate, (err, stats) => { - if (err || !stats?.isFile()) { - tryCandidates(index + 1); - return; - } - serveFaviconFile(candidate, res); + res.writeHead(statusCode, headers); + res.end(body); + }); +} + +function respondWithFavicon( + res: http.ServerResponse, + favicon: { + body: Uint8Array; + contentType: string; + }, +) { + return respond(res, 200, favicon.contentType, favicon.body); +} + +function respondWithFallbackFavicon(res: http.ServerResponse) { + return respond(res, 200, "image/svg+xml", FALLBACK_FAVICON_SVG); +} + +export function tryHandleProjectFaviconRequest( + url: URL, + res: http.ServerResponse, +): Effect.Effect { + if (url.pathname !== "/api/project-favicon") { + return Effect.succeed(false); + } + + const projectCwd = url.searchParams.get("cwd"); + if (!projectCwd) { + return Effect.sync(() => { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("Missing cwd parameter"); + return true; }); - }; + } + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const projectRootOption = yield* platformErrorToNone(fileSystem.realPath(projectCwd)); + if (Option.isNone(projectRootOption)) { + yield* respondWithFallbackFavicon(res); + return true; + } + + const projectRoot = projectRootOption.value; + const shouldFilterWithGitIgnore = yield* Effect.promise(() => + isInsideGitWorkTree(projectRoot).catch(() => false), + ); + const lookup = { + fileSystem, + projectRoot, + filterAllowedPaths: makeAllowedPathFilter(projectRoot, shouldFilterWithGitIgnore), + } satisfies FaviconLookupServices; - tryCandidates(0); - return true; + const rootFaviconOption = yield* findFaviconInSearchRoot(lookup, projectRoot); + if (Option.isSome(rootFaviconOption)) { + yield* respondWithFavicon(res, rootFaviconOption.value); + return true; + } + + const nestedFaviconOption = yield* findNestedFavicon(lookup); + if (Option.isSome(nestedFaviconOption)) { + yield* respondWithFavicon(res, nestedFaviconOption.value); + return true; + } + + yield* respondWithFallbackFavicon(res); + return true; + }); } diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index 684b005e8..d54e5ceff 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -8,24 +8,13 @@ import { ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; +import { filterGitIgnoredPaths, isInsideGitWorkTree } from "./gitIgnore"; +import { isPathInIgnoredWorkspaceDirectory } from "./workspaceIgnore"; const WORKSPACE_CACHE_TTL_MS = 15_000; const WORKSPACE_CACHE_MAX_KEYS = 4; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; -const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; -const IGNORED_DIRECTORY_NAMES = new Set([ - ".git", - ".convex", - "node_modules", - ".next", - ".turbo", - "dist", - "build", - "out", - ".cache", -]); - interface WorkspaceIndex { scannedAt: number; entries: SearchableWorkspaceEntry[]; @@ -197,12 +186,6 @@ function insertRankedEntry( rankedEntries.pop(); } -function isPathInIgnoredDirectory(relativePath: string): boolean { - const firstSegment = relativePath.split("/")[0]; - if (!firstSegment) return false; - return IGNORED_DIRECTORY_NAMES.has(firstSegment); -} - function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { const parts = input.split("\0"); if (parts.length === 0) return []; @@ -250,91 +233,6 @@ async function mapWithConcurrency( return results; } -async function isInsideGitWorkTree(cwd: string): Promise { - const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { - cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - maxBufferBytes: 4_096, - }).catch(() => null); - return Boolean( - insideWorkTree && insideWorkTree.code === 0 && insideWorkTree.stdout.trim() === "true", - ); -} - -async function filterGitIgnoredPaths(cwd: string, relativePaths: string[]): Promise { - if (relativePaths.length === 0) { - return relativePaths; - } - - const ignoredPaths = new Set(); - let chunk: string[] = []; - let chunkBytes = 0; - - const flushChunk = async (): Promise => { - if (chunk.length === 0) { - return true; - } - - const checkIgnore = await runProcess("git", ["check-ignore", "--no-index", "-z", "--stdin"], { - cwd, - allowNonZeroExit: true, - timeoutMs: 20_000, - maxBufferBytes: 16 * 1024 * 1024, - outputMode: "truncate", - stdin: `${chunk.join("\0")}\0`, - }).catch(() => null); - chunk = []; - chunkBytes = 0; - - if (!checkIgnore) { - return false; - } - - // git-check-ignore exits with 1 when no paths match. - if (checkIgnore.code !== 0 && checkIgnore.code !== 1) { - return false; - } - - const matchedIgnoredPaths = splitNullSeparatedPaths( - checkIgnore.stdout, - Boolean(checkIgnore.stdoutTruncated), - ); - for (const ignoredPath of matchedIgnoredPaths) { - ignoredPaths.add(ignoredPath); - } - return true; - }; - - for (const relativePath of relativePaths) { - const relativePathBytes = Buffer.byteLength(relativePath) + 1; - if ( - chunk.length > 0 && - chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES && - !(await flushChunk()) - ) { - return relativePaths; - } - - chunk.push(relativePath); - chunkBytes += relativePathBytes; - - if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES && !(await flushChunk())) { - return relativePaths; - } - } - - if (!(await flushChunk())) { - return relativePaths; - } - - if (ignoredPaths.size === 0) { - return relativePaths; - } - - return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); -} - async function buildWorkspaceIndexFromGit(cwd: string): Promise { if (!(await isInsideGitWorkTree(cwd))) { return null; @@ -360,13 +258,13 @@ async function buildWorkspaceIndexFromGit(cwd: string): Promise toPosixPath(entry)) - .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); + .filter((entry) => entry.length > 0 && !isPathInIgnoredWorkspaceDirectory(entry)); const filePaths = await filterGitIgnoredPaths(cwd, listedPaths); const directorySet = new Set(); for (const filePath of filePaths) { for (const directoryPath of directoryAncestorsOf(filePath)) { - if (!isPathInIgnoredDirectory(directoryPath)) { + if (!isPathInIgnoredWorkspaceDirectory(directoryPath)) { directorySet.add(directoryPath); } } @@ -421,7 +319,9 @@ async function buildWorkspaceIndex(cwd: string): Promise { async (relativeDir) => { const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; try { - const dirents = await fs.readdir(absoluteDir, { withFileTypes: true }); + const dirents = await fs.readdir(absoluteDir, { + withFileTypes: true, + }); return { relativeDir, dirents }; } catch (error) { if (!relativeDir) { @@ -445,7 +345,7 @@ async function buildWorkspaceIndex(cwd: string): Promise { if (!dirent.name || dirent.name === "." || dirent.name === "..") { continue; } - if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { + if (dirent.isDirectory() && isPathInIgnoredWorkspaceDirectory(dirent.name)) { continue; } if (!dirent.isDirectory() && !dirent.isFile()) { @@ -455,7 +355,7 @@ async function buildWorkspaceIndex(cwd: string): Promise { const relativePath = toPosixPath( relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, ); - if (isPathInIgnoredDirectory(relativePath)) { + if (isPathInIgnoredWorkspaceDirectory(relativePath)) { continue; } candidates.push({ dirent, relativePath }); diff --git a/apps/server/src/workspaceIgnore.ts b/apps/server/src/workspaceIgnore.ts new file mode 100644 index 000000000..f63bf78ca --- /dev/null +++ b/apps/server/src/workspaceIgnore.ts @@ -0,0 +1,20 @@ +const IGNORED_WORKSPACE_DIRECTORY_NAMES = new Set([ + ".git", + ".convex", + "node_modules", + ".next", + ".turbo", + "dist", + "build", + "out", + ".cache", +]); + +export function isPathInIgnoredWorkspaceDirectory(relativePath: string): boolean { + const firstSegment = relativePath.split("/")[0]; + if (!firstSegment) { + return false; + } + + return IGNORED_WORKSPACE_DIRECTORY_NAMES.has(firstSegment); +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7..166a0f488 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -424,7 +424,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< void Effect.runPromise( Effect.gen(function* () { const url = new URL(req.url ?? "/", `http://localhost:${port}`); - if (tryHandleProjectFaviconRequest(url, res)) { + if (yield* tryHandleProjectFaviconRequest(url, res)) { return; } @@ -566,7 +566,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return; } respond(200, { "Content-Type": contentType }, data); - }), + }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)), ).catch(() => { if (!res.headersSent) { respond(500, { "Content-Type": "text/plain" }, "Internal Server Error");