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}