import fs from 'node:fs' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import tailwindcss from '@tailwindcss/vite' import { VitePWA } from 'vite-plugin-pwa' import { fileURLToPath, URL } from 'node:url' // Opt-in HTTPS for the dev server, off by default so the usual // http://localhost:5173 workflow is unchanged. Only needed if you want to test // the installed PWA from a phone over the LAN *without* using // chrome://flags/#unsafely-treat-insecure-origin-as-secure -- and note Chrome // needs the mkcert root CA installed on the device for the origin to be // service-worker eligible; clicking through a cert warning is not enough. // DEV_HTTPS=1 pnpm dev const certDir = fileURLToPath(new URL('./certs', import.meta.url)) const keyPath = `${certDir}/192.168.1.164+4-key.pem` const certPath = `${certDir}/192.168.1.164+4.pem` const useHttps = process.env.DEV_HTTPS === '1' && fs.existsSync(keyPath) && fs.existsSync(certPath) export default defineConfig({ plugins: [ vue({ template: { transformAssetUrls: { includeAbsolute: false }, }, }), tailwindcss(), VitePWA({ // Updates are user-driven via the prompt in src/registerServiceWorker.js registerType: 'prompt', injectRegister: false, // Lands at dist/sw.js -- root of the served directory, so the worker gets // root scope without needing a Service-Worker-Allowed header. filename: 'sw.js', // We ship public/manifest.json by hand rather than generating one. manifest: false, workbox: { // // SECURITY INVARIANT -- do not relax without thinking it through. // // Solid Scribe is end-to-end encrypted. The service worker precaches // ONLY static build output and is network-only for /api/*. Caching an // API response would write decrypted note content into Cache Storage on // disk, where it survives logout and sits outside the encryption model. // // Structurally this holds because globPatterns only ever reads dist/, // and user content is served by Express from staticFiles/ at // /api/static -- which never enters dist. The NetworkOnly rule below is // belt-and-braces: it documents the invariant and stops a future // runtimeCaching entry from silently widening the net. // // woff2 is NOT in Workbox's default globPatterns. Without it the Roboto // files are skipped and the offline shell renders in a fallback face. globPatterns: ['**/*.{js,css,html,woff2,png,svg,ico,json}'], // Vendor chunks (fomantic, chart.js) exceed the 2MiB default. An // oversized file is silently OMITTED from the precache, which half // breaks the offline shell -- watch the build output for a warning. maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, navigateFallback: '/index.html', // Matters for top-level navigations to /api/static/ (clicking an // attachment link) -- without this the worker answers with index.html. navigateFallbackDenylist: [/^\/api\//, /^\/socket/], cleanupOutdatedCaches: true, // Let the user decide when to swap assets, rather than doing it // mid-session underneath them. clientsClaim: false, skipWaiting: false, // // runtimeCaching is deliberately EMPTY. Do not add an entry for /api/* // or /socket -- not even NetworkOnly. // // There used to be a NetworkOnly route for both, as belt-and-braces for // the invariant above. It was worse than useless. registerRoute() makes // the worker call event.respondWith() for every matching request, and // Socket.IO's long-poll requests are aborted as a matter of course -- // on reconnect, on transport upgrade, and whenever Android backgrounds // the page. An aborted fetch inside NetworkOnly throws Workbox's // `no-response`, the respondWith promise rejects, and Socket.IO sees a // dead transport and reconnects. The installed PWA was dropping its // live note sync on a loop and filling the logs with // "FetchEvent ... resulted in a network error response". // // With no route registered the worker never intercepts these at all and // the browser handles them natively, exactly as in a normal tab. // // Nothing is lost on the security side: the invariant is enforced by // globPatterns only ever reading dist/, and by there being no *caching* // strategy here. NetworkOnly enforced nothing -- it only inserted the // worker into the hot path of every poll. // }, // Flip to true temporarily to exercise the worker on the dev server. // Remember to unregister it afterwards -- a lingering dev service worker // is a classic "why is my phone showing an ancient build". devOptions: { enabled: false, type: 'module' }, }), ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, server: { host: '0.0.0.0', ...(useHttps ? { https: { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) } } : {}), proxy: { '/api': 'http://localhost:3000', '/socket': { target: 'http://localhost:3001', ws: true }, }, }, })