feat(desktop): move server to utilityProcess (#25962)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
co-authored by
LukeParkerDev
parent
9b30ee2db2
commit
54a78c9224
@@ -37,7 +37,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: { index: "src/main/index.ts" },
|
input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" },
|
||||||
},
|
},
|
||||||
externalizeDeps: { include: [nodePtyPkg] },
|
externalizeDeps: { include: [nodePtyPkg] },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { execFileSync } from "node:child_process"
|
import { execFile, execFileSync } from "node:child_process"
|
||||||
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
import { access, readFile, readdir } from "node:fs/promises"
|
||||||
import { dirname, extname, join } from "node:path"
|
import { dirname, extname, join } from "node:path"
|
||||||
|
import util from "node:util"
|
||||||
|
|
||||||
export function checkAppExists(appName: string): boolean {
|
const execFilePromise = util.promisify(execFile)
|
||||||
|
|
||||||
|
const exists = (path: string) =>
|
||||||
|
access(path)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
export function checkAppExists(appName: string) {
|
||||||
if (process.platform === "win32") return true
|
if (process.platform === "win32") return true
|
||||||
if (process.platform === "linux") return true
|
if (process.platform === "linux") return true
|
||||||
return checkMacosApp(appName)
|
return checkMacosApp(appName)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAppPath(appName: string): string | null {
|
export function resolveAppPath(appName: string) {
|
||||||
if (process.platform !== "win32") return appName
|
if (process.platform !== "win32") return appName
|
||||||
return resolveWindowsAppPath(appName)
|
return resolveWindowsAppPath(appName)
|
||||||
}
|
}
|
||||||
@@ -32,26 +40,25 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkMacosApp(appName: string) {
|
async function checkMacosApp(appName: string) {
|
||||||
const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
|
const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
|
||||||
|
|
||||||
const home = process.env.HOME
|
const home = process.env.HOME
|
||||||
if (home) locations.push(`${home}/Applications/${appName}.app`)
|
if (home) locations.push(`${home}/Applications/${appName}.app`)
|
||||||
|
|
||||||
if (locations.some((location) => existsSync(location))) return true
|
for (const location of locations) {
|
||||||
|
if (await exists(location)) return true
|
||||||
try {
|
|
||||||
execFileSync("which", [appName])
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return execFilePromise("which", [appName])
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWindowsAppPath(appName: string): string | null {
|
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
|
||||||
let output: string
|
let output: string
|
||||||
try {
|
try {
|
||||||
output = execFileSync("where", [appName]).toString()
|
output = execFilePromise("where", [appName]).toString()
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -66,8 +73,8 @@ function resolveWindowsAppPath(appName: string): string | null {
|
|||||||
const exe = paths.find((path) => hasExt(path, "exe"))
|
const exe = paths.find((path) => hasExt(path, "exe"))
|
||||||
if (exe) return exe
|
if (exe) return exe
|
||||||
|
|
||||||
const resolveCmd = (path: string) => {
|
const resolveCmd = async (path: string) => {
|
||||||
const content = readFileSync(path, "utf8")
|
const content = await readFile(path, "utf8")
|
||||||
for (const token of content.split('"').map((value: string) => value.trim())) {
|
for (const token of content.split('"').map((value: string) => value.trim())) {
|
||||||
const lower = token.toLowerCase()
|
const lower = token.toLowerCase()
|
||||||
if (!lower.includes(".exe")) continue
|
if (!lower.includes(".exe")) continue
|
||||||
@@ -85,10 +92,10 @@ function resolveWindowsAppPath(appName: string): string | null {
|
|||||||
return join(current, part)
|
return join(current, part)
|
||||||
}, base)
|
}, base)
|
||||||
|
|
||||||
if (existsSync(resolved)) return resolved
|
if (await exists(resolved)) return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(token)) return token
|
if (await exists(token)) return token
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -96,20 +103,20 @@ function resolveWindowsAppPath(appName: string): string | null {
|
|||||||
|
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
if (hasExt(path, "cmd") || hasExt(path, "bat")) {
|
if (hasExt(path, "cmd") || hasExt(path, "bat")) {
|
||||||
const resolved = resolveCmd(path)
|
const resolved = await resolveCmd(path)
|
||||||
if (resolved) return resolved
|
if (resolved) return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!extname(path)) {
|
if (!extname(path)) {
|
||||||
const cmd = `${path}.cmd`
|
const cmd = `${path}.cmd`
|
||||||
if (existsSync(cmd)) {
|
if (await exists(cmd)) {
|
||||||
const resolved = resolveCmd(cmd)
|
const resolved = await resolveCmd(cmd)
|
||||||
if (resolved) return resolved
|
if (resolved) return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
const bat = `${path}.bat`
|
const bat = `${path}.bat`
|
||||||
if (existsSync(bat)) {
|
if (await exists(bat)) {
|
||||||
const resolved = resolveCmd(bat)
|
const resolved = await resolveCmd(bat)
|
||||||
if (resolved) return resolved
|
if (resolved) return resolved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +133,7 @@ function resolveWindowsAppPath(appName: string): string | null {
|
|||||||
const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
|
const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
try {
|
try {
|
||||||
for (const entry of readdirSync(dir)) {
|
for (const entry of await readdir(dir)) {
|
||||||
const candidate = join(dir, entry)
|
const candidate = join(dir, entry)
|
||||||
if (!hasExt(candidate, "exe")) continue
|
if (!hasExt(candidate, "exe")) continue
|
||||||
const stem = entry.replace(/\.exe$/i, "")
|
const stem = entry.replace(/\.exe$/i, "")
|
||||||
|
|||||||
Vendored
+1
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
|
|||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "virtual:opencode-server" {
|
declare module "virtual:opencode-server" {
|
||||||
export namespace Server {
|
export namespace Server {
|
||||||
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
|
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
|
||||||
|
|||||||
@@ -47,7 +47,15 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio
|
|||||||
import { initLogging } from "./logging"
|
import { initLogging } from "./logging"
|
||||||
import { parseMarkdown } from "./markdown"
|
import { parseMarkdown } from "./markdown"
|
||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
import {
|
||||||
|
getDefaultServerUrl,
|
||||||
|
getWslConfig,
|
||||||
|
preferAppEnv,
|
||||||
|
setDefaultServerUrl,
|
||||||
|
setWslConfig,
|
||||||
|
spawnLocalServer,
|
||||||
|
type SidecarListener,
|
||||||
|
} from "./server"
|
||||||
import {
|
import {
|
||||||
createLoadingWindow,
|
createLoadingWindow,
|
||||||
createMainWindow,
|
createMainWindow,
|
||||||
@@ -55,15 +63,13 @@ import {
|
|||||||
setBackgroundColor,
|
setBackgroundColor,
|
||||||
setDockIcon,
|
setDockIcon,
|
||||||
} from "./windows"
|
} from "./windows"
|
||||||
import { drizzle } from "drizzle-orm/node-sqlite/driver"
|
|
||||||
import type { Server } from "virtual:opencode-server"
|
|
||||||
import { migrate } from "./migrate"
|
import { migrate } from "./migrate"
|
||||||
|
|
||||||
const initEmitter = new EventEmitter()
|
const initEmitter = new EventEmitter()
|
||||||
let initStep: InitStep = { phase: "server_waiting" }
|
let initStep: InitStep = { phase: "server_waiting" }
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let server: Server.Listener | null = null
|
let server: SidecarListener | null = null
|
||||||
const loadingComplete = defer<void>()
|
const loadingComplete = defer<void>()
|
||||||
|
|
||||||
const pendingDeepLinks: string[] = []
|
const pendingDeepLinks: string[] = []
|
||||||
@@ -107,6 +113,8 @@ function setupApp() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preferAppEnv(app.getPath("userData"))
|
||||||
|
|
||||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||||
if (urls.length) {
|
if (urls.length) {
|
||||||
@@ -123,17 +131,16 @@ function setupApp() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.on("before-quit", () => {
|
app.on("before-quit", () => {
|
||||||
killSidecar()
|
void killSidecar()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on("will-quit", () => {
|
app.on("will-quit", () => {
|
||||||
killSidecar()
|
void killSidecar()
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||||
process.on(signal, () => {
|
process.on(signal, () => {
|
||||||
killSidecar()
|
void killSidecar().finally(() => app.exit(0))
|
||||||
app.exit(0)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +191,6 @@ function setInitStep(step: InitStep) {
|
|||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
const needsMigration = !sqliteFileExists()
|
const needsMigration = !sqliteFileExists()
|
||||||
const sqliteDone = needsMigration ? defer<void>() : undefined
|
|
||||||
let overlay: BrowserWindow | null = null
|
let overlay: BrowserWindow | null = null
|
||||||
|
|
||||||
const port = await getSidecarPort()
|
const port = await getSidecarPort()
|
||||||
@@ -199,31 +205,26 @@ async function initialize() {
|
|||||||
setInitStep({ phase: "sqlite_waiting" })
|
setInitStep({ phase: "sqlite_waiting" })
|
||||||
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, 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 })
|
logger.log("spawning sidecar", { url })
|
||||||
const { listener, health } = await spawnLocalServer(hostname, port, password, () => {
|
const { listener, health } = await spawnLocalServer(
|
||||||
ensureLoopbackNoProxy()
|
hostname,
|
||||||
useEnvProxy()
|
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
|
server = listener
|
||||||
serverReady.resolve({
|
serverReady.resolve({
|
||||||
url,
|
url,
|
||||||
@@ -273,9 +274,10 @@ function wireMenu() {
|
|||||||
},
|
},
|
||||||
reload: () => mainWindow?.reload(),
|
reload: () => mainWindow?.reload(),
|
||||||
relaunch: () => {
|
relaunch: () => {
|
||||||
killSidecar()
|
void killSidecar().finally(() => {
|
||||||
app.relaunch()
|
app.relaunch()
|
||||||
app.exit(0)
|
app.exit(0)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -304,7 +306,7 @@ registerIpcHandlers({
|
|||||||
getDisplayBackend: async () => null,
|
getDisplayBackend: async () => null,
|
||||||
setDisplayBackend: async () => undefined,
|
setDisplayBackend: async () => undefined,
|
||||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||||
checkAppExists: async (appName) => checkAppExists(appName),
|
checkAppExists: (appName) => checkAppExists(appName),
|
||||||
wslPath: async (path, mode) => wslPath(path, mode),
|
wslPath: async (path, mode) => wslPath(path, mode),
|
||||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||||
loadingWindowComplete: () => loadingComplete.resolve(),
|
loadingWindowComplete: () => loadingComplete.resolve(),
|
||||||
@@ -314,10 +316,11 @@ registerIpcHandlers({
|
|||||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||||
})
|
})
|
||||||
|
|
||||||
function killSidecar() {
|
async function killSidecar() {
|
||||||
if (!server) return
|
if (!server) return
|
||||||
server.stop()
|
const current = server
|
||||||
server = null
|
server = null
|
||||||
|
await current.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureLoopbackNoProxy() {
|
function ensureLoopbackNoProxy() {
|
||||||
@@ -440,7 +443,7 @@ async function installUpdate() {
|
|||||||
logger.log("installing downloaded update", {
|
logger.log("installing downloaded update", {
|
||||||
version: downloadedUpdateVersion,
|
version: downloadedUpdateVersion,
|
||||||
})
|
})
|
||||||
killSidecar()
|
await killSidecar()
|
||||||
autoUpdater.quitAndInstall()
|
autoUpdater.quitAndInstall()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const pickerFilters = (ext?: string[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Deps = {
|
type Deps = {
|
||||||
killSidecar: () => void
|
killSidecar: () => Promise<void> | void
|
||||||
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||||
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
||||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
import { app } from "electron"
|
import { dirname, join } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { app, utilityProcess } from "electron"
|
||||||
|
import type { Details } from "electron"
|
||||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
|
||||||
import { getStore } from "./store"
|
import { getStore } from "./store"
|
||||||
|
import type { SqliteMigrationProgress } from "../preload/types"
|
||||||
|
|
||||||
export type WslConfig = { enabled: boolean }
|
export type WslConfig = { enabled: boolean }
|
||||||
|
|
||||||
export type HealthCheck = { wait: Promise<void> }
|
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: () => Promise<void> }
|
||||||
|
|
||||||
|
const SIDECAR_SERVICE_NAME = "opencode server"
|
||||||
|
const SIDECAR_START_STALL_TIMEOUT = 60_000
|
||||||
|
const SIDECAR_STOP_TIMEOUT = 6_000
|
||||||
|
|
||||||
|
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 {
|
export function getDefaultServerUrl(): string | null {
|
||||||
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
||||||
return typeof value === "string" ? value : null
|
return typeof value === "string" ? value : null
|
||||||
@@ -30,49 +55,155 @@ export function setWslConfig(config: WslConfig) {
|
|||||||
getStore().set(WSL_ENABLED_KEY, config.enabled)
|
getStore().set(WSL_ENABLED_KEY, config.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function spawnLocalServer(hostname: string, port: number, password: string, configureEnv?: () => void) {
|
export function preferAppEnv(userDataPath: string) {
|
||||||
prepareServerEnv(password)
|
const shell = process.platform === "win32" ? null : getUserShell()
|
||||||
|
Object.assign(
|
||||||
|
process.env,
|
||||||
|
mergeShellEnv(shell ? loadShellEnv(shell) : null, {
|
||||||
|
...process.env,
|
||||||
|
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||||
|
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||||
|
OPENCODE_CLIENT: "desktop",
|
||||||
|
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function spawnLocalServer(
|
||||||
|
hostname: string,
|
||||||
|
port: number,
|
||||||
|
password: string,
|
||||||
|
configureEnv: () => void,
|
||||||
|
options: SpawnLocalServerOptions,
|
||||||
|
) {
|
||||||
configureEnv?.()
|
configureEnv?.()
|
||||||
const { Log, Server } = await import("virtual:opencode-server")
|
const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
|
||||||
await Log.init({ level: "WARN" })
|
const child = utilityProcess.fork(sidecar, [], {
|
||||||
const listener = await Server.listen({
|
cwd: process.cwd(),
|
||||||
port,
|
env: createSidecarEnv(),
|
||||||
hostname,
|
serviceName: SIDECAR_SERVICE_NAME,
|
||||||
username: "opencode",
|
stdio: "pipe",
|
||||||
password,
|
})
|
||||||
cors: ["oc://renderer"],
|
let exited = false
|
||||||
|
const exit = defer<number>()
|
||||||
|
|
||||||
|
const onProcessGone = (_event: unknown, details: Details) => {
|
||||||
|
if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return
|
||||||
|
options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on("child-process-gone", onProcessGone)
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
exited = true
|
||||||
|
app.off("child-process-gone", onProcessGone)
|
||||||
|
options.onExit?.(code)
|
||||||
|
exit.resolve(code)
|
||||||
|
})
|
||||||
|
child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`))
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
let done = false
|
||||||
|
let timeout: NodeJS.Timeout
|
||||||
|
|
||||||
|
const fail = (error: Error) => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
cleanup()
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshTimeout = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`))
|
||||||
|
}, SIDECAR_START_STALL_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMessage = (message: SidecarMessage) => {
|
||||||
|
if (message.type === "sqlite") {
|
||||||
|
refreshTimeout()
|
||||||
|
options.onSqliteProgress?.(message.progress)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (message.type === "ready") {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
cleanup()
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (message.type === "error") {
|
||||||
|
fail(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onExit = (code: number) => {
|
||||||
|
fail(new Error(`Sidecar exited before ready with code ${code}`))
|
||||||
|
}
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
child.off("message", onMessage)
|
||||||
|
child.off("exit", onExit)
|
||||||
|
}
|
||||||
|
|
||||||
|
child.on("message", onMessage)
|
||||||
|
child.on("exit", onExit)
|
||||||
|
refreshTimeout()
|
||||||
|
child.postMessage({
|
||||||
|
type: "start",
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
password,
|
||||||
|
userDataPath: options.userDataPath,
|
||||||
|
needsMigration: options.needsMigration,
|
||||||
|
})
|
||||||
|
}).catch((error) => {
|
||||||
|
if (!exited) child.kill()
|
||||||
|
throw error
|
||||||
})
|
})
|
||||||
|
|
||||||
const wait = (async () => {
|
const wait = (async () => {
|
||||||
const url = `http://${hostname}:${port}`
|
const url = `http://${hostname}:${port}`
|
||||||
|
let healthy = false
|
||||||
|
const gone = exit.promise.then((code) => {
|
||||||
|
if (healthy) return
|
||||||
|
throw new Error(`Sidecar exited before health check passed with code ${code}`)
|
||||||
|
})
|
||||||
|
|
||||||
const ready = async () => {
|
const ready = async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
if (await checkHealth(url, password)) return
|
if (await checkHealth(url, password)) {
|
||||||
|
healthy = true
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await ready()
|
await Promise.race([ready(), gone])
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return { listener, health: { wait } }
|
let stopping: Promise<void> | undefined
|
||||||
}
|
|
||||||
|
|
||||||
function prepareServerEnv(password: string) {
|
return {
|
||||||
const shell = process.platform === "win32" ? null : getUserShell()
|
listener: {
|
||||||
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
|
stop: () => {
|
||||||
const env = {
|
if (stopping) return stopping
|
||||||
...process.env,
|
if (exited) return Promise.resolve()
|
||||||
...shellEnv,
|
child.postMessage({ type: "stop" })
|
||||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
stopping = Promise.race([
|
||||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
exit.promise.then(() => undefined),
|
||||||
OPENCODE_CLIENT: "desktop",
|
delay(SIDECAR_STOP_TIMEOUT).then(() => {
|
||||||
OPENCODE_SERVER_USERNAME: "opencode",
|
if (!exited) child.kill()
|
||||||
OPENCODE_SERVER_PASSWORD: password,
|
}),
|
||||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? app.getPath("userData"),
|
])
|
||||||
|
return stopping
|
||||||
|
},
|
||||||
|
},
|
||||||
|
health: { wait },
|
||||||
}
|
}
|
||||||
Object.assign(process.env, env)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||||
@@ -100,3 +231,31 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSidecarEnv(): Record<string, string> {
|
||||||
|
const env = Object.fromEntries(
|
||||||
|
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
|
||||||
|
)
|
||||||
|
delete env.DEBUG
|
||||||
|
if (process.platform === "linux") delete env.LD_PRELOAD
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeError(error: unknown) {
|
||||||
|
if (error instanceof Error) return { message: error.message, stack: error.stack }
|
||||||
|
return { message: String(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function defer<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (error: Error) => void
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/node-sqlite/driver"
|
||||||
|
import * as http from "node:http"
|
||||||
|
import * as tls from "node:tls"
|
||||||
|
|
||||||
|
type NodeHttpWithEnvProxy = typeof http & {
|
||||||
|
setGlobalProxyFromEnv: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeTlsWithSystemCertificates = typeof tls & {
|
||||||
|
getCACertificates: (type: "default" | "system") => string[]
|
||||||
|
setDefaultCACertificates: (certificates: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
prepareSidecarEnv(command.password, command.userDataPath)
|
||||||
|
ensureLoopbackNoProxy()
|
||||||
|
useSystemCertificates()
|
||||||
|
useEnvProxy()
|
||||||
|
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) })
|
||||||
|
setImmediate(() => process.exit(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
try {
|
||||||
|
await listener?.stop()
|
||||||
|
} finally {
|
||||||
|
listener = undefined
|
||||||
|
parentPort.postMessage({ type: "stopped" })
|
||||||
|
setImmediate(() => process.exit(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareSidecarEnv(password: string, userDataPath: string) {
|
||||||
|
Object.assign(process.env, {
|
||||||
|
OPENCODE_SERVER_USERNAME: "opencode",
|
||||||
|
OPENCODE_SERVER_PASSWORD: password,
|
||||||
|
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLoopbackNoProxy() {
|
||||||
|
const loopback = ["127.0.0.1", "localhost", "::1"]
|
||||||
|
const upsert = (key: string) => {
|
||||||
|
const items = (process.env[key] ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((value: string) => value.trim())
|
||||||
|
.filter((value: string) => Boolean(value))
|
||||||
|
|
||||||
|
for (const host of loopback) {
|
||||||
|
if (items.some((value: string) => value.toLowerCase() === host)) continue
|
||||||
|
items.push(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[key] = items.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert("NO_PROXY")
|
||||||
|
upsert("no_proxy")
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSystemCertificates() {
|
||||||
|
try {
|
||||||
|
const nodeTls = tls as NodeTlsWithSystemCertificates
|
||||||
|
nodeTls.setDefaultCACertificates([
|
||||||
|
...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]),
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("failed to load system certificates", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useEnvProxy() {
|
||||||
|
try {
|
||||||
|
;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("failed to load proxy environment", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user