From 955d1a1d5d62f6ec34cbd08f16de896585bf238e Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Fri, 20 Mar 2026 21:19:01 +0800 Subject: [PATCH] refactor(server-*): refactor client, better state managment --- packages/server-runtime/src/index.ts | 26 +- packages/server-runtime/tsconfig.json | 9 + packages/server-sdk/README.md | 25 +- packages/server-sdk/package.json | 4 +- packages/server-sdk/src/client.ts | 884 +++++++++++++++++------- packages/server-sdk/test/client.test.ts | 256 +++++++ packages/server-sdk/vitest.config.ts | 9 + packages/server-shared/package.json | 8 +- packages/server-shared/src/errors.ts | 167 +++++ packages/server-shared/src/index.ts | 3 +- packages/server-shared/tsdown.config.ts | 1 + pnpm-lock.yaml | 42 +- vitest.config.ts | 9 +- 13 files changed, 1129 insertions(+), 314 deletions(-) create mode 100644 packages/server-sdk/test/client.test.ts create mode 100644 packages/server-sdk/vitest.config.ts create mode 100644 packages/server-shared/src/errors.ts diff --git a/packages/server-runtime/src/index.ts b/packages/server-runtime/src/index.ts index bc067a846..c16d9a294 100644 --- a/packages/server-runtime/src/index.ts +++ b/packages/server-runtime/src/index.ts @@ -9,6 +9,10 @@ import type { import type { AuthenticatedPeer, Peer } from './types' import { availableLogLevelStrings, Format, LogLevelString, logLevelStringToLogLevelMap, useLogg } from '@guiiai/logg' +import { + createInvalidJsonServerErrorMessage, + ServerErrorMessages, +} from '@proj-airi/server-shared' import { MessageHeartbeat, MessageHeartbeatKind, WebSocketEventSource } from '@proj-airi/server-shared/types' import { defineWebSocketHandler, H3 } from 'h3' import { nanoid } from 'nanoid' @@ -50,7 +54,7 @@ const RESPONSES = { }), notAuthenticated: (serverInstanceId: string, parentId?: string) => ({ type: 'error', - data: { message: 'not authenticated' }, + data: { message: ServerErrorMessages.notAuthenticated }, metadata: createServerEventMetadata(serverInstanceId, parentId), }), error: (message: string, serverInstanceId: string, parentId?: string) => ({ @@ -281,7 +285,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => : JSON.parse(text) if (!potentialEvent || typeof potentialEvent !== 'object' || !('type' in potentialEvent)) { - send(peer, RESPONSES.error('invalid event format', instanceId)) + send(peer, RESPONSES.error(ServerErrorMessages.invalidEventFormat, instanceId)) return } @@ -289,7 +293,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err) - send(peer, RESPONSES.error(`invalid JSON, error: ${errorMessage}`, instanceId)) + send(peer, RESPONSES.error(createInvalidJsonServerErrorMessage(errorMessage), instanceId)) return } @@ -337,7 +341,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => 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, RESPONSES.error('invalid token', instanceId, event.metadata?.event.id)) + send(peer, RESPONSES.error(ServerErrorMessages.invalidToken, instanceId, event.metadata?.event.id)) return } @@ -364,24 +368,24 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => // verify const { name, index, identity } = event.data as { name: string, index?: number, identity?: MetadataEventSource } if (!name || typeof name !== 'string') { - send(peer, RESPONSES.error('the field \'name\' must be a non-empty string for event \'module:announce\'', instanceId)) + send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceNameInvalid, instanceId)) return } if (typeof index !== 'undefined') { if (!Number.isInteger(index) || index < 0) { - send(peer, RESPONSES.error('the field \'index\' must be a non-negative integer for event \'module:announce\'', instanceId)) + send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIndexInvalid, instanceId)) return } } if (!identity || identity.kind !== 'plugin' || !identity.plugin?.id) { - send(peer, RESPONSES.error('module identity must include kind=plugin and a plugin id for event \'module:announce\'', instanceId)) + send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid, instanceId)) return } if (authToken && !p.authenticated) { - send(peer, RESPONSES.error('must authenticate before announcing', instanceId)) + send(peer, RESPONSES.error(ServerErrorMessages.mustAuthenticateBeforeAnnouncing, instanceId)) return } @@ -420,13 +424,13 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => const config = data.config if (moduleName === '') { - send(peer, RESPONSES.error('the field \'moduleName\' can\'t be empty for event \'ui:configure\'', instanceId)) + send(peer, RESPONSES.error(ServerErrorMessages.uiConfigureModuleNameInvalid, instanceId)) return } if (typeof moduleIndex !== 'undefined') { if (!Number.isInteger(moduleIndex) || moduleIndex < 0) { - send(peer, RESPONSES.error('the field \'moduleIndex\' must be a non-negative integer for event \'ui:configure\'', instanceId)) + send(peer, RESPONSES.error(ServerErrorMessages.uiConfigureModuleIndexInvalid, instanceId)) return } @@ -442,7 +446,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => }) } else { - send(peer, RESPONSES.error('module not found, it hasn\'t announced itself or the name is incorrect', instanceId)) + send(peer, RESPONSES.error(ServerErrorMessages.moduleNotFound, instanceId)) } return diff --git a/packages/server-runtime/tsconfig.json b/packages/server-runtime/tsconfig.json index 00dcfd807..7ac9034c1 100644 --- a/packages/server-runtime/tsconfig.json +++ b/packages/server-runtime/tsconfig.json @@ -4,8 +4,17 @@ "lib": [ "ESNext" ], + "baseUrl": ".", "module": "ESNext", "moduleResolution": "bundler", + "paths": { + "@proj-airi/server-shared": [ + "../server-shared/src/index.ts" + ], + "@proj-airi/server-shared/*": [ + "../server-shared/src/*" + ] + }, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, diff --git a/packages/server-sdk/README.md b/packages/server-sdk/README.md index 018fb026f..ae6c5a48f 100644 --- a/packages/server-sdk/README.md +++ b/packages/server-sdk/README.md @@ -14,9 +14,32 @@ npm i @proj-airi/server-sdk -D ```typescript import { Client } from '@proj-airi/server-sdk' -const c = new Client({ name: 'your airi plugin' }) +const client = new Client({ + name: 'your airi plugin', + autoConnect: false, +}) + +await client.connect() + +client.onEvent('input:text', async (event) => { + console.info(event.data.text) +}) ``` +`connect()` now resolves when the client is fully ready for use, not just when the websocket transport has opened. In practice that means: + +- the socket is open +- authentication succeeded when a token is configured +- the module has announced itself successfully + +Useful runtime helpers: + +- `client.connectionStatus` exposes the current lifecycle state +- `client.isReady` tells you whether the client has completed authentication + announce +- `client.send()` returns `false` instead of silently dropping messages when the socket is unavailable +- `client.sendOrThrow()` is available when you want strict delivery semantics +- `client.onEvent()` returns an unsubscribe function + ## License [MIT](../../LICENSE) diff --git a/packages/server-sdk/package.json b/packages/server-sdk/package.json index 2a1267bde..90de8c1f6 100644 --- a/packages/server-sdk/package.json +++ b/packages/server-sdk/package.json @@ -37,11 +37,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@moeru/std": "catalog:", "@proj-airi/server-shared": "workspace:^", "crossws": "^0.4.4", "superjson": "catalog:" - }, - "devDependencies": { - "@moeru/std": "catalog:" } } diff --git a/packages/server-sdk/src/client.ts b/packages/server-sdk/src/client.ts index 1d02ca53c..c57e866ac 100644 --- a/packages/server-sdk/src/client.ts +++ b/packages/server-sdk/src/client.ts @@ -11,33 +11,70 @@ import type { import WebSocket from 'crossws/websocket' import superjson from 'superjson' -import { sleep } from '@moeru/std' -import { - MessageHeartbeat, - MessageHeartbeatKind, -} from '@proj-airi/server-shared/types' +import { errorMessageFrom, sleep } from '@moeru/std' +import { isTerminalAuthenticationServerErrorMessage, parseServerErrorMessage } from '@proj-airi/server-shared' +import { MessageHeartbeat, MessageHeartbeatKind } from '@proj-airi/server-shared/types' + +export type ClientStatus + = | 'idle' + | 'connecting' + | 'authenticating' + | 'announcing' + | 'ready' + | 'reconnecting' + | 'closing' + | 'closed' + | 'failed' + +export interface ClientHeartbeatOptions { + pingInterval?: number + readTimeout?: number + message?: MessageHeartbeat | string +} + +export interface ClientStateChangeContext { + previousStatus: ClientStatus + status: ClientStatus +} + +export interface ConnectOptions { + abortSignal?: AbortSignal + timeout?: number +} export interface ClientOptions { url?: string name: string - possibleEvents?: Array> token?: string + + possibleEvents?: Array> identity?: MetadataEventSource dependencies?: ModuleDependency[] configSchema?: ModuleConfigSchema - heartbeat?: { - readTimeout?: number - message?: MessageHeartbeat | string - } - onError?: (error: unknown) => void - onClose?: () => void + heartbeat?: ClientHeartbeatOptions + autoConnect?: boolean autoReconnect?: boolean maxReconnectAttempts?: number + + onError?: (error: unknown) => void + onClose?: () => void + onReady?: () => void + onStateChange?: (context: ClientStateChangeContext) => void + onAnyMessage?: (data: WebSocketEvent) => void onAnySend?: (data: WebSocketEvent) => void } +interface ConnectionAttempt { + announced: boolean + authenticated: boolean + promise: Promise + reject: (error: Error) => void + resolve: () => void + socket: WebSocket +} + function createInstanceId() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` } @@ -46,22 +83,55 @@ function createEventId() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` } +function createDeferredPromise() { + let resolve!: () => void + let reject!: (error: Error) => void + + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve + reject = innerReject + }) + + return { promise, reject, resolve } +} + +function normalizeHeartbeatOptions(heartbeat?: ClientHeartbeatOptions): Required { + const readTimeout = heartbeat?.readTimeout ?? 30_000 + const pingInterval = heartbeat?.pingInterval ?? Math.max(1_000, Math.floor(readTimeout / 2)) + + return { + readTimeout, + pingInterval: Math.min(pingInterval, readTimeout), + message: heartbeat?.message ?? MessageHeartbeat.Ping, + } +} + export class Client { - private connected = false - private connecting = false private websocket?: WebSocket private shouldClose = false - private connectAttempt?: Promise private connectTask?: Promise private heartbeatTimer?: ReturnType + private lastPingAt = 0 + private lastReadAt = 0 + private reconnectAttempts = 0 + private pendingReconnect = false + private connectionAttempt?: ConnectionAttempt + private failureReason?: Error + private status: ClientStatus = 'idle' private readonly identity: MetadataEventSource + private readonly heartbeat: Required + + private readonly opts: Required, 'token' | 'heartbeat'>> & Pick, 'token'> & { + heartbeat: Required + } - private readonly opts: Required, 'token'>> & Pick, 'token'> private readonly eventListeners = new Map< keyof WebSocketEvents, Set<(data: WebSocketBaseEvent) => void | Promise> >() + private readonly stateListeners = new Set<(context: ClientStateChangeContext) => void>() + constructor(options: ClientOptions) { const identity = options.identity ?? { kind: 'plugin', @@ -69,6 +139,8 @@ export class Client { id: createInstanceId(), } + const heartbeat = normalizeHeartbeatOptions(options.heartbeat) + this.opts = { url: 'ws://localhost:6121/ws', onAnyMessage: () => {}, @@ -78,259 +150,96 @@ export class Client { configSchema: undefined, onError: () => {}, onClose: () => {}, + onReady: () => {}, + onStateChange: () => {}, autoConnect: true, autoReconnect: true, maxReconnectAttempts: -1, - heartbeat: { - readTimeout: 30_000, - message: MessageHeartbeat.Ping, - }, ...options, + heartbeat, identity, } this.identity = identity - - // Authentication listener is registered once only - this.onEvent('module:authenticated', async (event) => { - if (event.data.authenticated) { - this.tryAnnounce() - } - else { - await this.retryWithExponentialBackoff(() => this.tryAuthenticate()) - } - }) - - this.onEvent('error', async (event) => { - if (event.data.message === 'not authenticated') { - await this._reconnectDueToUnauthorized() - } - }) - - this.onEvent('transport:connection:heartbeat', (event) => { - if (event.data.kind === MessageHeartbeatKind.Ping) { - this.sendHeartbeatPong() - } - }) + this.heartbeat = heartbeat if (this.opts.autoConnect) { void this.connect() } } - private async retryWithExponentialBackoff(fn: () => void | Promise) { - const { maxReconnectAttempts } = this.opts - let attempts = 0 - - // Loop until attempts exceed maxReconnectAttempts, or unlimited if -1 - while (true) { - if (maxReconnectAttempts !== -1 && attempts >= maxReconnectAttempts) { - console.error(`Maximum retry attempts (${maxReconnectAttempts}) reached`) - return - } - - try { - await fn() - return - } - catch (err) { - this.opts.onError?.(err) - const delay = Math.min(2 ** attempts * 1000, 30_000) // capped exponential backoff - await sleep(delay) - attempts++ - } - } + get connectionStatus() { + return this.status } - private async tryReconnectWithExponentialBackoff() { + get isReady() { + return this.status === 'ready' + } + + get isSocketOpen() { + return this.websocket?.readyState === WebSocket.OPEN + } + + get lastError() { + return this.failureReason + } + + async connect(options?: ConnectOptions) { if (this.shouldClose) { throw new Error('Client is closed') } - await this.retryWithExponentialBackoff(() => this._connect()) - } - - private _connect(): Promise { - if (this.shouldClose || this.connected) { - return Promise.resolve() - } - if (this.connecting) { - return this.connectAttempt ?? Promise.resolve() - } - - this.connectAttempt = new Promise((resolve, reject) => { - this.connecting = true - let settled = false - - const settle = (fn: () => void) => { - if (settled) - return - - settled = true - this.connecting = false - this.connectAttempt = undefined - fn() - } - - const ws = new WebSocket(this.opts.url) - this.websocket = ws - const isCurrentSocket = () => this.websocket === ws - - ws.onmessage = (event: MessageEvent) => { - if (!isCurrentSocket()) { - return - } - - this.handleMessageBound(event) - } - ws.onerror = (event: any) => { - if (!isCurrentSocket()) { - return - } - - settle(() => { - this.websocket = undefined - this.connected = false - - this.opts.onError?.(event) - reject(event?.error ?? new Error('WebSocket error')) - }) - } - ws.onclose = () => { - if (!isCurrentSocket()) { - return - } - - this.websocket = undefined - - if (!settled && !this.connected) { - settle(() => { - reject(new Error('WebSocket closed before open')) - }) - return - } - - if (this.connected) { - this.connected = false - this.stopHeartbeat() - this.opts.onClose?.() - } - if (this.opts.autoReconnect && !this.shouldClose) { - void this.tryReconnectWithExponentialBackoff() - } - } - ws.onopen = () => { - if (!isCurrentSocket()) { - return - } - - settle(() => { - this.connected = true - - this.startHeartbeat() - - if (this.opts.token) - this.tryAuthenticate() - else - this.tryAnnounce() - - resolve() - }) - } - }) - - return this.connectAttempt - } - - async connect() { - if (this.connected) { + if (this.status === 'ready') { return } + if (this.connectTask) { - return this.connectTask + return this.waitForConnection(this.connectTask, options) } - this.connectTask = this.tryReconnectWithExponentialBackoff().finally(() => (this.connectTask = undefined)) - - return this.connectTask - } - - private tryAnnounce() { - this.send({ - type: 'module:announce', - data: { - name: this.opts.name, - identity: this.identity, - possibleEvents: this.opts.possibleEvents, - dependencies: this.opts.dependencies, - configSchema: this.opts.configSchema, - }, + this.connectTask = this.runConnectLoop().finally(() => { + this.connectTask = undefined }) + + return this.waitForConnection(this.connectTask, options) } - private tryAuthenticate() { - if (this.opts.token) { - this.send({ - type: 'module:authenticate', - data: { token: this.opts.token }, - }) - } + ready(options?: ConnectOptions) { + return this.connect(options) } - // bound reference avoids new closure allocation on every connect - private readonly handleMessageBound = (event: MessageEvent) => { - void this.handleMessage(event) + ensureConnected(options?: ConnectOptions) { + return this.connect(options) } - private async handleMessage(event: MessageEvent) { - try { - // Try superjson first (used by SDK clients), fall back to plain JSON - // for external clients that send standard JSON-encoded messages. - const raw = event.data as string - const parsed = superjson.parse | undefined>(raw) - const data = (parsed && typeof parsed === 'object' && 'type' in parsed) - ? parsed - : JSON.parse(raw) as WebSocketEvent - if (!data || typeof data !== 'object' || !('type' in data)) { - console.warn('Received empty message') - return - } + onConnectionStateChange(callback: (context: ClientStateChangeContext) => void): () => void { + this.stateListeners.add(callback) - this.opts.onAnyMessage?.(data) - const listeners = this.eventListeners.get(data.type) - if (!listeners?.size) { - return - } - - // Execute all listeners concurrently - const executions: Promise[] = [] - for (const listener of listeners) { - executions.push(Promise.resolve(listener(data as any))) - } - - await Promise.allSettled(executions) - } - catch (err) { - console.error('Failed to parse message:', err) - this.opts.onError?.(err) + return () => { + this.stateListeners.delete(callback) } } onEvent>( event: E, callback: (data: WebSocketBaseEvent[E]>) => void | Promise, - ): void { + ): () => void { let listeners = this.eventListeners.get(event) if (!listeners) { listeners = new Set() this.eventListeners.set(event, listeners) } + listeners.add(callback as any) + + return () => { + this.offEvent(event, callback) + } } offEvent>( event: E, - callback?: (data: WebSocketBaseEvent[E]>) => void, + callback?: (data: WebSocketBaseEvent[E]>) => void | Promise, ): void { const listeners = this.eventListeners.get(event) if (!listeners) { @@ -348,61 +257,501 @@ export class Client { } } - send(data: WebSocketEventOptionalSource): void { - if (this.websocket && this.connected) { - const payload = { - ...data, - metadata: { - ...data?.metadata, - source: data?.metadata?.source ?? this.identity, - event: { - id: data?.metadata?.event?.id ?? createEventId(), - ...data?.metadata?.event, - }, - }, - } as WebSocketEvent + send(data: WebSocketEventOptionalSource): boolean { + if (!this.isSocketOpen || !this.websocket) { + return false + } - this.opts.onAnySend?.(payload) + const payload = this.createPayload(data) + this.opts.onAnySend?.(payload) + this.websocket.send(superjson.stringify(payload)) - this.websocket.send(superjson.stringify(payload)) + return true + } + + sendOrThrow(data: WebSocketEventOptionalSource): void { + if (!this.send(data)) { + throw new Error(`Client is not connected, current status: ${this.status}`) } } - sendRaw(data: string | ArrayBufferLike | ArrayBufferView): void { - if (this.websocket && this.connected) { - this.websocket.send(data) + sendRaw(data: string | ArrayBufferLike | ArrayBufferView): boolean { + if (!this.isSocketOpen || !this.websocket) { + return false } + + this.websocket.send(data) + return true } close(): void { this.shouldClose = true + this.pendingReconnect = false + this.transitionTo('closing') this.stopHeartbeat() + this.rejectAttempt(new Error('Client closed')) + const websocket = this.websocket this.websocket = undefined - if (websocket) { + + if (websocket && websocket.readyState !== WebSocket.CLOSED && websocket.readyState !== WebSocket.CLOSING) { websocket.close() - this.connected = false } + + this.transitionTo('closed') } - private startHeartbeat() { - if (!this.opts.heartbeat?.readTimeout) { + private async runConnectLoop() { + this.pendingReconnect = false + + while (!this.shouldClose) { + const reconnecting = this.reconnectAttempts > 0 + this.transitionTo(reconnecting ? 'reconnecting' : 'connecting') + + try { + await this.connectOnce() + this.reconnectAttempts = 0 + return + } + catch (error) { + const normalizedError = error instanceof Error ? error : new Error(errorMessageFrom(error) ?? 'Failed to connect websocket client') + this.failureReason = normalizedError + this.opts.onError?.(normalizedError) + + if (this.shouldClose) { + throw normalizedError + } + + if (isTerminalAuthenticationServerErrorMessage(normalizedError.message)) { + this.transitionTo('failed') + throw normalizedError + } + + if (!this.opts.autoReconnect && reconnecting) { + this.transitionTo('failed') + throw normalizedError + } + + if (!this.canRetry()) { + this.transitionTo('failed') + throw normalizedError + } + + const delay = this.getReconnectDelay(this.reconnectAttempts) + this.reconnectAttempts += 1 + await sleep(delay) + } + } + + throw new Error('Client is closed') + } + + private connectOnce(): Promise { + const ws = new WebSocket(this.opts.url) + this.websocket = ws + this.lastReadAt = Date.now() + this.lastPingAt = 0 + + const deferred = createDeferredPromise() + const attempt: ConnectionAttempt = { + announced: false, + authenticated: !this.opts.token, + promise: deferred.promise, + reject: deferred.reject, + resolve: deferred.resolve, + socket: ws, + } + + this.connectionAttempt = attempt + + const isCurrentSocket = () => this.websocket === ws + + ws.onmessage = (event: MessageEvent) => { + if (!isCurrentSocket()) { + return + } + + void this.handleMessage(event) + } + + ws.onerror = (event: any) => { + if (!isCurrentSocket()) { + return + } + + const error = event?.error instanceof Error ? event.error : new Error('WebSocket error') + if (this.connectionAttempt) { + this.handleSocketFailure(error, ws) + } + else { + this.opts.onError?.(error) + void this.reconnectAfterProtocolError(error) + } + } + + ws.onclose = () => { + if (!isCurrentSocket()) { + return + } + + const wasReady = this.status === 'ready' + this.cleanupSocket(ws) + this.opts.onClose?.() + + if (this.shouldClose) { + return + } + + if (wasReady && this.opts.autoReconnect) { + this.pendingReconnect = true + void this.connect() + return + } + + this.rejectAttempt(new Error('WebSocket closed')) + } + + ws.onopen = () => { + if (!isCurrentSocket()) { + return + } + + this.startHeartbeat() + + if (this.opts.token) { + attempt.authenticated = false + this.transitionTo('authenticating') + this.tryAuthenticate() + } + else { + attempt.authenticated = true + this.transitionTo('announcing') + this.tryAnnounce() + } + } + + return attempt.promise + } + + private handleSocketFailure(error: Error, socket?: WebSocket) { + if (socket && this.websocket !== socket) { + return + } + + const currentSocket = socket ?? this.websocket + this.cleanupSocket(socket) + + if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED && currentSocket.readyState !== WebSocket.CLOSING) { + currentSocket.close() + } + + this.rejectAttempt(error) + } + + private cleanupSocket(socket?: WebSocket) { + if (socket && this.websocket !== socket) { return } this.stopHeartbeat() - const ping = () => this.sendHeartbeatPing() + if (!socket || this.websocket === socket) { + this.websocket = undefined + } + } - ping() - this.heartbeatTimer = setInterval(ping, this.opts.heartbeat.readTimeout) + private rejectAttempt(error: Error) { + if (!this.connectionAttempt) { + return + } + + const attempt = this.connectionAttempt + this.connectionAttempt = undefined + attempt.reject(error) + } + + private resolveAttempt() { + if (!this.connectionAttempt) { + return + } + + const attempt = this.connectionAttempt + this.connectionAttempt = undefined + attempt.resolve() + } + + private canRetry() { + return this.opts.maxReconnectAttempts === -1 || this.reconnectAttempts < this.opts.maxReconnectAttempts + } + + private getReconnectDelay(attempts: number) { + return Math.min(2 ** attempts * 1_000, 30_000) + } + + private transitionTo(status: ClientStatus) { + if (this.status === status) { + return + } + + const previousStatus = this.status + this.status = status + const context = { previousStatus, status } + + this.opts.onStateChange?.(context) + + for (const listener of this.stateListeners) { + listener(context) + } + } + + private async waitForConnection(connectPromise: Promise, options?: ConnectOptions) { + if (!options?.timeout && !options?.abortSignal) { + return connectPromise + } + + const timeout = options?.timeout + if (typeof timeout !== 'undefined' && timeout <= 0) { + throw new Error(`Connection timed out after ${timeout}ms`) + } + + const abortSignal = options?.abortSignal + if (abortSignal?.aborted) { + throw new Error('Connection aborted') + } + + let timeoutHandle: ReturnType | undefined + let removeAbortListener: (() => void) | undefined + + try { + await Promise.race([ + connectPromise, + new Promise((_, reject) => { + if (typeof timeout !== 'undefined') { + timeoutHandle = setTimeout(() => { + reject(new Error(`Connection timed out after ${timeout}ms`)) + }, timeout) + } + + if (abortSignal) { + const onAbort = () => { + reject(new Error('Connection aborted')) + } + + abortSignal.addEventListener('abort', onAbort, { once: true }) + removeAbortListener = () => abortSignal.removeEventListener('abort', onAbort) + } + }), + ]) + } + finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle) + } + + removeAbortListener?.() + } + } + + private tryAnnounce() { + this.sendOrThrow({ + type: 'module:announce', + data: { + name: this.opts.name, + identity: this.identity, + possibleEvents: this.opts.possibleEvents, + dependencies: this.opts.dependencies, + configSchema: this.opts.configSchema, + }, + }) + } + + private tryAuthenticate() { + if (!this.opts.token) { + return + } + + this.sendOrThrow({ + type: 'module:authenticate', + data: { token: this.opts.token }, + }) + } + + private async handleMessage(event: MessageEvent) { + this.lastReadAt = Date.now() + + try { + const data = this.parseMessage(event.data as string) + this.opts.onAnyMessage?.(data) + + await this.handleControlMessage(data) + await this.dispatchMessage(data) + } + catch (error) { + const normalizedError = error instanceof Error ? error : new Error(errorMessageFrom(error) ?? 'Failed to handle websocket message') + this.opts.onError?.(normalizedError) + + if (this.connectionAttempt && this.status !== 'ready') { + this.handleSocketFailure(normalizedError) + } + } + } + + private parseMessage(raw: string): WebSocketEvent { + try { + const parsed = superjson.parse | undefined>(raw) + if (parsed && typeof parsed === 'object' && 'type' in parsed) { + return parsed + } + } + catch { + // Try standard JSON next. + } + + const parsed = JSON.parse(raw) as WebSocketEvent + if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) { + throw new Error('Received invalid websocket message') + } + + return parsed + } + + private async handleControlMessage(data: WebSocketEvent) { + switch (data.type) { + case 'error': { + const message = data.data?.message + if (!message || typeof message !== 'string') { + return + } + + const parsedServerError = parseServerErrorMessage(message) + if (parsedServerError.authentication) { + const error = new Error(message) + if (parsedServerError.terminal) { + this.shouldClose = true + this.handleSocketFailure(error) + this.transitionTo('failed') + return + } + + await this.reconnectAfterProtocolError(error) + return + } + + if (parsedServerError.code !== 'unknown') { + throw new Error(parsedServerError.message) + } + + throw new Error(message) + } + + case 'module:authenticated': { + if (data.data.authenticated) { + if (!this.connectionAttempt || this.connectionAttempt.authenticated) { + return + } + + this.connectionAttempt.authenticated = true + this.transitionTo('announcing') + this.tryAnnounce() + return + } + + throw new Error('Authentication failed') + } + + case 'module:announced': { + if (!this.isSelfAnnouncement(data)) { + return + } + + if (this.connectionAttempt) { + this.connectionAttempt.announced = true + } + + this.reconnectAttempts = 0 + this.transitionTo('ready') + this.resolveAttempt() + this.opts.onReady?.() + return + } + + case 'transport:connection:heartbeat': { + if (data.data.kind === MessageHeartbeatKind.Ping) { + this.sendHeartbeatPong() + } + } + } + } + + private isSelfAnnouncement(event: WebSocketBaseEvent<'module:announced', WebSocketEvents['module:announced']>) { + return event.data.name === this.opts.name && event.data.identity?.id === this.identity.id + } + + private async dispatchMessage(data: WebSocketEvent) { + const listeners = this.eventListeners.get(data.type) + if (!listeners?.size) { + return + } + + const results = await Promise.allSettled( + Array.from(listeners).map(listener => Promise.resolve(listener(data as any))), + ) + + for (const result of results) { + if (result.status === 'rejected') { + this.opts.onError?.(result.reason) + } + } + } + + private createPayload(data: WebSocketEventOptionalSource) { + return { + ...data, + metadata: { + ...data?.metadata, + source: data?.metadata?.source ?? this.identity, + event: { + id: data?.metadata?.event?.id ?? createEventId(), + ...data?.metadata?.event, + }, + }, + } as WebSocketEvent + } + + private startHeartbeat() { + if (!this.heartbeat.readTimeout || !this.heartbeat.pingInterval) { + return + } + + this.stopHeartbeat() + this.lastReadAt = Date.now() + this.lastPingAt = 0 + + const interval = Math.max(1_000, Math.min(this.heartbeat.pingInterval, Math.floor(this.heartbeat.readTimeout / 2))) + this.heartbeatTimer = setInterval(() => { + if (!this.isSocketOpen) { + return + } + + const now = Date.now() + if (now - this.lastReadAt > this.heartbeat.readTimeout) { + void this.reconnectAfterProtocolError(new Error(`Read timeout after ${this.heartbeat.readTimeout}ms`)) + return + } + + if (now - this.lastPingAt >= this.heartbeat.pingInterval) { + this.sendHeartbeatPing() + } + }, interval) } private stopHeartbeat() { - if (this.heartbeatTimer) { - clearInterval(this.heartbeatTimer) - this.heartbeatTimer = undefined + if (!this.heartbeatTimer) { + return } + + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = undefined } private sendNativeHeartbeat(kind: 'ping' | 'pong') { @@ -420,11 +769,12 @@ export class Client { } private sendHeartbeatPing() { + this.lastPingAt = Date.now() this.send({ type: 'transport:connection:heartbeat', data: { kind: MessageHeartbeatKind.Ping, - message: this.opts.heartbeat?.message ?? MessageHeartbeat.Ping, + message: this.heartbeat.message, at: Date.now(), }, }) @@ -443,17 +793,35 @@ export class Client { this.sendNativeHeartbeat('pong') } - private async _reconnectDueToUnauthorized() { - if (this.shouldClose) + private async reconnectAfterProtocolError(error: Error) { + if (this.shouldClose || this.pendingReconnect) { return - - const ws = this.websocket - this.connected = false - this.websocket = undefined - if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { - ws.close() } - await this.connect() + this.pendingReconnect = true + const hadSocket = !!this.websocket + + if (!this.connectionAttempt || this.status === 'ready') { + this.opts.onError?.(error) + } + + const websocket = this.websocket + this.cleanupSocket(websocket) + this.rejectAttempt(error) + + if (websocket && websocket.readyState !== WebSocket.CLOSED && websocket.readyState !== WebSocket.CLOSING) { + websocket.close() + } + + if (hadSocket) { + this.opts.onClose?.() + } + + if (!this.opts.autoReconnect) { + this.transitionTo('failed') + return + } + + void this.connect() } } diff --git a/packages/server-sdk/test/client.test.ts b/packages/server-sdk/test/client.test.ts new file mode 100644 index 000000000..653b67373 --- /dev/null +++ b/packages/server-sdk/test/client.test.ts @@ -0,0 +1,256 @@ +import type { WebSocketEvent } from '@proj-airi/server-shared/types' + +import superjson from 'superjson' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +class MockWebSocket { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + + static instances: MockWebSocket[] = [] + + readonly sent: string[] = [] + readyState = MockWebSocket.CONNECTING + onclose?: () => void + onerror?: (event: { error?: Error }) => void + onmessage?: (event: { data: string }) => void + onopen?: () => void + + constructor(public readonly url: string) { + MockWebSocket.instances.push(this) + } + + send(data: string) { + this.sent.push(data) + } + + close() { + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + } + + ping() {} + pong() {} +} + +vi.mock('crossws/websocket', () => ({ + default: MockWebSocket, +})) + +const { Client } = await import('../src/client') + +function lastSocket() { + const socket = MockWebSocket.instances.at(-1) + if (!socket) { + throw new Error('No mock websocket instance created') + } + + return socket +} + +function parseSent(socket: MockWebSocket, index = -1) { + const payload = socket.sent.at(index) + if (!payload) { + throw new Error(`No sent payload at index ${index}`) + } + + return superjson.parse(payload) +} + +function emitOpen(socket: MockWebSocket) { + socket.readyState = MockWebSocket.OPEN + socket.onopen?.() +} + +function emitMessage(socket: MockWebSocket, event: WebSocketEvent) { + socket.onmessage?.({ + data: superjson.stringify(event), + }) +} + +afterEach(() => { + MockWebSocket.instances.length = 0 + vi.useRealTimers() +}) + +describe('client', () => { + it('resolves connect only after authentication and self announcement', async () => { + const client = new Client({ + autoConnect: false, + autoReconnect: false, + name: 'test-plugin', + token: 'secret', + }) + + const connected = client.connect() + const socket = lastSocket() + + emitOpen(socket) + + expect(parseSent(socket)).toMatchObject({ + type: 'module:authenticate', + data: { token: 'secret' }, + }) + + emitMessage(socket, { + type: 'module:authenticated', + data: { authenticated: true }, + metadata: { + source: { kind: 'plugin', plugin: { id: 'server' }, id: 'server-1' }, + event: { id: 'auth-1' }, + }, + }) + + const announceEvent = parseSent(socket) + expect(announceEvent).toMatchObject({ + type: 'module:announce', + data: { name: 'test-plugin' }, + }) + + emitMessage(socket, { + type: 'module:announced', + data: { + name: 'test-plugin', + identity: announceEvent.data.identity, + }, + metadata: { + source: { kind: 'plugin', plugin: { id: 'server' }, id: 'server-1' }, + event: { id: 'announce-1' }, + }, + }) + + await expect(connected).resolves.toBeUndefined() + expect(client.connectionStatus).toBe('ready') + expect(client.isReady).toBe(true) + }) + + it('fails terminally on invalid token', async () => { + const client = new Client({ + autoConnect: false, + autoReconnect: true, + name: 'test-plugin', + token: 'wrong-token', + }) + + const connected = client.connect() + const socket = lastSocket() + + emitOpen(socket) + emitMessage(socket, { + type: 'error', + data: { message: 'invalid token' }, + metadata: { + source: { kind: 'plugin', plugin: { id: 'server' }, id: 'server-1' }, + event: { id: 'error-1' }, + }, + }) + + await expect(connected).rejects.toThrow('invalid token') + expect(client.connectionStatus).toBe('failed') + }) + + it('returns an unsubscribe function from onEvent', () => { + const client = new Client({ + autoConnect: false, + autoReconnect: false, + name: 'test-plugin', + }) + + const listener = vi.fn() + const dispose = client.onEvent('input:text', listener) + + dispose() + expect(() => client.offEvent('input:text', listener)).not.toThrow() + }) + + it('supports timeout-aware ensureConnected without cancelling the shared connect task', async () => { + vi.useFakeTimers() + + const client = new Client({ + autoConnect: false, + autoReconnect: false, + name: 'test-plugin', + }) + + const timedOut = client.ensureConnected({ timeout: 50 }) + const timedOutAssertion = expect(timedOut).rejects.toThrow('Connection timed out after 50ms') + const socket = lastSocket() + + await vi.advanceTimersByTimeAsync(50) + await timedOutAssertion + + emitOpen(socket) + const announceEvent = parseSent(socket) + + emitMessage(socket, { + type: 'module:announced', + data: { + name: 'test-plugin', + identity: announceEvent.data.identity, + }, + metadata: { + source: { kind: 'plugin', plugin: { id: 'server' }, id: 'server-1' }, + event: { id: 'announce-1' }, + }, + }) + + await expect(client.ensureConnected()).resolves.toBeUndefined() + expect(client.isReady).toBe(true) + }) + + it('supports abort-aware connect', async () => { + const client = new Client({ + autoConnect: false, + autoReconnect: false, + name: 'test-plugin', + }) + + const controller = new AbortController() + const connecting = client.connect({ abortSignal: controller.signal }) + + lastSocket() + controller.abort() + + await expect(connecting).rejects.toThrow('Connection aborted') + expect(client.connectionStatus).toBe('connecting') + }) + + it('notifies external state listeners', async () => { + const client = new Client({ + autoConnect: false, + autoReconnect: false, + name: 'test-plugin', + }) + + const listener = vi.fn() + const dispose = client.onConnectionStateChange(listener) + const connected = client.connect() + const socket = lastSocket() + + emitOpen(socket) + + const announceEvent = parseSent(socket) + emitMessage(socket, { + type: 'module:announced', + data: { + name: 'test-plugin', + identity: announceEvent.data.identity, + }, + metadata: { + source: { kind: 'plugin', plugin: { id: 'server' }, id: 'server-1' }, + event: { id: 'announce-1' }, + }, + }) + + await connected + + expect(listener).toHaveBeenCalledWith({ previousStatus: 'idle', status: 'connecting' }) + expect(listener).toHaveBeenCalledWith({ previousStatus: 'connecting', status: 'announcing' }) + expect(listener).toHaveBeenCalledWith({ previousStatus: 'announcing', status: 'ready' }) + + dispose() + }) +}) diff --git a/packages/server-sdk/vitest.config.ts b/packages/server-sdk/vitest.config.ts new file mode 100644 index 000000000..5f86a1f69 --- /dev/null +++ b/packages/server-sdk/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + root: import.meta.dirname, + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + }, +}) diff --git a/packages/server-shared/package.json b/packages/server-shared/package.json index 2590d0771..4369f74b9 100644 --- a/packages/server-shared/package.json +++ b/packages/server-shared/package.json @@ -15,13 +15,17 @@ "directory": "packages/server-shared" }, "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, "./types": { "types": "./dist/types/index.d.mts", "default": "./dist/types/index.mjs" } }, - "main": "./dist/types/index.mjs", - "types": "./dist/types/index.d.mts", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", "files": [ "README.md", "dist", diff --git a/packages/server-shared/src/errors.ts b/packages/server-shared/src/errors.ts new file mode 100644 index 000000000..3ba1dd4e3 --- /dev/null +++ b/packages/server-shared/src/errors.ts @@ -0,0 +1,167 @@ +export const ServerErrorMessages = { + invalidEventFormat: 'invalid event format', + invalidToken: 'invalid token', + mustAuthenticateBeforeAnnouncing: 'must authenticate before announcing', + moduleAnnounceIdentityInvalid: 'module identity must include kind=plugin and a plugin id for event \'module:announce\'', + moduleAnnounceIndexInvalid: 'the field \'index\' must be a non-negative integer for event \'module:announce\'', + moduleAnnounceNameInvalid: 'the field \'name\' must be a non-empty string for event \'module:announce\'', + moduleNotFound: 'module not found, it hasn\'t announced itself or the name is incorrect', + notAuthenticated: 'not authenticated', + uiConfigureModuleIndexInvalid: 'the field \'moduleIndex\' must be a non-negative integer for event \'ui:configure\'', + uiConfigureModuleNameInvalid: 'the field \'moduleName\' can\'t be empty for event \'ui:configure\'', +} as const + +export type ServerErrorCode + = | 'invalid-event-format' + | 'invalid-json' + | 'invalid-token' + | 'module-announce-identity-invalid' + | 'module-announce-index-invalid' + | 'module-announce-name-invalid' + | 'module-not-found' + | 'must-authenticate-before-announcing' + | 'not-authenticated' + | 'ui-configure-module-index-invalid' + | 'ui-configure-module-name-invalid' + | 'unknown' + +export interface ParsedServerErrorMessage { + authentication: boolean + code: ServerErrorCode + message: string + recoverable: boolean + terminal: boolean +} + +export function createInvalidJsonServerErrorMessage(errorMessage: string) { + return `invalid JSON, error: ${errorMessage}` +} + +export function parseServerErrorMessage(message: string): ParsedServerErrorMessage { + if (message === ServerErrorMessages.invalidToken) { + return { + authentication: true, + code: 'invalid-token', + message, + recoverable: false, + terminal: true, + } + } + + if (message === ServerErrorMessages.notAuthenticated) { + return { + authentication: true, + code: 'not-authenticated', + message, + recoverable: true, + terminal: false, + } + } + + if (message === ServerErrorMessages.mustAuthenticateBeforeAnnouncing) { + return { + authentication: true, + code: 'must-authenticate-before-announcing', + message, + recoverable: true, + terminal: false, + } + } + + if (message === ServerErrorMessages.invalidEventFormat) { + return { + authentication: false, + code: 'invalid-event-format', + message, + recoverable: false, + terminal: false, + } + } + + if (message.startsWith('invalid JSON, error: ')) { + return { + authentication: false, + code: 'invalid-json', + message, + recoverable: false, + terminal: false, + } + } + + if (message === ServerErrorMessages.moduleAnnounceNameInvalid) { + return { + authentication: false, + code: 'module-announce-name-invalid', + message, + recoverable: false, + terminal: false, + } + } + + if (message === ServerErrorMessages.moduleAnnounceIndexInvalid) { + return { + authentication: false, + code: 'module-announce-index-invalid', + message, + recoverable: false, + terminal: false, + } + } + + if (message === ServerErrorMessages.moduleAnnounceIdentityInvalid) { + return { + authentication: false, + code: 'module-announce-identity-invalid', + message, + recoverable: false, + terminal: false, + } + } + + if (message === ServerErrorMessages.moduleNotFound) { + return { + authentication: false, + code: 'module-not-found', + message, + recoverable: false, + terminal: false, + } + } + + if (message === ServerErrorMessages.uiConfigureModuleNameInvalid) { + return { + authentication: false, + code: 'ui-configure-module-name-invalid', + message, + recoverable: false, + terminal: false, + } + } + + if (message === ServerErrorMessages.uiConfigureModuleIndexInvalid) { + return { + authentication: false, + code: 'ui-configure-module-index-invalid', + message, + recoverable: false, + terminal: false, + } + } + + return { + authentication: false, + code: 'unknown', + message, + recoverable: false, + terminal: false, + } +} + +export function isAuthenticationServerErrorMessage(message: string) { + return parseServerErrorMessage(message).authentication +} + +export function isTerminalAuthenticationServerErrorMessage(message: string) { + const parsed = parseServerErrorMessage(message) + return parsed.authentication && parsed.terminal +} diff --git a/packages/server-shared/src/index.ts b/packages/server-shared/src/index.ts index 4a6af3ebd..979493029 100644 --- a/packages/server-shared/src/index.ts +++ b/packages/server-shared/src/index.ts @@ -1 +1,2 @@ -console.warn('import @proj-airi/server-shared/types instead') +export * from './errors' +export * from './types' diff --git a/packages/server-shared/tsdown.config.ts b/packages/server-shared/tsdown.config.ts index 6b35884f5..ddbc43fc5 100644 --- a/packages/server-shared/tsdown.config.ts +++ b/packages/server-shared/tsdown.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'tsdown' export default defineConfig({ entry: { + 'index': 'src/index.ts', 'types/index': 'src/types/index.ts', }, sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fcfee20e..a05dd0381 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1848,7 +1848,7 @@ importers: version: 5.0.0(vue@3.5.29(typescript@5.9.3)) '@intlify/unplugin-vue-i18n': specifier: ^11.0.7 - version: 11.0.7(@vue/compiler-dom@3.5.29)(eslint@9.39.3(jiti@2.6.1))(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)) + version: 11.0.7(@vue/compiler-dom@3.5.29)(eslint@9.39.3(jiti@2.6.1))(rollup@4.59.0)(typescript@5.9.3)(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)) '@mdit/plugin-footnote': specifier: ^0.22.4 version: 0.22.4(markdown-it@14.1.1) @@ -1908,7 +1908,7 @@ importers: version: 0.1.3 unplugin-yaml: specifier: ^4.0.0 - version: 4.0.0(@nuxt/kit@3.20.2(magicast@0.5.2))(esbuild@0.27.2)(rolldown@1.0.0-rc.3)(rollup@4.59.0)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.0(esbuild@0.27.2)(rolldown@1.0.0-rc.3)(rollup@4.59.0)(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) vitepress: specifier: ^2.0.0-alpha.16 version: 2.0.0-alpha.16(@types/node@24.10.14)(change-case@5.4.4)(fuse.js@7.1.0)(idb-keyval@6.2.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(nprogress@0.2.0)(postcss@8.5.6)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) @@ -2233,6 +2233,9 @@ importers: packages/server-sdk: dependencies: + '@moeru/std': + specifier: 'catalog:' + version: 0.1.0-beta.17 '@proj-airi/server-shared': specifier: workspace:^ version: link:../server-shared @@ -2242,10 +2245,6 @@ importers: superjson: specifier: 'catalog:' version: 2.2.6 - devDependencies: - '@moeru/std': - specifier: 'catalog:' - version: 0.1.0-beta.17 packages/server-shared: dependencies: @@ -3261,7 +3260,7 @@ importers: version: 14.1.0(vue@3.5.29(typescript@5.9.3)) '@wxt-dev/module-vue': specifier: ^1.0.3 - version: 1.0.3(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.0.3(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) nanoid: specifier: ^5.1.6 version: 5.1.6 @@ -19423,31 +19422,6 @@ snapshots: - supports-color - typescript - '@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.29)(eslint@9.39.3(jiti@2.6.1))(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) - '@intlify/bundle-utils': 11.0.7(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3))) - '@intlify/shared': 11.2.8 - '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.2.8)(@vue/compiler-dom@3.5.29)(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)) - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - debug: 4.4.3 - fast-glob: 3.3.3 - pathe: 2.0.3 - picocolors: 1.1.1 - unplugin: 2.3.11 - vite: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vue: 3.5.29(typescript@5.9.3) - optionalDependencies: - vue-i18n: 11.2.8(vue@3.5.29(typescript@5.9.3)) - transitivePeerDependencies: - - '@vue/compiler-dom' - - eslint - - rollup - - supports-color - - typescript - '@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.29)(eslint@9.39.3(jiti@2.6.1))(rollup@4.59.0)(typescript@5.9.3)(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) @@ -23682,9 +23656,9 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 - '@wxt-dev/module-vue@1.0.3(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@vitejs/plugin-vue': 6.0.4(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.4(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)) wxt: 0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - vite diff --git a/vitest.config.ts b/vitest.config.ts index 575052287..2a5274942 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,12 +5,13 @@ export default defineConfig({ projects: [ 'apps/server', 'apps/stage-tamagotchi', - 'packages/stage-ui', - 'packages/plugin-sdk', - 'packages/cap-vite', - 'packages/vite-plugin-warpdrive', 'packages/audio-pipelines-transcribe', + 'packages/cap-vite', + 'packages/plugin-sdk', 'packages/server-runtime', + 'packages/server-sdk', + 'packages/stage-ui', + 'packages/vite-plugin-warpdrive', ], }, })