25. SvelteKit: API Routes
🔌 SvelteKit API Routes: Сервер внутри приложения
Заголовок раздела «🔌 SvelteKit API Routes: Сервер внутри приложения»Привет! 👋 Иногда тебе нужен не просто сайт — тебе нужен API. SvelteKit позволяет создавать полноценные HTTP endpoints прямо в своём приложении. Файл +server.ts превращает папку в API маршрут, который умеет отвечать на GET, POST, PUT, DELETE и PATCH запросы.
Думай об API routes как о почтовых ящиках 📬: каждый URL — это отдельный ящик, а метод (GET/POST/etc.) — это тип входящей почты. SvelteKit автоматически доставляет запрос нужному обработчику.
📁 Базовый +server.ts
Заголовок раздела «📁 Базовый +server.ts»import type { RequestHandler } from './$types';import { json } from '@sveltejs/kit';
// GET /api/helloexport const GET: RequestHandler = async ({ url, locals }) => { const name = url.searchParams.get('name') ?? 'World';
return json({ message: `Hello, ${name}!` }); // json() эквивалентно: // return new Response(JSON.stringify({ message: ... }), { // headers: { 'Content-Type': 'application/json' }, // });};
// POST /api/helloexport const POST: RequestHandler = async ({ request }) => { const body = await request.json();
return json({ received: body, timestamp: new Date().toISOString(), }, { status: 201 });};🌐 RequestEvent: всё что нужно знает обработчик
Заголовок раздела «🌐 RequestEvent: всё что нужно знает обработчик»import type { RequestHandler } from './$types';import { json, error } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ request, // Request объект url, // URL объект с params, pathname, searchParams params, // Параметры роута (для /api/users/[id]) locals, // Данные из хуков (auth user, db connection) cookies, // Cookies API platform, // Платформо-специфичные данные (Cloudflare env) fetch, // SvelteKit fetch (поддерживает относительные URL) setHeaders, // Установить заголовки ответа getClientAddress, // IP клиента}) => { // Авторизация: if (!locals.user) { error(401, 'Необходима авторизация'); }
// Query параметры: const page = Number(url.searchParams.get('page') ?? '1'); const limit = Number(url.searchParams.get('limit') ?? '10'); const search = url.searchParams.get('q') ?? '';
// IP клиента (для rate limiting): const clientIP = getClientAddress();
// Кастомные заголовки: setHeaders({ 'X-Total-Count': '100', 'X-Page': String(page), });
const users = await db.user.findMany({ where: search ? { name: { contains: search } } : {}, skip: (page - 1) * limit, take: limit, });
return json(users);};📝 CRUD API: полный пример
Заголовок раздела «📝 CRUD API: полный пример»import type { RequestHandler } from './$types';import { json, error } from '@sveltejs/kit';
// GET /api/posts — список постовexport const GET: RequestHandler = async ({ url, locals }) => { const page = Number(url.searchParams.get('page') ?? '1'); const limit = Math.min(Number(url.searchParams.get('limit') ?? '10'), 100); const authorId = url.searchParams.get('author'); const tag = url.searchParams.get('tag');
const [posts, total] = await Promise.all([ db.post.findMany({ where: { published: true, ...(authorId && { authorId }), ...(tag && { tags: { some: { name: tag } } }), }, include: { author: { select: { id: true, name: true, avatar: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, }), db.post.count({ where: { published: true } }), ]);
return json({ posts, total, page, limit, pages: Math.ceil(total / limit) });};
// POST /api/posts — создать постexport const POST: RequestHandler = async ({ request, locals }) => { if (!locals.user) error(401, 'Требуется авторизация');
const body = await request.json();
// Валидация: if (!body.title?.trim()) error(400, 'Заголовок обязателен'); if (!body.content?.trim()) error(400, 'Содержимое обязательно'); if (body.title.length > 200) error(400, 'Заголовок слишком длинный');
const slug = body.title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '');
// Проверяем уникальность слага: const existing = await db.post.findUnique({ where: { slug } }); if (existing) error(409, `Пост с slug "${slug}" уже существует`);
const post = await db.post.create({ data: { title: body.title, slug, content: body.content, excerpt: body.excerpt ?? body.content.slice(0, 200), published: body.published ?? false, authorId: locals.user.id, tags: { connectOrCreate: (body.tags ?? []).map((tag: string) => ({ where: { name: tag }, create: { name: tag }, })), }, }, include: { author: true, tags: true }, });
return json(post, { status: 201 });};// src/routes/api/posts/[id]/+server.ts — единственный постimport type { RequestHandler } from './$types';
// GET /api/posts/[id]export const GET: RequestHandler = async ({ params }) => { const post = await db.post.findUnique({ where: { id: params.id }, include: { author: true, tags: true, comments: true }, });
if (!post) error(404, 'Пост не найден');
return json(post);};
// PUT /api/posts/[id] — полное обновлениеexport const PUT: RequestHandler = async ({ params, request, locals }) => { if (!locals.user) error(401, 'Требуется авторизация');
const post = await db.post.findUnique({ where: { id: params.id } }); if (!post) error(404, 'Пост не найден'); if (post.authorId !== locals.user.id && !locals.user.isAdmin) { error(403, 'Нет прав для редактирования'); }
const body = await request.json(); const updated = await db.post.update({ where: { id: params.id }, data: body, });
return json(updated);};
// PATCH /api/posts/[id] — частичное обновлениеexport const PATCH: RequestHandler = async ({ params, request, locals }) => { if (!locals.user) error(401, 'Требуется авторизация');
const body = await request.json();
const updated = await db.post.update({ where: { id: params.id }, data: body, // Только переданные поля });
return json(updated);};
// DELETE /api/posts/[id]export const DELETE: RequestHandler = async ({ params, locals }) => { if (!locals.user) error(401, 'Требуется авторизация');
const post = await db.post.findUnique({ where: { id: params.id } }); if (!post) error(404, 'Пост не найден'); if (post.authorId !== locals.user.id && !locals.user.isAdmin) { error(403, 'Нет прав для удаления'); }
await db.post.delete({ where: { id: params.id } });
return new Response(null, { status: 204 }); // 204 No Content};🌊 Стриминг ответов
Заголовок раздела «🌊 Стриминг ответов»import type { RequestHandler } from './$types';
// Стриминг Server-Sent Events:export const GET: RequestHandler = () => { let count = 0;
const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder();
// Отправляем события каждую секунду: while (count < 10) { await new Promise(resolve => setTimeout(resolve, 1000)); count++;
const data = JSON.stringify({ count, timestamp: Date.now() }); controller.enqueue(encoder.encode(`data: ${data}\n\n`)); }
controller.close(); }, cancel() { // Клиент отключился count = 999; // Прерываем цикл }, });
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, });};
// Стриминг JSON (NDJSON):export const POST: RequestHandler = async ({ request }) => { const { prompt } = await request.json();
const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder();
// Имитация AI стриминга: const words = `Ответ на "${prompt}": Svelte — отличный фреймворк!`.split(' ');
for (const word of words) { await new Promise(r => setTimeout(r, 100)); controller.enqueue(encoder.encode(JSON.stringify({ token: word + ' ' }) + '\n')); }
controller.close(); }, });
return new Response(stream, { headers: { 'Content-Type': 'application/x-ndjson' }, });};🪝 Хуки (hooks.server.ts): middleware для SvelteKit
Заголовок раздела «🪝 Хуки (hooks.server.ts): middleware для SvelteKit»Хуки — это функции, которые запускаются при каждом запросе. Это аналог middleware в Express:
import type { Handle, HandleFetch, HandleServerError } from '@sveltejs/kit';import { sequence } from '@sveltejs/kit/hooks';
// Основной хук — запускается для КАЖДОГО запроса:const auth: Handle = async ({ event, resolve }) => { const session = event.cookies.get('session');
if (session) { try { const user = await verifySession(session); event.locals.user = user; } catch { event.cookies.delete('session', { path: '/' }); } }
return resolve(event);};
// Хук для логирования:const logger: Handle = async ({ event, resolve }) => { const start = Date.now(); const response = await resolve(event); const duration = Date.now() - start;
console.log(`${event.request.method} ${event.url.pathname} → ${response.status} (${duration}ms)`);
return response;};
// Хук для заголовков безопасности:const security: Handle = async ({ event, resolve }) => { const response = await resolve(event);
response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); response.headers.set( 'Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" );
return response;};
// Комбинируем хуки с sequence():export const handle = sequence(auth, logger, security);
// Хук для модификации fetch запросов (SSR):export const handleFetch: HandleFetch = async ({ request, fetch, event }) => { // Добавляем авторизационный заголовок для API запросов: if (request.url.startsWith('https://api.example.com/')) { request = new Request(request, { headers: { ...Object.fromEntries(request.headers), Authorization: `Bearer ${event.locals.user?.token}`, }, }); }
return fetch(request);};
// Хук для обработки серверных ошибок:export const handleError: HandleServerError = async ({ error, event }) => { // Логируем в Sentry, Axiom, etc.: console.error('Server error:', error); await logToSentry(error, { url: event.url.href, user: event.locals.user?.id, });
// Возвращаем безопасное сообщение (без деталей ошибки для клиента): return { message: 'Что-то пошло не так. Мы уже работаем над этим!', code: (error as any)?.code ?? 'UNKNOWN', };};🔐 Серверные переменные окружения
Заголовок раздела «🔐 Серверные переменные окружения»// src/lib/server/env.ts — ТОЛЬКО серверный кодimport { env } from '$env/static/private';// import { env } from '$env/dynamic/private'; // Для runtime переменных
// static/private — вшивается при сборке, безопасно:const { DATABASE_URL, SECRET_KEY, STRIPE_SECRET_KEY } = env;
// Никогда не попадёт в клиентский бандл — $env/static/private// доступен ТОЛЬКО в файлах .server.ts и hooks.server.ts
export const db = new PrismaClient({ datasources: { db: { url: DATABASE_URL } },});// $env/static/public — доступен и на клиенте, и на сервере// Переменные должны начинаться с PUBLIC_import { PUBLIC_API_URL, PUBLIC_ANALYTICS_ID } from '$env/static/public';
// $env/dynamic/private — читается при каждом запросе (runtime)// Для Docker/Platform.sh где env меняется без пересборкиimport { env as dynamicPrivate } from '$env/dynamic/private';
// $env/dynamic/public — runtime переменные для клиентаimport { env as dynamicPublic } from '$env/dynamic/public';Переменные в .env файлах:
DATABASE_URL="postgresql://localhost/myapp"SECRET_KEY="super-secret-key"STRIPE_SECRET_KEY="sk_test_..."
# Публичные переменные:PUBLIC_API_URL="http://localhost:3000"PUBLIC_ANALYTICS_ID="G-XXXXXXXXXX"
# .env.production — для продаDATABASE_URL="postgresql://prod-server/myapp"🌍 CORS настройка
Заголовок раздела «🌍 CORS настройка»// src/routes/api/public/+server.ts — публичный API с CORSimport type { RequestHandler } from './$types';import { json } from '@sveltejs/kit';
const ALLOWED_ORIGINS = [ 'https://myapp.com', 'https://www.myapp.com', 'http://localhost:3000', // dev];
function corsHeaders(origin: string | null) { const allowed = origin && ALLOWED_ORIGINS.includes(origin);
return { 'Access-Control-Allow-Origin': allowed ? origin : ALLOWED_ORIGINS[0], 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400', };}
// Preflight запрос:export const OPTIONS: RequestHandler = ({ request }) => { const origin = request.headers.get('Origin'); return new Response(null, { status: 204, headers: corsHeaders(origin), });};
export const GET: RequestHandler = ({ request }) => { const origin = request.headers.get('Origin'); return json({ data: 'public data' }, { headers: corsHeaders(origin), });};// Глобальный CORS через хук:export const handle: Handle = async ({ event, resolve }) => { if (event.url.pathname.startsWith('/api/public')) { const origin = event.request.headers.get('Origin');
if (event.request.method === 'OPTIONS') { return new Response(null, { headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST', 'Access-Control-Allow-Headers': 'Content-Type', }, }); }
const response = await resolve(event); response.headers.set('Access-Control-Allow-Origin', '*'); return response; }
return resolve(event);};🔑 Rate limiting
Заголовок раздела «🔑 Rate limiting»const requests = new Map<string, { count: number; resetAt: number }>();
export function rateLimit(key: string, limit = 100, windowMs = 60000) { const now = Date.now(); const entry = requests.get(key);
if (!entry || now > entry.resetAt) { requests.set(key, { count: 1, resetAt: now + windowMs }); return { allowed: true, remaining: limit - 1 }; }
if (entry.count >= limit) { return { allowed: false, remaining: 0 }; }
entry.count++; return { allowed: true, remaining: limit - entry.count };}
// В API route:export const POST: RequestHandler = async ({ getClientAddress, request }) => { const ip = getClientAddress(); const { allowed, remaining } = rateLimit(ip, 10, 60000); // 10 запросов/минуту
if (!allowed) { return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': '60', 'X-RateLimit-Remaining': '0', }, }); }
return json({ result: 'ok' }, { headers: { 'X-RateLimit-Remaining': String(remaining) }, });};