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