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 acme

What 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 ServerlessHttpError

There 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-forgetslackNotify 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-transformcurrencyConvert 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.ts

voltro 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 Functions

Set 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 for edge-functions when 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 framework HttpClient (or global fetch) for outbound HTTP and crypto.subtle (Web Crypto) for hashing — a node:-prefixed import breaks the Cloudflare/Scaleway targets.
  • Skipping the input Schema. The defineServerless input is what turns a malformed request into a typed 400 before your handler runs. Don't hand-parse the body and trust it.
  • Returning a bare Effect.fail when you mean a specific status. That surfaces as a generic 500. Use ServerlessHttpError({ status, message }) so the caller gets 401 / 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-configured the caller can render. Don't swap that for a silent { ok: true }.
  • Treating the library like an api. A *.serverless.ts is standalone — bundled + deployed on its own with voltro serverless, never discovered by voltro dev. If you find yourself wanting queries, subscriptions, or a database next to it, you want an api template + a defineAction, not this.