fix(stage-tamagotchi): should handle EADDRINUSE, bug of srvx, robust for server channel restart, injeca fix

This commit is contained in:
Neko Ayaka
2026-03-10 23:47:39 +08:00
parent e6ac3ff46e
commit a2da08f2ce
14 changed files with 432 additions and 652 deletions
+5 -9
View File
@@ -2,14 +2,10 @@
import { env } from 'node:process'
import { plugin as ws } from 'crossws/server'
import { serve } from 'h3'
import { createServer } from '../server'
import { app } from '..'
serve(app, {
// TODO: fix types
// @ts-expect-error - the .crossws property wasn't extended in types
plugins: [ws({ resolve: async req => (await app.fetch(req)).crossws })],
port: env.PORT ? Number(env.PORT) : 6121,
const server = createServer({
port: env.PORT ? Number.parseInt(env.PORT) : 6121,
})
server.start()
+17 -4
View File
@@ -72,7 +72,7 @@ function send(peer: Peer, event: WebSocketEvent<Record<string, unknown>> | strin
peer.send(typeof event === 'string' ? event : stringify(event))
}
export function setupApp(options?: {
export interface AppOptions {
instanceId?: string
auth?: {
token: string
@@ -90,15 +90,28 @@ export function setupApp(options?: {
readTimeout?: number
message?: MessageHeartbeat | string
}
}): { app: H3, closeAllPeers: () => void } {
const instanceId = options?.instanceId || optionOrEnv(undefined, 'SERVER_INSTANCE_ID', nanoid())
const authToken = optionOrEnv(options?.auth?.token, 'AUTHENTICATION_TOKEN', '')
}
export function normalizeLoggerConfig(options?: AppOptions) {
const appLogLevel = optionOrEnv(options?.logger?.app?.level, 'LOG_LEVEL', LogLevelString.Log, { validator: (value): value is LogLevelString => availableLogLevelStrings.includes(value as LogLevelString) })
const appLogFormat = optionOrEnv(options?.logger?.app?.format, 'LOG_FORMAT', Format.Pretty, { validator: (value): value is Format => Object.values(Format).includes(value as Format) })
const websocketLogLevel = options?.logger?.websocket?.level || appLogLevel || LogLevelString.Log
const websocketLogFormat = options?.logger?.websocket?.format || appLogFormat || Format.Pretty
return {
appLogLevel,
appLogFormat,
websocketLogLevel,
websocketLogFormat,
}
}
export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => void } {
const instanceId = options?.instanceId || optionOrEnv(undefined, 'SERVER_INSTANCE_ID', nanoid())
const authToken = optionOrEnv(options?.auth?.token, 'AUTHENTICATION_TOKEN', '')
const { appLogLevel, appLogFormat, websocketLogLevel, websocketLogFormat } = normalizeLoggerConfig(options)
const appLogger = useLogg('@proj-airi/server-runtime').withLogLevel(logLevelStringToLogLevelMap[appLogLevel]).withFormat(appLogFormat)
const logger = useLogg('@proj-airi/server-runtime:websocket').withLogLevel(logLevelStringToLogLevelMap[websocketLogLevel]).withFormat(websocketLogFormat)
+200
View File
@@ -0,0 +1,200 @@
import type { AppOptions } from '..'
import { isIP } from 'node:net'
import { networkInterfaces } from 'node:os'
import { useLogg } from '@guiiai/logg'
import { merge } from '@moeru/std'
import { plugin as ws } from 'crossws/server'
import { serve } from 'h3'
import { normalizeLoggerConfig, setupApp } from '..'
export interface ServerOptions extends AppOptions {
port?: number
hostname?: string
tlsConfig?: {
cert?: string
key?: string
passphrase?: string
} | null
}
interface ServerInstance {
close: (closeActiveConnections?: boolean) => Promise<void>
}
export interface Server {
getConnectionHost: () => string[]
start: () => Promise<void>
stop: () => Promise<void>
restart: () => Promise<void>
updateConfig: (newOptions: ServerOptions) => void
}
export 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
}
export function createServer(opts?: ServerOptions): Server {
let options = merge<ServerOptions>({ port: 6121, hostname: '0.0.0.0' }, opts)
const { appLogFormat, appLogLevel } = normalizeLoggerConfig(options)
const log = useLogg('@proj-airi/server-runtime/server').withLogLevelString(appLogLevel).withFormat(appLogFormat)
let serverInstance: ServerInstance | null = null
log.withFields({ hasTlsConfig: !!options?.tlsConfig }).log('creating server channel')
async function closeServer(closeActiveConnections = false) {
if (!serverInstance || typeof serverInstance.close !== 'function') {
return
}
try {
if (closeActiveConnections) {
log.log('closing existing server instance')
}
await serverInstance.close(closeActiveConnections)
if (closeActiveConnections) {
log.log('existing server instance closed')
}
}
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')
}
}
finally {
serverInstance = null
}
}
async function start() {
if (serverInstance) {
return
}
const secureEnabled = options?.tlsConfig != null
try {
const h3App = setupApp()
const port = options.port
const hostname = options.hostname
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: options?.tlsConfig || undefined,
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}`)
}
}
catch (error) {
log.withError(error).error('failed to start 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: ServerOptions) {
options = { ...options, ...newOptions }
}
return {
getConnectionHost: () => {
return getLocalIPs()
},
start,
stop,
restart,
updateConfig,
}
}