33//! Provides high-performance server-side rendering by compiling the Rust
44//! WebUI handler directly into a `.node` native addon — no C ABI intermediary.
55//!
6+ //! The `render` function accepts pre-compiled protobuf protocol data (from
7+ //! `webui build`) and streams rendered HTML fragments via a callback, enabling
8+ //! true streaming SSR without buffering the entire response.
9+ //!
610//! ## Usage (from Node.js)
711//!
812//! ```js
9- //! // Load the native addon from the cargo build output
13+ //! import fs from 'node:fs';
14+ //!
15+ //! // Load the native addon
1016//! const mod = { exports: {} };
1117//! process.dlopen(mod, './target/release/libwebui_node.dylib');
12- //! const html = mod.exports.render('<h1>Hello, {{name}}!</h1>', '{"name": "WebUI"}');
13- //! // => "<h1>Hello, WebUI!</h1>"
18+ //! const { render } = mod.exports;
19+ //!
20+ //! // Read pre-compiled protocol (from `webui build`)
21+ //! const protocol = fs.readFileSync('./dist/protocol.bin');
22+ //! const state = '{"name": "WebUI"}';
23+ //!
24+ //! // Stream rendered fragments
25+ //! render(protocol, state, (chunk) => process.stdout.write(chunk));
1426//! ```
1527
28+ use napi:: bindgen_prelude:: { Buffer , Function } ;
1629use napi:: Error as NapiError ;
1730use napi_derive:: napi;
1831use serde_json:: Value ;
1932use webui_handler:: { ResponseWriter , WebUIHandler } ;
20- use webui_parser:: HtmlParser ;
2133use webui_protocol:: WebUIProtocol ;
2234
23- /// A simple string buffer for collecting rendered output .
24- struct StringWriter {
25- content : String ,
35+ /// A writer that streams each rendered fragment to a JS callback .
36+ struct CallbackWriter < ' a , ' env > {
37+ callback : & ' a Function < ' env , String , ( ) > ,
2638}
2739
28- impl StringWriter {
29- fn with_capacity ( cap : usize ) -> Self {
30- Self {
31- content : String :: with_capacity ( cap) ,
32- }
40+ impl < ' a , ' env > CallbackWriter < ' a , ' env > {
41+ fn new ( callback : & ' a Function < ' env , String , ( ) > ) -> Self {
42+ Self { callback }
3343 }
3444}
3545
36- impl ResponseWriter for StringWriter {
46+ impl ResponseWriter for CallbackWriter < ' _ , ' _ > {
3747 fn write ( & mut self , content : & str ) -> webui_handler:: Result < ( ) > {
38- self . content . push_str ( content) ;
48+ // Ignore return-value type mismatch — JS callbacks may return
49+ // non-undefined values (e.g. `res.write()` returns a boolean).
50+ let _ = self . callback . call ( content. to_owned ( ) ) ;
3951 Ok ( ( ) )
4052 }
4153
@@ -44,77 +56,98 @@ impl ResponseWriter for StringWriter {
4456 }
4557}
4658
47- /// Parse an HTML template and render it with JSON state data.
59+ /// Render a pre-compiled WebUI protocol with JSON state, streaming each
60+ /// fragment to the provided callback.
61+ ///
62+ /// # Arguments
4863///
49- /// This is the main entry point for Node.js consumers. It parses the template,
50- /// builds a protocol, and renders it with the provided state — all in one call.
64+ /// * `protocol_data` — Protobuf binary from `webui build` (zero-copy Buffer).
65+ /// * `state_json` — JSON string with the render state.
66+ /// * `on_chunk` — Called with each rendered HTML fragment as it is produced.
5167#[ napi]
52- pub fn render ( html : String , data_json : String ) -> napi:: Result < String > {
53- render_inner ( & html, & data_json) . map_err ( |e| NapiError :: from_reason ( e. to_string ( ) ) )
54- }
55-
56- fn render_inner ( html : & str , data_json : & str ) -> Result < String , RenderError > {
57- let mut parser = HtmlParser :: new ( ) ;
58- parser
59- . parse ( "index.html" , html)
60- . map_err ( |e| RenderError :: Parse ( e. to_string ( ) ) ) ?;
61-
62- let protocol = WebUIProtocol {
63- fragments : parser. into_fragment_records ( ) ,
64- } ;
65-
66- let state: Value =
67- serde_json:: from_str ( data_json) . map_err ( |e| RenderError :: State ( e. to_string ( ) ) ) ?;
68-
69- let initial_cap = html. len ( ) . max ( 1024 ) ;
70- let mut writer = StringWriter :: with_capacity ( initial_cap) ;
68+ pub fn render (
69+ protocol_data : Buffer ,
70+ state_json : String ,
71+ on_chunk : Function < String , ( ) > ,
72+ ) -> napi:: Result < ( ) > {
73+ let protocol = WebUIProtocol :: from_protobuf ( & protocol_data)
74+ . map_err ( |e| NapiError :: from_reason ( format ! ( "Protocol decode error: {e}" ) ) ) ?;
75+
76+ let state: Value = serde_json:: from_str ( & state_json)
77+ . map_err ( |e| NapiError :: from_reason ( format ! ( "State JSON error: {e}" ) ) ) ?;
78+
79+ let mut writer = CallbackWriter :: new ( & on_chunk) ;
7180 let handler = WebUIHandler :: new ( ) ;
7281 handler
7382 . render ( & protocol, & state, & mut writer)
74- . map_err ( |e| RenderError :: Render ( e . to_string ( ) ) ) ?;
83+ . map_err ( |e| NapiError :: from_reason ( format ! ( "Render error: {e}" ) ) ) ?;
7584
76- Ok ( writer. content )
77- }
78-
79- /// Errors from the render pipeline.
80- #[ derive( Debug , PartialEq ) ]
81- enum RenderError {
82- Parse ( String ) ,
83- State ( String ) ,
84- Render ( String ) ,
85- }
86-
87- impl std:: fmt:: Display for RenderError {
88- fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
89- match self {
90- RenderError :: Parse ( msg) => write ! ( f, "HTML parse error: {msg}" ) ,
91- RenderError :: State ( msg) => write ! ( f, "State JSON error: {msg}" ) ,
92- RenderError :: Render ( msg) => write ! ( f, "Render error: {msg}" ) ,
93- }
94- }
85+ Ok ( ( ) )
9586}
9687
9788#[ cfg( test) ]
9889mod tests {
9990 use super :: * ;
91+ use webui_parser:: HtmlParser ;
92+
93+ /// Helper: parse HTML into protobuf bytes for testing.
94+ fn build_protocol ( html : & str ) -> Vec < u8 > {
95+ let mut parser = HtmlParser :: new ( ) ;
96+ parser. parse ( "index.html" , html) . expect ( "parse failed" ) ;
97+ let protocol = WebUIProtocol {
98+ fragments : parser. into_fragment_records ( ) ,
99+ } ;
100+ protocol. to_protobuf ( ) . expect ( "protobuf encode failed" )
101+ }
102+
103+ /// Helper: render protocol bytes + state, collecting output into a String.
104+ fn render_to_string ( protocol_bytes : & [ u8 ] , state_json : & str ) -> Result < String , String > {
105+ let protocol = WebUIProtocol :: from_protobuf ( protocol_bytes) . map_err ( |e| e. to_string ( ) ) ?;
106+ let state: Value = serde_json:: from_str ( state_json) . map_err ( |e| e. to_string ( ) ) ?;
107+
108+ let mut output = String :: with_capacity ( 1024 ) ;
109+ let handler = WebUIHandler :: new ( ) ;
110+
111+ struct StringWriter < ' a > {
112+ output : & ' a mut String ,
113+ }
114+ impl ResponseWriter for StringWriter < ' _ > {
115+ fn write ( & mut self , content : & str ) -> webui_handler:: Result < ( ) > {
116+ self . output . push_str ( content) ;
117+ Ok ( ( ) )
118+ }
119+ fn end ( & mut self ) -> webui_handler:: Result < ( ) > {
120+ Ok ( ( ) )
121+ }
122+ }
123+
124+ let mut writer = StringWriter {
125+ output : & mut output,
126+ } ;
127+ handler
128+ . render ( & protocol, & state, & mut writer)
129+ . map_err ( |e| e. to_string ( ) ) ?;
130+ Ok ( output)
131+ }
100132
101133 #[ test]
102134 fn test_simple_passthrough ( ) {
103- let result = render_inner ( "<p>Hello</p>" , "{}" ) ;
135+ let proto = build_protocol ( "<p>Hello</p>" ) ;
136+ let result = render_to_string ( & proto, "{}" ) ;
104137 assert_eq ! ( result. as_deref( ) , Ok ( "<p>Hello</p>" ) ) ;
105138 }
106139
107140 #[ test]
108141 fn test_signal_substitution ( ) {
109- let result = render_inner ( "Hello, {{name}}!" , r#"{"name": "WebUI"}"# ) ;
142+ let proto = build_protocol ( "Hello, {{name}}!" ) ;
143+ let result = render_to_string ( & proto, r#"{"name": "WebUI"}"# ) ;
110144 assert_eq ! ( result. as_deref( ) , Ok ( "Hello, WebUI!" ) ) ;
111145 }
112146
113147 #[ test]
114148 fn test_for_loop ( ) {
115- let html = "<ul><for each=\" item in items\" ><li>{{item}}</li></for></ul>" ;
116- let state = r#"{"items": ["a", "b", "c"]}"# ;
117- let result = render_inner ( html, state) ;
149+ let proto = build_protocol ( "<ul><for each=\" item in items\" ><li>{{item}}</li></for></ul>" ) ;
150+ let result = render_to_string ( & proto, r#"{"items": ["a", "b", "c"]}"# ) ;
118151 assert_eq ! (
119152 result. as_deref( ) ,
120153 Ok ( "<ul><li>a</li><li>b</li><li>c</li></ul>" )
@@ -123,54 +156,58 @@ mod tests {
123156
124157 #[ test]
125158 fn test_if_condition_true ( ) {
126- let html = "<if condition=\" show\" ><p>Visible</p></if>" ;
127- let result = render_inner ( html , r#"{"show": true}"# ) ;
159+ let proto = build_protocol ( "<if condition=\" show\" ><p>Visible</p></if>" ) ;
160+ let result = render_to_string ( & proto , r#"{"show": true}"# ) ;
128161 assert_eq ! ( result. as_deref( ) , Ok ( "<p>Visible</p>" ) ) ;
129162 }
130163
131164 #[ test]
132165 fn test_if_condition_false ( ) {
133- let html = "<if condition=\" show\" ><p>Hidden</p></if>" ;
134- let result = render_inner ( html , r#"{"show": false}"# ) ;
166+ let proto = build_protocol ( "<if condition=\" show\" ><p>Hidden</p></if>" ) ;
167+ let result = render_to_string ( & proto , r#"{"show": false}"# ) ;
135168 assert_eq ! ( result. as_deref( ) , Ok ( "" ) ) ;
136169 }
137170
138171 #[ test]
139172 fn test_html_escaping ( ) {
140- let html = "<div>{{content}}</div>" ;
173+ let proto = build_protocol ( "<div>{{content}}</div>" ) ;
141174 let state = r#"{"content": "<script>alert('xss')</script>"}"# ;
142- let result = render_inner ( html , state) . expect ( "render should succeed" ) ;
175+ let result = render_to_string ( & proto , state) . expect ( "render should succeed" ) ;
143176 assert ! ( !result. contains( "<script>" ) ) ;
144177 assert ! ( result. contains( "<script>" ) ) ;
145178 }
146179
147180 #[ test]
148181 fn test_raw_signal ( ) {
149- let html = "<div>{{{content}}}</div>" ;
150- let state = r#"{"content": "<b>bold</b>"}"# ;
151- let result = render_inner ( html, state) ;
182+ let proto = build_protocol ( "<div>{{{content}}}</div>" ) ;
183+ let result = render_to_string ( & proto, r#"{"content": "<b>bold</b>"}"# ) ;
152184 assert_eq ! ( result. as_deref( ) , Ok ( "<div><b>bold</b></div>" ) ) ;
153185 }
154186
155187 #[ test]
156188 fn test_invalid_json ( ) {
157- let result = render_inner ( "<p>hi</p>" , "NOT JSON" ) ;
189+ let proto = build_protocol ( "<p>hi</p>" ) ;
190+ let result = render_to_string ( & proto, "NOT JSON" ) ;
158191 assert ! ( result. is_err( ) ) ;
159- let err = result. unwrap_err ( ) . to_string ( ) ;
160- assert ! ( err. contains( "State JSON error" ) , "Unexpected error: {err}" ) ;
161192 }
162193
163194 #[ test]
164195 fn test_empty_state ( ) {
165- let result = render_inner ( "<p>static</p>" , "{}" ) ;
196+ let proto = build_protocol ( "<p>static</p>" ) ;
197+ let result = render_to_string ( & proto, "{}" ) ;
166198 assert_eq ! ( result. as_deref( ) , Ok ( "<p>static</p>" ) ) ;
167199 }
168200
169201 #[ test]
170202 fn test_nested_object_signal ( ) {
171- let html = "{{user.name}}" ;
172- let state = r#"{"user": {"name": "Alice"}}"# ;
173- let result = render_inner ( html, state) ;
203+ let proto = build_protocol ( "{{user.name}}" ) ;
204+ let result = render_to_string ( & proto, r#"{"user": {"name": "Alice"}}"# ) ;
174205 assert_eq ! ( result. as_deref( ) , Ok ( "Alice" ) ) ;
175206 }
207+
208+ #[ test]
209+ fn test_invalid_protobuf ( ) {
210+ let result = render_to_string ( & [ 0xFF , 0xFF , 0xFF ] , "{}" ) ;
211+ assert ! ( result. is_err( ) ) ;
212+ }
176213}
0 commit comments