DocsDevelopersBackend Development

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:

  1. index.ts
  2. index.js
  3. main.ts
  4. main.js
  5. src/index.ts
  6. src/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.