Skip to main content

weft_package_sdk/
lib.rs

1//! WEFT Package SDK — shared types and host function wrappers for WASM packages.
2//!
3//! All WASM packages built for WEFT should depend on this crate.
4//! It provides ergonomic wrappers around Extism host functions registered by weft-core.
5
6pub use extism_pdk::{self, plugin_fn, FnResult, FromBytes, ToBytes};
7pub use serde;
8pub use serde_json;
9
10use base64::Engine;
11use extism_pdk::*;
12use serde::de::DeserializeOwned;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16// ── Common types ──
17
18/// WebSocket message envelope (matches weft-core's PackageWsMessage.payload).
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct WsRequest {
21    #[serde(default)]
22    pub action: String,
23    #[serde(default)]
24    pub data: serde_json::Value,
25}
26
27/// Standard package response.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PackageResult {
30    pub status: String,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub data: Option<serde_json::Value>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub error: Option<String>,
35}
36
37impl PackageResult {
38    pub fn ok(data: serde_json::Value) -> Self {
39        Self {
40            status: "ok".into(),
41            data: Some(data),
42            error: None,
43        }
44    }
45
46    pub fn ok_empty() -> Self {
47        Self {
48            status: "ok".into(),
49            data: None,
50            error: None,
51        }
52    }
53
54    pub fn err(msg: impl Into<String>) -> Self {
55        Self {
56            status: "error".into(),
57            data: None,
58            error: Some(msg.into()),
59        }
60    }
61
62    pub fn to_json(&self) -> String {
63        serde_json::to_string(self)
64            .unwrap_or_else(|_| r#"{"status":"error","error":"serialize failed"}"#.into())
65    }
66}
67
68// ── Host function imports ──
69// These match the host functions registered in weft-core bridge.rs.
70
71#[host_fn]
72extern "ExtismHost" {
73    pub fn host_log(input: String) -> String;
74    pub fn host_kv_get(key: String) -> String;
75    pub fn host_kv_set(input: String);
76    pub fn host_kv_list(prefix: String) -> String;
77    pub fn host_kv_delete(key: String);
78    pub fn host_env_get(key: String) -> String;
79    pub fn host_read_file(path: String) -> String;
80    pub fn host_write_file(input: String);
81    pub fn host_write_file_base64(input: String) -> String;
82    pub fn host_list_dir(path: String) -> String;
83    pub fn host_exec(input: String) -> String;
84    pub fn host_exec_advanced(input: String) -> String;
85    pub fn host_chat_completion(input: String) -> String;
86    pub fn host_chat_completion_stream(input: String) -> String;
87    pub fn host_http_request(input: String) -> String;
88    pub fn host_call_package(input: String) -> String;
89    pub fn host_call_package_ws(input: String) -> String;
90    pub fn host_capability_call(input: String) -> String;
91    pub fn host_process_spawn(config: String) -> String;
92    pub fn host_process_stop(name: String) -> String;
93    pub fn host_process_status(name: String) -> String;
94    pub fn host_process_write_stdin(input: String) -> String;
95    pub fn host_process_read_stdout(input: String) -> String;
96    pub fn host_sqlite_query(input: String) -> String;
97    pub fn host_sqlite_execute(input: String) -> String;
98    pub fn host_sqlite_batch(input: String) -> String;
99    pub fn host_now_ms(input: String) -> String;
100}
101
102// ── Ergonomic wrappers ──
103
104/// Log a message at the given level.
105pub fn log(level: &str, msg: &str) {
106    let input = serde_json::json!([level, msg]).to_string();
107    let _ = unsafe { host_log(input) };
108}
109
110pub fn log_info(msg: &str) {
111    log("info", msg);
112}
113pub fn log_warn(msg: &str) {
114    log("warn", msg);
115}
116pub fn log_error(msg: &str) {
117    log("error", msg);
118}
119pub fn log_debug(msg: &str) {
120    log("debug", msg);
121}
122
123/// Get a value from the KV store.
124pub fn kv_get(key: &str) -> Option<String> {
125    match unsafe { host_kv_get(key.to_string()) } {
126        Ok(v) if v.is_empty() => None,
127        Ok(v) => Some(v),
128        Err(_) => None,
129    }
130}
131
132/// Set a value in the KV store.
133pub fn kv_set(key: &str, value: &str) {
134    let input = serde_json::json!([key, value]).to_string();
135    let _ = unsafe { host_kv_set(input) };
136}
137
138pub fn kv_list(prefix: &str) -> Result<Vec<String>, String> {
139    match unsafe { host_kv_list(prefix.to_string()) } {
140        Ok(json) => parse_host_json(&json),
141        Err(e) => Err(format!("{}", e)),
142    }
143}
144
145pub fn kv_delete(key: &str) -> Result<(), String> {
146    match unsafe { host_kv_delete(key.to_string()) } {
147        Ok(_) => Ok(()),
148        Err(e) => Err(format!("{}", e)),
149    }
150}
151
152pub fn env_get(key: &str) -> Option<String> {
153    match unsafe { host_env_get(key.to_string()) } {
154        Ok(value) if value.is_empty() => None,
155        Ok(value) => Some(value),
156        Err(_) => None,
157    }
158}
159
160/// Read a file. Returns content or error JSON.
161pub fn read_file(path: &str) -> Result<String, String> {
162    match unsafe { host_read_file(path.to_string()) } {
163        Ok(content) => {
164            if content.starts_with(r#"{"error":"#) {
165                Err(content)
166            } else {
167                Ok(content)
168            }
169        }
170        Err(e) => Err(format!("{}", e)),
171    }
172}
173
174/// Write a file.
175pub fn write_file(path: &str, content: &str) {
176    let input = serde_json::json!([path, content]).to_string();
177    let _ = unsafe { host_write_file(input) };
178}
179
180/// Write binary content (given as base64) to a file. For large media that
181/// can't go through write_file's String content. Returns the path on success.
182pub fn write_file_base64(path: &str, b64: &str) -> Result<String, String> {
183    let input = serde_json::json!([path, b64]).to_string();
184    let raw = unsafe { host_write_file_base64(input) }
185        .map_err(|e| format!("host_write_file_base64 failed: {e}"))?;
186    let parsed: serde_json::Value = serde_json::from_str(&raw)
187        .map_err(|e| format!("host_write_file_base64 returned invalid json: {e}"))?;
188    if let Some(err) = parsed.get("error").and_then(|v| v.as_str()) {
189        if !err.trim().is_empty() {
190            return Err(err.to_string());
191        }
192    }
193    Ok(parsed
194        .get("path")
195        .and_then(|v| v.as_str())
196        .unwrap_or(path)
197        .to_string())
198}
199
200/// List directory entries. Returns JSON array of {name, is_dir}.
201pub fn list_dir(path: &str) -> Result<Vec<DirEntry>, String> {
202    match unsafe { host_list_dir(path.to_string()) } {
203        Ok(json) => parse_host_json(&json),
204        Err(e) => Err(format!("{}", e)),
205    }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct DirEntry {
210    pub name: String,
211    pub is_dir: bool,
212}
213
214/// Execute a shell command.
215pub fn exec_command(cmd: &str, args: &[&str]) -> Result<ExecResult, String> {
216    let input = serde_json::json!({
217        "command": cmd,
218        "args": args,
219    })
220    .to_string();
221    match unsafe { host_exec(input) } {
222        Ok(json) => parse_host_json(&json),
223        Err(e) => Err(format!("{}", e)),
224    }
225}
226
227#[derive(Debug, Clone, Default, Serialize, Deserialize)]
228pub struct ExecAdvancedOptions {
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub stdin: Option<String>,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub stdin_base64: Option<String>,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub workdir: Option<String>,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub timeout_ms: Option<u64>,
237    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
238    pub env: HashMap<String, String>,
239}
240
241pub fn exec_command_advanced_with_options(
242    cmd: &str,
243    args: &[&str],
244    options: &ExecAdvancedOptions,
245) -> Result<ExecResult, String> {
246    let input = serde_json::json!({
247        "command": cmd,
248        "args": args,
249        "stdin": options.stdin,
250        "stdin_base64": options.stdin_base64,
251        "workdir": options.workdir,
252        "timeout_ms": options.timeout_ms,
253        "env": options.env,
254    })
255    .to_string();
256
257    match unsafe { host_exec_advanced(input) } {
258        Ok(json) => parse_host_json(&json),
259        Err(e) => Err(format!("{}", e)),
260    }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct ExecResult {
265    pub status: i32,
266    pub stdout: String,
267    pub stderr: String,
268    #[serde(default)]
269    pub stdout_base64: Option<String>,
270    #[serde(default)]
271    pub stderr_base64: Option<String>,
272}
273
274impl ExecResult {
275    pub fn stdout_text(&self) -> String {
276        decode_exec_base64_text(&self.stdout_base64).unwrap_or_else(|| self.stdout.clone())
277    }
278
279    pub fn stderr_text(&self) -> String {
280        decode_exec_base64_text(&self.stderr_base64).unwrap_or_else(|| self.stderr.clone())
281    }
282}
283
284#[derive(Debug, Clone, Serialize)]
285struct HostChatCompletionInput<'a> {
286    request_label: &'a str,
287    endpoint: &'a str,
288    body: &'a str,
289}
290
291/// Issue an arbitrary HTTP request through the host (supports custom headers,
292/// e.g. Authorization for external model APIs). Returns the response body on 2xx.
293pub fn http_request(
294    method: &str,
295    url: &str,
296    headers: &[(&str, &str)],
297    body: &str,
298) -> Result<String, String> {
299    let header_map: std::collections::HashMap<&str, &str> = headers.iter().copied().collect();
300    let input = serde_json::json!({
301        "method": method,
302        "url": url,
303        "headers": header_map,
304        "body": body,
305    })
306    .to_string();
307
308    let raw = unsafe { host_http_request(input) }
309        .map_err(|e| format!("host_http_request failed: {e}"))?;
310    let parsed: serde_json::Value = serde_json::from_str(&raw)
311        .map_err(|e| format!("host_http_request returned invalid json: {e}"))?;
312    if let Some(err) = parsed.get("error").and_then(|v| v.as_str()) {
313        if !err.trim().is_empty() {
314            return Err(err.to_string());
315        }
316    }
317    let status = parsed.get("status").and_then(|v| v.as_u64()).unwrap_or(0);
318    let resp_body = parsed
319        .get("body")
320        .and_then(|v| v.as_str())
321        .unwrap_or("")
322        .to_string();
323    if (200..300).contains(&status) {
324        Ok(resp_body)
325    } else {
326        Err(format!("http status {status}: {resp_body}"))
327    }
328}
329
330pub fn chat_completion(request_label: &str, endpoint: &str, body: &str) -> Result<String, String> {
331    let input = serde_json::to_string(&HostChatCompletionInput {
332        request_label,
333        endpoint,
334        body,
335    })
336    .map_err(|error| format!("failed to serialize host chat completion input: {}", error))?;
337
338    match unsafe { host_chat_completion(input) } {
339        Ok(result) => {
340            if let Ok(error) = serde_json::from_str::<HostErrorResult>(&result) {
341                if !error.error.trim().is_empty() {
342                    return Err(error.error);
343                }
344            }
345            Ok(result)
346        }
347        Err(e) => Err(format!("{}", e)),
348    }
349}
350
351#[derive(Debug, Clone, Serialize)]
352struct HostChatCompletionStreamInput<'a> {
353    request_label: &'a str,
354    body: &'a str,
355    session_id: &'a str,
356}
357
358pub fn chat_completion_stream(
359    request_label: &str,
360    _endpoint: &str,
361    body: &str,
362    session_id: &str,
363) -> Result<String, String> {
364    let input = serde_json::to_string(&HostChatCompletionStreamInput {
365        request_label,
366        body,
367        session_id,
368    })
369    .map_err(|e| format!("failed to serialize stream input: {}", e))?;
370
371    match unsafe { host_chat_completion_stream(input) } {
372        Ok(result) => {
373            if let Ok(error) = serde_json::from_str::<HostErrorResult>(&result) {
374                if !error.error.trim().is_empty() {
375                    return Err(error.error);
376                }
377            }
378            Ok(result)
379        }
380        Err(e) => Err(format!("{}", e)),
381    }
382}
383
384/// Call another package exported function.
385pub fn call_package(package: &str, func: &str, args: &str) -> Result<String, String> {
386    let input = serde_json::json!({
387        "package": package,
388        "func": func,
389        "args": args,
390    })
391    .to_string();
392    match unsafe { host_call_package(input) } {
393        Ok(result) => {
394            if result.contains(r#""error""#) {
395                Err(result)
396            } else {
397                Ok(result)
398            }
399        }
400        Err(e) => Err(format!("{}", e)),
401    }
402}
403
404pub fn call_package_ws_action(
405    package: &str,
406    action: &str,
407    data: &serde_json::Value,
408) -> Result<String, String> {
409    let input = serde_json::json!({
410        "package": package,
411        "action": action,
412        "data": data,
413    })
414    .to_string();
415    match unsafe { host_call_package_ws(input) } {
416        Ok(result) => Ok(result),
417        Err(e) => Err(format!("{}", e)),
418    }
419}
420
421pub fn call_capability_action(
422    capability: &str,
423    action: &str,
424    data: &serde_json::Value,
425) -> Result<String, String> {
426    let input = serde_json::json!({
427        "capability": capability,
428        "action": action,
429        "data": data,
430    })
431    .to_string();
432    match unsafe { host_capability_call(input) } {
433        Ok(result) => Ok(result),
434        Err(e) => Err(format!("{}", e)),
435    }
436}
437
438/// Spawn a managed process.
439pub fn process_spawn(config_json: &str) -> Result<String, String> {
440    match unsafe { host_process_spawn(config_json.to_string()) } {
441        Ok(r) => Ok(r),
442        Err(e) => Err(format!("{}", e)),
443    }
444}
445
446/// Stop a managed process.
447pub fn process_stop(name: &str) -> Result<String, String> {
448    match unsafe { host_process_stop(name.to_string()) } {
449        Ok(r) => Ok(r),
450        Err(e) => Err(format!("{}", e)),
451    }
452}
453
454/// Get status of a managed process.
455pub fn process_status(name: &str) -> Result<String, String> {
456    match unsafe { host_process_status(name.to_string()) } {
457        Ok(r) => Ok(r),
458        Err(e) => Err(format!("{}", e)),
459    }
460}
461
462pub fn process_write_stdin(name: &str, input: &str) -> Result<String, String> {
463    let payload = serde_json::json!({
464        "name": name,
465        "input": input,
466    })
467    .to_string();
468    match unsafe { host_process_write_stdin(payload) } {
469        Ok(r) => Ok(r),
470        Err(e) => Err(format!("{}", e)),
471    }
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct ProcessStdoutRead {
476    pub status: String,
477    pub name: String,
478    pub next_offset: usize,
479    pub chunk: Vec<u8>,
480}
481
482pub fn process_read_stdout(name: &str, offset: usize) -> Result<ProcessStdoutRead, String> {
483    let payload = serde_json::json!({
484        "name": name,
485        "offset": offset,
486    })
487    .to_string();
488    match unsafe { host_process_read_stdout(payload) } {
489        Ok(json) => parse_host_json(&json),
490        Err(e) => Err(format!("{}", e)),
491    }
492}
493
494pub fn now_ms() -> u64 {
495    unsafe { host_now_ms(String::new()) }
496        .ok()
497        .and_then(|s| s.trim().parse::<u64>().ok())
498        .unwrap_or(0)
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct SqliteQueryResult {
503    pub columns: Vec<String>,
504    pub rows: Vec<Vec<serde_json::Value>>,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct SqliteExecResult {
509    pub rows_affected: u64,
510}
511
512pub fn sqlite_query(
513    path: &str,
514    sql: &str,
515    params: &[serde_json::Value],
516) -> Result<SqliteQueryResult, String> {
517    let input = serde_json::json!({
518        "path": path,
519        "sql": sql,
520        "params": params,
521    })
522    .to_string();
523    match unsafe { host_sqlite_query(input) } {
524        Ok(json) => parse_host_json(&json),
525        Err(e) => Err(format!("{}", e)),
526    }
527}
528
529pub fn sqlite_execute(
530    path: &str,
531    sql: &str,
532    params: &[serde_json::Value],
533) -> Result<SqliteExecResult, String> {
534    let input = serde_json::json!({
535        "path": path,
536        "sql": sql,
537        "params": params,
538    })
539    .to_string();
540    match unsafe { host_sqlite_execute(input) } {
541        Ok(json) => parse_host_json(&json),
542        Err(e) => Err(format!("{}", e)),
543    }
544}
545
546pub fn sqlite_batch(
547    path: &str,
548    statements: &[(String, Vec<serde_json::Value>)],
549) -> Result<SqliteExecResult, String> {
550    let payload: Vec<serde_json::Value> = statements
551        .iter()
552        .map(|(sql, params)| serde_json::json!({ "sql": sql, "params": params }))
553        .collect();
554    let input = serde_json::json!({
555        "path": path,
556        "statements": payload,
557    })
558    .to_string();
559    match unsafe { host_sqlite_batch(input) } {
560        Ok(json) => parse_host_json(&json),
561        Err(e) => Err(format!("{}", e)),
562    }
563}
564
565#[derive(Debug, Deserialize)]
566struct HostErrorResult {
567    error: String,
568}
569
570fn parse_host_json<T>(json: &str) -> Result<T, String>
571where
572    T: DeserializeOwned,
573{
574    match serde_json::from_str::<T>(json) {
575        Ok(value) => Ok(value),
576        Err(parse_error) => {
577            if let Ok(error) = serde_json::from_str::<HostErrorResult>(json) {
578                if !error.error.is_empty() {
579                    return Err(error.error);
580                }
581            }
582            Err(format!("parse error: {}", parse_error))
583        }
584    }
585}
586
587fn decode_exec_base64_text(encoded: &Option<String>) -> Option<String> {
588    encoded
589        .as_deref()
590        .and_then(|value| base64::engine::general_purpose::STANDARD.decode(value).ok())
591        .and_then(|bytes| String::from_utf8(bytes).ok())
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn parse_host_json_returns_structured_value() {
600        let parsed = parse_host_json::<ExecResult>(r#"{"status":0,"stdout":"ok","stderr":""}"#)
601            .expect("expected exec result");
602
603        assert_eq!(parsed.status, 0);
604        assert_eq!(parsed.stdout, "ok");
605        assert_eq!(parsed.stderr, "");
606    }
607
608    #[test]
609    fn parse_host_json_surfaces_host_error_without_wrapper_parse_failure() {
610        let err = parse_host_json::<ExecResult>(
611            r#"{"error":"The system cannot find the path specified."}"#,
612        )
613        .expect_err("expected host error");
614
615        assert_eq!(err, "The system cannot find the path specified.");
616    }
617
618    #[test]
619    fn parse_host_json_accepts_optional_base64_exec_fields() {
620        let parsed = parse_host_json::<ExecResult>(
621            r#"{"status":0,"stdout":"fallback","stderr":"","stdout_base64":"aGVsbG8=","stderr_base64":null}"#,
622        )
623        .expect("expected exec result with base64 fields");
624
625        assert_eq!(parsed.status, 0);
626        assert_eq!(parsed.stdout, "fallback");
627        assert_eq!(parsed.stdout_base64.as_deref(), Some("aGVsbG8="));
628        assert_eq!(parsed.stdout_text(), "hello");
629        assert_eq!(parsed.stderr_text(), "");
630    }
631
632    #[test]
633    fn exec_text_helpers_fall_back_to_plain_text() {
634        let parsed = ExecResult {
635            status: 0,
636            stdout: "plain stdout".into(),
637            stderr: "plain stderr".into(),
638            stdout_base64: None,
639            stderr_base64: Some("%%%invalid%%%".into()),
640        };
641
642        assert_eq!(parsed.stdout_text(), "plain stdout");
643        assert_eq!(parsed.stderr_text(), "plain stderr");
644    }
645}