Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Serving with celld and Cloudflare Workers

A reflectapi API can run in a Cloudflare-compatible Worker, on Cloudflare Workers or on celld, written in Rust with workers-rs and compiled to WebAssembly. The Worker runtime is the HTTP server, so axum is not used: the reflectapi::dispatch::Dispatcher routes requests, and the adapter on this page connects it to workers-rs.

Dependencies

[lib]
crate-type = ["cdylib", "rlib"] # rlib: the native build step below links the crate

[dependencies]
reflectapi = { version = "...", default-features = false, features = ["builder", "json"] }
worker = "0.8"
http = "1"
futures-util = "0.3"

# Generating the schema and clients happens natively, at build time.
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
reflectapi = { version = "...", features = ["builder", "json", "codegen"] }

[profile.release]
lto = true
codegen-units = 1
# Not `strip = true` (the same as "symbols"): wasm-bindgen then fails with
# "externref table required for catch wrappers". `strip = "debuginfo"` is fine.

Build with worker-build --release, as for any workers-rs Worker.

Define the API

Write the definition once, generic over the build mode: the Worker builds it with DispatchOnly, so no schema is built or linked into the wasm, and the build step builds it with WithSchema.

pub fn api<M: reflectapi::BuildMode>() -> reflectapi::Builder<Ctx, M> {
    reflectapi::Builder::default()
        .name("Routing agent")
        .stream_route(prompt, |b| b.name("agent.prompt"))
}

Handlers, their futures and their streams must be Send, but values backed by JavaScript objects (storage, bindings, fetch responses) are not. A Worker runs on one thread, so marking them Send is sound: wrap state in worker::send::SendWrapper, futures in worker::send::SendFuture, and streams in the SendStream from the adapter below.

pub type Ctx = worker::send::SendWrapper<std::rc::Rc<SessionState>>;

fn prompt(
    ctx: Ctx,
    request: PromptRequest,
    headers: SessionHeaders,
) -> Result<SendStream<impl Stream<Item = AgentEvent>>, PromptError> {
    let session = ctx.0.clone();
    Ok(SendStream::new(async_stream::stream! {
        // read and write `session`, await fetches and timers, yield events
    }))
}

The adapter

Copy this module into your Worker crate.

//! Serves a reflectapi `Dispatcher` from a Cloudflare-compatible Worker or Durable Object.

use std::pin::Pin;
use std::task::{Context, Poll};

use futures_util::{Stream, StreamExt};
use reflectapi::dispatch::{sse_event, Dispatcher, Reply};
use worker::{send::SendWrapper, Error, Headers, Request, Response, Result};

/// Dispatches one Worker request and converts the reply to a Worker response.
pub async fn serve<S>(dispatcher: &Dispatcher<S>, state: S, mut req: Request) -> Result<Response>
where
    S: Send + Clone + 'static,
{
    let method = http::Method::from_bytes(req.method().as_ref().as_bytes())
        .map_err(|err| Error::RustError(err.to_string()))?;
    let mut headers = http::HeaderMap::new();
    for (name, value) in req.headers().entries() {
        let name = http::HeaderName::from_bytes(name.as_bytes())
            .map_err(|err| Error::RustError(err.to_string()))?;
        let value =
            http::HeaderValue::from_str(&value).map_err(|err| Error::RustError(err.to_string()))?;
        headers.append(name, value);
    }
    let body = req.bytes().await?;

    match dispatcher
        .dispatch(state, &method, &req.path(), &headers, body.into())
        .await
    {
        None => Response::error("Not Found", 404),
        Some(Reply::Complete {
            status,
            headers,
            body,
        }) => Ok(Response::from_bytes(body.to_vec())?
            .with_status(status.as_u16())
            .with_headers(worker_headers(&headers)?)),
        Some(Reply::Stream(items)) => {
            let events = items.map(|item| {
                item.map(|event| sse_event(&event).into_bytes())
                    .map_err(Error::RustError)
            });
            let headers = Headers::new();
            headers.set("content-type", "text/event-stream")?;
            headers.set("cache-control", "no-cache")?;
            Ok(Response::from_stream(events)?.with_headers(headers))
        }
    }
}

fn worker_headers(headers: &http::HeaderMap) -> Result<Headers> {
    let out = Headers::new();
    for (name, value) in headers {
        let value = value
            .to_str()
            .map_err(|err| Error::RustError(err.to_string()))?;
        out.append(name.as_str(), value)?;
    }
    Ok(out)
}

/// Marks a stream `Send` for reflectapi, like `worker::send::SendWrapper` does for
/// values. Sound in a Worker, which runs on one thread.
pub struct SendStream<St>(SendWrapper<Pin<Box<St>>>);

impl<St> SendStream<St> {
    pub fn new(stream: St) -> Self {
        Self(SendWrapper::new(Box::pin(stream)))
    }
}

impl<St: Stream> Stream for SendStream<St> {
    type Item = St::Item;

    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<St::Item>> {
        self.get_mut().0.as_mut().poll_next(cx)
    }
}

Serve from a Worker or a Durable Object

A stateless API serves from the Worker's fetch handler. An API with state per entity (a session, a job) serves from a Durable Object, and the Worker forwards each request to the object it is for. Declare the routing key as a request header, so the schema and the generated clients carry it:

#[derive(serde::Deserialize, reflectapi::Input)]
pub struct SessionHeaders {
    #[serde(rename = "x-session-id")]
    pub session_id: String,
}

#[event(fetch)]
async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
    match req.headers().get("x-session-id")? {
        Some(id) if !id.is_empty() => {
            env.durable_object("SESSION")?.get_by_name(&id)?.fetch_with_request(req).await
        }
        _ => Response::error("missing x-session-id", 400),
    }
}

#[durable_object]
pub struct Session {
    ctx: Ctx,
    dispatcher: std::result::Result<Dispatcher<Ctx>, String>,
}

impl DurableObject for Session {
    fn new(state: State, _env: Env) -> Self {
        Self {
            ctx: SendWrapper::new(Rc::new(SessionState::new(state))),
            dispatcher: api::<reflectapi::DispatchOnly>()
                .build()
                .map(Dispatcher::new)
                .map_err(|errors| errors.to_string()),
        }
    }

    async fn fetch(&self, req: Request) -> Result<Response> {
        match &self.dispatcher {
            Ok(dispatcher) => reflectapi_worker::serve(dispatcher, self.ctx.clone(), req).await,
            Err(errors) => Response::error(format!("invalid API definition: {errors}"), 500),
        }
    }
}

The Dispatcher holds no request state: the state is passed to each dispatch call. A Durable Object builds it when it starts; to build it once for every object in an isolate instead, keep it in a thread_local!.

Generate the schema and clients at build time

The crate also builds natively, so a native test (or binary) can build the API WithSchema and write the schema and the clients:

#[cfg(all(test, not(target_arch = "wasm32")))]
#[test]
fn generate_clients() -> Result<(), Box<dyn std::error::Error>> {
    let (schema, _) = api::<reflectapi::WithSchema>().build()?;
    std::fs::write("clients/reflectapi.json", serde_json::to_string_pretty(&schema)?)?;
    let rust = reflectapi::codegen::rust::generate(schema.clone(), &Default::default())?;
    std::fs::write("clients/rust/src/generated.rs", rust)?;
    for (file, content) in reflectapi::codegen::typescript::generate(schema, &Default::default())? {
        std::fs::write(format!("clients/typescript/{file}"), content)?;
    }
    Ok(())
}

Commit the output, and have CI regenerate it and fail on a difference, so an API change cannot merge without its regenerated clients. The default codegen configurations run no formatter, so the output is the same on every machine; enabling formatting makes it depend on the formatter version installed.