Skip to content

Commit 2f2532d

Browse files
feat: add fast hydration plugin system with parser-handler pipeline (#61)
* feat: add fast hydration plugin system with parser-handler pipeline Introduce a framework-agnostic plugin architecture that lets parser and handler plugins collaborate through opaque protocol fragments. The first implementation targets FAST web-component hydration. Plugin infrastructure: - Add HandlerPlugin trait with lifecycle hooks (push_scope, pop_scope, on_binding_start/end, on_repeat_item_start/end, on_plugin_data) - Add ParserPlugin trait for build-time HTML transformation - Add WebUIFragmentPlugin protobuf message for opaque plugin data - Wire plugin support through FFI (webui_handler_create_with_plugin), Node.js NAPI, and WASM bindings FAST hydration plugin (parser + handler): - FastParserPlugin: transforms BTR directives to FAST syntax at build time (<if> -> <f-when>, <for> -> <f-repeat>, {{expr}} -> {expr}) - FastHydrationPlugin: emits FAST hydration markers with binding indices and repeat item boundaries at render time Supporting improvements: - Extend expression evaluator with comparison operators, compound conditions, and negation support - Add dotted-path state resolution and .length accessor - Expand component registry with file-system-backed lazy loading - Add condition parser for structured boolean expressions - Add CSS parser foundation for scoped styles - Restructure hello-world example to src/ layout with TypeScript - Add todo-fast example app demonstrating full plugin pipeline - Add xtask dev command for development workflow - Update DESIGN.md with plugin architecture specification
1 parent 510d3ec commit 2f2532d

66 files changed

Lines changed: 7644 additions & 225 deletions

Some content is hidden

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

Cargo.lock

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

Cargo.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ async-trait = "0.1.89"
3131
tokio = { version = "1.49.0", features = ["full"] }
3232
clap = { version = "4", features = ["derive"] }
3333
console = "0.15"
34+
ctrlc = "3.4"
3435
prost = "0.13"
3536
napi = { version = "3", features = ["napi4"] }
3637
napi-derive = "3"
@@ -39,6 +40,18 @@ serde-wasm-bindgen = "0.6"
3940
actix-web = "4.11.0"
4041
expand-tilde = "0.6.1"
4142
mime_guess = "2.0.5"
43+
html-escape = "0.2.13"
44+
tree-sitter = "0.26.5"
45+
tree-sitter-language = "0.1.7"
46+
tree-sitter-html = "0.23.2"
47+
tree-sitter-css = "0.25.0"
48+
walkdir = "2.5.0"
49+
libc = "0.2.182"
50+
windows = { version = "0.62.2", features = [
51+
"Win32_Foundation",
52+
"Win32_System_LibraryLoader",
53+
"Win32_System_Console",
54+
] }
4255

4356
# Test dependencies
4457
criterion = "0.8.2"
@@ -49,6 +62,7 @@ tokio-test = "0.4.5"
4962
# Build dependencies
5063
prost-build = "0.13"
5164
napi-build = "2"
65+
cbindgen = "0.29.2"
5266

5367
[profile.release]
5468
lto = true

DESIGN.md

Lines changed: 130 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ pub enum Fragment {
5454
ForLoop(WebUIFragmentFor),
5555
Signal(WebUIFragmentSignal),
5656
IfCond(WebUIFragmentIf),
57+
Attribute(WebUIFragmentAttribute),
58+
Plugin(WebUIFragmentPlugin),
5759
}
5860
```
5961
### Fragment Types
@@ -123,6 +125,16 @@ pub struct WebUIFragmentAttribute {
123125
}
124126
```
125127

128+
#### Plugin Fragment
129+
Plugin fragments carry opaque data from parser plugins to handler plugins. WebUI does
130+
not interpret this data — each parser/handler plugin pair defines its own binary contract.
131+
```rust
132+
pub struct WebUIFragmentPlugin {
133+
/// Opaque plugin-specific binary data.
134+
pub data: Vec<u8>,
135+
}
136+
```
137+
126138
**Attribute types:**
127139
- **Simple dynamic:** `href="{{url}}"``{ name: "href", value: "url" }`
128140
- **Boolean (`?` prefix):** `?disabled={{isDisabled}}``{ name: "disabled", condition_tree: identifier("isDisabled") }` — rendered only if condition is truthy; silently dropped if value is not a pure handlebars expression.
@@ -239,21 +251,70 @@ pub enum ExpressionError {
239251
```
240252

241253
## Handler Implementation (webui-handler)
242-
### Core Function
254+
### Core API
243255
```rust
244-
pub fn handler(
245-
protocol: &WebUIProtocol,
246-
state: &Value,
247-
writer: impl Writer
248-
) -> Result<(), HandlerError>
256+
pub struct WebUIHandler {
257+
plugin: Option<Box<dyn HandlerPlugin>>,
258+
}
259+
260+
impl WebUIHandler {
261+
pub fn new() -> Self;
262+
pub fn with_plugin(plugin: Box<dyn HandlerPlugin>) -> Self;
263+
264+
pub fn handle(
265+
&mut self,
266+
protocol: &WebUIProtocol,
267+
state: &Value,
268+
writer: &mut dyn ResponseWriter,
269+
) -> Result<()>;
270+
}
249271
```
250272
### Writer Interface
251273
```rust
252-
pub trait Writer {
253-
fn write(&mut self, content: &str) -> Result<(), io::Error>;
254-
fn end(&mut self) -> Result<(), io::Error>;
274+
pub trait ResponseWriter {
275+
/// Write content to the output
276+
fn write(&mut self, content: &str) -> Result<()>;
277+
/// Finalize the output
278+
fn end(&mut self) -> Result<()>;
255279
}
256280
```
281+
282+
### Handler Plugin System
283+
The handler supports a framework-agnostic plugin system. Plugins receive lifecycle
284+
callbacks during rendering and can inject arbitrary content. WebUI does not interpret
285+
what plugins write — each framework defines its own marker format.
286+
287+
```rust
288+
pub trait HandlerPlugin {
289+
fn push_scope(&mut self);
290+
fn pop_scope(&mut self);
291+
fn on_binding_start(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()>;
292+
fn on_binding_end(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()>;
293+
fn on_repeat_item_start(&mut self, index: usize, writer: &mut dyn ResponseWriter) -> Result<()>;
294+
fn on_repeat_item_end(&mut self, index: usize, writer: &mut dyn ResponseWriter) -> Result<()>;
295+
fn on_plugin_data(&mut self, data: &[u8], writer: &mut dyn ResponseWriter) -> Result<()>;
296+
}
297+
```
298+
299+
**Hook invocation points:**
300+
- **Signal**: `on_binding_start` before, `on_binding_end` after (same scope)
301+
- **For loop**: `on_binding_start/end` around entire loop; `on_repeat_item_start/end` + `push_scope/pop_scope` per item
302+
- **If condition**: `on_binding_start/end` around condition; `push_scope/pop_scope` if condition is true
303+
- **Component**: `push_scope/pop_scope` around component body
304+
- **Plugin fragment**: `on_plugin_data` with opaque bytes from protocol
305+
306+
**Built-in plugin: `FastHydrationPlugin`**
307+
Injects FAST-HTML hydration comment markers for client-side re-hydration:
308+
- Binding: `<!--fe-b$$start$$INDEX$$NAME$$fe-b-->` / `<!--fe-b$$end$$INDEX$$NAME$$fe-b-->`
309+
- Repeat: `<!--fe-repeat$$start$$INDEX$$fe-repeat-->` / `<!--fe-repeat$$end$$INDEX$$fe-repeat-->`
310+
- Attribute (single): ` data-fe-b-INDEX`
311+
- Attribute (multi): ` data-fe-c-INDEX-COUNT`
312+
313+
**Usage:**
314+
```rust
315+
let mut handler = WebUIHandler::with_plugin(Box::new(FastHydrationPlugin::new()));
316+
handler.handle(&protocol, &state, &mut writer)?;
317+
```
257318
### Fragment Processing
258319
- **Raw fragments:** Write value directly to output
259320
- **Signal fragments:**
@@ -280,6 +341,7 @@ pub trait Writer {
280341
the fields of the current item being looped over and the global state. The `Component` fragment doesn't need to use
281342
the `For` fragment item moniker and can access the fields without the qualification. If the `Component` fragment is
282343
nested in multiple `For` fragments only the closest enclosing `For` fragment item's state is accessible to it.
344+
- **Plugin fragments:** Pass opaque `data` bytes to the handler plugin's `on_plugin_data` hook. Skipped silently when no plugin is configured.
283345

284346
### State Management
285347
- Global state refers to the global application state that is available to all fragments at all times.
@@ -357,6 +419,44 @@ Set via `parser.set_css_strategy(CssStrategy::Inline)`.
357419
pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<(), ParserError>
358420
pub fn into_fragment_records(self) -> WebUIFragmentRecords
359421
```
422+
423+
### Parser Plugin System
424+
The parser supports a framework-agnostic plugin system. Plugins customize parsing
425+
behavior for framework-specific needs (component discovery, attribute filtering,
426+
hydration data emission) without WebUI knowing framework internals.
427+
428+
```rust
429+
pub trait ParserPlugin {
430+
fn on_parse_component(&mut self, tag_name: &str, component: &Component) -> Result<()>;
431+
fn should_skip_attribute(&self, attr_name: &str) -> bool;
432+
fn on_body_end(&mut self) -> Option<String>;
433+
fn on_element_parsed(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>>;
434+
}
435+
```
436+
437+
**Hook invocation points:**
438+
- **Attribute loop**: `should_skip_attribute` called per attribute; skipped attrs are not parsed
439+
- **Element completion**: `on_element_parsed` called with binding count after all attrs processed; returned bytes emitted as `Plugin` fragment
440+
- **Component encounter**: `on_parse_component` called when a custom element is found
441+
- **Body end**: `on_body_end` called before `body_end` signal; returned HTML injected as raw fragment
442+
443+
**Built-in plugin: `FastParserPlugin`**
444+
- Skips FAST-specific runtime attributes (`@click`, `f-ref`, `f-slotted`, `f-children`)
445+
- Emits `Plugin` fragments with u32 LE attribute binding counts
446+
- Tracks components and injects `<f-template>` wrappers at body end
447+
- Converts BTR syntax to FAST syntax: `<if>``<f-when>`, `<for>``<f-repeat>`, `{{expr}}``{expr}` in `:attr` values
448+
449+
**Usage:**
450+
```rust
451+
let mut parser = HtmlParser::with_plugin(Box::new(FastParserPlugin::new()));
452+
parser.parse("index.html", &html)?;
453+
```
454+
455+
**CLI integration:**
456+
```bash
457+
webui build ./templates --out ./dist --plugin=fast
458+
webui start ./templates --state ./data/state.json --plugin=fast
459+
```
360460
#### Content Processing
361461

362462
##### Raw Content
@@ -496,6 +596,27 @@ webui/
496596
- Error handling guidelines
497597
- Examples for all major features
498598

599+
## FFI Bindings (webui-ffi)
600+
601+
The FFI crate exposes WebUI to host languages via a C-compatible ABI. The generated
602+
header is at `crates/webui-ffi/include/webui_ffi.h`.
603+
604+
### Functions
605+
606+
| Function | Description |
607+
|----------|-------------|
608+
| `webui_render(html, data_json)` | Parse + render in one call. Returns heap-allocated string (caller frees with `webui_free`). |
609+
| `webui_handler_create()` | Create a reusable handler (no plugin). |
610+
| `webui_handler_create_with_plugin(plugin_id)` | Create a handler with a named plugin (e.g. `"fast"`). Returns `NULL` on error. |
611+
| `webui_handler_render(handler, data, len, json)` | Render a pre-compiled protocol. Returns heap-allocated string. |
612+
| `webui_handler_destroy(handler)` | Destroy a handler. `NULL` is a safe no-op. |
613+
| `webui_free(ptr)` | Free a string returned by any render function. `NULL` is a safe no-op. |
614+
| `webui_last_error()` | Return per-thread error message. Caller must **not** free. |
615+
616+
### Error Model
617+
Thread-local error storage following the POSIX `dlerror()` pattern. After any
618+
function returns `NULL`, call `webui_last_error()` for a human-readable diagnostic.
619+
499620
## CLI Tool (webui-cli)
500621

501622
The CLI specification and usage details are maintained in [crates/webui-cli/README.md](crates/webui-cli/README.md).

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@ WebUI follows a modular architecture with four primary components:
2424
- **Parser:** Processes HTML/CSS templates into protocol structures at build time
2525
- **Expression Evaluation:** Handles conditional logic for dynamic rendering
2626
- **Handler:** Renders protocol with state data into final HTML output at runtime
27+
- **Plugin System:** Extensible hooks for framework-specific behavior (parsing + rendering) without modifying core logic
2728

2829
## How It Works
2930
- **Write Standard HTML/CSS:** Create templates with familiar syntax plus WebUI directives (`<for>`, `<if>`, `{{signals}}`)
3031
- **Parse to WebUIProtocol:** Templates compile to a lightweight, language-agnostic protocol at build time
3132
- **Native Rendering:** The protocol is rendered with your data using a platform-specific handler in your language of choice
3233
- **Efficient Output:** The handler produces optimized HTML with Web Component support
34+
- **Framework Plugins:** Optional plugins (e.g., `--plugin=fast`) inject hydration markers for client-side frameworks like FAST-HTML
3335

3436
## Getting Started
3537

@@ -59,11 +61,14 @@ The `webui` CLI builds your app folder into the WebUI protocol format:
5961
# Build an app (outputs protocol.bin and component CSS to the out folder)
6062
cargo run -p webui-cli -- build ./my-app --out ./dist
6163

64+
# Build with the FAST plugin for hydration support
65+
cargo run -p webui-cli -- build ./my-app --out ./dist --plugin=fast
66+
6267
# Specify a custom entry file
6368
cargo run -p webui-cli -- build ./my-app --out ./dist --entry page.html
6469

6570
# Build the hello-world example
66-
cargo run -p webui-cli -- build examples/hello-world --out ./dist
71+
cargo run -p webui-cli -- build examples/app/hello-world/src --out ./dist
6772
```
6873

6974
After building with `--release`, use the binary directly:

0 commit comments

Comments
 (0)