Skip to content

Commit 5060a2c

Browse files
authored
Merge pull request #43 from fastly/streaming-processing
Implement nom-based ESI parser with streaming support
2 parents e35dc20 + 3ade5ef commit 5060a2c

34 files changed

Lines changed: 16180 additions & 3646 deletions

Cargo.lock

Lines changed: 773 additions & 104 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ members = [
88
"examples/esi_vars_example",
99
"examples/esi_example_variants",
1010
]
11+
resolver = "2"
1112

1213
[workspace.package]
13-
version = "0.6.2"
14+
version = "0.7.0-beta.3"
1415
authors = [
1516
"Kailan Blanks <kblanks@fastly.com>",
1617
"Vadim Getmanshchuk <vadim@fastly.com>",
1718
"Tyler McMullen <tyler@fastly.com>",
1819
]
1920
license = "MIT"
20-
edition = "2018"
21+
edition = "2021"

README.md

Lines changed: 281 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,265 @@
22

33
This crate provides a streaming Edge Side Includes parser and executor designed for Fastly Compute.
44

5-
The implementation is a subset of the [ESI Language Specification 1.0](https://www.w3.org/TR/esi-lang/) supporting the following tags:
5+
The implementation is a subset of Akamai ESI 5.0 supporting the following tags:
66

7-
- `<esi:include>` (+ `alt`, `onerror="continue"`)
7+
- `<esi:include>`
8+
- `<esi:eval>` - evaluates included content as ESI
89
- `<esi:try>` | `<esi:attempt>` | `<esi:except>`
9-
- `<esi:vars>` | `<esi:assign>`
10+
- `<esi:vars>` | `<esi:assign>` (with subscript support for dict/list assignment)
1011
- `<esi:choose>` | `<esi:when>` | `<esi:otherwise>`
12+
- `<esi:foreach>` | `<esi:break>` (loop over lists and dicts)
13+
- `<esi:function>` | `<esi:return>` (user-defined functions)
1114
- `<esi:comment>`
1215
- `<esi:remove>`
16+
- `<esi:text>` (raw passthrough — content is emitted verbatim, no ESI processing)
17+
18+
**Note:** The following tags support nested ESI tags: `<esi:try>`, `<esi:attempt>`, `<esi:except>`, `<esi:choose>`, `<esi:when>`, `<esi:otherwise>`, `<esi:foreach>`, `<esi:function>`, and `<esi:assign>` (long form only).
19+
20+
**Dynamic Content Assembly (DCA)**: Both `<esi:include>` and `<esi:eval>` support the `dca` attribute:
21+
22+
- `dca="none"` (default): For `include`, inserts raw content without ESI processing. For `eval`, fragment executes in parent's context (variables shared).
23+
- `dca="esi"`: Two-phase processing: fragment is first processed in an isolated context, then the output is processed in parent's context (variables from phase 1 don't leak, but output can contain ESI tags).
24+
25+
**Include vs Eval**:
26+
27+
- `<esi:include>`: Fetches content from origin
28+
- `dca="none"`: Inserts content verbatim (no ESI processing)
29+
- `dca="esi"`: Parses and evaluates content as ESI before insertion
30+
- `<esi:eval>`: Fetches content and **always** parses it as ESI (blocking operation)
31+
- `dca="none"`: Evaluates in parent's namespace (variables from fragment affect parent)
32+
- `dca="esi"`: **Two-phase**: Phase 1 processes fragment in isolated context (variables set here stay isolated), then Phase 2 processes the output in parent's context (output can contain ESI that accesses parent variables)
33+
34+
### Include/Eval Attributes
35+
36+
Both `<esi:include>` and `<esi:eval>` support the following attributes:
37+
38+
**Required:**
39+
40+
- `src="url"` - Source URL to fetch (supports ESI expressions)
41+
42+
**Fallback & Error Handling:**
43+
44+
- `alt="url"` - Fallback URL if primary request fails (include only, eval uses try/except)
45+
- `onerror="continue"` - On error, delete the tag with no output (continue processing without failing)
46+
47+
**Content Processing:**
48+
49+
- `dca="none|esi"` - Dynamic Content Assembly mode (default: `none`)
50+
- `none`: For include, insert content as-is. For eval, process in parent's context (single-phase).
51+
- `esi`: For include, parse and evaluate as ESI. For eval, two-phase processing: first in isolated context, then output processed in parent context.
52+
53+
**Caching:**
54+
55+
- `ttl="duration"` - Cache time-to-live (e.g., `"120m"`, `"1h"`, `"2d"`, `"0s"` to disable)
56+
- `no-store="on|off"` - Enable/disable cache bypass (`on` bypasses cache, `off` leaves caching enabled)
57+
58+
**Request Configuration:**
59+
60+
- `maxwait="milliseconds"` - Request timeout in milliseconds
61+
- `method="GET|POST"` - HTTP method (default: `GET`)
62+
- `entity="body"` - Request body for POST requests
63+
64+
**Headers:**
65+
66+
- `appendheaders="header:value"` - Append headers to the request
67+
- `removeheaders="header1,header2"` - Remove headers from the request
68+
- `setheaders="header:value"` - Set/replace headers on the request
69+
70+
**Parameters:**
71+
72+
- Nested `<esi:param name="key" value="val"/>` elements append query parameters to the URL
73+
74+
**Example:**
75+
76+
```html
77+
<esi:include src="http://api.example.com/user" alt="http://cache.example.com/user" dca="esi" ttl="5m" maxwait="1000" onerror="continue">
78+
<esi:param name="id" value="$(user_id)" />
79+
<esi:param name="format" value="'json'" />
80+
</esi:include>
81+
```
1382

1483
Other tags will be ignored and served to the client as-is.
1584

16-
This implementation also includes an expression interpreter and library of functions that can be used. Current functions include:
85+
### Expression Features
86+
87+
- **Integer literals**: `42`, `-10`, `0`
88+
- **String literals**: `'single quoted'`, `"double quoted"`, `'''triple quoted'''`
89+
- **Dict literals**: `{'key1': 'value1', 'key2': 'value2'}`
90+
- **List literals**: `['item1', 'item2', 'item3']`
91+
- **Nested structures**: Lists can be nested: `['one', ['a', 'b', 'c'], 'three']`
92+
- **Subscript assignment**: `<esi:assign name="dict{'key'}" value="val"/>` or `<esi:assign name="list{0}" value="val"/>`
93+
- **Subscript access**: `$(dict{'key'})` or `$(list{0})`
94+
- **Foreach loops**: Iterate over lists or dicts with `<esi:foreach>` and use `<esi:break>` to exit early
95+
- **Comparison operators**: `==`, `!=`, `<`, `>`, `<=`, `>=`, `has`, `has_i`, `matches`, `matches_i`
96+
- `has` - Case-sensitive substring containment: `$(str) has 'substring'`
97+
- `has_i` - Case-insensitive substring containment: `$(str) has_i 'substring'`
98+
- `matches` - Case-sensitive regex matching: `$(str) matches 'pattern'`
99+
- `matches_i` - Case-insensitive regex matching: `$(str) matches_i 'pattern'`
100+
- **Logical operators**: `&&` (and), `||` (or), `!` (not)
101+
102+
### Function Library
103+
104+
This implementation includes a comprehensive library of ESI functions:
105+
106+
**String Manipulation:**
107+
108+
- `$lower(string)` - Convert to lowercase
109+
- `$upper(string)` - Convert to uppercase
110+
- `$lstrip(string)`, `$rstrip(string)`, `$strip(string)` - Remove whitespace
111+
- `$substr(string, start [, length])` - Extract substring
112+
- `$replace(haystack, needle, replacement [, count])` - Replace occurrences
113+
- `$str(value)` - Convert to string
114+
- `$join(list, separator)` - Join list elements
115+
- `$string_split(string, delimiter [, maxsplit])` - Split string into list
116+
117+
**Encoding/Decoding:**
118+
119+
- `$html_encode(string)`, `$html_decode(string)` - HTML entity encoding
120+
- `$url_encode(string)`, `$url_decode(string)` - URL encoding
121+
- `$base64_encode(string)`, `$base64_decode(string)` - Base64 encoding/decoding
122+
- `$convert_to_unicode(string)`, `$convert_from_unicode(string)` - Unicode conversion
123+
124+
**Quote Helpers:**
125+
126+
- `$dollar()` - Returns `$`
127+
- `$dquote()` - Returns `"`
128+
- `$squote()` - Returns `'`
129+
130+
**Type Conversion & Checks:**
131+
132+
- `$int(value)` - Convert to integer
133+
- `$exists(value)` - Check if value exists
134+
- `$is_empty(value)` - Check if value is empty
135+
- `$len(value)` - Get length of string or list
136+
137+
**List Operations:**
138+
139+
- `$list_delitem(list, index)` - Remove item from list
140+
- `$index(string, substring)`, `$rindex(string, substring)` - Find substring position
141+
142+
**Cryptographic:**
143+
144+
- `$digest_md5(string)` - Generate MD5 hash (binary)
145+
- `$digest_md5_hex(string)` - Generate MD5 hash (hex string)
146+
147+
**Time/Date:**
148+
149+
- `$time()` - Current Unix timestamp
150+
- `$http_time(timestamp)` - Format timestamp as HTTP date
151+
- `$strftime(timestamp, format)` - Format timestamp with custom format
152+
- `$bin_int(binary_string)` - Convert binary string to integer
153+
154+
**Random & Response:**
17155

18-
- `$lower(string)`
19-
- `$html_encode(string)`
20-
- `$replace(haystack, needle, replacement [, count])`
156+
- `$rand()` - Generate random number
157+
- `$last_rand()` - Get last generated random number
158+
159+
**Response Manipulation:**
160+
161+
These functions modify the HTTP response sent to the client:
162+
163+
- `$add_header(name, value)` - Add a custom response header
164+
```html
165+
<esi:vars>$add_header('X-Custom-Header', 'my-value')</esi:vars>
166+
```
167+
- `$set_response_code(code [, body])` - Set HTTP status code and optionally override response body
168+
```html
169+
<esi:vars>$set_response_code(404, 'Page not found')</esi:vars>
170+
```
171+
- `$set_redirect(url)` - Set HTTP redirect (302 Moved Temporarily)
172+
```html
173+
<esi:vars>$set_redirect('https://example.com/new-location')</esi:vars> <esi:vars>$set_redirect('https://example.com/moved'</esi:vars>
174+
```
175+
176+
**Diagnostic:**
177+
178+
- `$ping()` - Returns the string `"pong"` (useful for testing)
179+
180+
**Note:** Response manipulation functions are buffered during ESI processing and applied when `process_response()` sends the final response to the client.
181+
182+
### User-Defined Functions
183+
184+
You can define reusable functions with `<esi:function>` and return values with `<esi:return>`:
185+
186+
```html
187+
<esi:function name="greet">
188+
<esi:assign name="greeting" value="'Hello, ' + $(ARGS{0}) + '!'" />
189+
<esi:return value="$(greeting)" />
190+
</esi:function>
191+
192+
<esi:vars>$greet('World')</esi:vars>
193+
```
194+
195+
- `<esi:function name="...">` defines a function; the body can contain any ESI tags.
196+
- `<esi:return value="..."/>` returns a value from the function.
197+
- Inside a function body, `$(ARGS)` is a list of the positional arguments passed to the call, and individual arguments can be accessed with `$(ARGS{0})`, `$(ARGS{1})`, etc.
198+
- Functions support recursion up to the configured depth (default: 5, see [Configuration](#configuration)).
199+
- User-defined functions take priority over built-in functions of the same name.
200+
201+
### Built-in Variables
202+
203+
The following variables are available in ESI expressions:
204+
205+
**Request metadata:**
206+
207+
- `$(REQUEST_METHOD)` - HTTP method of the original client request (e.g. `GET`)
208+
- `$(REQUEST_PATH)` - Path component of the request URL
209+
- `$(QUERY_STRING)` - Raw query string from the request URL
210+
- `$(REMOTE_ADDR)` - Client IP address
211+
212+
**HTTP headers:**
213+
214+
- `$(HTTP_<HEADER>)` - Value of the named request header (e.g. `$(HTTP_HOST)`, `$(HTTP_ACCEPT)`)
215+
- `$(HTTP_COOKIE{'name'})` - Value of a specific cookie from the `Cookie` header
216+
217+
**Regex captures:**
218+
219+
- `$(MATCHES{0})`, `$(MATCHES{1})`, … - Capture groups from the last `matches` / `matches_i` operator or `<esi:when matchname="...">` test
220+
221+
### Configuration
222+
223+
`Configuration` controls the processor's runtime behaviour. All fields have sensible defaults and can be customised with builder methods:
224+
225+
```rust,no_run
226+
let config = esi::Configuration::default()
227+
.with_escaped(true) // unescape HTML entities in URLs (default: true)
228+
.with_chunk_size(32768) // streaming read buffer, in bytes (default: 16384)
229+
.with_function_recursion_depth(10) // max depth for user-defined function calls (default: 10)
230+
.with_caching(esi::CacheConfig {
231+
is_rendered_cacheable: true,
232+
rendered_cache_control: true,
233+
rendered_ttl: Some(600),
234+
is_includes_cacheable: true,
235+
includes_default_ttl: Some(300),
236+
includes_force_ttl: None,
237+
});
238+
```
239+
240+
| Field | Builder method | Default | Description |
241+
| -------------------------- | ------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------- |
242+
| `is_escaped_content` | `with_escaped(bool)` | `true` | Unescape HTML entities in URLs. Set to `false` for non-HTML templates (e.g. JSON). |
243+
| `chunk_size` | `with_chunk_size(usize)` | `16384` | Size (bytes) of the read buffer used when streaming ESI input. Larger values may improve throughput; smaller values reduce memory. |
244+
| `function_recursion_depth` | `max_function_recursion_depth(usize)` | `5` | Maximum call-stack depth for user-defined ESI functions. |
245+
| `cache` | `with_caching(CacheConfig)` | see below | Cache settings for rendered output and included fragments. |
246+
247+
**`CacheConfig` fields:**
248+
249+
| Field | Default | Description |
250+
| ------------------------ | ------- | ---------------------------------------------------------------- |
251+
| `is_rendered_cacheable` | `false` | Whether the final rendered output is cacheable. |
252+
| `rendered_cache_control` | `false` | Emit a `Cache-Control` header on the rendered response. |
253+
| `rendered_ttl` | `None` | TTL (seconds) for the rendered response. |
254+
| `is_includes_cacheable` | `false` | Whether individual include responses should be cached. |
255+
| `includes_default_ttl` | `None` | Default TTL (seconds) for cached includes. |
256+
| `includes_force_ttl` | `None` | Force a specific TTL on all includes, overriding origin headers. |
21257

22258
## Example Usage
23259

260+
### Streaming Processing (Recommended)
261+
262+
The recommended approach uses streaming to process the document as it arrives, minimizing memory usage and latency:
263+
24264
```rust,no_run
25265
use fastly::{http::StatusCode, mime, Error, Request, Response};
26266
@@ -51,14 +291,15 @@ fn handle_request(req: Request) -> Result<(), Error> {
51291
esi::Configuration::default()
52292
);
53293
294+
// Stream the ESI response directly to the client
54295
processor.process_response(
55-
// The ESI source document. Note that the body will be consumed.
296+
// The ESI source document. Body will be consumed and streamed.
56297
&mut beresp,
57298
// Optionally provide a template for the client response.
58299
Some(Response::from_status(StatusCode::OK).with_content_type(mime::TEXT_HTML)),
59300
// Provide logic for sending fragment requests, otherwise the hostname
60301
// of the request URL will be used as the backend name.
61-
Some(&|req| {
302+
Some(&|req, _maxwait| {
62303
println!("Sending request {} {}", req.get_method(), req.get_path());
63304
Ok(req.with_ttl(120).send_async("mock-s3")?.into())
64305
}),
@@ -82,6 +323,37 @@ fn handle_request(req: Request) -> Result<(), Error> {
82323
}
83324
```
84325

326+
### Custom Stream Processing
327+
328+
For advanced use cases, you can process any `BufRead` source and write to any `Write` destination:
329+
330+
```rust,no_run
331+
use std::io::{BufReader, Write};
332+
use esi::{Processor, Configuration};
333+
334+
fn process_custom_stream(
335+
input: impl std::io::Read,
336+
output: &mut impl Write,
337+
) -> Result<(), esi::ESIError> {
338+
let mut processor = Processor::new(None, Configuration::default());
339+
340+
// Process from any readable source
341+
let reader = BufReader::new(input);
342+
343+
processor.process_stream(
344+
reader,
345+
output,
346+
Some(&|req, _maxwait| {
347+
// Custom fragment dispatcher
348+
Ok(req.send_async("backend")?.into())
349+
}),
350+
None,
351+
)?;
352+
353+
Ok(())
354+
}
355+
```
356+
85357
See example applications in the [`examples`](./examples) subdirectory or read the hosted documentation at [docs.rs/esi](https://docs.rs/esi). Due to the fact that this processor streams fragments to the client as soon as they are available, it is not possible to return a relevant status code for later errors once we have started streaming the response to the client. For this reason, it is recommended that you refer to the [`esi_example_advanced_error_handling`](./examples/esi_example_advanced_error_handling) application, which allows you to handle errors gracefully by maintaining ownership of the output stream.
86358

87359
## Testing

esi/Cargo.toml

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,38 @@ description = "A streaming parser and executor for Edge Side Includes"
88
repository = "https://github.com/fastly/esi"
99
readme = "./README.md"
1010

11+
[features]
12+
expose-internals = []
13+
1114
[dependencies]
12-
quick-xml = "0.38.0"
1315
thiserror = "2.0.6"
1416
fastly = "^0.11"
1517
log = "^0.4"
1618
regex = "1.11.1"
1719
html-escape = "0.2.13"
20+
nom = "8"
21+
bytes = "1.5"
22+
atoi = "2"
23+
base64 = "0.22"
24+
percent-encoding = "2.3"
25+
md5 = "0.8.0"
26+
chrono = { version = "0.4", default-features = false, features = [
27+
"clock",
28+
"std",
29+
] }
30+
rand = "0.10.0"
1831

1932
[dev-dependencies]
33+
esi = { path = ".", features = ["expose-internals"] }
2034
env_logger = "^0.11"
35+
criterion = { version = "0.5", default-features = false }
36+
37+
[[bench]]
38+
name = "parser_benchmarks"
39+
harness = false
40+
required-features = ["expose-internals"]
41+
42+
[[bench]]
43+
name = "interpolated_text_bench"
44+
harness = false
45+
required-features = ["expose-internals"]

0 commit comments

Comments
 (0)