Skip to main content

webui_node/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Node.js native addon for the WebUI framework via napi-rs.
5//!
6//! Provides high-performance server-side rendering by compiling the Rust
7//! WebUI handler directly into a `.node` native addon — no C ABI intermediary.
8//!
9//! The `render` function accepts pre-compiled protobuf protocol data (from
10//! `webui build`) and streams rendered HTML fragments via a callback, enabling
11//! true streaming SSR without buffering the entire response.
12//!
13//! ## Usage (from Node.js)
14//!
15//! ```js
16//! import fs from 'node:fs';
17//!
18//! // Load the native addon
19//! const mod = { exports: {} };
20//! process.dlopen(mod, './target/release/libwebui_node.dylib');
21//! const { render } = mod.exports;
22//!
23//! // Read pre-compiled protocol (from `webui build`)
24//! const protocol = fs.readFileSync('./dist/protocol.bin');
25//! const state = '{"name": "WebUI"}';
26//!
27//! // Stream rendered fragments
28//! render(protocol, state, (chunk) => process.stdout.write(chunk));
29//! ```
30
31use napi::bindgen_prelude::{Buffer, Function};
32use napi::Error as NapiError;
33use napi_derive::napi;
34use serde_json::Value;
35use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
36use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
37use webui_handler::plugin::webui::WebUIHydrationPlugin;
38use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
39use webui_protocol::WebUIProtocol;
40
41/// Build statistics returned from the build function.
42#[napi(object)]
43pub struct JsBuildStats {
44    /// Build duration in milliseconds.
45    pub duration_ms: f64,
46    /// Total number of protocol fragments.
47    pub fragment_count: u32,
48    /// Number of registered components.
49    pub component_count: u32,
50    /// Number of CSS files produced.
51    pub css_file_count: u32,
52    /// Size of the serialized protocol in bytes.
53    pub protocol_size_bytes: u32,
54    /// Number of unique CSS tokens discovered.
55    pub token_count: u32,
56}
57
58/// Result of a successful build operation.
59#[napi(object)]
60pub struct JsBuildResult {
61    /// Serialized protocol (protobuf binary).
62    pub protocol: Buffer,
63    /// CSS files as alternating [filename, content, filename, content, ...].
64    pub css_files: Vec<String>,
65    /// Build statistics.
66    pub stats: JsBuildStats,
67}
68
69/// Build options for the webui build API.
70#[napi(object)]
71pub struct JsBuildOptions {
72    /// Path to the application folder containing templates.
73    pub app_dir: String,
74    /// Entry HTML file name (defaults to "index.html").
75    pub entry: Option<String>,
76    /// CSS mode: "link" (default) or "style".
77    pub css: Option<String>,
78    /// DOM strategy for component rendering: "shadow" (default) or "light".
79    pub dom: Option<String>,
80    /// Plugin identifier (see crate documentation for available identifiers).
81    pub plugin: Option<String>,
82    /// Additional component sources (npm packages or local paths).
83    pub components: Option<Vec<String>>,
84    /// Link-mode CSS filename template using [name], [hash], [ext].
85    pub css_file_name_template: Option<String>,
86    /// Optional base URL/path prefix for Link-mode css hrefs.
87    pub css_public_base: Option<String>,
88}
89
90/// Build a WebUI application from an app directory.
91///
92/// Returns the compiled protocol bytes, CSS files, and build statistics.
93#[napi]
94#[allow(clippy::cast_possible_truncation)] // stats are bounded by component/file counts
95pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
96    let css = options
97        .css
98        .map(|s| s.parse::<webui::CssStrategy>())
99        .transpose()
100        .map_err(NapiError::from_reason)?
101        .unwrap_or_default();
102
103    let dom = options
104        .dom
105        .map(|s| s.parse::<webui::DomStrategy>())
106        .transpose()
107        .map_err(NapiError::from_reason)?
108        .unwrap_or_default();
109
110    let plugin = options
111        .plugin
112        .map(|s| s.parse::<webui::Plugin>())
113        .transpose()
114        .map_err(NapiError::from_reason)?;
115
116    let build_options = webui::BuildOptions {
117        app_dir: std::path::PathBuf::from(&options.app_dir),
118        entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
119        css,
120        dom,
121        plugin,
122        components: options.components.unwrap_or_default(),
123        css_file_name_template: options
124            .css_file_name_template
125            .unwrap_or_else(|| webui::DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string()),
126        css_public_base: options.css_public_base,
127    };
128
129    let result = webui::build(build_options)
130        .map_err(|e| NapiError::from_reason(format!("Build error: {e}")))?;
131
132    // Flatten css_files into alternating [filename, content, ...] for JS interop
133    let css_files: Vec<String> = result
134        .css_files
135        .into_iter()
136        .flat_map(|(name, content)| [name, content])
137        .collect();
138
139    Ok(JsBuildResult {
140        protocol: Buffer::from(result.protocol_bytes),
141        css_files,
142        stats: JsBuildStats {
143            duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
144            fragment_count: result.stats.fragment_count as u32,
145            component_count: result.stats.component_count as u32,
146            css_file_count: result.stats.css_file_count as u32,
147            protocol_size_bytes: result.stats.protocol_size_bytes as u32,
148            token_count: result.stats.token_count as u32,
149        },
150    })
151}
152
153/// Inspect protocol bytes and return a JSON representation.
154#[napi]
155pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
156    webui::inspect_bytes(&protocol_data)
157        .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
158}
159
160/// Produce a complete JSON partial response for client-side navigation.
161///
162/// Combines application state, route templates, inventory, request path, and
163/// matched route chain into a single JSON string:
164/// `{"state":{...},"templates":[...],"inventory":"...","path":"...","chain":[...]}`.
165///
166/// Host servers return this directly — no assembly required.
167#[napi]
168pub fn render_partial(
169    protocol_data: Buffer,
170    state_json: String,
171    entry_id: String,
172    request_path: String,
173    inventory_hex: String,
174) -> napi::Result<String> {
175    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
176        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
177
178    let state: serde_json::Value = serde_json::from_str(&state_json)
179        .map_err(|e| NapiError::from_reason(format!("invalid state JSON: {e}")))?;
180
181    // TODO: ProtocolIndex is created per-request here because the Node binding
182    // receives the protocol fresh each call. Ideally the host should cache it
183    // alongside the protocol for the lifetime of the server — it's deterministic
184    // per protocol and avoids rebuilding the component index + route cache.
185    let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
186
187    let mut result = webui_handler::route_handler::render_partial(
188        &protocol,
189        &entry_id,
190        &request_path,
191        &inventory_hex,
192        &mut index,
193    )
194    .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))?;
195    if let Some(obj) = result.as_object_mut() {
196        obj.insert("state".into(), state);
197    }
198
199    serde_json::to_string(&result)
200        .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
201}
202
203#[napi]
204pub fn render_component_templates(
205    protocol_data: Buffer,
206    component_tags_json: String,
207    inventory_hex: String,
208) -> napi::Result<String> {
209    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
210        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
211
212    let tags: Vec<String> = serde_json::from_str(&component_tags_json)
213        .map_err(|e| NapiError::from_reason(format!("invalid tags JSON: {e}")))?;
214    let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
215
216    // Per-request index — see ProtocolIndex doc for caching guidance.
217    let index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
218
219    let result = webui_handler::route_handler::render_component_templates(
220        &protocol,
221        &tag_refs,
222        &inventory_hex,
223        &index,
224    )
225    .map_err(|e| NapiError::from_reason(format!("render_component_templates failed: {e}")))?;
226
227    serde_json::to_string(&result)
228        .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
229}
230
231/// Extract the CSS token name list from a compiled protocol.
232///
233/// Returns the tokens as a JavaScript string array, preserving the original
234/// order from the build step.
235#[napi]
236pub fn protocol_tokens(protocol_data: Buffer) -> napi::Result<Vec<String>> {
237    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
238        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
239
240    Ok(protocol.tokens)
241}
242
243/// A writer that streams each rendered fragment to a JS callback.
244struct CallbackWriter<'a, 'env> {
245    callback: &'a Function<'env, String, ()>,
246    error: Option<String>,
247}
248
249impl<'a, 'env> CallbackWriter<'a, 'env> {
250    fn new(callback: &'a Function<'env, String, ()>) -> Self {
251        Self {
252            callback,
253            error: None,
254        }
255    }
256}
257
258impl ResponseWriter for CallbackWriter<'_, '_> {
259    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
260        if self.error.is_some() {
261            return Ok(());
262        }
263        if let Err(e) = self.callback.call(content.to_owned()) {
264            // Ignore "Value is not undefined" errors from callbacks that
265            // return non-void (e.g. res.write() returns a boolean).
266            let msg = format!("{e}");
267            if !msg.contains("Value is not undefined") {
268                self.error = Some(msg);
269            }
270        }
271        Ok(())
272    }
273
274    fn end(&mut self) -> webui_handler::Result<()> {
275        Ok(())
276    }
277}
278
279/// Render a pre-compiled WebUI protocol with JSON state, streaming each
280/// fragment to the provided callback.
281///
282/// # Arguments
283///
284/// * `protocol_data` — Protobuf binary from `webui build` (zero-copy Buffer).
285/// * `state_json` — JSON string with the render state.
286/// * `on_chunk` — Called with each rendered HTML fragment as it is produced.
287/// * `plugin` — Optional plugin identifier (see crate documentation for available identifiers).
288#[napi]
289#[allow(clippy::too_many_arguments)]
290pub fn render(
291    protocol_data: Buffer,
292    state_json: String,
293    entry: String,
294    request_path: String,
295    on_chunk: Function<String, ()>,
296    plugin: Option<String>,
297) -> napi::Result<()> {
298    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
299        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
300
301    let state: Value = serde_json::from_str(&state_json)
302        .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))?;
303
304    let mut writer = CallbackWriter::new(&on_chunk);
305    let plugin = plugin
306        .map(|s| s.parse::<webui::Plugin>())
307        .transpose()
308        .map_err(NapiError::from_reason)?;
309    let handler = match plugin {
310        Some(webui::Plugin::Fast | webui::Plugin::FastV2) => {
311            WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
312        }
313        Some(webui::Plugin::FastV3) => {
314            WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
315        }
316        Some(webui::Plugin::WebUI) => {
317            WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
318        }
319        None => WebUIHandler::new(),
320    };
321    handler
322        .render(
323            &protocol,
324            &state,
325            &RenderOptions::new(&entry, &request_path),
326            &mut writer,
327        )
328        .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
329
330    if let Some(err) = writer.error {
331        return Err(NapiError::from_reason(format!("Callback error: {err}")));
332    }
333
334    Ok(())
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use webui_parser::HtmlParser;
341
342    /// Helper: parse HTML into protobuf bytes for testing.
343    fn build_protocol(html: &str) -> Vec<u8> {
344        let mut parser = HtmlParser::new();
345        parser.parse("index.html", html).expect("parse failed");
346        let tokens = parser.take_tokens();
347        let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
348        protocol.to_protobuf().expect("protobuf encode failed")
349    }
350
351    /// Helper: render protocol bytes + state, collecting output into a String.
352    fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
353        let protocol = WebUIProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
354        let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
355
356        let mut output = String::with_capacity(1024);
357        let handler = WebUIHandler::new();
358
359        struct StringWriter<'a> {
360            output: &'a mut String,
361        }
362        impl ResponseWriter for StringWriter<'_> {
363            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
364                self.output.push_str(content);
365                Ok(())
366            }
367            fn end(&mut self) -> webui_handler::Result<()> {
368                Ok(())
369            }
370        }
371
372        let mut writer = StringWriter {
373            output: &mut output,
374        };
375        handler
376            .render(
377                &protocol,
378                &state,
379                &RenderOptions::new("index.html", "/"),
380                &mut writer,
381            )
382            .map_err(|e| e.to_string())?;
383        Ok(output)
384    }
385
386    #[test]
387    fn test_simple_passthrough() {
388        let proto = build_protocol("<p>Hello</p>");
389        let result = render_to_string(&proto, "{}");
390        assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
391    }
392
393    #[test]
394    fn test_signal_substitution() {
395        let proto = build_protocol("Hello, {{name}}!");
396        let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
397        assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
398    }
399
400    #[test]
401    fn test_for_loop() {
402        let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
403        let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
404        assert_eq!(
405            result.as_deref(),
406            Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
407        );
408    }
409
410    #[test]
411    fn test_if_condition_true() {
412        let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
413        let result = render_to_string(&proto, r#"{"show": true}"#);
414        assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
415    }
416
417    #[test]
418    fn test_if_condition_false() {
419        let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
420        let result = render_to_string(&proto, r#"{"show": false}"#);
421        assert_eq!(result.as_deref(), Ok(""));
422    }
423
424    #[test]
425    fn test_html_escaping() {
426        let proto = build_protocol("<div>{{content}}</div>");
427        let state = r#"{"content": "<script>alert('xss')</script>"}"#;
428        let result = render_to_string(&proto, state).expect("render should succeed");
429        assert!(!result.contains("<script>"));
430        assert!(result.contains("&lt;script&gt;"));
431    }
432
433    #[test]
434    fn test_raw_signal() {
435        let proto = build_protocol("<div>{{{content}}}</div>");
436        let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
437        assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
438    }
439
440    #[test]
441    fn test_invalid_json() {
442        let proto = build_protocol("<p>hi</p>");
443        let result = render_to_string(&proto, "NOT JSON");
444        assert!(result.is_err());
445    }
446
447    #[test]
448    fn test_empty_state() {
449        let proto = build_protocol("<p>static</p>");
450        let result = render_to_string(&proto, "{}");
451        assert_eq!(result.as_deref(), Ok("<p>static</p>"));
452    }
453
454    #[test]
455    fn test_nested_object_signal() {
456        let proto = build_protocol("{{user.name}}");
457        let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
458        assert_eq!(result.as_deref(), Ok("Alice"));
459    }
460
461    #[test]
462    fn test_invalid_protobuf() {
463        let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
464        assert!(result.is_err());
465    }
466
467    // ── Tests for build() and inspect() napi exports ─────────────────
468
469    #[test]
470    fn test_build_simple_app() {
471        let dir = tempfile::TempDir::new().unwrap();
472        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
473
474        let options = JsBuildOptions {
475            app_dir: dir.path().to_string_lossy().to_string(),
476            entry: None,
477            css: None,
478            dom: None,
479            plugin: None,
480            components: None,
481            css_file_name_template: None,
482            css_public_base: None,
483        };
484
485        let result = build(options).unwrap();
486        assert!(!result.protocol.is_empty());
487        assert!(result.stats.fragment_count > 0);
488        assert!(result.stats.protocol_size_bytes > 0);
489        assert!(result.stats.duration_ms >= 0.0);
490    }
491
492    #[test]
493    fn test_build_with_custom_entry() {
494        let dir = tempfile::TempDir::new().unwrap();
495        std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
496
497        let options = JsBuildOptions {
498            app_dir: dir.path().to_string_lossy().to_string(),
499            entry: Some("page.html".to_string()),
500            css: None,
501            dom: None,
502            plugin: None,
503            components: None,
504            css_file_name_template: None,
505            css_public_base: None,
506        };
507
508        let result = build(options).unwrap();
509        assert!(!result.protocol.is_empty());
510    }
511
512    #[test]
513    fn test_build_missing_app_dir() {
514        let options = JsBuildOptions {
515            app_dir: "/nonexistent/path".to_string(),
516            entry: None,
517            css: None,
518            dom: None,
519            plugin: None,
520            components: None,
521            css_file_name_template: None,
522            css_public_base: None,
523        };
524
525        let result = build(options);
526        assert!(result.is_err());
527    }
528
529    #[test]
530    fn test_build_invalid_css() {
531        let dir = tempfile::TempDir::new().unwrap();
532        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
533
534        let options = JsBuildOptions {
535            app_dir: dir.path().to_string_lossy().to_string(),
536            entry: None,
537            css: Some("bogus".to_string()),
538            dom: None,
539            plugin: None,
540            components: None,
541            css_file_name_template: None,
542            css_public_base: None,
543        };
544
545        let result = build(options);
546        assert!(result.is_err());
547    }
548
549    #[test]
550    fn test_build_with_components_css() {
551        let dir = tempfile::TempDir::new().unwrap();
552        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
553        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
554        std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
555
556        let options = JsBuildOptions {
557            app_dir: dir.path().to_string_lossy().to_string(),
558            entry: None,
559            css: Some("link".to_string()),
560            dom: None,
561            plugin: None,
562            components: None,
563            css_file_name_template: None,
564            css_public_base: None,
565        };
566
567        let result = build(options).unwrap();
568        // css_files is flattened: [filename, content, filename, content, ...]
569        assert_eq!(result.css_files.len(), 2);
570        assert_eq!(result.css_files[0], "my-card.css");
571        assert!(result.css_files[1].contains("color: red"));
572        assert_eq!(result.stats.css_file_count, 1);
573    }
574
575    #[test]
576    fn test_build_with_light_dom_omits_shadow_root_template() {
577        let dir = tempfile::TempDir::new().unwrap();
578        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
579        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
580
581        let options = JsBuildOptions {
582            app_dir: dir.path().to_string_lossy().to_string(),
583            entry: None,
584            css: None,
585            dom: Some("light".to_string()),
586            plugin: None,
587            components: None,
588            css_file_name_template: None,
589            css_public_base: None,
590        };
591
592        let result = build(options).unwrap();
593        let json = inspect(result.protocol).unwrap();
594        assert!(
595            !json.contains("shadowrootmode"),
596            "light DOM build should not emit shadowrootmode wrappers, got: {json}"
597        );
598    }
599
600    #[test]
601    fn test_build_with_shadow_dom_emits_shadow_root_template() {
602        let dir = tempfile::TempDir::new().unwrap();
603        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
604        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
605
606        let options = JsBuildOptions {
607            app_dir: dir.path().to_string_lossy().to_string(),
608            entry: None,
609            css: None,
610            dom: Some("shadow".to_string()),
611            plugin: None,
612            components: None,
613            css_file_name_template: None,
614            css_public_base: None,
615        };
616
617        let result = build(options).unwrap();
618        let json = inspect(result.protocol).unwrap();
619        assert!(
620            json.contains("shadowrootmode"),
621            "shadow DOM build should emit shadowrootmode wrappers, got: {json}"
622        );
623    }
624
625    #[test]
626    fn test_build_invalid_dom() {
627        let dir = tempfile::TempDir::new().unwrap();
628        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
629
630        let options = JsBuildOptions {
631            app_dir: dir.path().to_string_lossy().to_string(),
632            entry: None,
633            css: None,
634            dom: Some("bogus".to_string()),
635            plugin: None,
636            components: None,
637            css_file_name_template: None,
638            css_public_base: None,
639        };
640
641        let result = build(options);
642        assert!(result.is_err());
643    }
644
645    #[test]
646    fn test_inspect_valid_protocol() {
647        let proto = build_protocol("<h1>Hello {{name}}</h1>");
648        let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
649        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
650        assert!(parsed.get("fragments").is_some());
651    }
652
653    #[test]
654    fn test_inspect_invalid_protocol() {
655        let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
656        assert!(result.is_err());
657    }
658
659    // ── Tests for protocol_tokens napi export ────────────────────────
660
661    #[test]
662    fn test_protocol_tokens_empty() {
663        let proto = build_protocol("<p>Hello</p>");
664        let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
665        assert!(tokens.is_empty());
666    }
667
668    #[test]
669    fn test_protocol_tokens_returns_parsed_tokens() {
670        // Build from a protocol that has CSS tokens via with_tokens constructor.
671        let mut parser = HtmlParser::new();
672        parser.parse("index.html", "<p>Hi</p>").expect("parse");
673        let protocol = WebUIProtocol::with_tokens(
674            parser.into_fragment_records(),
675            vec![
676                "colorBrandBackground".to_string(),
677                "fontSizeBase300".to_string(),
678            ],
679        );
680        let proto = protocol.to_protobuf().expect("encode");
681        let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
682        assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
683    }
684
685    #[test]
686    fn test_protocol_tokens_invalid_protobuf() {
687        let result = protocol_tokens(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
688        assert!(result.is_err());
689    }
690}