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:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user