nuxt.config.ts
Rama main · réplica de lectura del repositorio original.
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<string, string[]> = {
'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 <html> 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',
],
},
},
});