~/docs / mercio
Mercio
Serverless functions for the Mercy platform. Upload a zip of Node.js or TypeScript code and get back a public URL that executes your handler on every HTTP request inside an isolated workerd V8 process.
Overview
Mercio is the serverless execution layer of Mercy. Each deployed function:
- ›Runs inside a dedicated workerd V8 isolate — fully separated from other functions, no shared globals or memory.
- ›Is bundled at upload time by esbuild, so you can use npm packages and TypeScript.
- ›Keeps a warm process in a pool so subsequent requests to the same function are served in single-digit milliseconds after the first.
- ›Supports any HTTP method. The same URL handles GET, POST, PATCH, DELETE, etc.
- ›Works with both a simple custom handler contract and the Hono web framework.
How it works
- 01
Upload
POST /api/mercio/upload with a zip file and entry point. The API stores the zip in memory and pushes a mercio-builds job onto the BullMQ queue.
- 02
Bundle
The Worker service picks up the build job, extracts the zip, and runs esbuild to produce a single worker.mjs bundle. The bundle is wrapped in a Cloudflare Workers-compatible shim (export default { async fetch(...) }) so workerd can execute it. The bundle is uploaded to R2 at mercio/<id>/worker.mjs.
- 03
Invoke
When a request arrives at /mercio/:id, the API enqueues a mercio-invocations job with the full request context (method, path, query, headers, body) and waits for the result using BullMQ's waitUntilFinished.
- 04
Execute
mercio-runtime dequeues the job. workerdPool.ensure(id) returns a warm workerd process (cold-starting from R2 if needed). The runtime forwards the request to the workerd process via loopback HTTP and collects the response.
- 05
Respond
The result (status, headers, body) is returned as the BullMQ job value. The API's waitUntilFinished resolves and the original HTTP caller receives the response.
Quickstart
1. Write your handler
module.exports = async (req) => {
return {
status: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ hello: req.query.name ?? 'world' }),
}
}2. Zip and upload
# create the zip
zip hello-world.zip index.js
# authenticate
TOKEN=$(curl -s -X POST https://api.yourdomain.com/auth/login \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"yourpassword"}' \
| jq -r .token)
# upload
curl -X POST https://api.yourdomain.com/api/mercio/upload \
-H "Authorization: Bearer $TOKEN" \
-F "zip=@hello-world.zip" \
-F "name=hello-world" \
-F "entry=index.js"Response contains id, status (QUEUED), and invokeUrl.
3. Poll until deployed
ID="<function-id>"
curl -s -H "Authorization: Bearer $TOKEN" \
https://api.yourdomain.com/api/mercio/$ID | jq .function.status
# "QUEUED" → "BUILDING" → "DEPLOYED"4. Invoke
curl "https://api.yourdomain.com/mercio/$ID?name=mercy"
# {"hello":"mercy"}Handler contract
Your entry file must export (or module.exports) an async function that receives a request object and returns a response object.
Request (req)
| Field | Type | Description |
|---|---|---|
method | string | HTTP method — "GET", "POST", etc. |
url | string | Full request URL |
path | string | Path component only |
headers | Record<string, string> | Request headers |
query | Record<string, string> | Parsed query string parameters |
body | string | object | null | Parsed JSON, raw text, or null for GET/HEAD |
Return value
| Field | Type | Description |
|---|---|---|
status | number | HTTP status code. Defaults to 200. |
headers | Record<string, string> | Response headers. |
body | string | Response body as a string — call JSON.stringify() yourself. |
Hono support (recommended)
The Hono web framework works natively in workerd and is the recommended way to build Mercio functions with routing, middleware, and typed responses.
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.json({ ok: true }))
app.get('/hello/:name', (c) =>
c.json({ message: `Hello, ${c.req.param('name')}!` })
)
app.post('/data', async (c) => {
const body = await c.req.json()
return c.json({ received: body })
})
export default appRules when using Hono:
- ›Set "type": "module" in package.json and add hono as a dependency.
- ›Run npm install locally before zipping — esbuild resolves Hono from your local node_modules.
- ›Use entry=index.ts (or .js) when uploading.
- ›All Hono features work: routing, middleware, path params, c.json(), c.text(), etc.
Zip and upload a Hono function
cd my-hono-app
npm install
zip -r ../my-hono-app.zip .
curl -X POST https://api.yourdomain.com/api/mercio/upload \
-H "Authorization: Bearer $TOKEN" \
-F "zip=@../my-hono-app.zip" \
-F "name=my-hono-app" \
-F "entry=index.ts"Configuration
Set these in apps/mercio-runtime/.env.
| Variable | Default | Description |
|---|---|---|
REDIS_HOST | localhost | Redis host for BullMQ |
REDIS_PORT | 6379 | Redis port |
R2_ENDPOINT | — | Cloudflare R2 S3-compatible endpoint URL |
R2_ACCESS_KEY_ID | — | R2 access key |
R2_SECRET_ACCESS_KEY | — | R2 secret key |
R2_BUCKET_NAME | — | R2 bucket name |
WORKERD_BIN | auto | Absolute path to the workerd binary (auto-resolved from npm package) |
MERCIO_CACHE_DIR | /var/mercio | Directory for worker.mjs cache and capnp configs |
MERCIO_POOL_MAX | 20 | Maximum simultaneous warm workerd processes |
MERCIO_IDLE_TTL_MS | 300000 | Kill a process idle longer than this (5 min default) |
Limits & constraints
- ›Zip upload size: 50 MB maximum.
- ›No filesystem writes at runtime — workerd V8 isolates have no disk access. Read-only use of bundled assets is fine.
- ›State is in-memory and per-process. Do not rely on state surviving a cold start, a pool eviction, or a process crash.
- ›Pool max: 20 warm processes by default (MERCIO_POOL_MAX). Least-recently-used processes are evicted when the pool is full.
- ›Idle processes are killed after 5 minutes of inactivity (MERCIO_IDLE_TTL_MS).
- ›Concurrency: 8 invocation jobs execute simultaneously inside mercio-runtime (BullMQ worker concurrency).