Skip to content

nullarch/supa-edge

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

supa-edge

A lightweight, type-safe framework for building Supabase Edge Functions with routing, middleware, and testing utilities.

import { cors, SupaEdgeApp } from "@supa-edge/framework";

const app = new SupaEdgeApp();
app.use(cors());

app.get("/hello", (ctx) => {
  return ctx.respond.json({ message: "Hello from supa-edge!" });
});

app.serve();

Packages

Package Description
@supa-edge/framework Router, middleware, context with lazy Supabase client, response helpers
@supa-edge/testing Mock utilities for testing edge functions without a running server

Install

// deno.json
{
  "imports": {
    "@supa-edge/framework": "jsr:@supa-edge/framework@^0.1.0",
    "@supa-edge/testing": "jsr:@supa-edge/testing@^0.1.0"
  }
}

Quick Start

Minimal example

import { SupaEdgeApp } from "@supa-edge/framework";

const app = new SupaEdgeApp();

app.get("/", (ctx) => ctx.respond.json({ status: "ok" }));

app.get("/greet/:name", (ctx) => {
  return ctx.respond.json({ message: `Hello, ${ctx.params.name}!` });
});

app.serve();

CRUD API with auth and validation

import { auth, cors, HttpError, logger, SupaEdgeApp, validator } from "@supa-edge/framework";
import type { InferValidated, ValidatorOptions } from "@supa-edge/framework";
import { z } from "zod";

const app = new SupaEdgeApp();

app.use(cors());
app.use(logger());
app.use(auth());

// List
app.get("/todos", async (ctx) => {
  const { data, error } = await ctx.supabase
    .from("todos")
    .select("*")
    .order("created_at", { ascending: false });

  if (error) throw HttpError.internal(error.message);
  return ctx.respond.json(data);
});

// Create with validation
const createOpts = {
  body: z.object({
    title: z.string().min(1).max(255),
    completed: z.boolean().optional().default(false),
  }),
} satisfies ValidatorOptions;

app.post("/todos", validator(createOpts), async (ctx) => {
  const { title, completed } = ctx.validated.body as InferValidated<typeof createOpts>["body"];

  const { data, error } = await ctx.supabase
    .from("todos")
    .insert({ title, completed, user_id: ctx.user!.id })
    .select()
    .single();

  if (error) throw HttpError.internal(error.message);
  return ctx.respond.json(data, 201);
});

// Delete
app.delete("/todos/:id", async (ctx) => {
  const { error } = await ctx.supabase
    .from("todos")
    .delete()
    .eq("id", ctx.params.id);

  if (error) throw HttpError.internal(error.message);
  return ctx.respond.empty();
});

app.serve();

Features

Routing

Express/Hono-style routing with path parameters via the URLPattern API.

app.get("/users/:id", (ctx) => {
  return ctx.respond.json({ id: ctx.params.id });
});

app.post("/users", async (ctx) => {
  const body = await ctx.json();
  return ctx.respond.json(body, 201);
});

// PUT, PATCH, DELETE also supported

The /functions/v1/{function-name} prefix that Supabase adds to edge function URLs is automatically stripped -- routes are written without it.

HEAD requests automatically match GET routes and return headers without a body.

Context

Every handler and middleware receives a Context object:

app.get("/example", async (ctx) => {
  ctx.request;        // Original Request
  ctx.method;         // "GET", "POST", etc.
  ctx.url;            // Parsed URL object
  ctx.params;         // { id: "123" } from /users/:id
  ctx.state;          // Extensible state bag for passing data between middleware

  await ctx.json();   // Parse body as JSON (cached)
  await ctx.text();   // Parse body as text (cached)

  ctx.supabase;       // Lazy Supabase client (user context, uses Authorization header)
  ctx.supabaseAdmin;  // Lazy Supabase admin client (service role key)

  ctx.respond.json({ ok: true });       // 200 JSON
  ctx.respond.json(data, 201);          // 201 JSON
  ctx.respond.text("hello");            // 200 plain text
  ctx.respond.empty();                  // 204 No Content
  ctx.respond.redirect("/other");       // 302 redirect
  ctx.respond.redirect("/other", 301);  // 301 redirect
});

The Supabase clients are created lazily on first access. ctx.supabase forwards the request's Authorization header, so queries run in the context of the authenticated user. ctx.supabaseAdmin uses the SUPABASE_SERVICE_ROLE_KEY for privileged operations.

Middleware

Middleware uses the onion model (Koa/Hono-style). Each middleware calls next() to proceed, and can run code before and after:

app.use(async (ctx, next) => {
  const start = performance.now();
  const response = await next();
  console.log(`${ctx.method} ${ctx.url.pathname} - ${performance.now() - start}ms`);
  return response;
});

Route-level middleware is passed as variadic arguments before the handler:

app.post("/todos", authMiddleware, validationMiddleware, handler);

Built-in middleware

cors(options?) -- CORS with automatic OPTIONS preflight handling.

import { cors } from "@supa-edge/framework";

app.use(cors());

app.use(cors({
  origin: ["https://myapp.com", "https://staging.myapp.com"],
  credentials: true,
  maxAge: 86400,
}));

// Function-based origin
app.use(cors({
  origin: (o) => o.endsWith(".myapp.com"),
}));

CORS headers are set on ctx.responseHeaders, so they're automatically included in all responses -- including error responses. When credentials: true with the default wildcard origin, the middleware reflects the request's Origin header instead of "*" (as required by the CORS spec).

auth(options?) -- JWT authentication via Supabase.

import { auth } from "@supa-edge/framework";

// Required auth (401 if missing/invalid)
app.use(auth());

// Optional auth (ctx.user is undefined if not authenticated)
app.use(auth({ optional: true }));

Sets ctx.user with id, email, role, and any user_metadata from the JWT.

validator(options) -- Zod-based request validation.

import { validator } from "@supa-edge/framework";
import type { InferValidated, ValidatorOptions } from "@supa-edge/framework";
import { z } from "zod";

const opts = {
  body: z.object({
    title: z.string().min(1),
    done: z.boolean().optional(),
  }),
} satisfies ValidatorOptions;

app.post("/items", validator(opts), (ctx) => {
  // Type-safe access via InferValidated
  const { title, done } = ctx.validated.body as InferValidated<typeof opts>["body"];
  // title: string, done: boolean | undefined
});

Validates body, query, and params. Multi-value query parameters (?tag=a&tag=b) are collected into arrays. Returns 400 with Zod issue details on failure.

logger() -- Request/response logging.

import { logger } from "@supa-edge/framework";

app.use(logger());
// [supa-edge] GET /todos 200 (12.3ms)

rateLimit(options?) -- In-memory rate limiting.

import { rateLimit } from "@supa-edge/framework";

app.use(rateLimit({ max: 100, windowMs: 60_000 }));

// Custom key function
app.use(rateLimit({
  keyFn: (ctx) => ctx.user?.id ?? "anonymous",
}));

Sets X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. Returns 429 when exceeded.

Note: Counters are stored in process memory. In Supabase Edge Functions, instances may be recycled between requests, making this best-effort. For strict rate limiting, use an external store (e.g., Redis or Supabase database).

Error Handling

Throw HttpError from handlers or middleware for structured error responses:

import { HttpError } from "@supa-edge/framework";

app.get("/todos/:id", async (ctx) => {
  const { data, error } = await ctx.supabase
    .from("todos")
    .select("*")
    .eq("id", ctx.params.id)
    .single();

  if (error) throw HttpError.notFound("Todo not found");
  return ctx.respond.json(data);
});

Errors are caught at the app level and serialized to JSON:

{ "error": "Todo not found", "status": 404 }

Available factories: HttpError.badRequest(), .unauthorized(), .forbidden(), .notFound(), .methodNotAllowed(), .conflict(), .tooManyRequests(), .internal(). All accept an optional details argument.

Custom error handling:

const app = new SupaEdgeApp({
  onError: (error, ctx) => {
    // Custom error response
    return ctx.respond.json({ message: "Something went wrong" }, 500);
  },
});

Testing

@supa-edge/testing provides utilities to test edge functions without a running server or real Supabase instance.

import { assertEquals } from "@std/assert";
import { auth, cors, HttpError, SupaEdgeApp, validator } from "@supa-edge/framework";
import type { Middleware } from "@supa-edge/framework";
import {
  authHeaders,
  createMockSupabase,
  createMockUser,
  mockEnv,
  TestHandler,
} from "@supa-edge/testing";

function createTestApp() {
  const mockSupabase = createMockSupabase()
    .onAuth(createMockUser())
    .onTable("todos", {
      select: { data: [{ id: 1, title: "Test" }], error: null },
      insert: { data: { id: 2, title: "New" }, error: null },
      delete: { data: null, error: null },
    })
    .onRpc("get_count", { data: { count: 42 }, error: null })
    .build();

  // Inject mock supabase via middleware
  const mockSupabaseMw: Middleware = async (ctx, next) => {
    Object.defineProperty(ctx, "supabase", {
      value: mockSupabase,
      configurable: true,
    });
    return await next();
  };

  const app = new SupaEdgeApp();
  app.use(cors());
  app.use(mockSupabaseMw);
  app.use(auth());

  app.get("/todos", async (ctx) => {
    const { data, error } = await ctx.supabase.from("todos").select("*");
    if (error) throw HttpError.internal(error.message);
    return ctx.respond.json(data);
  });

  return new TestHandler(app.handler);
}

Deno.test("GET /todos returns list", async () => {
  const cleanup = mockEnv();
  try {
    const t = createTestApp();
    const res = await t.get("/todos", { headers: authHeaders() });

    assertEquals(res.status, 200);
    assertEquals((await res.json()).length, 1);
  } finally {
    cleanup();
  }
});

Deno.test("GET /todos requires auth", async () => {
  const cleanup = mockEnv();
  try {
    const t = createTestApp();
    const res = await t.get("/todos");

    assertEquals(res.status, 401);
  } finally {
    cleanup();
  }
});

Testing API

TestHandler -- Invokes the app handler directly without Deno.serve().

const t = new TestHandler(app.handler);

await t.get("/path");
await t.post("/path", { body: { key: "value" } });
await t.put("/path", { body: data, headers: { "X-Custom": "value" } });
await t.patch("/path", { body: partial });
await t.delete("/path");
await t.options("/path");

// Query parameters
await t.get("/search", { params: { q: "test", page: "1" } });

mockEnv(overrides?) -- Stubs Deno.env with Supabase defaults. Returns a cleanup function.

const cleanup = mockEnv();
// SUPABASE_URL = "http://localhost:54321"
// SUPABASE_ANON_KEY = "test-anon-key"
// SUPABASE_SERVICE_ROLE_KEY = "test-service-role-key"

cleanup(); // restores original values

createMockSupabase() -- Builder-pattern mock Supabase client.

const supabase = createMockSupabase()
  .onTable("todos", {
    select: { data: [...], error: null },
    insert: { data: {...}, error: null },
    update: { data: {...}, error: null },
    upsert: { data: {...}, error: null },
    delete: { data: null, error: null },
  })
  .onRpc("my_function", { data: { result: 42 }, error: null })
  .onAuth(createMockUser())
  .build();

Supports full Supabase query chaining (.eq(), .order(), .limit(), .single(), etc.).

authHeaders(token?) -- Creates { Authorization: "Bearer <token>" }.

createMockUser(overrides?) -- Creates a mock user object with sensible defaults.

mockEdgeRuntime() -- Mocks EdgeRuntime.waitUntil() for testing background tasks.

Environment Variables

Variable Required Description
SUPABASE_URL Yes Supabase project URL
SUPABASE_ANON_KEY Yes Supabase anonymous key
SUPABASE_SERVICE_ROLE_KEY No Required only if using ctx.supabaseAdmin

Development

# Type check
deno check packages/framework/mod.ts packages/testing/mod.ts

# Lint
deno lint packages/ examples/

# Format
deno fmt packages/ examples/

# Run all tests
deno test --allow-env --allow-net packages/

# Run example tests
deno test --allow-env --allow-net examples/

# Run a single test file
deno test --allow-env --allow-net packages/framework/tests/app_test.ts

License

MIT

[VibeMon]

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors