Skip to content
Open
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
56 changes: 35 additions & 21 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ pub enum ExpressionError {
### Core API
```rust
pub struct WebUIHandler {
plugin: Option<Box<dyn HandlerPlugin>>,
plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
}

/// Options controlling how the handler renders a protocol.
Expand Down Expand Up @@ -515,10 +515,10 @@ impl<'a> RenderOptions<'a> {

impl WebUIHandler {
pub fn new() -> Self;
pub fn with_plugin(plugin: Box<dyn HandlerPlugin>) -> Self;
pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self;

pub fn handle(
&mut self,
&self,
protocol: &WebUIProtocol,
state: &Value,
options: &RenderOptions<'_>,
Expand All @@ -527,6 +527,16 @@ impl WebUIHandler {
}
```

#### Thread safety

`WebUIHandler` is `Send + Sync` (auto-derived; the sole field is a function
pointer). The handler is stateless — per-render state lives in a local
context created inside `handle`, and plugin instances are created fresh per
render from the stored factory. A single handler can therefore be shared
across threads (typically wrapped in `Arc`) and `handle` called with `&self`
from concurrent tasks. No `Mutex` is required; concurrent renders run in
parallel.

#### ProtocolIndex

`ProtocolIndex` is a pre-computed index over a `WebUIProtocol` that accelerates
Expand Down Expand Up @@ -564,35 +574,39 @@ pub fn match_route_cached(

#### Partial and Action Response Functions

These are free functions in `webui_handler::route_handler`; they operate
on the protocol + index directly and do not take a `WebUIHandler` (the
handler is only needed when emitting HTML through a `ResponseWriter`).

```rust
/// Produce a JSON partial response for client-side navigation.
/// `protocol_index` provides cached route matching and component indices.
/// `index` provides cached route matching and component indices.
pub fn render_partial(
handler: &mut WebUIHandler,
protocol: &WebUIProtocol,
protocol_index: &mut ProtocolIndex,
options: &RenderOptions<'_>,
entry_id: &str,
request_path: &str,
inventory_hex: &str,
) -> Result<PartialResponse, HandlerError>;
index: &mut ProtocolIndex,
) -> Result<Value, HandlerError>;

/// Produce a response for a mutation action (POST).
/// `protocol_index` provides cached route matching and component indices.
/// Produce a JSON response for a POST mutation action.
/// `index` provides cached route matching for invalidation-tag resolution.
pub fn render_action_response(
handler: &mut WebUIHandler,
protocol: &WebUIProtocol,
protocol_index: &mut ProtocolIndex,
options: &RenderOptions<'_>,
inventory_hex: &str,
) -> Result<ActionResponse, HandlerError>;
state: Value,
entry_id: &str,
request_path: &str,
index: &mut ProtocolIndex,
) -> Value;

/// Emit client template scripts/markup for the given components.
/// `protocol_index` provides the component index for inventory tracking.
/// Return compiled templates and CSS for specific components by tag name.
/// `index` provides the component index for inventory tracking.
pub fn render_component_templates(
handler: &WebUIHandler,
protocol: &WebUIProtocol,
protocol_index: &ProtocolIndex,
components: &[String],
) -> Vec<String>;
component_tags: &[&str],
inventory_hex: &str,
index: &ProtocolIndex,
) -> Result<Value, HandlerError>;
```

#### Component Inventory Functions
Expand Down
29 changes: 29 additions & 0 deletions crates/webui-handler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,27 @@ impl<'a> RenderOptions<'a> {
///
/// The handler is stateless: plugin instances are created per-render from
/// the stored factory function, allowing concurrent renders with `&self`.
///
/// # Thread safety
///
/// `WebUIHandler` is already `Send + Sync` (auto-derived). The only field
/// is a function pointer to a plugin factory; nothing in the handler
/// itself is borrowed or mutable across renders. Per-render state lives
/// in a private `WebUIProcessContext` created inside [`handle`](Self::handle).
///
/// This means you can share a single handler across threads without
/// locking. The typical pattern is to construct it once at startup,
/// wrap it in an [`Arc`](std::sync::Arc) (or store it in router
/// `State`), and call [`handle`](Self::handle) with `&self` from
/// any request task:
///
/// ```ignore
/// use std::sync::Arc;
/// use webui::WebUIHandler;
///
/// let handler = Arc::new(WebUIHandler::new());
/// // ... clone `handler` into each request handler closure ...
/// ```
pub struct WebUIHandler {
plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
}
Expand Down Expand Up @@ -1536,6 +1557,14 @@ mod tests {
};
use webui_test_utils::test_json;

// Compile-time guarantee that the handler can be shared across threads.
// If a future change introduces a non-Send/Sync field, this fails to
// compile, prompting an update to both the type and its documentation.
const _: fn() = || {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<WebUIHandler>();
};

// A simple test writer implementation
struct TestWriter {
content: RefCell<String>,
Expand Down
38 changes: 38 additions & 0 deletions docs/guide/integrations/rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,41 @@ HttpResponse::Ok()
| `MissingFragment(String)` | `entry_id` not found in the protocol. |
| `TypeError(String)` / `Evaluation(String)` | Template/expression runtime errors. |

## Thread safety

`WebUIHandler` is `Send + Sync`. The handler is stateless: per-render state lives in a local context created inside `handle`, and the only stored field is a plugin factory function pointer. Construct one handler at startup, wrap it in an [`Arc`], and call `handler.handle(...)` from any request task without locking.

### Sharing a handler across tasks

The realistic pattern is "construct once, clone into many tasks":

```rust
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need an example for thread safety, the docs state thread safety, we don't need more doc bloat showcasing how we can run the handler N times

use std::sync::Arc;
use webui::{RenderOptions, WebUIHandler, WebUIProtocol};
use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let protocol = Arc::new(WebUIProtocol::from_protobuf_file("dist/protocol.bin")?);
let handler = Arc::new(WebUIHandler::with_plugin(|| {
Box::new(FastV3HydrationPlugin::new())
}));

while let Some(request) = accept_request().await {
let handler = Arc::clone(&handler);
let protocol = Arc::clone(&protocol);
tokio::spawn(async move {
let options = RenderOptions::new("index.html", &request.path);
let mut writer = request.into_writer();
if let Err(error) = handler.handle(&protocol, &request.state, &options, &mut writer) {
tracing::error!(?error, "render failed");
}
});
}
Ok(())
}
```

The same shape applies to other async runtimes (`actix_web::rt::spawn`, `smol::spawn`, etc.) and to thread pools (`std::thread::spawn` with a `move` closure cloning the `Arc`). Because `handle` takes `&self`, no `Mutex` is needed; concurrent renders run in parallel.

[`Arc`]: https://doc.rust-lang.org/std/sync/struct.Arc.html
Loading