import { defineNuxtConfig } from 'nuxt/config'; import tailwindcss from '@tailwindcss/vite'; import { brandingEnvDefaults, brandingLockedDefault, config } from './config'; import path from 'path'; import { inMemoryCacheOptions } from './cache'; import { DORMANT_AUTH0_CONFIG } from './utils/auth0RuntimeConfig'; import { getAuthStorageConfig } from './utils/authStorageConfig'; import { resolveImageProvider } from './utils/imageProvider'; const isMockedE2E = process.env.VITE_E2E_MOCK_MODE === 'true'; const browserGraphqlUrl = config?.graphqlUrl || 'http://localhost:4000'; const serverGraphqlUrl = process.env.NUXT_BACKEND_GRAPHQL_URL || browserGraphqlUrl; const frontendGraphqlProxyUrl = '/api/graphql'; const imageProvider = resolveImageProvider({ nitroPreset: process.env.NITRO_PRESET, vercel: process.env.VERCEL, }); const runtimeGraphqlFetch: typeof globalThis.fetch = async (input, init) => { const runtimeBackendUrl = typeof window === 'undefined' ? process.env.NUXT_BACKEND_GRAPHQL_URL?.trim() : ''; const target = typeof window === 'undefined' ? runtimeBackendUrl || input : input; const startedAt = performance.now(); const response = await globalThis.fetch(target, init); if (typeof window === 'undefined') { // Nuxt serializes this callback into the generated Apollo plugin, so it // must remain self-contained rather than referring to imported helpers. const configuredThreshold = Number( process.env.NUXT_GRAPHQL_SLOW_REQUEST_MS ); const thresholdMs = Number.isFinite(configuredThreshold) && configuredThreshold >= 0 ? configuredThreshold : 500; const durationMs = performance.now() - startedAt; if (durationMs >= thresholdMs) { let operationName = 'unknown'; if (typeof init?.body === 'string') { try { const parsedBody = JSON.parse(init.body) as { operationName?: unknown; }; operationName = typeof parsedBody.operationName === 'string' && parsedBody.operationName ? parsedBody.operationName : 'anonymous'; } catch { // Keep the fallback name; never log raw request bodies or variables. } } console.info( `[graphql-timing] ${JSON.stringify({ operationName, durationMs: Math.round(durationMs * 10) / 10, status: response.status, serverTiming: response.headers.get('server-timing'), })}` ); } } return response; }; const ignoredDevWatchPatterns = [ '**/.stateful/**', '**/.claude/**', '**/.agents/**', '**/.vercel/**', '**/.output/**', '**/coverage/**', '**/coverage-combined/**', '**/playwright-report/**', '**/test-results/**', ]; const manualChunkGroups: Record = { 'vue-libs': ['vue', 'vue-router', 'pinia'], apollo: ['@apollo/client', '@vue/apollo-composable'], 'date-libs': ['luxon'], 'map-libs': ['@googlemaps/js-api-loader'], }; const ignoredNuxtPaths = [ '.stateful', '.stateful/**', '.claude', '.claude/**', '.agents', '.agents/**', '.vercel', '.vercel/**', '.output', '.output/**', 'coverage', 'coverage/**', 'coverage-combined', 'coverage-combined/**', 'playwright-report', 'playwright-report/**', 'test-results', 'test-results/**', ]; export default defineNuxtConfig({ srcDir: '.', ignore: ignoredNuxtPaths, app: { head: { title: config.serverDisplayName, meta: [ { charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }, { name: 'description', content: `Welcome to ${config.serverDisplayName}`, }, { name: 'color-scheme', content: 'dark light' }, ], htmlAttrs: { lang: 'en', // The `dark` class is applied per-request by the uiStore (from the // `theme` cookie) during SSR, so it is NOT hardcoded here — otherwise // light-mode users would get a dark server render and a flash on load. class: 'dark-mode-ready', }, }, }, vue: { compilerOptions: { whitespace: 'preserve', isCustomElement: (tag) => tag === 'model-viewer', }, }, experimental: { payloadExtraction: false, defaults: { // Default NuxtLink prefetch triggers on viewport visibility, so list // pages (discussions, events, images) speculatively download the route // chunks of every link on screen. Prefetch on hover/focus instead: links // the user actually aims at still load instantly, but we stop pulling // megabytes of route chunks for links that are merely visible. nuxtLink: { prefetchOn: { visibility: false, interaction: true }, }, }, }, compatibilityDate: '2024-04-03', components: true, css: ['@/assets/css/index.css'], devtools: { enabled: true }, imports: { autoImport: true, }, modules: [ '@sentry/nuxt/module', // Server-session auth: registers /auth/login, /auth/logout, // /auth/callback, /auth/backchannel-logout. Config is read from the private // `runtimeConfig.auth0` block below (NUXT_AUTH0_* envs). [ '@auth0/auth0-nuxt', { mountRoutes: true, // SPIKE Phase 2: use a server-side session store (small cookie holds // only a session id). Keeping the module in every build lets one // container select Auth0 or local-dev auth through runtime config. sessionStoreFactoryPath: '~/server/utils/session-store-factory.ts', }, ], [ '@nuxtjs/apollo', { clients: { default: { // The custom fetch keeps this module's generated Apollo client // reusable: SSR reads NUXT_BACKEND_GRAPHQL_URL at process startup, // while browser requests use the same-origin proxy below. The // endpoint value is only a backward-compatible build fallback. httpEndpoint: serverGraphqlUrl, browserHttpEndpoint: frontendGraphqlProxyUrl, httpLinkOptions: { fetch: runtimeGraphqlFetch, }, // @nuxtjs/apollo attaches localStorage['token'] as the Bearer // (tokenStorage: 'localStorage'). plugins/apollo-auth.client.ts keeps // that key in sync with the server-session access token. (The old // custom `apolloLink` here was dead config — this module version // builds its own link and ignored it — so it was removed.) tokenName: 'token', tokenStorage: 'localStorage', inMemoryCacheOptions, devtools: { enabled: process.env.NODE_ENV === 'development' }, defaultOptions: { watchQuery: { errorPolicy: 'all', notifyOnNetworkStatusChange: true, fetchPolicy: 'cache-first', }, query: { errorPolicy: 'all', notifyOnNetworkStatusChange: true, fetchPolicy: 'cache-first', }, mutation: { errorPolicy: 'all', }, }, }, }, }, ], // Add image optimization [ '@nuxt/image', { // Image quality options quality: 80, // Use WebP and AVIF formats where supported format: ['webp', 'avif', 'jpg', 'png'], // Vercel's native optimizer avoids packaging IPX and sharp into the // server function. Other deployment targets keep the self-hosted IPX // endpoint so their image behavior is unchanged. provider: imageProvider, // Responsive image breakpoints screens: { xs: 320, sm: 640, md: 768, lg: 1024, xl: 1280, xxl: 1536, '2xl': 1536, }, // Default image optimization options modifiers: { format: 'webp', quality: 80, width: 'auto', height: 'auto', }, // Domains to allow for remote images domains: ['storage.googleapis.com'], // Adjust placeholder behavior placeholder: { size: 10, }, // Presets for common image types presets: { avatar: { modifiers: { format: 'webp', width: 50, height: 50, }, }, thumbnail: { modifiers: { format: 'webp', width: 320, height: 180, }, }, cover: { modifiers: { format: 'webp', width: 1200, height: 630, }, }, }, }, ], // Light/dark mode is handled by the uiStore (single source of truth): // it reads the `theme` cookie on the server to set the initial class // and toggles it on the client. @nuxtjs/color-mode was unused, so it was // removed to eliminate a competing theme system. Tailwind's `dark` variant // is configured against the same class in assets/css/index.css. [ '@nuxtjs/google-fonts', { families: { // Inter is self-hosted in assets/css/index.css, and the public UI // only uses Montserrat for the wordmark. Limiting the remote font // request keeps first paint from competing with dozens of unused font // variants on public routes. Montserrat: [600], }, subsets: ['latin'], display: 'swap', // Keep font loading non-blocking. `display: swap` + `preconnect` are // enough for first paint here; explicit font prefetch competes with the // route's critical CSS/JS on public pages. prefetch: false, preconnect: true, }, ], ], sentry: { // Build-time module options ONLY (source maps upload). Runtime options such // as `dsn`, `environment`, `integrations`, and sample rates belong in // `Sentry.init()` inside sentry.client.config.ts / sentry.server.config.ts — // they are ignored if placed here. org: 'none-y1x', project: 'javascript-nuxt', authToken: process.env.VITE_SENTRY_AUTH_TOKEN, }, nitro: { preset: process.env.NITRO_PRESET || 'vercel', // Nuxt bundles pages, middleware, and API routes into one Vercel Function. // The GraphQL proxy can legitimately wait for a pre-download security scan, // which exceeds Vercel's 15-second non-Fluid default on a cold scanner. vercel: { functions: { maxDuration: 60, }, }, // `sanitize-html` (used by composables/useMarkdownRenderer.ts) is CommonJS // and does `require('htmlparser2')`. When Nitro externalizes it, the Vercel // serverless function performs that require at runtime — which crashes with // "require() of ES Module .../htmlparser2/dist/index.js not supported" // whenever the deployed htmlparser2 resolves to its ESM entry (e.g. a stale // copy served by Vercel's build cache). Inlining sanitize-html bundles it // (and htmlparser2) at build time, so there is no runtime require of // htmlparser2 — the server is immune regardless of which htmlparser2 the // dependency install yields. externals: { inline: ['sanitize-html', 'htmlparser2'], }, ignore: ignoredDevWatchPatterns, watchOptions: { ignoreInitial: true, ignored: ignoredDevWatchPatterns, }, // Enable CDN caching cdn: true, // Persistent store for @auth0/auth0-nuxt server sessions // (server/utils/session-store-factory.ts uses useStorage('auth0Sessions')). // The default in-memory store is wiped on every server restart, leaving the // browser with a stale session-id cookie whose id is no longer in the store; // StatefulStateStore then DELETES the cookie on that miss and the user is // silently logged out. A persistent, shared store survives restarts and (on // serverless) is shared across function instances, avoiding that cascade. // // Vercel uses Upstash Redis over its REST API so sessions are shared across // function instances. The official node-server container keeps Auth0 // sessions under /app/data and uses a bounded in-process profile cache, // avoiding an external Redis service for one frontend replica. storage: getAuthStorageConfig({ nitroPreset: process.env.NITRO_PRESET, dataDir: process.env.MULTIFORUM_DATA_DIR, }), // Local dev keeps filesystem-backed mounts (no Upstash creds needed) that // still survive `nuxt dev` restarts. devStorage overrides the production // mounts above only during development. devStorage: { auth0Sessions: { driver: 'fs', base: './.auth0-sessions', }, authProfileCache: { driver: 'lru-cache', max: 1000, ttl: 60 * 60 * 1000, }, }, // Enable server-side caching routeRules: { // Forum detail pages (discussions / events / downloads) are NOT cached. // Since the server-session migration, SSR is AUTH-AWARE: the server // personalizes the HTML from the user's session (edit/owner controls, // vote state, nav). A shared ISR/edge cache would capture ONE user's // personalized render and serve it to everyone — producing hydration // mismatches (cached auth state != the visitor's real auth state, causing // the page to blank and re-render) and leaking that user's auth state to // others. These must render on demand per request. // // (Before the migration SSR was anonymous, so ISR was safe; that premise // no longer holds. If per-page caching is wanted back, it would need to be // anonymous-only — e.g. bypass the cache whenever a session cookie is // present — not a blanket ISR rule.) // Pipeline history is interactive operational data, not SEO content. // Render it client-side so the detail page's SSR query waterfall cannot // exhaust the Vercel function timeout before pipeline data is requested. '/forums/*/downloads/*/pipelines': { ssr: false }, // SPIKE Phase 2: auth endpoints must NOT be route-cached. Nitro's route // cache serves shared, cookie-independent responses, so /api/auth/token // was reaching its handler with NO cookies (no session → null token → // "You must be logged in"). A more specific rule wins in Nitro, so this // disables caching for the auth routes only. '/api/auth/**': { cache: false }, '/api/session/**': { cache: false }, // GraphQL POST responses can be user-specific and proxyRequest returns a // streaming response that Nitro's route cache cannot serialize. '/api/graphql': { cache: false }, // Cache other API routes '/api/**': { cache: { // Let middleware handle specific cache times headers: ['cache-control'], }, }, // Cache static assets '/_nuxt/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable', }, }, // Cache public assets '/assets/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable', }, }, }, }, plugins: [ { src: '@/plugins/00.runtime-instance-config', mode: 'all' }, { src: '@/plugins/pinia', mode: 'all' }, { src: '@/plugins/click-outside', mode: 'all' }, { src: '@/plugins/accented.client', mode: 'client' }, { src: '@/plugins/test-auth.client', mode: 'client' }, ], runtimeConfig: { // Server-only GraphQL URL. Docker deployments override it at runtime to // reach the backend over the private Compose network; browsers use the // same-origin /api/graphql proxy instead. backendGraphqlUrl: serverGraphqlUrl, // Optional explicit backend token URL for local development auth. When it // is empty, the server derives /auth/local-dev/token from the server-side // GraphQL URL above, then falls back to the public browser URL. localAuthTokenEndpoint: '', // SPIKE (auth0-nuxt server-session migration): server-only Auth0 config. // These are overridden by NUXT_AUTH0_* env vars at runtime (see // .env.production.example). clientSecret + sessionSecret mean this is a // CONFIDENTIAL "Regular Web Application" in Auth0 — a different app type // than the current SPA. auth0: { // The Auth0 module validates these fields even when local-dev auth is // selected. Inert defaults let one image boot in either mode; the Nitro // startup validator rejects them whenever authProvider is actually // `auth0`, so production still requires real NUXT_AUTH0_* secrets. domain: DORMANT_AUTH0_CONFIG.domain, clientId: DORMANT_AUTH0_CONFIG.clientId, clientSecret: DORMANT_AUTH0_CONFIG.clientSecret, sessionSecret: DORMANT_AUTH0_CONFIG.sessionSecret, appBaseUrl: 'http://localhost:3000', audience: '', // Spread by the SDK into the session store's cookie options. The session // cookie defaults to Secure, which browsers don't reliably send over // http://localhost — so the session looked intermittently "lost" in dev. // Disable Secure in dev only; production (https) keeps Secure cookies. sessionConfiguration: { cookie: { secure: process.env.NODE_ENV === 'production', }, }, }, signupEmailVerification: { clientId: '', clientSecret: '', }, public: { // Deployment-specific values use matching NUXT_PUBLIC_* environment // variables at container startup. VITE_* remains the build-time fallback // for existing development and Vercel deployments. baseUrl: config.baseUrl, environment: config.environment, googleCloudStorageBucket: config.googleCloudStorageBucket, googleMapsApiKey: config.googleMapsApiKey, googleMapId: config.googleMapId, graphqlUrl: frontendGraphqlProxyUrl, logoutUrl: config.logoutUrl, openCageApiKey: config.openCageApiKey, openGraphApiKey: config.openGraphApiKey, serverName: config.serverName, serverDisplayName: config.serverDisplayName, // Instance branding (docs/source/support links and footer text). Nuxt // maps each nested key to a NUXT_PUBLIC_BRANDING_* variable, so a single // built image can be re-branded at container startup without a rebuild. // See docs/frontend-runtime-configuration.md. branding: brandingEnvDefaults, // When true, the values above win over the admin-editable ServerConfig // branding and the admin form renders read-only. brandingLocked: brandingLockedDefault, authProvider: process.env.NUXT_PUBLIC_AUTH_PROVIDER === 'local-dev' ? 'local-dev' : 'auth0', apollo: { clients: { default: { httpEndpoint: frontendGraphqlProxyUrl, }, }, }, googleMapsApiKey: config?.googleMapsApiKey, enableAccented: process.env.NUXT_PUBLIC_ENABLE_ACCENTED === 'false' ? false : true, }, }, ssr: !isMockedE2E, // Emit client source maps for the E2E coverage build so browser V8 coverage // can be mapped back to original source (spike: E2E_COVERAGE=true). sourcemap: { client: process.env.E2E_COVERAGE === 'true', }, typescript: { strict: false, shim: true, typeCheck: false, }, vite: { plugins: [tailwindcss()], // Pre-bundle these deps at startup. Removing Vuetify changed the lockfile, // which forces Vite to re-optimize from scratch; without this list it // discovers some deps (notably ones pulled in by client-only plugins) only // at runtime and issues an HMR full-reload to swap them in. That reload can // interrupt an in-flight Playwright navigation and blank the page (the // create/edit E2E flows were failing for this reason). The list is exactly // what `nuxt dev` reports as "discovered new dependencies at runtime". optimizeDeps: { include: [ '@floating-ui/vue', '@google/model-viewer', '@vue/apollo-composable', 'accented', 'luxon', 'three', ], }, resolve: { alias: { '@': path.resolve(__dirname), 'fast-deep-equal': 'fast-deep-equal/es6/index.js', }, }, $client: { resolve: { alias: { // See stubs/postcss.ts: sanitize-html only needs postcss for style // attribute parsing, which the markdown renderer disables. postcss: path.resolve(__dirname, 'stubs/postcss.ts'), }, }, }, define: { global: 'globalThis', // Keep Vue's production hydration warnings actionable. Without this, // production only reports that a mismatch occurred and omits the server // DOM node and expected client vnode needed to locate the source. __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: true, }, // Allow connections from ngrok for mobile testing server: { allowedHosts: ['localhost', '69f8-98-168-53-208.ngrok-free.app'], watch: { // Exclude nested worktrees and generated artifacts from dev watchers // so `nuxt dev` does not exhaust file watchers in the main checkout. ignored: ignoredDevWatchPatterns, }, }, build: { // Keep production assets small enough to download, parse, and execute // quickly. These had been disabled globally, which shipped unminified // JavaScript and CSS to every visitor. minify: 'esbuild', cssMinify: 'esbuild', // terserOptions: { // compress: { // drop_console: process.env.NODE_ENV === 'production', // drop_debugger: process.env.NODE_ENV === 'production' // } // }, rollupOptions: { output: { manualChunks(id) { for (const [chunkName, packageNames] of Object.entries( manualChunkGroups )) { if ( packageNames.some((packageName) => id.includes(`/node_modules/${packageName}/`) ) ) { return chunkName; } } }, }, }, }, optimizeDeps: { // Scan all pages so Vite discovers and pre-bundles every third-party // dependency upfront. Without this, the first client-side navigation to a // route whose components import a new (not yet pre-bundled) package causes // Vite to return 504 "Outdated Optimize Dep" responses, aborting the // router.push navigation (visible in CI as the createEditChannels test // timing out at the toHaveURL assertion). entries: ['./pages/**/*.vue'], include: [ 'vue', 'vue-router', '@vue/apollo-composable', 'luxon', 'three', '@google/model-viewer', ], }, }, });