Skip to content
Draft
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 }}
37 changes: 36 additions & 1 deletion crates/edgezero-adapter-cloudflare/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pub mod response;
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 +123,38 @@ 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.
///
/// # 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
}
17 changes: 13 additions & 4 deletions crates/edgezero-adapter-cloudflare/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,20 @@ 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)
for name in parts.headers.keys() {
let mut first = true;
for value in parts.headers.get_all(name) {
if let Ok(value_str) = value.to_str() {
// Replace any body-generated default once, then retain every
// additional application value (especially Set-Cookie).
if first {
headers.set(name.as_str(), value_str)
} else {
headers.append(name.as_str(), value_str)
}
.map_err(EdgeError::internal)?;
first = false;
}
}
}
Ok(cf_response)
Expand Down
200 changes: 200 additions & 0 deletions crates/edgezero-adapter-fastly/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
//! Utilities for bridging Fastly Compute@Edge requests into the
//! `edgezero-core` service abstractions.

#![cfg_attr(
feature = "fastly",
expect(
clippy::pub_use,
reason = "re-export the SDK serving builder rather than duplicate its API"
)
)]

// Only compiled where it is actually used (the CLI push/GC path and the Fastly
// runtime resolver). Gating it keeps a `--no-default-features` build dead-code
// clean instead of dragging in helpers no feature references.
Expand All @@ -13,6 +21,7 @@ pub mod config_store;
pub mod context;
#[cfg(feature = "fastly")]
pub mod key_value_store;
pub mod lifecycle;
#[cfg(feature = "fastly")]
pub mod logger;
#[cfg(feature = "fastly")]
Expand All @@ -24,6 +33,8 @@ pub mod response;
#[cfg(feature = "fastly")]
pub mod secret_store;

#[cfg(any(feature = "fastly", test))]
use edgezero_core::app::App;
#[cfg(feature = "fastly")]
use edgezero_core::app::Hooks;
#[cfg(any(feature = "fastly", test))]
Expand All @@ -36,6 +47,8 @@ use edgezero_core::http::Extensions;
use edgezero_core::manifest::ResolvedLoggingConfig;
#[cfg(feature = "fastly")]
use fastly::compute_runtime::service_id;
#[cfg(feature = "fastly")]
pub use fastly::http::serve::{Serve, ServeSummary};

#[cfg(any(feature = "cli", feature = "fastly", test))]
const RUNTIME_ENV_PREFIX: &str = "EDGEZERO__";
Expand Down Expand Up @@ -104,6 +117,35 @@ impl From<&EnvConfig> for FastlyLogging {
}
}

#[cfg(any(feature = "fastly", test))]
#[derive(Default)]
struct RetainedApp {
app: Option<App>,
}

#[cfg(any(feature = "fastly", test))]
impl RetainedApp {
fn get_or_init<E>(
&mut self,
env: &EnvConfig,
owns_logging: impl FnOnce() -> bool,
install_logger: impl FnOnce(&str, log::LevelFilter, bool) -> Result<(), E>,
build: impl FnOnce() -> App,
) -> Result<&App, E> {
if self.app.is_none() {
let logging = FastlyLogging::from(env);
if logging.use_fastly_logger && !owns_logging() {
install_logger(
logging.endpoint.as_deref().unwrap_or("stdout"),
logging.level,
logging.echo_stdout,
)?;
}
}
Ok(self.app.get_or_insert_with(build))
}
}

/// Prefix a canonical `EDGEZERO__*` key with its owning Fastly service.
///
/// The shared `edgezero_runtime_env` Config Store is account-wide. Service
Expand Down Expand Up @@ -432,3 +474,161 @@ mod runtime_env_key_tests {
);
}
}

#[cfg(test)]
mod retained_app_tests {
use super::*;
use edgezero_core::app::{App, Hooks};
use edgezero_core::router::RouterService;
use std::cell::{Cell, RefCell};

struct CountedApp;
thread_local! { static CONFIGURES: Cell<usize> = const { Cell::new(0) }; }
#[expect(
clippy::missing_trait_methods,
reason = "exercise default app construction"
)]
impl Hooks for CountedApp {
fn configure(app: &mut App) {
CONFIGURES.with(|count| count.set(count.get().checked_add(1).unwrap()));
app.set_name("retained");
}
fn routes() -> RouterService {
RouterService::builder().build()
}
}

fn configured_env() -> EnvConfig {
EnvConfig::from_vars([
("EDGEZERO__LOGGING__ENDPOINT", "fixture-logs"),
("EDGEZERO__LOGGING__LEVEL", "debug"),
])
}

#[test]
fn retained_app_initializes_logging_before_build_once() {
let mut retained = RetainedApp::default();
let events = RefCell::new(Vec::new());
CONFIGURES.with(|count| count.set(0));
for _ in 0_usize..2 {
let app = retained
.get_or_init(
&configured_env(),
|| false,
|endpoint, level, echo| {
assert_eq!(endpoint, "fixture-logs");
assert_eq!(level, log::LevelFilter::Debug);
assert!(echo);
events.borrow_mut().push("logger");
Ok::<(), &'static str>(())
},
|| {
events.borrow_mut().push("build");
CountedApp::build_app()
},
)
.unwrap();
assert_eq!(app.name(), "retained");
}
assert_eq!(*events.borrow(), ["logger", "build"]);
CONFIGURES.with(|count| assert_eq!(count.get(), 1));
}

#[test]
fn retained_app_keeps_first_degraded_snapshot() {
let mut retained = RetainedApp::default();
let builds = Cell::new(0_usize);
for env in [EnvConfig::default(), configured_env()] {
retained
.get_or_init(
&env,
|| false,
|_, _, _| -> Result<(), &'static str> { panic!("must not install later") },
|| {
builds.set(builds.get().checked_add(1).unwrap());
CountedApp::build_app()
},
)
.unwrap();
}
assert_eq!(builds.get(), 1);
}

#[test]
fn retained_app_respects_owned_logging_and_fresh_owners() {
let builds = Cell::new(0_usize);
for _ in 0_usize..2 {
let mut retained = RetainedApp::default();
retained
.get_or_init(
&configured_env(),
|| true,
|_, _, _| -> Result<(), &'static str> { panic!("caller owns logging") },
|| {
builds.set(builds.get().checked_add(1).unwrap());
CountedApp::build_app()
},
)
.unwrap();
}
assert_eq!(builds.get(), 2);
}

#[test]
fn retained_app_logger_error_prevents_construction() {
let mut retained = RetainedApp::default();
let result = retained.get_or_init(
&configured_env(),
|| false,
|_, _, _| Err("logger failed"),
|| panic!("must not build"),
);
assert_eq!(result.err(), Some("logger failed"));
assert!(retained.app.is_none());
}
}

#[cfg(feature = "fastly")]
/// Serve requests with an app initialized once on the first callback.
///
/// Opt in using an ordinary `main` and an explicitly bounded [`Serve`]. The
/// runtime may exit before any configured limit; every request must tolerate
/// fresh initialization. The standard response conversion buffers core streams.
/// Inspect the returned summary (whose request count includes failed attempts)
/// or call its `into_result()` method to propagate terminal callback errors.
#[must_use = "inspect the serving summary or call into_result() to handle terminal errors"]
#[inline]
pub fn serve_app<A: Hooks>(serve: Serve) -> ServeSummary<fastly::Error> {
serve_app_with_request_extensions::<A, _>(serve, |_request, _extensions| {})
}

#[cfg(feature = "fastly")]
/// Serve a retained app with fresh request extensions and store registries.
///
/// Runtime configuration is read on every callback. Logging uses only the first
/// snapshot, before app construction, unless `Hooks::owns_logging` is true.
/// An unavailable optional configuration store freezes logging as disabled,
/// even if later reads recover store selectors. Initialization errors terminate
/// the SDK loop; construction panics remain sandbox failures.
///
/// The mutable callback runs per request. For mutable native requests, manual
/// streaming, response finalization, or custom initialization, use [`Serve`]
/// directly with the existing raw conversion/dispatch APIs.
#[must_use = "inspect the serving summary or call into_result() to handle terminal errors"]
#[inline]
pub fn serve_app_with_request_extensions<A, F>(
serve: Serve,
mut extend: F,
) -> ServeSummary<fastly::Error>
where
A: Hooks,
F: FnMut(&fastly::Request, &mut Extensions),
{
let stores = A::stores();
let mut retained = RetainedApp::default();
serve.run(move |req| -> Result<fastly::Response, fastly::Error> {
let env = runtime_env_config(stores);
let app = retained.get_or_init(&env, A::owns_logging, init_logger, A::build_app)?;
request::dispatch_with_registries(app, req, stores, &env, &mut extend)
})
}
Loading
Loading