Back to home

dshbox

cordis-rs

Runtime-agnostic Rust port of Cordis, the plugin framework at the core of DeepSeek Harness — scoped dependency injection, lifecycle-owned effects, events, structured logging. Zero dependencies.

Stars
0
Language
Rust
Created
Aug 15, 2026
Updated
Aug 16, 2026

Introduction

cordis-rs

English | 简体中文

CI

A runtime-agnostic Rust port of Cordis 4.x — the plugin framework at the core of DeepSeek Harness, vendored there as @deepseek-ai/cordis.

This implementation is based on Cordis 4.0.1 from DeepSeek Harness. Its core structure mirrors the original Context / Events / Fiber / Logger / Reflect / Registry / Service modules and preserves automatic activation when dependencies arrive, automatic unloading when dependencies disappear, scoped isolation, effect cleanup, and all five event dispatch modes as closely as Rust allows.

Cordis is a context-based plugin framework for applications that need explicit dependency injection, scoped services, lifecycle-managed cleanup, structured events, and configuration-driven plugins. cordis-rs preserves that model while replacing JavaScript-only mechanisms (Proxy, prototype inheritance, callable objects, decorators, and any) with explicit Rust APIs, Arc, and checked downcasts.

Status

The crate currently ports the complete core runtime:

TypeScript CordisRust APIStatus
new Context() / extend()Context::new() / extend()
isolate() and shared labelsisolate() / isolate_with()
intercept()intercept() / intercepts()
Proxy-backed get/set/providetyped get/require/set/provide
Accessor and mixin reflectionaccessor() and explicit alias()✅¹
Function/object/class pluginsPlugin, plugin_sync, plugin_async, service adapters
inject dependency epochsInject and automatic unload/reload
FiberState, wait, restart, update, disposesame lifecycle operations
Sync/async/generator effectssync/async disposers and nested effect handles✅²
emit/parallel/serial/bail/waterfallsame five dispatch modes
Context listener filterswith_filter() / emit_from()
Logger buffer/exporters/levels/formatterscorresponding logger APIs
Standard Schema validationPlugin::validate_config + validation issues✅³
internal/plugin, internal/status, internal/service, internal/dispatchsame meta-events✅⁴
Intercept meta-events (internal/get/set/config/update/listener)not portedNot included
Decorators and callable servicesexplicit Rust traits/buildersRust-native
Loader/include/HMR packagesoutside the core crateNot included
  1. Rust cannot dynamically project arbitrary struct fields like a JavaScript Proxy, so alias() is the explicit counterpart to common mixin() usage.
  2. Rust plugin code registers multiple effects explicitly; EffectHandle::adopt() provides the original nested diagnostic/disposal tree.
  3. Validation is trait-based because Standard Schema is a JavaScript protocol.
  4. internal/dispatch carries (mode, name, args); the upstream fourth thisArg argument is omitted. The waterfall/bail interception points used by upstream HMR and config injection (internal/get, internal/set, internal/config, internal/update, internal/listener) are not part of this port, so downstream code relying on them needs a different extension point.

Design goals

  • Faithful lifecycle: a plugin remains Pending until every injected service is active. Replacing/removing a provider changes the dependency epoch, unloads the consumer, and starts it again when possible.
  • Scoped DI: isolated branches resolve different implementations of the same service. Reusing an Isolation label joins scopes.
  • Ownership-based cleanup: plugins, listeners, services, exporters, accessors, and child plugins are effects of their creating fiber.
  • No executor lock-in: the crate has no third-party dependencies. Futures are accepted through boxed standard-library futures; eager lifecycle operations use a small wake-aware executor.
  • Type-checked dynamic values: service, config, and event storage uses Value (Arc<dyn Any + Send + Sync>) with checked downcasting and useful type errors.

Install

cargo add cordis-rs
[dependencies]
cordis-rs = "0.3"

The package is published as cordis-rs; the library crate is still named cordis, so imports remain use cordis::....

The minimum supported Rust version (MSRV) is Rust 1.85, and the crate uses Rust 2024 Edition. The crate has no external dependencies.

Rust version policy

  • MSRV: Rust 1.85. CI and releases must continue to compile and test on this exact version.
  • Development toolchain: the latest stable Rust release is used for formatting, Clippy, documentation, and forward-compatibility testing.
  • Review cadence: the MSRV is reviewed every six months, around February and August. A review does not imply an automatic version increase.
  • Review factors: maintainers consider the compiler shipped by stable Linux distributions, requirements of official plugins and downstream projects, useful language or standard-library improvements, dependency/security constraints, and toolchain versions actually used by downstream users.
  • Version changes: the MSRV is raised only when there is a concrete maintenance or ecosystem benefit. An increase is documented in the changelog and release notes and is made in a minor release, never silently in a patch release.
  • Workspace consistency: official Cordis crates and plugins should use one shared MSRV unless a documented platform constraint requires an exception.

Quick start

use cordis::{plugin_sync, Context, Inject, LogArg, PluginOutput, Result, Service};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

struct Counter(AtomicUsize);

impl Service for Counter {
    const NAME: &'static str = "counter";
}

fn main() -> Result<()> {
    let root = Context::new();
    let counter = Arc::new(Counter(AtomicUsize::new(0)));
    let _provider = root.provide_service_arc(counter.clone())?;

    let greeter = plugin_sync::<(), _>(
        "greeter",
        Inject::new(["counter"]),
        |ctx, _config| {
            let counter = ctx.require::<Counter>("counter")?;
            let value = counter.0.fetch_add(1, Ordering::SeqCst) + 1;
            ctx.logger().info(
                "%s #%d",
                [LogArg::from("started"), LogArg::from(value)],
            );
            Ok(PluginOutput::none())
        },
    );

    let fiber = root.plugin_default(greeter);
    fiber.wait()?;
    assert_eq!(counter.0.load(Ordering::SeqCst), 1);

    fiber.dispose()?;
    root.fiber()?.dispose()?;
    Ok(())
}

Dependency injection and reload

Inject controls whether a plugin may be active. Service changes reconcile consumers immediately and deterministically.

use cordis::{plugin_sync, Context, FiberState, Inject, PluginOutput, Result};

fn main() -> Result<()> {
    let root = Context::new();
    let consumer = plugin_sync::<(), _>(
        "consumer",
        Inject::new(["database"]),
        |ctx, _| {
            println!("database = {}", *ctx.require::<String>("database")?);
            Ok(PluginOutput::infallible(|| println!("consumer unloaded")))
        },
    );

    let fiber = root.plugin_default(consumer);
    assert_eq!(fiber.state(), FiberState::Pending);

    let database = root.provide("database", "sqlite://app.db".to_owned())?;
    assert_eq!(fiber.state(), FiberState::Active);

    database.dispose()?;
    assert_eq!(fiber.state(), FiberState::Pending);
    Ok(())
}

A plugin can attach per-service intercept config as part of its inject declaration:

use cordis::{Inject, LoggerIntercept, LoggerLevel};

let inject = Inject::new(["database"]).require_with(
    "logger",
    LoggerIntercept {
        name: Some("worker".into()),
        level: Some(LoggerLevel::Debug),
    },
);

Scoped services

use cordis::{Context, Result};

fn main() -> Result<()> {
    let root = Context::new();
    let label = root.new_isolation();
    let tenant_a = root.isolate_with("cache", label);
    let tenant_a_worker = root.isolate_with("cache", label);
    let tenant_b = root.isolate("cache");

    let _cache = tenant_a.provide("cache", String::from("A"))?;
    assert_eq!(tenant_a_worker.require::<String>("cache")?.as_str(), "A");
    assert!(tenant_b.get::<String>("cache")?.is_none());
    assert!(root.get::<String>("cache")?.is_none());
    Ok(())
}

Effects

Every effect is single-shot and fiber-owned. Fiber unloading runs effects in reverse registration order. Cleanup errors are logged and do not prevent the remaining effects from running.

use cordis::{Context, Result};

let root = Context::new();
let handle = root.effect_infallible("temporary file", || {
    // remove the file
})?;

assert_eq!(handle.meta().label, "temporary file");
handle.dispose()?; // early cleanup
handle.dispose()?; // no-op
# Ok::<(), cordis::CordisError>(())

Use effect_async() or AsyncDisposer::from_async() for asynchronous cleanup. A child plugin, listener, provided service, logger exporter, or accessor is internally registered as the same kind of effect.

Events

Arguments and bail values are Values. None means “continue”; Some(value) means “bail”.

use cordis::utils::block_on;
use cordis::{Context, Result, Value};

let root = Context::new();
let _listener = root.on("math/double", |event| {
    let input = event.arg::<u32>(0)?.unwrap();
    Ok(Some(Value::new(*input * 2)))
})?;

let answer = root.events()
    .bail("math/double", [Value::new(21_u32)])?
    .unwrap()
    .downcast::<u32>()?;
assert_eq!(*answer, 42);

block_on(root.events().parallel("tick", []))?;
# Ok::<(), cordis::CordisError>(())

Dispatch modes:

  • emit: invoke in order and synchronously propagate the first error.
  • parallel: poll every listener concurrently and aggregate errors.
  • serial: await in order and stop on the first bail value.
  • bail: synchronous ordered bail.
  • waterfall / waterfall_async: each listener receives event.next() and may wrap or veto the rest of the chain.

Reflection

Normal Rust code should prefer typed services. Value, Accessor, and alias() support dynamic framework/loader use cases:

use cordis::{Accessor, Context, Result, Value};
use std::sync::{Arc, Mutex};

let root = Context::new();
let state = Arc::new(Mutex::new(1_u32));
let read = state.clone();
let write = state.clone();

let _property = root.accessor("answer", Accessor::read_write(
    move |_| Ok(Some(Value::new(*read.lock().unwrap()))),
    move |_, value| {
        *write.lock().unwrap() = *value.downcast::<u32>()?;
        Ok(())
    },
))?;

root.set("answer", 42_u32)?;
assert_eq!(*root.require::<u32>("answer")?, 42);
# Ok::<(), cordis::CordisError>(())

Logger

The logger keeps a bounded chronological buffer and sends structured Messages to effect-owned exporters. It supports Cordis placeholders (%s, %d, %i, %f, %o, %O, %c, %C, and %%), per-name levels, custom formatters, ANSI name colors, and logger intercepts.

use cordis::{default_format, Context, ExporterConfig, LogArg, LoggerLevel, Result};

let root = Context::new();
let mut config = ExporterConfig::default();
config.levels.insert("default".into(), LoggerLevel::Debug);
let render = config.clone();
let _exporter = root.logger_service().exporter_fn(config, move |message| {
    println!("{}", default_format(&render, message));
})?;

root.named_logger("app").info("listening on %d", [LogArg::from(8080)]);
# Ok::<(), cordis::CordisError>(())

Writing a custom plugin

Closure adapters cover most plugins. Dynamic loaders can implement the object-safe trait directly:

use cordis::utils::BoxFuture;
use cordis::{Config, Context, Inject, Plugin, PluginOutput, Result};

struct Worker {
    inject: Inject,
}

impl Plugin for Worker {
    fn name(&self) -> &str { "worker" }
    fn inject(&self) -> &Inject { &self.inject }

    fn apply(&self, ctx: Context, _config: Config)
        -> BoxFuture<Result<PluginOutput>>
    {
        Box::pin(async move {
            let _queue = ctx.require::<String>("queue")?;
            Ok(PluginOutput::none())
        })
    }
}

Override validate_config() to normalize config or return CordisError::validation(...). service_sync() and service_async() adapt constructors returning a type that implements Service.

Runtime notes

The original TypeScript implementation schedules lifecycle work through promises. This crate deliberately reconciles lifecycle transitions eagerly: provide, effect disposal, restart, and update return after affected fibers settle. This makes behavior deterministic without requiring Tokio or another executor. Fiber::await_ready, async event modes, async plugins, and async disposers remain available.

Executor-independent futures work everywhere. If a future creates runtime-specific resources (for example tokio::time::sleep), call Cordis while that runtime is entered.

Two consequences of the eager model: Fiber::wait() reports the settled state instead of suspending until dependencies arrive — it returns an error for Pending or disposed fibers. And futures driven by Cordis run on a small blocking executor while a lifecycle transition lock is held, so plugin apply callbacks and disposers must only await work that completes on other threads (never same-thread channels or spawn_blocking joins).

Fiber::update() mirrors upstream on inactive fibers: on an Active fiber it validates the new config, restarts, and reports the startup outcome; on a Pending or Failed fiber it stores the config and reconciles without waiting, so Ok(()) only means the config was accepted — inspect state()/error() for the outcome of the activation it schedules.

A panic in a plugin apply, disposer, or event listener propagates to the caller of the lifecycle operation that triggered it. Internal mutexes recover from poisoning, and a fiber interrupted mid-transition stays in Loading/Unloading — with already-registered effects still owned — until the next lifecycle event or dispose settles it. Context and Fiber do not implement UnwindSafe because their trait objects cannot prove it; when a plugin must not take down its caller, isolate it with std::panic::catch_unwind(std::panic::AssertUnwindSafe(...)).

Project layout

The source mirrors the upstream package:

src/
├── context.rs   # root/child context and scope overlays
├── events.rs    # event bus and five dispatch modes
├── fiber.rs     # plugin lifecycle and effect ownership
├── logger.rs    # messages, formatters, buffer, exporters
├── reflect.rs   # scoped service store and computed properties
├── registry.rs  # Plugin, Inject, runtime records
├── service.rs   # typed service and constructor adapters
├── effect.rs    # disposers, handles, diagnostic trees
├── value.rs     # Arc<dyn Any> values
└── utils.rs     # boxed futures, small executor

Development

# MSRV compatibility
cargo +1.85 check --all-targets --all-features
cargo +1.85 test --all-features

# Latest stable quality and forward-compatibility checks
cargo +stable fmt --all -- --check
cargo +stable clippy --all-targets --all-features -- -D warnings
cargo +stable test --all-features
RUSTDOCFLAGS="-D warnings" cargo +stable doc --no-deps --all-features

License

MIT. The architecture and behavior are based on Cordis by Shigma and the DeepSeek Harness vendored implementation.