Ravioli framework - introduction
Daniel Neveux
Posted on April 10, 2020
Stop doing spaghetti code
Ravioli is a JS/TS framework to develop full stack app. It is currently in development but ready for a public introduction.
Ravioli is like spaghetti bolognese, but minified and well organized. Also, it does not spread when you are hurry.
Installation
$npm install --save @warfog/crafter @warfog/ravioli
Simple Example
const store = component(object({counter: number()}))
.addAcceptor("increment", (model) => ({ mutator: () => model.counter++ }))
.addActions({increment: "increment"})
.create({counter: 0}) // initial model data
autorun(() => console.log(store.state.representation.counter))
store.actions.increment() // 1
store.actions.increment() // 2
store.actions.increment() // 3
What does Ravioli solve?
The goal of Ravioli is to code quick but not dirty.
Ravioli is an attempt for small teams or junior developer to organize their ideas to achieve great things like a todo list or a MMORPG.
Ravioli implements the SAM pattern, which helps you to cut down your software development by enforcing a temporal logic mindset and a strong separation of concern between business code, view and functionalities.
All along your development with Ravioli, you will handle your issues with a bunch of good practices:
- separate functional from business code
- think you app as a state machine (finite or not)
- write small, atomic, pure and focused business functions
- write abstract and meaningful actions for your user
"Ravioli makes the world a better place by providing a reactive and declarative framework with the simplicity of a mutable world backed by the security of an immutable plumbing"
An anonymous antousiast coming from the futur
Inspirations
- JJ.Dubray and its work on the SAM pattern. Big thanks to him.
- Mobx State Tree for its developer UX and the model shape management.
- Mobx for the reactive parts
Is ravioli for you?
- ✔️ you are a junior dev looking for a great development experience
- ✔️ you have to develop a temporal logic app (tabletop game, form with steps, ...)
- ✔️ you are lead dev who is looking for a simple solution to onboard junior dev on project with Separation of Concern in mind.
Is ravioli not for you?
- ❌ you need a library rather a framework (come back later, when the Crafter package will be documented)
- ❌ writing mutable code makes you sick
- ❌ you need incredible performance (come back later, not optimized yet.)
A Ravioli step cycle
SAM and Ravioli are based on temporal logic. Each action starts a new step and lead to a synchronisation with the external world. Here how it works:
____________________________________________
| | ^ ^ ^ ^
| ___________Model___________ | | | | |
v | Data | v Notify
Action -> | Acceptor(s) | -> Compute next -> Next actions OR changes
^ |___________________________| State | to observers
| | | | | |
|_______________________________________________________________| v v v v
- First something/someone triggers a action from the view.
- The action composes a proposal and present it to the model.
- The model acceptors accept/reject each part of the proposal and do the appropriate mutations.
- If the model changed we compute the new component state.
- This new state may lead to trigger some automatic actions. Each action will lead to a new step (if some changes are made)
- We notify the external world that our component state has changed (eg. to refresh the view, to trigger side effects...)
Ravioli parts
A Ravioli is a component
At this point of development, they are not composable yet.
Each component is like a Redux store or a Mobx State tree and its role is to handle the state of your software.
The main differences are:
- Ravioli comes out of the box with a temporal logic, based on state machine. Each Raviolo has its own state machine (either finite or not) which allows only certains actions/mutations/transformations. As en example you see a Player can be Alive or Dead. Each control states having its own actions. Fire, walk, when Alive. Revive, pop in cimetery when Dead. Those states being isolated, there is no chance that a Dead Player shots an Alive Player. Out of the box, without additional code.
- Actions are not mutations. It means that an action is like in real life. An attempt of the exterior world (view) to influence an interior world (model). In such, an action does not mutate the model but presents a proposal which the model will accept or reject. Think a Player A which shots another Player B. Even if the lifebar of B is low, only B will decide of its own death or not. Because only B has all the informations, like secret buffs, to take such decision.
- View and Model isolation. The view (as the compiled representation of the model) has no read/write access to the model. This clear isolation makes the component like a black box which the user interacts with through some actions.
- Modularity. Ravioli is still in early development but is designed in mind to have its "modules" (Action, Model, Representation) living in a decentralized manners. At term, it will be possible to have the model on the server, the actions on a micro services, the view on the client.
Action
An action is a high level interface for the external world of a component. It is the only way to interact with the model.
Its role is to abstract the low level API of the model by composing mutations in a declarative way.
Keep in mind that an action is just a fire and forget interface and does not guarantee a result. An action is decoupled from the model and does not know its actual state neither its internal shape or methods. It only barely knows how to form a proposal which maybe will accepted in part or in whole by the model.
An action is a pure function which return a proposal.
function hit() {
return [{
type: REMOVE_HP
payload: { hp: 3 }
}]
}
function drinkHealthPotion() {
return [{
type: REMOVE_IEM_FROM_INVENTORY
payload: { itemId: "healPotion" }
}, {
type: ADD_HP
payload: { hp: 3 }
}]
}
Action are "fire and forget".
This concept is important and reflects the real life. You are never sure that your action will lead to the expected result. There is always a little chance to fail, something unknown or something you have not anticipated.
Think about hiting a kobold in a table top game. Your action is to hit. But the success depends on the rolling dice and maybe on internal state (secret buff) which will cancel your attempt.
You want to be rich? Your only possible (async) action is to work, but here too, the success will depends of a billion of factors.
Proposal
As seen in the example above, a proposal is an array of declarative mutations that the action wants to perform on the model.
Note even that the proposal has a specific shape, its content does not need to be valid to be presented to the model. Only the model will decide it is valid or not.
[{
type: "ADD_TODO", // Well defined mutation
payload: {
text: "stop coding bugs",
checked: false
}
}, {
type: "CLEVER_XSS", // Will be reject
payload: {
injectedScript: "alert('trololo')"
}
}]
Model
The model contains the business data and methods and lives in a private scope. The external world has no access to it and actions can't act on it directly.
The only way to act on the model is to present a bunch of data, called a proposal. As stated above, it is the action role.
Once the proposal is received, the model will called its acceptors to let them decide either accept or reject each part of the proposal.
Data
The model shape of the component is built declaratively like this.
const Player = object({
name: string(),
health: number(),
inventory: array(object({
id: string(),
quantity: number()
})),
isConnected: boolean()
})
Acceptors
An acceptor has two roles:
- Accept or reject a proposal mutation (with a validator)
- Do the mutation (with a mutator)
Validator
A validator is a simple function of the model and the paylaod.
const setHpValidator = model => payload => (
model.health > 0 && // the player is alive
payload.hp < MAX_POINT // an internal constant rule
)
If the mutation passes the validator, it will be marked as accepted and passed down to the mutator.
Mutator
This is where the mutation happens. A mutator is a simple unpure function. It returns nothing and mutates the model data.
const setHpMutator = model => payload => (
model.health += payload.hp
)
State
It is how the world sees your component.
As I stated above, the model is isolated. The state is NOT the model, but its public interface.
The State is composed of:
- all the available actions for the current step
- the current control state of the component
- an observable computed representation of the model
Control State
This notion directly comes for the State Machine pattern. A Control State is a stable state your app can reach.
For example a Todo of a todo list can be Complete or UnComplete.
A character of a video game can be Alive, Dead or Fighting.
Also, Ravioli does not enforce the concept of a FINITE state machine. You can have multiple control states in a row. Eg a app state can be ['STARTED', 'RUNNING_ONLINE', 'FETCHING']
A control state predicate is a pure function of the model.
const isAlive = model => model.health > 0
The component state exposes the current control states of the model in an array. myApp.state.controlStates // ['STARTED', 'RUNNING_ONLINE', 'FETCHING']
Representation
Terminology, the difference between a view and a representation. By me.
A view is how an exterior observer sees a subject. In such, a view can be subjective and not conform to what the subject wants to look like. Eg. a view could be the rendered frame buffer or a HTML representation. And everyone know how the same code looks different from browser to browser.
A representation is decided by the subject of the observation. In our context, the subject is the component and the representation some HTML tag. In such, the represention is 100% conform to the attempt of the subject.
Is a function of the model
R = f(m) // Math semantic skillz +100
A representation could be anything you need: HTML markup, JSON, binary, data stream,... as long its expression is a pure function of the model.
// A object ready to send back on the client or inject in the UI.
const objectRepresentation = model => ({ name: model.name, loot: model.inventory })
// Or directly some html markup
const HTMLRepresentation = model => `
<h2>Player inventory:</h2>
${
model.inventory.length
? `
<ul>
${model.inventory.map(({ id }) => `<li>${id}</li>`).join("")}
</ul>
`
: "empty :("
}
`;
Reactivity
All representation are reactive, even primitive value like some HTML in template literals.
// Will rerender and only if the representation is updated.
autorun(() => document.getElementById("app")!.innerHTML = player.state.representation)
Ravioli comes with an adapter for React called Crafter-React but it is not published on NPM yet. It provides an observer for React component exactly like
react-mobx
to make your UI (really) reactive.
Default representation
Each component is instantiated with a default representation which is an exact synchronised clone of the model. In such, it not violates the decoupling principle but happily tramples the principles of private/public data isolation and business/functional abstraction.
In the counter example below, you can see that the representation has the same shape as the model.
Performance
Ravioli is not written with performance in mind but developper experience in mind. Treat a 100k+ array of complexe objects in no time is out of scope here.
However, the performance it provides is quite descent from the majority of the app.
And a lot of performance improvements should be still possible (use proxy instead of getter/setter to minimize memory heap, lazy props evaluation, cache some node values, ...)
What next
In the next blog we will start to write a basic (and ugly) HTML RPG to kill farm some monsters and see how Ravioli works.
Posted on April 10, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.