Skip to main content

ruststream/runtime/app/
service.rs

1//! The [`RustStream`] builder: construction, configuration and handler registration.
2
3use std::{
4    borrow::Cow, collections::BTreeMap, error::Error as StdError, fmt, future::Future,
5    marker::PhantomData, sync::Arc, time::Duration,
6};
7
8use crate::codec::Codec;
9use crate::runtime::publish_source::BrokerRegistration;
10use crate::{Broker, DescribeServer, ServerSpec};
11
12use tokio_util::task::TaskTracker;
13
14use crate::runtime::lifecycle::{BoxError, BrokerCell, BrokerLifecycle, ConnectedSlot};
15use crate::runtime::metadata::HandlerMetadata;
16use crate::runtime::middleware::{Identity, Stack};
17#[cfg(feature = "poll-diagnostics")]
18use crate::runtime::poll_diagnostics::PollDiagnostics;
19use crate::runtime::publish::{PublishIdentity, PublishLayer, PublishStack};
20use crate::runtime::redelivery::ScopeDelivery;
21use crate::runtime::router::RouterSink;
22#[cfg(feature = "testing")]
23use crate::testing::coordinator::TestHooks;
24
25use super::scope::BrokerScope;
26use super::{AppInfo, LifecycleHook, LifecyclePhase, Starter, StateInit};
27
28/// The top-level application object.
29///
30/// `RustStream` binds one or more brokers, the handlers attached to each, and the service
31/// lifecycle into a single runnable unit. Handlers are registered through [`with_broker`], which
32/// hands a scope bound to that broker; nothing connects or subscribes until [`run`]. Brokers are
33/// held type-erased (only their lifecycle), so a single service can mix broker types.
34///
35/// The type parameter `Layers` is the global middleware stack applied to every per-message
36/// handler registered on a broker scope, whether directly or via
37/// [`include_router`](BrokerScope::include_router) (batch handlers are the exception: a
38/// per-message layer cannot wrap a whole-batch handler); it defaults to the no-op [`Identity`]
39/// and grows as [`layer`] is called. Add all layers before [`with_broker`], since a layer only
40/// applies to handlers registered after it.
41///
42/// The type parameter `Phase` tracks the builder phase at compile time: [`new`](Self::new)
43/// starts in [`Setup`], where the state ([`on_startup`]), the middleware stack ([`layer`]) and
44/// the publish pipeline ([`publish_layer`](Self::publish_layer)) are still configurable; the
45/// first [`with_broker`] moves it to [`Wired`], where those methods no longer exist - so a
46/// configuration call that would silently not apply to already-registered handlers does not
47/// compile. `Phase` defaults to [`Wired`], the built service; `Setup` lives inside a builder
48/// chain and is rarely written out.
49///
50/// [`with_broker`]: Self::with_broker
51/// [`layer`]: Self::layer
52/// [`on_startup`]: Self::on_startup
53/// [`run`]: Self::run
54///
55/// # Examples
56///
57/// ```
58/// # #[cfg(all(feature = "macros", feature = "memory", feature = "json"))]
59/// # mod demo {
60/// use ruststream::memory::prelude::*;
61/// use ruststream::runtime::layers::TracingLayer;
62/// use serde::Deserialize;
63///
64/// #[derive(Deserialize)]
65/// struct Order {
66///     id: u64,
67/// }
68///
69/// #[subscriber("orders")]
70/// async fn accept(order: &Order) -> HandlerOutcome {
71///     tracing::info!(order.id, "accepted");
72///     HandlerOutcome::ack()
73/// }
74///
75/// /// What `#[ruststream::app]` runs: a span around every delivery, one broker, one handler.
76/// pub fn app() -> impl App {
77///     RustStream::new(AppInfo::new("orders", "0.1.0"))
78///         .layer(TracingLayer::default())
79///         .with_broker(MemoryBroker::new(), |b| {
80///             b.include(accept);
81///         })
82/// }
83/// # }
84/// # fn main() {}
85/// ```
86pub struct RustStream<Layers = Identity, State = (), Pipeline = PublishIdentity, Phase = Wired> {
87    pub(super) info: AppInfo,
88    pub(super) brokers: Vec<RegisteredBroker>,
89    pub(super) starters: Vec<Starter<State>>,
90    pub(super) handlers: Vec<HandlerMetadata>,
91    pub(super) servers: BTreeMap<String, ServerSpec>,
92    pub(super) publish_pipeline: Pipeline,
93    pub(super) state_init: StateInit<State>,
94    pub(super) after_startup: Vec<LifecycleHook<State>>,
95    pub(super) on_shutdown: Vec<LifecycleHook<State>>,
96    pub(super) after_shutdown: Vec<LifecycleHook<State>>,
97    pub(super) shutdown_timeout: Option<Duration>,
98    /// Tracks post-settle `and_after` continuations spawned during dispatch, so a graceful
99    /// shutdown drains them after the dispatch loops stop. Shared (cloned) into every
100    /// [`Delivery`].
101    pub(super) continuations: TaskTracker,
102    /// Shared recording-and-quiescence hooks for the [`TestApp`](crate::testing::TestApp) harness,
103    /// cloned into every scope's [`Delivery`]. Empty until a harness installs a coordinator, so a
104    /// non-harness run with the `testing` feature enabled stays inert.
105    #[cfg(feature = "testing")]
106    pub(super) test_hooks: Arc<TestHooks>,
107    /// Where every subscription's poll-time samples go, and what they are held to.
108    #[cfg(feature = "poll-diagnostics")]
109    pub(super) poll_diagnostics: PollDiagnostics,
110    pub(super) global: Layers,
111    // `fn() -> Phase` keeps the marker out of auto-trait and variance considerations, matching
112    // the router builder's broker marker.
113    pub(super) phase: PhantomData<fn() -> Phase>,
114}
115
116/// [`RustStream`] phase marker: the builder is still being configured - the state type, the
117/// middleware stack, and the publish pipeline may change, and no broker is registered yet.
118#[derive(Debug, Clone, Copy, Default)]
119pub struct Setup;
120
121/// [`RustStream`] phase marker: at least one broker (with its handlers) is registered, so the
122/// state type, the middleware stack, and the publish pipeline are fixed.
123#[derive(Debug, Clone, Copy, Default)]
124pub struct Wired;
125
126/// A broker held by the app for lifecycle management, paired with its optional label.
127///
128/// The label is the broker's stable runtime identity and its `AsyncAPI` server name; it is `Some`
129/// for a broker registered through [`with_broker_labeled`](RustStream::with_broker_labeled) (or its
130/// codec variant) and `None` otherwise.
131pub(crate) struct RegisteredBroker {
132    pub(crate) lifecycle: Box<dyn BrokerLifecycle>,
133    pub(crate) label: Option<String>,
134}
135
136/// The internals the [`TestApp`](crate::testing::TestApp) harness needs to drive an app without
137/// connecting: the brokers (to recover and instrument), the deferred starters, the lifecycle hooks,
138/// and the shared test hooks slot. Produced by [`RustStream::into_test_parts`].
139///
140/// Public in name only, and never nameable outside the crate: the sealed supertrait of
141/// [`App`](crate::runtime::App) hands it to the harness for an app a builder returned as
142/// `impl App`, and a method of that trait cannot return a crate-private type.
143#[cfg(feature = "testing")]
144#[doc(hidden)]
145#[allow(unreachable_pub, missing_debug_implementations)]
146pub struct TestParts<State> {
147    pub(crate) brokers: Vec<RegisteredBroker>,
148    pub(crate) starters: Vec<Starter<State>>,
149    pub(crate) state_init: StateInit<State>,
150    pub(crate) after_startup: Vec<LifecycleHook<State>>,
151    pub(crate) shutdown_timeout: Option<Duration>,
152    pub(crate) continuations: TaskTracker,
153    pub(crate) test_hooks: Arc<TestHooks>,
154}
155
156impl<Layers, State, Pipeline, Phase> fmt::Debug for RustStream<Layers, State, Pipeline, Phase> {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        f.debug_struct("RustStream")
159            .field("info", &self.info)
160            .field("brokers", &self.brokers.len())
161            .field("handlers", &self.handlers.len())
162            .finish_non_exhaustive()
163    }
164}
165
166impl RustStream<Identity, (), PublishIdentity, Setup> {
167    /// Creates an empty service with the given metadata, no global middleware, and the unit
168    /// application state `()`. Produce a typed state with [`on_startup`](Self::on_startup).
169    #[must_use]
170    pub fn new(info: AppInfo) -> Self {
171        Self {
172            info,
173            brokers: Vec::new(),
174            starters: Vec::new(),
175            handlers: Vec::new(),
176            servers: BTreeMap::new(),
177            publish_pipeline: PublishIdentity,
178            state_init: Box::new(|| Box::pin(async { Ok(()) })),
179            after_startup: Vec::new(),
180            on_shutdown: Vec::new(),
181            after_shutdown: Vec::new(),
182            shutdown_timeout: None,
183            continuations: TaskTracker::new(),
184            #[cfg(feature = "testing")]
185            test_hooks: Arc::new(TestHooks::detached()),
186            #[cfg(feature = "poll-diagnostics")]
187            poll_diagnostics: PollDiagnostics::new(),
188            global: Identity,
189            phase: PhantomData,
190        }
191    }
192}
193
194impl<Layers, State, Pipeline> RustStream<Layers, State, Pipeline, Setup> {
195    /// Adds a global middleware layer, applied to every handler registered later.
196    ///
197    /// The first layer added runs outermost. Only available before the first
198    /// [`with_broker`](Self::with_broker): a layer added later could not wrap the handlers
199    /// already registered, so that ordering does not compile.
200    #[must_use]
201    pub fn layer<N>(self, layer: N) -> RustStream<Stack<N, Layers>, State, Pipeline, Setup> {
202        RustStream {
203            info: self.info,
204            brokers: self.brokers,
205            starters: self.starters,
206            handlers: self.handlers,
207            servers: self.servers,
208            publish_pipeline: self.publish_pipeline,
209            state_init: self.state_init,
210            after_startup: self.after_startup,
211            on_shutdown: self.on_shutdown,
212            after_shutdown: self.after_shutdown,
213            shutdown_timeout: self.shutdown_timeout,
214            continuations: self.continuations,
215            #[cfg(feature = "testing")]
216            test_hooks: self.test_hooks,
217            #[cfg(feature = "poll-diagnostics")]
218            poll_diagnostics: self.poll_diagnostics,
219            global: Stack::new(layer, self.global),
220            phase: PhantomData,
221        }
222    }
223
224    /// Produces the typed application state at startup, transitioning the app's state type from the
225    /// previous `State` to `State2`.
226    ///
227    /// The hook runs once before brokers connect; its future can `await` (open a database pool,
228    /// connect a client), and the produced `State2` is shared with every handler (read via
229    /// [`Context::state`](crate::runtime::Context::state)) and the read-only lifecycle hooks. A
230    /// failing hook aborts startup. The initial state is `()` (from [`new`](Self::new)), so the
231    /// first call's hook receives `()`.
232    ///
233    /// Only available before the first [`with_broker`](Self::with_broker): the call fixes the
234    /// app's state type, and handlers registered against a different state type could not be
235    /// carried across - so that ordering does not compile.
236    ///
237    /// # Panics
238    ///
239    /// Panics if a lifecycle hook ([`after_startup`](Self::after_startup),
240    /// [`on_shutdown`](Self::on_shutdown), [`after_shutdown`](Self::after_shutdown)) was
241    /// registered first: hooks close over the state type and cannot be carried across the state
242    /// change. Register hooks after `on_startup`.
243    #[must_use]
244    pub fn on_startup<F, Fut, State2, E>(
245        self,
246        hook: F,
247    ) -> RustStream<Layers, State2, Pipeline, Setup>
248    where
249        F: FnOnce(State) -> Fut + Send + 'static,
250        Fut: Future<Output = Result<State2, E>> + Send,
251        State: Send + 'static,
252        State2: Send + Sync + 'static,
253        E: StdError + Send + Sync + 'static,
254    {
255        assert!(
256            self.after_startup.is_empty()
257                && self.on_shutdown.is_empty()
258                && self.after_shutdown.is_empty(),
259            "on_startup must be called before lifecycle hooks are registered: hooks close over \
260             the state type and cannot be carried across the state change"
261        );
262        let prev = self.state_init;
263        RustStream {
264            info: self.info,
265            brokers: self.brokers,
266            // Provably empty: with_broker (the only way to add starters) leaves Setup.
267            starters: Vec::new(),
268            handlers: self.handlers,
269            servers: self.servers,
270            publish_pipeline: self.publish_pipeline,
271            state_init: Box::new(move || {
272                Box::pin(async move {
273                    let prev_state = prev().await?;
274                    hook(prev_state).await.map_err(|e| Box::new(e) as BoxError)
275                })
276            }),
277            after_startup: Vec::new(),
278            on_shutdown: Vec::new(),
279            after_shutdown: Vec::new(),
280            shutdown_timeout: self.shutdown_timeout,
281            continuations: self.continuations,
282            #[cfg(feature = "testing")]
283            test_hooks: self.test_hooks,
284            #[cfg(feature = "poll-diagnostics")]
285            poll_diagnostics: self.poll_diagnostics,
286            global: self.global,
287            phase: PhantomData,
288        }
289    }
290
291    /// Adds an outgoing publish middleware, run on every publish a handler makes before it
292    /// reaches the broker (a Confluent / Avro envelope, publish metrics, dead-letter): a
293    /// `reply(..)` form's reply and every message that leaves through an injected
294    /// [`Out`](crate::runtime::Out) slot alike, each under the transforms its own mount site
295    /// named. It composes into the
296    /// pipeline type parameter, so the *last* one added wraps the rest and runs outermost (unlike the
297    /// consume-side [`layer`](Self::layer), where the first added is outermost); the middleware must
298    /// be [`Clone`] (the pipeline is cloned into each publishing handler and each slot). Only
299    /// available before
300    /// the first [`with_broker`](Self::with_broker): a middleware added later could not wrap the
301    /// publishers already handed out, so that ordering does not compile.
302    ///
303    /// A handler mounted through [`include_router`](BrokerScope::include_router) is the one
304    /// exception on the slot side: a router's routes are typed before the app that mounts them,
305    /// so its slots carry their own `.out(marker, policy).transform(..)` steps and not this
306    /// chain (its replies still travel it).
307    #[must_use]
308    pub fn publish_layer<M>(
309        self,
310        middleware: M,
311    ) -> RustStream<Layers, State, PublishStack<M, Pipeline>, Setup>
312    where
313        M: PublishLayer + Clone + 'static,
314    {
315        // Prepend `middleware` as the new outermost wrapper: the publish pipeline stays a statically
316        // composed type (no `dyn` dispatch), and the last one added runs outermost.
317        RustStream {
318            info: self.info,
319            brokers: self.brokers,
320            starters: self.starters,
321            handlers: self.handlers,
322            servers: self.servers,
323            publish_pipeline: PublishStack::new(middleware, self.publish_pipeline),
324            state_init: self.state_init,
325            after_startup: self.after_startup,
326            on_shutdown: self.on_shutdown,
327            after_shutdown: self.after_shutdown,
328            shutdown_timeout: self.shutdown_timeout,
329            continuations: self.continuations,
330            #[cfg(feature = "testing")]
331            test_hooks: self.test_hooks,
332            #[cfg(feature = "poll-diagnostics")]
333            poll_diagnostics: self.poll_diagnostics,
334            global: self.global,
335            phase: PhantomData,
336        }
337    }
338
339    /// Sets what the poll-time diagnostics hold every subscription's handler to, and where their
340    /// reports go (`poll-diagnostics` feature).
341    ///
342    /// With the feature on, every subscription is sampled whether or not this is called; the call
343    /// changes the threshold or the sampling interval, and the handle kept outside reads the
344    /// reports or hands them to an exporter. Only available before the first
345    /// [`with_broker`](Self::with_broker), which opens subscriptions under the settings in place
346    /// then, so that ordering does not compile.
347    ///
348    /// # Examples
349    ///
350    /// ```
351    /// # #[cfg(all(feature = "macros", feature = "memory", feature = "json"))]
352    /// # mod demo {
353    /// use std::time::Duration;
354    ///
355    /// use ruststream::memory::prelude::*;
356    /// use ruststream::runtime::PollDiagnostics;
357    /// use serde::Deserialize;
358    ///
359    /// #[derive(Deserialize)]
360    /// struct Order {
361    ///     id: u64,
362    /// }
363    ///
364    /// #[subscriber("orders")]
365    /// async fn accept(order: &Order) -> HandlerOutcome {
366    ///     tracing::info!(order.id, "accepted");
367    ///     HandlerOutcome::ack()
368    /// }
369    ///
370    /// /// The caller keeps a clone of `diagnostics` to read the reports while the app runs.
371    /// pub fn app(diagnostics: &PollDiagnostics) -> impl App {
372    ///     RustStream::new(AppInfo::new("orders", "0.1.0"))
373    ///         .poll_diagnostics(diagnostics.clone().threshold(Duration::from_micros(500)))
374    ///         .with_broker(MemoryBroker::new(), |b| {
375    ///             b.include(accept);
376    ///         })
377    /// }
378    /// # }
379    /// # fn main() {}
380    /// ```
381    #[cfg(feature = "poll-diagnostics")]
382    #[must_use]
383    pub fn poll_diagnostics(mut self, diagnostics: PollDiagnostics) -> Self {
384        self.poll_diagnostics = diagnostics;
385        self
386    }
387}
388
389impl<Layers, State, Pipeline, Phase> RustStream<Layers, State, Pipeline, Phase> {
390    /// Rebuilds the app under a different phase marker; the fields move as they are.
391    fn into_phase<Q>(self) -> RustStream<Layers, State, Pipeline, Q> {
392        RustStream {
393            info: self.info,
394            brokers: self.brokers,
395            starters: self.starters,
396            handlers: self.handlers,
397            servers: self.servers,
398            publish_pipeline: self.publish_pipeline,
399            state_init: self.state_init,
400            after_startup: self.after_startup,
401            on_shutdown: self.on_shutdown,
402            after_shutdown: self.after_shutdown,
403            shutdown_timeout: self.shutdown_timeout,
404            continuations: self.continuations,
405            #[cfg(feature = "testing")]
406            test_hooks: self.test_hooks,
407            #[cfg(feature = "poll-diagnostics")]
408            poll_diagnostics: self.poll_diagnostics,
409            global: self.global,
410            phase: PhantomData,
411        }
412    }
413
414    /// Adds a hook run after brokers connect and handlers are spawned (for example, to publish an
415    /// initial message or signal readiness). A failing hook aborts startup.
416    #[must_use]
417    pub fn after_startup<F, Fut, E>(self, hook: F) -> Self
418    where
419        State: Send + Sync + 'static,
420        F: FnOnce(Arc<State>) -> Fut + Send + 'static,
421        Fut: Future<Output = Result<(), E>> + Send,
422        E: StdError + Send + Sync + 'static,
423    {
424        self.push_lifecycle_hook(LifecyclePhase::AfterStartup, hook)
425    }
426
427    /// Adds a hook run when shutdown begins, while brokers are still connected. Errors are logged.
428    #[must_use]
429    pub fn on_shutdown<F, Fut, E>(self, hook: F) -> Self
430    where
431        State: Send + Sync + 'static,
432        F: FnOnce(Arc<State>) -> Fut + Send + 'static,
433        Fut: Future<Output = Result<(), E>> + Send,
434        E: StdError + Send + Sync + 'static,
435    {
436        self.push_lifecycle_hook(LifecyclePhase::OnShutdown, hook)
437    }
438
439    /// Adds a hook run after brokers have shut down (for final async resource teardown). Errors are
440    /// logged.
441    #[must_use]
442    pub fn after_shutdown<F, Fut, E>(self, hook: F) -> Self
443    where
444        State: Send + Sync + 'static,
445        F: FnOnce(Arc<State>) -> Fut + Send + 'static,
446        Fut: Future<Output = Result<(), E>> + Send,
447        E: StdError + Send + Sync + 'static,
448    {
449        self.push_lifecycle_hook(LifecyclePhase::AfterShutdown, hook)
450    }
451
452    fn push_lifecycle_hook<F, Fut, E>(mut self, phase: LifecyclePhase, hook: F) -> Self
453    where
454        State: Send + Sync + 'static,
455        F: FnOnce(Arc<State>) -> Fut + Send + 'static,
456        Fut: Future<Output = Result<(), E>> + Send,
457        E: StdError + Send + Sync + 'static,
458    {
459        let boxed: LifecycleHook<State> = Box::new(move |state| {
460            Box::pin(async move { hook(state).await.map_err(|e| Box::new(e) as BoxError) })
461        });
462        match phase {
463            LifecyclePhase::AfterStartup => self.after_startup.push(boxed),
464            LifecyclePhase::OnShutdown => self.on_shutdown.push(boxed),
465            LifecyclePhase::AfterShutdown => self.after_shutdown.push(boxed),
466        }
467        self
468    }
469
470    /// Sets how long [`run`](Self::run) waits for in-flight handlers to finish after shutdown is
471    /// triggered. After the timeout, the remaining handler tasks are aborted. The same bound then
472    /// applies to draining post-settle `and_after` continuations; on timeout they are abandoned
473    /// (they are at-most-once side effects, so this loses follow-up work, never a settlement).
474    /// Defaults to waiting indefinitely.
475    #[must_use]
476    pub fn shutdown_timeout(mut self, timeout: Duration) -> Self {
477        self.shutdown_timeout = Some(timeout);
478        self
479    }
480
481    /// Registers a broker for lifecycle management only (connect / shutdown), without attaching
482    /// subscribers. Use for publish-only brokers.
483    #[must_use]
484    pub fn register_broker<R>(mut self, broker: R) -> Self
485    where
486        R: BrokerRegistration,
487    {
488        // No subscriptions read the slot, but a Bindable registration's tokens do.
489        let (broker, slot) = broker.into_parts();
490        self.brokers.push(RegisteredBroker {
491            lifecycle: Box::new(BrokerCell { broker, slot }),
492            label: None,
493        });
494        self
495    }
496
497    /// Records an `AsyncAPI` server (one per broker the service connects to).
498    ///
499    /// Build the [`ServerSpec`] directly, or get it from a broker that implements
500    /// [`DescribeServer`](crate::DescribeServer): `app.server("nats", broker.describe_server())`.
501    /// `build_spec` emits these in the document's `servers` section.
502    ///
503    /// For a self-describing broker, prefer
504    /// [`with_broker_labeled`](Self::with_broker_labeled), which derives this entry from the broker
505    /// under its label in one step. Use this method for brokers without a network address (the
506    /// in-memory broker), or to override a labeled broker's own spec.
507    #[must_use]
508    pub fn server(mut self, name: impl Into<String>, spec: ServerSpec) -> Self {
509        self.servers.insert(name.into(), spec);
510        self
511    }
512
513    /// Registers a broker and the handlers attached to it.
514    ///
515    /// `build` receives a [`BrokerScope`] typed to this broker; use it to attach handlers. The
516    /// broker is then held for lifecycle management. Call this once per broker.
517    ///
518    /// The scope carries no codec of its own, so macro handlers mounted with `b.include(handle)`
519    /// decode with the [`DefaultCodec`](crate::codec::DefaultCodec). To decode with another codec
520    /// scope-wide, use [`with_broker_codec`](Self::with_broker_codec).
521    ///
522    /// The first call moves the builder to the [`Wired`] phase: the state, the middleware stack,
523    /// and the publish pipeline are fixed from here on, so a configuration call that could not
524    /// apply to this broker's handlers does not compile.
525    #[must_use]
526    pub fn with_broker<R, F>(
527        self,
528        broker: R,
529        build: F,
530    ) -> RustStream<Layers, State, Pipeline, Wired>
531    where
532        R: BrokerRegistration,
533        Layers: Clone,
534        Pipeline: Clone,
535        State: Send + Sync + 'static,
536        F: FnOnce(&mut BrokerScope<R::Broker, Layers, (), State, Pipeline>),
537    {
538        let mut this = self.into_phase::<Wired>();
539        let (broker, slot) = broker.into_parts();
540        let mut scope = this.new_scope(broker, slot, ());
541        build(&mut scope);
542        this.collect_scope(scope, None);
543        this
544    }
545
546    /// Registers a broker with a scope-wide `codec`, replacing the
547    /// [`DefaultCodec`](crate::codec::DefaultCodec) a codec-less scope decodes with.
548    ///
549    /// `build` receives a [`BrokerScope`] whose [`include`](BrokerScope::include) family reads
550    /// the same as on a codec-less scope (`b.include(handle)`) but decodes with `codec`.
551    #[must_use]
552    pub fn with_broker_codec<R, C, F>(
553        self,
554        broker: R,
555        codec: C,
556        build: F,
557    ) -> RustStream<Layers, State, Pipeline, Wired>
558    where
559        R: BrokerRegistration,
560        C: Codec + Clone + 'static,
561        Layers: Clone,
562        Pipeline: Clone,
563        State: Send + Sync + 'static,
564        F: FnOnce(&mut BrokerScope<R::Broker, Layers, C, State, Pipeline>),
565    {
566        let mut this = self.into_phase::<Wired>();
567        let (broker, slot) = broker.into_parts();
568        let mut scope = this.new_scope(broker, slot, codec);
569        build(&mut scope);
570        this.collect_scope(scope, None);
571        this
572    }
573
574    /// Registers a self-describing broker under `label`, along with the handlers attached to it.
575    ///
576    /// The `label` is the broker's stable identity in the service and the name of its `AsyncAPI`
577    /// server: the broker's [`describe_server`](DescribeServer::describe_server) coordinates are
578    /// recorded in the `servers` map under `label`, so the document stays in sync with the brokers
579    /// actually mounted, with no separate [`server`](Self::server) call. An explicit
580    /// [`server(label, spec)`](Self::server) entry for the same label takes precedence.
581    ///
582    /// Like [`with_broker`](Self::with_broker), the scope decodes with the
583    /// [`DefaultCodec`](crate::codec::DefaultCodec); use
584    /// [`with_broker_labeled_codec`](Self::with_broker_labeled_codec) to name another.
585    ///
586    /// # Examples
587    ///
588    /// ```no_run
589    /// # #[cfg(feature = "memory")]
590    /// # async fn run() -> Result<(), ruststream::runtime::RustStreamError> {
591    /// use ruststream::runtime::{AppInfo, RustStream};
592    /// use ruststream::{Broker, DescribeServer, ServerSpec};
593    ///
594    /// # struct NatsBroker;
595    /// # struct ConnectedNats;
596    /// # impl NatsBroker { fn new(_: &str) -> Self { Self } }
597    /// # impl Broker for NatsBroker {
598    /// #     type Error = std::io::Error;
599    /// #     type Connected = ConnectedNats;
600    /// #     async fn connect(self) -> Result<ConnectedNats, Self::Error> { Ok(ConnectedNats) }
601    /// # }
602    /// # impl ruststream::ConnectedBroker for ConnectedNats {
603    /// #     type Error = std::io::Error;
604    /// #     type Closed = ();
605    /// #     async fn shutdown(self) -> Result<(), Self::Error> { Ok(()) }
606    /// # }
607    /// # impl DescribeServer for NatsBroker {
608    /// #     fn describe_server(&self) -> ServerSpec { ServerSpec::new("nats:4222", "nats") }
609    /// # }
610    /// let app = RustStream::new(AppInfo::new("svc", "0.1.0"))
611    ///     .with_broker_labeled("ingress", NatsBroker::new("nats://localhost"), |_b| {});
612    /// // The AsyncAPI `servers` map now carries "ingress" with the broker's host / protocol.
613    /// app.run().await
614    /// # }
615    /// ```
616    #[must_use]
617    pub fn with_broker_labeled<R, F>(
618        self,
619        label: impl Into<String>,
620        broker: R,
621        build: F,
622    ) -> RustStream<Layers, State, Pipeline, Wired>
623    where
624        R: BrokerRegistration,
625        R::Broker: DescribeServer,
626        Layers: Clone,
627        Pipeline: Clone,
628        State: Send + Sync + 'static,
629        F: FnOnce(&mut BrokerScope<R::Broker, Layers, (), State, Pipeline>),
630    {
631        let mut this = self.into_phase::<Wired>();
632        let (broker, slot) = broker.into_parts();
633        let label = this.record_server(label, &broker);
634        let mut scope = this.new_scope(broker, slot, ());
635        build(&mut scope);
636        this.collect_scope(scope, Some(label));
637        this
638    }
639
640    /// Registers a self-describing broker under `label` with a default `codec`.
641    ///
642    /// Combines [`with_broker_labeled`](Self::with_broker_labeled) (the label is the broker's
643    /// identity and `AsyncAPI` server name) with
644    /// [`with_broker_codec`](Self::with_broker_codec) (the scope's macro handlers decode with
645    /// `codec`).
646    #[must_use]
647    pub fn with_broker_labeled_codec<R, C, F>(
648        self,
649        label: impl Into<String>,
650        broker: R,
651        codec: C,
652        build: F,
653    ) -> RustStream<Layers, State, Pipeline, Wired>
654    where
655        R: BrokerRegistration,
656        R::Broker: DescribeServer,
657        C: Codec + Clone + 'static,
658        Layers: Clone,
659        Pipeline: Clone,
660        State: Send + Sync + 'static,
661        F: FnOnce(&mut BrokerScope<R::Broker, Layers, C, State, Pipeline>),
662    {
663        let mut this = self.into_phase::<Wired>();
664        let (broker, slot) = broker.into_parts();
665        let label = this.record_server(label, &broker);
666        let mut scope = this.new_scope(broker, slot, codec);
667        build(&mut scope);
668        this.collect_scope(scope, Some(label));
669        this
670    }
671
672    /// Records `broker`'s server coordinates under `label` (keeping an explicit
673    /// [`server`](Self::server) entry already set for the same label), returning the owned label.
674    fn record_server<B: DescribeServer>(&mut self, label: impl Into<String>, broker: &B) -> String {
675        let label = label.into();
676        self.servers
677            .entry(label.clone())
678            .or_insert_with(|| broker.describe_server());
679        label
680    }
681
682    /// Builds a fresh scope bound to `broker` carrying `codec` and the app's publishers / pipeline.
683    fn new_scope<B, C>(
684        &self,
685        broker: B,
686        slot: ConnectedSlot<B>,
687        codec: C,
688    ) -> BrokerScope<B, Layers, C, State, Pipeline>
689    where
690        B: Broker + 'static,
691        Layers: Clone,
692        Pipeline: Clone,
693        State: Send + Sync + 'static,
694    {
695        BrokerScope {
696            broker,
697            slot,
698            startup_hooks: Vec::new(),
699            sink: RouterSink::new(),
700            pipeline: self.publish_pipeline.clone(),
701            global: self.global.clone(),
702            codec,
703        }
704    }
705
706    /// Drains a built scope's registrations into the app and holds the broker for lifecycle,
707    /// recording `label` as the broker's stable runtime identity (`None` when unlabeled).
708    ///
709    /// The broker itself is boxed into a [`BrokerCell`] whose consuming `connect` publishes the
710    /// typed connected form into a shared slot; each starter reads the slot at startup, after
711    /// every broker connected and before any subscription opens.
712    fn collect_scope<B, C>(
713        &mut self,
714        scope: BrokerScope<B, Layers, C, State, Pipeline>,
715        label: Option<String>,
716    ) where
717        B: Broker + 'static,
718        State: Send + Sync + 'static,
719    {
720        let BrokerScope {
721            broker,
722            slot,
723            startup_hooks,
724            sink,
725            ..
726        } = scope;
727        self.after_startup.extend(startup_hooks);
728        let (starters, handlers) = sink.into_parts();
729        for (bound, meta) in starters.into_iter().zip(handlers) {
730            let slot = Arc::clone(&slot);
731            // A live harness waits only for what reaches a subscription the app mounts, and tells
732            // two subscriptions reporting one name apart by the identity noted here.
733            #[cfg(feature = "testing")]
734            let subscription = self.test_hooks.subscribed(self.brokers.len(), &meta.name);
735            // The scope id is the index this broker will occupy once pushed below; the harness
736            // uses it to scope recorded deliveries per broker.
737            let scope_delivery = Arc::new(ScopeDelivery::new(
738                self.continuations.clone(),
739                #[cfg(feature = "testing")]
740                self.test_hooks.clone(),
741                #[cfg(feature = "testing")]
742                self.brokers.len(),
743                #[cfg(feature = "testing")]
744                subscription,
745                #[cfg(feature = "poll-diagnostics")]
746                self.poll_diagnostics.clone(),
747            ));
748            self.starters.push(Box::new(move |state, shutdown| {
749                let connected = slot
750                    .lock()
751                    .expect("connected slot mutex poisoned")
752                    .clone()
753                    .expect("brokers connect before subscriptions open");
754                bound(connected, state, scope_delivery, shutdown)
755            }));
756            // The scope's label is the name of this broker's AsyncAPI server, so every channel
757            // the scope mounts can say which server it lives on.
758            self.handlers.push(HandlerMetadata {
759                server: label.clone().map(Cow::Owned),
760                ..meta
761            });
762        }
763        self.brokers.push(RegisteredBroker {
764            lifecycle: Box::new(BrokerCell { broker, slot }),
765            label,
766        });
767    }
768
769    /// Returns metadata for every registered handler, in registration order. Input to the
770    /// `AsyncAPI` generator.
771    #[must_use]
772    pub fn handlers(&self) -> &[HandlerMetadata] {
773        &self.handlers
774    }
775
776    /// Returns the service metadata.
777    #[must_use]
778    pub fn info(&self) -> &AppInfo {
779        &self.info
780    }
781
782    /// Returns the registered `AsyncAPI` servers, keyed by name. Input to the `AsyncAPI` generator.
783    #[must_use]
784    pub fn servers(&self) -> &BTreeMap<String, ServerSpec> {
785        &self.servers
786    }
787
788    /// Decomposes the app into the pieces the [`TestApp`](crate::testing::TestApp) harness drives:
789    /// the brokers, the deferred starters, the lifecycle hooks, and the shared test-hooks slot.
790    /// Handlers metadata and `AsyncAPI` servers are dropped (the harness does not need them).
791    #[cfg(feature = "testing")]
792    pub(crate) fn into_test_parts(self) -> TestParts<State> {
793        TestParts {
794            brokers: self.brokers,
795            starters: self.starters,
796            state_init: self.state_init,
797            after_startup: self.after_startup,
798            shutdown_timeout: self.shutdown_timeout,
799            continuations: self.continuations,
800            test_hooks: self.test_hooks,
801        }
802    }
803}