summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src/index.ts
blob: 5615e088391c2356b0c23c6d8f3695a887d6eeb3 (plain)
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
import type { PermissionReply } from "@dispatch/core";
import { createBunWebSocket } from "hono/bun";
import { agentManager, app, permissionManager } from "./app.js";

const { upgradeWebSocket, websocket } = createBunWebSocket();

let clientIdCounter = 0;

app.get(
	"/ws",
	upgradeWebSocket((_c) => {
		const clientId = String(++clientIdCounter);

		return {
			onOpen(_event, ws) {
				// Send current statuses immediately
				ws.send(JSON.stringify({ type: "statuses", statuses: agentManager.getAllStatuses() }));

				// Send any pending permission prompts
				const pending = permissionManager.getPending();
				if (pending.length > 0) {
					ws.send(JSON.stringify({ type: "permission-prompt", pending }));
				}

				const unsubscribe = agentManager.onEvent((event) => {
					ws.send(JSON.stringify(event));
				});

				permissionManager.registerClient(clientId, (data) => {
					ws.send(JSON.stringify(data));
				});

				// Store cleanup on the raw socket
				(ws as unknown as { _unsub?: () => void; _clientId?: string })._unsub = unsubscribe;
				(ws as unknown as { _unsub?: () => void; _clientId?: string })._clientId = clientId;
			},
			onMessage(event, _ws) {
				try {
					const message = JSON.parse(String(event.data)) as {
						type?: string;
						id?: string;
						reply?: string;
					};
					if (
						message.type === "permission-reply" &&
						typeof message.id === "string" &&
						typeof message.reply === "string"
					) {
						const validReplies: PermissionReply[] = ["once", "always", "reject"];
						if (validReplies.includes(message.reply as PermissionReply)) {
							permissionManager.reply(message.id, message.reply as PermissionReply);
						}
					}
				} catch {
					// ignore malformed messages
				}
			},
			onClose(_event, ws) {
				const raw = ws as unknown as { _unsub?: () => void; _clientId?: string };
				if (raw._unsub) {
					raw._unsub();
				}
				if (raw._clientId) {
					permissionManager.unregisterClient(raw._clientId);
				}
			},
		};
	}),
);

export { app };

// Starting port (overridable via PORT). When the port is already in use we
// bump up one at a time (3000 → 3001 → 3002, …) until we find a free one, so
// multiple dispatch instances (e.g. testing several features at once) can
// coexist without manually juggling ports. The frontend defaults to :3000 —
// point it at the chosen port via the in-app API-URL field / VITE_API_URL
// when a bump happens.
const START_PORT = Number(process.env.PORT) || 3000;

/**
 * Bind the server to `START_PORT`, incrementing by one on EADDRINUSE until a
 * free port is found (up to the maximum valid TCP port, 65535). Bun's
 * `Bun.serve` throws synchronously when the port is taken, so we can catch and
 * retry. Returns the live server (whose `.port` reflects the port actually
 * bound).
 */
function serveWithPortFallback() {
	let lastError: unknown;
	for (let port = START_PORT; port <= 65535; port++) {
		try {
			const server = Bun.serve({
				port,
				idleTimeout: 60,
				fetch: app.fetch,
				websocket,
			});
			if (port !== START_PORT) {
				console.warn(
					`dispatch: port ${START_PORT} in use — bound to ${port} instead. ` +
						`Set the frontend's API URL to http://localhost:${port}.`,
				);
			}
			console.log(`dispatch: API listening on http://localhost:${server.port}`);
			return server;
		} catch (err) {
			const code = (err as NodeJS.ErrnoException)?.code;
			if (code === "EADDRINUSE") {
				lastError = err;
				continue;
			}
			throw err;
		}
	}
	console.error(
		`dispatch: no free port at or above ${START_PORT}. ` +
			`Free one up or set PORT to an open port.`,
	);
	throw lastError ?? new Error(`No free port at or above ${START_PORT}`);
}

// Only start the server when run as the entry point — importing this module
// (e.g. for `app`) must not bind a port. This preserves the prior
// default-export behavior where Bun served only the entry file.
if (import.meta.main) {
	serveWithPortFallback();
}