Merge pull request #16 from P2K0/feat/docker-compose-gateway-stability

feat: add docker-compose deployment and harden gateway startup
This commit is contained in:
ekko
2026-04-17 08:11:04 +08:00
committed by GitHub
10 changed files with 290 additions and 28 deletions
+15 -6
View File
@@ -268,6 +268,17 @@ async function ensureApiServerConfig() {
async function ensureGatewayRunning() {
const upstream = config.upstream.replace(/\/$/, '')
const waitForGatewayReady = async (timeoutMs: number = 15000) => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(2000) })
if (res.ok) return true
} catch { }
await new Promise(r => setTimeout(r, 300))
}
return false
}
try {
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
@@ -279,16 +290,14 @@ async function ensureGatewayRunning() {
try {
// 👉 关键:保存 PID
gatewayPid = await startGatewayBackground()
await new Promise(r => setTimeout(r, 3000))
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
if (res.ok) {
if (await waitForGatewayReady()) {
console.log(`✓ Gateway started (PID: ${gatewayPid})`)
} else {
console.error('gateway start failed: timed out waiting for health')
}
} catch (err: any) {
console.error('gateway start failed:', err.message)
}
}
bootstrap()
bootstrap()
@@ -1,6 +1,32 @@
import type { Context } from 'koa'
import { config } from '../../config'
function isTransientGatewayError(err: any): boolean {
const msg = String(err?.message || '')
const causeCode = String(err?.cause?.code || '')
return (
causeCode === 'ECONNREFUSED' ||
causeCode === 'ECONNRESET' ||
/ECONNREFUSED|ECONNRESET|fetch failed|socket hang up/i.test(msg)
)
}
async function waitForGatewayReady(upstream: string, timeoutMs: number = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
const healthUrl = `${upstream}/health`
while (Date.now() < deadline) {
try {
const res = await fetch(healthUrl, {
method: 'GET',
signal: AbortSignal.timeout(1200),
})
if (res.ok) return true
} catch { }
await new Promise(resolve => setTimeout(resolve, 250))
}
return false
}
export async function proxy(ctx: Context) {
const upstream = config.upstream.replace(/\/$/, '')
// Rewrite path for upstream gateway:
@@ -31,11 +57,23 @@ export async function proxy(ctx: Context) {
body = (ctx as any).request.rawBody as string | undefined
}
const res = await fetch(url, {
const requestInit: RequestInit = {
method: ctx.req.method,
headers,
body,
})
}
let res: Response
try {
res = await fetch(url, requestInit)
} catch (err: any) {
// Gateway may be restarting; wait briefly and retry once.
if (isTransientGatewayError(err) && await waitForGatewayReady(upstream)) {
res = await fetch(url, requestInit)
} else {
throw err
}
}
// Set response headers
res.headers.forEach((value, key) => {
+41 -20
View File
@@ -1,9 +1,19 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
const execOpts = { windowsHide: true }
const isDocker = existsSync('/.dockerenv')
function resolveHermesBin(): string {
const envBin = process.env.HERMES_BIN?.trim()
if (envBin) return envBin
return 'hermes'
}
const HERMES_BIN = resolveHermesBin()
export interface HermesSession {
id: string
@@ -64,7 +74,7 @@ export async function listSessions(source?: string, limit?: number): Promise<Her
if (source) args.push('--source', source)
try {
const { stdout } = await execFileAsync('hermes', args, {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024, // 50MB
timeout: 30000,
...execOpts,
@@ -128,7 +138,7 @@ export async function getSession(id: string): Promise<HermesSession | null> {
const args = ['sessions', 'export', '-', '--session-id', id]
try {
const { stdout } = await execFileAsync('hermes', args, {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -174,7 +184,7 @@ export async function getSession(id: string): Promise<HermesSession | null> {
*/
export async function deleteSession(id: string): Promise<boolean> {
try {
await execFileAsync('hermes', ['sessions', 'delete', id, '--yes'], {
await execFileAsync(HERMES_BIN, ['sessions', 'delete', id, '--yes'], {
timeout: 10000,
...execOpts,
})
@@ -190,7 +200,7 @@ export async function deleteSession(id: string): Promise<boolean> {
*/
export async function renameSession(id: string, title: string): Promise<boolean> {
try {
await execFileAsync('hermes', ['sessions', 'rename', id, title], {
await execFileAsync(HERMES_BIN, ['sessions', 'rename', id, title], {
timeout: 10000,
...execOpts,
})
@@ -212,7 +222,7 @@ export interface LogFileInfo {
*/
export async function getVersion(): Promise<string> {
try {
const { stdout } = await execFileAsync('hermes', ['--version'], { timeout: 5000, ...execOpts })
const { stdout } = await execFileAsync(HERMES_BIN, ['--version'], { timeout: 5000, ...execOpts })
return stdout.trim()
} catch {
return ''
@@ -223,7 +233,12 @@ export async function getVersion(): Promise<string> {
* Start Hermes gateway (uses launchd/systemd)
*/
export async function startGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'start'], {
if (isDocker) {
const pid = await startGatewayBackground()
return pid ? `Gateway started (PID: ${pid})` : 'Gateway start triggered'
}
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
timeout: 30000,
...execOpts,
})
@@ -236,7 +251,7 @@ export async function startGateway(): Promise<string> {
*/
export async function startGatewayBackground(): Promise<number | null> {
const { spawn } = require('child_process') as typeof import('child_process')
const child = spawn('hermes', ['gateway', 'run'], {
const child = spawn(HERMES_BIN, ['gateway', 'run'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
@@ -249,7 +264,13 @@ export async function startGatewayBackground(): Promise<number | null> {
* Restart Hermes gateway
*/
export async function restartGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'restart'], {
if (isDocker) {
try { await stopGateway() } catch { }
const pid = await startGatewayBackground()
return pid ? `Gateway restarted (PID: ${pid})` : 'Gateway restart triggered'
}
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'restart'], {
timeout: 30000,
...execOpts,
})
@@ -260,7 +281,7 @@ export async function restartGateway(): Promise<string> {
* Stop Hermes gateway
*/
export async function stopGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'stop'], {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
timeout: 30000,
...execOpts,
})
@@ -272,7 +293,7 @@ export async function stopGateway(): Promise<string> {
*/
export async function listLogFiles(): Promise<LogFileInfo[]> {
try {
const { stdout } = await execFileAsync('hermes', ['logs', 'list'], {
const { stdout } = await execFileAsync(HERMES_BIN, ['logs', 'list'], {
timeout: 10000,
...execOpts,
})
@@ -311,7 +332,7 @@ export async function readLogs(
if (since) args.push('--since', since)
try {
const { stdout } = await execFileAsync('hermes', args, {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 10 * 1024 * 1024,
timeout: 15000,
...execOpts,
@@ -349,7 +370,7 @@ export interface HermesProfileDetail {
*/
export async function listProfiles(): Promise<HermesProfile[]> {
try {
const { stdout } = await execFileAsync('hermes', ['profile', 'list'], {
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'list'], {
timeout: 10000,
...execOpts,
})
@@ -385,7 +406,7 @@ export async function listProfiles(): Promise<HermesProfile[]> {
*/
export async function getProfile(name: string): Promise<HermesProfileDetail> {
try {
const { stdout } = await execFileAsync('hermes', ['profile', 'show', name], {
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'show', name], {
timeout: 10000,
...execOpts,
})
@@ -429,7 +450,7 @@ export async function createProfile(name: string, clone?: boolean): Promise<stri
if (clone) args.push('--clone')
try {
const { stdout, stderr } = await execFileAsync('hermes', args, {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
timeout: 15000,
...execOpts,
})
@@ -445,7 +466,7 @@ export async function createProfile(name: string, clone?: boolean): Promise<stri
*/
export async function deleteProfile(name: string): Promise<boolean> {
try {
await execFileAsync('hermes', ['profile', 'delete', name, '--yes'], {
await execFileAsync(HERMES_BIN, ['profile', 'delete', name, '--yes'], {
timeout: 10000,
...execOpts,
})
@@ -461,7 +482,7 @@ export async function deleteProfile(name: string): Promise<boolean> {
*/
export async function renameProfile(oldName: string, newName: string): Promise<boolean> {
try {
await execFileAsync('hermes', ['profile', 'rename', oldName, newName], {
await execFileAsync(HERMES_BIN, ['profile', 'rename', oldName, newName], {
timeout: 10000,
...execOpts,
})
@@ -477,7 +498,7 @@ export async function renameProfile(oldName: string, newName: string): Promise<b
*/
export async function useProfile(name: string): Promise<string> {
try {
const { stdout, stderr } = await execFileAsync('hermes', ['profile', 'use', name], {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['profile', 'use', name], {
timeout: 10000,
...execOpts,
})
@@ -496,7 +517,7 @@ export async function exportProfile(name: string, outputPath?: string): Promise<
if (outputPath) args.push('--output', outputPath)
try {
const { stdout, stderr } = await execFileAsync('hermes', args, {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
timeout: 60000,
...execOpts,
})
@@ -512,7 +533,7 @@ export async function exportProfile(name: string, outputPath?: string): Promise<
*/
export async function setupReset(): Promise<string> {
try {
const { stdout, stderr } = await execFileAsync('hermes', ['setup', '--non-interactive', '--reset'], {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['setup', '--non-interactive', '--reset'], {
timeout: 30000,
...execOpts,
})
@@ -531,7 +552,7 @@ export async function importProfile(archivePath: string, name?: string): Promise
if (name) args.push('--name', name)
try {
const { stdout, stderr } = await execFileAsync('hermes', args, {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
timeout: 60000,
...execOpts,
})