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    BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, Protocol, RenderOptions,
31    SessionOptions, StreamStep, StreamingSession, WebUIHandler,
32};
33
34mod render_capacity;
35use render_capacity::RenderCapacityHint;
36
37/// Opaque C handle for a loaded WebUI protocol.
38#[allow(non_camel_case_types)]
39pub type webui_protocol_t = c_void;
40
41/// Opaque owned document returned by [`webui_handler_render`].
42#[allow(non_camel_case_types)]
43pub type webui_render_t = c_void;
44
45// ---------------------------------------------------------------------------
46// Thread-local error storage (POSIX dlerror() pattern)
47// ---------------------------------------------------------------------------
48
49thread_local! {
50    /// Stores the last error message for the current thread.
51    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
52}
53
54/// Record an error message so that `webui_last_error()` can return it.
55fn set_last_error(msg: impl Into<String>) {
56    let mut bytes = msg.into().into_bytes();
57    if let Some(nul_pos) = bytes.iter().position(|byte| *byte == 0) {
58        bytes.truncate(nul_pos);
59    }
60
61    // SAFETY: Any interior NUL byte was removed by truncating at its first position.
62    let c_string = unsafe { CString::from_vec_unchecked(bytes) };
63    LAST_ERROR.with(|cell| {
64        cell.replace(Some(c_string));
65    });
66}
67
68/// Clear any previously stored error.
69fn clear_last_error() {
70    LAST_ERROR.with(|cell| {
71        cell.replace(None);
72    });
73}
74
75// ---------------------------------------------------------------------------
76// Internal helpers
77// ---------------------------------------------------------------------------
78
79/// Opaque context wrapping a `WebUIHandler`.
80struct HandlerContext {
81    handler: Arc<WebUIHandler>,
82    /// CSP nonce for inline `<script>` tags (set via `webui_handler_set_nonce`).
83    nonce: Option<String>,
84}
85
86/// Opaque decoded protocol context shared across repeated host calls.
87struct ProtocolContext {
88    protocol: Arc<Protocol>,
89    /// Output reservation for full renders of this protocol.
90    render_capacity: RenderCapacityHint,
91}
92
93impl ProtocolContext {
94    fn new(protocol: Protocol) -> Self {
95        Self {
96            protocol: Arc::new(protocol),
97            render_capacity: RenderCapacityHint::default(),
98        }
99    }
100}
101
102/// Owns one rendered document and the bytes borrowed from it.
103struct RenderResultContext {
104    html: String,
105}
106
107webui_handler::define_string_response_writer!(StringResponseWriter, content);
108
109// ---------------------------------------------------------------------------
110// FFI: error reporting
111// ---------------------------------------------------------------------------
112
113/// Return the last error message, or `NULL` if no error has occurred.
114///
115/// The returned pointer is valid until the next FFI call **on the same thread**.
116/// Callers **must not** free the returned pointer.
117///
118/// # Thread Safety
119///
120/// Each thread has its own independent error state.
121#[no_mangle]
122pub extern "C" fn webui_last_error() -> *const c_char {
123    LAST_ERROR.with(|cell| {
124        let borrow = cell.borrow();
125        match borrow.as_ref() {
126            Some(c_str) => c_str.as_ptr(),
127            None => std::ptr::null(),
128        }
129    })
130}
131
132// ---------------------------------------------------------------------------
133// FFI: handler lifecycle
134// ---------------------------------------------------------------------------
135
136/// Create a new WebUI handler instance.
137///
138/// Returns an opaque pointer that must be passed to other `webui_handler_*`
139/// functions and eventually freed with [`webui_handler_destroy`].
140#[no_mangle]
141pub extern "C" fn webui_handler_create() -> *mut c_void {
142    let handler = WebUIHandler::new();
143    let context = Box::new(HandlerContext {
144        handler: Arc::new(handler),
145        nonce: None,
146    });
147    Box::into_raw(context) as *mut c_void
148}
149
150/// Create a new WebUI handler instance with a named plugin.
151///
152/// # Arguments
153///
154/// * `plugin_id` - Null-terminated UTF-8 string identifying the plugin.
155///   Refer to the CLI/crate documentation for the current list of supported
156///   identifiers.
157///
158/// # Returns
159///
160/// An opaque pointer that must be freed with [`webui_handler_destroy`],
161/// or `NULL` on error (call [`webui_last_error`] for details).
162///
163/// # Safety
164///
165/// `plugin_id` must be a valid null-terminated UTF-8 string, or `NULL`.
166#[no_mangle]
167pub unsafe extern "C" fn webui_handler_create_with_plugin(plugin_id: *const c_char) -> *mut c_void {
168    clear_last_error();
169
170    let handler = if plugin_id.is_null() {
171        WebUIHandler::new()
172    } else {
173        match CStr::from_ptr(plugin_id).to_str() {
174            Ok("fast" | "fast-v2") => {
175                WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
176            }
177            Ok("fast-v3") => WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new())),
178            Ok("webui") => WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new())),
179            Ok(unknown) => {
180                set_last_error(format!(
181                    "unknown plugin: {unknown}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
182                ));
183                return std::ptr::null_mut();
184            }
185            Err(e) => {
186                set_last_error(format!("invalid UTF-8 in plugin_id: {e}"));
187                return std::ptr::null_mut();
188            }
189        }
190    };
191
192    let context = Box::new(HandlerContext {
193        handler: Arc::new(handler),
194        nonce: None,
195    });
196    Box::into_raw(context) as *mut c_void
197}
198
199/// Destroy a WebUI handler instance.
200///
201/// # Safety
202///
203/// `handler_ptr` must be a valid pointer returned by [`webui_handler_create`],
204/// or `NULL` (in which case this function is a no-op).
205#[no_mangle]
206pub unsafe extern "C" fn webui_handler_destroy(handler_ptr: *mut c_void) {
207    if !handler_ptr.is_null() {
208        let _ = Box::from_raw(handler_ptr as *mut HandlerContext);
209    }
210}
211
212/// Decode and index a WebUI protocol for repeated rendering.
213///
214/// The returned handle is thread-safe and must be released with
215/// [`webui_protocol_destroy`].
216///
217/// # Safety
218///
219/// `protocol_data` must point to `protocol_len` readable bytes.
220#[no_mangle]
221pub unsafe extern "C" fn webui_protocol_create(
222    protocol_data: *const u8,
223    protocol_len: usize,
224) -> *mut webui_protocol_t {
225    clear_last_error();
226    if protocol_data.is_null() {
227        set_last_error("protocol_data is null");
228        return std::ptr::null_mut();
229    }
230
231    match std::panic::catch_unwind(|| {
232        // SAFETY: The caller guarantees that the input range is readable.
233        let bytes = unsafe { std::slice::from_raw_parts(protocol_data, protocol_len) };
234        match Protocol::from_protobuf(bytes) {
235            Ok(protocol) => {
236                Box::into_raw(Box::new(ProtocolContext::new(protocol))) as *mut webui_protocol_t
237            }
238            Err(error) => {
239                set_last_error(format!("failed to parse protobuf protocol: {error}"));
240                std::ptr::null_mut()
241            }
242        }
243    }) {
244        Ok(ptr) => ptr,
245        Err(_) => {
246            set_last_error("panic in webui_protocol_create");
247            std::ptr::null_mut()
248        }
249    }
250}
251
252/// Destroy a loaded WebUI protocol handle.
253///
254/// # Safety
255///
256/// `protocol_ptr` must be a pointer returned by [`webui_protocol_create`], or
257/// `NULL` for a no-op.
258#[no_mangle]
259pub unsafe extern "C" fn webui_protocol_destroy(protocol_ptr: *mut webui_protocol_t) {
260    if !protocol_ptr.is_null() {
261        // SAFETY: The caller guarantees this pointer came from
262        // `webui_protocol_create` and has not already been destroyed.
263        let _ = unsafe { Box::from_raw(protocol_ptr as *mut ProtocolContext) };
264    }
265}
266
267/// Set the CSP nonce for inline `<script>` tags on a handler instance.
268///
269/// When set, all subsequent renders via [`webui_handler_render`] will include
270/// `nonce="VALUE"` on inline script tags and emit a
271/// `<meta name="webui-nonce" content="VALUE">` tag in the `<head>`.
272///
273/// Pass `NULL` to clear a previously set nonce.
274///
275/// # Thread Safety
276///
277/// Concurrent render calls are supported after configuration. Callers must not
278/// call `set_nonce` or destroy the handler concurrently with any operation on
279/// the same `handler_ptr`.
280///
281/// # Safety
282///
283/// * `handler_ptr` must be a valid pointer returned by [`webui_handler_create`].
284/// * `nonce` must be a valid null-terminated UTF-8 string, or `NULL`.
285/// * Caller must ensure exclusive access to `handler_ptr` (no concurrent calls).
286#[no_mangle]
287pub unsafe extern "C" fn webui_handler_set_nonce(handler_ptr: *mut c_void, nonce: *const c_char) {
288    clear_last_error();
289
290    if handler_ptr.is_null() {
291        set_last_error("handler_ptr is null");
292        return;
293    }
294
295    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
296        // SAFETY: caller guarantees handler_ptr is valid and exclusively owned.
297        let context = &mut *(handler_ptr as *mut HandlerContext);
298
299        if nonce.is_null() {
300            context.nonce = None;
301        } else {
302            // SAFETY: caller guarantees nonce is a valid null-terminated string.
303            match CStr::from_ptr(nonce).to_str() {
304                Ok(s) => context.nonce = Some(s.to_string()),
305                Err(e) => {
306                    set_last_error(format!("invalid UTF-8 in nonce: {e}"));
307                }
308            }
309        }
310    })) {
311        Ok(()) => {}
312        Err(_) => {
313            set_last_error("panic in webui_handler_set_nonce");
314        }
315    }
316}
317
318// ---------------------------------------------------------------------------
319// FFI: protocol rendering
320// ---------------------------------------------------------------------------
321
322/// Render using a protocol previously returned by [`webui_protocol_create`].
323///
324/// Read the document with [`webui_render_bytes`] and release it with
325/// [`webui_render_free`]. The rendered buffer is handed over as is,
326/// without a terminator scan or copy. The handle owns the document, preserves
327/// interior NUL bytes, and stays valid after the handler or protocol it was
328/// rendered with is destroyed.
329///
330/// Returns `NULL` on error; call [`webui_last_error`] for details.
331///
332/// # Safety
333///
334/// * `handler_ptr` must be a valid handler pointer.
335/// * `protocol_ptr` must be a valid loaded protocol pointer.
336/// * String arguments must be valid null-terminated UTF-8.
337#[no_mangle]
338pub unsafe extern "C" fn webui_handler_render(
339    handler_ptr: *mut c_void,
340    protocol_ptr: *const webui_protocol_t,
341    data_json: *const c_char,
342    entry_id: *const c_char,
343    request_path: *const c_char,
344) -> *mut webui_render_t {
345    clear_last_error();
346
347    match std::panic::catch_unwind(|| {
348        // SAFETY: The caller upholds this function's pointer contract.
349        let Some(mut html) = (unsafe {
350            render_full_document(handler_ptr, protocol_ptr, data_json, entry_id, request_path)
351        }) else {
352            return std::ptr::null_mut();
353        };
354        // A capacity reserved for a much larger earlier render must not stay
355        // allocated for as long as the host holds this smaller document.
356        if html.capacity() / 2 > html.len() {
357            html.shrink_to_fit();
358        }
359        Box::into_raw(Box::new(RenderResultContext { html })) as *mut webui_render_t
360    }) {
361        Ok(ptr) => ptr,
362        Err(_) => {
363            set_last_error("panic in webui_handler_render");
364            std::ptr::null_mut()
365        }
366    }
367}
368
369/// Borrow a rendered document and write its length in bytes to `out_len`.
370///
371/// The returned UTF-8 bytes are borrowed from `result_ptr`, are not
372/// NUL-terminated, and remain valid only until [`webui_render_free`].
373/// They may be read for exactly `out_len` bytes. Returns `NULL` on error.
374///
375/// # Safety
376///
377/// * `result_ptr` must be a live result handle with no concurrent destroy.
378/// * `out_len` must be non-null and writable.
379#[no_mangle]
380pub unsafe extern "C" fn webui_render_bytes(
381    result_ptr: *const webui_render_t,
382    out_len: *mut usize,
383) -> *const u8 {
384    clear_last_error();
385    match std::panic::catch_unwind(|| {
386        if result_ptr.is_null() || out_len.is_null() {
387            set_last_error("result_ptr and out_len must not be null");
388            return std::ptr::null();
389        }
390        // SAFETY: The caller guarantees a live result handle.
391        let context = unsafe { &*(result_ptr as *const RenderResultContext) };
392        // SAFETY: The caller guarantees `out_len` is writable.
393        unsafe { *out_len = context.html.len() };
394        context.html.as_ptr()
395    }) {
396        Ok(bytes) => bytes,
397        Err(_) => {
398            set_last_error("panic in webui_render_bytes");
399            std::ptr::null()
400        }
401    }
402}
403
404/// Release a document returned by [`webui_handler_render`].
405///
406/// Invalidates the pointer previously returned by [`webui_render_bytes`].
407///
408/// # Safety
409///
410/// `result_ptr` must be a pointer returned by [`webui_handler_render`],
411/// or `NULL` for a no-op. A non-null pointer must not be used after this call.
412#[no_mangle]
413pub unsafe extern "C" fn webui_render_free(result_ptr: *mut webui_render_t) {
414    clear_last_error();
415    if result_ptr.is_null() {
416        return;
417    }
418    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
419        // SAFETY: The caller guarantees this is one live owned result.
420        drop(unsafe { Box::from_raw(result_ptr as *mut RenderResultContext) });
421    }));
422    if result.is_err() {
423        set_last_error("panic in webui_render_free");
424    }
425}
426
427/// Validate the shared full-render arguments and render one document.
428///
429/// Returns `None` after recording the failure for [`webui_last_error`].
430///
431/// # Safety
432///
433/// Non-null arguments must satisfy [`webui_handler_render`]'s contract.
434unsafe fn render_full_document(
435    handler_ptr: *mut c_void,
436    protocol_ptr: *const webui_protocol_t,
437    data_json: *const c_char,
438    entry_id: *const c_char,
439    request_path: *const c_char,
440) -> Option<String> {
441    if handler_ptr.is_null()
442        || protocol_ptr.is_null()
443        || data_json.is_null()
444        || entry_id.is_null()
445        || request_path.is_null()
446    {
447        set_last_error("one or more required arguments are null");
448        return None;
449    }
450
451    // SAFETY: The caller guarantees both opaque pointers are valid.
452    let context = unsafe { &*(handler_ptr as *const HandlerContext) };
453    let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
454    // SAFETY: The caller guarantees all string pointers are valid.
455    unsafe { render_decoded_protocol(context, protocol_context, data_json, entry_id, request_path) }
456}
457
458unsafe fn render_decoded_protocol(
459    context: &HandlerContext,
460    protocol_context: &ProtocolContext,
461    data_json: *const c_char,
462    entry_id: *const c_char,
463    request_path: *const c_char,
464) -> Option<String> {
465    // SAFETY: The caller validates all pointers before invoking this helper.
466    let data_str = match unsafe { CStr::from_ptr(data_json) }.to_str() {
467        Ok(s) => s,
468        Err(e) => {
469            set_last_error(format!("invalid UTF-8 in data_json: {e}"));
470            return None;
471        }
472    };
473    // SAFETY: The caller validates all pointers before invoking this helper.
474    let entry_str = match unsafe { CStr::from_ptr(entry_id) }.to_str() {
475        Ok(s) => s,
476        Err(e) => {
477            set_last_error(format!("invalid UTF-8 in entry_id: {e}"));
478            return None;
479        }
480    };
481    // SAFETY: The caller validates all pointers before invoking this helper.
482    let path_str = match unsafe { CStr::from_ptr(request_path) }.to_str() {
483        Ok(s) => s,
484        Err(e) => {
485            set_last_error(format!("invalid UTF-8 in request_path: {e}"));
486            return None;
487        }
488    };
489
490    let data: Value = match serde_json::from_str(data_str) {
491        Ok(d) => d,
492        Err(e) => {
493            set_last_error(format!("failed to parse data JSON: {e}"));
494            return None;
495        }
496    };
497
498    // Render
499    let mut options = RenderOptions::new(entry_str, path_str);
500    if let Some(ref nonce) = context.nonce {
501        options = options.with_nonce(nonce);
502    }
503
504    // An unknown entry fails before anything is written, so it must not
505    // reserve a buffer sized from earlier successful renders.
506    let capacity = if protocol_context.protocol.has_fragment(entry_str) {
507        protocol_context.render_capacity.get()
508    } else {
509        0
510    };
511    let mut writer = StringResponseWriter::with_capacity(capacity);
512    match context
513        .handler
514        .render(&protocol_context.protocol, &data, &options, &mut writer)
515    {
516        Ok(_) => {
517            protocol_context
518                .render_capacity
519                .record(writer.content.len());
520            Some(writer.content)
521        }
522        Err(e) => {
523            set_last_error(format!("render failed: {e}"));
524            None
525        }
526    }
527}
528
529// ---------------------------------------------------------------------------
530// FFI: unified partial response
531// ---------------------------------------------------------------------------
532
533/// Produce a complete partial response using a loaded protocol handle.
534///
535/// # Safety
536///
537/// * `protocol_ptr` must be a valid pointer returned by [`webui_protocol_create`].
538/// * All string pointers must be valid, non-null, null-terminated UTF-8.
539#[no_mangle]
540pub unsafe extern "C" fn webui_protocol_render_partial(
541    protocol_ptr: *const webui_protocol_t,
542    state_json: *const c_char,
543    entry_id: *const c_char,
544    request_path: *const c_char,
545    inventory_hex: *const c_char,
546) -> *mut c_char {
547    clear_last_error();
548
549    match std::panic::catch_unwind(|| {
550        if protocol_ptr.is_null()
551            || state_json.is_null()
552            || entry_id.is_null()
553            || request_path.is_null()
554            || inventory_hex.is_null()
555        {
556            set_last_error("one or more required arguments are null");
557            return std::ptr::null_mut();
558        }
559
560        // SAFETY: The caller guarantees that all opaque/string pointers are valid.
561        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
562        let state_str = match unsafe { CStr::from_ptr(state_json) }.to_str() {
563            Ok(value) => value,
564            Err(error) => {
565                set_last_error(format!("invalid UTF-8 in state_json: {error}"));
566                return std::ptr::null_mut();
567            }
568        };
569        let entry_str = match unsafe { CStr::from_ptr(entry_id) }.to_str() {
570            Ok(value) => value,
571            Err(error) => {
572                set_last_error(format!("invalid UTF-8 in entry_id: {error}"));
573                return std::ptr::null_mut();
574            }
575        };
576        let request_path_str = match unsafe { CStr::from_ptr(request_path) }.to_str() {
577            Ok(value) => value,
578            Err(error) => {
579                set_last_error(format!("invalid UTF-8 in request_path: {error}"));
580                return std::ptr::null_mut();
581            }
582        };
583        let inventory_str = match unsafe { CStr::from_ptr(inventory_hex) }.to_str() {
584            Ok(value) => value,
585            Err(error) => {
586                set_last_error(format!("invalid UTF-8 in inventory_hex: {error}"));
587                return std::ptr::null_mut();
588            }
589        };
590
591        let output = match protocol_context.protocol.render_partial_json(
592            state_str,
593            entry_str,
594            request_path_str,
595            inventory_str,
596        ) {
597            Ok(value) => value,
598            Err(error) => {
599                set_last_error(format!("render_partial failed: {error}"));
600                return std::ptr::null_mut();
601            }
602        };
603
604        match CString::new(output) {
605            Ok(value) => value.into_raw(),
606            Err(error) => {
607                set_last_error(format!("JSON output contains interior NUL byte: {error}"));
608                std::ptr::null_mut()
609            }
610        }
611    }) {
612        Ok(ptr) => ptr,
613        Err(_) => {
614            set_last_error("panic in webui_protocol_render_partial");
615            std::ptr::null_mut()
616        }
617    }
618}
619
620/// Render component templates using a loaded protocol handle.
621///
622/// # Safety
623///
624/// * `protocol_ptr` must be a valid pointer returned by [`webui_protocol_create`].
625/// * String arguments must be valid, non-null, null-terminated UTF-8.
626#[no_mangle]
627pub unsafe extern "C" fn webui_protocol_render_component_templates(
628    protocol_ptr: *const webui_protocol_t,
629    component_tags_json: *const c_char,
630    inventory_hex: *const c_char,
631) -> *mut c_char {
632    clear_last_error();
633
634    match std::panic::catch_unwind(|| {
635        if protocol_ptr.is_null() || component_tags_json.is_null() || inventory_hex.is_null() {
636            set_last_error("one or more required arguments are null");
637            return std::ptr::null_mut();
638        }
639
640        // SAFETY: The caller guarantees that all opaque/string pointers are valid.
641        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
642        let tags_str = match unsafe { CStr::from_ptr(component_tags_json) }.to_str() {
643            Ok(value) => value,
644            Err(error) => {
645                set_last_error(format!("invalid UTF-8 in component_tags_json: {error}"));
646                return std::ptr::null_mut();
647            }
648        };
649        let tags: Vec<String> = match serde_json::from_str(tags_str) {
650            Ok(value) => value,
651            Err(error) => {
652                set_last_error(format!("invalid tags JSON: {error}"));
653                return std::ptr::null_mut();
654            }
655        };
656        let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
657        let inventory_str = match unsafe { CStr::from_ptr(inventory_hex) }.to_str() {
658            Ok(value) => value,
659            Err(error) => {
660                set_last_error(format!("invalid UTF-8 in inventory_hex: {error}"));
661                return std::ptr::null_mut();
662            }
663        };
664
665        let result = match protocol_context
666            .protocol
667            .render_component_templates(&tag_refs, inventory_str)
668        {
669            Ok(value) => value,
670            Err(error) => {
671                set_last_error(format!("render_component_templates failed: {error}"));
672                return std::ptr::null_mut();
673            }
674        };
675
676        match CString::new(result.to_string()) {
677            Ok(value) => value.into_raw(),
678            Err(error) => {
679                set_last_error(format!("JSON output contains interior NUL byte: {error}"));
680                std::ptr::null_mut()
681            }
682        }
683    }) {
684        Ok(ptr) => ptr,
685        Err(_) => {
686            set_last_error("panic in webui_protocol_render_component_templates");
687            std::ptr::null_mut()
688        }
689    }
690}
691
692/// Free a string returned by a WebUI FFI function.
693///
694/// Only strings belong here. Opaque handles have their own release
695/// functions: a document from [`webui_handler_render`] is released
696/// with [`webui_render_free`], and streaming steps and sessions with
697/// [`webui_streaming_step_destroy`] and [`webui_streaming_session_destroy`].
698///
699/// # Safety
700///
701/// `string_ptr` must be a pointer returned by a WebUI FFI function such as
702/// [`webui_protocol_render_partial`], or `NULL`
703/// (in which case this function is a no-op).
704#[no_mangle]
705pub unsafe extern "C" fn webui_free(string_ptr: *mut c_char) {
706    if !string_ptr.is_null() {
707        let _ = CString::from_raw(string_ptr);
708    }
709}
710
711/// Extract CSS token names from a loaded protocol handle.
712///
713/// Returns a newline-delimited representation.
714///
715/// # Safety
716///
717/// * `protocol_ptr` must be a valid pointer returned by [`webui_protocol_create`].
718/// * The returned pointer must be freed with [`webui_free`].
719#[no_mangle]
720pub unsafe extern "C" fn webui_protocol_tokens(
721    protocol_ptr: *const webui_protocol_t,
722) -> *mut c_char {
723    clear_last_error();
724
725    match std::panic::catch_unwind(|| {
726        if protocol_ptr.is_null() {
727            set_last_error("protocol_ptr is null");
728            return std::ptr::null_mut();
729        }
730
731        // SAFETY: The caller guarantees protocol_ptr is a live loaded handle.
732        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
733        let joined = protocol_context.protocol.tokens().join("\n");
734
735        match CString::new(joined) {
736            Ok(value) => value.into_raw(),
737            Err(error) => {
738                set_last_error(format!("token string contains null byte: {error}"));
739                std::ptr::null_mut()
740            }
741        }
742    }) {
743        Ok(ptr) => ptr,
744        Err(_) => {
745            set_last_error("panic in webui_protocol_tokens");
746            std::ptr::null_mut()
747        }
748    }
749}
750
751// ---------------------------------------------------------------------------
752// FFI: host-driven streaming sessions
753// ---------------------------------------------------------------------------
754
755/// Opaque C handle for a host-driven progressive response.
756#[allow(non_camel_case_types)]
757pub type webui_streaming_session_t = c_void;
758
759/// Opaque owned result from one streaming start, resume, or advance call.
760#[allow(non_camel_case_types)]
761pub type webui_streaming_step_t = c_void;
762
763/// C-safe boundary mode value accepted by [`webui_streaming_session_resume`].
764#[allow(non_camel_case_types)]
765pub type webui_boundary_mode_t = u32;
766
767/// Commit the boundary once and release its boundary-local roots.
768pub const WEBUI_BOUNDARY_MODE_FINAL: webui_boundary_mode_t = 0;
769
770/// Retain live roots until terminal so updates may target the boundary.
771pub const WEBUI_BOUNDARY_MODE_UPDATABLE: webui_boundary_mode_t = 1;
772
773/// C-safe boundary key discriminator returned by
774/// [`webui_streaming_step_boundary_key_type`].
775#[allow(non_camel_case_types)]
776pub type webui_boundary_key_type_t = u32;
777
778/// The boundary declaration has no runtime key.
779pub const WEBUI_BOUNDARY_KEY_NONE: webui_boundary_key_type_t = 0;
780
781/// The boundary key is a UTF-8 string.
782pub const WEBUI_BOUNDARY_KEY_STRING: webui_boundary_key_type_t = 1;
783
784/// The boundary key is a finite JSON number.
785pub const WEBUI_BOUNDARY_KEY_NUMBER: webui_boundary_key_type_t = 2;
786
787/// Owns one progressive response between host calls.
788struct StreamingSessionContext {
789    session: StreamingSession,
790}
791
792/// Owns one result and every pointer borrowed from it.
793struct StreamingStepContext {
794    step: StreamStep,
795}
796
797/// Open a host-driven progressive response for a streaming entry.
798///
799/// Unlike [`webui_handler_render`], which produces the whole document in one
800/// call, the returned session advances through [`webui_streaming_session_start`],
801/// [`webui_streaming_session_resume`], and [`webui_streaming_session_advance`]
802/// so the host owns the socket, write order, and backpressure. Any nonce
803/// previously set with
804/// [`webui_handler_set_nonce`] is captured for the life of the session.
805///
806/// Returns `NULL` on error; call [`webui_last_error`] for details. The handle
807/// must be released with [`webui_streaming_session_destroy`].
808///
809/// # Thread Safety
810///
811/// A session is **not** thread-safe. Drive one session from one thread at a
812/// time. Independent sessions may run concurrently on the same handler and
813/// protocol.
814///
815/// # Safety
816///
817/// * `handler_ptr` must be a valid pointer from [`webui_handler_create`].
818/// * `protocol_ptr` must be a valid pointer from [`webui_protocol_create`].
819/// * `entry_id` and `request_path` must be non-null, null-terminated UTF-8.
820#[no_mangle]
821pub unsafe extern "C" fn webui_streaming_session_create(
822    handler_ptr: *mut c_void,
823    protocol_ptr: *const webui_protocol_t,
824    entry_id: *const c_char,
825    request_path: *const c_char,
826) -> *mut webui_streaming_session_t {
827    clear_last_error();
828
829    match std::panic::catch_unwind(|| {
830        if handler_ptr.is_null()
831            || protocol_ptr.is_null()
832            || entry_id.is_null()
833            || request_path.is_null()
834        {
835            set_last_error("one or more required arguments are null");
836            return std::ptr::null_mut();
837        }
838
839        // SAFETY: The caller guarantees both opaque pointers are live.
840        let handler_context = unsafe { &*(handler_ptr as *const HandlerContext) };
841        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
842
843        // SAFETY: The caller guarantees both strings are valid and terminated.
844        let Some(entry) = (unsafe { utf8_arg(entry_id, "entry_id") }) else {
845            return std::ptr::null_mut();
846        };
847        // SAFETY: The caller guarantees both strings are valid and terminated.
848        let Some(path) = (unsafe { utf8_arg(request_path, "request_path") }) else {
849            return std::ptr::null_mut();
850        };
851
852        let mut options = SessionOptions::new(entry, path);
853        options.nonce = handler_context.nonce.clone();
854
855        match StreamingSession::new(
856            Arc::clone(&handler_context.handler),
857            Arc::clone(&protocol_context.protocol),
858            options,
859        ) {
860            Ok(session) => Box::into_raw(Box::new(StreamingSessionContext { session }))
861                as *mut webui_streaming_session_t,
862            Err(error) => {
863                set_last_error(error.to_string());
864                std::ptr::null_mut()
865            }
866        }
867    }) {
868        Ok(ptr) => ptr,
869        Err(_) => {
870            set_last_error("panic in webui_streaming_session_create");
871            std::ptr::null_mut()
872        }
873    }
874}
875
876/// Release a streaming session handle.
877///
878/// Safe to call on an unfinished session; any buffered bytes are dropped.
879///
880/// # Safety
881///
882/// `session_ptr` must be a pointer returned by
883/// [`webui_streaming_session_create`], or `NULL` for a no-op. It must not be
884/// used again afterwards.
885#[no_mangle]
886pub unsafe extern "C" fn webui_streaming_session_destroy(
887    session_ptr: *mut webui_streaming_session_t,
888) {
889    clear_last_error();
890    if session_ptr.is_null() {
891        return;
892    }
893    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
894        // SAFETY: The caller guarantees this pointer came from
895        // `webui_streaming_session_create` and has not already been destroyed.
896        drop(unsafe { Box::from_raw(session_ptr as *mut StreamingSessionContext) });
897    }));
898    if result.is_err() {
899        set_last_error("panic in webui_streaming_session_destroy");
900    }
901}
902
903/// Render until the first runtime boundary occurrence or terminal completion.
904///
905/// The returned owned step must be released with
906/// [`webui_streaming_step_destroy`]. A `NULL` return indicates an error
907/// available through [`webui_last_error`].
908///
909/// # Safety
910///
911/// * `session_ptr` must be a live session handle with no concurrent operation.
912/// * `state_json` must be non-null, null-terminated UTF-8 and remain readable
913///   for this call.
914#[no_mangle]
915pub unsafe extern "C" fn webui_streaming_session_start(
916    session_ptr: *mut webui_streaming_session_t,
917    state_json: *const c_char,
918) -> *mut webui_streaming_step_t {
919    clear_last_error();
920    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
921        if session_ptr.is_null() || state_json.is_null() {
922            set_last_error("session_ptr and state_json must not be null");
923            return std::ptr::null_mut();
924        }
925        // SAFETY: The caller guarantees exclusive access to a live session.
926        let context = unsafe { &mut *(session_ptr as *mut StreamingSessionContext) };
927        // SAFETY: The caller guarantees the string is readable and terminated.
928        let Some(state) = (unsafe { streaming_json_arg(state_json, "state_json") }) else {
929            return std::ptr::null_mut();
930        };
931        match context.session.start(state) {
932            Ok(step) => owned_streaming_step(step),
933            Err(error) => {
934                set_last_error(error.to_string());
935                std::ptr::null_mut()
936            }
937        }
938    })) {
939        Ok(step) => step,
940        Err(_) => {
941            set_last_error("panic in webui_streaming_session_start");
942            std::ptr::null_mut()
943        }
944    }
945}
946
947/// Commit the pending occurrence through its checkpoint and stop.
948///
949/// `mode` must be [`WEBUI_BOUNDARY_MODE_FINAL`] or
950/// [`WEBUI_BOUNDARY_MODE_UPDATABLE`]. The returned owned step must be released
951/// with [`webui_streaming_step_destroy`]. A `NULL` return indicates an error
952/// available through [`webui_last_error`].
953///
954/// # Safety
955///
956/// * `session_ptr` must be a live session handle with no concurrent operation.
957/// * `state_json` must be non-null, null-terminated UTF-8 and remain readable
958///   for this call.
959#[no_mangle]
960pub unsafe extern "C" fn webui_streaming_session_resume(
961    session_ptr: *mut webui_streaming_session_t,
962    instance_id: u32,
963    state_json: *const c_char,
964    mode: webui_boundary_mode_t,
965) -> *mut webui_streaming_step_t {
966    clear_last_error();
967    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
968        if session_ptr.is_null() || state_json.is_null() {
969            set_last_error("session_ptr and state_json must not be null");
970            return std::ptr::null_mut();
971        }
972        let Some(mode) = streaming_boundary_mode(mode) else {
973            return std::ptr::null_mut();
974        };
975        // SAFETY: The caller guarantees exclusive access to a live session.
976        let context = unsafe { &mut *(session_ptr as *mut StreamingSessionContext) };
977        // SAFETY: The caller guarantees the string is readable and terminated.
978        let Some(state) = (unsafe { streaming_json_arg(state_json, "state_json") }) else {
979            return std::ptr::null_mut();
980        };
981        match context
982            .session
983            .resume(BoundaryInstanceId::from_raw(instance_id), state, mode)
984        {
985            Ok(step) => owned_streaming_step(step),
986            Err(error) => {
987                set_last_error(error.to_string());
988                std::ptr::null_mut()
989            }
990        }
991    })) {
992        Ok(step) => step,
993        Err(_) => {
994            set_last_error("panic in webui_streaming_session_resume");
995            std::ptr::null_mut()
996        }
997    }
998}
999
1000/// Advance through parent bytes to the next occurrence or terminal completion.
1001///
1002/// This call is valid only after [`webui_streaming_session_resume`] commits the
1003/// pending occurrence. The returned owned step follows the same ownership rules
1004/// as start and resume and must be released with [`webui_streaming_step_destroy`].
1005/// A `NULL` return indicates an error available through [`webui_last_error`].
1006///
1007/// # Safety
1008///
1009/// `session_ptr` must be a live session handle with no concurrent operation.
1010#[no_mangle]
1011pub unsafe extern "C" fn webui_streaming_session_advance(
1012    session_ptr: *mut webui_streaming_session_t,
1013) -> *mut webui_streaming_step_t {
1014    clear_last_error();
1015    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1016        if session_ptr.is_null() {
1017            set_last_error("session_ptr must not be null");
1018            return std::ptr::null_mut();
1019        }
1020        // SAFETY: The caller guarantees exclusive access to a live session.
1021        let context = unsafe { &mut *(session_ptr as *mut StreamingSessionContext) };
1022        match context.session.advance() {
1023            Ok(step) => owned_streaming_step(step),
1024            Err(error) => {
1025                set_last_error(error.to_string());
1026                std::ptr::null_mut()
1027            }
1028        }
1029    })) {
1030        Ok(step) => step,
1031        Err(_) => {
1032            set_last_error("panic in webui_streaming_session_advance");
1033            std::ptr::null_mut()
1034        }
1035    }
1036}
1037
1038/// Emit a projected state patch for a committed updatable occurrence.
1039///
1040/// Returns allocated bytes that must be freed with [`webui_free`]. On success,
1041/// `out_len` receives the authoritative byte length excluding the allocation's
1042/// trailing NUL. On failure, `NULL` is returned and `out_len` is untouched.
1043/// Call [`webui_last_error`] for details.
1044///
1045/// # Safety
1046///
1047/// * `session_ptr` must be a live session handle with no concurrent operation.
1048/// * `patch_json` must be non-null, null-terminated UTF-8 and remain readable
1049///   for this call.
1050/// * `out_len` must be non-null and writable.
1051#[no_mangle]
1052pub unsafe extern "C" fn webui_streaming_session_update(
1053    session_ptr: *mut webui_streaming_session_t,
1054    instance_id: u32,
1055    patch_json: *const c_char,
1056    out_len: *mut usize,
1057) -> *mut c_char {
1058    clear_last_error();
1059    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1060        if session_ptr.is_null() || patch_json.is_null() || out_len.is_null() {
1061            set_last_error("session_ptr, patch_json, and out_len must not be null");
1062            return std::ptr::null_mut();
1063        }
1064        // SAFETY: The caller guarantees exclusive access to the live session.
1065        let context = unsafe { &mut *(session_ptr as *mut StreamingSessionContext) };
1066        // SAFETY: The caller guarantees the string is readable and terminated.
1067        let Some(patch) = (unsafe { streaming_json_arg(patch_json, "patch_json") }) else {
1068            return std::ptr::null_mut();
1069        };
1070        let bytes = match context
1071            .session
1072            .update(BoundaryInstanceId::from_raw(instance_id), &patch)
1073        {
1074            Ok(bytes) => bytes,
1075            Err(error) => {
1076                set_last_error(error.to_string());
1077                return std::ptr::null_mut();
1078            }
1079        };
1080        let length = bytes.len();
1081        match CString::new(bytes) {
1082            Ok(chunk) => {
1083                // SAFETY: The caller guarantees `out_len` is writable.
1084                unsafe { *out_len = length };
1085                chunk.into_raw()
1086            }
1087            Err(error) => {
1088                set_last_error(format!(
1089                    "streaming update contains an interior NUL byte: {error}"
1090                ));
1091                std::ptr::null_mut()
1092            }
1093        }
1094    })) {
1095        Ok(bytes) => bytes,
1096        Err(_) => {
1097            set_last_error("panic in webui_streaming_session_update");
1098            std::ptr::null_mut()
1099        }
1100    }
1101}
1102
1103/// Release an owned streaming step.
1104///
1105/// Destroying a step invalidates its byte pointer and all descriptor string
1106/// pointers previously returned by step accessors.
1107///
1108/// # Safety
1109///
1110/// `step_ptr` must be a pointer returned by
1111/// [`webui_streaming_session_start`], [`webui_streaming_session_resume`], or
1112/// [`webui_streaming_session_advance`], or `NULL` for a no-op. A non-null
1113/// pointer must not be used after this call.
1114#[no_mangle]
1115pub unsafe extern "C" fn webui_streaming_step_destroy(step_ptr: *mut webui_streaming_step_t) {
1116    clear_last_error();
1117    if step_ptr.is_null() {
1118        return;
1119    }
1120    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1121        // SAFETY: The caller guarantees this is one live owned step.
1122        drop(unsafe { Box::from_raw(step_ptr as *mut StreamingStepContext) });
1123    }));
1124    if result.is_err() {
1125        set_last_error("panic in webui_streaming_step_destroy");
1126    }
1127}
1128
1129/// Borrow the bytes produced by this step and write their length to `out_len`.
1130///
1131/// The returned pointer is borrowed from `step_ptr`, is not NUL-terminated,
1132/// and remains valid only until [`webui_streaming_step_destroy`]. It may be
1133/// read for exactly `out_len` bytes. Returns `NULL` on error.
1134///
1135/// # Safety
1136///
1137/// * `step_ptr` must be a live step handle with no concurrent destroy.
1138/// * `out_len` must be non-null and writable.
1139#[no_mangle]
1140pub unsafe extern "C" fn webui_streaming_step_bytes(
1141    step_ptr: *const webui_streaming_step_t,
1142    out_len: *mut usize,
1143) -> *const u8 {
1144    clear_last_error();
1145    match std::panic::catch_unwind(|| {
1146        if step_ptr.is_null() || out_len.is_null() {
1147            set_last_error("step_ptr and out_len must not be null");
1148            return std::ptr::null();
1149        }
1150        // SAFETY: The caller guarantees a live step handle.
1151        let context = unsafe { &*(step_ptr as *const StreamingStepContext) };
1152        // SAFETY: The caller guarantees `out_len` is writable.
1153        unsafe { *out_len = context.step.bytes.len() };
1154        context.step.bytes.as_ptr()
1155    }) {
1156        Ok(bytes) => bytes,
1157        Err(_) => {
1158            set_last_error("panic in webui_streaming_step_bytes");
1159            std::ptr::null()
1160        }
1161    }
1162}
1163
1164/// Observe whether this step emitted the terminal record.
1165///
1166/// A valid non-terminal step returns `false` with no last error. A null handle
1167/// returns `false` and sets [`webui_last_error`].
1168///
1169/// # Safety
1170///
1171/// `step_ptr` must be a live step handle with no concurrent destroy.
1172#[no_mangle]
1173pub unsafe extern "C" fn webui_streaming_step_done(
1174    step_ptr: *const webui_streaming_step_t,
1175) -> bool {
1176    clear_last_error();
1177    match std::panic::catch_unwind(|| {
1178        if step_ptr.is_null() {
1179            set_last_error("step_ptr is null");
1180            return false;
1181        }
1182        // SAFETY: The caller guarantees a live step handle.
1183        unsafe { (*(step_ptr as *const StreamingStepContext)).step.done }
1184    }) {
1185        Ok(done) => done,
1186        Err(_) => {
1187            set_last_error("panic in webui_streaming_step_done");
1188            false
1189        }
1190    }
1191}
1192
1193/// Observe whether this step carries a pending boundary descriptor.
1194///
1195/// A valid boundary-free step returns `false` with no last error. A null handle
1196/// returns `false` and sets [`webui_last_error`].
1197///
1198/// # Safety
1199///
1200/// `step_ptr` must be a live step handle with no concurrent destroy.
1201#[no_mangle]
1202pub unsafe extern "C" fn webui_streaming_step_has_boundary(
1203    step_ptr: *const webui_streaming_step_t,
1204) -> bool {
1205    clear_last_error();
1206    match std::panic::catch_unwind(|| {
1207        if step_ptr.is_null() {
1208            set_last_error("step_ptr is null");
1209            return false;
1210        }
1211        // SAFETY: The caller guarantees a live step handle.
1212        unsafe {
1213            (*(step_ptr as *const StreamingStepContext))
1214                .step
1215                .boundary
1216                .is_some()
1217        }
1218    }) {
1219        Ok(has_boundary) => has_boundary,
1220        Err(_) => {
1221            set_last_error("panic in webui_streaming_step_has_boundary");
1222            false
1223        }
1224    }
1225}
1226
1227/// Read the pending boundary's response-local instance ID.
1228///
1229/// Returns `false` and leaves `out_instance_id` untouched when the step has no
1230/// boundary or an argument is invalid.
1231///
1232/// # Safety
1233///
1234/// * `step_ptr` must be a live step handle with no concurrent destroy.
1235/// * `out_instance_id` must be non-null and writable.
1236#[no_mangle]
1237pub unsafe extern "C" fn webui_streaming_step_boundary_instance_id(
1238    step_ptr: *const webui_streaming_step_t,
1239    out_instance_id: *mut u32,
1240) -> bool {
1241    clear_last_error();
1242    match std::panic::catch_unwind(|| {
1243        if step_ptr.is_null() || out_instance_id.is_null() {
1244            set_last_error("step_ptr and out_instance_id must not be null");
1245            return false;
1246        }
1247        // SAFETY: The caller guarantees a live step handle.
1248        let context = unsafe { &*(step_ptr as *const StreamingStepContext) };
1249        let Some(boundary) = streaming_step_boundary(context) else {
1250            return false;
1251        };
1252        // SAFETY: The caller guarantees `out_instance_id` is writable.
1253        unsafe { *out_instance_id = boundary.instance_id.raw() };
1254        true
1255    }) {
1256        Ok(ok) => ok,
1257        Err(_) => {
1258            set_last_error("panic in webui_streaming_step_boundary_instance_id");
1259            false
1260        }
1261    }
1262}
1263
1264/// Read the pending boundary's stable compiler declaration ID.
1265///
1266/// Returns `false` and leaves `out_declaration_id` untouched when the step has
1267/// no boundary or an argument is invalid.
1268///
1269/// # Safety
1270///
1271/// * `step_ptr` must be a live step handle with no concurrent destroy.
1272/// * `out_declaration_id` must be non-null and writable.
1273#[no_mangle]
1274pub unsafe extern "C" fn webui_streaming_step_boundary_declaration_id(
1275    step_ptr: *const webui_streaming_step_t,
1276    out_declaration_id: *mut u32,
1277) -> bool {
1278    clear_last_error();
1279    match std::panic::catch_unwind(|| {
1280        if step_ptr.is_null() || out_declaration_id.is_null() {
1281            set_last_error("step_ptr and out_declaration_id must not be null");
1282            return false;
1283        }
1284        // SAFETY: The caller guarantees a live step handle.
1285        let context = unsafe { &*(step_ptr as *const StreamingStepContext) };
1286        let Some(boundary) = streaming_step_boundary(context) else {
1287            return false;
1288        };
1289        // SAFETY: The caller guarantees `out_declaration_id` is writable.
1290        unsafe { *out_declaration_id = boundary.declaration_id };
1291        true
1292    }) {
1293        Ok(ok) => ok,
1294        Err(_) => {
1295            set_last_error("panic in webui_streaming_step_boundary_declaration_id");
1296            false
1297        }
1298    }
1299}
1300
1301/// Borrow the pending boundary owner's UTF-8 bytes.
1302///
1303/// The returned string pointer is not NUL-terminated. It is borrowed from the
1304/// step and remains valid only until [`webui_streaming_step_destroy`]. Read it
1305/// for exactly the byte length written to `out_len`. Returns `NULL` on error.
1306///
1307/// # Safety
1308///
1309/// * `step_ptr` must be a live step handle with no concurrent destroy.
1310/// * `out_len` must be non-null and writable.
1311#[no_mangle]
1312pub unsafe extern "C" fn webui_streaming_step_boundary_owner(
1313    step_ptr: *const webui_streaming_step_t,
1314    out_len: *mut usize,
1315) -> *const c_char {
1316    clear_last_error();
1317    match std::panic::catch_unwind(|| {
1318        if step_ptr.is_null() || out_len.is_null() {
1319            set_last_error("step_ptr and out_len must not be null");
1320            return std::ptr::null();
1321        }
1322        // SAFETY: The caller guarantees a live step handle.
1323        let context = unsafe { &*(step_ptr as *const StreamingStepContext) };
1324        let Some(boundary) = streaming_step_boundary(context) else {
1325            return std::ptr::null();
1326        };
1327        // SAFETY: The caller guarantees `out_len` is writable.
1328        unsafe { *out_len = boundary.owner.len() };
1329        boundary.owner.as_ptr().cast()
1330    }) {
1331        Ok(owner) => owner,
1332        Err(_) => {
1333            set_last_error("panic in webui_streaming_step_boundary_owner");
1334            std::ptr::null()
1335        }
1336    }
1337}
1338
1339/// Borrow the pending boundary name's UTF-8 bytes.
1340///
1341/// The returned string pointer is not NUL-terminated. It is borrowed from the
1342/// step and remains valid only until [`webui_streaming_step_destroy`]. Read it
1343/// for exactly the byte length written to `out_len`. Returns `NULL` on error.
1344///
1345/// # Safety
1346///
1347/// * `step_ptr` must be a live step handle with no concurrent destroy.
1348/// * `out_len` must be non-null and writable.
1349#[no_mangle]
1350pub unsafe extern "C" fn webui_streaming_step_boundary_name(
1351    step_ptr: *const webui_streaming_step_t,
1352    out_len: *mut usize,
1353) -> *const c_char {
1354    clear_last_error();
1355    match std::panic::catch_unwind(|| {
1356        if step_ptr.is_null() || out_len.is_null() {
1357            set_last_error("step_ptr and out_len must not be null");
1358            return std::ptr::null();
1359        }
1360        // SAFETY: The caller guarantees a live step handle.
1361        let context = unsafe { &*(step_ptr as *const StreamingStepContext) };
1362        let Some(boundary) = streaming_step_boundary(context) else {
1363            return std::ptr::null();
1364        };
1365        // SAFETY: The caller guarantees `out_len` is writable.
1366        unsafe { *out_len = boundary.name.len() };
1367        boundary.name.as_ptr().cast()
1368    }) {
1369        Ok(name) => name,
1370        Err(_) => {
1371            set_last_error("panic in webui_streaming_step_boundary_name");
1372            std::ptr::null()
1373        }
1374    }
1375}
1376
1377/// Read the pending boundary key discriminator.
1378///
1379/// On success, writes exactly one of [`WEBUI_BOUNDARY_KEY_NONE`],
1380/// [`WEBUI_BOUNDARY_KEY_STRING`], or [`WEBUI_BOUNDARY_KEY_NUMBER`]. Returns
1381/// `false` and leaves `out_key_type` untouched on error.
1382///
1383/// # Safety
1384///
1385/// * `step_ptr` must be a live step handle with no concurrent destroy.
1386/// * `out_key_type` must be non-null and writable.
1387#[no_mangle]
1388pub unsafe extern "C" fn webui_streaming_step_boundary_key_type(
1389    step_ptr: *const webui_streaming_step_t,
1390    out_key_type: *mut webui_boundary_key_type_t,
1391) -> bool {
1392    clear_last_error();
1393    match std::panic::catch_unwind(|| {
1394        if step_ptr.is_null() || out_key_type.is_null() {
1395            set_last_error("step_ptr and out_key_type must not be null");
1396            return false;
1397        }
1398        // SAFETY: The caller guarantees a live step handle.
1399        let context = unsafe { &*(step_ptr as *const StreamingStepContext) };
1400        let Some(boundary) = streaming_step_boundary(context) else {
1401            return false;
1402        };
1403        let key_type = match boundary.key {
1404            None => WEBUI_BOUNDARY_KEY_NONE,
1405            Some(BoundaryKey::String(_)) => WEBUI_BOUNDARY_KEY_STRING,
1406            Some(BoundaryKey::Number(_)) => WEBUI_BOUNDARY_KEY_NUMBER,
1407        };
1408        // SAFETY: The caller guarantees `out_key_type` is writable.
1409        unsafe { *out_key_type = key_type };
1410        true
1411    }) {
1412        Ok(ok) => ok,
1413        Err(_) => {
1414            set_last_error("panic in webui_streaming_step_boundary_key_type");
1415            false
1416        }
1417    }
1418}
1419
1420/// Borrow the pending boundary's string key as UTF-8 bytes.
1421///
1422/// The returned string pointer is not NUL-terminated. It is borrowed from the
1423/// step and remains valid only until [`webui_streaming_step_destroy`]. Read it
1424/// for exactly the byte length written to `out_len`. A non-string key returns
1425/// `NULL`, leaves `out_len` untouched, and sets [`webui_last_error`].
1426///
1427/// # Safety
1428///
1429/// * `step_ptr` must be a live step handle with no concurrent destroy.
1430/// * `out_len` must be non-null and writable.
1431#[no_mangle]
1432pub unsafe extern "C" fn webui_streaming_step_boundary_key_string(
1433    step_ptr: *const webui_streaming_step_t,
1434    out_len: *mut usize,
1435) -> *const c_char {
1436    clear_last_error();
1437    match std::panic::catch_unwind(|| {
1438        if step_ptr.is_null() || out_len.is_null() {
1439            set_last_error("step_ptr and out_len must not be null");
1440            return std::ptr::null();
1441        }
1442        // SAFETY: The caller guarantees a live step handle.
1443        let context = unsafe { &*(step_ptr as *const StreamingStepContext) };
1444        let Some(boundary) = streaming_step_boundary(context) else {
1445            return std::ptr::null();
1446        };
1447        let Some(BoundaryKey::String(value)) = boundary.key.as_ref() else {
1448            set_last_error("pending boundary key is not a string");
1449            return std::ptr::null();
1450        };
1451        // SAFETY: The caller guarantees `out_len` is writable.
1452        unsafe { *out_len = value.len() };
1453        value.as_ptr().cast()
1454    }) {
1455        Ok(value) => value,
1456        Err(_) => {
1457            set_last_error("panic in webui_streaming_step_boundary_key_string");
1458            std::ptr::null()
1459        }
1460    }
1461}
1462
1463/// Read the pending boundary's numeric key.
1464///
1465/// Returns `false`, leaves `out_value` untouched, and sets
1466/// [`webui_last_error`] when the key is absent or is a string.
1467///
1468/// # Safety
1469///
1470/// * `step_ptr` must be a live step handle with no concurrent destroy.
1471/// * `out_value` must be non-null and writable.
1472#[no_mangle]
1473pub unsafe extern "C" fn webui_streaming_step_boundary_key_number(
1474    step_ptr: *const webui_streaming_step_t,
1475    out_value: *mut f64,
1476) -> bool {
1477    clear_last_error();
1478    match std::panic::catch_unwind(|| {
1479        if step_ptr.is_null() || out_value.is_null() {
1480            set_last_error("step_ptr and out_value must not be null");
1481            return false;
1482        }
1483        // SAFETY: The caller guarantees a live step handle.
1484        let context = unsafe { &*(step_ptr as *const StreamingStepContext) };
1485        let Some(boundary) = streaming_step_boundary(context) else {
1486            return false;
1487        };
1488        let Some(BoundaryKey::Number(value)) = boundary.key.as_ref() else {
1489            set_last_error("pending boundary key is not a number");
1490            return false;
1491        };
1492        let Some(value) = value.as_f64() else {
1493            set_last_error("pending boundary key cannot be represented as a finite number");
1494            return false;
1495        };
1496        // SAFETY: The caller guarantees `out_value` is writable.
1497        unsafe { *out_value = value };
1498        true
1499    }) {
1500        Ok(ok) => ok,
1501        Err(_) => {
1502            set_last_error("panic in webui_streaming_step_boundary_key_number");
1503            false
1504        }
1505    }
1506}
1507
1508fn owned_streaming_step(step: StreamStep) -> *mut webui_streaming_step_t {
1509    Box::into_raw(Box::new(StreamingStepContext { step })) as *mut webui_streaming_step_t
1510}
1511
1512fn streaming_step_boundary(context: &StreamingStepContext) -> Option<&BoundaryDescriptor> {
1513    match context.step.boundary.as_ref() {
1514        Some(boundary) => Some(boundary),
1515        None => {
1516            set_last_error("streaming step has no pending boundary");
1517            None
1518        }
1519    }
1520}
1521
1522fn streaming_boundary_mode(mode: webui_boundary_mode_t) -> Option<BoundaryMode> {
1523    match mode {
1524        WEBUI_BOUNDARY_MODE_FINAL => Some(BoundaryMode::Final),
1525        WEBUI_BOUNDARY_MODE_UPDATABLE => Some(BoundaryMode::Updatable),
1526        _ => {
1527            set_last_error(format!(
1528                "invalid boundary mode {mode}; expected WEBUI_BOUNDARY_MODE_FINAL (0) \
1529                 or WEBUI_BOUNDARY_MODE_UPDATABLE (1)"
1530            ));
1531            None
1532        }
1533    }
1534}
1535
1536/// Parse one required JSON string argument.
1537///
1538/// # Safety
1539///
1540/// `value` must be non-null, null-terminated, and readable for this call.
1541unsafe fn streaming_json_arg(value: *const c_char, name: &str) -> Option<Value> {
1542    // SAFETY: The caller guarantees the string pointer is valid and terminated.
1543    let text = unsafe { utf8_arg(value, name) }?;
1544    match serde_json::from_str(text) {
1545        Ok(value) => Some(value),
1546        Err(error) => {
1547            set_last_error(format!("failed to parse {name}: {error}"));
1548            None
1549        }
1550    }
1551}
1552
1553/// Borrow a C string argument as UTF-8, recording an actionable error on failure.
1554///
1555/// # Safety
1556///
1557/// `value` must be non-null, null-terminated, and live for the returned borrow.
1558unsafe fn utf8_arg<'a>(value: *const c_char, name: &str) -> Option<&'a str> {
1559    // SAFETY: The caller guarantees the pointer is valid and terminated.
1560    match unsafe { CStr::from_ptr(value) }.to_str() {
1561        Ok(text) => Some(text),
1562        Err(error) => {
1563            set_last_error(format!("invalid UTF-8 in {name}: {error}"));
1564            None
1565        }
1566    }
1567}