Day 11 – Raku To The Stars

Datastar is a hypermedia systems library in the same tradition as htmx, Unpoly, Alpine AJAX, and Hotwire‘s Turbo. These libraries are generally Javascript/Typescript bundles that utilize the HTML standard to allow you to declaratively write AJAX calls or JS-powered CSS transitions as HTML tag attributes instead of hand-writing Javascript. @librasteve has been working on Air and the HARC stack which seeks to deeply integrate htmx into the Raku ecosystem, and so I highly recommend reading his posts to get a better understanding of why Hypermedia always been a compelling sell.

htmx in particular makes no prescription on handling browser-side, component-local state. Carson Gross, the creator of htmx, lists Alpine, his own project hyperscript, Web Component frameworks such as lit, and several others as options depending on the use case.

A Datastar Primer

Datastar does not take this approach; it aims to handle both browser-side state using signals and the server-side connectivity which htmx and Phoenix LiveView do. The main differentiating factor for Datastar is that it automatically handles Server-Sent Events (SSE) and text/event-stream responses, making it really good for real-time applications. Datastar also allows you to return a regular text/html response just like htmx; it morphs the HTML fragment into the DOM using a forked version of the same DOM-morphing library htmx uses. Datastar also accepts a application/json response from the server which it uses to patch signals (which are JSON objects), and it also accepts a text/javascript response from the server, which it uses to run custom Javascript on the browser.

Raku ❤ Datastar

Finally realizing I don’t have to write any React code in my side projects, I wrote a Datastar library in Raku, combining the two things I’ve recently taking a liking to.

Raku’s best on display

Multimethods and Multisubs

Let’s take a walk through the actual code and see how I’ve utilized Raku’s expressivity, starting with the use of multi subs and multi methods:

multi patch-signals(
    Str $signals, 
    Str :$event-id, 
    Bool :$only-if-missing, 
    Int :$retry-duration
is export {
    ...
}

multi patch-signals(%signals, *%options) {
    samewith to-json(%signals, :!pretty), |%options;
}

The reason we use multi subs here is to allow for the possibility that the signals may be serialized into a Str before patch-signals is called. However, assuming an Associative is passed as the first argument, we just turn it into a Str and call the same function.

Metaoperators

Let’s also take a look at the body of patch-signals, starting with this:

my @signal-lines = 
    "{SIGNALS-DATALINE-LITERAL} " X$signals.split("\n");

I love this line. Let’s start with what this code does; it prepends the word signal to every line of actual stringified JSON. So for example if we have this as our sample stringified JSON:

{
    "selected-element-index": 0,
    "selected-element-value": "Nominative"
}

The actual output of the first line will the following split per-line:

signal {
signal     "selected-element-index": 0,
signal     "selected-element-value": "Nominative"
signal } 

which is what Datastar expects when parsing signals being sent downstream from the server. We’re using X~ the same way Bruce Gray outlines it here, as a scaling/map operator to prepend "signal" to each line of the stringified JSON, an incredibly clever use of the cross-product meta-operator.

Another meta-operator we use is the reduction metaoperator [], mainly to join the lines of the resulting SSE response into one string:

class SseGen {
    method Str { [~@!data-lines }
}

DSL Building using dynamic variables and blocks

Let’s examine patch-signals‘s first statement:

fail 'You can only call this method inside a datastar { } block' 
    unless $*INSIDE-DATASTAR-RESPONSE-GENERATOR;

We use dynamic variables to help enforce the usage of these functions within the datastar block as shown here:

datastar {
    patch-signals { current-index => 0 };
}

sub datastar is defined as:

sub datastar(&fis export {
    my $*INSIDE-DATASTAR-RESPONSE-GENERATOR = True;
    my $*render-as-supply = False;
    my SseGen $*response-generator .= new;

    f();

    $*render-as-supply  
      ?? $*response-generator.Supply 
      !! $*response-generator.Str
}

We make use of blocks and dynamic variables here to have an implicit variable be passed down to functions that make use of it, so that we get very easy to read code. This is largely inspired from what I saw in Matthew Stuckwisch’s presentation here.

A Few More Ideas

Another improvement I had in mind was modifying the multi functions so that you could theoretically write code in a functional manner using the feed operators like this if you wanted:

SseResponse.new
    ==> patch-elements("<div></div>")
    ==> patch-signals({ a => 2, b => 3 })
    ==> as-supply();

You would theoretically import this functionality by adding the following to the top of your file: use DataStar :functional;

Another idea that I had was the following:

given SseGen.new {
    .patch-elements: "<div>Hello there</div>";
    .patch-signals: { a => 2, b => 3 };

    .Supply
}

This makes use of the given control structure which assigns an expression to the topic variable $_, and Raku’s syntax sugar for calling methods on $_ which is to just invoke method without a receiver/object.

I hope to make it easier for developers using this library to follow the TMTOWTDI philosophy by providing multiple strategies for interfacing with the library.

Next Steps

Here are the next steps regarding this library:

  • Run the full Datastar test suite against this library to make sure I’m on the same page.
  • Rename DataStar to Datastar. Datastar is the preferred name for the package and I incorrectly named it when I was in a rush to release this package.
  • Integrate this with Cro through a new package Cro::Datastar and integrate it with Air via a new package: Air::Jetstream.

Happy holidays everyone!

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.