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