1use crate::error::WasmError;
7use js_sys::{Function, Object, Reflect};
8use serde_json::Value;
9use std::sync::Arc;
10use wasm_bindgen::prelude::*;
11use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
12use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
13use webui_handler::plugin::webui::WebUIHydrationPlugin;
14use webui_handler::{
15 BoundaryId, BoundaryMode, HandlerError, Protocol as HandlerProtocol, RenderOptions,
16 ResponseWriter, SessionOptions, StreamingSession as HandlerStreamingSession, WebUIHandler,
17};
18#[cfg(test)]
19use webui_protocol::WebUIProtocol;
20
21const STREAM_CHUNK_SIZE: usize = 16 * 1024;
22
23struct StringWriter {
25 content: String,
26}
27
28impl StringWriter {
29 fn with_capacity(cap: usize) -> Self {
30 Self {
31 content: String::with_capacity(cap),
32 }
33 }
34}
35
36impl ResponseWriter for StringWriter {
37 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
38 self.content.push_str(content);
39 Ok(())
40 }
41
42 fn end(&mut self) -> webui_handler::Result<()> {
43 Ok(())
44 }
45}
46
47struct CallbackWriter<'a> {
49 on_chunk: &'a Function,
50 buffer: String,
51}
52
53impl<'a> CallbackWriter<'a> {
54 fn new(on_chunk: &'a Function) -> Self {
55 Self {
56 on_chunk,
57 buffer: String::with_capacity(STREAM_CHUNK_SIZE),
58 }
59 }
60
61 fn flush(&mut self) -> webui_handler::Result<()> {
62 if self.buffer.is_empty() {
63 return Ok(());
64 }
65
66 let chunk = std::mem::replace(&mut self.buffer, String::with_capacity(STREAM_CHUNK_SIZE));
67 self.on_chunk
68 .call1(&JsValue::UNDEFINED, &JsValue::from_str(&chunk))
69 .map(|_| ())
70 .map_err(|error| HandlerError::Writer(format!("{error:?}")))
71 }
72}
73
74impl ResponseWriter for CallbackWriter<'_> {
75 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
76 self.buffer.push_str(content);
77 if self.buffer.len() >= STREAM_CHUNK_SIZE {
78 self.flush()?;
79 }
80 Ok(())
81 }
82
83 fn end(&mut self) -> webui_handler::Result<()> {
84 self.flush()
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub(crate) enum HandlerPluginKind {
90 FastV2,
91 FastV3,
92 WebUI,
93}
94
95impl HandlerPluginKind {
96 fn parse(name: &str) -> Result<Self, WasmError> {
97 match name {
98 "fast" | "fast-v2" => Ok(Self::FastV2),
99 "fast-v3" => Ok(Self::FastV3),
100 "webui" => Ok(Self::WebUI),
101 other => Err(WasmError::UnknownPlugin(other.to_string())),
102 }
103 }
104}
105
106struct WasmRenderOptions {
107 entry: String,
108 request_path: String,
109}
110
111impl Default for WasmRenderOptions {
112 fn default() -> Self {
113 Self {
114 entry: "index.html".to_string(),
115 request_path: "/".to_string(),
116 }
117 }
118}
119
120#[wasm_bindgen]
122pub struct Protocol {
123 inner: Arc<HandlerProtocol>,
124 handler: Arc<WebUIHandler>,
125}
126
127#[wasm_bindgen]
128impl Protocol {
129 #[wasm_bindgen(constructor)]
131 pub fn new(protocol_bytes: &[u8], plugin: Option<String>) -> Result<Protocol, JsValue> {
132 let plugin = parse_optional_plugin(plugin.as_deref())
133 .map_err(|error| JsValue::from_str(&error.to_string()))?;
134 let inner = HandlerProtocol::from_protobuf(protocol_bytes)
135 .map_err(|error| JsValue::from_str(&format!("Protocol error: {error}")))?;
136 Ok(Self {
137 inner: Arc::new(inner),
138 handler: Arc::new(create_handler(plugin)),
139 })
140 }
141
142 #[wasm_bindgen(js_name = render)]
144 pub fn render(&self, state_json: &str, options: Option<Object>) -> Result<String, JsValue> {
145 let options =
146 parse_render_options(options).map_err(|error| JsValue::from_str(&error.to_string()))?;
147 let state =
148 parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))?;
149 render_protocol_to_string_value(&self.handler, &self.inner, &state, &options)
150 .map_err(|error| JsValue::from_str(&error.to_string()))
151 }
152
153 #[wasm_bindgen(js_name = renderStream)]
155 pub fn render_stream(
156 &self,
157 state_json: &str,
158 on_chunk: &Function,
159 options: Option<Object>,
160 ) -> Result<(), JsValue> {
161 let options =
162 parse_render_options(options).map_err(|error| JsValue::from_str(&error.to_string()))?;
163 let state =
164 parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))?;
165 render_protocol_to_callback_value(&self.handler, &self.inner, &state, &options, on_chunk)
166 .map_err(|error| JsValue::from_str(&error.to_string()))
167 }
168
169 #[wasm_bindgen(js_name = renderPartial)]
171 pub fn render_partial(
172 &self,
173 state_json: &str,
174 entry_id: &str,
175 request_path: &str,
176 inventory_hex: &str,
177 ) -> Result<String, JsValue> {
178 self.inner
179 .render_partial(state_json, entry_id, request_path, inventory_hex)
180 .map_err(|error| JsValue::from_str(&format!("render_partial failed: {error}")))
181 }
182
183 #[wasm_bindgen(js_name = renderComponentTemplates)]
185 pub fn render_component_templates(
186 &self,
187 component_tags: JsValue,
188 inventory_hex: &str,
189 ) -> Result<String, JsValue> {
190 let tags: Vec<String> = serde_wasm_bindgen::from_value(component_tags)
191 .map_err(|error| JsValue::from_str(&format!("invalid component tags: {error}")))?;
192 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
193 let result = self
194 .inner
195 .render_component_templates(&tag_refs, inventory_hex)
196 .map_err(|error| {
197 JsValue::from_str(&format!("render_component_templates failed: {error}"))
198 })?;
199 serde_json::to_string(&result)
200 .map_err(|error| JsValue::from_str(&format!("JSON serialize error: {error}")))
201 }
202
203 #[wasm_bindgen(js_name = tokens)]
205 pub fn tokens(&self) -> Result<JsValue, JsValue> {
206 serde_wasm_bindgen::to_value(self.inner.tokens())
207 .map_err(|error| JsValue::from_str(&format!("Serialization error: {error}")))
208 }
209
210 #[wasm_bindgen(js_name = streamResponse)]
216 pub fn stream_response(
217 &self,
218 entry: String,
219 request_path: String,
220 options: Option<Object>,
221 ) -> Result<StreamingSession, JsValue> {
222 let mut session_options = SessionOptions::new(entry, request_path);
223 if let Some(options) = options {
224 session_options.nonce = optional_string_property(&options, "nonce")?;
225 session_options.head_inject = optional_string_property(&options, "headInject")?;
226 session_options.body_inject = optional_string_property(&options, "bodyInject")?;
227 }
228
229 HandlerStreamingSession::new(
230 Arc::clone(&self.handler),
231 Arc::clone(&self.inner),
232 session_options,
233 )
234 .map(|inner| StreamingSession { inner })
235 .map_err(streaming_error)
236 }
237}
238
239#[wasm_bindgen]
254pub struct StreamingSession {
255 inner: HandlerStreamingSession,
256}
257
258#[wasm_bindgen]
259impl StreamingSession {
260 #[wasm_bindgen(js_name = boundary)]
264 pub fn boundary(&self, name: &str) -> Result<u32, JsValue> {
265 self.inner
266 .boundary(name)
267 .map(BoundaryId::raw)
268 .map_err(streaming_error)
269 }
270
271 #[wasm_bindgen(getter, js_name = boundaryCount)]
273 pub fn boundary_count(&self) -> u32 {
274 u32::try_from(self.inner.boundary_count()).unwrap_or(u32::MAX)
277 }
278
279 #[wasm_bindgen(getter, js_name = finished)]
281 pub fn finished(&self) -> bool {
282 self.inner.is_finished()
283 }
284
285 #[wasm_bindgen(js_name = writeShell)]
287 pub fn write_shell(&mut self, state_json: &str) -> Result<Vec<u8>, JsValue> {
288 let state = session_state(state_json)?;
289
290 self.inner.write_shell(&state).map_err(streaming_error)
291 }
292
293 #[wasm_bindgen(js_name = writeBoundary)]
298 pub fn write_boundary(
299 &mut self,
300 boundary: u32,
301 state_json: &str,
302 mode: Option<String>,
303 ) -> Result<Vec<u8>, JsValue> {
304 let state = session_state(state_json)?;
305
306 let mode = parse_boundary_mode(mode.as_deref())?;
307 self.inner
308 .write_boundary(BoundaryId::from_raw(boundary), &state, mode)
309 .map_err(streaming_error)
310 }
311
312 #[wasm_bindgen(js_name = update)]
314 pub fn update(&mut self, boundary: u32, state_json: &str) -> Result<Vec<u8>, JsValue> {
315 let state = session_state(state_json)?;
316
317 self.inner
318 .update(BoundaryId::from_raw(boundary), &state)
319 .map_err(streaming_error)
320 }
321
322 #[wasm_bindgen(js_name = finish)]
324 pub fn finish(&mut self, state_json: &str) -> Result<Vec<u8>, JsValue> {
325 let state = session_state(state_json)?;
326
327 self.inner.finish(&state).map_err(streaming_error)
328 }
329}
330
331fn session_state(state_json: &str) -> Result<Value, JsValue> {
332 parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))
333}
334
335fn parse_boundary_mode(mode: Option<&str>) -> Result<BoundaryMode, JsValue> {
336 match mode {
337 None | Some("final") => Ok(BoundaryMode::Final),
338 Some("updatable") => Ok(BoundaryMode::Updatable),
339 Some(other) => Err(JsValue::from_str(&format!(
340 "unknown boundary mode '{other}'; expected 'final' or 'updatable'"
341 ))),
342 }
343}
344
345fn streaming_error(error: HandlerError) -> JsValue {
346 JsValue::from_str(&error.to_string())
347}
348
349fn optional_string_property(options: &Object, key: &str) -> Result<Option<String>, JsValue> {
350 let value = Reflect::get(options, &JsValue::from_str(key))
351 .map_err(|_| JsValue::from_str(&format!("failed to read '{key}' option")))?;
352 if value.is_undefined() || value.is_null() {
353 return Ok(None);
354 }
355 value
356 .as_string()
357 .map(Some)
358 .ok_or_else(|| JsValue::from_str(&format!("'{key}' must be a string")))
359}
360
361#[cfg(test)]
362pub(crate) fn render_protocol_to_string(
363 protocol: &WebUIProtocol,
364 state_json: &str,
365 entry: &str,
366 request_path: &str,
367 plugin: Option<HandlerPluginKind>,
368) -> Result<String, WasmError> {
369 let state = parse_state_json(state_json)?;
370 let options = WasmRenderOptions {
371 entry: entry.to_string(),
372 request_path: request_path.to_string(),
373 };
374 let protocol = HandlerProtocol::new(protocol.clone());
375 let handler = create_handler(plugin);
376 render_protocol_to_string_value(&handler, &protocol, &state, &options)
377}
378
379fn parse_state_json(state_json: &str) -> Result<Value, WasmError> {
380 serde_json::from_str(state_json).map_err(WasmError::State)
381}
382
383fn render_protocol_to_string_value(
384 handler: &WebUIHandler,
385 protocol: &HandlerProtocol,
386 state: &Value,
387 options: &WasmRenderOptions,
388) -> Result<String, WasmError> {
389 let mut writer = StringWriter::with_capacity(4096);
390 handler.render(
391 protocol,
392 state,
393 &RenderOptions::new(&options.entry, &options.request_path),
394 &mut writer,
395 )?;
396 Ok(writer.content)
397}
398
399fn render_protocol_to_callback_value(
400 handler: &WebUIHandler,
401 protocol: &HandlerProtocol,
402 state: &Value,
403 options: &WasmRenderOptions,
404 on_chunk: &Function,
405) -> Result<(), WasmError> {
406 let mut writer = CallbackWriter::new(on_chunk);
407 handler.render(
408 protocol,
409 state,
410 &RenderOptions::new(&options.entry, &options.request_path),
411 &mut writer,
412 )?;
413 writer.flush()?;
414 Ok(())
415}
416
417pub(crate) fn parse_optional_plugin(
418 plugin: Option<&str>,
419) -> Result<Option<HandlerPluginKind>, WasmError> {
420 plugin.map(HandlerPluginKind::parse).transpose()
421}
422
423fn parse_render_options(options: Option<Object>) -> Result<WasmRenderOptions, WasmError> {
424 let mut parsed = WasmRenderOptions::default();
425 let Some(options) = options else {
426 return Ok(parsed);
427 };
428
429 if let Some(entry) = optional_string_field(options.as_ref(), "entry")? {
430 parsed.entry = entry;
431 }
432 if let Some(request_path) = optional_string_field(options.as_ref(), "requestPath")? {
433 parsed.request_path = request_path;
434 }
435 Ok(parsed)
436}
437
438fn optional_string_field(options: &JsValue, field: &str) -> Result<Option<String>, WasmError> {
439 let value = Reflect::get(options, &JsValue::from_str(field)).map_err(|_| {
440 WasmError::InvalidOptions(format!("failed to read `{field}` from options object"))
441 })?;
442 if value.is_null() || value.is_undefined() {
443 return Ok(None);
444 }
445 value.as_string().map(Some).ok_or_else(|| {
446 WasmError::InvalidOptions(format!("`{field}` must be a string when provided"))
447 })
448}
449
450fn create_handler(plugin: Option<HandlerPluginKind>) -> WebUIHandler {
451 match plugin {
452 Some(HandlerPluginKind::FastV2) => {
453 WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
454 }
455 Some(HandlerPluginKind::FastV3) => {
456 WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
457 }
458 Some(HandlerPluginKind::WebUI) => {
459 WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
460 }
461 None => WebUIHandler::new(),
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 fn structural_fragment(value: &str) -> webui_protocol::WebUIFragment {
470 let mut token = String::with_capacity("}}}webui:".len() + value.len());
471 token.push_str("}}}webui:");
472 token.push_str(value);
473 webui_protocol::WebUIFragment::signal(token, true)
474 }
475
476 #[test]
477 fn parse_plugin_keeps_fast_aliases_parser_free() {
478 assert_eq!(
479 parse_optional_plugin(Some("fast")).unwrap(),
480 Some(HandlerPluginKind::FastV2)
481 );
482 assert_eq!(
483 parse_optional_plugin(Some("fast-v2")).unwrap(),
484 Some(HandlerPluginKind::FastV2)
485 );
486 assert_eq!(
487 parse_optional_plugin(Some("fast-v3")).unwrap(),
488 Some(HandlerPluginKind::FastV3)
489 );
490 assert_eq!(
491 parse_optional_plugin(Some("webui")).unwrap(),
492 Some(HandlerPluginKind::WebUI)
493 );
494 }
495
496 #[test]
497 fn parse_plugin_rejects_unknown_names() {
498 let err = parse_optional_plugin(Some("unknown")).unwrap_err();
499 assert_eq!(
500 err.to_string(),
501 "Unknown plugin: unknown. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
502 );
503 }
504
505 #[test]
506 fn protocol_reuses_decoded_protocol() {
507 use std::collections::HashMap;
508 use webui_protocol::{FragmentList, WebUIFragment};
509
510 let mut fragments = HashMap::new();
511 fragments.insert(
512 "index.html".to_string(),
513 FragmentList {
514 fragments: vec![WebUIFragment::signal("name".to_string(), true)],
515 },
516 );
517 let bytes = WebUIProtocol::new(fragments)
518 .to_protobuf()
519 .expect("protocol should serialize");
520 let protocol = Protocol::new(&bytes, None).expect("protocol should load");
521
522 let first = protocol
523 .render(r#"{"name":"first"}"#, None)
524 .expect("first render should succeed");
525 let second = protocol
526 .render(r#"{"name":"second"}"#, None)
527 .expect("second render should succeed");
528
529 assert_eq!(first, "first");
530 assert_eq!(second, "second");
531 }
532
533 #[test]
534 fn render_projects_state_to_component_hydration_keys() {
535 use std::collections::HashMap;
536 use webui_protocol::{
537 ComponentData, FragmentList, InitialStateStrategy, StateProjectionMode, WebUIFragment,
538 };
539
540 let mut fragments = HashMap::new();
541 fragments.insert(
542 "index.html".to_string(),
543 FragmentList {
544 fragments: vec![
545 WebUIFragment::raw("<html><head>"),
546 structural_fragment("head_end"),
547 WebUIFragment::raw("</head><body>"),
548 WebUIFragment::component("client-card"),
549 structural_fragment("body_end"),
550 WebUIFragment::raw("</body></html>"),
551 ],
552 },
553 );
554 fragments.insert(
555 "client-card".to_string(),
556 FragmentList {
557 fragments: vec![WebUIFragment::raw("<p>client</p>")],
558 },
559 );
560 let mut protocol = WebUIProtocol::new(fragments);
561 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
562 protocol.components.insert(
563 "client-card".to_string(),
564 ComponentData {
565 hydration_mode: StateProjectionMode::Keys as i32,
566 hydration_keys: vec!["kept".to_string()],
567 ..Default::default()
568 },
569 );
570
571 let rendered = render_protocol_to_string(
572 &protocol,
573 r#"{"kept":"KEPT_VALUE_WASM","dropped":"DROPPED_VALUE_WASM"}"#,
574 "index.html",
575 "/",
576 Some(HandlerPluginKind::WebUI),
577 )
578 .expect("render should succeed");
579
580 assert!(
582 rendered.contains(r#""kept":"KEPT_VALUE_WASM""#),
583 "hydratable key missing from bootstrap state:\n{rendered}"
584 );
585 assert!(
587 !rendered.contains("DROPPED_VALUE_WASM"),
588 "server-only value leaked into render:\n{rendered}"
589 );
590 assert!(
591 !rendered.contains("dropped"),
592 "server-only key name leaked into render:\n{rendered}"
593 );
594 }
595}