Недостающая стандартная библиотека для 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, маппинг кодов773461
validationВалидация через Zod, парсинг запросов517733
authJWT-верификация, auth middleware424428
resilienceTimeout, retry, circuit breaker1 4092 134
loggerStructured JSON logging342279
testingMockDB, PostgREST-эмулятор, mock fetch1 025561
langfuseКлиент для Langfuse prompt management213268

Итого: 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 → 504
  • AuthError → 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 делает четыре вещи:

  1. Создаёт MockDBState с начальными данными
  2. Подключает PostgREST-эмулятор (понимает eq., ilike., order, limit, offset)
  3. Подменяет globalThis.fetch на URL-маршрутизатор
  4. Устанавливает тестовые 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_at
  • PATCH /rest/v1/users?id=eq.u1 — обновление с добавлением updated_at
  • POST /rest/v1/rpc/my_function — вызов RPC
  • HEAD /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-лицензия. Забирайте.

FAQ

Circuit breaker хранит состояние per-isolate, а Supabase поднимает несколько воркеров. Насколько критично это ограничение на высоконагруженных функциях?

Для большинства нагрузок это приемлемо. Каждый изолят независимо открывает circuit после failureThreshold отказов, поэтому если OpenAI лежит 30 секунд, все активные воркеры откроются в течение нескольких сотен миллисекунд друг за другом — каскад срабатывает быстро. Реальный пробел — это сброс: один изолят может восстановиться, пока другие остаются с открытым circuit, что порождает непоследовательную маршрутизацию запросов. Если нужно синхронизированное состояние, рекомендуемый путь — хранить состояние circuit в Supabase KV (сейчас в бета-тестировании) и опрашивать его при проверке half-open.

PostgREST-эмулятор в пакете testing поддерживает JOIN-операции или только одиночные таблицы?

Только операции с одной таблицей: фильтрация, сортировка, пагинация, вставка, обновление, upsert и вызовы RPC. Межтабличные JOIN-ы (.select('*, related_table(*)')) не эмулируются. Это было осознанным архитектурным решением — правильно эмулировать логику JOIN сложно, а функции, требующие JOIN-ов, можно тестировать через предварительно засеянные денормализованные данные или вынося JOIN в Postgres-вью и тестируя её как отдельную таблицу.

Пакет errors использует duck typing для определения типов ошибок. Сломается ли это, если несколько пакетов в одном проекте определяют собственный класс TimeoutError?

Может возникнуть ложное срабатывание, если ваш кастомный TimeoutError имеет ту же форму, что и TimeoutError тулкита — а именно, свойство code равное TIMEOUT. Безопасный паттерн — расширять классы ошибок тулкита, а не определять параллельные. Если нужна собственная иерархия, переопределите свойство code уникальным значением (например, MY_TIMEOUT), чтобы errorToResponse маршрутизировал её в общий обработчик 500, а не в 504.