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