bevy-react 0.3.0

Drive bevy_ui from a React app over an embedded V8 runtime.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Typed app messages emitted by the React app for the Bevy world to consume.
//!
//! This is the complement to the UI bridge: where ops describe UI mutations, an
//! app message carries an app-level signal (e.g. "set the count") from React
//! into the ECS. The JS side calls `emit(name, value)`; the plugin owns a single
//! consumption point that routes each message by name to the typed payload the
//! user registered with [`ReactAppExt::add_react_handler`], deserializing the
//! JSON for them and triggering it for [observers](bevy::ecs::observer) to handle.
//!
//! ```ignore
//! use bevy::prelude::*;
//! use bevy_react::{react_message, ReactAppExt};
//!
//! #[react_message]
//! struct Count(usize); // name defaults to "count"
//!
//! app.add_react_handler(|on: On<Count>| {
//!     let n = on.event().0; // typed — no serde_json::Value juggling
//! });
//! ```

use std::any::TypeId;
use std::collections::HashMap;
use std::path::Path;

use bevy::ecs::system::IntoObserverSystem;
use bevy::prelude::*;
use serde::de::DeserializeOwned;
use ts_rs::TS;

use crate::event::{ReactEvent, ReactEventRegistry};
use crate::filters::{FilterRegistry, ReactFilter};
use crate::registry::{NamedEntry, register_entry};
use crate::request::{ReactRequest, ReactRequestRegistry, RequestEvent};
use crate::ts_codegen::TsCollector;

/// A named, JSON-valued signal sent from the React app to Bevy.
///
/// This is the raw wire form carried across the JS↔Bevy channel. Consumers
/// don't read it directly: register a typed [`ReactPayload`] and observe that
/// instead. The plugin deserializes the [`value`](ReactMessage::value) into the
/// payload type whose [`ReactPayload::NAME`] matches [`name`](ReactMessage::name).
#[derive(Clone, Debug)]
pub struct ReactMessage {
    /// Application-defined message name (the first argument to `emit`).
    pub name: String,
    /// The payload (the second argument to `emit`), as JSON.
    pub value: serde_json::Value,
}

/// A typed payload a React `emit(NAME, value)` call deserializes into.
///
/// Usually you don't implement this by hand — apply [`#[react_message]`](crate::react_message),
/// which derives `Deserialize` and `TS` and implements both `Event` and this trait. The
/// JSON `value` is deserialized straight into `Self`, so the payload's shape must
/// match what JS emits: `emit("count", 5)` needs a payload that deserializes from
/// a number (e.g. `struct Count(usize)`), while `emit("move", { x, y })` needs a struct.
///
/// The [`TS`] bound lets [`ReactAppExt::export_react_typescript`] mirror the payload's
/// shape into a TypeScript type, so the JS `emit` is type-checked against the same struct.
pub trait ReactPayload: Event + DeserializeOwned + TS + Send + Sync + 'static {
    /// The `emit` name this type is routed from.
    const NAME: &'static str;
}

/// Type-erased deserialize-and-trigger closures keyed by `emit` name. Owned by
/// the plugin; the single dispatch system looks up each incoming message here.
/// The [`TypeId`] lets us treat re-registering the *same* payload (e.g. attaching
/// several observers via [`ReactAppExt::add_react_handler`]) as a harmless no-op,
/// while still warning when two *different* types claim one name.
#[derive(Resource, Default)]
pub(crate) struct ReactRegistry {
    pub(crate) handlers: HashMap<&'static str, Registration>,
}

/// What we record per registered payload: the dispatch closure plus the TypeScript
/// metadata [`ReactAppExt::export_react_typescript`] needs to mirror the type.
pub(crate) struct Registration {
    /// Distinguishes re-registering the same type (a no-op) from a name collision.
    type_id: TypeId,
    /// Deserialize-and-trigger for this payload.
    handler: Handler,
    /// The payload's TypeScript reference name (e.g. `Count`), used in the message map.
    pub(crate) ts_name: fn() -> String,
    /// Records this payload's declaration and all its transitive dependencies.
    pub(crate) ts_collect: fn(&mut TsCollector),
}

/// Deserializes a JSON payload and queues a trigger for it, or returns the serde
/// error if the JSON doesn't match the registered payload type.
type Handler =
    Box<dyn Fn(serde_json::Value, &mut Commands) -> Result<(), serde_json::Error> + Send + Sync>;

impl NamedEntry for Registration {
    fn type_id(&self) -> TypeId {
        self.type_id
    }
}

impl ReactRegistry {
    /// Register the deserialize-and-trigger handler for payload `T`. Idempotent
    /// for a given type; warns only if a different type already owns `T::NAME`.
    pub(crate) fn register<T>(&mut self)
    where
        T: ReactPayload,
        for<'a> <T as Event>::Trigger<'a>: Default,
    {
        register_entry(
            &mut self.handlers,
            T::NAME,
            "message",
            Registration {
                type_id: TypeId::of::<T>(),
                handler: Box::new(|value, commands| {
                    // `T` is concrete here, so serde and the trigger are baked in.
                    let payload: T = serde_json::from_value(value)?;
                    commands.trigger(payload);
                    Ok(())
                }),
                // `T` is concrete here too, so its TS shape is baked into these fns.
                ts_name: T::name,
                ts_collect: |c| c.add::<T>(),
            },
        );
    }

    /// Route one message: deserialize into its registered payload and trigger it.
    /// Logs a warning for an unregistered name and an error for malformed JSON.
    pub(crate) fn dispatch(&self, msg: ReactMessage, commands: &mut Commands) {
        match self.handlers.get(msg.name.as_str()) {
            None => warn!("no handler registered for react message {:?}", msg.name),
            Some(reg) => {
                if let Err(e) = (reg.handler)(msg.value, commands) {
                    error!("malformed react message {:?}: {e}", msg.name);
                }
            }
        }
    }
}

/// Registers typed React message payloads on a Bevy [`App`].
pub trait ReactAppExt {
    /// Register a typed React message payload without attaching an observer.
    ///
    /// After this, an `emit(T::NAME, value)` from the React app deserializes
    /// `value` into `T` and triggers it. Prefer [`add_react_handler`](Self::add_react_handler)
    /// unless you want to register the type and observe it separately.
    fn add_react_message<T>(&mut self) -> &mut Self
    where
        T: ReactPayload,
        for<'a> <T as Event>::Trigger<'a>: Default;

    /// Register a payload and attach an observer for it in one call.
    ///
    /// The payload type is inferred from the observer's `On<T>` parameter, so you
    /// never name it twice. Call it again with another observer to add more
    /// handlers for the same message — registration is idempotent.
    ///
    /// ```ignore
    /// app.add_react_handler(|count: On<Count>, mut desired: ResMut<DesiredCubes>| {
    ///     desired.0 = count.event().0;
    /// });
    /// ```
    fn add_react_handler<E, B, M, S>(&mut self, observer: S) -> &mut Self
    where
        E: ReactPayload,
        for<'a> <E as Event>::Trigger<'a>: Default,
        B: Bundle,
        S: IntoObserverSystem<E, B, M>;

    /// Register a typed React request without attaching an observer. Prefer
    /// [`add_react_request_handler`](Self::add_react_request_handler).
    fn add_react_request<T>(&mut self) -> &mut Self
    where
        T: ReactRequest;

    /// Register a request and attach its observer in one call.
    ///
    /// The request type is inferred from the observer's `On<Request<T>>` parameter.
    /// The observer answers the request via [`Request::respond`](crate::Request::respond).
    ///
    /// ```ignore
    /// app.add_react_request_handler(|req: On<Request<BoardGet>>, board: Res<Board>| {
    ///     req.respond(board.clone());
    /// });
    /// ```
    fn add_react_request_handler<E, B, M, S>(&mut self, observer: S) -> &mut Self
    where
        E: Event + RequestEvent,
        for<'a> <E as Event>::Trigger<'a>: Default,
        B: Bundle,
        S: IntoObserverSystem<E, B, M>;

    /// Register a Bevy → React event type so it appears in the generated
    /// `ReactEvents` map and `bevy.on` typing. Sending an event with
    /// [`ReactEvents`](crate::ReactEvents) does not require this, but then the type
    /// won't be known to the exporter.
    fn add_react_event<E>(&mut self) -> &mut Self
    where
        E: ReactEvent;

    /// Register a custom filter type (usually a
    /// [`#[react_filter]`](crate::react_filter) struct) so the `filter` style
    /// chain can resolve it by `T::NAME` — and so the exporter can mirror its
    /// params type into the generated TypeScript.
    ///
    /// The built-in filters (`blur`, `brightness`, …) are registered
    /// automatically by [`ReactUiPlugin`](crate::ReactUiPlugin); call this
    /// only for your own filters. Like events, keep the call in your single
    /// `register_bindings` site so the exporter path sees the same filters
    /// the running app does — a filter registered only at runtime never
    /// appears in the generated typing (see
    /// [`export_react_typescript`](Self::export_react_typescript)).
    ///
    /// To shadow a built-in name, register **after** `ReactUiPlugin` is
    /// added: the plugin's `build` registers the built-ins and would replace
    /// an earlier custom (with a warn), while the exporter — which never
    /// runs the plugin — would still show yours, silently diverging the
    /// generated types from runtime.
    fn add_react_filter<T>(&mut self) -> &mut Self
    where
        T: ReactFilter + DeserializeOwned + TS;

    /// Write a self-contained TypeScript module (conventionally `src/bevy.ts`)
    /// mirroring every registered React binding to `path`.
    ///
    /// The generated module covers all four typed surfaces in one pass: a type
    /// declaration per payload (mirrored from the `#[react_message]` /
    /// `#[react_request]` / `#[react_event]` structs and the filter params types
    /// via `ts-rs`), the `ReactMessages`/`ReactRequests`/`ReactEvents` name→type
    /// maps, a `declare module "bevy-react"` block augmenting the `BevyFilters`
    /// interface with every registered filter (built-ins included, so the
    /// `filter` style field types each name's params), typed `emit`/`request`/
    /// `on` wrappers, and a structured `bevy` proxy whose nested methods come
    /// from dotted request names (`"board.get"` → `bevy.board.get()`).
    /// App code imports that typed surface from `./bevy` instead of the untyped
    /// functions from `"bevy-react"`, so every call is checked against the same
    /// structs Bevy serializes and deserializes.
    ///
    /// Keep a **single registration site**: put your `add_react_*` calls in a
    /// `register_bindings(app)` function that both the real app (e.g. your
    /// plugin's `build`) and a small exporter entry point call, so a binding can
    /// never exist at runtime without appearing in the generated types. Wire the
    /// exporter to a CLI flag that returns before `app.run()`, commit the output,
    /// and have CI regenerate + `git diff --exit-code` to guarantee the
    /// TypeScript never drifts from Rust. (See `examples/demos/main.rs` and its
    /// `--export-bindings` flag, exposed as `npm run bevy:generate`.)
    ///
    /// ```ignore
    /// if std::env::args().nth(1).as_deref() == Some("--export-bindings") {
    ///     let path = std::env::args().nth(2).expect("output path");
    ///     let mut app = App::new();
    ///     register_bindings(&mut app); // the same fn the real app calls
    ///     app.export_react_typescript(&path)?;
    ///     return;
    /// }
    /// ```
    fn export_react_typescript(&self, path: impl AsRef<Path>) -> std::io::Result<()>;
}

impl ReactAppExt for App {
    fn add_react_message<T>(&mut self) -> &mut Self
    where
        T: ReactPayload,
        for<'a> <T as Event>::Trigger<'a>: Default,
    {
        self.world_mut()
            .get_resource_or_init::<ReactRegistry>()
            .register::<T>();
        self
    }

    fn add_react_handler<E, B, M, S>(&mut self, observer: S) -> &mut Self
    where
        E: ReactPayload,
        for<'a> <E as Event>::Trigger<'a>: Default,
        B: Bundle,
        S: IntoObserverSystem<E, B, M>,
    {
        self.add_react_message::<E>();
        self.add_observer(observer);
        self
    }

    fn add_react_request<T>(&mut self) -> &mut Self
    where
        T: ReactRequest,
    {
        self.world_mut()
            .get_resource_or_init::<ReactRequestRegistry>()
            .register::<T>();
        self
    }

    fn add_react_request_handler<E, B, M, S>(&mut self, observer: S) -> &mut Self
    where
        E: Event + RequestEvent,
        for<'a> <E as Event>::Trigger<'a>: Default,
        B: Bundle,
        S: IntoObserverSystem<E, B, M>,
    {
        // `E` is `Request<T>`; register the underlying request type `T`.
        self.add_react_request::<E::Req>();
        self.add_observer(observer);
        self
    }

    fn add_react_event<E>(&mut self) -> &mut Self
    where
        E: ReactEvent,
    {
        self.world_mut()
            .get_resource_or_init::<ReactEventRegistry>()
            .register::<E>();
        self
    }

    fn add_react_filter<T>(&mut self) -> &mut Self
    where
        T: ReactFilter + DeserializeOwned + TS,
    {
        self.world_mut()
            .get_resource_or_init::<FilterRegistry>()
            .register::<T>();
        self
    }

    fn export_react_typescript(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
        crate::ts_codegen::export(self.world(), path.as_ref())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::react_message;
    use bevy::ecs::world::CommandQueue;

    #[react_message]
    struct Count(usize);

    // Only used to assert their derived `NAME`, so their fields go unread.
    #[react_message(name = "hp")]
    #[allow(dead_code)]
    struct Health(u32);

    #[react_message]
    #[allow(dead_code)]
    struct PlayerScore(i64);

    #[derive(Resource, Default)]
    struct LastCount(usize);

    /// The macro defaults the name to the struct ident, first letter lowered, and
    /// honours an explicit override.
    #[test]
    fn derives_emit_name() {
        assert_eq!(Count::NAME, "count");
        assert_eq!(PlayerScore::NAME, "playerScore");
        assert_eq!(Health::NAME, "hp");
    }

    fn test_app() -> App {
        let mut app = App::new();
        app.init_resource::<LastCount>();
        // Single call registers the deserializer and attaches the observer.
        app.add_react_handler(|on: On<Count>, mut last: ResMut<LastCount>| last.0 = on.event().0);
        app
    }

    /// Run one message through the plugin's dispatch path, applying the trigger
    /// it queues so observers run before we assert.
    fn dispatch(app: &mut App, msg: ReactMessage) {
        app.world_mut()
            .resource_scope(|world, registry: Mut<ReactRegistry>| {
                let mut queue = CommandQueue::default();
                let mut commands = Commands::new(&mut queue, world);
                registry.dispatch(msg, &mut commands);
                queue.apply(world);
            });
    }

    /// A registered payload deserializes and reaches its observer.
    #[test]
    fn dispatches_to_observer() {
        let mut app = test_app();
        dispatch(
            &mut app,
            ReactMessage {
                name: "count".into(),
                value: serde_json::json!(3),
            },
        );
        assert_eq!(app.world().resource::<LastCount>().0, 3);
    }

    /// An unknown name and malformed JSON are tolerated (logged, not panicked).
    #[test]
    fn tolerates_unknown_and_malformed() {
        let mut app = test_app();
        dispatch(
            &mut app,
            ReactMessage {
                name: "nope".into(),
                value: serde_json::json!(1),
            },
        );
        dispatch(
            &mut app,
            ReactMessage {
                name: "count".into(),
                value: serde_json::json!("not a number"),
            },
        );
        // Neither message should have reached the observer.
        assert_eq!(app.world().resource::<LastCount>().0, 0);
    }
}