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";...@@ -3,6 +3,7 @@ import { Image } from "expo-image";
3import { useState } from "react";3import { useState } from "react";
4import { View } from "react-native";4import { View } from "react-native";
5import type { EnvironmentId } from "@t3tools/contracts";5import type { EnvironmentId } from "@t3tools/contracts";
6import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
6import { useThemeColor } from "../lib/useThemeColor";7import { useThemeColor } from "../lib/useThemeColor";
7import { useAssetUrl } from "../state/assets";8import { useAssetUrl } from "../state/assets";
89
...@@ -24,11 +25,12 @@ export function ProjectFavicon(props: {...@@ -24,11 +25,12 @@ export function ProjectFavicon(props: {
24 ? null25 ? null
25 : { _tag: "project-favicon", cwd: props.workspaceRoot },26 : { _tag: "project-favicon", cwd: props.workspaceRoot },
26 );27 );
28 const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
2729
28 return (30 return (
29 <ProjectFaviconImage31 <ProjectFaviconImage
30 key={faviconUrl}32 key={faviconUrl}
31 faviconUrl={faviconUrl}33 faviconUrl={renderableFaviconUrl}
32 open={props.open}34 open={props.open}
33 projectTitle={props.projectTitle}35 projectTitle={props.projectTitle}
34 size={size}36 size={size}
apps/server/src/assets/AssetAccess.test.ts+3-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { ThreadId } from "@t3tools/contracts";2import { ThreadId } from "@t3tools/contracts";
3import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
3import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
...@@ -228,6 +229,7 @@ describe("AssetAccess", () => {...@@ -228,6 +229,7 @@ describe("AssetAccess", () => {
228 const fallbackResult = yield* issueAssetUrl({229 const fallbackResult = yield* issueAssetUrl({
229 resource: { _tag: "project-favicon", cwd: root },230 resource: { _tag: "project-favicon", cwd: root },
230 });231 });
232 expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true);
231 const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);233 const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
232 const fallbackSeparatorIndex = fallbackSuffix.indexOf("/");234 const fallbackSeparatorIndex = fallbackSuffix.indexOf("/");
233 expect(235 expect(
...@@ -235,7 +237,7 @@ describe("AssetAccess", () => {...@@ -235,7 +237,7 @@ describe("AssetAccess", () => {
235 fallbackSuffix.slice(0, fallbackSeparatorIndex),237 fallbackSuffix.slice(0, fallbackSeparatorIndex),
236 fallbackSuffix.slice(fallbackSeparatorIndex + 1),238 fallbackSuffix.slice(fallbackSeparatorIndex + 1),
237 ),239 ),
238 ).toEqual({ kind: "project-favicon-fallback" });240 ).toBeNull();
239 }).pipe(Effect.provide(testLayer)),241 }).pipe(Effect.provide(testLayer)),
240 );242 );
241243
apps/server/src/assets/AssetAccess.ts+4-8
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,19 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
20 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,20 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
21} from "@t3tools/shared/filePreview";21} from "@t3tools/shared/filePreview";
22import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
22import * as Clock from "effect/Clock";23import * as Clock from "effect/Clock";
23import * as Effect from "effect/Effect";24import * as Effect from "effect/Effect";
24import * as FileSystem from "effect/FileSystem";25import * as FileSystem from "effect/FileSystem";
...@@ -40,7 +41,6 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";...@@ -40,7 +41,6 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
40import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";41import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
4142
42export const ASSET_ROUTE_PREFIX = "/api/assets";43export 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
45const SIGNING_SECRET_NAME = "asset-access-signing-key";45const SIGNING_SECRET_NAME = "asset-access-signing-key";
46const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;46const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
...@@ -91,9 +91,7 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);...@@ -91,9 +91,7 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
91const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);91const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
92const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);92const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);
9393
94export type ResolvedAsset =94export type ResolvedAsset = { readonly kind: "file"; readonly path: string };
95 | { readonly kind: "file"; readonly path: string }
96 | { readonly kind: "project-favicon-fallback" };
9795
98function decodeClaims(encodedPayload: string): AssetClaims | null {96function decodeClaims(encodedPayload: string): AssetClaims | null {
99 try {97 try {
...@@ -326,7 +324,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -326,7 +324,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
326 relativePath,324 relativePath,
327 expiresAt,325 expiresAt,
328 };326 };
329 fileName = relativePath ? path.basename(relativePath) : "favicon.svg";327 fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER;
330 break;328 break;
331 }329 }
332 }330 }
...@@ -391,9 +389,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -391,9 +389,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
391 }389 }
392390
393 if (claims.kind === "project-favicon") {391 if (claims.kind === "project-favicon") {
394 if (claims.relativePath === null) {392 if (claims.relativePath === null) return null;
395 return { kind: "project-favicon-fallback" } satisfies ResolvedAsset;
396 }
397 const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({393 const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({
398 workspaceRoot: claims.workspaceRoot,394 workspaceRoot: claims.workspaceRoot,
399 relativePath: claims.relativePath,395 relativePath: claims.relativePath,
apps/server/src/http.ts+1-16
...@@ -25,11 +25,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";...@@ -25,11 +25,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
25import { OtlpTracer } from "effect/unstable/observability";25import { OtlpTracer } from "effect/unstable/observability";
2626
27import * as ServerConfig from "./config.ts";27import * as ServerConfig from "./config.ts";
28import {28import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
29 ASSET_ROUTE_PREFIX,
30 FALLBACK_PROJECT_FAVICON_SVG,
31 resolveAsset,
32} from "./assets/AssetAccess.ts";
33import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";29import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
34import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";30import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
35import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";31import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
...@@ -196,17 +192,6 @@ export const assetRouteLayer = HttpRouter.add(...@@ -196,17 +192,6 @@ export const assetRouteLayer = HttpRouter.add(
196 if (!asset) {192 if (!asset) {
197 return HttpServerResponse.text("Not Found", { status: 404 });193 return HttpServerResponse.text("Not Found", { status: 404 });
198 }194 }
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
210 return yield* HttpServerResponse.file(asset.path, {195 return yield* HttpServerResponse.file(asset.path, {
211 status: 200,196 status: 200,
212 headers: {197 headers: {
apps/web/src/components/ProjectFavicon.tsx+2-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
2import { FolderIcon } from "lucide-react";3import { FolderIcon } from "lucide-react";
3import { useState } from "react";4import { useState } from "react";
4import { useAssetUrl } from "../assets/assetUrls";5import { useAssetUrl } from "../assets/assetUrls";
...@@ -15,7 +16,7 @@ export function ProjectFavicon(input: {...@@ -15,7 +16,7 @@ export function ProjectFavicon(input: {
15 cwd: input.cwd,16 cwd: input.cwd,
16 });17 });
1718
18 if (!src) {19 if (!src || isProjectFaviconFallbackUrl(src)) {
19 return <ProjectFaviconFallback className={input.className} />;20 return <ProjectFaviconFallback className={input.className} />;
20 }21 }
2122
packages/shared/package.json+4
...@@ -3,6 +3,10 @@...@@ -3,6 +3,10 @@
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 "./projectFavicon": {
7 "types": "./src/projectFavicon.ts",
8 "import": "./src/projectFavicon.ts"
9 },
6 "./model": {10 "./model": {
7 "types": "./src/model.ts",11 "types": "./src/model.ts",
8 "import": "./src/model.ts"12 "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}