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