Starbase is a framework for building performant command line applications and developer tools. A starbase is built with the following modules:
tokio
runtime.miette
.tracing
.Some things I have yet to resolve... help appreciated!
I'm not entirely sure if the event emitter implementation is the best choice to go with. We can't use channels because we need to mutate the event in listeners, and we also need to support async.
Async functions cannot be registered as listeners to on()
or once()
because of "mismatched
but equal" lifetime issues, which I've been unable to track down. This is the following error:
error[E0308]: mismatched types
--> crates/framework/tests/events_test.rs:179:5
|
179 | emitter.on(callback_func);
| ^^^^^^^^^^^^^^^^^^^^^^^^^ one type is more general than the other
|
= note: expected trait `for<'a> <for<'a> fn(&'a mut TestEvent) -> impl Future<Output = Result<EventState<<TestEvent as starbase::Event>::Value>, ErrReport>> {callback_func} as FnOnce<(&'a mut TestEvent,)>>`
found trait `for<'a> <for<'a> fn(&'a mut TestEvent) -> impl Future<Output = Result<EventState<<TestEvent as starbase::Event>::Value>, ErrReport>> {callback_func} as FnOnce<(&'a mut TestEvent,)>>`
An application is divided into phases, where systems in each phase will be processed and completed before moving onto the next phase. The following phases are available:
The startup phase processes systems serially in the main thread, as the order of initializations must be deterministic, and running in parallel may cause race conditions or unwanted side-effects.
The other 3 phases process systems concurrently by spawning a new thread for each system. Active systems are constrained using a semaphore and available CPU count. If a system fails, the application will abort and subsequent systems will not run (excluding shutdown systems).
Systems are async functions that implement the System
trait, are added to an application phase,
and are processed (only once) during the applications run cycle. Systems receive each
component type as a distinct parameter.
Systems are loosely based on the S in ECS that Bevy and other game engines utilize. The major difference is that our systems are async only, run once, and do not require the entity (E) or component (C) parts.
```rust use starbase::{App, States, Resources, Emitters, MainResult, SystemResult};
async fn load_config(states: States, resources: Resources, emitters: Emitters) -> SystemResult { let states = states.write().await;
let config: AppConfig = doloadconfig();
states.set::
Ok(()) }
async fn main() -> MainResult { App::setup_hooks();
let mut app = App::new(); app.startup(load_config); app.run().await?;
Ok(()) } ```
Each system parameter type (States
, Resources
, Emitters
) is a type alias that wraps the
underlying component manager in a Arc<RwLock<T>>
, allowing for distinct read/write locks per
component type. Separating components across params simplifies borrow semantics.
Furthermore, for better ergonomics and developer experience, we provide a #[system]
function
attribute that provides "magic" parameters similar to Axum and Bevy, which we call system
parameters. For example, the above system can be rewritten as:
```rust
async fn loadconfig(states: StatesMut) {
let config: AppConfig = doload_config();
states.set::
Which compiles down to the following, while taking mutable and immutable borrowship rules into account. If a rule is broken, we panic during compilation.
rust
async fn load_config(
states: starbase::States,
resources: starbase::Resources,
emitters: starbase::Emitters,
) -> starbase::SystemResult {
let mut states = states.write().await;
{
let config: AppConfig = do_load_config();
states.set::<AppConfig>(config);
}
Ok(())
}
Additional benefits of #[system]
are:
read().await
and write().await
over and over.Jump to the components section for a full list of supported system parameters.
In this phase, components are created and registered into their appropriate manager instance.
rust
app.startup(system_func);
app.add_system(Phase::Startup, system_instance);
In this phase, registered components are optionally updated based on the results of an analysis.
rust
app.analyze(system_func);
app.add_system(Phase::Analyze, system_instance);
In this phase, systems are processed using components to drive business logic. Ideally by this phase, all components are accessed immutably, but not a hard requirement.
rust
app.execute(system_func);
app.add_system(Phase::Execute, system_instance);
Shutdown runs on successful execution, or on a failure from any phase, and can be used to clean or reset the current environment, dump error logs or reports, so on and so forth.
rust
app.shutdown(system_func);
app.add_system(Phase::Shutdown, system_instance);
Components are values that live for the duration of the application ('static
) and are stored
internally as Any
instances, ensuring strict uniqueness. Components are dividied into 3
categories:
States are components that represent granular pieces of data, are typically implemented with a tuple
or unit struct, and must derive State
. For example, say we want to track the workspace root.
```rust use starbase::State; use std::path::PathBuf;
pub struct WorkspaceRoot(PathBuf); ```
The
State
derive macro automatically implementsAsRef
,Deref
, andDerefMut
when applicable. In the future, we may implement other traits deemed necessary.
States can be added directly to the application instance (before the run cycle has started), or
through the StatesMut
system parameter.
rust
app.set_state(WorkspaceRoot(PathBuf::from("/")));
```rust
async fn detect_root(states: StatesMut) { states.set(WorkspaceRoot(PathBuf::from("/"))); } ```
The StatesRef
system parameter can be used to acquire read access to the entire states manager. It
cannot be used alongside StatesMut
, StateRef
, or StateMut
.
```rust
async fn readstates(states: StatesRef) {
let workspaceroot = states.get::
Alternatively, the StateRef
system parameter can be used to immutably read an individual value
from the states manager. Multiple StateRef
s can be used together, but cannot be used with
StateMut
.
```rust
async fn readstates(workspaceroot: StateRef
The StatesMut
system parameter can be used to acquire write access to the entire states manager.
It cannot be used alongside StatesRef
, StateRef
or StateMut
.
```rust
async fn write_states(states: StatesMut) { states.set(SomeState); states.set(AnotherState); } ```
Furthermore, the StateMut
system parameter can be used to mutably access an individual value,
allowing for the value (or its inner value) to be modified. Only 1 StateMut
can be used in a
system, and no other state related system parameters can be used.
```rust
async fn writestate(touchedfiles: StateMut
Resources are components that represent compound data structures as complex structs, and are akin to instance singletons in other languages. Some examples of resources are project graphs, dependency trees, plugin registries, cache engines, etc.
Every resource must derive Resource
.
```rust use starbase::Resource; use std::path::PathBuf;
pub struct ProjectGraph { pub nodes; // ... pub edges; // ... } ```
The
Resource
derive macro automatically implementsAsRef
. In the future, we may implement other traits deemed necessary.
Resources can be added directly to the application instance (before the run cycle has started), or
through the ResourcesMut
system parameter.
rust
app.set_resource(ProjectGraph::new());
```rust
async fn create_graph(resources: ResourcesMut) { resources.set(ProjectGraph::new()); } ```
The ResourcesRef
system parameter can be used to acquire read access to the entire resources
manager. It cannot be used alongside ResourcesMut
, ResourceRef
, or ResourceMut
.
```rust
async fn readresources(resources: ResourcesRef) {
let projectgraph = resources.get::
Alternatively, the ResourceRef
system parameter can be used to immutably read an individual value
from the resources manager. Multiple ResourceRef
s can be used together, but cannot be used with
ResourceMut
.
```rust
async fn readresources(projectgraph: ResourceRef
The ResourcesMut
system parameter can be used to acquire write access to the entire resources
manager. It cannot be used alongside ResourcesRef
, ResourceRef
or ResourceMut
.
```rust
async fn write_resources(resources: ResourcesMut) { resources.set(ProjectGraph::new()); resources.set(CacheEngine::new()); } ```
Furthermore, the ResourceMut
system parameter can be used to mutably access an individual value.
Only 1 ResourceMut
can be used in a system, and no other resource related system parameters can be
used.
```rust
async fn writeresource(cache: ResourceMut
Emitters are components that can dispatch events to all registered listeners, allowing for
non-coupled layers to interact with each other. Unlike states and resources that are implemented and
registered individually, emitters are pre-built and provided by the starbase Emitter
struct, and
instead the individual events themselves are implemented.
Events must derive Event
, or implement the Event
trait. Events can be any type of struct, but
the major selling point is that events are mutable, allowing inner content to be modified by
listeners.
```rust use starbase::{Event, Emitter}; use app::Project;
pub struct ProjectCreatedEvent(pub Project);
let emitter = Emitter::
Jump to the how to section to learn more about emitting events.
Emitters can be added directly to the application instance (before the run cycle has started), or
through the EmittersMut
system parameter.
Each emitter represents a singular event, so the event type must be explicitly declared as a generic when creating a new emitter.
rust
app.set_emitter(Emitter::<ProjectCreatedEvent>::new());
```rust
async fn create_emitter(emitters: EmittersMut) {
emitters.set(Emitter::
Every method on Emitter
requires a mutable self, so no system parameters exist for immutably
reading an emitter.
The EmittersMut
system parameter can be used to acquire write access to the entire emitters
manager, where new emitters can be registered, or existing emitters can emit an event. It cannot
be used alongside EmitterMut
.
```rust
async fn write_emitters(emitters: EmittersMut) {
// Add emitter
emitters.set(Emitter::
// Emit event
emitters.get_mut::
// Emit event shorthand emitters.emit(ProjectCreatedEvent::new()).await?; } ```
Furthermore, the EmitterMut
system parameter can be used to mutably access an individual emitter.
Only 1 EmitterMut
can be used in a system, and no other emitter related system parameters can be
used.
```rust
async fn writeemitter(projectcreated: EmitterMut
Listeners are async functions or structs that implement Listener
, are registered into an emitter,
and are executed when an Emitter
emits an event. They are passed the event object as a mutable
parameter, allowing for the inner data to be modified.
```rust use starbase::{EventResult, EventState};
async fn listener(event: &mut ProjectCreatedEvent) -> EventResult
// TODO: These currently don't work because of lifetime issues! emitter.on(listener); // Runs multiple times emitter.once(listener); // Only runs once ```
```rust use starbase::{EventResult, EventState, Listener}; use asynctrait::asynctrait;
struct TestListener;
impl Listener
async fn onemit(&mut self, event: &mut ProjectCreatedEvent) -> EventResult
emitter.listen(TestListener); ```
As a temporary solution for async function lifetime issues, we provide #[listener]
and
#[listener(once)]
function attributes, which will convert the function to a Listener
struct
internally. The major drawback is that the function name is lost, and the new struct name must be
passed to listen()
.
```rust use starbase::{EventResult, EventState, listener};
async fn somefunc(event: &mut ProjectCreatedEvent) -> EventResult
// Rename to... emitter.listen(SomeFuncListener); ```
Listeners can control this emit execution flow by returning EventState
, which supports the
following variants:
Continue
- Continues to the next listener.Stop
- Stops after this listener, discarding subsequent listeners.Return
- Like Stop
but also returns a value for interception.```rust
async fn continue_flow(event: &mut CacheCheckEvent) -> EventResult
async fn stop_flow(event: &mut CacheCheckEvent) -> EventResult
async fn returnflow(event: &mut CacheCheckEvent) -> EventResult
For Return
flows, the type of value returned is inferred from the event. By default the value is a
unit type (()
), but can be customized with #[event]
or type Value
when implementing manually.
```rust use starbase::{Event, Emitter}; use std::path::PathBuf;
pub struct CacheCheckEvent(pub PathBuf);
// OR pub struct CacheCheckEvent(pub PathBuf);
impl Event for CacheCheckEvent { type Value = PathBuf; } ```
When an event is emitted, listeners are executed sequentially in the same thread so that each listener can mutate the event if necessary. Because of this, events do not support references for inner values, and instead must own everything.
An event can be emitted with the emit()
method, which requires an owned event (and owned inner
data).
```rust let (event, result) = emitters.emit(ProjectCreatedEvent::new(ownedinnerdata)).await?;
// Take ownership of inner data let project = event.0; ```
Emitting returns a tuple, containing the final event after all modifications, and a result of type
Option<Event::Value>
(which is provided with EventState::Return
).
Errors and diagnostics are provided by the miette
crate. All
layers of the application, from systems, to events, and the application itself, return the
miette::Result
type. This allows for errors to be easily converted to diagnostics, and for miette
to automatically render to the terminal for errors and panics.
To benefit from this, update your main
function to return MainResult
, and call
App::setup_hook()
to register error/panic handlers.
```rust use starbase::{App, MainResult};
async fn main() -> MainResult { App::setup_hook();
let mut app = App::new(); // ... app.run().await?;
Ok(()) } ```
To make the most out of errors, and in turn diagnostics, it's best (also suggested) to use the
thiserror
crate.
```rust use starbase::Diagnostic; use thiserror::Error;
pub enum AppError { #[error(transparent)] #[diagnostic(code(app::io_error))] IoError(#[from] std::io::Error),
#[error("Systems offline!")]
#[diagnostic(code(app::bad_code))]
SystemsOffline,
} ```
In systems, events, and other fallible layers, a returned Err
must be converted to a diagnostic
first. There are 2 approaches to achieve this:
```rust
async fn could_fail() { // Convert error using into() Err(AppError::SystemsOffline.into())
// OR use ? operator on Err() Err(AppError::SystemsOffline)? } ```