From 1b0dd9a9b0d48c1993ebf0ae0eb91328f99be2ff Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Mon, 13 Apr 2026 16:50:36 -0400 Subject: [PATCH 1/2] Add 5-minute polling fallback for webhook signal revalidation The WebSocket-based signal stream can miss events if the connection drops momentarily. This adds a polling fallback that checks signal timestamps on the server every 5 minutes and invalidates stale queries if any signals were missed. --- apps/dashboard/src/lib/github.functions.ts | 21 ++ .../src/lib/use-github-signal-stream.ts | 216 +++++++++++++----- 2 files changed, 179 insertions(+), 58 deletions(-) diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 8c82dae..d51b129 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -6499,3 +6499,24 @@ export const getRepoContributors = createServerFn({ method: "GET" }) }, }); }); + +type RevalidationSignalTimestampsInput = { signalKeys: string[] }; + +export const getRevalidationSignalTimestamps = createServerFn({ + method: "GET", +}) + .inputValidator(identityValidator) + .handler( + async ({ + data, + }): Promise> => { + const { getRequestSession } = await import("./auth-runtime"); + const session = await getRequestSession(); + if (!session) { + return []; + } + + const { getGitHubRevalidationSignals } = await import("./github-cache"); + return getGitHubRevalidationSignals(data.signalKeys); + }, + ); diff --git a/apps/dashboard/src/lib/use-github-signal-stream.ts b/apps/dashboard/src/lib/use-github-signal-stream.ts index 7e9ca5b..7732b6a 100644 --- a/apps/dashboard/src/lib/use-github-signal-stream.ts +++ b/apps/dashboard/src/lib/use-github-signal-stream.ts @@ -1,6 +1,7 @@ import { type QueryKey, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; import { debug } from "./debug"; +import { getRevalidationSignalTimestamps } from "./github.functions"; export type GitHubSignalStreamTarget = { queryKey: QueryKey; @@ -24,29 +25,69 @@ function isSignalMessage(data: unknown): data is SignalMessage { } const RECONNECT_DELAY_MS = 3_000; +const POLL_INTERVAL_MS = 5 * 60 * 1_000; function getWebSocketUrl() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${window.location.host}/api/ws/signals`; } -export function useGitHubSignalStream( +export function invalidateTargets( + queryClient: ReturnType, + targets: readonly GitHubSignalStreamTarget[], + receivedKeys: Set, + source: string, +) { + let invalidatedCount = 0; + + for (const target of targets) { + 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(source, "skipping query (no data or already fetching)", { + queryKey: target.queryKey, + matchedKeys, + reason: !queryState + ? "no-state" + : queryState.dataUpdatedAt === 0 + ? "no-data" + : "fetching", + }); + continue; + } + + debug(source, "invalidating query", { + queryKey: target.queryKey, + matchedKeys, + }); + + void queryClient.invalidateQueries({ + queryKey: target.queryKey, + exact: true, + refetchType: "active", + }); + invalidatedCount++; + } + + return invalidatedCount; +} + +function useGitHubSignalStreamWebSocket( targets: readonly GitHubSignalStreamTarget[], + signalKeysKey: string, ) { 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; @@ -92,48 +133,12 @@ export function useGitHubSignalStream( 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++; - } + const invalidatedCount = invalidateTargets( + queryClient, + currentTargets, + receivedKeys, + "github-signal-stream", + ); debug("github-signal-stream", "broadcast processed", { receivedKeys: message.keys, @@ -182,22 +187,117 @@ export function useGitHubSignalStream( reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS); } - function cleanup() { + connect(); + + return () => { + disposed = true; if (reconnectTimer) { clearTimeout(reconnectTimer); - reconnectTimer = null; } if (ws) { ws.close(); - ws = null; } + }; + }, [signalKeysKey, queryClient]); +} + +function useGitHubSignalPoll( + targets: readonly GitHubSignalStreamTarget[], + signalKeysKey: string, +) { + const queryClient = useQueryClient(); + const targetsRef = useRef(targets); + targetsRef.current = targets; + + useEffect(() => { + if (signalKeysKey.length === 0) { + return; + } + + const keys = signalKeysKey.split(","); + let pollTimer: ReturnType | null = null; + let disposed = false; + const lastSeenTimestamps = new Map(); + + async function pollSignals() { + if (disposed) return; + + try { + const signals = await getRevalidationSignalTimestamps({ + data: { signalKeys: keys }, + }); + + if (disposed) return; + + const updatedKeys: string[] = []; + for (const signal of signals) { + const lastSeen = lastSeenTimestamps.get(signal.signalKey); + if (lastSeen === undefined) { + lastSeenTimestamps.set(signal.signalKey, signal.updatedAt); + } else if (signal.updatedAt > lastSeen) { + lastSeenTimestamps.set(signal.signalKey, signal.updatedAt); + updatedKeys.push(signal.signalKey); + } + } + + if (updatedKeys.length > 0) { + debug("github-signal-poll", "detected missed signals", { + updatedKeys, + }); + + const currentTargets = targetsRef.current; + const invalidatedCount = invalidateTargets( + queryClient, + currentTargets, + new Set(updatedKeys), + "github-signal-poll", + ); + + debug("github-signal-poll", "poll processed", { + updatedKeys, + invalidatedCount, + totalTargets: currentTargets.length, + }); + } else { + debug("github-signal-poll", "no missed signals"); + } + } catch (error) { + debug("github-signal-poll", "poll failed", { error }); + } + + schedulePoll(); } - connect(); + function schedulePoll() { + if (disposed) return; + pollTimer = setTimeout(pollSignals, POLL_INTERVAL_MS); + } + + // Seed timestamps immediately, then poll every 5 minutes + void pollSignals(); return () => { disposed = true; - cleanup(); + if (pollTimer) { + clearTimeout(pollTimer); + } }; }, [signalKeysKey, queryClient]); } + +export function useGitHubSignalStream( + targets: readonly GitHubSignalStreamTarget[], +) { + const allSignalKeys = useMemo(() => { + return Array.from( + new Set(targets.flatMap((target) => [...target.signalKeys])), + ).sort(); + }, [targets]); + + // Stable string so the effects only re-run when the actual keys change, + // not when the array reference changes. + const signalKeysKey = allSignalKeys.join(","); + + useGitHubSignalStreamWebSocket(targets, signalKeysKey); + useGitHubSignalPoll(targets, signalKeysKey); +} From ad3d8e993204dd0cdb37521e3bd4550d8aafe686 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Mon, 13 Apr 2026 16:53:19 -0400 Subject: [PATCH 2/2] Update generated route tree --- apps/dashboard/src/routeTree.gen.ts | 68 +++++++++++++++++------------ 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index f62913f..ef55fe8 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -155,15 +155,15 @@ const ProtectedOwnerRepoPullPullIdRoute = } as any) const ProtectedOwnerRepoIssuesNewRoute = ProtectedOwnerRepoIssuesNewRouteImport.update({ - id: '/$owner/$repo/issues/new', - path: '/$owner/$repo/issues/new', - getParentRoute: () => ProtectedRoute, + id: '/new', + path: '/new', + getParentRoute: () => ProtectedOwnerRepoIssuesRoute, } as any) const ProtectedOwnerRepoIssuesIssueIdRoute = ProtectedOwnerRepoIssuesIssueIdRouteImport.update({ - id: '/$owner/$repo/issues/$issueId', - path: '/$owner/$repo/issues/$issueId', - getParentRoute: () => ProtectedRoute, + id: '/$issueId', + path: '/$issueId', + getParentRoute: () => ProtectedOwnerRepoIssuesRoute, } as any) export interface FileRoutesByFullPath { @@ -182,11 +182,11 @@ export interface FileRoutesByFullPath { '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/$owner/': typeof ProtectedOwnerIndexRoute '/settings/': typeof ProtectedSettingsIndexRoute + '/$owner/$repo/issues': typeof ProtectedOwnerRepoIssuesRouteWithChildren + '/$owner/$repo/pulls': typeof ProtectedOwnerRepoPullsRoute '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute - '/$owner/$repo/issues': typeof ProtectedOwnerRepoIssuesRoute - '/$owner/$repo/pulls': typeof ProtectedOwnerRepoPullsRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute @@ -207,11 +207,11 @@ export interface FileRoutesByTo { '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/$owner': typeof ProtectedOwnerIndexRoute '/settings': typeof ProtectedSettingsIndexRoute + '/$owner/$repo/issues': typeof ProtectedOwnerRepoIssuesRouteWithChildren + '/$owner/$repo/pulls': typeof ProtectedOwnerRepoPullsRoute '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/$owner/$repo': typeof ProtectedOwnerRepoIndexRoute - '/$owner/$repo/issues': typeof ProtectedOwnerRepoIssuesRoute - '/$owner/$repo/pulls': typeof ProtectedOwnerRepoPullsRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute @@ -235,11 +235,11 @@ export interface FileRoutesById { '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/_protected/$owner/': typeof ProtectedOwnerIndexRoute '/_protected/settings/': typeof ProtectedSettingsIndexRoute + '/_protected/$owner/$repo/issues': typeof ProtectedOwnerRepoIssuesRouteWithChildren + '/_protected/$owner/$repo/pulls': typeof ProtectedOwnerRepoPullsRoute '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/_protected/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute - '/_protected/$owner/$repo/issues': typeof ProtectedOwnerRepoIssuesRoute - '/_protected/$owner/$repo/pulls': typeof ProtectedOwnerRepoPullsRoute '/_protected/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/_protected/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/_protected/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute @@ -263,11 +263,11 @@ export interface FileRouteTypes { | '/api/webhooks/github' | '/$owner/' | '/settings/' + | '/$owner/$repo/issues' + | '/$owner/$repo/pulls' | '/api/github/app/authorize' | '/api/github/app/callback' | '/$owner/$repo/' - | '/$owner/$repo/issues' - | '/$owner/$repo/pulls' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/issues/new' | '/$owner/$repo/pull/$pullId' @@ -288,11 +288,11 @@ export interface FileRouteTypes { | '/api/webhooks/github' | '/$owner' | '/settings' + | '/$owner/$repo/issues' + | '/$owner/$repo/pulls' | '/api/github/app/authorize' | '/api/github/app/callback' | '/$owner/$repo' - | '/$owner/$repo/issues' - | '/$owner/$repo/pulls' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/issues/new' | '/$owner/$repo/pull/$pullId' @@ -315,11 +315,11 @@ export interface FileRouteTypes { | '/api/webhooks/github' | '/_protected/$owner/' | '/_protected/settings/' + | '/_protected/$owner/$repo/issues' + | '/_protected/$owner/$repo/pulls' | '/api/github/app/authorize' | '/api/github/app/callback' | '/_protected/$owner/$repo/' - | '/_protected/$owner/$repo/issues' - | '/_protected/$owner/$repo/pulls' | '/_protected/$owner/$repo/issues/$issueId' | '/_protected/$owner/$repo/issues/new' | '/_protected/$owner/$repo/pull/$pullId' @@ -504,17 +504,17 @@ declare module '@tanstack/react-router' { } '/_protected/$owner/$repo/issues/new': { id: '/_protected/$owner/$repo/issues/new' - path: '/$owner/$repo/issues/new' + path: '/new' fullPath: '/$owner/$repo/issues/new' preLoaderRoute: typeof ProtectedOwnerRepoIssuesNewRouteImport - parentRoute: typeof ProtectedRoute + parentRoute: typeof ProtectedOwnerRepoIssuesRoute } '/_protected/$owner/$repo/issues/$issueId': { id: '/_protected/$owner/$repo/issues/$issueId' - path: '/$owner/$repo/issues/$issueId' + path: '/$issueId' fullPath: '/$owner/$repo/issues/$issueId' preLoaderRoute: typeof ProtectedOwnerRepoIssuesIssueIdRouteImport - parentRoute: typeof ProtectedRoute + parentRoute: typeof ProtectedOwnerRepoIssuesRoute } } } @@ -532,6 +532,22 @@ const ProtectedSettingsRouteChildren: ProtectedSettingsRouteChildren = { const ProtectedSettingsRouteWithChildren = ProtectedSettingsRoute._addFileChildren(ProtectedSettingsRouteChildren) +interface ProtectedOwnerRepoIssuesRouteChildren { + ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute + ProtectedOwnerRepoIssuesNewRoute: typeof ProtectedOwnerRepoIssuesNewRoute +} + +const ProtectedOwnerRepoIssuesRouteChildren: ProtectedOwnerRepoIssuesRouteChildren = + { + ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, + ProtectedOwnerRepoIssuesNewRoute: ProtectedOwnerRepoIssuesNewRoute, + } + +const ProtectedOwnerRepoIssuesRouteWithChildren = + ProtectedOwnerRepoIssuesRoute._addFileChildren( + ProtectedOwnerRepoIssuesRouteChildren, + ) + interface ProtectedRouteChildren { ProtectedIssuesRoute: typeof ProtectedIssuesRoute ProtectedPullsRoute: typeof ProtectedPullsRoute @@ -539,11 +555,9 @@ interface ProtectedRouteChildren { ProtectedSettingsRoute: typeof ProtectedSettingsRouteWithChildren ProtectedIndexRoute: typeof ProtectedIndexRoute ProtectedOwnerIndexRoute: typeof ProtectedOwnerIndexRoute - ProtectedOwnerRepoIssuesRoute: typeof ProtectedOwnerRepoIssuesRoute + ProtectedOwnerRepoIssuesRoute: typeof ProtectedOwnerRepoIssuesRouteWithChildren ProtectedOwnerRepoPullsRoute: typeof ProtectedOwnerRepoPullsRoute ProtectedOwnerRepoIndexRoute: typeof ProtectedOwnerRepoIndexRoute - ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute - ProtectedOwnerRepoIssuesNewRoute: typeof ProtectedOwnerRepoIssuesNewRoute ProtectedOwnerRepoPullPullIdRoute: typeof ProtectedOwnerRepoPullPullIdRoute ProtectedOwnerRepoReviewPullIdRoute: typeof ProtectedOwnerRepoReviewPullIdRoute } @@ -555,11 +569,9 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedSettingsRoute: ProtectedSettingsRouteWithChildren, ProtectedIndexRoute: ProtectedIndexRoute, ProtectedOwnerIndexRoute: ProtectedOwnerIndexRoute, - ProtectedOwnerRepoIssuesRoute: ProtectedOwnerRepoIssuesRoute, + ProtectedOwnerRepoIssuesRoute: ProtectedOwnerRepoIssuesRouteWithChildren, ProtectedOwnerRepoPullsRoute: ProtectedOwnerRepoPullsRoute, ProtectedOwnerRepoIndexRoute: ProtectedOwnerRepoIndexRoute, - ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, - ProtectedOwnerRepoIssuesNewRoute: ProtectedOwnerRepoIssuesNewRoute, ProtectedOwnerRepoPullPullIdRoute: ProtectedOwnerRepoPullPullIdRoute, ProtectedOwnerRepoReviewPullIdRoute: ProtectedOwnerRepoReviewPullIdRoute, }