Skip to content

Commit 92e6a2f

Browse files
committed
feat(dashboard): live report stream via SSE with memory-safe lifecycle
Adds a GET /api/projects/:id/reports/:reportId/stream SSE endpoint that pushes minimal {kind, payload} events to a browser tab open on the report detail page. Every write-site — triage PATCH, comment create/edit/delete, the issue_comment / issues.* webhook branches — publishes after DB commit. The page's existing refresh() / activity refresh / comments refresh fans out from a single handler wired through useEventSource, replacing the 20s comment-tab polling with immediate updates. Memory-safety invariants (asserted by server/lib/report-events-bus.test.ts): - per-report subscriber cap (20) surfaces as 429 on excess - idempotent unsubscribe; empty Sets are deleted from the Map - listener mid-iteration unsubscribe is snapshot-safe - a throwing listener does not stop peers from receiving - dead listeners are lazy-reaped on next publish - SSE endpoint cleans up heartbeat + subscription via THREE paths: stream.onClosed, the underlying req 'close' event, and a self- eviction guard after LISTENER_FAILURE_THRESHOLD consecutive push failures — all routed through a single idempotent cleanup() - reconnect bounded to 10 attempts with 2s backoff (no forever loops) Includes @vueuse/nuxt as a dependency for useEventSource.
1 parent 7d9a90c commit 92e6a2f

13 files changed

Lines changed: 532 additions & 6 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// apps/dashboard/app/composables/use-report-stream.ts
2+
//
3+
// Client-side subscription to the per-report SSE endpoint. Wraps VueUse's
4+
// `useEventSource` and invokes the caller's handler for each message.
5+
//
6+
// Memory-safety design:
7+
// - `useEventSource` already teardowns the underlying EventSource on
8+
// `onScopeDispose`, which Vue fires when the component's setup scope
9+
// ends (route change, unmount). We additionally call `close()` in
10+
// `onBeforeUnmount` as a belt-and-suspenders hook.
11+
// - The `watch` we register on `data` is automatically disposed with the
12+
// component's setup scope — no manual `stop()` needed, assuming the
13+
// composable is only used inside `<script setup>` (the documented
14+
// contract; Vue warns at runtime if called outside a setup context).
15+
// - Reconnect is bounded: 10 retries with 2-second backoff. After that
16+
// the stream goes silent rather than retrying forever. Users get the
17+
// same data via the next page load or a manual refresh trigger.
18+
// - Parse errors from malformed server payloads are swallowed — a bad
19+
// single event must not tear down the stream.
20+
21+
import { onBeforeUnmount, watch } from "vue"
22+
import { useEventSource } from "@vueuse/core"
23+
24+
export type ReportStreamEvent = {
25+
kind:
26+
| "triage"
27+
| "comment_added"
28+
| "comment_edited"
29+
| "comment_deleted"
30+
| "github_synced"
31+
| "github_unlinked"
32+
payload?: Record<string, unknown>
33+
}
34+
35+
export function useReportStream(
36+
projectId: () => string,
37+
reportId: () => string,
38+
onEvent: (event: ReportStreamEvent) => void,
39+
) {
40+
const url = computed(() => `/api/projects/${projectId()}/reports/${reportId()}/stream`)
41+
42+
const { data, status, close } = useEventSource(url, [], {
43+
autoReconnect: {
44+
retries: 10,
45+
delay: 2_000,
46+
onFailed() {
47+
// Silent after bounded retries — a forever-retry loop burns battery
48+
// on mobile clients and adds server load during outages.
49+
console.warn("[report-stream] giving up after 10 reconnect attempts")
50+
},
51+
},
52+
})
53+
54+
watch(data, (raw) => {
55+
if (!raw) return
56+
try {
57+
const parsed = JSON.parse(raw) as ReportStreamEvent
58+
onEvent(parsed)
59+
} catch {
60+
// Malformed payload — ignore rather than break the stream.
61+
}
62+
})
63+
64+
// useEventSource registers an onScopeDispose internally, but an extra
65+
// close() on unmount is harmless (the library no-ops repeat closes) and
66+
// keeps the lifecycle intent explicit at the call-site level.
67+
onBeforeUnmount(() => {
68+
close()
69+
})
70+
71+
return { status }
72+
}

apps/dashboard/app/pages/projects/[id]/reports/[reportId].vue

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,25 @@ async function onPatched() {
114114
if (commentsRef.value) await commentsRef.value.refresh()
115115
}
116116
117+
// Live-update subscription: the SSE endpoint pushes an event every time this
118+
// report is touched (locally or via a GitHub webhook). Each event triggers the
119+
// same refetch used by the PATCH round-trip — the activity tab, triage
120+
// footer, and comments tab repaint from their new data.
121+
useReportStream(
122+
() => projectId.value,
123+
() => reportId.value,
124+
async (e) => {
125+
if (e.kind === "comment_added" || e.kind === "comment_edited" || e.kind === "comment_deleted") {
126+
if (commentsRef.value) await commentsRef.value.refresh()
127+
if (activityRef.value) await activityRef.value.refresh()
128+
return
129+
}
130+
// triage / github_synced / github_unlinked all warrant a full refetch so
131+
// the drawer's every tab reflects the new row state.
132+
await onPatched()
133+
},
134+
)
135+
117136
const triageOpen = ref(false)
118137
119138
// Keyboard shortcuts: 1-8 jump to each tab; Esc navigates back to the inbox.

apps/dashboard/nuxt.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import tailwindcss from "@tailwindcss/vite"
22

33
export default defineNuxtConfig({
44
compatibilityDate: "2026-04-17",
5-
modules: ["@nuxt/ui", "@nuxt/fonts", "nuxt-security"],
5+
modules: ["@nuxt/ui", "@nuxt/fonts", "nuxt-security", "@vueuse/nuxt"],
66
css: ["~/assets/css/tailwind.css"],
77
// Scan source at build time and bundle every `<UIcon>` / `i-*` reference
88
// into the client JS. Without this, icons fall through to `@nuxt/icon`'s

apps/dashboard/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@reprojs/integrations-github": "workspace:*",
2323
"@reprojs/shared": "workspace:*",
2424
"@tailwindcss/vite": "^4.2.1",
25+
"@vueuse/nuxt": "14.2.1",
2526
"better-auth": "^1.5.6",
2627
"drizzle-orm": "^0.45.2",
2728
"h3": "^1.15.11",

apps/dashboard/server/api/integrations/github/webhook.post.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "../../../db/schema"
1313
import { getWebhookSecret } from "../../../lib/github"
1414
import { invalidateInstallationRepos } from "../../../lib/github-repo-cache"
15+
import { publishReportStream } from "../../../lib/report-events-bus"
1516
import { githubCache } from "../../../lib/github-cache"
1617
import { parseGithubLabels } from "../../../lib/github-helpers"
1718
import {
@@ -491,6 +492,11 @@ export default defineEventHandler(async (event) => {
491492
.where(eq(reports.id, linked.r.id))
492493
}
493494
}
495+
496+
// Regardless of which issues-sub-action landed (closed/reopened/labeled/
497+
// unlabeled/assigned/unassigned/milestoned/demilestoned/edited), the
498+
// dashboard row was touched — nudge any open SSE subscribers to refetch.
499+
publishReportStream(linked.r.id, { kind: "triage", payload: { source: "github" } })
494500
} else if (kind === "issue_comment") {
495501
const p = payload as IssueCommentPayload
496502
const action = p.action
@@ -543,6 +549,10 @@ export default defineEventHandler(async (event) => {
543549
kind: "comment_added",
544550
payload: { githubCommentId: comment.id, source: "github" },
545551
})
552+
publishReportStream(linked.r.id, {
553+
kind: "comment_added",
554+
payload: { source: "github", githubCommentId: comment.id },
555+
})
546556
} else {
547557
// edited
548558
await db
@@ -556,6 +566,10 @@ export default defineEventHandler(async (event) => {
556566
kind: "comment_edited",
557567
payload: { githubCommentId: comment.id, source: "github" },
558568
})
569+
publishReportStream(linked.r.id, {
570+
kind: "comment_edited",
571+
payload: { source: "github", githubCommentId: comment.id },
572+
})
559573
}
560574
} else if (action === "deleted") {
561575
const commentDeleteSig = signCommentDelete(comment.id)
@@ -580,6 +594,10 @@ export default defineEventHandler(async (event) => {
580594
kind: "comment_deleted",
581595
payload: { githubCommentId: comment.id, source: "github" },
582596
})
597+
publishReportStream(linked.r.id, {
598+
kind: "comment_deleted",
599+
payload: { source: "github", githubCommentId: comment.id },
600+
})
583601
}
584602
}
585603

apps/dashboard/server/api/projects/[id]/reports/[reportId]/comments/[commentId].delete.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { db } from "../../../../../../db"
55
import { reportComments } from "../../../../../../db/schema/report-comments"
66
import { reportSyncJobs } from "../../../../../../db/schema/github-integrations"
77
import { requireProjectRole } from "../../../../../../lib/permissions"
8+
import { publishReportStream } from "../../../../../../lib/report-events-bus"
89
import { enqueueCommentDelete } from "../../../../../../lib/enqueue-sync"
910

1011
export default defineEventHandler(async (event) => {
@@ -64,6 +65,11 @@ export default defineEventHandler(async (event) => {
6465
}
6566
}
6667

68+
publishReportStream(reportId, {
69+
kind: "comment_deleted",
70+
payload: { commentId },
71+
})
72+
6773
setResponseStatus(event, 204)
6874
return null
6975
})

apps/dashboard/server/api/projects/[id]/reports/[reportId]/comments/[commentId].patch.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { db } from "../../../../../../db"
66
import { reportComments } from "../../../../../../db/schema/report-comments"
77
import { reports } from "../../../../../../db/schema/reports"
88
import { requireProjectRole } from "../../../../../../lib/permissions"
9+
import { publishReportStream } from "../../../../../../lib/report-events-bus"
910
import { enqueueCommentUpsert } from "../../../../../../lib/enqueue-sync"
1011

1112
const UpdateCommentBody = z.object({
@@ -62,5 +63,10 @@ export default defineEventHandler(async (event) => {
6263
}
6364
}
6465

66+
publishReportStream(reportId, {
67+
kind: "comment_edited",
68+
payload: { commentId },
69+
})
70+
6571
return { comment: updated }
6672
})

apps/dashboard/server/api/projects/[id]/reports/[reportId]/comments/index.post.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { reportComments } from "../../../../../../db/schema/report-comments"
1313
import { reports } from "../../../../../../db/schema/reports"
1414
import { githubIntegrations } from "../../../../../../db/schema/github-integrations"
1515
import { requireProjectRole } from "../../../../../../lib/permissions"
16+
import { publishReportStream } from "../../../../../../lib/report-events-bus"
1617
import { enqueueCommentUpsert } from "../../../../../../lib/enqueue-sync"
1718

1819
const CreateCommentBody = z.object({
@@ -55,6 +56,11 @@ export default defineEventHandler(async (event) => {
5556
}
5657
}
5758

59+
publishReportStream(reportId, {
60+
kind: "comment_added",
61+
payload: { commentId: inserted.id },
62+
})
63+
5864
setResponseStatus(event, 201)
5965
return { comment: inserted }
6066
})

apps/dashboard/server/api/projects/[id]/reports/[reportId]/index.patch.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "../../../../../db/schema"
1313
import { buildReportEvents } from "../../../../../lib/report-events"
1414
import { enqueueSync } from "../../../../../lib/enqueue-sync"
15+
import { publishReportStream } from "../../../../../lib/report-events-bus"
1516
import { compareRole, requireProjectRole } from "../../../../../lib/permissions"
1617
import type { ProjectRoleName } from "../../../../../lib/permissions"
1718

@@ -253,6 +254,11 @@ export default defineEventHandler(async (event) => {
253254
}
254255
}
255256

257+
// Push the change to any open report-stream subscribers (SSE).
258+
if (hasEvents) {
259+
publishReportStream(reportId, { kind: "triage" })
260+
}
261+
256262
return { ok: true, updated: true }
257263
})
258264
})

0 commit comments

Comments
 (0)