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
10 changes: 10 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ members = [
"examples/esi_try_example",
"examples/esi_vars_example",
"examples/esi_example_variants",
"examples/esi_dca_example",
]
resolver = "2"

Expand Down
2 changes: 1 addition & 1 deletion esi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ chrono = { version = "0.4", default-features = false, features = [
"clock",
"std",
] }
rand = "0.10.0"
rand = "0.10.1"

[dev-dependencies]
esi = { path = ".", features = ["expose-internals"] }
Expand Down
81 changes: 81 additions & 0 deletions esi/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::cache::CacheConfig;
use crate::parser_types::DcaMode;

/// This struct is used to configure optional behaviour within the ESI processor.
///
Expand All @@ -25,6 +26,25 @@ pub struct Configuration {
pub function_recursion_depth: usize,
/// Size of the read buffer (in bytes) used when streaming ESI input (default: 16384)
pub chunk_size: usize,
/// Default DCA mode for includes/evals without an explicit `dca` attribute.
/// When set to `DcaMode::Esi`, fragments are processed as ESI by default
/// (matching Akamai-style behaviour). Default: `DcaMode::None`.
pub default_dca: DcaMode,
/// Maximum nesting depth for ESI includes/evals (default: 15).
/// Per the EdgeSuite ESI spec, up to fifteen levels of nested include
/// statements are supported. When the limit is reached, fragment content
/// is passed through as raw bytes without ESI processing.
pub max_include_depth: usize,
/// Enable parsing of `Edge-Control` response headers for per-fragment DCA
/// directives (e.g. `Edge-Control: dca=esi`). When enabled, the header
/// value overrides `default_dca` but is itself overridden by an explicit
/// `dca` attribute on the tag. Default: `false`.
pub enable_edge_control: bool,
/// When true, an unspecified child fragment inside an explicit `dca=esi`
/// subtree inherits ESI processing instead of falling back to `default_dca`.
/// This lets `dca=esi` "stick" to its children without forcing all
/// top-level includes to default to ESI. Default: `false`.
pub inherit_parent_dca: bool,
}

impl Default for Configuration {
Expand All @@ -34,6 +54,10 @@ impl Default for Configuration {
cache: CacheConfig::default(),
function_recursion_depth: 5,
chunk_size: 16384,
default_dca: DcaMode::None,
max_include_depth: 15,
enable_edge_control: false,
inherit_parent_dca: false,
}
}
}
Expand Down Expand Up @@ -68,4 +92,61 @@ impl Configuration {
self.chunk_size = chunk_size;
self
}

/// Configure the default DCA mode for `<esi:include>` and `<esi:eval>` tags
/// that do not specify an explicit `dca` attribute.
///
/// Set to `DcaMode::Esi` to enable Akamai-style behaviour where all
/// fragments are ESI-processed by default. An explicit `dca="none"` on a
/// tag still opts out.
///
/// Default: `DcaMode::None`.
pub const fn with_default_dca(mut self, dca: DcaMode) -> Self {
self.default_dca = dca;
self
}

/// Configure the maximum nesting depth for ESI includes and evals.
///
/// Per the EdgeSuite ESI spec, up to fifteen levels of nested include
/// statements are supported by default. When the limit is reached,
/// fragment content is passed through as raw bytes without further ESI
/// processing.
///
/// Default: `15`.
pub const fn with_max_include_depth(mut self, depth: usize) -> Self {
self.max_include_depth = depth;
self
}

/// Enable or disable `Edge-Control` response header parsing.
///
/// When enabled, fragment responses may include an `Edge-Control` header
/// with a `dca=esi` or `dca=none` directive to control per-fragment ESI
/// processing. This matches Akamai's "Enable Through Response Headers"
/// property setting.
///
/// Precedence (highest wins): tag attribute → Edge-Control header → `default_dca`.
///
/// Default: `false` (disabled).
pub const fn with_edge_control(mut self, enabled: bool) -> Self {
self.enable_edge_control = enabled;
self
}

/// Enable or disable subtree DCA inheritance.
///
/// When enabled, an unspecified child fragment inside an explicit
/// `dca=esi` subtree is processed as ESI, rather than falling back to
/// the global `default_dca`. Top-level includes (not inside any ESI
/// subtree) still use `default_dca`.
///
/// Precedence (highest wins): tag attribute → Edge-Control header →
/// inherited parent DCA (if enabled & inside subtree) → `default_dca`.
///
/// Default: `false` (disabled).
pub const fn with_inherit_parent_dca(mut self, enabled: bool) -> Self {
self.inherit_parent_dca = enabled;
self
}
}
99 changes: 92 additions & 7 deletions esi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ pub(crate) mod parser_types;

use crate::element_handler::{ElementHandler, Flow};
use crate::expression::EvalContext;
use crate::parser_types::{DcaMode, IncludeAttributes};
pub use crate::parser_types::DcaMode;
use crate::parser_types::IncludeAttributes;
#[cfg(not(feature = "expose-internals"))]
use crate::parser_types::{Element, Expr};
use bytes::{Bytes, BytesMut};
use fastly::http::request::{select, PendingRequest};
use fastly::http::{header, Method, StatusCode, Url};
use fastly::{mime, Backend, Request, Response};
use log::debug;
use log::{debug, warn};
use std::borrow::Cow;
use std::collections::{HashMap, VecDeque};
use std::io::{BufRead, Write};
Expand Down Expand Up @@ -80,8 +81,10 @@ struct FragmentMetadata {
continue_on_error: bool,
/// Optional timeout in milliseconds for this specific request
maxwait: Option<u32>,
/// Dynamic Content Assembly mode for this request I(controls pre-processing)
dca: DcaMode,
/// Dynamic Content Assembly mode from the tag attribute.
/// `None` means no explicit attribute — resolved at response time using
/// Edge-Control header (if enabled) or config default.
dca: Option<DcaMode>,
}

/// Representation of an ESI fragment request with its metadata and pending response
Expand Down Expand Up @@ -214,6 +217,8 @@ pub struct Processor {
configuration: Configuration,
// Queue for pending fragments and blocked content
queue: VecDeque<QueuedElement>,
// Current include nesting depth (0 = top-level document)
include_depth: usize,
}

/// [`ElementHandler`] implementation for top-level ESI document processing.
Expand Down Expand Up @@ -299,6 +304,9 @@ impl<W: Write> ElementHandler for DocumentHandler<'_, W> {
});
}

// Resolve DCA mode from tag attr / Edge-Control header / config default
let dca_mode = self.processor.resolve_dca(fragment.metadata.dca, &response);

// Get the response body
let body_bytes = response.into_body_bytes();
let body_as_bytes = Bytes::from(body_bytes);
Expand All @@ -315,7 +323,15 @@ impl<W: Write> ElementHandler for DocumentHandler<'_, W> {
)));
}

if fragment.metadata.dca == DcaMode::Esi {
if dca_mode == DcaMode::Esi {
// Depth limit reached — silently omit fragment (per Akamai behaviour)
if self.processor.include_depth
>= self.processor.configuration.max_include_depth
{
warn!("ESI include depth limit ({}) exceeded for eval fragment {eval_url}, omitting", self.processor.configuration.max_include_depth);
return Ok(Flow::Continue);
}

// dca="esi": TWO-PHASE processing
// Phase 1: Process fragment in ISOLATED context
// Reborrow before the exclusive borrow of self.processor below
Expand All @@ -325,6 +341,7 @@ impl<W: Write> ElementHandler for DocumentHandler<'_, W> {
Some(self.processor.ctx.get_request().clone_without_body()),
self.processor.configuration.clone(),
);
isolated_processor.include_depth = self.processor.include_depth + 1;
let mut isolated_output = Vec::new();

{
Expand Down Expand Up @@ -370,13 +387,24 @@ impl<W: Write> ElementHandler for DocumentHandler<'_, W> {
}
}
} else {
// Depth limit reached — silently omit fragment (per Akamai behaviour)
if self.processor.include_depth
>= self.processor.configuration.max_include_depth
{
warn!("ESI include depth limit ({}) exceeded for eval fragment {eval_url}, omitting", self.processor.configuration.max_include_depth);
return Ok(Flow::Continue);
}

// dca="none": SINGLE-PHASE processing in PARENT's context
// Fragment included first, then executed in parent (variables affect parent)
self.processor.include_depth += 1;
for element in elements {
if matches!(self.process(&element)?, Flow::Break) {
self.processor.include_depth -= 1;
return Ok(Flow::Break); // Propagate break from eval'd content
}
}
self.processor.include_depth -= 1;
}

Ok(Flow::Continue)
Expand Down Expand Up @@ -433,6 +461,7 @@ impl Processor {
ctx,
configuration,
queue: VecDeque::new(),
include_depth: 0,
}
}

Expand Down Expand Up @@ -1724,10 +1753,11 @@ impl Processor {

// Check if successful
if final_response.get_status().is_success() {
let dca_mode = self.resolve_dca(fragment.metadata.dca, &final_response);
let body_bytes = final_response.into_body_bytes();
self.process_fragment_body(
body_bytes,
fragment.metadata.dca,
dca_mode,
&fragment_url,
output_writer,
dispatch_fragment_request,
Expand Down Expand Up @@ -1755,10 +1785,11 @@ impl Processor {
alt_response
};

let dca_mode = self.resolve_dca(fragment.metadata.dca, &final_alt);
let body_bytes = final_alt.into_body_bytes();
self.process_fragment_body(
body_bytes,
fragment.metadata.dca,
dca_mode,
&String::from_utf8_lossy(&alt_src),
output_writer,
dispatch_fragment_request,
Expand Down Expand Up @@ -1786,6 +1817,30 @@ impl Processor {
}
}

/// Resolve the effective DCA mode for a fragment, applying precedence:
/// tag attribute > Edge-Control header > inherited parent (if enabled & depth>0) > default_dca
fn resolve_dca(&self, tag_dca: Option<DcaMode>, response: &Response) -> DcaMode {
// 1. Explicit tag attribute wins
if let Some(mode) = tag_dca {
return mode;
}

// 2. Edge-Control header (if enabled)
if self.configuration.enable_edge_control {
if let Some(dca) = parse_edge_control_dca(response) {
return dca;
}
}

// 3. Inherit parent DCA (if enabled and inside an ESI subtree)
if self.configuration.inherit_parent_dca && self.include_depth > 0 {
return DcaMode::Esi;
}

// 4. Configuration default
self.configuration.default_dca
}

/// Process fragment body based on dca mode
/// - dca="esi": Parse and process content as ESI
/// - dca="none": Write raw content
Expand All @@ -1799,6 +1854,15 @@ impl Processor {
process_fragment_response: Option<&FragmentResponseProcessor>,
) -> Result<()> {
if dca_mode == DcaMode::Esi {
// Depth limit reached — silently omit fragment (per Akamai behaviour)
if self.include_depth >= self.configuration.max_include_depth {
warn!(
"ESI include depth limit ({}) exceeded for fragment {url}, omitting",
self.configuration.max_include_depth
);
return Ok(());
}

// Parse and process the content as ESI
let body_as_bytes = Bytes::from(body_bytes);
let (rest, elements) = parser::parse_complete(&body_as_bytes).map_err(|e| {
Expand All @@ -1820,6 +1884,7 @@ impl Processor {
Some(self.ctx.get_request().clone_without_body()),
self.configuration.clone(),
);
isolated_processor.include_depth = self.include_depth + 1;

{
let mut handler = DocumentHandler {
Expand Down Expand Up @@ -1849,6 +1914,26 @@ impl Processor {
/// Only emitted for HTML content (when `is_escaped_content` is true).
const FRAGMENT_REQUEST_FAILED: &[u8] = b"<!-- fragment request failed -->";

/// Parse the `Edge-Control` response header for a `dca=` directive.
///
/// Per the EdgeSuite ESI spec, the header form is `Edge-control:dca=esi`.
/// The `dca=` directive may appear alongside other directives (e.g.
/// `Edge-Control: no-store, dca=esi`).
/// Recognised values: `esi` (process as ESI) and `noop` (do not process).
/// Returns `None` if the header is absent or the value is unrecognised.
fn parse_edge_control_dca(response: &Response) -> Option<DcaMode> {
let header_value = response.get_header_str("edge-control")?;
let lower = header_value.to_ascii_lowercase();
let dca_pos = lower.find("dca=")?;
let after_eq = &lower[dca_pos + 4..];
let value = after_eq.split([',', ' ']).next()?;
match value {
"esi" => Some(DcaMode::Esi),
"noop" => Some(DcaMode::None),
_ => None,
}
}

/// Evaluate an [`Expr`] to a [`Bytes`] value.
///
/// Free function (not a `Processor` method) so callers can independently borrow other
Expand Down
10 changes: 5 additions & 5 deletions esi/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1048,11 +1048,11 @@ fn extract_include_attrs(mut attrs: Attrs<'_>, params: Vec<(String, Expr)>) -> I
let alt = attrs_remove(&mut attrs, "alt").map(parse_attr_as_expr);
let continue_on_error = attrs_get(&attrs, "onerror").is_some_and(|v| v == "continue");

// Parse dca attribute - default to None
let dca = if attrs_get(&attrs, "dca").is_some_and(|v| v.eq_ignore_ascii_case("esi")) {
DcaMode::Esi
} else {
DcaMode::None
// Parse dca attribute - None means not specified (inherits config default)
let dca = match attrs_get(&attrs, "dca") {
Some(v) if v.eq_ignore_ascii_case("esi") => Some(DcaMode::Esi),
Some(_) => Some(DcaMode::None),
None => None,
};

let ttl = attrs_remove(&mut attrs, "ttl").map(ToOwned::to_owned);
Expand Down
5 changes: 3 additions & 2 deletions esi/src/parser_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ pub struct IncludeAttributes {
pub alt: Option<Expr>,
/// Whether to continue on error (from onerror="continue")
pub continue_on_error: bool,
/// Dynamic Content Assembly mode - controls pre-processing
pub dca: DcaMode,
/// Dynamic Content Assembly mode - controls pre-processing.
/// `None` means no explicit attribute was set (inherits config default).
pub dca: Option<DcaMode>,
/// Time-To-Live for caching (e.g., "120m", "1h", "2d", "0s")
pub ttl: Option<String>,
/// Timeout in milliseconds for the request
Expand Down
Loading
Loading