Xebia Background Header Wave

Recently we stumbled upon a serverside oriented web framework called LiveViewJS. It’s inspired by Phoenix LiveView, a well known web framework written in Elixir . We (Arjan Molenaar and Albert Brand) decided to take it for a spin during one of our Innovation afternoons.

TL;DR: it’s not ready yet for production usage, but it’s promising.  In the serverside framework space many new paradigms have been popping up. This is another framework that shifts the focus from SPAs to serverside rendering.

So what is LiveView? And how does it relate to LiveViewJS?

To quote from the original LiveView GitHub page:

“Phoenix LiveView enables rich, real-time user experiences with server-rendered HTML.”

To re-paraphrase the page on how this works:

  • LiveView pages are server centric. The LiveView framework synchronises clients as changes happen on the server.
  • LiveView sends a page to the client as ‘classic’ rendered HTML. This means a fast meaningful paint and good crawling support for search engines.
  • The client sets up a connection with the server, which allows LiveView to react to user events. The page gets updates with minimal HTML diffs.

LiveViewJS is a re-implementation of the Phoenix LiveView server in NodeJS and Deno. This introduces a battle-tested architecture to a new audience. It uses the same definitions and protocols where possible. In fact, LiveViewJS uses the same JavaScript bundle as Phoenix LiveView on the client. This means there’s a lot of enforced parity between both frameworks. 

Show me an example

Sure! Here is a simple example inspired on the LiveViewJS introduction docs:

import { createLiveView, html } from "liveviewjs";

export const counterLiveView = createLiveView<
  { count: number }, // define the context (read: state) type
  { type: "increment" } // define event types
>({
  // called on initialisation
  mount: (socket) => {
    socket.assign({ count: 0 }); // initialise context
  },
  // called on user event
  handleEvent: (event, socket) => {
    const { count } = socket.context; // destructure context
    switch (event.type) {
      case "increment":
        socket.assign({ count: count + 1 }); // update context
        break;
    }
  },
  // called on context change
  render: (context) => {
    const { count } = context; // destructure context
    // return rendered context
    return html`
      <div>
        <h1>Count is: ${count}</h1>
        <button phx-click="increment">+</button>
      </div>
    `;
  },
});

You can read more in-depth explanation about the used APIs here. When you run this example, it shows a counter that increases whenever you click on the plus button. But instead of having an explicit REST endpoint to mutate server-side state, LiveViewJS handles events and updates to the DOM via websocket communication.

Websocket connection LiveView messages in Chrome DevTools

As you can see in the above picture, the websocket message has a response which contains a “1” for dynamic position “2”. The LiveView client code can use this information to update the correct part of the document tree. In more complex use cases, complete HTML fragments can be sent to the client. 

There are many more possibilities outside this simple example. See the anatomy of a LiveView for more info.

This seems rather complex!

Yes, but building a client/server side application with a RESTful or GraphQL interface is not a walk in the park either. LiveViews offer a lot of advantages:

  • LiveView is a generic client/server abstraction and requires a lot less code to build pages: no REST or GraphQL, no JSON (de)serialisation, just HTML templates.
  • The original architecture from Phoenix LiveView is battle-tested
  • Synchronisation between client and server is ‘solved’
  • Real-time is built in. Push a message to all connected clients? No extra set up needed.
  • Fast initial first paint
  • Fast page updates
  • No client-side router complexity: that’s all handled on the server
  • No client-side component libraries required to build pages, although you can progressively enhance a page if you need to.

What did we do?

Arjan and I discussed at the start of the Innovation afternoon what we could try out. Arjan already had some experience with Elixir and Phoenix LiveView, so he was interested in comparing the JS equivalent. I was mainly interested in the technology abstraction. Also, we both did not have Deno experience, and LiveViewJS also runs on Deno, so that seemed like a good match.

We wanted to get the examples running and from that starting point build something that would show the capabilities of multi-user communication. We thought that there probably would be loads of examples to show that off. Surprisingly, there were not that many.

First thing that was a bit of a let down was the installation. To use LiveViewJS, the official way is to check out the repo and build upon that. We really hoped it would be a more streamlined install process, something as simple as starting with an empty folder and doing a npm install liveviewjs, or run some command-line to create a boilerplate setup.

Another problem that we noticed: after checking out the repo and running npm install, running the examples is done with  deno run […] src/example/autorun.ts. We expected startup to be triggered from a npm script. However, the repo that we checked out serves as the workspace where the different npm packages are built, which is conflicting with developer run scripts. So it feels unnecessary complex navigating, modifying and running code in a workspace.

We read a little of the docs, which are pretty much what you’d expect. Then we looked at all examples and asked ourself: where is the obligatory chat server example? It was missing! Then we knew what to build.

Building a chat app

What do you need for a chat app? We chose to store messages together with an author and a timestamp. For this example the data is going to live in memory for as long the Deno process is running. Also, we want multiple users to chat with each other (duh!) so we’re going to broadcast chat updates to everyone connected to the LiveView.

type Chat = {
  author: string;
  message: string;
  timestamp: Date;
};

// in memory chat
const chatMessages: Chat[] = [
  {
    author: "Chatbot",
    message: "Welcome to the chat!",
    timestamp: new Date(),
  },
];

// use global broadcastchannel implementation
const pubSub = new BroadcastChannelPubSub();

We create the LiveView with the chat messages as context,  a send client event and an updated info event defined:

export const chatLiveView = createLiveView<
  { chatMessages: Chat[] }, 
  { type: "send"; author: string; message: string },
  { type: "updated" }
>(
  {
    mount: (socket) => {
      if (socket.connected) {
        socket.subscribe("chatMessages");
      }
      socket.assign({ chatMessages });
    },
    // ...
  }
)

As you can see, the client event allows for extra data to be sent to the server. In this case, we’ll send the author and message as form input. In the mount function we’re doing something new: we subscribe to the topic called chatMessages. If an event is pushed on that topic, there will be a special info method to handle it, which we will show below.

We define a render function that shows the chat messages and the form to input new messages:

    render: (context, meta) => {
      const { chatMessages } = context;
      return html`
        <h1>Chat</h1>
        ${chatMessages.map(renderChatMessage)}
        <form phx-submit="send">
          <input type="hidden" name="_csrf_token" value="${meta.csrfToken}" />
          <label>
            <span>Message:</span>
            <textarea type="text" name="message" placeholder="Message"></textarea>
          </label>
          <label>
            <span>Author:</span>
            <input type="text" name="author" placeholder="Author" autocomplete="off" />
          </label>
          <button type="submit">Send</button>
        </form>
      `;
    },

For each chat message in the context, a render function is called. This probably is not the best way to do it as there’s also the concept of LiveComponents that live in a LiveView. However, we did not spend time on this.

For handling the form, we copy-pasted the csrf protection from another example. Also, we skipped all model validation and error handling. The authors of LiveViewJS have built a very opinionated way to do form handling, which we admire, but we felt that it was too complex for our use case and we had severe time constraints. We noticed that the phx-submit="send" bound the form inputs as extra data on the event, which made us implement handleEvent as follows:

    handleEvent: (event) => {
      switch (event.type) {
        case "send":
          chatMessages.push({
            author: event.author,
            message: event.message,
            timestamp: new Date(),
          });
          pubSub.broadcast("chatMessages", { type: "updated" });
          break;
      }
    },

We push a new Chat object in our global array. Then, instead of assigning a new context, we broadcast the updated  event on the chatMessages topic. This will be picked up by the handleInfo method.

Maybe you’re wondering: how is the local pubSub instance in this file connected to the LiveView? We were puzzled as well, as you don’t pass this instance to the LiveView. It turns out that there’s more than one BroadcastChannelPubSub instance (in our repo, see src/chat/index.ts), and by calling socket.subscribe you let the LiveView internally subscribe using the Deno BroadcastChannel implementation which shares the same topics with all subscribers. 

Well, on to the next step.

    handleInfo: (info, socket) => {
      switch (info.type) {
        case "updated":
          socket.assign({ chatMessages });
          break;
      }
    },

The updated event will reach all LiveView server instances that are listening to the chatMessages topic. Then all clients will get an updated rendering via the context update.

And that’s that! In just about 100 lines of code we’ve written a multi-user synchronised chat app.

Next step: make it standalone (sort of)

At this point we got totally annoyed by working on top of the LiveViewJS repo (placing code in https://github.com/floodfx/liveviewjs/tree/main/packages/deno/src/example/liveview as specified in the docs). So we figured: let’s try to make this work outside of the repo.

We copied the https://github.com/floodfx/liveviewjs/tree/main/packages/deno folder as a separate project (offtopic: the readme in that folder needs some love, it’s quite outdated). However, trying to run it from there failed because it’s normally linked via import_map.json with the other packages in the workspace. We worked around this by installing "liveviewjs" and span>”@liveviewjs/examples” as dependencies (luckily these are published regularly!) and modifying import_map.json.

Then we cleaned up the examples from being populated in the LiveViewRouter.  So probably we can drop span>”@liveviewjs/examples” as a dependency, but we did not get around to do that.

There’s room for lots of improvement to clean this up, but this seems like a step in the right direction if the authors want this to be adopted.

Debugging > console.log

One thing that bothered us is that debugging the application from VSCode did not work out of the box. If you launch the autorun.ts script with the –attach flag, breakpoints are not hit. It turns out that this script runs esbuild to prepare the clientside JS bundle and then creates a new Deno process to run the actual server, without the –attach flag.

So we changed it slightly: extract the ESBuild code into a buildClient.ts that prepares the client build, and have a separate main entry point to start a debugging process. In the repo we’ve kept the launch config for whomever is interested.

There’s no time! Make it work on Deno Deploy

We were almost running out of time but we wanted to try out one last thing. Deploy our LiveViewJS chat app on Deno Deploy! We did not use Deno Deploy before but we thought: how hard can it be?

We quickly figured out that, because of the client build step, we needed to make a custom GitHub Action build configuration and push the end result to Deno Deploy. This went pretty smoothly and we’re happy with the Deno Deploy action.

However, the app crashed on startup:

Nobody likes those error messages!

And while reading the docs for Deno Deploy we realised that Deploy has a hard limitation: you can’t write to the filesystem. That was quite unexpected!

So while I was figuring out if we could run it as a Docker container, Arjan got into the code and removed all key.json writes (who needs those anyway?). And wow… that actually worked! (We’re not sure if this has any security implications but dang! We made it!)

Go ahead and chat on https://liveviewjs-deno-chat.deno.dev !

Verdict

  • The LiveViewJS code is a near complete reimplementation of Phoenix LiveView interfaces. We actually used to original LiveView documentation to figure our certain features, hence the handleEvent and handleInfo functions. The current LiveViewJS documentation is somewhat outdated and unclear (but work is underway to fix this).
  • The LiveView concepts are a different paradigm, with different trade-offs compared to SPA/RESTful applications. It definitely has advantages, but it’s not a silver bullet. 
  • If you have multiple event types, in TypeScript you’ll easily end up with a big switch statement. In Elixir, this is handled by pattern matching, which is a more elegant approach. This is a more fundamental problem of porting a codebase to another programming language.
  • The LiveViewJS implementation is not production ready yet. It is missing some features (although it is quite complete already) and has not been optimised for production usage. Run at your own risk.
  • It’s a framework, more than a library. To introduce this in an existing codebase is pretty complex. Also, it shares a lot of features with frontend libraries and frameworks such as React and Vue, which requires you to separate them correctly.
  • The JavaScript LiveView bundle is currently clocking ~60Kb gzipped which we think is pretty hefty, but the bundle is not minified so maybe there’s some room for optimisation.

You can check out the code here: https://github.com/AlbertBrand/liveviewjs-deno-chat

Questions?

Get in touch with us to learn more about the subject and related solutions

Explore related posts