Edge functions
A library of standalone *.serverless.ts functions — pure compute, request-header/geo, outbound HTTP, Web Crypto HMAC, an LLM call, status-controlled errors. Run with voltro serverless; ship to node / Cloudflare / Scaleway. No server, no port.
A standalone library of *.serverless.ts functions that walks the RANGE of what an edge function actually does: pure compute, reading the request's geo header, outbound HTTP (fire-and-forget AND fetch-and-transform), a Web Crypto HMAC verify, a one-shot LLM call, and status-controlled errors. There is NO long-running api here, no voltro dev, no port — each function is bundled + deployed on its own with voltro serverless, and scales to zero between requests. Every handler is edge-safe (no node:*), so the SAME file runs on Cloudflare Workers, Scaleway Functions, and your own node box. Template id: edge-functions.
Scaffold
edge-functions is kind: 'serverless' — added to an existing project with add-app, not create-project:
voltro add-app fns --template=edge-functions --to acmeWhat ships
apps/acme/fns/ # dir named by the app, not the template
├── package.json # scripts: list, build, serve, typecheck
├── tsconfig.json
├── README.md
└── functions/
├── health.serverless.ts # GET · pure Effect.sync diagnostic; ctx.env
├── shareLink.serverless.ts # POST · pure compute; runtime hints (memory/timeout/region)
├── geoGreeting.serverless.ts # GET · reads ctx.request.headers (edge geo)
├── slackNotify.serverless.ts # POST · ctx.waitUntil — background work after the response
├── currencyConvert.serverless.ts # POST · outbound HTTP via HttpClient; fetch + transform JSON
├── verifySignature.serverless.ts # POST · Web Crypto HMAC; ServerlessHttpError 401
├── aiComplete.serverless.ts # POST · an LLM call needing a secret (ctx.env); 502 mapping
└── resolveLink.serverless.ts # GET · a lookup; 404 via ServerlessHttpErrorThere is no app.config.ts, no database/, no queries/ — a serverless library is not an api. Each function is its own deploy artifact, discovered by filename (*.serverless.ts), bundled standalone. The app depends only on @voltro/serverless + @voltro/cli + @effect/platform (for HttpClient) + effect.
The shape — defineServerless({...})
Every function default-exports one defineServerless({...}) descriptor. The minimal shape is a name, an HTTP method, an input/output effect/Schema, and a handler. The input Schema is load-bearing: a malformed request becomes a typed 400 BEFORE your handler runs — you never hand-parse JSON.parse(body) and trust it. The handler receives (input, ctx) and returns an Effect (or a plain value via Effect.sync); ctx carries env, request, and waitUntil.
// functions/health.serverless.ts — the simplest function: a GET diagnostic
import { Effect, Schema } from 'effect'
import { defineServerless } from '@voltro/serverless'
export default defineServerless({
name: 'health',
method: 'GET',
input: Schema.Struct({}),
output: Schema.Struct({
status: Schema.Literal('ok'),
region: Schema.String,
now: Schema.String,
}),
handler: (_input, ctx) =>
Effect.sync(() => ({
status: 'ok' as const,
// The provider injects region into the env (Scaleway SCW_REGION, your own
// VOLTRO_REGION on a node host). Cloudflare is global → 'edge'.
region: ctx.env.VOLTRO_REGION ?? ctx.env.SCW_REGION ?? ctx.env.AWS_REGION ?? 'edge',
now: new Date().toISOString(),
})),
})ctx.env is how a function reads config and secrets — there is no defineEnv here, the host injects the env at deploy time. health is pure (Effect.sync, no I/O), the cheapest thing an edge function can be.
Pure compute + runtime hints
shareLink is still pure compute (Effect.sync, no I/O) but adds a runtime block — the memory/timeout/region hints the deploy targets honour. Pure-compute functions are trivially edge-safe and the fastest to cold-start:
// functions/shareLink.serverless.ts — id → short-lived share URL
export default defineServerless({
name: 'share-link',
method: 'POST',
input: Schema.Struct({
resourceId: Schema.String.pipe(Schema.minLength(1)),
ttlMinutes: Schema.Number.pipe(Schema.greaterThan(0)),
}),
output: Schema.Struct({ url: Schema.String, expiresAt: Schema.Number }),
runtime: { memoryMb: 128, timeoutSeconds: 10, region: 'fr-par' },
handler: ({ resourceId, ttlMinutes }, ctx) =>
Effect.sync(() => {
const base = ctx.env.SHARE_BASE_URL ?? 'https://share.example'
const expiresAt = Date.now() + ttlMinutes * 60_000
return { url: `${base}/s/${encodeURIComponent(resourceId)}?exp=${expiresAt}`, expiresAt }
}),
})Reading the request — edge geo
geoGreeting reaches past the decoded input to the raw request via ctx.request.headers — here the geo header the edge host injects. Being close to the user is the whole point of running at the edge, and this reacts to where they are:
// functions/geoGreeting.serverless.ts — a localized greeting from the caller's country
handler: ({ name }, ctx) =>
Effect.sync(() => {
// Cloudflare sets `cf-ipcountry`; other proxies use varied headers. We
// read whatever the host injected — the function code stays the same.
const country = (
ctx.request.headers.get('cf-ipcountry') ??
ctx.request.headers.get('x-vercel-ip-country') ??
ctx.request.headers.get('x-forwarded-country') ??
'XX'
).toUpperCase()
const hello = HELLO[country] ?? 'Hello'
return { greeting: `${hello}, ${name ?? 'there'}!`, country }
}),Outbound HTTP — two patterns
Fire-and-forget — slackNotify schedules delivery that OUTLIVES the response with ctx.waitUntil(...). The caller gets { queued: true } immediately; the actual POST runs in the background (on Cloudflare via the real ExecutionContext.waitUntil, best-effort elsewhere). A detached background POST needs no HttpClient features, so it uses the global fetch:
// functions/slackNotify.serverless.ts
handler: ({ text }, ctx) =>
Effect.gen(function* () {
const webhook = ctx.env.SLACK_WEBHOOK_URL
if (!webhook) {
return yield* Effect.fail(
new ServerlessHttpError({ status: 503, message: 'slack-not-configured', detail: 'Set SLACK_WEBHOOK_URL.' }),
)
}
// Schedule the delivery in the background and respond NOW.
ctx.waitUntil(
fetch(webhook, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text }),
}).catch(() => {
/* best-effort — a failed notification must not fail the request */
}),
)
return { queued: true }
}),Fetch-and-transform — currencyConvert calls a third-party API with the framework-provided HttpClient, reads the JSON response (yield* response.json), transforms it, and maps an unanswerable upstream to a typed 422. It hits a public, no-key FX endpoint so it runs out of the box:
// functions/currencyConvert.serverless.ts
import { HttpClient, HttpClientRequest } from '@effect/platform'
handler: ({ from, to, amount }, _ctx) =>
Effect.gen(function* () {
const base = from.toUpperCase()
const quote = to.toUpperCase()
const http = yield* HttpClient.HttpClient
const response = yield* http.execute(HttpClientRequest.get(`https://open.er-api.com/v6/latest/${base}`))
const body = (yield* response.json) as { rates?: Record<string, number> }
const rate = body.rates?.[quote]
if (typeof rate !== 'number') {
return yield* Effect.fail(
new ServerlessHttpError({ status: 422, message: 'unknown-currency', detail: `no rate ${base}->${quote}` }),
)
}
return { from: base, to: quote, amount, rate, result: Math.round(amount * rate * 100) / 100 }
}),Web Crypto — HMAC verify
verifySignature uses the Web Crypto API (crypto.subtle) — available on every serverless host (Workers, Scaleway node, your own node) without ANY import — wrapped in Effect.promise. There's no node:crypto here precisely so the Cloudflare target keeps working. A mismatch is a 401 via ServerlessHttpError:
// functions/verifySignature.serverless.ts — HMAC-SHA256 verify
const hmacHex = async (secret: string, payload: string): Promise<string> => {
const enc = new TextEncoder()
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
return toHex(await crypto.subtle.sign('HMAC', key, enc.encode(payload)))
}
handler: ({ payload, signature }, ctx) =>
Effect.gen(function* () {
const secret = ctx.env.SIGNING_SECRET
if (!secret) {
return yield* Effect.fail(
new ServerlessHttpError({ status: 503, message: 'signing-not-configured', detail: 'Set SIGNING_SECRET.' }),
)
}
const expected = yield* Effect.promise(() => hmacHex(secret, payload))
if (!safeEqual(expected, signature.toLowerCase())) {
return yield* Effect.fail(new ServerlessHttpError({ status: 401, message: 'invalid-signature' }))
}
return { valid: true as const }
}),An LLM call needing a secret
aiComplete calls a third-party API that needs a SECRET — the key lives in ctx.env, never in the bundle. It reads the JSON response and maps an upstream failure to a 502. It uses the OpenAI chat-completions shape but points at any compatible endpoint via OPENAI_BASE_URL:
// functions/aiComplete.serverless.ts — one-shot LLM completion at the edge
handler: ({ prompt, system }, ctx) =>
Effect.gen(function* () {
const apiKey = ctx.env.OPENAI_API_KEY
if (!apiKey) {
return yield* Effect.fail(
new ServerlessHttpError({ status: 503, message: 'ai-not-configured', detail: 'Set OPENAI_API_KEY.' }),
)
}
const baseUrl = ctx.env.OPENAI_BASE_URL ?? 'https://api.openai.com'
const model = ctx.env.OPENAI_MODEL ?? 'gpt-4o-mini'
const http = yield* HttpClient.HttpClient
const request = HttpClientRequest.post(`${baseUrl}/v1/chat/completions`).pipe(
HttpClientRequest.setHeader('authorization', `Bearer ${apiKey}`),
HttpClientRequest.bodyUnsafeJson({
model,
max_tokens: 400,
messages: [
...(system ? [{ role: 'system', content: system }] : []),
{ role: 'user', content: prompt },
],
}),
)
const response = yield* http.execute(request)
if (response.status >= 400) {
const detail = yield* response.text
return yield* Effect.fail(new ServerlessHttpError({ status: 502, message: 'ai-call-failed', detail }))
}
const body = (yield* response.json) as { choices?: ReadonlyArray<{ message?: { content?: string } }> }
return { text: body.choices?.[0]?.message?.content ?? '' }
}),Status-controlled errors — ServerlessHttpError
ServerlessHttpError({ status, message, detail? }) is how a handler controls the HTTP status the caller sees — the throughline of the library. resolveLink (the read side of a link shortener) returns a 404 for an unknown code; the same mechanism produces 401 (bad signature), 422 (no FX rate), 502 (upstream LLM failure), and 503 (a secret not wired). Without it, an Effect.fail would surface as a generic 500:
// functions/resolveLink.serverless.ts — short code → destination URL
handler: ({ code }, _ctx) =>
Effect.gen(function* () {
const url = LINKS[code.toLowerCase()]
if (!url) {
return yield* Effect.fail(new ServerlessHttpError({ status: 404, message: 'unknown-code', detail: code }))
}
return { code, url }
}),Run locally
The package scripts are the serverless workflow — there is no voltro dev:
voltro serverless list . # what functions are here
voltro serverless dev functions/health.serverless.ts # ONE function on :8910
curl http://localhost:8910/
# functions that need config read it from the env:
SLACK_WEBHOOK_URL=https://hooks.slack.com/... \
voltro serverless dev functions/slackNotify.serverless.tsvoltro serverless serve . runs ALL of them at once (each mounted at /<name>) — handy as a single self-hosted sidecar. The dev runner sends CORS headers — the same headers Cloudflare / Scaleway add at the edge — so a cross-origin browser POST works in dev exactly as it will in prod.
Deploy targets
voltro serverless build . bundles every function so you can inspect the output; then ship them to one of three targets:
voltro serverless deploy --target node # self-host kit (Dockerfile + README)
voltro serverless deploy --target cloudflare # Cloudflare Workers
voltro serverless deploy --target scaleway --namespace-id <id> # Scaleway FunctionsSet each function's secrets at deploy time (the host's env / secret store): SLACK_WEBHOOK_URL, SIGNING_SECRET, OPENAI_API_KEY, SHARE_BASE_URL, … The function host bills per invocation and idles to zero between requests.
Add your own
Drop a new functions/<name>.serverless.ts exporting export default defineServerless({ … }). It's discovered automatically — no registry, no config entry.
defineAction (api) vs. *.serverless.ts (this) — the decision rule
The framework's default for external I/O — an HTTP call, an email, an AI call — is a defineAction inside an always-on api. Reach for a *.serverless.ts instead ONLY when you specifically want one of:
| You want… | Pick |
|---|---|
| External I/O as part of a reactive app (it sits next to queries / mutations / a DB) | defineAction in an api |
| Process isolation — a function that can't take down your api | *.serverless.ts |
| Independent scaling — one endpoint that spikes without scaling the whole api | *.serverless.ts |
| Independent release — ship this one function without redeploying the api | *.serverless.ts |
| Scale-to-zero for a rarely-hit endpoint (no always-on server) | *.serverless.ts |
If none of those apply, an action in an api is the simpler, cheaper choice — it's typed end-to-end to the rpc client and shares the api's runtime, auth, and DB.
Pairs well with
- Serverless functions — the deploy + runtime model for
*.serverless.ts(node / Cloudflare / Scaleway). - Scale to zero — why these idle to zero and bill per invocation.
- Static sites — pair a function with a pre-rendered page on a CDN.
frontend-contact— the static-page + ONE-serverless-backend starter; reach foredge-functionswhen you need MORE than one function.- Actions — the in-api alternative for external I/O when you DON'T need process isolation / independent scaling.
Anti-patterns
- Reaching for
node:*in a function. The handler is edge-safe by design (it runs on a Cloudflare Worker). Use the frameworkHttpClient(or globalfetch) for outbound HTTP andcrypto.subtle(Web Crypto) for hashing — anode:-prefixed import breaks the Cloudflare/Scaleway targets. - Skipping the input Schema. The
defineServerlessinputis what turns a malformed request into a typed400before your handler runs. Don't hand-parse the body and trust it. - Returning a bare
Effect.failwhen you mean a specific status. That surfaces as a generic500. UseServerlessHttpError({ status, message })so the caller gets401/404/422/502/503— the whole point of the controlled-error functions. - Pretending to succeed when a secret isn't wired. Every config-dependent function here fails LOUD with a
503 *-not-configuredthe caller can render. Don't swap that for a silent{ ok: true }. - Treating the library like an api. A
*.serverless.tsis standalone — bundled + deployed on its own withvoltro serverless, never discovered byvoltro dev. If you find yourself wanting queries, subscriptions, or a database next to it, you want an api template + adefineAction, not this.