Files
moeka-project/packages/server-runtime/src/server/index.ts
T

191 lines
4.9 KiB
TypeScript

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
}
log.withError(error).error('Error closing WebSocket server')
}
finally {
serverInstance = null
}
}
async function start() {
if (serverInstance) {
return
}
const secureEnabled = options?.tlsConfig != null
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,
},
})
try {
await instance.serve()
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 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) {
serverInstance = null
h3App.closeAllPeers()
await instance.close(true).catch(() => {})
log.withError(error).error('failed to start WebSocket server')
throw error
}
}
async function stop() {
await closeServer(true)
}
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,
}
}