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

25. SvelteKit: API Routes

Привет! 👋 Иногда тебе нужен не просто сайт — тебе нужен API. SvelteKit позволяет создавать полноценные HTTP endpoints прямо в своём приложении. Файл +server.ts превращает папку в API маршрут, который умеет отвечать на GET, POST, PUT, DELETE и PATCH запросы.

Думай об API routes как о почтовых ящиках 📬: каждый URL — это отдельный ящик, а метод (GET/POST/etc.) — это тип входящей почты. SvelteKit автоматически доставляет запрос нужному обработчику.


src/routes/api/hello/+server.ts
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
// GET /api/hello
export 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/hello
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
return json({
received: body,
timestamp: new Date().toISOString(),
}, { status: 201 });
};

src/routes/api/users/+server.ts
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);
};

src/routes/api/posts/+server.ts
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
};

src/routes/api/stream/+server.ts
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' },
});
};

Хуки — это функции, которые запускаются при каждом запросе. Это аналог middleware в Express:

src/hooks.server.ts
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"

// src/routes/api/public/+server.ts — публичный API с CORS
import 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),
});
};
src/hooks.server.ts
// Глобальный 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);
};

src/lib/server/rateLimit.ts
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) },
});
};