Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ jobs:
- name: Nested AppConfig checker tests
run: cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config

- name: Lifecycle fixture host tests
run: |
cargo test --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-harness -p fixture-core

- name: Run workspace tests
run: cargo test --workspace --all-targets

Expand Down Expand Up @@ -247,5 +251,19 @@ jobs:
${{ matrix.runner_env }}: ${{ matrix.runner_value }}
run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib

- name: Fastly reusable HTTP smoke
if: matrix.adapter == 'fastly'
run: ./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke --require-runtime

- name: Preserve Fastly lifecycle evidence
if: always() && matrix.adapter == 'fastly'
uses: actions/upload-artifact@v4
with:
name: fastly-lifecycle-evidence
path: tests/fixtures/reusable-app/.runs/
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7

- name: Check ${{ matrix.adapter }} wasm target
run: cargo check -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }}
44 changes: 43 additions & 1 deletion crates/edgezero-adapter-cloudflare/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ pub mod proxy;
pub mod request;
#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
pub mod response;
#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))]
mod response_headers;
#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
pub mod secret_store;

#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
use edgezero_core::app::{Hooks, StoresMetadata};
use edgezero_core::app::{App, Hooks, StoresMetadata};
#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
use edgezero_core::env_config::EnvConfig;
#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
Expand Down Expand Up @@ -123,3 +125,43 @@ pub async fn run_app<A: Hooks>(
)
.await
}

#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
/// Dispatch a caller-owned app with explicit store metadata.
///
/// Resolves configuration and request resources for this invocation without
/// building or caching an app or installing logging. Pass metadata matching
/// the app (normally `MyApp::stores()`). Retain only application-owned values;
/// native handles and pending work belong to the request. Shared app state
/// must support overlapping invocations.
///
/// Store metadata is a caller precondition: `App` carries no store provenance,
/// so this function cannot validate the pairing. Mismatched metadata can fail
/// binding resolution or select unintended bindings. Keep the app and its
/// construction metadata together; normally both come from the same `Hooks` type.
///
/// # Errors
/// Returns conversion or dispatch errors from the existing adapter boundary.
#[inline]
pub async fn dispatch_app(
app: &App,
stores: StoresMetadata,
req: Request,
env: Env,
ctx: Context,
) -> Result<Response, WorkerError> {
let env_config = env_config_from_worker(&env, stores);
request::dispatch_with_registries(
app,
req,
env,
ctx,
request::RegistryInputs {
config_meta: stores.config,
kv_meta: stores.kv,
secret_meta: stores.secrets,
env_config: &env_config,
},
)
.await
}
18 changes: 10 additions & 8 deletions crates/edgezero-adapter-cloudflare/src/response.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::response_headers::copy_headers;
use edgezero_core::body::Body;
use edgezero_core::error::EdgeError;
use edgezero_core::http::Response;
Expand All @@ -9,8 +10,8 @@ use worker::{Error as WorkerError, Response as CfResponse};
/// # Errors
/// Returns an [`EdgeError`] if the response body cannot be materialised
/// into a Workers response (empty body construction failure, byte body
/// conversion failure, stream adoption failure) or if any response
/// header is non-UTF-8 and the Workers header table rejects it.
/// conversion failure, stream adoption failure) or the Workers header table
/// rejects a text header. Non-text header values are skipped.
#[inline]
pub fn from_core_response(response: Response) -> Result<CfResponse, EdgeError> {
let (parts, body) = response.into_parts();
Expand All @@ -33,13 +34,14 @@ pub fn from_core_response(response: Response) -> Result<CfResponse, EdgeError> {

let mut cf_response = body_response.with_status(parts.status.as_u16());
let headers = cf_response.headers_mut();
for (name, value) in &parts.headers {
if let Ok(value_str) = value.to_str() {
headers
.set(name.as_str(), value_str)
.map_err(EdgeError::internal)?;
copy_headers(&parts.headers, |name, value, replace| {
if replace {
headers.set(name, value)
} else {
headers.append(name, value)
}
}
})
.map_err(EdgeError::internal)?;
Ok(cf_response)
}

Expand Down
94 changes: 94 additions & 0 deletions crates/edgezero-adapter-cloudflare/src/response_headers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
use edgezero_core::http::HeaderMap;

/// Replace a body-generated default with the first valid application value,
/// then append additional values. Non-text values retain the adapter's skip policy.
pub(crate) fn copy_headers<E, F>(headers: &HeaderMap, mut write: F) -> Result<(), E>
where
F: FnMut(&str, &str, bool) -> Result<(), E>,
{
for name in headers.keys() {
let mut replace = true;
for value in headers.get_all(name) {
if let Ok(text) = value.to_str() {
write(name.as_str(), text, replace)?;
replace = false;
}
}
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::copy_headers;
use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue};

#[test]
fn replaces_defaults_and_preserves_separate_cookies() {
let mut source = HeaderMap::new();
source.append("set-cookie", HeaderValue::from_static("first=1; Path=/"));
source.append("set-cookie", HeaderValue::from_static("second=2; Path=/"));
source.insert("content-type", HeaderValue::from_static("text/plain"));
let mut target = HeaderMap::new();
target.insert(
"content-type",
HeaderValue::from_static("application/octet-stream"),
);
target.insert("set-cookie", HeaderValue::from_static("default=discard"));
copy_headers(&source, |name, value, replace| {
let parsed_name = name.parse::<HeaderName>().unwrap();
let parsed_value = HeaderValue::from_str(value).unwrap();
if replace {
target.insert(parsed_name, parsed_value);
} else {
target.append(parsed_name, parsed_value);
}
Ok::<_, ()>(())
})
.unwrap();
assert_eq!(target, source);
assert_eq!(target.get_all("set-cookie").iter().count(), 2);
}

#[test]
fn first_valid_value_replaces_even_after_invalid_value() {
let mut source = HeaderMap::new();
source.append("x-test", HeaderValue::from_bytes(b"\xff").unwrap());
source.append("x-test", HeaderValue::from_static("valid"));
source.append("x-test", HeaderValue::from_static("next"));
let mut writes = Vec::new();
copy_headers(&source, |_, value, replace| {
writes.push((value.to_owned(), replace));
Ok::<_, ()>(())
})
.unwrap();
assert_eq!(
writes,
[("valid".to_owned(), true), ("next".to_owned(), false)]
);
}

#[test]
fn invalid_only_values_do_not_touch_generated_defaults() {
let mut source = HeaderMap::new();
source.append("x-test", HeaderValue::from_bytes(b"\xff").unwrap());
copy_headers(&source, |_, _, _| -> Result<(), ()> {
panic!("invalid text must not reach the sink")
})
.unwrap();
}

#[test]
fn stops_at_sink_error() {
let mut source = HeaderMap::new();
source.append("set-cookie", HeaderValue::from_static("first=1"));
source.append("set-cookie", HeaderValue::from_static("second=2"));
let mut calls = 0_u32;
let result = copy_headers(&source, |_, _, _| {
calls += 1;
Err("header rejected")
});
assert_eq!(result, Err("header rejected"));
assert_eq!(calls, 1);
}
}
22 changes: 21 additions & 1 deletion crates/edgezero-adapter-cloudflare/tests/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ mod tests {
use edgezero_adapter_cloudflare::context::CloudflareRequestContext;
use edgezero_adapter_cloudflare::request::{CloudflareService, into_core_request};
use edgezero_adapter_cloudflare::response::from_core_response;
use edgezero_core::app::App;
use edgezero_core::app::{App, StoresMetadata};
use edgezero_core::body::Body;
use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle};
use edgezero_core::context::RequestContext;
Expand Down Expand Up @@ -150,6 +150,26 @@ mod tests {
(env, Context::new(js_context))
}

#[wasm_bindgen_test]
async fn dispatch_app_reuses_router_with_fresh_request_bodies() {
let app = build_test_app();
for body in [b"first".as_slice(), b"second".as_slice()] {
let (env, ctx) = test_env_ctx();
let request = cf_request(CfMethod::Post, "/mirror", Some(body));
let mut response = edgezero_adapter_cloudflare::dispatch_app(
&app,
StoresMetadata::default(),
request,
env,
ctx,
)
.await
.expect("prebuilt dispatch response");
assert_eq!(response.status_code(), 200);
assert_eq!(response.bytes().await.expect("response bytes"), body);
}
}

#[wasm_bindgen_test]
async fn dispatch_passes_request_body_to_handlers() {
let app = build_test_app();
Expand Down
Loading
Loading