Skip to main content

cratefield_runtime_cloudflare/
lib.rs

1//! `cratefield-runtime-cloudflare` runs a Factory Zero [`Harness`] on
2//! Cloudflare Workers (ADR 0001, 0002). It maps bindings to ports:
3//! D1 -> `Database`, KV -> `KeyValue`, the Rate Limiting binding ->
4//! `RateLimiter`, `HARNESS_SECRET` -> `Signer`, `Context::wait_until` ->
5//! `Defer`, `worker::Fetch` -> `HttpClient`.
6//!
7//! A venture's Worker is three lines:
8//!
9//! ```ignore
10//! #[event(fetch)]
11//! pub async fn fetch(req: HttpRequest, env: Env, ctx: Context)
12//!     -> Result<http::Response<axum::body::Body>> {
13//!     let (harness, runtime) = INSTANCE.get_or_init(build);
14//!     serve(harness, runtime, req, env, ctx).await
15//! }
16//! ```
17//!
18//! Every port adapter here holds JS handles that workers-rs already marks
19//! `Send + Sync` (`unsafe impl` inside the `worker` crate, sound because a
20//! Workers isolate is single-threaded — ADR 0002). This crate itself
21//! contains no `unsafe`.
22
23#![doc = include_str!("../README.md")]
24#![forbid(unsafe_code)]
25
26mod body_limit;
27mod config;
28mod ports;
29mod runtime;
30mod tracing_setup;
31
32pub use config::EnvConfig;
33pub use ports::{
34    ContextDefer, D1Database, D1RateLimiter, FetchClient, KvStorePort, Limit,
35    RATE_LIMIT_COUNTERS_SQL, RateLimitPolicy, RateLimitPort, RoomDriver, ScheduleDefer,
36    WorkersClock, client_ip,
37};
38pub use runtime::Cloudflare;
39pub use tracing_setup::install_tracing;
40
41use crate::body_limit::{BodyPlan, Capped, body_plan, read_capped};
42use axum::response::IntoResponse;
43use cratefield_core::{Harness, Problem, RequestSummary, ScheduledLimits, ScheduledSplit};
44use std::sync::Arc;
45use time::OffsetDateTime;
46use tower::ServiceExt;
47use worker::{Context, Env, Request as WorkerRequest, Response as WorkerResponse};
48
49/// Runs every module's [`Module::validate_config`] against the live config
50/// once per isolate and logs any failure to `console_error!`. `validate_config`
51/// cannot run at build or in `fz doctor` (neither has the deploy config; it
52/// lives on the `Env`), so cold start is the first place it can, and this
53/// makes a misconfigured deployment loud in Workers Logs (issue #101). It only
54/// logs — a bad module still degrades per request rather than failing the whole
55/// Worker's boot; that harder behaviour is a decision for an ADR.
56fn check_module_config_once(harness: &Harness, config: &dyn cratefield_core::Config) {
57    use std::sync::atomic::{AtomicBool, Ordering};
58    static CHECKED: AtomicBool = AtomicBool::new(false);
59    if CHECKED.swap(true, Ordering::Relaxed) {
60        return;
61    }
62    for module in harness.modules() {
63        if let Err(err) = module.validate_config(config) {
64            worker::console_error!(
65                "[config] module `{}` has invalid configuration: {err}",
66                module.name()
67            );
68        }
69    }
70}
71
72/// Logs the push wiring report once per isolate, next to
73/// [`check_module_config_once`] (issue #191). Names and verdicts only —
74/// [`PushWiring::summary`](cratefield_push_wiring::PushWiring::summary) never
75/// carries a value, so no secret can reach Workers Logs through it.
76///
77/// A half-wired transport is `console_error!` in production and
78/// `console_log!` below it: a venture that meant to enable FCM and mistyped
79/// one variable must not boot into a state where every Android send silently
80/// answers `NotConfigured`, while a developer wiring a transport one variable
81/// at a time must still be able to boot. Like the module-config check this
82/// only logs; refusing to serve is a decision for an ADR, and `fz doctor` is
83/// what refuses a deploy.
84///
85/// Nothing is logged when the venture passed its own adapter: `push_wiring`
86/// answers `None`, so no environment is read and no transport the venture
87/// deliberately overrode is reported on. It runs after `ports()`, which has
88/// already assembled and memoised the adapters, so this reads a report
89/// rather than building one.
90#[cfg(feature = "push")]
91fn check_push_wiring_once(
92    harness: &Harness,
93    runtime: &Cloudflare,
94    env: &Env,
95    config: &dyn cratefield_core::Config,
96) {
97    use cratefield_push_wiring::WiringSeverity;
98    use std::sync::atomic::{AtomicBool, Ordering};
99    static CHECKED: AtomicBool = AtomicBool::new(false);
100    if CHECKED.swap(true, Ordering::Relaxed) {
101        return;
102    }
103    let Some(wiring) = runtime.push_wiring(env) else {
104        return;
105    };
106    worker::console_log!("[push] {}", wiring.summary());
107    let deployed = cratefield_core::deployed_env(harness.venture().env, config);
108    match wiring.severity(deployed) {
109        WiringSeverity::Ok => {}
110        WiringSeverity::Warning => {
111            for problem in wiring.problems() {
112                worker::console_log!("[push] warning: {problem}");
113            }
114        }
115        WiringSeverity::Error => {
116            for problem in wiring.problems() {
117                worker::console_error!("[push] {problem}");
118            }
119        }
120    }
121}
122
123/// Serves one fetch event: resolves ports from the bindings, builds the
124/// router, refuses an oversize body before it is resident, hands the
125/// request over, and converts the response (issue #440).
126///
127/// Takes the native `worker::Request` (the fetch macro's `FromRequest`
128/// accepts it): `Request::bytes()` is the only body read that reliably
129/// resolves under workerd/miniflare — streaming a `worker::Body` through
130/// the axum bridge or re-wrapping it into `web_sys::Request` hangs the
131/// isolate (verified empirically). `bytes()` therefore remains the read for
132/// every body that declares a `content-length` within the ceiling.
133///
134/// Before any byte is read, the request's per-module body ceiling is looked
135/// up from the path ([`Harness::max_body_bytes`]) — the coarse value a
136/// module may raise (LinkedIn's image upload), never lower. A declared
137/// `content-length` above it is answered `413` (`request-too-large`)
138/// without reading the body: an isolate has a fixed memory ceiling, and the
139/// router's `DefaultBodyLimit` only fires once the body is already
140/// resident, which is too late for a Worker that has already died. A body
141/// with no usable `content-length` — chunked, streamed, or a header that
142/// does not parse — is read through `Request::stream()` (a worker-native
143/// stream, not the hanging axum bridge) and aborted the moment it passes
144/// the ceiling, so nothing larger than the ceiling is ever held. A request
145/// with no body at all — most GETs, HEAD, OPTIONS — reaches the router as
146/// an empty buffer without any read: worker 0.8.5's `Request::stream()`
147/// sets `body_used` before discovering there is no body, so the bodyless
148/// case is detected through `inner()` first (issue #440). Inside the
149/// router, `DefaultBodyLimit` stays the precise per-route enforcer.
150///
151/// # Errors
152///
153/// `worker::Error` on conversion/transport failures; problem+json
154/// responses are ordinary 4xx/5xx Worker responses.
155pub async fn serve(
156    harness: &Harness,
157    runtime: &Cloudflare,
158    mut req: WorkerRequest,
159    env: Env,
160    ctx: Context,
161) -> worker::Result<WorkerResponse> {
162    install_tracing();
163    let ports = runtime.ports(&env, Arc::new(ContextDefer(ctx)));
164    check_module_config_once(harness, ports.config.as_ref());
165    #[cfg(feature = "push")]
166    check_push_wiring_once(harness, runtime, &env, ports.config.as_ref());
167    // `ports` moves into `router()` below, and the ceiling lookup reads the
168    // same config the module contexts were built with — clone the `Arc`
169    // out first.
170    let config = Arc::clone(&ports.config);
171    let router = harness.router(ports);
172    let url = req.url()?;
173    let limit = harness.max_body_bytes(url.path(), config.as_ref());
174
175    // Copied out so the header borrow ends before the body is read mutably.
176    // `worker::Headers::get` already yields `Option<String>`; a lookup
177    // failure is treated as no declaration at all, which earns the capped
178    // stream rather than trust.
179    let declared = req.headers().get("content-length").ok().flatten();
180
181    let bytes = match body_plan(declared.as_deref(), limit) {
182        BodyPlan::Refuse => {
183            // The tail left unread here trips `wrangler dev`'s drain
184            // middleware — a dev-only artefact, recorded in this README's
185            // wasm notes.
186            return response_to_worker(Problem::request_too_large().into_response()).await;
187        }
188        BodyPlan::Buffer => req.bytes().await?,
189        BodyPlan::Stream => {
190            // A bodyless request (most GETs, HEAD, OPTIONS) must reach the
191            // router as an empty body, and worker 0.8.5 makes that a
192            // non-obvious requirement: `Request::stream()` sets
193            // `body_used = true` *before* it looks for a body
194            // (request.rs:199-204), so a null-body request answers
195            // `Err("no body for request")` having already poisoned the
196            // request — and `bytes()` afterwards can only answer
197            // `Error::BodyUsed` (request.rs:158-173). The old
198            // `Err(_) => req.bytes()` fallback therefore failed every
199            // bodyless request out of `serve()` — `/__health`, `/__ready`,
200            // `/ui/*`, `/.well-known/jwks.json` (issue #440). Probe
201            // non-destructively through `inner()`, the raw
202            // `web_sys::Request`, whose `body()` answers `None` exactly
203            // where `stream()` would fail: with no body, hand the router
204            // the empty buffer the Fetch spec gives a null body and never
205            // touch `stream()` at all.
206            match req.inner().body() {
207                None => Vec::new(),
208                Some(_) => match req.stream() {
209                    Ok(stream) => match read_capped(stream, limit).await {
210                        Ok(Capped::Within(bytes)) => bytes,
211                        Ok(Capped::TooLarge) => {
212                            return response_to_worker(
213                                Problem::request_too_large().into_response(),
214                            )
215                            .await;
216                        }
217                        Err(err) => return Err(err),
218                    },
219                    // Belt and braces: past the probe, `stream()`'s only
220                    // remaining failure mode is the `body_used` poison, and
221                    // `bytes()` reads that same flag — so no read can
222                    // succeed here. The arm answers empty, not silently:
223                    // it is unreachable while the probe and `stream()` read
224                    // the same `body()` getter (a body the probe saw cannot
225                    // disappear), so a genuine transport error is not being
226                    // swallowed, and a 500 on a body the getter had just
227                    // reported would be strictly worse.
228                    Err(_) => Vec::new(),
229                },
230            }
231        }
232    };
233
234    let method = http::Method::from_bytes(req.method().to_string().as_bytes())
235        .map_err(|err| worker::Error::RustError(err.to_string()))?;
236    let mut builder = http::Request::builder().method(method).uri(url.to_string());
237    {
238        let headers = req.headers();
239        for (name, value) in headers {
240            builder = builder.header(name.as_str(), value.as_str());
241        }
242    }
243    let buffered = builder
244        .body(axum::body::Body::from(bytes))
245        .map_err(|err| worker::Error::RustError(err.to_string()))?;
246
247    let response = router
248        .oneshot(buffered)
249        .await
250        .map_err(|err| worker::Error::RustError(err.to_string()))?;
251    if let Some(line) = response.extensions().get().and_then(summary_line) {
252        // The request span is inert on Workers (no dispatcher, see
253        // `tracing_setup`), so its fields reach Workers Logs as one JSON
254        // line instead — indexed as fields, and matched by
255        // `wrangler tail --search <request-id>`.
256        #[cfg(target_arch = "wasm32")]
257        worker::console_log!("{line}");
258        #[cfg(not(target_arch = "wasm32"))]
259        tracing::info!("{line}");
260    }
261    response_to_worker(response).await
262}
263
264/// The Workers Logs line for one request: the core [`RequestSummary`]
265/// serialized as JSON.
266fn summary_line(summary: &RequestSummary) -> Option<String> {
267    serde_json::to_string(summary).ok()
268}
269
270async fn response_to_worker(
271    response: http::Response<axum::body::Body>,
272) -> worker::Result<WorkerResponse> {
273    let (parts, body) = response.into_parts();
274    let bytes = axum::body::to_bytes(body, MAX_RESPONSE_BUFFER)
275        .await
276        .map_err(|err| worker::Error::RustError(err.to_string()))?;
277    let mut out =
278        WorkerResponse::from_bytes(bytes.as_ref().to_vec())?.with_status(parts.status.as_u16());
279    {
280        let worker_headers = out.headers_mut();
281        // Per name: drop what `from_bytes` pre-set (a default
282        // `content-type: application/octet-stream`), then `append` every
283        // value. `set` kept only the last value of a multi-valued header
284        // (`Vary` from the CORS layer plus the handler's own, later
285        // `Set-Cookie`; found by `GET /__surface` losing
286        // `Vary: Authorization`, issue #70), and a bare `append` stacked
287        // onto the pre-set default (found by `/ui` pages answering
288        // `content-type: application/octet-stream, text/html`, issue #72).
289        for name in parts.headers.keys() {
290            let _ = worker_headers.delete(name.as_str());
291            for value in parts.headers.get_all(name) {
292                let _ = worker_headers.append(name.as_str(), value.to_str().unwrap_or_default());
293            }
294        }
295    }
296    Ok(out)
297}
298
299/// Responses are JSON (small); 1 MiB is a generous ceiling.
300const MAX_RESPONSE_BUFFER: usize = 1024 * 1024;
301
302/// The conservative limits a scheduled invocation runs under when the
303/// venture does not choose its own: 25 seconds of wall time and 40
304/// subrequest-shaped steps. Chosen to fit the Workers Free plan's 50
305/// subrequests per invocation with headroom, not taken from the plan: it
306/// is a default, not a platform fact, and a venture on a paid plan passes
307/// its own limits to [`serve_scheduled_with_limits`].
308pub const CLOUDFLARE_SCHEDULED_LIMITS: ScheduledLimits = ScheduledLimits {
309    wall: Some(time::Duration::seconds(25)),
310    subrequests: Some(40),
311};
312
313/// Fans a scheduled event out to every module's `scheduled(ctx, cron)`
314/// under [`CLOUDFLARE_SCHEDULED_LIMITS`]. Handler errors are logged and
315/// never fail the cron.
316pub async fn serve_scheduled(
317    harness: &Harness,
318    runtime: &Cloudflare,
319    event: worker::ScheduledEvent,
320    env: Env,
321    ctx: worker::ScheduleContext,
322) {
323    serve_scheduled_with_limits(
324        harness,
325        runtime,
326        event,
327        env,
328        ctx,
329        CLOUDFLARE_SCHEDULED_LIMITS,
330    )
331    .await;
332}
333
334/// [`serve_scheduled`] with the invocation's limits named explicitly
335/// (issue #537): the whole run may spend `limits.wall` of wall time and
336/// `limits.subrequests` subrequest-shaped steps, split across the modules
337/// in order with each module's unspent share rolling forward to the ones
338/// after it. A module's share arrives as `ModuleContext::scheduled`, which
339/// the module — and `Outbox::drain_within` — checks cooperatively before
340/// each unit of work; the runtime never cancels a module that ignores it.
341/// A module that runs its share out is named in Workers Logs, and a
342/// handler error is still only logged, never a failed cron.
343pub async fn serve_scheduled_with_limits(
344    harness: &Harness,
345    runtime: &Cloudflare,
346    event: worker::ScheduledEvent,
347    env: Env,
348    ctx: worker::ScheduleContext,
349    limits: ScheduledLimits,
350) {
351    install_tracing();
352    let cron = event.cron();
353    let ports = runtime.ports(&env, Arc::new(ScheduleDefer(ctx)));
354    check_module_config_once(harness, ports.config.as_ref());
355    #[cfg(feature = "push")]
356    check_push_wiring_once(harness, runtime, &env, ports.config.as_ref());
357    // `ports` always wires WorkersClock, so the first arm is the real one.
358    // A ports set without a clock cannot check a wall limit at all, so it
359    // runs the unbounded split — which reads no clock and computes no
360    // deadline, so the epoch placeholders below are never observed.
361    let clock = ports.clock.clone();
362    let mut split = match clock.as_ref() {
363        Some(clock) => ScheduledSplit::new(limits, clock.now(), harness.modules().len()),
364        None => ScheduledSplit::new(
365            ScheduledLimits::UNBOUNDED,
366            OffsetDateTime::UNIX_EPOCH,
367            harness.modules().len(),
368        ),
369    };
370    for module in harness.modules() {
371        let now = clock
372            .as_ref()
373            .map_or(OffsetDateTime::UNIX_EPOCH, |clock| clock.now());
374        let budget = Arc::new(split.next(now));
375        let mut module_ctx = harness.module_context(module.as_ref(), &ports);
376        module_ctx.scheduled = Arc::clone(&budget);
377        if let Err(err) = module.scheduled(&module_ctx, &cron).await {
378            tracing::error!(
379                module = module.name(),
380                cron = %cron,
381                error = %err,
382                "scheduled module work failed",
383            );
384        }
385        // Judged on a fresh reading: a module that overran spent wall time
386        // this invocation does not have to wait for the next one to be named.
387        let after = clock
388            .as_ref()
389            .map_or(OffsetDateTime::UNIX_EPOCH, |clock| clock.now());
390        if budget.exhausted(after) {
391            tracing::warn!(
392                module = module.name(),
393                cron = %cron,
394                spent = budget.spent(),
395                "scheduled module work exhausted its budget",
396            );
397        }
398        split.settle(&budget);
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn summary_line_is_one_json_object_carrying_the_request_id() {
408        let summary = RequestSummary {
409            request_id: "01J9ZQ4V6X8Y2K3M5N7P9R1T3W".to_owned(),
410            method: "GET".to_owned(),
411            route: "/v1/sample/hello".to_owned(),
412            module: "sample".to_owned(),
413            status: 200,
414            ip_hash: "0123456789ab".to_owned(),
415            ua_family: "mozilla".to_owned(),
416        };
417        let line = summary_line(&summary).expect("serializes");
418        assert!(!line.contains('\n'), "one line: {line}");
419        let parsed: serde_json::Value = serde_json::from_str(&line).expect("valid JSON");
420        assert_eq!(parsed["request_id"], "01J9ZQ4V6X8Y2K3M5N7P9R1T3W");
421        assert_eq!(parsed["route"], "/v1/sample/hello");
422        assert_eq!(parsed["status"], 200);
423    }
424}