luminarys-sdk 0.1.0

Rust SDK for building Luminarys WASM skills
Documentation
//! TCP / TLS host API.
//!
//! Connections are managed on the host side.  The skill receives an opaque
//! `conn_id` string and passes it back to write/close calls.
//!
//! # Reading: callback model
//!
//! Reading is push-based — register a callback method name at connect time.
//! The host calls that method with a [`ConnEvent`] whenever data or an error arrives.
//!
//! ```rust,no_run
//! # use luminarys_sdk::{tcp::*, types::InvokeRequest};
//! let conn_id = tcp_connect("redis.internal:6379", 5000, "on_redis_data").unwrap();
//!
//! // In the callback:
//! fn on_redis_data(req: InvokeRequest) {
//!     let evt = unmarshal_conn_event(&req.payload).unwrap();
//!     if evt.error_kind != ErrorKind::None {
//!         // handle error / reconnect
//!     }
//!     // process evt.data …
//! }
//! ```
//!
//! Requires `tcp.enabled: true` in `manifest.yaml`.

use crate::abi::{call_host, raw};
use crate::types::SkillError;
use serde::{Deserialize, Serialize};

// ── ErrorKind ─────────────────────────────────────────────────────────────────

/// Classifies a TCP connection error.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ErrorKind {
    /// Data event — no error.
    #[default]
    #[serde(rename = "")]
    None,
    /// Remote peer closed the connection gracefully.
    Eof,
    /// Connection reset by peer.
    Reset,
    /// Read deadline exceeded.
    Timeout,
    /// TLS handshake or record-layer error.
    Tls,
    /// Generic I/O error.
    Io,
}

// ── ConnEvent ─────────────────────────────────────────────────────────────────

/// Payload delivered to the skill's TCP read callback.
///
/// A data event: `error_kind == ErrorKind::None` and `data` has received bytes.  
/// An error event: `error_kind != None` — `conn_id` is no longer valid.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ConnEvent {
    #[serde(rename = "conn_id")]
    pub conn_id: String,
    #[serde(rename = "data", default, with = "crate::bytes_or_null", skip_serializing_if = "Vec::is_empty")]
    pub data: Vec<u8>,
    #[serde(rename = "error_kind", default)]
    pub error_kind: ErrorKind,
    #[serde(rename = "error_msg", default, skip_serializing_if = "String::is_empty")]
    pub error_msg: String,
}

/// Deserialise a [`ConnEvent`] from the raw payload.
pub fn unmarshal_conn_event(payload: &[u8]) -> Result<ConnEvent, SkillError> {
    Ok(rmp_serde::from_slice(payload)?)
}

// ── TLS options ───────────────────────────────────────────────────────────────

/// Options for [`tcp_connect_tls`].
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct TcpConnectTlsOptions {
    /// Dial timeout in milliseconds. `0` = 30 s default.
    #[serde(rename = "timeout_ms")]
    pub timeout_ms: i64,
    /// Override TLS SNI hostname. Defaults to the host in `addr`.
    #[serde(rename = "server_name", default, skip_serializing_if = "String::is_empty")]
    pub server_name: String,
    /// Disable certificate verification — **dev/test only**.
    #[serde(rename = "insecure", default)]
    pub insecure: bool,
}

// ── Connect ───────────────────────────────────────────────────────────────────

/// Dial a plain TCP connection to `addr` (e.g. `"host:port"`).
///
/// `callback` is the WASM method name called with [`ConnEvent`] on reads.
/// Pass `""` to silently drain incoming data.
pub fn tcp_connect(addr: &str, timeout_ms: i64, callback: &str) -> Result<String, SkillError> {
    #[derive(Serialize, Default)]
    struct Req<'a> {
        addr: &'a str,
        timeout_ms: i64,
        callback: &'a str,
    }
    extract_conn_id(
        call_host(raw::tcp_connect, &Req { addr, timeout_ms, callback }),
        "tcp_connect",
    )
}

/// Dial a TLS connection to `addr` (e.g. `"host:443"`).
///
/// ```rust,no_run
/// # use luminarys_sdk::tcp::*;
/// // Production — verified TLS:
/// let conn_id = tcp_connect_tls("api.example.com:443",
///     TcpConnectTlsOptions { timeout_ms: 10000, ..Default::default() },
///     "on_api_data").unwrap();
///
/// // Development — self-signed cert:
/// let conn_id = tcp_connect_tls("localhost:8443",
///     TcpConnectTlsOptions { insecure: true, ..Default::default() },
///     "on_dev_data").unwrap();
/// ```
pub fn tcp_connect_tls(
    addr: &str,
    opts: TcpConnectTlsOptions,
    callback: &str,
) -> Result<String, SkillError> {
    #[derive(Serialize, Default)]
    struct Req<'a> {
        addr: &'a str,
        timeout_ms: i64,
        server_name: String,
        insecure: bool,
        callback: &'a str,
    }
    extract_conn_id(
        call_host(
            raw::tcp_connect_tls,
            &Req {
                addr,
                timeout_ms: opts.timeout_ms,
                server_name: opts.server_name,
                insecure: opts.insecure,
                callback,
            },
        ),
        "tcp_connect_tls",
    )
}

// ── Callback ──────────────────────────────────────────────────────────────────

/// Update the read callback for an existing connection.
///
/// Use when the callback name is not known at connect time, e.g. after protocol negotiation.
pub fn tcp_set_callback(conn_id: &str, callback: &str) -> Result<(), SkillError> {
    #[derive(Serialize, Default)]
    struct Req<'a> { conn_id: &'a str, callback: &'a str }
    extract_error(call_host(raw::tcp_set_callback, &Req { conn_id, callback }), "tcp_set_callback")
}

// ── Write / Close ─────────────────────────────────────────────────────────────

/// Send data over an existing connection.
pub fn tcp_write(conn_id: &str, data: Vec<u8>) -> Result<(), SkillError> {
    #[derive(Serialize, Default)]
    struct Req<'a> {
        conn_id: &'a str,
        #[serde(with = "crate::bytes_or_null")]
        data: Vec<u8>,
    }
    extract_error(call_host(raw::tcp_write, &Req { conn_id, data }), "tcp_write")
}

/// Close the connection and stop its read loop.
/// Idempotent — safe to call from a `Drop` impl even if the connection already failed.
pub fn tcp_close(conn_id: &str) -> Result<(), SkillError> {
    #[derive(Serialize, Default)]
    struct Req<'a> { conn_id: &'a str }
    extract_error(call_host(raw::tcp_close, &Req { conn_id }), "tcp_close")
}

// ── helpers ───────────────────────────────────────────────────────────────────

#[derive(Deserialize, Default)]
struct ConnResp {
    #[serde(default)]
    conn_id: String,
    #[serde(default)]
    error: String,
}

#[derive(Deserialize, Default)]
#[serde(default)]
struct ErrResp {
    #[serde(default)]
    error: String,
}

fn extract_conn_id(result: Result<ConnResp, SkillError>, op: &str) -> Result<String, SkillError> {
    match result {
        Err(e) => Err(SkillError(format!("{op}: {e}"))),
        Ok(r) if !r.error.is_empty() => Err(SkillError(r.error)),
        Ok(r) => Ok(r.conn_id),
    }
}

fn extract_error(result: Result<ErrResp, SkillError>, op: &str) -> Result<(), SkillError> {
    match result {
        Err(e) => Err(SkillError(format!("{op}: {e}"))),
        Ok(r) if !r.error.is_empty() => Err(SkillError(r.error)),
        Ok(_) => Ok(()),
    }
}