|
|
|
@@ -11,6 +11,9 @@ import type { AuthenticatedPeer, Peer } from './types'
|
|
|
|
|
import { availableLogLevelStrings, Format, LogLevelString, logLevelStringToLogLevelMap, useLogg } from '@guiiai/logg'
|
|
|
|
|
import { MessageHeartbeat, MessageHeartbeatKind, WebSocketEventSource } from '@proj-airi/server-shared/types'
|
|
|
|
|
import { defineWebSocketHandler, H3 } from 'h3'
|
|
|
|
|
import { nanoid } from 'nanoid'
|
|
|
|
|
|
|
|
|
|
import packageJSON from '../package.json'
|
|
|
|
|
|
|
|
|
|
import { optionOrEnv } from './config'
|
|
|
|
|
import {
|
|
|
|
@@ -20,10 +23,42 @@ import {
|
|
|
|
|
matchesDestinations,
|
|
|
|
|
} from './middlewares'
|
|
|
|
|
|
|
|
|
|
function createServerEventMetadata(serverInstanceId: string, parentId?: string) {
|
|
|
|
|
return {
|
|
|
|
|
event: {
|
|
|
|
|
id: nanoid(),
|
|
|
|
|
parentId,
|
|
|
|
|
},
|
|
|
|
|
source: {
|
|
|
|
|
plugin: WebSocketEventSource.Server,
|
|
|
|
|
instanceId: serverInstanceId,
|
|
|
|
|
version: packageJSON.version,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// pre-stringified responses
|
|
|
|
|
const RESPONSES = {
|
|
|
|
|
authenticated: JSON.stringify({ type: 'module:authenticated', data: { authenticated: true }, source: WebSocketEventSource.Server } satisfies WebSocketEvent),
|
|
|
|
|
notAuthenticated: JSON.stringify({ type: 'error', data: { message: 'not authenticated' }, source: WebSocketEventSource.Server } satisfies WebSocketEvent),
|
|
|
|
|
authenticated: (serverInstanceId: string, parentId?: string) => JSON.stringify({
|
|
|
|
|
type: 'module:authenticated',
|
|
|
|
|
data: { authenticated: true },
|
|
|
|
|
metadata: createServerEventMetadata(serverInstanceId, parentId),
|
|
|
|
|
} satisfies WebSocketEvent),
|
|
|
|
|
notAuthenticated: (serverInstanceId: string, parentId?: string) => JSON.stringify({
|
|
|
|
|
type: 'error',
|
|
|
|
|
data: { message: 'not authenticated' },
|
|
|
|
|
metadata: createServerEventMetadata(serverInstanceId, parentId),
|
|
|
|
|
} satisfies WebSocketEvent),
|
|
|
|
|
error: (message: string, serverInstanceId: string, parentId?: string) => JSON.stringify({
|
|
|
|
|
type: 'error',
|
|
|
|
|
data: { message },
|
|
|
|
|
metadata: createServerEventMetadata(serverInstanceId, parentId),
|
|
|
|
|
}),
|
|
|
|
|
heartbeat: (kind: MessageHeartbeatKind, message: MessageHeartbeat | string, serverInstanceId: string, parentId?: string) => JSON.stringify({
|
|
|
|
|
type: 'transport:connection:heartbeat',
|
|
|
|
|
data: { kind, message, at: Date.now() },
|
|
|
|
|
metadata: createServerEventMetadata(serverInstanceId, parentId),
|
|
|
|
|
} satisfies WebSocketEvent),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEFAULT_HEARTBEAT_TTL_MS = 60_000
|
|
|
|
@@ -34,6 +69,7 @@ function send(peer: Peer, event: WebSocketEvent<Record<string, unknown>> | strin
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setupApp(options?: {
|
|
|
|
|
instanceId?: string
|
|
|
|
|
auth?: {
|
|
|
|
|
token: string
|
|
|
|
|
}
|
|
|
|
@@ -51,6 +87,7 @@ export function setupApp(options?: {
|
|
|
|
|
message?: MessageHeartbeat | string
|
|
|
|
|
}
|
|
|
|
|
}): H3 {
|
|
|
|
|
const instanceId = options?.instanceId || optionOrEnv(undefined, 'SERVER_INSTANCE_ID', nanoid())
|
|
|
|
|
const authToken = optionOrEnv(options?.auth?.token, 'AUTHENTICATION_TOKEN', '')
|
|
|
|
|
|
|
|
|
|
const appLogLevel = optionOrEnv(options?.logger?.app?.level, 'LOG_LEVEL', LogLevelString.Log, { validator: (value): value is LogLevelString => availableLogLevelStrings.includes(value as LogLevelString) })
|
|
|
|
@@ -144,7 +181,7 @@ export function setupApp(options?: {
|
|
|
|
|
}
|
|
|
|
|
catch (err) {
|
|
|
|
|
const errorMessage = err instanceof Error ? err.message : String(err)
|
|
|
|
|
send(peer, { type: 'error', data: { message: `invalid JSON, error: ${errorMessage}` }, source: WebSocketEventSource.Server })
|
|
|
|
|
send(peer, RESPONSES.error(`invalid JSON, error: ${errorMessage}`, instanceId))
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
@@ -171,15 +208,7 @@ export function setupApp(options?: {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.data.kind === MessageHeartbeatKind.Ping) {
|
|
|
|
|
send(peer, {
|
|
|
|
|
type: 'transport:connection:heartbeat',
|
|
|
|
|
data: {
|
|
|
|
|
kind: MessageHeartbeatKind.Pong,
|
|
|
|
|
message: heartbeatMessage,
|
|
|
|
|
at: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
source: WebSocketEventSource.Server,
|
|
|
|
|
})
|
|
|
|
|
send(peer, RESPONSES.heartbeat(MessageHeartbeatKind.Pong, heartbeatMessage, instanceId, event.metadata?.event.id))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
@@ -188,11 +217,7 @@ export function setupApp(options?: {
|
|
|
|
|
case 'module:authenticate': {
|
|
|
|
|
if (authToken && event.data.token !== authToken) {
|
|
|
|
|
logger.withFields({ peer: peer.id, peerRemote: peer.remoteAddress, peerRequest: peer.request.url }).log('authentication failed')
|
|
|
|
|
send(peer, {
|
|
|
|
|
type: 'error',
|
|
|
|
|
data: { message: 'invalid token' },
|
|
|
|
|
source: WebSocketEventSource.Server,
|
|
|
|
|
})
|
|
|
|
|
send(peer, RESPONSES.error('invalid token', instanceId, event.metadata?.event.id))
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
@@ -217,31 +242,19 @@ export function setupApp(options?: {
|
|
|
|
|
// verify
|
|
|
|
|
const { name, index, identity } = event.data as { name: string, index?: number, identity?: MetadataEventSource }
|
|
|
|
|
if (!name || typeof name !== 'string') {
|
|
|
|
|
send(peer, {
|
|
|
|
|
type: 'error',
|
|
|
|
|
data: { message: 'the field \'name\' must be a non-empty string for event \'module:announce\'' },
|
|
|
|
|
source: WebSocketEventSource.Server,
|
|
|
|
|
})
|
|
|
|
|
send(peer, RESPONSES.error('the field \'name\' must be a non-empty string for event \'module:announce\'', instanceId))
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (typeof index !== 'undefined') {
|
|
|
|
|
if (!Number.isInteger(index) || index < 0) {
|
|
|
|
|
send(peer, {
|
|
|
|
|
type: 'error',
|
|
|
|
|
data: { message: 'the field \'index\' must be a non-negative integer for event \'module:announce\'' },
|
|
|
|
|
source: WebSocketEventSource.Server,
|
|
|
|
|
})
|
|
|
|
|
send(peer, RESPONSES.error('the field \'index\' must be a non-negative integer for event \'module:announce\'', instanceId))
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (authToken && !p.authenticated) {
|
|
|
|
|
send(peer, {
|
|
|
|
|
type: 'error',
|
|
|
|
|
data: { message: 'must authenticate before announcing' },
|
|
|
|
|
source: WebSocketEventSource.Server,
|
|
|
|
|
})
|
|
|
|
|
send(peer, RESPONSES.error('must authenticate before announcing', instanceId))
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
@@ -261,21 +274,13 @@ export function setupApp(options?: {
|
|
|
|
|
const { moduleName, moduleIndex, config } = event.data
|
|
|
|
|
|
|
|
|
|
if (moduleName === '') {
|
|
|
|
|
send(peer, {
|
|
|
|
|
type: 'error',
|
|
|
|
|
data: { message: 'the field \'moduleName\' can\'t be empty for event \'ui:configure\'' },
|
|
|
|
|
source: WebSocketEventSource.Server,
|
|
|
|
|
})
|
|
|
|
|
send(peer, RESPONSES.error('the field \'moduleName\' can\'t be empty for event \'ui:configure\'', instanceId))
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (typeof moduleIndex !== 'undefined') {
|
|
|
|
|
if (!Number.isInteger(moduleIndex) || moduleIndex < 0) {
|
|
|
|
|
send(peer, {
|
|
|
|
|
type: 'error',
|
|
|
|
|
data: { message: 'the field \'moduleIndex\' must be a non-negative integer for event \'ui:configure\'' },
|
|
|
|
|
source: WebSocketEventSource.Server,
|
|
|
|
|
})
|
|
|
|
|
send(peer, RESPONSES.error('the field \'moduleIndex\' must be a non-negative integer for event \'ui:configure\'', instanceId))
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
@@ -286,16 +291,12 @@ export function setupApp(options?: {
|
|
|
|
|
send(target.peer, {
|
|
|
|
|
type: 'module:configure',
|
|
|
|
|
data: { config },
|
|
|
|
|
// NOTICE: here we will forward the source as-is
|
|
|
|
|
source: event.source,
|
|
|
|
|
// NOTICE: this will forward the original event metadata as-is
|
|
|
|
|
metadata: event.metadata,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
send(peer, {
|
|
|
|
|
type: 'error',
|
|
|
|
|
data: { message: 'module not found, it hasn\'t announced itself or the name is incorrect' },
|
|
|
|
|
source: WebSocketEventSource.Server,
|
|
|
|
|
})
|
|
|
|
|
send(peer, RESPONSES.error('module not found, it hasn\'t announced itself or the name is incorrect', instanceId))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|