import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import { readFileSync } from 'node:fs' import { handleChatProxy } from './electron/server/chatProxy.js' import { describeConfig, formatConfigSummary } from './electron/server/configSummary.js' import { toChatRequest, toFetchRequest, sendWebResponse } from './electron/server/nodeHttp.js' import { createKeyFile, defaultKeyFile } from './electron/server/keyFile.js' import { handleProviderApi } from './electron/server/providerApi.js' import { handleSurgeApi } from './electron/server/surgeApi.js' import { handlePollinationsApi } from './electron/server/pollinationsApi.js' import { handlePixabayApi } from './electron/server/pixabayApi.js' import { handleTavilyApi } from './electron/server/tavilyApi.js' import { handleSeoScanApi } from './electron/server/seoScanApi.js' import { resolveAddresses } from './electron/server/dnsLookup.js' import { withSavedProvider } from './electron/server/savedProvider.js' import { hostAllowed } from './electron/server/hosts.js' import { ensureWhisperAssets } from './scripts/fetch-whisper-model.js' import { licensePlugin } from './scripts/licenses.js' // Vite's connect server does not catch rejections from async middleware; on // modern Node that kills the whole dev server, so the proxy goes through this // and an upstream failure costs one request, not the server. (Stream errors // are handled in sendWebResponse.) function guarded(handler) { return async (req, res, next) => { try { await handler(req, res, next) } catch (err) { if (err?.status === 413 && !res.headersSent) { res.statusCode = 413 res.setHeader('content-type', 'application/json') res.end(JSON.stringify({ error: err.message })) return } console.error('[dev-proxy] handler error:', err) if (!res.headersSent) { res.statusCode = 502 res.end('Proxy error') } else { res.destroy() } } } } // Prints which AI provider/mode is active (and what is still missing) once the // dev server is up, so a misconfigured .env is obvious before the first request. function configSummaryPlugin(mode) { return { name: 'appblips-config-summary', configureServer(server) { server.httpServer?.once('listening', () => { // Same env the middleware below reads: .env plus the process environment. console.log('\n' + formatConfigSummary(describeConfig(loadEnv(mode, process.cwd(), ''))) + '\n') }) }, } } // Runs the desktop app's LLM proxy handler (electron/server/chatProxy.js) as // dev-server middleware, so the renderer can be developed in a browser with // hot-reload. This is for working on AppBlips itself; to run a copy in a // browser, use server.js (npm start, or Docker). Reads the OPENAI_* LLM // variables from a local .env with no prefix filter: these never reach the // client bundle since they aren't VITE_-prefixed and are only read here, in // Node config code. function llmProxyDevMiddleware(mode) { return { name: 'appblips-llm-proxy-dev-middleware', configureServer(server) { const env = loadEnv(mode, process.cwd(), '') // The Settings → AI key, kept server-side as server.js does. These routes // run before Vite's own host check, so even when the server is put on the // network (`npm run dev -- --host`) the saved key is used only from this // computer (or a host in APPBLIPS_ALLOWED_HOSTS), never by another device // or a DNS-rebinding page. const keys = createKeyFile({ file: defaultKeyFile(env) }) server.middlewares.use('/api/chat', guarded(async (req, res) => { if (req.method !== 'POST') { res.statusCode = 405 res.end('Method not allowed') return } // A DNS-rebinding page sends a matching Host and Origin, so the Host // allow-list decides, as in server.js: neither the saved key nor the // .env provider is spent for another name. if (!hostAllowed(req.headers.host || '', env)) { res.statusCode = 403 res.end('AI requests are only answered for this computer. To allow this address, add it to APPBLIPS_ALLOWED_HOSTS.') return } const response = await handleChatProxy(await withSavedProvider(await toChatRequest(req, res), keys), env) sendWebResponse(res, response, {}, '[dev-proxy]') })) server.middlewares.use('/api/provider', guarded(async (req, res) => { if (!hostAllowed(req.headers.host || '', env)) { res.statusCode = 403 res.end('Settings → AI is only available on this computer. To allow this address, add it to APPBLIPS_ALLOWED_HOSTS.') return } const response = await handleProviderApi(await toFetchRequest(req, res), env, keys) sendWebResponse(res, response, {}, '[dev-proxy]') })) // Publish to Surge, with the Surge token kept server-side the same way. const surgeTokens = createKeyFile({ file: defaultKeyFile(env, 'surge-token.json') }) server.middlewares.use('/api/surge', guarded(async (req, res) => { if (!hostAllowed(req.headers.host || '', env)) { res.statusCode = 403 res.end('Publishing is only available on this computer. To allow this address, add it to APPBLIPS_ALLOWED_HOSTS.') return } const response = await handleSurgeApi(await toFetchRequest(req, res), env, surgeTokens) sendWebResponse(res, response, {}, '[dev-proxy]') })) // The Pollinations connector's sign-in, with its key kept the same way. const pollinationsKeys = createKeyFile({ file: defaultKeyFile(env, 'pollinations-key.json') }) server.middlewares.use('/api/pollinations', guarded(async (req, res) => { if (!hostAllowed(req.headers.host || '', env)) { res.statusCode = 403 res.end('Pollinations sign-in is only available on this computer. To allow this address, add it to APPBLIPS_ALLOWED_HOSTS.') return } const response = await handlePollinationsApi(await toFetchRequest(req, res), env, pollinationsKeys) sendWebResponse(res, response, {}, '[dev-proxy]') })) // The Pixabay connector's photo search, with the user's key kept the same way. const pixabayKeys = createKeyFile({ file: defaultKeyFile(env, 'pixabay-key.json') }) server.middlewares.use('/api/pixabay', guarded(async (req, res) => { if (!hostAllowed(req.headers.host || '', env)) { res.statusCode = 403 res.end('Pixabay search is only available on this computer. To allow this address, add it to APPBLIPS_ALLOWED_HOSTS.') return } const response = await handlePixabayApi(await toFetchRequest(req, res), env, pixabayKeys) sendWebResponse(res, response, {}, '[dev-proxy]') })) // The Tavily connector's web search for the AI, with the user's key kept the same way. const tavilyKeys = createKeyFile({ file: defaultKeyFile(env, 'tavily-key.json') }) server.middlewares.use('/api/tavily', guarded(async (req, res) => { if (!hostAllowed(req.headers.host || '', env)) { res.statusCode = 403 res.end('Tavily web search is only available on this computer. To allow this address, add it to APPBLIPS_ALLOWED_HOSTS.') return } const response = await handleTavilyApi(await toFetchRequest(req, res), env, tavilyKeys) sendWebResponse(res, response, {}, '[dev-proxy]') })) // The SEO connector's one-time read of the user's own website. server.middlewares.use('/api/seo/scan', guarded(async (req, res) => { if (!hostAllowed(req.headers.host || '', env)) { res.statusCode = 403 res.end('Scanning a site is only available on this computer. To allow this address, add it to APPBLIPS_ALLOWED_HOSTS.') return } const response = await handleSeoScanApi(await toFetchRequest(req, res), env, { lookup: resolveAddresses }) sendWebResponse(res, response, {}, '[dev-proxy]') })) }, } } // Voice input's Whisper model lives in public/whisper/ // (gitignored); this fetches them before Vite copies public/ or serves it, so // dev, every build and Docker/CI have them without a separate step. function whisperAssetsPlugin() { return { name: 'appblips-whisper-assets', async buildStart() { await ensureWhisperAssets() }, } } // Packages bundled into the app and its voice-input worker, for Settings → // Licenses. The worker is a separate build that finishes before the app's, so // both feed one collection and the app's plugin checks it (scripts/licenses.js). const appLicenseModules = new Set() // https://vite.dev/config/ export default defineConfig(({ mode }) => ({ // The app's version, for the update check (src/lib/updates.js). define: { __APPBLIPS_VERSION__: JSON.stringify(JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version), }, plugins: [ react(), whisperAssetsPlugin(), configSummaryPlugin(mode), llmProxyDevMiddleware(mode), licensePlugin('app', { collected: appLicenseModules }), ], // The voice-input worker imports transformers.js, which splits into chunks; // only module workers can load those. worker: { format: 'es', plugins: () => [licensePlugin('app', { collected: appLicenseModules, finish: false })], }, // Pre-bundling moves onnxruntime-web into .vite/deps, away from the .wasm // file it loads relative to itself. optimizeDeps: { exclude: ['@huggingface/transformers'], }, server: { // Loopback only, like server.js: `true` (0.0.0.0) also answered on every // other interface, including a Tailscale or LAN address. SSH/VS Code port // forwarding still works, since it connects to localhost on this machine. // `npm run dev -- --host` opts back in for a one-off phone test. host: '127.0.0.1', port: 5175, // Browser mode keeps the AI provider choice in this origin's localStorage; // a silent hop to 5176 when 5175 is busy is a new origin with none saved. strictPort: true, // Vite serves any file under the project to whoever reaches it (with // --host, the network). Its defaults (the first four) cover .env and // certificates; the rest are local files that aren't in the public repo: // old databases and Claude Code settings. fs: { deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '*.db', '*.db-*', '**/.claude/**', '**/pinokio/**'], }, watch: { usePolling: true }, headers: { // frame-ancestors is ignored in a tag, so it has to be a real // header. The desktop app sends these too (electron/main.js). 'Content-Security-Policy': "frame-ancestors 'none'", 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'Cross-Origin-Opener-Policy': 'same-origin-allow-popups' } }, build: { chunkSizeWarningLimit: 700, rollupOptions: { output: { manualChunks(id) { // Vite's dynamic-import helper otherwise lands in whichever chunk // first needs it (vendor_android), which every page then preloads. if (id.includes('vite/preload-helper')) return 'vendor'; if (id.includes('node_modules')) { // Only imported codebases use these, through dynamic import(), so // keep them out of the eager vendor chunk. if (id.includes('esbuild-wasm') || id.includes('fflate')) return 'vendor_codebase'; // Only the Android app loads these (src/lib/android.js). if (id.includes('@capacitor') || id.includes('capacitor-secure-storage-plugin') || id.includes('@anthropic-ai')) return 'vendor_android'; if (id.includes('lucide-react')) return 'vendor_icons'; if (id.includes('react')) return 'vendor_react'; return 'vendor'; } } } } } }))