feat(desktop): implement sidecar server process

Move server initialization to a utility process (sidecar) for better
isolation and resource management. This decouples the server lifecycle
from the main Electron process, improving stability and startup control.
This commit is contained in:
Brendan Allan
2026-05-06 11:39:34 +08:00
parent 6e7c9eb820
commit b72b2fe4d4
5 changed files with 264 additions and 58 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ export default defineConfig({
},
build: {
rollupOptions: {
input: { index: "src/main/index.ts" },
input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" },
},
externalizeDeps: { include: [nodePtyPkg] },
},
+10
View File
@@ -5,6 +5,16 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare namespace NodeJS {
interface Process {
readonly parentPort?: {
postMessage(message: unknown): void
on(event: "message", listener: (event: { data: unknown; ports: unknown[] }) => void): void
}
}
}
declare module "virtual:opencode-server" {
export namespace Server {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
+36 -29
View File
@@ -5,7 +5,7 @@ import * as http from "node:http"
import { createServer } from "node:net"
import { homedir } from "node:os"
import { join } from "node:path"
import { getCACertificates, setDefaultCACertificates } from "node:tls"
import * as tls from "node:tls"
import type { Event } from "electron"
import { app, BrowserWindow, dialog } from "electron"
import pkg from "electron-updater"
@@ -36,6 +36,11 @@ app.setAppUserModelId(appId)
app.setPath("userData", join(app.getPath("appData"), appId))
const { autoUpdater } = pkg
type NodeTlsWithSystemCertificates = typeof tls & {
getCACertificates: (type: "default" | "system") => string[]
setDefaultCACertificates: (certificates: string[]) => void
}
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import { CHANNEL, UPDATER_ENABLED } from "./constants"
@@ -43,7 +48,14 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio
import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import {
getDefaultServerUrl,
getWslConfig,
setDefaultServerUrl,
setWslConfig,
spawnLocalServer,
type SidecarListener,
} from "./server"
import {
createLoadingWindow,
createMainWindow,
@@ -51,15 +63,13 @@ import {
setBackgroundColor,
setDockIcon,
} from "./windows"
import { drizzle } from "drizzle-orm/node-sqlite/driver"
import type { Server } from "virtual:opencode-server"
import { migrate } from "./migrate"
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
let server: Server.Listener | null = null
let server: SidecarListener | null = null
const loadingComplete = defer<void>()
const pendingDeepLinks: string[] = []
@@ -129,7 +139,10 @@ function setupApp() {
function useSystemCertificates() {
try {
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
const nodeTls = tls as NodeTlsWithSystemCertificates
nodeTls.setDefaultCACertificates([
...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]),
])
} catch (error) {
logger.warn("failed to load system certificates", error)
}
@@ -164,7 +177,6 @@ function setInitStep(step: InitStep) {
async function initialize() {
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
let overlay: BrowserWindow | null = null
const port = await getSidecarPort()
@@ -179,31 +191,26 @@ async function initialize() {
setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
})
if (needsMigration) {
const { Database, JsonMigration } = await import("virtual:opencode-server")
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
progress: (event: { current: number; total: number }) => {
const percent = Math.round(event.current / event.total) * 100
initEmitter.emit("sqlite", { type: "InProgress", value: percent })
},
})
initEmitter.emit("sqlite", { type: "Done" })
sqliteDone?.resolve()
}
if (needsMigration) {
await sqliteDone?.promise
}
logger.log("spawning sidecar", { url })
const { listener, health } = await spawnLocalServer(hostname, port, password, () => {
ensureLoopbackNoProxy()
useEnvProxy()
})
const { listener, health } = await spawnLocalServer(
hostname,
port,
password,
() => {
ensureLoopbackNoProxy()
useEnvProxy()
},
{
needsMigration,
userDataPath: app.getPath("userData"),
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
onStdout: (message) => logger.log("sidecar stdout", { message }),
onStderr: (message) => logger.warn("sidecar stderr", { message }),
onExit: (code) => logger.warn("sidecar exited", { code }),
},
)
server = listener
serverReady.resolve({
url,
+84 -28
View File
@@ -1,12 +1,31 @@
import { app } from "electron"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { utilityProcess } from "electron"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import type { SqliteMigrationProgress } from "../preload/types"
export type WslConfig = { enabled: boolean }
export type HealthCheck = { wait: Promise<void> }
type SidecarMessage =
| { type: "sqlite"; progress: SqliteMigrationProgress }
| { type: "ready" }
| { type: "stopped" }
| { type: "error"; error: { message: string; stack?: string } }
export type SidecarListener = { stop: () => void }
type SpawnLocalServerOptions = {
needsMigration: boolean
userDataPath: string
onSqliteProgress?: (progress: SqliteMigrationProgress) => void
onStdout?: (message: string) => void
onStderr?: (message: string) => void
onExit?: (code: number) => void
}
export function getDefaultServerUrl(): string | null {
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null
@@ -30,19 +49,62 @@ export function setWslConfig(config: WslConfig) {
getStore().set(WSL_ENABLED_KEY, config.enabled)
}
export async function spawnLocalServer(hostname: string, port: number, password: string, configureEnv?: () => void) {
prepareServerEnv(password)
export async function spawnLocalServer(
hostname: string,
port: number,
password: string,
configureEnv: () => void,
options: SpawnLocalServerOptions,
) {
configureEnv?.()
const { Log, Server } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
const listener = await Server.listen({
port,
hostname,
username: "opencode",
password,
cors: ["oc://renderer"],
const child = utilityProcess.fork(join(dirname(fileURLToPath(import.meta.url)), "sidecar.js"), [], {
cwd: process.cwd(),
env: process.env,
serviceName: "opencode server",
stdio: "pipe",
})
child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd()))
child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd()))
await new Promise<void>((resolve, reject) => {
const onMessage = (message: SidecarMessage) => {
if (message.type === "sqlite") {
options.onSqliteProgress?.(message.progress)
return
}
if (message.type === "ready") {
cleanup()
resolve()
return
}
if (message.type === "error") {
cleanup()
reject(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
}
}
const onExit = (code: number) => {
cleanup()
reject(new Error(`Sidecar exited before ready with code ${code}`))
}
const cleanup = () => {
child.off("message", onMessage)
child.off("exit", onExit)
}
child.on("message", onMessage)
child.on("exit", onExit)
child.postMessage({
type: "start",
hostname,
port,
password,
userDataPath: options.userDataPath,
needsMigration: options.needsMigration,
})
})
child.on("exit", (code: number) => options.onExit?.(code))
const wait = (async () => {
const url = `http://${hostname}:${port}`
@@ -56,23 +118,17 @@ export async function spawnLocalServer(hostname: string, port: number, password:
await ready()
})()
return { listener, health: { wait } }
}
function prepareServerEnv(password: string) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
const env = {
...process.env,
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
XDG_STATE_HOME: app.getPath("userData"),
return {
listener: {
stop: () => {
child.postMessage({ type: "stop" })
setTimeout(() => {
if (child.pid) child.kill()
}, 2_000).unref()
},
},
health: { wait },
}
Object.assign(process.env, env)
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
+133
View File
@@ -0,0 +1,133 @@
import { drizzle } from "drizzle-orm/node-sqlite/driver"
import { getUserShell, loadShellEnv } from "./shell-env"
type StartCommand = {
type: "start"
hostname: string
port: number
password: string
userDataPath: string
needsMigration: boolean
}
type StopCommand = { type: "stop" }
type SidecarCommand = StartCommand | StopCommand
type SidecarMessage =
| { type: "sqlite"; progress: { type: "InProgress"; value: number } | { type: "Done" } }
| { type: "ready" }
| { type: "stopped" }
| { type: "error"; error: { message: string; stack?: string } }
type ParentPort = {
postMessage(message: SidecarMessage): void
on(event: "message", listener: (event: { data: unknown }) => void): void
}
type Listener = {
stop(close?: boolean): void | Promise<void>
}
const parentPort = getParentPort()
let listener: Listener | undefined
parentPort.on("message", (event) => {
const command = parseCommand(event.data)
if (!command) return
if (command.type === "stop") {
void stop()
return
}
void start(command)
})
async function start(command: StartCommand) {
try {
prepareServerEnv(command.password, command.userDataPath)
const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
if (command.needsMigration) {
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
progress: (event: { current: number; total: number }) => {
parentPort.postMessage({
type: "sqlite",
progress: {
type: "InProgress",
value: event.total === 0 ? 100 : Math.round((event.current / event.total) * 100),
},
})
},
})
parentPort.postMessage({ type: "sqlite", progress: { type: "Done" } })
}
listener = await Server.listen({
port: command.port,
hostname: command.hostname,
username: "opencode",
password: command.password,
cors: ["oc://renderer"],
})
parentPort.postMessage({ type: "ready" })
} catch (error) {
parentPort.postMessage({ type: "error", error: serializeError(error) })
}
}
async function stop() {
try {
await listener?.stop()
} finally {
listener = undefined
parentPort.postMessage({ type: "stopped" })
setImmediate(() => process.exit(0))
}
}
function prepareServerEnv(password: string, userDataPath: string) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
Object.assign(process.env, {
...process.env,
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
XDG_STATE_HOME: userDataPath,
})
}
function parseCommand(value: unknown): SidecarCommand | undefined {
if (!value || typeof value !== "object") return
const command = value as Partial<StartCommand | StopCommand>
if (command.type === "stop") return { type: "stop" }
if (command.type !== "start") return
if (typeof command.hostname !== "string") return
if (typeof command.port !== "number") return
if (typeof command.password !== "string") return
if (typeof command.userDataPath !== "string") return
if (typeof command.needsMigration !== "boolean") return
return {
type: "start",
hostname: command.hostname,
port: command.port,
password: command.password,
userDataPath: command.userDataPath,
needsMigration: command.needsMigration,
}
}
function serializeError(error: unknown) {
if (error instanceof Error) return { message: error.message, stack: error.stack }
return { message: String(error) }
}
function getParentPort() {
const port = process.parentPort as ParentPort | undefined
if (!port) throw new Error("Sidecar parent port unavailable")
return port
}