bevy_react/message.rs
1//! Typed app messages emitted by the React app for the Bevy world to consume.
2//!
3//! This is the complement to the UI bridge: where ops describe UI mutations, an
4//! app message carries an app-level signal (e.g. "set the count") from React
5//! into the ECS. The JS side calls `emit(name, value)`; the plugin owns a single
6//! consumption point that routes each message by name to the typed payload the
7//! user registered with [`ReactAppExt::add_react_handler`], deserializing the
8//! JSON for them and triggering it for [observers](bevy::ecs::observer) to handle.
9//!
10//! ```ignore
11//! use bevy::prelude::*;
12//! use bevy_react::{react_message, ReactAppExt};
13//!
14//! #[react_message]
15//! struct Count(usize); // name defaults to "count"
16//!
17//! app.add_react_handler(|on: On<Count>| {
18//! let n = on.event().0; // typed — no serde_json::Value juggling
19//! });
20//! ```
21
22use std::any::TypeId;
23use std::collections::HashMap;
24use std::path::Path;
25
26use bevy::ecs::system::IntoObserverSystem;
27use bevy::prelude::*;
28use serde::de::DeserializeOwned;
29use ts_rs::TS;
30
31use crate::event::{ReactEvent, ReactEventRegistry};
32use crate::registry::{NamedEntry, register_entry};
33use crate::request::{ReactRequest, ReactRequestRegistry, RequestEvent};
34use crate::ts_codegen::TsCollector;
35
36/// A named, JSON-valued signal sent from the React app to Bevy.
37///
38/// This is the raw wire form carried across the JS↔Bevy channel. Consumers
39/// don't read it directly: register a typed [`ReactPayload`] and observe that
40/// instead. The plugin deserializes the [`value`](ReactMessage::value) into the
41/// payload type whose [`ReactPayload::NAME`] matches [`name`](ReactMessage::name).
42#[derive(Clone, Debug)]
43pub struct ReactMessage {
44 /// Application-defined message name (the first argument to `emit`).
45 pub name: String,
46 /// The payload (the second argument to `emit`), as JSON.
47 pub value: serde_json::Value,
48}
49
50/// A typed payload a React `emit(NAME, value)` call deserializes into.
51///
52/// Usually you don't implement this by hand — apply [`#[react_message]`](crate::react_message),
53/// which derives `Deserialize` and `TS` and implements both `Event` and this trait. The
54/// JSON `value` is deserialized straight into `Self`, so the payload's shape must
55/// match what JS emits: `emit("count", 5)` needs a payload that deserializes from
56/// a number (e.g. `struct Count(usize)`), while `emit("move", { x, y })` needs a struct.
57///
58/// The [`TS`] bound lets [`ReactAppExt::export_react_typescript`] mirror the payload's
59/// shape into a TypeScript type, so the JS `emit` is type-checked against the same struct.
60pub trait ReactPayload: Event + DeserializeOwned + TS + Send + Sync + 'static {
61 /// The `emit` name this type is routed from.
62 const NAME: &'static str;
63}
64
65/// Type-erased deserialize-and-trigger closures keyed by `emit` name. Owned by
66/// the plugin; the single dispatch system looks up each incoming message here.
67/// The [`TypeId`] lets us treat re-registering the *same* payload (e.g. attaching
68/// several observers via [`ReactAppExt::add_react_handler`]) as a harmless no-op,
69/// while still warning when two *different* types claim one name.
70#[derive(Resource, Default)]
71pub(crate) struct ReactRegistry {
72 pub(crate) handlers: HashMap<&'static str, Registration>,
73}
74
75/// What we record per registered payload: the dispatch closure plus the TypeScript
76/// metadata [`ReactAppExt::export_react_typescript`] needs to mirror the type.
77pub(crate) struct Registration {
78 /// Distinguishes re-registering the same type (a no-op) from a name collision.
79 type_id: TypeId,
80 /// Deserialize-and-trigger for this payload.
81 handler: Handler,
82 /// The payload's TypeScript reference name (e.g. `Count`), used in the message map.
83 pub(crate) ts_name: fn() -> String,
84 /// Records this payload's declaration and all its transitive dependencies.
85 pub(crate) ts_collect: fn(&mut TsCollector),
86}
87
88/// Deserializes a JSON payload and queues a trigger for it, or returns the serde
89/// error if the JSON doesn't match the registered payload type.
90type Handler =
91 Box<dyn Fn(serde_json::Value, &mut Commands) -> Result<(), serde_json::Error> + Send + Sync>;
92
93impl NamedEntry for Registration {
94 fn type_id(&self) -> TypeId {
95 self.type_id
96 }
97}
98
99impl ReactRegistry {
100 /// Register the deserialize-and-trigger handler for payload `T`. Idempotent
101 /// for a given type; warns only if a different type already owns `T::NAME`.
102 pub(crate) fn register<T>(&mut self)
103 where
104 T: ReactPayload,
105 for<'a> <T as Event>::Trigger<'a>: Default,
106 {
107 register_entry(
108 &mut self.handlers,
109 T::NAME,
110 "message",
111 Registration {
112 type_id: TypeId::of::<T>(),
113 handler: Box::new(|value, commands| {
114 // `T` is concrete here, so serde and the trigger are baked in.
115 let payload: T = serde_json::from_value(value)?;
116 commands.trigger(payload);
117 Ok(())
118 }),
119 // `T` is concrete here too, so its TS shape is baked into these fns.
120 ts_name: T::name,
121 ts_collect: |c| c.add::<T>(),
122 },
123 );
124 }
125
126 /// Route one message: deserialize into its registered payload and trigger it.
127 /// Logs a warning for an unregistered name and an error for malformed JSON.
128 pub(crate) fn dispatch(&self, msg: ReactMessage, commands: &mut Commands) {
129 match self.handlers.get(msg.name.as_str()) {
130 None => warn!("no handler registered for react message {:?}", msg.name),
131 Some(reg) => {
132 if let Err(e) = (reg.handler)(msg.value, commands) {
133 error!("malformed react message {:?}: {e}", msg.name);
134 }
135 }
136 }
137 }
138}
139
140/// Registers typed React message payloads on a Bevy [`App`].
141pub trait ReactAppExt {
142 /// Register a typed React message payload without attaching an observer.
143 ///
144 /// After this, an `emit(T::NAME, value)` from the React app deserializes
145 /// `value` into `T` and triggers it. Prefer [`add_react_handler`](Self::add_react_handler)
146 /// unless you want to register the type and observe it separately.
147 fn add_react_message<T>(&mut self) -> &mut Self
148 where
149 T: ReactPayload,
150 for<'a> <T as Event>::Trigger<'a>: Default;
151
152 /// Register a payload and attach an observer for it in one call.
153 ///
154 /// The payload type is inferred from the observer's `On<T>` parameter, so you
155 /// never name it twice. Call it again with another observer to add more
156 /// handlers for the same message — registration is idempotent.
157 ///
158 /// ```ignore
159 /// app.add_react_handler(|count: On<Count>, mut desired: ResMut<DesiredCubes>| {
160 /// desired.0 = count.event().0;
161 /// });
162 /// ```
163 fn add_react_handler<E, B, M, S>(&mut self, observer: S) -> &mut Self
164 where
165 E: ReactPayload,
166 for<'a> <E as Event>::Trigger<'a>: Default,
167 B: Bundle,
168 S: IntoObserverSystem<E, B, M>;
169
170 /// Register a typed React request without attaching an observer. Prefer
171 /// [`add_react_request_handler`](Self::add_react_request_handler).
172 fn add_react_request<T>(&mut self) -> &mut Self
173 where
174 T: ReactRequest;
175
176 /// Register a request and attach its observer in one call.
177 ///
178 /// The request type is inferred from the observer's `On<Request<T>>` parameter.
179 /// The observer answers the request via [`Request::respond`](crate::Request::respond).
180 ///
181 /// ```ignore
182 /// app.add_react_request_handler(|req: On<Request<BoardGet>>, board: Res<Board>| {
183 /// req.respond(board.clone());
184 /// });
185 /// ```
186 fn add_react_request_handler<E, B, M, S>(&mut self, observer: S) -> &mut Self
187 where
188 E: Event + RequestEvent,
189 for<'a> <E as Event>::Trigger<'a>: Default,
190 B: Bundle,
191 S: IntoObserverSystem<E, B, M>;
192
193 /// Register a Bevy → React event type so it appears in the generated
194 /// `ReactEvents` map and `bevy.on` typing. Sending an event with
195 /// [`ReactEvents`](crate::ReactEvents) does not require this, but then the type
196 /// won't be known to the exporter.
197 fn add_react_event<E>(&mut self) -> &mut Self
198 where
199 E: ReactEvent;
200
201 /// Write a self-contained TypeScript module (conventionally `src/bevy.ts`)
202 /// mirroring every registered React binding to `path`.
203 ///
204 /// The generated module covers all three app-messaging surfaces in one pass:
205 /// a type declaration per payload (mirrored from the `#[react_message]` /
206 /// `#[react_request]` / `#[react_event]` structs via `ts-rs`), the
207 /// `ReactMessages`/`ReactRequests`/`ReactEvents` name→type maps, typed
208 /// `emit`/`request`/`on` wrappers, and a structured `bevy` proxy whose nested
209 /// methods come from dotted request names (`"board.get"` → `bevy.board.get()`).
210 /// App code imports that typed surface from `./bevy` instead of the untyped
211 /// functions from `"bevy-react"`, so every call is checked against the same
212 /// structs Bevy serializes and deserializes.
213 ///
214 /// Keep a **single registration site**: put your `add_react_*` calls in a
215 /// `register_bindings(app)` function that both the real app (e.g. your
216 /// plugin's `build`) and a small exporter entry point call, so a binding can
217 /// never exist at runtime without appearing in the generated types. Wire the
218 /// exporter to a CLI flag that returns before `app.run()`, commit the output,
219 /// and have CI regenerate + `git diff --exit-code` to guarantee the
220 /// TypeScript never drifts from Rust. (See `examples/demos/main.rs` and its
221 /// `--export-bindings` flag, exposed as `npm run bevy:generate`.)
222 ///
223 /// ```ignore
224 /// if std::env::args().nth(1).as_deref() == Some("--export-bindings") {
225 /// let path = std::env::args().nth(2).expect("output path");
226 /// let mut app = App::new();
227 /// register_bindings(&mut app); // the same fn the real app calls
228 /// app.export_react_typescript(&path)?;
229 /// return;
230 /// }
231 /// ```
232 fn export_react_typescript(&self, path: impl AsRef<Path>) -> std::io::Result<()>;
233}
234
235impl ReactAppExt for App {
236 fn add_react_message<T>(&mut self) -> &mut Self
237 where
238 T: ReactPayload,
239 for<'a> <T as Event>::Trigger<'a>: Default,
240 {
241 self.world_mut()
242 .get_resource_or_init::<ReactRegistry>()
243 .register::<T>();
244 self
245 }
246
247 fn add_react_handler<E, B, M, S>(&mut self, observer: S) -> &mut Self
248 where
249 E: ReactPayload,
250 for<'a> <E as Event>::Trigger<'a>: Default,
251 B: Bundle,
252 S: IntoObserverSystem<E, B, M>,
253 {
254 self.add_react_message::<E>();
255 self.add_observer(observer);
256 self
257 }
258
259 fn add_react_request<T>(&mut self) -> &mut Self
260 where
261 T: ReactRequest,
262 {
263 self.world_mut()
264 .get_resource_or_init::<ReactRequestRegistry>()
265 .register::<T>();
266 self
267 }
268
269 fn add_react_request_handler<E, B, M, S>(&mut self, observer: S) -> &mut Self
270 where
271 E: Event + RequestEvent,
272 for<'a> <E as Event>::Trigger<'a>: Default,
273 B: Bundle,
274 S: IntoObserverSystem<E, B, M>,
275 {
276 // `E` is `Request<T>`; register the underlying request type `T`.
277 self.add_react_request::<E::Req>();
278 self.add_observer(observer);
279 self
280 }
281
282 fn add_react_event<E>(&mut self) -> &mut Self
283 where
284 E: ReactEvent,
285 {
286 self.world_mut()
287 .get_resource_or_init::<ReactEventRegistry>()
288 .register::<E>();
289 self
290 }
291
292 fn export_react_typescript(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
293 crate::ts_codegen::export(self.world(), path.as_ref())
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::react_message;
301 use bevy::ecs::world::CommandQueue;
302
303 #[react_message]
304 struct Count(usize);
305
306 // Only used to assert their derived `NAME`, so their fields go unread.
307 #[react_message(name = "hp")]
308 #[allow(dead_code)]
309 struct Health(u32);
310
311 #[react_message]
312 #[allow(dead_code)]
313 struct PlayerScore(i64);
314
315 #[derive(Resource, Default)]
316 struct LastCount(usize);
317
318 /// The macro defaults the name to the struct ident, first letter lowered, and
319 /// honours an explicit override.
320 #[test]
321 fn derives_emit_name() {
322 assert_eq!(Count::NAME, "count");
323 assert_eq!(PlayerScore::NAME, "playerScore");
324 assert_eq!(Health::NAME, "hp");
325 }
326
327 fn test_app() -> App {
328 let mut app = App::new();
329 app.init_resource::<LastCount>();
330 // Single call registers the deserializer and attaches the observer.
331 app.add_react_handler(|on: On<Count>, mut last: ResMut<LastCount>| last.0 = on.event().0);
332 app
333 }
334
335 /// Run one message through the plugin's dispatch path, applying the trigger
336 /// it queues so observers run before we assert.
337 fn dispatch(app: &mut App, msg: ReactMessage) {
338 app.world_mut()
339 .resource_scope(|world, registry: Mut<ReactRegistry>| {
340 let mut queue = CommandQueue::default();
341 let mut commands = Commands::new(&mut queue, world);
342 registry.dispatch(msg, &mut commands);
343 queue.apply(world);
344 });
345 }
346
347 /// A registered payload deserializes and reaches its observer.
348 #[test]
349 fn dispatches_to_observer() {
350 let mut app = test_app();
351 dispatch(
352 &mut app,
353 ReactMessage {
354 name: "count".into(),
355 value: serde_json::json!(3),
356 },
357 );
358 assert_eq!(app.world().resource::<LastCount>().0, 3);
359 }
360
361 /// An unknown name and malformed JSON are tolerated (logged, not panicked).
362 #[test]
363 fn tolerates_unknown_and_malformed() {
364 let mut app = test_app();
365 dispatch(
366 &mut app,
367 ReactMessage {
368 name: "nope".into(),
369 value: serde_json::json!(1),
370 },
371 );
372 dispatch(
373 &mut app,
374 ReactMessage {
375 name: "count".into(),
376 value: serde_json::json!("not a number"),
377 },
378 );
379 // Neither message should have reached the observer.
380 assert_eq!(app.world().resource::<LastCount>().0, 0);
381 }
382}