Deno fundamentals

Lesson 19 · about 15 minutes · the runtime under every edge function: modules, config, lockfiles, permissions, and the Node habits that break here.

Deno is what makes an edge function a single file you can deploy without a build. It runs TypeScript directly, fetches dependencies by URL, and denies everything by default. Each of those choices shows up as a concrete pattern in this repo, and each has a failure mode you will hit exactly once.

Modules: three schemes, one repo

SpecifierResolves toSeen at
jsr:@supabase/supabase-js@2JSR, Deno's registry. Semver range in the specifier, exact version pinned in the lockfile (2.99.0)._shared/jwt/default.ts, root deno.lock
npm:react@19.2.4, npm:@resvg/resvg-wasm@2.6.2npm registry. Deno installs into its global cache, not a node_modules folder: "your project directory stays clean".functions/deno.json, _shared/newsFeaturedImage.ts:15
https://esm.sh/@supabase/supabase-js@2.39.0A raw URL. Older style; still the import in several stocks handlers. Same library, older version, different type identity.stocks/handlers/stockDetails.ts:14
node:crypto etc.Node built-ins, explicitly prefixed. Deno's docs: "Import them with the node: prefix."not used in functions; the legacy test/ suite is plain Node
Why does the same library appear three ways? History, not design. Handlers were written over a year as the recommended specifier moved from esm.sh to npm: to jsr:. The cost is real: TypeScript treats SupabaseClient from npm: and from jsr: as different types, which is why CLAUDE.md tells tests to import from the same specifier as the module under test, and why CI type-checks would fail otherwise. When you touch a handler, the Boy Scout move is to converge it on jsr:. CLAUDE.md "CI"

deno.json: three of them, doing different jobs

FileContentsEffect
src/supabase/functions/deno.jsoncompilerOptions.jsx = react-jsx, jsxImportSource = npm:react@19.2.4, lint excludes no-unused-vars. No imports.Lets send-email's .tsx templates compile. Shared across every function locally; Supabase's docs call a shared file "not recommended for deployment" and want one per function.
src/supabase/functions/submit-feedback/deno.jsonimports: bare @2toad/profanity → npm:@2toad/profanity@^3, @supabase/functions-js → jsr:.The per-function pattern the docs prefer. Declared to the CLI via import_map in config.toml:196. The imports block is an import map: "you only need to specify the module specifier without the trailing /".
scripts/deno.jsonJSX plus an imports map for react, react-email, resend.Lets the ops scripts render email templates outside the edge runtime.

The repo-root deno.lock pins what the tests resolve (jose, @std/assert, @std/dotenv, supabase-js from JSR); functions/deno.lock pins what the functions resolve (react-email, resend, supabase-js from npm at 2.99.2). Different lockfiles, different resolution roots, so a version bump in one does not touch the other. deno.lock:3-11, functions/deno.lock:3-9

Lockfiles

A deno.lock maps each specifier range to the exact version and an integrity hash: "jsr:@supabase/supabase-js@2": "2.99.0" plus a SHA for the package. Deno writes it on first resolution and verifies against it afterwards; a mismatch fails the run rather than silently upgrading. Commit it, as this repo does, and CI resolves the same bytes you tested. deno.lock:1-12

Permissions

From the manual: "Unless you specifically enable it, a program run with Deno has no access to sensitive APIs, such as file system access, network connectivity, or environment access." Flags are --allow-net, --allow-env, --allow-read, each scopeable (--allow-net=example.com); -A disables the sandbox. In a terminal with no flag, Deno pauses and prompts. The tests use --allow-all because they need net, env and read together; the scripts use deno run -A. The edge runtime grants its own fixed set, so a function never sees a prompt, and a NotCapable error in prod logs means the runtime, not your code, drew the line. .github/workflows/deno-tests.yml:35, scripts/backfill-daily.ts:12

The APIs the functions actually use

APIRole here
Deno.serve(handler)The whole HTTP layer. Handler takes a web-standard Request, returns a Response. Port is the runtime's business.
Deno.env.get(name)string | undefined. The ! in Deno.env.get("SUPABASE_URL")! is a promise to the compiler that the runtime injects it; the ?? '' elsewhere is the cautious form.
fetch, AbortSignal.timeout, crypto.randomUUID, URLWeb platform globals, no import. notify-slack uses AbortSignal.timeout(10_000) so a hung Slack cannot hold the isolate.
Deno.readFile(new URL('./resvg/index_bg.wasm', import.meta.url))The one filesystem read in the functions. There is no __dirname; import.meta.url is how you address a file next to the module, and static_files in config.toml is what makes it exist after deploy. _shared/newsFeaturedImage.ts:135
Top-level awaitLegal in every module because everything is ESM. Module-scope constants like SUPABASE_JWT_KEYS are built once per isolate, not per request. _shared/jwt/default.ts:7

Node habits that break here

NodeDeno
require('x')ESM import only, unless the file is .cjs. test/run-tests.js is Node and uses require; nothing under functions/ can.
__dirname, __filenameimport.meta.dirname, import.meta.filename, or new URL('.', import.meta.url).
process.env.XDeno.env.get('X'). process exists for npm compat but is not the idiom.
node_modules/ + package.jsonGlobal cache + deno.json + deno.lock. src/node_modules exists only for the supabase CLI itself.
Build step (tsc, bundler)None. Deno type-strips on load; deno test and deno check are where type errors surface, which is why CI runs with type-checking on.
Extensionless importsEvery relative import needs its extension: '../_shared/utils.ts'.

Check yourself

Do this in the repo

  1. From src/: deno info supabase/functions/notify-slack/index.ts. Read the dependency tree and find where jose comes from.
  2. grep -rn "esm.sh" supabase/functions --include='*.ts' | wc -l. That number is the size of the specifier convergence debt.
  3. Run deno check supabase/functions/stocks/index.ts and note whether it passes; the answer tells you whether CI would accept a test that imports its handlers.

Primary source

Deno: Modules and dependencies for specifiers and import maps; Deno: Security and permissions for the sandbox; Supabase: Managing dependencies for why they want one deno.json per function.