diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 97c61f9c7..6efe10968 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -22,7 +22,7 @@ - Content pages have no revalidate timer. `src/libs/orpc/client.rsc.ts` tags each server read with `WwwCacheTag` or `wwwFeedCacheTag`, and `workflow` invalidates those tags through the Vercel API, one five-minute window per tag; nothing calls into www. A write that changes what www renders must end in `scheduleFeedRevalidationStep` (in a workflow) or `workflow.startSiteRevalidation(tag)`. - The blog navigation and related posts load in the browser, so no cached page depends on a post other than its own. Home and projects keep a timer because Spotify and GitHub send no change event. - Pages set canonical, hreflang and `og:url` through `localizedMetadata` in `src/libs/i18n/alternates.ts`; the middleware sends no alternate links. Every URL the site emits (links, sitemap, RSS, `feedUrl`) leaves the default locale unprefixed, because `/zh-TW/…` redirects. -- Browser requests use the Better Auth session cookie and may access only public or caller-owned procedures. +- Browser requests use the Better Auth session cookie and may access only public or caller-owned procedures. Shared reads (`@chia/services/shared/shared-reads`) go without credentials so a CDN can serve them. - Content rendering belongs to `@chia/contents`, localization to `@chia/i18n` and shared agent UI to `@chia/agent-elements`. - Render access from `session.access`; do not infer authorization from failed requests or the raw role column. - `next/image` resizes through Cloudflare Image Transformations (`/cdn-cgi/image`) on the proxied production zone and serves originals elsewhere. Allowed image hosts are the zone's transformation origins, not `next.config` patterns; that list must name the site host (`chia1104.dev`) and `storage` too, because it replaces the same-zone default. `www` 301s to the apex at the Cloudflare edge. @@ -41,6 +41,7 @@ - Business logic belongs in packages. Database access goes through `@chia/db/repos/*`. - The service starts, resumes and cancels workflows but never executes them. - Keep heavy provider and agent dependencies behind dynamic imports so route imports stay lightweight. +- A Cloudflare cache rule makes GETs under `/api/v1/rpc/` cacheable, honoring the response's cache headers and bypassing the cache when there are none. ## `workflow` diff --git a/apps/service/__tests__/spotify.controller.test.ts b/apps/service/__tests__/spotify.controller.test.ts index f16b45062..27fe3bd5f 100644 --- a/apps/service/__tests__/spotify.controller.test.ts +++ b/apps/service/__tests__/spotify.controller.test.ts @@ -49,7 +49,17 @@ describe("Spotify Controller", () => { id: "playlist-id", }); mocks.getSpotifyNowPlayingService.mockResolvedValue({ - is_playing: true, + track: { + name: "Song", + url: "https://open.spotify.com/track/1", + artists: ["Artist"], + album: "Album", + imageUrl: null, + durationMs: 200_000, + }, + isPlaying: true, + progressMs: 60_000, + observedAt: 1_000_000, }); mocks.completeSpotifyAuthorizationService.mockResolvedValue("connected"); }); @@ -67,10 +77,41 @@ describe("Spotify Controller", () => { expect(res.status).toBe(200); expect(mocks.getSpotifyNowPlayingService).toHaveBeenCalledWith( + expect.anything(), expect.anything() ); }); + it("serves GET as a shared read any origin may cache", async () => { + const res = await app.request("/api/v1/rpc/spotify/playing", { + headers: { Origin: "http://localhost:3000" }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get("cdn-cache-control")).toContain("max-age="); + expect(res.headers.get("cache-control")).toBe("no-cache"); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect(res.headers.get("access-control-allow-credentials")).toBeNull(); + }); + + it("leaves POST responses uncached", async () => { + const res = await playing(); + + expect(res.headers.get("cdn-cache-control")).toBeNull(); + }); + + it("does not cache a failed GET", async () => { + mocks.getSpotifyNowPlayingService.mockRejectedValueOnce( + new mocks.SpotifyCredentialUnavailableError() + ); + + const res = await app.request("/api/v1/rpc/spotify/playing"); + + expect(res.status).toBe(503); + expect(res.headers.get("cdn-cache-control")).toBeNull(); + expect(res.headers.get("cache-control")).toBeNull(); + }); + it("returns 503 when no active or fallback credential exists", async () => { mocks.getSpotifyNowPlayingService.mockRejectedValueOnce( new mocks.SpotifyCredentialUnavailableError() @@ -85,6 +126,17 @@ describe("Spotify Controller", () => { describe("spotify.playlist", () => { beforeEach(() => guardMocks.setCallerTier(CallerTier.ApiKey)); + it("does not answer GET, which only shared reads accept", async () => { + const res = await app.request( + `/api/v1/rpc/spotify/playlist?data=${encodeURIComponent( + JSON.stringify({ json: { playlistId: "default" } }) + )}` + ); + + expect(res.status).toBe(404); + expect(mocks.getSpotifyPlaylistService).not.toHaveBeenCalled(); + }); + it("returns the default playlist", async () => { const res = await playlist("default"); diff --git a/apps/service/__tests__/spotify.service.test.ts b/apps/service/__tests__/spotify.service.test.ts index 016eca43a..6d02aedcb 100644 --- a/apps/service/__tests__/spotify.service.test.ts +++ b/apps/service/__tests__/spotify.service.test.ts @@ -59,6 +59,16 @@ import { const db = {} as DB; +const store = new Map(); +const kv = { + get: vi.fn((key: string) => store.get(key)), + set: vi.fn((key: string, value: unknown) => { + store.set(key, value); + return true; + }), + delete: vi.fn((key: string) => store.delete(key)), +} as unknown as Keyv; + const createCredential = (expiresAt: Date) => ({ userId: "admin-id", spotifyUserId: "spotify-id", @@ -165,9 +175,91 @@ describe("Spotify now playing service", () => { ); }; const unauthorizedError = createHTTPError(401, "Unauthorized"); + const spotifyTrack = (progressMs: number) => ({ + is_playing: true, + progress_ms: progressMs, + item: { + name: "Song", + duration_ms: 200_000, + external_urls: { spotify: "https://open.spotify.com/track/1" }, + artists: [{ name: "Artist" }, { name: "Featured" }], + album: { name: "Album", images: [{ url: "https://i.scdn.co/image/1" }] }, + }, + }); beforeEach(() => { + store.clear(); vi.clearAllMocks(); + vi.useRealTimers(); + mocks.getActiveSpotifyCredential.mockResolvedValue( + createCredential(new Date(Date.now() + 10 * 60 * 1000)) + ); + }); + + it("returns the track and when its progress was read", async () => { + vi.useFakeTimers({ now: 1_000_000 }); + mocks.getNowPlaying.mockResolvedValueOnce(spotifyTrack(60_000)); + + await expect(getSpotifyNowPlayingService(db, kv)).resolves.toEqual({ + track: { + name: "Song", + url: "https://open.spotify.com/track/1", + artists: ["Artist", "Featured"], + album: "Album", + imageUrl: "https://i.scdn.co/image/1", + durationMs: 200_000, + }, + isPlaying: true, + progressMs: 60_000, + observedAt: 1_000_000, + }); + }); + + it("returns null while an ad plays", async () => { + mocks.getNowPlaying.mockResolvedValueOnce({ + is_playing: true, + progress_ms: 0, + item: null, + }); + + await expect(getSpotifyNowPlayingService(db, kv)).resolves.toBeNull(); + }); + + it("shares one Spotify call within the cache window", async () => { + vi.useFakeTimers({ now: 1_000_000 }); + mocks.getNowPlaying.mockResolvedValueOnce(spotifyTrack(60_000)); + + await getSpotifyNowPlayingService(db, kv); + vi.advanceTimersByTime(5000); + + await expect(getSpotifyNowPlayingService(db, kv)).resolves.toMatchObject({ + progressMs: 60_000, + observedAt: 1_000_000, + }); + expect(mocks.getNowPlaying).toHaveBeenCalledTimes(1); + }); + + it("asks Spotify again once the cached track would have ended", async () => { + vi.useFakeTimers({ now: 1_000_000 }); + mocks.getNowPlaying + .mockResolvedValueOnce(spotifyTrack(198_000)) + .mockResolvedValueOnce(spotifyTrack(1000)); + + await getSpotifyNowPlayingService(db, kv); + vi.advanceTimersByTime(3000); + + await expect(getSpotifyNowPlayingService(db, kv)).resolves.toMatchObject({ + progressMs: 1000, + }); + expect(mocks.getNowPlaying).toHaveBeenCalledTimes(2); + }); + + it("caches an idle player", async () => { + mocks.getNowPlaying.mockResolvedValueOnce(null); + + await expect(getSpotifyNowPlayingService(db, kv)).resolves.toBeNull(); + await expect(getSpotifyNowPlayingService(db, kv)).resolves.toBeNull(); + expect(mocks.getNowPlaying).toHaveBeenCalledTimes(1); }); it("forces a refresh and retries when the stored token was revoked", async () => { @@ -187,10 +279,10 @@ describe("Spotify now playing service", () => { }); mocks.getNowPlaying .mockRejectedValueOnce(unauthorizedError) - .mockResolvedValueOnce({ is_playing: true }); + .mockResolvedValueOnce(spotifyTrack(1000)); - await expect(getSpotifyNowPlayingService(db)).resolves.toEqual({ - is_playing: true, + await expect(getSpotifyNowPlayingService(db, kv)).resolves.toMatchObject({ + progressMs: 1000, }); expect(mocks.getNowPlaying).toHaveBeenNthCalledWith(1, { accessToken: "stored-access-token", @@ -216,7 +308,7 @@ describe("Spotify now playing service", () => { ); mocks.getNowPlaying.mockRejectedValueOnce(unauthorizedError); - await expect(getSpotifyNowPlayingService(db)).rejects.toBeInstanceOf( + await expect(getSpotifyNowPlayingService(db, kv)).rejects.toBeInstanceOf( SpotifyCredentialUnavailableError ); }); @@ -228,7 +320,7 @@ describe("Spotify now playing service", () => { createHTTPError(500, "Internal Server Error") ); - await expect(getSpotifyNowPlayingService(db)).rejects.toThrow( + await expect(getSpotifyNowPlayingService(db, kv)).rejects.toThrow( "500 Internal Server Error" ); expect(mocks.refreshSpotifyAccessToken).not.toHaveBeenCalled(); @@ -236,16 +328,6 @@ describe("Spotify now playing service", () => { }); describe("Spotify authorization service", () => { - const store = new Map(); - const kv = { - get: vi.fn((key: string) => store.get(key)), - set: vi.fn((key: string, value: unknown) => { - store.set(key, value); - return true; - }), - delete: vi.fn((key: string) => store.delete(key)), - } as unknown as Keyv; - beforeEach(() => { store.clear(); vi.clearAllMocks(); diff --git a/apps/service/src/routes/rpc.route.ts b/apps/service/src/routes/rpc.route.ts index dc1d2ceec..e17087ede 100644 --- a/apps/service/src/routes/rpc.route.ts +++ b/apps/service/src/routes/rpc.route.ts @@ -1,9 +1,11 @@ import { trace } from "@opentelemetry/api"; import { RPCHandler } from "@orpc/server/fetch"; +import { RPC_DEFAULT_ALLOW_METHODS } from "@orpc/server/standard"; import { Hono } from "hono"; import { timeout } from "hono/timeout"; import { router } from "@chia/services/router"; +import { SHARED_READS } from "@chia/services/shared/shared-reads"; import { env } from "../env"; import { @@ -32,6 +34,11 @@ const isUntimedProcedure = (path: string): boolean => /** Built once per process; holds no per-request state. */ const handler = new RPCHandler(router, { errorStatusMap, + // GET is CSRF-safe only for reads that change nothing and ignore the caller's cookies. + allowMethods: (method, _procedure, path) => + method === "GET" + ? SHARED_READS.has(path.join(".")) + : RPC_DEFAULT_ALLOW_METHODS.includes(method), // Around the procedure call only: a body that fails to decode is oRPC's own BAD_REQUEST. clientInterceptors: [ (options) => withErrorReporting(options.context, () => options.next()), @@ -60,6 +67,16 @@ const api = new Hono() c.set("rpcProcedure", procedure); trace.getActiveSpan()?.setAttribute("rpc.method", procedure); } + const cdnCacheControl = procedure && SHARED_READS.get(procedure); + // Through `c.header`: Hono copies the CORS headers set earlier onto whatever is returned. + if (c.req.method === "GET" && response.ok && cdnCacheControl) { + c.header("CDN-Cache-Control", cdnCacheControl); + // Browsers ask again on every poll; the page decides when to. + c.header("Cache-Control", "no-cache"); + // A CDN stores one copy for every origin and ignores `Vary: Origin`. + c.header("Access-Control-Allow-Origin", "*"); + c.header("Access-Control-Allow-Credentials", undefined); + } return c.newResponse(response.body, response); } diff --git a/apps/www/src/components/commons/current-playing.tsx b/apps/www/src/components/commons/current-playing.tsx index 4e641d3ad..ac3c2da49 100644 --- a/apps/www/src/components/commons/current-playing.tsx +++ b/apps/www/src/components/commons/current-playing.tsx @@ -1,15 +1,8 @@ "use client"; import Link from "next/link"; -import type { ReactNode, Dispatch, SetStateAction } from "react"; -import { - useState, - useEffect, - createContext, - use, - useCallback, - useTransition, -} from "react"; +import type { ReactNode } from "react"; +import { useState, useCallback, useTransition } from "react"; import { ProgressBar, Tooltip } from "@heroui/react"; import type { UseQueryResult } from "@tanstack/react-query"; @@ -46,65 +39,34 @@ interface Props extends ExtendsProps { >[0]; } -/** Local progress advances between polls; the next poll corrects any drift. */ -const PROGRESS_TICK_MS = 1000; - -const ProgressContext = createContext< - [number, Dispatch>] | undefined ->(undefined); +type NowPlaying = NonNullable; -const useProgressContext = () => { - const context = use(ProgressContext); - if (!context) { - throw new Error( - "useProgressContext must be used within a ProgressProvider" - ); +const PROGRESS_TICK_MS = 1000; +const IDLE_REFETCH_MS = 60_000; +/** The next track needs a moment to register with Spotify after the last one ends. */ +const TRACK_CHANGE_GRACE_MS = 2000; +/** A shared cache may still hold a track that has ended; this keeps refetches from spinning. */ +const MIN_REFETCH_MS = 5000; + +const progressAt = (nowPlaying: NowPlaying, now: number) => + nowPlaying.isPlaying + ? Math.min( + nowPlaying.progressMs + now - nowPlaying.observedAt, + nowPlaying.track.durationMs + ) + : nowPlaying.progressMs; + +/** Refetch as the track should end; the idle cap still catches a skip or pause. */ +const refetchIntervalFor = (nowPlaying: CurrentPlayingResponse | undefined) => { + if (!nowPlaying?.isPlaying) { + return IDLE_REFETCH_MS; } - return context; -}; -const ProgressProvider = ({ - children, - initialProgress, -}: { - children: ReactNode; - initialProgress: number; -}) => { - const [progress, setProgress] = useState(initialProgress); - return ( - - {children} - - ); -}; - -const useProgressTracking = ( - isPlaying: boolean, - durationMs: number, - progressMs: number, - isFetching: boolean, - isSuccess: boolean, - refetch: () => Promise> -) => { - const [, setProgress] = useProgressContext(); - - useEffect(() => { - if (!isFetching && isSuccess && isPlaying) { - setProgress(progressMs); - } - }, [isPlaying, isFetching, isSuccess, progressMs, setProgress]); - - useInterval( - () => { - setProgress((prev) => { - if (prev < durationMs) { - return prev + PROGRESS_TICK_MS; - } - void refetch(); - return 0; - }); - }, - isPlaying ? PROGRESS_TICK_MS : null + const remaining = + nowPlaying.track.durationMs - progressAt(nowPlaying, Date.now()); + return Math.min( + Math.max(remaining + TRACK_CHANGE_GRACE_MS, MIN_REFETCH_MS), + IDLE_REFETCH_MS ); }; @@ -142,9 +104,14 @@ const getTextColorClass = ( return isLight ? "text-dark" : "text-light"; }; -const TrackProgress = ({ durationMs }: { durationMs: number }) => { - const [progress] = useProgressContext(); - const percentage = (progress / (durationMs || 1)) * 100; +const TrackProgress = ({ nowPlaying }: { nowPlaying: NowPlaying }) => { + const [now, setNow] = useState(Date.now); + useInterval( + () => setNow(Date.now()), + nowPlaying.isPlaying ? PROGRESS_TICK_MS : null + ); + const percentage = + (progressAt(nowPlaying, now) / (nowPlaying.track.durationMs || 1)) * 100; return ( @@ -159,7 +126,7 @@ const AlbumImage = ({ data, onLoad, }: { - data: CurrentPlayingResponse; + data: NowPlaying; onLoad: (img: HTMLImageElement) => void; }) => ( onLoad(e.currentTarget)} - src={data?.item.album.images[0]?.url ?? ""} - alt={data?.item.album.name ?? ""} + src={data.track.imageUrl ?? ""} + alt={data.track.album} className="m-0 size-20 rounded-2xl bg-gray-400 object-cover" /> ); @@ -227,11 +194,11 @@ const PlayingLink = ({ - {data.item.name} - {data.item.artists[0]?.name} + {data.track.name} - {data.track.artists[0]} @@ -242,8 +209,6 @@ const Card = ({ data, isLoading, isSuccess, - isFetching, - refetch, className, tooltipContentClassName, experimental, @@ -251,18 +216,9 @@ const Card = ({ const enableColorExtraction = experimental?.displayBackgroundColorFromImage ?? false; - useProgressTracking( - data?.is_playing ?? false, - data?.item.duration_ms ?? 0, - data?.progress_ms ?? 0, - isFetching, - isSuccess, - refetch - ); - const { bgRGB, isLight, isPending, handleImageLoad } = useImageColorExtraction( - data?.item.album.images[0]?.url, + data?.track.imageUrl ?? undefined, enableColorExtraction ); @@ -319,17 +275,17 @@ const Card = ({ : "prose dark:prose-invert" )}>

- {data.item.artists[0]?.name} + {data.track.artists[0]}

- {isSuccess && } + {isSuccess && } )} @@ -347,19 +303,6 @@ export const LoadingSkeleton = ({ className }: { className?: string }) => ( ); -const calculateRefetchInterval = ( - data: CurrentPlayingResponse | undefined -): number => { - if (!data?.is_playing || !data.item.duration_ms || !data.progress_ms) { - return 60_000; - } - - const remainingTime = data.item.duration_ms - data.progress_ms; - const delta = remainingTime / 2; - - return delta > 30_000 ? delta : 30_000; -}; - export const CurrentPlaying = ({ children, queryOptions, @@ -370,7 +313,7 @@ export const CurrentPlaying = ({ const result = useQuery( orpc.spotify.playing.queryOptions({ ...queryOptions, - refetchInterval: (ctx) => calculateRefetchInterval(ctx.state.data), + refetchInterval: (ctx) => refetchIntervalFor(ctx.state.data), refetchOnWindowFocus: "always", }) ); @@ -384,13 +327,11 @@ export const CurrentPlaying = ({ } return ( - - - + ); }; diff --git a/apps/www/src/libs/orpc/client.ts b/apps/www/src/libs/orpc/client.ts index be4341888..649548653 100644 --- a/apps/www/src/libs/orpc/client.ts +++ b/apps/www/src/libs/orpc/client.ts @@ -4,6 +4,7 @@ import type { RouterContractClient } from "@orpc/contract"; import { createTanstackQueryUtils } from "@orpc/tanstack-query"; import type { routerContract } from "@chia/services/router.contract"; +import { SHARED_READS } from "@chia/services/shared/shared-reads"; import { withServiceEndpoint } from "@chia/utils/config"; import { Service } from "@chia/utils/schema"; @@ -22,8 +23,14 @@ export const link = new RPCLink({ origin: endpoint.origin, /** `URL.pathname` always starts with `/`, so this is the pathname unchanged. */ url: `/${endpoint.pathname.slice(1)}`, + method: (_options, path) => + SHARED_READS.has(path.join(".")) ? "GET" : "POST", + /** A shared read is answered with `Access-Control-Allow-Origin: *`, which a credentialed request may not read. */ fetch: (url, init) => - globalThis.fetch(url, { ...init, credentials: "include" }), + globalThis.fetch(url, { + ...init, + credentials: init.method === "GET" ? "omit" : "include", + }), }); export const client: RouterContractClient = diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 7cf579458..42743a265 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -52,7 +52,16 @@ export async function getConnection( kv && DrizzleCache ? new DrizzleCache(kv, cacheOptions) : undefined; const connection = (async () => { - const db = drizzle(url, { + const db = drizzle({ + connection: { + connectionString: url, + // pg's 10s default closes the pool between most requests at this traffic, and each + // reconnect costs ~25ms. + idleTimeoutMillis: 5 * 60_000, + // Idle clients must not hold the process open, or one-shot scripts like `migrate.ts` + // wait out the timeout. + allowExitOnIdle: true, + }, relations, cache, codecs: storableCodecs, diff --git a/packages/service-kit/src/bootstrap.ts b/packages/service-kit/src/bootstrap.ts index b849ebeb7..e422ae0c9 100644 --- a/packages/service-kit/src/bootstrap.ts +++ b/packages/service-kit/src/bootstrap.ts @@ -121,6 +121,9 @@ export const bootstrap = < cors({ origin: options.cors.origin, credentials: options.cors.credentials, + // Every oRPC call is a JSON POST and so preflighted; without this, browsers re-ask after 5s. + // Chromium caps the cache at 7200. + maxAge: 7200, }) ); } diff --git a/packages/services/AGENTS.md b/packages/services/AGENTS.md index 742b4b632..7f9d4fae6 100644 --- a/packages/services/AGENTS.md +++ b/packages/services/AGENTS.md @@ -13,6 +13,7 @@ Contract-first oRPC API and the domain services behind it. Third-party clients l - Guards bind transport input to policies from `@chia/service-kit`; do not duplicate authorization logic in handlers. - Procedure rate-limit budgets belong in `shared/rate-limits.ts`. Mount-level budgets belong to the hosting app. +- A procedure in `shared/shared-reads.ts` must answer every caller alike and change nothing. `service` accepts GET only for those and marks a success with the listed `CDN-Cache-Control` and `Access-Control-Allow-Origin: *`; browser links call them as credential-less GETs. The module imports nothing so links can load it. ## Domain boundary diff --git a/packages/services/package.json b/packages/services/package.json index 931638913..c180c0587 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -33,6 +33,10 @@ "types": "./shared/guards/rate-limit.guard.ts", "default": "./shared/guards/rate-limit.guard.ts" }, + "./shared/shared-reads": { + "types": "./shared/shared-reads.ts", + "default": "./shared/shared-reads.ts" + }, "./agent/agent.contract": { "types": "./agent/agent.contract.ts", "default": "./agent/agent.contract.ts" diff --git a/packages/services/shared/shared-reads.ts b/packages/services/shared/shared-reads.ts new file mode 100644 index 000000000..3fd10aebc --- /dev/null +++ b/packages/services/shared/shared-reads.ts @@ -0,0 +1,9 @@ +/** + * Public procedures whose answer is the same for every caller, mapped to the `CDN-Cache-Control` + * a success carries. They travel as credential-less GETs, which skip the CORS preflight and let a + * CDN answer them. Imports nothing, so browser links read it as well as the handler. + */ +export const SHARED_READS: ReadonlyMap = new Map([ + // Not `s-maxage`: it implies `proxy-revalidate`, which turns off `stale-while-revalidate`. + ["spotify.playing", "max-age=10, stale-while-revalidate=50"], +]); diff --git a/packages/services/spotify/playback.service.ts b/packages/services/spotify/playback.service.ts index d9d2d94bc..f8f50da4c 100644 --- a/packages/services/spotify/playback.service.ts +++ b/packages/services/spotify/playback.service.ts @@ -1,3 +1,4 @@ +import type Keyv from "keyv"; import { HTTPError } from "ky"; import type { DB } from "@chia/db/client"; @@ -13,12 +14,21 @@ import { refreshSpotifyAccessToken, } from "@chia/integrations/spotify"; import { env } from "@chia/integrations/spotify/env"; +import type { CurrentPlaying } from "@chia/integrations/spotify/types"; import { logger } from "@chia/observability/logger"; import { SpotifyCredentialUnavailableError } from "./account.service"; +import type { SpotifyNowPlaying } from "./spotify.contract"; const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60_000; +const NOW_PLAYING_CACHE_KEY = "spotify:now-playing"; +const NOW_PLAYING_CACHE_TTL_MS = 10_000; + +interface CachedNowPlaying { + nowPlaying: SpotifyNowPlaying; +} + const isAccessTokenUsable = (expiresAt: Date) => { return expiresAt.getTime() - ACCESS_TOKEN_EXPIRY_BUFFER_MS > Date.now(); }; @@ -104,7 +114,7 @@ export const getSpotifyPlaylistService = (playlistId: string) => { }); }; -export const getSpotifyNowPlayingService = async (db: DB) => { +const fetchNowPlaying = async (db: DB) => { const accessToken = await resolveSpotifyAccessToken(db); if (!accessToken) { throw new SpotifyCredentialUnavailableError(); @@ -133,3 +143,53 @@ export const getSpotifyNowPlayingService = async (db: DB) => { return getNowPlaying({ accessToken: refreshedAccessToken }); } }; + +const toNowPlaying = ( + playing: CurrentPlaying | null, + observedAt: number +): SpotifyNowPlaying => { + // Spotify sends a null `item` for ads and podcasts, which the payload type does not model. + if (!playing?.item) { + return null; + } + + const { item } = playing; + return { + track: { + name: item.name, + url: item.external_urls.spotify, + artists: item.artists.map((artist) => artist.name), + album: item.album.name, + imageUrl: item.album.images[0]?.url ?? null, + durationMs: item.duration_ms, + }, + isPlaying: playing.is_playing, + progressMs: playing.progress_ms, + observedAt, + }; +}; + +/** Stale even inside the TTL, or the reader's end-of-track refetch would get the same track back. */ +const hasEnded = (nowPlaying: SpotifyNowPlaying) => + !!nowPlaying?.isPlaying && + nowPlaying.observedAt - nowPlaying.progressMs + nowPlaying.track.durationMs <= + Date.now(); + +/** Visitors share one Spotify call per cache window. */ +export const getSpotifyNowPlayingService = async ( + db: DB, + kv: Keyv +): Promise => { + const cached = await kv.get(NOW_PLAYING_CACHE_KEY); + if (cached && !hasEnded(cached.nowPlaying)) { + return cached.nowPlaying; + } + + const nowPlaying = toNowPlaying(await fetchNowPlaying(db), Date.now()); + await kv.set( + NOW_PLAYING_CACHE_KEY, + { nowPlaying }, + NOW_PLAYING_CACHE_TTL_MS + ); + return nowPlaying; +}; diff --git a/packages/services/spotify/spotify.contract.ts b/packages/services/spotify/spotify.contract.ts index c547810b9..04f15b2c6 100644 --- a/packages/services/spotify/spotify.contract.ts +++ b/packages/services/spotify/spotify.contract.ts @@ -1,10 +1,7 @@ import { oc } from "@orpc/contract"; import * as z from "zod"; -import type { - CurrentPlaying, - PlayList, -} from "@chia/integrations/spotify/types"; +import type { PlayList } from "@chia/integrations/spotify/types"; import { spotifyCredentialUserSchema } from "@chia/integrations/spotify/validator"; /** @@ -13,7 +10,26 @@ import { spotifyCredentialUserSchema } from "@chia/integrations/spotify/validato /** Spotify owns the payload shape; `z.custom` keeps types exact with no runtime validation. */ const spotifyPlaylistSchema = z.custom(); -const spotifyNowPlayingSchema = z.custom(); + +/** Null when nothing a visitor can open is playing, such as an ad or a podcast. */ +const spotifyNowPlayingSchema = z + .object({ + track: z.object({ + name: z.string(), + url: z.string(), + artists: z.array(z.string()), + album: z.string(), + imageUrl: z.string().nullable(), + durationMs: z.number(), + }), + isPlaying: z.boolean(), + progressMs: z.number(), + /** Epoch ms `progressMs` was read at; while playing, readers advance it from here. */ + observedAt: z.number(), + }) + .nullable(); + +export type SpotifyNowPlaying = z.infer; const spotifyAccountSchema = z.object({ userId: z.string(), @@ -54,7 +70,7 @@ export const getSpotifyPlaylistContract = oc .input(z.object({ playlistId: z.string().min(1) })) .output(spotifyPlaylistSchema); -/** Reached from the browser, so it stays public. */ +/** Reached from the browser, so it stays public; a shared read, see `shared/shared-reads.ts`. */ export const getSpotifyNowPlayingContract = oc .errors({ SERVICE_UNAVAILABLE: {}, diff --git a/packages/services/spotify/spotify.route.ts b/packages/services/spotify/spotify.route.ts index 5cdd0c9d0..21df7d224 100644 --- a/packages/services/spotify/spotify.route.ts +++ b/packages/services/spotify/spotify.route.ts @@ -36,7 +36,10 @@ export const getSpotifyNowPlayingRoute = contractOS.spotify.playing .use(rateLimitGuard("spotify")) .handler(async (opts) => { try { - return await getSpotifyNowPlayingService(opts.context.db); + return await getSpotifyNowPlayingService( + opts.context.db, + opts.context.kv + ); } catch (error) { // Unconfigured (no account, no fallback refresh token) is 503, not a crash. if (error instanceof SpotifyCredentialUnavailableError) {