Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
585cbc5
docs: add State<T> + nested #[secret] design spec
aram356 Jul 2, 2026
304e236
docs: fold second-pass review blockers into spec §8
aram356 Jul 2, 2026
051a9ad
Merge PR #300 (introspection routes) as base for State<T> + nested se…
aram356 Jul 3, 2026
c208524
docs: implementation plans for State<T> + nested secrets; reconcile s…
aram356 Jul 3, 2026
d51e8b9
feat(core): add State<T> extractor for app-owned shared state
aram356 Jul 3, 2026
4487355
feat(core): RouterBuilder::with_state injects app state into request …
aram356 Jul 3, 2026
21c1488
test(macros): prove #[action] composes State<T>; docs: sharing app state
aram356 Jul 3, 2026
a632669
chore: sync Cargo.lock for edgezero-macros futures dev-dep
aram356 Jul 3, 2026
6ebc29a
docs: fold review-round-4 plan fixes; reconcile spec §4.3 with fn sec…
aram356 Jul 3, 2026
44b32ad
refactor(secrets): owned path-qualified SecretField + AppConfigMeta::…
aram356 Jul 3, 2026
f8132bd
refactor(core): store app state in an Extensions bag, not closure ins…
aram356 Jul 3, 2026
d390874
feat(secrets): path-navigating secret_walk (nested objects, arrays, o…
aram356 Jul 3, 2026
fe7bd72
feat(secrets): nested/list-aware validate_excluding_secrets pruning
aram356 Jul 3, 2026
9351db1
feat(macros): #[app_config(nested)] recursion, Option<String> secrets…
aram356 Jul 3, 2026
e26529b
ci(secrets): allow nested AppConfig when field opts in via #[app_conf…
aram356 Jul 3, 2026
4ce2bc5
feat(cli): path-aware secret reflection in config validate/push/diff
aram356 Jul 3, 2026
aa8b67a
test(secrets): end-to-end nested + named-store resolution; docs: nest…
aram356 Jul 3, 2026
47c827f
fix(macros): hard-error empty/bare #[app_config]; refresh stale SECRE…
aram356 Jul 3, 2026
aaf6e28
test(cli): nested config push preserves key names; nested config diff…
aram356 Jul 3, 2026
8c2e8a7
docs: mark superseded StateInserter steps; correct derive empty-app_c…
aram356 Jul 3, 2026
af1e40c
docs: retire remaining stale SECRET_FIELDS / StateInserter wording in…
aram356 Jul 3, 2026
47a112c
docs: last stale SECRET_FIELDS in a config.rs test comment
aram356 Jul 3, 2026
65afbd3
docs: P0-C/P0-D spec (Fastly dispatch fidelity + macro app-state), ma…
aram356 Jul 4, 2026
c86c039
docs: implementation plans for P0-C (Fastly dispatch fidelity) + P0-D…
aram356 Jul 4, 2026
2174a54
docs(p0c plan): cover FastlyService::dispatch caller; add app! owns_l…
aram356 Jul 5, 2026
26f694c
docs(p0 plans): C3 handler-visible test; app-demo clippy (separate wo…
aram356 Jul 5, 2026
a8b6046
docs(p0c plan): correct run_app_with_config dispatch path (via Fastly…
aram356 Jul 5, 2026
b11c71e
fix(fastly): preserve multi-value response headers (Set-Cookie) in fr…
aram356 Jul 5, 2026
2332aeb
docs(p0c plan): correct fastly test invocation (wasm32-wasip1 + Vicer…
aram356 Jul 5, 2026
02a0292
fix(fastly): preserve multi-value headers in proxy response/request c…
aram356 Jul 5, 2026
fe5b675
feat: Hooks::owns_logging() opt-out gated in all four adapter run_app…
aram356 Jul 5, 2026
5795fe2
docs(p0c plan): targeted fastly Viceroy filters (blanket --lib aborts…
aram356 Jul 5, 2026
3518fc3
feat(macros): app!(owns_logging = <bool>) keyword argument + AppArgs …
aram356 Jul 5, 2026
44bd0d0
feat(fastly): run_app_with_request_extensions pre-dispatch hook for r…
aram356 Jul 5, 2026
77b001a
feat(macros): app!(state = <expr>) emits RouterBuilder::with_state fo…
aram356 Jul 5, 2026
6feed2a
docs(app-demo): app!(state = ...) + State<T> handler example with end…
aram356 Jul 5, 2026
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion crates/edgezero-adapter-axum/src/dev_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,9 @@ pub fn run_app<A: Hooks>() -> anyhow::Result<()> {
.and_then(|raw| LevelFilter::from_str(raw).ok())
.unwrap_or(LevelFilter::Info);

let _logger_init = SimpleLogger::new().with_level(level).init();
if !A::owns_logging() {
let _logger_init = SimpleLogger::new().with_level(level).init();
}

let resolution = resolve_addr(&env);
for warning in &resolution.warnings {
Expand Down
7 changes: 5 additions & 2 deletions crates/edgezero-adapter-cloudflare/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,11 @@ pub async fn run_app<A: Hooks>(
ctx: Context,
) -> Result<Response, WorkerError> {
// Best-effort: if a logger is already installed, ignore the error rather
// than panicking — every Worker request re-enters this function.
drop(init_logger());
// than panicking — every Worker request re-enters this function. Skipped
// entirely when the app owns logging.
if !A::owns_logging() {
drop(init_logger());
}
let stores = A::stores();
let env_config = env_config_from_worker(&env, stores);
let app = A::build_app();
Expand Down
37 changes: 34 additions & 3 deletions crates/edgezero-adapter-fastly/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ use edgezero_core::app::{Hooks, StoresMetadata};
#[cfg(feature = "fastly")]
use edgezero_core::env_config::EnvConfig;
#[cfg(feature = "fastly")]
use edgezero_core::http::Extensions;
#[cfg(feature = "fastly")]
use edgezero_core::manifest::ResolvedLoggingConfig;
#[cfg(feature = "fastly")]
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -111,15 +113,44 @@ fn logging_from_env(env: &EnvConfig) -> FastlyLogging {
#[cfg(feature = "fastly")]
#[inline]
pub fn run_app<A: Hooks>(req: fastly::Request) -> Result<fastly::Response, fastly::Error> {
run_app_with_request_extensions::<A, _>(req, |_req, _extensions| {})
}

/// Like [`run_app`], but runs `extend` against a scratch
/// [`Extensions`](edgezero_core::http::Extensions) populated from the raw
/// `fastly::Request` (TLS JA4, H2 fingerprint, client IP, …) before the request
/// is converted; the scratch values are merged into the core request's
/// extensions and are visible to middleware and the `State`/extractor layer.
///
/// # Errors
/// Returns an error if logger setup fails or any required store cannot be opened.
#[cfg(feature = "fastly")]
#[inline]
pub fn run_app_with_request_extensions<A, F>(
req: fastly::Request,
extend: F,
) -> Result<fastly::Response, fastly::Error>
where
A: Hooks,
F: FnOnce(&fastly::Request, &mut Extensions),
{
let stores = A::stores();
let env = env_config_from_runtime_dictionary(stores);
let logging = logging_from_env(&env);
if logging.use_fastly_logger {
if logging.use_fastly_logger && !A::owns_logging() {
let endpoint = logging.endpoint.as_deref().unwrap_or("stdout");
init_logger(endpoint, logging.level, logging.echo_stdout)?;
}
let app = A::build_app();
request::dispatch_with_registries(&app, req, stores.config, stores.kv, stores.secrets, &env)
request::dispatch_with_registries(
&app,
req,
stores.config,
stores.kv,
stores.secrets,
&env,
extend,
)
}

/// Build an [`EnvConfig`] from the optional `edgezero_runtime_env`
Expand Down Expand Up @@ -202,7 +233,7 @@ pub fn run_app_with_config<A: Hooks>(
req: fastly::Request,
config_store_name: Option<&str>,
) -> Result<fastly::Response, fastly::Error> {
if logging.use_fastly_logger {
if logging.use_fastly_logger && !A::owns_logging() {
let endpoint = logging.endpoint.as_deref().unwrap_or("stdout");
init_logger(endpoint, logging.level, logging.echo_stdout)?;
}
Expand Down
31 changes: 27 additions & 4 deletions crates/edgezero-adapter-fastly/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ fn build_fastly_request(method: Method, uri: &Uri, headers: &HeaderMap) -> Fastl
let mut fastly_request = FastlyRequest::new(method.clone(), uri.to_string());
fastly_request.set_method(method);

// Append (not set) so a multi-value client header survives; `Host` below is
// set explicitly as a single value.
for (name, value) in headers {
if name.as_str().eq_ignore_ascii_case("host") {
continue;
}
fastly_request.set_header(name.as_str(), value.clone());
fastly_request.append_header(name.as_str(), value.clone());
}

if let Some(host) = uri.host() {
Expand All @@ -64,9 +66,13 @@ fn convert_response(fastly_response: &mut FastlyResponse) -> ProxyResponse {
let status = fastly_response.get_status();
let mut proxy_response = ProxyResponse::new(status, Body::empty());

for header in fastly_response.get_header_names() {
if let Some(value) = fastly_response.get_header(header) {
proxy_response.headers_mut().insert(header, value.clone());
// Preserve multi-value ORIGIN response headers (e.g. Set-Cookie): read ALL
// values per name and append, instead of first-value + insert (which
// replaced). `get_header_names()` yields `&HeaderName`, usable for both
// `get_header_all` and `append`.
for name in fastly_response.get_header_names() {
for value in fastly_response.get_header_all(name) {
proxy_response.headers_mut().append(name, value.clone());
}
}

Expand Down Expand Up @@ -212,6 +218,23 @@ mod tests {
}
}

#[test]
fn convert_response_preserves_multi_value_set_cookie() {
let mut fastly_response = FastlyResponse::from_status(200);
fastly_response.append_header("set-cookie", "a=1");
fastly_response.append_header("set-cookie", "b=2");

let proxy_response = convert_response(&mut fastly_response);

let cookies: Vec<String> = proxy_response
.headers()
.get_all("set-cookie")
.into_iter()
.map(|value| value.to_str().expect("utf8").to_owned())
.collect();
assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]);
}

#[test]
fn stream_handles_brotli() {
let mut compressed = Vec::new();
Expand Down
96 changes: 90 additions & 6 deletions crates/edgezero-adapter-fastly/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use edgezero_core::body::Body;
use edgezero_core::config_store::ConfigStoreHandle;
use edgezero_core::env_config::EnvConfig;
use edgezero_core::error::EdgeError;
use edgezero_core::http::{request_builder, Request};
use edgezero_core::http::{request_builder, Extensions, Request};
use edgezero_core::key_value_store::KvHandle;
use edgezero_core::proxy::ProxyHandle;
use edgezero_core::secret_store::SecretHandle;
Expand Down Expand Up @@ -151,6 +151,7 @@ impl<'app> FastlyService<'app> {
secrets,
..Default::default()
},
|_req, _extensions| {},
)
}

Expand Down Expand Up @@ -276,12 +277,31 @@ fn dispatch_core_request(
from_core_response(response).map_err(|err| map_edge_error(&err))
}

fn dispatch_with_handles(
/// Run an app-provided closure against a scratch `Extensions` populated from the
/// RAW `fastly::Request` (JA4 / H2 / etc.), BEFORE `into_core_request` consumes
/// the request. Returns the scratch bag to be `extend`ed into the core request.
fn apply_request_extend<F>(req: &FastlyRequest, extend: F) -> Extensions
where
F: FnOnce(&FastlyRequest, &mut Extensions),
{
let mut scratch = Extensions::default();
extend(req, &mut scratch);
scratch
}

fn dispatch_with_handles<F>(
app: &App,
req: FastlyRequest,
stores: Stores,
) -> Result<FastlyResponse, FastlyError> {
let core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?;
extend: F,
) -> Result<FastlyResponse, FastlyError>
where
F: FnOnce(&FastlyRequest, &mut Extensions),
{
// Read raw-request signals into a scratch bag BEFORE conversion consumes `req`.
let scratch = apply_request_extend(&req, extend);
let mut core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?;
core_request.extensions_mut().extend(scratch);
dispatch_core_request(app, core_request, stores)
}

Expand All @@ -292,14 +312,18 @@ fn dispatch_with_handles(
/// id default). KV failures escalate via [`resolve_kv_handle`]'s
/// `kv_required=true` path; missing config / secret stores degrade silently
/// with a one-time warning.
pub(crate) fn dispatch_with_registries(
pub(crate) fn dispatch_with_registries<F>(
app: &App,
req: FastlyRequest,
config_meta: Option<StoreMetadata>,
kv_meta: Option<StoreMetadata>,
secret_meta: Option<StoreMetadata>,
env: &EnvConfig,
) -> Result<FastlyResponse, FastlyError> {
extend: F,
) -> Result<FastlyResponse, FastlyError>
where
F: FnOnce(&FastlyRequest, &mut Extensions),
{
let kv_registry = build_kv_registry(kv_meta, env)?;
let config_registry = build_config_registry(config_meta, env);
let secret_registry = build_secret_registry(secret_meta, env);
Expand All @@ -312,6 +336,7 @@ pub(crate) fn dispatch_with_registries(
secret_registry,
..Default::default()
},
extend,
)
}

Expand Down Expand Up @@ -573,6 +598,65 @@ mod synthesis_tests {
SecretHandle::new(Arc::new(NoopSecretStore))
}

#[test]
fn apply_request_extend_populates_scratch_from_raw_request() {
use edgezero_core::http::Method;

#[derive(Clone, Debug, PartialEq)]
struct Ja4(String);

let raw = FastlyRequest::new(Method::GET, "http://example.test/");
let scratch = apply_request_extend(&raw, |req, extensions| {
// A real closure would call req.get_tls_ja4(); deriving from the URL
// keeps the assertion deterministic under Viceroy.
let marker = req.get_url_str().to_owned();
extensions.insert(Ja4(marker));
});

assert_eq!(
scratch.get::<Ja4>(),
Some(&Ja4("http://example.test/".to_owned()))
);
}

#[test]
fn extended_request_extensions_are_visible_to_handler() {
use edgezero_core::body::Body;
use edgezero_core::context::RequestContext;
use edgezero_core::http::{request_builder, Method, StatusCode};
use edgezero_core::router::RouterService;
use futures::executor::block_on;

#[derive(Clone)]
struct Ja4(String);

async fn handler(ctx: RequestContext) -> Result<String, EdgeError> {
let ja4 = ctx
.request()
.extensions()
.get::<Ja4>()
.map_or_else(|| "missing".to_owned(), |value| value.0.clone());
Ok(ja4)
}

// Mirror what `dispatch_with_handles` does: a scratch bag built from the
// raw request is `extend`ed into the core request before dispatch.
let mut scratch = Extensions::default();
scratch.insert(Ja4("t13d1516h2".to_owned()));

let mut core_request = request_builder()
.method(Method::GET)
.uri("/ja4")
.body(Body::empty())
.expect("request");
core_request.extensions_mut().extend(scratch);

let service = RouterService::builder().get("/ja4", handler).build();
let response = block_on(service.oneshot(core_request)).expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.body().as_bytes().expect("buffered"), b"t13d1516h2");
}

#[test]
fn synthesis_wraps_bare_kv_handle_under_default_when_no_registry() {
let stores = Stores {
Expand Down
24 changes: 23 additions & 1 deletion crates/edgezero-adapter-fastly/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ pub fn from_core_response(response: Response) -> Result<FastlyResponse, EdgeErro
}
}

// `append_header` preserves multi-value headers (e.g. N `Set-Cookie`). The
// response starts empty (`from_status`) and `http::HeaderMap` iteration
// yields one entry per value, so appending is unconditionally correct.
for (name, value) in &parts.headers {
fastly_response.set_header(name.as_str(), value.as_bytes());
fastly_response.append_header(name.as_str(), value.as_bytes());
}

Ok(fastly_response)
Expand Down Expand Up @@ -57,6 +60,25 @@ mod tests {
assert_eq!(err.status().as_u16(), 400);
}

#[test]
fn multi_value_set_cookie_survives_conversion() {
// http::response::Builder::header APPENDS, so this is two Set-Cookie values.
let response = response_builder()
.status(200)
.header("set-cookie", "a=1")
.header("set-cookie", "b=2")
.body(Body::empty())
.expect("response");

let fastly_response = from_core_response(response).expect("fastly response");

let cookies: Vec<String> = fastly_response
.get_header_all("set-cookie")
.map(|value| value.to_str().expect("utf8").to_owned())
.collect();
assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]);
}

#[test]
fn stream_body_is_written_to_fastly_response() {
let response = response_builder()
Expand Down
2 changes: 1 addition & 1 deletion crates/edgezero-adapter-spin/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@ impl Adapter for SpinCliAdapter {
value = entry.key_value,
));
}
if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name) {
if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name.as_str()) {
return Err(format!(
"Spin variable `{spin_var}` would receive values from BOTH `#[secret]` field `{prev_field}` AND `#[secret]` field `{this_field}`; Spin's flat variable namespace cannot disambiguate them. Pick distinct `#[secret]` values whose lowercased forms differ.",
this_field = entry.field_name,
Expand Down
7 changes: 5 additions & 2 deletions crates/edgezero-adapter-spin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,11 @@ pub fn init_logger() -> Result<(), log::SetLoggerError> {
pub async fn run_app<A: Hooks>(req: SpinRequest) -> anyhow::Result<SpinFullResponse> {
// Best-effort: every Spin `#[http_service]` re-enters this function, so a
// second `log::set_logger` call returns Err — drop the result instead of
// `.expect()` to avoid panicking on every subsequent request.
drop(init_logger());
// `.expect()` to avoid panicking on every subsequent request. Skipped
// entirely when the app owns logging.
if !A::owns_logging() {
drop(init_logger());
}
let env = EnvConfig::from_env();
let stores = A::stores();
let app = A::build_app();
Expand Down
12 changes: 8 additions & 4 deletions crates/edgezero-adapter/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,8 @@ impl<'ctx> AdapterPushContext<'ctx> {
/// v2 source-compat; construction goes through `new`.
#[non_exhaustive]
pub struct TypedSecretEntry<'entry> {
/// Rust struct field name (e.g. `"api_token"`).
pub field_name: &'entry str,
/// Dotted secret-field path label (e.g. `"partners[3].api_key"`).
pub field_name: String,
/// Blob value — i.e. the secret-store KEY NAME.
pub key_value: &'entry str,
/// Logical secret-store id this key targets.
Expand All @@ -188,9 +188,13 @@ impl<'entry> TypedSecretEntry<'entry> {
/// Construct a new entry from its three components.
#[must_use]
#[inline]
pub fn new(store_id: &'entry str, field_name: &'entry str, key_value: &'entry str) -> Self {
pub fn new<Name: Into<String>>(
store_id: &'entry str,
field_name: Name,
key_value: &'entry str,
) -> Self {
Self {
field_name,
field_name: field_name.into(),
key_value,
store_id,
}
Expand Down
Loading