fix(stage-*): not respond if no websocket works

This commit is contained in:
Neko Ayaka
2025-10-01 22:18:08 +08:00
parent b6497dcc33
commit 51c38c8548
14 changed files with 187 additions and 220 deletions
@@ -1,6 +1,12 @@
import { initializeApp } from '@proj-airi/stage-ui/services'
import type { Plugin } from 'vue'
import Tres from '@tresjs/core'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
@@ -32,11 +38,12 @@ const router = createRouter({
routes: setupLayouts(routes),
})
// Initialize and mount the app using the shared initialization logic
initializeApp(App, {
router,
pinia,
i18n,
}).then((app) => {
app.mount('#app')
})
createApp(App)
.use(MotionPlugin)
// TODO: Fix autoAnimatePlugin type error
.use(autoAnimatePlugin as unknown as Plugin)
.use(router)
.use(pinia)
.use(i18n)
.use(Tres)
.mount('#app')
+16 -9
View File
@@ -1,6 +1,12 @@
import { initializeApp } from '@proj-airi/stage-ui/services'
import type { Plugin } from 'vue'
import Tres from '@tresjs/core'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
@@ -32,11 +38,12 @@ const router = createRouter({
routes: setupLayouts(routes),
})
// Initialize and mount the app using the shared initialization logic
initializeApp(App, {
router,
pinia,
i18n,
}).then((app) => {
app.mount('#app')
})
createApp(App)
.use(MotionPlugin)
// TODO: Fix autoAnimatePlugin type error
.use(autoAnimatePlugin as unknown as Plugin)
.use(router)
.use(pinia)
.use(i18n)
.use(Tres)
.mount('#app')
+7 -1
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { OnboardingDialog, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useConfiguratorForAiriSdk } from '@proj-airi/stage-ui/stores/configurator'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { StageTransitionGroup } from '@proj-airi/ui-transitions'
import { useDark } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router'
import { toast, Toaster } from 'vue-sonner'
@@ -23,6 +24,7 @@ const settings = storeToRefs(settingsStore)
const onboardingStore = useOnboardingStore()
const { shouldShowSetup } = storeToRefs(onboardingStore)
const isDark = useDark()
const { dispose } = useConfiguratorForAiriSdk()
const primaryColor = computed(() => {
return isDark.value
@@ -66,6 +68,10 @@ onMounted(async () => {
await settingsStore.initializeStageModel()
})
onUnmounted(() => {
dispose()
})
// Handle first-time setup events
function handleSetupConfigured() {
onboardingStore.markSetupCompleted()
+14 -29
View File
@@ -1,11 +1,14 @@
import type { App as VueApp } from 'vue'
import type { Plugin } from 'vue'
import type { Router } from 'vue-router'
import Tres from '@tresjs/core'
import NProgress from 'nprogress'
import { initializeApp } from '@proj-airi/stage-ui/services'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
import { createApp } from 'vue'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
@@ -37,30 +40,12 @@ router.afterEach(() => {
NProgress.done()
})
// Custom initialization callback for web-specific post-initialization
async function onInitialized(_app: VueApp, router: Router) {
// Handle PWA registration after router is ready
router.isReady()
.then(async () => {
if (import.meta.env.SSR) {
return
}
if (import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE) {
return
}
const { registerSW } = await import('./modules/pwa')
registerSW({ immediate: true })
})
.catch(error => console.error('Failed during post-initialization:', error))
}
// Initialize and mount the app using the shared initialization logic with web-specific options
initializeApp(App, {
router,
pinia,
i18n,
onInitialized,
}).then((app) => {
app.mount('#app')
})
createApp(App)
.use(MotionPlugin)
// TODO: Fix autoAnimatePlugin type error
.use(autoAnimatePlugin as unknown as Plugin)
.use(router)
.use(pinia)
.use(i18n)
.use(Tres)
.mount('#app')
+1
View File
@@ -49,6 +49,7 @@ export default defineConfig({
resolve: {
alias: {
'@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
},
+25 -4
View File
@@ -4,6 +4,13 @@ import WebSocket from 'crossws/websocket'
import { sleep } from '@moeru/std'
class ReconnectingError extends Error {
constructor(message: string) {
super(message)
this.name = 'ReconnectingError'
}
}
export interface ClientOptions<C = undefined> {
url?: string
name: string
@@ -18,6 +25,7 @@ export interface ClientOptions<C = undefined> {
export class Client<C = undefined> {
private connected = false
private connecting = false
private websocket?: WebSocket
private shouldClose = false
@@ -70,6 +78,10 @@ export class Client<C = undefined> {
return
}
catch (err) {
if (err instanceof ReconnectingError) {
return
}
this.opts.onError?.(err)
const delay = Math.min(2 ** attempts * 1000, 30_000) // capped exponential backoff
await sleep(delay)
@@ -82,6 +94,7 @@ export class Client<C = undefined> {
if (this.shouldClose) {
return
}
await this.retryWithExponentialBackoff(() => this._connect())
}
@@ -89,18 +102,27 @@ export class Client<C = undefined> {
if (this.shouldClose || this.connected) {
return Promise.resolve()
}
if (this.connecting) {
return Promise.reject(new ReconnectingError('Already connecting'))
}
return new Promise((resolve, reject) => {
this.connecting = true
const ws = new WebSocket(this.opts.url)
this.websocket = ws
ws.onmessage = this.handleMessageBound
ws.onerror = (event: any) => {
this.connecting = false
this.connected = false
this.opts.onError?.(event)
reject(event?.error ?? new Error('WebSocket error'))
}
ws.onclose = () => {
this.connecting = false
if (this.connected) {
this.connected = false
this.opts.onClose?.()
@@ -109,11 +131,10 @@ export class Client<C = undefined> {
void this.tryReconnectWithExponentialBackoff()
}
}
ws.onmessage = this.handleMessageBound
ws.onopen = () => {
this.connecting = false
this.connected = true
this.opts.token ? this.tryAuthenticate() : this.tryAnnounce()
resolve()
}
-2
View File
@@ -26,8 +26,6 @@
"./constants": "./src/constants/index.ts",
"./libs/*": "./src/libs/*.ts",
"./libs": "./src/libs/index.ts",
"./services/*": "./src/services/*.ts",
"./services": "./src/services/index.ts",
"./stores/*": "./src/stores/*.ts",
"./stores": "./src/stores/index.ts",
"./utils": "./src/utils/index.ts"
@@ -1,66 +0,0 @@
import type { Pinia } from 'pinia'
import type { App, Component, Plugin } from 'vue'
import type { I18n } from 'vue-i18n'
import type { Router } from 'vue-router'
import Tres from '@tresjs/core'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { MotionPlugin } from '@vueuse/motion'
import { createApp } from 'vue'
import { settingsBroadcaster } from './settings-broadcaster'
interface AppInitializationOptions {
/** Router for the app - must be provided */
router: Router
/** Pinia instance for state management */
pinia: Pinia
/** I18n instance for internationalization - using any to maintain compatibility with various locale configurations */
i18n: I18n<any, any, any, any, any>
/** Optional callback to run before mounting the app */
beforeMountCallback?: () => void | Promise<void>
/** Callback to run after the app is initialized but before mounting */
onInitialized?: (app: App, router: Router) => void | Promise<void>
}
/**
* Initializes a Vue application with common plugins and services.
* This function creates a Vue app instance with all the standard plugins
* and services used across AIRI applications.
*
* Note: The router, pinia, and i18n instances must be provided by the caller
* as they are application-specific and cannot be created in the shared module.
*/
export async function initializeApp(
AppComponent: Component,
options: AppInitializationOptions,
): Promise<App> {
const app = createApp(AppComponent)
.use(MotionPlugin)
.use(autoAnimatePlugin as unknown as Plugin) // TODO: Fix autoAnimatePlugin type error
.use(options.router)
.use(options.pinia)
.use(options.i18n)
.use(Tres)
// Run any custom initialization logic
if (options.onInitialized) {
await options.onInitialized(app, options.router)
}
// Initialize settings broadcaster connection
try {
await settingsBroadcaster.connect()
}
catch (error) {
console.error('Failed to connect settings broadcaster:', error)
}
// Run any custom before-mount logic
if (options.beforeMountCallback) {
await options.beforeMountCallback()
}
return app
}
-2
View File
@@ -1,2 +0,0 @@
export { initializeApp } from './app-initializer'
export { settingsBroadcaster } from './settings-broadcaster'
@@ -1,92 +0,0 @@
import { Client } from '@proj-airi/server-sdk'
class SettingsBroadcaster {
private client: Client | null = null
private connected = false
private pendingConfigurations: Array<{ moduleName: string, config: Record<string, unknown> }> = []
constructor() {
this.initClient()
}
private initClient() {
try {
this.client = new Client({
name: 'settings-broadcaster',
url: import.meta.env.VITE_AIRI_WS_URL || 'ws://localhost:6121/ws',
possibleEvents: [
'ui:configure',
'module:authenticated',
],
})
this.client.onEvent('module:authenticated', (event) => {
if (event.data.authenticated) {
this.connected = true
// Send any pending configurations
this.sendPendingConfigurations()
}
})
}
catch (error) {
// In a real implementation, you might want to use a proper error handling mechanism
// For now, we'll just log the error to comply with linting rules
console.error('Failed to initialize SettingsBroadcaster client:', error)
}
}
private sendPendingConfigurations() {
if (!this.client) {
console.warn('SettingsBroadcaster client is not initialized. Pending configurations were not sent.')
return
}
for (const { moduleName, config } of this.pendingConfigurations) {
this.client.send({
type: 'ui:configure' as const,
data: {
moduleName,
config,
},
})
}
this.pendingConfigurations = []
}
public sendConfiguration(moduleName: string, config: Record<string, unknown>): void {
if (!this.client) {
console.warn('SettingsBroadcaster client is not initialized. Configuration was not sent.', { moduleName, config })
return
}
const configData = {
type: 'ui:configure' as const,
data: {
moduleName,
config,
},
}
if (this.connected) {
this.client.send(configData)
}
else {
// Queue the configuration to send when connected
this.pendingConfigurations.push({ moduleName, config })
}
}
public async connect(): Promise<void> {
if (this.client) {
await this.client.connect()
}
}
public disconnect(): void {
if (this.client) {
this.client.close()
}
}
}
// Create a singleton instance
export const settingsBroadcaster = new SettingsBroadcaster()
@@ -0,0 +1,97 @@
import { Client } from '@proj-airi/server-sdk'
import { defineStore } from 'pinia'
import { onMounted, ref } from 'vue'
export const useConfiguratorForAiriSdk = defineStore('configurator:adapter:proj-airi:server-sdk', () => {
const connected = ref(false)
const client = ref<Client>()
const pendingUpdates = ref<Array<{
moduleName: string
config: Record<string, unknown>
}>>([])
function flushPending() {
if (client.value && connected.value) {
for (const update of pendingUpdates.value) {
client.value.send({
type: 'ui:configure' as const,
data: {
moduleName: update.moduleName,
config: update.config,
},
})
}
pendingUpdates.value = []
}
}
function updateFor(moduleName: string, config: Record<string, unknown>) {
if (client.value && connected.value) {
client.value.send({
type: 'ui:configure' as const,
data: {
moduleName,
config,
},
})
return
}
pendingUpdates.value.push({ moduleName, config })
}
function init(options?: { token?: string }) {
return new Promise((resolve, reject) => {
client.value = new Client({
name: 'proj-airi:ui:stage',
url: import.meta.env.VITE_AIRI_WS_URL || 'ws://localhost:6121/ws',
token: options?.token,
possibleEvents: [
'ui:configure',
'module:authenticated',
],
onError: (error) => {
reject(error)
},
})
client.value.onEvent('module:authenticated', (event) => {
if (event.data.authenticated) {
connected.value = true
flushPending()
resolve(true)
}
})
})
}
function clearAllPending() {
pendingUpdates.value = []
}
function dispose() {
flushPending()
client.value?.close()
connected.value = false
client.value = undefined
}
onMounted(() => {
init()
})
return {
connected,
client,
flushPending,
clearAllPending,
updateFor,
dispose,
init,
}
})
@@ -2,16 +2,17 @@ import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { settingsBroadcaster } from '../../services/settings-broadcaster'
import { useConfiguratorForAiriSdk } from '../configurator'
export const useDiscordStore = defineStore('discord', () => {
const configurator = useConfiguratorForAiriSdk()
const enabled = useLocalStorage('settings/discord/enabled', false)
const token = useLocalStorage('settings/discord/token', '')
function saveSettings() {
// Data is automatically saved to localStorage via useLocalStorage
// Also broadcast configuration to backend
settingsBroadcaster.sendConfiguration('discord', {
configurator.updateFor('discord', {
token: token.value,
enabled: enabled.value,
})
@@ -2,17 +2,19 @@ import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { settingsBroadcaster } from '../../services/settings-broadcaster'
import { useConfiguratorForAiriSdk } from '../configurator'
export function createGamingModuleStore(moduleName: string, defaultPort: number) {
return defineStore(moduleName, () => {
const configurator = useConfiguratorForAiriSdk()
const enabled = useLocalStorage(`settings/${moduleName}/enabled`, false)
const serverAddress = useLocalStorage(`settings/${moduleName}/server-address`, '')
const serverPort = useLocalStorage<number | null>(`settings/${moduleName}/server-port`, defaultPort)
const username = useLocalStorage(`settings/${moduleName}/username`, '')
function saveSettings() {
settingsBroadcaster.sendConfiguration(moduleName, {
configurator.updateFor(moduleName, {
enabled: enabled.value,
serverAddress: serverAddress.value,
serverPort: serverPort.value,
@@ -2,9 +2,11 @@ import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { settingsBroadcaster } from '../../services/settings-broadcaster'
import { useConfiguratorForAiriSdk } from '../configurator'
export const useTwitterStore = defineStore('twitter', () => {
const configurator = useConfiguratorForAiriSdk()
const enabled = useLocalStorage('settings/twitter/enabled', false)
const apiKey = useLocalStorage('settings/twitter/api-key', '')
const apiSecret = useLocalStorage('settings/twitter/api-secret', '')
@@ -14,7 +16,7 @@ export const useTwitterStore = defineStore('twitter', () => {
function saveSettings() {
// Data is automatically saved to localStorage via useLocalStorage
// Also broadcast configuration to backend
settingsBroadcaster.sendConfiguration('twitter', {
configurator.updateFor('twitter', {
enabled: enabled.value,
apiKey: apiKey.value,
apiSecret: apiSecret.value,