Backend Development
Backends are isolated Bun processes supervised by the Core. You define handlers via the serve() function. The Core handles process lifecycle, IPC, crash recovery, and job dispatch.
The serve() API
Every v2 worker calls serve() with a handler object:
serve({
rpc: {
greet: async (params, caller, ctx) => {
return { message: `Hello, ${params.name}!` };
},
pipeline: async (params, caller, ctx) => {
const deals = await ctx.collection("deals").find({});
return { total: deals.length };
},
},
onStart: (ctx) => {
ctx.log.info(`Worker started for ${ctx.appId}`);
},
onJob: async (payload, caller, ctx) => {
if (payload.type === "send_report") {
return { sent: true };
}
},
onShutdown: () => {
// cleanup before exit
},
});
If called with a flat object (serve({ greet: fn, pipeline: fn })), the prelude wraps it as { rpc: handlers } for backwards compatibility.
RPC handlers
Each key in rpc becomes a method callable via POST /api/v1/apps/{appId}/rpc:
rpc: {
methodName: async (params, caller, ctx) => {
// params: the request payload (from "params" field in RPC body)
// caller: { userId, email, authToken } or null for anonymous calls
// ctx: worker context object (see below)
return { result: "value" };
},
}
- Return a value: sent as the JSON response to the caller.
- Throw an error: sent as
{ "error": "message" }with HTTP 500. - Timeout: 30 seconds. For long-running work, enqueue a background job.
Caller object
{
userId: string; // UUID of the authenticated user
email: string; // User email
authToken: string; // JWT bearer token (use for Core API calls)
}
For anonymous/public RPC calls, caller is null.
Lifecycle hooks
| Hook | Signature | When it fires |
|---|---|---|
onStart |
(ctx) => void |
Once, after the IPC handshake completes. Use for initialization. |
onJob |
(payload, caller, ctx) => any |
A background job or scheduled cron fires. Return a result or throw. |
onShutdown |
() => void |
Process is about to exit (redeploy, stop). No ctx available. |
The ctx object
Every handler receives ctx as its last argument. It provides access to the full worker capability surface:
| Property/Method | Description |
|---|---|
ctx.appId |
The application ID. |
ctx.runtimeUrl |
Core API base URL (e.g., http://127.0.0.1:9100). |
ctx.databaseUrl |
PostgreSQL connection string for direct DB access. |
ctx.credentials |
Decrypted secrets (platform + app) as a key-value object. |
ctx.log.info(msg) |
Log at info level. Broadcasts via SSE. |
ctx.log.warn(msg) |
Log at warn level. |
ctx.log.error(msg) |
Log at error level. |
ctx.emit(name, data) |
Emit a named event. |
ctx.uploadFile(content, filename, contentType?) |
Upload a file to storage. Returns file ID. |
ctx.collection(entity) |
Access an entity's collection (see below). |
Collection operations
ctx.collection(entity) provides direct IPC access to your entities without HTTP round-trips:
// Insert a record
const record = await ctx.collection("contacts").insert({
first_name: "Alice",
email: "alice@example.com",
});
// Update a record (must include id)
await ctx.collection("contacts").update({
id: record.id,
first_name: "Alice B.",
});
// Find records (where clause is equality map)
const leads = await ctx.collection("contacts").find({ stage: "lead" });
// Find one record
const contact = await ctx.collection("contacts").findOne({ email: "alice@example.com" });
The find and findOne where clause is a simple equality map: { field: value }. For complex queries, use the Core's REST API via fetch.
Error handling
Throw to return an error. The Core translates it to HTTP 500 with { "error": "message" }:
rpc: {
closeDeal: async (params, caller, ctx) => {
if (!caller) throw new Error("Authentication required");
if (!params.dealId) throw new Error("Missing dealId");
// ...
},
}
Both synchronous and asynchronous errors are caught. The worker does not crash on unhandled errors in handlers.
Environment variables
Two environment variables are injected by the Core into the worker process:
| Variable | Description |
|---|---|
ROOTCX_APP_ID |
The application identifier. |
ROOTCX_RUNTIME_URL |
Core API base URL. |
These are also available on ctx.appId and ctx.runtimeUrl. The ctx values are preferred as they come from the IPC handshake.
Console interception
The prelude intercepts standard console methods:
| Console method | Routed to | Level |
|---|---|---|
console.log() |
ctx.log.info() |
info |
console.warn() |
ctx.log.warn() |
warn |
console.error() |
ctx.log.error() |
error |
console.debug() |
ctx.log.info() |
info |
All log messages are broadcast via the Real-time Logs SSE endpoint.
Background jobs
Enqueue a job from any handler:
rpc: {
startExport: async (params, caller, ctx) => {
await fetch(`${ctx.runtimeUrl}/api/v1/apps/${ctx.appId}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payload: { type: "export", userId: caller.userId } }),
});
return { queued: true };
},
}
Process jobs in onJob:
onJob: async (payload, caller, ctx) => {
if (payload.type === "export") {
const data = await ctx.collection("reports").find({});
await ctx.uploadFile(JSON.stringify(data), "export.json", "application/json");
return { exported: data.length };
}
},
Return a value from onJob to mark as completed (archived). Throw to mark as failed (deleted). See Job Queue for full details.
Process lifecycle
The Core supervises your worker with automatic crash recovery:
| Property | Value |
|---|---|
| Runtime | Bun |
| Crash recovery | Exponential backoff: 0s, 0s, 2s, 4s, 8s, 16s... |
| Crash loop detection | 5 crashes within 60 seconds marks worker as crashed |
| Worker states | starting, running, stopping, stopped, crashed |
| RPC timeout | 30 seconds |
Worker management
| Action | Endpoint |
|---|---|
| Start | POST /api/v1/apps/{appId}/worker/start |
| Stop | POST /api/v1/apps/{appId}/worker/stop |
| Status | GET /api/v1/apps/{appId}/worker/status |
Entry point resolution
The Core looks for the worker entry point in this order:
index.tsindex.jsmain.tsmain.jssrc/index.tssrc/index.js
Deploy
cd backend && tar -czf ../backend.tar.gz . && cd ..
curl -X POST https://<your-ref>.rootcx.com/api/v1/apps/{appId}/deploy \
-H "Authorization: Bearer $TOKEN" \
-F "archive=@backend.tar.gz"
The Core extracts the archive, runs bun install if package.json is present, and (re)starts the worker.
Or use the CLI: rootcx deploy handles everything automatically.