@adriandmitroca/relay 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ import type { IssueDB, IssueRow, ConfigDB } from "./db.ts";
2
+ import type { CallbackAction, CallbackData } from "./constants.ts";
3
+ import { createRouter } from "./api/router.ts";
4
+ import { logger } from "./utils/logger.ts";
5
+ import { join } from "node:path";
6
+
7
+ type WsClient = { send(msg: string | Buffer): void };
8
+
9
+ type ActionHandler = (data: CallbackData) => Promise<{ ok: boolean; error?: string; issue?: IssueRow }>;
10
+ type StatusProvider = () => Array<{ workspace: string; telegram: boolean; sources: string[] }>;
11
+
12
+ export class DashboardServer {
13
+ private db: IssueDB;
14
+ private configDB: ConfigDB | null = null;
15
+ private port: number;
16
+ private clients = new Set<WsClient>();
17
+ private server: ReturnType<typeof Bun.serve> | null = null;
18
+ private activeStreams = new Map<number, Record<string, unknown>>();
19
+ private actionHandler: ActionHandler | null = null;
20
+ private statusProvider: StatusProvider | null = null;
21
+ private configChangeHandler: (() => void) | null = null;
22
+ private distDir: string | null = null;
23
+
24
+ constructor(db: IssueDB, port = 7842) {
25
+ this.db = db;
26
+ this.port = port;
27
+ }
28
+
29
+ setActionHandler(handler: ActionHandler) {
30
+ this.actionHandler = handler;
31
+ }
32
+
33
+ setStatusProvider(provider: StatusProvider) {
34
+ this.statusProvider = provider;
35
+ }
36
+
37
+ setConfigDB(configDB: ConfigDB) {
38
+ this.configDB = configDB;
39
+ }
40
+
41
+ setConfigChangeHandler(handler: () => void) {
42
+ this.configChangeHandler = handler;
43
+ }
44
+
45
+ setDistDir(dir: string) {
46
+ this.distDir = dir;
47
+ }
48
+
49
+ start() {
50
+ const self = this;
51
+
52
+ const app = createRouter({
53
+ getDB: () => this.db,
54
+ getConfigDB: () => {
55
+ if (!this.configDB) throw new Error("ConfigDB not initialized");
56
+ return this.configDB;
57
+ },
58
+ getActionHandler: () => this.actionHandler,
59
+ getStatusProvider: () => this.statusProvider,
60
+ onConfigChange: () => this.configChangeHandler?.(),
61
+ });
62
+
63
+ try {
64
+ this.server = Bun.serve({
65
+ hostname: "127.0.0.1",
66
+ port: this.port,
67
+
68
+ async fetch(req, server) {
69
+ const url = new URL(req.url);
70
+
71
+ // WebSocket upgrade
72
+ if (url.pathname === "/ws") {
73
+ const ok = server.upgrade(req);
74
+ if (!ok) return new Response("WebSocket upgrade failed", { status: 400 });
75
+ return undefined;
76
+ }
77
+
78
+ // API routes via Hono
79
+ if (url.pathname.startsWith("/api/")) {
80
+ return app.fetch(req);
81
+ }
82
+
83
+ // Serve static files from dist/ if available
84
+ if (self.distDir) {
85
+ // Try exact file match
86
+ let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
87
+ const file = Bun.file(join(self.distDir, filePath));
88
+ if (await file.exists()) {
89
+ return new Response(file, {
90
+ headers: { "Content-Type": getMimeType(filePath) },
91
+ });
92
+ }
93
+
94
+ // SPA fallback: serve index.html for non-file paths
95
+ const indexFile = Bun.file(join(self.distDir, "index.html"));
96
+ if (await indexFile.exists()) {
97
+ return new Response(indexFile, {
98
+ headers: { "Content-Type": "text/html; charset=utf-8" },
99
+ });
100
+ }
101
+ }
102
+
103
+ return new Response("Dashboard not built. Run: bun run build", {
104
+ status: 503,
105
+ headers: { "Content-Type": "text/plain" },
106
+ });
107
+ },
108
+
109
+ websocket: {
110
+ open(ws) {
111
+ self.clients.add(ws);
112
+ // Send current stream state to new clients
113
+ for (const event of self.activeStreams.values()) {
114
+ try { ws.send(JSON.stringify(event)); } catch {}
115
+ }
116
+ },
117
+ close(ws) {
118
+ self.clients.delete(ws);
119
+ },
120
+ message() {},
121
+ },
122
+ });
123
+
124
+ logger.info("Dashboard running", { url: `http://localhost:${this.port}` });
125
+ } catch (err) {
126
+ logger.warn("Dashboard failed to start (port may be in use)", { port: this.port, error: String(err) });
127
+ }
128
+ }
129
+
130
+ broadcast(event: Record<string, unknown>) {
131
+ if (event.type === "stream_progress") {
132
+ this.activeStreams.set(event.issueId as number, event);
133
+ } else if (event.type === "issue_update") {
134
+ const issue = event.issue as { id: number; status: string };
135
+ if (issue.status === "triaging" || issue.status === "working") {
136
+ // Seed stream with startedAt so clients get a timer immediately
137
+ if (!this.activeStreams.has(issue.id)) {
138
+ this.activeStreams.set(issue.id, {
139
+ type: "stream_progress",
140
+ issueId: issue.id,
141
+ stage: issue.status === "triaging" ? "triage" : "fix",
142
+ startedAt: Date.now(),
143
+ });
144
+ }
145
+ } else {
146
+ this.activeStreams.delete(issue.id);
147
+ }
148
+ }
149
+
150
+ if (!this.clients.size) return;
151
+ const msg = JSON.stringify(event);
152
+ for (const client of this.clients) {
153
+ try {
154
+ client.send(msg);
155
+ } catch {}
156
+ }
157
+ }
158
+
159
+ stop() {
160
+ this.server?.stop(true);
161
+ this.server = null;
162
+ this.clients.clear();
163
+ }
164
+ }
165
+
166
+ function getMimeType(path: string): string {
167
+ if (path.endsWith(".html")) return "text/html; charset=utf-8";
168
+ if (path.endsWith(".js")) return "application/javascript";
169
+ if (path.endsWith(".css")) return "text/css";
170
+ if (path.endsWith(".json")) return "application/json";
171
+ if (path.endsWith(".svg")) return "image/svg+xml";
172
+ if (path.endsWith(".png")) return "image/png";
173
+ if (path.endsWith(".ico")) return "image/x-icon";
174
+ return "application/octet-stream";
175
+ }