Update commit checkpoints, add API route for stream viewers, and synchronize viewer count to database

This commit is contained in:
Nikita
2025-12-21 15:20:30 -08:00
parent e37e89dc4d
commit 7d24993b08
4 changed files with 162 additions and 8 deletions

View File

@@ -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"
}
}

View File

@@ -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<string, { value?: { lastActive: number } }>)[sessionId]
const entry = (presenceFeed.perSession as Record<string, { value?: { lastActive: number } }>)[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,

View File

@@ -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,

View File

@@ -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<string, string> }
} | 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,
},
},
})