diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml new file mode 100644 index 0000000..a9641d0 --- /dev/null +++ b/.github/workflows/rust-tests.yml @@ -0,0 +1,41 @@ +name: Rust Tests + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: rust-tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: fmt, clippy and tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install stable Rust toolchain + uses: dtolnay/rust-toolchain@18f0487477426dce244608110aa2660c2893deb7 # stable + with: + toolchain: stable + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Run tests + run: cargo test --workspace + + - name: Build examples + run: cargo build --workspace --examples diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 9fdfdbc..adb6603 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -20,12 +20,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install stable Rust toolchain - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + uses: dtolnay/rust-toolchain@18f0487477426dce244608110aa2660c2893deb7 # stable with: toolchain: stable diff --git a/CHANGELOG.md b/CHANGELOG.md index e40bcf7..a58d2d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # Changelog +## 2026-11-03 — v0.3.0 + +### Added + +- `xfetch-api-common` 0.3.0: shared JSON protocol constants, frame shape, + timeout helper and stdin/stdout JSON helpers. It carries no per-kind + request/response types; plugins, effects and extensions keep their own + contracts. +- `xfetch-extension-api`: `io` module, standard entrypoints + (`read_config_provider_request`, `read_config_provider_args_or_default`, + `write_config_provider_response`, `write_config`), `ExtensionApiError`, + `ConfigProviderRequest::validate()`, `kind_enum()`, `parse_args()` and + `parse_args_or_default()`. +- Effect and extension examples: `crates/effect-api/examples/simple-effect.rs` + and `crates/extension-api/examples/simple-extension.rs`. +- `rust-tests.yml`: fmt, clippy `-D warnings`, workspace tests and example + builds on push and pull request. + +### Changed + +- All SDK crates bumped to `0.3.0`. +- `with_timeout` and JSON IO now delegate to `xfetch-api-common`; the existing + public paths and error variants are preserved. +- `LogoAnimationRequest` and `EffectRequest` tolerate a missing optional + `args` field through `serde(default)`. +- `PluginApiError` and `EffectApiError` implement `From` + and `From`. +- `PROTOCOL_VERSION` and the kind constants now reference the shared + protocol module; values are unchanged (`1`, `logo_animation`, + `info_provider`, `effect`, `config_provider`). +- `xfetch-guest-api` uses `base64` 0.23. +- `security-audit.yml` uses `actions/checkout` 7.0.1 and the current stable + `dtolnay/rust-toolchain` pin. +- `AnimationFrame` and `EffectFrame` gained `From`/`Into` conversions to the + shared `Frame`; the existing types and field layout are unchanged. + +### Compatibility + +- JSON protocol remains version `1`. +- WIT remains `xfetch:runtime@0.1.0`. +- Existing public items, crate names and request/response shapes are + preserved; the release is additive. + ## 2026-10-24 — Dependency security automation - Added `.github/workflows/security-audit.yml`: runs `cargo audit` 0.22.2 with `--deny warnings` on push, pull request and a weekly schedule. The workflow generates a temporary lockfile before auditing because the workspace does not version one. diff --git a/Cargo.toml b/Cargo.toml index 763777a..ea2af52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/common", "crates/plugin-api", "crates/extension-api", "crates/effect-api", diff --git a/README.md b/README.md index f19812e..c5fd3e8 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,22 @@

- Today this repository contains the public plugin SDK used by the core, - official plugins, and third-party plugin authors. + The current release line is 0.3.0. It keeps the JSON + protocol at version 1 and the WIT at + xfetch:runtime@0.1.0; the changes are additive.

Current Scope

Main Crate

@@ -60,6 +63,8 @@
  • Getting Started
  • Plugin SDK
  • Protocol Reference
  • +
  • Extension SDK
  • +
  • Effect SDK
  • Examples
  • @@ -69,4 +74,5 @@
  • This repository owns shared contracts, not end-user config files.
  • Official plugins are expected to use the same public SDK as external plugins.
  • Protocol changes should land here first and then be adopted by the core and plugin repos.
  • +
  • Publishing order: xfetch-api-common first, then xfetch-plugin-api, xfetch-extension-api and xfetch-effect-api; xfetch-guest-api is independent.
  • diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml new file mode 100644 index 0000000..f12e530 --- /dev/null +++ b/crates/common/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "xfetch-api-common" +version = "0.3.0" +edition = "2024" +license = "MIT" +description = "Shared protocol, IO, frame and timeout primitives for the xfetch addon SDKs" +repository = "https://github.com/xfetch-cli/api" + +[dependencies] +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.148" diff --git a/crates/common/README.md b/crates/common/README.md new file mode 100644 index 0000000..96681f1 --- /dev/null +++ b/crates/common/README.md @@ -0,0 +1,14 @@ +# xfetch-api-common + +Shared primitives for the xfetch addon SDK crates: + +- JSON wire protocol version and kind constants; +- the common `Frame { delay_ms, lines }` shape; +- stdin/stdout JSON helpers returning `IoError`; +- `with_timeout` for native addons, with the host-enforced wasm behavior. + +This crate intentionally contains no per-kind request/response types. +Plugins, effects and extensions keep their own contracts in +`xfetch-plugin-api`, `xfetch-effect-api` and `xfetch-extension-api`. + +Published first, before the per-kind crates. diff --git a/crates/common/src/frame.rs b/crates/common/src/frame.rs new file mode 100644 index 0000000..e98f8c3 --- /dev/null +++ b/crates/common/src/frame.rs @@ -0,0 +1,34 @@ +//! Shared frame shape for logo animations and effects. +//! +//! The per-kind SDKs keep their public `AnimationFrame`/`EffectFrame` names; +//! this type is the common representation used by shared tooling and +//! conversions. + +use serde::{Deserialize, Serialize}; + +/// One rendered frame: how long to hold it and the lines to print. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Frame { + pub delay_ms: u64, + pub lines: Vec, +} + +impl Frame { + pub fn new(delay_ms: u64, lines: Vec) -> Self { + Self { delay_ms, lines } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_roundtrips_as_json() { + let frame = Frame::new(16, vec!["one".to_string(), "two".to_string()]); + let encoded = serde_json::to_string(&frame).expect("serialize"); + let decoded: Frame = serde_json::from_str(&encoded).expect("deserialize"); + + assert_eq!(decoded, frame); + } +} diff --git a/crates/common/src/io.rs b/crates/common/src/io.rs new file mode 100644 index 0000000..431f676 --- /dev/null +++ b/crates/common/src/io.rs @@ -0,0 +1,113 @@ +//! JSON stdin/stdout helpers shared by the addon SDKs. +//! +//! The per-kind crates keep their public `io` modules and error types; they +//! delegate the actual parsing and IO here and map [`IoError`] into their own +//! error enums. + +use std::fmt; +use std::io::{self, Read, Write}; + +use serde::Serialize; +use serde::de::DeserializeOwned; + +/// Failure while reading, parsing or writing a JSON protocol message. +#[derive(Debug)] +pub enum IoError { + Io(io::Error), + Serialize(serde_json::Error), + Deserialize(serde_json::Error), +} + +impl fmt::Display for IoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "I/O error: {}", err), + Self::Serialize(err) => write!(f, "Failed to serialize JSON: {}", err), + Self::Deserialize(err) => write!(f, "Failed to parse JSON: {}", err), + } + } +} + +impl std::error::Error for IoError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + Self::Serialize(err) | Self::Deserialize(err) => Some(err), + } + } +} + +impl From for IoError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +impl From for IoError { + fn from(value: serde_json::Error) -> Self { + Self::Deserialize(value) + } +} + +pub fn parse_json_str(input: &str) -> Result { + serde_json::from_str(input).map_err(IoError::Deserialize) +} + +pub fn parse_json_slice(input: &[u8]) -> Result { + serde_json::from_slice(input).map_err(IoError::Deserialize) +} + +pub fn to_json_vec(value: &T) -> Result, IoError> { + serde_json::to_vec(value).map_err(IoError::Serialize) +} + +pub fn read_json_from_stdin() -> Result { + let mut input = String::new(); + io::stdin().read_to_string(&mut input)?; + parse_json_str(&input) +} + +pub fn write_json_to_stdout(value: &T) -> Result<(), IoError> { + let body = to_json_vec(value)?; + let mut stdout = io::stdout(); + stdout.write_all(&body)?; + stdout.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] + struct Payload { + value: u32, + } + + #[test] + fn parses_from_str_and_slice() { + assert_eq!( + parse_json_str::(r#"{"value":7}"#).expect("str"), + Payload { value: 7 } + ); + assert_eq!( + parse_json_slice::(br#"{"value":7}"#).expect("slice"), + Payload { value: 7 } + ); + } + + #[test] + fn serializes_to_vec() { + let encoded = to_json_vec(&Payload { value: 7 }).expect("serialize"); + assert_eq!(encoded, br#"{"value":7}"#); + } + + #[test] + fn invalid_json_is_a_deserialize_error() { + assert!(matches!( + parse_json_str::("{"), + Err(IoError::Deserialize(_)) + )); + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs new file mode 100644 index 0000000..1a4f9ff --- /dev/null +++ b/crates/common/src/lib.rs @@ -0,0 +1,15 @@ +//! Shared primitives for the xfetch addon SDK crates. +//! +//! This crate deliberately contains no per-kind protocol types. Plugins, +//! effects and extensions keep their own request/response contracts; only the +//! wire constants, JSON IO, frame shape and timeout helper are shared. + +pub mod frame; +pub mod io; +pub mod protocol; +pub mod timeout; + +pub use frame::Frame; +pub use io::IoError; +pub use protocol::{JSON_PROTOCOL_VERSION, is_current_version}; +pub use timeout::{TimedOut, with_timeout}; diff --git a/crates/common/src/protocol.rs b/crates/common/src/protocol.rs new file mode 100644 index 0000000..27ab57c --- /dev/null +++ b/crates/common/src/protocol.rs @@ -0,0 +1,34 @@ +//! Wire-level constants shared by every xfetch addon kind. + +/// JSON protocol version used by native addons and WebAssembly core modules. +pub const JSON_PROTOCOL_VERSION: u32 = 1; + +/// Plugin kind: animated logo frames. +pub const KIND_LOGO_ANIMATION: &str = "logo_animation"; +/// Plugin kind: additional information lines. +pub const KIND_INFO_PROVIDER: &str = "info_provider"; +/// Effect kind: intro animation over the rendered lines. +pub const KIND_EFFECT: &str = "effect"; +/// Extension kind: config-provider hook. +pub const KIND_CONFIG_PROVIDER: &str = "config_provider"; + +/// Returns true when `version` is the current JSON protocol version. +pub const fn is_current_version(version: u32) -> bool { + version == JSON_PROTOCOL_VERSION +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_version_is_accepted() { + assert!(is_current_version(JSON_PROTOCOL_VERSION)); + } + + #[test] + fn other_versions_are_rejected() { + assert!(!is_current_version(JSON_PROTOCOL_VERSION + 1)); + assert!(!is_current_version(0)); + } +} diff --git a/crates/common/src/timeout.rs b/crates/common/src/timeout.rs new file mode 100644 index 0000000..1f62d87 --- /dev/null +++ b/crates/common/src/timeout.rs @@ -0,0 +1,60 @@ +//! Runtime budgets for addons. +//! +//! Native addons declare their own budget in code; the helper runs the task on +//! a worker thread and returns [`TimedOut`] when the budget elapses. Wasm +//! guests run single-threaded, so the host enforces the same deadline through +//! wasmtime epoch interruption. + +#[cfg(not(target_arch = "wasm32"))] +use std::sync::mpsc; +#[cfg(not(target_arch = "wasm32"))] +use std::thread; +use std::time::Duration; + +/// The task did not finish within its budget. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TimedOut; + +#[cfg(not(target_arch = "wasm32"))] +pub fn with_timeout( + budget: Duration, + task: impl FnOnce() -> T + Send + 'static, +) -> Result { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let _ = tx.send(task()); + }); + rx.recv_timeout(budget).map_err(|_| TimedOut) +} + +#[cfg(target_arch = "wasm32")] +pub fn with_timeout(_budget: Duration, task: impl FnOnce() -> T) -> Result { + Ok(task()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn task_completing_within_budget_returns_value() { + assert_eq!(with_timeout(Duration::from_secs(5), || 42u32), Ok(42)); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn slow_task_times_out() { + let started = std::time::Instant::now(); + let result = with_timeout(Duration::from_millis(50), || { + std::thread::sleep(Duration::from_secs(5)); + 1u32 + }); + + assert_eq!(result, Err(TimedOut)); + assert!( + started.elapsed() < Duration::from_secs(2), + "timeout must fire promptly, took {:?}", + started.elapsed() + ); + } +} diff --git a/crates/effect-api/Cargo.toml b/crates/effect-api/Cargo.toml index e9e6e5e..0e527d8 100644 --- a/crates/effect-api/Cargo.toml +++ b/crates/effect-api/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "xfetch-effect-api" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "MIT" description = "Shared effect protocol types and IO helpers for xfetch" repository = "https://github.com/xfetch-cli/api" [dependencies] +xfetch-api-common = { version = "0.3.0", path = "../common" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" diff --git a/crates/effect-api/examples/simple-effect.rs b/crates/effect-api/examples/simple-effect.rs new file mode 100644 index 0000000..d0879d8 --- /dev/null +++ b/crates/effect-api/examples/simple-effect.rs @@ -0,0 +1,10 @@ +//! Minimal effect: return the original lines as a single frame. + +use xfetch_effect_api::{EffectFrame, read_effect_request, write_effect_frames}; + +fn main() -> Result<(), Box> { + let request = read_effect_request()?; + let frame = EffectFrame::new(0, request.lines); + write_effect_frames(vec![frame])?; + Ok(()) +} diff --git a/crates/effect-api/src/error.rs b/crates/effect-api/src/error.rs index 32658cb..d83e6da 100644 --- a/crates/effect-api/src/error.rs +++ b/crates/effect-api/src/error.rs @@ -58,3 +58,19 @@ impl From for EffectApiError { Self::Io(value) } } + +impl From for EffectApiError { + fn from(value: serde_json::Error) -> Self { + Self::Deserialize(value) + } +} + +impl From for EffectApiError { + fn from(value: xfetch_api_common::IoError) -> Self { + match value { + xfetch_api_common::IoError::Io(err) => Self::Io(err), + xfetch_api_common::IoError::Serialize(err) => Self::Serialize(err), + xfetch_api_common::IoError::Deserialize(err) => Self::Deserialize(err), + } + } +} diff --git a/crates/effect-api/src/io.rs b/crates/effect-api/src/io.rs index 97fc925..08c3aca 100644 --- a/crates/effect-api/src/io.rs +++ b/crates/effect-api/src/io.rs @@ -1,32 +1,29 @@ -use crate::error::{EffectApiError, Result}; +//! JSON stdin/stdout helpers for effects. +//! +//! The actual implementation lives in `xfetch-api-common`; the public +//! signatures and [`EffectApiError`] mapping are preserved here. + use serde::Serialize; use serde::de::DeserializeOwned; -use std::io::{self, Read, Write}; + +use crate::error::{EffectApiError, Result}; pub fn parse_json_str(input: &str) -> Result { - serde_json::from_str(input).map_err(EffectApiError::Deserialize) + xfetch_api_common::io::parse_json_str(input).map_err(EffectApiError::from) } pub fn parse_json_slice(input: &[u8]) -> Result { - serde_json::from_slice(input).map_err(EffectApiError::Deserialize) + xfetch_api_common::io::parse_json_slice(input).map_err(EffectApiError::from) } pub fn to_json_vec(value: &T) -> Result> { - serde_json::to_vec(value).map_err(EffectApiError::Serialize) + xfetch_api_common::io::to_json_vec(value).map_err(EffectApiError::from) } pub fn read_json_from_stdin() -> Result { - let mut input = String::new(); - io::stdin() - .read_to_string(&mut input) - .map_err(EffectApiError::Io)?; - - parse_json_str(&input) + xfetch_api_common::io::read_json_from_stdin().map_err(EffectApiError::from) } pub fn write_json_to_stdout(value: &T) -> Result<()> { - let body = to_json_vec(value)?; - let mut stdout = io::stdout(); - stdout.write_all(&body).map_err(EffectApiError::Io)?; - stdout.flush().map_err(EffectApiError::Io) + xfetch_api_common::io::write_json_to_stdout(value).map_err(EffectApiError::from) } diff --git a/crates/effect-api/src/lib.rs b/crates/effect-api/src/lib.rs index c72b976..d1c6864 100644 --- a/crates/effect-api/src/lib.rs +++ b/crates/effect-api/src/lib.rs @@ -17,6 +17,7 @@ pub use io::{ parse_json_slice, parse_json_str, read_json_from_stdin, to_json_vec, write_json_to_stdout, }; pub use protocol::{ - EffectArgs, EffectFrame, EffectRequest, EffectResponse, KIND_EFFECT, PROTOCOL_VERSION, + EffectArgs, EffectFrame, EffectKind, EffectRequest, EffectResponse, KIND_EFFECT, + PROTOCOL_VERSION, }; pub use timeout::{TimedOut, with_timeout}; diff --git a/crates/effect-api/src/protocol.rs b/crates/effect-api/src/protocol.rs index d094647..b555c5c 100644 --- a/crates/effect-api/src/protocol.rs +++ b/crates/effect-api/src/protocol.rs @@ -3,8 +3,23 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; -pub const PROTOCOL_VERSION: u32 = 1; -pub const KIND_EFFECT: &str = "effect"; +pub const PROTOCOL_VERSION: u32 = xfetch_api_common::protocol::JSON_PROTOCOL_VERSION; +pub const KIND_EFFECT: &str = xfetch_api_common::protocol::KIND_EFFECT; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EffectKind { + Effect, +} + +impl EffectKind { + pub const fn as_str(self) -> &'static str { + KIND_EFFECT + } + + pub fn from_wire(kind: &str) -> Option { + (kind == KIND_EFFECT).then_some(Self::Effect) + } +} /// Parameters an effect can tune. `args` carries effect-specific free-form /// parameters (parsed by the effect via `EffectArgs::parse_args`). @@ -46,6 +61,18 @@ impl EffectFrame { } } +impl From for xfetch_api_common::Frame { + fn from(value: EffectFrame) -> Self { + Self::new(value.delay_ms, value.lines) + } +} + +impl From for EffectFrame { + fn from(value: xfetch_api_common::Frame) -> Self { + Self::new(value.delay_ms, value.lines) + } +} + /// The content lines handed to the effect, as rendered by the core (icon + /// value per line). The effect transforms them into per-frame states; the last /// frame should reach the original (final) content. @@ -54,6 +81,7 @@ pub struct EffectRequest { pub version: u32, pub kind: String, pub lines: Vec, + #[serde(default)] pub args: EffectArgs, } @@ -83,6 +111,10 @@ impl EffectRequest { Ok(()) } + + pub fn kind_enum(&self) -> Option { + EffectKind::from_wire(&self.kind) + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -160,4 +192,22 @@ mod tests { }) ); } + + #[test] + fn request_allows_missing_args() { + let json = r#"{"version":1,"kind":"effect","lines":["x"]}"#; + let request: EffectRequest = serde_json::from_str(json).expect("parse request"); + + assert_eq!(request.args, EffectArgs::default()); + assert_eq!(request.kind_enum(), Some(EffectKind::Effect)); + } + + #[test] + fn effect_frame_converts_to_shared_frame() { + let frame = EffectFrame::new(16, vec!["line".to_string()]); + let shared: xfetch_api_common::Frame = frame.clone().into(); + let roundtrip: EffectFrame = shared.into(); + + assert_eq!(roundtrip, frame); + } } diff --git a/crates/effect-api/src/timeout.rs b/crates/effect-api/src/timeout.rs index 255e882..4146312 100644 --- a/crates/effect-api/src/timeout.rs +++ b/crates/effect-api/src/timeout.rs @@ -1,78 +1,17 @@ //! Timeout helpers for effects. //! -//! Effects can be computationally heavy or slow (network fetch, large -//! terminal). Each effect declares its own budget in its code instead of the -//! core guessing. `with_timeout` runs the task on a worker thread and returns -//! `Err(TimedOut)` when the budget elapses; the effect can then respond with a -//! fallback (e.g. the unmodified lines) and exit normally. +//! The implementation is shared with plugins and extensions through +//! `xfetch-api-common`; this module preserves the existing public path. -#[cfg(not(target_arch = "wasm32"))] -use std::sync::mpsc; -#[cfg(not(target_arch = "wasm32"))] -use std::thread; -use std::time::Duration; - -/// The task did not finish within its budget. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TimedOut; - -/// Runs `task` on a worker thread, waiting at most `budget`. -/// -/// Returns `Ok(task())` when the task finishes in time, `Err(TimedOut)` -/// otherwise. The worker thread keeps running until the process exits (which -/// is immediate: the caller responds and the effect terminates). -/// -/// # Examples -/// -/// ``` -/// use std::time::Duration; -/// use xfetch_effect_api::with_timeout; -/// -/// let result = with_timeout(Duration::from_secs(1), || 2 + 2); -/// assert_eq!(result, Ok(4)); -/// ``` -#[cfg(not(target_arch = "wasm32"))] -pub fn with_timeout( - budget: Duration, - task: impl FnOnce() -> T + Send + 'static, -) -> Result { - let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - let _ = tx.send(task()); - }); - rx.recv_timeout(budget).map_err(|_| TimedOut) -} - -/// Wasm guests run single-threaded, so no worker thread is started: the host -/// enforces the effects invocation deadline through epoch interruption and -/// traps the guest when the budget is exhausted. -#[cfg(target_arch = "wasm32")] -pub fn with_timeout(_budget: Duration, task: impl FnOnce() -> T) -> Result { - Ok(task()) -} +pub use xfetch_api_common::timeout::{TimedOut, with_timeout}; #[cfg(test)] mod tests { use super::*; + use std::time::Duration; #[test] - fn task_completing_within_budget_returns_value() { - let result = with_timeout(Duration::from_secs(5), || 42u32); - assert_eq!(result, Ok(42)); - } - - #[test] - fn slow_task_times_out() { - let started = std::time::Instant::now(); - let result = with_timeout(Duration::from_millis(50), || { - std::thread::sleep(Duration::from_secs(5)); - 1u32 - }); - assert_eq!(result, Err(TimedOut)); - assert!( - started.elapsed() < Duration::from_secs(2), - "timeout must fire promptly, took {:?}", - started.elapsed() - ); + fn reexported_helper_runs_tasks() { + assert_eq!(with_timeout(Duration::from_secs(1), || 2 + 2), Ok(4)); } } diff --git a/crates/extension-api/Cargo.toml b/crates/extension-api/Cargo.toml index 8d1f5d7..9208dc7 100644 --- a/crates/extension-api/Cargo.toml +++ b/crates/extension-api/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "xfetch-extension-api" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "MIT" description = "Shared extension protocol types and IO helpers for xfetch" repository = "https://github.com/xfetch-cli/api" [dependencies] +xfetch-api-common = { version = "0.3.0", path = "../common" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" diff --git a/crates/extension-api/examples/simple-extension.rs b/crates/extension-api/examples/simple-extension.rs new file mode 100644 index 0000000..95e89af --- /dev/null +++ b/crates/extension-api/examples/simple-extension.rs @@ -0,0 +1,15 @@ +//! Minimal config provider: mark the config and return it unchanged otherwise. + +use xfetch_extension_api::{read_config_provider_request, write_config}; + +fn main() -> Result<(), Box> { + let request = read_config_provider_request()?; + let mut config = request.config; + + if let Some(object) = config.as_object_mut() { + object.insert("example_extension".to_string(), serde_json::json!(true)); + } + + write_config(config)?; + Ok(()) +} diff --git a/crates/extension-api/src/entrypoints.rs b/crates/extension-api/src/entrypoints.rs new file mode 100644 index 0000000..4bb90d7 --- /dev/null +++ b/crates/extension-api/src/entrypoints.rs @@ -0,0 +1,34 @@ +//! Standard entrypoints for config-provider extensions. + +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::error::Result; +use crate::io::{read_json_from_stdin, write_json_to_stdout}; +use crate::types::{ConfigProviderRequest, ConfigProviderResponse}; + +/// Reads and validates a [`ConfigProviderRequest`] from stdin. +pub fn read_config_provider_request() -> Result { + let request: ConfigProviderRequest = read_json_from_stdin()?; + request.validate()?; + Ok(request) +} + +/// Reads a request and returns its typed `args`, or `T::default()` when the +/// core sent no arguments. +pub fn read_config_provider_args_or_default() -> Result +where + T: DeserializeOwned + Default, +{ + read_config_provider_request()?.parse_args_or_default() +} + +/// Writes a [`ConfigProviderResponse`] to stdout. +pub fn write_config_provider_response(response: &ConfigProviderResponse) -> Result<()> { + write_json_to_stdout(response) +} + +/// Convenience wrapper for the common case: return an already-built config. +pub fn write_config(config: Value) -> Result<()> { + write_config_provider_response(&ConfigProviderResponse::new(config)) +} diff --git a/crates/extension-api/src/error.rs b/crates/extension-api/src/error.rs new file mode 100644 index 0000000..ffd1ca5 --- /dev/null +++ b/crates/extension-api/src/error.rs @@ -0,0 +1,81 @@ +//! Errors returned by extension helpers. + +use std::fmt; + +use crate::types::ExtensionKind; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum ExtensionApiError { + Io(std::io::Error), + Serialize(serde_json::Error), + Deserialize(serde_json::Error), + InvalidProtocolVersion { + expected: u32, + found: u32, + }, + InvalidExtensionKind { + expected: ExtensionKind, + found: String, + }, + InvalidArgs(serde_json::Error), +} + +impl fmt::Display for ExtensionApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "I/O error: {}", err), + Self::Serialize(err) => write!(f, "Failed to serialize JSON: {}", err), + Self::Deserialize(err) => write!(f, "Failed to parse JSON: {}", err), + Self::InvalidProtocolVersion { expected, found } => { + write!( + f, + "Unsupported protocol version: expected {}, found {}", + expected, found + ) + } + Self::InvalidExtensionKind { expected, found } => { + write!( + f, + "Unexpected extension kind: expected '{}', found '{}'", + expected.as_str(), + found + ) + } + Self::InvalidArgs(err) => write!(f, "Failed to parse extension args: {}", err), + } + } +} + +impl std::error::Error for ExtensionApiError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + Self::Serialize(err) | Self::Deserialize(err) | Self::InvalidArgs(err) => Some(err), + Self::InvalidProtocolVersion { .. } | Self::InvalidExtensionKind { .. } => None, + } + } +} + +impl From for ExtensionApiError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for ExtensionApiError { + fn from(value: serde_json::Error) -> Self { + Self::Deserialize(value) + } +} + +impl From for ExtensionApiError { + fn from(value: xfetch_api_common::IoError) -> Self { + match value { + xfetch_api_common::IoError::Io(err) => Self::Io(err), + xfetch_api_common::IoError::Serialize(err) => Self::Serialize(err), + xfetch_api_common::IoError::Deserialize(err) => Self::Deserialize(err), + } + } +} diff --git a/crates/extension-api/src/io.rs b/crates/extension-api/src/io.rs new file mode 100644 index 0000000..afe9727 --- /dev/null +++ b/crates/extension-api/src/io.rs @@ -0,0 +1,29 @@ +//! JSON stdin/stdout helpers for extensions. +//! +//! The actual implementation lives in `xfetch-api-common`; the public +//! signatures and [`ExtensionApiError`] mapping are defined here. + +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::error::{ExtensionApiError, Result}; + +pub fn parse_json_str(input: &str) -> Result { + xfetch_api_common::io::parse_json_str(input).map_err(ExtensionApiError::from) +} + +pub fn parse_json_slice(input: &[u8]) -> Result { + xfetch_api_common::io::parse_json_slice(input).map_err(ExtensionApiError::from) +} + +pub fn to_json_vec(value: &T) -> Result> { + xfetch_api_common::io::to_json_vec(value).map_err(ExtensionApiError::from) +} + +pub fn read_json_from_stdin() -> Result { + xfetch_api_common::io::read_json_from_stdin().map_err(ExtensionApiError::from) +} + +pub fn write_json_to_stdout(value: &T) -> Result<()> { + xfetch_api_common::io::write_json_to_stdout(value).map_err(ExtensionApiError::from) +} diff --git a/crates/extension-api/src/lib.rs b/crates/extension-api/src/lib.rs index 67123fc..147f5a8 100644 --- a/crates/extension-api/src/lib.rs +++ b/crates/extension-api/src/lib.rs @@ -1,12 +1,24 @@ -//! Protocol types for xfetch extensions (config providers, lifecycle hooks, etc). +//! Protocol types and helpers for xfetch extensions (config providers). //! -//! Extensions are standalone binaries that communicate with xfetch via -//! a stdin/stdout JSON protocol, separate from the plugin system. +//! Extensions are standalone binaries that communicate with xfetch through a +//! JSON stdin/stdout protocol, separate from the plugin and effect systems. +pub mod entrypoints; +pub mod error; +pub mod io; pub mod timeout; pub mod types; +pub use entrypoints::{ + read_config_provider_args_or_default, read_config_provider_request, write_config, + write_config_provider_response, +}; +pub use error::{ExtensionApiError, Result}; +pub use io::{ + parse_json_slice, parse_json_str, read_json_from_stdin, to_json_vec, write_json_to_stdout, +}; pub use timeout::{TimedOut, with_timeout}; pub use types::{ - ConfigProviderRequest, ConfigProviderResponse, ExtensionKind, KIND_CONFIG_PROVIDER, + ConfigProviderRequest, ConfigProviderResponse, EXTENSION_PROTOCOL_VERSION, ExtensionKind, + KIND_CONFIG_PROVIDER, PROTOCOL_VERSION, }; diff --git a/crates/extension-api/src/timeout.rs b/crates/extension-api/src/timeout.rs index ea3afb8..2dcf8e3 100644 --- a/crates/extension-api/src/timeout.rs +++ b/crates/extension-api/src/timeout.rs @@ -1,67 +1,17 @@ //! Timeout helpers for extensions. //! -//! Extensions have variable runtimes (config providers may hit the network -//! or do heavy work), so each extension declares its own budget in its code. -//! `with_timeout` runs the task on a worker thread and returns `Err(TimedOut)` -//! when the budget elapses; the extension can then respond with a fallback -//! config and exit normally, so the core's `wait_with_output` always returns. +//! The implementation is shared with plugins and effects through +//! `xfetch-api-common`; this module preserves the existing public path. -#[cfg(not(target_arch = "wasm32"))] -use std::sync::mpsc; -#[cfg(not(target_arch = "wasm32"))] -use std::thread; -use std::time::Duration; - -/// The task did not finish within its budget. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TimedOut; - -/// Runs `task` on a worker thread, waiting at most `budget`. -/// -/// Returns `Ok(task())` when the task finishes in time, `Err(TimedOut)` -/// otherwise. The worker thread keeps running until the process exits. -#[cfg(not(target_arch = "wasm32"))] -pub fn with_timeout( - budget: Duration, - task: impl FnOnce() -> T + Send + 'static, -) -> Result { - let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - let _ = tx.send(task()); - }); - rx.recv_timeout(budget).map_err(|_| TimedOut) -} - -/// Wasm guests run single-threaded, so no worker thread is started: the host -/// enforces the extensions invocation deadline through epoch interruption and -/// traps the guest when the budget is exhausted. -#[cfg(target_arch = "wasm32")] -pub fn with_timeout(_budget: Duration, task: impl FnOnce() -> T) -> Result { - Ok(task()) -} +pub use xfetch_api_common::timeout::{TimedOut, with_timeout}; #[cfg(test)] mod tests { use super::*; + use std::time::Duration; #[test] - fn task_completing_within_budget_returns_value() { - let result = with_timeout(Duration::from_secs(5), || 42u32); - assert_eq!(result, Ok(42)); - } - - #[test] - fn slow_task_times_out() { - let started = std::time::Instant::now(); - let result = with_timeout(Duration::from_millis(50), || { - std::thread::sleep(Duration::from_secs(5)); - 1u32 - }); - assert_eq!(result, Err(TimedOut)); - assert!( - started.elapsed() < Duration::from_secs(2), - "timeout must fire promptly, took {:?}", - started.elapsed() - ); + fn reexported_helper_runs_tasks() { + assert_eq!(with_timeout(Duration::from_secs(1), || 2 + 2), Ok(4)); } } diff --git a/crates/extension-api/src/types.rs b/crates/extension-api/src/types.rs index c12d652..e94c0d8 100644 --- a/crates/extension-api/src/types.rs +++ b/crates/extension-api/src/types.rs @@ -1,8 +1,12 @@ +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; -pub const EXTENSION_PROTOCOL_VERSION: u32 = 1; -pub const KIND_CONFIG_PROVIDER: &str = "config_provider"; +use crate::error::{ExtensionApiError, Result}; + +pub const PROTOCOL_VERSION: u32 = xfetch_api_common::protocol::JSON_PROTOCOL_VERSION; +pub const EXTENSION_PROTOCOL_VERSION: u32 = PROTOCOL_VERSION; +pub const KIND_CONFIG_PROVIDER: &str = xfetch_api_common::protocol::KIND_CONFIG_PROVIDER; /// Known extension kinds that xfetch can dispatch. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -27,8 +31,9 @@ impl ExtensionKind { /// Request sent to a config-provider extension. /// -/// The extension receives the fully resolved config (after default → file → theme merge) -/// and can modify any field. The modified config is returned in the response. +/// The extension receives the fully resolved config (after default -> file -> +/// theme merge) and can modify any field. The modified config is returned in +/// the response. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConfigProviderRequest { pub version: u32, @@ -40,12 +45,49 @@ pub struct ConfigProviderRequest { impl ConfigProviderRequest { pub fn new(config: Value, args: Option) -> Self { Self { - version: EXTENSION_PROTOCOL_VERSION, + version: PROTOCOL_VERSION, kind: KIND_CONFIG_PROVIDER.to_string(), config, args, } } + + /// Validates the wire version and kind. + pub fn validate(&self) -> Result<()> { + if self.version != PROTOCOL_VERSION { + return Err(ExtensionApiError::InvalidProtocolVersion { + expected: PROTOCOL_VERSION, + found: self.version, + }); + } + + if self.kind != KIND_CONFIG_PROVIDER { + return Err(ExtensionApiError::InvalidExtensionKind { + expected: ExtensionKind::ConfigProvider, + found: self.kind.clone(), + }); + } + + Ok(()) + } + + pub fn kind_enum(&self) -> Option { + ExtensionKind::from_wire(&self.kind) + } + + pub fn parse_args(&self) -> Result> { + self.args + .clone() + .map(|value| serde_json::from_value(value).map_err(ExtensionApiError::InvalidArgs)) + .transpose() + } + + pub fn parse_args_or_default(&self) -> Result + where + T: DeserializeOwned + Default, + { + Ok(self.parse_args()?.unwrap_or_default()) + } } /// Response from a config-provider extension. @@ -53,3 +95,59 @@ impl ConfigProviderRequest { pub struct ConfigProviderResponse { pub config: Value, } + +impl ConfigProviderResponse { + pub fn new(config: Value) -> Self { + Self { config } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_defaults_to_current_protocol_version() { + let request = ConfigProviderRequest::new(Value::Null, None); + + assert_eq!(request.version, PROTOCOL_VERSION); + assert_eq!(request.kind, KIND_CONFIG_PROVIDER); + assert_eq!(request.kind_enum(), Some(ExtensionKind::ConfigProvider)); + assert!(request.validate().is_ok()); + } + + #[test] + fn validation_rejects_wrong_version_and_kind() { + let mut request = ConfigProviderRequest::new(Value::Null, None); + request.version = PROTOCOL_VERSION + 1; + assert!(matches!( + request.validate(), + Err(ExtensionApiError::InvalidProtocolVersion { .. }) + )); + + let mut request = ConfigProviderRequest::new(Value::Null, None); + request.kind = "info_provider".to_string(); + assert!(matches!( + request.validate(), + Err(ExtensionApiError::InvalidExtensionKind { .. }) + )); + } + + #[test] + fn parses_typed_args() { + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct Args { + name: String, + } + + let request = + ConfigProviderRequest::new(Value::Null, Some(serde_json::json!({ "name": "x" }))); + + assert_eq!( + request.parse_args::().expect("parse args"), + Some(Args { + name: "x".to_string() + }) + ); + } +} diff --git a/crates/guest-api/Cargo.toml b/crates/guest-api/Cargo.toml index 4a583bd..8b2bc7f 100644 --- a/crates/guest-api/Cargo.toml +++ b/crates/guest-api/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "xfetch-guest-api" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "MIT" description = "Host-call helpers for xfetch WebAssembly guests" repository = "https://github.com/xfetch-cli/api" [dependencies] -base64 = "0.22" +base64 = "0.23" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/plugin-api/Cargo.toml b/crates/plugin-api/Cargo.toml index da6226c..5c224f0 100644 --- a/crates/plugin-api/Cargo.toml +++ b/crates/plugin-api/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "xfetch-plugin-api" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "MIT" description = "Shared plugin protocol types and IO helpers for xfetch" repository = "https://github.com/xfetch-cli/api" [dependencies] +xfetch-api-common = { version = "0.3.0", path = "../common" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" diff --git a/crates/plugin-api/src/error.rs b/crates/plugin-api/src/error.rs index 11ad91b..8dfedf5 100644 --- a/crates/plugin-api/src/error.rs +++ b/crates/plugin-api/src/error.rs @@ -62,3 +62,19 @@ impl From for PluginApiError { Self::Io(value) } } + +impl From for PluginApiError { + fn from(value: serde_json::Error) -> Self { + Self::Deserialize(value) + } +} + +impl From for PluginApiError { + fn from(value: xfetch_api_common::IoError) -> Self { + match value { + xfetch_api_common::IoError::Io(err) => Self::Io(err), + xfetch_api_common::IoError::Serialize(err) => Self::Serialize(err), + xfetch_api_common::IoError::Deserialize(err) => Self::Deserialize(err), + } + } +} diff --git a/crates/plugin-api/src/io.rs b/crates/plugin-api/src/io.rs index b1a3d77..bedd5f7 100644 --- a/crates/plugin-api/src/io.rs +++ b/crates/plugin-api/src/io.rs @@ -1,32 +1,29 @@ -use crate::error::{PluginApiError, Result}; +//! JSON stdin/stdout helpers for plugins. +//! +//! The actual implementation lives in `xfetch-api-common`; the public +//! signatures and [`PluginApiError`] mapping are preserved here. + use serde::Serialize; use serde::de::DeserializeOwned; -use std::io::{self, Read, Write}; + +use crate::error::{PluginApiError, Result}; pub fn parse_json_str(input: &str) -> Result { - serde_json::from_str(input).map_err(PluginApiError::Deserialize) + xfetch_api_common::io::parse_json_str(input).map_err(PluginApiError::from) } pub fn parse_json_slice(input: &[u8]) -> Result { - serde_json::from_slice(input).map_err(PluginApiError::Deserialize) + xfetch_api_common::io::parse_json_slice(input).map_err(PluginApiError::from) } pub fn to_json_vec(value: &T) -> Result> { - serde_json::to_vec(value).map_err(PluginApiError::Serialize) + xfetch_api_common::io::to_json_vec(value).map_err(PluginApiError::from) } pub fn read_json_from_stdin() -> Result { - let mut input = String::new(); - io::stdin() - .read_to_string(&mut input) - .map_err(PluginApiError::Io)?; - - parse_json_str(&input) + xfetch_api_common::io::read_json_from_stdin().map_err(PluginApiError::from) } pub fn write_json_to_stdout(value: &T) -> Result<()> { - let body = to_json_vec(value)?; - let mut stdout = io::stdout(); - stdout.write_all(&body).map_err(PluginApiError::Io)?; - stdout.flush().map_err(PluginApiError::Io) + xfetch_api_common::io::write_json_to_stdout(value).map_err(PluginApiError::from) } diff --git a/crates/plugin-api/src/protocol.rs b/crates/plugin-api/src/protocol.rs index d2cb87b..298776d 100644 --- a/crates/plugin-api/src/protocol.rs +++ b/crates/plugin-api/src/protocol.rs @@ -3,9 +3,9 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; -pub const PROTOCOL_VERSION: u32 = 1; -pub const KIND_LOGO_ANIMATION: &str = "logo_animation"; -pub const KIND_INFO_PROVIDER: &str = "info_provider"; +pub const PROTOCOL_VERSION: u32 = xfetch_api_common::protocol::JSON_PROTOCOL_VERSION; +pub const KIND_LOGO_ANIMATION: &str = xfetch_api_common::protocol::KIND_LOGO_ANIMATION; +pub const KIND_INFO_PROVIDER: &str = xfetch_api_common::protocol::KIND_INFO_PROVIDER; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PluginKind { @@ -45,6 +45,18 @@ impl AnimationFrame { } } +impl From for xfetch_api_common::Frame { + fn from(value: AnimationFrame) -> Self { + Self::new(value.delay_ms, value.lines) + } +} + +impl From for AnimationFrame { + fn from(value: xfetch_api_common::Frame) -> Self { + Self::new(value.delay_ms, value.lines) + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct LogoAnimationArgs { @@ -61,6 +73,7 @@ pub struct LogoAnimationRequest { pub kind: String, pub lines: Vec, pub frames: Option>>, + #[serde(default)] pub args: LogoAnimationArgs, } @@ -82,6 +95,10 @@ impl LogoAnimationRequest { pub fn validate(&self) -> Result<()> { validate_request(self.version, &self.kind, PluginKind::LogoAnimation) } + + pub fn kind_enum(&self) -> Option { + PluginKind::from_wire(&self.kind) + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -123,6 +140,10 @@ impl InfoPluginRequest { validate_request(self.version, &self.kind, PluginKind::InfoProvider) } + pub fn kind_enum(&self) -> Option { + PluginKind::from_wire(&self.kind) + } + pub fn parse_args(&self) -> Result> { self.args .clone() @@ -247,4 +268,22 @@ mod tests { Err(PluginApiError::EmptyAnimationFrames) )); } + + #[test] + fn logo_request_allows_missing_args() { + let json = r#"{"version":1,"kind":"logo_animation","lines":["x"],"frames":null}"#; + let request: LogoAnimationRequest = serde_json::from_str(json).expect("parse request"); + + assert_eq!(request.args, LogoAnimationArgs::default()); + assert_eq!(request.kind_enum(), Some(PluginKind::LogoAnimation)); + } + + #[test] + fn animation_frame_converts_to_shared_frame() { + let frame = AnimationFrame::new(16, vec!["line".to_string()]); + let shared: xfetch_api_common::Frame = frame.clone().into(); + let roundtrip: AnimationFrame = shared.into(); + + assert_eq!(roundtrip, frame); + } } diff --git a/crates/plugin-api/src/timeout.rs b/crates/plugin-api/src/timeout.rs index 210442a..263dc38 100644 --- a/crates/plugin-api/src/timeout.rs +++ b/crates/plugin-api/src/timeout.rs @@ -1,89 +1,17 @@ //! Timeout helpers for plugins. //! -//! Plugins have variable runtimes (network calls, daemons, heavy -//! computation), so each plugin declares its own budget in its code instead -//! of the core guessing. `with_timeout` runs the task on a worker thread and -//! returns `Err(TimedOut)` when the budget elapses; the plugin can then -//! respond with partial/fallback lines and exit normally, so the core's -//! `wait_with_output` always returns. When the plugin process exits, the -//! worker thread is killed with it. +//! The implementation is shared with effects and extensions through +//! `xfetch-api-common`; this module preserves the existing public path. -#[cfg(not(target_arch = "wasm32"))] -use std::sync::mpsc; -#[cfg(not(target_arch = "wasm32"))] -use std::thread; -use std::time::Duration; - -/// The task did not finish within its budget. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TimedOut; - -/// Runs `task` on a worker thread, waiting at most `budget`. -/// -/// Returns `Ok(task())` when the task finishes in time, `Err(TimedOut)` -/// otherwise. The worker thread keeps running until the process exits (which -/// is immediate: the caller responds and the plugin terminates). -/// -/// # Examples -/// -/// ``` -/// use std::time::Duration; -/// use xfetch_plugin_api::with_timeout; -/// -/// let result = with_timeout(Duration::from_secs(1), || 2 + 2); -/// assert_eq!(result, Ok(4)); -/// ``` -#[cfg(not(target_arch = "wasm32"))] -pub fn with_timeout( - budget: Duration, - task: impl FnOnce() -> T + Send + 'static, -) -> Result { - let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - let _ = tx.send(task()); - }); - rx.recv_timeout(budget).map_err(|_| TimedOut) -} - -/// Wasm guests run single-threaded, so no worker thread is started: the host -/// enforces the plugins invocation deadline through epoch interruption and -/// traps the guest when the budget is exhausted. -#[cfg(target_arch = "wasm32")] -pub fn with_timeout(_budget: Duration, task: impl FnOnce() -> T) -> Result { - Ok(task()) -} +pub use xfetch_api_common::timeout::{TimedOut, with_timeout}; #[cfg(test)] mod tests { use super::*; + use std::time::Duration; #[test] - fn task_completing_within_budget_returns_value() { - let result = with_timeout(Duration::from_secs(5), || 42u32); - assert_eq!(result, Ok(42)); - } - - #[test] - fn slow_task_times_out() { - let started = std::time::Instant::now(); - let result = with_timeout(Duration::from_millis(50), || { - std::thread::sleep(Duration::from_secs(5)); - 1u32 - }); - assert_eq!(result, Err(TimedOut)); - assert!( - started.elapsed() < Duration::from_secs(2), - "timeout must fire promptly, took {:?}", - started.elapsed() - ); - } - - #[test] - fn task_runs_on_separate_thread() { - let main_tid = std::thread::current().id(); - let result = with_timeout(Duration::from_secs(5), move || { - std::thread::current().id() != main_tid - }); - assert_eq!(result, Ok(true)); + fn reexported_helper_runs_tasks() { + assert_eq!(with_timeout(Duration::from_secs(1), || 2 + 2), Ok(4)); } } diff --git a/docs/README.md b/docs/README.md index ce2ee8b..961a5f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,10 +20,11 @@

    Current Focus

    - The public surface is centered on four crates: + The public surface is centered on five crates: + xfetch-api-common (shared protocol/IO/frame/timeout primitives), xfetch-plugin-api, xfetch-extension-api, xfetch-effect-api, and xfetch-guest-api (host calls - for WebAssembly guests). + for WebAssembly guests). The current release line is 0.3.0.

    diff --git a/docs/examples.md b/docs/examples.md index c44db9c..a132d21 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -11,7 +11,18 @@

  • crates/plugin-api/examples/logo-animation-plugin.rs
  • -

    Effect Examples

    +

    Compiled Examples

    + +
      +
    • crates/effect-api/examples/simple-effect.rs
    • +
    • crates/extension-api/examples/simple-extension.rs
    • +
    + +

    + Both build with cargo build --workspace --examples. +

    + +

    Effect Implementations

    Effect implementations live in the diff --git a/docs/extension-sdk.md b/docs/extension-sdk.md index 79aa7bb..1a72007 100644 --- a/docs/extension-sdk.md +++ b/docs/extension-sdk.md @@ -19,6 +19,18 @@

  • ConfigProviderRequest
  • ConfigProviderResponse
  • ExtensionKind
  • +
  • ExtensionApiError
  • + + +

    Entrypoints and Helpers

    + +
      +
    • read_config_provider_request(): read and validate stdin.
    • +
    • read_config_provider_args_or_default::<T>(): read the request and parse typed args.
    • +
    • write_config_provider_response(&response): write stdout.
    • +
    • write_config(value): convenience wrapper for returning a config.
    • +
    • ConfigProviderRequest::validate(), kind_enum(), parse_args::<T>() and parse_args_or_default::<T>().
    • +
    • io helpers: parse_json_str, parse_json_slice, to_json_vec, read_json_from_stdin, write_json_to_stdout.

    Runtime Model

    diff --git a/docs/getting-started.md b/docs/getting-started.md index 67d6642..897e920 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,11 +1,13 @@ # Getting Started

    - This repository hosts four public crates: xfetch-plugin-api + This repository hosts five public crates: xfetch-api-common + (shared protocol/IO/timeout primitives), xfetch-plugin-api (plugins), xfetch-extension-api (extensions), xfetch-effect-api (effects), and xfetch-guest-api (host calls for WebAssembly guests). The component protocol lives in - wit/xfetch-runtime.wit. + wit/xfetch-runtime.wit. The current release line is + 0.3.0.

    Git Dependency

    @@ -13,6 +15,7 @@
    [dependencies]
     serde = { version = "1", features = ["derive"] }
     xfetch-plugin-api = { git = "https://github.com/xfetch-cli/api", package = "xfetch-plugin-api" }
    +xfetch-api-common = { git = "https://github.com/xfetch-cli/api", package = "xfetch-api-common" }
     xfetch-extension-api = { git = "https://github.com/xfetch-cli/api", package = "xfetch-extension-api" }
     xfetch-effect-api = { git = "https://github.com/xfetch-cli/api", package = "xfetch-effect-api" }
     
    diff --git a/docs/timeouts.md b/docs/timeouts.md index c088db4..4849c0d 100644 --- a/docs/timeouts.md +++ b/docs/timeouts.md @@ -1,7 +1,7 @@ # Timeouts

    - Plugins and extensions have variable runtimes (network calls, daemon + Plugins, effects and extensions have variable runtimes (network calls, daemon queries, heavy computation), so each one declares its own runtime budget in its code. This page explains how that works.

    @@ -17,9 +17,10 @@

    The solution: with_timeout

    - All three crates (xfetch-plugin-api, - xfetch-extension-api, xfetch-effect-api) expose the - same helper: + The implementation is shared by xfetch-api-common and + re-exported from all three crates (xfetch-plugin-api, + xfetch-extension-api, xfetch-effect-api), which + expose the same helper:

    pub fn with_timeout<T: Send + 'static>(
    diff --git a/docs/wasm-guests.md b/docs/wasm-guests.md
    index 06e2d70..4ad4682 100644
    --- a/docs/wasm-guests.md
    +++ b/docs/wasm-guests.md
    @@ -51,12 +51,12 @@ cargo build --release --target wasm32-wasip1
    [dependencies]
     serde = { version = "1", features = ["derive"] }
     serde_json = "1"
    -xfetch-plugin-api = "0.2"
    -xfetch-guest-api = "0.2"
    +xfetch-plugin-api = "0.3" +xfetch-guest-api = "0.3"

    Only the guest depends on xfetch-guest-api; the core does not - pull it. With "0.2" Cargo resolves the newest compatible 0.2.x + pull it. With "0.3" Cargo resolves the newest compatible 0.3.x release, so patch updates arrive without changes on your side.