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