Serverless functions

Run isolated, independently-scaled work as *.serverless.ts functions — self-hosted (Node) or offloaded to Cloudflare Workers / Scaleway.

A *.serverless.ts function is a self-contained unit run + deployed SEPARATELY from your api. It's written ONCE, Effect-first and schema-typed; the framework runs it on your own infra (node) or adapts it to an edge host's entrypoint (fetch(request, env, ctx) on Cloudflare Workers, handle(event) on Scaleway) — you never write platform boilerplate.

First: do you actually need one?

For an always-on api, reach for defineAction first. An action runs in-process and shares the rpc schema, session/tenant resolution, the DB handle, typed errors, CDC, tracing, and the WebSocket transport — all of which a serverless function THROWS AWAY (it gets only HttpClient + ctx.env: no DB, no session, no tracing). A serverless function is the right tool only when you specifically want one of:

  • process isolation — a heavy/risky dependency (native module, fat ML lib, untrusted code) you don't want in the api's memory or crash domain;
  • independent scaling — a spiky endpoint scaled (or scaled-to-zero) separately from the always-on api;
  • independent release cadence — deploy/restart that one unit without bouncing the api and its live WebSocket connections.

If none of those apply, write an action. The rest of this page assumes one does.

Writing a function

// api/functions/resizeImage.serverless.ts
import { Effect, Schema } from 'effect'
import { HttpClient } from '@effect/platform'
import { defineServerless } from '@voltro/serverless'

export default defineServerless({
  name: 'resize-image',                        // kebab-case, DNS-safe — the deployed id
  method: 'POST',                              // default POST; GET reads input from the query string
  input:  Schema.Struct({ url: Schema.String, width: Schema.Number }),
  output: Schema.Struct({ resized: Schema.String }),
  runtime: { memoryMb: 512, timeoutSeconds: 30, region: 'fr-par' }, // best-effort host hints
  handler: ({ url, width }, ctx) =>
    Effect.gen(function* () {
      const http = yield* HttpClient.HttpClient    // provided by the framework base layer
      // ctx.env.SOME_SECRET  → the host's env / secret bindings
      // ctx.waitUntil(p)     → background work that outlives the response (Cloudflare)
      return { resized: `${url}?w=${width}` }
    }),
})
  • One default export per file — a defineServerless({ … }).
  • input decodes the request (JSON body, or the query string for a GET). output encodes the JSON response. A malformed input returns 400.
  • The handler is Effect-first and may yield* HttpClient.HttpClient — the only service the base layer provides. A serverless function is standalone: it has no app database, plugins, or session. If your work needs those, it belongs in the api as a mutation/action, not here.
  • To control the HTTP status, fail with ServerlessHttpError({ status, message }); any other failure becomes a 500.

Start from a template. The edge-functions template ships eight runnable *.serverless.ts examples across the common shapes (pure compute, geo from request headers, outbound HTTP, Web Crypto HMAC, an LLM call, status-controlled errors). For the static-site-plus-form combo, frontend-contact wires a static page's island form to a serverless email function. voltro add-app fns --template edge-functions drops the library into any project.

Develop locally

Run one function on a real http port — same decode → run → encode the deployed function uses:

voltro serverless dev api/functions/resizeImage.serverless.ts --port 8910
# POST http://localhost:8910/  with a JSON body matching `input`

Environment comes from process.env (load a .env with node --env-file if you like). voltro serverless list shows every discovered function.

CORS works locally. voltro serverless dev / serve send Access-Control-Allow-Origin and answer the OPTIONS preflight — the same headers Cloudflare / Scaleway add at the edge. So a browser form on a static dev site (localhost:5190) can POST to the function (localhost:8910) cross-origin out of the box. The default is permissive (*); lock it down via the cors option on serveServerless when you self-host.

Self-hosted (default) — run it on your own infra

The default target is node — no vendor, no cloud. Two shapes:

# Run ALL functions in ONE process — the cheap "one sidecar" case.
# Drop it behind your baseline's reverse proxy (Caddy/nginx) next to the api.
voltro serverless serve --port 8910 --host 0.0.0.0
#   ▸ each function mounts at its `path` (or /<name> when several share /)
#   ▸ GET /internal/liveness + /internal/readiness probes, like `voltro start`

# OR build a standalone per-function kit (process isolation / independent scaling):
voltro serverless build  --target node      # → index.mjs + Dockerfile + README (a complete kit)
#   then: node index.mjs   (PORT from env)   — or `docker build` the emitted Dockerfile

build --target node produces a COMPLETE, self-hostable folder: the bundled index.mjs server plus a Dockerfile and a README with the exact run / docker / compose / reverse-proxy commands — drop it on any box with Node, or any container platform. (deploy --target node is the same, with the run plan printed.)

serve is the everyday self-hosted runner; build --target node is the per-function kit you scale on its own. Either way the function runs the SAME serverlessWebHandler it would on the edge.

Edge offload (optional) — Cloudflare / Scaleway

To save money on spiky or globally-distributed work, push a function to an edge host instead. The framework owns the bundle (esbuild) and the platform entry; the host's official CLI does only the upload, so those CLIs must be on PATH + authenticated.

# Cloudflare Workers — needs `wrangler` + CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID
voltro serverless deploy --target cloudflare
voltro serverless deploy --target cloudflare --node-compat   # add nodejs_compat for node:* builtins

# Scaleway Functions — needs `scw` + SCW_ACCESS_KEY + SCW_SECRET_KEY + a namespace
voltro serverless deploy --target scaleway --namespace-id <ns-id> --runtime node22

# Inspect the plan without uploading:
voltro serverless deploy --target cloudflare --dry-run

Build output lands in .voltro/serverless/<target>/<name>/ — add .voltro/ to your .gitignore.

How the three targets differ

Aspect Node (self-hosted) Cloudflare Workers Scaleway Functions
Where it runs your own infra V8 isolate (web-standard, not Node) Scaleway's Node.js (node20 / node22)
node:* builtins native only with --node-compat native
Env / secrets process.env the env binding process.env (scw args)
ctx.waitUntil best-effort real (after the response) best-effort
Scaling you (compose replicas / helm) isolate, near-zero cold start scale-to-zero, real cold starts (min-scale ≥ 1 keeps warm)
Runs the whole set serve (one process) one Worker per function one function per namespace entry

Your handler code is identical across all three — only the target changes.

Notes

  • Effect on the edge: the framework pins effect ≥ 3.20.0, which carries the fix for cross-request context isolation under concurrency. Don't pin an older effect for a function you deploy to Workers.
  • Browser/edge safety: a Worker bundle must not pull node:* unless you pass --node-compat. Keep function dependencies edge-safe, or deploy to Scaleway.
  • This is distinct from scale-to-zero, which idles your WHOLE api on a single node. Serverless functions split INDIVIDUAL units off to a per-invocation host.