usethesource / salix

Elm-style Web GUIs in Rascal
BSD 2-Clause "Simplified" License
6 stars 6 forks source link

⚠️ The salix project now lives elsewhere at salix-core . The current project is not maintained anymore.

Salix: Elm-style Web GUIs in Rascal (work in progress)

© Tijs van der Storm @tvdstorm

DISCLAIMER: this very much work in progress; the design may change in significant ways, especially on the Javascript side of things.

Salix is Rascal library for developing Web-based GUI programs. It emulates the Elm Architecture, but since Rascal does not run in the browser (yet), all user code written in Rascal is executed on the server. HTML patches are sent to the browser and the browser sends messages back to the server, where they are interpreted on the model, to construct the new view.

The concepts described below are shamelessly copied from Elm; this document describes merely how they are realized in the context of Rascal.

Teaser: a web-based, live IDE for a simple state machine DSL

(Click to open the video)

ScreenShot

A Counter Application

Salix is best understood through an example. Here we describe a simple counter application.

First we define the model, which is simply an integer:

    alias Model = int;

The initial model is 0:

    Model init() = 0;

The model is changed by interpreting messages. In Salix, all messages are of the Msg type. Other components might extend the same algebraic data type Msg for their own purposes. Here we have two messages: one to increment the counter and one to decrement it.

    data Msg = inc() | dec();

The evaluator (conventionally called update) can be implemented as follows:

    Model update(Msg msg, Model model) {
      switch (msg) {
        case inc(): model += 1;
        case dec(): model -= 1;
      }
      return model;
    }

Note that the += and -= notation seems to suggest we're doing in-place mutation of the model here, this is not the case (even if the model is a tuple or constructor): Rascal's assignments will create a new model and assign it to the model variable.

With the model and the update function in place, we can now define a view as follows:

    void view(Model m) {
      div(() {
        h2("My first counter app in Rascal");
        button(onClick(inc()), "+");
        div(m.count);
        button(onClick(dec()), "-");
      });
    }

A few notes are in order here. A view in Salix is a function from a model (in this case, of type Model) to void. Views defined in this style call HTML generating functions defined in the salix::HTML module, which are all void functions too. Consider the void functions as "drawing" functions, painting HTML structure on an implicit canvas. This imperative style has the advantage that all regular control-flow constructs of Rascal can be used during view construction. Notice how void closures are used to express nesting.

The button elements receive attributes to setup event-handling. In this case, the onClick attribute wraps an Msg value to indicate that this message must be sent if the button is clicked. The main render loop will forward such messages to update to obtain a new model value, which in turn is used to create the updated view.

Now that we've defined all required components of a simple Salix app, how do we tie it all together? First we define a function to make a SalixApp using the makeApp function: it takes a unique identifier (as string), a function to produce the initial model, a view function and an update function. Salix applications could be served via other protocols than http, hence a SalixApp is a general model of the application.

    SalixApp[Model] counterApp(str appId = "counterApp") = makeApp(appId, init, view, update);

In the above example we have bound the appId using a keyword parameter. This means, if wanted, a user can give this application another id (see the section on Nesting Multiple Salix Apps).

Now we can create a web application using the webApp function. This function takes a SalixApp, the location of the index html file and the base directory for all other files that need to be served statically. Here's the definition of the counter web app:

    App[Model] counterWebApp() 
      = webApp(counterApp(), |file:///.../index.html|, |file:///...|); 

The returned value of type App[Model] is a special Rascal type called Content. Whenever the Rascal REPL interprets this type a webserver will automatically be started and its content shown in a tab in the IDE.

So starting our counter application in the REPL is nothing more than importing the module and calling the function:

    rascal> import Counter;
     ok
    rascal> counterWebApp();
     Serving visual content at |http://localhost:9050/|

And that's it! You can use the counter app directly in the IDE or click on the link to open it in your favorite browser.

Wait, we forgot one thing. Here's the minimally required index.html file need to run Salix apps:

    <!DOCTYPE html>
    <html>
    <head>
      <script src="https://github.com/usethesource/salix/raw/main/<somewhere>/salix.js"></script>
      <script>document.addEventListener("DOMContentLoaded", new Salix("counterApp").start);</script>
     <head>
      <body><div id="counterApp"></div></body>
    </html>

Both the Salix javascript object and the div that will hold the apps content must have the same name as the given id. In this case it is "counterApp".

Nesting Components by Mapping

Applications encapsulate their own models and sets of messages. Nesting multiple application (aka multiple SalixApp) is straightforward.

As an example, let's consider an app that contains the counter app twice. Clicking increment or decrement on either of the counters should not affect the other. Here's how to implement this in Salix:

    import Counter;
    import salix::HTML;

    // combine two counter models
    alias ModelTwice = tuple[Model counter1, Model counter2];

    // extend Msg
    data Msg = sub1(Msg msg) | sub2(Msg m);

    // update
    ModelTwice updateTwice(Msg msg, ModelTwice model) {
      switch (msg) {
        case sub1(Msg m): model.counter1 = update(m, model.counter1);
        case sub2(Msg m): model.counter2 = update(m, model.counter2);
      }
      return model;
    }

    // define the view
    void viewTwice(ModelTwice model) {
      div(() {
        mapView(sub1, model.counter1, view);
        mapView(sub2, model.counter2, view);
      });
    } 

The important bit here is that the view function of the counter app is embedded twice, via the special mapView function. It takes as its first argument a function of type Msg(Msg) (i.e., a message transformer), a model as its second argument, and a view (of type void(&T)) as its last argument. In this case we provide the sub1 and sub2 constructors as message transformers. The function mapView now ensures that whenever a message is received that originates from the first counter it is wrapped in sub1, and that any message from the second counter is wrapped in sub2. For instance, inc() from the first counter will be wrapped as sub1(inc()) and passed to updateTwice who will route it to update on m.counter1. Same for the second counter.

If we didn't use mapping here, the function updateTwice could directly interpret inc() and dec(), but it wouldn't know which counter model to update! Alternatively, however, you shouldn't use mapping if you want two views sharing the same model. In this case, there's no need for routing of messages, and the two view functions can be simply called twice, on the same model. For instance, like this:

    void viewTwice(Model model) {
      div(() {
        view(model);
        view(model);
      });
    }

Subscriptions

Subscriptions can be used to listen to events of interest which are not produced by users interacting with the page. Examples include incoming data on Web sockets, or timers. In Salix these are represented by the type Sub (defined in salix::Core). Currently, there's only one:

    timeEvery(str appId, Msg(int) time2msg, int interval) 

To be notified of subscriptions, provide a function of type list[Sub](str,&T) (where &T represents your model type and str the bound application id) to the subs keyword parameter of app.

As as example, let's say we'd like to automatically increment our counter every 5 seconds. This can be achieved as follows:

    import salix::Core; // defines the Sub ADT

    data Msg  // extend Msg to respond to timeEvery subscription
     = ...
     | tick(int time);

    list[Sub] counterSubs(str appId, Model m) = [timeEvery(appId, tick, 5000)];

    Model update(Msg msg, Model model) {
      switch (msg) {
        ...
        case tick(_): model += 1;
      }
      return model;
    }

This code states that every 5 seconds we will be notified of the event through the message tick which will contain the current time. The update function is changed to modify the model as intended.

Finally modify the invocation to app as follows:

    SalixApp[Model] counterApp() = makeApp(..., subs = counterSubs);

If your nested components have subscriptions, you need to map them in the same way that views are mapped, but this time using mapSubs. For instance, here's how to map the subscriptions of each counter to combine them into a list of subscriptions of counterTwice, assuming the counter app defines its list of subscriptions for a model as counterSubs(Model m):

    list[Sub] subsTwice(ModelTwice m)
      = mapSubs(sub1, m.counter1, counterSubs)
      + mapSubs(sub2, m.counter2, counterSubs);

Commands

Commands are used to trigger side-effects. Instead of simply returning a new model in update, this function will now also "do" commands. Commands are values of the type Cmd. The helper function do can be used to "execute" commands. Whenever you call do, however, the command is merely scheduled for execution in the runtime (client). The top-level app function will collect all commands that have been "done" during update (or init) and send them over to the client for actual execution.

So, let's add some additional logic to the counter applicaiton: whenever you press the increment button, we'll generate a command to add some random "jitter" to the counter value. Here's how:

    data Msg = ... | jitter(int j);

    Model update(Msg msg, Model model) {

      switch (msg) {
        case inc(): {
          model += 1;
          do(random(jitter, -10, 10));
        }
        ...
        case jitter(int j):
          model += j;
      }

      return model;
    }

We've added a new message, jitter with an integer argument. The update function is modified so that whenever the counter is incremented, we'll do that, but also produce a command using do, in this case the predefined random command which will generate a random integer in the provided range. When the resulting random number is sent back it will be wrapped in a jitter message. The update function uses this message to add "jitter" to the counter value.

Just like views and subscriptions, commands should be mapped whenever components are nested. Here's how mapCmds can be used to wrap commands generated by childeren in the twice app:

    ModelTwice updateTwice(Msg msg, ModelTwice model) {
      switch (msg) {
        case sub1(Msg m): model.counter1 = mapCmds(sub1, m, model.counter1, update);
        case sub2(Msg m): model.counter2 = mapCmds(sub2, m, model.counter2, update);
      }
      return model;
    }

Guide to the modules

Example programs

Extending the Framework

Extending the framework with new events, commands or subscriptions is facilitated by Rascal's extensible data types. In all cases, you define functions to produce handlers (Hnd), commands (Cmd) or subscriptions (Sub). Since all three of those values are sent over the wire, they have to be encoded. The framework provides functions to do so. Handlers, commands and subscriptions produce results, which are sent back to the server. This means that you'll also have to extend the parser to turn received data into a proper message of type Msg, if the type of data is not supported by the built-in parser (i.e. nothing, string, integer, or boolean). In some cases the Javascript needs to be modified in order to accommodate the construct.

Events

An event is defined using the following pattern:

    Attr <eventName>(Msg(...) something2msg) 
      = event("<eventName>", handler("<handler>", encode(something2msg));

This code defines an event function named eventName, accepting a function to map some event data to a Msg. It is defined using the event constructor which takes the name of the event and a "handler". Handlers are used to process event data such that it can eventually be fed into the argument function something2msg. Handlers thus are specific for such functions. The handler also encapsulates an encoded representation of the function needed to decode the event data.

Standard handlers include succeed(Msg) which simply returns the argument message when the event succeeds; targetValue(Msg(str)) feeds the value property of the target element of the event into the argument function to obtain a message; and targetChecked(Msg(bool)) which can be used on checkboxes and radio buttons. These are ready to use in your event definitions.

If the standard handlers are not sufficient, you can also define your own, by defining functions that produce Hnd values. As an example, targetValue is defined as follows:

    Hnd targetValue(Msg(str) str2msg) = handler("targetValue", encode(str2msg));

The reverse is also needed: turning a handle received from the client into the corresponding message as produced by the handler function. This is performed by interpreting the type of the result (represented as a string). Such a result is then converted to a message on the server. For instance, the result of targetValue is parsed using the following function:

    Msg parseMsg(str appId, "string", Handle h, map[str,str] p) 
      = applyMaps(appId, h, decode(appId, h, #Msg(str))(p["value"]));

The function parseMsg receives three parameters: first the type of the result (used to dispatch the parse function); second, the Handle as received from the client, and third the map of request data that was received from the client. The decode function is used to decode the handle into a function Msg(str), which is then applied to the request parameter value to obtain a message. The function applyMaps then transforms the resulting message according to the mappers that were active at the time this handle was produced. You should always apply this function, otherwise mapping (see above) won't work.

You can define a new parser in a similar way, this time dispatching on a different type string. Note, that this also requires modifying the salix.js Javascript code to actually produce these new results. Furthermore, the function extension won't be in scope automatically at the top-level app function. Thus, before calling app, make sure all required parsers are in scope at the call-site, and provide parseMsg to the parser keyword parameter of app.

Subscriptions & Commands

Salix can be extended with new kinds of subscriptions and commands, similar to how new handlers are defined (e.g., targetValue. The only difference is that instead of the Hnd type and the handler constructor, you now use Cmd and command, and Sub and subscription types and constructors, respectively.

Embedding "native" Javascript components

TBD

Discussion

Why are HTML nodes and commands dealt with implicitly?

Salix view functions are defined as void(&T), and update functions have type &T(Msg, &T). In the first case, HTML nodes are implicitly constructed; in the second case, commands are implicitly collected.

As mentioned above, the style of view functions, IMHO, leads to a better programming experience in Rascal, since it allows the use of ordinary control-flow constructs when programming views. In a purely functional, expression-oriented style, it would mean to simulate such control-flow using inline c ? t : e conditionals or comprehensions; the use of comprehensions puts the conditions at the end (which can be awkward), and requires visually distracting brackets or parentheses, suggesting nestings that are not there (e.g., nested lists will be flattened away).

For commands the story is somewhat similar: they need to be threaded through and returned from update functions. Rascal does not have syntactic sugar to make this "monadic" style convenient. So explicit threading would require annoying boilerplate code to get commands up to the main loop. Since the HTML nodes are produced implicitly anyway, doing commands implicitly is a compatible design choice. Finally, it's part of Rascal's design itself to be a hybrid functional programming language, in the sense that many idioms and constructs look imperative, without breaking referential transparency. Implicit propagation of commands and construction of HTML ties into that hybrid style.

The effects of views (in terms of DOM construction) and update functions (in terms of generated commands) are captured by the following two functions:

   // turn a message, update function and current model into new model + list of commands
   tuple[list[Cmd], &T] execute(Msg msg, &T(Msg, &T) update, &T model);

   // render a model through a view function to obtain a Node
   Node render(&T model, void(&T) view);

The function execute creates an updated model including a list of generated commands. The function render turns a void view function in a Node structure for some model. Both are called by the top-level app function; you should probably never have to call them yourself, except if you'd like to inspect nodes or commands for debugging purposes.

The current design leads to the following rule of thumb for the application programmer: update, init or view functions should never be called directly. As soon as you call do this, you're bound to break important invariants maintained by the framework.

Why is mapping part of the framework?

You'd think it would easy to realize mapping just using a standard map function, or comprehensions. You could just simply transform an embedded function, say of type Msg(int) using a transformer Msg(Msg). The transformed function would simply be attached at right position in the Node tree, -- nothing special.

Unfortunately, such transformed embedded functions can't be serialized over the wire. That's why they are encoded. When receiving a result, the encoding is used to find the original function again. This requires equality on functions. Function equality in Rascal is tricky: two functions are considered equal if they correspond to the same declaration, or if they are exactly the same closure (i.e. created at the same execution point). This basically means that you cannot use inline closures as handlers, because on every render, they will lead to new identities, and hence, spurious event handler updates in the browser.

The same holds for arguments to the mapping functions. Basically this means that you only nest components that are statically known :-(. For instance, a generic editable-list component won't work, since such a component will nest a statically unknown number of element components.

Why only a single, universal Msg type?

In Elm, the type for nodes and attributes is parametric on the application defined message type. This adds another level of additional type checking: you can't simply nested HTML generated for one component inside another, because their message types won't match. The type system thus enforces "mapping". Furthermore, the update functions can be checked for completeness, since there's no overlap between constructors from one message type and another.

In Salix, I've opted to not make the node and attribute types parametric, since the generics of Rascal are rather verbose. All messages are thus represented by the universal Msg type. Rascal supports extension of algebraic data types, so each component will simply add their message constructors as they see fit. Note however, that the application programmer has a slightly bigger obligation to make sure that mapping is performed where needed, and that all (relevant) messages are handled correctly.

How to communicate from child to parent?

Whenever a component is nested inside another one, the mapping of command and event messages ensures that messages created in a child component will be routed back to that very same child component. Sometimes, however, you'd like to communicate message up, because the responsibility of handling them lies outside the local level.

Here's an example. Imagin a read-eval-print-loop (REPL) component showing a commandline where commands can be entered. The REPL is in charge of maintaining history, printing the prompt, interpreting key strokes etc. Whenever the user presses enter however, some command or expression needs to be evaluated, but this is not the responsibility of the REPL itself: the effect of evaluation depends on what the REPL is used for, probably defined in its container.

Part of the solution is to pass down an eval function to the update function of the REPL component. Whenever the user now presses enter, the REPL will call that eval function, and print out the result at the command line. This is only half the story however: often the evaluation of a command also requires some domain-specific effect outside the REPL itself. How do we get it there? We can't simply trigger messages from the REPL, since nesting the REPL in some context using mapping will make them "local" to the REPL.

A solution is to model such child-parent communication through dedicated message constructors which are to be intercepted by the container. In our simplified REPL example, let's say we have the following update function which receives an additional eval function as a parameter. This eval function returns a parent-level message encapsulating what needs to be done upon command evaluation.

   data Msg
     = enter(str x)
     ...
     | parent(Msg msg);

    ReplModel updateRepl(Msg msg, ReplModel model, Msg(str) eval) {
      switch (msg) {
        case enter(str x): 
          do(write(parent(eval(x)), "ok"));
        ...
      }
    }

Whenever the user enters a command (enter(str)), the REPL responds by evaluating the command and writing ok to the command line. The command write in turn triggers the message returned by eval wrapped in the parent constructor. The parent constructor here functions as the dedicated up message constructor.

At the container level, we might have something like this:

   Msg eval(str x) {
     str y = ...; // do some eval
     return result(y);
   }

   data Msg
     = ...
     | result(str x)  // result of command eval
     | repl(Msg msg); // repl messages

   Model updateMain(Msg msg, Model model) {
     switch (msg) {
       case repl(parent(result(str x))):
          // interpret result 

       case repl(Msg sub):
         model.repl = mapCmds(Msg::repl, sub, model.repl, 
             ReplModel(Msg m, ReplModel rm) { return updateRepl(m, rm, eval); });  
     }   
   }

The first key thing to note here, is that eval is passed to updateRepl using an anonymous function. So eval is communicated down. Second, the function updateMain intercepts the parent repl message by pattern matching on msg, in this case only intercepting the result(str) message. Because this case comes before the normal repl message handling, it will prevent the REPL from handling a message it does not know about (result(str)).

So update functions can not only pass arbitrary additional data down the component tree, but they also can intercept messages coming in from above, but headed downwards, for specific purposes.

How are lists of commands executed?

Lists of commands are executed in sequence, causing one synchronous roundtrip to the server at every step. Commands are furthermore always executed before any (queued) events are handled, because commands might invalidate the UI that was actual at the time of queueing such events. It's perfectly possible to starve the UI by producing an infinite command loop.

One invocation of a model update function might produce a list of commands. Since every message sent back to the server produces a new model, this means that messages from consecutive commands, will be processed in the context of consecutive models, and not on the original model that produced the list of commands in the first place. Worse, if such intermediate steps on the model produce commands themselves, they are executed first, before continuing on the remainder of the original list of commands.

Aside: this describes the current situation of the implementation; I'm unsure of the general semantics of sequences of commands produced by a single update step. An obvious way out would be to disallow sequences of commands and only allow a single one per update; but, on the other hand, this seems overly restricted and inflexible.

Why is it 'slow'?

Since Rascal does not run in the browser, all communication between user events and commands and the main application loop requires HTTP communication. Basically, all code in Rascal is executed on the server, including differencing of two views. Patches are sent to the browser which updates the actual DOM accordingly, executes commands (if any), and sends back messages (from commands, subscriptions or user events). Then the cycle repeats.

Moreover, to guarantee that model and view evolve in lock-step, the communication between client and server is synchronous. This means, events occurring during processing of a previous event (or command) are queued up until the new view is ready. When processing a queued event, Salix detects if it is still valid with respect to the current view before starting to handle it; if an event turns out to be stale (because its handler has been removed, or it's node is not attached to the DOM anymore), it's discarded. Subscription events are treated just like user events, except that staleness is currently not detected. It is therefore possible to retrieve notifications of a subscription after you've stopped subscribing to it.

In my experience however, which is as of yet rather limited I must admit, average performance is just fine for many use cases. For doing things like animations, however, the possible latencies introduced by HTTP might present a problem. Fortunately, there are enough use cases left that don't require that kind of performance. Note further that Salix is not meant for developing web applications; it's meant to develop browser-based GUI applications. It's not a goal to serve Salix apps over the WWW; HTTP just represents a transport layer for messages, until Rascal can be run in the browser itself.