#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
mod body_limit;
mod config;
mod ports;
mod runtime;
mod tracing_setup;
pub use config::EnvConfig;
pub use ports::{
ContextDefer, D1Database, D1RateLimiter, FetchClient, KvStorePort, Limit,
RATE_LIMIT_COUNTERS_SQL, RateLimitPolicy, RateLimitPort, RoomDriver, ScheduleDefer,
WorkersClock, client_ip,
};
pub use runtime::Cloudflare;
pub use tracing_setup::install_tracing;
use crate::body_limit::{BodyPlan, Capped, body_plan, read_capped};
use axum::response::IntoResponse;
use cratefield_core::{Harness, Problem, RequestSummary, ScheduledLimits, ScheduledSplit};
use std::sync::Arc;
use time::OffsetDateTime;
use tower::ServiceExt;
use worker::{Context, Env, Request as WorkerRequest, Response as WorkerResponse};
fn check_module_config_once(harness: &Harness, config: &dyn cratefield_core::Config) {
use std::sync::atomic::{AtomicBool, Ordering};
static CHECKED: AtomicBool = AtomicBool::new(false);
if CHECKED.swap(true, Ordering::Relaxed) {
return;
}
for module in harness.modules() {
if let Err(err) = module.validate_config(config) {
worker::console_error!(
"[config] module `{}` has invalid configuration: {err}",
module.name()
);
}
}
}
#[cfg(feature = "push")]
fn check_push_wiring_once(
harness: &Harness,
runtime: &Cloudflare,
env: &Env,
config: &dyn cratefield_core::Config,
) {
use cratefield_push_wiring::WiringSeverity;
use std::sync::atomic::{AtomicBool, Ordering};
static CHECKED: AtomicBool = AtomicBool::new(false);
if CHECKED.swap(true, Ordering::Relaxed) {
return;
}
let Some(wiring) = runtime.push_wiring(env) else {
return;
};
worker::console_log!("[push] {}", wiring.summary());
let deployed = cratefield_core::deployed_env(harness.venture().env, config);
match wiring.severity(deployed) {
WiringSeverity::Ok => {}
WiringSeverity::Warning => {
for problem in wiring.problems() {
worker::console_log!("[push] warning: {problem}");
}
}
WiringSeverity::Error => {
for problem in wiring.problems() {
worker::console_error!("[push] {problem}");
}
}
}
}
pub async fn serve(
harness: &Harness,
runtime: &Cloudflare,
mut req: WorkerRequest,
env: Env,
ctx: Context,
) -> worker::Result<WorkerResponse> {
install_tracing();
let ports = runtime.ports(&env, Arc::new(ContextDefer(ctx)));
check_module_config_once(harness, ports.config.as_ref());
#[cfg(feature = "push")]
check_push_wiring_once(harness, runtime, &env, ports.config.as_ref());
let config = Arc::clone(&ports.config);
let router = harness.router(ports);
let url = req.url()?;
let limit = harness.max_body_bytes(url.path(), config.as_ref());
let declared = req.headers().get("content-length").ok().flatten();
let bytes = match body_plan(declared.as_deref(), limit) {
BodyPlan::Refuse => {
return response_to_worker(Problem::request_too_large().into_response()).await;
}
BodyPlan::Buffer => req.bytes().await?,
BodyPlan::Stream => {
match req.inner().body() {
None => Vec::new(),
Some(_) => match req.stream() {
Ok(stream) => match read_capped(stream, limit).await {
Ok(Capped::Within(bytes)) => bytes,
Ok(Capped::TooLarge) => {
return response_to_worker(
Problem::request_too_large().into_response(),
)
.await;
}
Err(err) => return Err(err),
},
Err(_) => Vec::new(),
},
}
}
};
let method = http::Method::from_bytes(req.method().to_string().as_bytes())
.map_err(|err| worker::Error::RustError(err.to_string()))?;
let mut builder = http::Request::builder().method(method).uri(url.to_string());
{
let headers = req.headers();
for (name, value) in headers {
builder = builder.header(name.as_str(), value.as_str());
}
}
let buffered = builder
.body(axum::body::Body::from(bytes))
.map_err(|err| worker::Error::RustError(err.to_string()))?;
let response = router
.oneshot(buffered)
.await
.map_err(|err| worker::Error::RustError(err.to_string()))?;
if let Some(line) = response.extensions().get().and_then(summary_line) {
#[cfg(target_arch = "wasm32")]
worker::console_log!("{line}");
#[cfg(not(target_arch = "wasm32"))]
tracing::info!("{line}");
}
response_to_worker(response).await
}
fn summary_line(summary: &RequestSummary) -> Option<String> {
serde_json::to_string(summary).ok()
}
async fn response_to_worker(
response: http::Response<axum::body::Body>,
) -> worker::Result<WorkerResponse> {
let (parts, body) = response.into_parts();
let bytes = axum::body::to_bytes(body, MAX_RESPONSE_BUFFER)
.await
.map_err(|err| worker::Error::RustError(err.to_string()))?;
let mut out =
WorkerResponse::from_bytes(bytes.as_ref().to_vec())?.with_status(parts.status.as_u16());
{
let worker_headers = out.headers_mut();
for name in parts.headers.keys() {
let _ = worker_headers.delete(name.as_str());
for value in parts.headers.get_all(name) {
let _ = worker_headers.append(name.as_str(), value.to_str().unwrap_or_default());
}
}
}
Ok(out)
}
const MAX_RESPONSE_BUFFER: usize = 1024 * 1024;
pub const CLOUDFLARE_SCHEDULED_LIMITS: ScheduledLimits = ScheduledLimits {
wall: Some(time::Duration::seconds(25)),
subrequests: Some(40),
};
pub async fn serve_scheduled(
harness: &Harness,
runtime: &Cloudflare,
event: worker::ScheduledEvent,
env: Env,
ctx: worker::ScheduleContext,
) {
serve_scheduled_with_limits(
harness,
runtime,
event,
env,
ctx,
CLOUDFLARE_SCHEDULED_LIMITS,
)
.await;
}
pub async fn serve_scheduled_with_limits(
harness: &Harness,
runtime: &Cloudflare,
event: worker::ScheduledEvent,
env: Env,
ctx: worker::ScheduleContext,
limits: ScheduledLimits,
) {
install_tracing();
let cron = event.cron();
let ports = runtime.ports(&env, Arc::new(ScheduleDefer(ctx)));
check_module_config_once(harness, ports.config.as_ref());
#[cfg(feature = "push")]
check_push_wiring_once(harness, runtime, &env, ports.config.as_ref());
let clock = ports.clock.clone();
let mut split = match clock.as_ref() {
Some(clock) => ScheduledSplit::new(limits, clock.now(), harness.modules().len()),
None => ScheduledSplit::new(
ScheduledLimits::UNBOUNDED,
OffsetDateTime::UNIX_EPOCH,
harness.modules().len(),
),
};
for module in harness.modules() {
let now = clock
.as_ref()
.map_or(OffsetDateTime::UNIX_EPOCH, |clock| clock.now());
let budget = Arc::new(split.next(now));
let mut module_ctx = harness.module_context(module.as_ref(), &ports);
module_ctx.scheduled = Arc::clone(&budget);
if let Err(err) = module.scheduled(&module_ctx, &cron).await {
tracing::error!(
module = module.name(),
cron = %cron,
error = %err,
"scheduled module work failed",
);
}
let after = clock
.as_ref()
.map_or(OffsetDateTime::UNIX_EPOCH, |clock| clock.now());
if budget.exhausted(after) {
tracing::warn!(
module = module.name(),
cron = %cron,
spent = budget.spent(),
"scheduled module work exhausted its budget",
);
}
split.settle(&budget);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn summary_line_is_one_json_object_carrying_the_request_id() {
let summary = RequestSummary {
request_id: "01J9ZQ4V6X8Y2K3M5N7P9R1T3W".to_owned(),
method: "GET".to_owned(),
route: "/v1/sample/hello".to_owned(),
module: "sample".to_owned(),
status: 200,
ip_hash: "0123456789ab".to_owned(),
ua_family: "mozilla".to_owned(),
};
let line = summary_line(&summary).expect("serializes");
assert!(!line.contains('\n'), "one line: {line}");
let parsed: serde_json::Value = serde_json::from_str(&line).expect("valid JSON");
assert_eq!(parsed["request_id"], "01J9ZQ4V6X8Y2K3M5N7P9R1T3W");
assert_eq!(parsed["route"], "/v1/sample/hello");
assert_eq!(parsed["status"], 200);
}
}