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
| Specifier | Resolves to | Seen at |
|---|---|---|
jsr:@supabase/supabase-js@2 | JSR, 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.2 | npm 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.0 | A 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 |
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
| File | Contents | Effect |
|---|---|---|
src/supabase/functions/deno.json | compilerOptions.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.json | imports: 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.json | JSX 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
| API | Role 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, URL | Web 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 await | Legal 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
| Node | Deno |
|---|---|
require('x') | ESM import only, unless the file is .cjs. test/run-tests.js is Node and uses require; nothing under functions/ can. |
__dirname, __filename | import.meta.dirname, import.meta.filename, or new URL('.', import.meta.url). |
process.env.X | Deno.env.get('X'). process exists for npm compat but is not the idiom. |
node_modules/ + package.json | Global 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 imports | Every relative import needs its extension: '../_shared/utils.ts'. |
Check yourself
Do this in the repo
- From
src/:deno info supabase/functions/notify-slack/index.ts. Read the dependency tree and find wherejosecomes from. grep -rn "esm.sh" supabase/functions --include='*.ts' | wc -l. That number is the size of the specifier convergence debt.- Run
deno check supabase/functions/stocks/index.tsand 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.
Next: back to the lessons index; if you came here from Lesson 1, its Deno table should now read as obvious. Ask the teacher: "show me the diff to move stockDetails from esm.sh to jsr:".