Skip to content

Commit 91d4804

Browse files
authored
Add server command to the entrypoint (#353)
This allows cmux to run as a web server. Run `node dist/main.js server` to start on port 3000. <img width="970" height="570" alt="image" src="https://github.com/user-attachments/assets/c777b7a6-46ca-447f-91a0-bd2694ee58f5" /> This directory select modal appears only when in browser mode for opening a new project. Everything else is the same.
1 parent a130274 commit 91d4804

File tree

11 files changed

+1329
-541
lines changed

11 files changed

+1329
-541
lines changed

bun.lock

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

eslint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ export default defineConfig([
329329
"src/config.ts",
330330
"src/debug/**/*.ts",
331331
"src/git.ts",
332-
"src/main.ts",
332+
"src/main-desktop.ts",
333333
"src/config.test.ts",
334334
"src/services/gitService.ts",
335335
"src/services/log.ts",

package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,21 +83,25 @@
8383
"@storybook/test-runner": "^0.23.0",
8484
"@testing-library/react": "^16.3.0",
8585
"@types/bun": "^1.2.23",
86+
"@types/cors": "^2.8.19",
8687
"@types/diff": "^8.0.0",
8788
"@types/escape-html": "^1.0.4",
89+
"@types/express": "^5.0.3",
8890
"@types/jest": "^30.0.0",
8991
"@types/katex": "^0.16.7",
9092
"@types/markdown-it": "^14.1.2",
9193
"@types/minimist": "^1.2.5",
9294
"@types/react": "^18.2.0",
9395
"@types/react-dom": "^18.2.0",
9496
"@types/write-file-atomic": "^4.0.3",
97+
"@types/ws": "^8.18.1",
9598
"@typescript-eslint/eslint-plugin": "^8.44.1",
9699
"@typescript-eslint/parser": "^8.44.1",
97100
"@typescript/native-preview": "^7.0.0-dev.20251014.1",
98101
"@vitejs/plugin-react": "^4.0.0",
99102
"babel-plugin-react-compiler": "^1.0.0",
100103
"concurrently": "^8.2.0",
104+
"cors": "^2.8.5",
101105
"dotenv": "^17.2.3",
102106
"electron": "^38.2.1",
103107
"electron-builder": "^24.6.0",
@@ -106,6 +110,7 @@
106110
"eslint": "^9.36.0",
107111
"eslint-plugin-react": "^7.37.5",
108112
"eslint-plugin-react-hooks": "^5.2.0",
113+
"express": "^5.1.0",
109114
"jest": "^30.1.3",
110115
"playwright": "^1.56.0",
111116
"prettier": "^3.6.2",
@@ -116,7 +121,8 @@
116121
"typescript-eslint": "^8.45.0",
117122
"vite": "^4.4.0",
118123
"vite-plugin-svgr": "^4.5.0",
119-
"vite-plugin-top-level-await": "^1.6.0"
124+
"vite-plugin-top-level-await": "^1.6.0",
125+
"ws": "^8.18.3"
120126
},
121127
"build": {
122128
"appId": "com.cmux.app",

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { WorkspaceSelection } from "./components/ProjectSidebar";
99
import type { FrontendWorkspaceMetadata } from "./types/workspace";
1010
import { LeftSidebar } from "./components/LeftSidebar";
1111
import NewWorkspaceModal from "./components/NewWorkspaceModal";
12+
import { DirectorySelectModal } from "./components/DirectorySelectModal";
1213
import { AIView } from "./components/AIView";
1314
import { ErrorBoundary } from "./components/ErrorBoundary";
1415
import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState";
@@ -870,6 +871,7 @@ function AppInner() {
870871
onAdd={handleCreateWorkspace}
871872
/>
872873
)}
874+
<DirectorySelectModal />
873875
</AppContainer>
874876
</>
875877
);

src/browser/api.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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 = (await response.json()) as InvokeResponse<T>;
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 parsed = JSON.parse(event.data as string) as { channel: string; args: unknown[] };
70+
const { channel, args } = parsed;
71+
const handlers = this.messageHandlers.get(channel);
72+
if (handlers && args.length > 0) {
73+
handlers.forEach((handler) => handler(args[0]));
74+
}
75+
} catch (error) {
76+
console.error("Error handling WebSocket message:", error);
77+
}
78+
};
79+
80+
this.ws.onerror = (error) => {
81+
console.error("WebSocket error:", error);
82+
this.isConnecting = false;
83+
};
84+
85+
this.ws.onclose = () => {
86+
console.log("WebSocket disconnected");
87+
this.isConnecting = false;
88+
this.ws = null;
89+
90+
// Attempt to reconnect after a delay
91+
if (this.shouldReconnect) {
92+
this.reconnectTimer = setTimeout(() => this.connect(), 2000);
93+
}
94+
};
95+
}
96+
97+
subscribe(channel: string, workspaceId?: string): void {
98+
if (this.ws?.readyState === WebSocket.OPEN) {
99+
if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) {
100+
console.log(
101+
`[WebSocketManager] Subscribing to workspace chat for workspaceId: ${workspaceId ?? "undefined"}`
102+
);
103+
this.ws.send(
104+
JSON.stringify({
105+
type: "subscribe",
106+
channel: "workspace:chat",
107+
workspaceId,
108+
})
109+
);
110+
} else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) {
111+
this.ws.send(
112+
JSON.stringify({
113+
type: "subscribe",
114+
channel: "workspace:metadata",
115+
})
116+
);
117+
}
118+
}
119+
}
120+
121+
unsubscribe(channel: string, workspaceId?: string): void {
122+
if (this.ws?.readyState === WebSocket.OPEN) {
123+
if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) {
124+
this.ws.send(
125+
JSON.stringify({
126+
type: "unsubscribe",
127+
channel: "workspace:chat",
128+
workspaceId,
129+
})
130+
);
131+
} else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) {
132+
this.ws.send(
133+
JSON.stringify({
134+
type: "unsubscribe",
135+
channel: "workspace:metadata",
136+
})
137+
);
138+
}
139+
}
140+
}
141+
142+
on(channel: string, handler: (data: unknown) => void, workspaceId?: string): () => void {
143+
if (!this.messageHandlers.has(channel)) {
144+
this.messageHandlers.set(channel, new Set());
145+
// Store workspaceId for this channel (needed for reconnection)
146+
if (workspaceId) {
147+
this.channelWorkspaceIds.set(channel, workspaceId);
148+
}
149+
this.connect();
150+
this.subscribe(channel, workspaceId);
151+
}
152+
153+
const handlers = this.messageHandlers.get(channel)!;
154+
handlers.add(handler);
155+
156+
// Return unsubscribe function
157+
return () => {
158+
handlers.delete(handler);
159+
if (handlers.size === 0) {
160+
this.messageHandlers.delete(channel);
161+
this.channelWorkspaceIds.delete(channel);
162+
this.unsubscribe(channel, workspaceId);
163+
}
164+
};
165+
}
166+
167+
disconnect(): void {
168+
this.shouldReconnect = false;
169+
if (this.reconnectTimer) {
170+
clearTimeout(this.reconnectTimer);
171+
this.reconnectTimer = null;
172+
}
173+
if (this.ws) {
174+
this.ws.close();
175+
this.ws = null;
176+
}
177+
}
178+
}
179+
180+
const wsManager = new WebSocketManager();
181+
182+
// Directory selection via custom event (for browser mode)
183+
interface DirectorySelectEvent extends CustomEvent {
184+
detail: {
185+
resolve: (path: string | null) => void;
186+
};
187+
}
188+
189+
function requestDirectorySelection(): Promise<string | null> {
190+
return new Promise((resolve) => {
191+
const event = new CustomEvent("directory-select-request", {
192+
detail: { resolve },
193+
}) as DirectorySelectEvent;
194+
window.dispatchEvent(event);
195+
});
196+
}
197+
198+
// Create the Web API implementation
199+
const webApi: IPCApi = {
200+
dialog: {
201+
selectDirectory: requestDirectorySelection,
202+
},
203+
providers: {
204+
setProviderConfig: (provider, keyPath, value) =>
205+
invokeIPC(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value),
206+
list: () => invokeIPC(IPC_CHANNELS.PROVIDERS_LIST),
207+
},
208+
projects: {
209+
create: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_CREATE, projectPath),
210+
remove: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_REMOVE, projectPath),
211+
list: () => invokeIPC(IPC_CHANNELS.PROJECT_LIST),
212+
listBranches: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_LIST_BRANCHES, projectPath),
213+
secrets: {
214+
get: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_SECRETS_GET, projectPath),
215+
update: (projectPath, secrets) =>
216+
invokeIPC(IPC_CHANNELS.PROJECT_SECRETS_UPDATE, projectPath, secrets),
217+
},
218+
},
219+
workspace: {
220+
list: () => invokeIPC(IPC_CHANNELS.WORKSPACE_LIST),
221+
create: (projectPath, branchName, trunkBranch) =>
222+
invokeIPC(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName, trunkBranch),
223+
remove: (workspaceId, options) =>
224+
invokeIPC(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, options),
225+
rename: (workspaceId, newName) =>
226+
invokeIPC(IPC_CHANNELS.WORKSPACE_RENAME, workspaceId, newName),
227+
fork: (sourceWorkspaceId, newName) =>
228+
invokeIPC(IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, newName),
229+
sendMessage: (workspaceId, message, options) =>
230+
invokeIPC(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options),
231+
resumeStream: (workspaceId, options) =>
232+
invokeIPC(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options),
233+
interruptStream: (workspaceId, options) =>
234+
invokeIPC(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options),
235+
truncateHistory: (workspaceId, percentage) =>
236+
invokeIPC(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage),
237+
replaceChatHistory: (workspaceId, summaryMessage) =>
238+
invokeIPC(IPC_CHANNELS.WORKSPACE_REPLACE_HISTORY, workspaceId, summaryMessage),
239+
getInfo: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId),
240+
executeBash: (workspaceId, script, options) =>
241+
invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options),
242+
openTerminal: (workspacePath) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath),
243+
244+
onChat: (workspaceId, callback) => {
245+
const channel = getChatChannel(workspaceId);
246+
return wsManager.on(channel, callback as (data: unknown) => void, workspaceId);
247+
},
248+
249+
onMetadata: (callback) => {
250+
return wsManager.on(IPC_CHANNELS.WORKSPACE_METADATA, callback as (data: unknown) => void);
251+
},
252+
},
253+
window: {
254+
setTitle: (title) => {
255+
document.title = title;
256+
return Promise.resolve();
257+
},
258+
},
259+
update: {
260+
check: () => invokeIPC(IPC_CHANNELS.UPDATE_CHECK),
261+
download: () => invokeIPC(IPC_CHANNELS.UPDATE_DOWNLOAD),
262+
install: () => {
263+
// Install is a one-way call that doesn't wait for response
264+
void invokeIPC(IPC_CHANNELS.UPDATE_INSTALL);
265+
},
266+
onStatus: (callback) => {
267+
return wsManager.on(IPC_CHANNELS.UPDATE_STATUS, callback as (data: unknown) => void);
268+
},
269+
},
270+
};
271+
272+
if (typeof window.api === "undefined") {
273+
// @ts-expect-error - Assigning to window.api which is not in TypeScript types
274+
window.api = webApi;
275+
}
276+
277+
window.addEventListener("beforeunload", () => {
278+
wsManager.disconnect();
279+
});

0 commit comments

Comments
 (0)