You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
442 lines
15 KiB
442 lines
15 KiB
import react from '@vitejs/plugin-react' |
|
import { execSync } from 'child_process' |
|
import path from 'path' |
|
import type { Plugin } from 'vite' |
|
import { loadEnv } from 'vite' |
|
import { defineConfig } from 'vitest/config' |
|
import { VitePWA } from 'vite-plugin-pwa' |
|
import packageJson from './package.json' |
|
/// <reference types="vitest" /> |
|
|
|
const getGitHash = () => { |
|
try { |
|
return JSON.stringify(execSync('git rev-parse --short HEAD').toString().trim()) |
|
} catch (error) { |
|
console.warn('Failed to retrieve commit hash:', error) |
|
return '"unknown"' |
|
} |
|
} |
|
|
|
const getAppVersion = () => { |
|
try { |
|
return JSON.stringify(packageJson.version) |
|
} catch (error) { |
|
console.warn('Failed to retrieve app version:', error) |
|
return '"unknown"' |
|
} |
|
} |
|
|
|
/** |
|
* React Fast Refresh can remount provider children without matching context after editing providers |
|
* or pages. Full page reload keeps the tree consistent. `nostr-context.tsx` fixes duplicate Nostr |
|
* `createContext` identity across HMR for most cases. |
|
*/ |
|
function fullReloadOnProvidersAndPages(): Plugin { |
|
return { |
|
name: 'full-reload-providers-pages', |
|
apply: 'serve', |
|
handleHotUpdate({ file, server }) { |
|
const normalized = file.replace(/\\/g, '/') |
|
if (normalized.includes('/src/providers/') || normalized.includes('/src/pages/')) { |
|
server.ws.send({ type: 'full-reload' }) |
|
return [] |
|
} |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Default proxy logs one multiline error + stack per failed request when the index relay is down. |
|
* Throttle to one hint: match `/api/events` paths (dev-index-relay), not other proxies like `/sites`. |
|
*/ |
|
function quietDevIndexRelayProxyErrors(devIndexRelayTarget: string): Plugin { |
|
let lastSuppressedLog = 0 |
|
const COOLDOWN_MS = 60_000 |
|
|
|
return { |
|
name: 'quiet-dev-index-relay-proxy-errors', |
|
apply: 'serve', |
|
configResolved(config) { |
|
const prevError = config.logger.error.bind(config.logger) |
|
config.logger.error = (msg, options) => { |
|
const text = typeof msg === 'string' ? msg : '' |
|
if ( |
|
text.includes('http proxy error') && |
|
text.includes('ECONNREFUSED') && |
|
text.includes('/api/events') |
|
) { |
|
const now = Date.now() |
|
if (now - lastSuppressedLog >= COOLDOWN_MS) { |
|
lastSuppressedLog = now |
|
config.logger.warn( |
|
`[vite] Dev index relay not reachable (${devIndexRelayTarget}). Start it or set VITE_DEV_INDEX_RELAY_TARGET. Suppressing duplicate proxy errors for ${COOLDOWN_MS / 1000}s.` |
|
) |
|
} |
|
return |
|
} |
|
prevError(msg, options) |
|
} |
|
} |
|
} |
|
} |
|
|
|
// https://vite.dev/config/ |
|
export default defineConfig(({ mode }) => { |
|
// `.env.local` is not on `process.env` when this file is evaluated unless we load it. |
|
const env = loadEnv(mode, process.cwd(), '') |
|
/** gc_index_relay (or compatible) HTTP API; app POSTs to /api/events/filter. HTTP 500 in the browser means this process errored, not that Vite failed. */ |
|
const devIndexRelayTarget = |
|
env.VITE_DEV_INDEX_RELAY_TARGET?.trim() || 'http://127.0.0.1:4000' |
|
|
|
/** |
|
* Desktop shell (`vite build --mode electron`): always bake public Imwald API origins into the bundle. |
|
* Relying on `cross-env` in npm scripts alone is fragile (skipped builds, CI, wrong script). `define` |
|
* wins over empty/missing `.env.*` for these keys. |
|
*/ |
|
const electronImwaldPublicDefaults = { |
|
VITE_PROXY_SERVER: 'https://jumble.imwald.eu', |
|
VITE_READ_ALOUD_TTS_URL: 'https://jumble.imwald.eu/api/piper-tts', |
|
VITE_LANGUAGE_TOOL_URL: 'https://jumble.imwald.eu/api/languagetool', |
|
VITE_TRANSLATE_URL: 'https://jumble.imwald.eu/api/translate' |
|
} as const |
|
const electronImwaldDefines: Record<string, string> = |
|
mode === 'electron' |
|
? Object.fromEntries( |
|
( |
|
[ |
|
'VITE_PROXY_SERVER', |
|
'VITE_READ_ALOUD_TTS_URL', |
|
'VITE_LANGUAGE_TOOL_URL', |
|
'VITE_TRANSLATE_URL' |
|
] as const |
|
).map((key) => { |
|
const fromEnv = env[key]?.trim() |
|
const fallback = electronImwaldPublicDefaults[key] |
|
return [`import.meta.env.${key}`, JSON.stringify(fromEnv || fallback)] |
|
}) |
|
) |
|
: {} |
|
|
|
return { |
|
base: '/', |
|
define: { |
|
'import.meta.env.GIT_COMMIT': getGitHash(), |
|
'import.meta.env.APP_VERSION': getAppVersion(), |
|
...electronImwaldDefines |
|
}, |
|
resolve: { |
|
alias: { |
|
'@': path.resolve(__dirname, './src') |
|
} |
|
}, |
|
server: { |
|
// OG/link preview uses `/sites/?url=…`. Without this, Vite serves `index.html` and WebService parses the app shell. |
|
// Run the scraper on 8090 per PROXY_SETUP.md, or rely on allorigins fallback in dev (web.service.ts). |
|
proxy: { |
|
// Read-aloud Piper: same path as production Apache → aitherboard (avoid cross-origin CORS in dev). |
|
'/api/piper-tts': { |
|
target: 'http://127.0.0.1:9876', |
|
changeOrigin: true |
|
}, |
|
'/api/languagetool': { |
|
target: 'http://127.0.0.1:8010', |
|
changeOrigin: true, |
|
rewrite: (p) => p.replace(/^\/api\/languagetool/u, '') || '/' |
|
}, |
|
'/api/translate': { |
|
target: 'http://127.0.0.1:5000', |
|
changeOrigin: true, |
|
rewrite: (p) => p.replace(/^\/api\/translate/u, '') || '/' |
|
}, |
|
'/sites': { |
|
target: 'http://127.0.0.1:8090', |
|
changeOrigin: true |
|
}, |
|
// Loopback HTTP index relay: `import.meta.env.DEV` rewrites kind 10243 URLs through this path. |
|
'/dev-index-relay': { |
|
target: devIndexRelayTarget, |
|
changeOrigin: true, |
|
rewrite: (p) => p.replace(/^\/dev-index-relay/, '') || '/' |
|
} |
|
} |
|
}, |
|
build: { |
|
rollupOptions: { |
|
output: { |
|
manualChunks(id) { |
|
const norm = id.replace(/\\/g, '/') |
|
|
|
// One chunk per locale file — `i18n/index` statically imports all of them; splitting keeps |
|
// the main app chunk smaller and allows parallel fetch + finer cache invalidation. |
|
const localeMatch = norm.match(/\/i18n\/locales\/([^/]+)\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/) |
|
if (localeMatch) { |
|
const code = localeMatch[1].replace(/[^a-zA-Z0-9_-]/g, '_') |
|
return `i18n-locale-${code}` |
|
} |
|
|
|
if (!norm.includes('node_modules')) return undefined |
|
|
|
// Lazy-loaded only — must not share a chunk with sync vendors or it gets preloaded |
|
if (norm.includes('@asciidoctor')) { |
|
return 'vendor-asciidoctor' |
|
} |
|
|
|
if (norm.includes('/katex/') || norm.includes('node_modules/katex/')) { |
|
return 'vendor-katex' |
|
} |
|
|
|
// React core (load first; keep together) |
|
if (/node_modules\/(react-dom|react\/|scheduler\/|use-sync-external-store\/)/.test(norm)) { |
|
return 'vendor-react' |
|
} |
|
|
|
// ProseMirror vs TipTap — avoids one ~750k editor blob; both load together when editor mounts |
|
if (norm.includes('prosemirror-')) { |
|
return 'vendor-prosemirror' |
|
} |
|
if (norm.includes('@tiptap')) { |
|
return 'vendor-tiptap' |
|
} |
|
|
|
// Radix UI primitives |
|
if (norm.includes('@radix-ui')) { |
|
return 'vendor-radix' |
|
} |
|
|
|
// Nostr + crypto used by the stack |
|
if ( |
|
norm.includes('nostr-tools') || |
|
norm.includes('@noble') || |
|
norm.includes('@scure') |
|
) { |
|
return 'vendor-nostr' |
|
} |
|
|
|
if (norm.includes('lucide-react')) { |
|
return 'vendor-lucide' |
|
} |
|
|
|
if (norm.includes('i18next') || norm.includes('react-i18next')) { |
|
return 'vendor-i18n-runtime' |
|
} |
|
|
|
if (norm.includes('@dnd-kit')) { |
|
return 'vendor-dnd' |
|
} |
|
|
|
if (norm.includes('highlight.js') || norm.includes('/src/lib/highlight')) { |
|
return 'vendor-highlight' |
|
} |
|
|
|
if (norm.includes('flexsearch')) { |
|
return 'vendor-flexsearch' |
|
} |
|
|
|
if (norm.includes('emoji-picker-element')) { |
|
return 'vendor-emoji' |
|
} |
|
|
|
if (norm.includes('yet-another-react-lightbox')) { |
|
return 'vendor-lightbox' |
|
} |
|
|
|
if (norm.includes('@getalby') || norm.includes('bitcoin-connect')) { |
|
return 'vendor-lightning-alby' |
|
} |
|
if (norm.includes('nstart-modal')) { |
|
return 'vendor-lightning-nstart' |
|
} |
|
|
|
if (norm.includes('embla-carousel')) { |
|
return 'vendor-embla' |
|
} |
|
|
|
if (norm.includes('qr-code-styling') || norm.includes('/qr-scanner/')) { |
|
return 'vendor-qr' |
|
} |
|
|
|
if (norm.includes('/cmdk/')) { |
|
return 'vendor-cmdk' |
|
} |
|
|
|
if (norm.includes('/vaul/')) { |
|
return 'vendor-vaul' |
|
} |
|
|
|
if (norm.includes('tippy.js')) { |
|
return 'vendor-tippy' |
|
} |
|
|
|
if (norm.includes('/zod/') || norm.includes('node_modules/zod')) { |
|
return 'vendor-zod' |
|
} |
|
|
|
if (norm.includes('/dayjs/')) { |
|
return 'vendor-dayjs' |
|
} |
|
|
|
if (norm.includes('/sonner/')) { |
|
return 'vendor-sonner' |
|
} |
|
|
|
if (norm.includes('blossom-client-sdk')) { |
|
return 'vendor-blossom' |
|
} |
|
|
|
if (norm.includes('@popperjs')) { |
|
return 'vendor-popper' |
|
} |
|
|
|
if (norm.includes('@floating-ui')) { |
|
return 'vendor-floating-ui' |
|
} |
|
|
|
if (norm.includes('/blurhash/') || norm.includes('node_modules/blurhash')) { |
|
return 'vendor-blurhash' |
|
} |
|
|
|
if (norm.includes('/dataloader/') || norm.includes('node_modules/dataloader')) { |
|
return 'vendor-dataloader' |
|
} |
|
|
|
if ( |
|
norm.includes('tailwind-merge') || |
|
norm.includes('/clsx/') || |
|
norm.includes('class-variance-authority') |
|
) { |
|
return 'vendor-clsx-tailwind' |
|
} |
|
|
|
return 'vendor-misc' |
|
} |
|
}, |
|
onwarn(warning, warn) { |
|
// Suppress vite:reporter warnings about mixed static/dynamic imports |
|
// These are informational warnings about code splitting, not errors |
|
if (warning.plugin === 'vite:reporter' && warning.message.includes('dynamically imported') && warning.message.includes('statically imported')) { |
|
return |
|
} |
|
// Use default warning handler for other warnings |
|
warn(warning) |
|
} |
|
} |
|
}, |
|
test: { |
|
globals: true, |
|
environment: 'jsdom', |
|
setupFiles: './src/test/setup.ts' |
|
}, |
|
plugins: [ |
|
react(), |
|
fullReloadOnProvidersAndPages(), |
|
quietDevIndexRelayProxyErrors(devIndexRelayTarget), |
|
VitePWA({ |
|
registerType: 'autoUpdate', |
|
// Use public/manifest.webmanifest and index.html <link> only; avoid duplicate manifest link in build |
|
manifest: false, |
|
workbox: { |
|
globPatterns: ['**/*.{js,css,html,png,jpg,svg,ico,webmanifest}'], |
|
globDirectory: 'dist/', |
|
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, |
|
cleanupOutdatedCaches: true, |
|
skipWaiting: true, |
|
clientsClaim: true, |
|
navigateFallback: '/index.html', |
|
navigateFallbackDenylist: [/^\/api\//, /^\/_/, /^\/admin/], |
|
// Exclude source files and development files from precaching |
|
globIgnores: [ |
|
'**/src/**', |
|
'**/node_modules/**', |
|
'**/*.map', |
|
'**/sw.js', |
|
'**/workbox-*.js' |
|
], |
|
runtimeCaching: [ |
|
{ |
|
// Exclude upload endpoints from service worker handling - use NetworkOnly to bypass cache |
|
// Match various upload URL patterns - comprehensive regex to catch all upload services |
|
// This ensures uploads (POST) and discovery endpoints (GET) bypass the service worker |
|
// Note: XMLHttpRequest should bypass service workers, but we add this as a safety measure |
|
urlPattern: ({ url, request }) => { |
|
const urlString = url.toString() |
|
const method = request.method?.toUpperCase() || 'GET' |
|
|
|
// Always bypass service worker for POST requests (uploads) |
|
if (method === 'POST') { |
|
return /(?:api\/v2\/nip96\/upload|\.well-known\/nostr\/nip96\.json|nostr\.build|nostrcheck\.me|void\.cat|\/upload|\/nip96\/)/i.test(urlString) |
|
} |
|
|
|
// Also bypass for GET requests to upload-related endpoints |
|
return /(?:\.well-known\/nostr\/nip96\.json|api\/v2\/nip96\/upload)/i.test(urlString) |
|
}, |
|
handler: 'NetworkOnly' |
|
}, |
|
{ |
|
// Well-known nostr media CDNs: cache aggressively since content is addressed by hash |
|
urlPattern: |
|
/^https:\/\/(?:image\.nostr\.build|cdn\.satellite\.earth|nostrimg\.com|void\.cat\/d|files\.sovbit\.host|cdn\.hzrd149\.com|blossom\.band|r2[a-z]?\.primal\.net)\/.*/i, |
|
handler: 'CacheFirst', |
|
options: { |
|
cacheName: 'nostr-media-cdn', |
|
expiration: { |
|
maxEntries: 300, |
|
maxAgeSeconds: 60 * 24 * 60 * 60 // 60 days — hash-addressed, effectively immutable |
|
}, |
|
// Only cache genuine 200 OK responses; prevents opaque/error responses from |
|
// filling storage quota with unusable entries. |
|
cacheableResponse: { statuses: [200] } |
|
} |
|
}, |
|
{ |
|
// Generic cross-origin images by file extension (covers hosts not matched above) |
|
urlPattern: /^https?:\/\/.+\.(?:png|jpg|jpeg|gif|webp|avif|svg|ico)(?:\?.*)?$/i, |
|
handler: 'CacheFirst', |
|
options: { |
|
cacheName: 'external-images', |
|
expiration: { |
|
maxEntries: 300, |
|
maxAgeSeconds: 7 * 24 * 60 * 60 // 7 days |
|
}, |
|
cacheableResponse: { statuses: [200] } |
|
} |
|
}, |
|
{ |
|
// Audio files (podcasts, voice notes) — stale-while-revalidate so playback starts |
|
// immediately from cache while the network check runs in the background. |
|
urlPattern: /^https?:\/\/.+\.(?:mp3|ogg|opus|flac|m4a|aac|wav)(?:\?.*)?$/i, |
|
handler: 'StaleWhileRevalidate', |
|
options: { |
|
cacheName: 'external-audio', |
|
expiration: { |
|
maxEntries: 30, |
|
maxAgeSeconds: 7 * 24 * 60 * 60 // 7 days |
|
}, |
|
cacheableResponse: { statuses: [200] } |
|
} |
|
}, |
|
{ |
|
// NIP-11 relay info documents: short-lived cache so relay metadata is fresh but |
|
// the app can render offline or on a slow connection without blocking on network. |
|
urlPattern: ({ request }: { request: Request }) => |
|
request.headers.get('accept')?.includes('application/nostr+json') ?? false, |
|
handler: 'StaleWhileRevalidate', |
|
options: { |
|
cacheName: 'nip11-relay-info', |
|
expiration: { |
|
maxEntries: 100, |
|
maxAgeSeconds: 60 * 60 // 1 hour |
|
}, |
|
cacheableResponse: { statuses: [200] } |
|
} |
|
} |
|
] |
|
}, |
|
devOptions: { |
|
// Disable in dev to avoid registerSW.js 404 → index.html returned → SyntaxError (expected expression, got '<') |
|
enabled: false, |
|
type: 'module' |
|
} |
|
}) |
|
] |
|
} |
|
})
|
|
|