Skip to main content

webui_ffi/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4// FFI crate requires unsafe for C-compatible ABI boundary.
5#![allow(unsafe_code)]
6
7//! WebUI FFI (Foreign Function Interface) for interoperability with other languages.
8//!
9//! This crate provides C-compatible APIs for the WebUI handler to be used from languages
10//! like Go, C#, Python, etc.
11//!
12//! Load a compiled protocol once with [`webui_protocol_create`], then reuse the
13//! handle with [`webui_handler_render`] and the other protocol operations.
14//!
15//! ## Error Handling
16//!
17//! All functions that can fail return `NULL` on error. Call [`webui_last_error`] to
18//! retrieve a human-readable error message. The error string is valid until the next
19//! FFI call on the same thread (follows the POSIX `dlerror()` pattern).
20
21use serde_json::Value;
22use std::cell::RefCell;
23use std::ffi::{CStr, CString};
24use std::os::raw::{c_char, c_void};
25use std::sync::Arc;
26use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
27use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
28use webui_handler::plugin::webui::WebUIHydrationPlugin;
29use webui_handler::{
30    BoundaryId, BoundaryMode, Protocol, RenderOptions, ResponseWriter, SessionOptions,
31    StreamingSession, WebUIHandler,
32};
33
34/// Opaque C handle for a loaded WebUI protocol.
35#[allow(non_camel_case_types)]
36pub type webui_protocol_t = c_void;
37
38// ---------------------------------------------------------------------------
39// Thread-local error storage (POSIX dlerror() pattern)
40// ---------------------------------------------------------------------------
41
42thread_local! {
43    /// Stores the last error message for the current thread.
44    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
45}
46
47/// Record an error message so that `webui_last_error()` can return it.
48fn set_last_error(msg: impl Into<String>) {
49    let mut bytes = msg.into().into_bytes();
50    if let Some(nul_pos) = bytes.iter().position(|byte| *byte == 0) {
51        bytes.truncate(nul_pos);
52    }
53
54    // SAFETY: Any interior NUL byte was removed by truncating at its first position.
55    let c_string = unsafe { CString::from_vec_unchecked(bytes) };
56    LAST_ERROR.with(|cell| {
57        cell.replace(Some(c_string));
58    });
59}
60
61/// Clear any previously stored error.
62fn clear_last_error() {
63    LAST_ERROR.with(|cell| {
64        cell.replace(None);
65    });
66}
67
68// ---------------------------------------------------------------------------
69// Internal helpers
70// ---------------------------------------------------------------------------
71
72/// Opaque context wrapping a `WebUIHandler`.
73struct HandlerContext {
74    handler: Arc<WebUIHandler>,
75    /// CSP nonce for inline `<script>` tags (set via `webui_handler_set_nonce`).
76    nonce: Option<String>,
77}
78
79/// Opaque decoded protocol context shared across repeated host calls.
80struct ProtocolContext {
81    protocol: Arc<Protocol>,
82}
83
84/// A simple string buffer for collecting rendered output.
85struct StringResponseWriter {
86    content: String,
87}
88
89impl StringResponseWriter {
90    fn new() -> Self {
91        Self {
92            content: String::new(),
93        }
94    }
95}
96
97impl ResponseWriter for StringResponseWriter {
98    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
99        self.content.push_str(content);
100        Ok(())
101    }
102
103    fn end(&mut self) -> webui_handler::Result<()> {
104        Ok(())
105    }
106}
107
108// ---------------------------------------------------------------------------
109// FFI: error reporting
110// ---------------------------------------------------------------------------
111
112/// Return the last error message, or `NULL` if no error has occurred.
113///
114/// The returned pointer is valid until the next FFI call **on the same thread**.
115/// Callers **must not** free the returned pointer.
116///
117/// # Thread Safety
118///
119/// Each thread has its own independent error state.
120#[no_mangle]
121pub extern "C" fn webui_last_error() -> *const c_char {
122    LAST_ERROR.with(|cell| {
123        let borrow = cell.borrow();
124        match borrow.as_ref() {
125            Some(c_str) => c_str.as_ptr(),
126            None => std::ptr::null(),
127        }
128    })
129}
130
131// ---------------------------------------------------------------------------
132// FFI: handler lifecycle
133// ---------------------------------------------------------------------------
134
135/// Create a new WebUI handler instance.
136///
137/// Returns an opaque pointer that must be passed to other `webui_handler_*`
138/// functions and eventually freed with [`webui_handler_destroy`].
139#[no_mangle]
140pub extern "C" fn webui_handler_create() -> *mut c_void {
141    let handler = WebUIHandler::new();
142    let context = Box::new(HandlerContext {
143        handler: Arc::new(handler),
144        nonce: None,
145    });
146    Box::into_raw(context) as *mut c_void
147}
148
149/// Create a new WebUI handler instance with a named plugin.
150///
151/// # Arguments
152///
153/// * `plugin_id` - Null-terminated UTF-8 string identifying the plugin.
154///   Refer to the CLI/crate documentation for the current list of supported
155///   identifiers.
156///
157/// # Returns
158///
159/// An opaque pointer that must be freed with [`webui_handler_destroy`],
160/// or `NULL` on error (call [`webui_last_error`] for details).
161///
162/// # Safety
163///
164/// `plugin_id` must be a valid null-terminated UTF-8 string, or `NULL`.
165#[no_mangle]
166pub unsafe extern "C" fn webui_handler_create_with_plugin(plugin_id: *const c_char) -> *mut c_void {
167    clear_last_error();
168
169    let handler = if plugin_id.is_null() {
170        WebUIHandler::new()
171    } else {
172        match CStr::from_ptr(plugin_id).to_str() {
173            Ok("fast" | "fast-v2") => {
174                WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
175            }
176            Ok("fast-v3") => WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new())),
177            Ok("webui") => WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new())),
178            Ok(unknown) => {
179                set_last_error(format!(
180                    "unknown plugin: {unknown}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
181                ));
182                return std::ptr::null_mut();
183            }
184            Err(e) => {
185                set_last_error(format!("invalid UTF-8 in plugin_id: {e}"));
186                return std::ptr::null_mut();
187            }
188        }
189    };
190
191    let context = Box::new(HandlerContext {
192        handler: Arc::new(handler),
193        nonce: None,
194    });
195    Box::into_raw(context) as *mut c_void
196}
197
198/// Destroy a WebUI handler instance.
199///
200/// # Safety
201///
202/// `handler_ptr` must be a valid pointer returned by [`webui_handler_create`],
203/// or `NULL` (in which case this function is a no-op).
204#[no_mangle]
205pub unsafe extern "C" fn webui_handler_destroy(handler_ptr: *mut c_void) {
206    if !handler_ptr.is_null() {
207        let _ = Box::from_raw(handler_ptr as *mut HandlerContext);
208    }
209}
210
211/// Decode and index a WebUI protocol for repeated rendering.
212///
213/// The returned handle is thread-safe and must be released with
214/// [`webui_protocol_destroy`].
215///
216/// # Safety
217///
218/// `protocol_data` must point to `protocol_len` readable bytes.
219#[no_mangle]
220pub unsafe extern "C" fn webui_protocol_create(
221    protocol_data: *const u8,
222    protocol_len: usize,
223) -> *mut webui_protocol_t {
224    clear_last_error();
225    if protocol_data.is_null() {
226        set_last_error("protocol_data is null");
227        return std::ptr::null_mut();
228    }
229
230    match std::panic::catch_unwind(|| {
231        // SAFETY: The caller guarantees that the input range is readable.
232        let bytes = unsafe { std::slice::from_raw_parts(protocol_data, protocol_len) };
233        match Protocol::from_protobuf(bytes) {
234            Ok(protocol) => Box::into_raw(Box::new(ProtocolContext {
235                protocol: Arc::new(protocol),
236            })) as *mut webui_protocol_t,
237            Err(error) => {
238                set_last_error(format!("failed to parse protobuf protocol: {error}"));
239                std::ptr::null_mut()
240            }
241        }
242    }) {
243        Ok(ptr) => ptr,
244        Err(_) => {
245            set_last_error("panic in webui_protocol_create");
246            std::ptr::null_mut()
247        }
248    }
249}
250
251/// Destroy a loaded WebUI protocol handle.
252///
253/// # Safety
254///
255/// `protocol_ptr` must be a pointer returned by [`webui_protocol_create`], or
256/// `NULL` for a no-op.
257#[no_mangle]
258pub unsafe extern "C" fn webui_protocol_destroy(protocol_ptr: *mut webui_protocol_t) {
259    if !protocol_ptr.is_null() {
260        // SAFETY: The caller guarantees this pointer came from
261        // `webui_protocol_create` and has not already been destroyed.
262        let _ = unsafe { Box::from_raw(protocol_ptr as *mut ProtocolContext) };
263    }
264}
265
266/// Set the CSP nonce for inline `<script>` tags on a handler instance.
267///
268/// When set, all subsequent renders via [`webui_handler_render`] will include
269/// `nonce="VALUE"` on inline script tags and emit a
270/// `<meta name="webui-nonce" content="VALUE">` tag in the `<head>`.
271///
272/// Pass `NULL` to clear a previously set nonce.
273///
274/// # Thread Safety
275///
276/// Concurrent render calls are supported after configuration. Callers must not
277/// call `set_nonce` or destroy the handler concurrently with any operation on
278/// the same `handler_ptr`.
279///
280/// # Safety
281///
282/// * `handler_ptr` must be a valid pointer returned by [`webui_handler_create`].
283/// * `nonce` must be a valid null-terminated UTF-8 string, or `NULL`.
284/// * Caller must ensure exclusive access to `handler_ptr` (no concurrent calls).
285#[no_mangle]
286pub unsafe extern "C" fn webui_handler_set_nonce(handler_ptr: *mut c_void, nonce: *const c_char) {
287    clear_last_error();
288
289    if handler_ptr.is_null() {
290        set_last_error("handler_ptr is null");
291        return;
292    }
293
294    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
295        // SAFETY: caller guarantees handler_ptr is valid and exclusively owned.
296        let context = &mut *(handler_ptr as *mut HandlerContext);
297
298        if nonce.is_null() {
299            context.nonce = None;
300        } else {
301            // SAFETY: caller guarantees nonce is a valid null-terminated string.
302            match CStr::from_ptr(nonce).to_str() {
303                Ok(s) => context.nonce = Some(s.to_string()),
304                Err(e) => {
305                    set_last_error(format!("invalid UTF-8 in nonce: {e}"));
306                }
307            }
308        }
309    })) {
310        Ok(()) => {}
311        Err(_) => {
312            set_last_error("panic in webui_handler_set_nonce");
313        }
314    }
315}
316
317// ---------------------------------------------------------------------------
318// FFI: protocol rendering
319// ---------------------------------------------------------------------------
320
321/// Render using a protocol previously returned by [`webui_protocol_create`].
322///
323/// # Safety
324///
325/// * `handler_ptr` must be a valid handler pointer.
326/// * `protocol_ptr` must be a valid loaded protocol pointer.
327/// * String arguments must be valid null-terminated UTF-8.
328#[no_mangle]
329pub unsafe extern "C" fn webui_handler_render(
330    handler_ptr: *mut c_void,
331    protocol_ptr: *const webui_protocol_t,
332    data_json: *const c_char,
333    entry_id: *const c_char,
334    request_path: *const c_char,
335) -> *mut c_char {
336    clear_last_error();
337
338    match std::panic::catch_unwind(|| {
339        if handler_ptr.is_null()
340            || protocol_ptr.is_null()
341            || data_json.is_null()
342            || entry_id.is_null()
343            || request_path.is_null()
344        {
345            set_last_error("one or more required arguments are null");
346            return std::ptr::null_mut();
347        }
348
349        // SAFETY: The caller guarantees both opaque pointers are valid.
350        let context = unsafe { &*(handler_ptr as *const HandlerContext) };
351        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
352        // SAFETY: The caller guarantees all string pointers are valid.
353        unsafe {
354            render_decoded_protocol(
355                context,
356                &protocol_context.protocol,
357                data_json,
358                entry_id,
359                request_path,
360            )
361        }
362    }) {
363        Ok(ptr) => ptr,
364        Err(_) => {
365            set_last_error("panic in webui_handler_render");
366            std::ptr::null_mut()
367        }
368    }
369}
370
371unsafe fn render_decoded_protocol(
372    context: &HandlerContext,
373    protocol: &Protocol,
374    data_json: *const c_char,
375    entry_id: *const c_char,
376    request_path: *const c_char,
377) -> *mut c_char {
378    // SAFETY: The caller validates all pointers before invoking this helper.
379    let data_str = match unsafe { CStr::from_ptr(data_json) }.to_str() {
380        Ok(s) => s,
381        Err(e) => {
382            set_last_error(format!("invalid UTF-8 in data_json: {e}"));
383            return std::ptr::null_mut();
384        }
385    };
386    // SAFETY: The caller validates all pointers before invoking this helper.
387    let entry_str = match unsafe { CStr::from_ptr(entry_id) }.to_str() {
388        Ok(s) => s,
389        Err(e) => {
390            set_last_error(format!("invalid UTF-8 in entry_id: {e}"));
391            return std::ptr::null_mut();
392        }
393    };
394    // SAFETY: The caller validates all pointers before invoking this helper.
395    let path_str = match unsafe { CStr::from_ptr(request_path) }.to_str() {
396        Ok(s) => s,
397        Err(e) => {
398            set_last_error(format!("invalid UTF-8 in request_path: {e}"));
399            return std::ptr::null_mut();
400        }
401    };
402
403    let data: Value = match serde_json::from_str(data_str) {
404        Ok(d) => d,
405        Err(e) => {
406            set_last_error(format!("failed to parse data JSON: {e}"));
407            return std::ptr::null_mut();
408        }
409    };
410
411    // Render
412    let mut options = RenderOptions::new(entry_str, path_str);
413    if let Some(ref nonce) = context.nonce {
414        options = options.with_nonce(nonce);
415    }
416
417    let mut writer = StringResponseWriter::new();
418    match context
419        .handler
420        .render(protocol, &data, &options, &mut writer)
421    {
422        Ok(_) => match CString::new(writer.content) {
423            Ok(s) => s.into_raw(),
424            Err(e) => {
425                set_last_error(format!("rendered output contains interior NUL byte: {e}"));
426                std::ptr::null_mut()
427            }
428        },
429        Err(e) => {
430            set_last_error(format!("render failed: {e}"));
431            std::ptr::null_mut()
432        }
433    }
434}
435
436// ---------------------------------------------------------------------------
437// FFI: unified partial response
438// ---------------------------------------------------------------------------
439
440/// Produce a complete partial response using a loaded protocol handle.
441///
442/// # Safety
443///
444/// * `protocol_ptr` must be a valid pointer returned by [`webui_protocol_create`].
445/// * All string pointers must be valid, non-null, null-terminated UTF-8.
446#[no_mangle]
447pub unsafe extern "C" fn webui_protocol_render_partial(
448    protocol_ptr: *const webui_protocol_t,
449    state_json: *const c_char,
450    entry_id: *const c_char,
451    request_path: *const c_char,
452    inventory_hex: *const c_char,
453) -> *mut c_char {
454    clear_last_error();
455
456    match std::panic::catch_unwind(|| {
457        if protocol_ptr.is_null()
458            || state_json.is_null()
459            || entry_id.is_null()
460            || request_path.is_null()
461            || inventory_hex.is_null()
462        {
463            set_last_error("one or more required arguments are null");
464            return std::ptr::null_mut();
465        }
466
467        // SAFETY: The caller guarantees that all opaque/string pointers are valid.
468        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
469        let state_str = match unsafe { CStr::from_ptr(state_json) }.to_str() {
470            Ok(value) => value,
471            Err(error) => {
472                set_last_error(format!("invalid UTF-8 in state_json: {error}"));
473                return std::ptr::null_mut();
474            }
475        };
476        let entry_str = match unsafe { CStr::from_ptr(entry_id) }.to_str() {
477            Ok(value) => value,
478            Err(error) => {
479                set_last_error(format!("invalid UTF-8 in entry_id: {error}"));
480                return std::ptr::null_mut();
481            }
482        };
483        let request_path_str = match unsafe { CStr::from_ptr(request_path) }.to_str() {
484            Ok(value) => value,
485            Err(error) => {
486                set_last_error(format!("invalid UTF-8 in request_path: {error}"));
487                return std::ptr::null_mut();
488            }
489        };
490        let inventory_str = match unsafe { CStr::from_ptr(inventory_hex) }.to_str() {
491            Ok(value) => value,
492            Err(error) => {
493                set_last_error(format!("invalid UTF-8 in inventory_hex: {error}"));
494                return std::ptr::null_mut();
495            }
496        };
497
498        let output = match protocol_context.protocol.render_partial(
499            state_str,
500            entry_str,
501            request_path_str,
502            inventory_str,
503        ) {
504            Ok(value) => value,
505            Err(error) => {
506                set_last_error(format!("render_partial failed: {error}"));
507                return std::ptr::null_mut();
508            }
509        };
510
511        match CString::new(output) {
512            Ok(value) => value.into_raw(),
513            Err(error) => {
514                set_last_error(format!("JSON output contains interior NUL byte: {error}"));
515                std::ptr::null_mut()
516            }
517        }
518    }) {
519        Ok(ptr) => ptr,
520        Err(_) => {
521            set_last_error("panic in webui_protocol_render_partial");
522            std::ptr::null_mut()
523        }
524    }
525}
526
527/// Render component templates using a loaded protocol handle.
528///
529/// # Safety
530///
531/// * `protocol_ptr` must be a valid pointer returned by [`webui_protocol_create`].
532/// * String arguments must be valid, non-null, null-terminated UTF-8.
533#[no_mangle]
534pub unsafe extern "C" fn webui_protocol_render_component_templates(
535    protocol_ptr: *const webui_protocol_t,
536    component_tags_json: *const c_char,
537    inventory_hex: *const c_char,
538) -> *mut c_char {
539    clear_last_error();
540
541    match std::panic::catch_unwind(|| {
542        if protocol_ptr.is_null() || component_tags_json.is_null() || inventory_hex.is_null() {
543            set_last_error("one or more required arguments are null");
544            return std::ptr::null_mut();
545        }
546
547        // SAFETY: The caller guarantees that all opaque/string pointers are valid.
548        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
549        let tags_str = match unsafe { CStr::from_ptr(component_tags_json) }.to_str() {
550            Ok(value) => value,
551            Err(error) => {
552                set_last_error(format!("invalid UTF-8 in component_tags_json: {error}"));
553                return std::ptr::null_mut();
554            }
555        };
556        let tags: Vec<String> = match serde_json::from_str(tags_str) {
557            Ok(value) => value,
558            Err(error) => {
559                set_last_error(format!("invalid tags JSON: {error}"));
560                return std::ptr::null_mut();
561            }
562        };
563        let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
564        let inventory_str = match unsafe { CStr::from_ptr(inventory_hex) }.to_str() {
565            Ok(value) => value,
566            Err(error) => {
567                set_last_error(format!("invalid UTF-8 in inventory_hex: {error}"));
568                return std::ptr::null_mut();
569            }
570        };
571
572        let result = match protocol_context
573            .protocol
574            .render_component_templates(&tag_refs, inventory_str)
575        {
576            Ok(value) => value,
577            Err(error) => {
578                set_last_error(format!("render_component_templates failed: {error}"));
579                return std::ptr::null_mut();
580            }
581        };
582
583        match CString::new(result.to_string()) {
584            Ok(value) => value.into_raw(),
585            Err(error) => {
586                set_last_error(format!("JSON output contains interior NUL byte: {error}"));
587                std::ptr::null_mut()
588            }
589        }
590    }) {
591        Ok(ptr) => ptr,
592        Err(_) => {
593            set_last_error("panic in webui_protocol_render_component_templates");
594            std::ptr::null_mut()
595        }
596    }
597}
598
599/// Free a string returned by a WebUI FFI function.
600///
601/// # Safety
602///
603/// `string_ptr` must be a pointer returned by a WebUI FFI function such as
604/// [`webui_handler_render`], or `NULL`
605/// (in which case this function is a no-op).
606#[no_mangle]
607pub unsafe extern "C" fn webui_free(string_ptr: *mut c_char) {
608    if !string_ptr.is_null() {
609        let _ = CString::from_raw(string_ptr);
610    }
611}
612
613/// Extract CSS token names from a loaded protocol handle.
614///
615/// Returns a newline-delimited representation.
616///
617/// # Safety
618///
619/// * `protocol_ptr` must be a valid pointer returned by [`webui_protocol_create`].
620/// * The returned pointer must be freed with [`webui_free`].
621#[no_mangle]
622pub unsafe extern "C" fn webui_protocol_tokens(
623    protocol_ptr: *const webui_protocol_t,
624) -> *mut c_char {
625    clear_last_error();
626
627    match std::panic::catch_unwind(|| {
628        if protocol_ptr.is_null() {
629            set_last_error("protocol_ptr is null");
630            return std::ptr::null_mut();
631        }
632
633        // SAFETY: The caller guarantees protocol_ptr is a live loaded handle.
634        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
635        let joined = protocol_context.protocol.tokens().join("\n");
636
637        match CString::new(joined) {
638            Ok(value) => value.into_raw(),
639            Err(error) => {
640                set_last_error(format!("token string contains null byte: {error}"));
641                std::ptr::null_mut()
642            }
643        }
644    }) {
645        Ok(ptr) => ptr,
646        Err(_) => {
647            set_last_error("panic in webui_protocol_tokens");
648            std::ptr::null_mut()
649        }
650    }
651}
652
653// ---------------------------------------------------------------------------
654// FFI: host-driven streaming sessions
655// ---------------------------------------------------------------------------
656
657/// Opaque C handle for a host-driven progressive response.
658#[allow(non_camel_case_types)]
659pub type webui_streaming_session_t = c_void;
660
661/// Owns one progressive response between host calls.
662struct StreamingSessionContext {
663    session: StreamingSession,
664}
665
666/// Open a host-driven progressive response for a streaming entry.
667///
668/// Unlike [`webui_handler_render`], which produces the whole document in one
669/// call, the returned session hands back one chunk per call so the host owns
670/// the socket, the write order, and backpressure. Any nonce previously set
671/// with [`webui_handler_set_nonce`] is captured for the life of the session.
672///
673/// Returns `NULL` on error; call [`webui_last_error`] for details. The handle
674/// must be released with [`webui_streaming_session_destroy`] even after
675/// [`webui_streaming_session_finish`] succeeds.
676///
677/// # Thread Safety
678///
679/// A session is **not** thread-safe. Drive one session from one thread at a
680/// time. Independent sessions may run concurrently on the same handler and
681/// protocol.
682///
683/// # Safety
684///
685/// * `handler_ptr` must be a valid pointer from [`webui_handler_create`].
686/// * `protocol_ptr` must be a valid pointer from [`webui_protocol_create`].
687/// * `entry_id` and `request_path` must be non-null, null-terminated UTF-8.
688#[no_mangle]
689pub unsafe extern "C" fn webui_streaming_session_create(
690    handler_ptr: *mut c_void,
691    protocol_ptr: *const webui_protocol_t,
692    entry_id: *const c_char,
693    request_path: *const c_char,
694) -> *mut webui_streaming_session_t {
695    clear_last_error();
696
697    match std::panic::catch_unwind(|| {
698        if handler_ptr.is_null()
699            || protocol_ptr.is_null()
700            || entry_id.is_null()
701            || request_path.is_null()
702        {
703            set_last_error("one or more required arguments are null");
704            return std::ptr::null_mut();
705        }
706
707        // SAFETY: The caller guarantees both opaque pointers are live.
708        let handler_context = unsafe { &*(handler_ptr as *const HandlerContext) };
709        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
710
711        // SAFETY: The caller guarantees both strings are valid and terminated.
712        let Some(entry) = (unsafe { utf8_arg(entry_id, "entry_id") }) else {
713            return std::ptr::null_mut();
714        };
715        // SAFETY: The caller guarantees both strings are valid and terminated.
716        let Some(path) = (unsafe { utf8_arg(request_path, "request_path") }) else {
717            return std::ptr::null_mut();
718        };
719
720        let mut options = SessionOptions::new(entry, path);
721        options.nonce = handler_context.nonce.clone();
722
723        match StreamingSession::new(
724            Arc::clone(&handler_context.handler),
725            Arc::clone(&protocol_context.protocol),
726            options,
727        ) {
728            Ok(session) => Box::into_raw(Box::new(StreamingSessionContext { session }))
729                as *mut webui_streaming_session_t,
730            Err(error) => {
731                set_last_error(error.to_string());
732                std::ptr::null_mut()
733            }
734        }
735    }) {
736        Ok(ptr) => ptr,
737        Err(_) => {
738            set_last_error("panic in webui_streaming_session_create");
739            std::ptr::null_mut()
740        }
741    }
742}
743
744/// Release a streaming session handle.
745///
746/// Safe to call on an unfinished session; any buffered bytes are dropped.
747///
748/// # Safety
749///
750/// `session_ptr` must be a pointer returned by
751/// [`webui_streaming_session_create`], or `NULL` for a no-op. It must not be
752/// used again afterwards.
753#[no_mangle]
754pub unsafe extern "C" fn webui_streaming_session_destroy(
755    session_ptr: *mut webui_streaming_session_t,
756) {
757    if !session_ptr.is_null() {
758        // SAFETY: The caller guarantees this pointer came from
759        // `webui_streaming_session_create` and has not already been destroyed.
760        let _ = unsafe { Box::from_raw(session_ptr as *mut StreamingSessionContext) };
761    }
762}
763
764/// Resolve an authored boundary name to a stable integer handle.
765///
766/// Resolve once outside the write loop and reuse the handle; the write calls
767/// never hash a name.
768///
769/// Returns `true` on success and writes the handle to `out_boundary`. On
770/// failure returns `false` and leaves `out_boundary` untouched; call
771/// [`webui_last_error`] for the valid names and a suggestion.
772///
773/// # Safety
774///
775/// * `session_ptr` must be a live session handle.
776/// * `name` must be non-null, null-terminated UTF-8.
777/// * `out_boundary` must be non-null and writable.
778#[no_mangle]
779pub unsafe extern "C" fn webui_streaming_session_boundary(
780    session_ptr: *const webui_streaming_session_t,
781    name: *const c_char,
782    out_boundary: *mut u32,
783) -> bool {
784    clear_last_error();
785
786    match std::panic::catch_unwind(|| {
787        if session_ptr.is_null() || name.is_null() || out_boundary.is_null() {
788            set_last_error("one or more required arguments are null");
789            return false;
790        }
791
792        // SAFETY: The caller guarantees the session handle is live.
793        let context = unsafe { &*(session_ptr as *const StreamingSessionContext) };
794        // SAFETY: The caller guarantees `name` is valid and terminated.
795        let Some(name) = (unsafe { utf8_arg(name, "name") }) else {
796            return false;
797        };
798
799        match context.session.boundary(name) {
800            Ok(boundary) => {
801                // SAFETY: The caller guarantees `out_boundary` is writable.
802                unsafe { *out_boundary = boundary.raw() };
803                true
804            }
805            Err(error) => {
806                set_last_error(error.to_string());
807                false
808            }
809        }
810    }) {
811        Ok(ok) => ok,
812        Err(_) => {
813            set_last_error("panic in webui_streaming_session_boundary");
814            false
815        }
816    }
817}
818
819/// Return the number of compile-time boundaries declared by this entry.
820///
821/// Returns `0` for a `NULL` handle.
822///
823/// # Safety
824///
825/// `session_ptr` must be a live session handle, or `NULL`.
826#[no_mangle]
827pub unsafe extern "C" fn webui_streaming_session_boundary_count(
828    session_ptr: *const webui_streaming_session_t,
829) -> u32 {
830    if session_ptr.is_null() {
831        return 0;
832    }
833    // SAFETY: The caller guarantees the session handle is live.
834    let context = unsafe { &*(session_ptr as *const StreamingSessionContext) };
835    u32::try_from(context.session.boundary_count()).unwrap_or(u32::MAX)
836}
837
838/// Report whether the terminal record has been written.
839///
840/// Returns `true` for a `NULL` handle, because a session that does not exist
841/// can never accept another call.
842///
843/// # Safety
844///
845/// `session_ptr` must be a live session handle, or `NULL`.
846#[no_mangle]
847pub unsafe extern "C" fn webui_streaming_session_is_finished(
848    session_ptr: *const webui_streaming_session_t,
849) -> bool {
850    if session_ptr.is_null() {
851        return true;
852    }
853    // SAFETY: The caller guarantees the session handle is live.
854    let context = unsafe { &*(session_ptr as *const StreamingSessionContext) };
855    context.session.is_finished()
856}
857
858/// Render everything before the first boundary.
859///
860/// Returns a NUL-terminated UTF-8 chunk that must be freed with
861/// [`webui_free`], or `NULL` on error. When `out_len` is non-null it receives
862/// the byte length excluding the terminator, so hosts writing to a socket do
863/// not need `strlen`.
864///
865/// # Safety
866///
867/// * `session_ptr` must be a live session handle.
868/// * `state_json` must be non-null, null-terminated UTF-8.
869/// * `out_len` must be writable, or `NULL`.
870#[no_mangle]
871pub unsafe extern "C" fn webui_streaming_session_write_shell(
872    session_ptr: *mut webui_streaming_session_t,
873    state_json: *const c_char,
874    out_len: *mut usize,
875) -> *mut c_char {
876    // SAFETY: Forwarded verbatim; this helper repeats every check.
877    unsafe {
878        streaming_chunk_call(
879            session_ptr,
880            state_json,
881            out_len,
882            "webui_streaming_session_write_shell",
883            |session, state| session.write_shell(state),
884        )
885    }
886}
887
888/// Render and commit the next boundary in declaration order.
889///
890/// Pass `updatable = true` only for boundaries you intend to patch later with
891/// [`webui_streaming_session_update`]; an updatable boundary retains its roots
892/// and projection until the terminal record.
893///
894/// Returns a NUL-terminated UTF-8 chunk that must be freed with
895/// [`webui_free`], or `NULL` on error. When `out_len` is non-null it receives
896/// the byte length excluding the terminator.
897///
898/// # Safety
899///
900/// * `session_ptr` must be a live session handle.
901/// * `state_json` must be non-null, null-terminated UTF-8.
902/// * `out_len` must be writable, or `NULL`.
903#[no_mangle]
904pub unsafe extern "C" fn webui_streaming_session_write_boundary(
905    session_ptr: *mut webui_streaming_session_t,
906    boundary: u32,
907    state_json: *const c_char,
908    updatable: bool,
909    out_len: *mut usize,
910) -> *mut c_char {
911    let mode = if updatable {
912        BoundaryMode::Updatable
913    } else {
914        BoundaryMode::Final
915    };
916    // SAFETY: Forwarded verbatim; this helper repeats every check.
917    unsafe {
918        streaming_chunk_call(
919            session_ptr,
920            state_json,
921            out_len,
922            "webui_streaming_session_write_boundary",
923            |session, state| session.write_boundary(BoundaryId::from_raw(boundary), state, mode),
924        )
925    }
926}
927
928/// Push a projected state patch to a committed updatable boundary.
929///
930/// Returns a NUL-terminated UTF-8 chunk that must be freed with
931/// [`webui_free`], or `NULL` on error. When `out_len` is non-null it receives
932/// the byte length excluding the terminator.
933///
934/// # Safety
935///
936/// * `session_ptr` must be a live session handle.
937/// * `state_json` must be non-null, null-terminated UTF-8.
938/// * `out_len` must be writable, or `NULL`.
939#[no_mangle]
940pub unsafe extern "C" fn webui_streaming_session_update(
941    session_ptr: *mut webui_streaming_session_t,
942    boundary: u32,
943    state_json: *const c_char,
944    out_len: *mut usize,
945) -> *mut c_char {
946    // SAFETY: Forwarded verbatim; this helper repeats every check.
947    unsafe {
948        streaming_chunk_call(
949            session_ptr,
950            state_json,
951            out_len,
952            "webui_streaming_session_update",
953            |session, state| session.update(BoundaryId::from_raw(boundary), state),
954        )
955    }
956}
957
958/// Render the document tail and emit the terminal record.
959///
960/// Every later call fails. The handle must still be released with
961/// [`webui_streaming_session_destroy`].
962///
963/// Returns a NUL-terminated UTF-8 chunk that must be freed with
964/// [`webui_free`], or `NULL` on error. When `out_len` is non-null it receives
965/// the byte length excluding the terminator.
966///
967/// # Safety
968///
969/// * `session_ptr` must be a live session handle.
970/// * `state_json` must be non-null, null-terminated UTF-8.
971/// * `out_len` must be writable, or `NULL`.
972#[no_mangle]
973pub unsafe extern "C" fn webui_streaming_session_finish(
974    session_ptr: *mut webui_streaming_session_t,
975    state_json: *const c_char,
976    out_len: *mut usize,
977) -> *mut c_char {
978    // SAFETY: Forwarded verbatim; this helper repeats every check.
979    unsafe {
980        streaming_chunk_call(
981            session_ptr,
982            state_json,
983            out_len,
984            "webui_streaming_session_finish",
985            |session, state| session.finish(state),
986        )
987    }
988}
989
990/// Shared body for every chunk-producing session call.
991///
992/// # Safety
993///
994/// Pointers must satisfy the contract documented on the calling function.
995unsafe fn streaming_chunk_call(
996    session_ptr: *mut webui_streaming_session_t,
997    state_json: *const c_char,
998    out_len: *mut usize,
999    operation: &str,
1000    render: impl FnOnce(&mut StreamingSession, &Value) -> webui_handler::Result<Vec<u8>>,
1001) -> *mut c_char {
1002    clear_last_error();
1003
1004    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1005        if session_ptr.is_null() || state_json.is_null() {
1006            set_last_error("one or more required arguments are null");
1007            return std::ptr::null_mut();
1008        }
1009
1010        // SAFETY: The caller guarantees exclusive access to a live session.
1011        let context = unsafe { &mut *(session_ptr as *mut StreamingSessionContext) };
1012        // SAFETY: The caller guarantees `state_json` is valid and terminated.
1013        let Some(state_str) = (unsafe { utf8_arg(state_json, "state_json") }) else {
1014            return std::ptr::null_mut();
1015        };
1016
1017        let state: Value = match serde_json::from_str(state_str) {
1018            Ok(value) => value,
1019            Err(error) => {
1020                set_last_error(format!("failed to parse state JSON: {error}"));
1021                return std::ptr::null_mut();
1022            }
1023        };
1024
1025        let bytes = match render(&mut context.session, &state) {
1026            Ok(bytes) => bytes,
1027            Err(error) => {
1028                set_last_error(error.to_string());
1029                return std::ptr::null_mut();
1030            }
1031        };
1032
1033        let length = bytes.len();
1034        match CString::new(bytes) {
1035            Ok(chunk) => {
1036                if !out_len.is_null() {
1037                    // SAFETY: The caller guarantees `out_len` is writable.
1038                    unsafe { *out_len = length };
1039                }
1040                chunk.into_raw()
1041            }
1042            Err(error) => {
1043                set_last_error(format!("chunk contains interior NUL byte: {error}"));
1044                std::ptr::null_mut()
1045            }
1046        }
1047    })) {
1048        Ok(ptr) => ptr,
1049        Err(_) => {
1050            set_last_error(format!("panic in {operation}"));
1051            std::ptr::null_mut()
1052        }
1053    }
1054}
1055
1056/// Borrow a C string argument as UTF-8, recording an actionable error on failure.
1057///
1058/// # Safety
1059///
1060/// `value` must be non-null, null-terminated, and live for the returned borrow.
1061unsafe fn utf8_arg<'a>(value: *const c_char, name: &str) -> Option<&'a str> {
1062    // SAFETY: The caller guarantees the pointer is valid and terminated.
1063    match unsafe { CStr::from_ptr(value) }.to_str() {
1064        Ok(text) => Some(text),
1065        Err(error) => {
1066            set_last_error(format!("invalid UTF-8 in {name}: {error}"));
1067            None
1068        }
1069    }
1070}