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+allocso it also runs on bare metal. - Browser wasm —
wasm32-unknown-unknown, the JS-hosted lane. Its canonical cfg predicate isall(target_arch = "wasm32", target_os = "unknown"). No crate links the standard library here. The async surface and the relaxedSend/Syncbounds 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.
| Feature | Native and WASI | Browser 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 streams | Inert 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 |
async | Switches the trait shapes from blocking to impl Future; the same definitions compile either way | Always on — the surface is async regardless of the feature |
single-threaded | Drops the Send/Sync bounds graph-wide so !Send types flow through the framework | Implied 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:
| Token | Meaning |
|---|---|
"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.