create a backend in your frontend
Writing frontend applications is difficult.
Makina may help you create an all-in-one backend for your frontend.
In Makina you should think of your frontend application as a complete stack, with your UI (React, Vue, Angular, etc...) as the frontend part and a StateMachine as a backend.
Application logic, I/O and state management of your application will be managed in the StateMachine while the UI will be responsible for presentational logic, displaying the current state and dispatch actions.
npm i @ezy/makina
createBase
to create and combine modules.filters
, to orchestrate them.index.ts
import { createBase } from '@ezy/makina';
interface UserProfile {
email: string;
}
interface CurrentUserState {
isConnecting?: boolean;
profile?: UserProfile;
error?: string;
}
interface Credentials {
email: string;
password: string;
}
interface CurrentUserIO {
api: {
login: (credentials: Credentials) => Promise<UserProfile>;
};
}
const CurrentUserBase = createBase({
states: {
CONNECTING: {
is: (state: CurrentUserState) => !!state.isConnecting,
set: (_: CurrentUserState) => ({ isConnecting: true }),
from: ['DISCONNECTED'],
},
CONNECTED: {
is: (state: CurrentUserState) => !!state.profile,
set: (_: CurrentUserState, profile: UserProfile) => ({ profile }),
from: ['CONNECTING'],
},
DISCONNECTED: {
is: (state: CurrentUserState) => !state.profile,
set: (_: CurrentUserState, error?: string) => ({ error }),
from: ['CONNECTING', 'CONNECTED'],
},
}
});
class CurrentUser extends CurrentUserBase<CurrentUserState, CurrentUserIO> {
/**
* connect currrent user
*
* @param credentials
*/
public async connect(credentials: Credentials) {
// transition to CONNECTING state
// there is no transition from CONNECTING to CONNECTING so double clicks are handled for us
if (this.to.CONNECTING()) {
try {
const profile = await this.IO.api.login(credentials);
return this.to.CONNECTED(profile);
} catch (e) {
this.to.DISCONNECTED(e);
}
}
return false;
}
/**
* disconnect currrent user
*/
public disconnect() {
return this.to.DISCONNECTED();
}
}
interface Notification {
id: number,
text: string
}
interface NotificationsState {
list: Notification[]
}
// create a Notifications module
class Notifications extends createBase()<NotificationsState> {
constructor(initialState, IO, options) {
// default state
super({ list: [], ...initialState }, IO, options)
}
add(notification) {
this.commit('notificationAdded', {
list: [...this.state.list, notification]
});
}
}
// define our app
const BaseApp = createBase({
modules: {
currentUser: CurrentUser,
notifications: Notifications
}
});
class App extends BaseApp {
init() {
// add a filter to the CurrentUser module connect method
// our filter will be triggered when the connect method is called
// to automatically create notification in our Notification module if neccessary
this.currentUser.connect.applyFilter(async (next, credentials) => {
if (await next(credentials)) {
this.notifications.add({
id: 0,
text: `welcome back ${this.currentUser.state.profile.email}`
});
return true
}
return false
});
}
}
// create an instance of our App with initial state and IOs
const app = App.create(
{},
{
api: {
login: (credentials: Credentials) => {
return Promise.resolve({ email: credentials.email });
}
}
}
);
/**
* wait for all init functions to be called
* not necessary in most cases but may be when unit testing
*/
// await app.ready;
// UI bindings
document
.getElementById('submit')
.addEventListener('click', () => {
app.currentUser.connect({
email: document.getElementById('email').value,
password: document.getElementById('password').value
})
});
document
.getElementById('logout')
.addEventListener('click', () => app.currentUser.disconnect());
const render = () => {
document.getElementById('notifications').innerHTML = `
<ul>${
app.notifications.state.list.map(msg => `<li>${msg.text}</li>`).join('')
}</ul>
`;
};
app.onStateChange(render);
render();
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App</title>
</head>
<body>
<div id="app">
<button id="logout">X</button>
<div id="notifications"></div>
<form>
<label>Email : </label>
<input type="text" placeholder="Enter Email" id="email" required>
<label>Password : </label>
<input type="password" placeholder="Enter Password" id="password" required>
<button id="submit">Login</button>
</form>
</div>
<script src="index.js"></script>
</body>
</html>
Under the hood Makina keep your state in a single place and use lenses to update that state
class App extends createBase({ modules: { messages: Messages } })
is equivalent to
class App extends createBase()<{ messages: MessagesState }> {
public messages: Filterables<Messages> = this.create(lensProp('messages'), Messages);
// or
// public messages: Filterables<Messages> = this.create('messages', Messages);
}
create
can be used to create state machines targeting just a part of your state on demand.
class App extends createBase({ modules: { messages: Messages } }) {
public getMessageByIndex(index) {
return this.create(lensPath(['messages', 'list', index]), Message);
}
}
alternatively the static create
method can be used to create detached state machines
class App extends createBase({ modules: { messages: Messages } }) {
public getMessageByIndex(index) {
return Message.create(this.state.messages.list[index], this.IO)
}
}
lenses being a composable pair of pure getter and setter functions, Makina also handle split lenses which is just a pure getter and a pure setter in an object.
const nameSplitLens = {
get: (s) => s.name,
set: (name, s) => ({ ...s, name })
}
Makina provide a way to ensure your application state is never mutated. set in the config object a freeze function to deep freeze your state when a new state is created. Deep freezing objects can be costly from a performance standpoint, it's recommended to disable it in production.
import { config } from '@ezy/makina';
// immutable state in all environnments except production
config.freeze =
process.env.NODE_ENV !== 'production' ? require('deep-freeze-strict') : undefined;
Every plugin is a decorator factory and can be used to extends Classes generated by createBase
.
myPlugin.ts
import { StateContainer, StateContainerClass } from '@ezy/makina';
declare class MyPlugin<S> extends StateContainer<S> {
public isPluginActive (): boolean;
}
declare module '@ezy/makina' {
interface Plugins<S> {
myPlugin: S extends { option1: boolean; }
? (options: { option1: boolean; }) => (Base: StateContainerClass) => typeof MyPlugin
: never;
}
}
export default {
name: 'myPlugin',
decoratorFactory: (options: { option1: boolean; }) =>
(Base: StateContainerClass) => {
return class extends Base {
public isPluginActive () {
return options.option1
}
} as any;
}
};
app.ts
import { install, createBase } from '@ezy/makina';
import plugin from './myPlugin'
install(plugin)
const Base = createBase({
myPlugin: {
option1: true
}
})
class Customized extends Base<{ somedata: boolean }> {
}
const custom = new Customized({ somedata: true })
// => true
custom.isPluginActive()
the plugin type is determined based on if the options
argument is required
(options: { option1: boolean; }) => ...
will only activate the plugin when the options
argument is provided to createBase.
(options: { option1: boolean; } = { option1: false }) => ...
will activate the plugin regardless if the options
argument is provided to createBase or not.
Generated using TypeDoc