forked from imbytecat/fullstack-starter
feat(desktop): 拆分 sidecar 管理并接入健康检查路由
This commit is contained in:
@@ -1,142 +1,57 @@
|
|||||||
import { spawn } from 'node:child_process'
|
|
||||||
import { createServer } from 'node:net'
|
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { app, BrowserWindow, dialog, shell } from 'electron'
|
import { app, BrowserWindow, dialog, shell } from 'electron'
|
||||||
import killProcessTree from 'tree-kill'
|
import { createSidecarRuntime } from './sidecar'
|
||||||
|
|
||||||
const DEV_SERVER_URL = 'http://localhost:3000'
|
const DEV_SERVER_URL = 'http://localhost:3000'
|
||||||
|
const SAFE_EXTERNAL_PROTOCOLS = new Set(['https:', 'http:', 'mailto:'])
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let serverProcess: ReturnType<typeof spawn> | null = null
|
let windowCreationPromise: Promise<void> | null = null
|
||||||
let isQuitting = false
|
let isQuitting = false
|
||||||
let serverReady = false
|
|
||||||
|
|
||||||
const shouldAbortWindowLoad = (): boolean =>
|
|
||||||
isQuitting || !mainWindow || mainWindow.isDestroyed()
|
|
||||||
|
|
||||||
const showErrorAndQuit = (title: string, detail: string) => {
|
const showErrorAndQuit = (title: string, detail: string) => {
|
||||||
if (isQuitting) return
|
if (isQuitting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
dialog.showErrorBox(title, detail)
|
dialog.showErrorBox(title, detail)
|
||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAvailablePort = (): Promise<number> =>
|
const sidecar = createSidecarRuntime({
|
||||||
new Promise((resolve, reject) => {
|
devServerUrl: DEV_SERVER_URL,
|
||||||
const server = createServer()
|
isPackaged: app.isPackaged,
|
||||||
server.listen(0, () => {
|
resourcesPath: process.resourcesPath,
|
||||||
const addr = server.address()
|
isQuitting: () => isQuitting,
|
||||||
if (!addr || typeof addr === 'string') {
|
onUnexpectedStop: (detail) => {
|
||||||
server.close()
|
showErrorAndQuit('Service Stopped', detail)
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
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) => {
|
const toErrorMessage = (error: unknown): string =>
|
||||||
serverProcess = null
|
error instanceof Error ? error.message : String(error)
|
||||||
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.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return `http://127.0.0.1:${port}`
|
const canOpenExternally = (url: string): boolean => {
|
||||||
}
|
|
||||||
|
|
||||||
const resolveServerUrl = async (): Promise<string | null> => {
|
|
||||||
if (!app.isPackaged) {
|
|
||||||
return DEV_SERVER_URL
|
|
||||||
}
|
|
||||||
|
|
||||||
let port: number
|
|
||||||
try {
|
try {
|
||||||
port = await getAvailablePort()
|
const parsed = new URL(url)
|
||||||
|
return SAFE_EXTERNAL_PROTOCOLS.has(parsed.protocol)
|
||||||
} catch {
|
} catch {
|
||||||
showErrorAndQuit(
|
return false
|
||||||
'Startup Failed',
|
|
||||||
"The app couldn't allocate a local port. Please close other apps and try again.",
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isQuitting) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return spawnServer(port)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createWindow = async () => {
|
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,
|
width: 1200,
|
||||||
height: 800,
|
height: 800,
|
||||||
show: false,
|
show: false,
|
||||||
@@ -145,89 +60,94 @@ const createWindow = async () => {
|
|||||||
sandbox: true,
|
sandbox: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
mainWindow = windowRef
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
windowRef.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url)
|
if (!canOpenExternally(url)) {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
console.warn(`Blocked external URL: ${url}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: 'deny' }
|
||||||
|
}
|
||||||
|
|
||||||
|
void shell.openExternal(url)
|
||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
})
|
})
|
||||||
|
|
||||||
mainWindow.on('closed', () => {
|
windowRef.on('closed', () => {
|
||||||
|
if (mainWindow === windowRef) {
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (process.env.ELECTRON_RENDERER_URL) {
|
try {
|
||||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
await windowRef.loadURL(targetUrl)
|
||||||
} else {
|
} catch (error) {
|
||||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
if (mainWindow === windowRef) {
|
||||||
}
|
mainWindow = null
|
||||||
mainWindow.show()
|
|
||||||
|
|
||||||
const serverUrl = await resolveServerUrl()
|
|
||||||
if (!serverUrl || shouldAbortWindowLoad()) {
|
|
||||||
stopServerProcess()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app.isPackaged) console.log(`Waiting for server at ${serverUrl}...`)
|
if (!windowRef.isDestroyed()) {
|
||||||
const ready = await waitForServer(serverUrl)
|
windowRef.destroy()
|
||||||
if (shouldAbortWindowLoad()) {
|
|
||||||
stopServerProcess()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ready) {
|
throw error
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
serverReady = true
|
if (!windowRef.isDestroyed()) {
|
||||||
mainWindow.loadURL(serverUrl)
|
windowRef.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureWindow = async () => {
|
||||||
|
if (windowCreationPromise) {
|
||||||
|
return windowCreationPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
windowCreationPromise = createWindow().finally(() => {
|
||||||
|
windowCreationPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return windowCreationPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
const beginQuit = () => {
|
const beginQuit = () => {
|
||||||
isQuitting = true
|
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
|
app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
.then(createWindow)
|
.then(() => ensureWindow())
|
||||||
.catch((e) => {
|
.catch((error) => {
|
||||||
console.error('Failed to create window:', e)
|
handleWindowCreationError(error, 'Failed to create window')
|
||||||
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)}`,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
beginQuit()
|
|
||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (!isQuitting && BrowserWindow.getAllWindows().length === 0) {
|
if (isQuitting || BrowserWindow.getAllWindows().length > 0) {
|
||||||
createWindow().catch((e) => {
|
return
|
||||||
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)}`,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureWindow().catch((error) => {
|
||||||
|
handleWindowCreationError(error, 'Failed to re-create window')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', beginQuit)
|
||||||
beginQuit()
|
|
||||||
})
|
|
||||||
|
|||||||
268
apps/desktop/src/main/sidecar.ts
Normal file
268
apps/desktop/src/main/sidecar.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
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 ApiSplatRouteImport } from './routes/api/$'
|
||||||
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
||||||
|
|
||||||
@@ -18,6 +19,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiHealthRoute = ApiHealthRouteImport.update({
|
||||||
|
id: '/api/health',
|
||||||
|
path: '/api/health',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiSplatRoute = ApiSplatRouteImport.update({
|
const ApiSplatRoute = ApiSplatRouteImport.update({
|
||||||
id: '/api/$',
|
id: '/api/$',
|
||||||
path: '/api/$',
|
path: '/api/$',
|
||||||
@@ -32,30 +38,34 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
|
'/api/health': typeof ApiHealthRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
|
'/api/health': typeof ApiHealthRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
|
'/api/health': typeof ApiHealthRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/api/$' | '/api/rpc/$'
|
fullPaths: '/' | '/api/$' | '/api/health' | '/api/rpc/$'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/api/$' | '/api/rpc/$'
|
to: '/' | '/api/$' | '/api/health' | '/api/rpc/$'
|
||||||
id: '__root__' | '/' | '/api/$' | '/api/rpc/$'
|
id: '__root__' | '/' | '/api/$' | '/api/health' | '/api/rpc/$'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
ApiSplatRoute: typeof ApiSplatRoute
|
ApiSplatRoute: typeof ApiSplatRoute
|
||||||
|
ApiHealthRoute: typeof ApiHealthRoute
|
||||||
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +78,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/health': {
|
||||||
|
id: '/api/health'
|
||||||
|
path: '/api/health'
|
||||||
|
fullPath: '/api/health'
|
||||||
|
preLoaderRoute: typeof ApiHealthRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/$': {
|
'/api/$': {
|
||||||
id: '/api/$'
|
id: '/api/$'
|
||||||
path: '/api/$'
|
path: '/api/$'
|
||||||
@@ -88,6 +105,7 @@ declare module '@tanstack/react-router' {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
ApiSplatRoute: ApiSplatRoute,
|
ApiSplatRoute: ApiSplatRoute,
|
||||||
|
ApiHealthRoute: ApiHealthRoute,
|
||||||
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
27
apps/server/src/routes/api/health.ts
Normal file
27
apps/server/src/routes/api/health.ts
Normal 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 }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user