Files
cline/apps/examples/desktop-app/sidecar/server.ts
T
Saoud Rizwan d41eed1198 feat(desktop-app): env-configurable sidecar bind host, trusted origins, and webview WS endpoint (#12250)
* feat(desktop-app): env-configurable sidecar bind host, trusted origins, and webview WS endpoint

Allows running the desktop app's web dev mode (dev:web + dev:sidecar) inside
a Docker container with published ports:

- CLINE_SIDECAR_HOST: sidecar bind hostname (default remains 127.0.0.1)
- CLINE_SIDECAR_TRUSTED_ORIGINS: comma-separated extra browser origins for
  the sidecar's origin allowlist (validation itself stays on)
- NEXT_PUBLIC_SIDECAR_WS_ENDPOINT: overrides the webview's hardcoded
  ws://127.0.0.1:3126/transport fallback so a browser on the Docker host can
  dial the published port

All defaults are unchanged, so local/Tauri behavior is unaffected when the
env vars are absent. When bound to 0.0.0.0 the printed ready endpoint
advertises 127.0.0.1 since a wildcard bind is not dialable.

* chore(desktop-app): untrack next-env.d.ts

It was added to .gitignore previously but never removed from the index, so
it kept showing as modified: Next.js rewrites the routes.d.ts import path
depending on whether 'next dev' or 'next build' ran last. The file is
regenerated by Next on every dev/build run, and the app's typecheck
(tsconfig.dev.json) excludes webview/, so nothing needs it tracked.

* style(desktop-app): format SIDECAR_HOST declaration
2026-07-12 21:59:07 -07:00

256 lines
6.1 KiB
TypeScript

import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
import { handleCommand } from "./commands";
import { sendEvent } from "./context";
import { fetchMarketplaceCatalog } from "./marketplace";
import {
BunRuntime,
SIDECAR_HOST,
SIDECAR_MODE,
SIDECAR_PORT,
type SidecarContext,
type SidecarWebSocketClient,
} from "./types";
type SidecarServer = {
port: number;
upgrade(req: Request): boolean;
};
// Comma-separated extra origins (e.g. a dev server on a nonstandard port when
// the sidecar runs inside a container). Origin validation itself stays on.
const EXTRA_TRUSTED_ORIGINS = (process.env.CLINE_SIDECAR_TRUSTED_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const TRUSTED_BROWSER_ORIGINS = new Set([
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost",
"http://localhost:3125",
"http://127.0.0.1:3125",
...EXTRA_TRUSTED_ORIGINS,
]);
const JSON_HEADERS = {
"content-type": "application/json",
};
function readOrigin(req: Request): string | undefined {
const origin = req.headers.get("origin")?.trim();
return origin ? origin : undefined;
}
function isTrustedRequestOrigin(req: Request): boolean {
const origin = readOrigin(req);
return !origin || TRUSTED_BROWSER_ORIGINS.has(origin);
}
function corsHeaders(req: Request): Record<string, string> {
const origin = readOrigin(req);
return {
"access-control-allow-headers": "accept, content-type",
"access-control-allow-methods": "GET, POST, OPTIONS",
...(origin && TRUSTED_BROWSER_ORIGINS.has(origin)
? {
"access-control-allow-origin": origin,
vary: "Origin",
}
: {}),
};
}
function jsonHeaders(req: Request): Record<string, string> {
return {
...JSON_HEADERS,
...corsHeaders(req),
};
}
// ---------------------------------------------------------------------------
// JSON response helper
// ---------------------------------------------------------------------------
function jsonResponse(
id: string,
ok: boolean,
result?: unknown,
error?: string,
): string {
return JSON.stringify({ type: "response", id, ok, result, error });
}
function createJsonResponse(
req: Request,
body: unknown,
status = 200,
): Response {
return new Response(JSON.stringify(body), {
status,
headers: jsonHeaders(req),
});
}
const EMPTY_MARKETPLACE_CATALOG = {
version: 1,
counts: {
total: 0,
plugins: 0,
skills: 0,
mcps: 0,
},
tags: [],
entries: [],
};
// ---------------------------------------------------------------------------
// Bun HTTP + WebSocket server
// ---------------------------------------------------------------------------
export function startServer(
ctx: SidecarContext,
preferredPort: number = SIDECAR_PORT,
onShutdown?: (reason?: string) => Promise<void>,
): { port: number } {
if (!BunRuntime) {
throw new Error("sidecar must be run with Bun");
}
let server: SidecarServer | undefined;
let lastError: unknown;
// Try the preferred port first, then fall back to OS-assigned port (0).
const candidates = [preferredPort, 0];
for (const candidate of candidates) {
try {
server = BunRuntime.serve({
hostname: SIDECAR_HOST,
port: candidate,
fetch: createFetchHandler(ctx, onShutdown),
websocket: createWebSocketHandler(ctx),
}) as SidecarServer;
break;
} catch (error) {
lastError = error;
}
}
if (!server) {
throw lastError ?? new Error("Failed to start sidecar server");
}
return { port: server.port };
}
export function createFetchHandler(
_ctx: SidecarContext,
onShutdown?: (reason?: string) => Promise<void>,
) {
return async (req: Request, server: SidecarServer) => {
const url = new URL(req.url);
if (req.method === "OPTIONS") {
if (!isTrustedRequestOrigin(req)) {
return new Response(null, { status: 403 });
}
return new Response(null, { status: 204, headers: corsHeaders(req) });
}
if (url.pathname === "/health") {
return new Response(
JSON.stringify({
ok: true,
mode: SIDECAR_MODE,
pid: process.pid,
}),
{ headers: jsonHeaders(req) },
);
}
if (
url.pathname === "/transport" &&
isTrustedRequestOrigin(req) &&
server.upgrade(req)
) {
return undefined;
}
if (url.pathname === "/api/marketplace/catalog") {
try {
return createJsonResponse(req, await fetchMarketplaceCatalog());
} catch (error) {
return createJsonResponse(req, {
...EMPTY_MARKETPLACE_CATALOG,
error:
error instanceof Error
? error.message
: "Failed to fetch marketplace catalog",
});
}
}
if (url.pathname === "/shutdown" && req.method === "POST") {
if (!isTrustedRequestOrigin(req)) {
return new Response(JSON.stringify({ ok: false }), {
status: 403,
headers: jsonHeaders(req),
});
}
queueMicrotask(() => {
void onShutdown?.("code_sidecar_shutdown_endpoint")
.catch((error) => {
process.stderr.write(
`sidecar shutdown failed: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
})
.finally(() => process.exit(0));
});
return new Response(JSON.stringify({ ok: true }), {
headers: jsonHeaders(req),
});
}
return new Response("Not found", { status: 404 });
};
}
function createWebSocketHandler(ctx: SidecarContext) {
return {
open(ws: SidecarWebSocketClient) {
ctx.wsClients.add(ws);
sendEvent(ctx, "host_ready", {
pid: process.pid,
mode: SIDECAR_MODE,
});
},
async message(ws: SidecarWebSocketClient, raw: string) {
let request: DesktopTransportRequest;
try {
request = JSON.parse(String(raw)) as DesktopTransportRequest;
} catch {
ws.send(
jsonResponse(
"",
false,
undefined,
"invalid desktop transport payload",
),
);
return;
}
try {
const result = await handleCommand(ctx, request.command, request.args);
ws.send(jsonResponse(request.id, true, result));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ws.send(jsonResponse(request.id, false, undefined, message));
}
},
close(ws: SidecarWebSocketClient) {
ctx.wsClients.delete(ws);
},
};
}