diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index f790e71f5cd..2f8f8c4dcaf 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -11,7 +11,24 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; -import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; +import { + ASSET_ROUTE_PREFIX, + FALLBACK_PROJECT_FAVICON_SVG, + issueAssetUrl, + resolveAsset, +} from "./AssetAccess.ts"; + +type FallbackTag = + | "project-favicon" + | "project-favicon-react" + | "project-favicon-nextjs" + | "project-favicon-vue" + | "project-favicon-svelte" + | "project-favicon-angular" + | "project-favicon-android" + | "project-favicon-youtube" + | "project-favicon-tiktok" + | "project-favicon-instagram"; const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", @@ -235,7 +252,108 @@ describe("AssetAccess", () => { fallbackSuffix.slice(0, fallbackSeparatorIndex), fallbackSuffix.slice(fallbackSeparatorIndex + 1), ), - ).toEqual({ kind: "project-favicon-fallback" }); + ).toEqual({ kind: "project-favicon-fallback", svg: FALLBACK_PROJECT_FAVICON_SVG }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("serves stack-specific fallback icons for projects without a favicon", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const resolveFallbackTag = Effect.fnUntraced(function* (root: string) { + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const resolved = yield* resolveAsset( + suffix.slice(0, separatorIndex), + suffix.slice(separatorIndex + 1), + ); + expect(resolved?.kind).toBe("project-favicon-fallback"); + if (resolved?.kind !== "project-favicon-fallback") return "project-favicon" as FallbackTag; + const match = /data-fallback="([^"]+)"/.exec(resolved.svg); + return (match?.[1] ?? "project-favicon") as FallbackTag; + }); + + const makePkgRoot = (prefix: string, deps: Record) => + Effect.gen(function* () { + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix }); + const entries = Object.entries(deps) + .map(([k, v]) => `"${k}":"${v}"`) + .join(","); + yield* fileSystem.writeFileString( + path.join(root, "package.json"), + `{"dependencies":{${entries}}}`, + ); + return root; + }); + + // Next.js takes priority over plain React + expect( + yield* resolveFallbackTag( + yield* makePkgRoot("t3-asset-favicon-next-", { next: "^15.0.0", react: "^19.0.0" }), + ), + ).toBe("project-favicon-nextjs"); + + // Plain React + expect( + yield* resolveFallbackTag( + yield* makePkgRoot("t3-asset-favicon-react-", { react: "^19.0.0" }), + ), + ).toBe("project-favicon-react"); + + // Angular + expect( + yield* resolveFallbackTag( + yield* makePkgRoot("t3-asset-favicon-ng-", { "@angular/core": "^18.0.0" }), + ), + ).toBe("project-favicon-angular"); + + // Svelte + expect( + yield* resolveFallbackTag( + yield* makePkgRoot("t3-asset-favicon-svelte-", { svelte: "^5.0.0" }), + ), + ).toBe("project-favicon-svelte"); + + // Vue + expect( + yield* resolveFallbackTag(yield* makePkgRoot("t3-asset-favicon-vue-", { vue: "^3.0.0" })), + ).toBe("project-favicon-vue"); + + // Android (Gradle marker file) + const androidRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-android-", + }); + yield* fileSystem.writeFileString(path.join(androidRoot, "build.gradle"), ""); + expect(yield* resolveFallbackTag(androidRoot)).toBe("project-favicon-android"); + + // YouTube (project name matching) + const ytRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-youtube-companion-", + }); + expect(yield* resolveFallbackTag(ytRoot)).toBe("project-favicon-youtube"); + + // TikTok (project name matching) + const tiktokRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-tiktok-bot-", + }); + expect(yield* resolveFallbackTag(tiktokRoot)).toBe("project-favicon-tiktok"); + + // Instagram (project name matching) + const igRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-insta-poster-", + }); + expect(yield* resolveFallbackTag(igRoot)).toBe("project-favicon-instagram"); + + // Generic fallback for unrecognized stacks + expect( + yield* resolveFallbackTag( + yield* makePkgRoot("t3-asset-favicon-plain-", { express: "^5.0.0" }), + ), + ).toBe("project-favicon"); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 8d8ecbc2af3..4a7297495c6 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -41,6 +41,26 @@ import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; export const FALLBACK_PROJECT_FAVICON_SVG = ``; +const NEXTJS_PROJECT_FAVICON_SVG = ``; +const REACT_PROJECT_FAVICON_SVG = ``; +const VUE_PROJECT_FAVICON_SVG = ``; +const SVELTE_PROJECT_FAVICON_SVG = ``; +const ANGULAR_PROJECT_FAVICON_SVG = ``; +const ANDROID_PROJECT_FAVICON_SVG = ``; +const YOUTUBE_PROJECT_FAVICON_SVG = ``; +const TIKTOK_PROJECT_FAVICON_SVG = ``; +const INSTAGRAM_PROJECT_FAVICON_SVG = ``; + +const ANDROID_PROJECT_MARKER_FILES = [ + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", +]; + +const YOUTUBE_PROJECT_NAME_PATTERNS = /\byt\b|youtube/i; +const TIKTOK_PROJECT_NAME_PATTERNS = /tiktok|tik[_-]?tok/i; +const INSTAGRAM_PROJECT_NAME_PATTERNS = /instagram|\binsta\b/i; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; @@ -93,7 +113,62 @@ const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); export type ResolvedAsset = | { readonly kind: "file"; readonly path: string } - | { readonly kind: "project-favicon-fallback" }; + | { readonly kind: "project-favicon-fallback"; readonly svg: string }; + +const PackageJsonDependenciesJson = Schema.fromJsonString( + Schema.Struct({ + dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + devDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + }), +); +const decodePackageJsonDependencies = Schema.decodeUnknownOption(PackageJsonDependenciesJson); + +function parsePackageJsonDependencyNames(contents: string): ReadonlySet { + const parsed = Option.getOrNull(decodePackageJsonDependencies(contents)); + return new Set([ + ...Object.keys(parsed?.dependencies ?? {}), + ...Object.keys(parsed?.devDependencies ?? {}), + ]); +} + +// Detection failures degrade to the generic fallback icon rather than failing +// the asset request. +const resolveProjectFaviconFallbackSvg = Effect.fn("AssetAccess.resolveProjectFaviconFallbackSvg")( + function* (workspaceRoot: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const projectName = path.basename(workspaceRoot); + + if (YOUTUBE_PROJECT_NAME_PATTERNS.test(projectName)) return YOUTUBE_PROJECT_FAVICON_SVG; + if (TIKTOK_PROJECT_NAME_PATTERNS.test(projectName)) return TIKTOK_PROJECT_FAVICON_SVG; + if (INSTAGRAM_PROJECT_NAME_PATTERNS.test(projectName)) return INSTAGRAM_PROJECT_FAVICON_SVG; + + const packageJsonContents = yield* fileSystem + .readFileString(path.join(workspaceRoot, "package.json")) + .pipe(Effect.orElseSucceed(() => null)); + if (packageJsonContents !== null) { + const dependencyNames = parsePackageJsonDependencyNames(packageJsonContents); + if (dependencyNames.has("next")) return NEXTJS_PROJECT_FAVICON_SVG; + if (dependencyNames.has("@angular/core")) return ANGULAR_PROJECT_FAVICON_SVG; + if (dependencyNames.has("svelte") || dependencyNames.has("@sveltejs/kit")) + return SVELTE_PROJECT_FAVICON_SVG; + if (dependencyNames.has("vue") || dependencyNames.has("nuxt")) return VUE_PROJECT_FAVICON_SVG; + if (dependencyNames.has("expo") || dependencyNames.has("react-native")) + return ANDROID_PROJECT_FAVICON_SVG; + if (dependencyNames.has("react")) return REACT_PROJECT_FAVICON_SVG; + } + + for (const markerFile of ANDROID_PROJECT_MARKER_FILES) { + const markerExists = yield* fileSystem + .exists(path.join(workspaceRoot, markerFile)) + .pipe(Effect.orElseSucceed(() => false)); + if (markerExists) return ANDROID_PROJECT_FAVICON_SVG; + } + + return FALLBACK_PROJECT_FAVICON_SVG; + }, +); function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -392,7 +467,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (claims.kind === "project-favicon") { if (claims.relativePath === null) { - return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; + return { + kind: "project-favicon-fallback", + svg: yield* resolveProjectFaviconFallbackSvg(claims.workspaceRoot), + } satisfies ResolvedAsset; } const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index fc7a9ef13a2..b3fff7b3d86 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -25,11 +25,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; -import { - ASSET_ROUTE_PREFIX, - FALLBACK_PROJECT_FAVICON_SVG, - resolveAsset, -} from "./assets/AssetAccess.ts"; +import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -197,7 +193,7 @@ export const assetRouteLayer = HttpRouter.add( return HttpServerResponse.text("Not Found", { status: 404 }); } if (asset.kind === "project-favicon-fallback") { - return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { + return HttpServerResponse.text(asset.svg, { status: 200, contentType: "image/svg+xml", headers: {