How To Use NDJSON Streams with can-connect

Bianca Gandolfo by Bianca Gandolfo

How To Use NDJSON Streams with can-connect

Bianca Gandolfo Learn how to improve your app’s performance and user experience with can-connect and NDJSON streams.

posted in Open Source ,Development ,CanJS on July 18, 2017 by Bianca Gandolfo

In our previous post, we talked about how to improve an app’s performance and user experience by incrementally updating our app’s UI as we received a stream of data from our API. Our example app was built on the Fetch API and can-ndjson-stream to get a ReadableStream of NDJSON and render the stream in our app.

If you’re using can-connect, there’s an even easier way to render a stream of data in your app—with can-connect-ndjson! This post will demonstrate how to configure this behavior to incrementally load an API response that’s streamed by your server.

incremental NDJSON rending vs. JSON object

Getting started with can-connect-ndjson

can-connect-ndjson is a can-connect behavior that can receive, transform, and render lines of a server response body as a stream on the client.

If you are familiar with can-connect, then you have probably used behaviors in the past to connect your model with the HTTP layer. If you’re not familiar, it looks something like this:

const connect = require("can-connect");
const DefineList = require("can-define/list/list");
const DefineMap = require("can-define/map/map");

//Require behaviors for connection
const urlBehavior = require("can-connect/data/url/url");
const constructorBehavior = require("can-connect/constructor/constructor");
const mapBehavior =  require("can-connect/can/map/map");

const behaviors = [urlBehavior, constructorBehavior, mapBehavior];

// Define model
const Todo = DefineMap.extend("Todo", {id: "number", name: "string"}); 
Todo.List = DefineList.extend("TodoList", {"#": Todo});


// Create connection passing behaviors and options
Todo.connection = connect(behaviors, {
    Map: Todo,
    List: Todo.List,
    url: "/todos"
});

//GET request for a list of todos from "/todos"
const todosPromise = Todo.getList({});

Add the can-connect-ndjson behavior to support streamable responses

can-connect-ndjson works by extending the Data and Instance interfaces to work with streamed NDJSON data to create instances of the data model. Simply require the behavior and pass the optional NDJSON endpoint if your backend serves NDJSON from an endpoint other than your default url endpoint.

Steps:

  1. Require the can-connect-ndjson behavior
  2. Add the can-connect-ndjson behavior to the behaviors array
  3. Pass the behaviors into the connection
  4. [Optional] Pass the NDJSON endpoint if it differs from your default url

The todosPromise will resolve with an empty list once a connection is established, then todosPromise.value will be updated with the first Todo instance once the first line of NDJSON is received. Each todo instance will be a line of the NDJSON.

const connect = require("can-connect");
const DefineList = require("can-define/list/list");
const DefineMap = require("can-define/map/map");

//Require behaviors for connection
const urlBehavior = require("can-connect/data/url/url");
const constructorBehavior = require("can-connect/constructor/constructor");
const mapBehavior =  require("can-connect/can/map/map");
//Step 1: Require the NDJSON behavior.
const ndjsonBehavior = require("can-connect-ndjson");
//Step 2: Add can-connect-ndjson (ndjsonBehavior) to the behaviors array. const behaviors = [urlBehavior, constructorBehavior, mapBehavior, ndjsonBehavior]; // Define model const Todo = DefineMap.extend("Todo", {id: "number", name: "string"}); Todo.List = DefineList.extend("TodoList", {"#": Todo}); //Step 3: Create the connection by passing behaviors and options Todo.connection = connect(behaviors, { Map: Todo, List: Todo.List, url: "/todos", ndjson: "ndjson/todos" //Step 4: [optional] specify the NDJSON API endpoint }); //GET request for NDJSON stream of todos from "/ndjson/todos" const todosPromise = Todo.getList({});

There you have it! Let’s render it incrementally.

You have now configured your can-connect connection to receive stream responses from your API and create instances of the data model. Now use the model with a template:

const stache = require("can-stache");

const template = "<ul>{{#each todosPromise.value}}<li>{{name}}</li>{{/each}}</ul>";
const render = stache(template);

document.body.append(render({todosPromise: todosPromise}));

Remember: once a connection is established, todosPromise.value will be an empty array until the first line of NDJSON data is received, then the NDJSON lines will be deserialized into Todo instances and pushed into your array.

Conditional rendering based on state

In a real-world environment, we not only need to render the state of the Listmodel, but also the state of the stream so that we can communicate to our users whether or not to expect more data or if there was an error. To do this, we have access to the following state properties:

Promise state, the state of the initial connection to the stream:

  • isPending—the list is not available yet
  • isRejected—an error prevented the final list from being determined
  • isResolved—the list is now available; note that the list is still empty at this point

Streaming state, available on the list after the promise is resolved to a stream:

  • isStreaming—the stream is still emitting data
  • streamError—an error that has prevented the stream from completing

Here is an example of a template capturing the various states and rendering conditionally for an improved user experience. In this example, we still pass todosPromise to render our template:

{{#if todosPromise.isPending}}
  Connecting
{{/if}}

{{#if todosPromise.isRejected}}
  {{todosPromise.reason.message}}
{{/if}}

{{#if todosPromise.isResolved}}
    <ul>
        {{#each todosPromise.value}}
            <li>{{name}}</li>
        {{/each}}
    </ul>
    {{#if todosPromise.value.isStreaming}}
        Loading more tasks
    {{else}}
        {{#if todosPromise.value.streamError}}
            Error: {{todosPromise.value.streamError}}
        {{else}}
            {{^todosPromise.value.length}}
                <li>No tasks</li>
            {{/todosPromise.value.length}}
        {{/if}}
    {{/if}}
{{/if}}

Next steps

Find more details about using can-connect with NDJSON streams in the can-connect-ndjson docs.

If you use this new module, let us know on our forums or Gitter chat! We’d love to hear about your experience using NDJSON streams with can-connect.

We’re working on even more streamable app features for DoneJS. Keep up with the latest in the community by following us on Twitter!

Create better web applications. We’ll help. Let’s work together.