microsoft-webui 3.0.3

Programmatic API for the WebUI build-time rendering framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

//! Benchmarks comparing buffered vs streaming render paths.
//!
//! Two benchmark groups against the real contact-book-manager protocol
//! at three contact scales (10/100/1000):
//!
//! ## `writer_paths` — total render throughput
//!
//! Compares four writer paths head-to-head, measuring **total** render
//! time (producer + consumer drain). All paths produce byte-identical
//! output; the only thing changing is how the bytes are delivered.
//!
//! 1. **String** — baseline. Pre-allocated `String` buffer.
//! 2. **StreamingWriter** — bounded tokio mpsc, default capacity = 4 chunks.
//! 3. **StreamingWriter + RenderOptions inject** — production path:
//!    head/body inject HTML emitted by the handler at the structural
//!    `head_end`/`body_end` signal boundaries. Zero scan cost.
//! 4. **String + post-render inject** — mirrors the legacy
//!    `lr.inject(&buf)` path the streaming work replaces.
//!
//! ## `ttfb` — time-to-first-byte
//!
//! Measures the latency from "render started" to "first chunk available
//! to the consumer." This is the metric streaming was designed to
//! improve. For each scenario, compares:
//!
//! * **buffered_ttfb** — String render: full render time (no chunks
//!   until end).
//! * **streaming_ttfb** — Streaming render: time until first 4 KB
//!   chunk is available on the receiver.
//!
//! Run with: `cargo bench -p microsoft-webui --bench streaming_bench`

#![allow(missing_docs)]

use bytes::Bytes;
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use serde_json::{json, Value};
use std::hint::black_box;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use webui::streaming::StreamingWriter;
use webui::{build, BuildOptions, CssStrategy, Protocol, ResponseWriter, WebUIHandler};
use webui_handler::RenderOptions;

const CONTACT_COUNTS: &[usize] = &[10, 100, 1000];
const MEASUREMENT_TIME: Duration = Duration::from_secs(8);
const SAMPLE_SIZE: usize = 50;

const HEAD_INJECT: &str = r#"<link rel="preload" as="image" href="/img/hero.jpg" fetchpriority="high"><link rel="preload" as="image" href="/img/p1.jpg"><link rel="preload" as="image" href="/img/p2.jpg">"#;
const BODY_INJECT: &str = r#"<script>(function(){var e=new EventSource('/__webui/livereload');e.addEventListener('reload',function(){location.reload()})})();</script>"#;

// ── State generation ──────────────────────────────────────────────────

const FIRST_NAMES: &[&str] = &[
    "Sarah", "Marcus", "Yuki", "Priya", "James", "Amara", "Luis", "Emma", "David", "Fatima",
];
const LAST_NAMES: &[&str] = &[
    "Chen",
    "Johnson",
    "Tanaka",
    "Sharma",
    "O'Brien",
    "Okafor",
    "Ramirez",
    "Lindström",
    "Kim",
    "Al-Hassan",
];
const GROUPS: &[&str] = &["Family", "Work", "Friends", "Other"];

fn generate_contact(idx: usize) -> Value {
    let first = FIRST_NAMES[idx % FIRST_NAMES.len()];
    let last = LAST_NAMES[idx % LAST_NAMES.len()];
    json!({
        "id": (idx + 1).to_string(),
        "firstName": first,
        "lastName": last,
        "email": format!("{}.{}@example.com", first.to_lowercase(), last.to_lowercase()),
        "phone": format!("+1 (555) {:03}-{:04}", (idx * 111) % 1000, (idx * 1234) % 10000),
        "company": "Contoso Ltd",
        "group": GROUPS[idx % GROUPS.len()],
        "favorite": idx.is_multiple_of(3),
        "initials": format!("{}{}", &first[..1], &last[..1]),
        "avatarColor": "#4A90D9",
        "notes": String::new(),
        "address": format!("{} St, Seattle, WA", (idx + 1) * 100),
    })
}

fn build_state(count: usize) -> Value {
    let contacts: Vec<Value> = (0..count).map(generate_contact).collect();
    let recent: Vec<Value> = contacts[count.saturating_sub(5)..].to_vec();
    json!({
        "page": "dashboard",
        "searchQuery": "",
        "activeGroup": "all",
        "groups": GROUPS,
        "totalContacts": count,
        "totalFavorites": 0,
        "totalGroups": GROUPS.len(),
        "contacts": contacts.clone(),
        "filteredContacts": contacts,
        "recentContacts": recent,
        "favoriteContacts": Vec::<Value>::new(),
        "selectedContact": null,
    })
}

fn build_protocol() -> Arc<Protocol> {
    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let app_dir = manifest
        .join("..")
        .join("..")
        .join("examples")
        .join("app")
        .join("contact-book-manager")
        .join("src");
    let document = build(BuildOptions {
        app_dir,
        entry: "index.html".to_string(),
        css: CssStrategy::Style,
        ..BuildOptions::default()
    })
    .expect("failed to build contact-book-manager protocol")
    .protocol;
    Arc::new(Protocol::new(document))
}

// ── Writers ────────────────────────────────────────────────────────────

struct StringWriter {
    buf: String,
}
impl StringWriter {
    fn with_capacity(cap: usize) -> Self {
        Self {
            buf: String::with_capacity(cap),
        }
    }
}
impl ResponseWriter for StringWriter {
    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
        self.buf.push_str(content);
        Ok(())
    }
    fn end(&mut self) -> webui_handler::Result<()> {
        Ok(())
    }
}

/// Drain a tokio mpsc receiver synchronously, summing bytes received.
/// Uses `try_recv` in a tight loop because the producer thread fills
/// the channel before the bench iteration ends; no async runtime is
/// involved in the measurement window.
fn drain_total(mut rx: mpsc::Receiver<Bytes>) -> usize {
    let mut total = 0;
    while let Some(chunk) = rx.blocking_recv() {
        total += chunk.len();
    }
    total
}

// ── writer_paths group: total render throughput ───────────────────────

fn bench_writers(c: &mut Criterion) {
    let protocol = build_protocol();
    let states: Vec<(usize, Value)> = CONTACT_COUNTS
        .iter()
        .map(|&n| (n, build_state(n)))
        .collect();

    // Measure output size per scenario (used for throughput).
    let sizes: Vec<usize> = states
        .iter()
        .map(|(_, state)| {
            let h = WebUIHandler::new();
            let mut w = StringWriter::with_capacity(512 * 1024);
            h.render(
                &protocol,
                state,
                &RenderOptions::new("index.html", "/"),
                &mut w,
            )
            .unwrap();
            w.buf.len()
        })
        .collect();

    let mut group = c.benchmark_group("writer_paths");
    group.measurement_time(MEASUREMENT_TIME);
    group.sample_size(SAMPLE_SIZE);

    for ((count, state), &output_size) in states.iter().zip(sizes.iter()) {
        group.throughput(Throughput::Bytes(output_size as u64));

        // Path 1: String (baseline).
        group.bench_with_input(
            BenchmarkId::new(format!("string/{count}"), output_size),
            state,
            |b, state| {
                let h = WebUIHandler::new();
                b.iter(|| {
                    let mut w = StringWriter::with_capacity(output_size);
                    h.render(
                        black_box(&protocol),
                        black_box(state),
                        &RenderOptions::new("index.html", "/"),
                        &mut w,
                    )
                    .unwrap();
                    black_box(w.buf.len());
                });
            },
        );

        // Path 2: StreamingWriter (bounded). Drain on the same thread
        // by running the producer first (fills channel up to its
        // capacity, then producer would block) — but with chunks
        // sized to fit in the channel we don't block.
        // To measure honestly without a separate thread, we use a
        // capacity that holds the entire output (~16 chunks for 64 KB).
        group.bench_with_input(
            BenchmarkId::new(format!("streaming/{count}"), output_size),
            state,
            |b, state| {
                let h = WebUIHandler::new();
                let cap = (output_size / StreamingWriter::CHUNK_TARGET) + 4;
                b.iter(|| {
                    let (tx, rx) = mpsc::channel::<Bytes>(cap);
                    let mut w = StreamingWriter::new(tx);
                    h.render(
                        black_box(&protocol),
                        black_box(state),
                        &RenderOptions::new("index.html", "/"),
                        &mut w,
                    )
                    .unwrap();
                    drop(w);
                    black_box(drain_total(rx));
                });
            },
        );

        // Path 3: Streaming + RenderOptions inject (production path).
        // The contact-book template is Shadow DOM (no <head>/<body>),
        // so the head_end/body_end signals never fire; the inject
        // strings are configured but unused. Cost = essentially the
        // same as path 2 (streaming alone).
        group.bench_with_input(
            BenchmarkId::new(format!("streaming+inject(opts)/{count}"), output_size),
            state,
            |b, state| {
                let h = WebUIHandler::new();
                let cap = (output_size / StreamingWriter::CHUNK_TARGET) + 4;
                b.iter(|| {
                    let (tx, rx) = mpsc::channel::<Bytes>(cap);
                    let mut w = StreamingWriter::new(tx);
                    let opts = RenderOptions::new("index.html", "/")
                        .with_head_inject(HEAD_INJECT)
                        .with_body_inject(BODY_INJECT);
                    h.render(black_box(&protocol), black_box(state), &opts, &mut w)
                        .unwrap();
                    drop(w);
                    black_box(drain_total(rx));
                });
            },
        );

        // Path 4: String + post-render injection (mirrors the OLD
        // livereload path the streaming work replaces).
        group.bench_with_input(
            BenchmarkId::new(format!("string+postinject/{count}"), output_size),
            state,
            |b, state| {
                let h = WebUIHandler::new();
                b.iter(|| {
                    let mut w = StringWriter::with_capacity(output_size);
                    h.render(
                        black_box(&protocol),
                        black_box(state),
                        &RenderOptions::new("index.html", "/"),
                        &mut w,
                    )
                    .unwrap();
                    let merged = post_inject(&w.buf, BODY_INJECT);
                    black_box(merged.len());
                });
            },
        );
    }
    group.finish();
}

/// Mirror of the legacy livereload injection: case-insensitive
/// `</body>` byte-window scan, then concatenate into a new String.
fn post_inject(html: &str, script: &str) -> String {
    if let Some(idx) = html
        .as_bytes()
        .windows(7)
        .position(|w| w.eq_ignore_ascii_case(b"</body>"))
    {
        let mut out = String::with_capacity(html.len() + script.len() + 2);
        out.push_str(&html[..idx]);
        out.push_str(script);
        out.push_str(&html[idx..]);
        out
    } else {
        let mut out = String::with_capacity(html.len() + script.len());
        out.push_str(html);
        out.push_str(script);
        out
    }
}

// ── ttfb group: time-to-first-byte (the streaming claim) ──────────────

/// Spawn the render on a dedicated thread (mirroring the production
/// `spawn_blocking` shape) and measure the time from "spawn" to "first
/// chunk available on the receiver." This is what the user sees as
/// "time to first byte" minus network latency.
///
/// Note: we deliberately drop the receiver after the first chunk to
/// measure latency, which causes the producer to error out with
/// `ClientDisconnected` on its next flush — that's the *correct*
/// production behaviour (cancel the render). We swallow that error
/// here because it's expected.
fn streaming_ttfb(protocol: &Arc<Protocol>, state: &Value) -> Duration {
    let (tx, mut rx) = mpsc::channel::<Bytes>(StreamingWriter::DEFAULT_CHANNEL_CAPACITY);
    let proto = protocol.clone();
    let st = state.clone();
    let start = Instant::now();
    std::thread::spawn(move || {
        let h = WebUIHandler::new();
        let mut w = StreamingWriter::new(tx);
        // Both calls may legitimately return Err(ClientDisconnected)
        // when the bench drops the receiver after the first chunk —
        // that's the production-correct cancellation path.
        if h.render(&proto, &st, &RenderOptions::new("index.html", "/"), &mut w)
            .is_err()
        {
            let _ = ResponseWriter::end(&mut w);
        }
    });
    // Block until the first chunk arrives.
    let _ = rx.blocking_recv();
    start.elapsed()
}

/// Buffered baseline: the receiver only sees bytes when the entire
/// render has completed and the result is handed off. This is what
/// `pnpm start:server` did before streaming.
fn buffered_ttfb(protocol: &Protocol, state: &Value) -> Duration {
    let h = WebUIHandler::new();
    let cap = 64 * 1024;
    let start = Instant::now();
    let mut w = StringWriter::with_capacity(cap);
    h.render(
        protocol,
        state,
        &RenderOptions::new("index.html", "/"),
        &mut w,
    )
    .unwrap();
    // "First byte" is when the response is complete in the buffered
    // model — there's nothing to send before that.
    start.elapsed()
}

fn bench_ttfb(c: &mut Criterion) {
    let protocol = build_protocol();
    let states: Vec<(usize, Value)> = CONTACT_COUNTS
        .iter()
        .map(|&n| (n, build_state(n)))
        .collect();

    let mut group = c.benchmark_group("ttfb");
    group.measurement_time(MEASUREMENT_TIME);
    group.sample_size(SAMPLE_SIZE);

    for (count, state) in &states {
        group.bench_with_input(BenchmarkId::new("buffered", count), state, |b, state| {
            b.iter_custom(|iters| {
                let mut total = Duration::ZERO;
                for _ in 0..iters {
                    total += buffered_ttfb(&protocol, state);
                }
                total
            });
        });

        group.bench_with_input(BenchmarkId::new("streaming", count), state, |b, state| {
            b.iter_custom(|iters| {
                let mut total = Duration::ZERO;
                for _ in 0..iters {
                    total += streaming_ttfb(&protocol, state);
                }
                total
            });
        });
    }
    group.finish();
}

criterion_group!(benches, bench_writers, bench_ttfb);
criterion_main!(benches);