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        // Feature 463 B5 fix — RwLock instead of Mutex so capability calls
678        // can run CONCURRENTLY (read-locked). The previous Mutex serialized
679        // every call, causing a deadlock when one capability (e.g. agent.prompt)
680        // invokes another capability on the SAME app re-entrantly through the
681        // graph engine (agent.prompt → graph.engine.send_event → graph node →
682        // graph.tasks.storage.* → core.agent_storage.* → BACK to agent app).
683        // init/shutdown still acquire write-lock so they remain exclusive.
684        static APP_INSTANCE: std::sync::OnceLock<std::sync::RwLock<$app_type>> =
685            std::sync::OnceLock::new();
686        static VTABLE: std::sync::OnceLock<$crate::NodeAppVTable> =
687            std::sync::OnceLock::new();
688
689        // OnceLock-backed CStrings for metadata (valid for process lifetime)
690        static META_NAME: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
691        static META_VERSION: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
692        static META_AUTHOR: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
693        static META_DESCRIPTION: std::sync::OnceLock<std::ffi::CString> =
694            std::sync::OnceLock::new();
695
696        unsafe extern "C" fn __node_app_init(
697            ctx: *const std::os::raw::c_void,
698        ) -> $crate::FfiResult {
699            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
700                let ctx_opt = if ctx.is_null() {
701                    None
702                } else {
703                    // Store context pointer for use by log() helper
704                    let ctx_typed = ctx as *const $crate::NodeAppContext;
705                    $crate::__store_context(ctx_typed);
706                    Some(unsafe { &*ctx_typed })
707                };
708                let app = APP_INSTANCE.get_or_init(|| {
709                    std::sync::RwLock::new(<$app_type>::default())
710                });
711                let mut guard = match app.write() {
712                    Ok(g) => g,
713                    Err(e) => {
714                        eprintln!("[node-app] rwlock poisoned in init: {}", e);
715                        return $crate::FfiResult::error(-10);
716                    }
717                };
718                match guard.init(ctx_opt) {
719                    Ok(()) => $crate::FfiResult::ok(),
720                    Err(e) => {
721                        let msg = format!("init error: {}", e);
722                        $crate::log($crate::log_level::ERROR, &msg);
723                        eprintln!("[node-app] {}", msg);
724                        $crate::FfiResult::error(-1)
725                    }
726                }
727            })) {
728                Ok(result) => result,
729                Err(_) => {
730                    eprintln!("[node-app] panic in init");
731                    $crate::FfiResult::error(-99)
732                }
733            }
734        }
735
736        unsafe extern "C" fn __node_app_shutdown() -> $crate::FfiResult {
737            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
738                if let Some(app) = APP_INSTANCE.get() {
739                    let mut guard = match app.write() {
740                        Ok(g) => g,
741                        Err(e) => {
742                            eprintln!("[node-app] rwlock poisoned in shutdown: {}", e);
743                            return $crate::FfiResult::error(-10);
744                        }
745                    };
746                    match guard.shutdown() {
747                        Ok(()) => $crate::FfiResult::ok(),
748                        Err(e) => {
749                            let msg = format!("shutdown error: {}", e);
750                            $crate::log($crate::log_level::ERROR, &msg);
751                            eprintln!("[node-app] {}", msg);
752                            $crate::FfiResult::error(-1)
753                        }
754                    }
755                } else {
756                    $crate::FfiResult::ok()
757                }
758            })) {
759                Ok(result) => result,
760                Err(_) => {
761                    eprintln!("[node-app] panic in shutdown");
762                    $crate::FfiResult::error(-99)
763                }
764            }
765        }
766
767        unsafe extern "C" fn __node_app_handle_request(
768            request_json: *const u8,
769            request_len: usize,
770        ) -> $crate::FfiResult {
771            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
772                let json_slice =
773                    unsafe { std::slice::from_raw_parts(request_json, request_len) };
774                let request: $crate::AppRequest = match serde_json::from_slice(json_slice) {
775                    Ok(r) => r,
776                    Err(e) => {
777                        eprintln!("[node-app] request deserialization error: {}", e);
778                        return $crate::FfiResult::error(-2);
779                    }
780                };
781
782                let app = match APP_INSTANCE.get() {
783                    Some(a) => a,
784                    None => return $crate::FfiResult::error(-3),
785                };
786                // Feature 463 B5 — read-lock allows concurrent calls
787                let guard = match app.read() {
788                    Ok(g) => g,
789                    Err(e) => {
790                        eprintln!("[node-app] rwlock poisoned in handle_request: {}", e);
791                        return $crate::FfiResult::error(-10);
792                    }
793                };
794
795                match guard.handle_request(request) {
796                    Ok(response) => match serde_json::to_vec(&response) {
797                        Ok(bytes) => {
798                            let len = bytes.len();
799                            let boxed = bytes.into_boxed_slice();
800                            let ptr = Box::into_raw(boxed) as *mut u8;
801                            $crate::FfiResult {
802                                success: true,
803                                error_code: 0,
804                                data: ptr,
805                                data_len: len,
806                            }
807                        }
808                        Err(e) => {
809                            eprintln!("[node-app] response serialization error: {}", e);
810                            $crate::FfiResult::error(-4)
811                        }
812                    },
813                    Err(e) => {
814                        eprintln!("[node-app] handle_request error: {}", e);
815                        $crate::FfiResult::error(-5)
816                    }
817                }
818            })) {
819                Ok(result) => result,
820                Err(_) => {
821                    eprintln!("[node-app] panic in handle_request");
822                    $crate::FfiResult::error(-99)
823                }
824            }
825        }
826
827        unsafe extern "C" fn __node_app_handle_event(
828            event_json: *const u8,
829            event_len: usize,
830        ) -> $crate::FfiResult {
831            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
832                let json_slice =
833                    unsafe { std::slice::from_raw_parts(event_json, event_len) };
834                let event: $crate::AppEvent = match serde_json::from_slice(json_slice) {
835                    Ok(e) => e,
836                    Err(e) => {
837                        eprintln!("[node-app] event deserialization error: {}", e);
838                        return $crate::FfiResult::error(-2);
839                    }
840                };
841
842                let app = match APP_INSTANCE.get() {
843                    Some(a) => a,
844                    None => return $crate::FfiResult::error(-3),
845                };
846                // Feature 463 B5 — read-lock allows concurrent calls
847                let guard = match app.read() {
848                    Ok(g) => g,
849                    Err(e) => {
850                        eprintln!("[node-app] rwlock poisoned in handle_event: {}", e);
851                        return $crate::FfiResult::error(-10);
852                    }
853                };
854
855                match guard.handle_event(event) {
856                    Ok(()) => $crate::FfiResult::ok(),
857                    Err(e) => {
858                        eprintln!("[node-app] handle_event error: {}", e);
859                        $crate::FfiResult::error(-5)
860                    }
861                }
862            })) {
863                Ok(result) => result,
864                Err(_) => {
865                    eprintln!("[node-app] panic in handle_event");
866                    $crate::FfiResult::error(-99)
867                }
868            }
869        }
870
871        unsafe extern "C" fn __node_app_handle_capability(
872            request_json: *const u8,
873            request_len: usize,
874        ) -> $crate::FfiResult {
875            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
876                let json_slice =
877                    unsafe { std::slice::from_raw_parts(request_json, request_len) };
878                let request: $crate::CapabilityRequest = match serde_json::from_slice(json_slice) {
879                    Ok(r) => r,
880                    Err(e) => {
881                        eprintln!("[node-app] capability request deserialization error: {}", e);
882                        return $crate::FfiResult::error(-2);
883                    }
884                };
885
886                let app = match APP_INSTANCE.get() {
887                    Some(a) => a,
888                    None => return $crate::FfiResult::error(-3),
889                };
890                // Feature 463 B5 — read-lock allows the SAME app to handle
891                // re-entrant capability calls (e.g. agent.prompt outer call
892                // and an inner core.agent_storage.write from a graph node)
893                // concurrently. handle_capability is &self so this is sound.
894                let guard = match app.read() {
895                    Ok(g) => g,
896                    Err(e) => {
897                        eprintln!("[node-app] rwlock poisoned in handle_capability: {}", e);
898                        return $crate::FfiResult::error(-10);
899                    }
900                };
901
902                // Set thread-local trace context for duration of this capability call.
903                // This allows invoke_capability() to propagate trace automatically.
904                $crate::CURRENT_TRACE.with(|tl| {
905                    *tl.borrow_mut() = if let Some(ref trace_id) = request.trace_id {
906                        Some($crate::CurrentTrace {
907                            trace_id: trace_id.clone(),
908                            span_id: request.span_id.clone().unwrap_or_default(),
909                            depth: request.trace_depth.unwrap_or(0),
910                        })
911                    } else {
912                        None
913                    };
914                });
915
916                let cap_result = guard.handle_capability(request);
917
918                // Clear trace context after call completes.
919                $crate::CURRENT_TRACE.with(|tl| {
920                    *tl.borrow_mut() = None;
921                });
922
923                match cap_result {
924                    Ok(response) => match serde_json::to_vec(&response) {
925                        Ok(bytes) => {
926                            // Enforce 16MB response limit
927                            if bytes.len() > $crate::MAX_CAPABILITY_RESPONSE_SIZE {
928                                eprintln!(
929                                    "[node-app] capability response exceeds 16MB limit ({} bytes)",
930                                    bytes.len()
931                                );
932                                return $crate::FfiResult::error(-6);
933                            }
934                            let len = bytes.len();
935                            let boxed = bytes.into_boxed_slice();
936                            let ptr = Box::into_raw(boxed) as *mut u8;
937                            $crate::FfiResult {
938                                success: true,
939                                error_code: 0,
940                                data: ptr,
941                                data_len: len,
942                            }
943                        }
944                        Err(e) => {
945                            eprintln!("[node-app] capability response serialization error: {}", e);
946                            $crate::FfiResult::error(-4)
947                        }
948                    },
949                    Err(e) => {
950                        eprintln!("[node-app] handle_capability error: {}", e);
951                        $crate::FfiResult::error(-5)
952                    }
953                }
954            })) {
955                Ok(result) => result,
956                Err(_) => {
957                    eprintln!("[node-app] panic in handle_capability");
958                    $crate::FfiResult::error(-99)
959                }
960            }
961        }
962
963        unsafe extern "C" fn __node_app_free(ptr: *mut u8, len: usize) {
964            if !ptr.is_null() && len > 0 {
965                let _ = unsafe { Box::from_raw(std::slice::from_raw_parts_mut(ptr, len)) };
966            }
967        }
968
969        #[no_mangle]
970        pub unsafe extern "C" fn _node_app_entry(
971            _ctx: *const std::os::raw::c_void,
972        ) -> *const $crate::NodeAppVTable {
973            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
974                let info = <$app_type as $crate::NodeApp>::metadata();
975                let caps = info.capability_flags();
976
977                let name = META_NAME
978                    .get_or_init(|| std::ffi::CString::new(info.name).unwrap_or_default());
979                let version = META_VERSION
980                    .get_or_init(|| std::ffi::CString::new(info.version).unwrap_or_default());
981                let author = META_AUTHOR
982                    .get_or_init(|| std::ffi::CString::new(info.author).unwrap_or_default());
983                let description = META_DESCRIPTION
984                    .get_or_init(|| std::ffi::CString::new(info.description).unwrap_or_default());
985
986                let metadata = $crate::NodeAppMetadata {
987                    api_version: $crate::API_VERSION,
988                    name: name.as_ptr(),
989                    version: version.as_ptr(),
990                    author: author.as_ptr(),
991                    description: description.as_ptr(),
992                    capabilities: caps.bits(),
993                };
994
995                VTABLE.get_or_init(|| $crate::NodeAppVTable {
996                    metadata,
997                    init: __node_app_init,
998                    shutdown: __node_app_shutdown,
999                    handle_request: __node_app_handle_request,
1000                    handle_event: __node_app_handle_event,
1001                    handle_capability: __node_app_handle_capability,
1002                    free: __node_app_free,
1003                }) as *const $crate::NodeAppVTable
1004            })) {
1005                Ok(ptr) => ptr,
1006                Err(_) => {
1007                    eprintln!("[node-app] panic in _node_app_entry");
1008                    std::ptr::null()
1009                }
1010            }
1011        }
1012    };
1013}