authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-14 17:59:00+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-14 17:59:00+02:00
log8de254a685f4356c719e744926397362c800f426
tree49ef16c05d5ca7d56c6b2ead5c2a34b73fadba7d
parentc1ec1915fc16f3dc1ec5d47d9a97f6210a574526
signature Signed by PGP key B5690EEEBB952194

Use client-side fallbacks for missing project favicons (#3959)


8 files changed, 57 insertions(+), 27 deletions(-)

apps/mobile/src/components/ProjectFavicon.tsx+3-1
......@@ -3,6 +3,7 @@ import { Image } from "expo-image";
33import { useState } from "react";
44import { View } from "react-native";
55import type { EnvironmentId } from "@t3tools/contracts";
6import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
67import { useThemeColor } from "../lib/useThemeColor";
78import { useAssetUrl } from "../state/assets";
89
......@@ -24,11 +25,12 @@ export function ProjectFavicon(props: {
2425 ? null
2526 : { _tag: "project-favicon", cwd: props.workspaceRoot },
2627 );
28 const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
2729
2830 return (
2931 <ProjectFaviconImage
3032 key={faviconUrl}
31 faviconUrl={faviconUrl}
33 faviconUrl={renderableFaviconUrl}
3234 open={props.open}
3335 projectTitle={props.projectTitle}
3436 size={size}
apps/server/src/assets/AssetAccess.test.ts+3-1
......@@ -1,5 +1,6 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { ThreadId } from "@t3tools/contracts";
3import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
34import { describe, expect, it } from "@effect/vitest";
45import * as Effect from "effect/Effect";
56import * as FileSystem from "effect/FileSystem";
......@@ -228,6 +229,7 @@ describe("AssetAccess", () => {
228229 const fallbackResult = yield* issueAssetUrl({
229230 resource: { _tag: "project-favicon", cwd: root },
230231 });
232 expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true);
231233 const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
232234 const fallbackSeparatorIndex = fallbackSuffix.indexOf("/");
233235 expect(
......@@ -235,7 +237,7 @@ describe("AssetAccess", () => {
235237 fallbackSuffix.slice(0, fallbackSeparatorIndex),
236238 fallbackSuffix.slice(fallbackSeparatorIndex + 1),
237239 ),
238 ).toEqual({ kind: "project-favicon-fallback" });
240 ).toBeNull();
239241 }).pipe(Effect.provide(testLayer)),
240242 );
241243
apps/server/src/assets/AssetAccess.ts+4-8
......@@ -19,6 +19,7 @@ import {
1919 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
2020 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
2121} from "@t3tools/shared/filePreview";
22import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
2223import * as Clock from "effect/Clock";
2324import * as Effect from "effect/Effect";
2425import * as FileSystem from "effect/FileSystem";
......@@ -40,7 +41,6 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
4041import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
4142
4243export const ASSET_ROUTE_PREFIX = "/api/assets";
43export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
4444
4545const SIGNING_SECRET_NAME = "asset-access-signing-key";
4646const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
......@@ -91,9 +91,7 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
9191const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
9292const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);
9393
94export type ResolvedAsset =
95 | { readonly kind: "file"; readonly path: string }
96 | { readonly kind: "project-favicon-fallback" };
94export type ResolvedAsset = { readonly kind: "file"; readonly path: string };
9795
9896function decodeClaims(encodedPayload: string): AssetClaims | null {
9997 try {
......@@ -326,7 +324,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
326324 relativePath,
327325 expiresAt,
328326 };
329 fileName = relativePath ? path.basename(relativePath) : "favicon.svg";
327 fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER;
330328 break;
331329 }
332330 }
......@@ -391,9 +389,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
391389 }
392390
393391 if (claims.kind === "project-favicon") {
394 if (claims.relativePath === null) {
395 return { kind: "project-favicon-fallback" } satisfies ResolvedAsset;
396 }
392 if (claims.relativePath === null) return null;
397393 const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({
398394 workspaceRoot: claims.workspaceRoot,
399395 relativePath: claims.relativePath,
apps/server/src/http.ts+1-16
......@@ -25,11 +25,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
2525import { OtlpTracer } from "effect/unstable/observability";
2626
2727import * as ServerConfig from "./config.ts";
28import {
29 ASSET_ROUTE_PREFIX,
30 FALLBACK_PROJECT_FAVICON_SVG,
31 resolveAsset,
32} from "./assets/AssetAccess.ts";
28import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
3329import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
3430import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
3531import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
......@@ -196,17 +192,6 @@ export const assetRouteLayer = HttpRouter.add(
196192 if (!asset) {
197193 return HttpServerResponse.text("Not Found", { status: 404 });
198194 }
199 if (asset.kind === "project-favicon-fallback") {
200 return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, {
201 status: 200,
202 contentType: "image/svg+xml",
203 headers: {
204 "Cache-Control": "private, max-age=3600",
205 "X-Content-Type-Options": "nosniff",
206 },
207 });
208 }
209
210195 return yield* HttpServerResponse.file(asset.path, {
211196 status: 200,
212197 headers: {
apps/web/src/components/ProjectFavicon.tsx+2-1
......@@ -1,4 +1,5 @@
11import type { EnvironmentId } from "@t3tools/contracts";
2import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
23import { FolderIcon } from "lucide-react";
34import { useState } from "react";
45import { useAssetUrl } from "../assets/assetUrls";
......@@ -15,7 +16,7 @@ export function ProjectFavicon(input: {
1516 cwd: input.cwd,
1617 });
1718
18 if (!src) {
19 if (!src || isProjectFaviconFallbackUrl(src)) {
1920 return <ProjectFaviconFallback className={input.className} />;
2021 }
2122
packages/shared/package.json+4
......@@ -3,6 +3,10 @@
33 "private": true,
44 "type": "module",
55 "exports": {
6 "./projectFavicon": {
7 "types": "./src/projectFavicon.ts",
8 "import": "./src/projectFavicon.ts"
9 },
610 "./model": {
711 "types": "./src/model.ts",
812 "import": "./src/model.ts"
packages/shared/src/projectFavicon.test.ts created+28
......@@ -0,0 +1,28 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts";
4
5describe("project favicon", () => {
6 it("identifies fallback asset URLs by their dedicated filename", () => {
7 expect(
8 isProjectFaviconFallbackUrl(
9 `https://environment.example/api/assets/signed-token/${PROJECT_FAVICON_FALLBACK_MARKER}`,
10 ),
11 ).toBe(true);
12 expect(
13 isProjectFaviconFallbackUrl(`/api/assets/signed-token/${PROJECT_FAVICON_FALLBACK_MARKER}`),
14 ).toBe(true);
15 });
16
17 it("does not mistake real favicons or query parameters for fallbacks", () => {
18 expect(
19 isProjectFaviconFallbackUrl("https://environment.example/api/assets/token/favicon.svg"),
20 ).toBe(false);
21 expect(
22 isProjectFaviconFallbackUrl(
23 `https://environment.example/api/assets/token/favicon.svg?name=${PROJECT_FAVICON_FALLBACK_MARKER}`,
24 ),
25 ).toBe(false);
26 expect(isProjectFaviconFallbackUrl(null)).toBe(false);
27 });
28});
packages/shared/src/projectFavicon.ts created+12
......@@ -0,0 +1,12 @@
1export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing";
2
3export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean {
4 if (!url) return false;
5
6 try {
7 const pathname = new URL(url, "https://t3.invalid").pathname;
8 return pathname.slice(pathname.lastIndexOf("/") + 1) === PROJECT_FAVICON_FALLBACK_MARKER;
9 } catch {
10 return false;
11 }
12}