Cloudflare adapter
How comet::cloudflare runs Rocket on Workers — dispatch, streaming, bindings, and WebSocket.
The comet::cloudflare module (feature cloudflare) is what actually
connects a Rocket application to the Cloudflare Workers runtime. This page
covers its API, the streaming mechanism, the typed binding guards, the R2
object responder, WebSocket support, and the full feature matrix.
Why a Rocket fork exists
Published Rocket depends on Hyper/Tokio for networking, which doesn't
compile for wasm32-unknown-unknown. Comet vendors a Rocket fork in
vendor/rocket (pinned to a specific upstream commit) with two patches:
rocket-worker-feature.patch— adds aworkerfeature that splits Rocket's core (routing, guards, responders, data body) from the server-only surface (Hyper listener, TLS, HTTP/2-3, Tokio'snet/fs/signal). This is what makes Rocket compile forwasm32-unknown-unknown. It also exposes external lifecycle hooks (Rocket<Build>::orbit_external(),Rocket<Orbit>::dispatch_external()) to run without opening a socket, and makes route handler futures local-boxed underworker, so routes can.await!SendJS futures directly.rocket-worker-streaming-request.patch— adds aRawStream::Workervariant and aData::from_stream()constructor, so a Worker request body can be streamed straight into Rocket'sDatatype instead of being buffered first.
The fork is vendored (checked into the repository) rather than pointing at
an external path — an earlier setup broke when the patched checkout lived
in /tmp and didn't survive a reboot.
Core API
All the types below live in comet::cloudflare.
fetch
The simplest entry point. Takes the worker::Request, Env, Context,
and a build_rocket: FnOnce(Env, Context) -> Rocket<Build> function:
#[event(fetch)]
pub async fn main(req: Request, env: Env, ctx: Context) -> Result<Response> {
comet::cloudflare::fetch(req, env, ctx, rocket).await
}Internally, fetch calls serve_cached, which ignites build_rocket() at
most once per Worker isolate and reuses the resulting Rocket<Orbit> for
every following request on the same isolate — routes, fairings, and
sentinels only run once per isolate lifetime.
FetchApp
A named wrapper (WorkerFetchApp<fn(Env, Context) -> Rocket<Build>>) around
the same builder-function shape, for apps that prefer to name the adapter
object explicitly instead of calling the free fetch function.
serve
Lower level: dispatches through anything implementing the Application
trait (fn dispatch(self, request: WorkerRequest) -> DispatchFuture).
Unlike fetch/serve_cached, it doesn't cache the ignited Rocket — it
re-ignites on every call. It's the primitive that impl Application for Rocket<Build> and serve_cached are built on.
impl Application for Rocket<Build>
Lets a plain Rocket<Build> (no worker::Env/Context needed) be
dispatched with a WorkerRequest/WorkerResponse pair — used heavily in
adapter-level tests, without needing a real Worker runtime.
local and local_stream
local(fut)wraps a!Sendfuture insend_wrapper::SendWrapper, so it type-checks asSendwhere required. Since the patched Rocket already local-boxes route futures underworker, ordinary handlers no longer need this — it remains for manual/compatibility cases.local_stream(stream)does the same forStreams, needed because Rocket's own streaming responders (ByteStream!/TextStream!) still requireS: Send:
#[get("/stream")]
pub fn stream_demo(
) -> rocket::response::stream::ByteStream<impl rocket::futures::stream::Stream<Item = Vec<u8>>> {
let raw = rocket::response::stream::stream! {
for chunk in 0..3u8 {
yield vec![b'0' + chunk; 4096];
worker::Delay::from(std::time::Duration::from_millis(400)).await;
}
};
rocket::response::stream::ByteStream(comet::cloudflare::local_stream(raw))
}Request/response streaming
Both the request and response bodies flow through Rocket without full buffering:
- Request → Rocket
Data:worker::Request::stream()is mapped toWorkerBody::Streamedand handed to Rocket viaData::from_stream()(the patch's new constructor). A body-less request (e.g.GET) falls back toWorkerBody::Buffered(vec![]). - Response → Worker stream: Rocket's response body implements
tokio::io::AsyncRead; the adapter reads it in 64KiB chunks viaasync_stream::try_stream!and hands it toworker::Response::builder().from_stream(...). - Small-body fast path: if the response size is known and ≤ 8 KiB (the
same threshold as Rocket's own
Limits::STRINGdefault), or the body is absent, the adapter reads it all at once and returnsWorkerBody::Buffered— skipping the streaming machinery for the common case of small API responses.
Concrete proof: examples/cloudflare-worker/tests/integration.sh posts
a 1 MiB body and checks a byte-for-byte round trip, and hits the /stream
route (3 chunks of 4096 bytes, separated by a real worker::Delay)
asserting that time-to-first-byte is a small fraction of total response
time — proving chunks are flushed as they're produced, not accumulated
until the handler finishes.
Typed guards for bindings
Every Cloudflare binding has a typed request guard, following the same
pattern: a marker type implementing BindingName (a NAME: &'static str
constant), a generic wrapper X<B: BindingName> that Derefs to the
matching worker type, and a FromRequest impl that pulls worker::Env
out of Rocket managed state (.manage(env)) and calls the right accessor
(env.d1, env.queue, env.kv, env.service, env.hyperdrive,
env.bucket).
pub struct DB;
impl BindingName for DB { const NAME: &'static str = "DB"; }
pub struct TaskEvents;
impl BindingName for TaskEvents { const NAME: &'static str = "TASK_EVENTS"; }
#[post("/tasks", data = "<new_task>")]
pub async fn create_task(
new_task: Json<NewTask>,
db: D1<DB>,
queue: QueueBinding<TaskEvents>,
) -> ApiResult<Json<Task>> {
let row = TaskRow::insert()
.set(TaskRow::TITLE, new_task.validated_title()?)
.to_statement()
.fetch_one_d1::<TaskRow>(&db)
.await?;
let task: Task = row.into();
queue.send(TaskEvent { task_id: task.id, kind: TaskEventKind::Created }).await?;
Ok(Json(task))
}The binding name ("DB", "TASK_EVENTS") needs to match the name
configured in wrangler.jsonc.
Available guards: D1<B>, QueueBinding<B>, Kv<B>, R2Bucket<B>,
ServiceBinding<B>, Hyperdrive<B>. All of them are Send + Sync — a
required property, since Rocket route inputs must be, even though the
underlying JS bindings are only ever touched by the Worker's single thread
(ServiceBinding and R2Bucket use unsafe impl Send/Sync, justified by
wasm32's single-threaded execution model).
R2Object: the R2 object responder
R2Object is a Rocket Responder that streams an R2 object's body instead
of buffering it, preserving R2's own HTTP metadata (ETag,
Content-Length, Accept-Ranges: bytes) and, for partial reads,
Content-Range with a 206 Partial Content status:
#[put("/assets/<key..>", data = "<body>")]
pub async fn put_asset(key: PathBuf, body: Capped<Vec<u8>>, bucket: R2Bucket<Assets>)
-> Result<Status, Status>
{
bucket.put(asset_key(key), body.value).execute().await
.map(|_| Status::Created)
.map_err(|_| Status::InternalServerError)
}
#[get("/assets/<key..>")]
pub async fn get_asset(key: PathBuf, bucket: R2Bucket<Assets>) -> Option<R2Object> {
R2Object::get(&bucket, asset_key(key)).await.ok().flatten()
}R2Object::get(bucket, key) does a plain GET (200); R2Object::get_range(bucket, key, range)
sets the status to 206. Parsing the incoming HTTP Range header is
not done automatically by the responder — the caller extracts the range
and passes a worker::Range to get_range().
WebSocket
Behind the cloudflare-websocket feature. The preferred API is a normal
Rocket route using WebSocketUpgrade as a guard (returns
Status::UpgradeRequired if the Upgrade: websocket header is missing) and
WebSocketResponse as the return type:
#[get("/ws/echo")]
pub async fn websocket_echo(ws: WebSocketUpgrade) -> WebSocketResponse {
ws.accept(|socket| async move {
let mut events = socket.events()?;
while let Some(event) = events.next().await {
match event? {
WebsocketEvent::Message(message) => {
if let Some(text) = message.text() {
socket.send_with_str(text)?;
} else if let Some(bytes) = message.bytes() {
socket.send_with_bytes(bytes)?;
}
}
WebsocketEvent::Close(_) => break,
}
}
Ok(())
})
}Under the hood: WebSocketResponse::respond_to stashes the handler closure
in a thread_local! and returns a sentinel 101 Switching Protocols
response. dispatch_on_orbit detects that sentinel, retrieves the handler,
creates a real worker::WebSocketPair, accepts the connection on the
server side, runs the handler via wasm_bindgen_futures::spawn_local, and
returns the real upgrade response. Lower-level escape hatches
(is_websocket_upgrade(), websocket_response()) remain available for
handling things manually outside of Rocket routing.
native-client and RocketWorker
The native-client feature (on by default) provides RocketWorker, which
dispatches WorkerRequest/WorkerResponse through Rocket's ordinary local
async client — no worker crate, no wasm target, in a pure native test
binary:
let app = rocket::build().mount("/", rocket::routes![index]);
let worker = RocketWorker::new(app).await?;
let response = worker.dispatch(WorkerRequest::get("/")).await?;
assert_eq!(response.status, 200);It's intentionally buffered-only (streamed bodies return
AdapterError::UnsupportedStreamedBody) and doesn't create a
worker::Env/bindings — great for testing pure route/status/JSON guard
logic. See the full testing strategy
for the three tiers: RocketWorker (native, cheapest) →
cloudflare::Application::dispatch (native, exercises the real external
dispatch path) → wrangler dev (full integration, the only tier that
exercises real bindings and WebSocket).
Feature matrix
| Feature | Enables |
|---|---|
native-client (default) | RocketWorker: dispatches via Rocket's local client, no worker runtime needed. |
cloudflare | The core of comet::cloudflare: fetch(), FetchApp, serve(), serve_cached(), Application for Rocket<Build>, local(), local_stream(). Requires worker. |
cloudflare-d1 | D1<B> guard (also enables worker/d1). |
cloudflare-queue | QueueBinding<B> guard (also enables worker/queue). |
cloudflare-kv | Kv<B> guard. |
cloudflare-r2 | R2Bucket<B> guard and the R2Object responder. |
cloudflare-service | ServiceBinding<B> guard. |
cloudflare-hyperdrive | Hyperdrive<B> guard. |
cloudflare-websocket | WebSocketUpgrade, WebSocketResponse, and low-level helpers. Keep it off for HTTP-only Workers. |
nebula | The Nebula ORM core (see Nebula ORM). |
nebula-d1 | Executing Nebula statements against D1; enables nebula, cloudflare-d1, serde. |
nebula-schema | A serializable mirror of Nebula's schema, used by comet-cli; enables nebula, serde; doesn't require cloudflare/worker. |
All cloudflare-* sub-features imply cloudflare (which in turn requires
dep:worker).
Known limitations
- No filesystem-backed responders:
FileServer,NamedFile, and disk-backedTempFileare not supported — Workers expose no durable local filesystem.R2Object/R2Bucketis the storage-backed replacement path, not a filesystem shim. Rangeparsing is manual:R2Object::get_range()requires the caller to build aworker::Range.cometisn't published to crates.io — see the matching section in Getting Started.RocketWorkerdoesn't test streaming or bindings — it always buffers and never creates a realworker::Env/bindings/WebSocket pair; that requires thecloudflare::Applicationlayer (still native) or full integration tests withwrangler dev.