Skip to content

Commit 15d325c

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 055145c commit 15d325c

File tree

9 files changed

+505
-27
lines changed

9 files changed

+505
-27
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/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ if (subcommand === "run") {
4242
process.argv.splice(2, 1);
4343
// eslint-disable-next-line @typescript-eslint/no-require-imports
4444
require("./api");
45-
} else if (subcommand === "desktop" || (isElectron && (subcommand === undefined || isElectronLaunchArg))) {
45+
} else if (
46+
subcommand === "desktop" ||
47+
(isElectron && (subcommand === undefined || isElectronLaunchArg))
48+
) {
4649
// Explicit `mux desktop`, or Electron runtime with no subcommand / Electron launch args
4750
launchDesktop();
4851
} else {

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";
@@ -304,33 +306,71 @@ async function loadServices(): Promise<void> {
304306
services = new ServiceContainerClass(config);
305307
await services.initialize();
306308

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

323+
// Build the oRPC context with all services
324+
const orpcContext = {
325+
projectService: services.projectService,
326+
workspaceService: services.workspaceService,
327+
providerService: services.providerService,
328+
terminalService: services.terminalService,
329+
windowService: services.windowService,
330+
updateService: services.updateService,
331+
tokenizerService: services.tokenizerService,
332+
serverService: services.serverService,
333+
menuEventService: services.menuEventService,
334+
voiceService: services.voiceService,
335+
};
336+
315337
electronIpcMain.on("start-orpc-server", (event) => {
316338
const [serverPort] = event.ports;
317339
orpcHandler.upgrade(serverPort, {
318340
context: {
319-
projectService: services!.projectService,
320-
workspaceService: services!.workspaceService,
321-
providerService: services!.providerService,
322-
terminalService: services!.terminalService,
323-
windowService: services!.windowService,
324-
updateService: services!.updateService,
325-
tokenizerService: services!.tokenizerService,
326-
serverService: services!.serverService,
327-
menuEventService: services!.menuEventService,
328-
voiceService: services!.voiceService,
341+
...orpcContext,
342+
// Inject synthetic auth header so auth middleware passes
343+
headers: { authorization: `Bearer ${authToken}` },
329344
},
330345
});
331346
serverPort.start();
332347
});
333348

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

570+
app.on("before-quit", () => {
571+
console.log(`[${timestamp()}] App before-quit - cleaning up API server...`);
572+
if (services) {
573+
void services.serverService.stopServer();
574+
}
575+
});
576+
530577
app.on("activate", () => {
531578
// Skip splash on reactivation - services already loaded, window creation is fast
532579
if (app.isReady() && mainWindow === null) {
Lines changed: 9 additions & 5 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";
@@ -30,12 +30,14 @@ export interface OrpcServerOptions {
3030
context: ORPCContext;
3131
/** Whether to serve static files and SPA fallback (default: false) */
3232
serveStatic?: boolean;
33-
/** Directory to serve static files from (default: __dirname/..) */
33+
/** Directory to serve static files from (default: dist/ relative to dist/node/orpc/) */
3434
staticDir?: string;
3535
/** Custom error handler for oRPC errors */
3636
onOrpcError?: (error: unknown) => void;
37-
/** Optional bearer token for HTTP auth */
37+
/** Optional bearer token for HTTP auth (used if router not provided) */
3838
authToken?: string;
39+
/** Optional pre-created router (if not provided, creates router(authToken)) */
40+
router?: AppRouter;
3941
}
4042

4143
export interface OrpcServer {
@@ -71,8 +73,10 @@ export async function createOrpcServer({
7173
authToken,
7274
context,
7375
serveStatic = false,
74-
staticDir = path.join(__dirname, ".."),
76+
// From dist/node/orpc/, go up 2 levels to reach dist/ where index.html lives
77+
staticDir = path.join(__dirname, "../.."),
7578
onOrpcError = (error) => log.error("ORPC Error:", error),
79+
router: existingRouter,
7680
}: OrpcServerOptions): Promise<OrpcServer> {
7781
// Express app setup
7882
const app = express();
@@ -94,7 +98,7 @@ export async function createOrpcServer({
9498
res.json({ ...VERSION, mode: "server" });
9599
});
96100

97-
const orpcRouter = router(authToken);
101+
const orpcRouter = existingRouter ?? router(authToken);
98102

99103
// oRPC HTTP handler
100104
const orpcHandler = new RPCHandler(orpcRouter, {

0 commit comments

Comments
 (0)