diff --git a/docs/skills/nuxt-architecture-review/SKILL.md b/docs/skills/nuxt-architecture-review/SKILL.md new file mode 100644 index 000000000..abcc30551 --- /dev/null +++ b/docs/skills/nuxt-architecture-review/SKILL.md @@ -0,0 +1,423 @@ +--- +name: nuxt-architecture-review +description: > + Review and audit a Nuxt application for architecture quality, code organization, and TypeScript + best practices. Use when asked to "review code quality", "audit architecture", "improve project + structure", "review TypeScript patterns", "check component design", "audit code organization", + or any structural/quality review on a Nuxt 3/4 project. Covers custom modules, shared/ directory, + composable organization, TypeScript patterns (discriminated unions, satisfies, schema inference), + server/client component split, route aliases, canonical redirects, defineModel, and security + patterns. +--- + +# Nuxt Architecture & Code Quality Review + +Audit a Nuxt application for architecture quality, project structure, TypeScript patterns, and component design. Scan the codebase for each pattern below, explain the concept, and propose fixes. + +For full code examples, see the `references/` files linked in each section. + +## Review Process + +1. Read project structure (`app/`, `server/`, `shared/`, `modules/`) +2. Check `modules/` for custom Nuxt modules +3. Scan composables for organization and patterns +4. Review TypeScript usage across `shared/` and `server/` +5. Check component architecture in `app/components/` +6. Review routing patterns and middleware +7. Report findings with explanations and suggested fixes + +--- + +## 1. Custom Nuxt Modules for Cross-Cutting Concerns + +**What:** Nuxt modules are the standard way to encapsulate cross-cutting concerns (cache configuration, build info, environment-specific behavior) that don't belong in `nuxt.config.ts` or in app code. Each module is a self-contained unit with a single responsibility. + +**Why:** Putting all configuration in `nuxt.config.ts` leads to a massive, hard-to-maintain config file. Modules keep concerns separated, testable, and conditionally applied based on environment. + +**What to look for:** Check `nuxt.config.ts` for complex logic that should be extracted. Look for environment-specific code scattered across the app. Check if a `modules/` directory exists. + +**Good:** + +```ts +// modules/cache.ts - single responsibility: cache configuration +export default defineNuxtModule({ + meta: { name: 'cache-config' }, + setup(_, nuxt) { + if (provider !== 'vercel') return // Environment guard + + nuxt.hook('nitro:config', nitroConfig => { + nitroConfig.storage = nitroConfig.storage || {} + nitroConfig.storage.cache = { + ...nitroConfig.storage.cache, + driver: 'vercel-runtime-cache', + } + }) + }, +}) +``` + +**Bad:** + +```ts +// nuxt.config.ts with 200+ lines of inline logic +export default defineNuxtConfig({ + hooks: { + 'nitro:config': nitroConfig => { + // 50 lines of cache config + // 30 lines of build info + // 20 lines of ISR fallback + }, + }, +}) +``` + +**Fix:** Extract cross-cutting concerns into `modules/`. One module per concern. Use `provider` guards from `std-env`. See [references/modules-patterns.md](references/modules-patterns.md). + +--- + +## 2. shared/ Directory for Cross-Boundary Code + +**What:** The `shared/` directory in Nuxt 4 contains types, schemas, and utilities shared between `app/` (client + SSR) and `server/` (Nitro). It's accessible via the `#shared` alias. + +**Why:** Without a shared directory, types and utilities get duplicated between `app/` and `server/`, or server-only code leaks into the client bundle. The `shared/` directory provides a clean boundary. + +**What to look for:** Check for duplicated types or utilities between `app/` and `server/`. Check if `server/` imports from `app/` (bad) or if types are defined inline in API routes. + +**Good:** + +``` +shared/ + types/ + product.ts # Shared between app and server + api-responses.ts + schemas/ + product.ts # Valibot schemas used in both + utils/ + formatters.ts # Pure functions used everywhere + constants.ts # Shared constants +``` + +**Bad:** + +``` +app/types/product.ts # Duplicated type definition +server/types/product.ts # Same type, different file +``` + +**Fix:** Move shared types, schemas, and pure utilities to `shared/`. Import via `#shared/types/product` or `#shared/utils/formatters`. See [references/modules-patterns.md](references/modules-patterns.md). + +--- + +## 3. Feature-Grouped Composables + +**What:** Organize composables by domain/feature (e.g., `composables/npm/`, `composables/auth/`) rather than by type (e.g., all composables flat in `composables/`). + +**Why:** A flat `composables/` directory with 30+ files becomes hard to navigate. Grouping by feature makes related composables discoverable and maintainable. The auto-import `dirs` config can handle nested directories. + +**What to look for:** Count composables in `app/composables/`. If there are more than 10, check if they're grouped by feature. + +**Good:** + +``` +app/composables/ + npm/ + useNpmSearch.ts + usePackage.ts + usePackageDownloads.ts + auth/ + useAuth.ts + useSession.ts + useColors.ts # Standalone utilities + useMarkdown.ts +``` + +```ts +// nuxt.config.ts - auto-import nested composables +imports: { + dirs: ['~/composables', '~/composables/*/*.ts'], +}, +``` + +**Bad:** + +``` +app/composables/ + useNpmSearch.ts + usePackage.ts + usePackageDownloads.ts + useAuth.ts + useSession.ts + useColors.ts + useMarkdown.ts + useVirtualScroll.ts + ... 20 more files +``` + +**Fix:** Group related composables into subdirectories. Update `imports.dirs` in `nuxt.config.ts`. + +--- + +## 4. TypeScript Discriminated Unions + +**What:** Use a shared `kind` or `type` property to create exhaustive, type-safe status objects. TypeScript narrows the type automatically in `switch` statements and `if` checks. + +**Why:** Discriminated unions make impossible states unrepresentable. Without them, you end up with optional properties and runtime checks that can be forgotten. + +**What to look for:** Search for status/state objects with multiple optional properties. These often indicate a missing discriminated union. + +**Good:** + +```ts +type TypesStatus = + | { kind: 'included' } + | { kind: '@types'; packageName: string; deprecated?: string } + | { kind: 'none' } + +function renderTypesStatus(status: TypesStatus) { + switch (status.kind) { + case 'included': + return 'Built-in types' + case '@types': + return `@types/${status.packageName}` // TS knows packageName exists + case 'none': + return 'No types' + } +} +``` + +**Bad:** + +```ts +interface TypesStatus { + hasTypes: boolean + typesPackage?: string + deprecated?: string +} + +// Easy to forget checks, no exhaustiveness guarantee +if (status.hasTypes) { + /* ... */ +} else if (status.typesPackage) { + /* ... */ +} // Could be undefined even when hasTypes is false +``` + +**Fix:** Refactor status objects to discriminated unions with a `kind` or `type` discriminant. See [references/typescript-patterns.md](references/typescript-patterns.md). + +--- + +## 5. satisfies Operator + +**What:** The `satisfies` operator checks that a value conforms to a type without widening it. Unlike `as`, it preserves the literal types and catches structural errors at compile time. + +**Why:** Using `as` to type a return value suppresses errors and widens types. `satisfies` ensures correctness while preserving the exact shape for consumers. + +**What to look for:** Search for `as` type assertions on return values and object literals. These are often better expressed with `satisfies`. + +**Good:** + +```ts +return { + package: packageName, + version: pkg.version ?? 'latest', + ...analysis, +} satisfies PackageAnalysisResponse + +const defaults = { + objects: [], + total: 0, + time: new Date().toISOString(), +} satisfies NpmSearchResponse +``` + +**Bad:** + +```ts +// 'as' suppresses errors and widens the type +return { + package: packageName, + version: pkg.version ?? 'latest', + ...analysis, +} as PackageAnalysisResponse +``` + +**Fix:** Replace `as Type` with `satisfies Type` on return values and object literals. See [references/typescript-patterns.md](references/typescript-patterns.md). + +--- + +## 6. Server/Client Component Split + +**What:** Nuxt supports `.server.vue` and `.client.vue` suffixes. A `.server.vue` component only renders on the server (SSR HTML, no hydration JS). A `.client.vue` component only renders on the client (after hydration). When both exist with the same name, Nuxt uses the server version during SSR and swaps to the client version after hydration. + +**Why:** Components that are only needed on the server (static UI, SEO content) should not ship JavaScript to the client. Components that need browser APIs (localStorage, window) should not run during SSR. + +**What to look for:** Check for components with `onMounted` guards wrapping all logic, or components that are purely presentational with no interactivity. These are candidates for server/client split. + +**Good:** + +``` +app/components/Header/ + AccountMenu.server.vue # SSR: shows login button placeholder + AccountMenu.client.vue # Client: shows actual auth state with dropdown +``` + +**Bad:** + +```vue + + + +``` + +**Fix:** Split into `.server.vue` and `.client.vue` files. See [references/component-patterns.md](references/component-patterns.md). + +--- + +## 7. Route Aliases in definePageMeta + +**What:** `definePageMeta` supports an `alias` property that maps multiple URL patterns to the same page component. This avoids duplicating page components for different URL shapes. + +**Why:** Without aliases, you either duplicate page files or use complex redirects. Aliases keep one source of truth while supporting multiple URL patterns. + +**What to look for:** Check for duplicated page components or complex redirect middleware that could be replaced with route aliases. + +**Good:** + +```ts +// app/pages/package-code/[...path].vue +definePageMeta({ + name: 'code', + path: '/package-code/:path+', + alias: ['/package/code/:path+', '/code/:path+'], +}) +``` + +**Bad:** + +``` +app/pages/ + package-code/[...path].vue # Original + code/[...path].vue # Duplicate component + package/code/[...path].vue # Another duplicate +``` + +**Fix:** Use `alias` in `definePageMeta` to support multiple URL patterns from a single page component. See [references/component-patterns.md](references/component-patterns.md). + +--- + +## 8. Canonical Redirects Middleware + +**What:** A server middleware that redirects legacy, shorthand, or non-canonical URLs to the canonical version with appropriate Cache-Control headers. Uses 301 (permanent) redirects. + +**Why:** Multiple URLs pointing to the same content hurt SEO (duplicate content), confuse caching (different cache entries for the same data), and create inconsistent user experience. + +**What to look for:** Check if the app has multiple URL patterns for the same content. Check for a canonical redirect middleware in `server/middleware/`. + +**Good:** + +```ts +// server/middleware/canonical-redirects.global.ts +const cacheControl = 's-maxage=3600, stale-while-revalidate=36000' + +export default defineEventHandler(async event => { + const [path] = event.path.split('?') + + // /vue -> /package/vue (301) + const match = path.match(/^\/(?[^/@]+)$/) + if (match?.groups) { + setHeader(event, 'cache-control', cacheControl) + return sendRedirect(event, `/package/${match.groups.name}`, 301) + } +}) +``` + +**Fix:** Create a `server/middleware/canonical-redirects.global.ts` that maps shorthand URLs to canonical ones. See [references/component-patterns.md](references/component-patterns.md). + +--- + +## 9. defineModel for v-model + +**What:** `defineModel()` is the Vue 3.4+ macro for two-way binding. It replaces the `defineProps` + `defineEmits` + `computed` boilerplate for `v-model`. + +**Why:** Reduces 10+ lines of boilerplate to a single line. Makes the component API clearer: "this prop supports v-model". + +**What to look for:** Search for the old `modelValue` prop + `update:modelValue` emit pattern. Also check for named v-model props using the old pattern. + +**Good:** + +```ts +// Child component +const sortOption = defineModel('sortOption', { required: true }) +const pageSize = defineModel('pageSize', { default: 25 }) + +// Parent: +``` + +**Bad:** + +```ts +const props = defineProps<{ sortOption: string; pageSize: number }>() +const emit = defineEmits<{ + 'update:sortOption': [value: string] + 'update:pageSize': [value: number] +}>() +const localSort = computed({ + get: () => props.sortOption, + set: v => emit('update:sortOption', v), +}) +``` + +**Fix:** Replace `defineProps` + `defineEmits` + `computed` patterns with `defineModel()`. See [references/component-patterns.md](references/component-patterns.md). + +--- + +## 10. XSS-Safe Markdown Rendering + +**What:** When rendering user-provided markdown (package READMEs, descriptions), sanitize HTML, validate URL protocols, and use ReDoS-safe regex patterns. + +**Why:** Rendering raw markdown without sanitization opens XSS vectors. Using unbounded regex on user input enables ReDoS attacks. + +**What to look for:** Search for `v-html` usage. Check if the HTML source is sanitized. Check regex patterns for catastrophic backtracking. + +**Good:** + +```ts +// Escape HTML entities before parsing markdown +function sanitize(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>') +} + +// Validate link protocols +html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => { + try { + const { protocol } = new URL(url) + if (['https:', 'mailto:'].includes(protocol)) { + return `${text}` + } + } catch {} + return `${text} (${url})` // Invalid URL: render as plain text +}) + +// ReDoS-safe: use bounded quantifiers instead of unbounded * +text.replace(/\[!\[[^\]]{0,500}\]\([^)]{0,2000}\)\]\([^)]{0,2000}\)?/g, '') +``` + +**Bad:** + +```ts +// Raw HTML injection +v-html="userMarkdown" + +// Unbounded regex - vulnerable to ReDoS +text.replace(/\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)/g, '') +``` + +**Fix:** Sanitize HTML before rendering. Validate URL protocols. Use bounded quantifiers in regex. See [references/component-patterns.md](references/component-patterns.md). diff --git a/docs/skills/nuxt-architecture-review/references/component-patterns.md b/docs/skills/nuxt-architecture-review/references/component-patterns.md new file mode 100644 index 000000000..29d121222 --- /dev/null +++ b/docs/skills/nuxt-architecture-review/references/component-patterns.md @@ -0,0 +1,261 @@ +# Component Patterns Reference + +## Table of Contents + +- [Server/Client Component Split](#serverclient-component-split) +- [defineModel Patterns](#definemodel-patterns) +- [Route Aliases](#route-aliases) +- [Canonical Redirects Middleware](#canonical-redirects-middleware) +- [XSS-Safe Markdown Rendering](#xss-safe-markdown-rendering) + +--- + +## Server/Client Component Split + +### When to split + +| Scenario | Approach | +| --------------------------------------------------- | ------------------------------------ | +| Component needs browser APIs (localStorage, window) | `.client.vue` | +| Component is purely presentational, no JS needed | `.server.vue` | +| Component has different SSR vs client behavior | Both `.server.vue` and `.client.vue` | +| Component needs interactivity + SSR | Single `.vue` (default) | + +### Example: auth-aware menu + +```vue + + +``` + +```vue + + + +``` + +### How it works + +1. During SSR, Nuxt renders `AccountMenu.server.vue` (no JS shipped) +2. After hydration, Nuxt replaces it with `AccountMenu.client.vue` +3. The client version has full interactivity and browser API access + +--- + +## defineModel Patterns + +### Basic v-model + +```vue + + + + + + +``` + +### Named v-model (multiple models) + +```vue + + + + + +``` + +### Replacing the old pattern + +Before (Vue 3.3 and earlier): + +```ts +const props = defineProps<{ sortOption: string }>() +const emit = defineEmits<{ 'update:sortOption': [value: string] }>() +const localSort = computed({ + get: () => props.sortOption, + set: v => emit('update:sortOption', v), +}) +``` + +After (Vue 3.4+): + +```ts +const sortOption = defineModel('sortOption', { required: true }) +``` + +--- + +## Route Aliases + +### Pattern: multiple URL shapes for one page + +```ts +// app/pages/package-code/[...path].vue +definePageMeta({ + name: 'code', + path: '/package-code/:path+', + alias: [ + '/package/code/:path+', // Legacy URL pattern + '/code/:path+', // Shorthand + ], +}) +``` + +All three URLs render the same page component: + +- `/package-code/vue/v/3.5.0/src/index.ts` +- `/package/code/vue/v/3.5.0/src/index.ts` +- `/code/vue/v/3.5.0/src/index.ts` + +### When to use aliases vs redirects + +| Scenario | Use | +| -------------------------------------------- | ---------------------------- | +| Both URLs are valid and should be accessible | Alias | +| One URL is canonical, others should redirect | Redirect (middleware) | +| Backwards compatibility with old URL scheme | Alias (or redirect with 301) | + +--- + +## Canonical Redirects Middleware + +### Full implementation + +```ts +// server/middleware/canonical-redirects.global.ts + +// Pages that should NOT be redirected (they have their own routes) +const reservedPaths = ['/about', '/search', '/settings', '/api', '/package'] + +const cacheControl = 's-maxage=3600, stale-while-revalidate=36000' + +export default defineEventHandler(async event => { + const [path = '/', query] = event.path.split('?') + + // Skip internal paths + if (path.startsWith('/~') || path.startsWith('/_')) return + + // Skip known page routes + if (reservedPaths.some(p => path === p || path.startsWith(p + '/'))) return + + // /vue -> /package/vue (shorthand package URL) + const pkgMatch = path.match(/^\/(?:(?@[^/]+)\/)?(?[^/@]+)$/) + if (pkgMatch?.groups) { + const parts = [pkgMatch.groups.org, pkgMatch.groups.name].filter(Boolean).join('/') + setHeader(event, 'cache-control', cacheControl) + return sendRedirect(event, `/package/${parts}${query ? '?' + query : ''}`, 301) + } + + // /vue@3.5.0 -> /package/vue/v/3.5.0 + const versionMatch = path.match(/^\/(?:(?@[^/]+)\/)?(?[^/@]+)@(?[^/]+)$/) + if (versionMatch?.groups) { + const parts = [versionMatch.groups.org, versionMatch.groups.name].filter(Boolean).join('/') + setHeader(event, 'cache-control', cacheControl) + return sendRedirect( + event, + `/package/${parts}/v/${versionMatch.groups.version}${query ? '?' + query : ''}`, + 301, + ) + } +}) +``` + +### Key design decisions + +1. **Early returns** -- Skip known paths first to avoid regex evaluation +2. **Cache-Control on redirects** -- CDN caches the redirect itself, avoiding a server roundtrip +3. **301 Permanent** -- Tells search engines to index only the canonical URL +4. **Query preservation** -- Redirects preserve query parameters + +--- + +## XSS-Safe Markdown Rendering + +### Complete inline markdown parser + +```ts +// app/composables/useMarkdown.ts + +function stripAndEscapeHtml(text: string): string { + // Decode HTML entities first + let stripped = decodeHtmlEntities(text) + + // Strip markdown image badges (bounded quantifiers for ReDoS safety) + stripped = stripped.replace(/\[!\[[^\]]{0,500}\]\([^)]{0,2000}\)\]\([^)]{0,2000}\)?/g, '') + stripped = stripped.replace(/!\[[^\]]{0,500}\]\([^)]{0,2000}\)/g, '') + + // Strip HTML tags (keep text content) + stripped = stripped.replace(/<\/?[a-z][^>]*>/gi, '') + + // Escape remaining HTML entities + return stripped + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function parseMarkdown(text: string): string { + if (!text) return '' + + let html = stripAndEscapeHtml(text) + + // Bold + html = html.replace(/\*\*(.+?)\*\*/g, '$1') + + // Italic + html = html.replace(/(?$1') + + // Inline code + html = html.replace(/`([^`]+)`/g, '$1') + + // Strikethrough + html = html.replace(/~~(.+?)~~/g, '$1') + + // Links with protocol validation + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => { + try { + const { protocol, href } = new URL(url) + if (['https:', 'mailto:'].includes(protocol)) { + const safeUrl = href.replace(/"/g, '"') + return `${text}` + } + } catch {} + return `${text} (${url})` + }) + + return html +} +``` + +### Security checklist for v-html + +1. **Escape HTML entities** before any markdown processing +2. **Validate URL protocols** -- only allow `https:` and `mailto:` +3. **Add `rel="nofollow noreferrer noopener"`** to all external links +4. **Use bounded quantifiers** in regex (e.g., `{0,500}` instead of `*`) +5. **Strip image badges** that could contain tracking pixels +6. **Never render raw user input** without sanitization diff --git a/docs/skills/nuxt-architecture-review/references/modules-patterns.md b/docs/skills/nuxt-architecture-review/references/modules-patterns.md new file mode 100644 index 000000000..f29fdfa5e --- /dev/null +++ b/docs/skills/nuxt-architecture-review/references/modules-patterns.md @@ -0,0 +1,247 @@ +# Custom Nuxt Modules Reference + +## Table of Contents + +- [Module Design Principles](#module-design-principles) +- [Build Info Module](#build-info-module) +- [Cache Configuration Module](#cache-configuration-module) +- [ISR Fallback Module](#isr-fallback-module) +- [Component Extension Module](#component-extension-module) +- [shared/ Directory Structure](#shared-directory-structure) + +--- + +## Module Design Principles + +1. **Single responsibility** -- One module per concern (cache, build info, ISR fallback) +2. **Environment guards** -- Use `provider` from `std-env` to skip non-relevant environments +3. **Type augmentation** -- Extend Nuxt's type system via `declare module` for type-safe `appConfig` +4. **Hook-based** -- Use Nuxt/Nitro hooks instead of modifying config directly + +### Module skeleton + +```ts +// modules/my-feature.ts +import { defineNuxtModule } from 'nuxt/kit' +import { provider } from 'std-env' + +export default defineNuxtModule({ + meta: { name: 'my-feature' }, + setup(_, nuxt) { + // Environment guard + if (provider !== 'vercel') return + + // Use hooks to modify behavior + nuxt.hook('nitro:config', nitroConfig => { + // Modify nitro config + }) + }, +}) +``` + +--- + +## Build Info Module + +Inject build metadata (version, commit, branch, environment) into `appConfig` for runtime access: + +```ts +// modules/build-env.ts +import type { BuildInfo } from '../shared/types' +import { createResolver, defineNuxtModule } from 'nuxt/kit' +import { isCI } from 'std-env' +import { getEnv, version } from '../config/env' + +const { resolve } = createResolver(import.meta.url) + +export default defineNuxtModule({ + meta: { name: 'build-env' }, + async setup(_, nuxt) { + const { env, commit, shortCommit, branch } = await getEnv(nuxt.options.dev) + + nuxt.options.appConfig = nuxt.options.appConfig || {} + nuxt.options.appConfig.buildInfo = { + version, + time: +Date.now(), + commit, + shortCommit, + branch, + env, + } satisfies BuildInfo + + // Environment-specific public assets + nuxt.options.nitro.publicAssets = nuxt.options.nitro.publicAssets || [] + if (env === 'dev') { + nuxt.options.nitro.publicAssets.unshift({ dir: resolve('../public-dev') }) + } else if (env === 'staging') { + nuxt.options.nitro.publicAssets.unshift({ dir: resolve('../public-staging') }) + } + }, +}) + +// Type augmentation for appConfig +declare module '@nuxt/schema' { + interface AppConfig { + buildInfo: BuildInfo + } +} +``` + +### Usage in app + +```ts +// In any component or composable +const { buildInfo } = useAppConfig() +console.log(buildInfo.version, buildInfo.commit) +``` + +--- + +## Cache Configuration Module + +Configure Nitro storage backends per deployment provider: + +```ts +// modules/cache.ts +import process from 'node:process' +import { defineNuxtModule } from 'nuxt/kit' +import { provider } from 'std-env' + +export default defineNuxtModule({ + meta: { name: 'cache-config' }, + setup(_, nuxt) { + if (provider !== 'vercel') return + + nuxt.hook('nitro:config', nitroConfig => { + nitroConfig.storage = nitroConfig.storage || {} + + // SSR cache -> Vercel runtime cache + nitroConfig.storage.cache = { + ...nitroConfig.storage.cache, + driver: 'vercel-runtime-cache', + } + + // Fetch cache -> Vercel runtime cache + nitroConfig.storage['fetch-cache'] = { + driver: 'vercel-runtime-cache', + } + + // Sessions -> KV in production, runtime cache in preview + const env = process.env.VERCEL_ENV + nitroConfig.storage.sessions = { + driver: env === 'production' ? 'vercel-kv' : 'vercel-runtime-cache', + } + }) + }, +}) +``` + +--- + +## ISR Fallback Module + +Generate SPA fallback HTML for ISR routes: + +```ts +// modules/isr-fallback.ts +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { defineNuxtModule } from 'nuxt/kit' +import { provider } from 'std-env' + +export default defineNuxtModule({ + meta: { name: 'isr-fallback' }, + setup(_, nuxt) { + if (provider !== 'vercel') return + + nuxt.hook('nitro:init', nitro => { + nitro.hooks.hook('compiled', () => { + const spaTemplate = readFileSync(nitro.options.output.publicDir + '/200.html', 'utf-8') + // Copy SPA shell to each dynamic route directory + const routes = ['product', 'product/[id]', 'product/[id]/v/[version]'] + for (const path of routes) { + const dir = resolve(nitro.options.output.serverDir, '..', path) + mkdirSync(dir, { recursive: true }) + writeFileSync(resolve(dir, 'spa.prerender-fallback.html'), spaTemplate) + } + }) + }) + }, +}) +``` + +--- + +## Component Extension Module + +Remove or modify auto-discovered components via hooks: + +```ts +// modules/og-image.ts +import { defineNuxtModule, useNuxt } from 'nuxt/kit' + +export default defineNuxtModule({ + meta: { name: 'og-image-tweaks' }, + setup() { + const nuxt = useNuxt() + + nuxt.hook('components:extend', components => { + // Remove conflicting OG image components + for (const component of [...components].toReversed()) { + if (component.filePath.includes('og-image')) { + components.splice(components.indexOf(component), 1) + } + } + }) + }, +}) +``` + +--- + +## shared/ Directory Structure + +### Recommended layout + +``` +shared/ + types/ + index.ts # Re-exports all types + product.ts # Domain types + api-responses.ts # API response shapes + user.ts # User/session types + schemas/ + product.ts # Valibot schemas + inferred types + auth.ts # Auth validation schemas + utils/ + constants.ts # Shared constants (API URLs, cache durations) + formatters.ts # Pure formatting functions + npm.ts # Domain-specific pure utilities + async.ts # Async utilities (mapWithConcurrency) + fetch-cache-config.ts # Cache config shared between plugin and composable +``` + +### Import examples + +```ts +// From app/ code +import type { Product } from '#shared/types' +import { PackageNameSchema } from '#shared/schemas/product' +import { mapWithConcurrency } from '#shared/utils/async' + +// From server/ code +import type { Product } from '#shared/types' +import { PackageNameSchema } from '#shared/schemas/product' +``` + +### What belongs in shared/ + +| Content | Belongs in `shared/`? | Why | +| --------------------------- | --------------------- | ------------------------------------------- | +| Type definitions | Yes | Used by both app and server | +| Valibot schemas | Yes | Validation in server, type inference in app | +| Pure utility functions | Yes | No side effects, works everywhere | +| Constants (URLs, durations) | Yes | Referenced by both sides | +| Vue composables | No | Client/SSR only, use `app/composables/` | +| Server utilities | No | Nitro-only, use `server/utils/` | +| Database clients | No | Server-only | diff --git a/docs/skills/nuxt-architecture-review/references/typescript-patterns.md b/docs/skills/nuxt-architecture-review/references/typescript-patterns.md new file mode 100644 index 000000000..1f23648b7 --- /dev/null +++ b/docs/skills/nuxt-architecture-review/references/typescript-patterns.md @@ -0,0 +1,232 @@ +# TypeScript Patterns Reference + +## Table of Contents + +- [Discriminated Unions](#discriminated-unions) +- [satisfies Operator](#satisfies-operator) +- [Schema-Driven Types](#schema-driven-types) +- [Type Augmentation](#type-augmentation) + +--- + +## Discriminated Unions + +### Pattern: status modeling + +Instead of an object with optional fields, use a `kind` discriminant: + +```ts +// Bad: optional fields, easy to misuse +interface LoadingState { + isLoading: boolean + data?: Product + error?: Error +} + +// Good: discriminated union, impossible states are unrepresentable +type LoadingState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'success'; data: Product } + | { status: 'error'; error: Error } +``` + +### Pattern: feature detection + +```ts +type TypesStatus = + | { kind: 'included' } + | { kind: '@types'; packageName: string; deprecated?: string } + | { kind: 'none' } + +function detectTypesStatus(pkg: PackageJson): TypesStatus { + if (pkg.types || pkg.typings) return { kind: 'included' } + const typesPackage = findTypesPackage(pkg.name) + if (typesPackage) return { kind: '@types', packageName: typesPackage.name } + return { kind: 'none' } +} + +// Usage: TypeScript narrows automatically +function renderBadge(status: TypesStatus): string { + switch (status.kind) { + case 'included': + return 'TS' + case '@types': + return `@types/${status.packageName}` + case 'none': + return 'No types' + } + // TypeScript error if a case is missing (exhaustiveness check) +} +``` + +### Pattern: API response variants + +```ts +type ApiResponse = + | { ok: true; data: T; isStale: boolean } + | { ok: false; error: string; statusCode: number } + +function handleResponse(response: ApiResponse) { + if (response.ok) { + // TypeScript knows: response.data exists, response.error doesn't + console.log(response.data) + } else { + // TypeScript knows: response.error exists, response.data doesn't + console.error(response.error) + } +} +``` + +--- + +## satisfies Operator + +### Pattern: type-checked return values + +```ts +// Bad: 'as' suppresses errors and widens the type +return { + package: name, + version: pkg.version, +} as PackageResponse // Typo in field name? No error! + +// Good: 'satisfies' catches errors while preserving literal types +return { + package: name, + version: pkg.version, +} satisfies PackageResponse // Error if shape doesn't match +``` + +### Pattern: typed defaults + +```ts +// The type is checked but the literal values are preserved +const emptyResponse = { + objects: [], + total: 0, + isStale: false, + time: new Date().toISOString(), +} satisfies SearchResponse + +// emptyResponse.total is typed as 0 (literal), not number +``` + +### Pattern: config objects + +```ts +// Ensures the config matches the expected shape +const theme = { + spacing: { DEFAULT: '4px' }, + font: { + mono: "'Geist Mono', monospace", + sans: "'Geist', system-ui, sans-serif", + }, +} satisfies ThemeConfig +``` + +### When to use satisfies vs as vs annotation + +| Approach | Use when | +| ---------------- | --------------------------------------------------------------- | +| `satisfies Type` | Checking shape without widening (return values, defaults) | +| `: Type` | Variable declarations where you want the wider type | +| `as Type` | Narrowing in assertions you're certain about (avoid on returns) | + +--- + +## Schema-Driven Types + +### Pattern: infer types from Valibot schemas + +```ts +import * as v from 'valibot' + +// Define the schema once +export const ProductSchema = v.object({ + id: v.pipe(v.string(), v.nonEmpty()), + name: v.pipe(v.string(), v.minLength(1)), + version: v.optional(v.pipe(v.string(), v.regex(/^[\w.+-]+$/))), + tags: v.array(v.string()), +}) + +// Infer the type from the schema - no manual interface needed +export type Product = v.InferOutput +// { id: string; name: string; version?: string; tags: string[] } + +// Use in routes +const product = v.parse(ProductSchema, rawInput) +// product is fully typed as Product +``` + +### Why this is better than manual types + +1. **Single source of truth** -- Schema and type are always in sync +2. **Runtime validation** -- The schema validates at runtime, not just compile time +3. **Auto-complete** -- IDE knows the shape from the schema +4. **Refactor-safe** -- Change the schema, the type updates automatically + +### Pattern: multiple schemas sharing fields + +```ts +const BaseProductSchema = v.object({ + name: PackageNameSchema, // Reuse validated fields +}) + +const ProductWithVersionSchema = v.object({ + ...BaseProductSchema.entries, + version: VersionSchema, +}) + +const ProductWithFileSchema = v.object({ + ...ProductWithVersionSchema.entries, + filePath: FilePathSchema, +}) + +// Each infers its own type +type ProductRoute = v.InferOutput +type ProductVersionRoute = v.InferOutput +type ProductFileRoute = v.InferOutput +``` + +--- + +## Type Augmentation + +### Pattern: extend Nuxt's AppConfig + +```ts +// modules/build-env.ts +declare module '@nuxt/schema' { + interface AppConfig { + env: 'dev' | 'staging' | 'production' + buildInfo: BuildInfo + } +} +``` + +Now `useAppConfig().buildInfo` is fully typed. + +### Pattern: extend H3 event context + +```ts +// server/plugins/fetch-cache.ts +declare module 'h3' { + interface H3EventContext { + cachedFetch?: CachedFetchFunction + } +} +``` + +Now `event.context.cachedFetch` is fully typed across all server code. + +### Pattern: extend component props globally + +```ts +// types/global.d.ts +declare module 'vue' { + interface ComponentCustomProperties { + $formatDate: (date: string) => string + } +} +``` diff --git a/docs/skills/nuxt-perf-review/SKILL.md b/docs/skills/nuxt-perf-review/SKILL.md new file mode 100644 index 000000000..00ef6b777 --- /dev/null +++ b/docs/skills/nuxt-perf-review/SKILL.md @@ -0,0 +1,282 @@ +--- +name: nuxt-perf-review +description: > + Review and audit a Nuxt application for client-side and rendering performance optimizations. + Use when asked to "review performance", "optimize my Nuxt app", "audit for speed", + "improve TTFB", "reduce bundle size", "optimize data fetching", or any performance-related + code review on a Nuxt 3/4 project. Covers ISR, SWR, getCachedData, lazy data fetching, + shallowRef, view transitions, conditional fetching, and incremental loading patterns. +--- + +# Nuxt Performance Optimization Review + +Audit a Nuxt application for performance optimizations. Scan the codebase for each pattern below, explain the concept, and propose fixes when the pattern is missing or misused. + +For full code examples, see the `references/` files linked in each section. + +## Review Process + +1. Read `nuxt.config.ts` for route rules, experimental features, and rendering config +2. Scan `app/composables/` and `app/pages/` for data fetching patterns +3. Check for `shallowRef` vs `ref` usage across components and composables +4. Report findings with explanations and suggested fixes + +--- + +## 1. ISR Route Rules + +**What:** Incremental Static Regeneration (ISR) pre-renders pages on first request, then serves the cached version for subsequent requests. After a configurable TTL (e.g., 60 seconds), the next request triggers a background regeneration while still serving the stale page instantly. + +**Why:** Without ISR, every request either hits SSR (slow TTFB, high server cost) or serves a fully static page (stale data). ISR gives the best of both: instant responses with fresh-enough data. + +**What to look for:** Check `routeRules` in `nuxt.config.ts`. Dynamic pages (e.g., `/product/[id]`) should have `isr` with an expiration. Static pages should use `prerender: true`. Auth/search routes should have `isr: false, cache: false`. + +**Good:** + +```ts +// nuxt.config.ts +routeRules: { + '/product/:id': { isr: { expiration: 60 } }, // revalidate every 60s + '/docs/**': { isr: true, cache: { maxAge: 31536000 } }, // immutable content + '/': { prerender: true }, // fully static + '/search': { isr: false, cache: false }, // never cache + '/api/auth/**': { isr: false, cache: false }, // never cache auth +} +``` + +**Bad:** + +```ts +// No routeRules at all, or only prerender for everything +routeRules: { + '/': { prerender: true }, + // Dynamic pages have no ISR config -> full SSR on every request +} +``` + +**Fix:** Add `isr` rules for dynamic pages. Use `prerender: true` for static pages. Disable caching for auth and real-time routes. See [references/isr-patterns.md](references/isr-patterns.md) for advanced patterns (SPA fallback, query-aware ISR). + +--- + +## 2. getCachedData for Navigation Caching + +**What:** `getCachedData` is a `useFetch`/`useAsyncData` option that checks Nuxt's payload cache before making a new request. On client-side navigation, data already fetched during SSR or a previous navigation is returned instantly without a network call. + +**Why:** Without `getCachedData`, navigating back to a previously visited page triggers a new API call, causing loading spinners and wasted bandwidth. With it, the page renders instantly from cache. + +**What to look for:** Search for `useFetch` and `useLazyFetch` calls. If the data rarely changes (avatars, config, metadata), add `getCachedData`. + +**Good:** + +```ts +const { data } = useFetch('/api/settings', { + getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key] ?? nuxtApp.static.data[key], +}) +``` + +**Bad:** + +```ts +// No getCachedData -> refetches on every navigation +const { data } = useFetch('/api/settings') +``` + +**Fix:** Add `getCachedData` to `useFetch`/`useLazyFetch` calls where data does not change frequently. See [references/client-caching-patterns.md](references/client-caching-patterns.md). + +--- + +## 3. Client-Side Stale-While-Revalidate + +**What:** When the server returns data from a stale cache (past TTL but still usable), the response includes an `isStale` flag. On the client, detect this flag and trigger a background `refresh()` after mount to get fresh data without blocking the initial render. + +**Why:** The user sees content instantly (even if slightly stale), and the page silently updates with fresh data in the background. This eliminates perceived loading time. + +**What to look for:** Search for composables that fetch external API data. Check if they propagate staleness metadata and trigger `refresh()` on mount when stale. + +**Good:** + +```ts +export function useProduct(id: MaybeRefOrGetter) { + const cachedFetch = useCachedFetch() + + const asyncData = useLazyAsyncData( + () => `product:${toValue(id)}`, + async () => { + const { data, isStale } = await cachedFetch(`/api/products/${toValue(id)}`) + return { ...data, isStale } + }, + ) + + // If SSR returned stale data, silently refresh on mount + if (import.meta.client && asyncData.data.value?.isStale) { + onMounted(() => asyncData.refresh()) + } + + return asyncData +} +``` + +**Bad:** + +```ts +// No stale detection -> user either sees old data forever or waits for fresh data +const { data } = useFetch('/api/products/' + id) +``` + +**Fix:** Propagate staleness from server cache, detect it on client, and call `refresh()` on mount. See [references/client-caching-patterns.md](references/client-caching-patterns.md). + +--- + +## 4. useLazyAsyncData / useLazyFetch for Non-Blocking Data + +**What:** `useLazyFetch` and `useLazyAsyncData` fetch data without blocking the page render. The page displays immediately with `null` data, which fills in as requests complete. This is ideal for secondary/supplementary data. + +**Why:** If a page fetches 5 different APIs with `useFetch`, the page waits for ALL of them before rendering. Using `useLazyFetch` for non-critical data (README, stats, related items) lets the page render with the critical data first. + +**What to look for:** Pages with multiple `useFetch` calls. Identify which data is critical (needed for the page to make sense) vs. supplementary (can load after render). + +**Good:** + +```ts +// Critical data - blocks render +const { data: product } = await useFetch(`/api/product/${id}`) + +// Supplementary data - loads after render +const { data: readme } = useLazyFetch(`/api/product/${id}/readme`, { + default: () => ({ html: '' }), +}) +const { data: stats } = useLazyFetch(`/api/product/${id}/stats`, { + server: false, // skip SSR entirely + immediate: false, // don't fetch until triggered +}) +onMounted(() => stats.execute()) +``` + +**Bad:** + +```ts +// All blocking -> page waits for everything +const { data: product } = await useFetch(`/api/product/${id}`) +const { data: readme } = await useFetch(`/api/product/${id}/readme`) +const { data: stats } = await useFetch(`/api/product/${id}/stats`) +``` + +**Fix:** Convert supplementary fetches to `useLazyFetch`. Use `server: false` + `immediate: false` for client-only data that can be deferred. See [references/data-fetching-patterns.md](references/data-fetching-patterns.md). + +--- + +## 5. shallowRef Over ref for Non-Deep State + +**What:** `shallowRef` creates a ref that only triggers reactivity when the `.value` itself is reassigned, not when nested properties change. For primitive values, Maps, arrays used as caches, or objects that are always replaced (never mutated), `shallowRef` avoids Vue wrapping every nested property in a Proxy. + +**Why:** `ref` deeply proxies the entire object tree. For a `Map` with 1000 entries or an array of search results, this creates thousands of Proxy objects. `shallowRef` avoids this overhead entirely. + +**What to look for:** Search for `ref(` in composables. Check if the value is: a boolean, a number, a string, a Map/Set, or an object that is always replaced wholesale. If so, it should be `shallowRef`. + +**Good:** + +```ts +const isOpen = shallowRef(false) +const cache = shallowRef(new Map()) +const results = shallowRef([]) + +// Replace the whole value to trigger reactivity +results.value = [...results.value, ...newResults] +``` + +**Bad:** + +```ts +const isOpen = ref(false) // Deep proxy on a boolean is wasteful +const cache = ref(new Map()) // Deep proxy on a Map is expensive +const results = ref([]) // Deep proxy on array of objects +``` + +**Fix:** Replace `ref` with `shallowRef` for primitives, Maps, Sets, and objects that are replaced (not mutated). See [references/client-caching-patterns.md](references/client-caching-patterns.md). + +--- + +## 6. View Transitions API + +**What:** The View Transitions API provides native browser animations between page navigations in SSR-rendered apps. Nuxt supports it via `experimental.viewTransition`. + +**Why:** Gives smooth cross-fade transitions between pages with zero JavaScript animation overhead. Handled entirely by the browser. + +**What to look for:** Check `nuxt.config.ts` for `experimental.viewTransition`. + +**Good:** + +```ts +// nuxt.config.ts +experimental: { + viewTransition: true, +} +``` + +**Fix:** Add `viewTransition: true` to the experimental config. Works out of the box for page transitions. + +--- + +## 7. Conditional / Deferred Fetching + +**What:** Use the `immediate` option on `useLazyFetch` to conditionally skip fetches that aren't needed. For example, only fetch JSR data for scoped packages, or only fetch install size after the component mounts. + +**Why:** Avoids unnecessary network requests, reducing server load and improving page load time. + +**What to look for:** `useFetch` calls that always execute but only make sense under certain conditions. + +**Good:** + +```ts +// Only fetch for scoped packages +const { data } = useLazyFetch(() => `/api/jsr/${name}`, { + immediate: computed(() => name.value.startsWith('@')).value, +}) + +// Only fetch on client after mount +const { data, execute } = useLazyFetch(`/api/heavy-data`, { + server: false, + immediate: false, +}) +onMounted(() => execute()) +``` + +**Bad:** + +```ts +// Fetches for every package, even non-scoped ones where it will 404 +const { data } = useFetch(() => `/api/jsr/${name}`) +``` + +**Fix:** Add `immediate: false` or `immediate: computed(...)` to skip unnecessary fetches. Use `server: false` for client-only data. See [references/data-fetching-patterns.md](references/data-fetching-patterns.md). + +--- + +## 8. Incremental Loading / Infinite Scroll + +**What:** Instead of fetching all results at once, fetch an initial batch and provide a `fetchMore()` function that appends results incrementally. The composable manages a local cache to merge pages. + +**Why:** Fetching 500 search results on initial load is slow and wastes bandwidth. Fetching 25 at a time and loading more on scroll is faster and more responsive. + +**What to look for:** Search/list pages that fetch all results in one call. Check if there is infinite scroll or pagination support. + +**Good:** + +```ts +const cache = shallowRef([]) + +async function fetchMore(targetSize: number) { + const from = cache.value.length + const response = await fetch(`/api/search?from=${from}&size=25`) + cache.value = [...cache.value, ...response.results] +} +``` + +**Bad:** + +```ts +// Fetches everything at once +const { data } = useFetch(`/api/search?size=500`) +``` + +**Fix:** Implement incremental loading with a local cache and `fetchMore()` function. See [references/data-fetching-patterns.md](references/data-fetching-patterns.md) for the full pattern. diff --git a/docs/skills/nuxt-perf-review/references/client-caching-patterns.md b/docs/skills/nuxt-perf-review/references/client-caching-patterns.md new file mode 100644 index 000000000..bc70c8eee --- /dev/null +++ b/docs/skills/nuxt-perf-review/references/client-caching-patterns.md @@ -0,0 +1,156 @@ +# Client Caching Patterns Reference + +## Table of Contents + +- [getCachedData Pattern](#getcacheddata-pattern) +- [Client-Side SWR with isStale](#client-side-swr-with-isstale) +- [shallowRef Optimization Guide](#shallowref-optimization-guide) + +--- + +## getCachedData Pattern + +Prevent refetching on client-side navigation by reading from Nuxt's payload cache. + +### How it works + +When Nuxt renders a page on the server, all `useFetch` data is serialized into `nuxtApp.payload.data`. On client-side navigation, `getCachedData` checks this cache first. If data exists, no network request is made. + +### Basic pattern + +```ts +const { data } = useFetch('/api/config', { + getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key] ?? nuxtApp.static.data[key], +}) +``` + +### When to use + +- Data that rarely changes (user settings, app config, avatar URLs) +- Data already fetched by another component on the same page +- Lookups with stable keys (e.g., `/api/user/123`) + +### When NOT to use + +- Real-time data (chat, live feeds) +- Data that changes between navigations (search results with different queries) +- Data gated by auth state (may leak between users in SSR) + +### With useLazyFetch + +```ts +const { data: avatarUrl } = useLazyFetch(() => `/api/avatar/${username}`, { + transform: res => res.url, + getCachedData: (key, nuxtApp) => nuxtApp.static.data[key] ?? nuxtApp.payload.data[key], +}) +``` + +--- + +## Client-Side SWR with isStale + +Propagate staleness metadata from server cache to client and trigger background revalidation. + +### Architecture + +``` +Server cache (SWR) Composable Client + | | | + |-- data + isStale ------->| | + | |-- render stale ----->| + | |-- onMounted -------->| + | | refresh() | + |<---- fresh fetch --------| | + | |-- update UI -------->| +``` + +### Full pattern + +```ts +export function useProduct(id: MaybeRefOrGetter) { + // Get cachedFetch in setup context (before async) + const cachedFetch = useCachedFetch() + + const asyncData = useLazyAsyncData( + () => `product:${toValue(id)}`, + async (_nuxtApp, { signal }) => { + const { data, isStale } = await cachedFetch( + `https://api.example.com/products/${toValue(id)}`, + { signal }, + ) + return { ...data, isStale } + }, + ) + + // On client: if the SSR data came from stale cache, refresh silently + if (import.meta.client && asyncData.data.value?.isStale) { + onMounted(() => { + asyncData.refresh() + }) + } + + return asyncData +} +``` + +### Key points + +1. `useCachedFetch()` must be called in setup context (not inside async handlers) +2. The `isStale` flag is returned by the server-side SWR cache plugin +3. `import.meta.client` guard ensures refresh only runs on client +4. `onMounted` ensures the DOM is ready before triggering a background fetch +5. The user sees stale content instantly, then it updates seamlessly + +--- + +## shallowRef Optimization Guide + +### When to use shallowRef + +| Value Type | Use `shallowRef`? | Reason | +| ---------------------------------- | ----------------- | ---------------------------------------- | +| `boolean` | Yes | No nested props to track | +| `number` / `string` | Yes | Primitives don't benefit from deep proxy | +| `Map` / `Set` | Yes | Vue proxies are expensive on collections | +| `Array` replaced wholesale | Yes | Full replacement triggers reactivity | +| `null \| Object` toggled | Yes | Only null/object transition matters | +| Object with mutated nested props | No, use `ref` | Need deep tracking for mutations | + +### Pattern: cache with shallowRef + +```ts +// Cache that is always replaced, never mutated +const cache = shallowRef>(new Map()) + +// Update by creating a new Map +function addToCache(key: string, item: CachedItem) { + const next = new Map(cache.value) + next.set(key, item) + cache.value = next // triggers reactivity +} +``` + +### Pattern: UI state with shallowRef + +```ts +const isOpen = shallowRef(false) +const isLoading = shallowRef(false) +const currentPage = shallowRef(1) +const selectedTab = shallowRef(null) +``` + +### Pattern: list results with shallowRef + +```ts +const results = shallowRef([]) + +// Append new results by replacing the array +results.value = [...results.value, ...newResults] + +// Reset +results.value = [] +``` + +### Impact + +In a codebase with 100+ reactive variables, replacing `ref` with `shallowRef` where appropriate can reduce Vue's proxy overhead by 30-50%, especially for composables that manage large data structures (search results, dependency trees, file listings). diff --git a/docs/skills/nuxt-perf-review/references/data-fetching-patterns.md b/docs/skills/nuxt-perf-review/references/data-fetching-patterns.md new file mode 100644 index 000000000..ae42604d4 --- /dev/null +++ b/docs/skills/nuxt-perf-review/references/data-fetching-patterns.md @@ -0,0 +1,211 @@ +# Data Fetching Patterns Reference + +## Table of Contents + +- [Lazy vs Blocking Fetch Strategy](#lazy-vs-blocking-fetch-strategy) +- [Conditional / Deferred Fetching](#conditional--deferred-fetching) +- [Incremental Loading with fetchMore](#incremental-loading-with-fetchmore) +- [Payload Optimization with Transform](#payload-optimization-with-transform) + +--- + +## Lazy vs Blocking Fetch Strategy + +### Decision guide + +For each `useFetch` on a page, ask: "Can the page render meaningfully without this data?" + +| Data | Strategy | Reason | +| -------------------------- | ----------------------------------- | ------------------------------ | +| Product name, price, image | `useFetch` (blocking) | Page makes no sense without it | +| README / documentation | `useLazyFetch` | Page renders; README fills in | +| Download stats | `useLazyFetch` + `server: false` | Not needed for SEO | +| Install size (slow API) | `useLazyFetch` + `immediate: false` | Only load on demand | +| Related packages | `useLazyFetch` | Supplementary content | + +### Example: page with mixed strategies + +```vue + +``` + +--- + +## Conditional / Deferred Fetching + +### Skip fetches based on reactive conditions + +```ts +// Only fetch for scoped packages (starts with @) +const { data: jsrInfo } = useLazyFetch(() => `/api/jsr/${name.value}`, { + default: () => ({ exists: false }), + immediate: computed(() => name.value.startsWith('@')).value, +}) +``` + +### Defer heavy fetches to after mount + +```ts +const { + data: installSize, + status: installSizeStatus, + execute: fetchInstallSize, +} = useLazyFetch(`/api/install-size/${name.value}`, { + server: false, // skip SSR - not needed for initial HTML + immediate: false, // don't fetch until told +}) + +// Trigger after the page has rendered +onMounted(() => fetchInstallSize()) +``` + +### Conditionally fetch in a watcher + +```ts +const shouldFetchDetails = computed(() => activeTab.value === 'details') + +watch(shouldFetchDetails, shouldFetch => { + if (shouldFetch && !detailsData.value) { + fetchDetails() + } +}) +``` + +--- + +## Incremental Loading with fetchMore + +### Architecture + +``` +Initial load: fetch 25 results -> cache.value = [0..24] +User scrolls: fetchMore(50) -> cache.value = [0..49] +User scrolls: fetchMore(75) -> cache.value = [0..74] +``` + +### Full composable pattern + +```ts +export function useSearch(query: MaybeRefOrGetter) { + const cachedFetch = useCachedFetch() + const cache = shallowRef<{ query: string; items: Result[]; total: number } | null>(null) + const isLoadingMore = shallowRef(false) + + const asyncData = useLazyAsyncData( + () => `search:${toValue(query)}`, + async (_nuxtApp, { signal }) => { + const q = toValue(query) + if (!q.trim()) return { items: [], total: 0 } + + cache.value = null // Reset cache for new query + + const params = new URLSearchParams({ text: q, size: '25' }) + const { data: response } = await cachedFetch( + `/api/search?${params}`, + { signal }, + 60, + ) + + cache.value = { query: q, items: response.items, total: response.total } + return response + }, + { default: () => ({ items: [], total: 0 }) }, + ) + + async function fetchMore(targetSize: number) { + const q = toValue(query).trim() + if (!q || !cache.value) return + + const currentCount = cache.value.items.length + if (currentCount >= targetSize || currentCount >= cache.value.total) return + + isLoadingMore.value = true + try { + const from = currentCount + const size = Math.min(targetSize - currentCount, cache.value.total - currentCount) + const params = new URLSearchParams({ text: q, size: String(size), from: String(from) }) + const { data: response } = await cachedFetch(`/api/search?${params}`, {}, 60) + + // Deduplicate and append + const existingIds = new Set(cache.value.items.map(i => i.id)) + const newItems = response.items.filter(i => !existingIds.has(i.id)) + cache.value = { + query: q, + items: [...cache.value.items, ...newItems], + total: response.total, + } + } finally { + isLoadingMore.value = false + } + } + + const data = computed(() => { + if (cache.value) return { items: cache.value.items, total: cache.value.total } + return asyncData.data.value + }) + + const hasMore = computed(() => { + if (!cache.value) return true + return cache.value.items.length < cache.value.total + }) + + return { ...asyncData, data, isLoadingMore, hasMore, fetchMore } +} +``` + +### Key design decisions + +1. **`shallowRef` for cache** -- Avoids deep proxying of the results array +2. **Deduplication** -- `existingIds` Set prevents duplicate items when pages overlap +3. **Computed `data`** -- Merges cache and asyncData transparently +4. **`isLoadingMore`** -- Separate from main `status` to show "loading more" vs "initial load" +5. **Query change resets cache** -- New query starts fresh + +--- + +## Payload Optimization with Transform + +### Reduce server-to-client payload by transforming data + +When the API returns a large object but the component only needs a subset, use the `transform` option to strip unnecessary fields before they're serialized into the HTML payload: + +```ts +export function usePackage(name: MaybeRefOrGetter) { + const cachedFetch = useCachedFetch() + + return useLazyAsyncData( + () => `package:${toValue(name)}`, + async () => { + const { data: fullPackument } = await cachedFetch( + `https://registry.npmjs.org/${toValue(name)}`, + ) + // Transform: only include the 5 most recent versions + dist-tags + // instead of the full 500-version packument + return transformPackument(fullPackument) + }, + ) +} +``` + +This can reduce payload size by 10x+ for packages with many versions. diff --git a/docs/skills/nuxt-perf-review/references/isr-patterns.md b/docs/skills/nuxt-perf-review/references/isr-patterns.md new file mode 100644 index 000000000..57d3f52b9 --- /dev/null +++ b/docs/skills/nuxt-perf-review/references/isr-patterns.md @@ -0,0 +1,163 @@ +# ISR Patterns Reference + +## Table of Contents + +- [Route Rules Strategy](#route-rules-strategy) +- [ISR Helper Function](#isr-helper-function) +- [SPA Fallback Module](#spa-fallback-module) +- [Query-Aware ISR](#query-aware-isr) +- [Immutable Content Caching](#immutable-content-caching) + +--- + +## Route Rules Strategy + +Categorize every route into one of these tiers: + +| Tier | Config | Use Case | +| --------------- | ---------------------------------------- | ---------------------------------- | +| Static | `prerender: true` | Homepage, about, privacy, settings | +| Dynamic + fresh | `isr: { expiration: 60 }` | Product pages, profiles | +| Immutable | `isr: true, cache: { maxAge: 31536000 }` | Versioned content (docs, code) | +| Never cache | `isr: false, cache: false` | Auth, search, real-time data | +| Proxy | `proxy: 'https://...'` | Analytics, avatars | + +Full example from a production app: + +```ts +// nuxt.config.ts +routeRules: { + // API routes - default 60s revalidation + '/api/**': { isr: 60 }, + // Immutable API responses (versioned - content never changes) + '/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, + '/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, + + // Never cache auth or social endpoints + '/api/auth/**': { isr: false, cache: false }, + '/api/social/**': { isr: false, cache: false }, + + // Query-aware ISR (search suggestions) + '/api/opensearch/suggestions': { + isr: { + expiration: 60 * 60 * 24, + passQuery: true, + allowQuery: ['q'], + }, + }, + + // Dynamic pages with SPA fallback + '/package/:name': { isr: getISRConfig(60, true) }, + '/package/:org/:name': { isr: getISRConfig(60, true) }, + + // Immutable versioned pages + '/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, + + // Static pages + '/': { prerender: true }, + '/about': { prerender: true }, + '/settings': { prerender: true }, + + // Never cache search + '/search': { isr: false, cache: false }, +} +``` + +--- + +## ISR Helper Function + +Use a helper to standardize ISR config and optionally add SPA fallback for dynamic routes: + +```ts +// At the bottom of nuxt.config.ts (or in a shared config file) +function getISRConfig(expirationSeconds: number, fallback = false) { + if (fallback) { + return { + expiration: expirationSeconds, + fallback: 'spa.prerender-fallback.html', + } as { expiration: number } + } + return { expiration: expirationSeconds } +} +``` + +The `fallback` option tells the hosting platform (e.g., Vercel) to serve an SPA shell while the ISR page is being generated for the first time. This prevents users from seeing a loading spinner or error on uncached pages. + +--- + +## SPA Fallback Module + +Generate SPA fallback HTML files for each dynamic route pattern. This Nuxt module copies the prerendered `200.html` SPA shell to every dynamic route directory at build time: + +```ts +// modules/isr-fallback.ts +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { defineNuxtModule } from 'nuxt/kit' +import { provider } from 'std-env' + +export default defineNuxtModule({ + meta: { name: 'isr-fallback' }, + setup(_, nuxt) { + // Only run on Vercel + if (provider !== 'vercel') return + + nuxt.hook('nitro:init', nitro => { + nitro.hooks.hook('compiled', () => { + const spaTemplate = readFileSync(nitro.options.output.publicDir + '/200.html', 'utf-8') + // Generate fallback for every dynamic route pattern + for (const path of [ + 'package', + 'package/[name]', + 'package/[name]/v/[version]', + 'package/[org]/[name]', + '', + ]) { + const outputPath = resolve( + nitro.options.output.serverDir, + '..', + path, + 'spa.prerender-fallback.html', + ) + mkdirSync(resolve(nitro.options.output.serverDir, '..', path), { + recursive: true, + }) + writeFileSync(outputPath, spaTemplate) + } + }) + }) + }, +}) +``` + +--- + +## Query-Aware ISR + +For routes that vary by query string (e.g., search suggestions), use `passQuery` and `allowQuery`: + +```ts +'/api/search/suggestions': { + isr: { + expiration: 86400, // 1 day + passQuery: true, // include query in cache key + allowQuery: ['q'], // only 'q' param affects the cache key + }, +} +``` + +Without `passQuery`, all query variations return the same cached response. Without `allowQuery`, tracking params like `utm_source` bust the cache. + +--- + +## Immutable Content Caching + +For content that never changes once published (e.g., versioned package docs, specific file at a version), use `isr: true` with a long `maxAge`: + +```ts +// Versioned content - cache for 1 year +'/docs/v/:version/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, +``` + +`isr: true` (without expiration) means: generate once, cache forever, never revalidate. Combined with `maxAge`, the CDN also caches it for the specified duration. diff --git a/docs/skills/nuxt-server-review/SKILL.md b/docs/skills/nuxt-server-review/SKILL.md new file mode 100644 index 000000000..fd62903d0 --- /dev/null +++ b/docs/skills/nuxt-server-review/SKILL.md @@ -0,0 +1,330 @@ +--- +name: nuxt-server-review +description: > + Review and audit a Nuxt application's server-side API routes, caching layers, and data fetching + infrastructure. Use when asked to "review server code", "optimize API routes", "audit caching + strategy", "improve server performance", "review error handling", "add validation", or any + server-side code review on a Nuxt 3/4 project. Covers defineCachedEventHandler, SWR, + defineCachedFunction, fetch-cache plugins, centralized error handling, Valibot validation, + Cache-Control headers, and parallel data fetching with concurrency control. +--- + +# Nuxt Server & Caching Architecture Review + +Audit a Nuxt application's server-side code for caching, validation, error handling, and data fetching patterns. Scan the codebase for each pattern below, explain the concept, and propose fixes. + +For full code examples, see the `references/` files linked in each section. + +## Review Process + +1. Read `server/api/` for API route handlers and their caching configuration +2. Read `server/utils/` for shared server utilities and cached functions +3. Read `server/plugins/` for Nitro plugins (fetch cache, middleware) +4. Read `server/middleware/` for request processing +5. Check `shared/schemas/` for validation schemas +6. Report findings with explanations and suggested fixes + +--- + +## 1. defineCachedEventHandler with SWR + +**What:** `defineCachedEventHandler` wraps a Nitro event handler with automatic caching. Combined with `swr: true` (Stale-While-Revalidate), it serves cached responses instantly and revalidates in the background after the TTL expires. The `getKey` function determines the cache key. + +**Why:** Without caching, every API request hits the full handler logic (database queries, external API calls). With SWR, the first request populates the cache, and subsequent requests are served in microseconds. After the TTL, the next request still gets the cached response instantly, while a background process fetches fresh data. + +**What to look for:** Check `server/api/` routes. Are they wrapped in `defineCachedEventHandler`? Do they have `swr: true`? Is the `getKey` function producing unique, deterministic keys? + +**Good:** + +```ts +// server/api/products/[id].get.ts +export default defineCachedEventHandler( + async event => { + const id = getRouterParam(event, 'id') + const product = await db.query('SELECT * FROM products WHERE id = ?', [id]) + return product + }, + { + maxAge: 300, // 5 minutes + swr: true, // serve stale while revalidating + getKey: event => { + const id = getRouterParam(event, 'id') ?? '' + return `product:v1:${id}` + }, + }, +) +``` + +**Bad:** + +```ts +// No caching - every request hits the database +export default defineEventHandler(async event => { + const id = getRouterParam(event, 'id') + return await db.query('SELECT * FROM products WHERE id = ?', [id]) +}) +``` + +**Fix:** Wrap with `defineCachedEventHandler`, set `swr: true`, and define a stable `getKey`. Version the cache key (e.g., `v1:`) to invalidate on format changes. See [references/cached-handlers.md](references/cached-handlers.md). + +--- + +## 2. defineCachedFunction for Reusable Cached Logic + +**What:** `defineCachedFunction` caches the result of any async function independently from HTTP handlers. This is useful for shared server utilities called from multiple routes. + +**Why:** If multiple API routes call the same external API (e.g., fetching package metadata), each route would make a redundant request. `defineCachedFunction` ensures one fetch populates a cache shared across all callers. + +**What to look for:** Check `server/utils/` for functions that call external APIs or perform expensive computations. Are they wrapped in `defineCachedFunction`? + +**Good:** + +```ts +// server/utils/products.ts +export const fetchProduct = defineCachedFunction( + async (id: string): Promise => { + return await $fetch(`https://api.example.com/products/${id}`) + }, + { + maxAge: 300, + swr: true, + name: 'product', + getKey: (id: string) => id, + }, +) +``` + +**Bad:** + +```ts +// Called from 3 different routes, each making a separate API call +export async function fetchProduct(id: string) { + return await $fetch(`https://api.example.com/products/${id}`) +} +``` + +**Fix:** Wrap with `defineCachedFunction`. Use `name` for cache namespace and `getKey` for unique keys. See [references/cached-handlers.md](references/cached-handlers.md). + +--- + +## 3. Custom SWR Fetch-Cache Plugin + +**What:** A Nitro plugin that intercepts outgoing `$fetch` calls during SSR and caches their responses with SWR semantics. It attaches a `cachedFetch` function to `event.context`, which composables can access via `useRequestEvent()`. Background revalidation uses `event.waitUntil()` for serverless compatibility. + +**Why:** Even with `defineCachedEventHandler`, the handler's internal `$fetch` calls to external APIs are not cached. A fetch-cache plugin adds a second layer: the handler is cached, AND the external API calls within it are cached independently. + +**What to look for:** Check if the app makes external API calls during SSR. Is there a fetch-caching layer? Does it use `event.waitUntil()` for background work? + +**Good:** A dedicated Nitro plugin with domain allowlisting and SWR semantics. See [references/swr-fetch-cache.md](references/swr-fetch-cache.md) for the complete implementation. + +**Fix:** Create a `server/plugins/fetch-cache.ts` Nitro plugin and a `useCachedFetch()` composable bridge. See [references/swr-fetch-cache.md](references/swr-fetch-cache.md). + +--- + +## 4. Centralized Error Handling + +**What:** A single `handleApiError()` utility that normalizes all error types (H3 errors, Valibot validation errors, generic exceptions) into consistent HTTP error responses. + +**Why:** Without centralized error handling, each route has its own try/catch with inconsistent status codes and messages. A 500 from a validation error is wrong (should be 400/404). Inconsistent error formats break frontend error handling. + +**What to look for:** Check `server/api/` routes for error handling patterns. Are they consistent? Do validation errors return appropriate status codes? + +**Good:** + +```ts +// server/utils/error-handler.ts +export function handleApiError(error: unknown, fallback: ErrorOptions): never { + // Re-throw H3 errors with fallback status if generic 500 + if (isError(error)) { + if (error.statusCode === 500 && fallback.statusCode) { + error.statusCode = fallback.statusCode + } + throw error + } + + // Validation errors -> 400/404 + if (v.isValiError(error)) { + throw createError({ + statusCode: 404, + message: error.issues[0].message, + }) + } + + // Generic fallback + throw createError({ + statusCode: fallback.statusCode ?? 502, + message: fallback.message, + }) +} + +// Usage in routes: +try { + const params = v.parse(Schema, rawInput) + // ... handler logic +} catch (error) { + handleApiError(error, { + statusCode: 502, + message: 'Failed to fetch product data', + }) +} +``` + +**Bad:** + +```ts +// Inconsistent error handling in every route +try { + // ... +} catch (e) { + throw createError({ statusCode: 500, message: 'Something went wrong' }) +} +``` + +**Fix:** Create a `handleApiError` utility in `server/utils/` and use it in all API routes. See [references/error-validation-patterns.md](references/error-validation-patterns.md). + +--- + +## 5. Valibot Schema Validation + +**What:** Validate all route parameters, query strings, and request bodies using Valibot schemas. Types are inferred from schemas, eliminating manual type definitions. + +**Why:** Without validation, invalid input causes cryptic 500 errors deep in the handler. Valibot catches bad input early with clear error messages and provides TypeScript types for free via `v.InferOutput`. + +**What to look for:** Check API routes for parameter validation. Are route params, query strings, and bodies validated? Are types inferred or manually defined? + +**Good:** + +```ts +// shared/schemas/product.ts +export const ProductIdSchema = v.pipe( + v.string(), + v.nonEmpty('Product ID is required'), + v.regex(/^[a-z0-9-]+$/, 'Invalid product ID format'), +) + +export const ProductRouteSchema = v.object({ + id: ProductIdSchema, + version: v.optional(v.pipe(v.string(), v.regex(/^[\w.+-]+$/))), +}) + +// Type inferred from schema - no manual interface needed +export type ProductRouteParams = v.InferOutput + +// Usage in route: +const params = v.parse(ProductRouteSchema, { id: rawId, version: rawVersion }) +// params is fully typed as { id: string; version?: string } +``` + +**Bad:** + +```ts +// No validation - trusts user input +const id = getRouterParam(event, 'id')! // could be anything +const product = await db.query(`SELECT * FROM products WHERE id = '${id}'`) +``` + +**Fix:** Create schemas in `shared/schemas/`, validate in routes, infer types. See [references/error-validation-patterns.md](references/error-validation-patterns.md). + +--- + +## 6. Cache-Control Headers + +**What:** Set explicit `Cache-Control` headers on responses for CDN caching. Use `s-maxage` (CDN cache duration) and `stale-while-revalidate` (how long stale content can be served while revalidating). + +**Why:** Even with ISR, explicit Cache-Control headers give you fine-grained control over CDN behavior. They work with any CDN (not just Vercel) and control how long redirects, static responses, and API responses are cached at the edge. + +**What to look for:** Check server middleware and API routes for `setHeader(event, 'Cache-Control', ...)` calls. Are redirects cached? Are API responses tagged with appropriate cache headers? + +**Good:** + +```ts +// Cache redirects for 1 hour, serve stale for 10 hours +const cacheControl = 's-maxage=3600, stale-while-revalidate=36000' +setHeader(event, 'cache-control', cacheControl) +return sendRedirect(event, newUrl, 301) +``` + +**Bad:** + +```ts +// No cache headers - CDN must re-request on every hit +return sendRedirect(event, newUrl, 301) +``` + +**Fix:** Add `Cache-Control` headers to redirects, API responses, and static assets. + +--- + +## 7. Parallel Fetching with Concurrency Control + +**What:** Use `Promise.all` for independent API calls and a `mapWithConcurrency` utility for bulk operations that need a concurrency limit to avoid overwhelming external APIs. + +**Why:** Sequential API calls are slow. If you need to fetch 50 packages, doing them one-by-one takes 50x longer than parallel. But unlimited `Promise.all` on 500 items can hit rate limits or exhaust connections. `mapWithConcurrency` balances speed and safety. + +**What to look for:** Check server utils and API routes for sequential `await` loops. Are independent fetches parallelized? Is there concurrency control for bulk operations? + +**Good:** + +```ts +// Independent fetches in parallel +const [product, reviews, related] = await Promise.all([ + fetchProduct(id), + fetchReviews(id), + fetchRelated(id), +]) + +// Bulk operations with concurrency limit +const results = await mapWithConcurrency( + packageNames, + async name => fetchPackageData(name), + 10, // max 10 concurrent requests +) +``` + +**Bad:** + +```ts +// Sequential - 3x slower +const product = await fetchProduct(id) +const reviews = await fetchReviews(id) +const related = await fetchRelated(id) + +// Unlimited parallelism - may hit rate limits +const results = await Promise.all( + packageNames.map(name => fetchPackageData(name)), // 500 concurrent requests! +) +``` + +**Fix:** Use `Promise.all` for independent fetches. Create a `mapWithConcurrency` utility for bulk operations. See [references/error-validation-patterns.md](references/error-validation-patterns.md). + +--- + +## 8. Multi-Layer Cache Architecture + +**What:** Use different storage backends for different cache types: runtime cache for SSR responses, KV/Redis for sessions, local storage for development. Configure via a custom Nuxt module that sets up Nitro storage based on the deployment provider. + +**Why:** One cache backend doesn't fit all needs. Runtime cache is fast but ephemeral. KV/Redis is persistent but slower. Local filesystem works for development. A module-based approach keeps this configuration clean and provider-aware. + +**What to look for:** Check for a cache configuration module. Is storage configured differently per environment? Are there fallbacks for local development? + +**Good:** + +```ts +// modules/cache.ts +export default defineNuxtModule({ + meta: { name: 'cache-config' }, + setup(_, nuxt) { + if (provider !== 'vercel') return + + nuxt.hook('nitro:config', nitroConfig => { + nitroConfig.storage = nitroConfig.storage || {} + nitroConfig.storage.cache = { + ...nitroConfig.storage.cache, + driver: 'vercel-runtime-cache', + } + }) + }, +}) +``` + +**Fix:** Create a `modules/cache.ts` module that configures storage per provider. See [references/cached-handlers.md](references/cached-handlers.md). diff --git a/docs/skills/nuxt-server-review/references/cached-handlers.md b/docs/skills/nuxt-server-review/references/cached-handlers.md new file mode 100644 index 000000000..008c1d953 --- /dev/null +++ b/docs/skills/nuxt-server-review/references/cached-handlers.md @@ -0,0 +1,236 @@ +# Cached Handlers Reference + +## Table of Contents + +- [defineCachedEventHandler Patterns](#definecachedeventhandler-patterns) +- [defineCachedFunction Patterns](#definecachedfunction-patterns) +- [Cache Key Design](#cache-key-design) +- [Multi-Layer Cache Module](#multi-layer-cache-module) + +--- + +## defineCachedEventHandler Patterns + +### Basic API route with SWR + +```ts +// server/api/products/[id].get.ts +export default defineCachedEventHandler( + async event => { + const id = getRouterParam(event, 'id') ?? '' + const params = v.parse(ProductIdSchema, id) + + const product = await $fetch(`https://api.example.com/products/${params}`) + return product + }, + { + maxAge: 300, // Fresh for 5 minutes + swr: true, // Serve stale while revalidating after 5 min + getKey: event => { + const id = getRouterParam(event, 'id') ?? '' + return `product:v1:${id}` // Versioned cache key + }, + }, +) +``` + +### Long-lived cache for immutable content + +```ts +// server/api/docs/[...path].get.ts +export default defineCachedEventHandler( + async event => { + const path = getRouterParam(event, 'path') ?? '' + return await fetchDocumentation(path) + }, + { + maxAge: 86400, // 24 hours + swr: true, + getKey: event => { + const path = getRouterParam(event, 'path') ?? '' + return `docs:v1:${path.replace(/\/+$/, '').trim()}` + }, + }, +) +``` + +### Cache key with query parameters + +```ts +{ + getKey: (event) => { + const type = getRouterParam(event, 'type') ?? 'default' + const pkg = getRouterParam(event, 'pkg') ?? '' + const query = getQuery(event) + return `badge:${type}:${pkg}:${hash(query)}` + }, +} +``` + +### Setting explicit response headers + +```ts +export default defineCachedEventHandler( + async (event) => { + const svg = generateBadge(params) + + setHeader(event, 'Content-Type', 'image/svg+xml') + setHeader(event, 'Cache-Control', + `public, max-age=3600, s-maxage=3600` + ) + + return svg + }, + { maxAge: 3600, swr: true, getKey: /* ... */ }, +) +``` + +--- + +## defineCachedFunction Patterns + +### Basic cached utility function + +```ts +// server/utils/products.ts +export const fetchProduct = defineCachedFunction( + async (name: string): Promise => { + const encoded = encodeURIComponent(name) + return await $fetch(`https://api.example.com/${encoded}`) + }, + { + maxAge: 300, + swr: true, + name: 'product', // Cache namespace + getKey: (name: string) => name, + }, +) +``` + +### Cached function with error recovery + +```ts +// server/utils/dependency-resolver.ts +export const fetchPackument = defineCachedFunction( + async (name: string): Promise => { + try { + return await $fetch(`https://registry.npmjs.org/${encodePackageName(name)}`) + } catch (error) { + if (import.meta.dev) { + console.warn(`Failed to fetch ${name}:`, error) + } + return null // Return null instead of throwing + } + }, + { + maxAge: 3600, + swr: true, + name: 'packument', + getKey: (name: string) => name, + }, +) +``` + +### Multiple cached functions with different TTLs + +```ts +// Short TTL for frequently changing data +export const fetchLatestVersion = defineCachedFunction( + async (name: string) => { + /* ... */ + }, + { maxAge: 300, swr: true, name: 'latest-version', getKey: n => n }, +) + +// Long TTL for rarely changing data +export const fetchUserEmail = defineCachedFunction( + async (username: string) => { + /* ... */ + }, + { maxAge: 86400, swr: true, name: 'user-email', getKey: u => u.toLowerCase() }, +) +``` + +--- + +## Cache Key Design + +### Principles + +1. **Version prefix** -- Include a version (e.g., `v1:`) so format changes can invalidate old entries +2. **Deterministic** -- Same input always produces the same key +3. **Normalized** -- Trim whitespace, normalize case, strip trailing slashes +4. **Namespaced** -- Use `name` option to separate different function caches + +### Examples + +```ts +// Good: versioned, normalized, unique +getKey: (name: string) => `product:v2:${name.trim().toLowerCase()}` + +// Good: includes all varying parameters +getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `readme:v8:${pkg.replace(/\/+$/, '').trim()}` +} + +// Bad: no version, not normalized +getKey: (name: string) => name // "Vue" and "vue" get different cache entries + +// Bad: includes non-deterministic data +getKey: () => `product:${Date.now()}` // Never hits cache +``` + +--- + +## Multi-Layer Cache Module + +### Complete module for provider-aware cache configuration + +```ts +// modules/cache.ts +import process from 'node:process' +import { defineNuxtModule } from 'nuxt/kit' +import { provider } from 'std-env' + +const FETCH_CACHE_STORAGE_BASE = 'fetch-cache' + +export default defineNuxtModule({ + meta: { name: 'cache-config' }, + setup(_, nuxt) { + if (provider !== 'vercel') return + + nuxt.hook('nitro:config', nitroConfig => { + nitroConfig.storage = nitroConfig.storage || {} + + // Main cache (defineCachedFunction, defineCachedEventHandler) + nitroConfig.storage.cache = { + ...nitroConfig.storage.cache, + driver: 'vercel-runtime-cache', + } + + // Fetch cache (custom SWR plugin) + nitroConfig.storage[FETCH_CACHE_STORAGE_BASE] = { + ...nitroConfig.storage[FETCH_CACHE_STORAGE_BASE], + driver: 'vercel-runtime-cache', + } + + // Session storage (persistent) + const env = process.env.VERCEL_ENV + nitroConfig.storage.sessions = { + driver: env === 'production' ? 'vercel-kv' : 'vercel-runtime-cache', + } + }) + }, +}) +``` + +### Cache layer overview + +| Layer | Backend | TTL | Use Case | +| ------------------------ | ------------- | ---------- | ---------------------- | +| ISR | CDN edge | 60s | Page responses | +| defineCachedEventHandler | Nitro storage | 5-60min | API responses | +| defineCachedFunction | Nitro storage | 5min-24h | Shared utility results | +| Fetch cache plugin | Nitro storage | 5min | External API calls | +| Sessions | KV/Redis | Persistent | User sessions | diff --git a/docs/skills/nuxt-server-review/references/error-validation-patterns.md b/docs/skills/nuxt-server-review/references/error-validation-patterns.md new file mode 100644 index 000000000..faac24d55 --- /dev/null +++ b/docs/skills/nuxt-server-review/references/error-validation-patterns.md @@ -0,0 +1,284 @@ +# Error Handling & Validation Patterns Reference + +## Table of Contents + +- [Centralized Error Handler](#centralized-error-handler) +- [Valibot Schema Patterns](#valibot-schema-patterns) +- [Route Validation Pattern](#route-validation-pattern) +- [mapWithConcurrency Utility](#mapwithconcurrency-utility) +- [Cache-Control Header Patterns](#cache-control-header-patterns) + +--- + +## Centralized Error Handler + +### Implementation + +```ts +// server/utils/error-handler.ts +import { isError, createError } from 'h3' +import * as v from 'valibot' + +interface ErrorOptions { + statusCode?: number + statusMessage?: string + message?: string +} + +export function handleApiError(error: unknown, fallback: ErrorOptions): never { + // 1. Re-throw H3 errors (already well-formed) + if (isError(error)) { + // Override generic 500 with more specific status + if (error.statusCode === 500 && fallback.statusCode) { + error.statusCode = fallback.statusCode + } + if (error.statusMessage === 'Server Error' && fallback.statusMessage) { + error.statusMessage = fallback.statusMessage + } + throw error + } + + // 2. Convert Valibot validation errors to HTTP errors + if (v.isValiError(error)) { + throw createError({ + statusCode: 404, // or 400 - cacheable status codes preferred + message: error.issues[0].message, + }) + } + + // 3. Generic fallback for unknown errors + throw createError({ + statusCode: fallback.statusCode ?? 502, + statusMessage: fallback.statusMessage, + message: fallback.message, + }) +} +``` + +### Usage in API routes + +```ts +// server/api/products/[id].get.ts +export default defineCachedEventHandler( + async (event) => { + const rawId = getRouterParam(event, 'id') ?? '' + + try { + const { id } = v.parse(ProductIdSchema, { id: rawId }) + const product = await $fetch(`https://api.example.com/products/${id}`) + return product + } catch (error) { + handleApiError(error, { + statusCode: 502, + message: 'Failed to fetch product', + }) + } + }, + { maxAge: 300, swr: true, getKey: /* ... */ }, +) +``` + +--- + +## Valibot Schema Patterns + +### Package/resource name validation + +```ts +// shared/schemas/package.ts +import * as v from 'valibot' +import validateNpmPackageName from 'validate-npm-package-name' + +export const PackageNameSchema = v.pipe( + v.string(), + v.nonEmpty('Package name is required'), + v.check(input => { + const result = validateNpmPackageName(input) + return result.validForNewPackages || result.validForOldPackages + }, 'Invalid package name format'), +) +``` + +### Version validation (prevents directory traversal) + +```ts +export const VersionSchema = v.pipe( + v.string(), + v.nonEmpty('Version is required'), + v.regex(/^[\w.+-]+$/, 'Invalid version format'), +) +``` + +### File path validation (prevents directory traversal) + +```ts +export const FilePathSchema = v.pipe( + v.string(), + v.nonEmpty('File path is required'), + v.check(input => !input.includes('..'), 'Directory traversal not allowed'), + v.check(input => !input.startsWith('/'), 'Must be relative'), +) +``` + +### Search query validation (prevents DoS) + +```ts +export const SearchQuerySchema = v.pipe( + v.string(), + v.trim(), + v.maxLength(100, 'Search query is too long'), +) +``` + +### Composite schemas with type inference + +```ts +export const PackageRouteParamsSchema = v.object({ + packageName: PackageNameSchema, + version: v.optional(VersionSchema), +}) + +// Types are inferred - no manual interface needed +export type PackageRouteParams = v.InferOutput +// { packageName: string; version?: string } +``` + +### Query parameter validation with safeParse + +```ts +const QUERY_SCHEMA = v.object({ + color: v.optional(v.string()), + label: v.optional(v.string()), + style: v.optional(v.picklist(['flat', 'plastic'])), +}) + +// safeParse doesn't throw - returns success/failure +const queryParams = v.safeParse(QUERY_SCHEMA, getQuery(event)) +const color = queryParams.success ? queryParams.output.color : undefined +``` + +--- + +## Route Validation Pattern + +### Parsing path segments into validated params + +API routes often receive package names split across path segments (e.g., `@scope/name/v/1.0.0`). Parse them before validation: + +```ts +// server/api/registry/analysis/[...pkg].get.ts +export default defineCachedEventHandler( + async (event) => { + const segments = getRouterParam(event, 'pkg')?.split('/') ?? [] + + // Parse: ["@scope", "name", "v", "1.0.0"] -> { name: "@scope/name", version: "1.0.0" } + const { rawPackageName, rawVersion } = parsePackageParams(segments) + + try { + const { packageName, version } = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + version: rawVersion, + }) + + // Now packageName and version are validated and typed + const data = await fetchData(packageName, version) + return data + } catch (error) { + handleApiError(error, { + statusCode: 502, + message: 'Failed to analyze package', + }) + } + }, + { maxAge: 86400, swr: true, getKey: /* ... */ }, +) +``` + +--- + +## mapWithConcurrency Utility + +### Implementation + +```ts +// shared/utils/async.ts +export async function mapWithConcurrency( + items: T[], + fn: (item: T, index: number) => Promise, + concurrency = 10, +): Promise { + const results: R[] = Array.from({ length: items.length }) as R[] + let currentIndex = 0 + + async function worker(): Promise { + while (currentIndex < items.length) { + const index = currentIndex++ + results[index] = await fn(items[index]!, index) + } + } + + const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker()) + await Promise.all(workers) + + return results +} +``` + +### Usage + +```ts +// Fetch 50 packages with max 10 concurrent requests +const packuments = await mapWithConcurrency( + packageNames, + async name => { + try { + const encoded = encodePackageName(name) + const { data } = await cachedFetch(`https://registry.npmjs.org/${encoded}`, { + signal, + }) + return data + } catch { + return null // Don't fail the whole batch + } + }, + 10, +) + +// Filter out failures +const validPackuments = packuments.filter((p): p is Packument => p !== null) +``` + +### Design: worker pool pattern + +Unlike `Promise.all(items.map(fn))` which starts all items immediately, `mapWithConcurrency` creates N workers that pull items from a shared queue. This ensures exactly N items are in-flight at any time. + +--- + +## Cache-Control Header Patterns + +### Redirect caching + +```ts +// Cache redirects at CDN level +const cacheControl = 's-maxage=3600, stale-while-revalidate=36000' +setHeader(event, 'cache-control', cacheControl) +return sendRedirect(event, canonicalUrl, 301) +``` + +### API response caching + +```ts +// Badge/image responses +setHeader(event, 'Content-Type', 'image/svg+xml') +setHeader(event, 'Cache-Control', `public, max-age=3600, s-maxage=3600`) +``` + +### Common patterns + +| Scenario | Header | +| -------------- | --------------------------------------------- | +| Static asset | `public, max-age=31536000, immutable` | +| API response | `public, max-age=60, s-maxage=300` | +| Redirect | `s-maxage=3600, stale-while-revalidate=36000` | +| Never cache | `no-store, no-cache` | +| Private (auth) | `private, no-store` | diff --git a/docs/skills/nuxt-server-review/references/swr-fetch-cache.md b/docs/skills/nuxt-server-review/references/swr-fetch-cache.md new file mode 100644 index 000000000..783247e03 --- /dev/null +++ b/docs/skills/nuxt-server-review/references/swr-fetch-cache.md @@ -0,0 +1,300 @@ +# SWR Fetch Cache Reference + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Fetch Cache Config](#fetch-cache-config) +- [Nitro Plugin Implementation](#nitro-plugin-implementation) +- [useCachedFetch Composable Bridge](#usecachedfetch-composable-bridge) +- [Integration in Composables](#integration-in-composables) + +--- + +## Architecture Overview + +``` + Server (SSR) Client + ┌─────────────────────────────────────────────┐ + │ Composable │ + │ │ │ + │ ▼ │ + │ useCachedFetch() ◄── useRequestEvent() │ useCachedFetch() + │ │ event.context │ │ + │ ▼ │ ▼ + │ cachedFetch(url) │ $fetch(url) + │ │ │ (no caching) + │ ├── isAllowedDomain? ──No──► $fetch(url) │ + │ │ │ + │ ├── Cache HIT (fresh)? ──► return data │ + │ │ │ + │ ├── Cache HIT (stale)? │ + │ │ ├── return stale data immediately │ + │ │ └── event.waitUntil(revalidate) │ + │ │ │ + │ └── Cache MISS? │ + │ ├── $fetch + return data │ + │ └── event.waitUntil(cache write) │ + └─────────────────────────────────────────────┘ +``` + +--- + +## Fetch Cache Config + +Shared configuration used by both the plugin and composable: + +```ts +// shared/utils/fetch-cache-config.ts + +// Only cache responses from these domains +export const FETCH_CACHE_ALLOWED_DOMAINS = [ + 'registry.npmjs.org', + 'api.npmjs.org', + 'api.github.com', + // Add your external API domains here +] as const + +// Default TTL: 5 minutes +export const FETCH_CACHE_DEFAULT_TTL = 60 * 5 + +// Increment to invalidate all cached entries +export const FETCH_CACHE_VERSION = 'v1' + +// Nitro storage key +export const FETCH_CACHE_STORAGE_BASE = 'fetch-cache' + +export function isAllowedDomain(url: string | URL): boolean { + try { + const urlObj = typeof url === 'string' ? new URL(url) : url + return FETCH_CACHE_ALLOWED_DOMAINS.some(domain => urlObj.host === domain) + } catch { + return false + } +} + +export interface CachedFetchEntry { + data: T + status: number + headers: Record + cachedAt: number + ttl: number +} + +export function isCacheEntryStale(entry: CachedFetchEntry): boolean { + return Date.now() > entry.cachedAt + entry.ttl * 1000 +} + +export interface CachedFetchResult { + data: T + isStale: boolean + cachedAt: number | null +} + +export type CachedFetchFunction = ( + url: string, + options?: Parameters[1], + ttl?: number, +) => Promise> +``` + +--- + +## Nitro Plugin Implementation + +```ts +// server/plugins/fetch-cache.ts +import type { H3Event } from 'h3' +import type { CachedFetchEntry, CachedFetchResult } from '#shared/utils/fetch-cache-config' +import { $fetch } from 'ofetch' +import { + FETCH_CACHE_DEFAULT_TTL, + FETCH_CACHE_STORAGE_BASE, + FETCH_CACHE_VERSION, + isAllowedDomain, + isCacheEntryStale, +} from '#shared/utils/fetch-cache-config' + +function simpleHash(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i) + hash = hash & hash + } + return Math.abs(hash).toString(36) +} + +function generateCacheKey(url: string, method: string, body?: unknown): string { + const urlObj = new URL(url) + return [ + FETCH_CACHE_VERSION, + urlObj.host, + method.toUpperCase(), + urlObj.pathname, + urlObj.search ? simpleHash(urlObj.search) : '', + body ? simpleHash(JSON.stringify(body)) : '', + ] + .filter(Boolean) + .join(':') +} + +export default defineNitroPlugin(nitroApp => { + const storage = useStorage(FETCH_CACHE_STORAGE_BASE) + + function createCachedFetch(event: H3Event): CachedFetchFunction { + return async ( + url: string, + options: Parameters[1] = {}, + ttl: number = FETCH_CACHE_DEFAULT_TTL, + ): Promise> => { + // Skip caching for non-allowed domains + if (!isAllowedDomain(url)) { + const data = (await $fetch(url, options)) as T + return { data, isStale: false, cachedAt: null } + } + + const cacheKey = generateCacheKey(url, options.method || 'GET', options.body) + + // Try cache (with error tolerance) + let cached: CachedFetchEntry | null = null + try { + cached = await storage.getItem>(cacheKey) + } catch { + /* storage failure - continue without cache */ + } + + if (cached) { + if (!isCacheEntryStale(cached)) { + // Fresh hit + return { data: cached.data, isStale: false, cachedAt: cached.cachedAt } + } + + // Stale hit - return stale data, revalidate in background + event.waitUntil( + (async () => { + try { + const freshData = (await $fetch(url, options)) as T + await storage.setItem(cacheKey, { + data: freshData, + status: 200, + headers: {}, + cachedAt: Date.now(), + ttl, + }) + } catch { + /* revalidation failed - keep stale data */ + } + })(), + ) + return { data: cached.data, isStale: true, cachedAt: cached.cachedAt } + } + + // Cache miss - fetch, cache in background + const data = (await $fetch(url, options)) as T + const cachedAt = Date.now() + event.waitUntil( + storage + .setItem(cacheKey, { + data, + status: 200, + headers: {}, + cachedAt, + ttl, + }) + .catch(() => {}), + ) + return { data, isStale: false, cachedAt } + } + } + + // Attach to every request + nitroApp.hooks.hook('request', event => { + event.context.cachedFetch ||= createCachedFetch(event) + }) +}) + +// Extend H3 types +declare module 'h3' { + interface H3EventContext { + cachedFetch?: CachedFetchFunction + } +} +``` + +### Key design decisions + +1. **Domain allowlist** -- Only caches external API calls to known domains, not internal routes +2. **`event.waitUntil()`** -- Background cache writes and revalidation work in serverless environments where the process may terminate after the response is sent +3. **Error tolerance** -- Cache read/write failures are logged but never break the request +4. **Stale metadata** -- `isStale` flag propagates to composables for client-side revalidation + +--- + +## useCachedFetch Composable Bridge + +```ts +// app/composables/useCachedFetch.ts +import type { CachedFetchResult } from '#shared/utils/fetch-cache-config' + +export function useCachedFetch(): CachedFetchFunction { + // Client: no caching, just use $fetch + if (import.meta.client) { + return async ( + url: string, + options: Parameters[1] = {}, + ): Promise> => { + const data = (await $fetch(url, options)) as T + return { data, isStale: false, cachedAt: null } + } + } + + // Server: get cachedFetch from Nitro plugin via event context + const event = useRequestEvent() + const serverCachedFetch = event?.context?.cachedFetch + if (serverCachedFetch) { + return serverCachedFetch as CachedFetchFunction + } + + // Fallback: regular $fetch + return async ( + url: string, + options: Parameters[1] = {}, + ): Promise> => { + const data = (await $fetch(url, options)) as T + return { data, isStale: false, cachedAt: null } + } +} +``` + +### Important: call in setup context + +`useCachedFetch()` must be called in the composable's setup scope (outside of async handlers), because `useRequestEvent()` only works in setup context. The returned function can then be used inside `useAsyncData` handlers. + +--- + +## Integration in Composables + +```ts +export function useProduct(id: MaybeRefOrGetter) { + // 1. Get cachedFetch in setup context + const cachedFetch = useCachedFetch() + + // 2. Use it inside the async handler + const asyncData = useLazyAsyncData( + () => `product:${toValue(id)}`, + async (_nuxtApp, { signal }) => { + const { data, isStale } = await cachedFetch( + `https://api.example.com/products/${toValue(id)}`, + { signal }, + ) + return { ...data, isStale } + }, + ) + + // 3. Client-side: refresh if data was stale + if (import.meta.client && asyncData.data.value?.isStale) { + onMounted(() => asyncData.refresh()) + } + + return asyncData +} +```