vite.config.js
Rama master · réplica de lectura del repositorio original.
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 <meta> 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';
}
}
}
}
}
}))