From 7d24993b0823eaa1e680ae5071242c69f50f9059 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 21 Dec 2025 15:20:30 -0800 Subject: [PATCH] Update commit checkpoints, add API route for stream viewers, and synchronize viewer count to database --- .ai/commit-checkpoints.json | 4 +- packages/web/src/lib/jazz/useStreamViewers.ts | 20 +++- packages/web/src/routeTree.gen.ts | 41 ++++++- .../routes/api/streams.$username.viewers.ts | 105 ++++++++++++++++++ 4 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 packages/web/src/routes/api/streams.$username.viewers.ts diff --git a/.ai/commit-checkpoints.json b/.ai/commit-checkpoints.json index 6e6fa9c6..d6bd15e6 100644 --- a/.ai/commit-checkpoints.json +++ b/.ai/commit-checkpoints.json @@ -1,7 +1,7 @@ { "last_commit": { - "timestamp": "2025-12-21T23:12:34.993938+00:00", + "timestamp": "2025-12-21T23:15:16.584895+00:00", "session_id": "60ef8f57-a5e4-4edb-a61d-0a4778a6de32", - "last_entry_timestamp": "2025-12-21T23:12:32.638Z" + "last_entry_timestamp": "2025-12-21T23:15:15.232Z" } } \ No newline at end of file diff --git a/packages/web/src/lib/jazz/useStreamViewers.ts b/packages/web/src/lib/jazz/useStreamViewers.ts index 791e8907..439ddec8 100644 --- a/packages/web/src/lib/jazz/useStreamViewers.ts +++ b/packages/web/src/lib/jazz/useStreamViewers.ts @@ -117,7 +117,7 @@ export function useStreamViewers(username: string): UseStreamViewersResult { const perSession = presenceFeed.perSession if (perSession) { for (const sessionId of Object.keys(perSession)) { - const entry = (perSession as Record)[sessionId] + const entry = (presenceFeed.perSession as Record)[sessionId] if (entry?.value) { const age = now - entry.value.lastActive if (age < PRESENCE_STALE_MS) { @@ -139,6 +139,24 @@ export function useStreamViewers(username: string): UseStreamViewersResult { return () => clearInterval(interval) }, [presenceFeed?.$isLoaded, presenceFeed]) + // Sync viewer count to database for external access + useEffect(() => { + if (viewerCount === 0) return + + // Debounce the API call + const timeout = setTimeout(() => { + fetch(`/api/streams/${username}/viewers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ viewerCount }), + }).catch((err) => { + console.error("Failed to sync viewer count:", err) + }) + }, 1000) + + return () => clearTimeout(timeout) + }, [viewerCount, username]) + return { viewerCount, isConnected: me.$isLoaded, diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 5ff88bd8..a0cf7691 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -56,6 +56,7 @@ import { Route as DemoStartSsrIndexRouteImport } from './routes/demo/start.ssr.i import { Route as DemoStartSsrSpaModeRouteImport } from './routes/demo/start.ssr.spa-mode' import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr.full-ssr' import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only' +import { Route as ApiStreamsUsernameViewersRouteImport } from './routes/api/streams.$username.viewers' import { Route as ApiCanvasImagesImageIdRouteImport } from './routes/api/canvas.images.$imageId' import { Route as ApiCanvasImagesImageIdGenerateRouteImport } from './routes/api/canvas.images.$imageId.generate' @@ -295,6 +296,12 @@ const DemoStartSsrDataOnlyRoute = DemoStartSsrDataOnlyRouteImport.update({ path: '/demo/start/ssr/data-only', getParentRoute: () => rootRouteImport, } as any) +const ApiStreamsUsernameViewersRoute = + ApiStreamsUsernameViewersRouteImport.update({ + id: '/viewers', + path: '/viewers', + getParentRoute: () => ApiStreamsUsernameRoute, + } as any) const ApiCanvasImagesImageIdRoute = ApiCanvasImagesImageIdRouteImport.update({ id: '/$imageId', path: '/$imageId', @@ -343,7 +350,7 @@ export interface FileRoutesByFullPath { '/api/chat/guest': typeof ApiChatGuestRoute '/api/chat/mutations': typeof ApiChatMutationsRoute '/api/flowglad/$': typeof ApiFlowgladSplatRoute - '/api/streams/$username': typeof ApiStreamsUsernameRoute + '/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren '/api/stripe/checkout': typeof ApiStripeCheckoutRoute '/api/stripe/webhooks': typeof ApiStripeWebhooksRoute '/api/usage-events/create': typeof ApiUsageEventsCreateRoute @@ -352,6 +359,7 @@ export interface FileRoutesByFullPath { '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute '/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren + '/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute '/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute '/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute @@ -393,7 +401,7 @@ export interface FileRoutesByTo { '/api/chat/guest': typeof ApiChatGuestRoute '/api/chat/mutations': typeof ApiChatMutationsRoute '/api/flowglad/$': typeof ApiFlowgladSplatRoute - '/api/streams/$username': typeof ApiStreamsUsernameRoute + '/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren '/api/stripe/checkout': typeof ApiStripeCheckoutRoute '/api/stripe/webhooks': typeof ApiStripeWebhooksRoute '/api/usage-events/create': typeof ApiUsageEventsCreateRoute @@ -402,6 +410,7 @@ export interface FileRoutesByTo { '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute '/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren + '/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute '/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute '/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute @@ -445,7 +454,7 @@ export interface FileRoutesById { '/api/chat/guest': typeof ApiChatGuestRoute '/api/chat/mutations': typeof ApiChatMutationsRoute '/api/flowglad/$': typeof ApiFlowgladSplatRoute - '/api/streams/$username': typeof ApiStreamsUsernameRoute + '/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren '/api/stripe/checkout': typeof ApiStripeCheckoutRoute '/api/stripe/webhooks': typeof ApiStripeWebhooksRoute '/api/usage-events/create': typeof ApiUsageEventsCreateRoute @@ -454,6 +463,7 @@ export interface FileRoutesById { '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute '/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren + '/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute '/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute '/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute @@ -507,6 +517,7 @@ export interface FileRouteTypes { | '/demo/start/api-request' | '/demo/start/server-funcs' | '/api/canvas/images/$imageId' + | '/api/streams/$username/viewers' | '/demo/start/ssr/data-only' | '/demo/start/ssr/full-ssr' | '/demo/start/ssr/spa-mode' @@ -557,6 +568,7 @@ export interface FileRouteTypes { | '/demo/start/api-request' | '/demo/start/server-funcs' | '/api/canvas/images/$imageId' + | '/api/streams/$username/viewers' | '/demo/start/ssr/data-only' | '/demo/start/ssr/full-ssr' | '/demo/start/ssr/spa-mode' @@ -608,6 +620,7 @@ export interface FileRouteTypes { | '/demo/start/api-request' | '/demo/start/server-funcs' | '/api/canvas/images/$imageId' + | '/api/streams/$username/viewers' | '/demo/start/ssr/data-only' | '/demo/start/ssr/full-ssr' | '/demo/start/ssr/spa-mode' @@ -644,7 +657,7 @@ export interface RootRouteChildren { ApiChatGuestRoute: typeof ApiChatGuestRoute ApiChatMutationsRoute: typeof ApiChatMutationsRoute ApiFlowgladSplatRoute: typeof ApiFlowgladSplatRoute - ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRoute + ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRouteWithChildren ApiStripeCheckoutRoute: typeof ApiStripeCheckoutRoute ApiStripeWebhooksRoute: typeof ApiStripeWebhooksRoute DemoApiNamesRoute: typeof DemoApiNamesRoute @@ -987,6 +1000,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DemoStartSsrDataOnlyRouteImport parentRoute: typeof rootRouteImport } + '/api/streams/$username/viewers': { + id: '/api/streams/$username/viewers' + path: '/viewers' + fullPath: '/api/streams/$username/viewers' + preLoaderRoute: typeof ApiStreamsUsernameViewersRouteImport + parentRoute: typeof ApiStreamsUsernameRoute + } '/api/canvas/images/$imageId': { id: '/api/canvas/images/$imageId' path: '/$imageId' @@ -1115,6 +1135,17 @@ const ApiUsersRouteWithChildren = ApiUsersRoute._addFileChildren( ApiUsersRouteChildren, ) +interface ApiStreamsUsernameRouteChildren { + ApiStreamsUsernameViewersRoute: typeof ApiStreamsUsernameViewersRoute +} + +const ApiStreamsUsernameRouteChildren: ApiStreamsUsernameRouteChildren = { + ApiStreamsUsernameViewersRoute: ApiStreamsUsernameViewersRoute, +} + +const ApiStreamsUsernameRouteWithChildren = + ApiStreamsUsernameRoute._addFileChildren(ApiStreamsUsernameRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, UsernameRoute: UsernameRoute, @@ -1144,7 +1175,7 @@ const rootRouteChildren: RootRouteChildren = { ApiChatGuestRoute: ApiChatGuestRoute, ApiChatMutationsRoute: ApiChatMutationsRoute, ApiFlowgladSplatRoute: ApiFlowgladSplatRoute, - ApiStreamsUsernameRoute: ApiStreamsUsernameRoute, + ApiStreamsUsernameRoute: ApiStreamsUsernameRouteWithChildren, ApiStripeCheckoutRoute: ApiStripeCheckoutRoute, ApiStripeWebhooksRoute: ApiStripeWebhooksRoute, DemoApiNamesRoute: DemoApiNamesRoute, diff --git a/packages/web/src/routes/api/streams.$username.viewers.ts b/packages/web/src/routes/api/streams.$username.viewers.ts new file mode 100644 index 00000000..c6cc14a9 --- /dev/null +++ b/packages/web/src/routes/api/streams.$username.viewers.ts @@ -0,0 +1,105 @@ +import { createFileRoute } from "@tanstack/react-router" +import { eq } from "drizzle-orm" +import { getDb } from "@/db/connection" +import { streams, users } from "@/db/schema" + +const resolveDatabaseUrl = () => { + try { + const { getServerContext } = require("@tanstack/react-start/server") as { + getServerContext: () => { + cloudflare?: { env?: Record } + } | null + } + const ctx = getServerContext() + const url = ctx?.cloudflare?.env?.DATABASE_URL + if (url) return url + } catch { + // Not in server context + } + if (process.env.DATABASE_URL) return process.env.DATABASE_URL + throw new Error("DATABASE_URL not configured") +} + +// GET - fetch current viewer count from database +const getViewerCount = async ({ + params, +}: { + request: Request + params: { username: string } +}) => { + const { username } = params + + try { + const db = getDb(resolveDatabaseUrl()) + + const user = await db.query.users.findFirst({ + where: eq(users.username, username), + }) + + if (!user) { + return Response.json({ viewerCount: 0, username }) + } + + const stream = await db.query.streams.findFirst({ + where: eq(streams.user_id, user.id), + }) + + return Response.json({ + viewerCount: stream?.viewer_count ?? 0, + username, + isLive: stream?.is_live ?? false, + }) + } catch (error) { + console.error("Failed to get viewer count:", error) + return Response.json({ viewerCount: 0, username }) + } +} + +// POST - update viewer count (called by Jazz client) +const updateViewerCount = async ({ + request, + params, +}: { + request: Request + params: { username: string } +}) => { + const { username } = params + + try { + const body = await request.json() + const { viewerCount } = body as { viewerCount: number } + + if (typeof viewerCount !== "number" || viewerCount < 0) { + return Response.json({ error: "Invalid viewer count" }, { status: 400 }) + } + + const db = getDb(resolveDatabaseUrl()) + + const user = await db.query.users.findFirst({ + where: eq(users.username, username), + }) + + if (!user) { + return Response.json({ error: "User not found" }, { status: 404 }) + } + + await db + .update(streams) + .set({ viewer_count: viewerCount }) + .where(eq(streams.user_id, user.id)) + + return Response.json({ success: true, viewerCount, username }) + } catch (error) { + console.error("Failed to update viewer count:", error) + return Response.json({ error: "Failed to update" }, { status: 500 }) + } +} + +export const Route = createFileRoute("/api/streams/$username/viewers")({ + server: { + handlers: { + GET: getViewerCount, + POST: updateViewerCount, + }, + }, +})