Недостающая стандартная библиотека для Supabase Edge Functions
Что такое Supabase Edge Toolkit?
Supabase Edge Toolkit — это open-source стандартная библиотека для Supabase Edge Functions, состоящая из 7 независимых пакетов: error handling, validation, auth, resilience, logging, testing и интеграция Langfuse. Библиотека устраняет копипаст и позволяет подключать только нужные пакеты.
TL;DR
- -76 production Edge Functions накопили 4 700 строк копипаста в _shared/ — мы упаковали это в stdlib
- -7 независимых пакетов: errors, validation, auth, resilience, logger, testing, langfuse — импортируй только нужное
- -Пакет resilience (1 409 строк) покрывает timeout, retry с backoff и circuit breaker
- -Строк тестов (4 864) больше, чем кода (4 703) — для библиотеки, от которой зависят 76 функций, это минимум
- -Open-source: github.com/spyrae/supabase-edge-toolkit
Как мы извлекли 7 переиспользуемых пакетов из 76 production-функций
Ловушка копипаста
Каждый проект на Supabase Edge Functions начинается одинаково. Создаёшь первую функцию — и пишешь CORS-заголовки. Вторую — и копируешь обработку ошибок из первой. Третью — и тащишь тот же JWT-верификатор. К десятой функции у тебя папка _shared/ весит больше, чем бизнес-логика.
У нас 76 Edge Functions в проде. Четыре тысячи семьсот строк общего кода. И каждый раз, когда что-то меняется — скажем, формат ответа ошибки — нужно обновлять один файл, но проверять все 76 потребителей.
Supabase сам рекомендует складывать утилиты в _shared/ и импортировать по относительному пути. Это работает, когда у тебя 5 функций. Когда 76 — это уже не утилиты. Это стандартная библиотека, которую никто не оформил как библиотеку.
Мы это сделали. Результат — supabase-edge-toolkit: 7 независимых пакетов, 518 тестов, ноль связанности между модулями.
Почему Edge Functions нуждаются в stdlib
Node.js-экосистема стоит на плечах npm. У Express есть middleware для всего. У Next.js — встроенная обработка ошибок.
У Supabase Edge Functions — ничего. Ты получаешь голый Deno.serve и Request/Response. Остальное — сам.
Каждый проект заново решает одни и те же задачи:
- CORS — три заголовка, которые обязательны, но их вечно забывают
- Error responses — формат JSON-ответа, маппинг кодов, обработка Zod-ошибок
- JWT-верификация — расшифровка токена, проверка
exp, извлечениеsub - Retry и timeout — внешние API падают, нужны повторы с бэкоффом
- Логирование — structured JSON для Grafana/Loki, а не
console.log - Тестирование — как мокать Supabase-клиент без реальной базы?
В итоге у каждого проекта появляется свой _shared/, который никто не тестирует отдельно, не версионирует и не документирует. Когда приходит новый разработчик — он читает исходники, потому что README нет.
Что мы построили
Семь пакетов. Каждый работает отдельно. Импортируешь только то, что нужно.
| Пакет | Назначение | Строки кода | Тесты |
|---|---|---|---|
errors | Ответы об ошибках, CORS, маппинг кодов | 773 | 461 |
validation | Валидация через Zod, парсинг запросов | 517 | 733 |
auth | JWT-верификация, auth middleware | 424 | 428 |
resilience | Timeout, retry, circuit breaker | 1 409 | 2 134 |
logger | Structured JSON logging | 342 | 279 |
testing | MockDB, PostgREST-эмулятор, mock fetch | 1 025 | 561 |
langfuse | Клиент для Langfuse prompt management | 213 | 268 |
Итого: 4 703 строк кода, 4 864 строк тестов. Тестов больше, чем кода. Для внутреннего проекта это перебор. Для библиотеки, от которой зависят 76 функций, — минимум.
Deep dive: обработка ошибок, которая масштабируется
Проблема
В типичном Edge Function обработка ошибок выглядит так:
Deno.serve(async (req) => {
try {
// ... бизнес-логика
} catch (error) {
if (error instanceof ZodError) {
return new Response(JSON.stringify({ error: "Validation failed" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (error.message?.includes("timeout")) {
return new Response(JSON.stringify({ error: "Timeout" }), { status: 504 });
}
console.error(error);
return new Response(JSON.stringify({ error: "Internal error" }), { status: 500 });
}
});
Каждая функция изобретает этот catch-блок заново. Формат ответов расходится. Фронтенд не может парсить ошибки единообразно.
Решение
Одна функция errorToResponse(), которая знает обо всех типах ошибок:
import {
createCorsResponse,
createSuccessResponse,
errorToResponse,
} from "@supa-edge-toolkit/errors";
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return createCorsResponse();
try {
const data = await handleRequest(req);
return createSuccessResponse(data);
} catch (error) {
return errorToResponse(error);
}
});
Три строки вместо двадцати. errorToResponse определяет тип ошибки через duck typing:
ZodError→ 400 с полями валидацииTimeoutError→ 504AuthError→ 401 с безопасным сообщением (детали не утекают наружу)Errorсо словом “rate limit” → 429 с заголовкомRetry-After- Всё остальное → 500 с generic-сообщением
Важнее другое: errorToResponse никогда не отдаёт внутреннее сообщение ошибки клиенту. Для auth-ошибок есть таблица безопасных сообщений:
const safeMessages: Record<string, string> = {
MISSING_AUTH_HEADER: "Missing authentication",
INVALID_TOKEN: "Invalid or expired token",
TOKEN_EXPIRED: "Token expired",
};
const safeMessage = safeMessages[authErr.code] ?? "Authentication failed";
Фронтенд получает стабильные коды ошибок (AUTH_ERROR, VALIDATION_ERROR, TIMEOUT_ERROR), а не сырые stack traces.
Deep dive: отказоустойчивость без фреймворка
Три примитива
Пакет resilience — самый объёмный (1 409 строк кода, 2 134 строк тестов). Он решает одну проблему: внешние API падают, и твоя Edge Function не должна падать вместе с ними.
Три примитива, каждый работает отдельно:
Timeout — обрывает запрос после N миллисекунд:
import { fetchWithTimeout } from "@supa-edge-toolkit/resilience";
// 5 секунд, потом TimeoutError
const response = await fetchWithTimeout("https://api.example.com/data", {}, 5000);
Retry — повторяет с экспоненциальным бэкоффом:
import { withRetry, RETRY_CONFIGS } from "@supa-edge-toolkit/resilience";
const result = await withRetry(
() => fetch("https://flaky-api.com/data"),
RETRY_CONFIGS.EXTERNAL_API, // 3 попытки, backoff 1s → 2s → 4s
);
Circuit Breaker — отключает вызовы к сервису, который лежит:
import { CircuitBreaker } from "@supa-edge-toolkit/resilience";
const breaker = CircuitBreaker.getOrCreate("payments", {
failureThreshold: 3,
resetTimeoutMs: 30_000,
});
const result = await breaker.call(() => fetch("https://payments.api/charge"));
Всё вместе: resilientFetch
На практике нужны все три сразу. resilientFetch компонует их:
import { resilientFetch } from "@supa-edge-toolkit/resilience";
const response = await resilientFetch(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
body: JSON.stringify({ model: "gpt-4", messages }),
headers: { Authorization: `Bearer ${apiKey}` },
},
{
serviceName: "openai",
timeout: { timeoutMs: 30_000 },
retry: { maxAttempts: 2 },
circuitBreaker: { failureThreshold: 5, resetTimeoutMs: 60_000 },
},
);
Одна функция — timeout обрывает зависшие запросы, retry повторяет после сбоев, circuit breaker отключает сервис после серии ошибок.
Jitter: защита от thundering herd
Деталь, которую легко пропустить. В конфигурации retry по умолчанию включен jitter:
EXTERNAL_API: {
maxAttempts: 3,
baseDelayMs: 1000,
backoffMultiplier: 2,
jitter: true, // ±50% случайное отклонение
}
Зачем? Представь: 100 запросов упали одновременно. Без jitter все 100 повторят через ровно 1 секунду, потом через 2, потом через 4. Синхронные волны. С jitter каждый повторяет через 500-1500 мс, 1000-3000 мс, 2000-6000 мс. Нагрузка размазывается.
Jitter — дефолт, а не опция. Потому что без него retry хуже, чем без retry.
Честность про ограничения
Circuit breaker хранит состояние в Map на уровне модуля:
const circuitRegistry = new Map<string, CircuitBreakerState>();
Это работает внутри одного Deno-изолята. Но Supabase разворачивает Edge Functions на нескольких воркерах. Один воркер откроет circuit — другой об этом не узнает.
Мы осознанно не пытались решить эту проблему. Distributed circuit breaker требует внешнего хранилища (Redis, Supabase Realtime, KV). Для 90% случаев per-isolate состояние достаточно: если OpenAI лежит, то лежит для всех, и каждый изолят откроет circuit после своих первых N ошибок.
Код документирует это ограничение. Никаких иллюзий.
Deep dive: тестирование без Supabase
Проблема
Edge Functions используют @supabase/supabase-js для работы с базой. Клиент внутри вызывает fetch. Чтобы тестировать функцию без реальной базы — нужно перехватить fetch.
Подмена globalThis.fetch
Supabase JS client читает URL из SUPABASE_URL и вызывает globalThis.fetch. Мы подменяем и то, и другое:
import { createTestContext, assertFetchCount } from "@supa-edge-toolkit/testing";
Deno.test("creates a user", async () => {
const ctx = createTestContext({
dbSeed: {
users: [{ id: "u1", name: "Alice", email: "alice@test.com" }],
},
});
try {
// Supabase client работает — fetch уходит в MockDB
const res = await fetch(
"http://localhost:54321/rest/v1/users?id=eq.u1",
{ headers: { Authorization: "Bearer test-anon-key" } },
);
const data = await res.json();
assertEquals(data[0].name, "Alice");
assertFetchCount(ctx.fetchLog, "/rest/v1/users", 1);
} finally {
ctx.cleanup(); // восстанавливает оригинальный fetch
}
});
createTestContext делает четыре вещи:
- Создаёт
MockDBStateс начальными данными - Подключает PostgREST-эмулятор (понимает
eq.,ilike.,order,limit,offset) - Подменяет
globalThis.fetchна URL-маршрутизатор - Устанавливает тестовые env-переменные (
SUPABASE_URL,SUPABASE_ANON_KEY)
Zero config. Написал createTestContext() — и обычный Supabase-клиент работает с in-memory базой.
PostgREST-эмулятор
MockDBState — это не просто словарь. Он понимает реальный PostgREST-протокол:
GET /rest/v1/users?email=eq.alice@test.com— фильтрация по полюGET /rest/v1/users?name=ilike.*lic*— нечёткий поискPOST /rest/v1/users— вставка с автогенерациейidиcreated_atPATCH /rest/v1/users?id=eq.u1— обновление с добавлениемupdated_atPOST /rest/v1/rpc/my_function— вызов RPCHEAD /rest/v1/users— подсчёт черезcontent-range
Тесты проходят через тот же путь, что и продакшен-код. Никаких моков уровня “вернуть захардкоженный JSON”.
Архитектурные решения
Нулевая связность
Каждый пакет — отдельный deno.json с собственным именем, версией и зависимостями. Нет общей core-библиотеки. Нет транзитивных зависимостей между пакетами.
Мы на это пошли намеренно. Общая core-библиотека удобна автору — но создаёт проблему для пользователя. Хочешь errors — тянешь core. Хочешь auth — тянешь core и errors. Дерево зависимостей растёт.
Вместо этого: validation опционально интегрируется с errors (если ты используешь оба), но работает и без него.
CryptoKey caching
crypto.subtle.importKey — асинхронная операция. В Edge Function, которая обрабатывает десятки запросов за время жизни изолята, вызывать её на каждый запрос — расточительство:
let _cachedVerifyKey: CryptoKey | null = null;
let _cachedJwtSecret: string | null = null;
async function getVerifyKey(): Promise<CryptoKey> {
const jwtSecret = Deno.env.get("SUPABASE_JWT_SECRET");
if (_cachedVerifyKey && _cachedJwtSecret === jwtSecret) {
return _cachedVerifyKey;
}
_cachedVerifyKey = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(jwtSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
_cachedJwtSecret = jwtSecret;
return _cachedVerifyKey;
}
Кэш живёт на уровне модуля. Инвалидируется автоматически, если секрет изменился (актуально для тестов, где секрет может отличаться между test cases).
Безопасный тестовый режим
Пакет auth поддерживает имперсонацию пользователей в dev-окружении. Но в отличие от наивного “если development — доверяй заголовку”, наш подход требует реальный service_role JWT:
export async function tryTestMode(req: Request): Promise<AuthResult | null> {
if (!isTestModeAvailable()) return null; // только в development
const testUserId = req.headers.get("X-Test-User-Id");
if (!testUserId) return null;
// Криптографическая проверка service_role JWT
const serviceAuth = await verifyServiceRole(req);
return {
userId: testUserId,
role: "authenticated",
payload: { ...serviceAuth.payload, sub: testUserId, _testMode: true },
};
}
Даже если кто-то случайно выставит ENVIRONMENT=development в продакшене — без валидного service_role JWT имперсонация не пройдёт.
Как использовать
Установка — одна строка на пакет:
// deno.json (import map)
{
"imports": {
"@supa-edge-toolkit/errors": "jsr:@supa-edge-toolkit/errors@0.1",
"@supa-edge-toolkit/auth": "jsr:@supa-edge-toolkit/auth@0.1",
"@supa-edge-toolkit/validation": "jsr:@supa-edge-toolkit/validation@0.1"
}
}
Полный пример Edge Function с тремя пакетами:
import { z } from "npm:zod";
import { createCorsResponse, createSuccessResponse, errorToResponse } from "@supa-edge-toolkit/errors";
import { verifyUserToken } from "@supa-edge-toolkit/auth";
import { parseRequestBody } from "@supa-edge-toolkit/validation";
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).optional(),
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return createCorsResponse();
try {
const { userId } = await verifyUserToken(req);
const body = await parseRequestBody(req, CreatePostSchema);
// body типизирован: { title: string; content: string; tags?: string[] }
const post = await createPost(userId, body);
return createSuccessResponse(post, 201);
} catch (error) {
return errorToResponse(error);
}
});
Восемь строк обработки запроса. Auth, валидация, типизация, обработка ошибок — всё работает.
От внутреннего кода к open source
Извлечь библиотеку из живого проекта сложнее, чем написать с нуля. Вот что мы поняли по ходу.
Убирай специфику жёстко. В нашем _shared/ были хелперы для конкретных API (Foursquare, Tinkoff, Google Places). Они не попали в тулкит. Если функция работает только с твоим стеком — она не библиотечная.
Тест-ratio 1:1 — минимум. 4 703 строк кода, 4 864 строк тестов. Для внутреннего проекта такое избыточно. Для библиотеки — нет. Любой сломанный edge case сломает код у всех потребителей.
Документируй ограничения. Circuit breaker не распределённый? Напиши это. CryptoKey кешируется per-isolate? Напиши это. Тот, кто выберет твою библиотеку, прочтёт это и не напорется сам.
Нулевая связность дороже при разработке, дешевле при использовании. Поддерживать 7 независимых пакетов сложнее, чем один монолит с общим core. Но для пользователя, которому нужен только errors — это разница между 773 строками и 4 703.
Репозиторий: github.com/spyrae/supabase-edge-toolkit
7 пакетов. 518 тестов. MIT-лицензия. Забирайте.
Нужна помощь с архитектурой Edge Functions и serverless-решений? Я помогаю стартапам внедрять AI-решения и строить продукты — belov.works.
Часто задаваемые вопросы
Circuit breaker хранит состояние per-isolate, а Supabase поднимает несколько воркеров. Насколько критично это ограничение на высоконагруженных функциях?
failureThreshold отказов, поэтому если OpenAI лежит 30 секунд, все активные воркеры откроются в течение нескольких сотен миллисекунд друг за другом — каскад срабатывает быстро. Реальный пробел — это сброс: один изолят может восстановиться, пока другие остаются с открытым circuit, что порождает непоследовательную маршрутизацию запросов. Если нужно синхронизированное состояние, рекомендуемый путь — хранить состояние circuit в Supabase KV (сейчас в бета-тестировании) и опрашивать его при проверке half-open.
PostgREST-эмулятор в пакете `testing` поддерживает JOIN-операции или только одиночные таблицы?
.select('*, related_table(*)')) не эмулируются. Это было осознанным архитектурным решением — правильно эмулировать логику JOIN сложно, а функции, требующие JOIN-ов, можно тестировать через предварительно засеянные денормализованные данные или вынося JOIN в Postgres-вью и тестируя её как отдельную таблицу.
Пакет `errors` использует duck typing для определения типов ошибок. Сломается ли это, если несколько пакетов в одном проекте определяют собственный класс `TimeoutError`?
TimeoutError имеет ту же форму, что и TimeoutError тулкита — а именно, свойство code равное TIMEOUT. Безопасный паттерн — расширять классы ошибок тулкита, а не определять параллельные. Если нужна собственная иерархия, переопределите свойство code уникальным значением (например, MY_TIMEOUT), чтобы errorToResponse маршрутизировал её в общий обработчик 500, а не в 504.