The _shared module

Lesson 5 · about 15 minutes · know what already exists before you write a helper, and what breaks when a handler bypasses it.

Every edge function is its own Deno program, so code they have in common lives in one folder that each function imports by relative path: src/supabase/functions/_shared/. The leading underscore is a Supabase convention: a folder starting with _ is not deployed as a function. Fourteen files, about 2,000 lines. Four of them are imported from dozens of places; the rest exist because two functions had to agree on one behaviour.

The inventory

FileImportersWhat it ownsIf you bypass it
utils.ts39createSuccessResponse (204 on null, else 200 JSON) and createErrorResponse ({success:false,error}).Clients parse two error shapes. The stocks handlers still carry local copies that add CORS headers; that is tolerated debt, not a pattern to copy.
cors.ts33Origin allow-list, OPTIONS handling, validateCorsOrigin, publicReadCorsHeaders for the no-Origin crawler surface. cors.ts:115, :157A hand-rolled header set silently allows any site, or 403s every cron caller.
jwt/default.ts28getUserId (JWKS-verified user JWT) and validateServiceRole (constant-time compare of apikey or bearer to the service key). jwt/default.ts:37, :74Timing-leaky string compare, or trusting sub from an unverified token.
validation.ts17ValidationResult<T> plus validators for ticker, fiscal period/year, page, pageSize, sortBy, sortDir, timeframe, date. Each normalises then rejects. validation.ts:35A ticker like '; DROP TABLE reaches a query string. The regex is ^[A-Z0-9.]{1,10}$ with no leading, trailing or doubled period.
pagination.tsfewfetchAllPages: loops .range() until an empty page. pagination.ts:34PostgREST caps at max_rows = 1000 with no error. A 1,000-row result that is really 5,400 looks like success.
audit.ts6AUDIT_ACTIONS const and writeAuditLog into audit.snaptrade_audit_logs. Failures log but never fail the request. audit.ts:20A string typo creates a new action name nobody queries for.
openaiModeration.ts6Client for the moderation API. Fail-closed: anything but a clean 2xx with a boolean verdict throws ModerationUnavailableError. openaiModeration.ts:9An outage becomes "allowed" and a slur lands in a username.
canonicalTicker.ts2pickCanonicalListing: chooses the tradeable US listing from Companies.tickers. Mirrors private.canonical_ticker(jsonb) in SQL. canonicalTicker.ts:89Feed rows (resolved in a trigger) and live rows (resolved in TypeScript) name different tickers for one company.
newsApiKey.ts2Mint, parse and authenticate wsb_news_<id>_<secret> keys sent in X-API-Key. Secret stored as SHA-256 only. newsApiKey.ts:73Overloading Authorization or apikey, both of which Kong already interprets.
newsRpc.ts4SQLSTATE to HTTP: 42501 403, 40900 409, P0002 404, 23514/23503 400, else 500. newsRpc.ts:11The JWT path and the API-key path answer differently for the same DB error.
newsMedia.ts2The only writer to the news-media bucket; the bucket has no write policy. newsMedia.ts:4There is no bypass. That is the design.
image.tsvia newsMediainspectImage reads JPEG/PNG headers for real type and size, and strips metadata. image.ts:112Trusting the uploader's Content-Type and filename.
newsFeaturedImage.ts2Renders a fallback featured image from a pre-built RGBA template plus a company icon, using resvg WASM. One render at a time per worker. newsFeaturedImage.ts:42Blowing the 2-second CPU budget on concurrent renders.
resvg/, newsImageAssets/assetsThe WASM binary and template pixels. Shipped via static_files in config.toml for the two news functions that need them.Deploy succeeds, then import.meta.url resolves to a file that is not in the bundle.
Why one folder rather than a package? Edge functions have no build step and no node_modules. The runtime bundles whatever the entrypoint imports, transitively, by path. A relative import into _shared/ is the cheapest possible module system: no publish, no version, no lockfile churn. The cost is that _shared must import its dependencies with the same specifier the handlers use, or two copies of supabase-js end up in one bundle with structurally incompatible SupabaseClient types. stocks/handlers/hydrate.ts:1-3 explains one such collision.

Three patterns to recognise

1Result objects instead of throws for validation. validateTicker returns {valid, value} or {valid, error}. The handler decides the status code. validateCorsOrigin goes further and hands back the finished 403 Response. The type is a discriminated union, so if (!r.valid) return narrows correctly. validation.ts:12, cors.ts:64-66
2Fail closed on external services. Moderation throws on any ambiguity; the caller must refuse the write. Compare with writeAuditLog, which fails open because a missing audit row must not block a user. Each file states which it is in its docblock. Read that before deciding how to handle its error.
3SQL mirrors. canonicalTicker.ts and private.canonical_ticker implement one rule twice because one caller is a trigger and the other is TypeScript. The file header says "change one and change the other". When you find a helper with a migration number in its comment, grep for the SQL twin before editing.

Not in _shared: the stocks function has its own utils/ with 33 files (Massive client, timezone, retry, concurrency). Those are stocks-only by decision, not accident. Promote one to _shared only when a second function imports it.

Check yourself

Do this in the repo

  1. Count the importers yourself and see which functions never touch _shared:
    cd src/supabase/functions
    grep -rhoE "_shared/[a-zA-Z/]+\.ts" --include='*.ts' --include='*.tsx' . | sort | uniq -c | sort -rn
  2. Exercise the validator without the stack:
    cd src/supabase/functions
    deno eval "import {validateTicker} from './_shared/validation.ts'; console.log(validateTicker(' brk.b '), validateTicker('.AAPL'), validateTicker(42))"
  3. Open _shared/cors.ts and find where a valid origin is echoed back rather than wildcarded, then explain why Allow-Credentials: true forces that.

Primary source

Supabase: Managing dependencies in Edge Functions covers the _shared convention, import specifiers and why the underscore folder is skipped on deploy.