feat(stage-tamagotchi,stage-ui,server-sdk,server-runtime): zero-trust websocket authentication (#1582)

This commit is contained in:
Richard Pinedo
2026-04-07 04:05:35 +08:00
committed by GitHub
parent 5452c72351
commit 99be852fe4
21 changed files with 630 additions and 122 deletions
@@ -71,8 +71,12 @@ async function main() {
exit(0)
}
process.on('SIGINT', () => { void close() })
process.on('SIGTERM', () => { void close() })
process.on('SIGINT', () => {
void close()
})
process.on('SIGTERM', () => {
void close()
})
}
if (import.meta.main) {
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest'
import { ensureServerChannelConfigDefaults } from './config'
describe('ensureServerChannelConfigDefaults', () => {
it('keeps an existing auth token', () => {
const generateToken = vi.fn(() => 'generated-token')
const result = ensureServerChannelConfigDefaults({
authToken: 'existing-token',
hostname: '0.0.0.0',
tlsConfig: null,
}, generateToken)
expect(result.changed).toBe(false)
expect(result.config).toEqual({
authToken: 'existing-token',
hostname: '0.0.0.0',
tlsConfig: null,
})
expect(generateToken).not.toHaveBeenCalled()
})
it('generates a token when the config is missing one', () => {
const generateToken = vi.fn(() => 'generated-token')
const result = ensureServerChannelConfigDefaults({
authToken: '',
hostname: '',
tlsConfig: null,
}, generateToken)
expect(result.changed).toBe(true)
expect(result.config).toEqual({
authToken: 'generated-token',
hostname: '127.0.0.1',
tlsConfig: null,
})
expect(generateToken).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,23 @@
import type { ElectronServerChannelConfig } from '../../../../shared/eventa'
export function ensureServerChannelConfigDefaults(
config: Partial<ElectronServerChannelConfig>,
generateToken: () => string,
) {
const nextConfig: ElectronServerChannelConfig = {
authToken: config.authToken?.trim() || generateToken(),
hostname: config.hostname?.trim() || '127.0.0.1',
tlsConfig: config.tlsConfig || null,
}
const previousConfig: ElectronServerChannelConfig = {
authToken: config.authToken?.trim() || '',
hostname: config.hostname?.trim() || '127.0.0.1',
tlsConfig: config.tlsConfig || null,
}
return {
changed: JSON.stringify(previousConfig) !== JSON.stringify(nextConfig),
config: nextConfig,
}
}
@@ -1,7 +1,9 @@
import type { Server, ServerOptions } from '@proj-airi/server-runtime/server'
import type { Lifecycle } from 'injeca'
import { X509Certificate } from 'node:crypto'
import type { ElectronServerChannelConfig } from '../../../../shared/eventa'
import { randomUUID, X509Certificate } from 'node:crypto'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { env, platform } from 'node:process'
@@ -23,8 +25,11 @@ import {
electronGetServerChannelConfig,
} from '../../../../shared/eventa'
import { createConfig } from '../../../libs/electron/persistence'
import { ensureServerChannelConfigDefaults } from './config'
const channelServerConfigSchema = object({
hostname: optional(string()),
authToken: optional(string()),
tlsConfig: optional(nullable(object({
cert: optional(string()),
key: optional(string()),
@@ -33,11 +38,15 @@ const channelServerConfigSchema = object({
})
const channelServerInvokeConfigSchema = z.object({
hostname: z.string().optional(),
authToken: z.string().optional(),
tlsConfig: z.object({ }).nullable().optional(),
}).strict()
const channelServerConfigStore = createConfig('server-channel', 'config.json', channelServerConfigSchema, {
default: {
hostname: '127.0.0.1',
authToken: '',
tlsConfig: null,
},
autoHeal: true,
@@ -62,25 +71,41 @@ interface ServerChannelCertificateVerifyRequest {
}
}
async function getChannelServerConfig(): Promise<ServerOptions> {
return channelServerConfigStore.get() || { tlsConfig: null }
function getServerChannelPort() {
return env.SERVER_CHANNEL_PORT ? Number.parseInt(env.SERVER_CHANNEL_PORT) : 6121
}
async function getChannelServerConfig(): Promise<ElectronServerChannelConfig> {
const config = channelServerConfigStore.get() || { hostname: '127.0.0.1', authToken: '', tlsConfig: null }
return {
hostname: config.hostname || '127.0.0.1',
authToken: config.authToken || '',
tlsConfig: config.tlsConfig || null,
}
}
function getServerRuntimeBaseOptions() {
return {
port: env.PORT ? Number.parseInt(env.PORT) : 6121,
hostname: env.SERVER_RUNTIME_HOSTNAME || '0.0.0.0',
port: getServerChannelPort(),
hostname: '127.0.0.1',
}
}
async function resolveServerRuntimeOptions(config: ServerOptions): Promise<ServerOptions> {
return {
...getServerRuntimeBaseOptions(),
auth: {
token: 'authToken' in config && typeof config.authToken === 'string' ? config.authToken : '',
},
hostname: 'hostname' in config && typeof config.hostname === 'string'
? config.hostname || '127.0.0.1'
: '127.0.0.1',
tlsConfig: config.tlsConfig ? await getOrCreateCertificate() : null,
}
}
async function normalizeChannelServerOptions(payload: unknown, fallback?: ServerOptions) {
async function normalizeChannelServerOptions(payload: unknown, fallback?: ElectronServerChannelConfig) {
if (!fallback) {
fallback = await getChannelServerConfig()
}
@@ -90,14 +115,18 @@ async function normalizeChannelServerOptions(payload: unknown, fallback?: Server
return fallback
}
return {
const normalizedConfig = {
hostname: parsed.data.hostname ?? fallback.hostname,
authToken: parsed.data.authToken ?? fallback.authToken,
tlsConfig: typeof parsed.data.tlsConfig === 'undefined' ? null : parsed.data.tlsConfig,
}
return ensureServerChannelConfigDefaults(normalizedConfig, randomUUID).config
}
function getCertificateDomains(): string[] {
const localIPs = getLocalIPs()
const hostname = env.SERVER_RUNTIME_HOSTNAME
const hostname = channelServerConfigStore.get()?.hostname || env.SERVER_RUNTIME_HOSTNAME
return Array.from(new Set([
'localhost',
'127.0.0.1',
@@ -283,11 +312,12 @@ export async function setupServerChannel(params: { lifecycle: Lifecycle }): Prom
configureServerChannelCertificateTrust()
const storedConfig = await getChannelServerConfig()
const { changed: storedConfigChanged, config: normalizedStoredConfig } = ensureServerChannelConfigDefaults(storedConfig, randomUUID)
if (storedConfigChanged) {
channelServerConfigStore.update(normalizedStoredConfig)
}
const serverChannel = createServer({
...storedConfig,
...(await resolveServerRuntimeOptions(storedConfig)),
})
const serverChannel = createServer(await resolveServerRuntimeOptions(normalizedStoredConfig))
const mutex = new Mutex()
@@ -386,26 +416,28 @@ export async function createServerChannelService(params: { serverChannel: Server
defineInvokeHandler(context, electronApplyServerChannelConfig, async (req) => {
const current = await getChannelServerConfig()
const next = await normalizeChannelServerOptions(req, current)
const changed = JSON.stringify(next.tlsConfig) !== JSON.stringify(current.tlsConfig)
const tlsChanged = JSON.stringify(next.tlsConfig) !== JSON.stringify(current.tlsConfig)
const hostnameChanged = next.hostname !== current.hostname
const authTokenChanged = next.authToken !== current.authToken
const runtimeChanged = tlsChanged || hostnameChanged || authTokenChanged
try {
if (changed) {
if (runtimeChanged) {
const nextRuntimeOptions = await resolveServerRuntimeOptions(next)
await params.serverChannel.updateConfig(nextRuntimeOptions)
await params.serverChannel.restart()
channelServerConfigStore.update(next)
return next
}
else {
await params.serverChannel.start()
}
await params.serverChannel.start()
channelServerConfigStore.update(next)
return next
}
catch (error) {
useLogg('main/server-runtime').withError(error).error('Failed to apply server channel configuration')
if (changed) {
if (runtimeChanged) {
const previousRuntimeOptions = await resolveServerRuntimeOptions(current)
try {
+7 -2
View File
@@ -131,9 +131,14 @@ onMounted(async () => {
await settingsAudioDeviceStore.initialize()
const serverChannelConfig = await getServerChannelConfig()
serverChannelSettingsStore.websocketTlsConfig = serverChannelConfig.tlsConfig
serverChannelSettingsStore.tlsConfig = serverChannelConfig.tlsConfig ?? null
serverChannelSettingsStore.hostname = serverChannelConfig.hostname
serverChannelSettingsStore.authToken = serverChannelConfig.authToken
await serverChannelStore.initialize({ possibleEvents: ['ui:configure'] }).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
await serverChannelStore.initialize({
token: serverChannelConfig.authToken || undefined,
possibleEvents: ['ui:configure'],
}).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
if (!isChatWindowRoute()) {
contextBridgeStore.initialize()
characterOrchestratorStore.initialize()
@@ -1,23 +1,53 @@
<script setup lang="ts">
import ConnectionSettings from '@proj-airi/stage-pages/pages/settings/connection/ConnectionSettings.vue'
import { Callout, FieldCheckbox } from '@proj-airi/ui'
import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { Callout, FieldCheckbox, FieldInput, SelectTab } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useServerChannelSettingsStore } from '../../../stores/settings/server-channel'
import {
hostnameFromExposureMode,
serverChannelExposureModeFromHostname,
} from '../../../stores/settings/server-channel-options'
const serverChannelSettingsStore = useServerChannelSettingsStore()
const { lastApplyError, websocketTlsConfig } = storeToRefs(serverChannelSettingsStore)
const { authToken, hostname, lastApplyError, tlsConfig } = storeToRefs(serverChannelSettingsStore)
const { t } = useI18n()
const websocketTlsEnabled = computed({
get: () => websocketTlsConfig.value != null,
get: () => tlsConfig.value != null,
set: (value: boolean) => {
serverChannelSettingsStore.websocketTlsConfig = value ? {} : null
serverChannelSettingsStore.tlsConfig = value ? {} : null
},
})
const exposureMode = computed({
get: () => serverChannelExposureModeFromHostname(hostname.value),
set: (mode) => {
hostname.value = hostnameFromExposureMode(mode, hostname.value)
},
})
const showAdvancedHostname = computed(() => exposureMode.value === 'advanced')
const showDesktopServerControls = computed(() => isStageTamagotchi())
const exposureModeOptions = computed(() => [
{
label: t('settings.pages.connection.server-hostname.options.this-device'),
value: 'this-device',
},
{
label: t('settings.pages.connection.server-hostname.options.all'),
value: 'all',
},
{
label: t('settings.pages.connection.server-hostname.options.advanced'),
value: 'advanced',
},
])
</script>
<template>
@@ -35,6 +65,55 @@ const websocketTlsEnabled = computed({
:description="t('settings.websocket-secure-enabled.description')"
/>
<div
v-if="showDesktopServerControls"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (6 * 10)"
:delay="6 * 50"
:class="['flex', 'flex-col', 'gap-2']"
>
<div :class="['text-sm', 'font-medium', 'text-neutral-900', 'dark:text-neutral-100']">
{{ t('settings.pages.connection.server-hostname.label') }}
</div>
<div :class="['text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
{{ t('settings.pages.connection.server-hostname.description') }}
</div>
<SelectTab
v-model="exposureMode"
size="sm"
:options="exposureModeOptions"
/>
</div>
<FieldInput
v-if="showDesktopServerControls && showAdvancedHostname"
v-model="hostname"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (7 * 10)"
:delay="7 * 50"
:label="t('settings.pages.connection.server-hostname.advanced-label')"
:description="t('settings.pages.connection.server-hostname.advanced-description')"
placeholder="192.168.1.25"
/>
<FieldInput
v-if="showDesktopServerControls"
v-model="authToken"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (8 * 10)"
:delay="8 * 50"
secure
:label="t('settings.pages.connection.server-auth-token.label')"
:description="t('settings.pages.connection.server-auth-token.description')"
:placeholder="t('settings.pages.connection.server-auth-token.placeholder')"
/>
<Callout
v-if="lastApplyError"
theme="orange"
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { hostnameFromExposureMode, serverChannelExposureModeFromHostname } from './server-channel-options'
describe('serverChannelExposureModeFromHostname', () => {
it('maps loopback hostnames to this-device mode', () => {
expect(serverChannelExposureModeFromHostname('127.0.0.1')).toBe('this-device')
expect(serverChannelExposureModeFromHostname('localhost')).toBe('this-device')
expect(serverChannelExposureModeFromHostname('::1')).toBe('this-device')
expect(serverChannelExposureModeFromHostname('')).toBe('this-device')
})
it('maps wildcard bind hostnames to all mode', () => {
expect(serverChannelExposureModeFromHostname('0.0.0.0')).toBe('all')
expect(serverChannelExposureModeFromHostname('::')).toBe('all')
})
it('maps custom hostnames to advanced mode', () => {
expect(serverChannelExposureModeFromHostname('192.168.1.25')).toBe('advanced')
expect(serverChannelExposureModeFromHostname('airi.local')).toBe('advanced')
})
})
describe('hostnameFromExposureMode', () => {
it('returns a secure loopback hostname for this-device mode', () => {
expect(hostnameFromExposureMode('this-device', '192.168.1.25')).toBe('127.0.0.1')
})
it('returns an all-interfaces hostname for all mode', () => {
expect(hostnameFromExposureMode('all', '127.0.0.1')).toBe('0.0.0.0')
})
it('uses the manual hostname for advanced mode and trims whitespace', () => {
expect(hostnameFromExposureMode('advanced', ' 192.168.1.25 ')).toBe('192.168.1.25')
})
it('falls back to loopback when advanced mode is empty', () => {
expect(hostnameFromExposureMode('advanced', ' ')).toBe('127.0.0.1')
})
})
@@ -0,0 +1,28 @@
export type ServerChannelExposureMode = 'this-device' | 'all' | 'advanced'
const LOOPBACK_HOSTNAMES = new Set(['', '127.0.0.1', 'localhost', '::1'])
const ALL_INTERFACE_HOSTNAMES = new Set(['0.0.0.0', '::'])
export function serverChannelExposureModeFromHostname(hostname?: string): ServerChannelExposureMode {
const normalizedHostname = hostname?.trim() ?? ''
if (LOOPBACK_HOSTNAMES.has(normalizedHostname))
return 'this-device'
if (ALL_INTERFACE_HOSTNAMES.has(normalizedHostname))
return 'all'
return 'advanced'
}
export function hostnameFromExposureMode(mode: ServerChannelExposureMode, manualHostname?: string) {
if (mode === 'all')
return '0.0.0.0'
if (mode === 'advanced') {
const normalizedHostname = manualHostname?.trim() ?? ''
return normalizedHostname || '127.0.0.1'
}
return '127.0.0.1'
}
@@ -0,0 +1,84 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
const invokeMocks = vi.hoisted(() => {
const getConfig = vi.fn(async () => ({
authToken: 'existing-token',
hostname: '127.0.0.1',
tlsConfig: null,
}))
const applyConfig = vi.fn(async (config: unknown) => config)
return {
applyConfig,
getConfig,
}
})
vi.mock('@proj-airi/electron-vueuse', () => ({
useElectronEventaInvoke: (event: { receiveEvent?: { id?: string } }) => {
if (event?.receiveEvent?.id === 'eventa:invoke:electron:server-channel:get-config-receive')
return invokeMocks.getConfig
if (event?.receiveEvent?.id === 'eventa:invoke:electron:server-channel:apply-config-receive')
return invokeMocks.applyConfig
throw new Error(`Unexpected eventa invoke: ${JSON.stringify(event)}`)
},
}))
vi.mock('@vueuse/core', () => ({
useLocalStorage: <T>(key: string, initialValue: T) => {
if (key === 'settings/server-channel/hostname')
return ref('127.0.0.1')
if (key === 'settings/server-channel/auth-token')
return ref('existing-token')
if (key === 'settings/server-channel/websocket-tls-config')
return ref(null)
return ref(initialValue)
},
}))
const toastError = vi.fn()
vi.mock('vue-sonner', () => ({
toast: {
error: toastError,
},
}))
describe('useServerChannelSettingsStore', async () => {
const { useServerChannelSettingsStore } = await import('./server-channel')
beforeEach(() => {
setActivePinia(createPinia())
invokeMocks.getConfig.mockClear()
invokeMocks.applyConfig.mockClear()
toastError.mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('rolls back optimistic values when applying server channel config fails', async () => {
invokeMocks.applyConfig.mockRejectedValueOnce(new Error('apply failed'))
const store = useServerChannelSettingsStore()
await Promise.resolve()
store.hostname = '0.0.0.0'
store.authToken = 'next-token'
store.tlsConfig = {}
await nextTick()
await vi.waitFor(() => {
expect(store.hostname).toBe('127.0.0.1')
expect(store.authToken).toBe('existing-token')
expect(store.tlsConfig).toBeNull()
expect(store.lastApplyError).toBe('apply failed')
expect(toastError).toHaveBeenCalledWith('apply failed')
})
})
})
@@ -1,3 +1,5 @@
import type { ElectronServerChannelConfig } from '../../../shared/eventa'
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useLocalStorage } from '@vueuse/core'
@@ -5,43 +7,65 @@ import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { electronApplyServerChannelConfig, electronGetServerChannelConfig } from '../../../shared/eventa'
import {
electronApplyServerChannelConfig,
electronGetServerChannelConfig,
} from '../../../shared/eventa'
export const useServerChannelSettingsStore = defineStore('tamagotchi-server-channel-settings', () => {
const websocketTlsConfig = useLocalStorage<{ cert?: string, key?: string, passphrase?: string } | null | undefined>('settings/server-channel/websocket-tls-config', null)
const tlsConfig = useLocalStorage<{ cert?: string, key?: string, passphrase?: string } | null | undefined>('settings/server-channel/websocket-tls-config', null)
const hostname = useLocalStorage<string>('settings/server-channel/hostname', '127.0.0.1')
const authToken = useLocalStorage<string>('settings/server-channel/auth-token', '')
const lastApplyError = ref<string | null>(null)
const syncingWithServer = ref(false)
const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig)
const applyServerChannelConfig = useElectronEventaInvoke(electronApplyServerChannelConfig)
function syncTlsConfigFromServer(value: { cert?: string, key?: string, passphrase?: string } | null | undefined) {
function syncConfigFromServer(config: ElectronServerChannelConfig) {
syncingWithServer.value = true
websocketTlsConfig.value = value ?? null
tlsConfig.value = config.tlsConfig ?? null
if (config.hostname !== undefined) {
hostname.value = config.hostname
}
if (config.authToken !== undefined) {
authToken.value = config.authToken
}
syncingWithServer.value = false
}
async function refreshServerChannelConfig() {
const config = await getServerChannelConfig()
syncTlsConfigFromServer(config.tlsConfig)
syncConfigFromServer(config)
return config
}
watch(websocketTlsConfig, async (newValue, oldValue) => {
if (syncingWithServer.value || (newValue != null) === (oldValue != null)) {
watch([tlsConfig, hostname, authToken], async ([newTls, newHost, newAuth], [oldTls, oldHost, oldAuth]) => {
if (syncingWithServer.value || (JSON.stringify(newTls) === JSON.stringify(oldTls) && newHost === oldHost && newAuth === oldAuth)) {
return
}
lastApplyError.value = null
try {
const config = await applyServerChannelConfig({ tlsConfig: newValue ? {} : null })
syncTlsConfigFromServer(config.tlsConfig)
const config = await applyServerChannelConfig({
tlsConfig: newTls ? {} : null,
hostname: newHost,
authToken: newAuth,
})
syncConfigFromServer(config)
}
catch (error) {
const message = errorMessageFrom(error) ?? 'Failed to apply WebSocket security setting'
lastApplyError.value = message
syncTlsConfigFromServer(oldValue)
syncingWithServer.value = true
tlsConfig.value = oldTls
hostname.value = oldHost
authToken.value = oldAuth
syncingWithServer.value = false
toast.error(message)
}
})
@@ -51,6 +75,8 @@ export const useServerChannelSettingsStore = defineStore('tamagotchi-server-chan
return {
lastApplyError,
refreshServerChannelConfig,
websocketTlsConfig,
tlsConfig,
hostname,
authToken,
}
})
@@ -25,6 +25,8 @@ export const electronOpenDevtoolsWindow = defineInvokeEventa<void, { route?: str
export interface ElectronServerChannelConfig {
tlsConfig?: ServerOptions['tlsConfig'] | null
authToken: string
hostname: string
}
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')
@@ -944,6 +944,19 @@ pages:
description: Full address of the WebSocket server (e.g., ws://localhost:6121/ws)
label: WebSocket Server Address
placeholder: ws://localhost:6121/ws
server-hostname:
label: Expose On Network
description: Choose whether the AIRI gateway only listens on this device or can be reached from your local network.
advanced-label: Bind Hostname
advanced-description: Use a specific hostname or IP address when you need manual network binding.
options:
this-device: This device
all: All
advanced: Advanced
server-auth-token:
label: Auth Token
description: Security token required by remote clients that connect to this gateway. When left empty, AIRI generates a new token automatically.
placeholder: Auto-generate
scene:
description: Configure the environment where the character lives
title: Scene
@@ -909,6 +909,19 @@ pages:
description: WebSocket 服务器的完整地址(例如,ws://localhost:6121/ws)
label: WebSocket 服务器地址
placeholder: ws://localhost:6121/ws
server-hostname:
label: 网络暴露范围
description: 选择 AIRI 网关只监听当前设备,还是允许局域网中的其他设备连接。
advanced-label: 绑定主机名
advanced-description: 需要手动绑定网络地址时,填写指定的主机名或 IP 地址。
options:
this-device: 当前设备
all: 全部
advanced: 高级
server-auth-token:
label: Auth Token
description: 连接到此网关的远程客户端需要提供此安全令牌。留空时 AIRI 会自动生成新的令牌。
placeholder: 自动生成
scene:
description: 配置角色所在环境
title: 场景
@@ -64,6 +64,9 @@ function isTimeoutLikeError(error: unknown): boolean {
export default defineScenario({
id: 'demo-controls-settings-chat-websocket',
async run({ capture, controlsIsland, settingsWindow, stageWindows }) {
const mainWindow = await stageWindows.waitFor('main')
await controlsIsland.waitForReady(mainWindow.page)
async function ensureControlsIslandExpanded() {
const chatButton = mainWindow.page
.locator('button')
@@ -79,33 +82,6 @@ export default defineScenario({
}
}
async function captureSettingsRoute(name: string, routePath: string, readyPattern: RegExp, waitMs = 250) {
await settingsWindow.goToRoute(settingsWindowSnapshot.page, routePath)
try {
await settingsWindowSnapshot.page.getByText(readyPattern).first().waitFor({ state: 'visible', timeout: 15_000 })
}
catch (error) {
if (!isTimeoutLikeError(error)) {
throw error
}
const currentHashPath = normalizeHashPath(new URL(settingsWindowSnapshot.page.url()).hash)
if (currentHashPath !== routePath) {
throw error
}
// NOTICE: Some settings/devtools pages animate in or hydrate content asynchronously.
// Give known-slow pages one final bounded grace period, but still fail if the target route never becomes ready.
await sleep(1250)
await settingsWindowSnapshot.page.getByText(readyPattern).first().waitFor({ state: 'visible', timeout: 5_000 })
}
await sleep(waitMs)
await capture(name, settingsWindowSnapshot.page)
}
const mainWindow = await stageWindows.waitFor('main')
await controlsIsland.waitForReady(mainWindow.page)
await capture('00-stage-tamagotchi', mainWindow.page)
await sleep(500)
@@ -130,6 +106,30 @@ export default defineScenario({
await sleep(1000)
await capture('02-settings-window', settingsWindowSnapshot.page)
async function captureSettingsRoute(name: string, routePath: string, readyPattern: RegExp, waitMs = 250) {
await settingsWindow.goToRoute(settingsWindowSnapshot.page, routePath)
try {
await settingsWindowSnapshot.page.getByText(readyPattern).first().waitFor({ state: 'visible', timeout: 15_000 })
}
catch (error) {
if (!isTimeoutLikeError(error)) {
throw error
}
const currentHashPath = normalizeHashPath(new URL(settingsWindowSnapshot.page.url()).hash)
if (currentHashPath !== routePath) {
throw error
}
// NOTICE: Some settings/devtools pages animate in or hydrate content asynchronously.
// Give known-slow pages one final bounded grace period, but still fail if the target route never becomes ready.
await sleep(1250)
await settingsWindowSnapshot.page.getByText(readyPattern).first().waitFor({ state: 'visible', timeout: 5_000 })
}
await sleep(waitMs)
await capture(name, settingsWindowSnapshot.page)
}
await settingsWindow.goToRoute(settingsWindowSnapshot.page, '/settings/airi-card')
await settingsWindowSnapshot.page.getByText(airiCardPattern).first().waitFor({ state: 'visible' })
await sleep(1000)
@@ -15,7 +15,12 @@ function createPeer(options: {
labels?: Record<string, string>
}): AuthenticatedPeer {
return {
peer: { id: options.id, send: () => 0 },
peer: {
id: options.id,
send: () => 0,
request: { url: 'http://localhost', headers: new Headers() },
remoteAddress: '127.0.0.1',
},
authenticated: true,
name: options.name,
identity: options.plugin && options.instanceId
@@ -92,6 +97,21 @@ describe('route middleware', () => {
expect(collectDestinations(event)).toEqual(['label:env=prod'])
})
it('respects explicit empty destinations as an override', () => {
const event = createSparkNotifyEvent({
data: {
id: 'evt-3',
eventId: 'spark-3',
kind: 'ping',
urgency: 'soon',
headline: 'hello',
destinations: ['module:character'],
},
route: { destinations: [] },
})
expect(collectDestinations(event)).toEqual([])
})
it('treats an explicit empty route destination list as the override', () => {
const event = createSparkNotifyEvent({
@@ -42,7 +42,7 @@ function getPeerLabels(peer: AuthenticatedPeer) {
}
}
export function matchesRouteExpression(expression: RouteTargetExpression, peer: AuthenticatedPeer) {
export function matchesRouteExpression(expression: RouteTargetExpression, peer: AuthenticatedPeer): boolean {
switch (expression.type) {
case 'and':
return expression.all.every(expr => matchesRouteExpression(expr, peer))
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const serveMocks = vi.hoisted(() => {
let resolveServe: (() => void) | null = null
let rejectServe: ((error: Error) => void) | null = null
const serveCall = vi.fn(() => new Promise<void>((resolve, reject) => {
resolveServe = resolve
rejectServe = reject
}))
const closeCall = vi.fn(async () => {})
const setupAppCall = vi.fn(() => ({
app: {
fetch: vi.fn(async () => ({ crossws: {} })),
},
closeAllPeers: vi.fn(),
}))
return {
closeCall,
rejectServe: (error: Error) => rejectServe?.(error),
resolveServe: () => resolveServe?.(),
serveCall,
setupAppCall,
}
})
vi.mock('h3', () => ({
H3: class {
get = vi.fn()
},
defineWebSocketHandler: vi.fn(handler => handler),
serve: vi.fn(() => ({
serve: serveMocks.serveCall,
close: serveMocks.closeCall,
})),
}))
vi.mock('crossws/server', () => ({
plugin: vi.fn(() => ({})),
}))
vi.mock('..', () => ({
normalizeLoggerConfig: () => ({
appLogFormat: 'pretty',
appLogLevel: 'log',
}),
setupApp: serveMocks.setupAppCall,
}))
describe('createServer', async () => {
const { createServer } = await import('./server')
beforeEach(() => {
vi.clearAllMocks()
})
it('deduplicates concurrent start calls while a start is already in progress', async () => {
const server = createServer({ hostname: '127.0.0.1', port: 6121 })
const firstStart = server.start()
const secondStart = server.start()
expect(serveMocks.serveCall).toHaveBeenCalledTimes(1)
serveMocks.resolveServe()
await Promise.all([firstStart, secondStart])
expect(serveMocks.serveCall).toHaveBeenCalledTimes(1)
})
it('clears the single-flight state when start fails', async () => {
const server = createServer({ hostname: '127.0.0.1', port: 6121 })
const firstStart = server.start()
serveMocks.rejectServe(new Error('bind failed'))
await expect(firstStart).rejects.toThrow('bind failed')
const retryStart = server.start()
expect(serveMocks.serveCall).toHaveBeenCalledTimes(2)
serveMocks.resolveServe()
await retryStart
})
})
+58 -49
View File
@@ -71,11 +71,12 @@ export function getLocalIPs(): string[] {
}
export function createServer(opts?: ServerOptions): Server {
let options = merge<ServerOptions>({ port: 6121, hostname: '0.0.0.0' }, opts)
let options = merge<ServerOptions>({ port: 6121, hostname: '127.0.0.1' }, opts)
const { appLogFormat, appLogLevel } = normalizeLoggerConfig(options)
const log = useLogg('@proj-airi/server-runtime/server').withLogLevelString(appLogLevel).withFormat(appLogFormat)
let serverInstance: ServerInstance | null = null
let startTask: Promise<void> | null = null
log.withFields({ hasTlsConfig: !!options?.tlsConfig }).log('creating server channel')
@@ -110,60 +111,68 @@ export function createServer(opts?: ServerOptions): Server {
if (serverInstance) {
return
}
if (startTask) {
return startTask
}
const secureEnabled = options?.tlsConfig != null
const h3App = setupApp()
startTask = (async () => {
const secureEnabled = options?.tlsConfig != null
const h3App = setupApp(options)
const port = options.port
const hostname = options.hostname
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,
},
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 {
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')
},
}
await instance.serve()
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
}
})().finally(() => {
startTask = null
})
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
}
return startTask
}
async function stop() {
await closeServer(true)
}
@@ -13,6 +13,11 @@ export interface Peer {
* WebSocket lifecycle state (mirrors WebSocket.readyState)
*/
readyState?: number
request?: {
url?: string
headers?: Headers
}
remoteAddress?: string
}
export interface NamedPeer {
+3
View File
@@ -2642,6 +2642,9 @@ importers:
'@proj-airi/plugin-protocol':
specifier: workspace:*
version: link:../plugin-protocol
crossws:
specifier: ^0.4.4
version: 0.4.4(patch_hash=4d79ec736d10d2a81a9e2a31b067d43f0b6665122267981e652ab9923d165958)(srvx@0.11.13(patch_hash=c761d25a70e22a0925c88fe91a9dd1c3153dd341d8fd4f260166678156c4df2d))
packages/stage-layouts:
dependencies:
@@ -226,12 +226,6 @@ export class RuleEngine {
if (this.detectorDecisions.length > MAX_DETECTOR_DECISIONS) {
this.detectorDecisions.splice(0, this.detectorDecisions.length - MAX_DETECTOR_DECISIONS)
}
// NOTICE: matched_not_fired is expected on high-frequency streams and would
// dominate logs; keep it in snapshots/devtools and reserve default logs for fired.
if (snapshot.decision === 'matched_not_fired') {
}
}
/**