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;
});