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({ … }). inputdecodes the request (JSON body, or the query string for aGET).outputencodes 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-functionstemplate ships eight runnable*.serverless.tsexamples 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-contactwires a static page's island form to a serverless email function.voltro add-app fns --template edge-functionsdrops 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 Dockerfilebuild --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-runBuild 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 oldereffectfor 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.