Skip to content

Commit 89e98b2

Browse files
mweatherleypcwaltonSmakoszJanChristopherBiscardiMrGVSV
authored
Initial implementation of the Bevy Remote Protocol (Adopted) (bevyengine#14880)
# Objective Adopted from bevyengine#13563. The goal is to implement the Bevy Remote Protocol over HTTP/JSON, allowing the ECS to be interacted with remotely. ## Solution At a high level, there are really two separate things that have been undertaken here: 1. First, `RemotePlugin` has been created, which has the effect of embedding a [JSON-RPC](https://www.jsonrpc.org/specification) endpoint into a Bevy application. 2. Second, the [Bevy Remote Protocol verbs](https://gist.github.com/coreh/1baf6f255d7e86e4be29874d00137d1d#file-bevy-remote-protocol-md) (excluding `POLL`) have been implemented as remote methods for that JSON-RPC endpoint under a Bevy-exclusive namespace (e.g. `bevy/get`, `bevy/list`, etc.). To avoid some repetition, here is the crate-level documentation, which explains the request/response structure, built-in-methods, and custom method configuration: <details> <summary>Click to view crate-level docs</summary> ```rust //! An implementation of the Bevy Remote Protocol over HTTP and JSON, to allow //! for remote control of a Bevy app. //! //! Adding the [`RemotePlugin`] to your [`App`] causes Bevy to accept //! connections over HTTP (by default, on port 15702) while your app is running. //! These *remote clients* can inspect and alter the state of the //! entity-component system. Clients are expected to `POST` JSON requests to the //! root URL; see the `client` example for a trivial example of use. //! //! The Bevy Remote Protocol is based on the JSON-RPC 2.0 protocol. //! //! ## Request objects //! //! A typical client request might look like this: //! //! ```json //! { //! "method": "bevy/get", //! "id": 0, //! "params": { //! "entity": 4294967298, //! "components": [ //! "bevy_transform::components::transform::Transform" //! ] //! } //! } //! ``` //! //! The `id` and `method` fields are required. The `param` field may be omitted //! for certain methods: //! //! * `id` is arbitrary JSON data. The server completely ignores its contents, //! and the client may use it for any purpose. It will be copied via //! serialization and deserialization (so object property order, etc. can't be //! relied upon to be identical) and sent back to the client as part of the //! response. //! //! * `method` is a string that specifies one of the possible [`BrpRequest`] //! variants: `bevy/query`, `bevy/get`, `bevy/insert`, etc. It's case-sensitive. //! //! * `params` is parameter data specific to the request. //! //! For more information, see the documentation for [`BrpRequest`]. //! [`BrpRequest`] is serialized to JSON via `serde`, so [the `serde` //! documentation] may be useful to clarify the correspondence between the Rust //! structure and the JSON format. //! //! ## Response objects //! //! A response from the server to the client might look like this: //! //! ```json //! { //! "jsonrpc": "2.0", //! "id": 0, //! "result": { //! "bevy_transform::components::transform::Transform": { //! "rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 }, //! "scale": { "x": 1.0, "y": 1.0, "z": 1.0 }, //! "translation": { "x": 0.0, "y": 0.5, "z": 0.0 } //! } //! } //! } //! ``` //! //! The `id` field will always be present. The `result` field will be present if the //! request was successful. Otherwise, an `error` field will replace it. //! //! * `id` is the arbitrary JSON data that was sent as part of the request. It //! will be identical to the `id` data sent during the request, modulo //! serialization and deserialization. If there's an error reading the `id` field, //! it will be `null`. //! //! * `result` will be present if the request succeeded and will contain the response //! specific to the request. //! //! * `error` will be present if the request failed and will contain an error object //! with more information about the cause of failure. //! //! ## Error objects //! //! An error object might look like this: //! //! ```json //! { //! "code": -32602, //! "message": "Missing \"entity\" field" //! } //! ``` //! //! The `code` and `message` fields will always be present. There may also be a `data` field. //! //! * `code` is an integer representing the kind of an error that happened. Error codes documented //! in the [`error_codes`] module. //! //! * `message` is a short, one-sentence human-readable description of the error. //! //! * `data` is an optional field of arbitrary type containing additional information about the error. //! //! ## Built-in methods //! //! The Bevy Remote Protocol includes a number of built-in methods for accessing and modifying data //! in the ECS. Each of these methods uses the `bevy/` prefix, which is a namespace reserved for //! BRP built-in methods. //! //! ### bevy/get //! //! Retrieve the values of one or more components from an entity. //! //! `params`: //! - `entity`: The ID of the entity whose components will be fetched. //! - `components`: An array of fully-qualified type names of components to fetch. //! //! `result`: A map associating each type name to its value on the requested entity. //! //! ### bevy/query //! //! Perform a query over components in the ECS, returning all matching entities and their associated //! component values. //! //! All of the arrays that comprise this request are optional, and when they are not provided, they //! will be treated as if they were empty. //! //! `params`: //! `params`: //! - `data`: //! - `components` (optional): An array of fully-qualified type names of components to fetch. //! - `option` (optional): An array of fully-qualified type names of components to fetch optionally. //! - `has` (optional): An array of fully-qualified type names of components whose presence will be //! reported as boolean values. //! - `filter` (optional): //! - `with` (optional): An array of fully-qualified type names of components that must be present //! on entities in order for them to be included in results. //! - `without` (optional): An array of fully-qualified type names of components that must *not* be //! present on entities in order for them to be included in results. //! //! `result`: An array, each of which is an object containing: //! - `entity`: The ID of a query-matching entity. //! - `components`: A map associating each type name from `components`/`option` to its value on the matching //! entity if the component is present. //! - `has`: A map associating each type name from `has` to a boolean value indicating whether or not the //! entity has that component. If `has` was empty or omitted, this key will be omitted in the response. //! //! ### bevy/spawn //! //! Create a new entity with the provided components and return the resulting entity ID. //! //! `params`: //! - `components`: A map associating each component's fully-qualified type name with its value. //! //! `result`: //! - `entity`: The ID of the newly spawned entity. //! //! ### bevy/destroy //! //! Despawn the entity with the given ID. //! //! `params`: //! - `entity`: The ID of the entity to be despawned. //! //! `result`: null. //! //! ### bevy/remove //! //! Delete one or more components from an entity. //! //! `params`: //! - `entity`: The ID of the entity whose components should be removed. //! - `components`: An array of fully-qualified type names of components to be removed. //! //! `result`: null. //! //! ### bevy/insert //! //! Insert one or more components into an entity. //! //! `params`: //! - `entity`: The ID of the entity to insert components into. //! - `components`: A map associating each component's fully-qualified type name with its value. //! //! `result`: null. //! //! ### bevy/reparent //! //! Assign a new parent to one or more entities. //! //! `params`: //! - `entities`: An array of entity IDs of entities that will be made children of the `parent`. //! - `parent` (optional): The entity ID of the parent to which the child entities will be assigned. //! If excluded, the given entities will be removed from their parents. //! //! `result`: null. //! //! ### bevy/list //! //! List all registered components or all components present on an entity. //! //! When `params` is not provided, this lists all registered components. If `params` is provided, //! this lists only those components present on the provided entity. //! //! `params` (optional): //! - `entity`: The ID of the entity whose components will be listed. //! //! `result`: An array of fully-qualified type names of components. //! //! ## Custom methods //! //! In addition to the provided methods, the Bevy Remote Protocol can be extended to include custom //! methods. This is primarily done during the initialization of [`RemotePlugin`], although the //! methods may also be extended at runtime using the [`RemoteMethods`] resource. //! //! ### Example //! ```ignore //! fn main() { //! App::new() //! .add_plugins(DefaultPlugins) //! .add_plugins( //! // `default` adds all of the built-in methods, while `with_method` extends them //! RemotePlugin::default() //! .with_method("super_user/cool_method".to_owned(), path::to::my::cool::handler) //! // ... more methods can be added by chaining `with_method` //! ) //! .add_systems( //! // ... standard application setup //! ) //! .run(); //! } //! ``` //! //! The handler is expected to be a system-convertible function which takes optional JSON parameters //! as input and returns a [`BrpResult`]. This means that it should have a type signature which looks //! something like this: //! ``` //! # use serde_json::Value; //! # use bevy_ecs::prelude::{In, World}; //! # use bevy_remote::BrpResult; //! fn handler(In(params): In<Option<Value>>, world: &mut World) -> BrpResult { //! todo!() //! } //! ``` //! //! Arbitrary system parameters can be used in conjunction with the optional `Value` input. The //! handler system will always run with exclusive `World` access. //! //! [the `serde` documentation]: https://serde.rs/ ``` </details> ### Message lifecycle At a high level, the lifecycle of client-server interactions is something like this: 1. The client sends one or more `BrpRequest`s. The deserialized version of that is just the Rust representation of a JSON-RPC request, and it looks like this: ```rust pub struct BrpRequest { /// The action to be performed. Parsing is deferred for the sake of error reporting. pub method: Option<Value>, /// Arbitrary data that will be returned verbatim to the client as part of /// the response. pub id: Option<Value>, /// The parameters, specific to each method. /// /// These are passed as the first argument to the method handler. /// Sometimes params can be omitted. pub params: Option<Value>, } ``` 2. These requests are accumulated in a mailbox resource (small lie but close enough). 3. Each update, the mailbox is drained by a system `process_remote_requests`, where each request is processed according to its `method`, which has an associated handler. Each handler is a Bevy system that runs with exclusive world access and returns a result; e.g.: ```rust pub fn process_remote_get_request(In(params): In<Option<Value>>, world: &World) -> BrpResult { // ... } ``` 4. The result (or an error) is reported back to the client. ## Testing This can be tested by using the `server` and `client` examples. The `client` example is not particularly exhaustive at the moment (it only creates barebones `bevy/query` requests) but is still informative. Other queries can be made using `curl` with the `server` example running. For example, to make a `bevy/list` request and list all registered components: ```bash curl -X POST -d '{ "jsonrpc": "2.0", "id": 1, "method": "bevy/list" }' 127.0.0.1:15702 | jq . ``` --- ## Future direction There were a couple comments on BRP versioning while this was in draft. I agree that BRP versioning is a good idea, but I think that it requires some consensus on a couple fronts: - First of all, what does the version actually mean? Is it a version for the protocol itself or for the `bevy/*` methods implemented using it? Both? - Where does the version actually live? The most natural place is just where we have `"jsonrpc"` right now (at least if it's versioning the protocol itself), but this means we're not actually conforming to JSON-RPC any more (so, for example, any client library used to construct JSON-RPC requests would stop working). I'm not really against that, but it's at least a real decision. - What do we actually do when we encounter mismatched versions? Adding handling for this would be actual scope creep instead of just a little add-on in my opinion. Another thing that would be nice is making the internal structure of the implementation less JSON-specific. Right now, for example, component values that will appear in server responses are quite eagerly converted to JSON `Value`s, which prevents disentangling the handler logic from the communication medium, but it can probably be done in principle and I imagine it would enable more code reuse (e.g. for custom method handlers) in addition to making the internals more readily usable for other formats. --------- Co-authored-by: Patrick Walton <[email protected]> Co-authored-by: DragonGamesStudios <[email protected]> Co-authored-by: Christopher Biscardi <[email protected]> Co-authored-by: Gino Valente <[email protected]>
1 parent 27bea6a commit 89e98b2

File tree

10 files changed

+1809
-0
lines changed

10 files changed

+1809
-0
lines changed

Cargo.toml

+37
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ default = [
7575
"bevy_sprite",
7676
"bevy_text",
7777
"bevy_ui",
78+
"bevy_remote",
7879
"multi_threaded",
7980
"png",
8081
"hdr",
@@ -174,6 +175,9 @@ bevy_gizmos = ["bevy_internal/bevy_gizmos", "bevy_color"]
174175
# Provides a collection of developer tools
175176
bevy_dev_tools = ["bevy_internal/bevy_dev_tools"]
176177

178+
# Enable the Bevy Remote Protocol
179+
bevy_remote = ["bevy_internal/bevy_remote"]
180+
177181
# Enable passthrough loading for SPIR-V shaders (Only supported on Vulkan, shader capabilities and extensions must agree with the platform implementation)
178182
spirv_shader_passthrough = ["bevy_internal/spirv_shader_passthrough"]
179183

@@ -376,6 +380,7 @@ rand_chacha = "0.3.1"
376380
ron = "0.8.0"
377381
flate2 = "1.0"
378382
serde = { version = "1", features = ["derive"] }
383+
serde_json = "1"
379384
bytemuck = "1.7"
380385
bevy_render = { path = "crates/bevy_render", version = "0.15.0-dev", default-features = false }
381386
# Needed to poll Task examples
@@ -385,6 +390,16 @@ crossbeam-channel = "0.5.0"
385390
argh = "0.1.12"
386391
thiserror = "1.0"
387392
event-listener = "5.3.0"
393+
hyper = { version = "1", features = ["server", "http1"] }
394+
http-body-util = "0.1"
395+
anyhow = "1"
396+
macro_rules_attribute = "0.2"
397+
398+
[target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
399+
smol = "2"
400+
smol-macros = "0.1"
401+
smol-hyper = "0.1"
402+
ureq = { version = "2.10.1", features = ["json"] }
388403

389404
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
390405
wasm-bindgen = { version = "0.2" }
@@ -3384,6 +3399,28 @@ description = "Demonstrates volumetric fog and lighting"
33843399
category = "3D Rendering"
33853400
wasm = true
33863401

3402+
[[example]]
3403+
name = "client"
3404+
path = "examples/remote/client.rs"
3405+
doc-scrape-examples = true
3406+
3407+
[package.metadata.example.client]
3408+
name = "client"
3409+
description = "A simple command line client that can control Bevy apps via the BRP"
3410+
category = "Remote Protocol"
3411+
wasm = false
3412+
3413+
[[example]]
3414+
name = "server"
3415+
path = "examples/remote/server.rs"
3416+
doc-scrape-examples = true
3417+
3418+
[package.metadata.example.server]
3419+
name = "server"
3420+
description = "A Bevy app that you can connect to with the BRP and edit"
3421+
category = "Remote Protocol"
3422+
wasm = false
3423+
33873424
[[example]]
33883425
name = "anisotropy"
33893426
path = "examples/3d/anisotropy.rs"

crates/bevy_internal/Cargo.toml

+4
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,9 @@ meshlet_processor = ["bevy_pbr?/meshlet_processor"]
192192
# Provides a collection of developer tools
193193
bevy_dev_tools = ["dep:bevy_dev_tools"]
194194

195+
# Enable support for the Bevy Remote Protocol
196+
bevy_remote = ["dep:bevy_remote"]
197+
195198
# Provides a picking functionality
196199
bevy_picking = [
197200
"dep:bevy_picking",
@@ -249,6 +252,7 @@ bevy_gizmos = { path = "../bevy_gizmos", optional = true, version = "0.15.0-dev"
249252
bevy_gltf = { path = "../bevy_gltf", optional = true, version = "0.15.0-dev" }
250253
bevy_pbr = { path = "../bevy_pbr", optional = true, version = "0.15.0-dev" }
251254
bevy_picking = { path = "../bevy_picking", optional = true, version = "0.15.0-dev" }
255+
bevy_remote = { path = "../bevy_remote", optional = true, version = "0.15.0-dev" }
252256
bevy_render = { path = "../bevy_render", optional = true, version = "0.15.0-dev" }
253257
bevy_scene = { path = "../bevy_scene", optional = true, version = "0.15.0-dev" }
254258
bevy_sprite = { path = "../bevy_sprite", optional = true, version = "0.15.0-dev" }

crates/bevy_internal/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ pub use bevy_pbr as pbr;
4646
pub use bevy_picking as picking;
4747
pub use bevy_ptr as ptr;
4848
pub use bevy_reflect as reflect;
49+
#[cfg(feature = "bevy_remote")]
50+
pub use bevy_remote as remote;
4951
#[cfg(feature = "bevy_render")]
5052
pub use bevy_render as render;
5153
#[cfg(feature = "bevy_scene")]

crates/bevy_remote/Cargo.toml

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
[package]
2+
name = "bevy_remote"
3+
version = "0.15.0-dev"
4+
edition = "2021"
5+
description = "The Bevy Remote Protocol"
6+
homepage = "https://bevyengine.org"
7+
repository = "https://github.com/bevyengine/bevy"
8+
license = "MIT OR Apache-2.0"
9+
keywords = ["bevy"]
10+
readme = "README.md"
11+
12+
[dependencies]
13+
# bevy
14+
bevy_app = { path = "../bevy_app", version = "0.15.0-dev" }
15+
bevy_derive = { path = "../bevy_derive", version = "0.15.0-dev" }
16+
bevy_ecs = { path = "../bevy_ecs", version = "0.15.0-dev", features = [
17+
"serialize",
18+
] }
19+
bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.15.0-dev" }
20+
bevy_reflect = { path = "../bevy_reflect", version = "0.15.0-dev" }
21+
bevy_tasks = { path = "../bevy_tasks", version = "0.15.0-dev" }
22+
bevy_utils = { path = "../bevy_utils", version = "0.15.0-dev" }
23+
24+
# other
25+
anyhow = "1"
26+
hyper = { version = "1", features = ["server", "http1"] }
27+
serde = { version = "1", features = ["derive"] }
28+
serde_json = { version = "1" }
29+
http-body-util = "0.1"
30+
31+
# dependencies that will not compile on wasm
32+
[target.'cfg(not(target_family = "wasm"))'.dependencies]
33+
smol = "2"
34+
smol-hyper = "0.1"
35+
36+
[lints]
37+
workspace = true
38+
39+
[package.metadata.docs.rs]
40+
rustdoc-args = ["-Zunstable-options", "--cfg", "docsrs"]
41+
all-features = true

0 commit comments

Comments
 (0)