fix(stage-tamagotchi): should handle EADDRINUSE, bug of srvx, robust for server channel restart, injeca fix
This commit is contained in:
@@ -87,6 +87,7 @@
|
||||
"@xsai/utils-chat": "catalog:",
|
||||
"alien-signals": "catalog:",
|
||||
"animejs": "^4.3.6",
|
||||
"async-mutex": "catalog:",
|
||||
"colorjs.io": "^0.6.1",
|
||||
"crossws": "^0.4.4",
|
||||
"culori": "^4.0.2",
|
||||
@@ -98,7 +99,7 @@
|
||||
"electron-click-drag-plugin": "^2.0.2",
|
||||
"electron-updater": "^6.8.3",
|
||||
"es-toolkit": "^1.44.0",
|
||||
"h3": "2.0.1-rc.5",
|
||||
"h3": "^2.0.1-rc.14",
|
||||
"injeca": "catalog:",
|
||||
"jszip": "^3.10.1",
|
||||
"localforage": "^1.10.0",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Format, LogLevel, setGlobalFormat, setGlobalHookPostLog, setGlobalLogLe
|
||||
import { initScreenCaptureForMain } from '@proj-airi/electron-screen-capture/main'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { noop } from 'es-toolkit'
|
||||
import { createLoggLogger, injeca } from 'injeca'
|
||||
import { createLoggLogger, injeca, lifecycle } from 'injeca'
|
||||
import { isLinux } from 'std-env'
|
||||
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
@@ -106,8 +106,8 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
const serverChannel = injeca.provide('modules:channel-server', {
|
||||
dependsOn: { app: electronApp },
|
||||
build: async () => setupServerChannel(),
|
||||
dependsOn: { app: electronApp, lifecycle },
|
||||
build: async ({ dependsOn }) => setupServerChannel(dependsOn),
|
||||
})
|
||||
|
||||
const mcpStdioManager = injeca.provide('modules:mcp-stdio-manager', {
|
||||
|
||||
@@ -1,134 +1,66 @@
|
||||
import type { ElectronServerChannelTlsConfig } from '../../../../shared/eventa'
|
||||
import type { Server, ServerOptions } from '@proj-airi/server-runtime/server'
|
||||
import type { Lifecycle } from 'injeca'
|
||||
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { isIP } from 'node:net'
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { env, platform } from 'node:process'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import { createServer, getLocalIPs } from '@proj-airi/server-runtime/server'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { createCA, createCert } from 'mkcert'
|
||||
import { x } from 'tinyexec'
|
||||
import { nullable, object, record, string, unknown } from 'valibot'
|
||||
import { nullable, object, optional, string } from 'valibot'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
electronApplyServerChannelConfig,
|
||||
electronGetServerChannelConfig,
|
||||
} from '../../../../shared/eventa'
|
||||
import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle'
|
||||
import { createConfig } from '../../../libs/electron/persistence'
|
||||
|
||||
interface ServerInstance { close: (closeActiveConnections?: boolean) => Promise<void> }
|
||||
interface ServerChannelOptions { websocketTlsConfig?: ElectronServerChannelTlsConfig | null }
|
||||
|
||||
export interface ServerChannel {
|
||||
start: () => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
restart: () => Promise<void>
|
||||
updateConfig: (newOptions: ServerChannelOptions) => void
|
||||
}
|
||||
|
||||
let isServerQuitHookRegistered = false
|
||||
|
||||
const channelServerConfigSchema = object({
|
||||
websocketTlsConfig: nullable(record(string(), unknown())),
|
||||
tlsConfig: optional(nullable(object({
|
||||
cert: optional(string()),
|
||||
key: optional(string()),
|
||||
passphrase: optional(string()),
|
||||
}))),
|
||||
})
|
||||
|
||||
const channelServerInvokeConfigSchema = z.object({
|
||||
websocketTlsConfig: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
tlsConfig: z.object({ }).nullable().optional(),
|
||||
}).strict()
|
||||
|
||||
const channelServerConfigStore = createConfig('server-channel', 'config.json', channelServerConfigSchema, {
|
||||
default: {
|
||||
websocketTlsConfig: null,
|
||||
tlsConfig: null,
|
||||
},
|
||||
autoHeal: true,
|
||||
})
|
||||
|
||||
function getChannelServerConfig() {
|
||||
return channelServerConfigStore.get() ?? { websocketTlsConfig: null }
|
||||
async function getChannelServerConfig(): Promise<ServerOptions> {
|
||||
return channelServerConfigStore.get() || { tlsConfig: null }
|
||||
}
|
||||
|
||||
function normalizeChannelServerOptions(
|
||||
payload: unknown,
|
||||
fallback = getChannelServerConfig(),
|
||||
) {
|
||||
async function normalizeChannelServerOptions(payload: unknown, fallback?: ServerOptions) {
|
||||
if (!fallback) {
|
||||
fallback = await getChannelServerConfig()
|
||||
}
|
||||
|
||||
const parsed = channelServerInvokeConfigSchema.safeParse(payload)
|
||||
if (!parsed.success) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return {
|
||||
websocketTlsConfig: typeof parsed.data.websocketTlsConfig === 'undefined' ? null : parsed.data.websocketTlsConfig,
|
||||
tlsConfig: typeof parsed.data.tlsConfig === 'undefined' ? null : parsed.data.tlsConfig,
|
||||
}
|
||||
}
|
||||
|
||||
function registerServerQuitHook(getServerChannel: () => ServerChannel | null) {
|
||||
if (isServerQuitHookRegistered)
|
||||
return
|
||||
|
||||
isServerQuitHookRegistered = true
|
||||
|
||||
onAppBeforeQuit(async () => {
|
||||
const log = useLogg('main/server-runtime').useGlobalConfig()
|
||||
const serverChannel = getServerChannel()
|
||||
if (!serverChannel) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await serverChannel.stop()
|
||||
log.log('WebSocket server closed')
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).error('Error closing WebSocket server')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getLocalIPs(): string[] {
|
||||
const interfaces = networkInterfaces()
|
||||
const addresses: string[] = []
|
||||
|
||||
const VIRTUAL_INTERFACE_PREFIXES = [
|
||||
'vboxnet',
|
||||
'vmnet',
|
||||
'docker',
|
||||
'br-',
|
||||
'veth',
|
||||
'utun',
|
||||
'wg',
|
||||
'tap',
|
||||
'tun',
|
||||
]
|
||||
const isVirtualInterface = (name: string) =>
|
||||
VIRTUAL_INTERFACE_PREFIXES.some(prefix => name.startsWith(prefix))
|
||||
|
||||
for (const [name, entries] of Object.entries(interfaces)) {
|
||||
if (!entries)
|
||||
continue
|
||||
if (isVirtualInterface(name))
|
||||
continue
|
||||
|
||||
for (const entry of entries) {
|
||||
const rawAddress = entry.address
|
||||
if (!rawAddress)
|
||||
continue
|
||||
|
||||
const address = rawAddress.includes('%') ? rawAddress.split('%')[0] : rawAddress
|
||||
if (isIP(address))
|
||||
addresses.push(address)
|
||||
}
|
||||
}
|
||||
|
||||
return addresses
|
||||
}
|
||||
|
||||
function getCertificateDomains(): string[] {
|
||||
const localIPs = getLocalIPs()
|
||||
const hostname = env.SERVER_RUNTIME_HOSTNAME
|
||||
@@ -264,170 +196,132 @@ async function getOrCreateCertificate() {
|
||||
return { cert, key }
|
||||
}
|
||||
|
||||
export function createServerChannel(initialOptions: ServerChannelOptions = getChannelServerConfig()): ServerChannel {
|
||||
const log = useLogg('main/server-runtime').useGlobalConfig()
|
||||
let serverInstance: ServerInstance | null = null
|
||||
let options = initialOptions
|
||||
export async function setupServerChannel(params: { lifecycle: Lifecycle }): Promise<Server> {
|
||||
channelServerConfigStore.setup()
|
||||
|
||||
log.withFields({ hasTlsConfig: !!options.websocketTlsConfig }).log('creating server channel')
|
||||
const storedConfig = await getChannelServerConfig()
|
||||
|
||||
async function closeServer(closeActiveConnections = false) {
|
||||
if (!serverInstance || typeof serverInstance.close !== 'function') {
|
||||
return
|
||||
}
|
||||
const serverChannel = createServer({
|
||||
...storedConfig,
|
||||
port: env.PORT ? Number.parseInt(env.PORT) : 6121,
|
||||
hostname: env.SERVER_RUNTIME_HOSTNAME || '0.0.0.0',
|
||||
tlsConfig: storedConfig.tlsConfig ? await getOrCreateCertificate() : null,
|
||||
})
|
||||
|
||||
const mutex = new Mutex()
|
||||
|
||||
params.lifecycle.appHooks.onStart(async () => {
|
||||
const release = await mutex.acquire()
|
||||
|
||||
const log = useLogg('main/server-runtime').useGlobalConfig()
|
||||
|
||||
try {
|
||||
if (closeActiveConnections) {
|
||||
log.log('closing existing server instance')
|
||||
}
|
||||
await serverInstance.close(closeActiveConnections)
|
||||
if (closeActiveConnections) {
|
||||
log.log('existing server instance closed')
|
||||
}
|
||||
await serverChannel.start()
|
||||
log.log('WebSocket server started')
|
||||
}
|
||||
catch (error) {
|
||||
const nodejsError = error as NodeJS.ErrnoException
|
||||
if ('code' in nodejsError && nodejsError.code === 'ERR_SERVER_NOT_RUNNING') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!closeActiveConnections) {
|
||||
log.withError(error).error('Error closing WebSocket server')
|
||||
}
|
||||
log.withError(error).error('Error starting WebSocket server')
|
||||
}
|
||||
finally {
|
||||
serverInstance = null
|
||||
release()
|
||||
}
|
||||
}
|
||||
})
|
||||
params.lifecycle.appHooks.onStop(async () => {
|
||||
const release = await mutex.acquire()
|
||||
|
||||
async function start() {
|
||||
if (serverInstance) {
|
||||
const log = useLogg('main/server-runtime').useGlobalConfig()
|
||||
if (!serverChannel) {
|
||||
return
|
||||
}
|
||||
|
||||
const secureEnabled = options?.websocketTlsConfig != null
|
||||
|
||||
try {
|
||||
const serverRuntime = await import('@proj-airi/server-runtime')
|
||||
const { plugin: ws } = await import('crossws/server')
|
||||
const { serve } = await import('h3')
|
||||
|
||||
const h3App = serverRuntime.setupApp()
|
||||
|
||||
const port = env.PORT ? Number(env.PORT) : 6121
|
||||
const hostname = env.SERVER_RUNTIME_HOSTNAME || '0.0.0.0'
|
||||
|
||||
// FIXME: should prompt user to grant permission to save certificate files on macOS
|
||||
const tls = secureEnabled ? await getOrCreateCertificate() : undefined
|
||||
|
||||
const instance = serve(h3App.app, {
|
||||
// @ts-expect-error - the .crossws property wasn't extended in types
|
||||
plugins: [ws({ resolve: async req => (await h3App.app.fetch(req)).crossws })],
|
||||
port,
|
||||
hostname,
|
||||
tls,
|
||||
reusePort: true,
|
||||
silent: true,
|
||||
manual: true,
|
||||
gracefulShutdown: {
|
||||
forceTimeout: 0.5,
|
||||
gracefulTimeout: 0.5,
|
||||
},
|
||||
})
|
||||
|
||||
serverInstance = {
|
||||
close: async (closeActiveConnections = false) => {
|
||||
log.log('closing all peers')
|
||||
h3App.closeAllPeers()
|
||||
log.log('closing server instance')
|
||||
await instance.close(closeActiveConnections)
|
||||
log.log('server instance closed')
|
||||
},
|
||||
}
|
||||
|
||||
const servePromise = instance.serve()
|
||||
if (servePromise instanceof Promise) {
|
||||
servePromise.catch((error) => {
|
||||
const nodejsError = error as NodeJS.ErrnoException
|
||||
if ('code' in nodejsError && nodejsError.code === 'EADDRINUSE') {
|
||||
log.withError(error).warn('Port already in use, assuming server is already running')
|
||||
return
|
||||
}
|
||||
|
||||
log.withError(error).error('Error serving WebSocket server')
|
||||
})
|
||||
}
|
||||
|
||||
const protocol = secureEnabled ? 'wss' : 'ws'
|
||||
if (hostname === '0.0.0.0') {
|
||||
const ips = getLocalIPs().filter(ip => ip !== '127.0.0.1' && ip !== '::1')
|
||||
const targets = ips.length > 0 ? ips.join(', ') : 'localhost'
|
||||
log.log(`@proj-airi/server-runtime started on ${protocol}://0.0.0.0:${port} (reachable via: ${targets})`)
|
||||
}
|
||||
else {
|
||||
log.log(`@proj-airi/server-runtime started on ${protocol}://${hostname}:${port}`)
|
||||
}
|
||||
await serverChannel.stop()
|
||||
log.log('WebSocket server closed')
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).error('failed to start WebSocket server')
|
||||
log.withError(error).error('Error closing WebSocket server')
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
await closeServer()
|
||||
}
|
||||
|
||||
async function restart() {
|
||||
log.log('restarting server channel', { options })
|
||||
await closeServer(true)
|
||||
await start()
|
||||
}
|
||||
|
||||
async function updateConfig(newOptions: ServerChannelOptions) {
|
||||
options = { ...options, ...newOptions }
|
||||
}
|
||||
finally {
|
||||
release()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
updateConfig,
|
||||
getConnectionHost() {
|
||||
return serverChannel.getConnectionHost()
|
||||
},
|
||||
async start() {
|
||||
const release = await mutex.acquire()
|
||||
try {
|
||||
await serverChannel.start()
|
||||
}
|
||||
finally {
|
||||
release()
|
||||
}
|
||||
},
|
||||
async restart() {
|
||||
const release = await mutex.acquire()
|
||||
try {
|
||||
await serverChannel.stop()
|
||||
await serverChannel.start()
|
||||
}
|
||||
finally {
|
||||
release()
|
||||
}
|
||||
},
|
||||
async stop() {
|
||||
const release = await mutex.acquire()
|
||||
try {
|
||||
await serverChannel.stop()
|
||||
}
|
||||
finally {
|
||||
release()
|
||||
}
|
||||
},
|
||||
async updateConfig(config) {
|
||||
const release = await mutex.acquire()
|
||||
try {
|
||||
await serverChannel.updateConfig(config)
|
||||
}
|
||||
finally {
|
||||
release()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupServerChannel() {
|
||||
channelServerConfigStore.setup()
|
||||
const serverChannel = createServerChannel(getChannelServerConfig())
|
||||
registerServerQuitHook(() => serverChannel)
|
||||
|
||||
// Start the server during module initialization so startup is bound to injeca lifecycle.
|
||||
await serverChannel.start()
|
||||
return serverChannel
|
||||
}
|
||||
|
||||
export async function createServerChannelService(params: { serverChannel: ServerChannel }) {
|
||||
export async function createServerChannelService(params: { serverChannel: Server }) {
|
||||
const { context } = createContext(ipcMain)
|
||||
|
||||
defineInvokeHandler(context, electronGetServerChannelConfig, async () => {
|
||||
return getChannelServerConfig()
|
||||
return await getChannelServerConfig()
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronApplyServerChannelConfig, async (req) => {
|
||||
const current = getChannelServerConfig()
|
||||
const next = normalizeChannelServerOptions(req, current)
|
||||
const changed = JSON.stringify(next.websocketTlsConfig) !== JSON.stringify(current.websocketTlsConfig)
|
||||
try {
|
||||
const current = await getChannelServerConfig()
|
||||
const next = await normalizeChannelServerOptions(req, current)
|
||||
const changed = JSON.stringify(next.tlsConfig) !== JSON.stringify(current.tlsConfig)
|
||||
|
||||
channelServerConfigStore.update(next)
|
||||
channelServerConfigStore.update(next)
|
||||
|
||||
if (changed) {
|
||||
await params.serverChannel.stop()
|
||||
await params.serverChannel.updateConfig(next)
|
||||
await params.serverChannel.start()
|
||||
if (changed) {
|
||||
await params.serverChannel.stop()
|
||||
await params.serverChannel.updateConfig({
|
||||
port: env.PORT ? Number.parseInt(env.PORT) : 6121,
|
||||
hostname: env.SERVER_RUNTIME_HOSTNAME || '0.0.0.0',
|
||||
tlsConfig: next.tlsConfig ? await getOrCreateCertificate() : null,
|
||||
})
|
||||
await params.serverChannel.start()
|
||||
}
|
||||
else {
|
||||
await params.serverChannel.start()
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
else {
|
||||
await params.serverChannel.start()
|
||||
catch (error) {
|
||||
useLogg('main/server-runtime').withError(error).error('Failed to apply server channel configuration')
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { ElectronServerChannelTlsConfig } from '../../../shared/eventa'
|
||||
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { useAsyncState, useLocalStorage } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
@@ -8,7 +6,7 @@ import { watch } from 'vue'
|
||||
import { electronApplyServerChannelConfig, electronGetServerChannelConfig } from '../../../shared/eventa'
|
||||
|
||||
export const useServerChannelSettingsStore = defineStore('tamagotchi-server-channel-settings', () => {
|
||||
const websocketTlsConfig = useLocalStorage<ElectronServerChannelTlsConfig | null>('settings/server-channel/websocket-tls-config', null)
|
||||
const websocketTlsConfig = useLocalStorage<{ cert?: string, key?: string, passphrase?: string } | null>('settings/server-channel/websocket-tls-config', null)
|
||||
|
||||
const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig)
|
||||
const applyServerChannelConfig = useElectronEventaInvoke(electronApplyServerChannelConfig)
|
||||
@@ -17,11 +15,11 @@ export const useServerChannelSettingsStore = defineStore('tamagotchi-server-chan
|
||||
|
||||
watch(websocketTlsConfig, async (newValue) => {
|
||||
websocketTlsConfig.value = newValue
|
||||
await applyServerChannelConfig({ websocketTlsConfig: newValue ? {} : null })
|
||||
await applyServerChannelConfig({ tlsConfig: newValue ? {} : null })
|
||||
})
|
||||
|
||||
watch(serverChannelConfig.state, (newConfig) => {
|
||||
websocketTlsConfig.value = newConfig?.websocketTlsConfig
|
||||
websocketTlsConfig.value = newConfig?.tlsConfig
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Locale } from '@intlify/core'
|
||||
import type { ServerOptions } from '@proj-airi/server-runtime/server'
|
||||
|
||||
import { defineEventa, defineInvokeEventa } from '@moeru/eventa'
|
||||
|
||||
@@ -11,12 +12,8 @@ export const electronOpenChat = defineInvokeEventa('eventa:invoke:electron:windo
|
||||
export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:electron:windows:settings:devtools:open')
|
||||
export const electronOpenDevtoolsWindow = defineInvokeEventa<void, { route?: string }>('eventa:invoke:electron:windows:devtools:open')
|
||||
|
||||
export interface ElectronServerChannelTlsConfig {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ElectronServerChannelConfig {
|
||||
websocketTlsConfig: ElectronServerChannelTlsConfig | null
|
||||
tlsConfig?: ServerOptions['tlsConfig'] | null
|
||||
}
|
||||
export const electronGetServerChannelConfig = defineInvokeEventa<ElectronServerChannelConfig>('eventa:invoke:electron:server-channel:get-config')
|
||||
export const electronApplyServerChannelConfig = defineInvokeEventa<ElectronServerChannelConfig, Partial<ElectronServerChannelConfig>>('eventa:invoke:electron:server-channel:apply-config')
|
||||
|
||||
Reference in New Issue
Block a user