Skip to content

Commit 683202c

Browse files
authored
Add opt-in reusable application lifecycles (#379)
* feat: add opt-in reusable application lifecycles Add retained Fastly serving helpers and explicit prebuilt-app dispatch for Cloudflare and Spin while preserving default lifecycles. Preserve repeated Cloudflare response headers. Add Rust lifecycle fixtures, isolation and cancellation coverage, local measurement tooling, CI integration, and adapter documentation with explicit provider validation limits. * fix: import dispatch types for WASM Clippy * test: preserve lifecycle smoke failure diagnostics * Complete custom lifecycle contract and scope config validation Verify lazy initialization, handled failure, recovery and retained reuse in one Fastly guest. Carry request-specific finalization metadata through router dispatch and document the supported custom lifecycle boundary. Scope push and diff adapter validation to the selected destination while preserving standalone portability checks. Redact raw and normalized secret references from Spin diagnostics. Validated with workspace and fixture tests, Clippy, formatting, adapter feature checks, Spin WASM compilation, documentation checks and the local Fastly smoke suite. * Add reusable lifecycle helpers for custom Fastly dispatch Own successful-only lazy state retention, initialization attempts, callback counts, and independent setup in Sandbox<T>. Add reusable and single-request serving wrappers that preserve the SDK sending boundary and application-controlled streaming. Migrate custom lifecycle fixtures to the public helpers and document ownership across adapters. Verified workspace tests, fixture tests, Clippy, adapter compilation checks, formatting, and local Fastly streaming and recovery smoke tests. * Cover Cloudflare response headers and clarify lifecycle contracts Extract the production header-copy logic for host regression tests covering duplicate cookies, default replacement, invalid values, and sink errors. Exercise prebuilt dispatch with fresh request bodies in the Cloudflare browser contract suite. Document store metadata pairing, retained callback captures, and first-snapshot logging limitations without changing existing policies. * Fix Cloudflare contract test import for WASM Clippy * fix: match selected adapter case-insensitively in config validation Adapter lookup is case-insensitive everywhere else (Manifest::adapter_entry, adapter_registry::get_adapter), but the --adapter selection filters in run_adapter_shared_checks, run_adapter_typed_checks, and strict_capability_completeness compared raw strings. So [adapters.Spin] with --adapter spin (or the reverse) passed ensure_adapter_defined but skipped the selected entry's manifest, merged-store, typed-secret, and store-capability checks, letting config push write invalid configuration that fails at runtime. Extract is_unselected_adapter and compare with eq_ignore_ascii_case at all three filters. Add a regression test covering both casing directions. * fix: address reusable app lifecycle review findings * fix: address follow-up lifecycle review findings Docs: - Fastly guide: add a complete serve_custom skeleton, state precise error semantics, use app.router(), avoid echoing setup errors to clients, clarify request vs callback wording, billing and Viceroy caveats. - Correct streaming, cloudflare, overview, spin, axum, handlers, configuration and cli-reference wording; document what --adapter skips. - Trim the fixture README to commands, assertions and limits; move notes into the spec. Drop the removed --require-runtime flag from the plan. - CI: skip the duplicate fastly fixture clippy on the "fastly cli" row. Fixtures: - Custom Fastly dispatch installs its logger via Sandbox::setup_once and joins the logging checks; gate post-send work on the streaming route. - Share MissingBindingApp and the loopback check in fixture-core; map missing bindings to EdgeError::internal without unwraps. - Remove dead max_inflight and configures outputs; route /other through run_app in per-request mode; Spin logger parity and ~6.0 pin. Harness: - Kill live runtime process groups on Ctrl-C (ctrlc crate). - Record Viceroy version mismatch as unverified and keep running. - Single contextual fail record per failure, including startup failures, with a phase field; fail record for mixed build fingerprints. - Serve and fingerprint artifacts copied into the run directory. - Extract settle/cohort_stats helpers; resolve rustc next to $CARGO. * fix: tighten follow-up review fixes after self-review - Custom Fastly fixture: log logger-setup failures to stderr and send a bare 503 instead of echoing the error, matching the guide. - Harness: only skip the generic fail record when a runner already recorded the same error, so an earlier non-fatal failure cannot hide it. - Docs: ServeSummary may be inspected or converted; list EdgeError render failure among terminal errors (guide and serve_app rustdoc); push keeps strict handler-path checks under --adapter; app.router() in rustdoc; qualify the heap-snapshot and setup-once README claims. * docs: polish lifecycle review fixes after re-review - Drop the stale spin.toml copy from the run directory; the served manifest is the rewritten spin-<mode>.toml pointing at staged wasm. - README: note that runtimes serve and fingerprint staged artifact copies. - Plan: exit 2 covers unverified or unsupported evidence. - CLI reference: fix push --adapter sentence grammar.
1 parent 9893091 commit 683202c

65 files changed

Lines changed: 9875 additions & 90 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/format.yml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ jobs:
2828
~/.cargo/registry/cache/
2929
~/.cargo/git/db/
3030
target/
31+
tests/fixtures/reusable-app/target/
3132
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
3233
restore-keys: |
3334
${{ runner.os }}-cargo-
@@ -49,6 +50,12 @@ jobs:
4950
- name: Run cargo fmt
5051
run: cargo fmt --all -- --check
5152

53+
- name: Run cargo fmt (lifecycle fixtures)
54+
run: cargo fmt --manifest-path tests/fixtures/reusable-app/Cargo.toml --all -- --check
55+
56+
- name: Run cargo clippy (native lifecycle fixtures)
57+
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
58+
5259
- name: Run cargo clippy
5360
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
5461

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

143+
- name: Clippy ${{ matrix.adapter }} lifecycle fixture
144+
if: matrix.features != 'fastly cli'
145+
run: cargo clippy --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-${{ matrix.adapter }} --target ${{ matrix.target }} --all-targets -- -D warnings
146+
135147
format-docs:
136148
runs-on: ubuntu-latest
137149
defaults:

‎.github/workflows/test.yml‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ jobs:
2929
~/.cargo/registry/cache/
3030
~/.cargo/git/db/
3131
target/
32+
tests/fixtures/reusable-app/target/
3233
key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }}
3334
restore-keys: |
3435
${{ runner.os }}-cargo-test-
@@ -65,6 +66,13 @@ jobs:
6566
- name: Nested AppConfig checker tests
6667
run: cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config
6768

69+
- name: Lifecycle fixture host tests
70+
run: |
71+
cargo test --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-harness -p fixture-core
72+
73+
- name: Check Axum lifecycle fixture
74+
run: cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-axum
75+
6876
- name: Run workspace tests
6977
run: cargo test --workspace --all-targets
7078

@@ -133,6 +141,7 @@ jobs:
133141
~/.cargo/git/db/
134142
~/.wasmtime/bin/
135143
target/
144+
tests/fixtures/reusable-app/target/
136145
key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-${{ hashFiles('**/Cargo.lock', '.tool-versions') }}
137146
restore-keys: |
138147
${{ runner.os }}-cargo-${{ matrix.adapter }}-
@@ -247,5 +256,22 @@ jobs:
247256
${{ matrix.runner_env }}: ${{ matrix.runner_value }}
248257
run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib
249258

259+
- name: Fastly reusable HTTP smoke
260+
if: matrix.adapter == 'fastly'
261+
run: ./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke
262+
263+
- name: Preserve Fastly lifecycle evidence
264+
if: always() && matrix.adapter == 'fastly'
265+
uses: actions/upload-artifact@v4
266+
with:
267+
name: fastly-lifecycle-evidence
268+
path: tests/fixtures/reusable-app/.runs/
269+
include-hidden-files: true
270+
if-no-files-found: ignore
271+
retention-days: 7
272+
250273
- name: Check ${{ matrix.adapter }} wasm target
251274
run: cargo check -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }}
275+
276+
- name: Check ${{ matrix.adapter }} lifecycle fixture
277+
run: cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-${{ matrix.adapter }} --target ${{ matrix.target }} --all-targets

‎crates/edgezero-adapter-cloudflare/src/lib.rs‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ pub mod proxy;
1717
pub mod request;
1818
#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
1919
pub mod response;
20+
#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))]
21+
mod response_headers;
2022
#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
2123
pub mod secret_store;
2224

2325
#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
24-
use edgezero_core::app::{Hooks, StoresMetadata};
26+
use edgezero_core::app::{App, Hooks, StoresMetadata};
2527
#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
2628
use edgezero_core::env_config::EnvConfig;
2729
#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
@@ -106,11 +108,32 @@ pub async fn run_app<A: Hooks>(
106108
if !A::owns_logging() {
107109
drop(init_logger());
108110
}
111+
let app = A::build_app();
112+
dispatch_app::<A>(&app, req, env, ctx).await
113+
}
114+
115+
#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]
116+
/// Dispatch a caller-owned app using `A::stores()` for fresh request bindings.
117+
///
118+
/// This does not build or cache an app or install logging. The caller must pass
119+
/// an app built from `A`; `App` erases its construction type, so this pairing
120+
/// remains a caller precondition. Retain only application-owned values and keep
121+
/// native handles and pending work request-local. Shared state must support
122+
/// overlapping invocations.
123+
///
124+
/// # Errors
125+
/// Returns conversion or dispatch errors from the existing adapter boundary.
126+
#[inline]
127+
pub async fn dispatch_app<A: Hooks>(
128+
app: &App,
129+
req: Request,
130+
env: Env,
131+
ctx: Context,
132+
) -> Result<Response, WorkerError> {
109133
let stores = A::stores();
110134
let env_config = env_config_from_worker(&env, stores);
111-
let app = A::build_app();
112135
request::dispatch_with_registries(
113-
&app,
136+
app,
114137
req,
115138
env,
116139
ctx,

‎crates/edgezero-adapter-cloudflare/src/response.rs‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::response_headers::copy_headers;
12
use edgezero_core::body::Body;
23
use edgezero_core::error::EdgeError;
34
use edgezero_core::http::Response;
@@ -9,8 +10,8 @@ use worker::{Error as WorkerError, Response as CfResponse};
910
/// # Errors
1011
/// Returns an [`EdgeError`] if the response body cannot be materialised
1112
/// into a Workers response (empty body construction failure, byte body
12-
/// conversion failure, stream adoption failure) or if any response
13-
/// header is non-UTF-8 and the Workers header table rejects it.
13+
/// conversion failure, stream adoption failure) or the Workers header table
14+
/// rejects a text header. Non-text header values are skipped.
1415
#[inline]
1516
pub fn from_core_response(response: Response) -> Result<CfResponse, EdgeError> {
1617
let (parts, body) = response.into_parts();
@@ -33,13 +34,14 @@ pub fn from_core_response(response: Response) -> Result<CfResponse, EdgeError> {
3334

3435
let mut cf_response = body_response.with_status(parts.status.as_u16());
3536
let headers = cf_response.headers_mut();
36-
for (name, value) in &parts.headers {
37-
if let Ok(value_str) = value.to_str() {
38-
headers
39-
.set(name.as_str(), value_str)
40-
.map_err(EdgeError::internal)?;
37+
copy_headers(&parts.headers, |name, value, replace| {
38+
if replace {
39+
headers.set(name, value)
40+
} else {
41+
headers.append(name, value)
4142
}
42-
}
43+
})
44+
.map_err(EdgeError::internal)?;
4345
Ok(cf_response)
4446
}
4547

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
use edgezero_core::http::HeaderMap;
2+
3+
/// Replace a body-generated default with the first valid application value,
4+
/// then append additional values. Non-text values retain the adapter's skip policy.
5+
pub(crate) fn copy_headers<E, F>(headers: &HeaderMap, mut write: F) -> Result<(), E>
6+
where
7+
F: FnMut(&str, &str, bool) -> Result<(), E>,
8+
{
9+
for name in headers.keys() {
10+
let mut replace = true;
11+
for value in headers.get_all(name) {
12+
if let Ok(text) = value.to_str() {
13+
write(name.as_str(), text, replace)?;
14+
replace = false;
15+
}
16+
}
17+
}
18+
Ok(())
19+
}
20+
21+
#[cfg(test)]
22+
mod tests {
23+
use super::copy_headers;
24+
use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue};
25+
26+
#[test]
27+
fn replaces_defaults_and_preserves_separate_cookies() {
28+
let mut source = HeaderMap::new();
29+
source.append("set-cookie", HeaderValue::from_static("first=1; Path=/"));
30+
source.append("set-cookie", HeaderValue::from_static("second=2; Path=/"));
31+
source.insert("content-type", HeaderValue::from_static("text/plain"));
32+
let mut target = HeaderMap::new();
33+
target.insert(
34+
"content-type",
35+
HeaderValue::from_static("application/octet-stream"),
36+
);
37+
target.insert("set-cookie", HeaderValue::from_static("default=discard"));
38+
copy_headers(&source, |name, value, replace| {
39+
let parsed_name = name.parse::<HeaderName>().unwrap();
40+
let parsed_value = HeaderValue::from_str(value).unwrap();
41+
if replace {
42+
target.insert(parsed_name, parsed_value);
43+
} else {
44+
target.append(parsed_name, parsed_value);
45+
}
46+
Ok::<_, ()>(())
47+
})
48+
.unwrap();
49+
assert_eq!(target, source);
50+
assert_eq!(target.get_all("set-cookie").iter().count(), 2);
51+
}
52+
53+
#[test]
54+
fn first_valid_value_replaces_even_after_invalid_value() {
55+
let mut source = HeaderMap::new();
56+
source.append("x-test", HeaderValue::from_bytes(b"\xff").unwrap());
57+
source.append("x-test", HeaderValue::from_static("valid"));
58+
source.append("x-test", HeaderValue::from_static("next"));
59+
let mut writes = Vec::new();
60+
copy_headers(&source, |_, value, replace| {
61+
writes.push((value.to_owned(), replace));
62+
Ok::<_, ()>(())
63+
})
64+
.unwrap();
65+
assert_eq!(
66+
writes,
67+
[("valid".to_owned(), true), ("next".to_owned(), false)]
68+
);
69+
}
70+
71+
#[test]
72+
fn invalid_only_values_do_not_touch_generated_defaults() {
73+
let mut source = HeaderMap::new();
74+
source.append("x-test", HeaderValue::from_bytes(b"\xff").unwrap());
75+
copy_headers(&source, |_, _, _| -> Result<(), ()> {
76+
panic!("invalid text must not reach the sink")
77+
})
78+
.unwrap();
79+
}
80+
81+
#[test]
82+
fn stops_at_sink_error() {
83+
let mut source = HeaderMap::new();
84+
source.append("set-cookie", HeaderValue::from_static("first=1"));
85+
source.append("set-cookie", HeaderValue::from_static("second=2"));
86+
let mut calls = 0_u32;
87+
let result = copy_headers(&source, |_, _, _| {
88+
calls += 1;
89+
Err("header rejected")
90+
});
91+
assert_eq!(result, Err("header rejected"));
92+
assert_eq!(calls, 1);
93+
}
94+
}

‎crates/edgezero-adapter-cloudflare/tests/contract.rs‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ mod tests {
2020
use edgezero_adapter_cloudflare::context::CloudflareRequestContext;
2121
use edgezero_adapter_cloudflare::request::{CloudflareService, into_core_request};
2222
use edgezero_adapter_cloudflare::response::from_core_response;
23-
use edgezero_core::app::App;
23+
use edgezero_core::app::{App, Hooks};
2424
use edgezero_core::body::Body;
2525
use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle};
2626
use edgezero_core::context::RequestContext;
@@ -150,6 +150,32 @@ mod tests {
150150
(env, Context::new(js_context))
151151
}
152152

153+
#[wasm_bindgen_test]
154+
async fn dispatch_app_reuses_router_with_fresh_request_bodies() {
155+
struct TestApp;
156+
// This fixture deliberately exercises the default Hooks metadata.
157+
#[expect(
158+
clippy::missing_trait_methods,
159+
reason = "exercise default Hooks metadata and construction"
160+
)]
161+
impl Hooks for TestApp {
162+
fn routes() -> RouterService {
163+
build_test_app().router().clone()
164+
}
165+
}
166+
let app = TestApp::build_app();
167+
for body in [b"first".as_slice(), b"second".as_slice()] {
168+
let (env, ctx) = test_env_ctx();
169+
let request = cf_request(CfMethod::Post, "/mirror", Some(body));
170+
let mut response =
171+
edgezero_adapter_cloudflare::dispatch_app::<TestApp>(&app, request, env, ctx)
172+
.await
173+
.expect("prebuilt dispatch response");
174+
assert_eq!(response.status_code(), 200);
175+
assert_eq!(response.bytes().await.expect("response bytes"), body);
176+
}
177+
}
178+
153179
#[wasm_bindgen_test]
154180
async fn dispatch_passes_request_body_to_handlers() {
155181
let app = build_test_app();

0 commit comments

Comments
 (0)