Cells/Writing Cells

Writing Cells


JavaScript Cells

A JavaScript Cell is a module that exports a default object with a fetch handler.

Routing

Route on url.pathname:

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname === "/api/status") {
      return Response.json({ ok: true, ts: Date.now() });
    }

    if (request.method === "POST" && url.pathname === "/echo") {
      const body = await request.text();
      return new Response(body, { headers: { "content-type": "text/plain" } });
    }

    return new Response("Not found", { status: 404 });
  },
};

Reading request data

// URL and query params
const url = new URL(request.url);
const page = url.searchParams.get("page") ?? "0";

// JSON body
const data = await request.json();

// Form data
const form = await request.formData();
const name = form.get("name");

// Raw text
const text = await request.text();

// Headers
const auth = request.headers.get("authorization");

Outbound fetch

Cells have unrestricted outbound internet access via the standard fetch():

const res = await fetch("https://api.example.com/data");
const json = await res.json();

Fan out to multiple APIs in parallel:

const [weather, rates] = await Promise.all([
  fetch("https://api.open-meteo.com/v1/forecast?...").then(r => r.json()),
  fetch("https://api.exchangerate.host/latest").then(r => r.json()),
]);

Use AbortSignal.timeout() to enforce deadlines:

const res = await fetch("https://slow-api.example.com/data", {
  signal: AbortSignal.timeout(5000), // 5 seconds
});

Returning responses

// Plain text
return new Response("hello", { headers: { "content-type": "text/plain" } });

// JSON
return Response.json({ ok: true, items: [] });

// HTML
return new Response("<h1>Hello</h1>", {
  headers: { "content-type": "text/html; charset=utf-8" },
});

// Redirect
return Response.redirect("https://example.com", 302);

// Custom status and headers
return new Response("Created", {
  status: 201,
  headers: {
    "content-type": "text/plain",
    "x-request-id": crypto.randomUUID(),
  },
});

TypeScript

ribo.toml supports a build command. Bundle TypeScript with any tool that produces a single ESM file:

[cell]
name  = "my-cell"
js    = "./dist/cell.js"
build = "npx esbuild src/cell.ts --bundle --outfile=dist/cell.js --format=esm --platform=browser"

ribo runs the build command before uploading the output file.


Rust / WASM Cells

Compile a Rust library to WebAssembly and deploy it as a Cell. The full HTTP handler — routing, logic, response generation — lives in the WASM binary.

How it works

When a request arrives, the tissue runtime:

  1. Serialises the request to a JSON string: { method, url, headers, body }
  2. Calls your exported fetch(req_json: String) function
  3. Deserialises the returned { status, headers, body } into an HTTP response

Your Rust code never sees the JS runtime or the DOM — just plain Rust strings.

Cargo.toml

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
serde        = { version = "1", features = ["derive"] }
serde_json   = "1"

[profile.release]
opt-level = "z"
lto = true

src/lib.rs

use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct IncomingRequest {
    method:  String,
    url:     String,
    headers: std::collections::HashMap<String, String>,
    body:    Option<String>,
}

#[derive(Serialize)]
struct OutgoingResponse {
    status:  u16,
    headers: std::collections::HashMap<String, String>,
    body:    String,
}

#[wasm_bindgen]
pub fn fetch(req_json: String) -> JsValue {
    let req: IncomingRequest = serde_json::from_str(&req_json).unwrap();

    let resp = match (req.method.as_str(), req.url.as_str()) {
        ("GET", _) => OutgoingResponse {
            status:  200,
            headers: [("content-type".into(), "text/plain".into())].into(),
            body:    format!("Hello from Rust! URL: {}", req.url),
        },
        _ => OutgoingResponse {
            status:  405,
            headers: [("content-type".into(), "application/json".into())].into(),
            body:    r#"{"error":"method not allowed"}"#.into(),
        },
    };

    serde_wasm_bindgen::to_value(&resp).unwrap()
}

Build and deploy

# requires wasm-pack: https://rustwasm.github.io/wasm-pack/
wasm-pack build --target web --out-dir pkg

ribo.toml

[cell]
name         = "my-rust-cell"
wasm         = "./pkg/my_cell_bg.wasm"
build        = "wasm-pack build --target web --out-dir pkg"
bindgen_glue = "./pkg/my_cell.js"

The build command runs automatically before upload. bindgen_glue points to the JS file generated by wasm-pack — it bridges the JS runtime and the WASM binary.


Bindings in cell code

When a Cell has bindings declared in ribo.toml, they appear on the env object passed to fetch:

export default {
  async fetch(request, env) {
    // env.DB      — a c3 database
    // env.BUCKET  — a g7 object storage bucket
    // env.FILES   — a g7 static file tree
    // env.API_KEY — a plain text value
  },
};

See c3 API Reference and g7 API Reference for how to use these bindings.