# Недостающая стандартная библиотека для Supabase Edge Functions

> Как мы извлекли 7 переиспользуемых пакетов из 76 production Edge Functions: error handling, resilience, auth, testing и другие.
> Author: Roman Belov · Published: 2026-02-23 · Source: https://futurecraft.pro/ru/blog/supabase-edge-toolkit-stdlib/

*Как мы извлекли 7 переиспользуемых пакетов из 76 production-функций*

## Ловушка копипаста

Каждый проект на Supabase Edge Functions начинается одинаково. Создаёшь первую функцию — и пишешь CORS-заголовки. Вторую — и копируешь обработку ошибок из первой. Третью — и тащишь тот же JWT-верификатор. К десятой функции у тебя папка `_shared/` весит больше, чем бизнес-логика.

У нас 76 Edge Functions в проде. Четыре тысячи семьсот строк общего кода. И каждый раз, когда что-то меняется — скажем, формат ответа ошибки — нужно обновлять один файл, но проверять все 76 потребителей.

Supabase сам [рекомендует](https://supabase.com/docs/guides/functions/dependencies) складывать утилиты в `_shared/` и импортировать по относительному пути. Это работает, когда у тебя 5 функций. Когда 76 — это уже не утилиты. Это стандартная библиотека, которую никто не оформил как библиотеку.

Мы это сделали. Результат — [supabase-edge-toolkit](https://github.com/spyrae/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 обработка ошибок выглядит так:

```typescript
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()`, которая знает обо всех типах ошибок:

```typescript
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-ошибок есть таблица безопасных сообщений:

```typescript
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 миллисекунд:

```typescript
import { fetchWithTimeout } from "@supa-edge-toolkit/resilience";

// 5 секунд, потом TimeoutError
const response = await fetchWithTimeout("https://api.example.com/data", {}, 5000);
```

**Retry** — повторяет с экспоненциальным бэкоффом:

```typescript
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** — отключает вызовы к сервису, который лежит:

```typescript
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` компонует их:

```typescript
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-5.5", 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:

```typescript
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` на уровне модуля:

```typescript
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`. Мы подменяем и то, и другое:

```typescript
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, которая обрабатывает десятки запросов за время жизни изолята, вызывать её на каждый запрос — расточительство:

```typescript
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:

```typescript
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 имперсонация не пройдёт.

## Как использовать

Установка — одна строка на пакет:

```typescript
// 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 с тремя пакетами:

```typescript
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](https://github.com/spyrae/supabase-edge-toolkit)

7 пакетов. 518 тестов. MIT-лицензия. Забирайте.

---

*Нужна помощь с архитектурой Edge Functions и serverless-решений? Я помогаю стартапам внедрять AI-решения и строить продукты — [belov.works](https://belov.works).*
