diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx index 4643ee3..994970b 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx @@ -12,7 +12,7 @@ import { githubQueryKeys, } from "#/lib/github.query"; import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; -import { useGitHubSignalRefresh } from "#/lib/use-github-signal-refresh"; +import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; import { useHasMounted } from "#/lib/use-has-mounted"; import { useRegisterTab } from "#/lib/use-register-tab"; import { IssueDetailActivitySection } from "./issue-detail-activity"; @@ -49,11 +49,7 @@ export function IssueDetailPage() { ...githubIssuePageQueryOptions(scope, input), enabled: hasMounted, }); - useGitHubSignalRefresh({ - enabled: - hasMounted && pageQuery.data !== undefined && !pageQuery.isFetching, - targets: webhookRefreshTargets, - }); + useGitHubSignalStream(webhookRefreshTargets); const issue = pageQuery.data?.detail; const comments = pageQuery.data?.comments; diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx index 9adb80b..da65cba 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx @@ -13,7 +13,7 @@ import { githubViewerQueryOptions, } from "#/lib/github.query"; import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; -import { useGitHubSignalRefresh } from "#/lib/use-github-signal-refresh"; +import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; import { useHasMounted } from "#/lib/use-has-mounted"; import { useRegisterTab } from "#/lib/use-register-tab"; import { PullBodySection } from "./pull-body-section"; @@ -59,11 +59,7 @@ export function PullDetailPage() { ...githubViewerQueryOptions(scope), enabled: hasMounted, }); - useGitHubSignalRefresh({ - enabled: - hasMounted && pageQuery.data !== undefined && !pageQuery.isFetching, - targets: webhookRefreshTargets, - }); + useGitHubSignalStream(webhookRefreshTargets); const pr = pageQuery.data?.detail; const comments = pageQuery.data?.comments; diff --git a/apps/dashboard/src/components/pulls/review/review-page.tsx b/apps/dashboard/src/components/pulls/review/review-page.tsx index 109c3d7..f65dcbe 100644 --- a/apps/dashboard/src/components/pulls/review/review-page.tsx +++ b/apps/dashboard/src/components/pulls/review/review-page.tsx @@ -53,7 +53,7 @@ import type { PullReviewComment, } from "#/lib/github.types"; import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; -import { useGitHubSignalRefresh } from "#/lib/use-github-signal-refresh"; +import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; import { useRegisterTab } from "#/lib/use-register-tab"; import { checkPermissionWarning } from "#/lib/warning-store"; import type { ReviewDiffPaneHandle } from "./review-diff-pane"; @@ -174,15 +174,7 @@ export function ReviewPage() { enabled: hasDiffPayload, refetchOnWindowFocus: false, }); - useGitHubSignalRefresh({ - enabled: - pageQuery.data !== undefined && - !pageQuery.isFetching && - !fileSummariesQuery.isFetching && - !filesQuery.isFetching && - !reviewCommentsQuery.isFetching, - targets: webhookRefreshTargets, - }); + useGitHubSignalStream(webhookRefreshTargets); const pr = pageQuery.data?.detail ?? null; const sidebarFiles = fileSummariesQuery.data ?? []; diff --git a/apps/dashboard/src/entry-worker.ts b/apps/dashboard/src/entry-worker.ts new file mode 100644 index 0000000..b527bba --- /dev/null +++ b/apps/dashboard/src/entry-worker.ts @@ -0,0 +1,58 @@ +import startEntry from "@tanstack/react-start/server-entry"; + +export { SignalRelay } from "./lib/signal-relay.server"; + +async function handleWebSocketUpgrade( + request: Request, + env: Record, +): Promise { + const { getAuth } = await import("#/lib/auth.server"); + const session = await getAuth().api.getSession({ + headers: request.headers, + }); + + if (!session) { + return new Response("Unauthorized", { status: 401 }); + } + + const signalRelay = env.SIGNAL_RELAY as DurableObjectNamespace | undefined; + if (!signalRelay) { + return new Response("Signal relay not configured", { status: 503 }); + } + + const id = signalRelay.idFromName("global"); + const stub = signalRelay.get(id); + const doUrl = new URL(request.url); + doUrl.pathname = "/connect"; + + return stub.fetch( + new Request(doUrl.toString(), { headers: request.headers }), + ); +} + +export default { + async fetch( + request: Request, + env: Record, + ctx: ExecutionContext, + ): Promise { + const url = new URL(request.url); + + if ( + url.pathname === "/api/ws/signals" && + request.headers.get("Upgrade") === "websocket" + ) { + return handleWebSocketUpgrade(request, env); + } + + // TanStack Start's type only declares (request, env?) but the runtime + // handler created by @cloudflare/vite-plugin passes (request, env, ctx) + // through to the underlying Worker fetch signature. + type WorkerFetch = ( + request: Request, + env: Record, + ctx: ExecutionContext, + ) => Promise; + return (startEntry.fetch as unknown as WorkerFetch)(request, env, ctx); + }, +}; diff --git a/apps/dashboard/src/env.d.ts b/apps/dashboard/src/env.d.ts index b768ad2..e92403d 100644 --- a/apps/dashboard/src/env.d.ts +++ b/apps/dashboard/src/env.d.ts @@ -15,5 +15,6 @@ declare namespace Cloudflare { GITHUB_CLIENT_SECRET?: string; BETTER_AUTH_SECRET: string; BETTER_AUTH_URL: string; + SIGNAL_RELAY: DurableObjectNamespace; } } diff --git a/apps/dashboard/src/lib/github-revalidation.ts b/apps/dashboard/src/lib/github-revalidation.ts index c10d112..d94b40e 100644 --- a/apps/dashboard/src/lib/github-revalidation.ts +++ b/apps/dashboard/src/lib/github-revalidation.ts @@ -24,15 +24,6 @@ export const githubRevalidationSignalKeys = { `repoCode:${input.owner}/${input.repo}`, } as const; -export type GitHubRevalidationSignalRecord = { - signalKey: string; - updatedAt: number; -}; - -export type GitHubRevalidationSignalInput = { - signalKeys: string[]; -}; - function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object"; } diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 74391b5..8c82dae 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -60,14 +60,10 @@ import { createGitHubResponseMetadata, type GitHubConditionalHeaders, type GitHubFetchResult, - getGitHubRevalidationSignals, getOrRevalidateGitHubResource, } from "./github-cache"; import { githubCachePolicy } from "./github-cache-policy"; -import { - type GitHubRevalidationSignalInput, - githubRevalidationSignalKeys, -} from "./github-revalidation"; +import { githubRevalidationSignalKeys } from "./github-revalidation"; type GitHubClient = OctokitType; type AuthSession = { @@ -4326,20 +4322,6 @@ function identityValidator(data: TInput) { return data; } -export const getGitHubRevalidationSignalRecords = createServerFn({ - method: "POST", -}) - .inputValidator(identityValidator) - .handler(async ({ data }) => { - const { getRequestSession } = await import("./auth-runtime"); - const session = await getRequestSession(); - if (!session) { - return []; - } - - return getGitHubRevalidationSignals(data.signalKeys); - }); - export const getGitHubViewer = createServerFn({ method: "GET" }).handler( async () => { const context = await getGitHubContext(); diff --git a/apps/dashboard/src/lib/signal-relay-broadcast.server.ts b/apps/dashboard/src/lib/signal-relay-broadcast.server.ts new file mode 100644 index 0000000..b0d6d4a --- /dev/null +++ b/apps/dashboard/src/lib/signal-relay-broadcast.server.ts @@ -0,0 +1,32 @@ +import "@tanstack/react-start/server-only"; +import { debug } from "./debug"; + +export async function broadcastSignalKeys(signalKeys: string[]) { + try { + const { env } = await import("cloudflare:workers"); + const workerEnv = env as typeof env & { + SIGNAL_RELAY?: DurableObjectNamespace; + }; + + if (!workerEnv.SIGNAL_RELAY) { + debug( + "signal-relay", + "SIGNAL_RELAY binding not available, skipping broadcast", + ); + return; + } + + const id = workerEnv.SIGNAL_RELAY.idFromName("global"); + const stub = workerEnv.SIGNAL_RELAY.get(id); + + await stub.fetch("https://signal-relay/broadcast", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ signalKeys }), + }); + } catch (error) { + debug("signal-relay", "broadcast failed", { + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/apps/dashboard/src/lib/signal-relay.server.ts b/apps/dashboard/src/lib/signal-relay.server.ts new file mode 100644 index 0000000..7f49be6 --- /dev/null +++ b/apps/dashboard/src/lib/signal-relay.server.ts @@ -0,0 +1,102 @@ +import "@tanstack/react-start/server-only"; +import { DurableObject } from "cloudflare:workers"; + +type SubscribeMessage = { + type: "subscribe"; + keys: string[]; +}; + +function isSubscribeMessage(data: unknown): data is SubscribeMessage { + return ( + typeof data === "object" && + data !== null && + "type" in data && + data.type === "subscribe" && + "keys" in data && + Array.isArray(data.keys) && + data.keys.every((k: unknown) => typeof k === "string") + ); +} + +export class SignalRelay extends DurableObject { + private subscriptions = new Map>(); + + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === "/broadcast" && request.method === "POST") { + return this.handleBroadcast(request); + } + + if (url.pathname === "/connect") { + return this.handleConnect(request); + } + + return new Response("Not found", { status: 404 }); + } + + private handleConnect(request: Request): Response { + const upgradeHeader = request.headers.get("Upgrade"); + if (upgradeHeader !== "websocket") { + return new Response("Expected WebSocket upgrade", { status: 426 }); + } + + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + server.accept(); + this.subscriptions.set(server, new Set()); + + server.addEventListener("message", (event) => { + if (typeof event.data !== "string") return; + + try { + const message: unknown = JSON.parse(event.data); + if (isSubscribeMessage(message)) { + this.subscriptions.set(server, new Set(message.keys)); + } + } catch { + // ignore malformed messages + } + }); + + server.addEventListener("close", () => { + this.subscriptions.delete(server); + }); + + server.addEventListener("error", () => { + this.subscriptions.delete(server); + }); + + return new Response(null, { + status: 101, + webSocket: client, + } as ResponseInit); + } + + private async handleBroadcast(request: Request): Promise { + const body = (await request.json()) as { signalKeys?: string[] }; + const signalKeys = body.signalKeys; + if (!Array.isArray(signalKeys) || signalKeys.length === 0) { + return new Response("Missing signalKeys", { status: 400 }); + } + + const signalSet = new Set(signalKeys); + const payload = JSON.stringify({ type: "signals", keys: signalKeys }); + let notified = 0; + + for (const [ws, subscribedKeys] of this.subscriptions) { + const hasMatch = [...subscribedKeys].some((key) => signalSet.has(key)); + if (!hasMatch) continue; + + try { + ws.send(payload); + notified++; + } catch { + this.subscriptions.delete(ws); + } + } + + return Response.json({ ok: true, notified }); + } +} diff --git a/apps/dashboard/src/lib/use-github-signal-refresh.ts b/apps/dashboard/src/lib/use-github-signal-refresh.ts deleted file mode 100644 index a123d3d..0000000 --- a/apps/dashboard/src/lib/use-github-signal-refresh.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { type QueryKey, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useMemo, useRef } from "react"; -import { debug } from "./debug"; -import { getGitHubRevalidationSignalRecords } from "./github.functions"; - -export type GitHubSignalRefreshTarget = { - queryKey: QueryKey; - signalKeys: readonly string[]; -}; - -export function useGitHubSignalRefresh({ - enabled, - targets, -}: { - enabled: boolean; - targets: readonly GitHubSignalRefreshTarget[]; -}) { - const queryClient = useQueryClient(); - const checkedSignatureRef = useRef(null); - const signature = useMemo( - () => - JSON.stringify( - targets.map((target) => ({ - queryKey: target.queryKey, - signalKeys: Array.from(new Set(target.signalKeys)).sort(), - })), - ), - [targets], - ); - - useEffect(() => { - if (!enabled || targets.length === 0) { - return; - } - - if (checkedSignatureRef.current === signature) { - return; - } - - const signalKeys = Array.from( - new Set(targets.flatMap((target) => target.signalKeys)), - ); - if (signalKeys.length === 0) { - return; - } - - checkedSignatureRef.current = signature; - let cancelled = false; - - void (async () => { - const records = await getGitHubRevalidationSignalRecords({ - data: { signalKeys }, - }); - if (cancelled) { - return; - } - - const updatedAtBySignalKey = new Map( - records.map((record) => [record.signalKey, record.updatedAt]), - ); - - await Promise.all( - targets.map(async (target) => { - const queryState = queryClient.getQueryState(target.queryKey); - const queryUpdatedAt = queryState?.dataUpdatedAt ?? 0; - if (queryUpdatedAt === 0 || queryState?.fetchStatus === "fetching") { - return; - } - - const signalUpdatedAt = target.signalKeys.reduce( - (latest, signalKey) => - Math.max(latest, updatedAtBySignalKey.get(signalKey) ?? 0), - 0, - ); - - if (signalUpdatedAt <= queryUpdatedAt) { - return; - } - - debug("github-revalidation", "refreshing query after webhook", { - queryKey: target.queryKey, - queryUpdatedAt, - signalUpdatedAt, - }); - - await queryClient.invalidateQueries({ - queryKey: target.queryKey, - exact: true, - refetchType: "active", - }); - }), - ); - })().catch((error: unknown) => { - debug("github-revalidation", "webhook signal check failed", { - error: error instanceof Error ? error.message : String(error), - }); - }); - - return () => { - cancelled = true; - }; - }, [enabled, queryClient, signature, targets]); -} diff --git a/apps/dashboard/src/lib/use-github-signal-stream.ts b/apps/dashboard/src/lib/use-github-signal-stream.ts new file mode 100644 index 0000000..7e9ca5b --- /dev/null +++ b/apps/dashboard/src/lib/use-github-signal-stream.ts @@ -0,0 +1,203 @@ +import { type QueryKey, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef } from "react"; +import { debug } from "./debug"; + +export type GitHubSignalStreamTarget = { + queryKey: QueryKey; + signalKeys: readonly string[]; +}; + +type SignalMessage = { + type: "signals"; + keys: string[]; +}; + +function isSignalMessage(data: unknown): data is SignalMessage { + return ( + typeof data === "object" && + data !== null && + "type" in data && + data.type === "signals" && + "keys" in data && + Array.isArray(data.keys) + ); +} + +const RECONNECT_DELAY_MS = 3_000; + +function getWebSocketUrl() { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}/api/ws/signals`; +} + +export function useGitHubSignalStream( + targets: readonly GitHubSignalStreamTarget[], +) { + const queryClient = useQueryClient(); + const targetsRef = useRef(targets); + targetsRef.current = targets; + + const allSignalKeys = useMemo(() => { + return Array.from( + new Set(targets.flatMap((target) => [...target.signalKeys])), + ).sort(); + }, [targets]); + + // Stable string so the effect only re-runs when the actual keys change, + // not when the array reference changes. + const signalKeysKey = allSignalKeys.join(","); + + useEffect(() => { + if (signalKeysKey.length === 0) { + return; + } + + const keys = signalKeysKey.split(","); + let ws: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let disposed = false; + + function sendSubscription(socket: WebSocket) { + if (socket.readyState === WebSocket.OPEN) { + debug("github-signal-stream", "subscribing to signal keys", { + keys, + }); + socket.send(JSON.stringify({ type: "subscribe", keys })); + } + } + + function handleMessage(event: MessageEvent) { + if (typeof event.data !== "string") return; + + let message: unknown; + try { + message = JSON.parse(event.data); + } catch { + debug("github-signal-stream", "received malformed message", { + data: event.data, + }); + return; + } + + if (!isSignalMessage(message)) { + debug("github-signal-stream", "received unknown message type", { + message, + }); + return; + } + + debug("github-signal-stream", "received signal broadcast", { + keys: message.keys, + }); + + const receivedKeys = new Set(message.keys); + const currentTargets = targetsRef.current; + let invalidatedCount = 0; + + for (const target of currentTargets) { + const matchedKeys = target.signalKeys.filter((key) => + receivedKeys.has(key), + ); + if (matchedKeys.length === 0) continue; + + const queryState = queryClient.getQueryState(target.queryKey); + if ( + !queryState || + queryState.dataUpdatedAt === 0 || + queryState.fetchStatus === "fetching" + ) { + debug( + "github-signal-stream", + "skipping query (no data or already fetching)", + { + queryKey: target.queryKey, + matchedKeys, + reason: !queryState + ? "no-state" + : queryState.dataUpdatedAt === 0 + ? "no-data" + : "fetching", + }, + ); + continue; + } + + debug("github-signal-stream", "invalidating query", { + queryKey: target.queryKey, + matchedKeys, + }); + + void queryClient.invalidateQueries({ + queryKey: target.queryKey, + exact: true, + refetchType: "active", + }); + invalidatedCount++; + } + + debug("github-signal-stream", "broadcast processed", { + receivedKeys: message.keys, + invalidatedCount, + totalTargets: currentTargets.length, + }); + } + + function connect() { + if (disposed) return; + + debug("github-signal-stream", "connecting", { + url: getWebSocketUrl(), + }); + + ws = new WebSocket(getWebSocketUrl()); + + ws.addEventListener("open", () => { + debug("github-signal-stream", "connected"); + if (ws) sendSubscription(ws); + }); + + ws.addEventListener("message", handleMessage); + + ws.addEventListener("close", (event) => { + debug("github-signal-stream", "disconnected", { + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + }); + ws = null; + scheduleReconnect(); + }); + + ws.addEventListener("error", () => { + debug("github-signal-stream", "connection error"); + ws?.close(); + }); + } + + function scheduleReconnect() { + if (disposed) return; + debug("github-signal-stream", "scheduling reconnect", { + delayMs: RECONNECT_DELAY_MS, + }); + reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS); + } + + function cleanup() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (ws) { + ws.close(); + ws = null; + } + } + + connect(); + + return () => { + disposed = true; + cleanup(); + }; + }, [signalKeysKey, queryClient]); +} diff --git a/apps/dashboard/src/routes/_protected/index.tsx b/apps/dashboard/src/routes/_protected/index.tsx index aeae1a6..466f206 100644 --- a/apps/dashboard/src/routes/_protected/index.tsx +++ b/apps/dashboard/src/routes/_protected/index.tsx @@ -1,14 +1,17 @@ import { GitPullRequestIcon, IssuesIcon, ReviewsIcon } from "@diffkit/icons"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; -import type { ComponentType } from "react"; +import { type ComponentType, useMemo } from "react"; import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; import { PullRequestRow } from "#/components/pulls/pull-request-row"; import { githubMyIssuesQueryOptions, githubMyPullsQueryOptions, + githubQueryKeys, } from "#/lib/github.query"; +import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; import { buildSeo, formatPageTitle } from "#/lib/seo"; +import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/")({ @@ -32,7 +35,7 @@ export const Route = createFileRoute("/_protected/")({ function OverviewPage() { const { user } = Route.useRouteContext(); - const scope = { userId: user.id }; + const scope = useMemo(() => ({ userId: user.id }), [user.id]); const hasMounted = useHasMounted(); const pullsQuery = useQuery({ ...githubMyPullsQueryOptions(scope), @@ -42,6 +45,20 @@ function OverviewPage() { ...githubMyIssuesQueryOptions(scope), enabled: hasMounted, }); + const webhookTargets = useMemo( + () => [ + { + queryKey: githubQueryKeys.pulls.mine(scope), + signalKeys: [githubRevalidationSignalKeys.pullsMine], + }, + { + queryKey: githubQueryKeys.issues.mine(scope), + signalKeys: [githubRevalidationSignalKeys.issuesMine], + }, + ], + [scope], + ); + useGitHubSignalStream(webhookTargets); if (pullsQuery.error) throw pullsQuery.error; if (issuesQuery.error) throw issuesQuery.error; diff --git a/apps/dashboard/src/routes/_protected/issues.tsx b/apps/dashboard/src/routes/_protected/issues.tsx index e6ad3a0..a56483c 100644 --- a/apps/dashboard/src/routes/_protected/issues.tsx +++ b/apps/dashboard/src/routes/_protected/issues.tsx @@ -21,9 +21,14 @@ import { } from "#/components/filters"; import { IssueRow } from "#/components/issues/issue-row"; import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; -import { githubMyIssuesQueryOptions } from "#/lib/github.query"; +import { + githubMyIssuesQueryOptions, + githubQueryKeys, +} from "#/lib/github.query"; import type { IssueSummary } from "#/lib/github.types"; +import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; import { buildSeo, formatPageTitle } from "#/lib/seo"; +import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/issues")({ @@ -49,13 +54,23 @@ export const Route = createFileRoute("/_protected/issues")({ function IssuesPage() { const { filterStore } = Route.useLoaderData(); const { user } = Route.useRouteContext(); - const scope = { userId: user.id }; + const scope = useMemo(() => ({ userId: user.id }), [user.id]); const hasMounted = useHasMounted(); const scrollContainerRef = useRef(null); const query = useQuery({ ...githubMyIssuesQueryOptions(scope), enabled: hasMounted, }); + const webhookTargets = useMemo( + () => [ + { + queryKey: githubQueryKeys.issues.mine(scope), + signalKeys: [githubRevalidationSignalKeys.issuesMine], + }, + ], + [scope], + ); + useGitHubSignalStream(webhookTargets); const allIssues = useMemo(() => { if (!query.data) return []; diff --git a/apps/dashboard/src/routes/_protected/pulls.tsx b/apps/dashboard/src/routes/_protected/pulls.tsx index b047c13..370496f 100644 --- a/apps/dashboard/src/routes/_protected/pulls.tsx +++ b/apps/dashboard/src/routes/_protected/pulls.tsx @@ -27,9 +27,11 @@ import { } from "#/components/filters"; import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; import { PullRequestRow } from "#/components/pulls/pull-request-row"; -import { githubMyPullsQueryOptions } from "#/lib/github.query"; +import { githubMyPullsQueryOptions, githubQueryKeys } from "#/lib/github.query"; import type { PullSummary } from "#/lib/github.types"; +import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; import { buildSeo, formatPageTitle } from "#/lib/seo"; +import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/pulls")({ @@ -55,13 +57,23 @@ export const Route = createFileRoute("/_protected/pulls")({ function PullRequestsPage() { const { filterStore } = Route.useLoaderData(); const { user } = Route.useRouteContext(); - const scope = { userId: user.id }; + const scope = useMemo(() => ({ userId: user.id }), [user.id]); const hasMounted = useHasMounted(); const scrollContainerRef = useRef(null); const query = useQuery({ ...githubMyPullsQueryOptions(scope), enabled: hasMounted, }); + const webhookTargets = useMemo( + () => [ + { + queryKey: githubQueryKeys.pulls.mine(scope), + signalKeys: [githubRevalidationSignalKeys.pullsMine], + }, + ], + [scope], + ); + useGitHubSignalStream(webhookTargets); // Flatten all pulls for filter option extraction const allPulls = useMemo(() => { diff --git a/apps/dashboard/src/routes/_protected/reviews.tsx b/apps/dashboard/src/routes/_protected/reviews.tsx index 703fbc1..19e3ab8 100644 --- a/apps/dashboard/src/routes/_protected/reviews.tsx +++ b/apps/dashboard/src/routes/_protected/reviews.tsx @@ -12,8 +12,10 @@ import { } from "#/components/filters"; import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; import { PullRequestRow } from "#/components/pulls/pull-request-row"; -import { githubMyPullsQueryOptions } from "#/lib/github.query"; +import { githubMyPullsQueryOptions, githubQueryKeys } from "#/lib/github.query"; +import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; import { buildSeo, formatPageTitle } from "#/lib/seo"; +import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/reviews")({ @@ -39,13 +41,23 @@ export const Route = createFileRoute("/_protected/reviews")({ function ReviewsPage() { const { filterStore } = Route.useLoaderData(); const { user } = Route.useRouteContext(); - const scope = { userId: user.id }; + const scope = useMemo(() => ({ userId: user.id }), [user.id]); const hasMounted = useHasMounted(); const scrollContainerRef = useRef(null); const query = useQuery({ ...githubMyPullsQueryOptions(scope), enabled: hasMounted, }); + const webhookTargets = useMemo( + () => [ + { + queryKey: githubQueryKeys.pulls.mine(scope), + signalKeys: [githubRevalidationSignalKeys.pullsMine], + }, + ], + [scope], + ); + useGitHubSignalStream(webhookTargets); const rawReviews = useMemo( () => query.data?.reviewRequested ?? [], diff --git a/apps/dashboard/src/routes/api/webhooks/github.ts b/apps/dashboard/src/routes/api/webhooks/github.ts index 8736a3c..4ff508a 100644 --- a/apps/dashboard/src/routes/api/webhooks/github.ts +++ b/apps/dashboard/src/routes/api/webhooks/github.ts @@ -9,6 +9,7 @@ import { markGitHubRevalidationSignals } from "#/lib/github-cache"; import { getGitHubWebhookRevalidationSignalKeys } from "#/lib/github-revalidation"; import { getGitHubWebhookPayloadMetadata } from "#/lib/github-webhook-debug"; import { PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; +import { broadcastSignalKeys } from "#/lib/signal-relay-broadcast.server"; const INSTALLATION_TOKEN_INVALIDATION_EVENTS = new Set([ "installation", @@ -126,6 +127,10 @@ export const Route = createFileRoute("/api/webhooks/github")({ const updatedSignalCount = await markGitHubRevalidationSignals(signalKeys); + if (signalKeys.length > 0) { + await broadcastSignalKeys(signalKeys); + } + debug("github-webhook", "processed webhook", { deliveryId, event, diff --git a/apps/dashboard/wrangler.jsonc b/apps/dashboard/wrangler.jsonc index 9be66f8..0326489 100644 --- a/apps/dashboard/wrangler.jsonc +++ b/apps/dashboard/wrangler.jsonc @@ -6,7 +6,7 @@ "nodejs_compat", "no_handle_cross_request_promise_resolution" ], - "main": "@tanstack/react-start/server-entry", + "main": "src/entry-worker.ts", "observability": { "logs": { "enabled": true, @@ -24,6 +24,20 @@ "migrations_dir": "drizzle" } ], + "durable_objects": { + "bindings": [ + { + "name": "SIGNAL_RELAY", + "class_name": "SignalRelay" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_classes": ["SignalRelay"] + } + ], "kv_namespaces": [ { "binding": "GITHUB_CACHE_KV",