1use crate::error::WasmError;
7use js_sys::{Function, Object, Reflect};
8use serde_json::Value;
9use wasm_bindgen::prelude::*;
10use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
11use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
12use webui_handler::plugin::webui::WebUIHydrationPlugin;
13use webui_handler::{
14 HandlerError, Protocol as HandlerProtocol, RenderOptions, ResponseWriter, WebUIHandler,
15};
16#[cfg(test)]
17use webui_protocol::WebUIProtocol;
18
19const STREAM_CHUNK_SIZE: usize = 16 * 1024;
20
21struct StringWriter {
23 content: String,
24}
25
26impl StringWriter {
27 fn with_capacity(cap: usize) -> Self {
28 Self {
29 content: String::with_capacity(cap),
30 }
31 }
32}
33
34impl ResponseWriter for StringWriter {
35 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
36 self.content.push_str(content);
37 Ok(())
38 }
39
40 fn end(&mut self) -> webui_handler::Result<()> {
41 Ok(())
42 }
43}
44
45struct CallbackWriter<'a> {
47 on_chunk: &'a Function,
48 buffer: String,
49}
50
51impl<'a> CallbackWriter<'a> {
52 fn new(on_chunk: &'a Function) -> Self {
53 Self {
54 on_chunk,
55 buffer: String::with_capacity(STREAM_CHUNK_SIZE),
56 }
57 }
58
59 fn flush(&mut self) -> webui_handler::Result<()> {
60 if self.buffer.is_empty() {
61 return Ok(());
62 }
63
64 let chunk = std::mem::replace(&mut self.buffer, String::with_capacity(STREAM_CHUNK_SIZE));
65 self.on_chunk
66 .call1(&JsValue::UNDEFINED, &JsValue::from_str(&chunk))
67 .map(|_| ())
68 .map_err(|error| HandlerError::Writer(format!("{error:?}")))
69 }
70}
71
72impl ResponseWriter for CallbackWriter<'_> {
73 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
74 self.buffer.push_str(content);
75 if self.buffer.len() >= STREAM_CHUNK_SIZE {
76 self.flush()?;
77 }
78 Ok(())
79 }
80
81 fn end(&mut self) -> webui_handler::Result<()> {
82 self.flush()
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub(crate) enum HandlerPluginKind {
88 FastV2,
89 FastV3,
90 WebUI,
91}
92
93impl HandlerPluginKind {
94 fn parse(name: &str) -> Result<Self, WasmError> {
95 match name {
96 "fast" | "fast-v2" => Ok(Self::FastV2),
97 "fast-v3" => Ok(Self::FastV3),
98 "webui" => Ok(Self::WebUI),
99 other => Err(WasmError::UnknownPlugin(other.to_string())),
100 }
101 }
102}
103
104struct WasmRenderOptions {
105 entry: String,
106 request_path: String,
107}
108
109impl Default for WasmRenderOptions {
110 fn default() -> Self {
111 Self {
112 entry: "index.html".to_string(),
113 request_path: "/".to_string(),
114 }
115 }
116}
117
118#[wasm_bindgen]
120pub struct Protocol {
121 inner: HandlerProtocol,
122 handler: WebUIHandler,
123}
124
125#[wasm_bindgen]
126impl Protocol {
127 #[wasm_bindgen(constructor)]
129 pub fn new(protocol_bytes: &[u8], plugin: Option<String>) -> Result<Protocol, JsValue> {
130 let plugin = parse_optional_plugin(plugin.as_deref())
131 .map_err(|error| JsValue::from_str(&error.to_string()))?;
132 let inner = HandlerProtocol::from_protobuf(protocol_bytes)
133 .map_err(|error| JsValue::from_str(&format!("Protocol error: {error}")))?;
134 Ok(Self {
135 inner,
136 handler: create_handler(plugin),
137 })
138 }
139
140 #[wasm_bindgen(js_name = render)]
142 pub fn render(&self, state_json: &str, options: Option<Object>) -> Result<String, JsValue> {
143 let options =
144 parse_render_options(options).map_err(|error| JsValue::from_str(&error.to_string()))?;
145 let state =
146 parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))?;
147 render_protocol_to_string_value(&self.handler, &self.inner, &state, &options)
148 .map_err(|error| JsValue::from_str(&error.to_string()))
149 }
150
151 #[wasm_bindgen(js_name = renderStream)]
153 pub fn render_stream(
154 &self,
155 state_json: &str,
156 on_chunk: &Function,
157 options: Option<Object>,
158 ) -> Result<(), JsValue> {
159 let options =
160 parse_render_options(options).map_err(|error| JsValue::from_str(&error.to_string()))?;
161 let state =
162 parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))?;
163 render_protocol_to_callback_value(&self.handler, &self.inner, &state, &options, on_chunk)
164 .map_err(|error| JsValue::from_str(&error.to_string()))
165 }
166
167 #[wasm_bindgen(js_name = renderPartial)]
169 pub fn render_partial(
170 &self,
171 state_json: &str,
172 entry_id: &str,
173 request_path: &str,
174 inventory_hex: &str,
175 ) -> Result<String, JsValue> {
176 self.inner
177 .render_partial(state_json, entry_id, request_path, inventory_hex)
178 .map_err(|error| JsValue::from_str(&format!("render_partial failed: {error}")))
179 }
180
181 #[wasm_bindgen(js_name = renderComponentTemplates)]
183 pub fn render_component_templates(
184 &self,
185 component_tags: JsValue,
186 inventory_hex: &str,
187 ) -> Result<String, JsValue> {
188 let tags: Vec<String> = serde_wasm_bindgen::from_value(component_tags)
189 .map_err(|error| JsValue::from_str(&format!("invalid component tags: {error}")))?;
190 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
191 let result = self
192 .inner
193 .render_component_templates(&tag_refs, inventory_hex)
194 .map_err(|error| {
195 JsValue::from_str(&format!("render_component_templates failed: {error}"))
196 })?;
197 serde_json::to_string(&result)
198 .map_err(|error| JsValue::from_str(&format!("JSON serialize error: {error}")))
199 }
200
201 #[wasm_bindgen(js_name = tokens)]
203 pub fn tokens(&self) -> Result<JsValue, JsValue> {
204 serde_wasm_bindgen::to_value(self.inner.tokens())
205 .map_err(|error| JsValue::from_str(&format!("Serialization error: {error}")))
206 }
207}
208
209#[cfg(test)]
210pub(crate) fn render_protocol_to_string(
211 protocol: &WebUIProtocol,
212 state_json: &str,
213 entry: &str,
214 request_path: &str,
215 plugin: Option<HandlerPluginKind>,
216) -> Result<String, WasmError> {
217 let state = parse_state_json(state_json)?;
218 let options = WasmRenderOptions {
219 entry: entry.to_string(),
220 request_path: request_path.to_string(),
221 };
222 let protocol = HandlerProtocol::new(protocol.clone());
223 let handler = create_handler(plugin);
224 render_protocol_to_string_value(&handler, &protocol, &state, &options)
225}
226
227fn parse_state_json(state_json: &str) -> Result<Value, WasmError> {
228 serde_json::from_str(state_json).map_err(WasmError::State)
229}
230
231fn render_protocol_to_string_value(
232 handler: &WebUIHandler,
233 protocol: &HandlerProtocol,
234 state: &Value,
235 options: &WasmRenderOptions,
236) -> Result<String, WasmError> {
237 let mut writer = StringWriter::with_capacity(4096);
238 handler.render(
239 protocol,
240 state,
241 &RenderOptions::new(&options.entry, &options.request_path),
242 &mut writer,
243 )?;
244 Ok(writer.content)
245}
246
247fn render_protocol_to_callback_value(
248 handler: &WebUIHandler,
249 protocol: &HandlerProtocol,
250 state: &Value,
251 options: &WasmRenderOptions,
252 on_chunk: &Function,
253) -> Result<(), WasmError> {
254 let mut writer = CallbackWriter::new(on_chunk);
255 handler.render(
256 protocol,
257 state,
258 &RenderOptions::new(&options.entry, &options.request_path),
259 &mut writer,
260 )?;
261 writer.flush()?;
262 Ok(())
263}
264
265pub(crate) fn parse_optional_plugin(
266 plugin: Option<&str>,
267) -> Result<Option<HandlerPluginKind>, WasmError> {
268 plugin.map(HandlerPluginKind::parse).transpose()
269}
270
271fn parse_render_options(options: Option<Object>) -> Result<WasmRenderOptions, WasmError> {
272 let mut parsed = WasmRenderOptions::default();
273 let Some(options) = options else {
274 return Ok(parsed);
275 };
276
277 if let Some(entry) = optional_string_field(options.as_ref(), "entry")? {
278 parsed.entry = entry;
279 }
280 if let Some(request_path) = optional_string_field(options.as_ref(), "requestPath")? {
281 parsed.request_path = request_path;
282 }
283 Ok(parsed)
284}
285
286fn optional_string_field(options: &JsValue, field: &str) -> Result<Option<String>, WasmError> {
287 let value = Reflect::get(options, &JsValue::from_str(field)).map_err(|_| {
288 WasmError::InvalidOptions(format!("failed to read `{field}` from options object"))
289 })?;
290 if value.is_null() || value.is_undefined() {
291 return Ok(None);
292 }
293 value.as_string().map(Some).ok_or_else(|| {
294 WasmError::InvalidOptions(format!("`{field}` must be a string when provided"))
295 })
296}
297
298fn create_handler(plugin: Option<HandlerPluginKind>) -> WebUIHandler {
299 match plugin {
300 Some(HandlerPluginKind::FastV2) => {
301 WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
302 }
303 Some(HandlerPluginKind::FastV3) => {
304 WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
305 }
306 Some(HandlerPluginKind::WebUI) => {
307 WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
308 }
309 None => WebUIHandler::new(),
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn parse_plugin_keeps_fast_aliases_parser_free() {
319 assert_eq!(
320 parse_optional_plugin(Some("fast")).unwrap(),
321 Some(HandlerPluginKind::FastV2)
322 );
323 assert_eq!(
324 parse_optional_plugin(Some("fast-v2")).unwrap(),
325 Some(HandlerPluginKind::FastV2)
326 );
327 assert_eq!(
328 parse_optional_plugin(Some("fast-v3")).unwrap(),
329 Some(HandlerPluginKind::FastV3)
330 );
331 assert_eq!(
332 parse_optional_plugin(Some("webui")).unwrap(),
333 Some(HandlerPluginKind::WebUI)
334 );
335 }
336
337 #[test]
338 fn parse_plugin_rejects_unknown_names() {
339 let err = parse_optional_plugin(Some("unknown")).unwrap_err();
340 assert_eq!(
341 err.to_string(),
342 "Unknown plugin: unknown. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
343 );
344 }
345
346 #[test]
347 fn protocol_reuses_decoded_protocol() {
348 use std::collections::HashMap;
349 use webui_protocol::{FragmentList, WebUIFragment};
350
351 let mut fragments = HashMap::new();
352 fragments.insert(
353 "index.html".to_string(),
354 FragmentList {
355 fragments: vec![WebUIFragment::signal("name".to_string(), true)],
356 },
357 );
358 let bytes = WebUIProtocol::new(fragments)
359 .to_protobuf()
360 .expect("protocol should serialize");
361 let protocol = Protocol::new(&bytes, None).expect("protocol should load");
362
363 let first = protocol
364 .render(r#"{"name":"first"}"#, None)
365 .expect("first render should succeed");
366 let second = protocol
367 .render(r#"{"name":"second"}"#, None)
368 .expect("second render should succeed");
369
370 assert_eq!(first, "first");
371 assert_eq!(second, "second");
372 }
373
374 #[test]
375 fn render_projects_state_to_component_hydration_keys() {
376 use std::collections::HashMap;
377 use webui_protocol::{
378 ComponentData, FragmentList, InitialStateStrategy, StateProjectionMode, WebUIFragment,
379 };
380
381 let mut fragments = HashMap::new();
382 fragments.insert(
383 "index.html".to_string(),
384 FragmentList {
385 fragments: vec![
386 WebUIFragment::raw("<html><head>"),
387 WebUIFragment::signal("head_end".to_string(), true),
388 WebUIFragment::raw("</head><body>"),
389 WebUIFragment::component("client-card"),
390 WebUIFragment::signal("body_end".to_string(), true),
391 WebUIFragment::raw("</body></html>"),
392 ],
393 },
394 );
395 fragments.insert(
396 "client-card".to_string(),
397 FragmentList {
398 fragments: vec![WebUIFragment::raw("<p>client</p>")],
399 },
400 );
401 let mut protocol = WebUIProtocol::new(fragments);
402 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
403 protocol.components.insert(
404 "client-card".to_string(),
405 ComponentData {
406 hydration_mode: StateProjectionMode::Keys as i32,
407 hydration_keys: vec!["kept".to_string()],
408 ..Default::default()
409 },
410 );
411
412 let rendered = render_protocol_to_string(
413 &protocol,
414 r#"{"kept":"KEPT_VALUE_WASM","dropped":"DROPPED_VALUE_WASM"}"#,
415 "index.html",
416 "/",
417 Some(HandlerPluginKind::WebUI),
418 )
419 .expect("render should succeed");
420
421 assert!(
423 rendered.contains(r#""kept":"KEPT_VALUE_WASM""#),
424 "hydratable key missing from bootstrap state:\n{rendered}"
425 );
426 assert!(
428 !rendered.contains("DROPPED_VALUE_WASM"),
429 "server-only value leaked into render:\n{rendered}"
430 );
431 assert!(
432 !rendered.contains("dropped"),
433 "server-only key name leaked into render:\n{rendered}"
434 );
435 }
436}