Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Features and Targets

One endpoint definition compiles for a blocking desktop client, an async server, a browser tab, and a bare-metal board. Two mechanisms make that true, and they are deliberately kept apart: the compilation target picks the platform, and cargo features switch capabilities on. No feature selects a platform, and no feature set can lie about one — a build that misuses a target fails in the compiler, not at run time.

This chapter is the one place the book states that model. Every other chapter that touches platform — the manifest, the client adapters, the canaries — defers here.

The platform is the target

Capi splits platforms into two camps by target:

  • Native and WASI — every target that is not browser wasm. Crates that genuinely need a platform (transports, cryptography, HTML scraping) link the standard library here; the core pipeline does not, and stays no_std + alloc so it also runs on bare metal.
  • Browser wasmwasm32-unknown-unknown, the JS-hosted lane. Its canonical cfg predicate is all(target_arch = "wasm32", target_os = "unknown"). No crate links the standard library here. The async surface and the relaxed Send/Sync bounds are implied by the target itself.

WASI targets (wasm32-wasip1 and friends) are wasm, but they are not the browser: they have an operating system underneath, so they ride the first camp.

Building for either camp needs no feature flags:

cargo build                                    # native: std rides the defaults
cargo build --target wasm32-unknown-unknown    # browser: same defaults, other camp

No RUSTFLAGS either. The one host-specific requirement in the browser build — getrandom’s JS backend — is a plain feature the framework’s manifests enable from a browser-wasm target table (capi_context asks getrandom for wasm_js there), so the flag never reaches you. Bare-metal targets are the exception: with no entropy source to link, they register a custom backend with --cfg getrandom_backend="custom", which the nostd_canary keeps in its .cargo/config.toml.

The three axes

Three features propagate across the whole crate graph. Each is additive to the build; one of them is not additive to the API contract.

FeatureNative and WASIBrowser wasm
std (default)Links the standard library and switches on the family’s std surfaces: parking_lot locks, std::io, the native clock, filesystem-backed streamsInert as a standard-library switch — nothing links std. Acts as the platform-capability switch instead: each crate’s std includes its own js feature, which activates that crate’s JS host bindings (js-sys, wasm-bindgen, web-sys) from its browser-wasm target table
asyncSwitches the trait shapes from blocking to impl Future; the same definitions compile either wayAlways on — the surface is async regardless of the feature
single-threadedDrops the Send/Sync bounds graph-wide so !Send types flow through the frameworkImplied by the target; the feature changes nothing here

std is the only default. Disable it on a native target and you get the featureless core: the request pipeline on no_std + alloc, with no transport that can reach a network — that is what the bare-metal canary builds.

single-threaded is the one axis that is not additive to the API contract: turning it on removes Send for every crate in the build, so a native async runtime has to run on a current-thread configuration. It is graph-wide by construction — a build where some crates relax the bounds and others keep them would be a working binary whose strict crates silently keep bounds the lane meant to drop, which is exactly what the guard below refuses.

How the axes propagate

Every framework crate that declares async or single-threaded forwards it to capi_feature_state, so that crate’s feature set is the unified feature state of a build on those two axes. Cargo unifies features across the whole graph: if any crate turns async on, capi_feature_state’s async is on for everyone, and every crate compares its own local feature against that unified state at compile time.

std is deliberately not part of the unified state. It is additive within a target, so a graph that mixes std-on and std-off family edges still compiles and merely lacks some surfaces; you hunt a missing surface with the usual cargo tree -e features exercise, not with a guard.

The alignment guard

capi_feature_state::assert_feature_alignment! is invoked once at a crate root and lists the crate’s stance on each axis. It fails the build with a message naming the crate and the feature when the local flag and the unified state disagree. Its tokens:

TokenMeaning
"async", "single-threaded"Align: the crate declares or forwards this feature, and it must match the unified state
!"async", !"single-threaded"Forbid: the crate cannot participate in a build where any other crate turns this feature on
"native-only", "browser-only"Reach: the crate is pinned to one camp, and a build for the other target fails

For each axis a crate picks exactly one stance, or neither — a crate indifferent to both axes invokes no guard at all (the wire model, the codecs, the format contracts and capi_url carry none). There is no std token; std needs no guard.

The shipped invocations show the shapes:

// capi_core — dual-shape, and it declares both axes.
capi_feature_state::assert_feature_alignment!("async", "single-threaded");

// capic_reqwest and capic_native_client — native, dual-shape, and their
// worker-thread runtimes cannot give up Send.
capi_feature_state::assert_feature_alignment!("native-only", "async", !"single-threaded");

// capic_ureq — native, blocking only.
capi_feature_state::assert_feature_alignment!("native-only", "single-threaded", !"async");

// capic_wasm_fetch — the Fetch transport exists only in a browser.
capi_feature_state::assert_feature_alignment!("browser-only", "async", "single-threaded");

// capic_reqwless — embedded network stacks are single-threaded by nature.
capi_feature_state::assert_feature_alignment!("single-threaded");

A client library never writes the invocation itself. Every #[capi] and #[derive(ApiEndpoint)] expansion emits assert_feature_alignment!("async") through capi_macro_support, so a client whose async feature stops forwarding to the framework fails at its first derived endpoint, with the mismatch named.

A forbid token probes the crate’s own feature set with cfg, which trips the unexpected_cfgs lint when the crate (correctly) does not declare the forbidden feature. The crate silences it by telling check-cfg about the probed value:

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("async"))'] }

When the guard fires, the enablers are found with the flags of the failing build:

cargo tree -e features -i capi_feature_state

Near the end of the output, the capi_feature_state feature "async" branch lists every crate whose own async is on. Restore alignment by enabling the feature on every crate the error names as missing it, or by removing it from the enablers.

single-threaded gets a second, run-time check. A mixed build on that axis compiles, so a test binary declares capi_feature_state::assert_lane_alignment!("single-threaded") in its integration-test root; it expands to a #[test] that fails when the crate’s own feature disagrees with the unified state. async needs no such probe — a mixed async build fails to compile outright, because the trait shapes differ.

MaybeSend and MaybeSync

The bounds the framework places on futures, streams, and trait objects are spelled MaybeSend and MaybeSync, not Send and Sync. Their definition is the whole mechanism:

#[cfg(not(any(feature = "single-threaded", all(target_arch = "wasm32", target_os = "unknown"))))]
pub trait MaybeSend: Send {}

#[cfg(any(feature = "single-threaded", all(target_arch = "wasm32", target_os = "unknown")))]
pub trait MaybeSend {}

Off browser wasm and without single-threaded, MaybeSend is Send, so a worker-thread runtime such as Tokio’s multi-thread scheduler can move the framework’s futures between threads. On browser wasm the target relaxes the bound by itself; single-threaded is the native opt-in for the same relaxation, which is why capic_reqwless requires it and capic_reqwest forbids it. Because the bound is graph-wide, a crate that only bounds on MaybeSend — most add-ons — declares no single-threaded feature and no token: the relaxation rides the markers. Only a crate that cfg-gates code of its own on the axis declares the feature and aligns it.

What a client library declares

A client forwards the axes into every dependency that has them and activates none of them itself:

[features]
default = ["default-codecs", "std"]
default-codecs = ["dep:capiw_serde_json", "dep:capiw_urlencoded"]
async = ["capi_rs/async", "capi_websocket?/async", "capic_wasm_fetch/async"]
std = ["capi_rs/std", "capi_wire_datetime/std", "thiserror/std", "capi_websocket?/std"]
websocket = ["dep:capi_websocket", "std"]

# Browser wasm ships the Fetch transport with the client; the target selects it.
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
capic_wasm_fetch = { version = "0.5" }

Three things to read off that block. std is a default, as it is everywhere in the family. The async line reaches the Fetch transport as well as the facade — capic_wasm_fetch declares its own async axis, and a browser build with --features async that did not forward it would fail at that crate’s guard. And the client declares no per-platform feature of any kind: the native transports are the consumer’s choice, and the browser one arrives with the target. The manifest chapter walks the whole file.

Feature sets and lanes

Features are additive per target, so --all-features is a valid build on either camp, and docs.rs renders the family’s crates with rich feature sets (all-features = true is the client convention). The one exclusion is single-threaded: it compiles everywhere but changes the documented bounds, so it stays out of docs.rs feature sets.

async cannot be forwarded into a dev-only test harness from a library feature, so the test lanes are named rather than inferred. cargo capi aliases writes them into a repo’s committed .cargo/config.toml:

cargo test-sync     # cargo test
cargo test-async    # cargo test --features async,capi_test_framework/async
cargo check-wasm    # cargo check --target wasm32-unknown-unknown --all-targets

The verifier runs those lanes — with the featureless build, the single-threaded lane where a crate declares it, and the doctests beside them — on every push; Verifying a Repo has the full lane table, the manifest lint, and the alias block’s rules.

That completes the Foundations runway. Scaffolding a Client Crate starts building a real client crate on exactly these rules.