import { loadEnv, type Plugin } from 'vite'; import type { SessionPersona } from '../../project/persona.js'; import { NoopAnalyticsRecorder, SqliteAnalyticsRecorder } from './analytics.js'; import { loadConfig } from './config.js'; import { resolveSessionDefaults } from './defaults.js'; import { OpenAiRealtimeProvider } from './providers/openai.js'; import type { ToolRegistry } from './tools.js'; import { handleRealtimeUpgrade } from './ws-handler.js'; export interface RealtimeDevWsPluginOptions { /** * Raw, env-independent pieces (project persona + tool registry) — taken as * parameters rather than imported here so this module (server/core) never * has to import project code directly. See vite.config.ts, the * composition root that supplies these, and docs/TOOLS.md. * * Deliberately NOT pre-resolved against env vars by the caller: resolving * them happens inside configureServer below, gated by the VITEST check — * see the comment on that check for why. */ persona: SessionPersona; toolRegistry: ToolRegistry; } /** * Wires the same upgrade handling used in production (see repo-root * server.js) into Vite's dev server, so `npm run dev` supports the realtime * WS endpoint without a production build. * * Loads .env itself: this hook runs at dev-server startup, outside * SvelteKit's own request/module graph, so `$env/...` isn't available here — * see the note in ./tokens.ts for why this whole module tree reads * `process.env` directly instead. */ export function realtimeDevWsPlugin(options: RealtimeDevWsPluginOptions): Plugin { return { name: 'webavatar-realtime-ws-dev', configureServer(server) { // vitest's browser-mode project boots its own Vite dev server for // component tests; this plugin has nothing to do there. Critically, // this guard is also what keeps loadConfig()'s fail-fast validation // (below) from running during `npm run test:unit` — that suite's // `test.projects` entries both `extends: './vite.config.ts'`, so this // plugin's config gets evaluated there too, and unit tests need no env // vars set at all. Don't move config resolution above this line. if (process.env.VITEST) return; const loaded = loadEnv('development', process.cwd(), ''); for (const [key, value] of Object.entries(loaded)) { if (process.env[key] === undefined) process.env[key] = value; } const config = loadConfig(); const provider = new OpenAiRealtimeProvider({ apiKey: config.openaiApiKey }); const recorder = config.analytics.enabled ? new SqliteAnalyticsRecorder(config.analytics.dbPath, config.analytics) : new NoopAnalyticsRecorder(); const defaults = { ...resolveSessionDefaults(options.persona, config.session), toolRegistry: options.toolRegistry }; server.httpServer?.on('upgrade', (req, socket, head) => { handleRealtimeUpgrade(req, socket, head, { provider, defaults, recorder, clientToolTimeoutMs: config.session.clientToolTimeoutMs }); }); } }; }