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::{HandlerError, RenderOptions, ResponseWriter, WebUIHandler};
14use webui_protocol::WebUIProtocol;
15
16#[cfg(test)]
18struct StringWriter {
19 content: String,
20}
21
22#[cfg(test)]
23impl StringWriter {
24 fn with_capacity(cap: usize) -> Self {
25 Self {
26 content: String::with_capacity(cap),
27 }
28 }
29}
30
31#[cfg(test)]
32impl ResponseWriter for StringWriter {
33 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
34 self.content.push_str(content);
35 Ok(())
36 }
37
38 fn end(&mut self) -> webui_handler::Result<()> {
39 Ok(())
40 }
41}
42
43struct CallbackWriter<'a> {
45 on_chunk: &'a Function,
46}
47
48impl<'a> CallbackWriter<'a> {
49 fn new(on_chunk: &'a Function) -> Self {
50 Self { on_chunk }
51 }
52}
53
54impl ResponseWriter for CallbackWriter<'_> {
55 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
56 self.on_chunk
57 .call1(&JsValue::UNDEFINED, &JsValue::from_str(content))
58 .map(|_| ())
59 .map_err(|e| HandlerError::Writer(format!("{e:?}")))
60 }
61
62 fn end(&mut self) -> webui_handler::Result<()> {
63 Ok(())
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub(crate) enum HandlerPluginKind {
69 FastV2,
70 FastV3,
71 WebUI,
72}
73
74impl HandlerPluginKind {
75 fn parse(name: &str) -> Result<Self, WasmError> {
76 match name {
77 "fast" | "fast-v2" => Ok(Self::FastV2),
78 "fast-v3" => Ok(Self::FastV3),
79 "webui" => Ok(Self::WebUI),
80 other => Err(WasmError::UnknownPlugin(other.to_string())),
81 }
82 }
83}
84
85struct WasmRenderOptions {
86 entry: String,
87 request_path: String,
88 plugin: Option<HandlerPluginKind>,
89}
90
91impl Default for WasmRenderOptions {
92 fn default() -> Self {
93 Self {
94 entry: "index.html".to_string(),
95 request_path: "/".to_string(),
96 plugin: None,
97 }
98 }
99}
100
101#[wasm_bindgen]
114pub fn render(
115 protocol_bytes: &[u8],
116 state_json: &str,
117 on_chunk: &Function,
118 options: Option<Object>,
119) -> Result<(), JsValue> {
120 let options = parse_render_options(options).map_err(|e| JsValue::from_str(&e.to_string()))?;
121 render_stream_inner(protocol_bytes, state_json, on_chunk, &options)
122 .map_err(|e| JsValue::from_str(&e.to_string()))
123}
124
125#[wasm_bindgen]
133pub fn render_partial(
134 protocol_bytes: &[u8],
135 state_json: &str,
136 entry_id: &str,
137 request_path: &str,
138 inventory_hex: &str,
139) -> Result<String, JsValue> {
140 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)
141 .map_err(|e| JsValue::from_str(&format!("Protocol error: {e}")))?;
142
143 let state: serde_json::Value = serde_json::from_str(state_json)
144 .map_err(|e| JsValue::from_str(&format!("invalid state JSON: {e}")))?;
145
146 let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
147
148 let mut result = webui_handler::route_handler::render_partial(
149 &protocol,
150 entry_id,
151 request_path,
152 inventory_hex,
153 &mut index,
154 )
155 .map_err(|e| JsValue::from_str(&format!("render_partial failed: {e}")))?;
156 if let Some(obj) = result.as_object_mut() {
157 obj.insert("state".into(), state);
158 }
159
160 serde_json::to_string(&result)
161 .map_err(|e| JsValue::from_str(&format!("JSON serialize error: {e}")))
162}
163
164#[wasm_bindgen]
169pub fn protocol_tokens(protocol_bytes: &[u8]) -> Result<JsValue, JsValue> {
170 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)
171 .map_err(|e| JsValue::from_str(&format!("Protocol error: {e}")))?;
172
173 serde_wasm_bindgen::to_value(&protocol.tokens)
174 .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
175}
176
177#[wasm_bindgen]
179pub fn render_component_templates(
180 protocol_bytes: &[u8],
181 component_tags_json: &str,
182 inventory_hex: &str,
183) -> Result<String, JsValue> {
184 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)
185 .map_err(|e| JsValue::from_str(&format!("Protocol error: {e}")))?;
186
187 let tags: Vec<String> = serde_json::from_str(component_tags_json)
188 .map_err(|e| JsValue::from_str(&format!("invalid tags JSON: {e}")))?;
189 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
190
191 let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
192
193 let result = webui_handler::route_handler::render_component_templates(
194 &protocol,
195 &tag_refs,
196 inventory_hex,
197 &mut index,
198 )
199 .map_err(|e| JsValue::from_str(&format!("render_component_templates failed: {e}")))?;
200
201 serde_json::to_string(&result)
202 .map_err(|e| JsValue::from_str(&format!("JSON serialize error: {e}")))
203}
204
205fn render_stream_inner(
206 protocol_bytes: &[u8],
207 state_json: &str,
208 on_chunk: &Function,
209 options: &WasmRenderOptions,
210) -> Result<(), WasmError> {
211 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
212 render_protocol_to_callback(&protocol, state_json, options, on_chunk)
213}
214
215#[cfg(test)]
216pub(crate) fn render_protocol_to_string(
217 protocol: &WebUIProtocol,
218 state_json: &str,
219 entry: &str,
220 request_path: &str,
221 plugin: Option<HandlerPluginKind>,
222) -> Result<String, WasmError> {
223 let state: Value = serde_json::from_str(state_json).map_err(WasmError::State)?;
224
225 let mut writer = StringWriter::with_capacity(1024);
226 let handler = create_handler(plugin);
227 handler.render(
228 protocol,
229 &state,
230 &RenderOptions::new(entry, request_path),
231 &mut writer,
232 )?;
233
234 Ok(writer.content)
235}
236
237fn render_protocol_to_callback(
238 protocol: &WebUIProtocol,
239 state_json: &str,
240 options: &WasmRenderOptions,
241 on_chunk: &Function,
242) -> Result<(), WasmError> {
243 let state: Value = serde_json::from_str(state_json).map_err(WasmError::State)?;
244
245 let mut writer = CallbackWriter::new(on_chunk);
246 let handler = create_handler(options.plugin);
247 handler.render(
248 protocol,
249 &state,
250 &RenderOptions::new(&options.entry, &options.request_path),
251 &mut writer,
252 )?;
253
254 Ok(())
255}
256
257pub(crate) fn parse_optional_plugin(
258 plugin: Option<&str>,
259) -> Result<Option<HandlerPluginKind>, WasmError> {
260 plugin.map(HandlerPluginKind::parse).transpose()
261}
262
263fn parse_render_options(options: Option<Object>) -> Result<WasmRenderOptions, WasmError> {
264 let mut parsed = WasmRenderOptions::default();
265 let Some(options) = options else {
266 return Ok(parsed);
267 };
268
269 if let Some(entry) = optional_string_field(options.as_ref(), "entry")? {
270 parsed.entry = entry;
271 }
272 if let Some(request_path) = optional_string_field(options.as_ref(), "requestPath")? {
273 parsed.request_path = request_path;
274 }
275 let plugin = optional_string_field(options.as_ref(), "plugin")?;
276 parsed.plugin = parse_optional_plugin(plugin.as_deref())?;
277
278 Ok(parsed)
279}
280
281fn optional_string_field(options: &JsValue, field: &str) -> Result<Option<String>, WasmError> {
282 let value = Reflect::get(options, &JsValue::from_str(field)).map_err(|_| {
283 WasmError::InvalidOptions(format!("failed to read `{field}` from options object"))
284 })?;
285 if value.is_null() || value.is_undefined() {
286 return Ok(None);
287 }
288 value.as_string().map(Some).ok_or_else(|| {
289 WasmError::InvalidOptions(format!("`{field}` must be a string when provided"))
290 })
291}
292
293fn create_handler(plugin: Option<HandlerPluginKind>) -> WebUIHandler {
294 match plugin {
295 Some(HandlerPluginKind::FastV2) => {
296 WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
297 }
298 Some(HandlerPluginKind::FastV3) => {
299 WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
300 }
301 Some(HandlerPluginKind::WebUI) => {
302 WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
303 }
304 None => WebUIHandler::new(),
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn parse_plugin_keeps_fast_aliases_parser_free() {
314 assert_eq!(
315 parse_optional_plugin(Some("fast")).unwrap(),
316 Some(HandlerPluginKind::FastV2)
317 );
318 assert_eq!(
319 parse_optional_plugin(Some("fast-v2")).unwrap(),
320 Some(HandlerPluginKind::FastV2)
321 );
322 assert_eq!(
323 parse_optional_plugin(Some("fast-v3")).unwrap(),
324 Some(HandlerPluginKind::FastV3)
325 );
326 assert_eq!(
327 parse_optional_plugin(Some("webui")).unwrap(),
328 Some(HandlerPluginKind::WebUI)
329 );
330 }
331
332 #[test]
333 fn parse_plugin_rejects_unknown_names() {
334 let err = parse_optional_plugin(Some("unknown")).unwrap_err();
335 assert_eq!(
336 err.to_string(),
337 "Unknown plugin: unknown. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
338 );
339 }
340}