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();
}
|