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