Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .github/workflows/format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
tests/fixtures/reusable-app/target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
Expand All @@ -49,6 +50,12 @@ jobs:
- name: Run cargo fmt
run: cargo fmt --all -- --check

- name: Run cargo fmt (lifecycle fixtures)
run: cargo fmt --manifest-path tests/fixtures/reusable-app/Cargo.toml --all -- --check

- name: Run cargo clippy (native lifecycle fixtures)
run: cargo clippy --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-harness -p fixture-core -p fixture-axum --all-targets -- -D warnings

- name: Run cargo clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings

Expand Down Expand Up @@ -108,6 +115,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
tests/fixtures/reusable-app/target/
key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-clippy-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ matrix.adapter }}-clippy-
Expand All @@ -132,6 +140,10 @@ jobs:
- name: Clippy ${{ matrix.adapter }} (${{ matrix.features }}) on ${{ matrix.target }}
run: cargo clippy -p edgezero-adapter-${{ matrix.adapter }} --features "${{ matrix.features }}" --target ${{ matrix.target }} --all-targets -- -D warnings

- name: Clippy ${{ matrix.adapter }} lifecycle fixture
Comment thread
prk-Jr marked this conversation as resolved.
if: matrix.features != 'fastly cli'
run: cargo clippy --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-${{ matrix.adapter }} --target ${{ matrix.target }} --all-targets -- -D warnings

format-docs:
runs-on: ubuntu-latest
defaults:
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
tests/fixtures/reusable-app/target/
key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-test-
Expand Down Expand Up @@ -65,6 +66,13 @@ 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
Comment thread
prk-Jr marked this conversation as resolved.
run: |
cargo test --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-harness -p fixture-core

- name: Check Axum lifecycle fixture
run: cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-axum

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

Expand Down Expand Up @@ -133,6 +141,7 @@ jobs:
~/.cargo/git/db/
~/.wasmtime/bin/
target/
tests/fixtures/reusable-app/target/
key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-${{ hashFiles('**/Cargo.lock', '.tool-versions') }}
restore-keys: |
${{ runner.os }}-cargo-${{ matrix.adapter }}-
Expand Down Expand Up @@ -247,5 +256,22 @@ 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

- 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 }}

- name: Check ${{ matrix.adapter }} lifecycle fixture
run: cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-${{ matrix.adapter }} --target ${{ matrix.target }} --all-targets
29 changes: 26 additions & 3 deletions 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 @@ -106,11 +108,32 @@ pub async fn run_app<A: Hooks>(
if !A::owns_logging() {
drop(init_logger());
}
let app = A::build_app();
dispatch_app::<A>(&app, req, env, ctx).await
}

#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
/// Dispatch a caller-owned app using `A::stores()` for fresh request bindings.
///
/// This does not build or cache an app or install logging. The caller must pass
/// an app built from `A`; `App` erases its construction type, so this pairing
/// remains a caller precondition. Retain only application-owned values and keep
/// native handles and pending work request-local. Shared state must support
/// overlapping invocations.
///
/// # Errors
/// Returns conversion or dispatch errors from the existing adapter boundary.
#[inline]
pub async fn dispatch_app<A: Hooks>(
app: &App,
req: Request,
env: Env,
ctx: Context,
) -> Result<Response, WorkerError> {
let stores = A::stores();
let env_config = env_config_from_worker(&env, stores);
let app = A::build_app();
request::dispatch_with_registries(
&app,
app,
req,
env,
ctx,
Expand Down
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>
Comment thread
prk-Jr marked this conversation as resolved.
Comment thread
prk-Jr marked this conversation as resolved.
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);
}
}
28 changes: 27 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, Hooks};
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,32 @@ mod tests {
(env, Context::new(js_context))
}

#[wasm_bindgen_test]
async fn dispatch_app_reuses_router_with_fresh_request_bodies() {
struct TestApp;
// This fixture deliberately exercises the default Hooks metadata.
#[expect(
clippy::missing_trait_methods,
reason = "exercise default Hooks metadata and construction"
)]
impl Hooks for TestApp {
fn routes() -> RouterService {
build_test_app().router().clone()
}
}
let app = TestApp::build_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::<TestApp>(&app, 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