- Notifications
You must be signed in to change notification settings - Fork4
Transform-Signal-Executor framework for Reactive Streams
License
tsers-js/core
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
Transform-Signal-Executor framework forReactiveStreams(RxJS only at the moment... 😞).
"tsers!"
Pronunciation:[tsers] (Also note that the /r/ is an alveolar trill, or"rolled r", not the postalveolar approximant ɹ̠ that is used in English andsometimes written similarly for convenience.)
In the era of the JavaScript fatigue, new JS frameworks pop up like mushroomsafter the rain, each of them providing some new and revolutionary concepts.So overwhelming! That's why TSERS was created.It doesn't provide anythingnew. Instead, it combines some old and well-known techniques/conceptsand packs them into single compact form suitable for the modern web applicationdevelopment.
Technically the closest relative to TSERS isCycle.js,but conceptually the closest one isCALM^2.Roughly it could be said that TSERS tries to combine the excellent state consistencymaintaining strategies from CALM^2 and explicit input/output gates fromCycle - the best from both worlds.
The mandatory "Hello world" applicatin with TSERS:
import{ObservableasO}from"rx"importTSERSfrom"@tsers/core"importReactDOMfrom"@tsers/react"importModelfrom"@tsers/model"functionmain(signals){const{DOM,model$:text$, mux}=signalsconst{h}=DOMconstvdom$=DOM.prepare(text$.map(text=>h("div",[h("h1",text),h("button","Click me!")])))constclick$=DOM.events(vdom$,"button","click")constupdateMod$=text$.mod(click$.map(()=>text=>text+"!"))returnmux({DOM:vdom$,model$:updateMod$})}TSERS(main,{DOM:ReactDOM("#app"),model$:Model("Tsers")})
TSERS applications are built upon the three following concepts
- Signals flowing through the application
- SignalTransform functions transforming
inputsignals intooutputsignals - Executors performing effects based on the
outputsignals
Signals are the backbone ofTSERS application. They are the only way totransfer inter-app information and information frommain to interpretersand vice versa. InTSERS applications, signals are modeled as (RxJS) observables.
- Observables are immutable so the defined control flow is alwaysexplicit and declarative
- Observables are first-class objects so they can be transformed intoother observables easily by using higher-order functions
TSERS relies entirely on (RxJS) observables and reactive programmingso if those concepts are not familiar, you should take a look at someonline resources or books before exploring TSERS. One good online tutorialto RxJS can be foundhere.
TODO: muxing and demuxing
Assuming you are somehow familiar with RxJS (or some other reactivelibrary like Kefir or Bacon.js), you've definitely familiar withsignaltransform functions.
The signature of signal transform functionf is:
f :: (Observable A, ...params) => Observable BSo basically it's just a pure function that transforms an observable intoanother observable. So all observable's higher order functions likemap,filter,scan (just to name a few) are also signal transformers.
Let's take another example:
functiontitlesWithPrefix(item$,prefix){returnitem$.map(it=>it.title).filter(title=>title.indexOf(prefix)===0)}
titlesWithPrefix is also a signal transform function: it takesan observable of items and the prefix that must match the item title and returnsan observable of item titles having the given prefix.
titlesWithPrefix :: (Observable Item, String) => Observable StringAnd as you can see,titlesWithPrefix used internally two other signaltransform functions:map andfilter. Because signal transform functionsare pure, it's trivial to compose and reuse them in order to create thedesired control flow frominput signals tooutput signals.
If the signals are the backbone ofTSERS applications, signal transformersare the muscles around it and moving it.
After flowing through the pure signal transformers, the transformedoutput signals arrive to theexecutors. InTSERS, executorsare also functions. Butnot pure. They are functions that do nastythings: cause side-effects and change state. That is, executors' signaturelooks like this:
executor :: Observable A => EffectsLet's write an executor for our titles:
functionalertNewTitles(title$){title$.subscribe(title=>{alert(`Got new title!${title}`)})}
And what this makes executors by using the previous analogy... signalsflowing through the backbone down and down and finally to the...anus 💩.Yeah, unfortunately the reality is that somewhere in the application youmust do the crappy part: render DOM to the browser window, modify the globalstate etc. InTSERS applications, this part falls down to executors.
But the good news is that these crappy things are (usually) not applicationspecific and easily generalizable! That's whyTSERS has theinterpreterabstraction.
As told before, every application inevitably contains good parts and badparts. And that's whyTSERS tries to create an explicit border betweenthose parts: theinterpreter abstraction.
The good (pure) parts are inside the signal transform functionmain,and the bad parts are encoded into interpreters.
Conceptually the full application structure looks like:
functionmain(input$){// ... app logic ...returnoutput$}interpreters=makeInterpreters()output$=main(interpreters.signals())interpreters.execute(output$)
main function is the place where you should put the application logicinTSERS application. It describes the user interactions and as a resultof those interactions, provides an observable of output signals thatare passed to the interpreters' executor functions.
That is,main is just another signal transform function that receivessome core transform functions (explained later) plus input signals andother transform functions from interpreters. By using those signals andtransforms,main is able to produce the output signals that are consumedby the interpreter executors.
Interpreters are not a new concept: they come from theFree Monad Pattern.In common language (and rounding some edges) interpreters are an APIthat separates the representation from the actual computation. If youare interested in Free Monads, I recommend to readthis article.
InTSERS, interpreters consist of two parts:
- Input signals and/or signal transforms
- Executor function
Input signals and signal transforms are given to themain. They area way for interpreter to encapsulate the computation from the representation.For exampleHTTP interpreter provides therequest transform. It takesan observable of request params and returns an observable of request observables(request :: Observable params => Observable (Observable respose)).
Now themain can use that transform:
functionmain({HTTP}){constclick$= ....constusers$=HTTP.request(click$.map(()=>({url:"/users",method:"get"})).switch()// ...}
Note thatmain doesn't need to know the actual details what happens insiderequest - it might create the request by using vanilla JavaScript,superagent or any other JS library. It may not even make a HTTP request everytime when the click happens but returns a cached result instead! It's notmain's business to know such things.
Some interactions may produce output signals that are not interesting inmain. That's why interpreters have also possibility to define anexecutorfunction which receives those output signals andinterprets them,(usually) causing some (side-)effects.
Let's take theDOM interpreter as an example.main may produce virtualdom elements as output signals but it's not interested in how (or where)those virtual dom elements are rendered.
functionmain({DOM}){const{h}=DOMreturnDOM.prepare(Observable.just(h("h1","Tsers!")))}
In a rule of thumb, you should use interpreter if you need to produce someeffects. Usually this reduces into three main cases:
- You need to use Observable's
.subscribe- you should never need to use that inside themain - You need to communicate with the external world somehow
- You need to change some global state
In a rule of thumb, you should encode the side-effects into signal transformfunctions if the input signal and the side effect result signal have adirect causation, for examplerequest => response.
You should encode the side-effects into output signals and interpret them withtheexecutor when the input there is no input => output causation (onlyeffects), for exampleVNode => ().
You may think that the separation ofmain and interpreters is just waste.What benefit you get by doing that? The answer is that separating thosesignificantly improvestestability, extensibility and the separationof concerns of the application.
Imagine that you need to implement universal server rendering to yourapplication - just change theDOM interpreter to serverDOM interpreterthat produces HTML strings instead of rendering the virtual dom to theactual DOM. How about if you need to test your application? Just replace theinterpreters with test interpreters so that they produce signals your testcase needs and assert the output signals your application produces. Howabout if you need to implement undo/redo? Just change the application stateinterpreter to keep state revisions in memory. How about if you API versionchanges? Just modify you API interpreter to convert the new version datato the current one.
Now that you're familiar with TSER's core concepts and the applicationstructure, let's see how to build TSERS application in practice. Thissection is just a quick introduction. For more detailed tutorial, pleasetake a look at the TSERS tutorial in theexamples repository.
First you need to install@tsers/core and some interpreters. We're gonnause two basic interpreters: React DOM interpreter for rendering and Modelinterpreter for our application state managing.
npm i --save @tsers/core @tsers/react @tsers/model
Now we can create and start our application.@tsers/core providesa function that takes themain and the interpreters that are attachedto the application. The official interpreter packages provide alwaysa factory function that can be used to initialize the actual interpreter.
importTSERSfrom"@tsers/core"importReactDOMfrom"@tsers/react"importModelfrom"@tsers/model"functionmain(signals){// your app logic comes here!}// start the application with model$ and DOM interpretersTSERS(main,{DOM:ReactDOM("#app"),// render to #app elementmodel$:Model(0)// create application state model by using initial value: 0})
Now we can use the signals and transforms provided by those interpreters,as well as TSERS's core transform functions (see API reference below).Interpreters' signals and transform functions are always accessible by theirkeys. Alsomain's output signals match those keys:
functionmain(signals){// All core transforms (like "mux") are also accessible// via "signals" input parameterconst{DOM, model$, mux}=signalsconst{h}=DOM// model$ is an instance of@tsers/model - it provides the application// state as an observable, so you can use model$ like any other observable// (map, filter, combineLatest, ...).// let's use the model$ observable to get its value and render a virtual-dom// based on the value. DOM.prepare is needed so that we can derive user event// streams from the virtual dom streamconstvdom$=DOM.prepare(model$.map(counter=>h("div",[h("h1",`Counter value is${counter}`),h("button.inc","++"),h("button.dec","--")])))// model$ enables you to change the state by emitting "modify functions"// as out output signals. The modify functions have always signature// (curState => nextState) - they receive the current state of the model// as input and must provide the next state based on the current state// Let's make modify functions for the counter: when increment button is// clicked, increment the counter state by +1. When decrement button is clicked,// decrement the state by -1constincMod$=DOM.events(vdom$,".inc","click").map(()=>state=>state+1)constdecMod$=DOM.events(vdom$,".dec","click").map(()=>state=>state-1)// And because the mods are just observables, we can merge themconstmod$=O.merge(incMod$,decMod$)// Finally we must produce the output signals. Because JavaScript functions// can return only one value (observable), we must multiplex ("mux") DOM// and model$ signals into single observable by using "mux" core transformreturnmux({DOM:vdom$,model$:model$.mod(mod$)})}
Again: more detailed tutorial can be found from the TSERSexamples repository.
If you read through this documentation, you might wonder that TSERS resemblesCycle very much. Technically that's true. Then why not to use Cycle?
Although the technical implementations of TSERS and Cycle are very similar,their ideologies are not. Cycle is strongly driven by the classification ofread-effects andwrite-effects which means that drivers are not"allowed" to provide signal transforms encoding side-effects. Instead,all side effects must go to sinks and their results must be read from thesources, regardless of the causation of the side-effect and it's input.
Cycle's drivers are also meant for external world communicationsonly, hence e.g. maintaining the global application state withdrivers is not "allowed" in Cycle (although maintaining it with e.g. Relaydriver is!!).
In practice, those features in Cycle result in some unnecessary symptoms likethe existence ofisolation, usage ofIMVinstead ofMVI (which works pretty well btw, until your intents start to dependon the model),proxy subjectsusage,performance issuesandunnecessary complexitywhe sharing the state between parent and child components.
And those are the reasons for the existence of TSERS.
JavaScript allows function to return only one value. That means thatmain canreturn only one observable of signals. However, applications usually producemultiple types of signals (DOM, WebSocket messages, model state changes...).
That's why TSERS usesmultiplexing to"combine" multiple types of signals into single observable. Multiplexing is way of combiningmultiple signal streams into one stream of signals so that different type ofsignals are identifiable from other signals.
The signature ofmux is:
mux :: ({signalKey: signal$}, otherMuxed$ = Observable.empty()) => muxedSignal$mux takes the multiplexed streams as an object so that object's keys represent thetype of the multiplexed signals.mux takes also second (optional) parameter, thatis a stream of already muxed other signals (coming usually from the child components)and merges it to output.
Usually you want to usemux in the end ofmain to combine all applicationsignals into single observable of signals:
functionmain({DOM, model$}){// ....returnmux({DOM:vdom$,model$:mod$})}
De-muxing (or de-multiplexing) is the reverse operation for muxing: it takesan observable of the muxed signals, extracts the given signal types by their keys andreturns also the rest of the signals that were not multiplexed
demux :: (muxedSignal$, ...keys) => [{signalKey: signal$}, otherMuxed$]Usually you want to use this when you call child component from the parentcomponent and want to post-process child's specific output signals (e.g. DOM)in the parent component:
constchildOut$=Counter({...signals,model$:childModel$})const[{DOM:childDOM$},rest$]=demux(childOut$,"DOM")
loop is a transform that allows "looping" signals from downstream back to upstream.It takes input signals and a transform function producingoutput$ andloop$ signalsarray -output$ signals are passed through as they are, butloop$ signalsare merged back to the transform function as input signals.
constinitialText$=O.just("Tsers").shareReplay(1)constvdom$=loop(initialText$,text$=>{constvdom$=DOM.prepare(text$.map(...))constclick$=DOM.events(vdom$,"button","click")constupdatedText$=click$.withLatestFrom(text$,(_,text)=>text+"!")// vdom$ signals are passed out, updatedText$ signals are looped back to text$ signalsreturn[vdom$,updatedText$]})
Takes a list observable (whose items haveid property) and iterator function, appliesthe iterator function to each list item and returns a list observable by using the returnvalues from the iterator function (conceptually same aslist$.map(items => items.map(...))).
- Item idsmust be unique within the list.
- Iterator function receives two arguments: iterated item id andan observable containing the item and it's state changes
ATTENTION: iterator function is applied onlyonce per item (byid), although thelist observable emits multiple values. This enables some heavy performance optimizationsto the list processing like duplicate detection, cold->hot observable conversion andcaching.
TODO: example...
Same asmapListById but allows user to define custom identity function instead ofusingid property. Actually themapListById is just a shorthand for this transform:
constmapListById=mapListBy(item=>item.id)
demuxCombined has the same API contract asdemux but instead of bare outputsignals,demuxCombined handles alist of output signals. The name alreadyimplies the extraction strategy: after the output signals are extracted by usingthe given keys, their latest values are combined by usingObservable.combineLatest,thus resulting an observable that produces a list of latest values from theextracted output signals. Rest of the signals are flattened and merged by usingObservable.merge so the return value ofdemuxCombined is identical withdemux (hence can be used in the same way when muxing child signals to parent'soutput signals).
TODO: example...
TODO: ...
MIT
Logo byGlobalicon (CC BY 3.0)
About
Transform-Signal-Executor framework for Reactive Streams
Resources
License
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Packages0
Uh oh!
There was an error while loading.Please reload this page.
Contributors4
Uh oh!
There was an error while loading.Please reload this page.