Skip to main content

node_app_sdk_rust/
lib.rs

1//! # Node-App SDK for Rust
2//!
3//! Build native [Node-App] plugins as shared libraries (`cdylib`) with a
4//! safe, ergonomic Rust API. The SDK targets the **Node Host API v1**
5//! (the canonical C header is `core/host-abi-v1/include/node-host-api-v1.h`
6//! in the host repository).
7//!
8//! [Node-App]: https://github.com/econ-v1/node-app-distribution
9//!
10//! ## What this crate provides
11//!
12//! - The [`NodeApp`] trait — implement it on your plugin type to get HTTP,
13//!   event, and capability handling.
14//! - The [`declare_node_app!`] macro — generates all FFI boilerplate
15//!   (vtable, panic guards, JSON serialization) from your trait impl.
16//! - Helpers for talking to the host: [`log`], [`invoke_capability`],
17//!   [`publish_event`], [`get_config`], [`get_storage`], [`set_storage`].
18//! - Distributed-trace propagation across capability invocations
19//!   (thread-local span context — see [`CurrentTrace`]).
20//! - Hard limits matching the host: [`MAX_CAPABILITY_RESPONSE_SIZE`]
21//!   (16 MiB), [`MAX_EVENT_NAME_LEN`] (256 B), [`MAX_EVENT_DATA_LEN`]
22//!   (64 KiB).
23//!
24//! ## Stability
25//!
26//! This crate is published as **`1.0.0-experimental`**. The trait surface
27//! and the underlying C ABI are FROZEN for the v1 series, but breaking
28//! changes between `1.0.0-experimental.*` releases are possible until at
29//! least two first-party packages have proven the surface in production.
30//! Pin to an exact version in `Cargo.toml`.
31//!
32//! ## Quick start
33//!
34//! ```toml
35//! # Cargo.toml
36//! [lib]
37//! crate-type = ["cdylib"]
38//!
39//! [dependencies]
40//! node-app-sdk-rust = "1.0.0-experimental"
41//! serde_json = "1.0"
42//! ```
43//!
44//! ```rust,ignore
45//! use node_app_sdk_rust::*;
46//!
47//! #[derive(Default)]
48//! pub struct MyApp;
49//!
50//! impl NodeApp for MyApp {
51//!     fn metadata() -> NodeAppInfo {
52//!         NodeAppInfo {
53//!             name: "my-app".into(),
54//!             version: "0.1.0".into(),
55//!             author: "Me".into(),
56//!             description: "Hello-world Node-App".into(),
57//!             capabilities: vec!["http_handler".into()],
58//!         }
59//!     }
60//!
61//!     fn handle_request(&self, _req: AppRequest) -> Result<AppResponse, NodeAppError> {
62//!         Ok(AppResponse {
63//!             status: 200,
64//!             headers: Default::default(),
65//!             body: serde_json::json!({ "hello": "world" }),
66//!         })
67//!     }
68//! }
69//!
70//! declare_node_app!(MyApp);
71//! ```
72//!
73//! Build with `cargo build --release`; the resulting shared library plus a
74//! `manifest.json` is installed into the host's app directory.
75//!
76//! ## Calling host capabilities
77//!
78//! Use [`invoke_capability`] to call any capability registered with the
79//! host's capability router (e.g. `core.storage.get`,
80//! `core.lightning.payment.send`):
81//!
82//! ```rust,ignore
83//! use node_app_sdk_rust::{invoke_capability, CapabilityRequest};
84//!
85//! let response = invoke_capability(&CapabilityRequest {
86//!     id: "req-1".into(),
87//!     capability: "core.storage.get".into(),
88//!     payload: serde_json::json!({ "key": "user_pref" }),
89//!     caller_node_id: None,
90//!     trace_id: None,
91//!     span_id: None,
92//!     parent_span_id: None,
93//!     trace_depth: None,
94//! })?;
95//! # Ok::<(), node_app_sdk_rust::NodeAppError>(())
96//! ```
97//!
98//! Trace context (`trace_id`, `span_id`, `trace_depth`) is propagated
99//! automatically when invoking from inside `handle_capability` — you do
100//! not need to thread it manually.
101//!
102//! ## Publishing events
103//!
104//! ```rust,ignore
105//! use node_app_sdk_rust::publish_event;
106//!
107//! publish_event(
108//!     "my-app.something_happened",
109//!     &serde_json::json!({ "user_id": 42 }),
110//! )?;
111//! # Ok::<(), node_app_sdk_rust::NodeAppError>(())
112//! ```
113//!
114//! Event names **must** be namespaced with the app name (`my-app.*`); the
115//! host rejects un-namespaced events.
116
117#![warn(missing_docs)]
118#![warn(rustdoc::broken_intra_doc_links)]
119
120pub use node_app_api::types::{
121    AppEvent, AppRequest, AppResponse, Capabilities, CapabilityExample, CapabilityRequest,
122    CapabilityResponse, NodeAppInfo, ProvidedCapability,
123};
124pub use node_app_api::context::NodeAppContext;
125pub use node_app_api::ffi::{FfiResult, NodeAppMetadata, NodeAppVTable};
126pub use node_app_api::API_VERSION;
127
128use std::cell::RefCell;
129use std::ffi::CString;
130use std::sync::OnceLock;
131
132/// Maximum response size for capability handlers (16 MiB).
133///
134/// The host enforces this limit on every capability response. Apps that
135/// produce a larger response will see [`FfiResult::error`] returned with
136/// error code `-6`. Use streaming or pagination for larger payloads.
137pub const MAX_CAPABILITY_RESPONSE_SIZE: usize = 16 * 1024 * 1024;
138
139/// Trace fields for the currently-executing capability span (per-thread).
140///
141/// Set at the start of `handle_capability` by the [`declare_node_app!`]
142/// macro and cleared on return. Allows [`invoke_capability`] to propagate
143/// distributed trace context automatically without the caller having to
144/// thread `trace_id` / `span_id` through every call site.
145#[doc(hidden)]
146#[derive(Clone)]
147pub struct CurrentTrace {
148    /// Trace ID inherited from the inbound capability request.
149    pub trace_id: String,
150    /// Span ID of the current execution — becomes `parent_span_id` for
151    /// sub-invocations made from this thread.
152    pub span_id: String,
153    /// Depth of the current span in the trace tree (root = 0).
154    pub depth: u8,
155}
156
157thread_local! {
158    /// Current trace context for the executing capability (set by
159    /// [`declare_node_app!`]).
160    #[doc(hidden)]
161    pub static CURRENT_TRACE: RefCell<Option<CurrentTrace>> = const { RefCell::new(None) };
162}
163
164/// Wrapper for the context pointer to implement Send + Sync.
165///
166/// # Safety
167/// The context pointer points to host-controlled memory that outlives the
168/// app. It is only read after being set during init, and the host
169/// guarantees thread safety. The pointer is never dereferenced for
170/// writes from app code.
171struct AppContextWrapper(*const NodeAppContext);
172
173// Safety: The context pointer points to host-controlled memory that outlives the app.
174// The host side (NativeLoader) ensures the HostData and NodeAppContext remain valid
175// for the lifetime of the loaded app. All access is read-only after init.
176unsafe impl Send for AppContextWrapper {}
177unsafe impl Sync for AppContextWrapper {}
178
179/// Global storage for the host context pointer.
180///
181/// Set during init by [`declare_node_app!`], used by every host helper
182/// in this module ([`log`], [`invoke_capability`], etc.).
183static APP_CONTEXT: OnceLock<AppContextWrapper> = OnceLock::new();
184
185/// Log levels accepted by the [`log`] function.
186///
187/// Use these constants instead of magic numbers when calling
188/// [`log`] directly. The convenience macros ([`log_info!`], etc.)
189/// take care of this for you.
190pub mod log_level {
191    /// Most verbose level — use for fine-grained tracing.
192    pub const TRACE: u32 = 0;
193    /// Debug-level diagnostics, typically not shown in production.
194    pub const DEBUG: u32 = 1;
195    /// Informational messages indicating normal operation.
196    pub const INFO: u32 = 2;
197    /// Warnings — recoverable issues or unusual conditions.
198    pub const WARN: u32 = 3;
199    /// Errors — operations that failed and require attention.
200    pub const ERROR: u32 = 4;
201}
202
203/// Log a message to the host using the stored context.
204///
205/// Logs are written to the host's per-app log file
206/// (`{log_dir}/{app_name}.log`) and forwarded to the host's tracing
207/// subscriber, so they appear in the daemon's main log output too.
208///
209/// This function is a no-op if the context was not provided during init
210/// or if the message contains invalid UTF-8.
211///
212/// # Arguments
213/// * `level` - Log level (0=trace, 1=debug, 2=info, 3=warn, 4=error). Use the [`log_level`] constants.
214/// * `message` - The log message (must not contain interior NUL bytes).
215///
216/// # Example
217/// ```ignore
218/// use node_app_sdk_rust::{log, log_level};
219///
220/// log(log_level::INFO, "App initialized successfully");
221/// log(log_level::ERROR, "Something went wrong!");
222/// ```
223///
224/// Most callers should use the convenience macros ([`log_info!`],
225/// [`log_error!`], etc.) which accept `format!`-style arguments.
226pub fn log(level: u32, message: &str) {
227    let wrapper = match APP_CONTEXT.get() {
228        Some(w) => w,
229        None => return, // No context available
230    };
231
232    let ctx_ptr = wrapper.0;
233    if ctx_ptr.is_null() {
234        return;
235    }
236
237    let c_message = match CString::new(message) {
238        Ok(s) => s,
239        Err(_) => return, // Invalid message (contains null byte)
240    };
241
242    // Safety: ctx_ptr is valid for the app's lifetime, and host_log is a valid function pointer
243    unsafe {
244        let ctx = &*ctx_ptr;
245        (ctx.host_log)(ctx.host_data, level, c_message.as_ptr());
246    }
247}
248
249/// Invoke a capability on the host via the capability router.
250///
251/// This is the primary mechanism for app-to-app communication. The host
252/// resolves the capability name to the providing app, dispatches the
253/// request, and returns the response. The provider may itself be a
254/// different app, the host kernel, or a remote node (transparent to the
255/// caller).
256///
257/// Trace context is propagated automatically: if this call happens
258/// inside `handle_capability` and the inbound request carried a
259/// `trace_id`, the same trace ID is injected on outbound calls. Callers
260/// may override this by setting `trace_id` explicitly on the request.
261///
262/// # Errors
263///
264/// Returns [`NodeAppError::CapabilityError`] when:
265/// - The host context is not available (called before init).
266/// - The host's invoke callback is not wired up.
267/// - The capability is not registered, the provider rejects the call,
268///   or the response cannot be deserialized.
269pub fn invoke_capability(request: &CapabilityRequest) -> Result<CapabilityResponse, NodeAppError> {
270    let wrapper = match APP_CONTEXT.get() {
271        Some(w) => w,
272        None => {
273            return Err(NodeAppError::CapabilityError(
274                "Host context not available".into(),
275            ))
276        }
277    };
278
279    let ctx_ptr = wrapper.0;
280    if ctx_ptr.is_null() {
281        return Err(NodeAppError::CapabilityError(
282            "Host context pointer is null".into(),
283        ));
284    }
285
286    // Propagate distributed trace context if a parent span is active on this thread.
287    // Only inject when the caller hasn't already set trace fields.
288    let effective_request: std::borrow::Cow<CapabilityRequest> =
289        if request.trace_id.is_none() {
290            CURRENT_TRACE.with(|tl| {
291                if let Some(trace) = tl.borrow().as_ref() {
292                    let injected = CapabilityRequest {
293                        trace_id: Some(trace.trace_id.clone()),
294                        span_id: Some(trace.span_id.clone()),
295                        parent_span_id: None,
296                        trace_depth: Some(trace.depth),
297                        ..request.clone()
298                    };
299                    std::borrow::Cow::Owned(injected)
300                } else {
301                    std::borrow::Cow::Borrowed(request)
302                }
303            })
304        } else {
305            std::borrow::Cow::Borrowed(request)
306        };
307
308    // Serialize the request to JSON
309    let request_json = serde_json::to_vec(effective_request.as_ref())?;
310
311    // Safety: ctx_ptr is valid for the app's lifetime
312    unsafe {
313        let ctx = &*ctx_ptr;
314
315        // Check if the callback is available
316        if ctx.host_invoke_capability as usize == 0 {
317            return Err(NodeAppError::CapabilityError(
318                "host_invoke_capability callback not available".into(),
319            ));
320        }
321
322        let result = (ctx.host_invoke_capability)(
323            ctx.host_data,
324            request_json.as_ptr(),
325            request_json.len(),
326        );
327
328        if result.success && !result.data.is_null() && result.data_len > 0 {
329            let response_slice = std::slice::from_raw_parts(result.data, result.data_len);
330            let response: CapabilityResponse = serde_json::from_slice(response_slice)
331                .map_err(|e| NodeAppError::CapabilityError(format!("Response deserialization error: {}", e)))?;
332            // Free the host-allocated data
333            // Note: The host is responsible for freeing this memory via its own allocator
334            Ok(response)
335        } else if !result.success {
336            Err(NodeAppError::CapabilityError(format!(
337                "Host capability invocation failed with error code {}",
338                result.error_code
339            )))
340        } else {
341            Err(NodeAppError::CapabilityError(
342                "Empty response from host".into(),
343            ))
344        }
345    }
346}
347
348/// Maximum event name length in bytes (256).
349///
350/// Names that exceed this limit are rejected by [`publish_event`] with
351/// [`NodeAppError::EventFailed`]. The host enforces the same limit on
352/// the receiving side.
353pub const MAX_EVENT_NAME_LEN: usize = 256;
354
355/// Maximum event data length in bytes (64 KiB).
356///
357/// Payloads that exceed this limit are rejected by [`publish_event`]
358/// with [`NodeAppError::EventFailed`]. For larger artifacts, store them
359/// (e.g. via `core.storage.insert`) and emit an event referencing the
360/// storage key instead.
361pub const MAX_EVENT_DATA_LEN: usize = 64 * 1024;
362
363/// Publish a domain event to the host event bus.
364///
365/// The event is queued asynchronously (fire-and-forget). The event
366/// name **must** be namespaced with the app name prefix
367/// (e.g. `lightning.payment_received`, `my-app.user_created`); the host
368/// rejects events whose name does not start with `{app_name}.`.
369///
370/// # Errors
371///
372/// Returns [`NodeAppError::EventFailed`] if the host context is not
373/// available, the event name or data exceeds size limits
374/// ([`MAX_EVENT_NAME_LEN`] / [`MAX_EVENT_DATA_LEN`]), or the host
375/// rejects the event.
376pub fn publish_event(name: &str, data: &serde_json::Value) -> Result<(), NodeAppError> {
377    let wrapper = match APP_CONTEXT.get() {
378        Some(w) => w,
379        None => {
380            return Err(NodeAppError::EventFailed(
381                "Host context not available".into(),
382            ))
383        }
384    };
385
386    let ctx_ptr = wrapper.0;
387    if ctx_ptr.is_null() {
388        return Err(NodeAppError::EventFailed(
389            "Host context pointer is null".into(),
390        ));
391    }
392
393    let name_bytes = name.as_bytes();
394    if name_bytes.len() > MAX_EVENT_NAME_LEN {
395        return Err(NodeAppError::EventFailed(format!(
396            "Event name exceeds {} byte limit (got {})",
397            MAX_EVENT_NAME_LEN,
398            name_bytes.len()
399        )));
400    }
401
402    let data_json = serde_json::to_vec(data)?;
403    if data_json.len() > MAX_EVENT_DATA_LEN {
404        return Err(NodeAppError::EventFailed(format!(
405            "Event data exceeds {} byte limit (got {})",
406            MAX_EVENT_DATA_LEN,
407            data_json.len()
408        )));
409    }
410
411    unsafe {
412        let ctx = &*ctx_ptr;
413
414        if ctx.host_publish_event as usize == 0 {
415            return Err(NodeAppError::EventFailed(
416                "host_publish_event callback not available".into(),
417            ));
418        }
419
420        let result = (ctx.host_publish_event)(
421            ctx.host_data,
422            name_bytes.as_ptr(),
423            name_bytes.len(),
424            data_json.as_ptr(),
425            data_json.len(),
426        );
427
428        if result == 0 {
429            Ok(())
430        } else {
431            Err(NodeAppError::EventFailed(format!(
432                "host_publish_event returned error code {}",
433                result
434            )))
435        }
436    }
437}
438
439/// Log a TRACE-level message using `format!`-style arguments.
440///
441/// No-op when the host context has not been wired up yet. See [`log`]
442/// for details about delivery semantics.
443#[macro_export]
444macro_rules! log_trace {
445    ($($arg:tt)*) => {
446        $crate::log($crate::log_level::TRACE, &format!($($arg)*))
447    };
448}
449
450/// Log a DEBUG-level message using `format!`-style arguments.
451#[macro_export]
452macro_rules! log_debug {
453    ($($arg:tt)*) => {
454        $crate::log($crate::log_level::DEBUG, &format!($($arg)*))
455    };
456}
457
458/// Log an INFO-level message using `format!`-style arguments.
459#[macro_export]
460macro_rules! log_info {
461    ($($arg:tt)*) => {
462        $crate::log($crate::log_level::INFO, &format!($($arg)*))
463    };
464}
465
466/// Log a WARN-level message using `format!`-style arguments.
467#[macro_export]
468macro_rules! log_warn {
469    ($($arg:tt)*) => {
470        $crate::log($crate::log_level::WARN, &format!($($arg)*))
471    };
472}
473
474/// Log an ERROR-level message using `format!`-style arguments.
475#[macro_export]
476macro_rules! log_error {
477    ($($arg:tt)*) => {
478        $crate::log($crate::log_level::ERROR, &format!($($arg)*))
479    };
480}
481
482/// Store the context pointer for use by host helper functions.
483///
484/// Called automatically by [`declare_node_app!`] during init. App code
485/// must not call this directly.
486#[doc(hidden)]
487pub fn __store_context(ctx: *const NodeAppContext) {
488    let _ = APP_CONTEXT.set(AppContextWrapper(ctx));
489}
490
491/// Get a configuration value from the host by key.
492///
493/// Common keys include `data_dir`, `host_port`, `app_name`, and
494/// `app_id`. The full set of available keys is determined by the host
495/// at app-init time.
496///
497/// Returns `None` if the key is not registered or the context is not
498/// available (called before init).
499pub fn get_config(key: &str) -> Option<String> {
500    let wrapper = APP_CONTEXT.get()?;
501    let ctx_ptr = wrapper.0;
502    if ctx_ptr.is_null() {
503        return None;
504    }
505    let c_key = CString::new(key).ok()?;
506    unsafe {
507        let ctx = &*ctx_ptr;
508        let result = (ctx.host_get_config)(ctx.host_data, c_key.as_ptr());
509        if result.is_null() {
510            return None;
511        }
512        Some(std::ffi::CStr::from_ptr(result).to_string_lossy().into_owned())
513    }
514}
515
516/// Set a storage value scoped to this app.
517///
518/// Storage is persisted by the host across app restarts and is isolated
519/// per-app: other apps cannot read or write keys you set here. Useful
520/// for small bits of configuration or state; for larger or relational
521/// data, prefer the `core.storage.*` capabilities.
522///
523/// No-op when the host context is not available or `key`/`value`
524/// contain interior NUL bytes.
525pub fn set_storage(key: &str, value: &str) {
526    let wrapper = match APP_CONTEXT.get() {
527        Some(w) => w,
528        None => return,
529    };
530    let ctx_ptr = wrapper.0;
531    if ctx_ptr.is_null() {
532        return;
533    }
534    let c_key = match CString::new(key) {
535        Ok(s) => s,
536        Err(_) => return,
537    };
538    let c_value = match CString::new(value) {
539        Ok(s) => s,
540        Err(_) => return,
541    };
542    unsafe {
543        let ctx = &*ctx_ptr;
544        (ctx.host_set_storage)(ctx.host_data, c_key.as_ptr(), c_value.as_ptr());
545    }
546}
547
548/// Get a storage value by key, scoped to this app.
549///
550/// See [`set_storage`] for scoping semantics. Returns `None` if the key
551/// is not found or the context is not available.
552pub fn get_storage(key: &str) -> Option<String> {
553    let wrapper = APP_CONTEXT.get()?;
554    let ctx_ptr = wrapper.0;
555    if ctx_ptr.is_null() {
556        return None;
557    }
558    let c_key = CString::new(key).ok()?;
559    unsafe {
560        let ctx = &*ctx_ptr;
561        let result = (ctx.host_get_storage)(ctx.host_data, c_key.as_ptr());
562        if result.is_null() {
563            return None;
564        }
565        Some(std::ffi::CStr::from_ptr(result).to_string_lossy().into_owned())
566    }
567}
568
569/// Error type returned by app operations.
570///
571/// Variants surface specific failure modes from each lifecycle hook plus
572/// transport-level errors (serialization, capability dispatch). The
573/// `#[from] serde_json::Error` impl on [`NodeAppError::SerializationError`]
574/// allows the `?` operator to propagate JSON errors directly.
575#[derive(Debug, thiserror::Error)]
576pub enum NodeAppError {
577    /// Returned from [`NodeApp::init`] when initialization failed.
578    #[error("Initialization failed: {0}")]
579    InitFailed(String),
580    /// Returned from [`NodeApp::handle_request`] when handling failed.
581    #[error("Request handling failed: {0}")]
582    RequestFailed(String),
583    /// Returned from [`NodeApp::handle_event`] or [`publish_event`].
584    #[error("Event handling failed: {0}")]
585    EventFailed(String),
586    /// Returned from [`NodeApp::shutdown`] when graceful shutdown failed.
587    #[error("Shutdown failed: {0}")]
588    ShutdownFailed(String),
589    /// JSON (de)serialization error — auto-converted from
590    /// [`serde_json::Error`] via the `?` operator.
591    #[error("Serialization error: {0}")]
592    SerializationError(#[from] serde_json::Error),
593    /// Returned from [`NodeApp::handle_capability`] or [`invoke_capability`].
594    #[error("Capability error: {0}")]
595    CapabilityError(String),
596}
597
598/// Trait implemented by node-app plugins.
599///
600/// Pair this with [`declare_node_app!`] to generate all the FFI
601/// boilerplate. Implementors must be `Default + Send + Sync + 'static`
602/// because the macro stores the singleton instance behind a static
603/// `OnceLock<Mutex<T>>`.
604///
605/// All trait methods have sensible default implementations; override
606/// only the hooks the app uses. Apps that opt into a hook must also
607/// declare the matching capability in their manifest, e.g.
608/// `"http_handler"` for HTTP request routing.
609pub trait NodeApp: Default + Send + Sync + 'static {
610    /// Return app metadata (name, version, author, description, capabilities).
611    ///
612    /// Called once when the host loads the shared library. The values
613    /// are cached for the lifetime of the process.
614    fn metadata() -> NodeAppInfo;
615
616    /// Initialize the app with the host context.
617    ///
618    /// Called once after loading. The default impl is a no-op — override
619    /// to set up state (DB connections, caches, etc.). The context may
620    /// be `None` if the host has not wired up callbacks yet (very early
621    /// in bootstrap or in unit tests).
622    fn init(&mut self, _ctx: Option<&NodeAppContext>) -> Result<(), NodeAppError> {
623        Ok(())
624    }
625
626    /// Shut down the app gracefully.
627    ///
628    /// Called before unloading. Default is a no-op. Override to flush
629    /// pending writes, close connections, etc.
630    fn shutdown(&mut self) -> Result<(), NodeAppError> {
631        Ok(())
632    }
633
634    /// Handle an incoming HTTP request proxied from the host.
635    ///
636    /// Only invoked if the app declared the `http_handler` capability.
637    /// Default returns `501 Not Implemented`.
638    fn handle_request(&self, _request: AppRequest) -> Result<AppResponse, NodeAppError> {
639        Ok(AppResponse {
640            status: 501,
641            headers: Default::default(),
642            body: serde_json::json!({"error": "Not implemented"}),
643        })
644    }
645
646    /// Handle a domain event from the host event bus.
647    ///
648    /// Only invoked if the app declared the `event_listener` capability
649    /// and subscribed to the event's namespace via the manifest.
650    fn handle_event(&self, _event: AppEvent) -> Result<(), NodeAppError> {
651        Ok(())
652    }
653
654    /// Return the list of service capabilities this app provides.
655    ///
656    /// Override to declare capabilities for the host's capability
657    /// registry. Default returns an empty list (no capabilities
658    /// provided). Each entry must use the namespace `{app_name}.{domain}.{action}`
659    /// — the `core.*` namespace is reserved for first-party apps.
660    fn provided_capabilities() -> Vec<ProvidedCapability> {
661        Vec::new()
662    }
663
664    /// Handle a capability invocation from another app via the
665    /// capability router.
666    ///
667    /// Override to implement capability handling logic. The default
668    /// returns [`NodeAppError::CapabilityError`] indicating the
669    /// capability is not implemented.
670    ///
671    /// Trace context is automatically captured into thread-local state
672    /// for the duration of this call so [`invoke_capability`] can
673    /// propagate it on outbound calls without explicit threading.
674    fn handle_capability(
675        &self,
676        _request: CapabilityRequest,
677    ) -> Result<CapabilityResponse, NodeAppError> {
678        Err(NodeAppError::CapabilityError(
679            "Capability handling not implemented".into(),
680        ))
681    }
682}
683
684/// Generate all FFI boilerplate for a [`NodeApp`] implementation.
685///
686/// This macro creates:
687/// - A `OnceLock<Mutex<T>>` instance for the app (sound concurrent access).
688/// - The `_node_app_entry` export symbol returning a [`NodeAppVTable`].
689/// - FFI wrapper functions for `init`, `shutdown`, `handle_request`,
690///   `handle_event`, `handle_capability`, and `free`.
691/// - `catch_unwind` guards on every FFI boundary to prevent UB from
692///   panics crossing the Rust/C ABI.
693/// - Thread-local trace span management around `handle_capability`.
694///
695/// Invoke once at the crate root after defining the app type:
696///
697/// ```ignore
698/// declare_node_app!(MyApp);
699/// ```
700#[macro_export]
701macro_rules! declare_node_app {
702    ($app_type:ty) => {
703        static APP_INSTANCE: std::sync::OnceLock<std::sync::Mutex<$app_type>> =
704            std::sync::OnceLock::new();
705        static VTABLE: std::sync::OnceLock<$crate::NodeAppVTable> =
706            std::sync::OnceLock::new();
707
708        // OnceLock-backed CStrings for metadata (valid for process lifetime)
709        static META_NAME: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
710        static META_VERSION: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
711        static META_AUTHOR: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
712        static META_DESCRIPTION: std::sync::OnceLock<std::ffi::CString> =
713            std::sync::OnceLock::new();
714
715        unsafe extern "C" fn __node_app_init(
716            ctx: *const std::os::raw::c_void,
717        ) -> $crate::FfiResult {
718            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
719                let ctx_opt = if ctx.is_null() {
720                    None
721                } else {
722                    // Store context pointer for use by log() helper
723                    let ctx_typed = ctx as *const $crate::NodeAppContext;
724                    $crate::__store_context(ctx_typed);
725                    Some(unsafe { &*ctx_typed })
726                };
727                let app = APP_INSTANCE.get_or_init(|| {
728                    std::sync::Mutex::new(<$app_type>::default())
729                });
730                let mut guard = match app.lock() {
731                    Ok(g) => g,
732                    Err(e) => {
733                        eprintln!("[node-app] mutex poisoned in init: {}", e);
734                        return $crate::FfiResult::error(-10);
735                    }
736                };
737                match guard.init(ctx_opt) {
738                    Ok(()) => $crate::FfiResult::ok(),
739                    Err(e) => {
740                        eprintln!("[node-app] init error: {}", e);
741                        $crate::FfiResult::error(-1)
742                    }
743                }
744            })) {
745                Ok(result) => result,
746                Err(_) => {
747                    eprintln!("[node-app] panic in init");
748                    $crate::FfiResult::error(-99)
749                }
750            }
751        }
752
753        unsafe extern "C" fn __node_app_shutdown() -> $crate::FfiResult {
754            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
755                if let Some(app) = APP_INSTANCE.get() {
756                    let mut guard = match app.lock() {
757                        Ok(g) => g,
758                        Err(e) => {
759                            eprintln!("[node-app] mutex poisoned in shutdown: {}", e);
760                            return $crate::FfiResult::error(-10);
761                        }
762                    };
763                    match guard.shutdown() {
764                        Ok(()) => $crate::FfiResult::ok(),
765                        Err(e) => {
766                            eprintln!("[node-app] shutdown error: {}", e);
767                            $crate::FfiResult::error(-1)
768                        }
769                    }
770                } else {
771                    $crate::FfiResult::ok()
772                }
773            })) {
774                Ok(result) => result,
775                Err(_) => {
776                    eprintln!("[node-app] panic in shutdown");
777                    $crate::FfiResult::error(-99)
778                }
779            }
780        }
781
782        unsafe extern "C" fn __node_app_handle_request(
783            request_json: *const u8,
784            request_len: usize,
785        ) -> $crate::FfiResult {
786            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
787                let json_slice =
788                    unsafe { std::slice::from_raw_parts(request_json, request_len) };
789                let request: $crate::AppRequest = match serde_json::from_slice(json_slice) {
790                    Ok(r) => r,
791                    Err(e) => {
792                        eprintln!("[node-app] request deserialization error: {}", e);
793                        return $crate::FfiResult::error(-2);
794                    }
795                };
796
797                let app = match APP_INSTANCE.get() {
798                    Some(a) => a,
799                    None => return $crate::FfiResult::error(-3),
800                };
801                let guard = match app.lock() {
802                    Ok(g) => g,
803                    Err(e) => {
804                        eprintln!("[node-app] mutex poisoned in handle_request: {}", e);
805                        return $crate::FfiResult::error(-10);
806                    }
807                };
808
809                match guard.handle_request(request) {
810                    Ok(response) => match serde_json::to_vec(&response) {
811                        Ok(bytes) => {
812                            let len = bytes.len();
813                            let boxed = bytes.into_boxed_slice();
814                            let ptr = Box::into_raw(boxed) as *mut u8;
815                            $crate::FfiResult {
816                                success: true,
817                                error_code: 0,
818                                data: ptr,
819                                data_len: len,
820                            }
821                        }
822                        Err(e) => {
823                            eprintln!("[node-app] response serialization error: {}", e);
824                            $crate::FfiResult::error(-4)
825                        }
826                    },
827                    Err(e) => {
828                        eprintln!("[node-app] handle_request error: {}", e);
829                        $crate::FfiResult::error(-5)
830                    }
831                }
832            })) {
833                Ok(result) => result,
834                Err(_) => {
835                    eprintln!("[node-app] panic in handle_request");
836                    $crate::FfiResult::error(-99)
837                }
838            }
839        }
840
841        unsafe extern "C" fn __node_app_handle_event(
842            event_json: *const u8,
843            event_len: usize,
844        ) -> $crate::FfiResult {
845            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
846                let json_slice =
847                    unsafe { std::slice::from_raw_parts(event_json, event_len) };
848                let event: $crate::AppEvent = match serde_json::from_slice(json_slice) {
849                    Ok(e) => e,
850                    Err(e) => {
851                        eprintln!("[node-app] event deserialization error: {}", e);
852                        return $crate::FfiResult::error(-2);
853                    }
854                };
855
856                let app = match APP_INSTANCE.get() {
857                    Some(a) => a,
858                    None => return $crate::FfiResult::error(-3),
859                };
860                let guard = match app.lock() {
861                    Ok(g) => g,
862                    Err(e) => {
863                        eprintln!("[node-app] mutex poisoned in handle_event: {}", e);
864                        return $crate::FfiResult::error(-10);
865                    }
866                };
867
868                match guard.handle_event(event) {
869                    Ok(()) => $crate::FfiResult::ok(),
870                    Err(e) => {
871                        eprintln!("[node-app] handle_event error: {}", e);
872                        $crate::FfiResult::error(-5)
873                    }
874                }
875            })) {
876                Ok(result) => result,
877                Err(_) => {
878                    eprintln!("[node-app] panic in handle_event");
879                    $crate::FfiResult::error(-99)
880                }
881            }
882        }
883
884        unsafe extern "C" fn __node_app_handle_capability(
885            request_json: *const u8,
886            request_len: usize,
887        ) -> $crate::FfiResult {
888            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
889                let json_slice =
890                    unsafe { std::slice::from_raw_parts(request_json, request_len) };
891                let request: $crate::CapabilityRequest = match serde_json::from_slice(json_slice) {
892                    Ok(r) => r,
893                    Err(e) => {
894                        eprintln!("[node-app] capability request deserialization error: {}", e);
895                        return $crate::FfiResult::error(-2);
896                    }
897                };
898
899                let app = match APP_INSTANCE.get() {
900                    Some(a) => a,
901                    None => return $crate::FfiResult::error(-3),
902                };
903                let guard = match app.lock() {
904                    Ok(g) => g,
905                    Err(e) => {
906                        eprintln!("[node-app] mutex poisoned in handle_capability: {}", e);
907                        return $crate::FfiResult::error(-10);
908                    }
909                };
910
911                // Set thread-local trace context for duration of this capability call.
912                // This allows invoke_capability() to propagate trace automatically.
913                $crate::CURRENT_TRACE.with(|tl| {
914                    *tl.borrow_mut() = if let Some(ref trace_id) = request.trace_id {
915                        Some($crate::CurrentTrace {
916                            trace_id: trace_id.clone(),
917                            span_id: request.span_id.clone().unwrap_or_default(),
918                            depth: request.trace_depth.unwrap_or(0),
919                        })
920                    } else {
921                        None
922                    };
923                });
924
925                let cap_result = guard.handle_capability(request);
926
927                // Clear trace context after call completes.
928                $crate::CURRENT_TRACE.with(|tl| {
929                    *tl.borrow_mut() = None;
930                });
931
932                match cap_result {
933                    Ok(response) => match serde_json::to_vec(&response) {
934                        Ok(bytes) => {
935                            // Enforce 16MB response limit
936                            if bytes.len() > $crate::MAX_CAPABILITY_RESPONSE_SIZE {
937                                eprintln!(
938                                    "[node-app] capability response exceeds 16MB limit ({} bytes)",
939                                    bytes.len()
940                                );
941                                return $crate::FfiResult::error(-6);
942                            }
943                            let len = bytes.len();
944                            let boxed = bytes.into_boxed_slice();
945                            let ptr = Box::into_raw(boxed) as *mut u8;
946                            $crate::FfiResult {
947                                success: true,
948                                error_code: 0,
949                                data: ptr,
950                                data_len: len,
951                            }
952                        }
953                        Err(e) => {
954                            eprintln!("[node-app] capability response serialization error: {}", e);
955                            $crate::FfiResult::error(-4)
956                        }
957                    },
958                    Err(e) => {
959                        eprintln!("[node-app] handle_capability error: {}", e);
960                        $crate::FfiResult::error(-5)
961                    }
962                }
963            })) {
964                Ok(result) => result,
965                Err(_) => {
966                    eprintln!("[node-app] panic in handle_capability");
967                    $crate::FfiResult::error(-99)
968                }
969            }
970        }
971
972        unsafe extern "C" fn __node_app_free(ptr: *mut u8, len: usize) {
973            if !ptr.is_null() && len > 0 {
974                let _ = unsafe { Box::from_raw(std::slice::from_raw_parts_mut(ptr, len)) };
975            }
976        }
977
978        #[no_mangle]
979        pub unsafe extern "C" fn _node_app_entry(
980            _ctx: *const std::os::raw::c_void,
981        ) -> *const $crate::NodeAppVTable {
982            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
983                let info = <$app_type as $crate::NodeApp>::metadata();
984                let caps = info.capability_flags();
985
986                let name = META_NAME
987                    .get_or_init(|| std::ffi::CString::new(info.name).unwrap_or_default());
988                let version = META_VERSION
989                    .get_or_init(|| std::ffi::CString::new(info.version).unwrap_or_default());
990                let author = META_AUTHOR
991                    .get_or_init(|| std::ffi::CString::new(info.author).unwrap_or_default());
992                let description = META_DESCRIPTION
993                    .get_or_init(|| std::ffi::CString::new(info.description).unwrap_or_default());
994
995                let metadata = $crate::NodeAppMetadata {
996                    api_version: $crate::API_VERSION,
997                    name: name.as_ptr(),
998                    version: version.as_ptr(),
999                    author: author.as_ptr(),
1000                    description: description.as_ptr(),
1001                    capabilities: caps.bits(),
1002                };
1003
1004                VTABLE.get_or_init(|| $crate::NodeAppVTable {
1005                    metadata,
1006                    init: __node_app_init,
1007                    shutdown: __node_app_shutdown,
1008                    handle_request: __node_app_handle_request,
1009                    handle_event: __node_app_handle_event,
1010                    handle_capability: __node_app_handle_capability,
1011                    free: __node_app_free,
1012                }) as *const $crate::NodeAppVTable
1013            })) {
1014                Ok(ptr) => ptr,
1015                Err(_) => {
1016                    eprintln!("[node-app] panic in _node_app_entry");
1017                    std::ptr::null()
1018                }
1019            }
1020        }
1021    };
1022}