Skip to content

Commit 18d85b3

Browse files
committed
feat: unify Electron and CLI with shared oRPC API server
Electron now runs an HTTP/WS API server on 127.0.0.1 alongside the existing MessagePort transport. Both transports share the same oRPC router instance with auth middleware. - Add ServerLockfile to manage ~/.mux/server.lock for discovery - Extend ServerService with startServer/stopServer lifecycle - Move orpcServer.ts to src/node/orpc/server.ts (shared module) - Electron generates auth token, injects into both transports - CLI server checks lockfile to prevent conflicts - CLI api auto-discovers running server via lockfile Env vars: - MUX_SERVER_AUTH_TOKEN: Override auth token - MUX_SERVER_PORT: Fixed port (default: random) - MUX_NO_API_SERVER=1: Disable API server in Electron
1 parent a808f8a commit 18d85b3

File tree

8 files changed

+498
-24
lines changed

8 files changed

+498
-24
lines changed

src/cli/api.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,63 @@
33
*
44
* This module is loaded lazily to avoid pulling in ESM-only dependencies
55
* (trpc-cli) when running other commands like the desktop app.
6+
*
7+
* Server discovery priority:
8+
* 1. MUX_SERVER_URL env var (explicit override)
9+
* 2. Lockfile at ~/.mux/server.lock (running Electron or mux server)
10+
* 3. Fallback to http://localhost:3000
611
*/
712

813
import { createCli } from "trpc-cli";
914
import { router } from "@/node/orpc/router";
1015
import { proxifyOrpc } from "./proxifyOrpc";
16+
import { ServerLockfile } from "@/node/services/serverLockfile";
17+
import { getMuxHome } from "@/common/constants/paths";
1118
import type { Command } from "commander";
1219

13-
const baseUrl = process.env.MUX_SERVER_URL ?? "http://localhost:3000";
14-
const authToken = process.env.MUX_SERVER_AUTH_TOKEN;
20+
interface ServerDiscovery {
21+
baseUrl: string;
22+
authToken: string | undefined;
23+
}
24+
25+
async function discoverServer(): Promise<ServerDiscovery> {
26+
// Priority 1: Explicit env vars override everything
27+
if (process.env.MUX_SERVER_URL) {
28+
return {
29+
baseUrl: process.env.MUX_SERVER_URL,
30+
authToken: process.env.MUX_SERVER_AUTH_TOKEN,
31+
};
32+
}
33+
34+
// Priority 2: Try lockfile discovery (running Electron or mux server)
35+
try {
36+
const lockfile = new ServerLockfile(getMuxHome());
37+
const data = await lockfile.read();
38+
if (data) {
39+
return {
40+
baseUrl: `http://127.0.0.1:${data.port}`,
41+
authToken: data.token,
42+
};
43+
}
44+
} catch {
45+
// Ignore lockfile errors
46+
}
47+
48+
// Priority 3: Default fallback (standalone server on default port)
49+
return {
50+
baseUrl: "http://localhost:3000",
51+
authToken: process.env.MUX_SERVER_AUTH_TOKEN,
52+
};
53+
}
54+
55+
// Run async discovery then start CLI
56+
(async () => {
57+
const { baseUrl, authToken } = await discoverServer();
1558

16-
const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken });
17-
const cli = createCli({ router: proxiedRouter }).buildProgram() as Command;
59+
const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken });
60+
const cli = createCli({ router: proxiedRouter }).buildProgram() as Command;
1861

19-
cli.name("mux api");
20-
cli.description("Interact with the mux API via a running server");
21-
cli.parse();
62+
cli.name("mux api");
63+
cli.description("Interact with the mux API via a running server");
64+
cli.parse();
65+
})();

src/cli/server.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import type { ORPCContext } from "@/node/orpc/context";
2424
import { Config } from "@/node/config";
2525
import { ServiceContainer } from "@/node/services/serviceContainer";
2626
import type { RouterClient } from "@orpc/server";
27-
import { createOrpcServer, type OrpcServer } from "./orpcServer";
27+
import { createOrpcServer, type OrpcServer } from "@/node/orpc/server";
2828

2929
// --- Test Server Factory ---
3030

src/cli/server.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
*/
55
import { Config } from "@/node/config";
66
import { ServiceContainer } from "@/node/services/serviceContainer";
7-
import { migrateLegacyMuxHome } from "@/common/constants/paths";
7+
import { ServerLockfile } from "@/node/services/serverLockfile";
8+
import { getMuxHome, migrateLegacyMuxHome } from "@/common/constants/paths";
89
import type { BrowserWindow } from "electron";
910
import { Command } from "commander";
1011
import { validateProjectPath } from "@/node/utils/pathUtils";
11-
import { createOrpcServer } from "./orpcServer";
12+
import { createOrpcServer } from "@/node/orpc/server";
1213
import type { ORPCContext } from "@/node/orpc/context";
1314

1415
const program = new Command();
@@ -44,6 +45,15 @@ const mockWindow: BrowserWindow = {
4445
(async () => {
4546
migrateLegacyMuxHome();
4647

48+
// Check for existing server (Electron or another mux server instance)
49+
const lockfile = new ServerLockfile(getMuxHome());
50+
const existing = await lockfile.read();
51+
if (existing) {
52+
console.error(`Error: mux API server is already running on port ${existing.port}`);
53+
console.error(`Use 'mux api' commands to interact with the running instance.`);
54+
process.exit(1);
55+
}
56+
4757
const config = new Config();
4858
const serviceContainer = new ServiceContainer(config);
4959
await serviceContainer.initialize();
@@ -78,7 +88,22 @@ const mockWindow: BrowserWindow = {
7888
serveStatic: true,
7989
});
8090

91+
// Acquire lockfile so other instances know we're running
92+
await lockfile.acquire(server.port, AUTH_TOKEN ?? "");
93+
8194
console.log(`Server is running on ${server.baseUrl}`);
95+
96+
// Cleanup on shutdown
97+
const cleanup = () => {
98+
console.log("Shutting down server...");
99+
void lockfile
100+
.release()
101+
.then(() => server.close())
102+
.then(() => process.exit(0));
103+
};
104+
105+
process.on("SIGINT", cleanup);
106+
process.on("SIGTERM", cleanup);
82107
})().catch((error) => {
83108
console.error("Failed to initialize server:", error);
84109
process.exit(1);

src/desktop/main.ts

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// Enable source map support for better error stack traces in production
22
import "source-map-support/register";
3+
import { randomBytes } from "crypto";
34
import { RPCHandler } from "@orpc/server/message-port";
45
import { onError } from "@orpc/server";
56
import { router } from "@/node/orpc/router";
7+
import { ServerLockfile } from "@/node/services/serverLockfile";
68
import "disposablestack/auto";
79

810
import type { MenuItemConstructorOptions } from "electron";
@@ -301,33 +303,71 @@ async function loadServices(): Promise<void> {
301303
services = new ServiceContainerClass(config);
302304
await services.initialize();
303305

304-
const orpcHandler = new RPCHandler(router(), {
306+
// Generate auth token (use env var or random per-session)
307+
const authToken = process.env.MUX_SERVER_AUTH_TOKEN ?? randomBytes(32).toString("hex");
308+
309+
// Single router instance with auth middleware - used for both MessagePort and HTTP/WS
310+
const orpcRouter = router(authToken);
311+
312+
const orpcHandler = new RPCHandler(orpcRouter, {
305313
interceptors: [
306314
onError((error) => {
307315
console.error("ORPC Error:", error);
308316
}),
309317
],
310318
});
311319

320+
// Build the oRPC context with all services
321+
const orpcContext = {
322+
projectService: services.projectService,
323+
workspaceService: services.workspaceService,
324+
providerService: services.providerService,
325+
terminalService: services.terminalService,
326+
windowService: services.windowService,
327+
updateService: services.updateService,
328+
tokenizerService: services.tokenizerService,
329+
serverService: services.serverService,
330+
menuEventService: services.menuEventService,
331+
voiceService: services.voiceService,
332+
};
333+
312334
electronIpcMain.on("start-orpc-server", (event) => {
313335
const [serverPort] = event.ports;
314336
orpcHandler.upgrade(serverPort, {
315337
context: {
316-
projectService: services!.projectService,
317-
workspaceService: services!.workspaceService,
318-
providerService: services!.providerService,
319-
terminalService: services!.terminalService,
320-
windowService: services!.windowService,
321-
updateService: services!.updateService,
322-
tokenizerService: services!.tokenizerService,
323-
serverService: services!.serverService,
324-
menuEventService: services!.menuEventService,
325-
voiceService: services!.voiceService,
338+
...orpcContext,
339+
// Inject synthetic auth header so auth middleware passes
340+
headers: { authorization: `Bearer ${authToken}` },
326341
},
327342
});
328343
serverPort.start();
329344
});
330345

346+
// Start HTTP/WS API server for CLI access (unless explicitly disabled)
347+
if (process.env.MUX_NO_API_SERVER !== "1") {
348+
const lockfile = new ServerLockfile(config.rootDir);
349+
const existing = await lockfile.read();
350+
351+
if (existing) {
352+
console.log(`[${timestamp()}] API server already running on port ${existing.port}, skipping`);
353+
} else {
354+
try {
355+
const port = process.env.MUX_SERVER_PORT ? parseInt(process.env.MUX_SERVER_PORT, 10) : 0;
356+
const serverInfo = await services.serverService.startServer({
357+
muxHome: config.rootDir,
358+
context: orpcContext,
359+
router: orpcRouter,
360+
authToken,
361+
port,
362+
});
363+
console.log(`[${timestamp()}] API server started on port ${serverInfo.port}`);
364+
} catch (error) {
365+
console.error(`[${timestamp()}] Failed to start API server:`, error);
366+
// Non-fatal - continue without API server
367+
}
368+
}
369+
}
370+
331371
// Set TerminalWindowManager for desktop mode (pop-out terminal windows)
332372
const terminalWindowManager = new TerminalWindowManagerClass(config);
333373
services.setProjectDirectoryPicker(async () => {
@@ -524,6 +564,13 @@ if (gotTheLock) {
524564
}
525565
});
526566

567+
app.on("before-quit", () => {
568+
console.log(`[${timestamp()}] App before-quit - cleaning up API server...`);
569+
if (services) {
570+
void services.serverService.stopServer();
571+
}
572+
});
573+
527574
app.on("activate", () => {
528575
// Skip splash on reactivation - services already loaded, window creation is fast
529576
if (app.isReady() && mainWindow === null) {
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { WebSocketServer } from "ws";
1313
import { RPCHandler } from "@orpc/server/node";
1414
import { RPCHandler as ORPCWebSocketServerHandler } from "@orpc/server/ws";
1515
import { onError } from "@orpc/server";
16-
import { router } from "@/node/orpc/router";
16+
import { router, type AppRouter } from "@/node/orpc/router";
1717
import type { ORPCContext } from "@/node/orpc/context";
1818
import { extractWsHeaders } from "@/node/orpc/authMiddleware";
1919
import { VERSION } from "@/version";
@@ -33,8 +33,10 @@ export interface OrpcServerOptions {
3333
staticDir?: string;
3434
/** Custom error handler for oRPC errors */
3535
onOrpcError?: (error: unknown) => void;
36-
/** Optional bearer token for HTTP auth */
36+
/** Optional bearer token for HTTP auth (used if router not provided) */
3737
authToken?: string;
38+
/** Optional pre-created router (if not provided, creates router(authToken)) */
39+
router?: AppRouter;
3840
}
3941

4042
export interface OrpcServer {
@@ -72,6 +74,7 @@ export async function createOrpcServer({
7274
serveStatic = false,
7375
staticDir = path.join(__dirname, ".."),
7476
onOrpcError = (error) => console.error("ORPC Error:", error),
77+
router: existingRouter,
7578
}: OrpcServerOptions): Promise<OrpcServer> {
7679
// Express app setup
7780
const app = express();
@@ -93,7 +96,7 @@ export async function createOrpcServer({
9396
res.json({ ...VERSION, mode: "server" });
9497
});
9598

96-
const orpcRouter = router(authToken);
99+
const orpcRouter = existingRouter ?? router(authToken);
97100

98101
// oRPC HTTP handler
99102
const orpcHandler = new RPCHandler(orpcRouter, {

0 commit comments

Comments
 (0)