Skip to content

Commit bce56f2

Browse files
committed
Add server command to the entrypoint
This allows cmux to run as a web server. Run `node dist/main.js server` to start on port 3000.
1 parent a130274 commit bce56f2

File tree

8 files changed

+1163
-540
lines changed

8 files changed

+1163
-540
lines changed

bun.lock

Lines changed: 115 additions & 3 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"@types/bun": "^1.2.23",
8686
"@types/diff": "^8.0.0",
8787
"@types/escape-html": "^1.0.4",
88+
"@types/express": "^5.0.3",
8889
"@types/jest": "^30.0.0",
8990
"@types/katex": "^0.16.7",
9091
"@types/markdown-it": "^14.1.2",
@@ -98,6 +99,7 @@
9899
"@vitejs/plugin-react": "^4.0.0",
99100
"babel-plugin-react-compiler": "^1.0.0",
100101
"concurrently": "^8.2.0",
102+
"cors": "^2.8.5",
101103
"dotenv": "^17.2.3",
102104
"electron": "^38.2.1",
103105
"electron-builder": "^24.6.0",
@@ -106,6 +108,7 @@
106108
"eslint": "^9.36.0",
107109
"eslint-plugin-react": "^7.37.5",
108110
"eslint-plugin-react-hooks": "^5.2.0",
111+
"express": "^5.1.0",
109112
"jest": "^30.1.3",
110113
"playwright": "^1.56.0",
111114
"prettier": "^3.6.2",
@@ -116,7 +119,8 @@
116119
"typescript-eslint": "^8.45.0",
117120
"vite": "^4.4.0",
118121
"vite-plugin-svgr": "^4.5.0",
119-
"vite-plugin-top-level-await": "^1.6.0"
122+
"vite-plugin-top-level-await": "^1.6.0",
123+
"ws": "^8.18.3"
120124
},
121125
"build": {
122126
"appId": "com.cmux.app",

src/browser/api.ts

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
/**
2+
* Browser API client. Used when running cmux in server mode.
3+
*/
4+
import { IPC_CHANNELS, getChatChannel } from "@/constants/ipc-constants";
5+
import type { IPCApi } from "@/types/ipc";
6+
7+
const API_BASE = window.location.origin;
8+
const WS_BASE = API_BASE.replace("http://", "ws://").replace("https://", "wss://");
9+
10+
interface InvokeResponse<T> {
11+
success: boolean;
12+
data?: T;
13+
error?: string;
14+
}
15+
16+
// Helper function to invoke IPC handlers via HTTP
17+
async function invokeIPC<T>(channel: string, ...args: unknown[]): Promise<T> {
18+
const response = await fetch(`${API_BASE}/ipc/${encodeURIComponent(channel)}`, {
19+
method: "POST",
20+
headers: {
21+
"Content-Type": "application/json",
22+
},
23+
body: JSON.stringify({ args }),
24+
});
25+
26+
if (!response.ok) {
27+
throw new Error(`HTTP error! status: ${response.status}`);
28+
}
29+
30+
const result: InvokeResponse<T> = await response.json();
31+
32+
if (!result.success) {
33+
throw new Error(result.error || "Unknown error");
34+
}
35+
36+
return result.data as T;
37+
}
38+
39+
// WebSocket connection manager
40+
class WebSocketManager {
41+
private ws: WebSocket | null = null;
42+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
43+
private messageHandlers = new Map<string, Set<(data: unknown) => void>>();
44+
private channelWorkspaceIds = new Map<string, string>(); // Track workspaceId for each channel
45+
private isConnecting = false;
46+
private shouldReconnect = true;
47+
48+
connect(): void {
49+
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
50+
return;
51+
}
52+
53+
this.isConnecting = true;
54+
this.ws = new WebSocket(`${WS_BASE}/ws`);
55+
56+
this.ws.onopen = () => {
57+
console.log("WebSocket connected");
58+
this.isConnecting = false;
59+
60+
// Resubscribe to all channels with their workspace IDs
61+
for (const channel of this.messageHandlers.keys()) {
62+
const workspaceId = this.channelWorkspaceIds.get(channel);
63+
this.subscribe(channel, workspaceId);
64+
}
65+
};
66+
67+
this.ws.onmessage = (event) => {
68+
try {
69+
const { channel, args } = JSON.parse(event.data);
70+
const handlers = this.messageHandlers.get(channel);
71+
if (handlers) {
72+
handlers.forEach((handler) => handler(args[0]));
73+
}
74+
} catch (error) {
75+
console.error("Error handling WebSocket message:", error);
76+
}
77+
};
78+
79+
this.ws.onerror = (error) => {
80+
console.error("WebSocket error:", error);
81+
this.isConnecting = false;
82+
};
83+
84+
this.ws.onclose = () => {
85+
console.log("WebSocket disconnected");
86+
this.isConnecting = false;
87+
this.ws = null;
88+
89+
// Attempt to reconnect after a delay
90+
if (this.shouldReconnect) {
91+
this.reconnectTimer = setTimeout(() => this.connect(), 2000);
92+
}
93+
};
94+
}
95+
96+
subscribe(channel: string, workspaceId?: string): void {
97+
if (this.ws?.readyState === WebSocket.OPEN) {
98+
if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) {
99+
console.log(
100+
`[WebSocketManager] Subscribing to workspace chat for workspaceId: ${workspaceId}`
101+
);
102+
this.ws.send(
103+
JSON.stringify({
104+
type: "subscribe",
105+
channel: "workspace:chat",
106+
workspaceId,
107+
})
108+
);
109+
} else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) {
110+
this.ws.send(
111+
JSON.stringify({
112+
type: "subscribe",
113+
channel: "workspace:metadata",
114+
})
115+
);
116+
}
117+
}
118+
}
119+
120+
unsubscribe(channel: string, workspaceId?: string): void {
121+
if (this.ws?.readyState === WebSocket.OPEN) {
122+
if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) {
123+
this.ws.send(
124+
JSON.stringify({
125+
type: "unsubscribe",
126+
channel: "workspace:chat",
127+
workspaceId,
128+
})
129+
);
130+
} else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) {
131+
this.ws.send(
132+
JSON.stringify({
133+
type: "unsubscribe",
134+
channel: "workspace:metadata",
135+
})
136+
);
137+
}
138+
}
139+
}
140+
141+
on(channel: string, handler: (data: unknown) => void, workspaceId?: string): () => void {
142+
if (!this.messageHandlers.has(channel)) {
143+
this.messageHandlers.set(channel, new Set());
144+
// Store workspaceId for this channel (needed for reconnection)
145+
if (workspaceId) {
146+
this.channelWorkspaceIds.set(channel, workspaceId);
147+
}
148+
this.connect();
149+
this.subscribe(channel, workspaceId);
150+
}
151+
152+
const handlers = this.messageHandlers.get(channel)!;
153+
handlers.add(handler);
154+
155+
// Return unsubscribe function
156+
return () => {
157+
handlers.delete(handler);
158+
if (handlers.size === 0) {
159+
this.messageHandlers.delete(channel);
160+
this.channelWorkspaceIds.delete(channel);
161+
this.unsubscribe(channel, workspaceId);
162+
}
163+
};
164+
}
165+
166+
disconnect(): void {
167+
this.shouldReconnect = false;
168+
if (this.reconnectTimer) {
169+
clearTimeout(this.reconnectTimer);
170+
this.reconnectTimer = null;
171+
}
172+
if (this.ws) {
173+
this.ws.close();
174+
this.ws = null;
175+
}
176+
}
177+
}
178+
179+
const wsManager = new WebSocketManager();
180+
181+
// Create the Web API implementation
182+
const webApi: IPCApi = {
183+
dialog: {
184+
selectDirectory: async () => {
185+
// TODO: Implement remote directory selection for mobile
186+
// For now, return hardcoded path for testing
187+
return "/home/kyle/projects/coder/cmux";
188+
},
189+
},
190+
providers: {
191+
setProviderConfig: (provider, keyPath, value) =>
192+
invokeIPC(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value),
193+
list: () => invokeIPC(IPC_CHANNELS.PROVIDERS_LIST),
194+
},
195+
projects: {
196+
create: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_CREATE, projectPath),
197+
remove: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_REMOVE, projectPath),
198+
list: () => invokeIPC(IPC_CHANNELS.PROJECT_LIST),
199+
listBranches: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_LIST_BRANCHES, projectPath),
200+
secrets: {
201+
get: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_SECRETS_GET, projectPath),
202+
update: (projectPath, secrets) =>
203+
invokeIPC(IPC_CHANNELS.PROJECT_SECRETS_UPDATE, projectPath, secrets),
204+
},
205+
},
206+
workspace: {
207+
list: () => invokeIPC(IPC_CHANNELS.WORKSPACE_LIST),
208+
create: (projectPath, branchName, trunkBranch) =>
209+
invokeIPC(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName, trunkBranch),
210+
remove: (workspaceId, options) =>
211+
invokeIPC(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, options),
212+
rename: (workspaceId, newName) =>
213+
invokeIPC(IPC_CHANNELS.WORKSPACE_RENAME, workspaceId, newName),
214+
fork: (sourceWorkspaceId, newName) =>
215+
invokeIPC(IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, newName),
216+
sendMessage: (workspaceId, message, options) =>
217+
invokeIPC(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options),
218+
resumeStream: (workspaceId, options) =>
219+
invokeIPC(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options),
220+
interruptStream: (workspaceId, options) =>
221+
invokeIPC(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options),
222+
truncateHistory: (workspaceId, percentage) =>
223+
invokeIPC(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage),
224+
replaceChatHistory: (workspaceId, summaryMessage) =>
225+
invokeIPC(IPC_CHANNELS.WORKSPACE_REPLACE_HISTORY, workspaceId, summaryMessage),
226+
getInfo: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId),
227+
executeBash: (workspaceId, script, options) =>
228+
invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options),
229+
openTerminal: (workspacePath) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath),
230+
231+
onChat: (workspaceId, callback) => {
232+
const channel = getChatChannel(workspaceId);
233+
return wsManager.on(channel, callback as (data: unknown) => void, workspaceId);
234+
},
235+
236+
onMetadata: (callback) => {
237+
return wsManager.on(IPC_CHANNELS.WORKSPACE_METADATA, callback as (data: unknown) => void);
238+
},
239+
},
240+
window: {
241+
setTitle: (title) => {
242+
document.title = title;
243+
return Promise.resolve();
244+
},
245+
},
246+
update: {
247+
check: () => invokeIPC(IPC_CHANNELS.UPDATE_CHECK),
248+
download: () => invokeIPC(IPC_CHANNELS.UPDATE_DOWNLOAD),
249+
install: () => {
250+
// Install is a one-way call that doesn't wait for response
251+
invokeIPC(IPC_CHANNELS.UPDATE_INSTALL);
252+
},
253+
onStatus: (callback) => {
254+
return wsManager.on(IPC_CHANNELS.UPDATE_STATUS, callback as (data: unknown) => void);
255+
},
256+
},
257+
};
258+
259+
if (typeof window["api"] === "undefined") {
260+
// @ts-ignore
261+
window["api"] = webApi;
262+
}
263+
264+
window.addEventListener("beforeunload", () => {
265+
wsManager.disconnect();
266+
});

0 commit comments

Comments
 (0)