diff --git a/apps/server/src/gitIgnore.test.ts b/apps/server/src/gitIgnore.test.ts new file mode 100644 index 0000000000..37fb88788c --- /dev/null +++ b/apps/server/src/gitIgnore.test.ts @@ -0,0 +1,80 @@ +import { assert, beforeEach, describe, it, vi } from "vitest"; + +import type { ProcessRunOptions, ProcessRunResult } from "./processRunner"; + +const { runProcessMock } = vi.hoisted(() => ({ + runProcessMock: + vi.fn< + ( + command: string, + args: readonly string[], + options?: ProcessRunOptions, + ) => Promise + >(), +})); + +vi.mock("./processRunner", () => ({ + runProcess: runProcessMock, +})); + +function processResult( + overrides: Partial & Pick, +): ProcessRunResult { + return { + stdout: overrides.stdout, + code: overrides.code, + stderr: overrides.stderr ?? "", + signal: overrides.signal ?? null, + timedOut: overrides.timedOut ?? false, + stdoutTruncated: overrides.stdoutTruncated ?? false, + stderrTruncated: overrides.stderrTruncated ?? false, + }; +} + +describe("gitIgnore", () => { + beforeEach(() => { + runProcessMock.mockReset(); + vi.resetModules(); + }); + + it("chunks large git check-ignore requests and filters ignored matches", async () => { + const ignoredPaths = Array.from( + { length: 320 }, + (_, index) => `ignored/${index.toString().padStart(4, "0")}/${"x".repeat(1024)}.ts`, + ); + const keptPaths = ["src/keep.ts", "docs/readme.md"]; + const relativePaths = [...ignoredPaths, ...keptPaths]; + let checkIgnoreCalls = 0; + + runProcessMock.mockImplementation(async (_command, args, options) => { + if (args[0] === "check-ignore") { + checkIgnoreCalls += 1; + const chunkPaths = (options?.stdin ?? "").split("\0").filter((value) => value.length > 0); + const chunkIgnored = chunkPaths.filter((value) => value.startsWith("ignored/")); + return processResult({ + code: chunkIgnored.length > 0 ? 0 : 1, + stdout: chunkIgnored.length > 0 ? `${chunkIgnored.join("\0")}\0` : "", + }); + } + + throw new Error(`Unexpected command: git ${args.join(" ")}`); + }); + + const { filterGitIgnoredPaths } = await import("./gitIgnore"); + const result = await filterGitIgnoredPaths("/virtual/workspace", relativePaths); + + assert.isAbove(checkIgnoreCalls, 1); + assert.deepEqual(result, keptPaths); + }); + + it("fails open when git check-ignore cannot complete", async () => { + const relativePaths = ["src/keep.ts", "ignored.txt"]; + + runProcessMock.mockRejectedValueOnce(new Error("spawn failed")); + + const { filterGitIgnoredPaths } = await import("./gitIgnore"); + const result = await filterGitIgnoredPaths("/virtual/workspace", relativePaths); + + assert.deepEqual(result, relativePaths); + }); +}); diff --git a/apps/server/src/gitIgnore.ts b/apps/server/src/gitIgnore.ts new file mode 100644 index 0000000000..e6ea2bf921 --- /dev/null +++ b/apps/server/src/gitIgnore.ts @@ -0,0 +1,123 @@ +import { runProcess } from "./processRunner"; + +const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; + +/** + * Shared git-ignore helpers for server-side workspace scans. + * + * Both callers use these helpers as an optimization and a consistency layer, not + * as a hard dependency. If git is unavailable, slow, or returns an unexpected + * result, we intentionally fail open so the UI keeps working and avoids hiding + * files unpredictably. + */ + +export function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { + const parts = input.split("\0"); + if (truncated && parts[parts.length - 1]?.length) { + parts.pop(); + } + return parts.filter((value) => value.length > 0); +} + +/** + * Returns whether `cwd` is inside a git work tree. + * + * This is a cheap capability probe used to decide whether later git-aware + * filtering is worth attempting. + */ +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", + ); +} + +/** + * Filters repo-relative paths that match git ignore rules for `cwd`. + * + * We use `git check-ignore --no-index` so both tracked and untracked candidates + * respect the current ignore rules. Input is chunked to keep stdin bounded, and + * unexpected git failures return the original paths unchanged so callers fail + * open instead of dropping potentially valid files. + */ +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/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts index f2d44a7609..386b45e7bf 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -1,5 +1,9 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; +import path from "node:path"; +import { Effect, FileSystem, Layer, Option } from "effect"; +import * as PlatformError from "effect/PlatformError"; +import { filterGitIgnoredPaths, isInsideGitWorkTree } from "../../gitIgnore"; +import { isPathInIgnoredWorkspaceDirectory } from "../../workspaceIgnore"; import { ProjectFaviconResolver, type ProjectFaviconResolverShape, @@ -47,75 +51,316 @@ 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 { +type ExistingPathType = "File" | "Directory"; + +interface FaviconLookupServices { + fileSystem: FileSystem.FileSystem; + projectRoot: string; + filterAllowedPaths: (candidatePaths: readonly string[]) => Effect.Effect; +} + +function extractIconHref(source: string): Option.Option { 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; + if (htmlMatch?.[1]) { + return Option.some(htmlMatch[1]); + } + + const objectMatch = source.match(LINK_ICON_OBJ_RE); + if (objectMatch?.[1]) { + return Option.some(objectMatch[1]); + } + + return Option.none(); } -export const makeProjectFaviconResolver = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const resolveIconHref = (projectCwd: string, href: string): string[] => { - const clean = href.replace(/^\//, ""); - return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; - }; - - const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => { - const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); - }; - - const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( - projectCwd: string, - candidates: ReadonlyArray, - ): Effect.fn.Return { - for (const candidate of candidates) { - if (!isPathWithinProject(projectCwd, candidate)) { - continue; +function platformErrorToNone( + effect: Effect.Effect, +): Effect.Effect, never, R> { + return effect.pipe( + Effect.map(Option.some), + Effect.catchTag("PlatformError", () => Effect.succeed(Option.none())), + ); +} + +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 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(); + } + + const resolvedPath = resolvedPathOption.value; + 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, + }); + }); +} + +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.from( + new Set( + candidatePaths.flatMap((candidatePath) => + Option.match(toProjectRelativePath(projectRoot, candidatePath), { + onNone: () => [], + onSome: (relativePath) => + gitIgnorePathCache.has(relativePath) ? [] : [relativePath], + }), + ), + ), + ); + + if (uncachedRelativePaths.length > 0) { + 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)); + } } - const stats = yield* fileSystem - .stat(candidate) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (stats?.type === "File") { - return candidate; + + return candidatePaths.filter((candidatePath) => + Option.match(toProjectRelativePath(projectRoot, candidatePath), { + onNone: () => true, + onSome: (relativePath) => gitIgnorePathCache.get(relativePath) !== false, + }), + ); + }); +} + +function findFirstReadableFaviconPath( + 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(fileOption.value.path); } } - return null; + + return Option.none(); }); +} - const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( - "ProjectFaviconResolver.resolvePath", - )(function* (cwd: string): Effect.fn.Return { - for (const candidate of FAVICON_CANDIDATES) { - const resolved = path.join(cwd, candidate); - const existing = yield* findExistingFile(cwd, [resolved]); - if (existing) { - return existing; +function iconHrefCandidatePaths(searchRoot: string, href: string): string[] { + const cleanHref = href.replace(/^\//, ""); + return [path.join(searchRoot, "public", cleanHref), path.join(searchRoot, cleanHref)]; +} + +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 hrefOption = extractIconHref(sourceFileOption.value.content); + if (Option.isNone(hrefOption)) { + return Option.none(); + } + + return yield* findFirstReadableFaviconPath( + 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)), + ); + + for (const sourcePath of sourcePaths) { + const faviconPathOption = yield* findFaviconFromSourcePath(lookup, searchRoot, sourcePath); + if (Option.isSome(faviconPathOption)) { + return faviconPathOption; } } - for (const sourceFile of ICON_SOURCE_FILES) { - const sourcePath = path.join(cwd, sourceFile); - const source = yield* fileSystem - .readFileString(sourcePath) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!source) { + return Option.none(); + }); +} + +function findFaviconInSearchRoot(lookup: FaviconLookupServices, searchRoot: string) { + return Effect.gen(function* () { + const faviconPathOption = yield* findFirstReadableFaviconPath( + lookup, + FAVICON_CANDIDATES.map((candidate) => path.join(searchRoot, candidate)), + ); + if (Option.isSome(faviconPathOption)) { + return faviconPathOption; + } + + 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 directories: string[] = []; + for (const entry of entriesOption.value.toSorted((left, right) => left.localeCompare(right))) { + if (entry.length === 0 || entry.includes("/") || entry.includes("\\")) { continue; } - const href = extractIconHref(source); - if (!href) { + if (isPathInIgnoredWorkspaceDirectory(entry)) { continue; } - const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href)); - if (existing) { - return existing; + + const directoryPathOption = yield* resolveExistingPath( + lookup, + path.join(rootPath, entry), + "Directory", + ); + if (Option.isSome(directoryPathOption)) { + directories.push(directoryPathOption.value); } } + return directories; + }); +} + +function listCandidateSearchRoots(lookup: FaviconLookupServices) { + return Effect.gen(function* () { + 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)), + ); + + for (const searchRoot of searchRoots) { + const faviconPathOption = yield* findFaviconInSearchRoot(lookup, searchRoot); + if (Option.isSome(faviconPathOption)) { + return faviconPathOption; + } + } + + return Option.none(); + }); +} + +export const makeProjectFaviconResolver = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( + "ProjectFaviconResolver.resolvePath", + )(function* (cwd): Effect.fn.Return { + const projectRootOption = yield* platformErrorToNone(fileSystem.realPath(cwd)); + if (Option.isNone(projectRootOption)) { + return null; + } + + const projectRoot = projectRootOption.value; + const shouldFilterWithGitIgnore = yield* Effect.promise(() => + isInsideGitWorkTree(projectRoot).catch(() => false), + ); + const lookup = { + fileSystem, + projectRoot, + filterAllowedPaths: makeAllowedPathFilter(projectRoot, shouldFilterWithGitIgnore), + } satisfies FaviconLookupServices; + + const rootFaviconOption = yield* findFaviconInSearchRoot(lookup, projectRoot); + if (Option.isSome(rootFaviconOption)) { + return rootFaviconOption.value; + } + + const nestedFaviconOption = yield* findNestedFavicon(lookup); + if (Option.isSome(nestedFaviconOption)) { + return nestedFaviconOption.value; + } + return null; }); diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts new file mode 100644 index 0000000000..1353eacf5f --- /dev/null +++ b/apps/server/src/projectFaviconRoute.test.ts @@ -0,0 +1,292 @@ +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 { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; +import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; + +interface HttpResponse { + statusCode: number; + contentType: string | null; + body: string; +} + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + 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"); + void Effect.runPromise( + tryHandleProjectFaviconRequest(url, res).pipe( + Effect.provide(ProjectFaviconResolverLive), + Effect.provide(NodeServices.layer), + Effect.flatMap((handled) => + handled + ? Effect.void + : Effect.sync(() => { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + }), + ), + ), + ).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) => { + server.listen(0, "127.0.0.1", (error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + const address = server.address(); + if (typeof address !== "object" || address === null) { + throw new Error("Expected server address to be an object"); + } + const baseUrl = `http://127.0.0.1:${address.port}`; + + try { + await run(baseUrl); + } finally { + await new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +async function request(baseUrl: string, pathname: string): Promise { + const response = await fetch(`${baseUrl}${pathname}`); + return { + statusCode: response.status, + contentType: response.headers.get("content-type"), + body: await response.text(), + }; +} + +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)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns 400 when cwd is missing", async () => { + await withRouteServer(async (baseUrl) => { + const response = await request(baseUrl, "/api/project-favicon"); + expect(response.statusCode).toBe(400); + expect(response.body).toBe("Missing cwd parameter"); + }); + }); + + it("serves a well-known favicon file from the project root", async () => { + const projectDir = makeTempDir("t3code-favicon-route-root-"); + writeFile(path.join(projectDir, "favicon.svg"), "favicon"); + + await withRouteServer(async (baseUrl) => { + expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "favicon"); + }); + }); + + 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"), + '', + ); + writeFile( + path.join(projectDir, "apps", "frontend", "public", "brand", "logo.svg"), + "nested-app", + ); + + await withRouteServer(async (baseUrl) => { + expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "nested-app"); + }); + }); + + 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", + ); + + await withRouteServer(async (baseUrl) => { + expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir)); + }); + }); + + 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", + ); + + await withRouteServer(async (baseUrl) => { + expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "nested-kept"); + }); + }); + + 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) => { + expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir)); + }); + }); +}); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts new file mode 100644 index 0000000000..fa109b2ebd --- /dev/null +++ b/apps/server/src/workspaceEntries.ts @@ -0,0 +1,453 @@ +import fs from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import path from "node:path"; +import { runProcess } from "./processRunner"; + +import { + ProjectEntry, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, +} from "@t3tools/contracts"; +import { filterGitIgnoredPaths, isInsideGitWorkTree, splitNullSeparatedPaths } 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; +interface WorkspaceIndex { + scannedAt: number; + entries: SearchableWorkspaceEntry[]; + truncated: boolean; +} + +interface SearchableWorkspaceEntry extends ProjectEntry { + normalizedPath: string; + normalizedName: string; +} + +interface RankedWorkspaceEntry { + entry: SearchableWorkspaceEntry; + score: number; +} + +const workspaceIndexCache = new Map(); +const inFlightWorkspaceIndexBuilds = new Map>(); + +function toPosixPath(input: string): string { + return input.split(path.sep).join("/"); +} + +function parentPathOf(input: string): string | undefined { + const separatorIndex = input.lastIndexOf("/"); + if (separatorIndex === -1) { + return undefined; + } + return input.slice(0, separatorIndex); +} + +function basenameOf(input: string): string { + const separatorIndex = input.lastIndexOf("/"); + if (separatorIndex === -1) { + return input; + } + return input.slice(separatorIndex + 1); +} + +function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry { + const normalizedPath = entry.path.toLowerCase(); + return { + ...entry, + normalizedPath, + normalizedName: basenameOf(normalizedPath), + }; +} + +function normalizeQuery(input: string): string { + return input + .trim() + .replace(/^[@./]+/, "") + .toLowerCase(); +} + +function scoreSubsequenceMatch(value: string, query: string): number | null { + if (!query) return 0; + + let queryIndex = 0; + let firstMatchIndex = -1; + let previousMatchIndex = -1; + let gapPenalty = 0; + + for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) { + if (value[valueIndex] !== query[queryIndex]) { + continue; + } + + if (firstMatchIndex === -1) { + firstMatchIndex = valueIndex; + } + if (previousMatchIndex !== -1) { + gapPenalty += valueIndex - previousMatchIndex - 1; + } + + previousMatchIndex = valueIndex; + queryIndex += 1; + if (queryIndex === query.length) { + const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length; + const lengthPenalty = Math.min(64, value.length - query.length); + return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty; + } + } + + return null; +} + +function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { + if (!query) { + return entry.kind === "directory" ? 0 : 1; + } + + const { normalizedPath, normalizedName } = entry; + + if (normalizedName === query) return 0; + if (normalizedPath === query) return 1; + if (normalizedName.startsWith(query)) return 2; + if (normalizedPath.startsWith(query)) return 3; + if (normalizedPath.includes(`/${query}`)) return 4; + if (normalizedName.includes(query)) return 5; + if (normalizedPath.includes(query)) return 6; + + const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query); + if (nameFuzzyScore !== null) { + return 100 + nameFuzzyScore; + } + + const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query); + if (pathFuzzyScore !== null) { + return 200 + pathFuzzyScore; + } + + return null; +} + +function compareRankedWorkspaceEntries( + left: RankedWorkspaceEntry, + right: RankedWorkspaceEntry, +): number { + const scoreDelta = left.score - right.score; + if (scoreDelta !== 0) return scoreDelta; + return left.entry.path.localeCompare(right.entry.path); +} + +function findInsertionIndex( + rankedEntries: RankedWorkspaceEntry[], + candidate: RankedWorkspaceEntry, +): number { + let low = 0; + let high = rankedEntries.length; + + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + const current = rankedEntries[middle]; + if (!current) { + break; + } + + if (compareRankedWorkspaceEntries(candidate, current) < 0) { + high = middle; + } else { + low = middle + 1; + } + } + + return low; +} + +function insertRankedEntry( + rankedEntries: RankedWorkspaceEntry[], + candidate: RankedWorkspaceEntry, + limit: number, +): void { + if (limit <= 0) { + return; + } + + const insertionIndex = findInsertionIndex(rankedEntries, candidate); + if (rankedEntries.length < limit) { + rankedEntries.splice(insertionIndex, 0, candidate); + return; + } + + if (insertionIndex >= limit) { + return; + } + + rankedEntries.splice(insertionIndex, 0, candidate); + rankedEntries.pop(); +} + +function directoryAncestorsOf(relativePath: string): string[] { + const segments = relativePath.split("/").filter((segment) => segment.length > 0); + if (segments.length <= 1) return []; + const directories: string[] = []; + for (let index = 1; index < segments.length; index += 1) { + directories.push(segments.slice(0, index).join("/")); + } + return directories; +} + +async function mapWithConcurrency( + items: readonly TInput[], + concurrency: number, + mapper: (item: TInput, index: number) => Promise, +): Promise { + if (items.length === 0) { + return []; + } + + const boundedConcurrency = Math.max(1, Math.min(concurrency, items.length)); + const results = Array.from({ length: items.length }) as TOutput[]; + let nextIndex = 0; + + const workers = Array.from({ length: boundedConcurrency }, async () => { + while (nextIndex < items.length) { + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await mapper(items[currentIndex] as TInput, currentIndex); + } + }); + + await Promise.all(workers); + return results; +} + +async function buildWorkspaceIndexFromGit(cwd: string): Promise { + if (!(await isInsideGitWorkTree(cwd))) { + return null; + } + + const listedFiles = await runProcess( + "git", + ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], + { + cwd, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxBufferBytes: 16 * 1024 * 1024, + outputMode: "truncate", + }, + ).catch(() => null); + if (!listedFiles || listedFiles.code !== 0) { + return null; + } + + const listedPaths = splitNullSeparatedPaths( + listedFiles.stdout, + Boolean(listedFiles.stdoutTruncated), + ) + .map((entry) => toPosixPath(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 (!isPathInIgnoredWorkspaceDirectory(directoryPath)) { + directorySet.add(directoryPath); + } + } + } + + const directoryEntries = [...directorySet] + .toSorted((left, right) => left.localeCompare(right)) + .map( + (directoryPath): ProjectEntry => ({ + path: directoryPath, + kind: "directory", + parentPath: parentPathOf(directoryPath), + }), + ) + .map(toSearchableWorkspaceEntry); + const fileEntries = [...new Set(filePaths)] + .toSorted((left, right) => left.localeCompare(right)) + .map( + (filePath): ProjectEntry => ({ + path: filePath, + kind: "file", + parentPath: parentPathOf(filePath), + }), + ) + .map(toSearchableWorkspaceEntry); + + const entries = [...directoryEntries, ...fileEntries]; + return { + scannedAt: Date.now(), + entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), + truncated: Boolean(listedFiles.stdoutTruncated) || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, + }; +} + +async function buildWorkspaceIndex(cwd: string): Promise { + const gitIndexed = await buildWorkspaceIndexFromGit(cwd); + if (gitIndexed) { + return gitIndexed; + } + const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd); + + let pendingDirectories: string[] = [""]; + const entries: SearchableWorkspaceEntry[] = []; + let truncated = false; + + while (pendingDirectories.length > 0 && !truncated) { + const currentDirectories = pendingDirectories; + pendingDirectories = []; + const directoryEntries = await mapWithConcurrency( + currentDirectories, + WORKSPACE_SCAN_READDIR_CONCURRENCY, + async (relativeDir) => { + const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; + try { + const dirents = await fs.readdir(absoluteDir, { + withFileTypes: true, + }); + return { relativeDir, dirents }; + } catch (error) { + if (!relativeDir) { + throw new Error( + `Unable to scan workspace entries at '${cwd}': ${error instanceof Error ? error.message : "unknown error"}`, + { cause: error }, + ); + } + return { relativeDir, dirents: null }; + } + }, + ); + + const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { + const { relativeDir, dirents } = directoryEntry; + if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>; + + dirents.sort((left, right) => left.name.localeCompare(right.name)); + const candidates: Array<{ dirent: Dirent; relativePath: string }> = []; + for (const dirent of dirents) { + if (!dirent.name || dirent.name === "." || dirent.name === "..") { + continue; + } + if (dirent.isDirectory() && isPathInIgnoredWorkspaceDirectory(dirent.name)) { + continue; + } + if (!dirent.isDirectory() && !dirent.isFile()) { + continue; + } + + const relativePath = toPosixPath( + relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, + ); + if (isPathInIgnoredWorkspaceDirectory(relativePath)) { + continue; + } + candidates.push({ dirent, relativePath }); + } + return candidates; + }); + + const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) => + candidateEntries.map((entry) => entry.relativePath), + ); + const allowedPathSet = shouldFilterWithGitIgnore + ? new Set(await filterGitIgnoredPaths(cwd, candidatePaths)) + : null; + + for (const candidateEntries of candidateEntriesByDirectory) { + for (const candidate of candidateEntries) { + if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) { + continue; + } + + const entry = toSearchableWorkspaceEntry({ + path: candidate.relativePath, + kind: candidate.dirent.isDirectory() ? "directory" : "file", + parentPath: parentPathOf(candidate.relativePath), + }); + entries.push(entry); + + if (candidate.dirent.isDirectory()) { + pendingDirectories.push(candidate.relativePath); + } + + if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) { + truncated = true; + break; + } + } + + if (truncated) { + break; + } + } + } + + return { + scannedAt: Date.now(), + entries, + truncated, + }; +} + +async function getWorkspaceIndex(cwd: string): Promise { + const cached = workspaceIndexCache.get(cwd); + if (cached && Date.now() - cached.scannedAt < WORKSPACE_CACHE_TTL_MS) { + return cached; + } + + const inFlight = inFlightWorkspaceIndexBuilds.get(cwd); + if (inFlight) { + return inFlight; + } + + const nextPromise = buildWorkspaceIndex(cwd) + .then((next) => { + workspaceIndexCache.set(cwd, next); + while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) { + const oldestKey = workspaceIndexCache.keys().next().value; + if (!oldestKey) break; + workspaceIndexCache.delete(oldestKey); + } + return next; + }) + .finally(() => { + inFlightWorkspaceIndexBuilds.delete(cwd); + }); + inFlightWorkspaceIndexBuilds.set(cwd, nextPromise); + return nextPromise; +} + +export function clearWorkspaceIndexCache(cwd: string): void { + workspaceIndexCache.delete(cwd); + inFlightWorkspaceIndexBuilds.delete(cwd); +} + +export async function searchWorkspaceEntries( + input: ProjectSearchEntriesInput, +): Promise { + const index = await getWorkspaceIndex(input.cwd); + const normalizedQuery = normalizeQuery(input.query); + const limit = Math.max(0, Math.floor(input.limit)); + const rankedEntries: RankedWorkspaceEntry[] = []; + let matchedEntryCount = 0; + + for (const entry of index.entries) { + const score = scoreEntry(entry, normalizedQuery); + if (score === null) { + continue; + } + + matchedEntryCount += 1; + insertRankedEntry(rankedEntries, { entry, score }, limit); + } + + return { + entries: rankedEntries.map((candidate) => candidate.entry), + truncated: index.truncated || matchedEntryCount > limit, + }; +} diff --git a/apps/server/src/workspaceIgnore.ts b/apps/server/src/workspaceIgnore.ts new file mode 100644 index 0000000000..f63bf78cad --- /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 new file mode 100644 index 0000000000..641416484e --- /dev/null +++ b/apps/server/src/wsServer.ts @@ -0,0 +1,999 @@ +/** + * Server - HTTP/WebSocket server service interface. + * + * Owns startup and shutdown lifecycle of the HTTP server, static asset serving, + * and WebSocket request routing. + * + * @module Server + */ + import http from "node:http"; + import type { Duplex } from "node:stream"; + + import Mime from "@effect/platform-node/Mime"; + import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + type ClientOrchestrationCommand, + type OrchestrationCommand, + ORCHESTRATION_WS_CHANNELS, + ORCHESTRATION_WS_METHODS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + ProjectId, + ThreadId, + WS_CHANNELS, + WS_METHODS, + WebSocketRequest, + type WsResponse as WsResponseMessage, + WsResponse, + type WsPushEnvelopeBase, + } from "@t3tools/contracts"; + import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; + import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Path, + Ref, + Result, + Schema, + Scope, + ServiceMap, + Stream, + Struct, + } from "effect"; + import { WebSocketServer, type WebSocket } from "ws"; + + import { createLogger } from "./logger"; + import { GitManager } from "./git/Services/GitManager.ts"; + import { TerminalManager } from "./terminal/Services/Manager.ts"; + import { Keybindings } from "./keybindings"; + import { ServerSettingsService } from "./serverSettings"; + import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; + import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; + import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; + import { ProviderService } from "./provider/Services/ProviderService"; + import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; + import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; + import { clamp } from "effect/Number"; + import { Open, resolveAvailableEditors } from "./open"; + import { ServerConfig } from "./config"; + import { GitCore } from "./git/Services/GitCore.ts"; + import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; + import { + ATTACHMENTS_ROUTE_PREFIX, + normalizeAttachmentRelativePath, + resolveAttachmentRelativePath, + } from "./attachmentPaths"; + + import { + createAttachmentId, + resolveAttachmentPath, + resolveAttachmentPathById, + } from "./attachmentStore.ts"; + import { parseBase64DataUrl } from "./imageMime.ts"; + import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; + import { makeServerPushBus } from "./wsServer/pushBus.ts"; + import { makeServerReadiness } from "./wsServer/readiness.ts"; + import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; + import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; + import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; + import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; + import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; + + /** + * ServerShape - Service API for server lifecycle control. + */ + export interface ServerShape { + /** + * Start HTTP and WebSocket listeners. + */ + readonly start: Effect.Effect< + http.Server, + ServerLifecycleError, + Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + >; + + /** + * Wait for process shutdown signals. + */ + readonly stopSignal: Effect.Effect; + } + + /** + * Server - Service tag for HTTP/WebSocket lifecycle management. + */ + export class Server extends ServiceMap.Service()("t3/wsServer/Server") {} + + const isServerNotRunningError = (error: Error): boolean => { + const maybeCode = (error as NodeJS.ErrnoException).code; + return ( + maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running") + ); + }; + + function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { + socket.end( + `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + + "Connection: close\r\n" + + "Content-Type: text/plain\r\n" + + `Content-Length: ${Buffer.byteLength(message)}\r\n` + + "\r\n" + + message, + ); + } + + function websocketRawToString(raw: unknown): string | null { + if (typeof raw === "string") { + return raw; + } + if (raw instanceof Uint8Array) { + return Buffer.from(raw).toString("utf8"); + } + if (raw instanceof ArrayBuffer) { + return Buffer.from(new Uint8Array(raw)).toString("utf8"); + } + if (Array.isArray(raw)) { + const chunks: string[] = []; + for (const chunk of raw) { + if (typeof chunk === "string") { + chunks.push(chunk); + continue; + } + if (chunk instanceof Uint8Array) { + chunks.push(Buffer.from(chunk).toString("utf8")); + continue; + } + if (chunk instanceof ArrayBuffer) { + chunks.push(Buffer.from(new Uint8Array(chunk)).toString("utf8")); + continue; + } + return null; + } + return chunks.join(""); + } + return null; + } + + function stripRequestTag(body: T) { + return Struct.omit(body, ["_tag"]); + } + + const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse)); + const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest); + + export type ServerCoreRuntimeServices = + | OrchestrationEngineService + | ProjectionSnapshotQuery + | CheckpointDiffQuery + | OrchestrationReactor + | ProviderService + | ProviderRegistry; + + export type ServerRuntimeServices = + | ServerCoreRuntimeServices + | GitManager + | GitCore + | TerminalManager + | Keybindings + | ServerSettingsService + | ProjectFaviconResolver + | WorkspaceEntries + | WorkspaceFileSystem + | WorkspacePaths + | Open + | AnalyticsService; + + export class ServerLifecycleError extends Schema.TaggedErrorClass()( + "ServerLifecycleError", + { + operation: Schema.String, + cause: Schema.optional(Schema.Defect), + }, + ) {} + + class RouteRequestError extends Schema.TaggedErrorClass()("RouteRequestError", { + message: Schema.String, + }) {} + + export const createServer = Effect.fn(function* (): Effect.fn.Return< + http.Server, + ServerLifecycleError, + Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + > { + const serverConfig = yield* ServerConfig; + const { + port, + cwd, + keybindingsConfigPath, + staticDir, + devUrl, + authToken, + host, + logWebSocketEvents, + autoBootstrapProjectFromCwd, + } = serverConfig; + const availableEditors = resolveAvailableEditors(); + + const runtimeServices = yield* Effect.services< + ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + >(); + const runPromise = Effect.runPromiseWith(runtimeServices); + + const gitManager = yield* GitManager; + const terminalManager = yield* TerminalManager; + const keybindingsManager = yield* Keybindings; + const serverSettingsManager = yield* ServerSettingsService; + const providerRegistry = yield* ProviderRegistry; + const git = yield* GitCore; + const workspaceEntries = yield* WorkspaceEntries; + const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspacePaths = yield* WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( + Effect.catch((error) => + Effect.logWarning("failed to sync keybindings defaults on startup", { + path: error.configPath, + detail: error.detail, + cause: error.cause, + }), + ), + ); + + const providersRef = yield* Ref.make(yield* providerRegistry.getProviders); + + const clients = yield* Ref.make(new Set()); + const logger = createLogger("ws"); + const readiness = yield* makeServerReadiness; + + function logOutgoingPush(push: WsPushEnvelopeBase, recipients: number) { + if (!logWebSocketEvents) return; + logger.event("outgoing push", { + channel: push.channel, + sequence: push.sequence, + recipients, + payload: push.data, + }); + } + + const pushBus = yield* makeServerPushBus({ + clients, + logOutgoingPush, + }); + yield* readiness.markPushBusReady; + yield* keybindingsManager.start.pipe( + Effect.mapError( + (cause) => new ServerLifecycleError({ operation: "keybindingsRuntimeStart", cause }), + ), + ); + yield* readiness.markKeybindingsReady; + yield* serverSettingsManager.start.pipe( + Effect.mapError( + (cause) => new ServerLifecycleError({ operation: "serverSettingsRuntimeStart", cause }), + ), + ); + + const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { + readonly command: ClientOrchestrationCommand; + }) { + if (input.command.type === "project.create") { + return { + ...input.command, + workspaceRoot: yield* workspacePaths + .normalizeWorkspaceRoot(input.command.workspaceRoot) + .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))), + } satisfies OrchestrationCommand; + } + + if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) { + return { + ...input.command, + workspaceRoot: yield* workspacePaths + .normalizeWorkspaceRoot(input.command.workspaceRoot) + .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))), + } satisfies OrchestrationCommand; + } + + if (input.command.type !== "thread.turn.start") { + return input.command as OrchestrationCommand; + } + const turnStartCommand = input.command; + + const normalizedAttachments = yield* Effect.forEach( + turnStartCommand.message.attachments, + (attachment) => + Effect.gen(function* () { + const parsed = parseBase64DataUrl(attachment.dataUrl); + if (!parsed || !parsed.mimeType.startsWith("image/")) { + return yield* new RouteRequestError({ + message: `Invalid image attachment payload for '${attachment.name}'.`, + }); + } + + const bytes = Buffer.from(parsed.base64, "base64"); + if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + return yield* new RouteRequestError({ + message: `Image attachment '${attachment.name}' is empty or too large.`, + }); + } + + const attachmentId = createAttachmentId(turnStartCommand.threadId); + if (!attachmentId) { + return yield* new RouteRequestError({ + message: "Failed to create a safe attachment id.", + }); + } + + const persistedAttachment = { + type: "image" as const, + id: attachmentId, + name: attachment.name, + mimeType: parsed.mimeType.toLowerCase(), + sizeBytes: bytes.byteLength, + }; + + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment: persistedAttachment, + }); + if (!attachmentPath) { + return yield* new RouteRequestError({ + message: `Failed to resolve persisted path for '${attachment.name}'.`, + }); + } + + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( + Effect.mapError( + () => + new RouteRequestError({ + message: `Failed to create attachment directory for '${attachment.name}'.`, + }), + ), + ); + yield* fileSystem.writeFile(attachmentPath, bytes).pipe( + Effect.mapError( + () => + new RouteRequestError({ + message: `Failed to persist attachment '${attachment.name}'.`, + }), + ), + ); + + return persistedAttachment; + }), + { concurrency: 1 }, + ); + + return { + ...turnStartCommand, + message: { + ...turnStartCommand.message, + attachments: normalizedAttachments, + }, + } satisfies OrchestrationCommand; + }); + + // HTTP server — serves static files or redirects to Vite dev server + const httpServer = http.createServer((req, res) => { + const respond = ( + statusCode: number, + headers: Record, + body?: string | Uint8Array, + ) => { + res.writeHead(statusCode, headers); + res.end(body); + }; + + void runPromise( + Effect.gen(function* () { + const url = new URL(req.url ?? "/", `http://localhost:${port}`); + if (yield* tryHandleProjectFaviconRequest(url, res)) { + return; + } + + if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { + const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); + const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); + if (!normalizedRelativePath) { + respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); + return; + } + + const isIdLookup = + !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); + const filePath = isIdLookup + ? resolveAttachmentPathById({ + attachmentsDir: serverConfig.attachmentsDir, + attachmentId: normalizedRelativePath, + }) + : resolveAttachmentRelativePath({ + attachmentsDir: serverConfig.attachmentsDir, + relativePath: normalizedRelativePath, + }); + if (!filePath) { + respond( + isIdLookup ? 404 : 400, + { "Content-Type": "text/plain" }, + isIdLookup ? "Not Found" : "Invalid attachment path", + ); + return; + } + + const fileInfo = yield* fileSystem + .stat(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + + const contentType = Mime.getType(filePath) ?? "application/octet-stream"; + res.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }); + const streamExit = yield* Stream.runForEach(fileSystem.stream(filePath), (chunk) => + Effect.sync(() => { + if (!res.destroyed) { + res.write(chunk); + } + }), + ).pipe(Effect.exit); + if (Exit.isFailure(streamExit)) { + if (!res.destroyed) { + res.destroy(); + } + return; + } + if (!res.writableEnded) { + res.end(); + } + return; + } + + // In dev mode, redirect to Vite dev server + if (devUrl) { + respond(302, { Location: devUrl.href }); + return; + } + + // Serve static files from the web app build + if (!staticDir) { + respond( + 503, + { "Content-Type": "text/plain" }, + "No static directory configured and no dev URL set.", + ); + return; + } + + const staticRoot = path.resolve(staticDir); + const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname; + const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); + const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); + const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); + const hasPathTraversalSegment = staticRelativePath.startsWith(".."); + if ( + staticRelativePath.length === 0 || + hasRawLeadingParentSegment || + hasPathTraversalSegment || + staticRelativePath.includes("\0") + ) { + respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); + return; + } + + const isWithinStaticRoot = (candidate: string) => + candidate === staticRoot || + candidate.startsWith( + staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`, + ); + + let filePath = path.resolve(staticRoot, staticRelativePath); + if (!isWithinStaticRoot(filePath)) { + respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); + return; + } + + const ext = path.extname(filePath); + if (!ext) { + filePath = path.resolve(filePath, "index.html"); + if (!isWithinStaticRoot(filePath)) { + respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); + return; + } + } + + const fileInfo = yield* fileSystem + .stat(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + const indexPath = path.resolve(staticRoot, "index.html"); + const indexData = yield* fileSystem + .readFile(indexPath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!indexData) { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + respond(200, { "Content-Type": "text/html; charset=utf-8" }, indexData); + return; + } + + const contentType = Mime.getType(filePath) ?? "application/octet-stream"; + const data = yield* fileSystem + .readFile(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!data) { + respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); + 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"); + } + }); + }); + + // WebSocket server — upgrades from the HTTP server + const wss = new WebSocketServer({ noServer: true }); + + const closeWebSocketServer = Effect.callback((resume) => { + wss.close((error) => { + if (error && !isServerNotRunningError(error)) { + resume( + Effect.fail( + new ServerLifecycleError({ operation: "closeWebSocketServer", cause: error }), + ), + ); + } else { + resume(Effect.void); + } + }); + }); + + const closeAllClients = Ref.get(clients).pipe( + Effect.flatMap(Effect.forEach((client) => Effect.sync(() => client.close()))), + Effect.flatMap(() => Ref.set(clients, new Set())), + ); + + const listenOptions = host ? { host, port } : { port }; + + const orchestrationEngine = yield* OrchestrationEngineService; + const projectionReadModelQuery = yield* ProjectionSnapshotQuery; + const checkpointDiffQuery = yield* CheckpointDiffQuery; + const orchestrationReactor = yield* OrchestrationReactor; + const { openInEditor } = yield* Open; + + const subscriptionsScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); + + yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => + pushBus.publishAll(ORCHESTRATION_WS_CHANNELS.domainEvent, event), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(keybindingsManager.streamChanges, (event) => + pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: event.issues, + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(serverSettingsManager.streamChanges, (settings) => + pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: [], + settings, + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(providerRegistry.streamChanges, (providers) => + Effect.gen(function* () { + yield* Ref.set(providersRef, providers); + yield* pushBus.publishAll(WS_CHANNELS.serverProvidersUpdated, { + providers, + }); + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Scope.provide(orchestrationReactor.start(), subscriptionsScope); + yield* readiness.markOrchestrationSubscriptionsReady; + + let welcomeBootstrapProjectId: ProjectId | undefined; + let welcomeBootstrapThreadId: ThreadId | undefined; + + if (autoBootstrapProjectFromCwd) { + yield* Effect.gen(function* () { + const snapshot = yield* projectionReadModelQuery.getSnapshot(); + const existingProject = snapshot.projects.find( + (project) => project.workspaceRoot === cwd && project.deletedAt === null, + ); + let bootstrapProjectId: ProjectId; + let bootstrapProjectDefaultModelSelection; + + if (!existingProject) { + const createdAt = new Date().toISOString(); + bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); + const bootstrapProjectTitle = path.basename(cwd) || "project"; + bootstrapProjectDefaultModelSelection = { + provider: "codex" as const, + model: "gpt-5-codex", + }; + yield* orchestrationEngine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe(crypto.randomUUID()), + projectId: bootstrapProjectId, + title: bootstrapProjectTitle, + workspaceRoot: cwd, + defaultModelSelection: bootstrapProjectDefaultModelSelection, + createdAt, + }); + } else { + bootstrapProjectId = existingProject.id; + bootstrapProjectDefaultModelSelection = existingProject.defaultModelSelection ?? { + provider: "codex" as const, + model: "gpt-5-codex", + }; + } + + const existingThread = snapshot.threads.find( + (thread) => thread.projectId === bootstrapProjectId && thread.deletedAt === null, + ); + if (!existingThread) { + const createdAt = new Date().toISOString(); + const threadId = ThreadId.makeUnsafe(crypto.randomUUID()); + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe(crypto.randomUUID()), + threadId, + projectId: bootstrapProjectId, + title: "New thread", + modelSelection: bootstrapProjectDefaultModelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + welcomeBootstrapProjectId = bootstrapProjectId; + welcomeBootstrapThreadId = threadId; + } else { + welcomeBootstrapProjectId = bootstrapProjectId; + welcomeBootstrapThreadId = existingThread.id; + } + }).pipe( + Effect.mapError( + (cause) => new ServerLifecycleError({ operation: "autoBootstrapProject", cause }), + ), + ); + } + + const unsubscribeTerminalEvents = yield* terminalManager.subscribe((event) => + pushBus.publishAll(WS_CHANNELS.terminalEvent, event), + ); + yield* Scope.addFinalizer(subscriptionsScope, Effect.sync(unsubscribeTerminalEvents)); + yield* readiness.markTerminalSubscriptionsReady; + + yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( + Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), + ); + yield* readiness.markHttpListening; + + yield* Effect.addFinalizer(() => + Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]), + ); + + const routeRequest = Effect.fnUntraced(function* (ws: WebSocket, request: WebSocketRequest) { + switch (request.body._tag) { + case ORCHESTRATION_WS_METHODS.getSnapshot: + return yield* projectionReadModelQuery.getSnapshot(); + + case ORCHESTRATION_WS_METHODS.dispatchCommand: { + const { command } = request.body; + const normalizedCommand = yield* normalizeDispatchCommand({ command }); + return yield* orchestrationEngine.dispatch(normalizedCommand); + } + + case ORCHESTRATION_WS_METHODS.getTurnDiff: { + const body = stripRequestTag(request.body); + return yield* checkpointDiffQuery.getTurnDiff(body); + } + + case ORCHESTRATION_WS_METHODS.getFullThreadDiff: { + const body = stripRequestTag(request.body); + return yield* checkpointDiffQuery.getFullThreadDiff(body); + } + + case ORCHESTRATION_WS_METHODS.replayEvents: { + const { fromSequenceExclusive } = request.body; + return yield* Stream.runCollect( + orchestrationEngine.readEvents( + clamp(fromSequenceExclusive, { + maximum: Number.MAX_SAFE_INTEGER, + minimum: 0, + }), + ), + ).pipe(Effect.map((events) => Array.from(events))); + } + + case WS_METHODS.projectsSearchEntries: { + const body = stripRequestTag(request.body); + return yield* workspaceEntries.search(body).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to search workspace entries: ${cause.detail}`, + }), + ), + ); + } + + case WS_METHODS.projectsWriteFile: { + const body = stripRequestTag(request.body); + return yield* workspaceFileSystem.writeFile(body).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to write workspace file: ${cause.message}`, + }), + ), + ); + } + + case WS_METHODS.shellOpenInEditor: { + const body = stripRequestTag(request.body); + return yield* openInEditor(body); + } + + case WS_METHODS.gitStatus: { + const body = stripRequestTag(request.body); + return yield* gitManager.status(body); + } + + case WS_METHODS.gitPull: { + const body = stripRequestTag(request.body); + return yield* git.pullCurrentBranch(body.cwd); + } + + case WS_METHODS.gitRunStackedAction: { + const body = stripRequestTag(request.body); + return yield* gitManager.runStackedAction(body, { + actionId: body.actionId, + progressReporter: { + publish: (event) => + pushBus.publishClient(ws, WS_CHANNELS.gitActionProgress, event).pipe(Effect.asVoid), + }, + }); + } + + case WS_METHODS.gitResolvePullRequest: { + const body = stripRequestTag(request.body); + return yield* gitManager.resolvePullRequest(body); + } + + case WS_METHODS.gitPreparePullRequestThread: { + const body = stripRequestTag(request.body); + return yield* gitManager.preparePullRequestThread(body); + } + + case WS_METHODS.gitListBranches: { + const body = stripRequestTag(request.body); + return yield* git.listBranches(body); + } + + case WS_METHODS.gitCreateWorktree: { + const body = stripRequestTag(request.body); + return yield* git.createWorktree(body); + } + + case WS_METHODS.gitRemoveWorktree: { + const body = stripRequestTag(request.body); + return yield* git.removeWorktree(body); + } + + case WS_METHODS.gitCreateBranch: { + const body = stripRequestTag(request.body); + return yield* git.createBranch(body); + } + + case WS_METHODS.gitCheckout: { + const body = stripRequestTag(request.body); + return yield* Effect.scoped(git.checkoutBranch(body)); + } + + case WS_METHODS.gitInit: { + const body = stripRequestTag(request.body); + return yield* git.initRepo(body); + } + + case WS_METHODS.terminalOpen: { + const body = stripRequestTag(request.body); + return yield* terminalManager.open(body); + } + + case WS_METHODS.terminalWrite: { + const body = stripRequestTag(request.body); + return yield* terminalManager.write(body); + } + + case WS_METHODS.terminalResize: { + const body = stripRequestTag(request.body); + return yield* terminalManager.resize(body); + } + + case WS_METHODS.terminalClear: { + const body = stripRequestTag(request.body); + return yield* terminalManager.clear(body); + } + + case WS_METHODS.terminalRestart: { + const body = stripRequestTag(request.body); + return yield* terminalManager.restart(body); + } + + case WS_METHODS.terminalClose: { + const body = stripRequestTag(request.body); + return yield* terminalManager.close(body); + } + + case WS_METHODS.serverGetConfig: { + const keybindingsConfig = yield* keybindingsManager.loadConfigState; + const settings = yield* serverSettingsManager.getSettings; + const providers = yield* Ref.get(providersRef); + return { + cwd, + keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors, + settings, + }; + } + + case WS_METHODS.serverRefreshProviders: { + const providers = yield* providerRegistry.refresh(); + yield* Ref.set(providersRef, providers); + return { providers }; + } + + case WS_METHODS.serverUpsertKeybinding: { + const body = stripRequestTag(request.body); + const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); + return { keybindings: keybindingsConfig, issues: [] }; + } + + case WS_METHODS.serverGetSettings: { + return yield* serverSettingsManager.getSettings; + } + + case WS_METHODS.serverUpdateSettings: { + const body = stripRequestTag(request.body); + return yield* serverSettingsManager.updateSettings(body.patch); + } + + default: { + const _exhaustiveCheck: never = request.body; + return yield* new RouteRequestError({ + message: `Unknown method: ${String(_exhaustiveCheck)}`, + }); + } + } + }); + + const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) { + const sendWsResponse = (response: WsResponseMessage) => + encodeWsResponse(response).pipe( + Effect.tap((encodedResponse) => Effect.sync(() => ws.send(encodedResponse))), + Effect.asVoid, + ); + + const messageText = websocketRawToString(raw); + if (messageText === null) { + return yield* sendWsResponse({ + id: "unknown", + error: { message: "Invalid request format: Failed to read message" }, + }); + } + + const request = decodeWebSocketRequest(messageText); + if (Result.isFailure(request)) { + return yield* sendWsResponse({ + id: "unknown", + error: { message: `Invalid request format: ${formatSchemaError(request.failure)}` }, + }); + } + + const result = yield* Effect.exit(routeRequest(ws, request.success)); + if (Exit.isFailure(result)) { + return yield* sendWsResponse({ + id: request.success.id, + error: { message: Cause.pretty(result.cause) }, + }); + } + + return yield* sendWsResponse({ + id: request.success.id, + result: result.value, + }); + }); + + httpServer.on("upgrade", (request, socket, head) => { + socket.on("error", () => {}); // Prevent unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake + + if (authToken) { + let providedToken: string | null = null; + try { + const url = new URL(request.url ?? "/", `http://localhost:${port}`); + providedToken = url.searchParams.get("token"); + } catch { + rejectUpgrade(socket, 400, "Invalid WebSocket URL"); + return; + } + + if (providedToken !== authToken) { + rejectUpgrade(socket, 401, "Unauthorized WebSocket connection"); + return; + } + } + + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request); + }); + }); + + wss.on("connection", (ws) => { + const segments = cwd.split(/[/\\]/).filter(Boolean); + const projectName = segments[segments.length - 1] ?? "project"; + + const welcomeData = { + cwd, + projectName, + ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}), + ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}), + }; + // Send welcome before adding to broadcast set so publishAll calls + // cannot reach this client before the welcome arrives. + void runPromise( + readiness.awaitServerReady.pipe( + Effect.flatMap(() => pushBus.publishClient(ws, WS_CHANNELS.serverWelcome, welcomeData)), + Effect.flatMap((delivered) => + delivered ? Ref.update(clients, (clients) => clients.add(ws)) : Effect.void, + ), + ), + ); + + ws.on("message", (raw) => { + void runPromise(handleMessage(ws, raw).pipe(Effect.ignoreCause({ log: true }))); + }); + + ws.on("close", () => { + void runPromise( + Ref.update(clients, (clients) => { + clients.delete(ws); + return clients; + }), + ); + }); + + ws.on("error", () => { + void runPromise( + Ref.update(clients, (clients) => { + clients.delete(ws); + return clients; + }), + ); + }); + }); + + return httpServer; + }); + +export const ServerLive = Layer.succeed(Server, { + start: createServer(), + stopSignal: Effect.never, +} satisfies ServerShape);