Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
live-events.ts4.5 KB · 148 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/**
 * SSE endpoint: `GET /live-events/:topic`.
 *
 * Topic format: `repo:{repoId}`, `pr:{prId}`, `user:{userId}`. The regex
 * `^[a-z]+:[a-zA-Z0-9\-]+$` is enforced; anything else is a 400.
 *
 * Auth / authorization:
 *   - Runs behind softAuth so we have the viewer (or null).
 *   - For `repo:{repoId}` topics, we do a cheap DB check that the viewer has
 *     read access via `resolveRepoAccess`. `pr:` and `user:` topics currently
 *     only require a valid topic string — when we add PR-level privacy we'll
 *     extend this handler in place.
 *
 * Transport:
 *   - `text/event-stream` with keep-alive + nginx-friendly `X-Accel-Buffering`.
 *   - We write `id:` / `event:` / `data:` blocks per SSEEvent and send a
 *     `: ping` comment every 25s to keep intermediaries from timing out.
 *   - On stream close we unsubscribe and clear the heartbeat timer.
 */

import { Hono } from "hono";
import { eq } from "drizzle-orm";
import { softAuth } from "../middleware/auth";
import type { AuthEnv } from "../middleware/auth";
import { db } from "../db";
import { repositories } from "../db/schema";
import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
import { subscribe, type SSEEvent } from "../lib/sse";

const app = new Hono<AuthEnv>();

const TOPIC_RE = /^[a-z]+:[a-zA-Z0-9\-]+$/;
const HEARTBEAT_MS = 25_000;

app.get("/live-events/:topic", softAuth, async (c) => {
  const topic = c.req.param("topic");
  if (!topic || !TOPIC_RE.test(topic)) {
    return c.json({ error: "Invalid topic" }, 400);
  }

  const user = c.get("user") ?? null;
  const colon = topic.indexOf(":");
  const kind = topic.slice(0, colon);
  const id = topic.slice(colon + 1);

  // For repo topics, gate on read access. Other topic kinds pass through.
  if (kind === "repo") {
    try {
      const [repo] = await db
        .select({ id: repositories.id, isPrivate: repositories.isPrivate })
        .from(repositories)
        .where(eq(repositories.id, id))
        .limit(1);

      if (!repo) {
        return c.json({ error: "Not found" }, 404);
      }

      const access = await resolveRepoAccess({
        repoId: repo.id,
        userId: user?.id ?? null,
        isPublic: !repo.isPrivate,
      });

      if (!satisfiesAccess(access, "read")) {
        return c.json({ error: "Forbidden" }, 403);
      }
    } catch {
      return c.json({ error: "Not found" }, 404);
    }
  }

  const encoder = new TextEncoder();

  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      let closed = false;

      const safeEnqueue = (chunk: string) => {
        if (closed) return;
        try {
          controller.enqueue(encoder.encode(chunk));
        } catch {
          // Controller already closed — mark local state so we stop trying.
          closed = true;
        }
      };

      // Initial comment flushes headers on some proxies.
      safeEnqueue(": open\n\n");

      const unsubscribe = subscribe(topic, (event: SSEEvent) => {
        let payload = "";
        if (event.id !== undefined) payload += `id: ${event.id}\n`;
        if (event.event !== undefined) payload += `event: ${event.event}\n`;
        const data =
          typeof event.data === "string"
            ? event.data
            : JSON.stringify(event.data);
        // SSE `data:` lines must not contain raw newlines — split if present.
        for (const line of data.split("\n")) {
          payload += `data: ${line}\n`;
        }
        payload += "\n";
        safeEnqueue(payload);
      });

      const heartbeat = setInterval(() => {
        safeEnqueue(": ping\n\n");
      }, HEARTBEAT_MS);

      const cleanup = () => {
        if (closed) return;
        closed = true;
        clearInterval(heartbeat);
        unsubscribe();
        try {
          controller.close();
        } catch {
          // Already closed — nothing to do.
        }
      };

      // Client-side abort (navigation, tab close) surfaces via the request's
      // AbortSignal. Bun's fetch-style request exposes this on `c.req.raw`.
      const signal = c.req.raw.signal;
      if (signal) {
        if (signal.aborted) {
          cleanup();
        } else {
          signal.addEventListener("abort", cleanup, { once: true });
        }
      }
    },
  });

  return new Response(stream, {
    status: 200,
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    },
  });
});

export default app;