From ff49649ca4cacced842d43304d52170651013e81 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:49:32 +0000 Subject: [PATCH] feat: robust cache busting on deployment - Vite build: explicit content-hash filenames (entryFileNames, chunkFileNames, assetFileNames) - Vite plugin: inject BUILD_HASH + BUILD_TIMESTAMP globals, write build-manifest.json - Service worker: auto-versioned CACHE_VERSION from deploy script, stale cache cleanup on activate, client notification via postMessage - Express: content-hash-aware cache headers (immutable for hashed assets, no-cache for HTML/SW/manifests) - /api/version endpoint for client-side stale detection - Client: useVersionCheck hook polls /api/version, triggers SW update on mismatch - Deploy script: ops/deploy/cache-bust.sh stamps SW + purges CDN (Cloudflare/CloudFront/Fastly) - CI/CD: cache-bust job in deploy.yml between build and deploy stages 0 TypeScript errors, 1526/1528 tests passing (2 pre-existing). Co-Authored-By: Patrick Munis --- .github/workflows/deploy.yml | 28 ++++- client/public/sw.js | 35 +++++- client/src/App.tsx | 7 ++ client/src/build-meta.d.ts | 2 + client/src/hooks/useVersionCheck.ts | 69 ++++++++++ ops/deploy/cache-bust.sh | 147 ++++++++++++++++++++++ server/_core/vite.ts | 96 +++++++++++++- server/middleware/performanceHardening.ts | 6 +- vite.config.ts | 66 ++++++++++ 9 files changed, 446 insertions(+), 10 deletions(-) create mode 100644 client/src/build-meta.d.ts create mode 100644 client/src/hooks/useVersionCheck.ts create mode 100755 ops/deploy/cache-bust.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f8555077..f2876388 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -248,10 +248,36 @@ jobs: ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.sha }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:latest + # ─── Cache Busting ────────────────────────────────────────────────────────── + cache-bust: + name: Cache Bust (SW stamp + CDN purge) + needs: [build-api] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - run: npm ci + - name: Build frontend + run: npm run build + - name: Run cache busting + run: ./ops/deploy/cache-bust.sh --purge-cdn + env: + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} + - name: Upload cache-busted dist + uses: actions/upload-artifact@v4 + with: + name: dist-cache-busted + path: dist/public/ + retention-days: 7 + # ─── Deploy Staging ───────────────────────────────────────────────────────── deploy-staging: name: Deploy to Staging - needs: [build-api, build-go-services, build-rust-services, build-python-services, build-infra-services] + needs: [build-api, build-go-services, build-rust-services, build-python-services, build-infra-services, cache-bust] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || github.event.inputs.environment == 'staging' environment: staging diff --git a/client/public/sw.js b/client/public/sw.js index 7a3480de..3887fe4d 100644 --- a/client/public/sw.js +++ b/client/public/sw.js @@ -6,6 +6,10 @@ // v204 additions: Form M history, HNW banking, CBN compliance, BDC portal, SME trade routes cached // v23 additions: Tier 1 (Expense, Contractor, KYB, Payroll Tax), Tier 2 (Savings, Bonds, LC, Invoice Financing, Payroll Run), Tier 3 (Embedded Payroll, Mortgage, Credit Scoring, ESG) +// CACHE_VERSION is auto-set by the deploy pipeline (ops/deploy/cache-bust.sh). +// During development it falls back to 'v24'. On every production deploy the +// script rewrites this line with a content-derived hash so the SW byte-changes, +// which triggers the browser's update-check and forces re-installation. const CACHE_VERSION = 'v24'; const STATIC_CACHE = `remitflow-static-${CACHE_VERSION}`; const API_CACHE = `remitflow-api-${CACHE_VERSION}`; @@ -13,6 +17,14 @@ const FX_CACHE = `remitflow-fx-${CACHE_VERSION}`; const COMMUNITY_CACHE = `remitflow-community-${CACHE_VERSION}`; const REVENUE_SHARE_CACHE = `remitflow-revenue-share-${CACHE_VERSION}`; +const KNOWN_CACHE_PREFIXES = [ + 'remitflow-static-', + 'remitflow-api-', + 'remitflow-fx-', + 'remitflow-community-', + 'remitflow-revenue-share-', +]; + // Cache size limits per cache bucket — prevents unbounded storage growth const CACHE_SIZE_LIMITS = { [STATIC_CACHE]: 100, // 100 items max @@ -125,13 +137,30 @@ self.addEventListener('install', (event) => { }); self.addEventListener('activate', (event) => { + const currentCaches = new Set([STATIC_CACHE, API_CACHE, FX_CACHE, COMMUNITY_CACHE, REVENUE_SHARE_CACHE]); event.waitUntil( caches.keys().then((keys) => Promise.all( - keys.filter((k) => k.startsWith('remitflow-') && ![STATIC_CACHE, API_CACHE, FX_CACHE, REVENUE_SHARE_CACHE].includes(k)) - .map((k) => caches.delete(k)) + keys + .filter((k) => { + if (currentCaches.has(k)) return false; + return KNOWN_CACHE_PREFIXES.some((prefix) => k.startsWith(prefix)); + }) + .map((k) => { + console.log('[SW] Deleting stale cache:', k); + return caches.delete(k); + }) ) - ).then(() => self.clients.claim()) + ) + .then(() => self.clients.claim()) + .then(() => { + // Notify all clients that a new version is active + self.clients.matchAll({ type: 'window' }).then((clients) => { + clients.forEach((client) => { + client.postMessage({ type: 'SW_UPDATED', version: CACHE_VERSION }); + }); + }); + }) ); }); diff --git a/client/src/App.tsx b/client/src/App.tsx index 920f8128..453ce1fc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -7,6 +7,7 @@ import { lazy, Suspense } from "react"; import { PWAInstallPrompt, PWAOfflineBanner, PWAUpdateBanner } from "./components/PWAInstallPrompt"; import { MobileBottomNav } from "./components/MobileBottomNav"; import { ConnectionQualityIndicator } from "./components/ConnectionQualityIndicator"; +import { useVersionCheck } from "./hooks/useVersionCheck"; import { Loader2 } from "lucide-react"; const AMLBatchEnginePage = lazy(() => import("@/pages/AMLBatchEnginePage")); const PBACPolicies = lazy(() => import("@/pages/PBACPolicies")); @@ -747,11 +748,17 @@ function Router() { ); } +function VersionChecker() { + useVersionCheck(); + return null; +} + function App() { return ( + diff --git a/client/src/build-meta.d.ts b/client/src/build-meta.d.ts new file mode 100644 index 00000000..c1464cfe --- /dev/null +++ b/client/src/build-meta.d.ts @@ -0,0 +1,2 @@ +declare const __BUILD_HASH__: string; +declare const __BUILD_TIMESTAMP__: string; diff --git a/client/src/hooks/useVersionCheck.ts b/client/src/hooks/useVersionCheck.ts new file mode 100644 index 00000000..0d2b497d --- /dev/null +++ b/client/src/hooks/useVersionCheck.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef, useCallback } from "react"; + +const VERSION_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes +const currentBuildHash = typeof __BUILD_HASH__ !== "undefined" ? __BUILD_HASH__ : "dev"; + +interface VersionInfo { + hash: string; + timestamp: string; + version: string; +} + +export function useVersionCheck() { + const lastKnownHash = useRef(currentBuildHash); + + const checkVersion = useCallback(async () => { + if (currentBuildHash === "dev") return; + + try { + const res = await fetch("/api/version", { cache: "no-store" }); + if (!res.ok) return; + + const info: VersionInfo = await res.json(); + if (info.hash !== "dev" && info.hash !== lastKnownHash.current) { + lastKnownHash.current = info.hash; + + if ("serviceWorker" in navigator && navigator.serviceWorker.controller) { + const reg = await navigator.serviceWorker.getRegistration(); + if (reg) { + reg.update(); + } + } + } + } catch { + // Network error — skip this check + } + }, []); + + useEffect(() => { + // Check on mount (page load / navigation) + checkVersion(); + + // Periodic polling + const interval = setInterval(checkVersion, VERSION_CHECK_INTERVAL); + + // Check when tab becomes visible (user returns from another tab) + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + checkVersion(); + } + }; + document.addEventListener("visibilitychange", onVisibilityChange); + + // Listen for SW update messages + const onSWMessage = (event: MessageEvent) => { + if (event.data?.type === "SW_UPDATED") { + window.location.reload(); + } + }; + navigator.serviceWorker?.addEventListener("message", onSWMessage); + + return () => { + clearInterval(interval); + document.removeEventListener("visibilitychange", onVisibilityChange); + navigator.serviceWorker?.removeEventListener("message", onSWMessage); + }; + }, [checkVersion]); + + return { buildHash: currentBuildHash }; +} diff --git a/ops/deploy/cache-bust.sh b/ops/deploy/cache-bust.sh new file mode 100755 index 00000000..98694466 --- /dev/null +++ b/ops/deploy/cache-bust.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# RemitFlow — Deployment Cache Busting +# ───────────────────────────────────────────────────────────────────────────── +# Run this AFTER `npm run build` and BEFORE deploying to production. +# It performs three cache-busting steps: +# 1. Stamps the service worker with the build hash (forces SW update) +# 2. Generates a build-manifest.json (used by /api/version endpoint) +# 3. Optionally purges CDN caches (Cloudflare, CloudFront, Fastly) +# +# Usage: +# ./ops/deploy/cache-bust.sh # stamp only +# ./ops/deploy/cache-bust.sh --purge-cdn # stamp + CDN purge +# ./ops/deploy/cache-bust.sh --purge-cdn --dry-run # preview without executing +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +DIST_DIR="${REPO_ROOT}/dist/public" +SW_PATH="${DIST_DIR}/sw.js" +MANIFEST_PATH="${DIST_DIR}/build-manifest.json" + +PURGE_CDN=false +DRY_RUN=false + +for arg in "$@"; do + case "$arg" in + --purge-cdn) PURGE_CDN=true ;; + --dry-run) DRY_RUN=true ;; + esac +done + +# ─── Step 0: Validate build output exists ───────────────────────────────────── + +if [ ! -d "$DIST_DIR" ]; then + echo "ERROR: Build output not found at $DIST_DIR" + echo " Run 'npm run build' first." + exit 1 +fi + +# ─── Step 1: Compute build hash from dist contents ─────────────────────────── + +echo "→ Computing build hash..." +BUILD_HASH=$(find "$DIST_DIR" -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' \) \ + -exec sha256sum {} + | sort | sha256sum | cut -c1-12) +BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +BUILD_VERSION="v-${BUILD_HASH}" + +echo " Hash: ${BUILD_HASH}" +echo " Timestamp: ${BUILD_TIMESTAMP}" +echo " Version: ${BUILD_VERSION}" + +# ─── Step 2: Stamp the service worker ───────────────────────────────────────── + +if [ -f "$SW_PATH" ]; then + echo "→ Stamping service worker with build hash..." + if $DRY_RUN; then + echo " [DRY RUN] Would replace CACHE_VERSION in $SW_PATH" + else + # Replace the CACHE_VERSION line with the new build hash + sed -i "s/const CACHE_VERSION = '[^']*';/const CACHE_VERSION = '${BUILD_VERSION}';/" "$SW_PATH" + echo " ✓ SW CACHE_VERSION set to '${BUILD_VERSION}'" + fi +else + echo " ⚠ sw.js not found in dist — skipping SW stamp" +fi + +# ─── Step 3: Write build manifest ───────────────────────────────────────────── + +echo "→ Writing build manifest..." +if $DRY_RUN; then + echo " [DRY RUN] Would write: { hash: ${BUILD_HASH}, timestamp: ${BUILD_TIMESTAMP} }" +else + cat > "$MANIFEST_PATH" </dev/null || echo " ⚠ CloudFront invalidation failed (aws CLI required)" + echo " ✓ CloudFront invalidation created" + fi + else + echo " ⚠ CloudFront: CLOUDFRONT_DISTRIBUTION_ID not set — skipping" + fi + + # Fastly + if [ -n "${FASTLY_SERVICE_ID:-}" ] && [ -n "${FASTLY_API_KEY:-}" ]; then + echo " → Fastly service ${FASTLY_SERVICE_ID}..." + if $DRY_RUN; then + echo " [DRY RUN] Would purge all" + else + curl -s -X POST "https://api.fastly.com/service/${FASTLY_SERVICE_ID}/purge_all" \ + -H "Fastly-Key: ${FASTLY_API_KEY}" | jq -r '.status // "failed"' + echo " ✓ Fastly cache purged" + fi + else + echo " ⚠ Fastly: FASTLY_SERVICE_ID or FASTLY_API_KEY not set — skipping" + fi +fi + +# ─── Summary ────────────────────────────────────────────────────────────────── + +echo "" +echo "═══════════════════════════════════════════════════════" +echo " Cache busting complete" +echo " Build: ${BUILD_VERSION}" +echo " Hash: ${BUILD_HASH}" +echo " Time: ${BUILD_TIMESTAMP}" +echo " CDN: $(if $PURGE_CDN; then echo 'purged'; else echo 'skipped (use --purge-cdn)'; fi)" +echo " Mode: $(if $DRY_RUN; then echo 'DRY RUN'; else echo 'applied'; fi)" +echo "═══════════════════════════════════════════════════════" diff --git a/server/_core/vite.ts b/server/_core/vite.ts index 96be0c8d..23a42799 100644 --- a/server/_core/vite.ts +++ b/server/_core/vite.ts @@ -1,4 +1,4 @@ -import express, { type Express } from "express"; +import express, { type Express, type Request, type Response, type NextFunction } from "express"; import fs from "fs"; import { type Server } from "http"; import { nanoid } from "nanoid"; @@ -6,6 +6,77 @@ import path from "path"; import { createServer as createViteServer } from "vite"; import viteConfig from "../../vite.config"; +// ── Build Manifest ─────────────────────────────────────────────────────────── +// Read once at startup; used by the /api/version endpoint and cache headers. + +interface BuildManifest { + hash: string; + timestamp: string; + version: string; +} + +let buildManifest: BuildManifest = { hash: "dev", timestamp: new Date().toISOString(), version: "dev" }; + +function loadBuildManifest(distPath: string): void { + try { + const raw = fs.readFileSync(path.join(distPath, "build-manifest.json"), "utf-8"); + buildManifest = JSON.parse(raw); + } catch { + // Not a production build or manifest missing — use dev defaults + } +} + +export function getBuildManifest(): BuildManifest { + return buildManifest; +} + +// ── Content-Hash Aware Cache Headers ───────────────────────────────────────── +// Files under /assets/ contain content hashes in their filenames and are safe to +// cache immutably. Everything else (index.html, sw.js, manifest.json) MUST +// revalidate on every request so browsers pick up new asset references. + +const HASHED_ASSET_RE = /\/assets\/[^/]+-[a-f0-9]{8,}\.(js|css|woff2?|ttf|eot|png|jpg|jpeg|gif|svg|webp|avif|ico)$/; +const NEVER_CACHE_RE = /\/(sw\.js|workbox-[^.]+\.js|manifest\.json|build-manifest\.json)$/; + +function cacheBustingHeaders(req: Request, res: Response, next: NextFunction): void { + const p = req.path; + + if (HASHED_ASSET_RE.test(p)) { + // Content-hashed files — immutable forever + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.setHeader("CDN-Cache-Control", "public, max-age=31536000, immutable"); + } else if (NEVER_CACHE_RE.test(p)) { + // Service worker & manifests — always revalidate + res.setHeader("Cache-Control", "no-cache, must-revalidate"); + res.setHeader("CDN-Cache-Control", "no-cache"); + } else if (p.startsWith("/api/")) { + // API responses — never cache in shared caches + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + } else if (p.endsWith(".html") || p === "/") { + // HTML documents — always revalidate to pick up new asset hashes + res.setHeader("Cache-Control", "no-cache, must-revalidate"); + res.setHeader("CDN-Cache-Control", "no-cache"); + } else if (p.match(/\.(png|jpg|jpeg|gif|svg|ico|webp|avif|woff2?|ttf|eot)$/)) { + // Non-hashed static assets (public/ files like favicon) — short cache + res.setHeader("Cache-Control", "public, max-age=3600, must-revalidate"); + } + + next(); +} + +// ── Version Endpoint ───────────────────────────────────────────────────────── +// Clients poll this to detect when a new deploy has happened. + +function registerVersionEndpoint(app: Express): void { + app.get("/api/version", (_req, res) => { + res.setHeader("Cache-Control", "no-store"); + res.json(buildManifest); + }); +} + +// ── Vite Dev Server ────────────────────────────────────────────────────────── + export async function setupVite(app: Express, server: Server) { const serverOptions = { middlewareMode: true, @@ -20,6 +91,7 @@ export async function setupVite(app: Express, server: Server) { appType: "custom", }); + registerVersionEndpoint(app); app.use(vite.middlewares); app.use("*", async (req, res, next) => { const url = req.originalUrl; @@ -55,6 +127,8 @@ export async function setupVite(app: Express, server: Server) { }); } +// ── Production Static Server ───────────────────────────────────────────────── + export function serveStatic(app: Express) { const distPath = process.env.NODE_ENV === "development" @@ -66,10 +140,26 @@ export function serveStatic(app: Express) { ); } - app.use(express.static(distPath)); + loadBuildManifest(distPath); + registerVersionEndpoint(app); + + // Cache busting headers BEFORE express.static so they apply to all responses + app.use(cacheBustingHeaders); + + app.use(express.static(distPath, { + // Let our cacheBustingHeaders middleware handle Cache-Control + setHeaders: (res, filePath) => { + // express.static sets its own headers — we override for hashed assets + if (HASHED_ASSET_RE.test(filePath)) { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + } + }, + })); - // fall through to index.html if the file doesn't exist + // fall through to index.html if the file doesn't exist (SPA routing) app.use("*", (_req, res) => { + // index.html must never be cached so new deploys are picked up + res.setHeader("Cache-Control", "no-cache, must-revalidate"); res.sendFile(path.resolve(distPath, "index.html")); }); } diff --git a/server/middleware/performanceHardening.ts b/server/middleware/performanceHardening.ts index ec33c8bb..77012000 100644 --- a/server/middleware/performanceHardening.ts +++ b/server/middleware/performanceHardening.ts @@ -82,18 +82,18 @@ export function compressionHeaders(req: Request, res: Response, next: NextFuncti } // ─── Cache Control for Static Assets ───────────────────────────────────────── +// NOTE: Production cache headers are handled by cacheBustingHeaders() in +// server/_core/vite.ts which is content-hash-aware. This legacy middleware is +// kept for backward compatibility but is NOT wired into the Express app. export function staticCacheHeaders(req: Request, res: Response, next: NextFunction) { if (req.path.match(/\.(js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|eot|ico)$/)) { - // Immutable static assets — cache for 1 year res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); res.setHeader("CDN-Cache-Control", "public, max-age=31536000"); } else if (req.path.startsWith("/api/")) { - // API responses — no cache by default res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); res.setHeader("Pragma", "no-cache"); } else { - // HTML pages — short cache with revalidation res.setHeader("Cache-Control", "public, max-age=300, must-revalidate"); } next(); diff --git a/vite.config.ts b/vite.config.ts index 438f5ad0..3d48ec88 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import { jsxLocPlugin } from "@builder.io/vite-plugin-jsx-loc"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { defineConfig, type Plugin, type ViteDevServer } from "vite"; @@ -151,6 +152,65 @@ function vitePluginManusDebugCollector(): Plugin { }; } +// ============================================================================= +// Build Metadata — inject BUILD_HASH + BUILD_TIMESTAMP for cache busting +// Every production build gets a unique content-derived hash. The service worker +// and client-side stale-detection both key off this value. +// ============================================================================= + +const BUILD_TIMESTAMP = new Date().toISOString(); + +function generateBuildHash(): string { + const src = path.resolve(import.meta.dirname, "client", "src"); + const hash = createHash("sha256"); + hash.update(BUILD_TIMESTAMP); + try { + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) walk(path.join(dir, entry.name)); + else if (/\.(tsx?|jsx?|css)$/.test(entry.name)) { + hash.update(fs.readFileSync(path.join(dir, entry.name))); + } + } + }; + walk(src); + } catch { /* fallback to timestamp only */ } + return hash.digest("hex").slice(0, 12); +} + +const BUILD_HASH = generateBuildHash(); + +function vitePluginBuildMetadata(): Plugin { + return { + name: "remitflow-build-metadata", + config(_, { command }) { + if (command === "build") { + return { + define: { + "__BUILD_HASH__": JSON.stringify(BUILD_HASH), + "__BUILD_TIMESTAMP__": JSON.stringify(BUILD_TIMESTAMP), + }, + }; + } + return { + define: { + "__BUILD_HASH__": JSON.stringify("dev"), + "__BUILD_TIMESTAMP__": JSON.stringify(BUILD_TIMESTAMP), + }, + }; + }, + // Write build manifest for deployment scripts + closeBundle() { + const manifest = { hash: BUILD_HASH, timestamp: BUILD_TIMESTAMP, version: `v-${BUILD_HASH}` }; + const outDir = path.resolve(import.meta.dirname, "dist", "public"); + try { + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, "build-manifest.json"), JSON.stringify(manifest, null, 2)); + } catch { /* non-fatal */ } + }, + }; +} + const pwaPlugin = VitePWA({ registerType: "autoUpdate", injectRegister: "auto", @@ -272,6 +332,7 @@ const plugins = [ tailwindcss(), vitePluginManusRuntime(), vitePluginManusDebugCollector(), + vitePluginBuildMetadata(), pwaPlugin, ]; export default defineConfig({ @@ -291,6 +352,11 @@ export default defineConfig({ emptyOutDir: true, rollupOptions: { output: { + // Content-hash filenames guarantee unique URLs per build. + // Browsers cache these immutably; new deploys produce new hashes. + entryFileNames: 'assets/[name]-[hash].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash][extname]', manualChunks(id: string) { if (id.includes('node_modules/react-dom') || id.includes('node_modules/react/')) return 'vendor-react'; if (id.includes('node_modules/@radix-ui')) return 'vendor-ui';