vite.config.ts9.4 KBView on GitHub
import { readdirSync } from 'node:fs';
import { join } from 'node:path';

import { paraglideVitePlugin } from '@inlang/paraglide-js';
import { defineConfig, loadEnv, type Plugin, type UserConfig } from 'vite';
import { reactRouter } from '@react-router/dev/vite';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import tsconfigPaths from 'vite-tsconfig-paths';
import oxlintPlugin from 'vite-plugin-oxlint';
import tailwindcss from '@tailwindcss/vite';
import babel from 'vite-plugin-babel';

function getBlogSlugs(): string[] {
  try {
    return readdirSync(join(process.cwd(), 'app', '(full-width)', 'blog', 'posts'))
      .filter((f) => f.endsWith('.tsx'))
      .map((f) => f.replace(/\.tsx$/, ''));
  } catch {
    return [];
  }
}

function seoFilesPlugin(siteUrl: string): Plugin {
  return {
    name: 'cedar-seo-files',
    applyToEnvironment: (e) => e.name === 'client',
    generateBundle() {
      const origin = siteUrl.replace(/\/$/, '');

      // Only the marketing surface belongs in an index. Everything disallowed below is
      // either an authenticated app route (a crawler gets a login redirect or an empty
      // shell) or a per-user/tokenised page that must never be indexed at all.
      const disallowed = [
        // Authenticated app surface
        '/home',
        '/agent',
        '/mail',
        '/tasks',
        '/agenda',
        '/calendar',
        '/conversations',
        '/conversation-inbox',
        '/pipeline',
        '/outbound',
        '/meetings',
        '/reports',
        '/statistics',
        '/brain',
        '/agents',
        '/gallery',
        '/compose',
        '/linkedin',
        '/whatsapp',
        // Auth, onboarding and admin
        '/login',
        '/consent',
        '/onboarding',
        '/onboarding-login',
        '/gallery-login',
        '/admin',
        '/cedarAdmin',
        '/aws-migration',
        '/playground',
        '/not-setup',
        '/not-paying',
        // Tokenised share links — public by URL, but must not be indexed
        '/share/',
        '/shared/',
        // Internal previews
        '/landing',
        '/dashboard-demo',
        '/call',
        '/api/',
      ];

      this.emitFile({
        fileName: 'robots.txt',
        type: 'asset',
        source:
          [
            // No blank line before the Disallows: under the original robots.txt
            // convention an empty line ends the record, which would leave every rule
            // below attached to no user-agent at all.
            'User-agent: *',
            'Allow: /',
            ...disallowed.map((path) => `Disallow: ${path}`),
            '',
            `Sitemap: ${origin}/sitemap.xml`,
          ].join('\n') + '\n',
      });

      const today = new Date().toISOString().slice(0, 10);
      const urls: { loc: string; priority: string; changefreq: string }[] = [
        { loc: `${origin}/`, priority: '1.0', changefreq: 'weekly' },
        { loc: `${origin}/pricing`, priority: '0.7', changefreq: 'monthly' },
        { loc: `${origin}/bookdemo`, priority: '0.8', changefreq: 'monthly' },
        { loc: `${origin}/blog`, priority: '0.9', changefreq: 'weekly' },
        ...getBlogSlugs().map((slug) => ({
          loc: `${origin}/blog/${slug}`,
          priority: '0.8',
          changefreq: 'monthly',
        })),
      ];

      const body = urls
        .map(
          (u) =>
            `  <url>\n    <loc>${u.loc}</loc>\n    <lastmod>${today}</lastmod>\n    <changefreq>${u.changefreq}</changefreq>\n    <priority>${u.priority}</priority>\n  </url>`,
        )
        .join('\n');

      this.emitFile({
        fileName: 'sitemap.xml',
        type: 'asset',
        source: `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`,
      });
    },
  };
}

const ReactCompilerConfig = {
  /* ... */
};

// Disable React Compiler in development for easier debugging
const shouldUseReactCompiler = process.env.NODE_ENV !== 'development';

/**
 * Force full page reload for files that create React contexts.
 * HMR + React contexts can cause "hook can only be used inside Provider" errors
 * because context identity changes during hot reload.
 */
function forceReloadContextFiles(): Plugin {
  return {
    name: 'force-reload-context-files',
    handleHotUpdate({ file, server }) {
      // Files that create React contexts - editing these should trigger full reload
      if (file.includes('modules/trpc/context.ts')) {
        server.ws.send({ type: 'full-reload' });
        return [];
      }
    },
  };
}

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');
  const isDev = mode === 'development' || process.env.NODE_ENV === 'development';
  const sentryRelease =
    env.VITE_PUBLIC_SENTRY_RELEASE ??
    env.SENTRY_RELEASE ??
    process.env.GITHUB_SHA;
  const shouldUploadSentrySourcemaps = Boolean(env.SENTRY_AUTH_TOKEN && sentryRelease && !isDev);
  const sentryPlugins = sentryVitePlugin({
    authToken=[redacted],
    org: env.SENTRY_ORG ?? 'cedar-2k',
    project: env.SENTRY_PROJECT ?? 'scm-platform-features',
    release: {
      name: sentryRelease,
      setCommits: sentryRelease ? { auto: true } : undefined,
    },
    sourcemaps: {
      filesToDeleteAfterUpload: ['build/client/**/*.map'],
    },
    telemetry: false,
    disable: !shouldUploadSentrySourcemaps,
    errorHandler: (err) => {
      console.warn('[Sentry] Failed to upload source maps:', err.message);
    },
  }) as unknown as Plugin[];

  return {
    plugins: [
      forceReloadContextFiles(),
      oxlintPlugin(),
      reactRouter(),
      babel({
        filter: /\.[jt]sx?$/,
        babelConfig: {
          presets: ['@babel/preset-typescript'], // if you use TypeScript
          plugins: shouldUseReactCompiler
            ? [['babel-plugin-react-compiler', ReactCompilerConfig]]
            : [],
        },
      }),
      tsconfigPaths(),
      tailwindcss(),
      paraglideVitePlugin({
        project: './project.inlang',
        outdir: './paraglide',
        strategy: ['cookie', 'baseLocale'],
      }),
      // robots/sitemap describe the marketing site, so they follow the marketing
      // origin once it is configured (see apps/mail/lib/marketing-host.ts).
      seoFilesPlugin(
        env.VITE_PUBLIC_MARKETING_URL || env.VITE_PUBLIC_APP_URL || 'https://cedarcopilot.com',
      ),
      ...sentryPlugins,
    ],
    server: {
      port: env.VITE_PUBLIC_APP_URL ? (parseInt(new URL(env.VITE_PUBLIC_APP_URL).port, 10) || undefined) : undefined,
      // Vite rejects requests with an unrecognized Host header by default —
      // needed to tunnel this dev server (e.g. via ngrok, for testing OAuth
      // consent/login screens that must be reachable at a public URL; see
      // apps/server/scripts/mcp-connector-tunnel.sh). A leading dot matches
      // any subdomain. Dev-server-only option, has no effect on production
      // builds.
      allowedHosts: ['.ngrok.app', '.ngrok-free.app'],
      // Forwards backend-shaped paths to the local API server so a single
      // ngrok tunnel (pointed at this frontend port) can serve both — found
      // necessary live: with two SEPARATE tunnels (one per port), the
      // frontend and backend sit on different ngrok.app subdomains, which
      // are different browser *sites* per the Public Suffix List (ngrok.app
      // is on it). better-auth's OAuth `state` cookie (SameSite=Lax) is set
      // via a cross-origin fetch() from authClient's signIn.social() —
      // browsers silently drop a SameSite=Lax Set-Cookie on a cross-SITE
      // fetch response, so the state never persists and every social
      // sign-in fails with "State not persisted correctly" (confirmed via
      // curl + server logs, not guessed). Proxying instead means the
      // browser only ever talks to ONE origin — this tunnel — matching the
      // existing preview-deployment pattern in lib/runtime-url-resolution.ts
      // (VITE_PUBLIC_BACKEND_URL === VITE_PUBLIC_APP_URL, backend calls
      // follow whatever origin the page is actually served from).
      proxy: {
        // Prefer PORT_API (axiom uses 8790); fall back to the default API port.
        '/api': `http://localhost:${env.PORT_API || process.env.PORT_API || '8787'}`,
        '/mcp': `http://localhost:${env.PORT_API || process.env.PORT_API || '8787'}`,
        '/.well-known': `http://localhost:${env.PORT_API || process.env.PORT_API || '8787'}`,
      },
      // Keep warmup narrow. Pre-transforming all of app/** + components/** (~300+
      // files) saturates the transform pipeline and can leave the first document
      // SSR request hung with 0 bytes forever (Chrome shows a blank tab).
      warmup: {
        ssrFiles: ['./app/entry.server.tsx', './app/routes.ts'],
      },
    },
    ssr: {
      optimizeDeps: {
        // `novel` was removed from the composer; leaving it here makes Vite log
        // "Failed to resolve dependency: novel" on every boot.
        include: ['@tiptap/extension-placeholder', 'motion/react', 'date-fns'],
      },
    },
    esbuild: {
      pure: ['console.log'],
    },
    build: {
      sourcemap: isDev ? 'inline' : shouldUploadSentrySourcemaps ? 'hidden' : false,
    },
    resolve: {
      alias: {
        tslib: 'tslib/tslib.es6.js',
        ...(process.env.NODE_ENV === 'development' && {
          'react-dom/client': 'react-dom/profiling',
          'scheduler/tracing': 'scheduler/tracing-profiling',
        }),
      },
    },
  } as UserConfig;
});