Writing functions: Deno, imports and structure
How to structure an Edge Function on SupaHost: the index.ts entry file, Deno.serve, npm/jsr/https imports, extra files, routing and error handling.
An Edge Function is a Deno program. SupaHost loads its index.ts, which registers a request handler with Deno.serve. Everything else (helper files, npm packages, routing) builds on that.
The entry file
Every function needs a file named exactly index.ts at its root, and that file must call Deno.serve. The handler receives a standard Request and returns a Response:
Deno.serve(async (req: Request) => {
const url = new URL(req.url);
const lang = url.searchParams.get("lang") ?? "en";
return Response.json({ greeting: lang === "fr" ? "Bonjour" : "Hello" });
});
Deno has the web platform APIs you know from browsers: fetch, URL, crypto.subtle, TextEncoder, ReadableStream, FormData and so on. TypeScript works without a build step.
Code at the top level of the module runs once when a worker boots, not on every request. Create clients and read secrets there so warm requests skip that work. Don't rely on in-memory state lasting between requests, though: workers are started and stopped as needed.
Imports
Three kinds of import specifier work:
| Specifier | Example |
|---|---|
npm: |
import Stripe from "npm:stripe@18"; |
jsr: |
import { createClient } from "jsr:@supabase/supabase-js@2"; |
https: |
import { format } from "https://deno.land/[email protected]/datetime/format.ts"; |
Pin a version in every import (@2, @18, an exact version for https: URLs). An unpinned import can pick up a breaking release the next time you deploy.
Imports are resolved when you deploy. SupaHost bundles the function as a check, which downloads every dependency and fails the deploy if one can't be found, so a bad import never replaces a working version. See Import errors with esm.sh or npm packages if a package won't resolve.
Write full specifiers in your import statements. A deno.json or import map is stored like any other file, but don't count on the runtime reading it.
Several files and folders
A function can have up to 50 files (1 MB of source in total), in folders up to 8 levels deep. In the dashboard editor, click + File and enter a path such as _shared/cors.ts or lib/pricing.ts. Import them with relative paths:
// index.ts
import { corsHeaders } from "./_shared/cors.ts";
import { quote } from "./lib/pricing.ts";
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
const { nights, room } = await req.json();
return Response.json(quote(room, nights), { headers: corsHeaders });
});
// lib/pricing.ts
const RATES: Record<string, number> = { single: 80, double: 120, suite: 260 };
export function quote(room: string, nights: number) {
const rate = RATES[room];
if (!rate) throw new Error(`Unknown room type: ${room}`);
return { room, nights, total: rate * nights, currency: "EUR" };
}
Each function's files belong to that function only. If two functions need the same helper, include the file in both. (Supabase's CLI layout has a supabase/functions/_shared folder next to the functions; on SupaHost, put _shared/ inside each function instead.)
Routing inside one function
The function sees the path after /functions/v1, starting with its own name. A request to /functions/v1/bookings/42 arrives with pathname /bookings/42. That lets one function serve a small REST API. URLPattern is built into Deno:
import { createClient } from "jsr:@supabase/supabase-js@2";
const byId = new URLPattern({ pathname: "/bookings/:id" });
const list = new URLPattern({ pathname: "/bookings" });
Deno.serve(async (req) => {
// Act as the caller so Row Level Security applies.
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } } },
);
const one = byId.exec(req.url);
if (one && req.method === "GET") {
const { data, error } = await supabase
.from("bookings").select("*").eq("id", one.pathname.groups.id).maybeSingle();
if (error) return Response.json({ error: error.message }, { status: 400 });
return data ? Response.json(data) : Response.json({ error: "Not found" }, { status: 404 });
}
if (list.test(req.url) && req.method === "GET") {
const { data, error } = await supabase.from("bookings").select("*").order("starts_at");
if (error) return Response.json({ error: error.message }, { status: 400 });
return Response.json(data);
}
return Response.json({ error: "No such route" }, { status: 404 });
});
From supabase-js, call a sub-path by putting it in the name: supabase.functions.invoke("bookings/42", { method: "GET" }).
Reading the request
const body = await req.json(); // JSON
const text = await req.text(); // raw body (needed for webhook signatures)
const form = await req.formData(); // multipart or urlencoded forms
const file = form.get("photo") as File; // an uploaded file
const auth = req.headers.get("Authorization");
A body can be read only once. If you need the raw text and the parsed JSON, read the text and call JSON.parse yourself.
Errors
Return errors on purpose with a status code and a message your app can show. If your code throws and nothing catches it, the worker fails and the caller gets a 500 with sb-error-code: WORKER_ERROR; the stack trace goes to the function's logs. A useful pattern:
Deno.serve(async (req) => {
try {
const { nights } = await req.json();
if (!Number.isInteger(nights) || nights < 1) {
return Response.json({ error: "nights must be a positive integer" }, { status: 400 });
}
return Response.json({ ok: true });
} catch (err) {
console.error("Unexpected failure", err);
return Response.json({ error: "Something went wrong" }, { status: 500 });
}
});
Environment variables
Read secrets and the built-in variables with Deno.env.get("NAME"). The built-ins are SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY and SUPABASE_FUNCTION_SLUG. See Environment variables and secrets.