| author | |
| committer | |
| log | 1d77cec991971d3113918e896b1c871e8b044da7 |
| tree | 99685a2dac59fd9b9f487e73c5df9c9bc5347e4e |
| parent | 4ba4871af76081f0e830a91b8f747ff2de53ba7d |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 552 insertions(+), 48 deletions(-)
apps/mobile/src/components/ProjectFavicon.tsx+45-12| ... | @@ -1,14 +1,21 @@ | ... | @@ -1,14 +1,21 @@ |
| 1 | import { SymbolView } from "./AppSymbol"; | 1 | import { SymbolView } from "./AppSymbol"; |
| 2 | import { Image } from "expo-image"; | 2 | import { Image } from "expo-image"; |
| 3 | import { useState } from "react"; | 3 | import { useLayoutEffect, useMemo, useState } from "react"; |
| 4 | import { View } from "react-native"; | 4 | import { View } from "react-native"; |
| 5 | import type { EnvironmentId } from "@t3tools/contracts"; | 5 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 6 | import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; | 6 | import { |
| 7 | getProjectFaviconCacheKey, | ||
| 8 | isProjectFaviconFallbackUrl, | ||
| 9 | } from "@t3tools/shared/projectFavicon"; | ||
| 7 | import { useThemeColor } from "../lib/useThemeColor"; | 10 | import { useThemeColor } from "../lib/useThemeColor"; |
| 8 | import { useAssetUrl } from "../state/assets"; | 11 | import { useAssetUrl } from "../state/assets"; |
| 9 | 12 | import { | |
| 10 | /* ─── Favicon cache (matches web pattern) ────────────────────────────── */ | 13 | beginProjectFaviconRequest, |
| 11 | const loadedFaviconUrls = new Set<string>(); | 14 | createProjectFaviconRequest, |
| 15 | hasLoadedProjectFavicon, | ||
| 16 | markProjectFaviconFailed, | ||
| 17 | markProjectFaviconLoaded, | ||
| 18 | } from "./projectFaviconCache"; | ||
| 12 | 19 | ||
| 13 | /* ─── Component ──────────────────────────────────────────────────────── */ | 20 | /* ─── Component ──────────────────────────────────────────────────────── */ |
| 14 | export function ProjectFavicon(props: { | 21 | export function ProjectFavicon(props: { |
| ... | @@ -26,10 +33,15 @@ export function ProjectFavicon(props: { | ... | @@ -26,10 +33,15 @@ export function ProjectFavicon(props: { |
| 26 | : { _tag: "project-favicon", cwd: props.workspaceRoot }, | 33 | : { _tag: "project-favicon", cwd: props.workspaceRoot }, |
| 27 | ); | 34 | ); |
| 28 | const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; | 35 | const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; |
| 36 | const cacheKey = | ||
| 37 | renderableFaviconUrl && props.workspaceRoot | ||
| 38 | ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) | ||
| 39 | : null; | ||
| 29 | 40 | ||
| 30 | return ( | 41 | return ( |
| 31 | <ProjectFaviconImage | 42 | <ProjectFaviconImage |
| 32 | key={faviconUrl} | 43 | key={cacheKey} |
| 44 | cacheKey={cacheKey} | ||
| 33 | faviconUrl={renderableFaviconUrl} | 45 | faviconUrl={renderableFaviconUrl} |
| 34 | open={props.open} | 46 | open={props.open} |
| 35 | projectTitle={props.projectTitle} | 47 | projectTitle={props.projectTitle} |
| ... | @@ -39,18 +51,32 @@ export function ProjectFavicon(props: { | ... | @@ -39,18 +51,32 @@ export function ProjectFavicon(props: { |
| 39 | } | 51 | } |
| 40 | 52 | ||
| 41 | function ProjectFaviconImage(props: { | 53 | function ProjectFaviconImage(props: { |
| 54 | readonly cacheKey: string | null; | ||
| 42 | readonly faviconUrl: string | null; | 55 | readonly faviconUrl: string | null; |
| 43 | readonly open?: boolean; | 56 | readonly open?: boolean; |
| 44 | readonly projectTitle: string; | 57 | readonly projectTitle: string; |
| 45 | readonly size: number; | 58 | readonly size: number; |
| 46 | }) { | 59 | }) { |
| 47 | const iconMuted = useThemeColor("--color-icon-subtle"); | 60 | const iconMuted = useThemeColor("--color-icon-subtle"); |
| 61 | const faviconRequest = useMemo( | ||
| 62 | () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl), | ||
| 63 | [props.cacheKey, props.faviconUrl], | ||
| 64 | ); | ||
| 65 | const [activeFaviconRequest, setActiveFaviconRequest] = useState<typeof faviconRequest>(null); | ||
| 66 | useLayoutEffect(() => { | ||
| 67 | if (faviconRequest === null) return; | ||
| 68 | |||
| 69 | const endRequest = beginProjectFaviconRequest(faviconRequest); | ||
| 70 | setActiveFaviconRequest(faviconRequest); | ||
| 71 | return endRequest; | ||
| 72 | }, [faviconRequest]); | ||
| 48 | 73 | ||
| 49 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => | 74 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => |
| 50 | props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading", | 75 | hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", |
| 51 | ); | 76 | ); |
| 52 | 77 | ||
| 53 | const showImage = props.faviconUrl !== null && status === "loaded"; | 78 | const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; |
| 79 | const showImage = requestIsActive && status === "loaded"; | ||
| 54 | 80 | ||
| 55 | return ( | 81 | return ( |
| 56 | <View | 82 | <View |
| ... | @@ -72,11 +98,15 @@ function ProjectFaviconImage(props: { | ... | @@ -72,11 +98,15 @@ function ProjectFaviconImage(props: { |
| 72 | ) : null} | 98 | ) : null} |
| 73 | 99 | ||
| 74 | {/* Favicon image (hidden until loaded) */} | 100 | {/* Favicon image (hidden until loaded) */} |
| 75 | {props.faviconUrl ? ( | 101 | {requestIsActive ? ( |
| 76 | <Image | 102 | <Image |
| 103 | key={faviconRequest.faviconUrl} | ||
| 77 | source={{ | 104 | source={{ |
| 78 | uri: props.faviconUrl, | 105 | uri: faviconRequest.faviconUrl, |
| 106 | cacheKey: faviconRequest.cacheKey, | ||
| 79 | }} | 107 | }} |
| 108 | cachePolicy="memory-disk" | ||
| 109 | recyclingKey={faviconRequest.cacheKey} | ||
| 80 | accessibilityLabel={`${props.projectTitle} favicon`} | 110 | accessibilityLabel={`${props.projectTitle} favicon`} |
| 81 | style={{ | 111 | style={{ |
| 82 | width: props.size, | 112 | width: props.size, |
| ... | @@ -86,10 +116,13 @@ function ProjectFaviconImage(props: { | ... | @@ -86,10 +116,13 @@ function ProjectFaviconImage(props: { |
| 86 | }} | 116 | }} |
| 87 | contentFit="contain" | 117 | contentFit="contain" |
| 88 | onLoad={() => { | 118 | onLoad={() => { |
| 89 | if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); | 119 | if (!markProjectFaviconLoaded(faviconRequest)) return; |
| 90 | setStatus("loaded"); | 120 | setStatus("loaded"); |
| 91 | }} | 121 | }} |
| 92 | onError={() => setStatus("error")} | 122 | onError={() => { |
| 123 | if (!markProjectFaviconFailed(faviconRequest)) return; | ||
| 124 | setStatus("error"); | ||
| 125 | }} | ||
| 93 | /> | 126 | /> |
| 94 | ) : null} | 127 | ) : null} |
| 95 | </View> | 128 | </View> |
apps/mobile/src/components/projectFaviconCache.test.ts created+108| ... | @@ -0,0 +1,108 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | beginProjectFaviconRequest, | ||
| 5 | createProjectFaviconRequest, | ||
| 6 | hasLoadedProjectFavicon, | ||
| 7 | markProjectFaviconFailed, | ||
| 8 | markProjectFaviconLoaded, | ||
| 9 | } from "./projectFaviconCache"; | ||
| 10 | |||
| 11 | describe("project favicon cache", () => { | ||
| 12 | it("ignores callbacks from a superseded URL", () => { | ||
| 13 | const cacheKey = "environment-1:/workspace:v1-favicon.svg"; | ||
| 14 | const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; | ||
| 15 | const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg"; | ||
| 16 | |||
| 17 | const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl); | ||
| 18 | const endExpiredRequest = beginProjectFaviconRequest(expiredRequest); | ||
| 19 | markProjectFaviconLoaded(expiredRequest); | ||
| 20 | const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl); | ||
| 21 | const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest); | ||
| 22 | |||
| 23 | expect(markProjectFaviconLoaded(expiredRequest)).toBe(false); | ||
| 24 | expect(markProjectFaviconFailed(expiredRequest)).toBe(false); | ||
| 25 | expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); | ||
| 26 | expect(markProjectFaviconFailed(refreshedRequest)).toBe(true); | ||
| 27 | expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); | ||
| 28 | |||
| 29 | endRefreshedRequest(); | ||
| 30 | endExpiredRequest(); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("evicts the URL that actually failed", () => { | ||
| 34 | const cacheKey = "environment-1:/workspace:v2-favicon.svg"; | ||
| 35 | const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; | ||
| 36 | const request = createProjectFaviconRequest(cacheKey, faviconUrl); | ||
| 37 | const endRequest = beginProjectFaviconRequest(request); | ||
| 38 | |||
| 39 | markProjectFaviconLoaded(request); | ||
| 40 | |||
| 41 | expect(markProjectFaviconFailed(request)).toBe(true); | ||
| 42 | expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); | ||
| 43 | |||
| 44 | endRequest(); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("does not supersede a request until the next request begins", () => { | ||
| 48 | const cacheKey = "environment-1:/workspace:v3-favicon.svg"; | ||
| 49 | const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg"; | ||
| 50 | const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg"; | ||
| 51 | const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl); | ||
| 52 | const endCommittedRequest = beginProjectFaviconRequest(committedRequest); | ||
| 53 | |||
| 54 | createProjectFaviconRequest(cacheKey, abandonedUrl); | ||
| 55 | |||
| 56 | expect(markProjectFaviconLoaded(committedRequest)).toBe(true); | ||
| 57 | expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); | ||
| 58 | |||
| 59 | endCommittedRequest(); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("requires a cache key before creating a URL-bearing request", () => { | ||
| 63 | const firstUrl = "https://environment.example/api/assets/first/favicon.svg"; | ||
| 64 | const secondUrl = "https://environment.example/api/assets/second/favicon.svg"; | ||
| 65 | |||
| 66 | expect(createProjectFaviconRequest(null, firstUrl)).toBeNull(); | ||
| 67 | expect(createProjectFaviconRequest(null, secondUrl)).toBeNull(); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it("restores the remaining active URL when a newer request ends", () => { | ||
| 71 | const cacheKey = "environment-1:/workspace:v4-favicon.svg"; | ||
| 72 | const firstRequest = createProjectFaviconRequest( | ||
| 73 | cacheKey, | ||
| 74 | "https://environment.example/api/assets/first/v4-favicon.svg", | ||
| 75 | ); | ||
| 76 | const secondRequest = createProjectFaviconRequest( | ||
| 77 | cacheKey, | ||
| 78 | "https://environment.example/api/assets/second/v4-favicon.svg", | ||
| 79 | ); | ||
| 80 | const endFirstRequest = beginProjectFaviconRequest(firstRequest); | ||
| 81 | const endSecondRequest = beginProjectFaviconRequest(secondRequest); | ||
| 82 | |||
| 83 | expect(markProjectFaviconLoaded(firstRequest)).toBe(false); | ||
| 84 | endSecondRequest(); | ||
| 85 | expect(markProjectFaviconLoaded(firstRequest)).toBe(true); | ||
| 86 | endFirstRequest(); | ||
| 87 | expect(markProjectFaviconLoaded(firstRequest)).toBe(false); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("bounds remembered loaded revisions", () => { | ||
| 91 | const firstCacheKey = "environment-1:/workspace:revision-0"; | ||
| 92 | let lastCacheKey = firstCacheKey; | ||
| 93 | |||
| 94 | for (let revision = 0; revision < 300; revision++) { | ||
| 95 | lastCacheKey = `environment-1:/workspace:revision-${revision}`; | ||
| 96 | const request = createProjectFaviconRequest( | ||
| 97 | lastCacheKey, | ||
| 98 | `https://environment.example/api/assets/revision-${revision}/favicon.svg`, | ||
| 99 | ); | ||
| 100 | const endRequest = beginProjectFaviconRequest(request); | ||
| 101 | markProjectFaviconLoaded(request); | ||
| 102 | endRequest(); | ||
| 103 | } | ||
| 104 | |||
| 105 | expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false); | ||
| 106 | expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true); | ||
| 107 | }); | ||
| 108 | }); | ||
apps/mobile/src/components/projectFaviconCache.ts created+94| ... | @@ -0,0 +1,94 @@ | ||
| 1 | export interface ProjectFaviconRequest { | ||
| 2 | readonly cacheKey: string; | ||
| 3 | readonly faviconUrl: string; | ||
| 4 | } | ||
| 5 | |||
| 6 | interface ActiveFaviconRequests { | ||
| 7 | readonly urls: Map<string, number>; | ||
| 8 | currentUrl: string; | ||
| 9 | } | ||
| 10 | |||
| 11 | const MAX_LOADED_FAVICONS = 256; | ||
| 12 | const activeFaviconRequests = new Map<string, ActiveFaviconRequests>(); | ||
| 13 | const loadedFaviconKeys = new Map<string, true>(); | ||
| 14 | |||
| 15 | export function createProjectFaviconRequest( | ||
| 16 | cacheKey: string, | ||
| 17 | faviconUrl: string, | ||
| 18 | ): ProjectFaviconRequest; | ||
| 19 | export function createProjectFaviconRequest( | ||
| 20 | cacheKey: string | null, | ||
| 21 | faviconUrl: string | null, | ||
| 22 | ): ProjectFaviconRequest | null; | ||
| 23 | export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { | ||
| 24 | if (!cacheKey || !faviconUrl) return null; | ||
| 25 | return { cacheKey, faviconUrl }; | ||
| 26 | } | ||
| 27 | |||
| 28 | export function beginProjectFaviconRequest(request: ProjectFaviconRequest) { | ||
| 29 | let activeRequests = activeFaviconRequests.get(request.cacheKey); | ||
| 30 | if (!activeRequests) { | ||
| 31 | activeRequests = { currentUrl: request.faviconUrl, urls: new Map() }; | ||
| 32 | activeFaviconRequests.set(request.cacheKey, activeRequests); | ||
| 33 | } | ||
| 34 | |||
| 35 | const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0; | ||
| 36 | activeRequests.urls.delete(request.faviconUrl); | ||
| 37 | activeRequests.urls.set(request.faviconUrl, activeCount + 1); | ||
| 38 | activeRequests.currentUrl = request.faviconUrl; | ||
| 39 | |||
| 40 | let ended = false; | ||
| 41 | return () => { | ||
| 42 | if (ended) return; | ||
| 43 | ended = true; | ||
| 44 | |||
| 45 | const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1; | ||
| 46 | if (remainingCount > 0) { | ||
| 47 | activeRequests.urls.set(request.faviconUrl, remainingCount); | ||
| 48 | return; | ||
| 49 | } | ||
| 50 | |||
| 51 | activeRequests.urls.delete(request.faviconUrl); | ||
| 52 | if (activeRequests.urls.size === 0) { | ||
| 53 | if (activeFaviconRequests.get(request.cacheKey) === activeRequests) { | ||
| 54 | activeFaviconRequests.delete(request.cacheKey); | ||
| 55 | } | ||
| 56 | return; | ||
| 57 | } | ||
| 58 | |||
| 59 | if (activeRequests.currentUrl === request.faviconUrl) { | ||
| 60 | activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!; | ||
| 61 | } | ||
| 62 | }; | ||
| 63 | } | ||
| 64 | |||
| 65 | export function hasLoadedProjectFavicon(cacheKey: string | null) { | ||
| 66 | return cacheKey !== null && loadedFaviconKeys.has(cacheKey); | ||
| 67 | } | ||
| 68 | |||
| 69 | function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) { | ||
| 70 | return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl; | ||
| 71 | } | ||
| 72 | |||
| 73 | function rememberLoadedProjectFavicon(cacheKey: string) { | ||
| 74 | loadedFaviconKeys.delete(cacheKey); | ||
| 75 | loadedFaviconKeys.set(cacheKey, true); | ||
| 76 | |||
| 77 | if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) { | ||
| 78 | loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!); | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { | ||
| 83 | if (!isCurrentProjectFaviconRequest(request)) return false; | ||
| 84 | |||
| 85 | rememberLoadedProjectFavicon(request.cacheKey); | ||
| 86 | return true; | ||
| 87 | } | ||
| 88 | |||
| 89 | export function markProjectFaviconFailed(request: ProjectFaviconRequest) { | ||
| 90 | if (!isCurrentProjectFaviconRequest(request)) return false; | ||
| 91 | |||
| 92 | loadedFaviconKeys.delete(request.cacheKey); | ||
| 93 | return true; | ||
| 94 | } | ||
apps/server/src/assets/AssetAccess.test.ts+45-1| ... | @@ -2,11 +2,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; | ... | @@ -2,11 +2,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { ThreadId } from "@t3tools/contracts"; | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 3 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; | 3 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; |
| 4 | import { describe, expect, it } from "@effect/vitest"; | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import * as Crypto from "effect/Crypto"; | ||
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | 7 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 8 | import * as Path from "effect/Path"; | 9 | import * as Path from "effect/Path"; |
| 9 | import * as PlatformError from "effect/PlatformError"; | 10 | import * as PlatformError from "effect/PlatformError"; |
| 11 | import * as TestClock from "effect/testing/TestClock"; | ||
| 10 | 12 | ||
| 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 13 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 12 | import * as ServerConfig from "../config.ts"; | 14 | import * as ServerConfig from "../config.ts"; |
| ... | @@ -214,12 +216,21 @@ describe("AssetAccess", () => { | ... | @@ -214,12 +216,21 @@ describe("AssetAccess", () => { |
| 214 | prefix: "t3-asset-favicon-", | 216 | prefix: "t3-asset-favicon-", |
| 215 | }); | 217 | }); |
| 216 | const faviconPath = path.join(root, "favicon.svg"); | 218 | const faviconPath = path.join(root, "favicon.svg"); |
| 217 | yield* fileSystem.writeFileString(faviconPath, "<svg />"); | 219 | const initialFavicon = "<svg>a</svg>"; |
| 220 | const updatedFavicon = "<svg>b</svg>"; | ||
| 221 | expect(updatedFavicon).toHaveLength(initialFavicon.length); | ||
| 222 | yield* fileSystem.writeFileString(faviconPath, initialFavicon); | ||
| 218 | const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath); | 223 | const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath); |
| 219 | 224 | ||
| 220 | const faviconResult = yield* issueAssetUrl({ | 225 | const faviconResult = yield* issueAssetUrl({ |
| 221 | resource: { _tag: "project-favicon", cwd: root }, | 226 | resource: { _tag: "project-favicon", cwd: root }, |
| 222 | }); | 227 | }); |
| 228 | expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); | ||
| 229 | expect( | ||
| 230 | yield* issueAssetUrl({ | ||
| 231 | resource: { _tag: "project-favicon", cwd: root }, | ||
| 232 | }), | ||
| 233 | ).toEqual(faviconResult); | ||
| 223 | const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); | 234 | const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); |
| 224 | const faviconSeparatorIndex = faviconSuffix.indexOf("/"); | 235 | const faviconSeparatorIndex = faviconSuffix.indexOf("/"); |
| 225 | expect( | 236 | expect( |
| ... | @@ -229,6 +240,14 @@ describe("AssetAccess", () => { | ... | @@ -229,6 +240,14 @@ describe("AssetAccess", () => { |
| 229 | ), | 240 | ), |
| 230 | ).toEqual({ kind: "file", path: canonicalFaviconPath }); | 241 | ).toEqual({ kind: "file", path: canonicalFaviconPath }); |
| 231 | 242 | ||
| 243 | yield* fileSystem.writeFileString(faviconPath, updatedFavicon); | ||
| 244 | const updatedFaviconResult = yield* issueAssetUrl({ | ||
| 245 | resource: { _tag: "project-favicon", cwd: root }, | ||
| 246 | }); | ||
| 247 | expect( | ||
| 248 | updatedFaviconResult.relativeUrl.slice(updatedFaviconResult.relativeUrl.lastIndexOf("/")), | ||
| 249 | ).not.toBe(faviconResult.relativeUrl.slice(faviconResult.relativeUrl.lastIndexOf("/"))); | ||
| 250 | |||
| 232 | yield* fileSystem.remove(faviconPath); | 251 | yield* fileSystem.remove(faviconPath); |
| 233 | const fallbackResult = yield* issueAssetUrl({ | 252 | const fallbackResult = yield* issueAssetUrl({ |
| 234 | resource: { _tag: "project-favicon", cwd: root }, | 253 | resource: { _tag: "project-favicon", cwd: root }, |
| ... | @@ -245,6 +264,31 @@ describe("AssetAccess", () => { | ... | @@ -245,6 +264,31 @@ describe("AssetAccess", () => { |
| 245 | }).pipe(Effect.provide(testLayer)), | 264 | }).pipe(Effect.provide(testLayer)), |
| 246 | ); | 265 | ); |
| 247 | 266 | ||
| 267 | it.effect("buckets project favicon expiry after content hashing", () => | ||
| 268 | Effect.gen(function* () { | ||
| 269 | const crypto = yield* Crypto.Crypto; | ||
| 270 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 271 | const path = yield* Path.Path; | ||
| 272 | const root = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 273 | prefix: "t3-asset-favicon-expiry-", | ||
| 274 | }); | ||
| 275 | yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "<svg />"); | ||
| 276 | |||
| 277 | const bucketMs = 30 * 60 * 1000; | ||
| 278 | yield* TestClock.setTime(bucketMs - 1); | ||
| 279 | const crossingCrypto = Crypto.make({ | ||
| 280 | randomBytes: (size) => new Uint8Array(size), | ||
| 281 | digest: (algorithm, data) => | ||
| 282 | TestClock.adjust("2 millis").pipe(Effect.andThen(crypto.digest(algorithm, data))), | ||
| 283 | }); | ||
| 284 | const result = yield* issueAssetUrl({ | ||
| 285 | resource: { _tag: "project-favicon", cwd: root }, | ||
| 286 | }).pipe(Effect.provideService(Crypto.Crypto, crossingCrypto)); | ||
| 287 | |||
| 288 | expect(result.expiresAt).toBe(3 * bucketMs); | ||
| 289 | }).pipe(Effect.provide(testLayer)), | ||
| 290 | ); | ||
| 291 | |||
| 248 | it.effect("preserves structured project favicon resolution causes", () => | 292 | it.effect("preserves structured project favicon resolution causes", () => |
| 249 | Effect.gen(function* () { | 293 | Effect.gen(function* () { |
| 250 | const fileSystem = yield* FileSystem.FileSystem; | 294 | const fileSystem = yield* FileSystem.FileSystem; |
apps/server/src/assets/AssetAccess.ts+49-14| ... | @@ -21,7 +21,9 @@ import { | ... | @@ -21,7 +21,9 @@ import { |
| 21 | } from "@t3tools/shared/filePreview"; | 21 | } from "@t3tools/shared/filePreview"; |
| 22 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; | 22 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; |
| 23 | import * as Clock from "effect/Clock"; | 23 | import * as Clock from "effect/Clock"; |
| 24 | import * as Crypto from "effect/Crypto"; | ||
| 24 | import * as Effect from "effect/Effect"; | 25 | import * as Effect from "effect/Effect"; |
| 26 | import * as Encoding from "effect/Encoding"; | ||
| 25 | import * as FileSystem from "effect/FileSystem"; | 27 | import * as FileSystem from "effect/FileSystem"; |
| 26 | import * as Option from "effect/Option"; | 28 | import * as Option from "effect/Option"; |
| 27 | import * as Path from "effect/Path"; | 29 | import * as Path from "effect/Path"; |
| ... | @@ -44,6 +46,8 @@ export const ASSET_ROUTE_PREFIX = "/api/assets"; | ... | @@ -44,6 +46,8 @@ export const ASSET_ROUTE_PREFIX = "/api/assets"; |
| 44 | 46 | ||
| 45 | const SIGNING_SECRET_NAME = "asset-access-signing-key"; | 47 | const SIGNING_SECRET_NAME = "asset-access-signing-key"; |
| 46 | const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; | 48 | const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; |
| 49 | const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; | ||
| 50 | const PROJECT_FAVICON_VERSION_PREFIX = "v"; | ||
| 47 | const PREVIEW_ASSET_EXTENSIONS = new Set([ | 51 | const PREVIEW_ASSET_EXTENSIONS = new Set([ |
| 48 | ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, | 52 | ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, |
| 49 | ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, | 53 | ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, |
| ... | @@ -169,7 +173,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -169,7 +173,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 169 | const fileSystem = yield* FileSystem.FileSystem; | 173 | const fileSystem = yield* FileSystem.FileSystem; |
| 170 | const path = yield* Path.Path; | 174 | const path = yield* Path.Path; |
| 171 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; | 175 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; |
| 172 | const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; | 176 | let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; |
| 173 | let claims: AssetClaims; | 177 | let claims: AssetClaims; |
| 174 | let fileName: string; | 178 | let fileName: string; |
| 175 | 179 | ||
| ... | @@ -293,18 +297,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -293,18 +297,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 293 | ), | 297 | ), |
| 294 | ); | 298 | ); |
| 295 | const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; | 299 | const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; |
| 296 | if ( | 300 | const canonicalFaviconPath = relativePath |
| 297 | relativePath && | 301 | ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( |
| 298 | !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( | 302 | Effect.mapError( |
| 299 | Effect.mapError( | 303 | (cause) => |
| 300 | (cause) => | 304 | new AssetProjectFaviconInspectionError({ |
| 301 | new AssetProjectFaviconInspectionError({ | 305 | resource: input.resource, |
| 302 | resource: input.resource, | 306 | cause, |
| 303 | cause, | 307 | }), |
| 304 | }), | 308 | ), |
| 305 | ), | 309 | ) |
| 306 | )) | 310 | : null; |
| 307 | ) { | 311 | if (relativePath && !canonicalFaviconPath) { |
| 308 | return yield* new AssetProjectFaviconNotFoundError({ | 312 | return yield* new AssetProjectFaviconNotFoundError({ |
| 309 | resource: input.resource, | 313 | resource: input.resource, |
| 310 | }); | 314 | }); |
| ... | @@ -324,7 +328,31 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -324,7 +328,31 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 324 | relativePath, | 328 | relativePath, |
| 325 | expiresAt, | 329 | expiresAt, |
| 326 | }; | 330 | }; |
| 327 | fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; | 331 | if (relativePath && canonicalFaviconPath) { |
| 332 | const crypto = yield* Crypto.Crypto; | ||
| 333 | const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe( | ||
| 334 | Effect.mapError( | ||
| 335 | (cause) => | ||
| 336 | new AssetProjectFaviconInspectionError({ | ||
| 337 | resource: input.resource, | ||
| 338 | cause, | ||
| 339 | }), | ||
| 340 | ), | ||
| 341 | ); | ||
| 342 | const revision = yield* crypto.digest("SHA-256", faviconBytes).pipe( | ||
| 343 | Effect.map(Encoding.encodeHex), | ||
| 344 | Effect.mapError( | ||
| 345 | (cause) => | ||
| 346 | new AssetProjectFaviconInspectionError({ | ||
| 347 | resource: input.resource, | ||
| 348 | cause, | ||
| 349 | }), | ||
| 350 | ), | ||
| 351 | ); | ||
| 352 | fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`; | ||
| 353 | } else { | ||
| 354 | fileName = PROJECT_FAVICON_FALLBACK_MARKER; | ||
| 355 | } | ||
| 328 | break; | 356 | break; |
| 329 | } | 357 | } |
| 330 | } | 358 | } |
| ... | @@ -339,6 +367,13 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -339,6 +367,13 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 339 | }), | 367 | }), |
| 340 | ), | 368 | ), |
| 341 | ); | 369 | ); |
| 370 | if (claims.kind === "project-favicon") { | ||
| 371 | const issuedAt = yield* Clock.currentTimeMillis; | ||
| 372 | expiresAt = | ||
| 373 | (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * | ||
| 374 | PROJECT_FAVICON_TOKEN_BUCKET_MS; | ||
| 375 | claims = { ...claims, expiresAt }; | ||
| 376 | } | ||
| 342 | const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); | 377 | const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); |
| 343 | const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; | 378 | const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; |
| 344 | return { | 379 | return { |
apps/web/src/components/ProjectFavicon.test.tsx created+128| ... | @@ -0,0 +1,128 @@ | ||
| 1 | import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react"; | ||
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | const testState = vi.hoisted(() => ({ | ||
| 6 | faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", | ||
| 7 | })); | ||
| 8 | |||
| 9 | const hooks = vi.hoisted(() => { | ||
| 10 | let cursor = 0; | ||
| 11 | let slots: unknown[] = []; | ||
| 12 | const nextIndex = () => cursor++; | ||
| 13 | |||
| 14 | return { | ||
| 15 | beginRender() { | ||
| 16 | cursor = 0; | ||
| 17 | }, | ||
| 18 | reset() { | ||
| 19 | cursor = 0; | ||
| 20 | slots = []; | ||
| 21 | }, | ||
| 22 | useMemoCache(size: number): unknown[] { | ||
| 23 | const index = nextIndex(); | ||
| 24 | if (!slots[index]) { | ||
| 25 | slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); | ||
| 26 | } | ||
| 27 | return slots[index] as unknown[]; | ||
| 28 | }, | ||
| 29 | useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] { | ||
| 30 | const index = nextIndex(); | ||
| 31 | if (index >= slots.length) { | ||
| 32 | slots[index] = | ||
| 33 | typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; | ||
| 34 | } | ||
| 35 | const setValue: Dispatch<SetStateAction<T>> = (nextValue) => { | ||
| 36 | const previous = slots[index] as T; | ||
| 37 | slots[index] = | ||
| 38 | typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; | ||
| 39 | }; | ||
| 40 | return [slots[index] as T, setValue]; | ||
| 41 | }, | ||
| 42 | }; | ||
| 43 | }); | ||
| 44 | |||
| 45 | vi.mock("react", async (importOriginal) => { | ||
| 46 | const actual = await importOriginal<typeof import("react")>(); | ||
| 47 | return { | ||
| 48 | ...actual, | ||
| 49 | useState: hooks.useState, | ||
| 50 | }; | ||
| 51 | }); | ||
| 52 | |||
| 53 | vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); | ||
| 54 | vi.mock("../assets/assetUrls", () => ({ | ||
| 55 | useAssetUrl: () => testState.faviconUrl, | ||
| 56 | })); | ||
| 57 | |||
| 58 | import { ProjectFavicon } from "./ProjectFavicon"; | ||
| 59 | |||
| 60 | type ProjectFaviconImageProps = { | ||
| 61 | readonly cacheKey: string; | ||
| 62 | readonly src: string; | ||
| 63 | readonly className?: string | undefined; | ||
| 64 | readonly fallbackIcon: ComponentType<{ className?: string }>; | ||
| 65 | }; | ||
| 66 | |||
| 67 | type ImageElement = ReactElement<{ | ||
| 68 | readonly src: string; | ||
| 69 | readonly onLoad?: () => void; | ||
| 70 | readonly onError?: () => void; | ||
| 71 | }>; | ||
| 72 | |||
| 73 | type ProjectFaviconImageElement = ReactElement<{ | ||
| 74 | readonly children: [ReactElement | null, ImageElement | null, ImageElement | null]; | ||
| 75 | }>; | ||
| 76 | |||
| 77 | function resolveImageComponent(): { | ||
| 78 | readonly Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement; | ||
| 79 | readonly props: ProjectFaviconImageProps; | ||
| 80 | } { | ||
| 81 | hooks.beginRender(); | ||
| 82 | const element = ProjectFavicon({ | ||
| 83 | environmentId: "environment-test" as EnvironmentId, | ||
| 84 | cwd: "/workspace-test", | ||
| 85 | }) as ReactElement<ProjectFaviconImageProps>; | ||
| 86 | hooks.reset(); | ||
| 87 | |||
| 88 | return { | ||
| 89 | Component: element.type as (props: ProjectFaviconImageProps) => ProjectFaviconImageElement, | ||
| 90 | props: element.props, | ||
| 91 | }; | ||
| 92 | } | ||
| 93 | |||
| 94 | function renderImage( | ||
| 95 | Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement, | ||
| 96 | props: ProjectFaviconImageProps, | ||
| 97 | ): ProjectFaviconImageElement { | ||
| 98 | hooks.beginRender(); | ||
| 99 | return Component(props); | ||
| 100 | } | ||
| 101 | |||
| 102 | describe("ProjectFavicon", () => { | ||
| 103 | beforeEach(() => { | ||
| 104 | hooks.reset(); | ||
| 105 | }); | ||
| 106 | |||
| 107 | it("falls back when the displayed favicon fails without discarding a valid older image early", () => { | ||
| 108 | const { Component, props } = resolveImageComponent(); | ||
| 109 | const initialLoadingImage = renderImage(Component, props).props.children[2]; | ||
| 110 | initialLoadingImage?.props.onLoad?.(); | ||
| 111 | |||
| 112 | const refreshedProps = { | ||
| 113 | ...props, | ||
| 114 | src: "https://environment.test/api/assets/token-b/v1-20-favicon.svg", | ||
| 115 | }; | ||
| 116 | const refreshing = renderImage(Component, refreshedProps).props.children; | ||
| 117 | expect(refreshing[1]?.props.src).toBe(props.src); | ||
| 118 | refreshing[2]?.props.onError?.(); | ||
| 119 | |||
| 120 | const afterRefreshError = renderImage(Component, refreshedProps).props.children; | ||
| 121 | expect(afterRefreshError[1]?.props.src).toBe(props.src); | ||
| 122 | afterRefreshError[1]?.props.onError?.(); | ||
| 123 | |||
| 124 | const afterDisplayedError = renderImage(Component, refreshedProps).props.children; | ||
| 125 | expect(afterDisplayedError[0]).not.toBeNull(); | ||
| 126 | expect(afterDisplayedError[1]).toBeNull(); | ||
| 127 | }); | ||
| 128 | }); | ||
apps/web/src/components/ProjectFavicon.tsx+41-20| ... | @@ -1,12 +1,15 @@ | ... | @@ -1,12 +1,15 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; | 2 | import { |
| 3 | getProjectFaviconCacheKey, | ||
| 4 | isProjectFaviconFallbackUrl, | ||
| 5 | } from "@t3tools/shared/projectFavicon"; | ||
| 3 | import { FolderIcon } from "lucide-react"; | 6 | import { FolderIcon } from "lucide-react"; |
| 4 | import type { ComponentType } from "react"; | 7 | import type { ComponentType } from "react"; |
| 5 | import { useState } from "react"; | 8 | import { useState } from "react"; |
| 6 | import { useAssetUrl } from "../assets/assetUrls"; | 9 | import { useAssetUrl } from "../assets/assetUrls"; |
| 7 | import { cn } from "~/lib/utils"; | 10 | import { cn } from "~/lib/utils"; |
| 8 | 11 | ||
| 9 | const loadedProjectFaviconSrcs = new Set<string>(); | 12 | const loadedProjectFaviconSrcs = new Map<string, string>(); |
| 10 | 13 | ||
| 11 | export function ProjectFavicon(input: { | 14 | export function ProjectFavicon(input: { |
| 12 | environmentId: EnvironmentId; | 15 | environmentId: EnvironmentId; |
| ... | @@ -24,9 +27,12 @@ export function ProjectFavicon(input: { | ... | @@ -24,9 +27,12 @@ export function ProjectFavicon(input: { |
| 24 | return <ProjectFaviconFallback className={input.className} icon={FallbackIcon} />; | 27 | return <ProjectFaviconFallback className={input.className} icon={FallbackIcon} />; |
| 25 | } | 28 | } |
| 26 | 29 | ||
| 30 | const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); | ||
| 31 | |||
| 27 | return ( | 32 | return ( |
| 28 | <ProjectFaviconImage | 33 | <ProjectFaviconImage |
| 29 | key={src} | 34 | key={cacheKey} |
| 35 | cacheKey={cacheKey} | ||
| 30 | src={src} | 36 | src={src} |
| 31 | className={input.className} | 37 | className={input.className} |
| 32 | fallbackIcon={FallbackIcon} | 38 | fallbackIcon={FallbackIcon} |
| ... | @@ -45,37 +51,52 @@ function ProjectFaviconFallback({ | ... | @@ -45,37 +51,52 @@ function ProjectFaviconFallback({ |
| 45 | } | 51 | } |
| 46 | 52 | ||
| 47 | function ProjectFaviconImage({ | 53 | function ProjectFaviconImage({ |
| 54 | cacheKey, | ||
| 48 | src, | 55 | src, |
| 49 | className, | 56 | className, |
| 50 | fallbackIcon: FallbackIcon, | 57 | fallbackIcon: FallbackIcon, |
| 51 | }: { | 58 | }: { |
| 59 | readonly cacheKey: string; | ||
| 52 | readonly src: string; | 60 | readonly src: string; |
| 53 | readonly className?: string | undefined; | 61 | readonly className?: string | undefined; |
| 54 | readonly fallbackIcon: ComponentType<{ className?: string }>; | 62 | readonly fallbackIcon: ComponentType<{ className?: string }>; |
| 55 | }) { | 63 | }) { |
| 56 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => | 64 | const [displayedSrc, setDisplayedSrc] = useState<string | null>( |
| 57 | loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", | 65 | () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, |
| 58 | ); | 66 | ); |
| 67 | const isLoading = displayedSrc !== src; | ||
| 68 | const handleLoadError = (failedSrc: string) => { | ||
| 69 | if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { | ||
| 70 | loadedProjectFaviconSrcs.delete(cacheKey); | ||
| 71 | } | ||
| 72 | setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); | ||
| 73 | }; | ||
| 59 | 74 | ||
| 60 | return ( | 75 | return ( |
| 61 | <> | 76 | <> |
| 62 | {status !== "loaded" ? ( | 77 | {displayedSrc === null ? ( |
| 63 | <ProjectFaviconFallback className={className} icon={FallbackIcon} /> | 78 | <ProjectFaviconFallback className={className} icon={FallbackIcon} /> |
| 64 | ) : null} | 79 | ) : null} |
| 65 | <img | 80 | {displayedSrc ? ( |
| 66 | src={src} | 81 | <img |
| 67 | alt="" | 82 | src={displayedSrc} |
| 68 | className={cn( | 83 | alt="" |
| 69 | "size-3.5 shrink-0 rounded-sm object-contain", | 84 | className={cn("size-3.5 shrink-0 rounded-sm object-contain", className)} |
| 70 | status !== "loaded" && "hidden", | 85 | onError={() => handleLoadError(displayedSrc)} |
| 71 | className, | 86 | /> |
| 72 | )} | 87 | ) : null} |
| 73 | onLoad={() => { | 88 | {isLoading ? ( |
| 74 | loadedProjectFaviconSrcs.add(src); | 89 | <img |
| 75 | setStatus("loaded"); | 90 | src={src} |
| 76 | }} | 91 | alt="" |
| 77 | onError={() => setStatus("error")} | 92 | className="hidden" |
| 78 | /> | 93 | onLoad={() => { |
| 94 | loadedProjectFaviconSrcs.set(cacheKey, src); | ||
| 95 | setDisplayedSrc(src); | ||
| 96 | }} | ||
| 97 | onError={() => handleLoadError(src)} | ||
| 98 | /> | ||
| 99 | ) : null} | ||
| 79 | </> | 100 | </> |
| 80 | ); | 101 | ); |
| 81 | } | 102 | } |
packages/shared/src/projectFavicon.test.ts+25-1| ... | @@ -1,8 +1,32 @@ | ... | @@ -1,8 +1,32 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts"; | 3 | import { |
| 4 | getProjectFaviconCacheKey, | ||
| 5 | isProjectFaviconFallbackUrl, | ||
| 6 | PROJECT_FAVICON_FALLBACK_MARKER, | ||
| 7 | } from "./projectFavicon.ts"; | ||
| 4 | 8 | ||
| 5 | describe("project favicon", () => { | 9 | describe("project favicon", () => { |
| 10 | it("uses the project and versioned filename as the cache identity", () => { | ||
| 11 | const firstUrl = "https://environment.example/api/assets/first-signed-token/v1-20-favicon.svg"; | ||
| 12 | const refreshedUrl = | ||
| 13 | "https://environment.example/api/assets/refreshed-signed-token/v1-20-favicon.svg"; | ||
| 14 | |||
| 15 | expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).toBe( | ||
| 16 | getProjectFaviconCacheKey("environment-1", "/workspace", refreshedUrl), | ||
| 17 | ); | ||
| 18 | expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( | ||
| 19 | getProjectFaviconCacheKey( | ||
| 20 | "environment-1", | ||
| 21 | "/workspace", | ||
| 22 | "https://environment.example/api/assets/refreshed-signed-token/v2-20-favicon.svg", | ||
| 23 | ), | ||
| 24 | ); | ||
| 25 | expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( | ||
| 26 | getProjectFaviconCacheKey("environment-2", "/workspace", firstUrl), | ||
| 27 | ); | ||
| 28 | }); | ||
| 29 | |||
| 6 | it("identifies fallback asset URLs by their dedicated filename", () => { | 30 | it("identifies fallback asset URLs by their dedicated filename", () => { |
| 7 | expect( | 31 | expect( |
| 8 | isProjectFaviconFallbackUrl( | 32 | isProjectFaviconFallbackUrl( |
packages/shared/src/projectFavicon.ts+17| ... | @@ -1,5 +1,22 @@ | ... | @@ -1,5 +1,22 @@ |
| 1 | export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; | 1 | export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; |
| 2 | 2 | ||
| 3 | export function getProjectFaviconCacheKey( | ||
| 4 | environmentId: string, | ||
| 5 | workspaceRoot: string, | ||
| 6 | url: string, | ||
| 7 | ) { | ||
| 8 | let revision = url; | ||
| 9 | |||
| 10 | try { | ||
| 11 | const pathname = new URL(url, "https://t3.invalid").pathname; | ||
| 12 | revision = pathname.slice(pathname.lastIndexOf("/") + 1); | ||
| 13 | } catch { | ||
| 14 | // Keep the full value as a safe fallback for malformed URLs. | ||
| 15 | } | ||
| 16 | |||
| 17 | return JSON.stringify([environmentId, workspaceRoot, revision]); | ||
| 18 | } | ||
| 19 | |||
| 3 | export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean { | 20 | export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean { |
| 4 | if (!url) return false; | 21 | if (!url) return false; |
| 5 | 22 |