What it is
bevy_net_backend connects a Bevy game to its own backend: the
servers behind accounts and logins, cloud saves, leaderboards, shops and inventories, friends
lists, chat, matchmaking and the other MMO-style services a game runs itself. It also lets admin
and developer tools reach those servers.
Three parts share one pattern: a system fires a request and gets a RequestId back at once; a
few frames later exactly one typed answer arrives as a Bevy message.
- HTTP (default): calls to your HTTPS JSON API (Laravel, Express, Go, Django, FastAPI,
ASP.NET, …) with your serde types, and file uploads as
multipart/form-data. - WebSocket (feature
ws): named long-lived connections for chat, lobbies, match events and server pushes, with reconnect and heartbeat built in. - SSH and SFTP (features
ssh,sftp), for admin and developer tools only: run commands on your servers and move files. Release builds refuse it unless the tool opts in.
The answer is the decoded value or an error that says what happened (network, TLS, timeout, an HTTP status with the server's body, a decode error, cancelled, or shutdown when the app exits). The network never blocks a frame, nothing is dropped silently, and bad input never panics.
use *;
use *;
use Deserialize;
Contents
- What it is
- Features at a glance
- What it guarantees
- Where it sits
- Backend compatibility
- Install
- Quick start
- How to use it
- 1. Configure the backend
- 2. Typed JSON requests
- 3. Raw requests and full control
- 4. Reading answers and errors
- 5. Logging in: credentials
- 6. Cancel, in-flight tracking, app exit
- 7. Plain http:// for local development
- 8. Testing your game without a server
- 9. Your own transport
- 10. WebSocket connections (feature
ws) - 11. SSH commands and SFTP (feature
ssh, admin / dev builds only) - 12. File uploads (multipart)
- How it works
- TLS exception: the default build is not pure Rust
- API reference
- Limits and what it does not do
- Versions
- Examples
- How it's tested
- FAQ
- License
- Contributing
Features at a glance
| Feature | Default | For | What it adds |
|---|---|---|---|
http |
yes | your HTTPS API, file uploads | The real transport, UreqTransport: ureq 3.4 on a few worker threads, rustls with ring's crypto and the Mozilla root certificates (webpki-roots). multipart/form-data uploads with Multipart (no extra dependency). ring compiles C and assembly (see TLS exception). |
json |
yes | typed requests and answers | get_json / post_json / send_json / post_multipart_json, JsonResponse<T>, OutgoingRequest::with_json, RawResponse::json, JsonBodyField (serde + serde_json). |
gzip |
no | compressed answers | Accept gzip-compressed responses (ureq's decoder, flate2). |
ws |
no | live data: chat, lobbies, pushes | Named WebSocket connections: WsClient, WsConnections, the Ws* messages, TungsteniteTransport (tungstenite 0.30, sync, one thread per connection, rustls + ring, no permessage-deflate). With json: JsonEnvelope, WsRequest, WsPushMessage, WsResponse<T>, WsPush<P>. |
ssh |
no | admin / dev tools only | Named SSH connections that run commands: SshClient, SshConnections, the Ssh* messages, RusshTransport (russh 0.63, ring for the AEAD ciphers and RustCrypto for the rest; tokio on one private thread), strict known_hosts, key files / ssh-agent / ~/.ssh/config. |
sftp |
no | admin / dev tools: files | SFTP on SSH connections (implies ssh): upload, download, list, create / remove directory, remove file, rename (russh-sftp). |
ssh-rsa |
no | old RSA-only servers | RSA host keys and RSA key files for SSH (implies ssh; rsa-sha2-256/512, never SHA-1). Off by default: the rsa crate carries the unfixed Marvin timing advisory RUSTSEC-2023-0071. Without it, ed25519 and ECDSA keys work. |
Crates in the build (normal and build dependencies, this crate excluded, measured with
cargo tree on Windows; other platforms differ by a few platform crates):
| Features | Crates |
|---|---|
default-features = false (types and fake transports only) |
67 |
default (http, json) |
90 |
default + gzip |
95 |
default + ws |
104 |
default + ssh |
209 |
default + ssh, sftp |
219 |
default + ssh, ssh-rsa |
212 |
ssh without default features |
195 |
| all features | 228 |
Without http the crate still builds: every type, the FakeHttpTransport and your own
HttpTransport work, and requests without a transport are answered with NoTransport. The
default set is deliberately not empty: the crate exists to call an HTTPS JSON API, and it should
do that with no feature fiddling.
What it guarantees
- Every request gets exactly one answer: success, or an error such as
Status(the server's status, headers and body),Network,Tls,Timeout,Decode,Cancelled,Shutdown(the app exited),NoTransport,RequestTooLargeorInvalidRequest. HTTP requests, WebSocket requests, SSH commands and SFTP operations alike. A late result from the network after a cancel or a timeout is discarded, never delivered twice. - An answered request is never sent afterwards. A request cancelled, timed out or answered
Shutdownwhile it still waited (for a worker, a connection or a free channel) never goes out later. Errors say honestly whether the request went out:error.was_sent()isSome(false)(never sent),Some(true)(the server has it) orNone(unknown), and SSH answers carrystarted. Retry decisions can rely on it. - Real deadlines, bounded buffers, size limits. A timeout covers the whole call, waiting included; a WebSocket or SSH connect is ONE deadline over TCP, TLS / key exchange and the handshake, so a server that trickles bytes cannot stretch it. Answers are capped (HTTP body 10 MiB after gzip decoding, so a gzip bomb stops at the limit; WebSocket messages 1 MiB; SSH output 8 MiB; SFTP transfers 256 MiB) and so are requests (uploads 32 MiB and 256 parts, checked before anything is sent). Every default can be changed.
- Never blocks a frame, never panics on bad input. Requests run on the crate's own threads,
not on Bevy's task pools; answers are written in
First, soPreUpdateandUpdateread them in the frame they arrived. An invalid path, header, name or form is anInvalidRequestanswer. - No ordering ceremony:
HttpClient,WsClientandSshClientare used throughRes<..>(shared access), so any number of systems in any schedule can fire requests. - Secrets stay out of logs: tokens and passwords are redacted from
Debug,Displayand the crate's own log lines (a test captures every log event to prove it). - Safe defaults: HTTPS only (plain
http://only tolocalhost/127.x.x.x/[::1]unless you allow it), redirects not followed, a 15 s timeout, strict SSH host key checking with no trust-on-first-use. - No tokio unless you enable
ssh, and then only on one private thread. HTTPS uses rustls with ring, no OpenSSL, native-tls or aws-lc in any feature set. ring compiles C and assembly: see the TLS exception. - Testable offline:
FakeHttpTransport,FakeWsTransportandFakeSshTransportanswer from scripts, so your game's systems can be tested headless without a server.
Where it sits
This crate is the game talking to your servers, not players talking to each other.
Real-time gameplay between players (inputs, positions, replication at 30–60 Hz) belongs to UDP
netcode such as bevy_replicon with
renet. bevy_net_backend sits next to it: a typical online game
logs in, loads the save and joins matchmaking over HTTP, keeps a WebSocket open for chat and
lobby updates, plays the match over replicon, and posts the result over HTTP again. Nothing here
competes with the netcode for the frame or the socket.
Backend compatibility
- HTTP works with any backend that speaks HTTPS and JSON (or raw bytes): Laravel / PHP,
Express / Node, Go, Rust, Django, FastAPI, Spring, Rails, ASP.NET, serverless functions. Nothing
is tailored to one framework. File uploads (
multipart/form-data) were checked byte for byte against real PHP, Express + multer, FastAPI, Go and Django; frameworks differ in their limits and in how they read some file names, so read what real backends do with an upload. - WebSocket (feature
ws) works with any plain WebSocket server (RFC 6455), through the default JSON envelope or your ownWsProtocolfor another message layout. - Frameworks that run their own protocol on top of WebSocket (Laravel Reverb / Pusher,
Socket.IO, SignalR, Phoenix Channels) need an adapter for that protocol. Adapters are planned
for a later version; until then use
WsProtocolor raw frames if your server can also speak plain WebSocket. - SSH (feature
ssh, admin / dev tools) works with any standard SSH server: OpenSSH on Linux, BSD, macOS or Windows, and other servers speaking SSH-2 with ed25519 or ECDSA host keys (RSA with featuressh-rsa). Servers without strict key exchange (OpenSSH before 9.6, unless the distribution backported it) connect through AES-GCM, which the client prefers; only a server that offers nothing but ChaCha20-Poly1305 or CBC + encrypt-then-MAC is refused (see Terrapin in the SSH section). SFTP needs the server'ssftpsubsystem (OpenSSH's default).
Install
[]
= { = "0.1.0" }
Other sets:
# Also accept gzip-compressed answers.
= { = "0.1.0", = ["gzip"] }
# HTTP + WebSocket.
= { = "0.1.0", = ["ws"] }
# An admin / dev tool: SSH commands and SFTP (never in a build for players).
= { = "0.1.0", = ["ssh", "sftp"] }
# Only the types and the fake transport (e.g. a crate that brings its own transport).
= { = "0.1.0", = false }
The crate uses Bevy's sub-crates bevy_app, bevy_ecs and bevy_time 0.19.0 without default
features, so it adds no Bevy feature your game did not ask for.
Quick start
- Add
BackendPluginwith your API's base URL. - Register each JSON answer type once:
app.add_json_response::<T>(). - Fire requests from any system with
Res<HttpClient>; keep theRequestIdif you need to match the answer. - Read
JsonResponse<T>(orHttpResponsefor raw calls) with aMessageReader.
use *;
use *;
use ;
/// The request being waited for.
;
How to use it
1. Configure the backend
HttpConfig holds the settings; BackendPlugin::new(config) inserts it as a resource.
use Duration;
use ;
let config = new // paths are appended to this
.with_timeout // whole call; default 15 s
.with_header // sent with every request
.with_workers // worker threads; default 2, 1..=8
.with_max_body_bytes; // default 10 MiB
assert!;
let plugin = new;
# let _ = plugin;
| Setting | Default | Read |
|---|---|---|
base URL (new, with_base_url, set_base_url) |
none: requests fail with InvalidRequest until set |
per request |
with_timeout |
15 s (DEFAULT_TIMEOUT), clamped to 1 ms ..= 1 h |
per request |
with_header / without_header |
User-Agent: bevy_net_backend/0.1.0 |
per request |
allow_insecure_http |
false |
per request |
with_max_body_bytes |
10 MiB (DEFAULT_MAX_BODY_BYTES), at least 1 |
per request |
with_workers |
2 (DEFAULT_WORKERS), clamped to 1 ..= 8 (MAX_WORKERS) |
once, at plugin build |
The base URL must be http:// or https:// with a host, and have no user name / password,
query or fragment. HttpConfig::validate() tells you what is wrong; the plugin logs it as a
warning. Change settings at runtime on the resource, for example after reading the game's own
settings file:
use *;
use HttpConfig;
# let _ = use_staging;
2. Typed JSON requests
Register every answer type once, then use the typed calls. T is any
serde::de::DeserializeOwned + Send + Sync + 'static type; the request body is anything
serde::Serialize.
use *;
use *;
use ;
let mut app = new;
app.add_plugins
.
.add_systems;
- The typed calls add
Accept: application/jsonunless you setAcceptyourself (Laravel answers validation errors as JSON only with it).with_json/post_jsonsetContent-Type: application/json. - A 2xx body is decoded into
T. An empty body decodes as JSONnull, so()andOption<T>accept a204 No Content. - A type that was never registered is not sent: the request is answered on
HttpResponsewithInvalidRequest(naming the missingadd_json_response) and an error is logged. - A body that cannot be serialized is answered with
Encodeand never sent.
3. Raw requests and full control
Raw calls answer with HttpResponse (status, headers, bytes):
use *;
use Method;
use *;
use Duration;
# let _ = raw;
OutgoingRequest has new(method, path) and get / post / put / patch / delete, plus
with_query, with_header, with_body, with_json, with_timeout, without_credentials.
Paths start with / and are appended to the base URL; absolute URLs and a ? in the path are
refused (use with_query, which percent-encodes names and values). So is anything a server
could resolve outside the base URL's path, at every level of percent-decoding: . / ..
segments (split on / and \, ..; included), backslashes, encoded separators (%2F, %5C)
and control characters (%00 included). A path is sent as
written: percent-encode user text you put into it. The methods sent are the standard ones except
CONNECT (GET, POST, PUT, PATCH, DELETE, HEAD without a body, OPTIONS, TRACE).
An invalid header, method or path does not panic: the request is answered with InvalidRequest
and never sent.
4. Reading answers and errors
use *;
use StatusCode;
use *;
use Deserialize;
/// A typical error body (Laravel: `message` + `errors`).
# let _ = on_save;
| Error | When | Sent? |
|---|---|---|
InvalidRequest(why) |
bad path, header, base URL, unregistered JSON type, credentials that cannot apply | no |
InsecureHttp { host } |
plain text (http://) to a non-loopback host without allow_insecure_http |
no |
Encode(why) |
the JSON body cannot be serialized | no |
Network(why) |
DNS, connect, reset, protocol error, worker threads not starting (ureq's words) | no for a DNS / connect / worker-start failure, else maybe |
Tls(why) |
TLS failure: handshake, certificate (rustls' / ureq's words) | no for a handshake or certificate failure (before any request byte), else maybe |
Timeout(why) |
the timeout ran out; it counts from hand-over to the transport, waiting for a free worker included | no if why starts with not sent:, else maybe |
BodyTooLarge { limit } |
the ANSWER is over its limit: the response body (SSH: the command's output or an SFTP download) | yes |
RequestTooLarge { limit, size } |
the REQUEST is over its limit and was refused before anything was sent: a multipart form over Multipart::with_max_bytes, a WebSocket request over the message limit, an SSH command line over 64 KiB, an SFTP upload over the transfer limit |
no |
Status(response) |
a status outside 200–299, 3xx included (redirects are not followed) | yes |
Decode { message, response } |
a 2xx body that is not the expected JSON | yes |
Cancelled |
HttpClient::cancel |
no if it was still waiting for a worker, else maybe |
Shutdown |
the app exited (AppExit) first |
no, unless it was already on the wire before the exit frame |
NoTransport |
no HttpTransportRes, or it was removed / replaced first |
no if it was still waiting for a worker, else maybe |
Disconnected { reason, sent } |
WebSocket and SSH: the connection went away, was closed by the game, or never opened (see WebSocket, SSH) | as sent says: Some(true) it went out before, Some(false) never, None unknown |
Closed { code, reason } |
WebSocket only, on WsStateChanged / WsConnectionInfo: the server closed with a close frame |
– |
Rejected(rejection) |
WebSocket only: the server answered the request with an error; rejection.bytes() / text() / json() (Debug / Display do not show it) |
yes |
HostKey { host, fingerprint, problem } |
SSH only: the server's host key is unknown, changed or revoked (HostKeyProblem) |
no |
AuthFailed(why) |
SSH only: no configured key was accepted (or none could be loaded) | no |
Ssh(why) |
SSH only: a protocol error, a refused channel / exec / subsystem, an SFTP status (the server's words) | see SshFinished::started |
error.was_sent() sums the column up: Some(false) never sent, Some(true) the server has it
(or had it before a loss), None maybe.
"Waiting for a worker" is the UreqTransport queue: a request answered while it waits there is
never sent afterwards. One already on the wire may still reach the server; its result is
discarded.
BackendError is #[non_exhaustive]: keep a catch-all arm. error.status() and
error.response() give the server's answer for Status and Decode; RawResponse has
status, headers, body, text() and json::<E>(). Display of every error is safe to log:
it never shows a body, a header value or a query. The message field of Decode is serde_json's
text and may quote part of the body (a token, say); the crate never logs it, and neither should a
release build.
5. Logging in: credentials
Log in with an ordinary request, then store the token. From then on every request carries it (applied last, after the config's default headers and the request's own headers):
use *;
use *;
use ;
# let _ = ;
| Credentials | Sends | Typical backend |
|---|---|---|
BearerToken::new(token) |
Authorization: Bearer <token> |
Laravel Sanctum / Passport, JWT APIs, most Node / Go APIs |
ApiKeyHeader::new("X-Api-Key", key) |
X-Api-Key: <key> (any header name) |
API gateways, simple game servers |
ApiKeyQuery::new("api_key", key) |
?api_key=<key> |
APIs that only take a query key |
JsonBodyField::new("token", token) (feature json) |
"token": "<token>" added to JSON-object bodies |
APIs that read the token from the body |
Anything else is one small trait:
use ;
use HeaderValue;
/// Two headers: a player id and a session key.
Secretprints as<redacted>inDebugandDisplay; read it withexpose().BackendCredentials,BearerToken,ApiKeyHeader,ApiKeyQueryandJsonBodyFieldnever show the secret inDebug;OutgoingRequestandPreparedRequestprint header names but no header values, no query values and no body;RawResponseprints the body's length only.- The crate's own log lines never contain a header value, a query string or a body.
- Dependency logs at
tracecontain secrets. Attracelevel the HTTP client logs raw request and response bytes (ureq_proto) and full paths with queries (ureq):Authorizationheaders, login bodies, tokens in answers. Keep those targets belowtrace, e.g.RUST_LOG=trace,ureq=debug,ureq_proto=debug, or in BevyLogPlugin { filter: "wgpu=error,naga=warn,ureq=debug,ureq_proto=debug".into(), ..default() }. Bevy's default level (info) is safe. ApiKeyQuerysecrets end up in access logs. A query key is part of the URL, so reverse proxies and servers log it: in testing Caddy's access log showedapi_key=…(and anX-Api-Keyheader) in plain text while it maskedAuthorization. PreferBearerToken(or a header your proxy redacts) wherever your API allows it.- Methods added to
Credentialslater always come with a default implementation. - Storing the token between sessions (keyring, file) and refreshing it are the game's job.
6. Cancel, in-flight tracking, app exit
use *;
use *;
;
# let _ = ;
- Cancel: answered with
Cancelledin the next frame'sFirst. A request already on the wire keeps its worker thread until it finishes or times out (a blocking call cannot be interrupted); its result is discarded. Cancelling an answered id does nothing. - InFlight lists every request not answered yet: HTTP, WebSocket (feature
ws) and SSH / SFTP (featuressh) alike. An HTTP request enters it inPostUpdateof the frame it was made in; a WebSocket request there too, also while it waits for its connection. A request leaves it when it is answered; the answer message follows inFirst(of the same frame, or of the next one for answers decided inPostUpdate, such as a cancel).describe(id)returns aRequestInfo:kind(Http,WebSocket,SshorSftp),method(HTTP),target(the path without the query, or the connection's name; never an SSH command line). - One cancel for everything:
HttpClient::cancel(id)cancels HTTP, WebSocket and SSH / SFTP requests alike (WsClient::cancelandSshClient::cancelare the same call). - App exit: in the frame an
AppExitmessage is written, nothing new is sent:BackendSystems::Sendhands no request to the transport, andBackendSystems::Exit(inLast) answers every open request withShutdown(results that already arrived are delivered as they are) and stops the worker threads without waiting for busy ones. A request answeredShutdownwas never sent, except one that was already on the wire before that frame (it may still reach the server). Systems ordered afterBackendSystems::ExitinLastcan read those answers. WriteAppExitbeforeBackendSystems::Send(anywhere inUpdateor earlier is fine; aPostUpdatewriter must be ordered.before(BackendSystems::Send)): written later, requests of that frame may still go out, and written afterExitinLastit is seen by nobody in this crate. - Save on quit: send the save, wait for its answer (
Okor an error), and only then writeAppExit. A save fired in the same frame asAppExitis answeredShutdownand never sent. (Flushing pending requests on exit is not a feature of 0.1.0.)
7. Plain http:// for local development
http://localhost, http://127.x.x.x and http://[::1] work out of the box, for
php artisan serve, npm run dev, go run . and the like. Any other plain http:// host is
refused with InsecureHttp (nothing is sent), because plain HTTP shows tokens to everyone on
the path. For a dev server on a trusted LAN:
use HttpConfig;
let config = new.allow_insecure_http;
# let _ = config;
Loopback requests never use a proxy. Other requests use the proxy from HTTPS_PROXY /
HTTP_PROXY / ALL_PROXY (with NO_PROXY) when set (ureq's default).
8. Testing your game without a server
Insert a FakeHttpTransport before (or after) the plugin. It answers from scripted routes and
records every request, so your game's systems can be tested headless and offline:
use *;
use ;
use *;
use ;
let fake = new;
fake.route;
let mut app = new;
app.add_plugins
.insert_resource
.add_plugins;
let id = app.world..get;
app.update; // PostUpdate: handed to the fake
app.update; // First: answered, readable in Update
let = fake.last_request.expect;
assert_eq!;
let answers = app.world.;
let mut cursor = answers.get_cursor;
let answer = cursor.read.find.expect;
assert_eq!;
route(method, path, result): every matching request (method + URL path, newest route first) is answered with a copy ofresulton the next poll.resultcan be an error, e.g.Err(BackendError::Network("reset".into())).- Unrouted requests wait: answer them with
reply(id, result), or let the plugin's deadline answer them withTimeout.waiting(),requests(),last_request(),cancelled(),shutdown_count()let tests assert what the game sent. - The crate's own tests use
bevy_headless_test's strictTestApp(ambiguity detection on every main schedule); the plugin's systems are ordered, andRes<HttpClient>never conflicts.
9. Your own transport
HttpTransport is the seam: submit(id, PreparedRequest) (never block), poll() (every result
since the last call), and optionally cancel(id) and shutdown(). Wrap it in
HttpTransportRes::new(..) and insert it; the plugin keeps doing all the bookkeeping (deadlines,
cancel, exit, status and body-limit rules). Report each request at most once; a late or unknown
result is discarded. Methods added to HttpTransport later always come with a default
implementation.
10. WebSocket connections (feature ws)
For live data (chat, lobbies, match events, server pushes) open a named WebSocket
connection. Most games open one, "main"; a game that needs more simply opens another name.
Everything is keyed by that name: the state in WsConnections, the messages, the requests.
= { = "0.1.0", = ["ws"] }
use *;
use *;
use ;
/// A request: `{"id":7,"type":"chat.send","data":{"text":…}}` → `{"id":7,"ok":true,"data":{…}}`.
/// A server push: `{"type":"chat.message","data":{"from":…,"text":…}}`.
WsClient(a resource,Res<WsClient>, no ordering needed):connect(name, settings),disconnect(name),send_text/send_binary/send(fire and forget),request::<R>(typed, featurejson),request_raw(name, WsOutgoing),cancel(id). Applied inPostUpdate(BackendSystems::Send).- Messages out, all written in
Firstof the frame they arrive and all carrying the connection'sname:WsStateChanged { name, state, error },WsMessage { name, frame }(every data frame the server sends, text or binary),WsResponse<T>/WsRawResponse(answers), andWsPush<P>(typed pushes). - State:
WsConnections(resource):state(name),is_connected(name),get(name)→WsConnectionInfo(state, failed attempts, last error, pending requests, queued frames).WsStateisConnecting,Connected,Reconnecting { attempt, retry_in }orDisconnected(#[non_exhaustive]). Not BevyStates: a game maps it to its own states if it wants. WsSettings(builder): read timeout (default 20 ms, 5–250 ms; it is also roughly the latency added to every frame you send, because the thread sends between reads: measured median request round trips through a TLS proxy were 30 ms at 5 ms, 43 ms at the default 20 ms and 118 ms at 100 ms, against about 20 ms for HTTP), connect timeout (10 s, ONE deadline for TCP + TLS + handshake, at most 1 h), heartbeat (ping every 15 s, dead after 45 s without a single byte, at most 1 h), request timeout (10 s), message limit (1 MiB, incoming and outgoing), reconnect policy, handshake headers,allow_insecure_ws,without_credentials, the protocol, outbox (64 frames), resend (32) and waiting (64 requests) limits,with_auth_ack.- Reconnect: exponential backoff with full jitter (
WsReconnect: base 500 ms, cap 30 s, optionalwith_max_attempts, reset after 10 s connected,never()). Each attempt is aWsStateChangedwithReconnecting { attempt, retry_in }and the error that caused it. Permanent (not retried, the connection goesDisconnectedwith the error): a401/403handshake, a TLS / certificate error (unlessWsReconnect::with_tls_retry(true)), a close code 4000–4099, a refused first-message auth, a missing auth acknowledgement (with_auth_ack), invalid settings or a refused plainws://URL,disconnect, exhausted attempts. Everything else (connect failures, drops, timeouts, dead peers, 5xx) is retried. - Credentials from
BackendCredentialsgo on every handshake (headers and query; the request's purpose isRequestPurpose::WebSocketHandshake). A token changed while connected is used on the next reconnect (no forced reconnect).JsonBodyFieldcannot authenticate a handshake (it has no body): that is a clearInvalidRequest; use first-message auth instead:Credentials::ws_auth_message()returns a text frame sent first on every connection. Requests follow it at once (frames on one socket stay in order); a server that authenticates asynchronously can opt in toWsSettings::with_auth_ack(timeout): then nothing else goes out until the protocol reportsWsIncoming::AuthOk({"type":"auth.ok"}withJsonEnvelope); without it in time the waiting requests are answeredTimeout(honest about an earlier send), the link closes with 1008 and the connection goesDisconnectedwith that error. - Protocol: with feature
jsonthe default isJsonEnvelope: requests{"id":…,"type":…,"data":…}, answers{"id":…,"ok":true,"data":…}or{"id":…,"ok":false,"error":…}(answeredBackendError::Rejected), pushes{"type":…,"data":…}(a push may carry its ownidas long as it has nook), auth{"type":"auth.ok"}/{"type":"auth.failed",…}. ImplementWsProtocolfor another layout (payloads are bytes, so binary formats work);without_protocol()for raw frames only. - Plain
ws://only to loopback hosts unlessallow_insecure_ws(true), as for HTTP. TLS is the same rustls + ring setup as HTTP. No permessage-deflate compression.
WebSocket answers ("Sent?" as for HTTP):
| Answer | When | Sent? |
|---|---|---|
response / Rejected |
the server answered | yes |
Timeout("not sent: …") |
the connection (or the auth acknowledgement) did not come in time | no |
Timeout("no answer …") |
sent, no answer within the request timeout | yes |
Timeout("sent before the connection was lost, …") |
a resend request that went out, then the link dropped and it timed out waiting | yes (on the earlier link) |
Disconnected { sent, .. } |
the connection went away or was replaced / closed by the game; not connected when asked; too many waiting | as sent says |
Cancelled |
cancel |
maybe |
Shutdown |
the app exited first | no, unless it went out before the exit frame |
InvalidRequest / Encode |
unknown connection, no protocol, unregistered type, bad payload | no |
RequestTooLarge { limit, size } |
the request is larger than the message limit | no |
Requests made while a connection is opening or reconnecting wait for it (until their timeout,
64 at most). A server close frame is BackendError::Closed { code, reason } in WsStateChanged /
WsConnectionInfo::last_error (error.close_code()).
When a connection drops, requests already sent are answered Disconnected, except those marked
resend_on_reconnect (a WsOutgoing option, or WsRequest::resend_on_reconnect), which are sent
again after the reconnect; the default is off. Frames sent while not connected wait in an outbox
(64 by default) and go out when the connection opens.
11. SSH commands and SFTP (feature ssh, admin / dev builds only)
Never ship SSH to players. An SSH key (or access to an ssh-agent) inside a build you give to players is shell access to your server for anyone who extracts it — and extracting it is easy. SSH is for admin and developer tools that stay on your own machines: a deploy button in an editor build, a server console in an internal tool. Keys are loaded at runtime from the admin's machine; there is deliberately no way to pass key bytes. In a release build (
debug_assertionsoff) the plugin refuses every SSH request (answeredInvalidRequest, nothing connects) unless the tool opts in withBackendPlugin::default().with_ssh(SshSettings::default().allow_in_release(true)). Give the key a narrow account on the server (command="…",from="…",no-pty,no-port-forwardinginauthorized_keys, a dedicated user with narrow sudo rights).
= { = "0.1.0", = ["ssh", "sftp"] }
use *;
use *;
use ;
use Duration;
SshClient(a resource,Res<SshClient>, no ordering needed):connect(name, SshTarget),disconnect(name),run(name, command)(a&str,StringorSshCommand),cancel(id)(the shared cancel of every protocol), and withsftpthe file operations below. Applied inPostUpdate(BackendSystems::Send). A command made while its connection is connecting waits for it.- Messages out, all written in
First:SshStateChanged { name, state, error },SshOutput { id, name, stream, data }(stdout / stderr chunks as they arrive; 0..n per command, all before or in the same frame as its answer; a chunk can end in the middle of a line or a UTF-8 character,text()decodes lossily), and exactly oneSshFinished { id, name, started, result }per command. A non-zero exit status is stillOk(SshExit { status, signal, … }): the command ran;exit.success()checks for 0. - State:
SshConnections(resource):state(name),is_connected(name),get(name)→SshConnectionInfo(state, the server's host key fingerprint, last error, open requests, reconnect attempts).SshStateisConnecting,Connected,Reconnecting { attempt, retry_in }orDisconnected. Every name passed toconnectgets an entry, a refused one too (Disconnectedwith the error). Only open connections count againstwith_max_connections; beyond 256 remembered names the oldestDisconnectedones are forgotten. - Reconnect is OFF by default.
SshTarget::with_reconnect(SshReconnect::default())turns it on: exponential backoff with full jitter as for WebSocket (base 1 s, cap 30 s, optionalwith_max_attempts, the counter resets after 10 s connected). A reconnect never re-runs a command: commands running when the connection was lost are answeredDisconnectedwith their honeststarted; commands not sent yet wait for the new connection (until their own timeout). Host key, authentication, protocol (Ssh) and invalid-settings errors are not retried. Without it, a lost connection goesDisconnectedwith the error;connectagain. - Host keys are always checked. The server's key must be in a known_hosts file
(
with_known_hosts_file, any number; default~/.ssh/known_hostswhen neither a file nor a pinned fingerprint is given) or match a fingerprint pinned in code withtrust_host_key_fingerprint("SHA256:…")(only pin a fingerprint you read on the server itself, e.g.ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub). An unknown, changed or@revokedkey isBackendError::HostKey { host, fingerprint, problem }before anything is sent; there is no trust-on-first-use and nothing is ever written to known_hosts. The matcher follows OpenSSH: patterns with*/?/!, hashed hosts,[host]:portfor other ports,@revoked(wins over pins too). As OpenSSH does, the key types already listed for the host are asked for first, and only a different key of the same type isChanged; a host listed only with another type (an old RSA line, say) isUnknownfor the new type.@cert-authoritylines and host certificates are not supported. - Authentication (
SshAuth, tried in order):key_file(path),key_file_with_passphrase(path, passphrase)(OpenSSH, PKCS#8 or PuTTY format, at most 256 KiB; the passphrase is a redactedSecret),agent()(Unix:SSH_AUTH_SOCK; Windows: the OpenSSH agent's pipe, then Pageant; certificate identities are skipped). ed25519 and ECDSA keys always; RSA keys with featuressh-rsa(SHA-2 signatures only). Keys are the recommendation. For servers that need it, opt in toSshAuth::password(secret)orSshAuth::keyboard_interactive(responder)(multi-prompt flows such as a password plus a 2FA code;SshPromptAnswers::new().answer_containing("password", pw).answer_containing("code", otp)answers prompts by word, or implementSshPromptResponder; it runs on the SSH thread, must not block, at most 8 rounds; prompt texts are server-supplied). Collect the values from the admin at runtime; they are held inSecretand never logged or shown inDebug. If every method fails, the answer isBackendError::AuthFailednaming the methods tried (key file names, never paths or secrets). ~/.ssh/config:SshTarget::from_ssh_config("alias")(orfrom_ssh_config_file(path, alias)) takesHostName,Port,User,IdentityFileandConnectTimeoutfrom it when connecting; settings given in code win.Includeis followed by the crate itself, with limits (nesting depth 16 like OpenSSH, 64 files, 1 MiB for all files together;~and relative paths as OpenSSH, relative to~/.ssh; globs): a config that includes itself is an error, not a crash.Match,%tokens,ProxyJump/ProxyCommandandUserKnownHostsFileare not supported (the connection goes straight to the host).SshTarget(builder, per connection): port (22), user, auth, known_hosts files, pins, connect timeout (15 s: ONE deadline for TCP + key exchange + host key + authentication, a server that trickles bytes cannot stretch it), keepalive (every 15 s of silence, lost after 3 unanswered), command timeout (60 s), output limit (8 MiB), SFTP timeout (5 min) and transfer limit (256 MiB), channels (8 commands at once; more wait, counted in their timeout).SshCommand:with_timeout,with_max_output_bytes,with_stdin(bytes)(then end-of-file; without it stdin is closed at once).with_reconnect,allow_terrapin_vulnerable(below).SshSettings(plugin):allow_in_release,with_max_connections(16 open at once),with_max_requests_per_connection(256). The release guard is also built intoRusshTransportitself (RusshTransport::new().with_release_allowed(..); the plugin passesallow_in_releaseon), so calling the transport directly does not bypass it.- Command lines may hold secrets:
SshCommand'sDebugshows only lengths, and the crate never logs a command or its output. Remember that a command line is visible in the server's process list: pass a secret throughwith_stdininstead. - Security (Terrapin, CVE-2023-48795): strict key exchange is always offered, and AES-GCM is
the preferred cipher (it is not affected), so servers without strict key exchange (OpenSSH before
9.6 without a distribution backport) still connect through AES-GCM. Only the truly vulnerable
combination is refused: no strict key exchange AND ChaCha20-Poly1305 or CBC with an
encrypt-then-MAC MAC negotiated (a server that offers nothing else); the
Ssherror says why.SshTarget::allow_terrapin_vulnerable(true)(off by default, logged as a warning) accepts it anyway, for an old server you cannot update. SHA-1ssh-rsasignatures are never used. - Requests on one connection run at the same time (commands and SFTP operations alike): when one step depends on another (upload, then move), start it after the first one's answer.
SSH answers:
| Answer | When | started |
|---|---|---|
Ok(SshExit) |
the command ended (any exit status or signal) | Some(true) |
Timeout("not sent: …") |
no free channel, or the connection did not open in time | Some(false) |
Timeout(…) otherwise |
ran longer than its timeout: TERM signal sent, channel closed (the remote process may keep running, see below) | Some(true) (or None if the exec reply never came) |
Cancelled |
cancel(id): TERM and close as for a timeout |
Some(true) if it was running, Some(false) if it was still waiting, None in between |
BodyTooLarge { limit } |
its output went over the limit; it was stopped | Some(true) |
Disconnected { sent, .. } |
the connection went away / was closed / was replaced (also with reconnect on: a running command is never re-run); or not connected when asked | as sent |
Shutdown |
the app exited first (a command of the exit frame is never sent) | as far as known |
Ssh(…) |
the server refused the channel or the exec request | Some(false) |
InvalidRequest |
unknown connection, bad command (empty, NUL), too many requests, SSH disabled in a release build | Some(false) |
RequestTooLarge { limit, size } |
the command line is over 64 KiB, or (SFTP) an upload is over the transfer limit | Some(false) |
Stopping a remote command is best effort: SSH has no reliable kill. The crate sends a TERM
signal and closes the channel; a server may ignore the signal, and a process without a terminal
may keep running after its channel is gone. Commands that must stop should
have their own timeout on the server (timeout 30 ./deploy.sh).
SFTP (feature sftp)
use *;
use *;
# let _ = ;
upload(name, remote, bytes), upload_file(name, local, remote) (both create or truncate the
remote file), download(name, remote) (into memory: SftpOutcome::Data),
download_file(name, remote, local) (written as <local>.part, renamed over local only when
complete; the part file's name is unique, <local>.<process>-<id>-<n>.part, so nothing else is
overwritten; it is removed on failure, but a transfer killed after the 1 s grace can leave it),
list_dir (Listing(Vec<SftpEntry>), sorted, at most 10 000 entries, names at most 4 KiB, 4 MiB
of names in total), create_dir, remove_file, remove_dir (empty directories), rename, or any
SftpOp with sftp(name, op). Each gets exactly one SftpFinished { id, name, started, result };
transfers also report SftpProgress (about 10 per second). Relative remote paths start in the
login directory. A download over with_max_transfer_bytes is BodyTooLarge; an upload over it
(from memory or from a file) is RequestTooLarge, refused before anything is sent
(started: Some(false)); a whole operation is bounded by with_sftp_timeout. An
interrupted upload can leave a partial remote file; so can a local file that grows or shrinks
during upload_file, which is answered Ssh("the local file changed size …"). Errors carry the server's SFTP status text
(Ssh("SFTP: No such file")). The SFTP channel is opened on first use and shared by the
connection's operations. Local files are read and written on tokio's small blocking pool, never on
the SSH thread itself.
Listed names are untrusted input. A hostile or broken server can list
../../.bashrc,C:\Windows\evil.dllora/b. Never joinSftpEntry::nameinto a local path: useentry.safe_file_name(), which returnsNonefor anything that is not one plain file name (.., separators, drive letters, control characters, Windows device names, …). The crate never turns a listed name into a local path itself;download_filewrites only where you tell it to.
12. File uploads (multipart)
Upload files the way a browser form does: multipart/form-data (RFC 7578). Part of http: no
extra feature, no new dependency, nothing to configure app-wide. Avatars, screenshots, replays,
bug reports and cloud saves go this way (SFTP is for admin tools only).
use *;
use *;
use Deserialize;
#
- Builder:
Multipart::new(),.text(name, value),.file(name, filename, content_type, bytes)(an empty content type meansapplication/octet-stream; text parts have no content type, as in a browser), repeated names allowed (photos[]twice),.with_max_bytes(n)(the whole encoded body, part headers included, default 32 MiB: a single file of exactly 32 MiB is just over it),.with_max_parts(n)(default 256, at most 10 000),len(),is_empty(),encoded_len(). - Sending:
HttpClient::post_multipart(path, &form)(rawHttpResponse),post_multipart_json::<T>(path, &form)(typed, featurejson),send_multipart(method, path, &form)(PUT,PATCH, …), orOutgoingRequest::with_multipart(&form)for headers, query and a longer timeout on a big upload (with_timeout). Everything else is as for any HTTP request: one answer, the sharedcancel,InFlight, the plain-http rule, and credentials applied as headers or query (BearerToken,ApiKeyHeader,ApiKeyQuery).JsonBodyFieldcannot go into a form: such a request is answeredInvalidRequestand not sent (use a header credential, or add the field to the form yourself). A form later replaced bywith_json,with_bodyorset_bodyis an ordinary request again. - Refused before sending (answered, never sent,
was_sent() == Some(false)): a form overwith_max_bytesisRequestTooLarge { limit, size }; more parts thanwith_max_parts, an empty field name, an invalid content type, and a name or file name that ends with a backslash or contains a control character (NUL, TAB, …; CR and LF are escaped instead) areInvalidRequest. A trailing backslash would turn the closing quote into an escaped one for most parsers: in the live test every real framework lost such a part (dropped it, or turned the file into a text field). - Bytes only: there is no "upload this path" helper, so no file is read on the main thread. Load the bytes first (at startup, from an asset, in an async task). Encoding copies the bytes once and scans them for the boundary on the calling thread (measured: about 4 ms per 10 MiB and 13 ms for 32 MiB in a release build on a desktop PC). Memory: while it is sent, the form and its encoded body both exist, so the peak is about twice the form (three times if you also keep your own copy).
- Encoding details: a fresh 128-bit random boundary per request (
bnb-+ 32 hex digits, from the operating system through ring), checked not to occur in any part; CRLF line breaks; the headerContent-Type: multipart/form-data; boundary=…; a fixedContent-Length(never chunked). InContent-Disposition, names and file names are escaped as browsers do (WHATWG HTML):"→%22, CR →%0D, LF →%0A, everything else (backslashes, non-ASCII as UTF-8) unchanged, nofilename*. Text values are sent exactly as given (line breaks are not rewritten).Debugof a form shows names and sizes only.
How backends read the same upload (display_name text + avatar file):
| Backend | Text field | File (name / type / size) | Repeated names |
|---|---|---|---|
| Laravel | $request->input('display_name') |
$request->file('avatar'): getClientOriginalName(), getClientMimeType(), getSize() |
name them photos[]: $request->file('photos') is an array |
| Plain PHP | $_POST['display_name'] |
$_FILES['avatar']['name'], ['type'], ['size'], ['tmp_name'], ['error'] |
photos[] (without [] only the last one is kept) |
| Express + multer | req.body.display_name |
upload.single('avatar') → req.file.originalname, .mimetype, .size, .buffer |
upload.array('photos'): the name must match exactly (photos or photos[]) |
Go (net/http) |
r.FormValue("display_name") after r.ParseMultipartForm(max) |
f, h, _ := r.FormFile("avatar"): h.Filename, h.Header.Get("Content-Type"), h.Size |
r.MultipartForm.File["photos"] (same name, no brackets needed) |
| Django | request.POST['display_name'] |
request.FILES['avatar']: .name, .content_type, .size |
request.FILES.getlist('photos') |
| FastAPI | display_name: str = Form() |
avatar: UploadFile: .filename, .content_type, await avatar.read() (needs python-multipart) |
photos: list[UploadFile] |
| Spring | @RequestParam("display_name") String |
@RequestParam("avatar") MultipartFile: getOriginalFilename(), getContentType(), getSize() |
@RequestParam("photos") List<MultipartFile> |
| Rails | params[:display_name] |
params[:avatar]: original_filename, content_type, size |
photos[]: params[:photos] is an array |
| ASP.NET Core | [FromForm] string display_name |
IFormFile avatar: FileName, ContentType, Length |
List<IFormFile> photos |
| axum | the Multipart extractor: field.name(), field.text().await |
field.file_name(), field.content_type(), field.bytes().await |
every part is its own field; group them yourself |
Array naming: PHP, Laravel and Rails turn photos[] into an array (without brackets they keep
only the last value); the others read repeated names as a list and see photos[] literally as
the name. Use what your backend expects.
What real backends do with an upload
Measured by sending the same 38 upload scenarios from this crate to real servers with their
default settings: PHP 8.3 (stock php.ini), Express 4.21 + multer 2.0.2, FastAPI 0.115
(Starlette 0.46, python-multipart 0.0.20), Go 1.22 net/http and Django 5.2. Every file that
arrived had the right size and CRC-32. What differed was limits, file names and parts a
framework dropped:
| PHP 8.3 | Express + multer 2.0.2 | FastAPI / Starlette | Go 1.22 | Django 5.2 | |
|---|---|---|---|---|---|
| Over a size limit | a file over upload_max_filesize (2 MB): 200, the file has error 1 and no data; a body over post_max_size (8 MB): 200 with an empty form |
a text field over 1 MB (fieldSize): 500 (MulterError, Express's default handler); limits.fileSize likewise 500 |
a text part over 1 MiB: 400 | no default limit (33 MB accepted): the handler must set one (http.MaxBytesReader) |
text over 2.5 MiB (DATA_UPLOAD_MAX_MEMORY_SIZE): 400 |
| Many files | keeps 20 (max_file_uploads) and silently drops the rest (200) |
all 25 kept | all kept | all kept | more than 100 files: 400 |
Repeated photos without [] |
only the last is kept | all kept | all kept | all kept | all kept |
Non-ASCII names (mentés.json, 😀) |
exact | mojibake in file names AND field names (read as latin1) | exact | exact | exact |
Empty file name "" |
a file with error 4 (no file), no data |
a text field | a file named "" |
a text field | a text field |
| CSRF | – | – | – | – | 403 without @csrf_exempt, whatever the body |
-
multer mojibake: convert each name back with
Buffer.from(name, 'latin1').toString('utf8')(checked exact). multer'sdefParamCharsetoption has no effect in multer 2.0.2. -
"in a file name arrives as a literal%22on every one of them (nothing decodes it). -
Everything else was read the same everywhere: text before and after files,
tags[]twice, an empty value, CRLF inside a value, a 0-byte file, binary data containing--and CRLF lines, an empty form, 20 uploads in parallel. -
Paths and backslashes in file names are read three different ways, so send a plain name:
File name sent PHP multer FastAPI Go Django a\b.pngb.pngb.pnga\b.pnga\b.pngb.pngC:\Users\me\a.pnga.pnga.pnga.pngC:\Users\me\a.pnga.pngdir/file.pngfile.pngfile.pngdir/file.pngfile.pngfile.pngx\(the crate refuses it)x"file dropped x\file dropped file dropped
Documented, not live-tested (from the frameworks' documentation):
- ASP.NET Core: Kestrel's
MaxRequestBodySizedefaults to 30,000,000 bytes, below this crate's 32 MiB default: lowerwith_max_bytesor raise the server limit.FormOptionsallows 1024 form values by default. - Spring Boot:
spring.servlet.multipart.max-file-size1 MB,max-request-size10 MB. - Laravel: PHP's limits above apply (Laravel answers
413for a body overpost_max_size). - Rails (Rack): percent-decodes file names (
%22becomes") and accepts at most 128 files. - nginx
client_max_body_size1 MB and axumDefaultBodyLimit2 MB answer413. - CSRF protection refuses a game's POST whatever its body: Laravel
webroutes (419), Railsprotect_from_forgery(422), like Django's measured 403 above. Use API routes (Laravelroutes/api.php), or exempt the endpoint, and authenticate with a token instead.
Advice:
- For PHP and Laravel, name repeated fields
photos[], and keep at most 20 files per form (or raisemax_file_uploads). - Keep file names ASCII-safe and plain: letters, digits,
-,_,.; no path, no\, no". Keep the original name in a text field if you need it. - Set the server's limits explicitly (upload size, body size, file count, text field size) and
keep
with_max_bytesat or below them. Do not rely on a413: only proxies with a body limit (nginx's default 1 MB, Caddy'srequest_bodywhen configured) and some frameworks send one. PHP answers 200 with missing data (check$_FILES[..]['error']and that the fields arrived), multer 500, FastAPI and Django 400, Go whatever the handler decides. A413or any other status is a normalStatusanswer (error.status()); a server that closes the connection mid-upload is aNetworkerror. - Put text fields before files if the server streams files to disk: multer documents that its disk storage only sees the fields sent before a file (not measured here; the live test used memory storage, where the order did not matter).
How it works
game system ──Res<HttpClient>──▶ queue ─┐
│ PostUpdate BackendSystems::Send
▼ defaults + credentials + URL checks
InFlight map ──submit──▶ HttpTransport (UreqTransport:
▲ N worker threads, one ureq Agent)
│ First BackendSystems::Receive
poll ◀─────────┘ deadlines, status + body-limit rules
│
▼
HttpResponse / JsonResponse<T> ──▶ PreUpdate / Update readers
- One owner of every answer. The plugin's
InFlightmap (ECS side) is the only thing that answers requests. The transport only reports; a result for an id that is no longer waiting is dropped. So every request gets exactly one answer, whatever the network does. - Scheduling.
BackendSystems::Receiveruns inFirst, after Bevy'sTimeSystemsand beforeMessageUpdateSystems, so answers are readable inPreUpdate/Updateof the frame they arrive.BackendSystems::Sendruns inPostUpdate, so a request made inUpdategoes out the same frame (one made after it goes out next frame). A game system inPostUpdatethat fires requests should be ordered.before(BackendSystems::Send)if the frame matters: both only readHttpClient, so the strict ambiguity check cannot flag the race.BackendSystems::Exitruns inLastonly in a frame withAppExit. The sets are#[non_exhaustive]and phase-named, so a later kind of connection can use the same three. - Threads. ureq is blocking.
UreqTransportstarts its worker threads (namednet-backend-N) on the first request and shares oneureq::Agent(keep-alive connection pool) between them. At mostworkersrequests are on the wire; the rest wait in a queue, and that wait counts against their timeout. A request answered while it waits (cancel, timeout, exit, transport removed or replaced) is dropped from the queue, never sent. Worker results come back over a channel thatpolldrains without blocking. A panic inside the HTTP client is caught and answered asNetwork(with the usual unwinding panics; a game built withpanic = "abort"aborts instead). - Timeouts. A request's timeout counts from the moment it is handed to the transport. The
worker gives ureq what is left of it (a request that waited its whole timeout in the queue is
answered
Timeout("not sent: …")). As a backstop the plugin answersTimeoutitself when a request is still waiting after its timeout + 5 s (DEADLINE_GRACE, measured onTime<Real>, or a monotonic clock withoutTimePlugin).Time<Real>follows Bevy'sTimeUpdateStrategy: with aManual*strategy (replays, some headless servers) the backstop fires on that clock, early or late; ureq's own timeout always uses the wall clock. - Status codes. The transport returns every status; the plugin turns anything outside
200–299 into
Status. Redirects are not followed (ureqmax_redirects(0)), so a redirect can never downgradehttps://tohttp://or carry credentials to another host. - Body limit. The response body is read with a cap (
max_body_bytes) on the bytes on the wire AND on the bytes after gzip decoding (featuregzip), so a gzip bomb stops at the limit; the plugin checks it again for any transport. - JSON decoding runs on the main thread in
Receive, in the frame the answer arrives. A multi-megabyte answer can cost that frame a few milliseconds; keep big payloads raw (HttpResponse) or small. - WebSocket threads (feature
ws). Each connection attempt runs on its own std thread (net-backend-ws-link#N): TCP, then rustls forwss://, then the tungstenite handshake, then a loop: send what the game queued, ping when due, flush, one read whose socket reads together stop after one read timeout (Windows reports a read timeout asTimedOut, Unix asWouldBlock; both mean "no data"), check for a dead peer (no byte fordead_after). The handshake (TCP + TLS + upgrade) runs under ONE deadline. Both limits sit under rustls and tungstenite, so a peer that trickles bytes can neither stretch the handshake nor starve outgoing frames and pings. An idle connection wakes about 50 times a second, and a frame you send goes out within about one read timeout, plus the time the socket needs for earlier outgoing data. The heartbeat runs in the thread, so it keeps going while the game does not tick. Reconnects, backoff, credentials and every answer live on the ECS side; a reconnect is a new thread. On exit a close (1001) is queued to every link and no thread is joined (a process that exits right away usually wins that race). - SSH thread (feature
ssh). russh needs tokio, soRusshTransportowns ONE std thread (net-backend-ssh) with a tokio current-thread runtime, started on the firstconnectand stopped when the app exits (or the transport is dropped); tokio's blocking pool is capped at 2 threads (net-backend-ssh-io, for DNS lookups and decrypting key files). Nothing runs on the game's threads or Bevy's task pools, and without featuresshtokio is not even compiled. Every connection is a task on that thread; every command or SFTP operation is a task of its connection with its own deadline. The socket of each connection sits behind a kill switch tied to its task, so a connection that times out or is closed never lingers in the background. On exit the plugin answers everythingShutdownfirst; then the thread closes each connection (≤ 1 s to say goodbye) and ends, and the game does not wait for it. - TLS. rustls with ring's crypto and the Mozilla root certificates (webpki-roots; the OS certificate store is not used). The ring provider is always handed to ureq explicitly and never installed process-wide, so a game that also links another rustls provider (for example aws-lc-rs through another crate) neither changes this crate's TLS nor triggers rustls' provider-selection panic.
TLS exception: the default build is not pure Rust
bevy_net_backend is written in Rust, but its HTTPS stack is not pure Rust. TLS is done by
rustls, whose cryptography comes from ring, and ring compiles C code and ships assembly. A C
compiler is needed at build time (MSVC, clang or gcc, which Rust toolchains normally have at
hand). ring was picked because it is mature and widely deployed, needs no CMake or NASM, and adds
no system library. The crate never uses OpenSSL, native-tls or aws-lc, in any feature set.
Without the http feature nothing of this is compiled (and no C either).
SSH (feature ssh) uses the same ring crate for its AEAD ciphers (ChaCha20-Poly1305, AES-GCM);
everything else in russh (key exchange, ed25519 / ECDSA, AES-CTR, HMAC) is RustCrypto. It never
uses OpenSSL, libssh2 or aws-lc either.
API reference
Everything is re-exported at the crate root; prelude holds the everyday items.
| Item | Kind | What it is |
|---|---|---|
BackendPlugin |
plugin | new(config), with_config, with_ssh (feature ssh), default(). Inserts config, client, in-flight map, credentials, HttpResponse, and (feature http) a UreqTransport unless an HttpTransportRes exists. |
BackendSystems |
system sets | Receive (First), Send (PostUpdate), Exit (Last, on AppExit). #[non_exhaustive]. |
HttpConfig |
resource | base URL, timeout, default headers, workers, allow_insecure_http, body limit; validate(), getters, set_base_url, set_timeout. |
ConfigError |
enum | NoBaseUrl, BadBaseUrl, BadHeader. |
HttpClient |
resource | send, request, get, cancel; post_multipart, send_multipart (feature http); with json: send_json, get_json, post_json, post_multipart_json, is_json_registered. |
Multipart |
builder (http) |
new, text, file, with_max_bytes, with_max_parts, len, is_empty, encoded_len. Debug shows names and sizes only. |
DEFAULT_MULTIPART_MAX_BYTES, DEFAULT_MULTIPART_MAX_PARTS |
consts (http) |
32 MiB, 256. |
BackendAppExt |
trait on App |
add_json_response::<T>() (feature json); add_ws_request::<R>(), add_ws_push::<P>() (features ws + json). Sealed. |
RequestId |
id | opaque, unique per process, Copy + Eq + Hash + Ord + Display. |
OutgoingRequest |
request | constructors, with_* builders, accessors (method, path, query, headers, body, timeout, purpose, uses_credentials, is_multipart, error), query_mut, headers_mut, set_body, reject; with_multipart (feature http). |
RequestPurpose |
enum | Http, WebSocketHandshake. |
HttpResponse |
message | id, result: Result<RawResponse, BackendError>. |
JsonResponse<T> |
message | id, result: Result<T, BackendError> (feature json). |
RawResponse |
struct | status, headers, body; new, with_header, is_success, body(), text(), json(). |
BackendError |
enum | see Reading answers and errors; status(), response(), is_invalid_request(), was_sent(), close_code(), host_key(..), request_too_large(..) (constructors for fakes). |
Credentials |
trait | apply(&self, &mut OutgoingRequest); ws_auth_message() (default none: a first frame for WebSocket auth). |
BackendCredentials |
resource | new, set, clear, is_set. |
BearerToken, ApiKeyHeader, ApiKeyQuery, JsonBodyField |
credentials | ready-made Credentials (JsonBodyField: feature json). |
Secret |
string | redacted in Debug / Display; new, expose, is_empty. No comparison, no zeroing on drop. |
InFlight |
resource | HTTP, WebSocket and SSH: contains, len, is_empty, ids, describe (→ RequestInfo). |
RequestInfo (struct), RequestKind (enum) |
types | what a pending request is: kind (Http, WebSocket, Ssh, Sftp), method, target (never an SSH command line); #[non_exhaustive]. |
Rejection |
struct | the payload of BackendError::Rejected: new, bytes, text, json (json). |
HttpTransport |
trait | submit, poll, cancel, shutdown. |
HttpTransportResult |
type | Result<RawResponse, BackendError>. |
HttpTransportRes |
resource | new(transport). |
PreparedRequest |
struct | what a transport receives: method, uri, headers, body, timeout, max_body_bytes, purpose; path(), is_loopback(), is_https(). |
FakeHttpTransport |
transport | new, route, clear_routes, reply, requests, last_request, waiting, cancelled, shutdown_count. |
UreqTransport |
transport | feature http: new(&config), workers(). |
http |
crate | the http 1.x crate, re-exported (Method, StatusCode, HeaderMap, …). |
DEFAULT_TIMEOUT, MAX_TIMEOUT, DEFAULT_WORKERS, MAX_WORKERS, DEFAULT_MAX_BODY_BYTES, DEADLINE_GRACE |
consts | 15 s, 1 h, 2, 8, 10 MiB, 5 s. |
WsClient |
resource (ws) |
connect, disconnect, send, send_text, send_binary, request_raw, request (json), cancel (the shared one). |
WsConnections, WsConnectionInfo |
resource / struct (ws) |
get, state, is_connected, iter; info: state, attempt, last_error, pending_requests, queued_frames. |
WsName |
name (ws) |
a connection's name; From<&str> / From<String>, as_str, compares with &str. |
WsState |
enum (ws) |
Connecting, Connected, Reconnecting { attempt, retry_in }, Disconnected; #[non_exhaustive]. |
WsSettings, WsReconnect |
builders (ws) |
per connection: timeouts, heartbeat, limits, reconnect, headers, allow_insecure_ws, without_credentials, protocol; backoff: base, cap, max attempts, stable-after, jitter, never(), delay_bound. |
WsFrame, WsOutgoing |
types (ws) |
a text / binary frame (Debug shows the length only); a raw request (kind, payload, resend_on_reconnect, with_timeout). |
WsStateChanged, WsMessage, WsRawResponse |
messages (ws) |
state changes (with the error), every data frame, raw answers; all with name. |
WsRequest, WsPushMessage, WsResponse<T>, WsPush<P>, JsonEnvelope |
(ws + json) |
typed requests (Response, KIND, resend_on_reconnect), typed pushes (KIND), their messages, the default protocol. |
WsProtocol, WsIncoming |
trait / enum (ws) |
encode_request, decode, retry_after_close; Response { wire_id, result: Result<bytes, bytes> }, Push, AuthOk, AuthFailed, Ignore. |
DEFAULT_WS_READ_TIMEOUT, DEFAULT_WS_MAX_MESSAGE_BYTES |
consts (ws) |
20 ms, 1 MiB. |
WsTransport, WsTransportRes, WsLinkId, WsLinkEvent, WsHandshake |
seam (ws) |
open, send, close, poll, shutdown; one link = one connection attempt. |
FakeWsTransport |
transport (ws) |
manual_accept, accept, reject_next, echo_envelope, push, drop_link, fail_link, opened, last_link, live_links, sent, all_sent, closed, shutdown_count. |
TungsteniteTransport |
transport (ws) |
the real one; new(). |
SshClient |
resource (ssh) |
connect, disconnect, run, cancel (the shared one); with sftp: upload, upload_file, download, download_file, list_dir, create_dir, remove_file, remove_dir, rename, sftp. |
SshTarget |
builder (ssh) |
new(host, user), from_ssh_config, from_ssh_config_file, with_port, with_user, with_auth, with_known_hosts_file, trust_host_key_fingerprint, timeouts, keepalive, limits, with_max_channels, with_reconnect, allow_terrapin_vulnerable, validate, getters. |
SshAuth |
auth (ssh) |
key_file, key_file_with_passphrase, agent; opt-in password, keyboard_interactive. Debug shows file names only, never secrets. |
SshPromptResponder, SshPromptAnswers, SshPromptRequest, SshPrompt |
auth (ssh) |
keyboard-interactive: the responder trait (respond(&request) -> Option<Vec<Secret>>), a ready-made word-matching responder (answer_containing), one round of server prompts (name, instructions, prompts: text, echo). |
SshReconnect |
builder (ssh) |
with_base, with_cap, with_max_attempts, with_stable_after, with_jitter, delay_bound; given with SshTarget::with_reconnect. |
SshCommand, SshExit, SshStream |
types (ssh) |
a command (new, with_timeout, with_max_output_bytes, with_stdin; From<&str>); how it ended (status, signal, byte counts, success()); stdout / stderr. |
SshSettings |
builder (ssh) |
plugin-wide: allow_in_release, with_max_connections, with_max_requests_per_connection, is_allowed. Given with BackendPlugin::with_ssh. |
SshConnections, SshConnectionInfo, SshState, SshName |
state (ssh) |
as for WebSocket; info: state, fingerprint, last_error, pending_requests, attempt; SshState::Reconnecting { attempt, retry_in }. |
SshStateChanged, SshOutput, SshFinished |
messages (ssh) |
state changes; output chunks; the one answer per command (started, result). |
SftpOp, SftpOutcome, SftpEntry, SftpEntryKind, SftpProgress, SftpFinished |
types / messages (sftp) |
an operation, its outcome (Uploaded, Downloaded, Data, Listing, Done), a listing entry (safe_file_name(); name is untrusted), progress, the one answer. |
HostKeyProblem |
enum | Unknown, Changed, Revoked (in BackendError::HostKey). |
SshTransport, SshTransportRes, SshEvent, SshConnId |
seam (ssh) |
connect, run, sftp + supports_sftp (default: none), cancel, close, poll, shutdown. |
FakeSshTransport |
transport (ssh) |
manual_connect, accept, reject_next, on_command, output, finish, drop_conn, on_next_sftp, sftp_progress, sftp_finish, connects, last_conn, live_conns, commands, running, sftp_ops, cancelled, closed, shutdown_count. Never touches the network. |
RusshTransport |
transport (ssh) |
the real one; new(), with_release_allowed. |
DEFAULT_SSH_CONNECT_TIMEOUT, DEFAULT_SSH_COMMAND_TIMEOUT, DEFAULT_SSH_MAX_OUTPUT_BYTES, DEFAULT_SFTP_TIMEOUT, DEFAULT_SFTP_MAX_BYTES, MAX_SSH_COMMAND_BYTES |
consts (ssh) |
15 s, 60 s, 8 MiB, 5 min, 256 MiB, 64 KiB. |
Limits and what it does not do
-
Native only (Windows, Linux, macOS). No WebAssembly in this version.
-
HTTP/1.1 only (ureq). No HTTP/2, no streaming bodies: a response is read whole (up to the body limit) before it is delivered.
-
No redirects followed: a 3xx arrives as
Statuswith itsLocationheader. -
No retries, no offline queue, no caching, no cookies. Retry in your game if a request matters; the error kind and the "Sent?" column in Reading answers and errors tell you whether it may have been sent.
-
No token refresh, no keyring: credentials are whatever the game puts into
BackendCredentials. -
One base URL per app. Paths are relative to it; absolute URLs are refused.
-
A reverse proxy in front of your API must pass responses through unchanged: no decompressing or recompressing on its own (Caddy: no
encodedirective for these routes; nginx:gzip off). The body limit and gzip handling assume the client sees exactly what your application sent; a proxy that re-encodes can turn a body under the limit into one over it, or add aContent-Encodingthe client (without featuregzip) cannot read. -
Root certificates come from webpki-roots (Mozilla's list), not the OS store: a private CA or a corporate TLS-inspecting proxy is not trusted.
-
Cancel does not interrupt a request already on the wire; it holds its worker thread until ureq's timeout at most.
-
Uploads are built in memory (the whole form, at most
with_max_bytes, about twice that at the peak while sending): no streaming from a file, no "upload this path" helper, no upload progress messages. A text part cannot carry its own content type (e.g. a JSON part for Spring's@RequestPart);file(..)always adds a file name. -
WebSocket (feature
ws): no permessage-deflate (a server that requires compression cannot be used), no subprotocol negotiation helper (setSec-WebSocket-Protocolwithwith_header), one thread per connection (fine for a few connections, not for hundreds). A large message you send occupies its connection thread until the socket takes it (that time does not count as the server's silence); if the server accepts no data for 30 s (ordead_after, if longer), the connection ends with aTimeoutsaying so. Attracelevel tungstenite prints the whole handshake request,Authorizationand query included: keeptungstenitebelowtracelikeureq. Received frames the game does not take are limited to 32 times the message limit per connection (then it closes with 1008). -
SSH (feature
ssh): reconnect only when enabled and never for a running command; no agent or port forwarding, no PTY / interactive shell, no host certificates; ssh_config withoutMatch,%tokens,ProxyJump/ProxyCommandorUserKnownHostsFile. Output arrives in chunks, not lines. A cancelled or timed-out remote process may keep running (see the SSH section). SFTP downloads read 64 KiB at a time, one request after the other (uploads keep 16 writes of 32 KiB in flight), so a download's speed is bounded by the round trip. russh logs agent sign requests atdebug(challenge bytes, not secrets) and packet details attrace: keeprusshatinfoor below likeureq. A lost connection is noticed through russh's own disconnect report, backed by a once-a-second check of the session; a lost network without any reset is noticed by the keepalive (15 s, 3 misses).
Versions
| bevy_net_backend | Bevy | ureq | tungstenite (ws) |
russh (ssh) |
rustls | Rust (MSRV) |
|---|---|---|---|---|---|---|
| 0.1.0 | 0.19.0 | 3.4.2 | 0.30.0 | 0.63.3 | 0.23.45 | 1.95 |
Examples
All examples are headless and exit on their own. Without BACKEND_URL they start the mock server
from examples/mock_server.rs on 127.0.0.1 inside the example process.
| Example | Shows |
|---|---|
fetch_json |
get_json::<Character>, matching the answer by id, error bodies. |
upload |
a multipart/form-data avatar upload (a text field + an image) with post_multipart_json; the mock parses it and answers what it received. |
post_with_token |
401 before login, login without_credentials, BearerToken, a 422 validation error decoded from the error body, a successful authenticated POST. |
mock_server |
the mock API on its own and its JSON contract (including POST /upload, a real multipart parser that echoes what it received): --seconds N (maximum runtime, default 60; it exits by itself), --bind ADDR (default 127.0.0.1:0). |
chat_client (features ws, json) |
a named connection, a typed request and its answer, typed pushes, state changes, disconnect. Starts mock_ws_server unless BACKEND_WS_URL is set. |
mock_ws_server (features ws, json) |
the mock WebSocket server and its envelope contract (echo, chat.send + push, fail, close, drop, stall, periodic server.tick, /secure needing a bearer token): --seconds N, --bind ADDR (default 127.0.0.1:0), --tick-ms N. |
ssh_console (feature ssh; SFTP steps with sftp) |
connect with a known_hosts file, run commands and print their output and exit, then upload, list, download and remove a file one step after the other, disconnect. Starts mock_ssh_server with a throwaway key (written to target/ssh-example/) unless SSH_HOST, SSH_USER, SSH_KEY and SSH_KNOWN_HOSTS are set. |
mock_ssh_server (feature ssh; SFTP with sftp) |
the mock SSH server: canned commands (never executes anything), an in-memory SFTP file system, a throwaway host key; alone it writes a throwaway client key and a known_hosts file to --out-dir (default target/mock-ssh): --seconds N, --bind ADDR (default 127.0.0.1:0), --user NAME, --host NAME (the name clients reach it by, for the known_hosts line; with --bind 0.0.0.0:P and no --host the line says CHANGE-ME, or pin the printed fingerprint), --password PW and --kbd PW:CODE (also accept a password / a keyboard-interactive Password: + Verification code: login; throwaway test values only, visible in the process list). |
cargo run --example fetch_json
cargo run --example post_with_token
cargo run --example upload
cargo run --example mock_server -- --seconds 120
cargo run --example mock_server -- --seconds 1800 --bind 127.0.0.1:8080
BACKEND_URL=http://127.0.0.1:8000/api cargo run --example fetch_json
cargo run --example chat_client --features ws,json
cargo run --example mock_ws_server --features ws,json -- --seconds 1800 --bind 127.0.0.1:9001
cargo run --example ssh_console --features ssh,sftp
cargo run --example mock_ssh_server --features ssh,sftp -- --seconds 600
Pointed at your own backend (BACKEND_URL), fetch_json expects GET /characters/1 →
{"id":1,"name":"…","class":"…","level":7}, and post_with_token expects the routes in its
header comment (the login reads BACKEND_USERNAME / BACKEND_PASSWORD).
How it's tested
- 271 tests with all features (unit tests, integration tests and every Rust block of this
README), plus 11 live tests that are
#[ignore]d by default. CI runs the tests for 15 feature combinations on Linux, Windows and macOS with Rust 1.96.0, clippy with-D warningsfor every combination, rustfmt, the docs with-D warnings, a build with the minimum Rust version (1.95), dependency-tree checks (one ring, one rustls, no tokio withoutssh, no OpenSSL / native-tls / aws-lc / libssh2) and a RustSec advisory check (cargo-deny) on every pull request, every push and weekly. RUSTSEC-2023-0071 (rsa, Marvin) is accepted indeny.toml:rsais compiled only with the opt-inssh-rsafeature, butCargo.lockalways lists it, and no fixed release exists. - An adversarial review every round: each part (core + HTTP, WebSocket, SSH / SFTP, uploads) was reviewed line by line against its specification and this README before it was accepted, and every finding was fixed or documented as a known limit.
- Hostile and slow servers are part of the regular suite: the real transports run against mock servers on 127.0.0.1 inside the test process: gzip bombs, bodies over the limit, servers that answer too late, trickle a handshake or a TLS record byte by byte, stop reading while the client sends 32 MiB, send a 2 MB SSH banner or never say anything. Tests never contact another host.
- Live-tested against a real server: an Ubuntu machine on the internet behind Caddy with a real
Let's Encrypt certificate.
- HTTP: the real certificate chain accepted, and a wrong-name and an untrusted certificate rejected; every credential type; timeouts, including a request that waited for a worker; body limits; redirects not followed; a gzip bomb stopped at the limit; 50 parallel requests, each answered exactly once; cancel and exit mid-flight.
- WebSocket over
wss://: typed requests and pushes, named connections, handshake credentials, close codes, message limits; the server was stopped and restarted in the middle of a session, and the client reconnected, authenticated again and answered the lost request honestly as "sent". - SSH and SFTP against real OpenSSH: strict key exchange and AES-GCM confirmed in the server's own log; cancelled and timed-out commands gone from the server within 1.5 s; a 50 MB upload; a reconnect that never re-ran a command; connection resets noticed within about half a second.
- Uploads: byte for byte (CRC-32) against real PHP, Express + multer, FastAPI, Go and Django (see what real backends do with an upload); limits refused before sending really never reached the server.
Run it yourself:
cargo testruns the unit tests, theFakeHttpTransporttests (every answer kind, exactly one answer each, strict ambiguity detection), a log-capture test proving no secret is logged, the loopback tests (the realUreqTransportagainst the mock server on 127.0.0.1: statuses, redirects, timeouts, body limit, TLS handshake failure, login flow, exit while busy) and the upload tests (the real transport against the mock's multipart parser). With--features ws(and--all-features) also the WebSocket tests: every lifecycle path on aFakeWsTransport, the real transport againstmock_ws_server(large messages across many short read timeouts, reconnect, heartbeat, 401, 1009, exit), and a TLS test with large messages cut by read timeouts mid-record. With--features ssh(andssh,sftp) also the SSH tests: every lifecycle path on aFakeSshTransport(including one app with HTTP, WebSocket and SSH cancelling each other's requests), the realRusshTransportagainstmock_ssh_serverwith throwaway keys generated at runtime (commands, timeouts, cancel, output limit, strict host keys, passphrases, ssh_config, SFTP) and hostile raw TCP peers (silent, trickling, huge banner) that must not stretch the connect deadline.cargo test --all-featuresalso compiles every Rust block of this README.tests/live.rsholds live HTTPS checks,#[ignore]d: they run only withcargo test --test live -- --ignoredandBNB_TEST_HTTPS_URLset to a server that serves the mock's contract over HTTPS (for examplemock_serverbehind a TLS-terminating reverse proxy on a test machine).tests/live_ws.rsdoes the same for WebSocket:cargo test --features ws --test live_ws -- --ignoredwithBNB_TEST_WSS_URLset tomock_ws_serverbehind a TLS proxy (for examplewss://…/ws).tests/live_multipart.rsuploads to the mock (BNB_TEST_HTTPS_URL+/upload) and to any echo servers listed inBNB_TEST_MULTIPART_URLS(comma-separated upload URLs, e.g. small PHP / Express / FastAPI / Go / Django servers that answer the mock's JSON echo shape, documented inexamples/mock_server.rs), and checks the names, values, content types, sizes and CRC-32 they report, for a simple form and for the cases every tested framework reads the same way:cargo test --test live_multipart -- --ignored --test-threads 1.tests/live_ssh.rschecks a real OpenSSH server:cargo test --features ssh,sftp --test live_ssh -- --ignored --test-threads 1withBNB_TEST_HOST,BNB_TEST_SSH_USER,BNB_TEST_SSH_KEY(a key file) andBNB_TEST_SSH_KNOWN_HOSTS(a known_hosts file) set, optionallyBNB_TEST_SSH_PORTandBNB_TEST_SSH_PASSPHRASE. It runs harmless commands only (echo,uname,whoami,sleep) and SFTP inside a new temporary directory in the user's home that it removes again.- Against your real API, run the examples with
BACKEND_URL(see Examples).
FAQ
Why not reqwest? reqwest needs tokio (an async runtime next to Bevy's) and is a much larger tree. A game API client does a handful of requests; blocking ureq on two threads is plenty.
Why not Bevy's IoTaskPool? It has 1–4 threads shared with asset loading. A 15-second
request would stall asset IO; a dedicated pool cannot.
Can I call two different APIs? Not in 0.1.0: one base URL per app.
Is the answer delivered if my reader runs in PostUpdate? Yes. Answers are written in
First before Bevy's message update, so they are readable in every schedule of that frame (and
in the next frame's First before the update), then dropped. A reader that runs only every
other frame can miss them.
Can I build a JsonResponse<T> myself for a unit test? No (it is #[non_exhaustive] and
RequestId has no public constructor). Drive your systems through the plugin with a
FakeHttpTransport instead (see Testing your game).
Where does the token live between sessions? Wherever your game keeps it; this crate only
sends what is in BackendCredentials.
Can my game use SSH to talk to its servers? Not a game you give to players: an SSH key in a
player build is shell access for anyone who extracts it. Use HTTP or WebSocket with per-player
tokens for that. SSH is for your own admin and developer tools, and release builds refuse it
unless the tool explicitly opts in (SshSettings::allow_in_release).
How do I upload a screenshot or a save file? HttpClient::post_multipart with a
Multipart form (see File uploads); load the bytes first, the
crate never reads files for an HTTP upload.
Can SSH log in with a password or a 2FA code? Yes, opt-in: SshAuth::password and
SshAuth::keyboard_interactive (see the SSH section). Keys stay the recommendation; the values are
typed by the admin at runtime and never logged.
Why does ssh bring tokio when nothing else does? Every maintained, complete SSH client in
Rust that needs neither C nor OpenSSL is built on tokio (russh). The crate keeps it contained: one
current-thread runtime on one thread of its own, started on the first connect, and nothing of it
without the feature.
Does it follow the system proxy? The HTTPS_PROXY / HTTP_PROXY / ALL_PROXY / NO_PROXY
environment variables, yes (not for loopback hosts). Windows' registry proxy settings, no.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Contributing
Issues and pull requests are welcome. Please run cargo fmt, cargo clippy --all-targets --all-features -- -D warnings and cargo test (with and without default features) before
opening a pull request. Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual
licensed as above, without any additional terms or conditions.