forked from imbytecat/fullstack-starter
269 lines
6.5 KiB
TypeScript
269 lines
6.5 KiB
TypeScript
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,
|
|
}
|
|
}
|