import { defineConfig, type Plugin } from "vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; import tailwindcss from "@tailwindcss/vite"; import { cloudflare } from "@cloudflare/vite-plugin"; import { serwist } from "@serwist/vite"; import path from "path"; import { cpSync, existsSync, readFileSync } from "node:fs"; import { parseEnv } from "node:util"; // Captured once per build. Used as the revision for the precached Worker // rendered shell so every deploy refreshes the offline fallback copy. const SHELL_PRECACHE_REVISION = Date.now().toString(36); // Non-user-facing URL for the precached shell. Using a URL that no visitor // ever navigates to keeps Serwist's precache route (registered before any // runtime route) from intercepting real `/` navigations; that would serve // an install-time cached shell to online users, reintroducing the stale // deploy hazard. The Worker treats any non-asset, non-api GET path as the // SPA shell (see `src/worker/lib/http-entry.ts`), so this path resolves to // `renderSpaShell` at SW install time. const SHELL_PRECACHE_URL = "/__pwa-shell"; const persistStatePath = process.env.BLAND_PERSIST_STATE_PATH; const ignoredWatchPaths = ["docs/**", "scripts/**", "playwright-report/**", "test-results/**", "tests/**"]; const fontAssetPattern = /\.(?:woff2?|ttf|otf|eot)$/i; function readDevVarsAiMode(): string { const cloudflareEnv = process.env.CLOUDFLARE_ENV; const devVarsPaths = [ ...(cloudflareEnv ? [path.resolve(import.meta.dirname, `.dev.vars.${cloudflareEnv}`)] : []), path.resolve(import.meta.dirname, ".dev.vars"), ]; for (const devVarsPath of devVarsPaths) { if (!existsSync(devVarsPath)) continue; return parseEnv(readFileSync(devVarsPath, "utf8")).BLAND_AI_MODE || "workers-ai"; } return "workers-ai"; } const useRemoteBindings = readDevVarsAiMode() === "workers-ai"; // Excalidraw defaults to fetching its font .woff2 files from esm.sh. Self-host // by copying `dist/prod/fonts/*` from node_modules into `public/fonts/`; at // mount time we set `window.EXCALIDRAW_ASSET_PATH = "/"` so Excalidraw's // internal `@font-face` URLs resolve against same-origin. Runs during // `configureServer` (dev) and `buildStart` (build) so both `npm run dev` and // `npm run build` pick up the assets. The esm.sh origin stays in CSP // `font-src` as a fallback; see `src/worker/lib/security-headers.ts`. function excalidrawFontsPlugin(): Plugin { const src = path.resolve(import.meta.dirname, "node_modules/@excalidraw/excalidraw/dist/prod/fonts"); const dest = path.resolve(import.meta.dirname, "public/fonts"); const sentinel = path.join(dest, "Virgil", "Virgil-Regular.woff2"); const copy = () => { if (!existsSync(src) || existsSync(sentinel)) return; cpSync(src, dest, { recursive: true, force: false }); }; return { name: "excalidraw-fonts-copy", configureServer: copy, buildStart: copy, }; } export default defineConfig({ environments: { bland: { build: { sourcemap: true, }, }, }, plugins: [ react(), babel({ presets: [reactCompilerPreset()], }), tailwindcss(), excalidrawFontsPlugin(), cloudflare({ ...(persistStatePath ? { persistState: { path: persistStatePath } } : {}), remoteBindings: useRemoteBindings, }), serwist({ swSrc: "src/client/service-worker.ts", // Serwist runs in Vite's client build, whose outDir is `dist/client`. swDest: "sw.js", swUrl: "/sw.js", scope: "/", type: "classic", rollupFormat: "iife", injectionPoint: "self.__SW_MANIFEST", globDirectory: "dist/client", // Precache a fetched copy of the Worker-rendered shell at SW install so // an installed app launched offline still boots. The revision changes per // build so each deploy refreshes the entry. See `SHELL_PRECACHE_URL` for // why the URL is non-user-facing. additionalPrecacheEntries: [{ url: SHELL_PRECACHE_URL, revision: SHELL_PRECACHE_REVISION }], // The Excalidraw vendor chunk is ~4.75 MB; raising the default 2 MB cap // so the canvas still works offline (goal: cache all assets). maximumFileSizeToCacheInBytes: 8 * 1024 * 1024, globPatterns: ["**/*.{js,css,woff,woff2,ttf,png,svg,ico,webmanifest,json}"], globIgnores: ["**/index.html", "**/sw.js", "**/workbox-*.js"], }), ], server: { watch: { ignored: ignoredWatchPaths, }, }, resolve: { alias: { "@": path.resolve(import.meta.dirname, "src"), }, }, build: { assetsDir: "app-assets", assetsInlineLimit: (filePath) => (fontAssetPattern.test(filePath) ? false : undefined), chunkSizeWarningLimit: 1000, manifest: "manifest.json", rollupOptions: { input: { index: path.resolve(import.meta.dirname, "index.html"), }, output: { codeSplitting: { groups: [ { name: "vendor-react", test: /[\\/]node_modules[\\/](react|react-dom|scheduler|use-sync-external-store)[\\/]/, priority: 100, entriesAware: true, }, { name: "vendor-prosemirror", test: /[\\/]prosemirror-|[\\/]@tiptap[\\/]pm[\\/]/, priority: 40, entriesAware: true, }, { name: "vendor-tiptap", test: /[\\/]@tiptap[\\/]/, priority: 30, entriesAware: true, }, { name: "vendor-yjs", test: /[\\/](yjs|y-protocols|y-indexeddb|y-partyserver|partysocket|lib0)[\\/]/, priority: 30, entriesAware: true, }, { name: "vendor-sentry", test: /[\\/]@sentry[\\/]/, priority: 20, entriesAware: true, }, { name: "vendor-excalidraw", test: /[\\/]@excalidraw[\\/]/, priority: 20, entriesAware: true, }, ], }, }, }, }, });