Перейти к содержимому

9. Server Middleware

Server Middleware в Nuxt 3 выполняется перед каждым запросом к серверу. Это идеальное место для логирования, CORS, аутентификации, rate limiting и любой сквозной серверной логики.


HTTP запрос
[Server Middleware 1] — logger.ts
[Server Middleware 2] — cors.ts
[Server Middleware 3] — auth.ts
Обработчик маршрута (api/users.get.ts)
HTTP ответ

Server Middleware — это функция defineEventHandler, которая:

  1. Получает объект события H3Event
  2. Может модифицировать запрос/ответ
  3. Может прервать цепочку (выбросить ошибку)
  4. Может ничего не возвращать (передать дальше)

server/middleware/logger.ts
export default defineEventHandler((event) => {
const start = Date.now()
const method = getMethod(event)
const url = getRequestURL(event)
// Логируем после завершения запроса
event.node.res.on('finish', () => {
const duration = Date.now() - start
const status = event.node.res.statusCode
console.log(
\`[\${new Date().toISOString()}] \${method} \${url.pathname} → \${status} (\${duration}ms)\`
)
})
// Не возвращаем ничего — передаём дальше
})

Middleware выполняются в алфавитном порядке имён файлов:

server/middleware/
├── 01.logger.ts ← Выполняется первым
├── 02.cors.ts ← Вторым
├── 03.auth.ts ← Третьим
└── 04.rate-limit.ts ← Последним

server/middleware/02.cors.ts
export default defineEventHandler((event) => {
const allowedOrigins = [
'https://myapp.com',
'https://admin.myapp.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : ''
].filter(Boolean)
const origin = getHeader(event, 'origin')
// Проверяем origin
if (origin && allowedOrigins.includes(origin)) {
setHeader(event, 'Access-Control-Allow-Origin', origin)
}
setHeader(event, 'Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS')
setHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With')
setHeader(event, 'Access-Control-Allow-Credentials', 'true')
setHeader(event, 'Access-Control-Max-Age', '86400') // 24 часа preflight кэш
// Обрабатываем OPTIONS preflight запросы
if (getMethod(event) === 'OPTIONS') {
setResponseStatus(event, 204)
return '' // Завершаем запрос
}
})

server/middleware/03.auth.ts
export default defineEventHandler(async (event) => {
// Маршруты, не требующие аутентификации
const publicPaths = [
'/api/auth/login',
'/api/auth/register',
'/api/auth/refresh',
'/api/public',
]
const path = getRequestURL(event).pathname
// Пропускаем публичные маршруты
if (publicPaths.some(p => path.startsWith(p))) return
// Проверяем только API маршруты
if (!path.startsWith('/api/')) return
// Ищем токен в заголовке или cookie
const authHeader = getHeader(event, 'authorization')
const token = authHeader?.startsWith('Bearer ')
? authHeader.slice(7)
: getCookie(event, 'auth-token')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Токен не предоставлен',
})
}
try {
// Верифицируем JWT
const payload = verifyJWT(token)
// Добавляем пользователя в контекст
event.context.user = payload
} catch {
throw createError({
statusCode: 401,
statusMessage: 'Недействительный токен',
})
}
})

server/middleware/04.rate-limit.ts
// Простое in-memory хранилище (используй Redis в продакшне)
const requestCounts = new Map<string, { count: number; resetAt: number }>()
const RATE_LIMIT = 100 // запросов
const WINDOW_MS = 60 * 1000 // за минуту
export default defineEventHandler((event) => {
const ip = event.node.req.socket.remoteAddress || 'unknown'
const now = Date.now()
let record = requestCounts.get(ip)
if (!record || now > record.resetAt) {
record = { count: 0, resetAt: now + WINDOW_MS }
requestCounts.set(ip, record)
}
record.count++
// Добавляем заголовки
setHeader(event, 'X-RateLimit-Limit', String(RATE_LIMIT))
setHeader(event, 'X-RateLimit-Remaining', String(Math.max(0, RATE_LIMIT - record.count)))
setHeader(event, 'X-RateLimit-Reset', String(record.resetAt))
if (record.count > RATE_LIMIT) {
throw createError({
statusCode: 429,
statusMessage: 'Too Many Requests',
data: {
retryAfter: Math.ceil((record.resetAt - now) / 1000)
}
})
}
})

server/middleware/01.request-id.ts
import { randomUUID } from 'crypto'
export default defineEventHandler((event) => {
// Генерируем уникальный ID запроса
const requestId = getHeader(event, 'x-request-id') || randomUUID()
// Сохраняем в контексте
event.context.requestId = requestId
// Добавляем в ответ
setHeader(event, 'X-Request-ID', requestId)
})
server/middleware/02.body-parser.ts
export default defineEventHandler(async (event) => {
// Только для запросов с телом
const method = getMethod(event)
if (!['POST', 'PUT', 'PATCH'].includes(method)) return
const contentType = getHeader(event, 'content-type') || ''
// Валидируем Content-Type
if (contentType.includes('application/json')) {
try {
event.context.parsedBody = await readBody(event)
} catch {
throw createError({
statusCode: 400,
statusMessage: 'Неверный формат JSON',
})
}
}
})

server/middleware/05.session.ts
export default defineEventHandler(async (event) => {
// Используем nuxt-auth-utils или свою реализацию
const sessionId = getCookie(event, 'session_id')
if (sessionId) {
// Загружаем сессию из хранилища
const session = await storage.getItem(\`sessions:\${sessionId}\`)
if (session) {
event.context.session = session
}
}
})
server/api/session-demo.ts
export default defineEventHandler(async (event) => {
const session = event.context.session || {}
if (!session.visits) {
session.visits = 0
}
session.visits++
session.lastVisit = new Date().toISOString()
// Сохраняем обновлённую сессию
let sessionId = getCookie(event, 'session_id')
if (!sessionId) {
sessionId = crypto.randomUUID()
setCookie(event, 'session_id', sessionId, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 7
})
}
await storage.setItem(\`sessions:\${sessionId}\`, session)
return { visits: session.visits, lastVisit: session.lastVisit }
})

// server/api/admin/[...].ts — middleware только для /api/admin/**
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
if (user.role !== 'admin') {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
}
// Передаём в следующий обработчик
})

server/plugins/error-handler.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('error', (error, { event }) => {
// Логируем все серверные ошибки
console.error('Server Error:', {
url: event?.path,
statusCode: error.statusCode,
message: error.message,
stack: error.stack,
})
// Оповещаем Sentry или другой сервис
// sentry.captureException(error)
})
})

server/middleware/security.ts
export default defineEventHandler((event) => {
// Защита от XSS
setHeader(event, 'X-Content-Type-Options', 'nosniff')
setHeader(event, 'X-Frame-Options', 'SAMEORIGIN')
setHeader(event, 'X-XSS-Protection', '1; mode=block')
// HSTS (только HTTPS)
if (event.node.req.connection?.encrypted) {
setHeader(event, 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
}
// Content Security Policy
setHeader(event, 'Content-Security-Policy', [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
].join('; '))
// Referrer Policy
setHeader(event, 'Referrer-Policy', 'strict-origin-when-cross-origin')
// Permissions Policy
setHeader(event, 'Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
})