Node.js, Express и Prisma: API и работа с базой
Шестнадцать серверных сниппетов: HTTP-серверы, переменные окружения с дефолтами, bcrypt, сравнение за постоянное время, идемпотентность вебхуков Stripe, middleware Express для CORS, JWT, лимитов и ошибок, плюс схема, миграции, CRUD и пагинация в Prisma.
Весь путь запроса в шестнадцати частях: сервер принимает соединение, middleware решает, продолжать ли, обработчик идёт в базу — и что-то должно произойти, когда любой из шагов провалится.
Порядок middleware и есть реальное поведение API
Express выполняет middleware в порядке регистрации, и потому порядок — часть контракта, а не деталь. Лимитер до аутентификации означает, что неавторизованный поток отсекается дёшево; после неё — что каждый запрос атакующего стоит вам проверки токена. CORS раньше всего означает, что preflight-запросы получают ответ, не касаясь логики авторизации.
Обработчик ошибок — исключение, на котором спотыкаются: Express опознаёт его по сигнатуре из четырёх аргументов и доходит до него, только если он зарегистрирован последним. Зарегистрируйте раньше — он не выполнится никогда, отчего «централизованный обработчик ошибок» иногда выглядит полностью бездействующим.
Вебхукам нужна идемпотентность, а не только проверка подписи
Stripe повторяет вебхук, пока не получит 2xx, и сетевой таймаут на вашей стороне уже после выполнения работы всё равно считается неудачей — то есть то же событие приходит дважды. Проверка подписи доказывает подлинность события, но не мешает обработать его повторно. Безопасными повторы делает запись идентификатора события и отказ от дублей — именно это отличает надёжную интеграцию от той, что иногда выдаёт две подписки за один платёж. Документация Stripe о вебхуках говорит об этом прямо.
Секреты сравниваются не так, как пароли
bcrypt.compare закрывает проверку паролей. Всё остальное — API-ключи, секреты вебхуков, токены сессий — требует crypto.timingSafeEqual, потому что === на строках возвращает результат сразу на различающемся байте и выдаёт длину совпавшего префикса через время ответа. Два случая выглядят похоже и не взаимозаменяемы.
Prisma: рискованная часть — это миграции
Схема и CRUD — простая половина. Внимательно читать стоит миграции: prisma migrate dev — команда для разработки, способная сбросить базу, и запуск её против продакшена это классическая авария. В продакшене применяется prisma migrate deploy, который только накатывает ожидающие миграции и никогда не сбрасывает.
У пагинации похожее разделение. Offset проста и замедляется с ростом смещения, потому что база всё равно проходит пропускаемые строки. Cursor остаётся быстрой на любой глубине, но не умеет прыгать на произвольную страницу. Оба сниппета здесь, потому что правильный ответ зависит от того, есть ли в интерфейсе номера страниц.
Создание простого HTTP-сервера
Способ обслуживать запросы средствами самого Node.js — Express не нужен для чего-то настолько простого.
Как это работает: createServer принимает callback, который выполняется на каждый входящий запрос; вы сами проверяете req.url/req.method, чтобы решить, что делать, и всегда должны вызвать res.end() для завершения ответа.
import { createServer } from 'node:http'
const server = createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'ok' }))
return
}
res.writeHead(404)
res.end('Not found')
})
server.listen(3000, () => console.log('Listening on http://localhost:3000'))
Хеширование и проверка пароля через bcrypt
Стандартный способ безопасно хранить пароли в Node.js — никогда не храните и не сравнивайте их в открытом виде.
Как это работает: bcrypt генерирует уникальную случайную соль при каждом вызове (она встроена в саму возвращённую хеш-строку), поэтому хеширование одного и того же пароля два раза даёт два разных хеша — compare() заново извлекает эту соль из сохранённого хеша для проверки пароля-кандидата, поэтому хранить соль отдельно не нужно.
import bcrypt from 'bcrypt'
async function hashPassword(plainPassword) {
const saltRounds = 12
return bcrypt.hash(plainPassword, saltRounds)
}
async function verifyPassword(plainPassword, storedHash) {
return bcrypt.compare(plainPassword, storedHash)
}
// Example usage
const hash = await hashPassword('correct horse battery staple')
const isValid = await verifyPassword('correct horse battery staple', hash)
Чтение переменных окружения со значением по умолчанию
Небольшой хелпер, который читает переменную окружения Node.js и возвращает значение по умолчанию, если она не задана.
Как это работает: функция проверяет process.env по ключу и возвращает fallback-значение, если переменная не задана или равна пустой строке.
function env(key, fallback) {
const value = process.env[key]
return value === undefined || value === '' ? fallback : value
}
// Example usage
const port = Number(env('PORT', 3000))
const dbUrl = env('DATABASE_URL', 'sqlite://local.db')
Асинхронное чтение файла
Современный способ на промисах прочитать содержимое файла в Node.js — без коллбэков и без блокировки event loop, в отличие от fs.readFileSync.
Как это работает: fs/promises (встроен в Node с v14) позволяет делать await файловых операций напрямую вместо вложенных коллбэков или ручного оборачивания fs.readFile в Promise — и в отличие от readFileSync, не блокирует обработку других запросов на время чтения с диска.
import { readFile } from 'node:fs/promises'
async function loadConfig(path) {
const contents = await readFile(path, 'utf-8')
return JSON.parse(contents)
}
// Example usage
const config = await loadConfig('./config.json')
Сравнение строк за постоянное время для секретов
Сравнивает два секрета (API-токены, подписи вебхуков) не раскрывая информацию о времени выполнения, которая могла бы помочь атакующему подобрать их посимвольно.
Как это работает: обычное сравнение === завершается сразу при первом несовпавшем символе, поэтому время ответа тонко коррелирует с тем, сколько первых символов совпало — за много запросов атакующий может этим воспользоваться, чтобы подобрать секрет посимвольно. timingSafeEqual всегда занимает одинаковое время независимо от того, где строки расходятся.
import { timingSafeEqual } from 'node:crypto'
function timingSafeStringEquals(a, b) {
const bufferA = Buffer.from(a, 'utf8')
const bufferB = Buffer.from(b, 'utf8')
// Length check short-circuits before the constant-time compare — safe,
// since the expected length is fixed per endpoint and doesn't leak secret content
if (bufferA.length !== bufferB.length) return false
return timingSafeEqual(bufferA, bufferB)
}
// Example usage — verifying a webhook signature
if (!timingSafeStringEquals(receivedSignature, expectedSignature)) {
throw new Error('Invalid signature')
}
Stripe webhook handler с идемпотентностью
Безопасная обработка Stripe-вебхуков — проверка подписи, дедупликация событий, обработка checkout.session.completed и обновлений подписок.
Как это работает: Stripe отправляет каждое событие с подписью в заголовке. Обработчик проверяет подпись через секрет эндпоинта, затем проверяет набор дедупликации — один и тот же event.id никогда не обрабатывается дважды, даже если Stripe делает повторную отправку.
import Stripe from 'stripe'
import express from 'express'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature']
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(req.body, sig!, endpointSecret)
} catch {
return res.status(400).send('Invalid signature')
}
// Дедупликация — event.id уникален
if (await hasBeenProcessed(event.id)) {
return res.json({ received: true }) // уже обработано
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object
await activateSubscription(session.client_reference_id, session.subscription)
break
case 'invoice.payment_succeeded':
await extendSubscription(event.data.object.subscription)
break
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await syncSubscriptionStatus(event.data.object)
break
}
await markProcessed(event.id)
res.json({ received: true })
})
async function hasBeenProcessed(id: string): Promise<boolean> {
// Использовать Redis SET или таблицу в БД
return false
}
async function markProcessed(id: string): Promise<void> {
// Сохранить ID события с TTL
}
JWT access и refresh token rotation в Express
Полноценная JWT-аутентификация с короткоживущим access токеном и ротацией httpOnly refresh токена — без уязвимости повторного использования.
Как это работает: access token живёт 15 минут и передаётся в теле ответа. Refresh token хранится в httpOnly cookie и ротируется при каждом использовании — старый сразу инвалидируется, предотвращая перехват.
import jwt from 'jsonwebtoken'
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!
function signTokens(userId: string) {
const accessToken = jwt.sign({ sub: userId }, ACCESS_SECRET, { expiresIn: '15m' })
const refreshToken = jwt.sign({ sub: userId, jti: crypto.randomUUID() }, REFRESH_SECRET, { expiresIn: '7d' })
return { accessToken, refreshToken }
}
function authMiddleware(req, res, next) {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) return res.sendStatus(401)
try {
req.user = jwt.verify(header.slice(7), ACCESS_SECRET)
next()
} catch {
res.sendStatus(401)
}
}
// Refresh endpoint с ротацией
app.post('/auth/refresh', (req, res) => {
const token = req.cookies.refreshToken
if (!token) return res.sendStatus(401)
try {
const payload = jwt.verify(token, REFRESH_SECRET)
// Инвалидируем старый refresh token в БД
invalidateToken(payload.jti)
// Выдаём новую пару
const tokens = signTokens(payload.sub)
setRefreshCookie(res, tokens.refreshToken)
res.json({ accessToken: tokens.accessToken })
} catch {
res.clearCookie('refreshToken')
res.sendStatus(401)
}
})
function setRefreshCookie(res, token) {
res.cookie('refreshToken', token, {
httpOnly: true, secure: true, sameSite: 'strict',
path: '/auth/refresh', maxAge: 7 * 24 * 60 * 60 * 1000,
})
}
Включение CORS в Express
Стандартный способ разрешить кросс-доменные запросы с фронтенда — либо быстро и открыто для всех, либо ограничено конкретными источниками.
Как это работает: без CORS-заголовков браузер блокирует любой fetch/XHR-запрос с другого origin, чем у API (классическая ошибка "No 'Access-Control-Allow-Origin' header") — middleware cors добавляет эти заголовки автоматически согласно конфигу, включая обработку preflight-запроса OPTIONS.
import cors from 'cors'
// Quick and open — any origin can call this API
app.use(cors())
// Locked down to specific origins, with credentials (cookies) allowed
app.use(cors({
origin: ['https://example.com', 'https://app.example.com'],
credentials: true,
}))
Централизованный middleware обработки ошибок
Ловит ошибки из любого роута/middleware в одном месте, вместо повторения try/catch и логики форматирования ошибок повсюду.
Как это работает: Express не отлавливает автоматически отклонённые промисы внутри async-обработчиков роутов — необработанное отклонение просто "подвешивает" запрос. Оборачивание обработчиков в asyncHandler передаёт любую брошенную/отклонённую ошибку в next(), что направляет её в middleware обработки ошибок с 4 аргументами (Express определяет обработчики ошибок именно по этой сигнатуре).
// Wrap async route handlers so thrown errors reach Express's error handling
function asyncHandler(fn) {
return (req, res, next) => fn(req, res, next).catch(next)
}
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id)
if (!user) {
const error = new Error('User not found')
error.status = 404
throw error
}
res.json(user)
}))
// Must be registered LAST, after all other app.use()/routes
app.use((err, req, res, next) => {
console.error(err)
res.status(err.status || 500).json({ error: err.message || 'Internal server error' })
})
JWT Auth Middleware (Express)
Express middleware для проверки авторизации через JWT. Считывает заголовок Authorization, верифицирует токен и безопасно расширяет Request объект в TypeScript.
Сниппет для авторизации роутов в Node.js / Express приложении с использованием библиотеки jsonwebtoken. Включает расширение глобального типа Request в TypeScript, чтобы безопасно обращаться к свойству req.user.
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
// 1. Расширение глобального типа Request для TypeScript
declare global {
namespace Express {
interface Request {
user?: {
id: string
email: string
role?: string
}
}
}
}
const JWT_SECRET = process.env.JWT_SECRET || 'your-default-secret-key'
// 2. Middleware функция верификации токена
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Access token is missing or malformed'
})
}
const token = authHeader.split(' ')[1]
try {
const decoded = jwt.verify(token, JWT_SECRET) as {
id: string
email: string
role?: string
}
// Записываем payload токена в объект запроса
req.user = {
id: decoded.id,
email: decoded.email,
role: decoded.role
}
next()
} catch (err) {
return res.status(403).json({
error: 'Forbidden',
message: 'Invalid or expired access token'
})
}
}
Ограничение частоты запросов
Ограничивает, сколько запросов может сделать один IP за временное окно — стандартная первая линия защиты от абьюза и брутфорса.
Как это работает: этот лимитер в памяти достаточен для одного инстанса сервера; за балансировщиком нагрузки с несколькими инстансами нужно заменить стандартное хранилище на общее (например, rate-limit-redis), чтобы все инстансы считали в один общий лимит.
import rateLimit from 'express-rate-limit'
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per IP per window
standardHeaders: true, // adds RateLimit-* response headers
message: { error: 'Too many requests, please try again later.' },
})
app.use('/api/', limiter)
// Stricter limit for a sensitive endpoint
app.post('/api/login', rateLimit({ windowMs: 15 * 60 * 1000, max: 5 }), loginHandler)
Санитизация имени загруженного файла
Убирает расширение файла и приводит оставшееся имя к безопасному URL-совместимому виду перед сохранением на диск.
Как это работает:
replace(/\.[^/.]+$/, '')убирает расширение.replace(/[^a-zA-Z0-9]+/g, '-')заменяет каждую последовательность не-алфанумерических символов на один дефис.replace(/^-+|-+$/g, '')убирает лишние дефисы в начале/конце.
function sanitizeFilename(filename) {
const nameWithoutExt = filename.replace(/\.[^/.]+$/, '')
const sanitized = nameWithoutExt
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return sanitized
}
// Example usage in an Express upload handler
app.post('/upload', upload.single('file'), (req, res) => {
const safeName = sanitizeFilename(req.file.originalname)
res.json({ savedAs: `${safeName}${path.extname(req.file.originalname)}` })
})
Prisma CRUD — создание, чтение, обновление, удаление
Четыре базовые операции с данными через Prisma Client — создать пользователя, найти по email, обновить профиль, удалить аккаунт — всё с полной типизацией.
Как это работает: Prisma Client предоставляет отдельные методы для каждой операции. Каждый запрос типобезопасен — тип возврата выводится из схемы, поэтому обращение к несуществующему полю — ошибка компиляции.
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
// Создание
const user = await prisma.user.create({
data: {
email: '[email protected]',
name: 'Alice',
role: 'USER',
},
})
// Чтение по уникальному полю
const found = await prisma.user.findUnique({
where: { email: '[email protected]' },
})
// Чтение с фильтром и связями
const admins = await prisma.user.findMany({
where: { role: 'ADMIN' },
include: { posts: true },
orderBy: { createdAt: 'desc' },
})
// Обновление
const updated = await prisma.user.update({
where: { id: user.id },
data: { name: 'Alice Smith' },
})
// Upsert — обновить или создать
const upserted = await prisma.user.upsert({
where: { email: '[email protected]' },
update: { name: 'Bob' },
create: { email: '[email protected]', name: 'Bob' },
})
// Удаление
await prisma.user.delete({
where: { id: user.id },
})
// Количество
const total = await prisma.user.count()
const active = await prisma.user.count({
where: { role: 'USER' },
})
Prisma миграции — создание, применение и откат
Управление изменениями схемы базы данных через Prisma Migrate — создание миграций из изменений схемы, применение на продакшене и обработка рассинхронизации.
Как это работает: после изменений в schema.prisma запустите prisma migrate dev, чтобы создать новый файл миграции. CLI генерирует SQL, применяет к локальной БД и создаёт файл с временной меткой — его нужно закоммитить. Команда и продакшен применяют миграции через prisma migrate deploy.
# Создать и применить миграцию после изменений схемы
npx prisma migrate dev --name add-user-role
# Применить ожидающие миграции (CI/CD, продакшен)
npx prisma migrate deploy
# Сброс БД — удалить данные и переприменить все миграции
npx prisma migrate reset
# Статус миграций
npx prisma migrate status
# Создать миграцию без применения (проверить SQL)
npx prisma migrate dev --create-only
# Применить на продакшене
npx prisma migrate deploy
# Если миграции разсинхронизировались:
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma
# Скрипт деплоя на продакшен
npx prisma migrate deploy
npx prisma generate
npx next start
// Пример: добавление нового поля
model User {
id String @id @default(cuid())
email String @unique
name String?
phone String? // ← новое поле
role Role @default(USER) // ← новое поле
}
Prisma пагинация — cursor и offset
Эффективная пагинация больших наборов данных через Prisma — offset для простых списков, cursor для infinite scroll и real-time лент.
Как это работает: offset-пагинация использует skip и take — просто, но медленно на глубоких страницах (база сканирует пропущенные строки). Cursor-пагинация использует уникальное значение (id) для старта после конкретной записи — O(1) на страницу и стабильна при вставке новых элементов.
// Offset пагинация — простая, но тормозит на глубоких страницах
const page1 = await prisma.post.findMany({
skip: 0,
take: 20,
orderBy: { createdAt: 'desc' },
})
const page2 = await prisma.post.findMany({
skip: 20,
take: 20,
orderBy: { createdAt: 'desc' },
})
// Cursor пагинация — быстрая и стабильная
const firstPage = await prisma.post.findMany({
take: 20,
orderBy: { id: 'asc' },
})
const lastId = firstPage[firstPage.length - 1].id
const secondPage = await prisma.post.findMany({
take: 20,
cursor: { id: lastId },
skip: 1, // пропустить сам курсор
orderBy: { id: 'asc' },
})
// Cursor с where
const filtered = await prisma.post.findMany({
take: 20,
where: { published: true },
cursor: { id: lastId },
skip: 1,
orderBy: { id: 'asc' },
})
// Хелпер для cursor пагинации
async function paginatePosts(cursor?: string, limit = 20) {
return prisma.post.findMany({
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { createdAt: 'desc' },
})
}
Prisma схема — User, Post и связи
Практическая Prisma-схема с моделями User, Post, Session и Subscription, использующая связи, перечисления и индексы.
Как это работает: схема определяет четыре связанных модели. User имеет много Post и Session; Subscription привязывает пользователя к Stripe-подписке. Enums ограничивают значения статусов, а @@index ускоряет частые запросы.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
USER
ADMIN
}
enum SubscriptionStatus {
ACTIVE
CANCELED
PAST_DUE
EXPIRED
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
posts Post[]
sessions Session[]
subscription Subscription?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([published, createdAt])
}
model Session {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
}
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
stripeId String @unique
status SubscriptionStatus
currentPeriodEnd DateTime?
createdAt DateTime @default(now())
@@index([stripeId])
@@index([status])
}
// Найти пользователя с постами и подпиской
const user = await prisma.user.findUnique({
where: { email: '[email protected]' },
include: { posts: true, subscription: true }
})
Другие подборки сниппетов
Контактная информация
Оставьте заявку или напишите напрямую в телеграм. Я отвечу в течение 24 часов.