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