Skip to content

Commit 20665fb

Browse files
fix to more streamable node
1 parent bbfa3bd commit 20665fb

9 files changed

Lines changed: 158 additions & 122 deletions

File tree

crates/webui-node/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ crate-type = ["cdylib"]
1313
[dependencies]
1414
napi = { workspace = true }
1515
napi-derive = { workspace = true }
16-
webui-parser = { path = "../webui-parser", default-features = false }
1716
webui-handler = { path = "../webui-handler" }
1817
webui-protocol = { path = "../webui-protocol" }
1918
serde_json = { workspace = true }
@@ -22,4 +21,5 @@ serde_json = { workspace = true }
2221
napi-build = { workspace = true }
2322

2423
[dev-dependencies]
24+
webui-parser = { path = "../webui-parser", default-features = false }
2525
webui-test-utils = { path = "../webui-test-utils" }

crates/webui-node/src/lib.rs

Lines changed: 114 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,51 @@
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};
1629
use napi::Error as NapiError;
1730
use napi_derive::napi;
1831
use serde_json::Value;
1932
use webui_handler::{ResponseWriter, WebUIHandler};
20-
use webui_parser::HtmlParser;
2133
use 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)]
9889
mod 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("&lt;script&gt;"));
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
}

examples/integration/node-express/src/hmr.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,18 @@ import fs from "node:fs";
55
/**
66
* Return the HMR version string (latest mtime in ms since epoch).
77
*
8-
* @param {string} templatePath - Path to the template file
8+
* @param {string} protocolBinPath - Path to the protocol.bin file
99
* @param {string} dataPath - Path to the data file
1010
* @returns {string} Milliseconds since epoch, or "0"
1111
*/
12-
export function hmrVersion(templatePath, dataPath) {
12+
export function hmrVersion(protocolBinPath, dataPath) {
1313
let latest = 0;
1414

1515
try {
16-
const tStat = fs.statSync(templatePath);
16+
const tStat = fs.statSync(protocolBinPath);
1717
latest = Math.max(latest, tStat.mtimeMs);
1818
} catch {
19-
// Template file may not exist
19+
// Protocol file may not exist
2020
}
2121

2222
try {

examples/integration/node-express/src/index.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url";
1212
import express from "express";
1313
import minimist from "minimist";
1414

15-
import { renderToIndexHtml } from "./render.js";
15+
import { renderToResponse } from "./render.js";
1616
import { startFileWatcher } from "./watcher.js";
1717
import { indexRoute } from "./routes/index.js";
1818
import { assetsRoute } from "./routes/assets.js";
@@ -35,27 +35,27 @@ function main() {
3535
process.exit(1);
3636
}
3737

38+
// Protocol must be pre-built by `webui build`
39+
const protocolBin = path.join(appDir, "dist", "protocol.bin");
40+
if (!fs.existsSync(protocolBin)) {
41+
process.stderr.write(
42+
` ✘ Protocol not found: ${protocolBin}\n` +
43+
` hint: Run 'cargo run -p webui-cli -- build ${path.join(appDir, "templates")} --out ${path.join(appDir, "dist")}' first\n`,
44+
);
45+
process.exit(1);
46+
}
47+
3848
const paths = {
39-
template: path.join(appDir, "templates", "index.html"),
49+
protocolBin,
4050
data: path.join(appDir, "data", "state.json"),
4151
assetsDir: path.join(appDir, "assets"),
42-
distDir: path.resolve(__dirname, "..", "dist"),
4352
};
4453

4554
// Styled console output (mirrors Rust Printer)
4655
process.stderr.write(`\n ⚡ WebUI Express Server\n`);
4756
process.stderr.write(` ▸ App ${args.app}\n`);
4857
process.stderr.write(` ▸ Directory ${appDir}\n`);
4958

50-
// Initial render
51-
try {
52-
renderToIndexHtml(paths);
53-
process.stderr.write(` ✔ Initial render complete\n`);
54-
} catch (err) {
55-
process.stderr.write(` ✘ Initial render failed: ${err.message}\n`);
56-
process.exit(1);
57-
}
58-
5959
// Start file watcher for HMR
6060
startFileWatcher(paths);
6161
process.stderr.write(` ✔ File watcher started\n`);

0 commit comments

Comments
 (0)