feat(desktop): 拆分 sidecar 管理并接入健康检查路由

This commit is contained in:
2026-02-16 04:06:41 +08:00
parent aa1e2c81c6
commit e76a03d0f4
4 changed files with 408 additions and 175 deletions

View File

@@ -1,142 +1,57 @@
import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
import { join } from 'node:path'
import { app, BrowserWindow, dialog, shell } from 'electron'
import killProcessTree from 'tree-kill'
import { createSidecarRuntime } from './sidecar'
const DEV_SERVER_URL = 'http://localhost:3000'
const SAFE_EXTERNAL_PROTOCOLS = new Set(['https:', 'http:', 'mailto:'])
let mainWindow: BrowserWindow | null = null
let serverProcess: ReturnType<typeof spawn> | null = null
let windowCreationPromise: Promise<void> | null = null
let isQuitting = false
let serverReady = false
const shouldAbortWindowLoad = (): boolean =>
isQuitting || !mainWindow || mainWindow.isDestroyed()
const showErrorAndQuit = (title: string, detail: string) => {
if (isQuitting) return
if (isQuitting) {
return
}
dialog.showErrorBox(title, detail)
app.quit()
}
const getAvailablePort = (): Promise<number> =>
new Promise((resolve, reject) => {
const server = createServer()
server.listen(0, () => {
const addr = server.address()
if (!addr || typeof addr === 'string') {
server.close()
reject(new Error('Failed to resolve port'))
return
}
server.close(() => resolve(addr.port))
})
server.on('error', reject)
})
const isServerReady = async (url: string): Promise<boolean> => {
try {
const response = await fetch(url, { method: 'HEAD' })
return response.ok
} catch {
return false
}
}
const waitForServer = async (
url: string,
timeoutMs = 15_000,
): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < timeoutMs && !isQuitting) {
if (await isServerReady(url)) return true
await new Promise<void>((resolve) => setTimeout(resolve, 200))
}
return false
}
const stopServerProcess = () => {
if (!serverProcess) {
return
}
const runningServer = serverProcess
serverProcess = null
if (!runningServer.pid || runningServer.exitCode !== null) {
return
}
killProcessTree(runningServer.pid, (error?: Error) => {
if (error) {
console.error('Failed to stop server process:', error)
}
})
}
const spawnServer = (port: number): string => {
const binaryName = process.platform === 'win32' ? 'server.exe' : 'server'
const binaryPath = join(process.resourcesPath, binaryName)
serverProcess = spawn(binaryPath, [], {
env: {
...process.env,
PORT: String(port),
HOST: '127.0.0.1',
const sidecar = createSidecarRuntime({
devServerUrl: DEV_SERVER_URL,
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
isQuitting: () => isQuitting,
onUnexpectedStop: (detail) => {
showErrorAndQuit('Service Stopped', detail)
},
stdio: 'ignore',
})
serverProcess.unref()
serverProcess.on('error', (err) => {
console.error('Failed to start server:', err)
showErrorAndQuit(
'Startup Failed',
'A required component failed to start. Please reinstall the app.',
)
})
serverProcess.on('exit', (code) => {
serverProcess = null
if (!isQuitting && serverReady) {
showErrorAndQuit(
'Service Stopped',
app.isPackaged
? 'The background service stopped unexpectedly. Please restart the app.'
: `Server process exited unexpectedly (code ${code}). Check the server logs for details.`,
)
}
})
const toErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error)
return `http://127.0.0.1:${port}`
}
const resolveServerUrl = async (): Promise<string | null> => {
if (!app.isPackaged) {
return DEV_SERVER_URL
}
let port: number
const canOpenExternally = (url: string): boolean => {
try {
port = await getAvailablePort()
const parsed = new URL(url)
return SAFE_EXTERNAL_PROTOCOLS.has(parsed.protocol)
} catch {
showErrorAndQuit(
'Startup Failed',
"The app couldn't allocate a local port. Please close other apps and try again.",
)
return null
return false
}
if (isQuitting) {
return null
}
return spawnServer(port)
}
const createWindow = async () => {
mainWindow = new BrowserWindow({
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.focus()
return
}
const targetUrl = await sidecar.resolveUrl()
if (isQuitting) {
return
}
const windowRef = new BrowserWindow({
width: 1200,
height: 800,
show: false,
@@ -145,89 +60,94 @@ const createWindow = async () => {
sandbox: true,
},
})
mainWindow = windowRef
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
windowRef.webContents.setWindowOpenHandler(({ url }) => {
if (!canOpenExternally(url)) {
if (!app.isPackaged) {
console.warn(`Blocked external URL: ${url}`)
}
return { action: 'deny' }
}
void shell.openExternal(url)
return { action: 'deny' }
})
mainWindow.on('closed', () => {
windowRef.on('closed', () => {
if (mainWindow === windowRef) {
mainWindow = null
}
})
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
mainWindow.show()
const serverUrl = await resolveServerUrl()
if (!serverUrl || shouldAbortWindowLoad()) {
stopServerProcess()
return
try {
await windowRef.loadURL(targetUrl)
} catch (error) {
if (mainWindow === windowRef) {
mainWindow = null
}
if (!app.isPackaged) console.log(`Waiting for server at ${serverUrl}...`)
const ready = await waitForServer(serverUrl)
if (shouldAbortWindowLoad()) {
stopServerProcess()
return
if (!windowRef.isDestroyed()) {
windowRef.destroy()
}
if (!ready) {
showErrorAndQuit(
'Startup Failed',
app.isPackaged
? 'The app is taking too long to start. Please try again.'
: 'Dev server not responding. Run `bun dev` in apps/server first.',
)
return
throw error
}
serverReady = true
mainWindow.loadURL(serverUrl)
if (!windowRef.isDestroyed()) {
windowRef.show()
}
}
const ensureWindow = async () => {
if (windowCreationPromise) {
return windowCreationPromise
}
windowCreationPromise = createWindow().finally(() => {
windowCreationPromise = null
})
return windowCreationPromise
}
const beginQuit = () => {
isQuitting = true
stopServerProcess()
sidecar.stop()
}
const handleWindowCreationError = (error: unknown, context: string) => {
console.error(`${context}:`, error)
showErrorAndQuit(
"App Couldn't Start",
app.isPackaged
? 'A required component failed to start. Please reinstall the app.'
: `${context}: ${toErrorMessage(error)}`,
)
}
app
.whenReady()
.then(createWindow)
.catch((e) => {
console.error('Failed to create window:', e)
showErrorAndQuit(
"App Couldn't Start",
app.isPackaged
? "We couldn't open the application window. Please restart your computer and try again."
: `Failed to create window: ${e instanceof Error ? e.message : String(e)}`,
)
.then(() => ensureWindow())
.catch((error) => {
handleWindowCreationError(error, 'Failed to create window')
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
beginQuit()
app.quit()
}
})
app.on('activate', () => {
if (!isQuitting && BrowserWindow.getAllWindows().length === 0) {
createWindow().catch((e) => {
console.error('Failed to re-create window:', e)
showErrorAndQuit(
"App Couldn't Start",
app.isPackaged
? "We couldn't open the application window. Please restart the app."
: `Failed to re-create window: ${e instanceof Error ? e.message : String(e)}`,
)
})
if (isQuitting || BrowserWindow.getAllWindows().length > 0) {
return
}
ensureWindow().catch((error) => {
handleWindowCreationError(error, 'Failed to re-create window')
})
})
app.on('before-quit', () => {
beginQuit()
})
app.on('before-quit', beginQuit)

View File

@@ -0,0 +1,268 @@
import { type ChildProcess, spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { createServer } from 'node:net'
import { join } from 'node:path'
import killProcessTree from 'tree-kill'
const SERVER_HOST = '127.0.0.1'
const SERVER_READY_TIMEOUT_MS = 10_000
const SERVER_REQUEST_TIMEOUT_MS = 1_500
const SERVER_POLL_INTERVAL_MS = 250
const SERVER_PROBE_PATHS = ['/api/health', '/']
type SidecarState = {
process: ChildProcess | null
startup: Promise<string> | null
url: string | null
}
type SidecarRuntimeOptions = {
devServerUrl: string
isPackaged: boolean
resourcesPath: string
isQuitting: () => boolean
onUnexpectedStop: (detail: string) => void
}
type SidecarRuntime = {
resolveUrl: () => Promise<string>
stop: () => void
}
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
const isProcessAlive = (
processToCheck: ChildProcess | null,
): processToCheck is ChildProcess => {
if (!processToCheck || !processToCheck.pid) {
return false
}
return processToCheck.exitCode === null && !processToCheck.killed
}
const getAvailablePort = (): Promise<number> =>
new Promise((resolve, reject) => {
const server = createServer()
server.listen(0, () => {
const addr = server.address()
if (!addr || typeof addr === 'string') {
server.close()
reject(new Error('Failed to resolve port'))
return
}
server.close(() => resolve(addr.port))
})
server.on('error', reject)
})
const isServerReady = async (url: string): Promise<boolean> => {
for (const probePath of SERVER_PROBE_PATHS) {
try {
const probeUrl = new URL(probePath, `${url}/`)
const response = await fetch(probeUrl, {
method: 'GET',
cache: 'no-store',
signal: AbortSignal.timeout(SERVER_REQUEST_TIMEOUT_MS),
})
if (response.status < 500) {
if (probePath === '/api/health' && response.status === 404) {
continue
}
return true
}
} catch {}
}
return false
}
const waitForServer = async (
url: string,
isQuitting: () => boolean,
processRef?: ChildProcess,
): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < SERVER_READY_TIMEOUT_MS && !isQuitting()) {
if (processRef && processRef.exitCode !== null) {
return false
}
if (await isServerReady(url)) {
return true
}
await sleep(SERVER_POLL_INTERVAL_MS)
}
return false
}
const resolveBinaryPath = (resourcesPath: string): string => {
const binaryName = process.platform === 'win32' ? 'server.exe' : 'server'
return join(resourcesPath, binaryName)
}
const formatUnexpectedStopMessage = (
isPackaged: boolean,
code: number | null,
signal: NodeJS.Signals | null,
): string => {
if (isPackaged) {
return 'The background service stopped unexpectedly. Please restart the app.'
}
return `Server process exited unexpectedly (code ${code ?? 'unknown'}, signal ${signal ?? 'none'}).`
}
export const createSidecarRuntime = (
options: SidecarRuntimeOptions,
): SidecarRuntime => {
const state: SidecarState = {
process: null,
startup: null,
url: null,
}
const resetState = (processRef?: ChildProcess) => {
if (processRef && state.process !== processRef) {
return
}
state.process = null
state.url = null
}
const stop = () => {
const runningServer = state.process
resetState()
if (!runningServer?.pid || runningServer.exitCode !== null) {
return
}
killProcessTree(runningServer.pid, 'SIGTERM', (error?: Error) => {
if (error) {
console.error('Failed to stop server process:', error)
}
})
}
const attachLifecycleHandlers = (processRef: ChildProcess) => {
processRef.on('error', (error) => {
if (state.process !== processRef) {
return
}
const hadReadyServer = state.url !== null
resetState(processRef)
if (!options.isQuitting() && hadReadyServer) {
options.onUnexpectedStop(
'The background service crashed unexpectedly. Please restart the app.',
)
return
}
console.error('Failed to start server process:', error)
})
processRef.on('exit', (code, signal) => {
if (state.process !== processRef) {
return
}
const hadReadyServer = state.url !== null
resetState(processRef)
if (!options.isQuitting() && hadReadyServer) {
options.onUnexpectedStop(
formatUnexpectedStopMessage(options.isPackaged, code, signal),
)
}
})
}
const startPackagedServer = async (): Promise<string> => {
if (state.url && isProcessAlive(state.process)) {
return state.url
}
if (state.startup) {
return state.startup
}
state.startup = (async () => {
const binaryPath = resolveBinaryPath(options.resourcesPath)
if (!existsSync(binaryPath)) {
throw new Error(`Sidecar server binary is missing: ${binaryPath}`)
}
if (options.isQuitting()) {
throw new Error('Application is shutting down.')
}
const port = await getAvailablePort()
const nextServerUrl = `http://${SERVER_HOST}:${port}`
const processRef = spawn(binaryPath, [], {
env: {
...process.env,
HOST: SERVER_HOST,
PORT: String(port),
},
stdio: 'ignore',
windowsHide: true,
})
processRef.unref()
state.process = processRef
attachLifecycleHandlers(processRef)
const ready = await waitForServer(
nextServerUrl,
options.isQuitting,
processRef,
)
if (ready && isProcessAlive(processRef)) {
state.url = nextServerUrl
return nextServerUrl
}
const failureReason =
processRef.exitCode !== null
? `The service exited early (code ${processRef.exitCode}).`
: `The service did not respond at ${nextServerUrl} within 10 seconds.`
stop()
throw new Error(failureReason)
})().finally(() => {
state.startup = null
})
return state.startup
}
const resolveUrl = async (): Promise<string> => {
if (options.isPackaged) {
return startPackagedServer()
}
const ready = await waitForServer(options.devServerUrl, options.isQuitting)
if (!ready) {
throw new Error(
'Dev server not responding. Run `bun dev` in apps/server first.',
)
}
return options.devServerUrl
}
return {
resolveUrl,
stop,
}
}

View File

@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as ApiSplatRouteImport } from './routes/api/$'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
@@ -18,6 +19,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHealthRoute = ApiHealthRouteImport.update({
id: '/api/health',
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSplatRoute = ApiSplatRouteImport.update({
id: '/api/$',
path: '/api/$',
@@ -32,30 +38,34 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/api/$' | '/api/rpc/$'
fullPaths: '/' | '/api/$' | '/api/health' | '/api/rpc/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/api/$' | '/api/rpc/$'
id: '__root__' | '/' | '/api/$' | '/api/rpc/$'
to: '/' | '/api/$' | '/api/health' | '/api/rpc/$'
id: '__root__' | '/' | '/api/$' | '/api/health' | '/api/rpc/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ApiSplatRoute: typeof ApiSplatRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
}
@@ -68,6 +78,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/health': {
id: '/api/health'
path: '/api/health'
fullPath: '/api/health'
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/api/$': {
id: '/api/$'
path: '/api/$'
@@ -88,6 +105,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ApiSplatRoute: ApiSplatRoute,
ApiHealthRoute: ApiHealthRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
}
export const routeTree = rootRouteImport

View File

@@ -0,0 +1,27 @@
import { createFileRoute } from '@tanstack/react-router'
import { name, version } from '@/../package.json'
const createHealthResponse = (): Response =>
Response.json(
{
status: 'ok',
service: name,
version,
timestamp: new Date().toISOString(),
},
{
status: 200,
headers: {
'cache-control': 'no-store',
},
},
)
export const Route = createFileRoute('/api/health')({
server: {
handlers: {
GET: async () => createHealthResponse(),
HEAD: async () => new Response(null, { status: 200 }),
},
},
})