fix(stage-tamagotchi): should handle EADDRINUSE, bug of srvx, robust for server channel restart, injeca fix
This commit is contained in:
@@ -18,6 +18,10 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server.d.mts",
|
||||
"default": "./dist/server.mjs"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.mjs",
|
||||
@@ -36,10 +40,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@guiiai/logg": "catalog:",
|
||||
"@moeru/std": "catalog:",
|
||||
"@proj-airi/server-shared": "workspace:^",
|
||||
"crossws": "^0.4.4",
|
||||
"h3": "^2.0.1-rc.14",
|
||||
"listhen": "^1.9.0",
|
||||
"nanoid": "catalog:",
|
||||
"srvx": "^0.11.8",
|
||||
"superjson": "catalog:"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from 'tsdown'
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
'index': 'src/index.ts',
|
||||
'server': 'src/server/index.ts',
|
||||
'bin/run': 'src/bin/run.ts',
|
||||
},
|
||||
target: 'node18',
|
||||
|
||||
Reference in New Issue
Block a user